From 6c4fe6151980ae70a1a780c153966101c9a6a2fa Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:42:57 -0700 Subject: [PATCH 01/12] feat(core): debugValue and the ctx rows the manifest could not see debugValue(name) is debugFlag's non-boolean sibling: ?quality= on the web, TN_DEBUG_QUALITY on a native launch. The four ctx members an author searches for (assets.memo, startup.progress, after, beforeRender) are interface properties, so walking exports never found them; they now live in scripts/ctx-capabilities.ts, the same hand-authored registry the render-chain config keys use. Manifest gains 5 rows: 571 entries, pnpm capabilities:check fresh. --- packages/core/capabilities.json | 115 ++++++++++++++++++ packages/core/src/debug.ts | 16 +++ packages/core/src/index.ts | 9 ++ packages/engine-mcp/__tests__/search.spec.ts | 17 +++ scripts/build-capability-manifest.ts | 2 + scripts/ctx-capabilities.ts | 121 +++++++++++++++++++ 6 files changed, 280 insertions(+) create mode 100644 scripts/ctx-capabilities.ts diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index 435153b02c..7711e67cd3 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", @@ -10325,6 +10345,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", @@ -10550,6 +10595,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 46a1a38467..7928a356d2 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/engine-mcp/__tests__/search.spec.ts b/packages/engine-mcp/__tests__/search.spec.ts index 37ab56f133..4ade4d3177 100644 --- a/packages/engine-mcp/__tests__/search.spec.ts +++ b/packages/engine-mcp/__tests__/search.spec.ts @@ -192,6 +192,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/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();", + }), +]; From 85155ea935f7b6c3d919af17c9b39c914b62e2b6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:43:33 -0700 Subject: [PATCH 02/12] feat(quality): the lint sees entity frame methods and names the engine call trackHotCallbacks treats a non-static update, fixedUpdate, tick or step on any class as per-frame, so the three hot-callback rules reach a game's own entity classes; 12 real sites in 9 kits, 0 false positives. src/quality/reinventions.ts is a data table of the six hand-written forms a shipped capability already covers, fed to no-restricted-syntax. Each row's capability is resolved against capabilities.json or three by the spec, so a message cannot name a removed symbol. --- .../__tests__/authoring-quality.spec.ts | 9 +- .../__tests__/quality-rules.spec.ts | 126 ++++++++++++++++++ .../references/performance-basics.md | 3 +- .../src/quality/hotCallbackAllocation.ts | 3 +- .../src/quality/hotCallbacks.ts | 29 +++- .../create-threenative/src/quality/index.ts | 2 + .../src/quality/reinventions.ts | 103 ++++++++++++++ 7 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 packages/create-threenative/src/quality/reinventions.ts 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..d3000b561c 100644 --- a/packages/create-threenative/__tests__/quality-rules.spec.ts +++ b/packages/create-threenative/__tests__/quality-rules.spec.ts @@ -1,7 +1,10 @@ +import { readFileSync } from "node:fs"; 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 +170,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 +202,102 @@ 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: Record = { + createRandom: { + bad: "let s = 7;\ns = (Math.imul(s, 1664525) + 1013904223) | 0;\nconst r = Math.random();\n", + good: "const random = createRandom(7)();\n", + }, + "MathUtils.damp": { + bad: "const k = 1 - Math.exp(-dt * 4);\n", + good: "const k = MathUtils.damp(a, b, 4, dt);\n", + }, + debugFlag: { + bad: 'const asked = new URLSearchParams(location.search).get("quality");\n', + good: 'const asked = debugValue("quality");\n', + }, + "renderer.matrixWorld": { + bad: "scene.matrixWorldAutoUpdate = false;\n", + good: "const config = { renderer: { matrixWorld: true } };\n", + }, + "InputMap.axis": { + bad: 'const x = (keys.has("KeyA") ? 1 : 0) - (keys.has("KeyD") ? 1 : 0);\n', + good: 'const x = ctx.input.axis("moveX");\n', + }, + "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 [capability, test] of Object.entries(CASES)) { + expect(reinvented([row(capability)], test.bad), capability).not.toEqual([]); + expect(reinvented([row(capability)], test.good), capability).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); + } + }); + + 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/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..111ac47e6d --- /dev/null +++ b/packages/create-threenative/src/quality/reinventions.ts @@ -0,0 +1,103 @@ +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[]; +} + +/** The engine's launch seam is the URL query on web and an environment variable on native; audio + * synthesis and UI animation may use the platform RNG without a seed to preserve. */ +export const PRNG_OUTSIDE = ["src/audio.ts", "src/audio/**", "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="-"][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 batched pass: set `renderer.matrixWorld` in the game config and let `MatrixWorldPass` walk the scene.", + }, + { + 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) }, + ]; +} From c99612c5812aefe4a1163bbd9ae73756808399c3 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:44:10 -0700 Subject: [PATCH 03/12] refactor(templates): the kits name the engine call the lint now rejects 109 hand-written forms replaced across the 16 kits: 38 inline smoothsteps, 23 exponential damp factors, 34 PRNG copies, 3 location.search launch seams. The LCG copies were the engine's own sequence and produce identical values through createRandom; the authored mulberry32 streams (adventure level, kart course, rts world, rain storm) keep a described suppression because swapping them builds different content. Scaffold pins re-recorded; scaffold spec 79 passed. PRD-872 ticks carry their proof; the two sandbox boxes stay open (separate checkout). --- .../PRD-872-the-lint-names-the-engine-call.md | 115 +++++++++++++++++ ...engine-owns-the-pool-and-the-bodys-rest.md | 118 ++++++++++++++++++ ...-kit-plumbing-leaves-the-generated-game.md | 106 ++++++++++++++++ .../authoring/absorb-2026-11-10/README.md | 107 ++++++++++++++++ .../__tests__/scaffold.spec.ts | 32 ++--- .../templates/action-rpg/src/loot/drops.ts | 10 +- .../templates/action-rpg/src/render/camera.ts | 4 +- .../templates/action-rpg/src/render/clouds.ts | 15 +-- .../action-rpg/src/render/materials.ts | 5 +- .../action-rpg/src/render/touch-controls.ts | 13 +- .../adventure/src/entities/LockOn.ts | 36 +++++- .../adventure/src/entities/Warden.ts | 5 +- .../templates/adventure/src/level/Vale.ts | 4 +- .../templates/adventure/src/render/camera.ts | 4 +- .../templates/adventure/src/render/foliage.ts | 3 +- .../adventure/src/render/materials.ts | 5 +- .../adventure/src/render/touch-controls.ts | 5 +- .../templates/adventure/src/scenes/Play.ts | 2 +- .../collectathon/src/level/Course.ts | 9 +- .../collectathon/src/level/terrain.ts | 3 +- .../collectathon/src/render/camera.ts | 4 +- .../collectathon/src/render/ground.ts | 8 +- .../templates/collectathon/src/render/hero.ts | 2 +- .../collectathon/src/render/materials.ts | 8 +- .../templates/collectathon/src/render/sky.ts | 15 ++- .../templates/kart/src/entities/Kart.ts | 3 +- .../templates/kart/src/render/course.ts | 4 +- .../templates/kart/src/track/Course.ts | 4 +- .../templates/minimal/src/render/camera.ts | 4 +- .../templates/minimal/src/render/clouds.ts | 15 +-- .../templates/minimal/src/render/materials.ts | 5 +- .../minimal/src/render/touch-controls.ts | 13 +- .../templates/platformer/src/level/Stage.ts | 8 +- .../platformer/src/render/pickups.ts | 10 +- .../platformer/src/render/waterfall.ts | 29 ++--- .../templates/puzzle/src/entities/Warden.ts | 6 +- .../templates/puzzle/src/render/camera.ts | 4 +- .../puzzle/src/render/touch-controls.ts | 13 +- .../templates/racing/src/render/camera.ts | 2 +- .../templates/racing/src/render/clouds.ts | 15 +-- .../templates/racing/src/render/materials.ts | 7 +- .../racing/src/render/touch-controls.ts | 13 +- .../templates/racing/src/track/circuit.ts | 6 +- .../templates/rain/src/render/noise-volume.ts | 4 +- .../templates/rain/src/state.ts | 4 +- .../templates/rain/src/ui/GameUi.tsx | 2 +- .../templates/rain/src/ui/automation.ts | 18 +-- .../templates/rain/src/ui/tempest.ts | 7 +- .../templates/rts/src/sim/terrain.ts | 4 +- .../templates/rts/src/sim/types.ts | 1 + .../templates/runner/src/render/clouds.ts | 15 +-- .../templates/runner/src/render/materials.ts | 5 +- .../runner/src/render/touch-controls.ts | 13 +- .../sailing/src/render/touch-controls.ts | 13 +- .../templates/shooter/src/render/clouds.ts | 15 +-- .../templates/shooter/src/render/shapes.ts | 7 +- .../templates/snow/src/entities/Explorer.ts | 18 +-- .../templates/snow/src/render/bootPrint.ts | 4 +- .../templates/snow/src/render/camera.ts | 5 +- .../templates/snow/src/render/scenery.ts | 11 +- .../snow/src/render/touch-controls.ts | 13 +- .../templates/snow/src/render/weather.ts | 8 +- .../templates/snow/src/terrain.ts | 3 +- .../templates/starter/src/entities/Player.ts | 12 +- .../templates/starter/src/render/camera.ts | 4 +- .../templates/starter/src/render/clouds.ts | 15 +-- .../templates/starter/src/render/materials.ts | 5 +- .../starter/src/render/touch-controls.ts | 13 +- .../tower-defense/src/render/shapes.ts | 8 +- 69 files changed, 742 insertions(+), 284 deletions(-) create mode 100644 docs/PRDs/authoring/absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md create mode 100644 docs/PRDs/authoring/absorb-2026-11-10/PRD-873-the-engine-owns-the-pool-and-the-bodys-rest.md create mode 100644 docs/PRDs/authoring/absorb-2026-11-10/PRD-874-kit-plumbing-leaves-the-generated-game.md create mode 100644 docs/PRDs/authoring/absorb-2026-11-10/README.md 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..45565e280e --- /dev/null +++ b/docs/PRDs/authoring/absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md @@ -0,0 +1,115 @@ +# 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) + +## 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` | + +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 tsx scripts/check-template-quality.ts` — exit 0; the three hot rules at `error` over all 16 `templates/*/src` trees report 0 findings. 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. + +#### 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. Kit PRNG copies inside `src/render/clouds.ts` change in the canonical source and are restamped. +**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 tsx scripts/check-template-quality.ts && pnpm test:templates` — kits pass the rows (scan at `error`: 0 findings; 109 sites replaced: 38 smoothstep, 23 damp, 34 PRNG, 3 launch seams; 4 authored mulberry32 sequences — adventure, kart, rts, rain-noise — keep a described `eslint-disable-next-line ... -- ` because replacing them would change the authored level, replay or storm). `pnpm test:templates` result recorded when the run finishes: pending. + +#### 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..d8abba21c0 --- /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:** NOT STARTED +**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:** NOT STARTED +**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 5 kit scenes with a changed-only check. +**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 5 kits pass with their hand checks deleted. proof: `pnpm exec vitest run packages/core/__tests__/state.spec.ts && TN_TEMPLATE_ONLY=action-rpg,runner,tower-defense,racing,rts pnpm test:templates` + +#### 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/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index a602d36b3d..f2e4dd9717 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: "3dfee3757561db8b2824b23d6131e9b7308bf13998e7a4bf7b6b6e3adf6a7f53", - kit: "a8d70b273cef24257fcffa5ff29033005501615a23a20ad6e542afee61013efa", + kit: "cbb3b3b48378826ebb54f260103befba67de3a9ad7008bd344a99008e7814e46", }, adventure: { shared: "c05c558a93c9297b490fd7d40b9340fb2fa639cbf631026df3ef86c2d14fb81b", - kit: "d2fa700c0186ed7eb9199700ed5b027078420801d0d09de76954364f94937d7a", + kit: "8dc3b31209842025cc6c63db9a8ad6061c6d20b75230202b13383a22cef705eb", }, collectathon: { shared: "b2f3bc054c911405e898063214a2e18c5916b08e7bce35cabfbaa6ef6f7777a7", - kit: "d442044c496f13384c098fa60573220cf41f5c0c07ac13ada43c1a5f0f64de72", + kit: "a4cd91dc17a14c6d30de8f14e7fa1541fd76bbfcade537fc5b3c8c7f3b06bd54", }, kart: { shared: "8a4de1bce71339bd7249b9a41e921f8e111106149ae5680f461d202d265508a8", - kit: "edd8ff2a1a9bb9465eaa6c82ff830b57d75f3b594018c45cc7cf3a6c737bd9c2", + kit: "602200828199d9f56b573f8fdcd2f669e8d5f4026739c7f042b8658a26c46cfd", }, minimal: { shared: "899d2d05ab5d879a320bab1e7efd1c8735126154792c01df7fe86f0582d412c6", - kit: "29c32badb8a5c214d2284d6a2635570c03f3241feb8180cce5da681cd8222b96", + kit: "2feedd53f2a6a046dabb13341ede82e1f29ab47b82fc2ea26752481e51471f0c", }, platformer: { shared: "5d295a3d2bee8ed41207d3630d07f1f325d80ce763be0c119378249b9c87ed0c", - kit: "abddd25226bc58d65bf32d8dc38f7f10ef4af80e3ba1905290ff63bb7d25032e", + kit: "77156efd7310f883f41b939cedfee51c50c583edb36a8fa52af99742a7a1f0b3", }, puzzle: { shared: "9900dba834fb339115c9aa9e7afd0581d9935f3004b228dd654f991b11493847", - kit: "81db308b4a7c9dbb711555f4b4355d4c0fa3baa6d5d2f3a80327333fe5b39401", + kit: "f3c2ecbaf4ab1294827379efc6af8e05595e67aff5b4e2d08642b2923c750eaf", }, racing: { shared: "c8b7db38ef9b68fbdf55dead10560df29569f5c183cd0f8f4c1dbf6469071f64", - kit: "502ce8d65e38e69bfcec7c4c9981284048809676c6267f2a945534ed6a3f14c9", + kit: "f28dd5a95c8a79035ce9b522ce12ef6cc7d4cd3a812bd17e2fcf16ed16c924e9", }, rain: { shared: "0856ecca144a6d2419c0bf4d1b26da6448d358f9fa8ebaa9b6604780442054b3", - kit: "f0adf851fb6d0e14acdf903b47a00e3fa41ff88ca5f7086e43ea3a09d4609b96", + kit: "eac34cbbd7549ccbca4fdd65d0578a9b00dc043b00e214e6df30c487502f178e", }, rts: { shared: "94c096399f971506d873735610fc2759b108d672e200472279a8d0d4fcd92e09", - kit: "15ae383f30b33a6ca2c3185f51d3365f5fbe71b49f1432a3d6b30c3dc576d669", + kit: "b97e2fc08525da6e4411162482ef9bb8d0debb9d95e8d515c59fc46c78e00afe", }, runner: { shared: "e522646cf7dc0567c1496c1abda755fb13d9bdba62d98e18cc3e1c68264db3af", - kit: "b90f44f06df584db1557264c0fb07167adeb0b136c547839884ff1bcddca1158", + kit: "4130106e37912ee13d8a44d19400b501e785cde9c49bb609f2f8b92fc55f198d", }, sailing: { shared: "e230d0ea5ae2e458c32f0ed0aa31e5c7a0bce6771adf41293623489611acd41c", - kit: "db196dbad5d5b1e1c6b20bac8369c28426f7e2d7dac5e2ee948ec18a79e70cf0", + kit: "fbfd8c80d5e7c0ec8f42ed9697ce9df9e4649f19aa28e51e1f20550f45b6fd2e", }, shooter: { shared: "ddcf11d581b12631f2f49319a022abddf66d59f9ce83265bb2935acb8414953e", - kit: "0e89ad8f17773794527b8bb153f265b6a7344792498f2a173447a666ecd60cf5", + kit: "18c26c3b75c5b2455deb75bebdd66a0d611957baef60d68432d53c1357147ea8", }, snow: { shared: "1b360f0661b462c2872dff12b403cf072690e19434094966a84db41ef34718bb", - kit: "4da758c138d7f80b9a4e88903e2c86172c0029bd160da1ebe5f37b72bcfea823", + kit: "056539766dda3074f121a69bf9bc3546dd992029b4f69a8720c1e53dcf7cbf7b", }, starter: { shared: "45287923ee472856d7fee5781bf527f953789cb893b951151eaa3d64c1ac539b", - kit: "232c5278ee55f8581bf3b2b25c53d8a852a4a1704947ee2a6d22f3756c724a1e", + kit: "ad561025f388f81e11f88cd3d3d19e3f5ecf769e330d627ed342f571ac928aff", }, "tower-defense": { shared: "9d9bbb42f8a7f0845bf827030f6978aa4feae5ac455e3739ee612e1d18c5bf9b", - kit: "7fa359a8a390a9bcbb2c832ba4fe66f45129170f387333173625cab93e0c7ba0", + kit: "27593ab909936d5f2054b34b06fe1d381cc668a59da5d15bf219dabf5bfdae3c", }, // scaffold-pin:end }; 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..e004db490b 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/clouds.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/clouds.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -6,6 +7,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,18 +113,10 @@ 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; -function rng(seed: number): () => number { - let state = seed | 0; - return () => { - state = (Math.imul(state, 1664525) + 1013904223) | 0; - return (state >>> 0) / 4294967296; - }; -} - /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -298,7 +293,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = rng(seed); + const next = createRandom(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { 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/adventure/src/entities/LockOn.ts b/packages/create-threenative/templates/adventure/src/entities/LockOn.ts index 25c6fcf061..748e6ec639 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, @@ -100,8 +106,16 @@ export class LockOn { 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. + const box = this.bounds.makeEmpty(); + for (const mesh of this.#meshesOf(target)) { + mesh.updateWorldMatrix(false, false); + 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 +132,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..44716a019a 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); 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..11f381dc88 100644 --- a/packages/create-threenative/templates/collectathon/src/render/materials.ts +++ b/packages/create-threenative/templates/collectathon/src/render/materials.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // ThreeNative does not read this file. // @@ -180,13 +181,8 @@ export function paintedTexture( /** A seeded 0..1 random source (an LCG), for texture painting that must be the same every build. */ export function seeded(seed: number): () => number { - let state = seed >>> 0; - return () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 0x1_0000_0000; - }; + return createRandom(seed); } - /** 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..add6130747 100644 --- a/packages/create-threenative/templates/collectathon/src/render/sky.ts +++ b/packages/create-threenative/templates/collectathon/src/render/sky.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // // A blue gradient with the sun's warm halo and a deck of big cumulus, painted per texel from seeded @@ -11,6 +12,7 @@ import { EquirectangularReflectionMapping, FogExp2, LinearFilter, + MathUtils, RGBAFormat, SRGBColorSpace, type Scene, @@ -28,19 +30,16 @@ export const SUN_DIRECTION = new Vector3(0.9, 0.38, 0.16).normalize(); /** A 256 x 256 lattice of seeded values: the noise reads a table, far cheaper than a hash per call. */ const LATTICE = (() => { - let state = 9137; - return Float32Array.from({ length: 256 * 256 }, () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 0x1_0000_0000; - }); + const random = createRandom(9137); + return Float32Array.from({ length: 256 * 256 }, () => random()); })(); /** Smooth 2D value noise on the lattice, for the cloud deck: deterministic, repeats every 256 units. */ 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 +78,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..3d40884d23 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, @@ -29,6 +30,7 @@ import { C } from "./palette.js"; export function makeRng(seed: number): () => number { let s = seed >>> 0; return () => { + // eslint-disable-next-line no-restricted-syntax -- The tree and grass placement is authored against this mulberry32 stream; the engine's seeded RNG is an LCG, so swapping it places a different course. s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); @@ -521,7 +523,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..e004db490b 100644 --- a/packages/create-threenative/templates/minimal/src/render/clouds.ts +++ b/packages/create-threenative/templates/minimal/src/render/clouds.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -6,6 +7,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,18 +113,10 @@ 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; -function rng(seed: number): () => number { - let state = seed | 0; - return () => { - state = (Math.imul(state, 1664525) + 1013904223) | 0; - return (state >>> 0) / 4294967296; - }; -} - /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -298,7 +293,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = rng(seed); + const next = createRandom(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { 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/platformer/src/render/pickups.ts b/packages/create-threenative/templates/platformer/src/render/pickups.ts index 3e77fe5289..67bde1a6f2 100644 --- a/packages/create-threenative/templates/platformer/src/render/pickups.ts +++ b/packages/create-threenative/templates/platformer/src/render/pickups.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // // What the fox collects: a spinning coin, a faceted gem, and the big gold star that ends the run. @@ -19,6 +20,9 @@ import { import { flat, toon } from "./materials.js"; import { C } from "./palette.js"; +/** The decoration is placed once at load; a fixed seed makes every run place it the same. */ +const random = createRandom(40465); + const TAU = Math.PI * 2; /** Spinning coin with an embossed star on both faces. */ @@ -96,9 +100,9 @@ export function burst( const material = flat(color); const bits: Mesh[] = []; for (let i = 0; i < count; i += 1) { - const bit = new Mesh(new SphereGeometry(0.09 + Math.random() * 0.07, 5, 4), material); - const a = Math.random() * TAU; - bit.userData.v = new Vector3(Math.cos(a) * 3.2, (0.4 + Math.random()) * 4.5, Math.sin(a) * 3.2); + const bit = new Mesh(new SphereGeometry(0.09 + random() * 0.07, 5, 4), material); + const a = random() * TAU; + bit.userData.v = new Vector3(Math.cos(a) * 3.2, (0.4 + random()) * 4.5, Math.sin(a) * 3.2); group.add(bit); bits.push(bit); } diff --git a/packages/create-threenative/templates/platformer/src/render/waterfall.ts b/packages/create-threenative/templates/platformer/src/render/waterfall.ts index 108e62395b..1c33857641 100644 --- a/packages/create-threenative/templates/platformer/src/render/waterfall.ts +++ b/packages/create-threenative/templates/platformer/src/render/waterfall.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // // One waterfall: two translucent sheets that narrow at the lip and spread as they fall, streaks @@ -7,6 +8,9 @@ import { CylinderGeometry, Group, Mesh, PlaneGeometry, SphereGeometry } from "th import { flat } from "./materials.js"; import { C } from "./palette.js"; +/** The decoration is placed once at load; a fixed seed makes every run place it the same. */ +const random = createRandom(40466); + export function waterfall( width: number, height: number, @@ -35,40 +39,33 @@ export function waterfall( // Vertical streaks give the sheet motion without a texture. for (let s = 0; s < 4; s += 1) { const streak = new Mesh( - new PlaneGeometry( - width * (0.05 + Math.random() * 0.05), - height * (0.12 + Math.random() * 0.2), - ), + new PlaneGeometry(width * (0.05 + random() * 0.05), height * (0.12 + random() * 0.2)), flat(0xffffff, { opacity: 0.5 }), ); - streak.position.set( - (Math.random() - 0.5) * width * 0.7, - -Math.random() * height, - width * 0.5, - ); - streak.userData.speed = 8 + Math.random() * 10; + streak.position.set((random() - 0.5) * width * 0.7, -random() * height, width * 0.5); + streak.userData.speed = 8 + random() * 10; streaks.push(streak); group.add(streak); } } for (let i = 0; i < 3; i += 1) { const lip = new Mesh( - new SphereGeometry(width * (0.2 + Math.random() * 0.14), 8, 6), + new SphereGeometry(width * (0.2 + random() * 0.14), 8, 6), flat(0xffffff, { opacity: 0.9 }), ); - lip.position.set((Math.random() - 0.5) * width * 0.9, -0.2, width * 0.3); + lip.position.set((random() - 0.5) * width * 0.9, -0.2, width * 0.3); lip.scale.y = 0.55; group.add(lip); } for (let i = 0; i < 4; i += 1) { const puff = new Mesh( - new SphereGeometry(width * (0.18 + Math.random() * 0.16), 8, 6), + new SphereGeometry(width * (0.18 + random() * 0.16), 8, 6), flat(0xffffff, { opacity: 0.75 }), ); puff.position.set( - (Math.random() - 0.5) * width * 1.4, - -height + Math.random() * 0.8, - (Math.random() - 0.5) * 1.2, + (random() - 0.5) * width * 1.4, + -height + random() * 0.8, + (random() - 0.5) * 1.2, ); puff.scale.y = 0.7; group.add(puff); 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..e004db490b 100644 --- a/packages/create-threenative/templates/racing/src/render/clouds.ts +++ b/packages/create-threenative/templates/racing/src/render/clouds.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -6,6 +7,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,18 +113,10 @@ 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; -function rng(seed: number): () => number { - let state = seed | 0; - return () => { - state = (Math.imul(state, 1664525) + 1013904223) | 0; - return (state >>> 0) / 4294967296; - }; -} - /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -298,7 +293,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = rng(seed); + const next = createRandom(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { 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..4173f87c8e 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, @@ -24,6 +25,7 @@ export const SEED = 13291; function mulberry32(seed: number): () => number { let a = seed | 0; return () => { + // eslint-disable-next-line no-restricted-syntax -- The storm's identity is this mulberry32 sequence; the engine's seeded RNG is an LCG, so swapping it renders a different storm. a += 0x6d2b79f5; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); @@ -76,7 +78,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..43d1c50ace 100644 --- a/packages/create-threenative/templates/rts/src/sim/terrain.ts +++ b/packages/create-threenative/templates/rts/src/sim/terrain.ts @@ -5,6 +5,8 @@ * asks where something is asks it in these units. */ +import { MathUtils } from "three"; + export const MAP_SIZE = 224; export const MAP_CELL = 2; export const WORLD = MAP_SIZE; @@ -102,7 +104,7 @@ export const ROCK_FIELDS: readonly (readonly [number, number, number])[] = [ const smooth01 = (t: number): number => { const c = Math.max(0, Math.min(1, t)); - return c * c * (3 - 2 * c); + return MathUtils.smoothstep(c, 0, 1); }; const noiseHash = (x: number, z: number): number => { 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..e004db490b 100644 --- a/packages/create-threenative/templates/runner/src/render/clouds.ts +++ b/packages/create-threenative/templates/runner/src/render/clouds.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -6,6 +7,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,18 +113,10 @@ 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; -function rng(seed: number): () => number { - let state = seed | 0; - return () => { - state = (Math.imul(state, 1664525) + 1013904223) | 0; - return (state >>> 0) / 4294967296; - }; -} - /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -298,7 +293,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = rng(seed); + const next = createRandom(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { 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/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..e004db490b 100644 --- a/packages/create-threenative/templates/shooter/src/render/clouds.ts +++ b/packages/create-threenative/templates/shooter/src/render/clouds.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -6,6 +7,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,18 +113,10 @@ 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; -function rng(seed: number): () => number { - let state = seed | 0; - return () => { - state = (Math.imul(state, 1664525) + 1013904223) | 0; - return (state >>> 0) / 4294967296; - }; -} - /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -298,7 +293,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = rng(seed); + const next = createRandom(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { diff --git a/packages/create-threenative/templates/shooter/src/render/shapes.ts b/packages/create-threenative/templates/shooter/src/render/shapes.ts index 91ea1b28eb..e8f357b505 100644 --- a/packages/create-threenative/templates/shooter/src/render/shapes.ts +++ b/packages/create-threenative/templates/shooter/src/render/shapes.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // ThreeNative does not read this file. // @@ -170,9 +171,5 @@ export function sculpture(material: Material): Mesh { * from a reroll. */ export function makeRandom(seed = 1337): () => number { - let state = seed >>> 0; - return () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 4294967296; - }; + return createRandom(seed); } 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..2a3cc25e2c 100644 --- a/packages/create-threenative/templates/snow/src/render/scenery.ts +++ b/packages/create-threenative/templates/snow/src/render/scenery.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. The forest, rocks, grass stems and the far ridge are this game's look. // Everything here is ordinary Three.js built from code; nothing is loaded. import { @@ -9,6 +10,7 @@ import { Group, InstancedMesh, LineSegments, + MathUtils, Mesh, Object3D, PlaneGeometry, @@ -27,16 +29,11 @@ export interface IObstacle { } function seededRandom(seed: number): () => number { - let state = seed; - return () => { - state = (state * 1664525 + 1013904223) >>> 0; - return state / 4294967296; - }; + return createRandom(seed); } - 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..e004db490b 100644 --- a/packages/create-threenative/templates/starter/src/render/clouds.ts +++ b/packages/create-threenative/templates/starter/src/render/clouds.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -6,6 +7,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,18 +113,10 @@ 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; -function rng(seed: number): () => number { - let state = seed | 0; - return () => { - state = (Math.imul(state, 1664525) + 1013904223) | 0; - return (state >>> 0) / 4294967296; - }; -} - /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -298,7 +293,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = rng(seed); + const next = createRandom(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { 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/tower-defense/src/render/shapes.ts b/packages/create-threenative/templates/tower-defense/src/render/shapes.ts index 01a1766c7f..9b38c4f18a 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/shapes.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/shapes.ts @@ -1,3 +1,4 @@ +import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // ThreeNative does not read this file. // @@ -397,13 +398,8 @@ export function reactor(): Group { /** Seeded, so the same forest grows on every machine. Same generator Bastion used. */ export function lcg(seed: number): () => number { - let state = seed >>> 0; - return () => { - state = (Math.imul(state, 1664525) + 1013904223) >>> 0; - return state / 4294967296; - }; + return createRandom(seed); } - function scatter( geometry: BufferGeometry, material: Material, From 1b2788d3b798e2c1a1d27a17f4841c49ec9be5d1 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:44:20 -0700 Subject: [PATCH 04/12] chore(api): record debugValue and the four ctx rows in the surface --- scripts/api-surface.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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": { From b56a630c9aa6d3a0b1072e94698b7238f2cce6a1 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:51:44 -0700 Subject: [PATCH 05/12] feat(core): state.set skips unchanged keys A key whose value is Object.is-equal to the current one does not enter pending, so a frame that changed nothing publishes nothing and flush stays quiet. Four kits carried the comparison by hand (starter's 11 fields, action-rpg's 12, tower-defense's sameState, runner's five) and the store now owns it; runner floors distance where the HUD always did, so a fractional odometer cannot republish every frame. state.spec.ts: 3 new cases, red on the previous store (2 failed). --- packages/core/__tests__/state.spec.ts | 48 +++++++++++++++++++ packages/core/src/state.ts | 19 ++++++-- .../__tests__/scaffold.spec.ts | 8 ++-- .../templates/action-rpg/src/scenes/Play.ts | 16 +------ .../templates/runner/src/scenes/Run.ts | 13 ++--- .../templates/starter/src/scenes/Play.ts | 15 +----- .../tower-defense/src/scenes/Battle.ts | 11 ++--- 7 files changed, 77 insertions(+), 53 deletions(-) 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/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__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index f2e4dd9717..253fcef001 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -117,7 +117,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin "action-rpg": { shared: "3dfee3757561db8b2824b23d6131e9b7308bf13998e7a4bf7b6b6e3adf6a7f53", - kit: "cbb3b3b48378826ebb54f260103befba67de3a9ad7008bd344a99008e7814e46", + kit: "e5be1db849ffcfa5cea0ff160aed7d7e2caa14692e9d9595dccfd3dafa22cadc", }, adventure: { shared: "c05c558a93c9297b490fd7d40b9340fb2fa639cbf631026df3ef86c2d14fb81b", @@ -157,7 +157,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, runner: { shared: "e522646cf7dc0567c1496c1abda755fb13d9bdba62d98e18cc3e1c68264db3af", - kit: "4130106e37912ee13d8a44d19400b501e785cde9c49bb609f2f8b92fc55f198d", + kit: "fdf636a0ad108d7dcdc23e258a0562f42e30b1b4ef6d036c8722796ba486d1d8", }, sailing: { shared: "e230d0ea5ae2e458c32f0ed0aa31e5c7a0bce6771adf41293623489611acd41c", @@ -173,11 +173,11 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, starter: { shared: "45287923ee472856d7fee5781bf527f953789cb893b951151eaa3d64c1ac539b", - kit: "ad561025f388f81e11f88cd3d3d19e3f5ecf769e330d627ed342f571ac928aff", + kit: "22aae8c373d34c19243b64e954a6fac2e16246ae0d0bae0b141af3093f3d535e", }, "tower-defense": { shared: "9d9bbb42f8a7f0845bf827030f6978aa4feae5ac455e3739ee612e1d18c5bf9b", - kit: "27593ab909936d5f2054b34b06fe1d381cc668a59da5d15bf219dabf5bfdae3c", + kit: "97c10ae58e66be10a1a0a2d582a56ec72ce052c5bbf261a156e97f31b3b45c64", }, // scaffold-pin:end }; 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/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/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/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( From b29aca3af01306f47f1904eabb98354c5e2376e9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 20:04:55 -0700 Subject: [PATCH 06/12] fix(quality): the render layer keeps its own RNG, and the lint gate runs on every kit Review findings from the fresh Opus pass: - A kit's src/render/ may not import a core symbol outside RENDER_PORTABLE_CORE_SYMBOLS, so the six clouds copies, collectathon's paint tables, platformer's decoration, snow's scenery and the shooter and tower-defense shape streams go back to their own generators, and the PRNG row excludes src/render/** (it still covers a level, an entity, a scene). check-core-boundary passes again; it failed on 13 render files. - LockOn updates the target's whole world-matrix chain once per frame; a leaf-only update left the reticle one frame behind. - The damp row requires the literal 1 on the left, so a fog factor cannot match; the matrix row's message names markStatic for a single static node. - The kit-wide gate is now a spec: all 16 templates/*/src trees linted with the hot rules and the six rows at error, failing closed under 300 files. It found two suppressions that had gone unused and would have failed a generated game's pnpm lint. - PRD claims corrected: check-template-quality does not run ESLint, the render PRNG revert, the playtest run's environmental failures, and the 4 measured kit changed-only checks (not 5). state.spec.ts 9 passed, quality-rules 18 passed (incl. the kit gate), scaffold 79 passed, capabilities fresh, ci:fast pass. --- .../PRD-872-the-lint-names-the-engine-call.md | 10 +- ...engine-owns-the-pool-and-the-bodys-rest.md | 8 +- .../__tests__/quality-rules.spec.ts | 94 ++++++++++++++++--- .../__tests__/scaffold.spec.ts | 26 ++--- .../src/quality/reinventions.ts | 17 +++- .../templates/action-rpg/src/render/clouds.ts | 11 ++- .../adventure/src/entities/LockOn.ts | 4 +- .../collectathon/src/render/materials.ts | 7 +- .../templates/collectathon/src/render/sky.ts | 8 +- .../templates/kart/src/render/course.ts | 1 - .../templates/minimal/src/render/clouds.ts | 11 ++- .../platformer/src/render/pickups.ts | 10 +- .../platformer/src/render/waterfall.ts | 29 +++--- .../templates/racing/src/render/clouds.ts | 11 ++- .../templates/rain/src/render/noise-volume.ts | 1 - .../templates/runner/src/render/clouds.ts | 11 ++- .../templates/shooter/src/render/clouds.ts | 11 ++- .../templates/shooter/src/render/shapes.ts | 7 +- .../templates/snow/src/render/scenery.ts | 7 +- .../templates/starter/src/render/clouds.ts | 11 ++- .../tower-defense/src/render/shapes.ts | 7 +- 21 files changed, 219 insertions(+), 83 deletions(-) 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 index 45565e280e..fea663e40c 100644 --- 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 @@ -44,6 +44,12 @@ that. This PRD takes the cheaper route that already runs in each game: `pnpm lin | `(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` @@ -92,7 +98,7 @@ Not in scope: the detector of PRD-355, capability search ranking (PRD-865), auto **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 tsx scripts/check-template-quality.ts` — exit 0; the three hot rules at `error` over all 16 `templates/*/src` trees report 0 findings. 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. +- [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 `[]` (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 @@ -102,7 +108,7 @@ Not in scope: the detector of PRD-355, capability search ranking (PRD-865), auto **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 tsx scripts/check-template-quality.ts && pnpm test:templates` — kits pass the rows (scan at `error`: 0 findings; 109 sites replaced: 38 smoothstep, 23 damp, 34 PRNG, 3 launch seams; 4 authored mulberry32 sequences — adventure, kart, rts, rain-noise — keep a described `eslint-disable-next-line ... -- ` because replacing them would change the authored level, replay or storm). `pnpm test:templates` result recorded when the run finishes: pending. +- [ ] 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). 109 sites replaced outside render: the launch seams, damp factors and smoothsteps stay. `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. No green playtest is claimed. #### Phase 3: The missing `ctx` members are searchable, and the sandbox games adopt the rules 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 index d8abba21c0..8fb2fcd175 100644 --- 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 @@ -1,6 +1,6 @@ # PRD-873 — The engine owns the pool and the body's rest -**Status:** NOT STARTED +**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 @@ -99,14 +99,14 @@ Proposed, not run. #### Phase 2: A body rests, a warm-up restores, an unchanged state stays quiet -**Status:** NOT STARTED -**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 5 kit scenes with a changed-only check. +**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 5 kits pass with their hand checks deleted. proof: `pnpm exec vitest run packages/core/__tests__/state.spec.ts && TN_TEMPLATE_ONLY=action-rpg,runner,tower-defense,racing,rts pnpm test:templates` +- [ ] `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=action-rpg,runner,tower-defense,racing,rts 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 diff --git a/packages/create-threenative/__tests__/quality-rules.spec.ts b/packages/create-threenative/__tests__/quality-rules.spec.ts index d3000b561c..eff74a0601 100644 --- a/packages/create-threenative/__tests__/quality-rules.spec.ts +++ b/packages/create-threenative/__tests__/quality-rules.spec.ts @@ -1,4 +1,7 @@ -import { readFileSync } from "node:fs"; +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"; @@ -205,32 +208,52 @@ describe("threenative/hot-callback-allocation after the shared tracker", () => { describe("the reinvention rows", () => { /** One hand-written form and the call that replaces it, per row. */ - const CASES: Record = { - createRandom: { - bad: "let s = 7;\ns = (Math.imul(s, 1664525) + 1013904223) | 0;\nconst r = Math.random();\n", + 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", }, - "MathUtils.damp": { + { + 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", }, - debugFlag: { + { + capability: "debugFlag", bad: 'const asked = new URLSearchParams(location.search).get("quality");\n', good: 'const asked = debugValue("quality");\n', }, - "renderer.matrixWorld": { + { + capability: "renderer.matrixWorld", bad: "scene.matrixWorldAutoUpdate = false;\n", good: "const config = { renderer: { matrixWorld: true } };\n", }, - "InputMap.axis": { + { + 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', }, - "MathUtils.smoothstep": { + { + 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( @@ -260,9 +283,9 @@ describe("the reinvention rows", () => { }; it("fails each hand-written form and passes the call it names", () => { - for (const [capability, test] of Object.entries(CASES)) { - expect(reinvented([row(capability)], test.bad), capability).not.toEqual([]); - expect(reinvented([row(capability)], test.good), capability).toEqual([]); + 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([]); } }); @@ -286,6 +309,51 @@ describe("the reinvention rows", () => { 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", () => { diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 253fcef001..2c6bec6e31 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -117,27 +117,27 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin "action-rpg": { shared: "3dfee3757561db8b2824b23d6131e9b7308bf13998e7a4bf7b6b6e3adf6a7f53", - kit: "e5be1db849ffcfa5cea0ff160aed7d7e2caa14692e9d9595dccfd3dafa22cadc", + kit: "3abc2cea04a93caa14ff1ad36a7f55c310f310f0c56462ed4e25c4ee1a1fd48c", }, adventure: { shared: "c05c558a93c9297b490fd7d40b9340fb2fa639cbf631026df3ef86c2d14fb81b", - kit: "8dc3b31209842025cc6c63db9a8ad6061c6d20b75230202b13383a22cef705eb", + kit: "e6fe3310d38b54e0c12b96a2ecaec2971d721f26a387d4fb5d08d4594070ac5e", }, collectathon: { shared: "b2f3bc054c911405e898063214a2e18c5916b08e7bce35cabfbaa6ef6f7777a7", - kit: "a4cd91dc17a14c6d30de8f14e7fa1541fd76bbfcade537fc5b3c8c7f3b06bd54", + kit: "69fcac12372cecbac729d2fa9b5b30aa00c95497209299444cea4da3cec15859", }, kart: { shared: "8a4de1bce71339bd7249b9a41e921f8e111106149ae5680f461d202d265508a8", - kit: "602200828199d9f56b573f8fdcd2f669e8d5f4026739c7f042b8658a26c46cfd", + kit: "35f8fba0f18121466cb5750a456ffd9fe94692391ecd25219a8f4816c28c661a", }, minimal: { shared: "899d2d05ab5d879a320bab1e7efd1c8735126154792c01df7fe86f0582d412c6", - kit: "2feedd53f2a6a046dabb13341ede82e1f29ab47b82fc2ea26752481e51471f0c", + kit: "cc82ebcaec522ba5786ee160857c8664048adb49208e5d89ea197bceebc2ef41", }, platformer: { shared: "5d295a3d2bee8ed41207d3630d07f1f325d80ce763be0c119378249b9c87ed0c", - kit: "77156efd7310f883f41b939cedfee51c50c583edb36a8fa52af99742a7a1f0b3", + kit: "22af8669a0f616db1069c16ad0a94d04b4564c2c22154ae11b98943bca3cf084", }, puzzle: { shared: "9900dba834fb339115c9aa9e7afd0581d9935f3004b228dd654f991b11493847", @@ -145,11 +145,11 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, racing: { shared: "c8b7db38ef9b68fbdf55dead10560df29569f5c183cd0f8f4c1dbf6469071f64", - kit: "f28dd5a95c8a79035ce9b522ce12ef6cc7d4cd3a812bd17e2fcf16ed16c924e9", + kit: "d2552be8ba971387ea086ea8ed39beca7ca7492a7bc175ba9b0db9eb533d39c7", }, rain: { shared: "0856ecca144a6d2419c0bf4d1b26da6448d358f9fa8ebaa9b6604780442054b3", - kit: "eac34cbbd7549ccbca4fdd65d0578a9b00dc043b00e214e6df30c487502f178e", + kit: "62155c8e3aaad74cc49bc0c2063a61a7bf1bc78ad28d40fee4708e1f1dfefd5a", }, rts: { shared: "94c096399f971506d873735610fc2759b108d672e200472279a8d0d4fcd92e09", @@ -157,7 +157,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, runner: { shared: "e522646cf7dc0567c1496c1abda755fb13d9bdba62d98e18cc3e1c68264db3af", - kit: "fdf636a0ad108d7dcdc23e258a0562f42e30b1b4ef6d036c8722796ba486d1d8", + kit: "2c3f0a910f0ad2b4895795926c4a1fb5cb4336eb615ecc3f6347fa82170f5da9", }, sailing: { shared: "e230d0ea5ae2e458c32f0ed0aa31e5c7a0bce6771adf41293623489611acd41c", @@ -165,19 +165,19 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, shooter: { shared: "ddcf11d581b12631f2f49319a022abddf66d59f9ce83265bb2935acb8414953e", - kit: "18c26c3b75c5b2455deb75bebdd66a0d611957baef60d68432d53c1357147ea8", + kit: "8b8fadb8b10756d56af91dd08c1e6c16a9fd9409dc24b2f989c60cec22c67d4f", }, snow: { shared: "1b360f0661b462c2872dff12b403cf072690e19434094966a84db41ef34718bb", - kit: "056539766dda3074f121a69bf9bc3546dd992029b4f69a8720c1e53dcf7cbf7b", + kit: "7856c03a93624b00854cdcae6a08d8921e7ff6b4735a6beafd94b750dd89fa70", }, starter: { shared: "45287923ee472856d7fee5781bf527f953789cb893b951151eaa3d64c1ac539b", - kit: "22aae8c373d34c19243b64e954a6fac2e16246ae0d0bae0b141af3093f3d535e", + kit: "36f72e33d1d2274f07f8fea8cc88545fdd1bf2b7ba902f16dddec492fd072071", }, "tower-defense": { shared: "9d9bbb42f8a7f0845bf827030f6978aa4feae5ac455e3739ee612e1d18c5bf9b", - kit: "97c10ae58e66be10a1a0a2d582a56ec72ce052c5bbf261a156e97f31b3b45c64", + kit: "a99e95e97957bf1e940821629f926b7ddb180d438d6b5795f701885d313fc5df", }, // scaffold-pin:end }; diff --git a/packages/create-threenative/src/quality/reinventions.ts b/packages/create-threenative/src/quality/reinventions.ts index 111ac47e6d..146644a397 100644 --- a/packages/create-threenative/src/quality/reinventions.ts +++ b/packages/create-threenative/src/quality/reinventions.ts @@ -24,9 +24,16 @@ export interface IReinvention { readonly outside?: readonly string[]; } -/** The engine's launch seam is the URL query on web and an environment variable on native; audio - * synthesis and UI animation may use the platform RNG without a seed to preserve. */ -export const PRNG_OUTSIDE = ["src/audio.ts", "src/audio/**", "src/ui/**"] as const; +/** + * 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[] = [ { @@ -40,7 +47,7 @@ export const REINVENTIONS: readonly IReinvention[] = [ }, { selector: - 'BinaryExpression[operator="-"][right.callee.object.name="Math"][right.callee.property.name="exp"]', + 'BinaryExpression[operator="-"][left.value=1][right.callee.object.name="Math"][right.callee.property.name="exp"]', capability: "MathUtils.damp", source: "three", message: @@ -59,7 +66,7 @@ export const REINVENTIONS: readonly IReinvention[] = [ capability: "renderer.matrixWorld", source: "core", message: - "Turning `matrixWorldAutoUpdate` off by hand is the engine's batched pass: set `renderer.matrixWorld` in the game config and let `MatrixWorldPass` walk the scene.", + "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: 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 e004db490b..1693ce841a 100644 --- a/packages/create-threenative/templates/action-rpg/src/render/clouds.ts +++ b/packages/create-threenative/templates/action-rpg/src/render/clouds.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -117,6 +116,14 @@ const smooth = (v: number): number => { }; const mod = (a: number, n: number): number => ((a % n) + n) % n; +function rng(seed: number): () => number { + let state = seed | 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) | 0; + return (state >>> 0) / 4294967296; + }; +} + /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -293,7 +300,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = createRandom(seed); + const next = rng(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { diff --git a/packages/create-threenative/templates/adventure/src/entities/LockOn.ts b/packages/create-threenative/templates/adventure/src/entities/LockOn.ts index 748e6ec639..84f0095351 100644 --- a/packages/create-threenative/templates/adventure/src/entities/LockOn.ts +++ b/packages/create-threenative/templates/adventure/src/entities/LockOn.ts @@ -108,9 +108,11 @@ export class LockOn { // The target's live bounds, not a fixed offset: a rearing Warden or a hopping Mossling keeps // 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. + // The root and every group between it and the meshes move this frame, so the chain is + // updated once here; a leaf-only update leaves the reticle a frame behind. + target.mesh.updateWorldMatrix(true, true); const box = this.bounds.makeEmpty(); for (const mesh of this.#meshesOf(target)) { - mesh.updateWorldMatrix(false, false); if (mesh.geometry.boundingBox === null) mesh.geometry.computeBoundingBox(); const part = mesh.geometry.boundingBox; if (part === null) continue; diff --git a/packages/create-threenative/templates/collectathon/src/render/materials.ts b/packages/create-threenative/templates/collectathon/src/render/materials.ts index 11f381dc88..4b5f864e72 100644 --- a/packages/create-threenative/templates/collectathon/src/render/materials.ts +++ b/packages/create-threenative/templates/collectathon/src/render/materials.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // ThreeNative does not read this file. // @@ -181,7 +180,11 @@ export function paintedTexture( /** A seeded 0..1 random source (an LCG), for texture painting that must be the same every build. */ export function seeded(seed: number): () => number { - return createRandom(seed); + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x1_0000_0000; + }; } /** The surfaces the island is made of, resolved once. */ export function createMaterials() { diff --git a/packages/create-threenative/templates/collectathon/src/render/sky.ts b/packages/create-threenative/templates/collectathon/src/render/sky.ts index add6130747..1b32526a0f 100644 --- a/packages/create-threenative/templates/collectathon/src/render/sky.ts +++ b/packages/create-threenative/templates/collectathon/src/render/sky.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // // A blue gradient with the sun's warm halo and a deck of big cumulus, painted per texel from seeded @@ -30,8 +29,11 @@ export const SUN_DIRECTION = new Vector3(0.9, 0.38, 0.16).normalize(); /** A 256 x 256 lattice of seeded values: the noise reads a table, far cheaper than a hash per call. */ const LATTICE = (() => { - const random = createRandom(9137); - return Float32Array.from({ length: 256 * 256 }, () => random()); + let state = 9137; + return Float32Array.from({ length: 256 * 256 }, () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x1_0000_0000; + }); })(); /** Smooth 2D value noise on the lattice, for the cloud deck: deterministic, repeats every 256 units. */ diff --git a/packages/create-threenative/templates/kart/src/render/course.ts b/packages/create-threenative/templates/kart/src/render/course.ts index 3d40884d23..86772ffd18 100644 --- a/packages/create-threenative/templates/kart/src/render/course.ts +++ b/packages/create-threenative/templates/kart/src/render/course.ts @@ -30,7 +30,6 @@ import { C } from "./palette.js"; export function makeRng(seed: number): () => number { let s = seed >>> 0; return () => { - // eslint-disable-next-line no-restricted-syntax -- The tree and grass placement is authored against this mulberry32 stream; the engine's seeded RNG is an LCG, so swapping it places a different course. s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); diff --git a/packages/create-threenative/templates/minimal/src/render/clouds.ts b/packages/create-threenative/templates/minimal/src/render/clouds.ts index e004db490b..1693ce841a 100644 --- a/packages/create-threenative/templates/minimal/src/render/clouds.ts +++ b/packages/create-threenative/templates/minimal/src/render/clouds.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -117,6 +116,14 @@ const smooth = (v: number): number => { }; const mod = (a: number, n: number): number => ((a % n) + n) % n; +function rng(seed: number): () => number { + let state = seed | 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) | 0; + return (state >>> 0) / 4294967296; + }; +} + /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -293,7 +300,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = createRandom(seed); + const next = rng(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { diff --git a/packages/create-threenative/templates/platformer/src/render/pickups.ts b/packages/create-threenative/templates/platformer/src/render/pickups.ts index 67bde1a6f2..3e77fe5289 100644 --- a/packages/create-threenative/templates/platformer/src/render/pickups.ts +++ b/packages/create-threenative/templates/platformer/src/render/pickups.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // // What the fox collects: a spinning coin, a faceted gem, and the big gold star that ends the run. @@ -20,9 +19,6 @@ import { import { flat, toon } from "./materials.js"; import { C } from "./palette.js"; -/** The decoration is placed once at load; a fixed seed makes every run place it the same. */ -const random = createRandom(40465); - const TAU = Math.PI * 2; /** Spinning coin with an embossed star on both faces. */ @@ -100,9 +96,9 @@ export function burst( const material = flat(color); const bits: Mesh[] = []; for (let i = 0; i < count; i += 1) { - const bit = new Mesh(new SphereGeometry(0.09 + random() * 0.07, 5, 4), material); - const a = random() * TAU; - bit.userData.v = new Vector3(Math.cos(a) * 3.2, (0.4 + random()) * 4.5, Math.sin(a) * 3.2); + const bit = new Mesh(new SphereGeometry(0.09 + Math.random() * 0.07, 5, 4), material); + const a = Math.random() * TAU; + bit.userData.v = new Vector3(Math.cos(a) * 3.2, (0.4 + Math.random()) * 4.5, Math.sin(a) * 3.2); group.add(bit); bits.push(bit); } diff --git a/packages/create-threenative/templates/platformer/src/render/waterfall.ts b/packages/create-threenative/templates/platformer/src/render/waterfall.ts index 1c33857641..108e62395b 100644 --- a/packages/create-threenative/templates/platformer/src/render/waterfall.ts +++ b/packages/create-threenative/templates/platformer/src/render/waterfall.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // // One waterfall: two translucent sheets that narrow at the lip and spread as they fall, streaks @@ -8,9 +7,6 @@ import { CylinderGeometry, Group, Mesh, PlaneGeometry, SphereGeometry } from "th import { flat } from "./materials.js"; import { C } from "./palette.js"; -/** The decoration is placed once at load; a fixed seed makes every run place it the same. */ -const random = createRandom(40466); - export function waterfall( width: number, height: number, @@ -39,33 +35,40 @@ export function waterfall( // Vertical streaks give the sheet motion without a texture. for (let s = 0; s < 4; s += 1) { const streak = new Mesh( - new PlaneGeometry(width * (0.05 + random() * 0.05), height * (0.12 + random() * 0.2)), + new PlaneGeometry( + width * (0.05 + Math.random() * 0.05), + height * (0.12 + Math.random() * 0.2), + ), flat(0xffffff, { opacity: 0.5 }), ); - streak.position.set((random() - 0.5) * width * 0.7, -random() * height, width * 0.5); - streak.userData.speed = 8 + random() * 10; + streak.position.set( + (Math.random() - 0.5) * width * 0.7, + -Math.random() * height, + width * 0.5, + ); + streak.userData.speed = 8 + Math.random() * 10; streaks.push(streak); group.add(streak); } } for (let i = 0; i < 3; i += 1) { const lip = new Mesh( - new SphereGeometry(width * (0.2 + random() * 0.14), 8, 6), + new SphereGeometry(width * (0.2 + Math.random() * 0.14), 8, 6), flat(0xffffff, { opacity: 0.9 }), ); - lip.position.set((random() - 0.5) * width * 0.9, -0.2, width * 0.3); + lip.position.set((Math.random() - 0.5) * width * 0.9, -0.2, width * 0.3); lip.scale.y = 0.55; group.add(lip); } for (let i = 0; i < 4; i += 1) { const puff = new Mesh( - new SphereGeometry(width * (0.18 + random() * 0.16), 8, 6), + new SphereGeometry(width * (0.18 + Math.random() * 0.16), 8, 6), flat(0xffffff, { opacity: 0.75 }), ); puff.position.set( - (random() - 0.5) * width * 1.4, - -height + random() * 0.8, - (random() - 0.5) * 1.2, + (Math.random() - 0.5) * width * 1.4, + -height + Math.random() * 0.8, + (Math.random() - 0.5) * 1.2, ); puff.scale.y = 0.7; group.add(puff); diff --git a/packages/create-threenative/templates/racing/src/render/clouds.ts b/packages/create-threenative/templates/racing/src/render/clouds.ts index e004db490b..1693ce841a 100644 --- a/packages/create-threenative/templates/racing/src/render/clouds.ts +++ b/packages/create-threenative/templates/racing/src/render/clouds.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -117,6 +116,14 @@ const smooth = (v: number): number => { }; const mod = (a: number, n: number): number => ((a % n) + n) % n; +function rng(seed: number): () => number { + let state = seed | 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) | 0; + return (state >>> 0) / 4294967296; + }; +} + /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -293,7 +300,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = createRandom(seed); + const next = rng(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { 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 4173f87c8e..3c35447a82 100644 --- a/packages/create-threenative/templates/rain/src/render/noise-volume.ts +++ b/packages/create-threenative/templates/rain/src/render/noise-volume.ts @@ -25,7 +25,6 @@ export const SEED = 13291; function mulberry32(seed: number): () => number { let a = seed | 0; return () => { - // eslint-disable-next-line no-restricted-syntax -- The storm's identity is this mulberry32 sequence; the engine's seeded RNG is an LCG, so swapping it renders a different storm. a += 0x6d2b79f5; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); diff --git a/packages/create-threenative/templates/runner/src/render/clouds.ts b/packages/create-threenative/templates/runner/src/render/clouds.ts index e004db490b..1693ce841a 100644 --- a/packages/create-threenative/templates/runner/src/render/clouds.ts +++ b/packages/create-threenative/templates/runner/src/render/clouds.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -117,6 +116,14 @@ const smooth = (v: number): number => { }; const mod = (a: number, n: number): number => ((a % n) + n) % n; +function rng(seed: number): () => number { + let state = seed | 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) | 0; + return (state >>> 0) / 4294967296; + }; +} + /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -293,7 +300,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = createRandom(seed); + const next = rng(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { diff --git a/packages/create-threenative/templates/shooter/src/render/clouds.ts b/packages/create-threenative/templates/shooter/src/render/clouds.ts index e004db490b..1693ce841a 100644 --- a/packages/create-threenative/templates/shooter/src/render/clouds.ts +++ b/packages/create-threenative/templates/shooter/src/render/clouds.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -117,6 +116,14 @@ const smooth = (v: number): number => { }; const mod = (a: number, n: number): number => ((a % n) + n) % n; +function rng(seed: number): () => number { + let state = seed | 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) | 0; + return (state >>> 0) / 4294967296; + }; +} + /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -293,7 +300,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = createRandom(seed); + const next = rng(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { diff --git a/packages/create-threenative/templates/shooter/src/render/shapes.ts b/packages/create-threenative/templates/shooter/src/render/shapes.ts index e8f357b505..91ea1b28eb 100644 --- a/packages/create-threenative/templates/shooter/src/render/shapes.ts +++ b/packages/create-threenative/templates/shooter/src/render/shapes.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // ThreeNative does not read this file. // @@ -171,5 +170,9 @@ export function sculpture(material: Material): Mesh { * from a reroll. */ export function makeRandom(seed = 1337): () => number { - return createRandom(seed); + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; } diff --git a/packages/create-threenative/templates/snow/src/render/scenery.ts b/packages/create-threenative/templates/snow/src/render/scenery.ts index 2a3cc25e2c..b14b00b67c 100644 --- a/packages/create-threenative/templates/snow/src/render/scenery.ts +++ b/packages/create-threenative/templates/snow/src/render/scenery.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. The forest, rocks, grass stems and the far ridge are this game's look. // Everything here is ordinary Three.js built from code; nothing is loaded. import { @@ -29,7 +28,11 @@ export interface IObstacle { } function seededRandom(seed: number): () => number { - return createRandom(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))); diff --git a/packages/create-threenative/templates/starter/src/render/clouds.ts b/packages/create-threenative/templates/starter/src/render/clouds.ts index e004db490b..1693ce841a 100644 --- a/packages/create-threenative/templates/starter/src/render/clouds.ts +++ b/packages/create-threenative/templates/starter/src/render/clouds.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. Plain maths, edit or delete it freely: the cloud layer Unreal's default level draws. // Model references (UE 5.8.3): Engine/Content/EngineSky/VolumetricClouds/m_SimpleVolumetricCloud // (the default cloud material; its node graph and parameter defaults are read through the editor), @@ -117,6 +116,14 @@ const smooth = (v: number): number => { }; const mod = (a: number, n: number): number => ((a % n) + n) % n; +function rng(seed: number): () => number { + let state = seed | 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) | 0; + return (state >>> 0) / 4294967296; + }; +} + /** * One axis of a periodic lattice, per voxel: the two cells it blends (wrapped), and the blend weight of * the second (`smooth` of the fraction) with its complement. Computed once per axis position, not once @@ -293,7 +300,7 @@ export function primeCloudTextures(textures: ICloudTextures, seed = 631): void { export function cloudTextures(seed = 631): ICloudTextures { const cached = textureCache.get(seed); if (cached) return cached; - const next = createRandom(seed); + const next = rng(seed); const n = PATTERN_SIZE; const pattern = new Float32Array(n * n * 4); for (let c = 0; c < 3; c++) { 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 9b38c4f18a..e5a93682e3 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/shapes.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/shapes.ts @@ -1,4 +1,3 @@ -import { createRandom } from "@threenative/core"; // Generated for you. This is ordinary Three.js — edit or delete it freely. // ThreeNative does not read this file. // @@ -398,7 +397,11 @@ export function reactor(): Group { /** Seeded, so the same forest grows on every machine. Same generator Bastion used. */ export function lcg(seed: number): () => number { - return createRandom(seed); + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; } function scatter( geometry: BufferGeometry, From 7d057342cd51a815bb3aa0ce83e43f1a4960f16b Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 20:11:05 -0700 Subject: [PATCH 07/12] fix(adventure): the lock reticle is placed after the creatures move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review: the widened world-matrix update was not the cause of the one-frame trail — the frame order was. lock.update runs before the hero reads its target, while the creatures it frames move later in the same frame, so bounds and reticle were always a pose behind. LockOn.update keeps the selection; place(camera, dt) owns the bounds and the reticle and runs after warden, mosslings, foes and dummies have moved. PRD counts corrected to what git grep measures (38 smoothstep, 24 damp calls from 22 expressions, 3 launch seams, 3 LCGs), and the 873 proof names the four kits that changed. --- .../PRD-872-the-lint-names-the-engine-call.md | 6 +++--- ...73-the-engine-owns-the-pool-and-the-bodys-rest.md | 2 +- .../create-threenative/__tests__/scaffold.spec.ts | 2 +- .../templates/adventure/src/entities/LockOn.ts | 12 ++++++++++-- .../templates/adventure/src/scenes/Play.ts | 2 ++ 5 files changed, 17 insertions(+), 7 deletions(-) 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 index fea663e40c..370ce47ef7 100644 --- 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 @@ -98,17 +98,17 @@ Not in scope: the detector of PRD-355, capability search ranking (PRD-865), auto **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 `[]` (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. +- [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. Kit PRNG copies inside `src/render/clouds.ts` change in the canonical source and are restamped. +**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). 109 sites replaced outside render: the launch seams, damp factors and smoothsteps stay. `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. No green playtest is claimed. +- [ ] 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`; 2 suppressions remain. `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. No green playtest is claimed. #### Phase 3: The missing `ctx` members are searchable, and the sandbox games adopt the rules 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 index 8fb2fcd175..b2861c4726 100644 --- 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 @@ -106,7 +106,7 @@ Proposed, not run. - [ ] 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=action-rpg,runner,tower-defense,racing,rts 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. +- [ ] `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 diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 2c6bec6e31..6d64205006 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -121,7 +121,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, adventure: { shared: "c05c558a93c9297b490fd7d40b9340fb2fa639cbf631026df3ef86c2d14fb81b", - kit: "e6fe3310d38b54e0c12b96a2ecaec2971d721f26a387d4fb5d08d4594070ac5e", + kit: "0c54f1d66dc0aa5ab2720f49c27ae824b9f672a89bdaa2a888e6de349e90b8b6", }, collectathon: { shared: "b2f3bc054c911405e898063214a2e18c5916b08e7bce35cabfbaa6ef6f7777a7", diff --git a/packages/create-threenative/templates/adventure/src/entities/LockOn.ts b/packages/create-threenative/templates/adventure/src/entities/LockOn.ts index 84f0095351..9364319be7 100644 --- a/packages/create-threenative/templates/adventure/src/entities/LockOn.ts +++ b/packages/create-threenative/templates/adventure/src/entities/LockOn.ts @@ -100,6 +100,16 @@ 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; @@ -108,8 +118,6 @@ export class LockOn { // The target's live bounds, not a fixed offset: a rearing Warden or a hopping Mossling keeps // 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. - // The root and every group between it and the meshes move this frame, so the chain is - // updated once here; a leaf-only update leaves the reticle a frame behind. target.mesh.updateWorldMatrix(true, true); const box = this.bounds.makeEmpty(); for (const mesh of this.#meshesOf(target)) { diff --git a/packages/create-threenative/templates/adventure/src/scenes/Play.ts b/packages/create-threenative/templates/adventure/src/scenes/Play.ts index 44716a019a..69c6ae8754 100644 --- a/packages/create-threenative/templates/adventure/src/scenes/Play.ts +++ b/packages/create-threenative/templates/adventure/src/scenes/Play.ts @@ -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; From 6d7833dab6c41616f716ab44d35ae5aa954047c2 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 22:49:32 -0700 Subject: [PATCH 08/12] docs(prd): record where the kit playtest run stops on a GPU-less machine The fresh shooter scaffold passes typecheck, the new lint and format; the remaining failures are the software adapter (kit-contract engine-cost, a playtest-worker RangeError) and cold asset cooking. The CI board carries the playtest proof. --- .../absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 370ce47ef7..8b21462eda 100644 --- 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 @@ -108,7 +108,7 @@ Not in scope: the detector of PRD-355, capability search ranking (PRD-865), auto **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`; 2 suppressions remain. `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. No green playtest is claimed. +- [ ] 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`; 2 suppressions remain. `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`). This machine has no GPU; the CI board carries this box's proof. #### Phase 3: The missing `ctx` members are searchable, and the sandbox games adopt the rules From 92b2749fe1c1bbbf4dcf7a8fd92c1fea5ff856ed Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 23:06:05 -0700 Subject: [PATCH 09/12] fix(quality): the sim stays three-free and the rain URL tier reaches the engine seam CI's unit lane found the two places the rows changed a contract: - rts/src/sim/terrain.ts imported MathUtils from three for the smoothstep, and rts-runtime-cost.spec asserts a sim module imports no three. The inline form returns with a described suppression naming that spec. - rain's GameUi reads the engine's debugValue seam now, and the unit test stubbed only window.location; a browser's window.location is the same object as globalThis.location, so the stub shares one object and the URL tier test exercises the real path again. quality-rules 18, rain-quality 10, rts-runtime-cost 8, scaffold 79: pass. --- .../PRD-872-the-lint-names-the-engine-call.md | 2 +- .../__tests__/rain-quality-initialization.spec.tsx | 6 +++++- packages/create-threenative/__tests__/scaffold.spec.ts | 2 +- .../create-threenative/templates/rts/src/sim/terrain.ts | 5 ++--- 4 files changed, 9 insertions(+), 6 deletions(-) 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 index 8b21462eda..7f147ec0fa 100644 --- 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 @@ -108,7 +108,7 @@ Not in scope: the detector of PRD-355, capability search ranking (PRD-865), auto **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`; 2 suppressions remain. `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`). This machine has no GPU; the CI board carries this box's proof. +- [ ] 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`). This machine has no GPU; the CI board carries this box's proof. #### Phase 3: The missing `ctx` members are searchable, and the sandbox games adopt the rules 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 6d64205006..ef83ce9044 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -153,7 +153,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, rts: { shared: "94c096399f971506d873735610fc2759b108d672e200472279a8d0d4fcd92e09", - kit: "b97e2fc08525da6e4411162482ef9bb8d0debb9d95e8d515c59fc46c78e00afe", + kit: "11c32528f07476b2a5ac2a214529e611c22a4317a8c89aa96b9c5648208f84b8", }, runner: { shared: "e522646cf7dc0567c1496c1abda755fb13d9bdba62d98e18cc3e1c68264db3af", diff --git a/packages/create-threenative/templates/rts/src/sim/terrain.ts b/packages/create-threenative/templates/rts/src/sim/terrain.ts index 43d1c50ace..75bebeffa2 100644 --- a/packages/create-threenative/templates/rts/src/sim/terrain.ts +++ b/packages/create-threenative/templates/rts/src/sim/terrain.ts @@ -5,8 +5,6 @@ * asks where something is asks it in these units. */ -import { MathUtils } from "three"; - export const MAP_SIZE = 224; export const MAP_CELL = 2; export const WORLD = MAP_SIZE; @@ -104,7 +102,8 @@ export const ROCK_FIELDS: readonly (readonly [number, number, number])[] = [ const smooth01 = (t: number): number => { const c = Math.max(0, Math.min(1, t)); - return MathUtils.smoothstep(c, 0, 1); + // 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); }; const noiseHash = (x: number, z: number): number => { From 5f0e376946fbcb5cf1a5c47a163e39da0955f78f Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 11 Nov 2026 12:52:46 -0700 Subject: [PATCH 10/12] docs(prd): record the GPU retry and what it separates With the GPU idle the branch passes adventure, collectathon, kart, puzzle and snow outright; the remaining rows either also fail on origin/develop in the same environment (platformer sight line, shooter worker RangeError) or are frame-cost rows under a CPU load of 9-13 from other agents. action-rpg's restart row is green when run alone. No green sweep, so the box stays open. --- .../absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7f147ec0fa..2d871cfbbe 100644 --- 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 @@ -108,7 +108,7 @@ Not in scope: the detector of PRD-355, capability search ranking (PRD-865), auto **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`). This machine has no GPU; the CI board carries this box's proof. +- [ ] 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 From bf593f2f70f2e6a99b0da6110c18195a41246ce6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 11 Nov 2026 19:10:56 -0700 Subject: [PATCH 11/12] chore(scaffold): re-pin the merged starter tree develop's starter kit change and this branch's both land in the tree the pin hashes; 16 kits, 1 moved. quality-rules 18, scaffold 79, rain-quality 10, rts-runtime-cost 8, state 9, engine-mcp search 108, typecheck and ci:fast pass on the merge. --- packages/create-threenative/__tests__/scaffold.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index ef83ce9044..fd74897c7b 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -173,7 +173,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, starter: { shared: "45287923ee472856d7fee5781bf527f953789cb893b951151eaa3d64c1ac539b", - kit: "36f72e33d1d2274f07f8fea8cc88545fdd1bf2b7ba902f16dddec492fd072071", + kit: "35466c0bf26142c37b789a5b434473279b40018b5a8dc58145d5717fa75c6608", }, "tower-defense": { shared: "9d9bbb42f8a7f0845bf827030f6978aa4feae5ac455e3739ee612e1d18c5bf9b", From 7a2c03d170645a34bafa4aa9cf824eb8f89842ca Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Wed, 11 Nov 2026 20:35:58 -0700 Subject: [PATCH 12/12] docs(prd): record the develop merge and its checks --- .../absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md | 2 ++ 1 file changed, 2 insertions(+) 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 index 2d871cfbbe..ec20f75f4e 100644 --- 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 @@ -7,6 +7,8 @@ **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