@relu-interactives/spatial-ecs 0.3.19 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{chunk-JYKFLQB5.js → chunk-CDSE7476.js} +38 -3
  2. package/dist/chunk-CDSE7476.js.map +1 -0
  3. package/dist/docs/guide/accessing-components.md +7 -6
  4. package/dist/docs/guide/behaviour-data-binding/component-input.md +10 -6
  5. package/dist/docs/guide/behaviour-data-binding/entity-input.md +13 -5
  6. package/dist/docs/guide/camera-rigs.md +2 -2
  7. package/dist/docs/guide/components/animation.md +12 -5
  8. package/dist/docs/guide/components/audio.md +13 -6
  9. package/dist/docs/guide/components/camera.md +10 -4
  10. package/dist/docs/guide/components/entity-type.md +13 -6
  11. package/dist/docs/guide/components/image-target-anchor.md +6 -2
  12. package/dist/docs/guide/components/image-target.md +10 -5
  13. package/dist/docs/guide/components/image.md +10 -4
  14. package/dist/docs/guide/components/instance-slot.md +7 -3
  15. package/dist/docs/guide/components/instanced-mesh.md +15 -7
  16. package/dist/docs/guide/components/light.md +10 -4
  17. package/dist/docs/guide/components/material.md +14 -8
  18. package/dist/docs/guide/components/mesh-geometry.md +10 -4
  19. package/dist/docs/guide/components/mesh.md +10 -4
  20. package/dist/docs/guide/components/model.md +4 -2
  21. package/dist/docs/guide/components/name.md +11 -6
  22. package/dist/docs/guide/components/object3d-ref.md +7 -3
  23. package/dist/docs/guide/components/parent-id.md +10 -4
  24. package/dist/docs/guide/components/physics.md +12 -6
  25. package/dist/docs/guide/components/script.md +10 -4
  26. package/dist/docs/guide/components/sprite.md +10 -4
  27. package/dist/docs/guide/components/transform.md +6 -2
  28. package/dist/docs/guide/components/video.md +10 -4
  29. package/dist/docs/guide/prefabs.md +2 -0
  30. package/dist/docs/guide/runtime-entity-management.md +1 -1
  31. package/dist/docs/guide/spawning/instanced-mesh.md +11 -6
  32. package/dist/docs/guide/systems/input-system.md +4 -1
  33. package/dist/docs/guide/systems/script-object-system.md +9 -1
  34. package/dist/docs/guide/vr-and-hand-poses.md +105 -1
  35. package/dist/docs/guide/world-api.md +23 -15
  36. package/dist/{index-C_589fc4.d.ts → index-6l4hn2Sh.d.ts} +14 -0
  37. package/dist/{index-3pNbH0iP.d.cts → index-C2I5PxCI.d.cts} +14 -0
  38. package/dist/index.cjs +36 -1
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.cts +2 -2
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.js +2 -2
  43. package/dist/systems/index.cjs +36 -1
  44. package/dist/systems/index.cjs.map +1 -1
  45. package/dist/systems/index.d.cts +1 -1
  46. package/dist/systems/index.d.ts +1 -1
  47. package/dist/systems/index.js +1 -1
  48. package/package.json +4 -2
  49. package/dist/chunk-JYKFLQB5.js.map +0 -1
@@ -125,6 +125,66 @@ Vision Pro drives WebXR entirely through **gaze and pinch**. There are no contro
125
125
  - Hand tracking (and therefore hand poses) is available on visionOS 2 and later, but asks the user for a second permission. The OS draws the user's real hands.
126
126
  - `immersive-ar` is not supported by Safari on visionOS, so VR projects are the only kind that run there.
127
127
 
128
+ ### Scripting VR interactions
129
+
130
+ A few patterns that cover most scenes.
131
+
132
+ **Score when a thrown object passes through something.** Give the ball a dynamic rigidbody and an XR Interactable; the throw itself is handled for you.
133
+
134
+ ```ts
135
+ import { Behaviour, type XrInputState } from "@relu-interactives/spatial-ecs";
136
+
137
+ export default class Ball extends Behaviour {
138
+ onRelease(input: XrInputState) {
139
+ // Fires the moment it leaves the hand — the engine has already applied the
140
+ // velocity, so this is where scoring or audio goes.
141
+ console.log("released by", input.handedness);
142
+ }
143
+ }
144
+ ```
145
+
146
+ **A panel you point at but cannot pick up.** Set `pointable: true` and `grabbable: false`, then act on the pointer.
147
+
148
+ ```ts
149
+ export default class Button extends Behaviour {
150
+ onSelectStart() {
151
+ this.world.emit?.("door:open");
152
+ }
153
+ }
154
+ ```
155
+
156
+ **React to a two-handed hold.** Useful for steadying a weapon, showing a bipod, or gating an action on both hands being committed.
157
+
158
+ ```ts
159
+ export default class Rifle extends Behaviour {
160
+ private braced = false;
161
+
162
+ onSecondaryGrab() {
163
+ this.braced = true;
164
+ }
165
+
166
+ onSecondaryRelease() {
167
+ this.braced = false;
168
+ }
169
+
170
+ onUpdate() {
171
+ if (this.braced) { /* tighter aim, less sway… */ }
172
+ }
173
+ }
174
+ ```
175
+
176
+ **Adapt to the device.** Branch on what the session granted, never on a device name.
177
+
178
+ ```ts
179
+ onXRSessionStart(capabilities: XrCapabilities) {
180
+ if (!capabilities.handTracking) {
181
+ // Controllers only — hide anything that depends on finger poses.
182
+ }
183
+ }
184
+ ```
185
+
186
+ Two rules worth internalising: **move the rig, never the camera** (the headset owns the camera while presenting and overwrites it every frame), and give any destructive gesture a longer **Hold Time** so it cannot fire in passing.
187
+
128
188
  ## Hand poses
129
189
 
130
190
  Add an **XR Hand Pose** component and list the poses to watch for. Each is either a built-in preset or a custom set of finger curls.
@@ -132,9 +192,11 @@ Add an **XR Hand Pose** component and list the poses to watch for. Each is eithe
132
192
  ```ts
133
193
  import {
134
194
  Behaviour,
135
- Object3DRef,
136
195
  type XrHandPoseEventDetail,
137
196
  } from "@relu-interactives/spatial-ecs";
197
+ import {
198
+ Object3DRef,
199
+ } from "@relu-interactives/spatial-ecs/components";
138
200
 
139
201
  export default class Portal extends Behaviour {
140
202
  onHandPoseStart(event: XrHandPoseEventDetail) {
@@ -154,6 +216,48 @@ export default class Portal extends Behaviour {
154
216
 
155
217
  `XrHandPoseEventDetail` is `{ poseId, poseName, handedness, confidence }`. Match on `poseId` — it is the id set in the inspector, not the preset name.
156
218
 
219
+ **Changing another object's colour from a pose.** `MaterialComponent.updateValue` takes an index and a PATCH OBJECT — `updateValue(0, { color })`, not `updateValue(0, { color: value })`.
220
+
221
+ ```ts
222
+ import {
223
+ Behaviour,
224
+ type EntityInput,
225
+ type WorldEntityView,
226
+ type XrHandPoseEventDetail,
227
+ } from "@relu-interactives/spatial-ecs";
228
+ import {
229
+ MaterialComponent,
230
+ } from "@relu-interactives/spatial-ecs/components";
231
+
232
+ export default class PoseColor extends Behaviour {
233
+ data = { target: { type: "entity", value: null } as unknown as EntityInput };
234
+ private original = "#ffffff";
235
+
236
+ init() {
237
+ const material = this.material();
238
+ const current = material?.values?.[0]?.color;
239
+ if (typeof current === "string") this.original = current;
240
+ }
241
+
242
+ onHandPoseStart(event: XrHandPoseEventDetail) {
243
+ if (event.poseId === "approve") this.material()?.updateValue(0, { color: "#0066ff" });
244
+ }
245
+
246
+ onHandPoseEnd() {
247
+ this.material()?.updateValue(0, { color: this.original });
248
+ }
249
+
250
+ private material() {
251
+ const target = this.data.target.value as WorldEntityView | null;
252
+ return target
253
+ ? this.world.getComponent(target.entityId, MaterialComponent)
254
+ : null;
255
+ }
256
+ }
257
+ ```
258
+
259
+ **Hand poses require HAND TRACKING.** They are read from the 25 tracked finger joints, so nothing fires while the user is holding controllers — put them down and enable hand tracking on the headset. If a scene has poses configured and no tracked hand is seen for a few seconds, the runtime says so in the console.
260
+
157
261
  ### Presets
158
262
 
159
263
  `fist`, `open-palm`, `point`, `thumbs-up`, `thumbs-down`, `peace`, `pinch`, `ok`, `gun`, `rock`, `shaka`, `palm-up`.
@@ -10,7 +10,8 @@ This page summarises the methods most useful from a `Behaviour` and shows how to
10
10
  ## Inside a Behaviour
11
11
 
12
12
  ```ts
13
- import { Behaviour, TransformComponent } from "@relu-interactives/spatial-ecs";
13
+ import { Behaviour } from "@relu-interactives/spatial-ecs";
14
+ import { TransformComponent } from "@relu-interactives/spatial-ecs/components";
14
15
 
15
16
  export default class Spinner extends Behaviour {
16
17
  onUpdate() {
@@ -120,7 +121,8 @@ Both methods accept `Vec3` (`THREE.Vector3` or `{ x, y, z }`) and return `Inters
120
121
  ### Find an entity by name and move it
121
122
 
122
123
  ```ts
123
- import { Behaviour, TransformComponent } from "@relu-interactives/spatial-ecs";
124
+ import { Behaviour } from "@relu-interactives/spatial-ecs";
125
+ import { TransformComponent } from "@relu-interactives/spatial-ecs/components";
124
126
 
125
127
  export default class TargetMover extends Behaviour {
126
128
  onUpdate() {
@@ -138,7 +140,8 @@ export default class TargetMover extends Behaviour {
138
140
  ### Iterate every entity that owns a component
139
141
 
140
142
  ```ts
141
- import { Behaviour, LightComponent } from "@relu-interactives/spatial-ecs";
143
+ import { Behaviour } from "@relu-interactives/spatial-ecs";
144
+ import { LightComponent } from "@relu-interactives/spatial-ecs/components";
142
145
 
143
146
  export default class FlickerAllLights extends Behaviour {
144
147
  onUpdate() {
@@ -188,7 +191,10 @@ export default class HierarchyWalker extends Behaviour {
188
191
 
189
192
  ```ts
190
193
  import { Behaviour } from "@relu-interactives/spatial-ecs";
191
- import type Health from "./Health";
194
+ // Your own Behaviour, in a sibling file.
195
+ declare class Health extends Behaviour {
196
+ takeDamage(amount: number): void;
197
+ }
192
198
 
193
199
  export default class DamageDealer extends Behaviour {
194
200
  fire() {
@@ -229,12 +235,12 @@ export default class Spawner extends Behaviour {
229
235
  );
230
236
  this.world.getScene().add(mesh);
231
237
 
232
- this.world.addComponents(id, [
233
- createNameComponent({ value: "RuntimeCube" }),
234
- createEntityTypeComponent({ value: "cube" }),
235
- createTransformComponent({ position: { x: 0, y: 1, z: 0 } }),
236
- createObject3DRefComponent({ object: mesh }),
237
- ]);
238
+ // Each factory takes (world, entityId, ...constructorArgs) and attaches
239
+ // the component itself — there is nothing to pass to addComponents.
240
+ createNameComponent(this.world, id, "RuntimeCube");
241
+ createEntityTypeComponent(this.world, id, "cube");
242
+ createTransformComponent(this.world, id, { position: { x: 0, y: 1, z: 0 } });
243
+ createObject3DRefComponent(this.world, id, mesh);
238
244
  }
239
245
  }
240
246
  ```
@@ -343,7 +349,8 @@ export default class LookDetector extends Behaviour {
343
349
  ### Read keyboard and mouse input
344
350
 
345
351
  ```ts
346
- import { Behaviour, TransformComponent, KeyboardKeys } from "@relu-interactives/spatial-ecs";
352
+ import { Behaviour, KeyboardKeys } from "@relu-interactives/spatial-ecs";
353
+ import { TransformComponent } from "@relu-interactives/spatial-ecs/components";
347
354
 
348
355
  export default class WASDMove extends Behaviour {
349
356
  speed = 3;
@@ -392,11 +399,12 @@ export default class FpsLook extends Behaviour {
392
399
  ### Reach into the physics world
393
400
 
394
401
  ```ts
395
- import { Behaviour, PhysicsComponent } from "@relu-interactives/spatial-ecs";
402
+ import { Behaviour, KeyboardKeys } from "@relu-interactives/spatial-ecs";
403
+ import { PhysicsComponent } from "@relu-interactives/spatial-ecs/components";
396
404
 
397
405
  export default class Jump extends Behaviour {
398
406
  onUpdate() {
399
- if (!this.world.getKeyDown("space")) return;
407
+ if (!this.world.getKeyDown(KeyboardKeys.Space)) return;
400
408
 
401
409
  const physics = this.world.getComponent(this.entity.entityId, PhysicsComponent);
402
410
  physics?.rigidBody?.applyImpulse({ x: 0, y: 5, z: 0 }, true);
@@ -411,12 +419,12 @@ ticks stop when paused — physics, animation, scripts, and the render pass.
411
419
  The last rendered frame stays frozen on the canvas.
412
420
 
413
421
  ```ts
414
- import { Behaviour } from "@relu-interactives/spatial-ecs";
422
+ import { Behaviour, KeyboardKeys } from "@relu-interactives/spatial-ecs";
415
423
 
416
424
  export default class PauseOnHit extends Behaviour {
417
425
  onUpdate() {
418
426
  // Pause when the spacebar is pressed, resume when pressed again.
419
- if (!this.world.getKeyDown("Space")) return;
427
+ if (!this.world.getKeyDown(KeyboardKeys.Space)) return;
420
428
 
421
429
  if (this.world.isPaused()) {
422
430
  this.world.resume();
@@ -401,6 +401,18 @@ declare class XRInputSystem implements EcsSystem {
401
401
  getInputs(): XrInputState[];
402
402
  /** The first source for a hand, or null. Prefer this for hand-specific logic. */
403
403
  getInput(handedness: XrHandedness): XrInputState | null;
404
+ /**
405
+ * The ARTICULATED source for a hand — the one carrying joint poses.
406
+ *
407
+ * Distinct from {@link getInput} because a hand can be backed by several
408
+ * sources at once: a Quest reports a controller AND a tracked hand for the
409
+ * same handedness, and Vision Pro adds a transient pinch alongside its two
410
+ * persistent hands. `getInput` returns whichever was seen first, so anything
411
+ * needing JOINTS has to ask for them specifically — taking the first match
412
+ * silently handed pose detection a controller with no joints, and no pose
413
+ * ever fired.
414
+ */
415
+ getHandInput(handedness: XrHandedness): XrInputState | null;
404
416
  /** True while ANY source is selecting — the portable "is the user acting" check. */
405
417
  isSelecting(): boolean;
406
418
  dispose(): void;
@@ -800,6 +812,8 @@ declare class XRHandPoseSystem implements EcsSystem {
800
812
  /** Reused so head-relative orientation checks allocate nothing per frame. */
801
813
  private readonly headPosition;
802
814
  private elapsed;
815
+ private warnedNoHands;
816
+ private noHandSeconds;
803
817
  constructor(inputSystem: XRInputSystem);
804
818
  update(world: World, deltaTime: number): void;
805
819
  /** Derive each hand's shape once for the whole frame. */
@@ -401,6 +401,18 @@ declare class XRInputSystem implements EcsSystem {
401
401
  getInputs(): XrInputState[];
402
402
  /** The first source for a hand, or null. Prefer this for hand-specific logic. */
403
403
  getInput(handedness: XrHandedness): XrInputState | null;
404
+ /**
405
+ * The ARTICULATED source for a hand — the one carrying joint poses.
406
+ *
407
+ * Distinct from {@link getInput} because a hand can be backed by several
408
+ * sources at once: a Quest reports a controller AND a tracked hand for the
409
+ * same handedness, and Vision Pro adds a transient pinch alongside its two
410
+ * persistent hands. `getInput` returns whichever was seen first, so anything
411
+ * needing JOINTS has to ask for them specifically — taking the first match
412
+ * silently handed pose detection a controller with no joints, and no pose
413
+ * ever fired.
414
+ */
415
+ getHandInput(handedness: XrHandedness): XrInputState | null;
404
416
  /** True while ANY source is selecting — the portable "is the user acting" check. */
405
417
  isSelecting(): boolean;
406
418
  dispose(): void;
@@ -800,6 +812,8 @@ declare class XRHandPoseSystem implements EcsSystem {
800
812
  /** Reused so head-relative orientation checks allocate nothing per frame. */
801
813
  private readonly headPosition;
802
814
  private elapsed;
815
+ private warnedNoHands;
816
+ private noHandSeconds;
803
817
  constructor(inputSystem: XRInputSystem);
804
818
  update(world: World, deltaTime: number): void;
805
819
  /** Derive each hand's shape once for the whole frame. */
package/dist/index.cjs CHANGED
@@ -11019,6 +11019,25 @@ var _XRInputSystem = class _XRInputSystem {
11019
11019
  }
11020
11020
  return null;
11021
11021
  }
11022
+ /**
11023
+ * The ARTICULATED source for a hand — the one carrying joint poses.
11024
+ *
11025
+ * Distinct from {@link getInput} because a hand can be backed by several
11026
+ * sources at once: a Quest reports a controller AND a tracked hand for the
11027
+ * same handedness, and Vision Pro adds a transient pinch alongside its two
11028
+ * persistent hands. `getInput` returns whichever was seen first, so anything
11029
+ * needing JOINTS has to ask for them specifically — taking the first match
11030
+ * silently handed pose detection a controller with no joints, and no pose
11031
+ * ever fired.
11032
+ */
11033
+ getHandInput(handedness) {
11034
+ for (const record of this.records.values()) {
11035
+ const state = record.state;
11036
+ if (state.handedness !== handedness) continue;
11037
+ if (state.joints) return state;
11038
+ }
11039
+ return null;
11040
+ }
11022
11041
  /** True while ANY source is selecting — the portable "is the user acting" check. */
11023
11042
  isSelecting() {
11024
11043
  for (const record of this.records.values()) {
@@ -13283,6 +13302,8 @@ var _XRHandPoseSystem = class _XRHandPoseSystem {
13283
13302
  /** Reused so head-relative orientation checks allocate nothing per frame. */
13284
13303
  this.headPosition = new THREE51__namespace.Vector3();
13285
13304
  this.elapsed = 0;
13305
+ this.warnedNoHands = false;
13306
+ this.noHandSeconds = 0;
13286
13307
  this.inputSystem = inputSystem;
13287
13308
  }
13288
13309
  update(world, deltaTime) {
@@ -13294,6 +13315,20 @@ var _XRHandPoseSystem = class _XRHandPoseSystem {
13294
13315
  this.refreshShapes();
13295
13316
  const entries = this.collectEntries(world);
13296
13317
  if (entries.length === 0) return;
13318
+ if (!this.warnedNoHands) {
13319
+ const tracked = this.shapes.get("left") !== null || this.shapes.get("right") !== null;
13320
+ if (tracked) {
13321
+ this.warnedNoHands = true;
13322
+ } else {
13323
+ this.noHandSeconds += deltaTime;
13324
+ if (this.noHandSeconds > 5) {
13325
+ this.warnedNoHands = true;
13326
+ console.warn(
13327
+ "[XRHandPoseSystem] Hand poses are configured but no tracked hands have been seen. Hand poses need HAND TRACKING \u2014 put the controllers down and enable hand tracking on the headset."
13328
+ );
13329
+ }
13330
+ }
13331
+ }
13297
13332
  world.getCamera().getWorldPosition(this.headPosition);
13298
13333
  const context = { headPosition: this.headPosition };
13299
13334
  for (const { entityId, entry } of entries) {
@@ -13338,7 +13373,7 @@ var _XRHandPoseSystem = class _XRHandPoseSystem {
13338
13373
  /** Derive each hand's shape once for the whole frame. */
13339
13374
  refreshShapes() {
13340
13375
  for (const handedness of ["left", "right"]) {
13341
- const input = this.inputSystem.getInput(handedness);
13376
+ const input = this.inputSystem.getHandInput(handedness);
13342
13377
  const joints = input?.joints ?? null;
13343
13378
  this.shapes.set(
13344
13379
  handedness,