diff --git a/.github/workflows/integration-perception.yml b/.github/workflows/integration-perception.yml new file mode 100644 index 0000000000..36d15144a0 --- /dev/null +++ b/.github/workflows/integration-perception.yml @@ -0,0 +1,24 @@ +name: Integration perception +on: + pull_request: + paths: + - 'examples/integrations/perception/**' + - '.github/workflows/integration-perception.yml' +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: examples/integrations/perception + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - run: npm install --ignore-scripts --no-audit --no-fund + - run: npm test diff --git a/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md b/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md new file mode 100644 index 0000000000..82ce4d3d2b --- /dev/null +++ b/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md @@ -0,0 +1,68 @@ +# PRD: Optional NPC perception and search example + +**Status:** PARTIAL — actual Yuka vision adapter, editable search policy and executable tests added; playable/platform qualification open. +**Priority:** P2, example only. **PR:** #336. **Base:** develop `663de7c69fca3446303da8a39de7c8871bfe33c6`. + +## Goal and ownership + +An NPC acquires an unobstructed target, remembers its last observed position, searches without reading its hidden live position and eventually abandons the search. Compare the donor against simple editable TypeScript policy; keep navigation, physics, replay and fixed time authoritative in existing systems. No mandatory AI dependency, Yuka EntityManager, alternate navigation world or second movement owner. + +**Ruling, 2026-09-25:** use nested standalone `examples/integrations/perception`, outside core and the frozen benchmark arm. `YukaPerception` calls real Yuka Vision with Three world transforms and a caller-supplied nearest-blocking ray query. `SearchController` owns editable deterministic memory/search policy. Both expose lifecycle cleanup without starting a loop or moving a body. The donor dependency is yuka 0.7.8 with Three 0.185.1. + +## Test contract + +Cover occlusion, authored forward axis, range/FOV, last-seen copies, simulation-time expiry, zero-dt pause, arrival, reacquisition, deleted targets, invalid input without partial state mutation, immutable output and repeatable traces. Integration tests use real Yuka and Three raycasts against a wall. Prove behavior in a playable navigation encounter on each claimed platform before admission; unit tests alone do not establish pathfinding or gameplay quality. + +## Implementation order + +### Phase 1 — baseline and dependency admission +- [ ] Search existing navigation, ray-query, scheduling and replay capabilities. +- [ ] Implement the direct state-machine baseline and pin its expected event trace. +- [ ] Pin and audit only the Yuka modules needed for the comparison. +- [x] Execute ten dependency-free policy tests after observing the stub fail: 10 passed, 0 failed on Node 22.16.0. + +### Phase 2 — optional perception example +- [ ] Add failing occlusion, memory-expiry and single-movement-owner tests. +- [x] Implement the Yuka candidate without adopting its world or navigation architecture. + Three real Yuka/Three integration tests now pass, including occlusion, authored-axis transforms and unchanged movement ownership. +- [ ] Pass replay, pause/resume, target-deletion and cleanup tests. +- [x] Strict-check search.ts with locally available TypeScript 5.8.3: exit 0. + +### Phase 3 — playable platform proof +- [ ] Pass the patrol/search browser WebGPU playtest. +- [ ] Pass the same input/event trace on desktop native, naming the actual OS and adapter. +- [ ] Pass the same input/event trace on Android, naming the executed lane. +- [ ] Compare behavior, code and cost against the direct state-machine baseline. + +### Phase 4 — ship as game source +- [ ] Prove a normal core-only game loads no Yuka code. +- [ ] Document editable policy and the existing physics/navigation integration. +- [ ] Run repository checks and keep the smaller solution if the donor adds no value. +- [ ] Complete a separate code review and synchronize PRD, PR and progress label. + +## Acceptance criteria +- [ ] The example demonstrates occlusion-aware acquire, last-seen search and expiry. +- [ ] Search does not consult an occluded target's live position. +- [ ] Replay uses simulation time and reproduces the expected decision events. +- [ ] Exactly one existing movement/physics path controls the NPC body. +- [ ] Gameplay policy stays in editable source and core does not depend on Yuka. +- [ ] Browser WebGPU evidence is recorded. +- [ ] Desktop-native evidence is recorded. +- [ ] Android evidence is recorded. + +## Verification + +Executed: pure Node policy tests 10/10 and TypeScript 5.8.3 strict checking of search.ts. The complete package exposes npm test: strict TypeScript 5.9.3 build, contracts, real Yuka/Three tests. Dependency downloads were unavailable locally; no dependency-backed test/build or runtime result is reported green. The focused PR workflow runs the full command because root Vitest excludes examples. Formal capability tools, installed license/lockfile audit, Biome, repository suite, independent review and all playable GPU/native lanes remain open. Keep draft. No iOS support claim and no unmeasured improvement over direct policy. + +## CI repair verification — 2026-09-26 + +`npm test` now passes with the pinned dependencies: strict TypeScript 5.9.3 build, 12 policy contracts and 3 real Yuka/Three integration tests (15 passed, 0 failed). Two new regression tests failed on the prior validator and pass after validating all three coordinate slots explicitly. Sparse arrays, null and undefined are rejected before the memory clock or last-seen target can change. + +Applied Biome 1.9.4's captured formatting changes and its single-variable-declaration repair without changing lint policy. Fresh repository CI must validate the pushed tree; the complete repository suite and playable browser/native/Android requirements remain open. This replaces the earlier local dependency limitation, not those platform gates. + +## References + +- [Implementation and commands](../../../examples/integrations/perception/README.md) +- [Yuka](https://github.com/Mugen87/yuka) +- [Original planning revision](https://github.com/ThreeNativeHQ/threenative/blob/ddef035c791d144c62320f0019a08ea7764556b8/docs/PRDs/threejs-integrations/PRD-threejs-yuka-perception.md) +- [Charter](../../architecture/CHARTER.md) diff --git a/examples/integrations/perception/.gitignore b/examples/integrations/perception/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/examples/integrations/perception/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/examples/integrations/perception/README.md b/examples/integrations/perception/README.md new file mode 100644 index 0000000000..cc806e8298 --- /dev/null +++ b/examples/integrations/perception/README.md @@ -0,0 +1,27 @@ +# Optional perception and search gameplay + +Editable game source using real Yuka Vision plus the existing world ray query, not a core AI framework. + +```sh +cd examples/integrations/perception +npm install --ignore-scripts +npm test +``` + +```ts +const senses = new YukaPerception({ + body: enemy, forward: [0,0,-1], fieldOfView: Math.PI / 2, range: 30, + memorySeconds: 5, arrivalDistance: 0.5, + raycast: ({origin, direction, distance}) => nearestBlockingDistance(origin, direction, distance), +}); +const decision = senses.update(dt, {id: 'player', object: player}); +// Give decision.destination to the EXISTING navigation/CharacterBody movement path. +``` + +The ray callback returns the nearest blocking distance or null, never undefined. Exclude observer and target bodies from obstruction filtering. Yuka only answers the view cone; it does not own movement, navigation, physics or a clock. Hidden target positions are never passed into search policy. Memory retains a copied last-seen position and expires on simulation time or arrival. Zero dt pauses expiry. Invalid inputs fail before memory advances. Decisions are immutable. Call forget when a target is deleted and dispose on scene teardown. + +The narrow yuka.d.ts declaration describes the real upstream exports; it is not a runtime mock. Integration tests invoke actual Yuka and actual Three raycasts against a wall. + +Executed locally: 10 search-policy CPU tests passed after the initial failing run, plus strict TypeScript 5.8.3 checking of search.ts. Donor-backed tests/full build are written but unrun locally because downloads are unavailable. Integration perception runs npm test on PR changes. Playable navigation, native/browser proof and the direct-policy value comparison remain open. Generate/review the lockfile and test with the framework's patched runtime before admission. + +Yuka and Three retain MIT notices as separate dependencies. No donor entity manager, game or assets are copied. Ordinary core-only games load none of this code. diff --git a/examples/integrations/perception/package.json b/examples/integrations/perception/package.json new file mode 100644 index 0000000000..bcbbbe203c --- /dev/null +++ b/examples/integrations/perception/package.json @@ -0,0 +1,16 @@ +{ + "name": "threenative-integration-perception", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "engines": { "node": ">=22.16.0" }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test:contracts": "node --experimental-strip-types --test tests/contracts.test.mjs", + "test:integration": "node --test tests/integration.test.mjs", + "test": "npm run build && npm run test:contracts && npm run test:integration" + }, + "dependencies": { "three": "0.185.1", "yuka": "0.7.8" }, + "devDependencies": { "typescript": "5.9.3", "@types/three": "0.185.3", "@types/node": "24.3.0" } +} diff --git a/examples/integrations/perception/src/perception.ts b/examples/integrations/perception/src/perception.ts new file mode 100644 index 0000000000..705d7ca61d --- /dev/null +++ b/examples/integrations/perception/src/perception.ts @@ -0,0 +1,119 @@ +import { type Object3D, Quaternion, Vector3 } from "three"; +import { Vision, Vector3 as YukaVector3 } from "yuka"; +import { + type ISearchDecision, + type ISearchOptions, + type Point3, + SearchController, + validatePoint, +} from "./search.js"; +export interface IVisibilityRay { + readonly origin: Point3; + readonly direction: Point3; + readonly distance: number; +} +export interface IPerceptionOptions extends ISearchOptions { + readonly body: Object3D; + readonly fieldOfView: number; + readonly range: number; + /** Model-local forward direction; choose +Z or -Z explicitly for the authored character. */ + readonly forward: Point3; + /** Nearest blocking distance, or null. Exclude observer and target bodies in this query. */ + readonly raycast: (ray: IVisibilityRay) => number | null; +} +export interface IPerceptionTarget { + readonly id: string; + readonly object: Object3D; +} +/** Yuka answers the view cone; the game's existing world answers occlusion and owns movement. */ +export class YukaPerception { + readonly #body: Object3D; + readonly #brain: SearchController; + readonly #vision: Vision; + readonly #raycast: IPerceptionOptions["raycast"]; + readonly #forward = new Vector3(); + readonly #position = new Vector3(); + readonly #target = new Vector3(); + readonly #direction = new Vector3(); + readonly #rotation = new Quaternion(); + readonly #point = new YukaVector3(); + #disposed = false; + constructor(options: IPerceptionOptions) { + validatePoint(options.forward); + if ( + !Number.isFinite(options.fieldOfView) || + options.fieldOfView <= 0 || + options.fieldOfView > Math.PI * 2 || + !Number.isFinite(options.range) || + options.range <= 0 || + typeof options.raycast !== "function" + ) + throw new Error( + "Perception requires a finite view cone, positive range and world ray query.", + ); + this.#forward.fromArray(options.forward); + if (!this.#forward.lengthSq()) throw new Error("Perception forward direction cannot be zero."); + this.#forward.normalize(); + this.#body = options.body; + this.#raycast = options.raycast; + this.#brain = new SearchController(options); + this.#vision = new Vision({ + getWorldPosition: (out) => out.set(this.#position.x, this.#position.y, this.#position.z), + getWorldDirection: (out) => { + this.#direction.copy(this.#forward).applyQuaternion(this.#rotation); + return out.set(this.#direction.x, this.#direction.y, this.#direction.z); + }, + }); + this.#vision.fieldOfView = options.fieldOfView; + this.#vision.range = options.range; + } + update(dt: number, target: IPerceptionTarget | null): ISearchDecision { + if (this.#disposed) throw new Error("Perception adapter is disposed."); + if (!Number.isFinite(dt) || dt < 0) + throw new Error("Perception dt must be finite and nonnegative."); + this.#body.getWorldPosition(this.#position); + this.#body.getWorldQuaternion(this.#rotation); + const origin = this.#position.toArray() as [number, number, number]; + validatePoint(origin); + if ( + this.#rotation.toArray().some((value) => !Number.isFinite(value)) || + this.#rotation.lengthSq() < 1e-12 + ) + throw new Error("Perception body orientation must be finite and nonzero."); + this.#rotation.normalize(); + let observation = null; + if (target) { + if (!target.id.trim()) throw new Error("Perception target id is required."); + target.object.getWorldPosition(this.#target); + const position = this.#target.toArray() as [number, number, number]; + validatePoint(position); + this.#point.set(...position); + if (this.#vision.visible(this.#point)) { + const distance = this.#direction.subVectors(this.#target, this.#position).length(); + let blocked = false; + if (distance > 0) { + this.#direction.divideScalar(distance); + const hit = this.#raycast({ + origin, + direction: this.#direction.toArray() as [number, number, number], + distance, + }); + if (hit !== null && (!Number.isFinite(hit) || hit < 0)) + throw new Error("Perception ray query returned an invalid distance."); + blocked = hit !== null && hit <= distance; + } + if (!blocked) observation = { id: target.id, position }; + } + } + return this.#brain.step(dt, origin, observation); + } + forget(): void { + this.#brain.forget(); + } + dispose(): void { + if (!this.#disposed) { + this.#disposed = true; + this.#brain.dispose(); + } + } +} diff --git a/examples/integrations/perception/src/search.ts b/examples/integrations/perception/src/search.ts new file mode 100644 index 0000000000..b4cddddbcb --- /dev/null +++ b/examples/integrations/perception/src/search.ts @@ -0,0 +1,86 @@ +export type Point3 = readonly [number, number, number]; +export interface IObservation { + readonly id: string; + readonly position: Point3; +} +export interface ISearchOptions { + readonly memorySeconds: number; + readonly arrivalDistance: number; +} +export interface ISearchDecision { + readonly state: "patrol" | "chase" | "search"; + readonly destination: Point3 | null; + readonly targetId: string | null; + readonly changed: boolean; +} +export function validatePoint(point: Point3): void { + if ( + point?.length !== 3 || + !Number.isFinite(point[0]) || + !Number.isFinite(point[1]) || + !Number.isFinite(point[2]) + ) + throw new Error("Perception positions must be finite vec3 values."); +} +/** Editable gameplay policy. Receives observations, never an occluded target's current position. */ +export class SearchController { + readonly #memorySeconds: number; + readonly #arrivalDistance: number; + #age = 0; + #memory: IObservation | null = null; + #state: ISearchDecision["state"] = "patrol"; + #targetId: string | null = null; + #disposed = false; + constructor(options: ISearchOptions) { + if ( + !Number.isFinite(options.memorySeconds) || + options.memorySeconds <= 0 || + !Number.isFinite(options.arrivalDistance) || + options.arrivalDistance < 0 + ) + throw new Error("Perception memory must be positive and arrival distance nonnegative."); + this.#memorySeconds = options.memorySeconds; + this.#arrivalDistance = options.arrivalDistance; + } + step(dt: number, position: Point3, observation: IObservation | null): ISearchDecision { + if (this.#disposed) throw new Error("Perception controller is disposed."); + if (!Number.isFinite(dt) || dt < 0) + throw new Error("Perception dt must be finite and nonnegative."); + validatePoint(position); + if (observation) { + if (typeof observation.id !== "string" || !observation.id.trim()) + throw new Error("Perception target id is required."); + validatePoint(observation.position); + } + if (observation) { + this.#memory = { + id: observation.id, + position: Object.freeze([...observation.position]) as Point3, + }; + this.#age = 0; + } else if (this.#memory) { + // Saturation avoids losing a small fixed step against a very large absolute wall clock. + this.#age = Math.min(this.#memorySeconds, this.#age + dt); + const destination = this.#memory.position; + if ( + this.#age >= this.#memorySeconds || + Math.hypot(...position.map((v, i) => v - destination[i])) <= this.#arrivalDistance + ) + this.#memory = null; + } + const state = observation ? "chase" : this.#memory ? "search" : "patrol"; + const targetId = this.#memory?.id ?? null; + const changed = state !== this.#state || targetId !== this.#targetId; + this.#state = state; + this.#targetId = targetId; + return Object.freeze({ state, targetId, changed, destination: this.#memory?.position ?? null }); + } + forget(): void { + this.#memory = null; + this.#age = 0; + } + dispose(): void { + this.forget(); + this.#disposed = true; + } +} diff --git a/examples/integrations/perception/src/yuka.d.ts b/examples/integrations/perception/src/yuka.d.ts new file mode 100644 index 0000000000..80812d22d9 --- /dev/null +++ b/examples/integrations/perception/src/yuka.d.ts @@ -0,0 +1,19 @@ +// Minimal structural typing for the two upstream 0.7.8 exports used here. +// No runtime shim: npm supplies the actual Yuka implementation. +declare module "yuka" { + export class Vector3 { + x: number; + y: number; + z: number; + set(x: number, y: number, z: number): this; + } + export class Vision { + constructor(owner: { + getWorldPosition(out: Vector3): unknown; + getWorldDirection(out: Vector3): unknown; + }); + fieldOfView: number; + range: number; + visible(point: Vector3): boolean; + } +} diff --git a/examples/integrations/perception/tests/contracts.test.mjs b/examples/integrations/perception/tests/contracts.test.mjs new file mode 100644 index 0000000000..d71bac41d3 --- /dev/null +++ b/examples/integrations/perception/tests/contracts.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SearchController } from "../src/search.ts"; +const make = () => new SearchController({ memorySeconds: 3, arrivalDistance: 0.5 }); +const at = [0, 0, 0]; +const seen = { id: "player", position: [4, 0, 0] }; +test("starts patrolling and acquires only observed positions", () => { + const brain = make(); + assert.equal(brain.step(0, at, null).state, "patrol"); + const result = brain.step(0.1, at, seen); + assert.equal(result.state, "chase"); + assert.deepEqual(result.destination, [4, 0, 0]); +}); +test("occluded search retains a copied last-seen position", () => { + const brain = make(); + const observation = { id: "player", position: [4, 0, 0] }; + brain.step(0.1, at, observation); + observation.position[0] = 99; + const result = brain.step(1, at, null); + assert.equal(result.state, "search"); + assert.deepEqual(result.destination, [4, 0, 0]); +}); +test("expires memory at the exact simulation-time boundary", () => { + const brain = make(); + brain.step(0, at, seen); + assert.equal(brain.step(2.999, at, null).state, "search"); + const result = brain.step(0.001, at, null); + assert.equal(result.state, "patrol"); + assert.equal(result.destination, null); + assert.equal(result.targetId, null); +}); +test("zero dt pauses memory age", () => { + const brain = make(); + brain.step(0, at, seen); + brain.step(2, at, null); + for (let i = 0; i < 10; i++) assert.equal(brain.step(0, at, null).state, "search"); + assert.equal(brain.step(1, at, null).state, "patrol"); +}); +test("arrival clears the remembered destination without moving the body", () => { + const brain = make(); + brain.step(0, at, seen); + const position = [3.6, 0, 0]; + const result = brain.step(0.1, position, null); + assert.equal(result.state, "patrol"); + assert.equal(result.destination, null); + assert.deepEqual(position, [3.6, 0, 0]); +}); +test("reacquisition refreshes memory and identifies target switches", () => { + const brain = make(); + brain.step(0, at, seen); + brain.step(2, at, null); + const result = brain.step(0.1, at, { id: "second", position: [5, 0, 0] }); + assert.equal(result.targetId, "second"); + assert.equal(result.changed, true); + assert.equal(brain.step(2, at, null).state, "search"); +}); +test("invalid input does not advance simulation time or alter memory", () => { + const brain = make(); + brain.step(0, at, seen); + assert.throws(() => brain.step(2.9, at, { id: "x", position: [Number.NaN, 0, 0] })); + assert.throws(() => brain.step(-1, at, null)); + assert.throws(() => brain.step(Number.POSITIVE_INFINITY, at, null)); + assert.equal(brain.step(0.2, at, null).state, "search"); +}); +test("forget clears deleted targets and output cannot mutate internal memory", () => { + const brain = make(); + const result = brain.step(0, at, seen); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.destination)); + brain.forget(); + assert.equal(brain.step(0, at, null).targetId, null); +}); +test("fixed input produces identical decision traces", () => { + const run = () => { + const brain = make(); + return [brain.step(0.2, at, seen), brain.step(0.2, at, null), brain.step(3, at, null)]; + }; + assert.deepEqual(run(), run()); +}); +test("rejects invalid settings and updates after disposal", () => { + for (const memorySeconds of [-1, 0, Number.NaN]) + assert.throws(() => new SearchController({ memorySeconds, arrivalDistance: 0.5 })); + const brain = make(); + brain.dispose(); + brain.dispose(); + assert.throws(() => brain.step(0, at, null), /disposed/); +}); + +test("sparse observations are rejected before changing last-seen memory", () => { + for (let axis = 0; axis < 3; axis++) { + const brain = make(); + brain.step(0, at, seen); + const position = [9, 9, 9]; + Reflect.deleteProperty(position, axis); + assert.throws(() => brain.step(2.9, at, { id: "invalid", position }), /finite vec3/); + const result = brain.step(0.2, at, null); + assert.equal(result.state, "search"); + assert.equal(result.targetId, "player"); + assert.deepEqual(result.destination, seen.position); + } +}); +test("missing observer coordinates fail before advancing the memory clock", () => { + const missing = Array(3); + for (const position of [missing, null, undefined]) { + const brain = make(); + brain.step(0, at, seen); + assert.throws(() => brain.step(2.9, position, null), /finite vec3/); + const result = brain.step(0.2, at, null); + assert.equal(result.state, "search"); + assert.deepEqual(result.destination, seen.position); + } +}); diff --git a/examples/integrations/perception/tests/integration.test.mjs b/examples/integrations/perception/tests/integration.test.mjs new file mode 100644 index 0000000000..b9b2440e25 --- /dev/null +++ b/examples/integrations/perception/tests/integration.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Raycaster, Vector3 } from "three"; +import { YukaPerception } from "../dist/perception.js"; +function fixture() { + const body = new Object3D(); + const target = new Object3D(); + target.position.z = 8; + const wall = new Mesh(new BoxGeometry(5, 5, 0.5), new MeshBasicMaterial()); + wall.position.z = 4; + const raycaster = new Raycaster(); + let blocked = false; + const brain = new YukaPerception({ + body, + fieldOfView: Math.PI / 2, + range: 10, + forward: [0, 0, 1], + memorySeconds: 2, + arrivalDistance: 0.2, + raycast: ({ origin, direction, distance }) => { + if (!blocked) return null; + wall.updateMatrixWorld(true); + raycaster.set(new Vector3(...origin), new Vector3(...direction)); + raycaster.near = 0; + raycaster.far = distance; + return raycaster.intersectObject(wall, false)[0]?.distance ?? null; + }, + }); + return { + body, + target, + brain, + block: (value) => { + blocked = value; + }, + dispose: () => { + brain.dispose(); + wall.geometry.dispose(); + wall.material.dispose(); + }, + }; +} +test("actual Yuka vision and Three raycasts respect occlusion and last-seen memory", () => { + const f = fixture(); + const target = { id: "player", object: f.target }; + assert.equal(f.brain.update(0, target).state, "chase"); + f.block(true); + f.target.position.z = 9; + assert.deepEqual(f.brain.update(1, target).destination, [0, 0, 8]); + assert.equal(f.brain.update(1, target).state, "patrol"); + f.dispose(); +}); +test("FOV and world rotation use the authored forward axis", () => { + const f = fixture(); + const target = { id: "player", object: f.target }; + f.body.rotation.y = Math.PI; + assert.equal(f.brain.update(0, target).state, "patrol"); + f.target.position.z = -8; + assert.equal(f.brain.update(0, target).state, "chase"); + f.target.position.z = -11; + assert.equal(f.brain.update(0.1, target).state, "search"); + f.dispose(); +}); +test("perception never takes ownership of movement and disposal is enforced", () => { + const f = fixture(); + const original = f.body.position.clone(); + for (let i = 0; i < 30; i++) f.brain.update(1 / 60, { id: "player", object: f.target }); + assert.deepEqual(f.body.position, original); + f.dispose(); + assert.throws(() => f.brain.update(0, null), /disposed/); +}); diff --git a/examples/integrations/perception/tsconfig.json b/examples/integrations/perception/tsconfig.json new file mode 100644 index 0000000000..31e94b3c8b --- /dev/null +++ b/examples/integrations/perception/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "noEmitOnError": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +}