diff --git a/docs/PRDs/authoring/absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md b/docs/PRDs/authoring/absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md new file mode 100644 index 0000000000..ec20f75f4e --- /dev/null +++ b/docs/PRDs/authoring/absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md @@ -0,0 +1,123 @@ +# PRD-872 — The lint names the engine call + +**Status:** IN PROGRESS (2026-11-10) — phases 1 and 2 landed on `absorb-2026-11-10`; phase 3's engine half landed and its sandbox-game refactor is open because those games are a separate checkout +**Priority:** P1 — the kit lint does not check entity `update` methods and rejects no reinvention, so the three newest games each rebuilt shipped capabilities +**Complexity:** 4 (MEDIUM); risk override: none +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Absorb report, 2026-11-10](README.md) + +Merged `origin/develop` (`b01de92c7`) on 2026-11-12: the only conflict was `starter`'s scaffold pin, recomputed from the merged tree; `pnpm typecheck`, `ci:fast`, `capabilities:check`, `api:surface:check` and the focused suites (quality-rules 18, scaffold 79, rain-quality 10, rts-runtime-cost 8, state 9, engine-mcp search 108) pass on the merge. + +## Context + +An agent that does not search the manifest writes the capability again. PRD-355 plans a detector for +that. This PRD takes the cheaper route that already runs in each game: `pnpm lint`. + +| Problem | Where | Checked by | +| --- | --- | --- | +| The hot-callback rules track `update` and `render` only on a `Scene` subclass, plus 6 hooks. Entity classes are not checked. | `packages/create-threenative/src/quality/hotCallbacks.ts:4-15`, `:73-75` | lead | +| 22 per-frame allocation or tree-walk sites in entity `update` methods across about 12 kits. Example: `new Vector2` each tick. | `templates/starter/src/render/touch-controls.ts:118`; `adventure/src/entities/LockOn.ts:104,121` | surveyor (syntactic scan) | +| 18 kit files carry their own PRNG; 7 use `createRandom`. `starlance` has 5 PRNG copies and 6 `Math.random` calls. | `rg '1664525\|0x6d2b79f5' templates` | lead | +| Damping written as `1 - Math.exp(-k * dt)`: 21 sites in 16 kit files, 10 in `starlance`. `MathUtils.damp` has 9 uses. | `rg '1 - Math\.exp\(-' templates` | lead | +| Inline smoothstep `t * t * (3 - 2 * t)`: 38 sites in 26 kit files. Local `clamp`, `smooth`, `lerp` definitions: 32 in 21 files. | `rg '\* \(3 - 2 \* ' templates` | lead | +| Launch seams read from `location.search` by regex: 10 sites in `starlance` and `stress-lab`, 4 in kits. They are off on native. `readLaunchValue` is not exported. | `packages/core/src/debug.ts:96`; `starlance/src/scenes/Play.ts:200-205` | lead (export), surveyor (sites) | +| `midway-open-pacific` sets `scene.matrixWorldAutoUpdate = false` and walks matrices itself (96 lines). `MatrixWorldPass` is the default. | `midway-open-pacific/src/scenes/Midway.ts:295-310`, `src/render/world.ts:1261-1312` | surveyor | +| Axes built from raw keys, 5 times. `InputMap` has `axis`. | `midway-open-pacific/src/scenes/Midway.ts:495-519` | surveyor | +| The manifest has 0 rows for `ctx.assets.memo` and `ctx.startup.progress`. `ctx.beforeRender` is not a symbol. `starlance` rebuilds a 9-second stage on each load. | `rg -c 'assets\.memo' packages/core/capabilities.json` | lead | + +## Solution + +1. **Widen the hot-callback tracker.** `trackHotCallbacks` also treats a non-static class method named + `update`, `fixedUpdate`, `tick` or `step` as per-frame. The 3 existing hot-callback rules then apply + with no change. +2. **Add one data table of reinventions.** `packages/create-threenative/src/quality/reinventions.ts` + exports rows `{ selector, capability, message }`. `qualityConfig` feeds them to the ESLint built-in + `no-restricted-syntax`. No new rule code. A spec checks that each `capability` is a symbol in + `packages/core/capabilities.json`, so a message cannot name a removed symbol. +3. **First rows** (each has 3 or more measured sites): + + | Pattern | Message names | + | --- | --- | + | `Math.random()`, or the literals `1664525`, `1013904223`, `0x6d2b79f5`, outside `src/ui/` and `src/audio/` | `createRandom`, `ctx.random` | + | `1 - Math.exp(-…)` | `MathUtils.damp` | + | `location.search` or `new URLSearchParams(location…)` | `debugFlag`, `debugValue` | + | `….matrixWorldAutoUpdate = false` | config `renderer.matrixWorld` | + | `(keys.has(a) ? 1 : 0) - (keys.has(b) ? 1 : 0)` | `ctx.input.axis` | + | `x * x * (3 - 2 * x)` outside TSL | `MathUtils.smoothstep` | + + Amended 2026-11-10, after review: the PRNG row also excludes `src/render/**`. A kit's render + sources may not import a core symbol outside `RENDER_PORTABLE_CORE_SYMBOLS` + (`scripts/check-core-boundary.ts`), so they keep their own generators; the row still covers a + level, an entity, a scene and a sim. The smoothstep and damp rows name `three`, which every + render source already imports, so those stay everywhere. + +4. **Export `debugValue(name)`.** It wraps `readLaunchValue` with the `debugFlag` naming rule + (`?name` on web, `TN_DEBUG_NAME` on native). +5. **Add manifest rows** for `ctx.assets.memo`, `ctx.startup.progress`, `ctx.after`, `ctx.beforeRender` + and `debugValue`, each with the situations an author searches for. + +```mermaid +flowchart LR + Src["game src/**/*.ts"] --> Lint["pnpm lint"] + Lint --> Hot["hot-callback rules
Scene + entity update methods"] + Lint --> Table["reinventions.ts rows
no-restricted-syntax"] + Table --> Msg["error names the symbol
and its import path"] + Manifest["capabilities.json"] --> Spec["spec: each row names a live symbol"] + Spec --> Table +``` + +Risks: + +- **False positives on `update`.** A class method named `update` that does not run each frame is + flagged. Record the count on the 16 kits and the 3 games before the rule ships. More than 5 false + positives means the tracker also requires a `dt` first parameter. +- **A message that points nowhere.** The spec in point 2 closes it. + +Not in scope: the detector of PRD-355, capability search ranking (PRD-865), autofix. + +## Acceptance Criteria + +- [x] AC-1 [local]: A generated game that allocates in an entity `update` method, or reinvents one of the 6 rows, fails `pnpm lint` with a message that names the engine symbol. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts` — 17 passed. Each of the 6 rows fails its fixture, passes the fixed form, and names a symbol that resolves in `capabilities.json` (or in `three`); `src/ui/**` keeps the platform RNG and still gets the smoothstep row. +- [ ] AC-2 [local]: `starlance`, `midway-open-pacific` and `stress-lab` pass `pnpm lint` under the new rules. proof: `pnpm lint` exit 0 in each game, with the removed line count beside this box — OPEN: the three games are a separate checkout (`/home/joao/projects/threenative/sandbox`), so their refactor cannot land in this repository's PR. Engine-side proof is green; the game-side pass is not claimed. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Widened hot-callback tracker | `pnpm lint` in a generated game → `eslint.config.js` → `qualityConfig` → `threenativePlugin` | Extends `trackHotCallbacks`; no rule replaced | AC-1 | +| Reinvention rows | Same path → `no-restricted-syntax` | New | AC-1 | +| `debugValue` | Game import from `@threenative/core` | Replaces hand-written `location.search` parsing in kits and games | Phase 2 box | +| Manifest rows for `ctx` members | `engine_search_capabilities` in `threenative-engine-mcp` | New rows; no row removed | Phase 3 box | + +## Execution Phases + +#### Phase 1: The hot-callback rules see entity `update` methods + +**Status:** DONE (2026-11-10) +**Files:** `packages/create-threenative/src/quality/hotCallbacks.ts`, `__tests__/quality-rules.spec.ts`, the kit sources that the widened rule flags. +**Implementation:** Add the method-name branch beside the `Scene` branch at `hotCallbacks.ts:73-75`. Fix each flagged kit site with a module-level scratch object. Do not add a suppression. +**Verification:** the spec is red before the tracker change and green after it. + +- [x] An entity class whose `update` method calls `new Vector2()` fails `threenative/hot-callback-allocation`. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts` — 14 passed at this phase (red before the tracker change: the entity `update` returned no finding). `update`, `fixedUpdate`, `tick` and `step` are tracked; a static method and a method named otherwise stay quiet. The widened rule found 12 real sites in 9 kits, 0 false positives, so the `dt`-parameter narrowing the PRD held in reserve was not needed. +- [x] The 16 kits pass the widened rules with no suppression. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts` — the new "should pass every kit's own source under the hot rules and the rows" case lints all 16 `templates/*/src` trees with the three hot rules and the six rows at `error` and expects `[]` (the hot rules with no suppression; two described suppressions remain for the authored mulberry32 streams the rows still see) (it fails closed under 300 discovered files; ~3.8 s). Fixes: 9 `touch-controls.ts` files reuse one `knobPosition`, `Warden.update` reuses `toHero`, `LockOn.update` caches the target's mesh list and the reticle's own box instead of `setFromObject` each frame. `scripts/check-template-quality.ts` checks the quality presets, not ESLint — quoting it here was wrong. + +#### Phase 2: One table rejects the six reinventions + +**Status:** DONE engine-side (2026-11-10); the kit playtest run is recorded on the second box +**Files:** `packages/create-threenative/src/quality/reinventions.ts` (new), `src/quality/index.ts`, `packages/core/src/debug.ts`, `packages/core/src/index.ts`, `__tests__/quality-rules.spec.ts`, the kit sources that the rows flag. +**Implementation:** One row per pattern, with one failing fixture and one passing fixture each. Replace each kit site with the named call. Amended after review: the `src/render/clouds.ts` PRNG copies are **not** changed — a render source may not import a core symbol outside `RENDER_PORTABLE_CORE_SYMBOLS`, so the row excludes `src/render/**` and those files keep their own generator. No canonical render source moved. +**Verification:** the spec fails when a row's `capability` is not in `capabilities.json`. + +- [x] Each row fails its fixture, and each row's `capability` is a manifest symbol. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts` — 17 passed. `scripts/../src/quality/reinventions.ts` is data only; `qualityConfig` feeds it to `no-restricted-syntax`. The spec resolves each `capability` against `packages/core/capabilities.json` (`createRandom`, `debugFlag`, `renderer.matrixWorld`, `InputMap.axis`) or against `three` (`MathUtils.damp`, `MathUtils.smoothstep`), and proves the `src/ui/**` / `src/audio*` exclusions keep only the intended rows. +- [ ] The 16 kits pass the rows, and the touched kits pass their playtests. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts && pnpm test:templates` — the kit-wide spec is green (18 tests), with two suppressions left, one per authored mulberry32 stream outside `src/render/` (adventure's level, rts's replay world); kart's course and rain's storm are `src/render/` and now need none. The render PRNG edits were reverted after review: a kit's `src/render/` may not import `createRandom` (`pnpm exec tsx scripts/check-core-boundary.ts` now passes; it failed on 13 render files before). Measured by `git grep` before and after: 38 inline smoothstep expressions became `MathUtils.smoothstep` calls, 24 `MathUtils.damp` calls replace 22 hand-written exponential factors (one local `damp` wrapper had three call sites), 3 launch seams became `debugFlag`/`debugValue`, and 3 hand-written LCGs outside `src/render/` became `createRandom`; 3 suppressions remain: two authored mulberry32 streams and one sim module that must not import `three`. `pnpm test:templates` was run after rebuilding the web engine (`node scripts/build-wasm.mjs`) and **failed for environment reasons, not game reasons**: 8 kits report `TN_PLAYTEST_PERF_FEW_GPU_SAMPLES` (software adapter), 6 templates' managed dev servers miss their 15 s readiness window while cooking KTX2 assets, and 2 report `TN_PLAYTEST_ENGINE_COST_OVER_BOUND` under software. A fresh `shooter` scaffold passes its own gate (`pnpm typecheck`, `pnpm lint` with the new rules, `biome format`), so the template is not the failure; its own `pnpm test` then crashes the playtest worker under the software adapter (`RangeError: Offset is outside the bounds of the DataView`). On the branch with the GPU idle (RTX 2080, Node 20, `TN_PLAYTEST_ALLOW_SOFTWARE` unset) the sweep goes much further: `adventure`, `collectathon`, `kart`, `puzzle` and `snow` pass their scaffolded suites, and the kit contract is green except for rows that also fail on `origin/develop` in the same environment (`platformer`'s sight line, `shooter`'s worker `RangeError`) or that are frame-cost rows under a CPU load of 9-13 from other agents on this machine (`no-hitch` few-GPU-samples, `engine-cost` over bound). `action-rpg restart-resets` failed once under that load and passes when run alone. The box stays open: no green sweep has been recorded, and the repo runs this sweep in the schedule-only hardware lane, not on the PR board. + +#### Phase 3: The missing `ctx` members are searchable, and the sandbox games adopt the rules + +**Status:** IN PROGRESS (2026-11-10) — the manifest rows and `debugValue` landed; the sandbox refactor is open (separate checkout) +**Files:** the JSDoc of `memo`, `startup.progress`, `after`, `beforeRender` in `packages/core/src/`, `packages/engine-mcp/__tests__/search.spec.ts`; in `sandbox/`: the flagged sources of the 3 games. +**Implementation:** The manifest is built from JSDoc by `pnpm build`; add the tags, do not edit `capabilities.json` by hand. In the games, replace each flagged site with the named call. `starlance` wraps its stage build in `ctx.assets.memo` and deletes the `#delayed` list for `ctx.after`. `midway-open-pacific` deletes its matrix walk and declares its axes in `defineGame({ input })`. +**Verification:** search queries return the new rows; each game passes its lint and its smoke playtest. + +- [x] The queries "cache generated geometry between page loads", "boot progress bar" and "run a callback after a delay" return `ctx.assets.memo`, `ctx.startup.progress` and `ctx.after` in the first 3 rows. proof: `pnpm exec vitest run packages/engine-mcp/__tests__/search.spec.ts` — 108 passed, including the four ctx queries (`ctx.beforeRender` too). Interface-member JSDoc alone is not indexed by `scripts/build-capability-manifest.ts`, so the four rows live in `scripts/ctx-capabilities.ts`, the same hand-authored registry pattern the render-chain config keys already use; `debugValue` is a real export and is indexed from its JSDoc. `pnpm capabilities:check` reports 571 fresh entries. +- [ ] The 3 sandbox games pass `pnpm lint`, `starlance` passes `playtests/smoke.playtest.json` and `stage-clear.playtest.json`, and `midway-open-pacific` passes `playtests/flight.playtest.json`. proof: `pnpm lint` and `threenative-playtest --scenario --browser-recipe webgpu --headed` in each game — OPEN: those games are a separate repository checkout, not part of this diff. Not run, not claimed. diff --git a/docs/PRDs/authoring/absorb-2026-11-10/PRD-873-the-engine-owns-the-pool-and-the-bodys-rest.md b/docs/PRDs/authoring/absorb-2026-11-10/PRD-873-the-engine-owns-the-pool-and-the-bodys-rest.md new file mode 100644 index 0000000000..b2861c4726 --- /dev/null +++ b/docs/PRDs/authoring/absorb-2026-11-10/PRD-873-the-engine-owns-the-pool-and-the-bodys-rest.md @@ -0,0 +1,118 @@ +# PRD-873 — The engine owns the pool and the body's rest + +**Status:** IN PROGRESS (2026-11-10) — the store's changed-only publication landed on `absorb-2026-11-10`; `ObjectPool`, the body rest options and the `prewarm` restore are open +**Priority:** P2 — three games and three kits hand-write object pools, two games work around a `RigidBody3D` with no damping, and three games work around `prewarm` (all phases open) +**Complexity:** 7 (HIGH); risk override: the pool changes the draw path, so the visual-judge rule applies +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Absorb report, 2026-11-10](README.md) + +## Context + +| Problem | Where | Checked by | +| --- | --- | --- | +| 8 object pools in one game, about 390 lines. Each scans for a free slot with `find((i) => !i.visible)` and wires `ctx.add`, `ctx.interpolate` and warm-up by hand. | `starlance/src/render/shotFx.ts:117-302`, `src/entities/Enemies.ts:276-349`, `Weapons.ts:295-334`, `Ship.ts:783-836`, `src/render/fx.ts:342-421` | surveyor; lead confirmed 5 scans | +| The same keyed entity-to-view loop 3 times, about 230 lines: a `Map`, a new `Set` each frame, a removal loop, a build budget. | `midway-open-pacific/src/render/world.ts:1499-1589`, `1907-1944`, `2128-2265` | surveyor | +| Pools in 3 kits. | `templates/kart/src/scenes/Race.ts:268-285`, `tower-defense/src/scenes/Battle.ts:140-190`, `shooter/src/render/pooled-billboards.ts` (144 lines) | surveyor | +| About 200 sparks and shards are one `Mesh` each, so one draw each. | `starlance/src/render/shotFx.ts:396-434` | surveyor; draw count not measured | +| The manifest has 2 pools and both are specific: `TracerPool3D`, `CastPool`. | `packages/core/capabilities.json` | lead | +| `RigidBody3D` has no damping, friction, restitution, angular velocity, sleep flag or point impulse. | `packages/physics/src/RigidBody3D.ts` (0 hits) | lead | +| Two games worked around it. `starlance` applies a drag force each tick and infers rest from mesh motion. | `starlance/FRICTION.md` 2026-11-05; `starlance/src/entities/Towers.ts:84-120`; `fluid-lab/FRICTION.md:34` | lead (starlance log), surveyor | +| `prewarm` on a live root leaves cloned materials at opacity 0. Nothing throws. Three games hit it or wrote their own warm-up. | `packages/core/src/renderer.ts:129-155`; `fps-framework/FRICTION.md:10`; `starlance/src/entities/Enemies.ts:246-319`; `midway-open-pacific/src/render/world.ts:918-1203` | surveyor | +| `state.set` marks the store dirty on every call, so `flush` publishes unchanged values. 5 kits hand-write a changed-only check. | `packages/core/src/state.ts:48-53`; `templates/action-rpg/src/scenes/Play.ts:583-611`, `tower-defense/src/scenes/Battle.ts:521-559` | lead (store), surveyor (kits) | + +## Solution + +1. **`ObjectPool` in `@threenative/core`.** `new ObjectPool({ make, reset, size, onFull })` with + `acquire()`, `release(item)` and `forEachLive(fn)`. A free list replaces the scan. The pool adds its + members to the scene, registers interpolation, and warms each distinct member through `warmUnculled`. +2. **Instanced by default.** When `make` returns members that share one geometry and one material, the + pool draws them as one instanced batch. `instanced: false` is the named override, and the pool reports + it. The game supplies geometry, material, colour and timing (root `AGENTS.md` rule 3). +3. **`RigidBody3D` rest options.** Options `linearDamping`, `angularDamping`, `friction`, `restitution`. + Members `angularVelocity` (read and write), `sleeping` (read) and `applyImpulseAtPoint`. Each maps to + the Rapier call of the same meaning. The native host gets the same options in the same commit. +4. **`prewarm` cannot hide a live scene.** `prewarm` restores each material it changed when the compile + settles. A root that the engine cannot restore throws `TN_PREWARM_LIVE_SCENE` and names `warmUnculled`. +5. **`state.set` skips unchanged keys.** A key whose new value is `Object.is`-equal to the current value + does not enter `pending`. `flush` publishes nothing when nothing changed. + +```mermaid +flowchart LR + Game["game: new ObjectPool({ make, reset, size })"] --> Pool["ObjectPool
free list, lifetime, warm-up"] + Pool -->|"members share geometry + material"| Inst["one instanced batch"] + Pool -->|"instanced: false (reported)"| Sep["separate objects"] + Pool --> Warm["warmUnculled"] + Game -->|"geometry, material, colour, timing"| Pool +``` + +Reused: `warmUnculled`, `InstancedBatch`, `ctx.interpolate`, the Rapier body API. Check +`engine_search_capabilities` for `pool`, `recycle` and `instanced` before you write `ObjectPool`. + +Risks: + +- **Kill switch.** `ObjectPool` must cost less code at the call site than the scan it replaces. + `scripts/count-loc.ts` scores it on `starlance`. +- **Instancing changes the draw path.** Per-member material state (opacity, colour) moves to instance + attributes. A member that needs its own material uses `instanced: false`. +- **Store equality hides a mutated object.** A game that mutates an object in place and sets it again + no longer publishes. The error text for that case names a new object or a version field. + +## Validation scene + +Proposed, not run. + +| Item | Pool | Store | +| --- | --- | --- | +| Consumer | `starlance`, `playtests/tower-break.playtest.json` (shards, flashes, rings) | `action-rpg` kit, idle HUD for 600 ticks | +| Baseline and candidate | Same commit, `instanced: false` against the default, paired runs | Same commit, hand check removed, store skip off and on | +| Small control | A pool of 4 members: charges the free list and the instance buffer | A state that changes each tick: charges the equality check | +| Hardware | Local RTX 2080, headed, `--browser-recipe webgpu`, `adapter.info` checked | Same | +| Pass bar | Fewer draws, and presented p95 not worse than run noise on the control | 0 publishes while idle, and frame time not worse than run noise on the control | + +## Acceptance Criteria + +- [ ] AC-1 [local]: `starlance` and `midway-open-pacific` run on `ObjectPool` with no hand-written pool left, and their playtests pass. proof: `rg -c 'find\(\([a-z]+\) => !' src` returns 0 in `starlance`, and `threenative-playtest --scenario playtests/tower-break.playtest.json --scenario playtests/smoke.playtest.json --browser-recipe webgpu --headed` passes — Evidence: pending. +- [ ] AC-2 [local]: The validation scene shows a net whole-frame gain on the pool workload and no loss on the small control. proof: playtest `perf` on `tower-break`, paired arms, numbers beside this box — Evidence: pending. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| `ObjectPool` | Game import from `@threenative/core`; `shooter` kit `pooled-billboards.ts`; `starlance` `shotFx.ts` | Hand-written pools are deleted in the kits and the 2 games | AC-1 | +| `RigidBody3D` rest options | `new RigidBody3D({ linearDamping })` in `starlance/src/entities/Towers.ts` | The drag-force workaround is deleted | Phase 2 box | +| `prewarm` restore | `prewarm(root)` in any game | `starlance` hand warm-up (`Enemies.ts:246-319`) is deleted | Phase 2 box | +| `state.set` skip | `ctx.state.set` in each kit scene | Changed-only checks are deleted in 5 kits | Phase 2 box | + +## Execution Phases + +#### Phase 1: `ObjectPool`, instanced by default + +**Status:** NOT STARTED +**Files:** `packages/core/src/object-pool.ts` (new), `packages/core/src/index.ts`, `packages/core/__tests__/object-pool.spec.ts` (new), `templates/shooter/src/render/pooled-billboards.ts`, `templates/kart/src/scenes/Race.ts`, `templates/tower-defense/src/scenes/Battle.ts`, one playtest scenario. +**Implementation:** Free list; `onFull: "recycle-oldest" | "drop"`; a bad `size` throws. Add a `reinventions.ts` row for the `find(!visible)` scan when PRD-872 Phase 2 has landed. +**Verification:** unit spec for the lifetime contract; a `shooter` playtest scenario for the frame loop. + +- [ ] `acquire`, `release`, `onFull` and `forEachLive` hold their contract, and a pool of members that share geometry and material issues one draw. proof: `pnpm exec vitest run packages/core/__tests__/object-pool.spec.ts` +- [ ] The `shooter`, `kart` and `tower-defense` kits use `ObjectPool` and pass their playtests. proof: `TN_TEMPLATE_ONLY=shooter,kart,tower-defense pnpm test:templates` +- [ ] The visual judge returns NEUTRAL or better on the `shooter` kit, same pose before and after. proof: judge verdict recorded on this box + +#### Phase 2: A body rests, a warm-up restores, an unchanged state stays quiet + +**Status:** IN PROGRESS (2026-11-10) — the store skip landed; the body options and the `prewarm` restore are open +**Files:** `packages/physics/src/RigidBody3D.ts`, `packages/physics/__tests__/rigidbody.spec.ts`, `packages/core/src/renderer.ts`, `packages/core/src/state.ts`, `packages/core/__tests__/state.spec.ts`, the native host body options, the 4 kit scenes that carried a changed-only check (`starter`, `action-rpg`, `tower-defense`, `runner` — the survey counted 5, a grep finds 4). +**Implementation:** Each body option is validated (a negative damping throws). Native proof ships in the same commit as the option (root `AGENTS.md` rule 5). +**Verification:** red-green unit specs; one desktop-target playtest for the body options. + +- [ ] A body with `angularDamping` stops and reports `sleeping`, on web and on the desktop target. proof: `pnpm exec vitest run packages/physics/__tests__/rigidbody.spec.ts` and one `--target desktop` playtest scenario +- [ ] `prewarm` on a live root leaves each material at its authored opacity, or throws `TN_PREWARM_LIVE_SCENE`. proof: `pnpm exec vitest run packages/core/__tests__/prewarm.spec.ts` +- [ ] `state.set` with unchanged values publishes nothing, and the kits pass with their hand checks deleted. proof: `pnpm exec vitest run packages/core/__tests__/state.spec.ts && TN_TEMPLATE_ONLY=starter,action-rpg,tower-defense,runner pnpm test:templates` — engine proof green: `state.spec.ts` has 3 new cases (600 unchanged frames publish 0 times; one changed key publishes once; the same object mutated and set again publishes nothing), red on the previous store. The four hand-written checks are deleted. The playtest arm is not green: `pnpm test:templates` fails this machine's software adapter (`TN_PLAYTEST_PERF_FEW_GPU_SAMPLES`, 15 s dev-server readiness while cooking KTX2), not the store. + +#### Phase 3: The sandbox games adopt it + +**Status:** NOT STARTED +**Files:** in `sandbox/starlance`: `src/render/shotFx.ts`, `src/render/fx.ts`, `src/entities/{Enemies,Weapons,Ship,Towers}.ts`. In `sandbox/midway-open-pacific`: `src/render/world.ts`, `src/render/particles.ts`. +**Implementation:** Reinstall from the branch tarballs. `starlance`: 8 pools to `ObjectPool`, `Towers.ts` drag workaround to `linearDamping` and `sleeping`, hand warm-up deleted. `midway-open-pacific`: the 3 view loops to `ObjectPool`. Record the removed lines per game beside AC-1. A fresh Opus 5.5 subagent at high effort reviews each game diff. +**Verification:** AC-1 and AC-2. + +- [ ] The visual judge returns NEUTRAL or better on `starlance` (`tower-break`, `laser`) and `midway-open-pacific` (`flight`), same pose before and after. proof: judge verdict recorded on this box diff --git a/docs/PRDs/authoring/absorb-2026-11-10/PRD-874-kit-plumbing-leaves-the-generated-game.md b/docs/PRDs/authoring/absorb-2026-11-10/PRD-874-kit-plumbing-leaves-the-generated-game.md new file mode 100644 index 0000000000..723294d88f --- /dev/null +++ b/docs/PRDs/authoring/absorb-2026-11-10/PRD-874-kit-plumbing-leaves-the-generated-game.md @@ -0,0 +1,106 @@ +# PRD-874 — Kit plumbing leaves the generated game + +**Status:** NOT STARTED +**Priority:** P2 — a new game starts with about 850 lines of mechanism that no game should edit, and 17 kit files pass facts that the engine can measure (all phases open) +**Complexity:** 9 (HIGH); risk override: the moved files draw the loading cover and assign materials, so the visual-judge rule applies +**Owner:** ThreeNative maintainers +**Depends on:** None +**Batch:** [Absorb report, 2026-11-10](README.md) + +## Context + +PRD-833 stamps shared kit sources from one copy. That removed the drift. The lines still ship in each +generated game, where an agent reads them and can edit them. + +| Problem | Where | Checked by | +| --- | --- | --- | +| The loading cover is about 300 lines in 14 kits. The per-kit look is one marked block of 14 to 21 lines. The rest is layout, progress, texture attach and teardown. | `templates/starter/src/render/loading.ts:14-27` (look), `:29-325` (mechanism); canonical `template-assets/loading.ts` | surveyor; lead confirmed 16 files, 12 variants | +| On-screen touch controls: 1 976 lines in 12 kits, plus 276 in `shooter`. Each entity then merges touch into its keyboard input by hand. `InputMap` has no on-screen stick. | `templates/*/src/render/touch-controls.ts`; `starter/src/entities/Player.ts:126,139-140` | lead (line count, 13 wiring files), surveyor (split) | +| Each kit passes measured facts to the quality tier: `mobile: isMobile()` at 35 sites in 17 files, `softwareAdapter !== undefined` at 50 sites, `gpuClass?.class` at 33 sites. 4 kits also read `globalThis.__THREENATIVE_PROFILE__`. | `templates/*/src/render/postprocessing.ts`; `packages/core/src/look/quality.ts:49-51` | lead | +| Restart, pause and resume are hand-wired in 12 kits, with `paused` and `uiReady` fields in 14 `state.ts` files. | `templates/starter/src/game.ts:48-59`, `src/scenes/Play.ts:399-404` | lead (12 kits), surveyor | +| Material assignment and material lighting: 3 614 lines in 12 kits. The look enters through one convert function and one options object. | `templates/starter/src/render/materialAssignments.ts:82`, `materialLighting.ts:28` | surveyor | + +Root `AGENTS.md` puts "plumbing every game repeats" in `packages/core/src/` and the look in the kit's +`src/render/`. Each row above mixes the two in one file. + +## Solution + +Each mechanism moves to `@threenative/core`. Each kit keeps one short file that holds only its look and +one call. + +| Mechanism | Engine symbol | The kit keeps | +| --- | --- | --- | +| Loading cover | `createLoadingScreen(ctx, appearance)` | The appearance block: colours, images, bar geometry, `showStatus` | +| Touch overlay | `TouchOverlay(ctx.input, { stick, buttons, style })`, which writes into `InputMap` actions | Radii, positions and colours | +| Quality-tier inputs | `qualityTiers.resolve(ctx)` reads platform, software adapter, GPU class and the hosted profile | `quality.ts` presets; `tier` stays the named override and is reported | +| Restart and pause | `defineGame({ flow: { restart, pause } })`; the engine publishes `paused` and `uiReady` | The scene name and the initial state | +| Material assignment | `createMaterialAssignments(scene, convert)` and `createMaterialLighting(options)` in `@threenative/core/render/environment` | `backlightMaterial.ts`, `backlightControls.ts` and the convention values | + +```mermaid +flowchart LR + Kit["kit src/render/*.ts
look values only"] -->|"appearance, style, convert"| Core["@threenative/core
createLoadingScreen, TouchOverlay,
qualityTiers.resolve, flow, createMaterialAssignments"] + Core --> Host["ctx: canvasLayer, startup, input, renderer facts"] + Touch["touch pointers"] --> Core + Core -->|"InputMap actions"| Entity["entity reads ctx.input only"] +``` + +Consumer flow: a kit scene calls the engine symbol once in `enter`. An entity reads `ctx.input.vector` +and `ctx.input.justPressed` and has no touch branch. + +Risks: + +- **The engine owns a look by accident.** Each symbol takes each colour, size and position from the + kit. A layout that the options cannot express stays possible: the kit draws its own cover on + `ctx.canvasLayer`. The spec renders one cover with every option changed. +- **Web-only.** The touch overlay and the loading cover must run on the native host. Each ships a + native playtest in the same commit. +- **Scaffold hashes.** Each move re-pins the `shared` and `kit` hashes with `pnpm scaffold:pin`. + +Not in scope: height fog and sky plumbing (rows L5 and L6 of the batch README), the kit contract (PRD-749). + +## Acceptance Criteria + +- [ ] AC-1 [local]: A fresh `starter` scaffold holds at least 800 fewer source lines than at 86ba395ab (9 606 lines), and passes its golden playtest. proof: `find src -name '*.ts*' | xargs cat | wc -l` in a fresh scaffold, and `TN_GOLDEN_PATH_TEMPLATES=starter pnpm verify:golden-path` — Evidence: pending. +- [ ] AC-2 [local]: `stress-lab` and `starlance` run on the engine symbols with their kit plumbing files deleted, and pass their playtests. proof: `pnpm lint` and `threenative-playtest --scenario "playtests/*.playtest.json" --browser-recipe webgpu --headed` in each game, removed line count beside this box — Evidence: pending. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| `createLoadingScreen` | Kit `src/render/loading.ts` → scene boot | The mechanism in `template-assets/loading.ts` is deleted; the stamp of the appearance block stays | Phase 1 box | +| `qualityTiers.resolve(ctx)` | Kit `src/render/postprocessing.ts` → `setupPost` | The fact arguments are deleted in 17 files | Phase 1 box | +| `TouchOverlay` | Kit scene `enter` on a touch device | 12 `touch-controls.ts` files shrink to style values; entity touch branches are deleted | Phase 2 box | +| `defineGame({ flow })` | Kit `src/game.ts` | The `onIntent` restart and pause blocks are deleted in 12 kits | Phase 2 box | +| `createMaterialAssignments` | Kit scene `enter` | 2 files per kit shrink to one call | Phase 3 box | + +## Execution Phases + +#### Phase 1: The engine draws the cover and measures the tier inputs + +**Status:** NOT STARTED +**Files:** `packages/core/src/loading-screen.ts` (new), `packages/core/src/look/quality.ts`, `packages/core/src/index.ts`, `packages/create-threenative/src/index.ts` (stamp), `template-assets/loading.ts`, the 16 kit `loading.ts` and `postprocessing.ts` files, `__tests__/scaffold*.spec.ts`. +**Implementation:** Move `loading.ts:29-325` behind `createLoadingScreen`. `qualityTiers.resolve` accepts `ctx` and keeps its argument form for one release with a deprecation row in the manifest. Each kit prints the same `TN_QUALITY_TIER` before and after. +**Verification:** core spec with each appearance option changed; template playtests; the judge. + +- [ ] Each kit's `loading.ts` holds its appearance block and one `createLoadingScreen` call, and the 16 kits pass their playtests. proof: `pnpm exec vitest run packages/core/__tests__/loading-screen.spec.ts && pnpm test:templates` +- [ ] No kit passes `isMobile()`, `softwareAdapter`, `gpuClass` or `__THREENATIVE_PROFILE__` to the tier. proof: `rg -c 'mobile: isMobile\(\)|softwareAdapter !== undefined|__THREENATIVE_PROFILE__' packages/create-threenative/templates` returns 0, and `pnpm exec vitest run packages/core/__tests__/quality-tiers.spec.ts` (new spec; none covers `qualityTiers` today) +- [ ] The visual judge returns NEUTRAL or better on the loading cover and the first settled frame of `starter`, `racing` and `snow`. proof: judge verdict recorded on this box + +#### Phase 2: Touch writes into the input map, and the game flow is declared + +**Status:** NOT STARTED +**Files:** `packages/core/src/touch-overlay.ts` (new), `packages/core/src/input*.ts`, `packages/core/src/game.ts` (flow), the 13 kit touch files, the 12 kit `game.ts` files, the kit entities with a touch branch. +**Implementation:** `TouchOverlay` maps a stick to an `InputMap` vector and a button to an action. `flow.restart` resets the store to the declared initial state, flushes, and re-enters the scene. +**Verification:** core specs; a touch playtest on web and on the Android emulator; a restart playtest. + +- [ ] A touch drag moves the `starter` player through `ctx.input.vector("move")`, on web and on the Android emulator. proof: one playtest scenario with touch pointers, run with `--browser-recipe webgpu` and with `--target android` +- [ ] Restart and pause work in 12 kits with no `onIntent` block in `game.ts`. proof: `rg -c 'intent === "restart"' packages/create-threenative/templates` returns 0, and `pnpm test:templates` + +#### Phase 3: Material assignment moves, and the sandbox games adopt it + +**Status:** NOT STARTED +**Files:** `packages/core/src/render/environment/` (2 new files), the 12 kit `materialAssignments.ts` and `materialLighting.ts` files; in `sandbox/`: `stress-lab/src/render/`, `starlance/src/render/`. +**Implementation:** Move the enroll, ref-count and restore code. The kit passes `backlightMaterial` as `convert`. Reinstall the 2 games from the branch tarballs and delete their copies of the moved files. A fresh Opus 5.5 subagent at high effort reviews each game diff. +**Verification:** the judge on 3 kits; AC-1 and AC-2. + +- [ ] The visual judge returns NEUTRAL or better on `starter`, `racing` and `snow` after the material move, same pose before and after. proof: judge verdict recorded on this box diff --git a/docs/PRDs/authoring/absorb-2026-11-10/README.md b/docs/PRDs/authoring/absorb-2026-11-10/README.md new file mode 100644 index 0000000000..d8e86f6f01 --- /dev/null +++ b/docs/PRDs/authoring/absorb-2026-11-10/README.md @@ -0,0 +1,107 @@ +# Absorb report, 2026-11-10 + +Date: 2026-11-10. Snapshot: local `develop` 86ba395ab. No code changed. + +Owner, 2026-11-10: "look for abstractions opportunity that would make dev life easier, gain +performance optimizations by default, or have anything thats a net positive overall", then "lint rules +to avoid anti patterns", then "goal: DRY, save on tokens, high performance by default (devs dont even +need to know the heavy lifting its being done)", then "we'll have to refactor the sandbox games to use +these new abstractions, and opus 5.5 high will have to review also". + +## Method + +Four read-only Sonnet surveyors, one area each. The lead merged the reports and re-ran the checks marked +"lead" below. Everything else is a surveyor claim: re-check it before you build on it. + +| Surveyor | Area | Size read | +| --- | --- | --- | +| A | `sandbox/starlance` (rail shooter, `minimal` kit) and `sandbox/stress-lab` (`starter` kit) | 11 523 new lines, 3 226 changed lines | +| B | `sandbox/midway-open-pacific` (carrier flight game) | 27 594 lines | +| C | The 16 kits in `packages/create-threenative/templates/` | 108 179 source lines | +| D | 9 sandbox `FRICTION.md` files; `starlance` and `midway-open-pacific` logs went to A and B | 843 lines in 11 files | + +Excluded because a PRD already owns it: kit base and kit contract (PRD-749), draw-call and hitch defaults +(PRD-748), frame-order and collider seams (PRD-325), stamped shared render sources (PRD-833, done), the +reinvention detector (PRD-355), capability search (PRD-298, PRD-300, PRD-865). + +## Findings + +Three patterns hold across the three newest games and the 16 kits. + +1. **A shipped capability is rebuilt by hand because the author does not see it.** + `midway-open-pacific` rebuilt `MatrixWorldPass`, `FrameBudget` with `DebugOverlay`, `mergeByMaterial` + and `InputMap` axes, about 350 lines. `starlance` rebuilt `ctx.after`, `createRandom` and + `ctx.assets.memo`, and rebuilds a 9-second stage on each page load. Lead: the manifest has 0 rows for + `assets.memo` and `startup.progress`, and 18 kit files carry their own PRNG while 7 use `createRandom`. +2. **A mechanism that every game needs is missing, so each game writes it.** Object pools: 8 copies in + `starlance` (about 390 lines), 3 keyed view loops in `midway-open-pacific` (about 230 lines), 3 kits. + `RigidBody3D` has no damping, friction, restitution, angular velocity or sleep (lead: 0 hits in + `packages/physics/src/RigidBody3D.ts`; 2 games logged it). +3. **Kit plumbing that no game should edit ships as game source.** Loading cover (about 300 lines in + 14 kits), on-screen touch controls (1 976 lines in 12 kits, lead), material assignment and lighting + (3 614 lines in 12 kits), and quality-tier inputs that the engine can measure (lead: `mobile: + isMobile()` at 35 sites in 17 files). A new game starts with these lines in its context. + +The kit lint cannot see most of it. Lead: `hotCallbacks.ts:73-75` tracks `update` and `render` only on a +`Scene` subclass, so entity `update` methods are not checked. + +## PRDs + +| PRD | Goal | Priority | +| --- | --- | --- | +| [PRD-872](PRD-872-the-lint-names-the-engine-call.md) | The kit lint sees every per-frame method and names the engine call for each reinvention. Missing `ctx` members become manifest rows. | P1 | +| [PRD-873](PRD-873-the-engine-owns-the-pool-and-the-bodys-rest.md) | `ObjectPool`, `RigidBody3D` rest options, a `prewarm` that cannot hide a live scene, and a state store that skips unchanged keys. | P2 | +| [PRD-874](PRD-874-kit-plumbing-leaves-the-generated-game.md) | Loading cover, touch overlay, restart and pause flow, quality-tier inputs and material assignment move to the engine with every appearance value passed in. | P2 | + +Order: PRD-872 first (cheapest, guards the other two), then PRD-873, then PRD-874. No PRD blocks another. + +## Rules for every PRD here + +1. **Search first.** Run `engine_search_capabilities` for each new symbol before you write it. A row in + this report that names "none found" is a surveyor claim. +2. **Mechanism only.** A new engine symbol takes every colour, size, count and timing from the game + (root `AGENTS.md` rule 3). A value the engine can measure is not an option. +3. **The sandbox games adopt it.** Owner, 2026-11-10. Each PRD refactors `starlance`, + `midway-open-pacific` and `stress-lab` to the new symbols in its last phase. Reinstall the packages + from the branch tarballs, the way `stress-lab` does (`sandbox/.packages/*.tgz`). Do not patch a + sandbox copy of the engine. +4. **Opus 5.5 at high effort reviews.** Owner, 2026-11-10. A fresh Opus 5.5 subagent at high effort + reviews each phase diff and each sandbox refactor. The verdict goes in the PR body, not in a box. +5. **Visual judge.** A change that alters what reaches the screen needs the judge from root `AGENTS.md`. + The verdict goes on the box that claims the outcome. + +## Later: ranked rows with no PRD yet + +Each row gets its own PRD when it is picked. Effort: S under 1 hour, M half a day, L 1 to 3 days. + +| # | Row | Evidence | Effort | +| --- | --- | --- | --- | +| L1 | Scene-warning pass names a material that reads a missing attribute, and a scene where every surface is transparent | 4 games (`grove`, `fps-framework`, `fps-vanilla`, `wildwood`); `TN_SCENE_WARNING` covers unlit foliage only | M | +| L2 | `shadowProxy(root, { skip })`: merged shadow-only casters with the layer mask owned by the engine | `midway-open-pacific/src/render/world.ts`, about 170 lines; logged 2026-09-22 (351 submissions down to 5) | M | +| L3 | `ProjectilePool` with swept sphere hits and a `SphereIndex` for near queries | `starlance` `Enemies.ts`, `Weapons.ts`, about 235 lines; `midway-open-pacific` allocates a `prev` object per projectile per tick | L | +| L4 | `HudPainter`: instanced quads, bitmap font and `projectToHud` for games with no React | `starlance` `hud.ts`, `hudStyle.ts`, `cockpitHud.ts`, about 360 lines | L | +| L5 | `heightFogNode` in `@threenative/core/render/environment` | 118 lines in 11 kits, 4 variants that differ by 1 or 2 lines | M | +| L6 | Sky and cloud render-target plumbing reuses `Atmosphere` and `AtmosphereLuts` | About 370 lines in 6 kits; 0 kits construct `Atmosphere`. Open: whether the compute LUTs run on the owned engine | L | +| L7 | `CharacterBody3D({ coyoteTime, jumpBuffer })` | 4 kits and `stress-lab`, about 20 lines each | S | +| L8 | `runInSlices(generator, { budgetMs })` | `midway-open-pacific` passes fake 4 096-slot arrays to `addInSlices`, 52 lines | S | +| L9 | `CueBank` over `AudioBus`: cooldown per cue, speed-of-sound delay, keyed loop set under a budget | `midway-open-pacific/src/audio.ts`, about 235 lines, 1 game | L | +| L10 | Model textures take the engine anisotropy default | `midway-open-pacific` walks every texture 4 times; core sets `anisotropy = 8` only for terrain splats | M | +| L11 | Shadow map size from a texel budget | `mobile ? 2048 : 4096` in 12 kit `lighting.ts` files (lead) | M | +| L12 | Portable `GridNavigator` | A* hand-written in `rts` (276 lines) and `shooter`; `NavigationAgent3D` is browser-only | L | +| L13 | `--template empty` kit, and `--force` into a folder that holds only `.packages/` | `fluid-lab` 2026-10-01; `minimal` holds 3 701 lines | M | + +Quick wins with no PRD (commit directly after the related check): + +- `TN_PLAYTEST_SCENARIO_ASSERTS_NOTHING` suggestion names `warmupFrames: 0`. +- `TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING` suggestion names `doctor` and `--headed --browser-recipe webgpu`. +- The `maxReadyMs: 8000` example in `packages/playtest/src/assertion-schema.ts` becomes a placeholder. +- `atSteps` gains `gte`. + +## Not surveyed + +- **Engine package architecture.** The untangle batch (PRD-832 to PRD-842) and the god-files batch + (PRD-800 to PRD-804) closed between 2026-11-06 and 2026-11-08. This survey did not repeat them. +- **Stale PRDs that overlap this batch.** PRD-203 (loading drift) and PRD-193 (allocation-free frames) + are NOT STARTED. PRD-874 Phase 1 and PRD-872 Phase 1 cover their goal by a different route. Closing + them is an owner call. +- **Older sandbox games.** Only the three newest games were read in full. diff --git a/packages/core/__tests__/state.spec.ts b/packages/core/__tests__/state.spec.ts index 0c32bc824a..5fe179e464 100644 --- a/packages/core/__tests__/state.spec.ts +++ b/packages/core/__tests__/state.spec.ts @@ -98,3 +98,51 @@ describe("createGameStore", () => { store.stop(); }); }); + +describe("createGameStore changed-only publication", () => { + it("should publish nothing when every value put back is the one already published", () => { + const store = createGameStore({ credits: 40, score: 7 }); + let notifications = 0; + const unsubscribe = store.subscribe(() => notifications++); + + for (let frame = 0; frame < 600; frame++) { + store.set({ credits: 40, score: 7 }); + store.flush(); + } + + expect(notifications).toBe(0); + unsubscribe(); + store.stop(); + }); + + it("should publish the one changed key and keep the unchanged siblings quiet", () => { + const store = createGameStore({ credits: 40, score: 7 }); + const observed: Array<{ credits: number; score: number }> = []; + const unsubscribe = store.subscribe((state) => observed.push({ ...state })); + + store.set({ credits: 40, score: 8 }); + store.flush(); + + expect(observed).toEqual([{ credits: 40, score: 8 }]); + unsubscribe(); + store.stop(); + }); + + it("should publish nothing when the same object is mutated and set again", () => { + const inventory = { count: 1 }; + const store = createGameStore<{ inventory: { count: number } }>({ inventory }); + let notifications = 0; + const unsubscribe = store.subscribe(() => notifications++); + + inventory.count = 2; + store.set({ inventory }); + store.flush(); + expect(notifications).toBe(0); + + store.set({ inventory: { count: 2 } }); + store.flush(); + expect(notifications).toBe(1); + unsubscribe(); + store.stop(); + }); +}); diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index db3df42439..12c36ea4c7 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -1486,6 +1486,26 @@ "supersedes": [], "symbol": "debugFlag" }, + { + "aliases": [], + "constraints": [ + "the same name rule as `debugFlag`: `?quality=` on the web, `TN_DEBUG_QUALITY` on a native launch", + "a name in camelCase becomes UPPER_SNAKE: `debugValue(\"quality\")` reads `?quality=` or `TN_DEBUG_QUALITY`" + ], + "example": "import { debugValue } from \"@threenative/core\";\nconst tier = debugValue(\"quality\");", + "importPath": "@threenative/core", + "kind": "function", + "overrides": [], + "package": "@threenative/core", + "signature": "export function debugValue(name: string): string | undefined { … }", + "situations": [ + "read a debug override whose value is not a boolean", + "override a quality tier or a mode for one run without changing the code" + ], + "summary": "Read the text a debug switch was given by the launch, on the same rule as `debugFlag`.", + "supersedes": [], + "symbol": "debugValue" + }, { "aliases": [ "nearest target crosshair spawns", @@ -10332,6 +10352,31 @@ "supersedes": [], "symbol": "useUiStateDuringStartup" }, + { + "symbol": "ctx.assets.memo", + "package": "@threenative/core", + "importPath": "src/assets.ts", + "kind": "function", + "signature": "ctx.assets.memo(key: string, build: () => T | Promise): Promise", + "summary": "Returns what `build` returned the last time this build of the game ran, read back from disk, and calls `build` only when nothing is stored. Use it for seeded work that is the same every run: placement lists, painted sky pixels, terrain heights, a generated stage.", + "situations": [ + "cache generated geometry between page loads", + "stop rebuilding the same generated stage on every page load", + "cache painted sky pixels or baked terrain heights between runs", + "keep seeded placement work the same every run without recomputing it", + "make a slow boot fast without shipping a baked asset" + ], + "aliases": [], + "example": "const { pixels } = await ctx.assets.memo(\"sky\", () => ({ pixels: paintSky() }));", + "constraints": [ + "The value is named typed arrays; a map of names to typed arrays is what is stored.", + "On the dev server and on a native host `build` runs every time, and `TN_ASSET_MEMO` reports why: the cache is a build artifact, not a substitute for authoring.", + "A new build of the game regenerates: the key alone does not pin a value across a rebuild.", + "A game that drives a loader outside `defineGame` may call it whenever it likes; the reveal is not waited on." + ], + "overrides": ["TN_ASSET_MEMO reports why a run rebuilt instead of reading the store"], + "supersedes": [] + }, { "symbol": "createStormAudio", "package": "template:rain", @@ -10557,6 +10602,76 @@ "supersedes": [], "aliases": [] }, + { + "symbol": "ctx.after", + "package": "@threenative/core", + "importPath": "src/scene.ts", + "kind": "function", + "signature": "ctx.after(delay: number, callback: () => void): ScheduleHandle", + "summary": "Runs `callback` once, `delay` seconds of game time later, and returns a handle that cancels it. Game time stops while the game is paused, so a delayed beat does not fire behind a paused frame.", + "situations": [ + "run a callback after a delay", + "schedule a one-shot timer that pauses with the game", + "do something a moment later without setTimeout", + "cancel a pending delayed action when a scene exits" + ], + "aliases": [], + "example": "const spawn = ctx.after(0.4, () => ctx.add(makeWave()));\nspawn(); // cancels", + "constraints": [ + "The delay is game seconds: a paused game does not age it, and a slow frame does not skip it.", + "`setTimeout` keeps running while the game is paused and after the scene is gone; this does not.", + "Call the returned handle to cancel: a pending callback holds its scene's closure alive." + ], + "overrides": [], + "supersedes": [] + }, + { + "symbol": "ctx.beforeRender", + "package": "@threenative/core", + "importPath": "src/scene.ts", + "kind": "function", + "signature": "ctx.beforeRender(callback: () => void): () => void", + "summary": "Registers work that runs once per actual world render, after the frame's last fixed update and before the projection reconciles and the renderer draws. The last place to change a transform, a uniform or a camera before the frame is presented.", + "situations": [ + "run a callback just before the frame renders", + "update a camera or a uniform after physics and before the draw", + "change a transform in the same frame it is seen", + "run per-render work that must not run on a loader-only frame" + ], + "aliases": [], + "example": "const stop = ctx.beforeRender(() => (material.uniforms.time.value = clock.elapsed));\n// later: stop();", + "constraints": [ + "A held, loader-only frame has no world draw and dispatches nothing.", + "Runs after every fixed update in the frame, so a value it writes is the one drawn.", + "Returns an unregister function; call it when the object that needed it is gone." + ], + "overrides": [], + "supersedes": [] + }, + { + "symbol": "ctx.startup.progress", + "package": "@threenative/core", + "importPath": "src/scene.ts", + "kind": "function", + "signature": "ctx.startup.progress: number", + "summary": "How much of the start scene has loaded and presented, 0 to 1, for a loading cover's bar. It never falls: a loader whose pending work grows keeps the number it reached rather than jumping backwards.", + "situations": [ + "boot progress bar", + "show how much of the start scene has loaded", + "loading screen percentage", + "a progress bar that never goes backwards", + "report startup progress while assets stream in" + ], + "aliases": [], + "example": "const width = `${Math.round(ctx.startup.progress * 100)}%`; // a cover the game draws on ctx.canvasLayer", + "constraints": [ + "Never decreases, even when the loader is asked for more work mid-boot.", + "Reaches 1 only after the first world render has been presented, not when the last byte arrived.", + "A loading view the engine drew for you already reads it; this is for a cover the game draws itself." + ], + "overrides": [], + "supersedes": [] + }, { "symbol": "bloom", "package": "three", diff --git a/packages/core/src/debug.ts b/packages/core/src/debug.ts index 089f2c2bef..4e14c0cf3b 100644 --- a/packages/core/src/debug.ts +++ b/packages/core/src/debug.ts @@ -139,6 +139,22 @@ export function debugFlag(name: string): boolean { return readLaunchFlag({ environment: envName(name), query: name }); } +/** + * The text this launch gave a debug switch, or `undefined` when no seam named it. + * + * `debugFlag`'s non-boolean sibling: the same naming rule — `?name` on the web, `TN_DEBUG_NAME` on + * a native launch — for a switch whose value is a word rather than a yes or no, such as a quality + * tier or a mode. An empty string means a seam named the switch and gave no value. + * + * @situation read a debug override whose value is not a boolean + * @situation override a quality tier or a mode for one run without changing the code + * @constraint a name in camelCase becomes UPPER_SNAKE: `debugValue("quality")` reads `?quality=` or `TN_DEBUG_QUALITY` + * @example const tier = debugValue("quality"); + */ +export function debugValue(name: string): string | undefined { + return readLaunchValue({ environment: envName(name), query: name }); +} + /** * Publish one game object for a capture script or the console, in development builds only. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ac2c57f319..2793140691 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -456,6 +456,15 @@ export type { IMergeByMaterialOptions } from "./merge-parts.js"; * if (debugFlag("freeCam")) camera.flyMode = true; */ export { debugFlag } from "./debug.js"; +/** + * Read the text a debug switch was given by the launch, on the same rule as `debugFlag`. + * @situation read a debug override whose value is not a boolean + * @situation override a quality tier or a mode for one run without changing the code + * @constraint the same name rule as `debugFlag`: `?quality=` on the web, `TN_DEBUG_QUALITY` on a native launch + * @example import { debugValue } from "@threenative/core"; + * const tier = debugValue("quality"); + */ +export { debugValue } from "./debug.js"; /** * Publish one game object under `__THREENATIVE__.debug` for a capture script or the console. * @situation expose a game object to a capture script or the console in dev builds diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 57d539cfdc..7e07f0ee13 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -13,6 +13,11 @@ export type GameStore> = StoreApi & { /** * A store the UI reads, coalesced to one publication per frame unless a game names an interval. * + * A key whose new value is `Object.is`-equal to the current value does not enter `pending`, so a + * frame that changed nothing publishes nothing and `flush` stays quiet. The flip side: mutating an + * object in place and setting it again is the same object, so nothing publishes — publish a new + * object or bump a version field on the value. + * * `intervalMs` is an override, not the rule. A fixed 100 ms default published the HUD ten times a * second whatever the frame rate was, which reads as a HUD that is not live — the number a player * watches moves in visible steps while the scene beside it runs smoothly. Left unset, `flush` is @@ -47,9 +52,17 @@ export function createGameStore>( gameStore.getState = () => current; gameStore.set = (patch: StatePatch) => { const next = typeof patch === "function" ? patch(current) : patch; - Object.assign(pending, next); - Object.assign(current, next); - hasPending = true; + let changed = false; + for (const key in next) { + const value = next[key]; + // The value the HUD would publish is the one it already has: no publish, no cross-process + // write on native. Five kits hand-wrote this comparison before every `set`. + if (Object.is(value, current[key])) continue; + Reflect.set(pending, key, value); + Reflect.set(current, key, value); + changed = true; + } + if (changed) hasPending = true; }; gameStore.flush = flush; gameStore.start = () => { diff --git a/packages/create-threenative/__tests__/authoring-quality.spec.ts b/packages/create-threenative/__tests__/authoring-quality.spec.ts index bdcf596d9d..135bab58cd 100644 --- a/packages/create-threenative/__tests__/authoring-quality.spec.ts +++ b/packages/create-threenative/__tests__/authoring-quality.spec.ts @@ -141,7 +141,7 @@ describe("threenative/hot-callback-allocation", () => { expect(rules(r)).toEqual(["threenative/hot-callback-allocation"]); }); - it("stays quiet for startup allocation, deferred functions, non-scene classes and unrelated classes", async () => { + it("stays quiet for startup allocation, deferred functions and methods that are not frame names", async () => { const quiet = await lint( "s.ts", SCENE( @@ -151,9 +151,14 @@ describe("threenative/hot-callback-allocation", () => { expect(quiet.messages).toEqual([]); const other = await lint( "o.ts", - 'import { Vector3 } from "three";\nclass Other { update(): void { void new Vector3(); } }\nclass Vector2 {}\nexport class P { render(): void { void new Vector2(); } }\nexport { Other };\n', + 'import { Vector3 } from "three";\nclass Other { refresh(): void { void new Vector3(); } }\nclass Vector2 {}\nexport class P { render(): void { void new Vector2(); } }\nexport { Other };\n', ); expect(other.messages).toEqual([]); + const entity = await lint( + "e.ts", + 'import { Vector3 } from "three";\nexport class Enemies { update(): void { void new Vector3(); } }\n', + ); + expect(rules(entity)).toEqual(["threenative/hot-callback-allocation"]); }); it("leaves authored bytes unchanged", async () => { diff --git a/packages/create-threenative/__tests__/quality-rules.spec.ts b/packages/create-threenative/__tests__/quality-rules.spec.ts index 396afe3f0a..eff74a0601 100644 --- a/packages/create-threenative/__tests__/quality-rules.spec.ts +++ b/packages/create-threenative/__tests__/quality-rules.spec.ts @@ -1,7 +1,13 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { InputMap } from "@threenative/core"; import tsParser from "@typescript-eslint/parser"; import { Linter } from "eslint"; +import { MathUtils } from "three"; import { describe, expect, it } from "vitest"; import { threenativePlugin } from "../src/quality/plugin.js"; +import { REINVENTIONS, reinventionConfigs, reinventionRules } from "../src/quality/reinventions.js"; const RULES = [ "hot-callback-allocation", @@ -167,6 +173,30 @@ describe("threenative/startup-override-reason", () => { }); }); +describe("threenative/hot-callback-* in an entity update method", () => { + const entity = (body: string): string => `import * as THREE from "three"; +export class Enemies {\n${body}\n}\n`; + + it("treats a non-static update, fixedUpdate, tick or step as per-frame", () => { + for (const name of ["update", "fixedUpdate", "tick", "step"]) { + expect(fired(entity(`${name}() { const v = new THREE.Vector2(); return v; }`))).toEqual([ + "threenative/hot-callback-allocation", + ]); + } + expect(fired(entity("update() { this.world.traverse(() => undefined); }"))).toEqual([ + "threenative/hot-callback-scene-work", + ]); + }); + + it("passes construction in load, a static update, and a method that is not a frame name", () => { + expect(fired(entity("load() { const v = new THREE.Vector2(); return v; }"))).toEqual([]); + expect(fired(entity("static update() { const v = new THREE.Vector2(); return v; }"))).toEqual( + [], + ); + expect(fired(entity("refresh() { const v = new THREE.Vector2(); return v; }"))).toEqual([]); + }); +}); + describe("threenative/hot-callback-allocation after the shared tracker", () => { it("still flags a Vector3 in update and passes it in enter", () => { expect(fired(scene("update() { const v = new THREE.Vector3(); return v; }"))).toEqual([ @@ -175,3 +205,167 @@ describe("threenative/hot-callback-allocation after the shared tracker", () => { expect(fired(scene("enter() { this.v = new THREE.Vector3(); }"))).toEqual([]); }); }); + +describe("the reinvention rows", () => { + /** One hand-written form and the call that replaces it, per row. */ + const CASES: readonly { + readonly capability: string; + readonly bad: string; + readonly good: string; + }[] = [ + { + capability: "createRandom", + bad: "const r = Math.random();\n", + good: "const random = createRandom(7)();\n", + }, + { + capability: "createRandom", + bad: "let s = 7;\ns = (Math.imul(s, 1664525) + 1013904223) | 0;\n", + good: "const random = createRandom(7)();\n", + }, + { + capability: "createRandom", + bad: "let a = 7;\na = (a + 0x6d2b79f5) | 0;\n", + good: "const random = createRandom(7)();\n", + }, + { + capability: "MathUtils.damp", + bad: "const k = 1 - Math.exp(-dt * 4);\n", + good: "const k = MathUtils.damp(a, b, 4, dt);\n", + }, + { + capability: "debugFlag", + bad: 'const asked = new URLSearchParams(location.search).get("quality");\n', + good: 'const asked = debugValue("quality");\n', + }, + { + capability: "renderer.matrixWorld", + bad: "scene.matrixWorldAutoUpdate = false;\n", + good: "const config = { renderer: { matrixWorld: true } };\n", + }, + { + capability: "InputMap.axis", + bad: 'const x = (keys.has("KeyA") ? 1 : 0) - (keys.has("KeyD") ? 1 : 0);\n', + good: 'const x = ctx.input.axis("moveX");\n', + }, + { + capability: "MathUtils.smoothstep", + bad: "const t = u * u * (3 - 2 * u);\n", + good: "const t = MathUtils.smoothstep(u, 0, 1);\n", + }, + ]; + + /** Lint one file with only `rows` on, at error. */ + function reinvented( + rows: Parameters[0], + source: string, + filename = "src/game.ts", + ): string[] { + return new Linter() + .verify( + source, + [ + { + files: ["src/**/*.ts"], + languageOptions: { parser: tsParser as Linter.Parser }, + rules: reinventionRules(rows), + }, + ], + filename, + ) + .map((message) => message.ruleId ?? `parse:${message.message}`); + } + + const row = (capability: string) => { + const found = REINVENTIONS.find((candidate) => candidate.capability === capability); + if (found === undefined) throw new Error(`no reinvention row names ${capability}`); + return found; + }; + + it("fails each hand-written form and passes the call it names", () => { + for (const test of CASES) { + expect(reinvented([row(test.capability)], test.bad), test.bad).not.toEqual([]); + expect(reinvented([row(test.capability)], test.good), test.good).toEqual([]); + } + }); + + it("names only a symbol that still exists", () => { + const manifest = JSON.parse( + readFileSync(new URL("../../core/capabilities.json", import.meta.url), "utf8"), + ) as { entries: readonly Record[] }; + const entries = new Map( + manifest.entries.map((entry) => [String(entry.symbol), JSON.stringify(entry)]), + ); + for (const reinvention of REINVENTIONS) { + const [owner, member] = reinvention.capability.split("."); + if (reinvention.source === "three") { + expect(owner, reinvention.capability).toBe("MathUtils"); + const members = MathUtils as unknown as Record; + expect(typeof members[member ?? ""], reinvention.capability).toBe("function"); + continue; + } + if (entries.has(reinvention.capability)) continue; + const ownerRow = owner === undefined ? undefined : entries.get(owner); + expect(ownerRow, reinvention.capability).toBeDefined(); + expect(ownerRow?.includes(member ?? ""), reinvention.capability).toBe(true); + } + // The messages also name these, and a message that names nothing is the bug the spec exists for. + expect(entries.has("debugValue"), "debugValue").toBe(true); + expect(typeof (InputMap.prototype as unknown as Record).axis).toBe("function"); + }); + + it("should pass every kit's own source under the hot rules and the rows", () => { + const templatesRoot = fileURLToPath(new URL("../templates", import.meta.url)); + const sourceFiles = (directory: string): string[] => + readdirSync(directory).flatMap((entry) => { + const file = path.join(directory, entry); + if (statSync(file).isDirectory()) return sourceFiles(file); + return /\.tsx?$/u.test(entry) ? [file] : []; + }); + const files = readdirSync(templatesRoot).flatMap((kit) => { + const source = path.join(templatesRoot, kit, "src"); + if (!existsSync(source)) return []; + return sourceFiles(source).map((file) => ({ file, kit })); + }); + // Fail closed: a discovery bug must not report a green zero. + expect(files.length).toBeGreaterThan(300); + + const found: string[] = []; + for (const { file, kit } of files) { + const relative = path.relative(path.join(templatesRoot, kit), file); + const messages = new Linter().verify( + readFileSync(file, "utf8"), + [ + { + files: ["src/**/*.ts", "src/**/*.tsx"], + languageOptions: { parser: tsParser as Linter.Parser }, + plugins: { threenative: threenativePlugin }, + rules: { + "threenative/hot-callback-allocation": "error", + "threenative/hot-callback-resource-creation": "error", + "threenative/hot-callback-scene-work": "error", + }, + }, + ...reinventionConfigs(["src/**/*.ts", "src/**/*.tsx"]), + ], + relative, + ); + for (const message of messages) + found.push(`${kit}/${relative}:${message.line} ${message.message}`); + } + expect(found).toEqual([]); + }); + + it("keeps a path's excluded rows out and leaves the others on", () => { + const configs = reinventionConfigs(["src/**/*.ts"]); + const lint = (source: string, filename: string): string[] => + new Linter() + .verify(source, configs, filename) + .map((message) => message.ruleId ?? `parse:${message.message}`); + const ui = "const r = Math.random();\nconst t = u * u * (3 - 2 * u);\n"; + // The UI keeps the platform RNG and still gets the smoothstep row. + expect(lint(ui, "src/ui/hud.ts").length).toBe(1); + // The same two lines in a scene are both reinventions. + expect(lint(ui, "src/scenes/Play.ts").length).toBe(2); + }); +}); diff --git a/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx b/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx index 8c19355a92..edf5051a42 100644 --- a/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx +++ b/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx @@ -55,13 +55,17 @@ describe("Rain initial quality ownership", () => { qualityExplicit: false, loading: { ready: false, progress: 0 }, }; + // A browser's `window.location` is the same object as `globalThis.location`, and the engine's + // debug seam reads the latter; the stub shares one object so `?quality=` reaches both. + const location = { search: "" }; vi.stubGlobal("window", { innerWidth: 1440, - location: { search: "" }, + location, addEventListener: vi.fn(), removeEventListener: vi.fn(), matchMedia: () => ({ matches: hooks.media }), }); + vi.stubGlobal("location", location); vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); hooks.media = false; }); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index f935cfa20c..f70b4a3bb7 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -117,67 +117,67 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin "action-rpg": { shared: "89aa333d822a6627d2ad23f96521abf4ea7ba92bcec1ed55f9c30a8ee83ff250", - kit: "a8d70b273cef24257fcffa5ff29033005501615a23a20ad6e542afee61013efa", + kit: "3abc2cea04a93caa14ff1ad36a7f55c310f310f0c56462ed4e25c4ee1a1fd48c", }, adventure: { shared: "ddac540bdf78253f87db7432d1b80a2a4dc7e20809cd1006b3cc261492b7d6fa", - kit: "d2fa700c0186ed7eb9199700ed5b027078420801d0d09de76954364f94937d7a", + kit: "0c54f1d66dc0aa5ab2720f49c27ae824b9f672a89bdaa2a888e6de349e90b8b6", }, collectathon: { shared: "3a0df8488b8d94deb01464e10fabde2d6ac4e65647e98f39f5afb235ec71ffae", - kit: "d442044c496f13384c098fa60573220cf41f5c0c07ac13ada43c1a5f0f64de72", + kit: "69fcac12372cecbac729d2fa9b5b30aa00c95497209299444cea4da3cec15859", }, kart: { shared: "676434e30b0ed4e2dfe698e5d6ab393e47fe7779ef7df617ca8f12f1f24c7748", - kit: "edd8ff2a1a9bb9465eaa6c82ff830b57d75f3b594018c45cc7cf3a6c737bd9c2", + kit: "35f8fba0f18121466cb5750a456ffd9fe94692391ecd25219a8f4816c28c661a", }, minimal: { shared: "f4b43e245b3a9810947d7a3b5652b409ffb097cafb7834ed215567f83d3379af", - kit: "29c32badb8a5c214d2284d6a2635570c03f3241feb8180cce5da681cd8222b96", + kit: "cc82ebcaec522ba5786ee160857c8664048adb49208e5d89ea197bceebc2ef41", }, platformer: { shared: "0720f0c8f3374f5d41e7540dd3fe16f4ceeb2b5717fe2b163bafd4d7742983a8", - kit: "acb3a17cf7c1371188eedeab5e7a921929c56e35461f9c18c92edd031a56393f", + kit: "828eddf479a880adcf2f81fdb493a32ea1724a738daa95fd19e628cf6fe22e1a", }, puzzle: { shared: "865623ec3097b295dfc67012b3e61eaba0d7a322e2b6ec59620478863fdbdae6", - kit: "81db308b4a7c9dbb711555f4b4355d4c0fa3baa6d5d2f3a80327333fe5b39401", + kit: "f3c2ecbaf4ab1294827379efc6af8e05595e67aff5b4e2d08642b2923c750eaf", }, racing: { shared: "8463fb44077a88e0c5938db571af259c59f668eedca9d477a0bc4b70239c3c60", - kit: "502ce8d65e38e69bfcec7c4c9981284048809676c6267f2a945534ed6a3f14c9", + kit: "d2552be8ba971387ea086ea8ed39beca7ca7492a7bc175ba9b0db9eb533d39c7", }, rain: { shared: "f3d68ab1eb2759202e15499046cc80d362d3586be314f68610eed0bf2c20367e", - kit: "f0adf851fb6d0e14acdf903b47a00e3fa41ff88ca5f7086e43ea3a09d4609b96", + kit: "62155c8e3aaad74cc49bc0c2063a61a7bf1bc78ad28d40fee4708e1f1dfefd5a", }, rts: { shared: "9e7c20d1b7eceb245eb9b1db1e30d1294f6533a9b69ef0f3e3bfad1c67d86b38", - kit: "15ae383f30b33a6ca2c3185f51d3365f5fbe71b49f1432a3d6b30c3dc576d669", + kit: "11c32528f07476b2a5ac2a214529e611c22a4317a8c89aa96b9c5648208f84b8", }, runner: { shared: "cd43eda17fbdc91bedb7680441daf0a30dee0f3b1e19bacebb124ab4471ff1ba", - kit: "b90f44f06df584db1557264c0fb07167adeb0b136c547839884ff1bcddca1158", + kit: "2c3f0a910f0ad2b4895795926c4a1fb5cb4336eb615ecc3f6347fa82170f5da9", }, sailing: { shared: "6e39c4c67676a2c1d4ae7fdf148a0e7cc4dde3b6207b49da84701a4266e720c0", - kit: "db196dbad5d5b1e1c6b20bac8369c28426f7e2d7dac5e2ee948ec18a79e70cf0", + kit: "fbfd8c80d5e7c0ec8f42ed9697ce9df9e4649f19aa28e51e1f20550f45b6fd2e", }, shooter: { shared: "dbe1eb0f2872275fc9759deea95c4c624f7fa82fb7adccab3d22a20889cabaac", - kit: "0e89ad8f17773794527b8bb153f265b6a7344792498f2a173447a666ecd60cf5", + kit: "8b8fadb8b10756d56af91dd08c1e6c16a9fd9409dc24b2f989c60cec22c67d4f", }, snow: { shared: "130389a38fb48663bd0cd391751ab3d2eb0c47e0d535e236b09366fd1fc93099", - kit: "4da758c138d7f80b9a4e88903e2c86172c0029bd160da1ebe5f37b72bcfea823", + kit: "7856c03a93624b00854cdcae6a08d8921e7ff6b4735a6beafd94b750dd89fa70", }, starter: { shared: "000bd75177222e5b3ab03beee4118b7c63a8dd59fdc316487d61ff7b3758376d", - kit: "9b70f462c27add3f689ea04925631afe92ab296b36309660107b900bf7f40a69", + kit: "35466c0bf26142c37b789a5b434473279b40018b5a8dc58145d5717fa75c6608", }, "tower-defense": { shared: "119e88ddb3fdcf2f3f4c0aade6e62e4b480e2b6a1292d69552040fb6e8083470", - kit: "7fa359a8a390a9bcbb2c832ba4fe66f45129170f387333173625cab93e0c7ba0", + kit: "a99e95e97957bf1e940821629f926b7ddb180d438d6b5795f701885d313fc5df", }, // scaffold-pin:end }; diff --git a/packages/create-threenative/agent-docs/references/performance-basics.md b/packages/create-threenative/agent-docs/references/performance-basics.md index c950c294cc..0ee4d4e3be 100644 --- a/packages/create-threenative/agent-docs/references/performance-basics.md +++ b/packages/create-threenative/agent-docs/references/performance-basics.md @@ -134,7 +134,8 @@ change a line. `pnpm check` runs the strict typecheck, ESLint and a Biome format check, and `pnpm test` runs it first. ESLint fails on a floating Promise and on an async callback given to a synchronous slot, and it warns when a `new Vector3()`-style math constructor runs directly inside `Scene.update`, -`Scene.render` or a `ctx.beforeRender` callback. That warning is a prompt to measure, not a +`Scene.render`, an entity's `update`/`fixedUpdate`/`tick`/`step` method or a `ctx.beforeRender` +callback. That warning is a prompt to measure, not a finding: reuse an instance the scene owns only if `TN_FRAME_BUDGET` shows allocation pressure. It does not follow helper calls, so a quiet result is not proof a frame allocates nothing. A suppression needs a reason: `// eslint-disable-next-line -- `. Lint does not prove a diff --git a/packages/create-threenative/src/quality/hotCallbackAllocation.ts b/packages/create-threenative/src/quality/hotCallbackAllocation.ts index 685b37809b..6166d45996 100644 --- a/packages/create-threenative/src/quality/hotCallbackAllocation.ts +++ b/packages/create-threenative/src/quality/hotCallbackAllocation.ts @@ -13,7 +13,8 @@ function importedName( } /** Warns on a Three.js math constructor that runs directly inside a per-frame callback: a - * `Scene` `update`/`render` method or a function registered with `ctx.beforeRender` or `ctx.afterPhysics`. It is + * `Scene` `update`/`render` method, a non-static `update`/`fixedUpdate`/`tick`/`step` method on an + * entity class, or a function registered with `ctx.beforeRender` or `ctx.afterPhysics`. It is * syntactic on purpose. A callback hidden behind a helper is outside its coverage. */ export const hotCallbackAllocation: Rule.RuleModule = { meta: { diff --git a/packages/create-threenative/src/quality/hotCallbacks.ts b/packages/create-threenative/src/quality/hotCallbacks.ts index 75cc6b26d9..33a2d94d1e 100644 --- a/packages/create-threenative/src/quality/hotCallbacks.ts +++ b/packages/create-threenative/src/quality/hotCallbacks.ts @@ -2,6 +2,10 @@ import type { Rule } from "eslint"; import type * as ESTree from "estree"; const FRAME_METHODS = new Set(["render", "update"]); +/** Method names a class other than a `Scene` may use for its own per-frame step. A game's entity + * class is not a node, so the engine cannot see its `update`, and 22 allocation or tree-walk sites + * in kit entity `update` methods were invisible to the rules. */ +const ENTITY_FRAME_METHODS = new Set(["fixedUpdate", "step", "tick", "update"]); /** Registration calls whose first argument runs every frame (or every render call), by label. The * TSL node hooks run once per render call, and a post chain makes many a frame: the shooter's * backlight validated its controls in eight `onRenderUpdate` callbacks, 525 bridge reads a frame. */ @@ -58,8 +62,24 @@ function hookName(parent: ESTree.Node, node: FunctionNode): string | undefined { return name === undefined ? undefined : FRAME_HOOKS.get(name); } +/** The label a class method carries when it runs each frame: `Scene.update`, or `Enemies.tick` for a + * game's own entity class. */ +function methodLabel(parent: Rule.Node, sceneLocals: ReadonlySet): string | undefined { + if (parent.type !== "MethodDefinition" || parent.static || parent.key.type !== "Identifier") + return undefined; + const owner = (parent.parent?.parent as ESTree.Class | undefined) ?? undefined; + const base = owner?.superClass; + const isSceneBase = base?.type === "Identifier" && sceneLocals.has(base.name); + if (isSceneBase) + return FRAME_METHODS.has(parent.key.name) ? `Scene.${parent.key.name}` : undefined; + if (!ENTITY_FRAME_METHODS.has(parent.key.name)) return undefined; + const className = owner?.id?.name; + return className === undefined ? parent.key.name : `${className}.${parent.key.name}`; +} + /** Tracks whether the visitor is directly inside a per-frame callback: a `Scene` `update`/`render` - * method, a function registered with `ctx.beforeRender` / `ctx.afterPhysics`, a TSL node's + * method, a non-static `update`/`fixedUpdate`/`tick`/`step` method on any other class, a function + * registered with `ctx.beforeRender` / `ctx.afterPhysics`, a TSL node's * `onRenderUpdate` / `onFrameUpdate` / `onObjectUpdate`, or `setAnimationLoop`; a callback handed to * an array iterator inside one is inside it too. Syntactic on purpose: a callback hidden behind a * helper is outside its coverage. Shared by every rule that means "this must not run every frame". */ @@ -69,11 +89,8 @@ export function trackHotCallbacks(): IHotCallbacks { const frameName = (node: FunctionNode): string | undefined => { const parent = (node as Rule.Node).parent as Rule.Node; - if (parent.type === "MethodDefinition" && !parent.static && parent.key.type === "Identifier") { - const base = (parent.parent?.parent as ESTree.Class | undefined)?.superClass; - const isSceneBase = base?.type === "Identifier" && sceneLocals.has(base.name); - if (isSceneBase && FRAME_METHODS.has(parent.key.name)) return `Scene.${parent.key.name}`; - } + const method = methodLabel(parent, sceneLocals); + if (method !== undefined) return method; const hook = hookName(parent, node); if (hook !== undefined) return hook; const member = calleeMember(parent, node); diff --git a/packages/create-threenative/src/quality/index.ts b/packages/create-threenative/src/quality/index.ts index 16da6bc0eb..12651f89a5 100644 --- a/packages/create-threenative/src/quality/index.ts +++ b/packages/create-threenative/src/quality/index.ts @@ -6,6 +6,7 @@ import tsParser from "@typescript-eslint/parser"; import type { Linter } from "eslint"; import reactHooks from "eslint-plugin-react-hooks"; import { threenativePlugin } from "./plugin.js"; +import { reinventionConfigs } from "./reinventions.js"; export interface IQualityOptions { /** The project root: where `tsconfig.json` lives and the typed rules resolve from. */ @@ -65,6 +66,7 @@ export function qualityConfig({ root }: IQualityOptions): Linter.Config[] { }, }, ]; + config.push(...reinventionConfigs(SOURCE)); if (hasReact(root)) { config.push({ files: ["src/**/*.tsx"], diff --git a/packages/create-threenative/src/quality/reinventions.ts b/packages/create-threenative/src/quality/reinventions.ts new file mode 100644 index 0000000000..146644a397 --- /dev/null +++ b/packages/create-threenative/src/quality/reinventions.ts @@ -0,0 +1,110 @@ +import type { Linter } from "eslint"; + +/** + * The hand-written forms of a capability the engine already ships, one row each. + * + * Every row is a `no-restricted-syntax` selector, so the rule that rejects them is ESLint's own + * and this file is data. The three newest games rebuilt `MatrixWorldPass`, `InputMap` axes and + * `createRandom` by hand because the author never saw them, about 350 lines of shipped capability + * written again; the lint runs in every generated game, so it is the cheapest place to say the + * name. `__tests__/quality-rules.spec.ts` proves each row fails a fixture, passes the fixed form, + * and that its `capability` still resolves in the manifest (or in `three`) — a message cannot name + * a symbol that was removed. + */ +export interface IReinvention { + /** What the error says, naming the engine call and where it comes from. */ + readonly message: string; + /** The symbol the message names; the spec proves it still exists. */ + readonly capability: string; + /** Where the named symbol lives: a core export, or a `MathUtils` member from `three`. */ + readonly source: "core" | "three"; + /** The selector where the named call replaces the hand-written form. */ + readonly selector: string; + /** Project paths where the hand-written form is the content, not a reinvention. */ + readonly outside?: readonly string[]; +} + +/** + * Where a hand-written PRNG is the content, not a reinvention of the engine's seeded stream. + * + * `src/render/` stays plain Three.js by rule: a kit's render sources may not import a core + * symbol that is not in `RENDER_PORTABLE_CORE_SYMBOLS` (`scripts/check-core-boundary.ts`), and + * their noise and placement streams are authored against those exact numbers — a different + * generator draws different clouds. Audio synthesis and UI animation likewise keep the platform + * RNG. A level, an entity or a scene uses `createRandom`. + */ +export const PRNG_OUTSIDE = ["src/audio.ts", "src/audio/**", "src/render/**", "src/ui/**"] as const; + +export const REINVENTIONS: readonly IReinvention[] = [ + { + selector: + ':matches(CallExpression[callee.object.name="Math"][callee.property.name="random"], Literal[value=1664525], Literal[value=1013904223], Literal[value=1831565813])', + capability: "createRandom", + source: "core", + outside: PRNG_OUTSIDE, + message: + "A hand-written PRNG (Math.random or the LCG constants) is a capability the engine ships: use `createRandom(seed)` from `@threenative/core`, or `ctx.random`, so the same seed replays the same game.", + }, + { + selector: + 'BinaryExpression[operator="-"][left.value=1][right.callee.object.name="Math"][right.callee.property.name="exp"]', + capability: "MathUtils.damp", + source: "three", + message: + "`1 - Math.exp(-…)` is a damp factor the engine ships: use `MathUtils.damp(current, target, rate, dt)` from `three`, which is the same curve with the rate named.", + }, + { + selector: + ':matches(MemberExpression[object.name="location"][property.name="search"], MemberExpression[object.property.name="location"][property.name="search"])', + capability: "debugFlag", + source: "core", + message: + "Reading `location.search` by hand is a launch seam the engine owns: use `debugFlag(name)` or `debugValue(name)` from `@threenative/core` (`?name` on web, `TN_DEBUG_NAME` on native).", + }, + { + selector: 'AssignmentExpression[left.property.name="matrixWorldAutoUpdate"][right.value=false]', + capability: "renderer.matrixWorld", + source: "core", + message: + "Turning `matrixWorldAutoUpdate` off by hand is the engine's own walk: set `renderer.matrixWorld` in the game config for a whole scene, or `markStatic(node)` for one node that never moves.", + }, + { + selector: + 'BinaryExpression[operator="-"][left.type="ConditionalExpression"][left.consequent.value=1][left.alternate.value=0][right.type="ConditionalExpression"][right.consequent.value=1][right.alternate.value=0]', + capability: "InputMap.axis", + source: "core", + message: + "An axis built from raw keys is a binding the engine already reads: declare it in `defineGame({ input })` and use `ctx.input.axis(name)` from `@threenative/core`.", + }, + { + selector: + 'BinaryExpression[operator="*"][right.type="BinaryExpression"][right.operator="-"][right.left.value=3][right.right.type="BinaryExpression"][right.right.operator="*"][right.right.left.value=2]', + capability: "MathUtils.smoothstep", + source: "three", + message: + "`x * x * (3 - 2 * x)` is the engine's smoothstep: use `MathUtils.smoothstep(x, 0, 1)` from `three`, which clamps the same way.", + }, +]; + +/** The `no-restricted-syntax` entries for one group of rows. */ +export function reinventionRules(rows: readonly IReinvention[]): Linter.RulesRecord { + return { + "no-restricted-syntax": [ + "error", + ...rows.map(({ selector, message }) => ({ selector, message })), + ], + }; +} + +/** + * The two config objects the rows need: every row everywhere, and the rows a project path excludes + * on top. Later objects win a rule for a file both match, so the wider list comes last. + */ +export function reinventionConfigs(files: readonly string[]): Linter.Config[] { + const always = REINVENTIONS.filter((row) => row.outside === undefined); + const ignores = [...new Set(REINVENTIONS.flatMap((row) => row.outside ?? []))]; + return [ + { files: [...files], rules: reinventionRules(always) }, + { files: [...files], ignores, rules: reinventionRules(REINVENTIONS) }, + ]; +} diff --git a/packages/create-threenative/templates/action-rpg/src/loot/drops.ts b/packages/create-threenative/templates/action-rpg/src/loot/drops.ts index 4d52b6ed41..c688c49337 100644 --- a/packages/create-threenative/templates/action-rpg/src/loot/drops.ts +++ b/packages/create-threenative/templates/action-rpg/src/loot/drops.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; import type { ItemId } from "../items/Inventory.js"; export interface IDrop { @@ -10,19 +11,14 @@ const TABLE: readonly { readonly itemId: ItemId; readonly weight: number }[] = [ { itemId: "ember-blade", weight: 30 }, ]; -function next(seed: number): number { - return (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; -} - export function rollDrops(seed: number, rolls = 2): IDrop[] { if (!Number.isInteger(seed) || seed < 0) throw new RangeError("Drop seeds must be non-negative integers."); if (!Number.isInteger(rolls) || rolls < 1) throw new RangeError("Drop rolls must be positive."); - let state = seed >>> 0; + const random = createRandom(seed); const drops: IDrop[] = []; for (let index = 0; index < rolls; index += 1) { - state = next(state); - const pick = (state / 4_294_967_296) * 100; + const pick = random() * 100; let cursor = 0; for (const entry of TABLE) { cursor += entry.weight; diff --git a/packages/create-threenative/templates/action-rpg/src/render/camera.ts b/packages/create-threenative/templates/action-rpg/src/render/camera.ts index ea64641ed8..1bf9787d3e 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/camera.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/camera.ts @@ -3,7 +3,7 @@ // The action-RPG three-quarter view: high and behind, far enough back that the room reads as a // room. It has to clear the **south wall**, which stands at z = +6 and is low so the camera can // see over it — the oldest trick in an isometric dungeon, and it costs one row of wall table. -import { type PerspectiveCamera, Vector3 } from "three"; +import { MathUtils, type PerspectiveCamera, Vector3 } from "three"; /** Where the camera sits relative to the player's body centre. */ const OFFSET = new Vector3(0, 10.2, 9.2); @@ -25,7 +25,7 @@ export function createDungeonCamera(camera: PerspectiveCamera): ICameraRig { return { follow: (target, dt) => { _desired.copy(target).add(OFFSET); - camera.position.lerp(_desired, 1 - Math.exp(-dt / 0.2)); + camera.position.lerp(_desired, MathUtils.damp(0, 1, 5, dt)); camera.lookAt(_aim.copy(target).add(AIM)); }, snap: pose, diff --git a/packages/create-threenative/templates/action-rpg/src/render/clouds.ts b/packages/create-threenative/templates/action-rpg/src/render/clouds.ts index 0bb22540cb..1693ce841a 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/clouds.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/clouds.ts @@ -6,6 +6,8 @@ // Independent CPU implementation. The material samples four Unreal textures that this repository // must not carry, so each one is regenerated here from a seeded noise whose per-channel value // quantiles and correlation lengths match the Unreal texture (measured, see the spec). +import { MathUtils } from "three"; + import { BOTTOM_KM, CAMERA_KM, @@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [ const clamp = (v: number): number => Math.max(0, Math.min(1, v)); const smooth = (v: number): number => { const t = clamp(v); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; const mod = (a: number, n: number): number => ((a % n) + n) % n; diff --git a/packages/create-threenative/templates/action-rpg/src/render/materials.ts b/packages/create-threenative/templates/action-rpg/src/render/materials.ts index 6b139b31d3..181db224d5 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/materials.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/materials.ts @@ -11,6 +11,7 @@ import { DataTexture, Float32BufferAttribute, LinearMipmapLinearFilter, + MathUtils, MeshStandardMaterial, RepeatWrapping, SRGBColorSpace, @@ -84,8 +85,8 @@ function grainNormalTexture(size = 128): DataTexture { const y0 = Math.floor(v); const fx = u - x0; const fy = v - y0; - const sx = fx * fx * (3 - 2 * fx); - const sy = fy * fy * (3 - 2 * fy); + const sx = MathUtils.smoothstep(fx, 0, 1); + const sy = MathUtils.smoothstep(fy, 0, 1); const at = (i: number, j: number): number => values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0; const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx; diff --git a/packages/create-threenative/templates/action-rpg/src/render/touch-controls.ts b/packages/create-threenative/templates/action-rpg/src/render/touch-controls.ts index a7a4612e71..7ace35728d 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/touch-controls.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/touch-controls.ts @@ -15,6 +15,9 @@ const MOVE_RADIUS = 72; const BUTTON_RADIUS = 58; const EDGE = 36; +/** The knob's next point: `update` runs every frame, so it uses one instance. */ +const knobPosition = new Vector2(); + export interface ITouchPointer { readonly position: Vector2; } @@ -122,13 +125,11 @@ export class TouchControls { } const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting; - place( - this.#moveKnob, - new Vector2( - center.x + this.#input.move.x * MOVE_RADIUS, - center.y - this.#input.move.y * MOVE_RADIUS, - ), + knobPosition.set( + center.x + this.#input.move.x * MOVE_RADIUS, + center.y - this.#input.move.y * MOVE_RADIUS, ); + place(this.#moveKnob, knobPosition); this.#ability.material = ability ? this.#activeMaterial : this.#idleMaterial; this.#attack.material = attack ? this.#activeMaterial : this.#idleMaterial; this.#input.abilityPressed = ability && !this.#wasAbility; diff --git a/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts b/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts index 3fb77e7c11..9d30315906 100644 --- a/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts +++ b/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts @@ -580,7 +580,6 @@ export class Play extends Scene { } currentRoom = roomFor(player.mesh.position.x); - const previous = frameCtx.state.getState(); frameState.abilityCooldown = quantize(ability.cooldownRemaining, 1000); frameState.baseDamage = damageStats.base; frameState.damage = Math.round(damageStats.value(elapsed)); @@ -595,20 +594,7 @@ export class Play extends Scene { visibleEnemy.state === "aggro" || visibleEnemy.state === "attack" ? 1 : 0; frameState.wallEnemyAggro = wallEnemy.state === "aggro" || wallEnemy.state === "attack" ? 1 : 0; - const changed = - frameState.abilityCooldown !== previous.abilityCooldown || - frameState.baseDamage !== previous.baseDamage || - frameState.damage !== previous.damage || - frameState.health !== previous.health || - frameState.lineOfSightBlocked !== previous.lineOfSightBlocked || - frameState.modifierActive !== previous.modifierActive || - frameState.playerX !== previous.playerX || - frameState.playerY !== previous.playerY || - frameState.playerZ !== previous.playerZ || - frameState.room !== previous.room || - frameState.visibleEnemyAggro !== previous.visibleEnemyAggro || - frameState.wallEnemyAggro !== previous.wallEnemyAggro; - if (changed) frameCtx.state.set(frameState); + frameCtx.state.set(frameState); if (currentRoom !== lastSavedRoom) { lastSavedRoom = currentRoom; saveRequested = true; diff --git a/packages/create-threenative/templates/adventure/src/entities/LockOn.ts b/packages/create-threenative/templates/adventure/src/entities/LockOn.ts index 25c6fcf061..9364319be7 100644 --- a/packages/create-threenative/templates/adventure/src/entities/LockOn.ts +++ b/packages/create-threenative/templates/adventure/src/entities/LockOn.ts @@ -1,5 +1,5 @@ import type { Group, PerspectiveCamera, Vector3 } from "three"; -import { Box3, MathUtils, Vector3 as V3 } from "three"; +import { Box3, MathUtils, Mesh, Vector3 as V3 } from "three"; import { createReticle } from "../render/creatures.js"; /** Anything Wren can lock onto and cut: Mosslings, the Warden, the practice dummies. */ @@ -60,6 +60,12 @@ export class LockOn { readonly #view = new V3(); readonly #to = new V3(); readonly #marker = new Box3(); + /** The reticle's own box, measured once: it only moves, turns and scales. */ + readonly #reticleBox = new Box3().setFromObject(this.reticle); + readonly #partBox = new Box3(); + /** The target's meshes, collected once per target. */ + #meshSource: ITarget | undefined; + #meshes: Mesh[] = []; update( dt: number, @@ -94,14 +100,32 @@ export class LockOn { this.#sticky = this.target !== undefined; this.justAcquired = this.target !== undefined; } + } + + /** + * Bounds and reticle for this frame, after everything that moves has moved. + * + * Called separately from `update` because the lock must be picked before the hero reads its + * target, while the creatures it frames move later in the frame; placing it here is what keeps + * the arrow on the body rather than a frame behind it. + */ + place(camera: PerspectiveCamera, dt: number): void { const target = this.target; this.reticle.visible = target !== undefined; this.markerLift = 0; this.markerTop = 0; if (target !== undefined) { // The target's live bounds, not a fixed offset: a rearing Warden or a hopping Mossling keeps - // the arrow over its real top. - const box = this.bounds.setFromObject(target.mesh); + // the arrow over its real top. The meshes are collected once per target and each frame only + // transforms its own box, which is what `setFromObject` did without the per-frame walk. + target.mesh.updateWorldMatrix(true, true); + const box = this.bounds.makeEmpty(); + for (const mesh of this.#meshesOf(target)) { + if (mesh.geometry.boundingBox === null) mesh.geometry.computeBoundingBox(); + const part = mesh.geometry.boundingBox; + if (part === null) continue; + box.union(this.#partBox.copy(part).applyMatrix4(mesh.matrixWorld)); + } const tall = MathUtils.clamp((box.max.y - box.min.y) / 1.8, 0.75, 1.25); this.#spin += dt * 1.6; const lift = LIFT + 0.05 * tall + Math.sin(this.#spin * 2.5) * 0.06; @@ -118,12 +142,26 @@ export class LockOn { Math.tan(MathUtils.degToRad(camera.fov / 2)); this.reticle.scale.setScalar(Math.max(tall, (MIN_SCREEN_SHARE * span) / ARROW_HEIGHT)); // Measured, not assumed: the gap from the target's top to the marker's own lowest point. - const marker = this.#marker.setFromObject(this.reticle); + this.reticle.updateWorldMatrix(true, false); + const marker = this.#marker.copy(this.#reticleBox).applyMatrix4(this.reticle.matrixWorld); this.markerLift = marker.min.y - box.max.y; this.markerTop = marker.max.y; } } + /** The meshes a target's bounds cover: `setFromObject` walked them every frame, and a creature's + * mesh set never changes while it is locked. */ + #meshesOf(target: ITarget): readonly Mesh[] { + if (this.#meshSource !== target) { + this.#meshes = []; + target.mesh.traverse((object) => { + if (object instanceof Mesh) this.#meshes.push(object); + }); + this.#meshSource = target; + } + return this.#meshes; + } + /** * The toggle's next target: with none locked, the same pick a hold makes; with one locked, the * next farther hostile in range, and `undefined` (drop) after the farthest. diff --git a/packages/create-threenative/templates/adventure/src/entities/Warden.ts b/packages/create-threenative/templates/adventure/src/entities/Warden.ts index 51161a4d0d..61c743a51b 100644 --- a/packages/create-threenative/templates/adventure/src/entities/Warden.ts +++ b/packages/create-threenative/templates/adventure/src/entities/Warden.ts @@ -7,6 +7,9 @@ import type { ITarget } from "./LockOn.js"; const CAPSULE = { halfHeight: 0.55, radius: 0.75 } as const; const FOOT_OFFSET = CAPSULE.halfHeight + CAPSULE.radius; +/** The vector from the Warden to the hero: `update` runs every frame, so it uses one instance. */ +const toHero = new Vector3(); + /** The Warden's numbers, in metres and seconds. Kit design values. */ export const WARDEN_FEEL = { health: 3, @@ -101,7 +104,7 @@ export class Warden implements ITarget { const feel = WARDEN_FEEL; this.#timer += dt; this.#flash = Math.max(0, this.#flash - dt); - const to = new Vector3().subVectors(hero.mesh.position, this.mesh.position).setY(0); + const to = toHero.subVectors(hero.mesh.position, this.mesh.position).setY(0); let speed = 0; switch (this.state) { case "dormant": diff --git a/packages/create-threenative/templates/adventure/src/level/Vale.ts b/packages/create-threenative/templates/adventure/src/level/Vale.ts index 49f0ce14a3..a9c74501d7 100644 --- a/packages/create-threenative/templates/adventure/src/level/Vale.ts +++ b/packages/create-threenative/templates/adventure/src/level/Vale.ts @@ -2,6 +2,7 @@ import { BoxGeometry, CylinderGeometry, Group, + MathUtils, Mesh, type Object3D, PlaneGeometry, @@ -17,6 +18,7 @@ import { surfaced } from "../render/surface.js"; export function makeRng(seed: number): () => number { let state = seed >>> 0; return () => { + // eslint-disable-next-line no-restricted-syntax -- The clearing is authored against this mulberry32 stream; the engine's seeded RNG is an LCG, so swapping it builds a different level. state = (state + 0x6d2b79f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); @@ -52,7 +54,7 @@ export interface IVale { const smooth = (a: number, b: number, t: number): number => { const u = Math.min(1, Math.max(0, (t - a) / (b - a))); - return u * u * (3 - 2 * u); + return MathUtils.smoothstep(u, 0, 1); }; /** 0..1 rolling noise from two crossed sine sheets. */ const wave = (x: number, z: number, f: number): number => diff --git a/packages/create-threenative/templates/adventure/src/render/camera.ts b/packages/create-threenative/templates/adventure/src/render/camera.ts index e877014b22..c2cada611c 100644 --- a/packages/create-threenative/templates/adventure/src/render/camera.ts +++ b/packages/create-threenative/templates/adventure/src/render/camera.ts @@ -335,8 +335,8 @@ export function setupCamera( return yaw; }, update(focus: Vector3, lock: ILockFrame | undefined, dt: number, watch?: Box3): void { - const rate = 1 - Math.exp(-YAW_RATE * dt); - const quick = 1 - Math.exp(-FRAME_RATE * dt); + const rate = MathUtils.damp(0, 1, YAW_RATE, dt); + const quick = MathUtils.damp(0, 1, FRAME_RATE, dt); if (lock !== undefined) { lock.bounds.getCenter(middle); const line = Math.atan2(focus.x - middle.x, focus.z - middle.z); diff --git a/packages/create-threenative/templates/adventure/src/render/foliage.ts b/packages/create-threenative/templates/adventure/src/render/foliage.ts index c138c78b23..8c6310a910 100644 --- a/packages/create-threenative/templates/adventure/src/render/foliage.ts +++ b/packages/create-threenative/templates/adventure/src/render/foliage.ts @@ -24,6 +24,7 @@ import { IcosahedronGeometry, InstancedMesh, type Material, + MathUtils, Mesh, Object3D, OctahedronGeometry, @@ -1006,7 +1007,7 @@ export function valeGround( 0.5 + 0.25 * (Math.sin(x * f + z * f * 0.55) + Math.sin(z * f * 1.4 - x * f * 0.37 + 1.3)); const smooth = (a: number, b: number, t: number): number => { const u = Math.min(1, Math.max(0, (t - a) / (b - a))); - return u * u * (3 - 2 * u); + return MathUtils.smoothstep(u, 0, 1); }; const paintGround = (geometry: BufferGeometry): void => { const position = geometry.attributes.position; diff --git a/packages/create-threenative/templates/adventure/src/render/materials.ts b/packages/create-threenative/templates/adventure/src/render/materials.ts index 6f32601649..caf6653593 100644 --- a/packages/create-threenative/templates/adventure/src/render/materials.ts +++ b/packages/create-threenative/templates/adventure/src/render/materials.ts @@ -15,6 +15,7 @@ import { LinearFilter, LinearMipmapLinearFilter, type Material, + MathUtils, MeshBasicMaterial, MeshStandardMaterial, NoColorSpace, @@ -56,8 +57,8 @@ export function detailTexture(): DataTexture { const tx = fx - x0; const ty = fy - y0; const at = (i: number, j: number): number => rng(((j % cells) * cells + (i % cells)) * 1.37); - const sx = tx * tx * (3 - 2 * tx); - const sy = ty * ty * (3 - 2 * ty); + const sx = MathUtils.smoothstep(tx, 0, 1); + const sy = MathUtils.smoothstep(ty, 0, 1); const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx; const bottom = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * sx; return top + (bottom - top) * sy; diff --git a/packages/create-threenative/templates/adventure/src/render/touch-controls.ts b/packages/create-threenative/templates/adventure/src/render/touch-controls.ts index 6a45dec9cb..df99eaf467 100644 --- a/packages/create-threenative/templates/adventure/src/render/touch-controls.ts +++ b/packages/create-threenative/templates/adventure/src/render/touch-controls.ts @@ -36,6 +36,9 @@ const STICK_RADIUS = 72; const BUTTON_RADIUS = 46; const EDGE = 36; +/** The resting stick centre: `update` runs every frame, so it uses one instance. */ +const restPoint = new Vector2(); + /** Where each button sits: a quarter-circle fan around the bottom-right corner. */ function buttonPoint(size: ITouchViewport, index: number): Vector2 { const cx = size.width - EDGE - BUTTON_RADIUS; @@ -84,7 +87,7 @@ export class TouchControls { } update(pointers: ReadonlyMap, size: ITouchViewport): ITouchInput { - const rest = new Vector2(STICK_RADIUS + EDGE, size.height - STICK_RADIUS - EDGE); + const rest = restPoint.set(STICK_RADIUS + EDGE, size.height - STICK_RADIUS - EDGE); let thumb: ITouchPointer | undefined; for (const pointer of pointers.values()) { if (pointer.position.x < size.width * 0.45 && pointer.position.y > size.height * 0.4) { diff --git a/packages/create-threenative/templates/adventure/src/scenes/Play.ts b/packages/create-threenative/templates/adventure/src/scenes/Play.ts index 2450d5aacf..69c6ae8754 100644 --- a/packages/create-threenative/templates/adventure/src/scenes/Play.ts +++ b/packages/create-threenative/templates/adventure/src/scenes/Play.ts @@ -609,7 +609,7 @@ export class Play extends Scene { hero.mesh.position.z - f.x * 0.7 - f.z * 0.4 + Math.sin(orb.clock * 1.3) * 0.15, ); if (companion.group.position.distanceTo(orbGoal) > 6) companion.group.position.copy(orbGoal); - else companion.group.position.lerp(orbGoal, 1 - Math.exp(-dt * 4)); + else companion.group.position.lerp(orbGoal, MathUtils.damp(0, 1, 4, dt)); const orbGap = companion.group.position.distanceTo(head); orb.nearest = Math.min(orb.nearest, orbGap); orb.farthest = Math.max(orb.farthest, orbGap); @@ -774,6 +774,8 @@ export class Play extends Scene { Object.assign(seen, { blocks: hero.shieldBlocks, clanks, hits: hero.slashHits }); warden.update(dt, hero); for (const dummy of dummies) dummy.update(dt); + // Everything that moves has moved, so the lock's bounds and reticle are this frame's pose. + lock.place(camera, dt); // A target falls inside Wren's slash, so count the fallen after everything has moved. for (const fallen of targets) { if (fallen.alive || counted.has(fallen)) continue; diff --git a/packages/create-threenative/templates/collectathon/src/level/Course.ts b/packages/create-threenative/templates/collectathon/src/level/Course.ts index 983700ca54..1b8c9fc092 100644 --- a/packages/create-threenative/templates/collectathon/src/level/Course.ts +++ b/packages/create-threenative/templates/collectathon/src/level/Course.ts @@ -1,4 +1,4 @@ -import { type IAssetLoader, WaveField } from "@threenative/core"; +import { type IAssetLoader, WaveField, createRandom } from "@threenative/core"; import { Box3, BoxGeometry, @@ -228,13 +228,8 @@ function boulderGeometry(size: number, rng: () => number): BufferGeometry { } export function makeRng(seed: number): () => number { - let state = seed >>> 0; - return () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 0x1_0000_0000; - }; + return createRandom(seed); } - /** * The island planted: drawn from `site` on a run that has nothing stored, and read back from * `memo` on the next run of this build, so `buildPlanting` has the same placements either way. diff --git a/packages/create-threenative/templates/collectathon/src/level/terrain.ts b/packages/create-threenative/templates/collectathon/src/level/terrain.ts index 352168f8ea..6c811738d6 100644 --- a/packages/create-threenative/templates/collectathon/src/level/terrain.ts +++ b/packages/create-threenative/templates/collectathon/src/level/terrain.ts @@ -1,4 +1,5 @@ import { Heightfield } from "@threenative/core/world"; +import { MathUtils } from "three"; /** A rectangle [x min, x max, z min, z max] or a disc [x, z, radius] of level ground at y = 0. */ export type LevelZone = @@ -35,7 +36,7 @@ const HEADLAND = { x: 2, z: -60, rx: 30, rz: 26 } as const; const clamp01 = (t: number): number => Math.min(1, Math.max(0, t)); const smooth = (from: number, to: number, value: number): number => { const t = clamp01((value - from) / (to - from)); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; diff --git a/packages/create-threenative/templates/collectathon/src/render/camera.ts b/packages/create-threenative/templates/collectathon/src/render/camera.ts index 871ed5265c..bf5bc9fe42 100644 --- a/packages/create-threenative/templates/collectathon/src/render/camera.ts +++ b/packages/create-threenative/templates/collectathon/src/render/camera.ts @@ -151,10 +151,10 @@ export function createOrbitCamera(camera: PerspectiveCamera): IOrbitCamera { // Running at the camera does not spin it round: only travel within 120° of the view swings it. if (Math.abs(turn) < (Math.PI * 2) / 3) { const rate = CAMERA_RIG.swingRate * Math.min(1, speed / 9.6); - yaw += turn * (1 - Math.exp(-dt * rate)); + yaw = MathUtils.damp(yaw, yaw + turn, rate, dt); } } - place(target, probe, 1 - Math.exp(-dt * CAMERA_RIG.followRate)); + place(target, probe, MathUtils.damp(0, 1, CAMERA_RIG.followRate, dt)); }, debug() { return { closest, cuts, distance, elevation, peakElevation, pulledIn, tilt, yaw }; diff --git a/packages/create-threenative/templates/collectathon/src/render/ground.ts b/packages/create-threenative/templates/collectathon/src/render/ground.ts index 0a59ae751f..45b7f190ae 100644 --- a/packages/create-threenative/templates/collectathon/src/render/ground.ts +++ b/packages/create-threenative/templates/collectathon/src/render/ground.ts @@ -3,7 +3,7 @@ // The island's ground: one terrain mesh whose vertex attributes say where grass, worn grass, the // sandy dirt path, beach sand and rock lie, by height, slope and distance to the path. The textures // and the blend are `groundMaterial` in surface.ts. -import { BufferAttribute, BufferGeometry, Color, Mesh } from "three"; +import { BufferAttribute, BufferGeometry, Color, MathUtils, Mesh } from "three"; import { lit, seeded } from "./materials.js"; import { C } from "./palette.js"; @@ -29,8 +29,8 @@ export function valueNoise(cells: number, seed: number): (u: number, v: number) const y = v * cells; const i = Math.floor(x); const j = Math.floor(y); - const fx = (x - i) * (x - i) * (3 - 2 * (x - i)); - const fy = (y - j) * (y - j) * (3 - 2 * (y - j)); + const fx = MathUtils.smoothstep(x - i, 0, 1); + const fy = MathUtils.smoothstep(y - j, 0, 1); const top = at(i, j) + (at(i + 1, j) - at(i, j)) * fx; const bottom = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * fx; return top + (bottom - top) * fy; @@ -39,7 +39,7 @@ export function valueNoise(cells: number, seed: number): (u: number, v: number) const smooth = (from: number, to: number, value: number): number => { const t = Math.min(1, Math.max(0, (value - from) / (to - from))); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; /** diff --git a/packages/create-threenative/templates/collectathon/src/render/hero.ts b/packages/create-threenative/templates/collectathon/src/render/hero.ts index f671ed7995..abdc852dd1 100644 --- a/packages/create-threenative/templates/collectathon/src/render/hero.ts +++ b/packages/create-threenative/templates/collectathon/src/render/hero.ts @@ -366,7 +366,7 @@ export function createHero(): IHero { update(pose: IHeroPose): void { time += pose.dt; const run = Math.min(1, pose.speed / 9.6); - const k = 1 - Math.exp(-pose.dt * 18); + const k = MathUtils.damp(0, 1, 18, pose.dt); let pitch = 0; let roll = 0; let squash = 1; diff --git a/packages/create-threenative/templates/collectathon/src/render/materials.ts b/packages/create-threenative/templates/collectathon/src/render/materials.ts index 5b636a6f53..4b5f864e72 100644 --- a/packages/create-threenative/templates/collectathon/src/render/materials.ts +++ b/packages/create-threenative/templates/collectathon/src/render/materials.ts @@ -186,7 +186,6 @@ export function seeded(seed: number): () => number { return state / 0x1_0000_0000; }; } - /** The surfaces the island is made of, resolved once. */ export function createMaterials() { return { diff --git a/packages/create-threenative/templates/collectathon/src/render/sky.ts b/packages/create-threenative/templates/collectathon/src/render/sky.ts index 0cec31563c..1b32526a0f 100644 --- a/packages/create-threenative/templates/collectathon/src/render/sky.ts +++ b/packages/create-threenative/templates/collectathon/src/render/sky.ts @@ -11,6 +11,7 @@ import { EquirectangularReflectionMapping, FogExp2, LinearFilter, + MathUtils, RGBAFormat, SRGBColorSpace, type Scene, @@ -39,8 +40,8 @@ const LATTICE = (() => { function noise(x: number, y: number): number { const i = Math.floor(x); const j = Math.floor(y); - const fx = (x - i) * (x - i) * (3 - 2 * (x - i)); - const fy = (y - j) * (y - j) * (3 - 2 * (y - j)); + const fx = MathUtils.smoothstep(x - i, 0, 1); + const fy = MathUtils.smoothstep(y - j, 0, 1); const i0 = i & 255; const i1 = (i + 1) & 255; const j0 = (j & 255) << 8; @@ -79,7 +80,7 @@ function density(x: number, y: number): number { const smooth = (from: number, to: number, value: number): number => { const t = Math.min(1, Math.max(0, (value - from) / (to - from))); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; /** diff --git a/packages/create-threenative/templates/kart/src/entities/Kart.ts b/packages/create-threenative/templates/kart/src/entities/Kart.ts index 467be28b1e..27de2bc1cc 100644 --- a/packages/create-threenative/templates/kart/src/entities/Kart.ts +++ b/packages/create-threenative/templates/kart/src/entities/Kart.ts @@ -1,3 +1,4 @@ +import { MathUtils } from "three"; import { type ICourse, SURFACE_DRAG, type Surface } from "../track/Course.js"; /** @@ -327,7 +328,7 @@ export class Kart { Math.max(0, 1 - resist) * (1 + this.boost + this.miniTurbo + this.catchUp); if (brake && this.throttle === 0 && this.speed < 0.5) target = -f.reverse; - this.speed += (target - this.speed) * (1 - Math.exp(-ticks / f.lagTicks)); + this.speed = MathUtils.damp(this.speed, target, 1 / f.lagTicks, ticks); } // Hop and drift. A press while grounded hops; with the stick held and enough speed the hop diff --git a/packages/create-threenative/templates/kart/src/render/course.ts b/packages/create-threenative/templates/kart/src/render/course.ts index 0a05aa475e..86772ffd18 100644 --- a/packages/create-threenative/templates/kart/src/render/course.ts +++ b/packages/create-threenative/templates/kart/src/render/course.ts @@ -15,6 +15,7 @@ import { Group, IcosahedronGeometry, InstancedMesh, + MathUtils, Matrix4, Mesh, MeshStandardMaterial, @@ -521,7 +522,7 @@ export function buildCourseView(course: ICourse, seed = 734): ICourseView { function smooth(edge0: number, edge1: number, x: number): number { const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0))); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); } /** True when x/z is at least `clearance` metres from every sample of the line. */ diff --git a/packages/create-threenative/templates/kart/src/track/Course.ts b/packages/create-threenative/templates/kart/src/track/Course.ts index 3af0e70561..bee19aaba7 100644 --- a/packages/create-threenative/templates/kart/src/track/Course.ts +++ b/packages/create-threenative/templates/kart/src/track/Course.ts @@ -10,6 +10,8 @@ */ /** Metres between samples of the centre line. */ +import { MathUtils } from "three"; + export const STEP = 1; /** The lateral profile, in metres from the centre line. */ @@ -69,7 +71,7 @@ function plateau(s: number, at: number, length: number, flat: number, ramp: numb function smooth(edge0: number, edge1: number, x: number): number { const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0))); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); } export interface ICourse { diff --git a/packages/create-threenative/templates/minimal/src/render/camera.ts b/packages/create-threenative/templates/minimal/src/render/camera.ts index bff5d15f6f..fe09af9338 100644 --- a/packages/create-threenative/templates/minimal/src/render/camera.ts +++ b/packages/create-threenative/templates/minimal/src/render/camera.ts @@ -1,5 +1,5 @@ // Generated for you. Camera framing is yours to edit. -import { type PerspectiveCamera, Vector3 } from "three"; +import { MathUtils, type PerspectiveCamera, Vector3 } from "three"; /** Where the camera sits relative to the player's body centre: up and behind, over the shoulder. */ const OFFSET = new Vector3(0.6, 1.4, 4.6); @@ -51,7 +51,7 @@ export function followCamera( const wall = probe(_aim, _desired); if (wall === undefined) { const gap = camera.position.distanceTo(_desired); - const step = Math.max(gap * (1 - Math.exp(-dt * 8)), SETTLE_SPEED * dt); + const step = Math.max(gap * MathUtils.damp(0, 1, 8, dt), SETTLE_SPEED * dt); if (gap <= 2 * step) camera.position.copy(_desired); else camera.position.lerp(_desired, step / gap); } else { diff --git a/packages/create-threenative/templates/minimal/src/render/clouds.ts b/packages/create-threenative/templates/minimal/src/render/clouds.ts index 0bb22540cb..1693ce841a 100644 --- a/packages/create-threenative/templates/minimal/src/render/clouds.ts +++ b/packages/create-threenative/templates/minimal/src/render/clouds.ts @@ -6,6 +6,8 @@ // Independent CPU implementation. The material samples four Unreal textures that this repository // must not carry, so each one is regenerated here from a seeded noise whose per-channel value // quantiles and correlation lengths match the Unreal texture (measured, see the spec). +import { MathUtils } from "three"; + import { BOTTOM_KM, CAMERA_KM, @@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [ const clamp = (v: number): number => Math.max(0, Math.min(1, v)); const smooth = (v: number): number => { const t = clamp(v); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; const mod = (a: number, n: number): number => ((a % n) + n) % n; diff --git a/packages/create-threenative/templates/minimal/src/render/materials.ts b/packages/create-threenative/templates/minimal/src/render/materials.ts index a7a5f172f0..7cd27b338c 100644 --- a/packages/create-threenative/templates/minimal/src/render/materials.ts +++ b/packages/create-threenative/templates/minimal/src/render/materials.ts @@ -11,6 +11,7 @@ import { Float32BufferAttribute, FrontSide, LinearMipmapLinearFilter, + MathUtils, MeshStandardMaterial, RepeatWrapping, SRGBColorSpace, @@ -84,8 +85,8 @@ function grainNormalTexture(size = 128): DataTexture { const y0 = Math.floor(v); const fx = u - x0; const fy = v - y0; - const sx = fx * fx * (3 - 2 * fx); - const sy = fy * fy * (3 - 2 * fy); + const sx = MathUtils.smoothstep(fx, 0, 1); + const sy = MathUtils.smoothstep(fy, 0, 1); const at = (i: number, j: number): number => values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0; const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx; diff --git a/packages/create-threenative/templates/minimal/src/render/touch-controls.ts b/packages/create-threenative/templates/minimal/src/render/touch-controls.ts index 34eb30206b..2ad759de86 100644 --- a/packages/create-threenative/templates/minimal/src/render/touch-controls.ts +++ b/packages/create-threenative/templates/minimal/src/render/touch-controls.ts @@ -15,6 +15,9 @@ const MOVE_RADIUS = 72; const BUTTON_RADIUS = 62; const EDGE = 36; +/** The knob's next point: `update` runs every frame, so it uses one instance. */ +const knobPosition = new Vector2(); + export interface ITouchPointer { readonly position: Vector2; } @@ -111,13 +114,11 @@ export class TouchControls { } const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting; - place( - this.#moveKnob, - new Vector2( - center.x + this.#input.move.x * MOVE_RADIUS, - center.y - this.#input.move.y * MOVE_RADIUS, - ), + knobPosition.set( + center.x + this.#input.move.x * MOVE_RADIUS, + center.y - this.#input.move.y * MOVE_RADIUS, ); + place(this.#moveKnob, knobPosition); this.#jump.material = jump ? this.#activeMaterial : this.#idleMaterial; this.#input.jumpPressed = jump && !this.#wasJump; this.#wasJump = jump; diff --git a/packages/create-threenative/templates/platformer/src/level/Stage.ts b/packages/create-threenative/templates/platformer/src/level/Stage.ts index 5bba4f80f2..ec3a672094 100644 --- a/packages/create-threenative/templates/platformer/src/level/Stage.ts +++ b/packages/create-threenative/templates/platformer/src/level/Stage.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; import { BoxGeometry, ConeGeometry, @@ -18,13 +19,8 @@ import { waterfall } from "../render/waterfall.js"; /** Deterministic, so the route is the same course every run. */ export function makeRng(seed = 90210): () => number { - let state = seed >>> 0; - return () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 4294967296; - }; + return createRandom(seed); } - export interface IStage { /** The one root to add to the scene and hand to `buildStaticColliders`. */ readonly group: Group; diff --git a/packages/create-threenative/templates/puzzle/src/entities/Warden.ts b/packages/create-threenative/templates/puzzle/src/entities/Warden.ts index 02a0345839..3037a97119 100644 --- a/packages/create-threenative/templates/puzzle/src/entities/Warden.ts +++ b/packages/create-threenative/templates/puzzle/src/entities/Warden.ts @@ -1,6 +1,6 @@ import type { ICtx } from "@threenative/core"; import { CharacterBody3D, CollisionShape3D, type IPhysicsContext } from "@threenative/physics"; -import { Group, Vector3 } from "three"; +import { Group, MathUtils, Vector3 } from "three"; import { wardenFigure } from "../render/warden.js"; import type { GameState } from "../state.js"; import { WARDEN_MASK } from "./Crate.js"; @@ -90,12 +90,12 @@ export class Warden { // Shortest-arc turn, framerate independent, so the figure faces the shove rather than // snapping between the four arrow directions. const delta = Math.atan2(Math.sin(target - this.#facing), Math.cos(target - this.#facing)); - this.#facing += delta * (1 - Math.exp(-dt / 0.06)); + this.#facing = MathUtils.damp(this.#facing, this.#facing + delta, 1 / 0.06, dt); this.#figure.rotation.y = this.#facing; } // Lean into the push. Purely cosmetic, and it is most of what makes the shove read. const lean = Math.min(0.28, Math.hypot(move.x, move.z) * 0.28); - this.#figure.rotation.x += (lean - this.#figure.rotation.x) * (1 - Math.exp(-dt / 0.09)); + this.#figure.rotation.x = MathUtils.damp(this.#figure.rotation.x, lean, 1 / 0.09, dt); } teleport(position: { readonly x: number; readonly y: number; readonly z: number }): void { diff --git a/packages/create-threenative/templates/puzzle/src/render/camera.ts b/packages/create-threenative/templates/puzzle/src/render/camera.ts index a34910bf17..028ab3b11c 100644 --- a/packages/create-threenative/templates/puzzle/src/render/camera.ts +++ b/packages/create-threenative/templates/puzzle/src/render/camera.ts @@ -7,7 +7,7 @@ // It still breathes. `settle` eases the framing in over the opening drop, and `nudge` lets the // shot drift a fraction of a metre toward the warden so the picture is not completely static. import type { PerspectiveCamera, Vector3 } from "three"; -import { Vector3 as Vec3 } from "three"; +import { MathUtils, Vector3 as Vec3 } from "three"; export const VAULT_SHOT = { fov: 31, @@ -64,7 +64,7 @@ export function createVaultCamera(camera: PerspectiveCamera): IVaultCamera { desired.copy(VAULT_SHOT.position).add(aim); // 1 - e^(-dt/tau) is the framerate-independent form of a lerp: the same second of drift at // 30 fps and at 120 fps ends in the same place. - camera.position.lerp(desired, 1 - Math.exp(-dt / 0.9)); + camera.position.lerp(desired, MathUtils.damp(0, 1, 1 / 0.9, dt)); camera.lookAt(currentAim); }; diff --git a/packages/create-threenative/templates/puzzle/src/render/touch-controls.ts b/packages/create-threenative/templates/puzzle/src/render/touch-controls.ts index bb6cd6978c..f2552278ea 100644 --- a/packages/create-threenative/templates/puzzle/src/render/touch-controls.ts +++ b/packages/create-threenative/templates/puzzle/src/render/touch-controls.ts @@ -14,6 +14,9 @@ import { palette } from "./palette.js"; const MOVE_RADIUS = 72; const EDGE = 36; +/** The knob's next point: `update` runs every frame, so it uses one instance. */ +const knobPosition = new Vector2(); + export interface ITouchPointer { readonly position: Vector2; } @@ -101,13 +104,11 @@ export class TouchControls { } const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting; - place( - this.#moveKnob, - new Vector2( - center.x + this.#input.move.x * MOVE_RADIUS, - center.y - this.#input.move.y * MOVE_RADIUS, - ), + knobPosition.set( + center.x + this.#input.move.x * MOVE_RADIUS, + center.y - this.#input.move.y * MOVE_RADIUS, ); + place(this.#moveKnob, knobPosition); this.#layout(size); return this.#input; } diff --git a/packages/create-threenative/templates/racing/src/render/camera.ts b/packages/create-threenative/templates/racing/src/render/camera.ts index f0c3eb9009..4433da1f49 100644 --- a/packages/create-threenative/templates/racing/src/render/camera.ts +++ b/packages/create-threenative/templates/racing/src/render/camera.ts @@ -54,7 +54,7 @@ export function chaseCamera( desired.copy(target).addScaledVector(behind, -EYE_BACK); desired.y += EYE_UP; if (dt >= 1) camera.position.copy(desired); - else camera.position.lerp(desired, 1 - Math.exp(-dt / 0.2)); + else camera.position.lerp(desired, MathUtils.damp(0, 1, 5, dt)); // Look ahead along the same travel direction, so a fast car sees the corner it is entering // rather than the road immediately under its own nose. const speed = Math.hypot(velocity.x, velocity.z); diff --git a/packages/create-threenative/templates/racing/src/render/clouds.ts b/packages/create-threenative/templates/racing/src/render/clouds.ts index 0bb22540cb..1693ce841a 100644 --- a/packages/create-threenative/templates/racing/src/render/clouds.ts +++ b/packages/create-threenative/templates/racing/src/render/clouds.ts @@ -6,6 +6,8 @@ // Independent CPU implementation. The material samples four Unreal textures that this repository // must not carry, so each one is regenerated here from a seeded noise whose per-channel value // quantiles and correlation lengths match the Unreal texture (measured, see the spec). +import { MathUtils } from "three"; + import { BOTTOM_KM, CAMERA_KM, @@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [ const clamp = (v: number): number => Math.max(0, Math.min(1, v)); const smooth = (v: number): number => { const t = clamp(v); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; const mod = (a: number, n: number): number => ((a % n) + n) % n; diff --git a/packages/create-threenative/templates/racing/src/render/materials.ts b/packages/create-threenative/templates/racing/src/render/materials.ts index 53429ea2c1..1a8ada0e3b 100644 --- a/packages/create-threenative/templates/racing/src/render/materials.ts +++ b/packages/create-threenative/templates/racing/src/render/materials.ts @@ -12,6 +12,7 @@ import { DataTexture, Float32BufferAttribute, LinearMipmapLinearFilter, + MathUtils, MeshStandardMaterial, RepeatWrapping, SRGBColorSpace, @@ -31,7 +32,7 @@ function noise(x: number, y: number, seed: number): number { /** Smoothstep, so the rubber band has a soft edge instead of a stripe painted across the road. */ function falloff(value: number, edge: number): number { const t = Math.min(Math.max((Math.abs(value) - edge) / 0.28, 0), 1); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); } function rgbaTexture( @@ -121,8 +122,8 @@ function grainNormalTexture(size = 128): DataTexture { const y0 = Math.floor(v); const fx = u - x0; const fy = v - y0; - const sx = fx * fx * (3 - 2 * fx); - const sy = fy * fy * (3 - 2 * fy); + const sx = MathUtils.smoothstep(fx, 0, 1); + const sy = MathUtils.smoothstep(fy, 0, 1); const at = (i: number, j: number): number => values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0; const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx; diff --git a/packages/create-threenative/templates/racing/src/render/touch-controls.ts b/packages/create-threenative/templates/racing/src/render/touch-controls.ts index 063aaa179a..111af94101 100644 --- a/packages/create-threenative/templates/racing/src/render/touch-controls.ts +++ b/packages/create-threenative/templates/racing/src/render/touch-controls.ts @@ -15,6 +15,9 @@ const MOVE_RADIUS = 72; const BUTTON_RADIUS = 58; const EDGE = 36; +/** The knob's next point: `update` runs every frame, so it uses one instance. */ +const knobPosition = new Vector2(); + export interface ITouchPointer { readonly position: Vector2; } @@ -122,13 +125,11 @@ export class TouchControls { } const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting; - place( - this.#moveKnob, - new Vector2( - center.x + this.#input.move.x * MOVE_RADIUS, - center.y - this.#input.move.y * MOVE_RADIUS, - ), + knobPosition.set( + center.x + this.#input.move.x * MOVE_RADIUS, + center.y - this.#input.move.y * MOVE_RADIUS, ); + place(this.#moveKnob, knobPosition); this.#boost.material = boost ? this.#activeMaterial : this.#idleMaterial; this.#brake.material = brake ? this.#activeMaterial : this.#idleMaterial; this.#input.boostPressed = boost && !this.#wasBoost; diff --git a/packages/create-threenative/templates/racing/src/track/circuit.ts b/packages/create-threenative/templates/racing/src/track/circuit.ts index e3bf84ad20..d872a741dd 100644 --- a/packages/create-threenative/templates/racing/src/track/circuit.ts +++ b/packages/create-threenative/templates/racing/src/track/circuit.ts @@ -1,4 +1,4 @@ -import { CatmullRomCurve3, Vector3 } from "three"; +import { CatmullRomCurve3, MathUtils, Vector3 } from "three"; /** * The circuit, once. @@ -126,7 +126,7 @@ function bankFade(radius: number): number { if (!Number.isFinite(radius) || radius >= BANK_FULL_RADIUS) return 1; if (radius <= BANK_NONE_RADIUS) return 0; const fade = (radius - BANK_NONE_RADIUS) / (BANK_FULL_RADIUS - BANK_NONE_RADIUS); - return fade * fade * (3 - 2 * fade); + return MathUtils.smoothstep(fade, 0, 1); } /** @@ -435,7 +435,7 @@ export function terrainHeight(x: number, z: number): number { const plane = centre.point.y - ROAD_LIFT - projection.lateral * centre.bank; if (lateral <= CORRIDOR) return plane; const blend = (lateral - CORRIDOR) / (BLEND_END - CORRIDOR); - return plane + (open - plane) * blend * blend * (3 - 2 * blend); + return plane + (open - plane) * MathUtils.smoothstep(blend, 0, 1); } /** Where the start/finish line, the sectors and the grid are, as a fraction of the lap. */ diff --git a/packages/create-threenative/templates/rain/src/render/noise-volume.ts b/packages/create-threenative/templates/rain/src/render/noise-volume.ts index decd04a291..3c35447a82 100644 --- a/packages/create-threenative/templates/rain/src/render/noise-volume.ts +++ b/packages/create-threenative/templates/rain/src/render/noise-volume.ts @@ -4,6 +4,7 @@ import { Data3DTexture, LinearFilter, + MathUtils, NoColorSpace, RGBAFormat, RepeatWrapping, @@ -76,7 +77,7 @@ function sampleLattice(lattice: ILattice, x: number, y: number, z: number): numb values[((c % n) * n + (b % n)) * n + (a % n)] as number; const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; const blend = (fx: number, fy: number, fz: number): number => { - const smooth = (f: number): number => f * f * (3 - 2 * f); + const smooth = (f: number): number => MathUtils.smoothstep(f, 0, 1); const tx = smooth(fx); const ty = smooth(fy); const tz = smooth(fz); diff --git a/packages/create-threenative/templates/rain/src/state.ts b/packages/create-threenative/templates/rain/src/state.ts index 4bd464ccfd..73353fe009 100644 --- a/packages/create-threenative/templates/rain/src/state.ts +++ b/packages/create-threenative/templates/rain/src/state.ts @@ -6,6 +6,8 @@ * they live here rather than in a package. */ +import { MathUtils } from "three"; + export const WEATHER_KEYS = ["rain", "cloud", "wind", "fog", "exposure", "wet"] as const; export type WeatherKey = (typeof WEATHER_KEYS)[number]; @@ -74,7 +76,7 @@ export function sanitizeWeather( /** Exponential ease towards the target, independent of frame rate because `dt` is real seconds. */ export function easeWeather(current: Weather, target: Weather, dt: number): Weather { - const k = 1 - Math.exp(-dt / EASE_TAU); + const k = MathUtils.damp(0, 1, 1 / EASE_TAU, dt); const next = { ...current }; for (const key of WEATHER_KEYS) next[key] = current[key] + (target[key] - current[key]) * k; return next; diff --git a/packages/create-threenative/templates/rain/src/ui/GameUi.tsx b/packages/create-threenative/templates/rain/src/ui/GameUi.tsx index 588e60e07d..e45372ac0f 100644 --- a/packages/create-threenative/templates/rain/src/ui/GameUi.tsx +++ b/packages/create-threenative/templates/rain/src/ui/GameUi.tsx @@ -154,7 +154,7 @@ function RainInterface() { return; qualityChosen.current = true; // An operator asking for high on a phone or software adapter keeps that explicit choice. - const asked = automationRequest(window.location.search).quality; + const asked = automationRequest().quality; if (asked !== undefined) { send("setQuality", asked); return; diff --git a/packages/create-threenative/templates/rain/src/ui/automation.ts b/packages/create-threenative/templates/rain/src/ui/automation.ts index fd5e132ea7..dd32294b22 100644 --- a/packages/create-threenative/templates/rain/src/ui/automation.ts +++ b/packages/create-threenative/templates/rain/src/ui/automation.ts @@ -7,10 +7,11 @@ * whole mapping. A name in neither list throws: a typo must not silently pick a tier and report * success. * - * Query reading takes a search string rather than reaching for `location`, so the rules are the - * same on every target and a test can hold one. + * Query reading goes through the engine's debug seam when no search string is given, so the same + * rule reads the URL on the web and `TN_DEBUG_*` on a native launch; a test can still hold one. */ +import { debugFlag, debugValue } from "@threenative/core"; import type { QualityName } from "../state.js"; /** The four engine tiers, plus the four names the source study published for them. */ @@ -48,13 +49,14 @@ export interface IAutomationRequest { * What the query asks for. An unreadable tier is named in `rejectedQuality` instead of thrown: a * URL is a place somebody typed, and the page still has to open. */ -export function automationRequest(search: string): IAutomationRequest { - const query = new URLSearchParams(search); - const asked = query.get("quality"); - if (asked === null) return { still: query.has("still") }; +export function automationRequest(search?: string): IAutomationRequest { + const query = search === undefined ? undefined : new URLSearchParams(search); + const asked = query === undefined ? debugValue("quality") : query.get("quality"); + const still = query === undefined ? debugFlag("still") : query.has("still"); + if (asked === null || asked === undefined) return { still }; try { - return { still: query.has("still"), quality: qualityTier(asked) }; + return { still, quality: qualityTier(asked) }; } catch { - return { still: query.has("still"), rejectedQuality: asked }; + return { still, rejectedQuality: asked }; } } diff --git a/packages/create-threenative/templates/rain/src/ui/tempest.ts b/packages/create-threenative/templates/rain/src/ui/tempest.ts index 143f7c9f14..96f50a64d0 100644 --- a/packages/create-threenative/templates/rain/src/ui/tempest.ts +++ b/packages/create-threenative/templates/rain/src/ui/tempest.ts @@ -13,7 +13,7 @@ * browser APIs. `src/game.ts` stays portable: this sends the same validated intents the buttons do. */ -import type { IGame } from "@threenative/core"; +import { type IGame, debugFlag } from "@threenative/core"; import { connectUiBridge, sendUiIntent, subscribeUiState } from "@threenative/core/ui-layer"; import type { GameState, PresetName, WeatherKey } from "../state.js"; import type { ICaptureResult, ISceneSizes } from "./Hud.js"; @@ -78,15 +78,14 @@ export function installTempest(game: IGame): ITempestApi { const send = (intent: IntentName, payload?: unknown): void => { sendUiIntent(bridge, intent, payload); }; - const query = new URLSearchParams(window.location.search); - const request = automationRequest(window.location.search); + const request = automationRequest(); if (request.rejectedQuality !== undefined) { say( `?quality=${request.rejectedQuality} is not a tier: expected low, balanced, high or cinematic`, ); } if (request.quality !== undefined) send("setQuality", request.quality); - if (query.has("offline")) { + if (debugFlag("offline")) { // The source's offline WebGL2 adapter stood in for a Three.js build it could not load from a // CDN. This build installs Three locally and never reaches for one, so there is no fallback // to select and nothing here pretends otherwise. diff --git a/packages/create-threenative/templates/rts/src/sim/terrain.ts b/packages/create-threenative/templates/rts/src/sim/terrain.ts index a74b6753cf..75bebeffa2 100644 --- a/packages/create-threenative/templates/rts/src/sim/terrain.ts +++ b/packages/create-threenative/templates/rts/src/sim/terrain.ts @@ -102,6 +102,7 @@ export const ROCK_FIELDS: readonly (readonly [number, number, number])[] = [ const smooth01 = (t: number): number => { const c = Math.max(0, Math.min(1, t)); + // eslint-disable-next-line no-restricted-syntax -- a sim module must not import three (rts-runtime-cost.spec), so the inline form stays here. return c * c * (3 - 2 * c); }; diff --git a/packages/create-threenative/templates/rts/src/sim/types.ts b/packages/create-threenative/templates/rts/src/sim/types.ts index cdf6e77865..646a5672fe 100644 --- a/packages/create-threenative/templates/rts/src/sim/types.ts +++ b/packages/create-threenative/templates/rts/src/sim/types.ts @@ -603,6 +603,7 @@ export function seeded(seed = 17): () => number { let s = seed; return () => { s |= 0; + // eslint-disable-next-line no-restricted-syntax -- Replays depend on this exact mulberry32 stream; the engine's seeded RNG is an LCG, so swapping it changes every recorded match. s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; diff --git a/packages/create-threenative/templates/runner/src/render/clouds.ts b/packages/create-threenative/templates/runner/src/render/clouds.ts index 0bb22540cb..1693ce841a 100644 --- a/packages/create-threenative/templates/runner/src/render/clouds.ts +++ b/packages/create-threenative/templates/runner/src/render/clouds.ts @@ -6,6 +6,8 @@ // Independent CPU implementation. The material samples four Unreal textures that this repository // must not carry, so each one is regenerated here from a seeded noise whose per-channel value // quantiles and correlation lengths match the Unreal texture (measured, see the spec). +import { MathUtils } from "three"; + import { BOTTOM_KM, CAMERA_KM, @@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [ const clamp = (v: number): number => Math.max(0, Math.min(1, v)); const smooth = (v: number): number => { const t = clamp(v); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; const mod = (a: number, n: number): number => ((a % n) + n) % n; diff --git a/packages/create-threenative/templates/runner/src/render/materials.ts b/packages/create-threenative/templates/runner/src/render/materials.ts index 21354018ef..941e3a708b 100644 --- a/packages/create-threenative/templates/runner/src/render/materials.ts +++ b/packages/create-threenative/templates/runner/src/render/materials.ts @@ -9,6 +9,7 @@ import { DataTexture, Float32BufferAttribute, LinearMipmapLinearFilter, + MathUtils, MeshStandardMaterial, RepeatWrapping, SRGBColorSpace, @@ -82,8 +83,8 @@ function grainNormalTexture(size = 128): DataTexture { const y0 = Math.floor(v); const fx = u - x0; const fy = v - y0; - const sx = fx * fx * (3 - 2 * fx); - const sy = fy * fy * (3 - 2 * fy); + const sx = MathUtils.smoothstep(fx, 0, 1); + const sy = MathUtils.smoothstep(fy, 0, 1); const at = (i: number, j: number): number => values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0; const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx; diff --git a/packages/create-threenative/templates/runner/src/render/touch-controls.ts b/packages/create-threenative/templates/runner/src/render/touch-controls.ts index 4e57eb24e1..03ffe52228 100644 --- a/packages/create-threenative/templates/runner/src/render/touch-controls.ts +++ b/packages/create-threenative/templates/runner/src/render/touch-controls.ts @@ -15,6 +15,9 @@ const MOVE_RADIUS = 72; const BUTTON_RADIUS = 58; const EDGE = 36; +/** The knob's next point: `update` runs every frame, so it uses one instance. */ +const knobPosition = new Vector2(); + export interface ITouchPointer { readonly position: Vector2; } @@ -122,13 +125,11 @@ export class TouchControls { } const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting; - place( - this.#moveKnob, - new Vector2( - center.x + this.#input.move.x * MOVE_RADIUS, - center.y - this.#input.move.y * MOVE_RADIUS, - ), + knobPosition.set( + center.x + this.#input.move.x * MOVE_RADIUS, + center.y - this.#input.move.y * MOVE_RADIUS, ); + place(this.#moveKnob, knobPosition); this.#left.material = left ? this.#activeMaterial : this.#idleMaterial; this.#right.material = right ? this.#activeMaterial : this.#idleMaterial; this.#input.leftPressed = left && !this.#wasLeft; diff --git a/packages/create-threenative/templates/runner/src/scenes/Run.ts b/packages/create-threenative/templates/runner/src/scenes/Run.ts index 64fcd23664..24177651c5 100644 --- a/packages/create-threenative/templates/runner/src/scenes/Run.ts +++ b/packages/create-threenative/templates/runner/src/scenes/Run.ts @@ -205,7 +205,9 @@ export class Run extends Scene { const previous = frameCtx.state.getState(); const next: GameState = { chunks: track.built, - distance, + // Whole metres: the HUD shows the floor, and a fractional distance would republish every + // frame. The store skips a key it already holds, so this is the only stillness it needs. + distance: Math.floor(distance), lane: runner.lane, nearMisses, paused: previous.paused, @@ -213,14 +215,7 @@ export class Run extends Scene { status, uiReady: previous.uiReady, }; - if ( - next.chunks !== previous.chunks || - next.lane !== previous.lane || - next.nearMisses !== previous.nearMisses || - next.status !== previous.status || - Math.floor(next.distance) !== Math.floor(previous.distance) - ) - frameCtx.state.set(next); + frameCtx.state.set(next); }; } diff --git a/packages/create-threenative/templates/sailing/src/render/touch-controls.ts b/packages/create-threenative/templates/sailing/src/render/touch-controls.ts index 6f0ba0d96e..3f47c637d2 100644 --- a/packages/create-threenative/templates/sailing/src/render/touch-controls.ts +++ b/packages/create-threenative/templates/sailing/src/render/touch-controls.ts @@ -14,6 +14,9 @@ import { palette } from "./palette.js"; const MOVE_RADIUS = 72; const EDGE = 36; +/** The knob's next point: `update` runs every frame, so it uses one instance. */ +const knobPosition = new Vector2(); + export interface ITouchPointer { readonly position: Vector2; } @@ -100,13 +103,11 @@ export class TouchControls { } const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting; - place( - this.#moveKnob, - new Vector2( - center.x + this.#input.move.x * MOVE_RADIUS, - center.y - this.#input.move.y * MOVE_RADIUS, - ), + knobPosition.set( + center.x + this.#input.move.x * MOVE_RADIUS, + center.y - this.#input.move.y * MOVE_RADIUS, ); + place(this.#moveKnob, knobPosition); this.#moveKnob.material = this.#hasMoveAnchor ? this.#activeMaterial : this.#idleMaterial; this.#layout(size); return this.#input; diff --git a/packages/create-threenative/templates/shooter/src/render/clouds.ts b/packages/create-threenative/templates/shooter/src/render/clouds.ts index 0bb22540cb..1693ce841a 100644 --- a/packages/create-threenative/templates/shooter/src/render/clouds.ts +++ b/packages/create-threenative/templates/shooter/src/render/clouds.ts @@ -6,6 +6,8 @@ // Independent CPU implementation. The material samples four Unreal textures that this repository // must not carry, so each one is regenerated here from a seeded noise whose per-channel value // quantiles and correlation lengths match the Unreal texture (measured, see the spec). +import { MathUtils } from "three"; + import { BOTTOM_KM, CAMERA_KM, @@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [ const clamp = (v: number): number => Math.max(0, Math.min(1, v)); const smooth = (v: number): number => { const t = clamp(v); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; const mod = (a: number, n: number): number => ((a % n) + n) % n; diff --git a/packages/create-threenative/templates/snow/src/entities/Explorer.ts b/packages/create-threenative/templates/snow/src/entities/Explorer.ts index 2a8507518a..9901dc6405 100644 --- a/packages/create-threenative/templates/snow/src/entities/Explorer.ts +++ b/packages/create-threenative/templates/snow/src/entities/Explorer.ts @@ -1,6 +1,6 @@ import type { SnowField } from "@threenative/core/world"; import { CharacterBody3D, CollisionShape3D, type IPhysicsContext } from "@threenative/physics"; -import { Object3D } from "three"; +import { MathUtils, Object3D } from "three"; import { BOOT_AREA, bootPrint } from "../render/bootPrint.js"; import { ExplorerModel, type IFootPose } from "../render/explorer.js"; import type { SnowMaterials } from "../render/materials.js"; @@ -45,12 +45,6 @@ export const STANCE = 0.2; const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value)); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; -const damp = (a: number, b: number, rate: number, dt: number) => - lerp(a, b, 1 - Math.exp(-rate * Math.max(0, dt))); -const smoothstep = (edge0: number, edge1: number, value: number) => { - const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); - return t * t * (3 - 2 * t); -}; /** Walking pace through powder: deeper, softer snow is slower; packed snow is faster. */ function paceThrough(depth: number, compaction: number, running: boolean): number { @@ -146,8 +140,8 @@ export class Explorer { const length = Math.hypot(move.x, move.z); const scale = length > 1 ? 1 / length : 1; const moving = length > 0.01; - this.#velocity.x = damp(this.#velocity.x, move.x * scale * pace, moving ? 5 : 8, dt); - this.#velocity.z = damp(this.#velocity.z, move.z * scale * pace, moving ? 5 : 8, dt); + this.#velocity.x = MathUtils.damp(this.#velocity.x, move.x * scale * pace, moving ? 5 : 8, dt); + this.#velocity.z = MathUtils.damp(this.#velocity.z, move.z * scale * pace, moving ? 5 : 8, dt); if (!moving && Math.hypot(this.#velocity.x, this.#velocity.z) < 0.025) { this.#velocity.x = 0; this.#velocity.z = 0; @@ -159,7 +153,7 @@ export class Explorer { if (this.speed > 0.05) { const desired = Math.atan2(this.#velocity.x, this.#velocity.z); const turn = Math.atan2(Math.sin(desired - this.heading), Math.cos(desired - this.heading)); - this.heading += turn * (1 - Math.exp(-7 * dt)); + this.heading = MathUtils.damp(this.heading, this.heading + turn, 7, dt); } this.#cycle += travelled / (running ? 1.22 : 1.04); this.#stepFeet(dt, running, hardness); @@ -169,7 +163,7 @@ export class Explorer { this.speed > 0.02 ? Math.cos(this.#cycle * Math.PI * 4) * 0.016 : Math.sin(this.#elapsed * 1.8) * 0.003; - this.position.y = damp( + this.position.y = MathUtils.damp( this.position.y, terrainHeight(anchor.x, anchor.z) + snow.depth - sink + bob, 14, @@ -221,7 +215,7 @@ export class Explorer { progress = Math.max(foot.progress, clamp(foot.progress + foot.stop, 0, 1)); } foot.progress = progress; - const eased = smoothstep(0, 1, progress); + const eased = MathUtils.smoothstep(progress, 0, 1); foot.x = lerp(foot.from.x, foot.to.x, eased); foot.z = lerp(foot.from.z, foot.to.z, eased); const lift = diff --git a/packages/create-threenative/templates/snow/src/render/bootPrint.ts b/packages/create-threenative/templates/snow/src/render/bootPrint.ts index ad6c1adcfe..a4e96a30c9 100644 --- a/packages/create-threenative/templates/snow/src/render/bootPrint.ts +++ b/packages/create-threenative/templates/snow/src/render/bootPrint.ts @@ -2,6 +2,8 @@ // Change the sole, the tread or the rim here; `SnowField` only presses whatever shape it is given. /** One sample of a contact profile, in the shape `SnowField.stamp` reads. */ +import { MathUtils } from "three"; + interface IPrintSample { readonly bank: number; readonly coverage: number; @@ -14,7 +16,7 @@ const NOTHING: IPrintSample = { bank: 0, coverage: 0, disturbance: 0, relief: 0, function smoothstep(edge0: number, edge1: number, value: number): number { const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); } /** The sole's area, m². Load over this is the pressure a planted boot puts on the snow. */ diff --git a/packages/create-threenative/templates/snow/src/render/camera.ts b/packages/create-threenative/templates/snow/src/render/camera.ts index 91eaeebedf..f368b1bb6a 100644 --- a/packages/create-threenative/templates/snow/src/render/camera.ts +++ b/packages/create-threenative/templates/snow/src/render/camera.ts @@ -1,5 +1,6 @@ // Generated for you. Framing is a game-owned decision. import type { PerspectiveCamera } from "three"; +import { MathUtils } from "three"; import type { CameraView } from "../state.js"; export function setupCamera(camera: PerspectiveCamera): void { @@ -62,8 +63,8 @@ export class OrbitFollow { ground: (x: number, z: number) => number, dt: number, ): void { - const ease = 1 - Math.exp(-dt * 4.5); - const settle = 1 - Math.exp(-dt * 5); + const ease = MathUtils.damp(0, 1, 4.5, dt); + const settle = MathUtils.damp(0, 1, 5, dt); this.#target.x += (goal.x - this.#target.x) * ease; this.#target.y += (goal.y - this.#target.y) * ease; this.#target.z += (goal.z - this.#target.z) * ease; diff --git a/packages/create-threenative/templates/snow/src/render/scenery.ts b/packages/create-threenative/templates/snow/src/render/scenery.ts index 85be4b9685..b14b00b67c 100644 --- a/packages/create-threenative/templates/snow/src/render/scenery.ts +++ b/packages/create-threenative/templates/snow/src/render/scenery.ts @@ -9,6 +9,7 @@ import { Group, InstancedMesh, LineSegments, + MathUtils, Mesh, Object3D, PlaneGeometry, @@ -27,16 +28,15 @@ export interface IObstacle { } function seededRandom(seed: number): () => number { - let state = seed; + let state = seed >>> 0; return () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 4294967296; }; } - const smoothstep = (edge0: number, edge1: number, value: number) => { const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; /** Glade-edge pines by hand, then a ring of forest further out. `[x, z, height]`. */ diff --git a/packages/create-threenative/templates/snow/src/render/touch-controls.ts b/packages/create-threenative/templates/snow/src/render/touch-controls.ts index c82c6fd30f..9c40138178 100644 --- a/packages/create-threenative/templates/snow/src/render/touch-controls.ts +++ b/packages/create-threenative/templates/snow/src/render/touch-controls.ts @@ -14,6 +14,9 @@ import { palette } from "./palette.js"; const MOVE_RADIUS = 72; const EDGE = 36; +/** The knob's next point: `update` runs every frame, so it uses one instance. */ +const knobPosition = new Vector2(); + export interface ITouchPointer { readonly position: Vector2; } @@ -101,13 +104,11 @@ export class TouchControls { } const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting; - place( - this.#moveKnob, - new Vector2( - center.x + this.#input.move.x * MOVE_RADIUS, - center.y - this.#input.move.y * MOVE_RADIUS, - ), + knobPosition.set( + center.x + this.#input.move.x * MOVE_RADIUS, + center.y - this.#input.move.y * MOVE_RADIUS, ); + place(this.#moveKnob, knobPosition); this.#moveKnob.material = this.#hasMoveAnchor ? this.#activeMaterial : this.#idleMaterial; this.#layout(size); return this.#input; diff --git a/packages/create-threenative/templates/snow/src/render/weather.ts b/packages/create-threenative/templates/snow/src/render/weather.ts index 42da6c2e5a..a86920195e 100644 --- a/packages/create-threenative/templates/snow/src/render/weather.ts +++ b/packages/create-threenative/templates/snow/src/render/weather.ts @@ -1,7 +1,7 @@ // Generated for you. Snowfall, the blizzard and the powder a boot kicks up. `GPUParticles3D` (the // scene passes it in) owns // the buffers, dispatch and lifetime; the flake's shape, colour, size, drift and fall are here. -import { type Sprite, Vector2 } from "three"; +import { MathUtils, type Sprite, Vector2 } from "three"; import { Fn, atan, @@ -233,8 +233,6 @@ export function createWeather(options: { let wind = 0.4; let drifted = { x: 0, z: 0 }; let fallen = 0; - const damp = (a: number, b: number, rate: number, dt: number) => - a + (b - a) * (1 - Math.exp(-rate * Math.max(0, dt))); return { bursts: bursts.map((burst) => burst.particles), burst(at, strength) { @@ -253,10 +251,10 @@ export function createWeather(options: { update(dt, centre, pixelScale, settings) { fall.pixelScale.value = pixelScale; time += dt; - storm = damp(storm, settings.blizzard ? 1 : 0, 0.95, dt); + storm = MathUtils.damp(storm, settings.blizzard ? 1 : 0, 0.95, dt); const gust = 1 + 0.22 * Math.sin(time * 1.1) + 0.12 * Math.sin(time * 2.73 + 1.6); const target = (settings.wind * (1 - storm) + (9.5 + settings.wind * 0.5) * storm) * gust; - wind = damp(wind, target, 2.3, dt); + wind = MathUtils.damp(wind, target, 2.3, dt); drifted = { x: drifted.x + wind * dt, z: drifted.z + wind * 0.24 * dt }; fallen += settings.fallSpeed * (0.95 + storm * 0.7) * dt; fall.drift.value.set(drifted.x, drifted.z); diff --git a/packages/create-threenative/templates/snow/src/terrain.ts b/packages/create-threenative/templates/snow/src/terrain.ts index e65e3f34bf..f0f346bb87 100644 --- a/packages/create-threenative/templates/snow/src/terrain.ts +++ b/packages/create-threenative/templates/snow/src/terrain.ts @@ -1,4 +1,5 @@ import { Heightfield, SnowField } from "@threenative/core/world"; +import { MathUtils } from "three"; /** The playable snowfield is a square this many metres across, centred on the origin. */ export const FIELD_SIZE = 24; @@ -22,7 +23,7 @@ export const EXPLORER_LAYER = 0b1000; function smoothstep(edge0: number, edge1: number, value: number): number { const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); } /** Rolling ground under the snow: gentle swells in the glade, hills beyond it. */ diff --git a/packages/create-threenative/templates/starter/src/entities/Player.ts b/packages/create-threenative/templates/starter/src/entities/Player.ts index 07d81e4bd7..673ad32ca8 100644 --- a/packages/create-threenative/templates/starter/src/entities/Player.ts +++ b/packages/create-threenative/templates/starter/src/entities/Player.ts @@ -1,6 +1,14 @@ import { BlendSpace1D, type ICtx, SkeletalMesh3D } from "@threenative/core"; import { CharacterBody3D, CollisionShape3D, type IPhysicsContext } from "@threenative/physics"; -import { type AnimationClip, Group, type Mesh, type Object3D, Quaternion, Vector3 } from "three"; +import { + type AnimationClip, + Group, + MathUtils, + type Mesh, + type Object3D, + Quaternion, + Vector3, +} from "three"; import { type IStarterConventions, preparePlayerConventions } from "../conventions.js"; import type { ITouchInput } from "../render/touch-controls.js"; import type { GameState } from "../state.js"; @@ -143,7 +151,7 @@ export class Player { // Touch walks until the stick is fully deflected; no separate run button. const running = ctx.input.pressed("run") || (touch?.move.length() ?? 0) >= 1; const speed = running ? RUN_SPEED : WALK_SPEED; - const ease = 1 - Math.exp(-dt / 0.15); + const ease = MathUtils.damp(0, 1, 1 / 0.15, dt); const moveZ = -move.y; this.body.velocity.x += (move.x * speed - this.body.velocity.x) * ease; this.body.velocity.z += (moveZ * speed - this.body.velocity.z) * ease; diff --git a/packages/create-threenative/templates/starter/src/render/camera.ts b/packages/create-threenative/templates/starter/src/render/camera.ts index c7fcaf92ba..b17958a991 100644 --- a/packages/create-threenative/templates/starter/src/render/camera.ts +++ b/packages/create-threenative/templates/starter/src/render/camera.ts @@ -9,7 +9,7 @@ // Camera framing is one of the loudest things in a screenshot, so it lives // here in your repo rather than behind a framework option. import type { PerspectiveCamera, Vector3 } from "three"; -import { Vector3 as Vec3 } from "three"; +import { MathUtils, Vector3 as Vec3 } from "three"; export interface ISpringArmOptions { /** Where the camera sits relative to its target, before smoothing. */ @@ -102,7 +102,7 @@ export function createSpringArm( desired.copy(target).add(targetOffset()); // 1 - e^(-dt/damping): the frame-rate-independent form of a lerp. At 30fps // and 120fps the camera ends up in the same place after the same second. - camera.position.lerp(desired, 1 - Math.exp(-dt / damping)); + camera.position.lerp(desired, MathUtils.damp(0, 1, 1 / damping, dt)); clear(target); camera.lookAt(aim.copy(target).add(lookAhead)); }; diff --git a/packages/create-threenative/templates/starter/src/render/clouds.ts b/packages/create-threenative/templates/starter/src/render/clouds.ts index 0bb22540cb..1693ce841a 100644 --- a/packages/create-threenative/templates/starter/src/render/clouds.ts +++ b/packages/create-threenative/templates/starter/src/render/clouds.ts @@ -6,6 +6,8 @@ // Independent CPU implementation. The material samples four Unreal textures that this repository // must not carry, so each one is regenerated here from a seeded noise whose per-channel value // quantiles and correlation lengths match the Unreal texture (measured, see the spec). +import { MathUtils } from "three"; + import { BOTTOM_KM, CAMERA_KM, @@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [ const clamp = (v: number): number => Math.max(0, Math.min(1, v)); const smooth = (v: number): number => { const t = clamp(v); - return t * t * (3 - 2 * t); + return MathUtils.smoothstep(t, 0, 1); }; const mod = (a: number, n: number): number => ((a % n) + n) % n; diff --git a/packages/create-threenative/templates/starter/src/render/materials.ts b/packages/create-threenative/templates/starter/src/render/materials.ts index 44bd289187..c31b239bc1 100644 --- a/packages/create-threenative/templates/starter/src/render/materials.ts +++ b/packages/create-threenative/templates/starter/src/render/materials.ts @@ -11,6 +11,7 @@ import { DoubleSide, Float32BufferAttribute, LinearMipmapLinearFilter, + MathUtils, MeshStandardMaterial, RepeatWrapping, SRGBColorSpace, @@ -86,8 +87,8 @@ function grainNormalTexture(size = 128): DataTexture { const y0 = Math.floor(v); const fx = u - x0; const fy = v - y0; - const sx = fx * fx * (3 - 2 * fx); - const sy = fy * fy * (3 - 2 * fy); + const sx = MathUtils.smoothstep(fx, 0, 1); + const sy = MathUtils.smoothstep(fy, 0, 1); const at = (i: number, j: number): number => values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0; const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx; diff --git a/packages/create-threenative/templates/starter/src/render/touch-controls.ts b/packages/create-threenative/templates/starter/src/render/touch-controls.ts index 124c217579..54683cd1a5 100644 --- a/packages/create-threenative/templates/starter/src/render/touch-controls.ts +++ b/packages/create-threenative/templates/starter/src/render/touch-controls.ts @@ -15,6 +15,9 @@ const MOVE_RADIUS = 72; const BUTTON_RADIUS = 62; const EDGE = 36; +/** The knob's next point: `update` runs every frame, so it uses one instance. */ +const knobPosition = new Vector2(); + export interface ITouchPointer { readonly position: Vector2; } @@ -113,13 +116,11 @@ export class TouchControls { } const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting; - place( - this.#moveKnob, - new Vector2( - center.x + this.#input.move.x * MOVE_RADIUS, - center.y - this.#input.move.y * MOVE_RADIUS, - ), + knobPosition.set( + center.x + this.#input.move.x * MOVE_RADIUS, + center.y - this.#input.move.y * MOVE_RADIUS, ); + place(this.#moveKnob, knobPosition); this.#jump.material = jump ? this.#activeMaterial : this.#idleMaterial; this.#input.jumpPressed = jump && !this.#wasJump; this.#wasJump = jump; diff --git a/packages/create-threenative/templates/starter/src/scenes/Play.ts b/packages/create-threenative/templates/starter/src/scenes/Play.ts index b97ef35df0..92e715fed7 100644 --- a/packages/create-threenative/templates/starter/src/scenes/Play.ts +++ b/packages/create-threenative/templates/starter/src/scenes/Play.ts @@ -451,20 +451,7 @@ export class Play extends Scene { ), ); frameState.respawns = previous.respawns + (respawned ? 1 : 0); - const current = frameCtx.state.getState(); - const changed = - frameState.coyoteJumps !== current.coyoteJumps || - frameState.flagDisplacement !== current.flagDisplacement || - frameState.flagReadbacks !== current.flagReadbacks || - frameState.flagSteps !== current.flagSteps || - frameState.jumps !== current.jumps || - frameState.lives !== current.lives || - frameState.odometer !== current.odometer || - frameState.peakRise !== current.peakRise || - frameState.playerX !== current.playerX || - frameState.progress !== current.progress || - frameState.respawns !== current.respawns; - if (changed) frameCtx.state.set(frameState); + frameCtx.state.set(frameState); if (respawned) frameCtx.state.flush(); }; } diff --git a/packages/create-threenative/templates/tower-defense/src/render/shapes.ts b/packages/create-threenative/templates/tower-defense/src/render/shapes.ts index 01a1766c7f..e5a93682e3 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/shapes.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/shapes.ts @@ -399,11 +399,10 @@ export function reactor(): Group { export function lcg(seed: number): () => number { let state = seed >>> 0; return () => { - state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + state = (state * 1664525 + 1013904223) >>> 0; return state / 4294967296; }; } - function scatter( geometry: BufferGeometry, material: Material, diff --git a/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts b/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts index 46a91469d1..2e177b88f4 100644 --- a/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts +++ b/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts @@ -68,11 +68,6 @@ const STRIKE_BOUNDS = { x: 19, z: 12 } as const; const LEFT_SHIFT = "ShiftLeft"; const RIGHT_SHIFT = "ShiftRight"; -function sameState(a: GameState, b: GameState): boolean { - for (const key of Object.keys(a) as (keyof GameState)[]) if (a[key] !== b[key]) return false; - return true; -} - export class Battle extends Scene { #environmentSample: IEnvironmentSample | undefined; #materialLighting: ReturnType | undefined; @@ -514,8 +509,8 @@ export class Battle extends Scene { ghost.visible = true; }; - // Publish only what changed: the HUD is another process on native, and a frame that changed - // nothing should cost it nothing. + // The store publishes only what changed: the HUD is another process on native, and a frame + // that changed nothing costs it nothing. const next: GameState = { ...INITIAL_STATE }; const publish = (frameCtx: FrameCtx, speed: number): void => { const previous = frameCtx.state.getState(); @@ -556,7 +551,7 @@ export class Battle extends Scene { next.towers = towers.size; next.uiReady = previous.uiReady; next.wave = waves.wave; - if (!sameState(next, previous)) frameCtx.state.set(next); + frameCtx.state.set(next); }; this.#materialLighting = ctx.entities.add( diff --git a/packages/engine-mcp/__tests__/search.spec.ts b/packages/engine-mcp/__tests__/search.spec.ts index d9c7402ef4..3bccbee697 100644 --- a/packages/engine-mcp/__tests__/search.spec.ts +++ b/packages/engine-mcp/__tests__/search.spec.ts @@ -197,6 +197,23 @@ describe("threenative-engine-mcp", () => { expect(results.slice(0, 3).map((result) => result.symbol)).toContain("CharacterBody3D"); }); + it("finds the ctx members an author searches for by name (PRD-872)", () => { + const cases = [ + ["cache generated geometry between page loads", "ctx.assets.memo"], + ["boot progress bar", "ctx.startup.progress"], + ["run a callback after a delay", "ctx.after"], + ["run a callback before the frame renders", "ctx.beforeRender"], + ] as const; + for (const [situation, symbol] of cases) { + expect( + searchResults(situation) + .slice(0, 3) + .map((result) => result.symbol), + situation, + ).toContain(symbol); + } + }); + it("finds measured miss-list vocabulary across the owning packages", () => { const cases = [ ["different props in each area", "createAssetLoader"], diff --git a/scripts/api-surface.json b/scripts/api-surface.json index bb75ad7b40..4e9ea51128 100644 --- a/scripts/api-surface.json +++ b/scripts/api-surface.json @@ -176,6 +176,7 @@ "@threenative/core#createReplayDriver": "export function createReplayDriver( recording: Recording, target: EventTarget, pointerTarget = target, ) { … }", "@threenative/core#Daylight": "export class Daylight extends Group implements IComputeDriven { … }", "@threenative/core#debugFlag": "export function debugFlag(name: string): boolean { … }", + "@threenative/core#debugValue": "export function debugValue(name: string): string | undefined { … }", "@threenative/core#defineGame": "export function defineGame, TPhysics = undefined>( config: IGameConfig, ): IGame { … }", "@threenative/core#describeSceneShape": "export function describeSceneShape( window: IFrameBudgetWindow, cull: IRenderCameraCullReport | undefined, ): ISceneShape | undefined { … }", "@threenative/core#describeSceneWarning": "export function describeSceneWarning(warning: ISceneWarning): string { … }", @@ -314,9 +315,13 @@ "@threenative/core#withVelocityContext": "export function withVelocityContext(node: T, source: Node): T { … }", "@threenative/core#WorldEnvironment": "export class WorldEnvironment { … }", "@threenative/core#zenithTransmittance": "export function zenithTransmittance( parameters: IAtmosphereParameters | IResolvedAtmosphereParameters, ): AtmosphereRgb { … }", + "src/assets.ts#ctx.assets.memo": "ctx.assets.memo(key: string, build: () => T | Promise): Promise", "src/game.ts#renderer.matrixWorld": "renderer.matrixWorld?: \"visible\" | \"all\"", "src/game.ts#renderer.minimumProjectedPixels": "renderer.minimumProjectedPixels?: number | false", - "src/game.ts#renderer.projection": "renderer.projection?: boolean | { materialChecks?: 'spread' | 'everyFrame' }" + "src/game.ts#renderer.projection": "renderer.projection?: boolean | { materialChecks?: 'spread' | 'everyFrame' }", + "src/scene.ts#ctx.after": "ctx.after(delay: number, callback: () => void): ScheduleHandle", + "src/scene.ts#ctx.beforeRender": "ctx.beforeRender(callback: () => void): () => void", + "src/scene.ts#ctx.startup.progress": "ctx.startup.progress: number" } }, "@threenative/metahuman": { diff --git a/scripts/build-capability-manifest.ts b/scripts/build-capability-manifest.ts index 16f5c3a6a3..1077a29ad4 100644 --- a/scripts/build-capability-manifest.ts +++ b/scripts/build-capability-manifest.ts @@ -5,6 +5,7 @@ import ts from "typescript"; import { RELEVANCE_FLOOR, capabilitySituationTokens } from "../packages/engine-mcp/src/index.js"; import { CAPABILITY_PACKAGE_ALLOWLIST, isPublicClassOrFunction } from "./check-capability-docs.js"; +import { CTX_MANIFEST_ENTRIES } from "./ctx-capabilities.js"; import { type INotOwnedCapability, NOT_OWNED_CAPABILITIES } from "./not-owned-capabilities.js"; import { REALISM_EFFECTS_COVERAGE, @@ -728,6 +729,7 @@ export function buildCapabilityManifest( symbol: candidate.symbol, })), ...REALISM_EFFECTS_MANIFEST_ENTRIES.map((entry) => ({ ...entry, aliases: [] })), + ...CTX_MANIFEST_ENTRIES.map((entry) => ({ ...entry, aliases: entry.aliases ?? [] })), ...RENDER_CHAIN_MANIFEST_ENTRIES.map((entry) => ({ ...entry, aliases: entry.aliases ?? [], diff --git a/scripts/ctx-capabilities.ts b/scripts/ctx-capabilities.ts new file mode 100644 index 0000000000..543e363365 --- /dev/null +++ b/scripts/ctx-capabilities.ts @@ -0,0 +1,121 @@ +import type { ICapabilityManifestEntry } from "./build-capability-manifest.js"; + +/** + * The `ctx` members that exist only as interface properties, so nothing exports them and the + * manifest cannot find them by walking exports. + * + * This is the same gap the render-chain stages had: `ctx.assets.memo` shipped and worked, and + * `engine_search_capabilities("cache generated geometry between page loads")` returned nothing, + * so a game rebuilt a nine-second stage on every load. The manifest is the thing an agent searches + * first; a member absent from it does not exist. Each row therefore carries the words an author + * types before they know the name, plus the constraint that actually bites. + * + * The JSDoc on the interface member stays the source of the behaviour; this file is the index. + */ +function member(entry: { + readonly symbol: string; + readonly signature: string; + readonly summary: string; + readonly situations: readonly string[]; + readonly constraints: readonly string[]; + readonly example: string; + readonly overrides?: readonly string[]; + readonly importPath?: string; +}): ICapabilityManifestEntry { + return { + symbol: entry.symbol, + package: "@threenative/core", + importPath: entry.importPath ?? "src/scene.ts", + kind: "function", + signature: entry.signature, + summary: entry.summary, + situations: entry.situations, + aliases: [], + example: entry.example, + constraints: entry.constraints, + overrides: entry.overrides ?? [], + supersedes: [], + }; +} + +export const CTX_MANIFEST_ENTRIES: readonly ICapabilityManifestEntry[] = [ + member({ + symbol: "ctx.assets.memo", + importPath: "src/assets.ts", + signature: "ctx.assets.memo(key: string, build: () => T | Promise): Promise", + summary: + "Returns what `build` returned the last time this build of the game ran, read back from disk, and calls `build` only when nothing is stored. Use it for seeded work that is the same every run: placement lists, painted sky pixels, terrain heights, a generated stage.", + situations: [ + "cache generated geometry between page loads", + "stop rebuilding the same generated stage on every page load", + "cache painted sky pixels or baked terrain heights between runs", + "keep seeded placement work the same every run without recomputing it", + "make a slow boot fast without shipping a baked asset", + ], + constraints: [ + "The value is named typed arrays; a map of names to typed arrays is what is stored.", + "On the dev server and on a native host `build` runs every time, and `TN_ASSET_MEMO` reports why: the cache is a build artifact, not a substitute for authoring.", + "A new build of the game regenerates: the key alone does not pin a value across a rebuild.", + "A game that drives a loader outside `defineGame` may call it whenever it likes; the reveal is not waited on.", + ], + example: 'const { pixels } = await ctx.assets.memo("sky", () => ({ pixels: paintSky() }));', + overrides: ["TN_ASSET_MEMO reports why a run rebuilt instead of reading the store"], + }), + member({ + symbol: "ctx.startup.progress", + signature: "ctx.startup.progress: number", + summary: + "How much of the start scene has loaded and presented, 0 to 1, for a loading cover's bar. It never falls: a loader whose pending work grows keeps the number it reached rather than jumping backwards.", + situations: [ + "boot progress bar", + "show how much of the start scene has loaded", + "loading screen percentage", + "a progress bar that never goes backwards", + "report startup progress while assets stream in", + ], + constraints: [ + "Never decreases, even when the loader is asked for more work mid-boot.", + "Reaches 1 only after the first world render has been presented, not when the last byte arrived.", + "A loading view the engine drew for you already reads it; this is for a cover the game draws itself.", + ], + example: + "const width = `${Math.round(ctx.startup.progress * 100)}%`; // a cover the game draws on ctx.canvasLayer", + }), + member({ + symbol: "ctx.after", + signature: "ctx.after(delay: number, callback: () => void): ScheduleHandle", + summary: + "Runs `callback` once, `delay` seconds of game time later, and returns a handle that cancels it. Game time stops while the game is paused, so a delayed beat does not fire behind a paused frame.", + situations: [ + "run a callback after a delay", + "schedule a one-shot timer that pauses with the game", + "do something a moment later without setTimeout", + "cancel a pending delayed action when a scene exits", + ], + constraints: [ + "The delay is game seconds: a paused game does not age it, and a slow frame does not skip it.", + "`setTimeout` keeps running while the game is paused and after the scene is gone; this does not.", + "Call the returned handle to cancel: a pending callback holds its scene's closure alive.", + ], + example: "const spawn = ctx.after(0.4, () => ctx.add(makeWave()));\nspawn(); // cancels", + }), + member({ + symbol: "ctx.beforeRender", + signature: "ctx.beforeRender(callback: () => void): () => void", + summary: + "Registers work that runs once per actual world render, after the frame's last fixed update and before the projection reconciles and the renderer draws. The last place to change a transform, a uniform or a camera before the frame is presented.", + situations: [ + "run a callback just before the frame renders", + "update a camera or a uniform after physics and before the draw", + "change a transform in the same frame it is seen", + "run per-render work that must not run on a loader-only frame", + ], + constraints: [ + "A held, loader-only frame has no world draw and dispatches nothing.", + "Runs after every fixed update in the frame, so a value it writes is the one drawn.", + "Returns an unregister function; call it when the object that needed it is gone.", + ], + example: + "const stop = ctx.beforeRender(() => (material.uniforms.time.value = clock.elapsed));\n// later: stop();", + }), +];