@relu-interactives/spatial-ecs 0.2.16 → 0.2.20

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 (67) hide show
  1. package/dist/{System-BeuJ1xt5.d.cts → System-BjwOBx81.d.cts} +146 -1
  2. package/dist/{System-BeuJ1xt5.d.ts → System-BjwOBx81.d.ts} +146 -1
  3. package/dist/{Video-D4B-z74P.d.cts → Video-B8yVI1tF.d.cts} +17 -3
  4. package/dist/{Video-Cym2C52L.d.ts → Video-B9z5AtIf.d.ts} +17 -3
  5. package/dist/{chunk-EPK6MA7X.js → chunk-67MFQDHX.js} +1646 -8
  6. package/dist/chunk-67MFQDHX.js.map +1 -0
  7. package/dist/{chunk-GRUK5TNL.js → chunk-DY4ETMP2.js} +3 -3
  8. package/dist/{chunk-GRUK5TNL.js.map → chunk-DY4ETMP2.js.map} +1 -1
  9. package/dist/{chunk-CJW6K3CL.js → chunk-HUHN6IFD.js} +62 -5
  10. package/dist/chunk-HUHN6IFD.js.map +1 -0
  11. package/dist/{chunk-FTIQQK62.js → chunk-Z6OMYEXU.js} +42 -3
  12. package/dist/chunk-Z6OMYEXU.js.map +1 -0
  13. package/dist/{chunk-R2WMVXZZ.js → chunk-ZXGHKX7N.js} +5 -3
  14. package/dist/chunk-ZXGHKX7N.js.map +1 -0
  15. package/dist/components/index.cjs +39 -0
  16. package/dist/components/index.cjs.map +1 -1
  17. package/dist/components/index.d.cts +3 -2
  18. package/dist/components/index.d.ts +3 -2
  19. package/dist/components/index.js +1 -1
  20. package/dist/docs/guide/accessing-components.md +9 -9
  21. package/dist/docs/guide/camera-rigs.md +525 -0
  22. package/dist/docs/guide/components/animation.md +5 -5
  23. package/dist/docs/guide/components/entity-type.md +2 -2
  24. package/dist/docs/guide/components/image.md +2 -2
  25. package/dist/docs/guide/components/instanced-mesh.md +3 -3
  26. package/dist/docs/guide/components/material.md +4 -0
  27. package/dist/docs/guide/components/mesh-geometry.md +3 -3
  28. package/dist/docs/guide/components/mesh.md +3 -3
  29. package/dist/docs/guide/components/model.md +2 -2
  30. package/dist/docs/guide/components/name.md +4 -4
  31. package/dist/docs/guide/components/object3d-ref.md +2 -2
  32. package/dist/docs/guide/components/parent-id.md +2 -2
  33. package/dist/docs/guide/components/physics.md +10 -0
  34. package/dist/docs/guide/components/video.md +2 -2
  35. package/dist/docs/guide/getting-started/editor/assets-browser.md +1 -1
  36. package/dist/docs/guide/prefabs.md +133 -0
  37. package/dist/docs/guide/world-api.md +4 -3
  38. package/dist/factories/index.cjs +136 -64
  39. package/dist/factories/index.cjs.map +1 -1
  40. package/dist/factories/index.d.cts +5 -4
  41. package/dist/factories/index.d.ts +5 -4
  42. package/dist/factories/index.js +3 -3
  43. package/dist/{index-BoJ3_6a3.d.ts → index-BBI2YdCu.d.ts} +60 -4
  44. package/dist/{index-BfoOC2CG.d.cts → index-LHLKpJhZ.d.cts} +60 -4
  45. package/dist/index.cjs +5978 -4972
  46. package/dist/index.cjs.map +1 -1
  47. package/dist/index.d.cts +215 -7
  48. package/dist/index.d.ts +215 -7
  49. package/dist/index.js +203 -960
  50. package/dist/index.js.map +1 -1
  51. package/dist/systems/index.cjs +1367 -0
  52. package/dist/systems/index.cjs.map +1 -1
  53. package/dist/systems/index.d.cts +2 -2
  54. package/dist/systems/index.d.ts +2 -2
  55. package/dist/systems/index.js +3 -3
  56. package/dist/types.d.cts +1 -1
  57. package/dist/types.d.ts +1 -1
  58. package/dist/worldSnapshotUtils.cjs +3 -1
  59. package/dist/worldSnapshotUtils.cjs.map +1 -1
  60. package/dist/worldSnapshotUtils.d.cts +1 -1
  61. package/dist/worldSnapshotUtils.d.ts +1 -1
  62. package/dist/worldSnapshotUtils.js +1 -1
  63. package/package.json +4 -2
  64. package/dist/chunk-CJW6K3CL.js.map +0 -1
  65. package/dist/chunk-EPK6MA7X.js.map +0 -1
  66. package/dist/chunk-FTIQQK62.js.map +0 -1
  67. package/dist/chunk-R2WMVXZZ.js.map +0 -1
@@ -1,4 +1,5 @@
1
- export { A as AnimationComponent, a as AnimationComponentOptions, b as AudioComponent, C as CameraComponent, c as CameraComponentOptions, d as CameraProjection, e as CameraRuntimeType, f as ColliderShape, g as ColliderState, h as CubeMeshParameters, D as DEFAULT_COLLIDER_STATE, i as DEFAULT_INSTANCED_MESH_PARAMETERS, j as DEFAULT_INSTANCE_TRANSFORM, k as DEFAULT_MESH_GEOMETRY_PARAMETERS, l as DEFAULT_PHYSICS_STATE, m as DEFAULT_RIGIDBODY_STATE, E as EditableMeshObjectKind, n as EntityType, I as ImageComponent, o as ImageTarget, p as ImageTargetAnchor, q as ImageTargetAnchorOptions, r as ImageTargetReference, s as InstanceSlotComponent, t as InstanceTransform, u as InstancedMeshComponent, v as InstancedMeshKind, L as LightComponent, w as LightValueState, M as MaterialComponent, x as MaterialTypeValue, y as MaterialValueState, z as MeshComponent, B as MeshGeometryComponent, F as MeshGeometryParameters, G as MeshGeometryParametersByKind, H as ModelComponent, N as Name, O as Object3DRef, P as PhysicsComponent, J as PhysicsState, K as PlaneMeshParameters, R as RadiusHeightMeshParameters, Q as RadiusMeshParameters, S as RigidbodyState, T as RigidbodyType, U as ScriptComponent, V as ScriptEntry, W as Selectable, X as SelectionTransformState, Y as SpriteComponent, Z as TextureFilteringValue, _ as TransformModeValue, $ as TransformSpaceValue, a0 as VideoComponent, a1 as normalizePhysicsState } from '../Video-D4B-z74P.cjs';
2
- export { D as DEFAULT_ENVIRONMENT_STATE, a as DEFAULT_POSTPROCESSING_STATE, E as Environment, b as EnvironmentFogState, c as EnvironmentFogType, d as EnvironmentGradientEffect, e as EnvironmentSkyboxState, f as EnvironmentSkyboxType, g as EnvironmentState, P as ParentId, h as Postprocessing, i as PostprocessingState, T as TransformComponent, j as TransformData, V as Vec3 } from '../System-BeuJ1xt5.cjs';
1
+ export { A as AnimationComponent, a as AnimationComponentOptions, b as AudioComponent, C as CameraComponent, c as CameraComponentOptions, d as CameraProjection, e as CameraRuntimeType, f as ColliderShape, g as ColliderState, h as CubeMeshParameters, D as DEFAULT_COLLIDER_STATE, i as DEFAULT_INSTANCED_MESH_PARAMETERS, j as DEFAULT_INSTANCE_TRANSFORM, k as DEFAULT_MESH_GEOMETRY_PARAMETERS, l as DEFAULT_PHYSICS_STATE, m as DEFAULT_RIGIDBODY_STATE, E as EditableMeshObjectKind, n as EntityType, I as ImageComponent, o as ImageTarget, p as ImageTargetAnchor, q as ImageTargetAnchorOptions, r as ImageTargetReference, s as InstanceSlotComponent, t as InstanceTransform, u as InstancedMeshComponent, v as InstancedMeshKind, L as LightComponent, w as LightValueState, M as MaterialComponent, x as MaterialTypeValue, y as MaterialValueState, z as MeshComponent, B as MeshGeometryComponent, F as MeshGeometryParameters, G as MeshGeometryParametersByKind, H as ModelComponent, N as Name, O as Object3DRef, P as PhysicsComponent, J as PhysicsState, K as PlaneMeshParameters, R as RadiusHeightMeshParameters, Q as RadiusMeshParameters, S as RigidbodyState, T as RigidbodyType, U as ScriptComponent, V as ScriptEntry, W as Selectable, X as SelectionTransformState, Y as SpriteComponent, Z as TextureFilteringValue, _ as TransformModeValue, $ as TransformSpaceValue, a0 as VideoComponent, a1 as defaultMaterialValue, a2 as normalizePhysicsState } from '../Video-B8yVI1tF.cjs';
2
+ export { D as DEFAULT_ENVIRONMENT_STATE, a as DEFAULT_POSTPROCESSING_STATE, E as Environment, b as EnvironmentFogState, c as EnvironmentFogType, d as EnvironmentGradientEffect, e as EnvironmentSkyboxState, f as EnvironmentSkyboxType, g as EnvironmentState, P as ParentId, h as Postprocessing, i as PostprocessingState, T as TransformComponent, j as TransformData, V as Vec3 } from '../System-BjwOBx81.cjs';
3
+ export { P as PrefabInstance, a as PrefabNode } from '../index-LHLKpJhZ.cjs';
3
4
  import 'three';
4
5
  import '@dimforge/rapier3d-compat';
@@ -1,4 +1,5 @@
1
- export { A as AnimationComponent, a as AnimationComponentOptions, b as AudioComponent, C as CameraComponent, c as CameraComponentOptions, d as CameraProjection, e as CameraRuntimeType, f as ColliderShape, g as ColliderState, h as CubeMeshParameters, D as DEFAULT_COLLIDER_STATE, i as DEFAULT_INSTANCED_MESH_PARAMETERS, j as DEFAULT_INSTANCE_TRANSFORM, k as DEFAULT_MESH_GEOMETRY_PARAMETERS, l as DEFAULT_PHYSICS_STATE, m as DEFAULT_RIGIDBODY_STATE, E as EditableMeshObjectKind, n as EntityType, I as ImageComponent, o as ImageTarget, p as ImageTargetAnchor, q as ImageTargetAnchorOptions, r as ImageTargetReference, s as InstanceSlotComponent, t as InstanceTransform, u as InstancedMeshComponent, v as InstancedMeshKind, L as LightComponent, w as LightValueState, M as MaterialComponent, x as MaterialTypeValue, y as MaterialValueState, z as MeshComponent, B as MeshGeometryComponent, F as MeshGeometryParameters, G as MeshGeometryParametersByKind, H as ModelComponent, N as Name, O as Object3DRef, P as PhysicsComponent, J as PhysicsState, K as PlaneMeshParameters, R as RadiusHeightMeshParameters, Q as RadiusMeshParameters, S as RigidbodyState, T as RigidbodyType, U as ScriptComponent, V as ScriptEntry, W as Selectable, X as SelectionTransformState, Y as SpriteComponent, Z as TextureFilteringValue, _ as TransformModeValue, $ as TransformSpaceValue, a0 as VideoComponent, a1 as normalizePhysicsState } from '../Video-Cym2C52L.js';
2
- export { D as DEFAULT_ENVIRONMENT_STATE, a as DEFAULT_POSTPROCESSING_STATE, E as Environment, b as EnvironmentFogState, c as EnvironmentFogType, d as EnvironmentGradientEffect, e as EnvironmentSkyboxState, f as EnvironmentSkyboxType, g as EnvironmentState, P as ParentId, h as Postprocessing, i as PostprocessingState, T as TransformComponent, j as TransformData, V as Vec3 } from '../System-BeuJ1xt5.js';
1
+ export { A as AnimationComponent, a as AnimationComponentOptions, b as AudioComponent, C as CameraComponent, c as CameraComponentOptions, d as CameraProjection, e as CameraRuntimeType, f as ColliderShape, g as ColliderState, h as CubeMeshParameters, D as DEFAULT_COLLIDER_STATE, i as DEFAULT_INSTANCED_MESH_PARAMETERS, j as DEFAULT_INSTANCE_TRANSFORM, k as DEFAULT_MESH_GEOMETRY_PARAMETERS, l as DEFAULT_PHYSICS_STATE, m as DEFAULT_RIGIDBODY_STATE, E as EditableMeshObjectKind, n as EntityType, I as ImageComponent, o as ImageTarget, p as ImageTargetAnchor, q as ImageTargetAnchorOptions, r as ImageTargetReference, s as InstanceSlotComponent, t as InstanceTransform, u as InstancedMeshComponent, v as InstancedMeshKind, L as LightComponent, w as LightValueState, M as MaterialComponent, x as MaterialTypeValue, y as MaterialValueState, z as MeshComponent, B as MeshGeometryComponent, F as MeshGeometryParameters, G as MeshGeometryParametersByKind, H as ModelComponent, N as Name, O as Object3DRef, P as PhysicsComponent, J as PhysicsState, K as PlaneMeshParameters, R as RadiusHeightMeshParameters, Q as RadiusMeshParameters, S as RigidbodyState, T as RigidbodyType, U as ScriptComponent, V as ScriptEntry, W as Selectable, X as SelectionTransformState, Y as SpriteComponent, Z as TextureFilteringValue, _ as TransformModeValue, $ as TransformSpaceValue, a0 as VideoComponent, a1 as defaultMaterialValue, a2 as normalizePhysicsState } from '../Video-B9z5AtIf.js';
2
+ export { D as DEFAULT_ENVIRONMENT_STATE, a as DEFAULT_POSTPROCESSING_STATE, E as Environment, b as EnvironmentFogState, c as EnvironmentFogType, d as EnvironmentGradientEffect, e as EnvironmentSkyboxState, f as EnvironmentSkyboxType, g as EnvironmentState, P as ParentId, h as Postprocessing, i as PostprocessingState, T as TransformComponent, j as TransformData, V as Vec3 } from '../System-BjwOBx81.js';
3
+ export { P as PrefabInstance, a as PrefabNode } from '../index-BBI2YdCu.js';
3
4
  import 'three';
4
5
  import '@dimforge/rapier3d-compat';
@@ -1,4 +1,4 @@
1
- export { AnimationComponent, AudioComponent, CameraComponent, DEFAULT_COLLIDER_STATE, DEFAULT_ENVIRONMENT_STATE, DEFAULT_INSTANCED_MESH_PARAMETERS, DEFAULT_INSTANCE_TRANSFORM, DEFAULT_MESH_GEOMETRY_PARAMETERS, DEFAULT_PHYSICS_STATE, DEFAULT_POSTPROCESSING_STATE, DEFAULT_RIGIDBODY_STATE, EntityType, Environment, ImageComponent, ImageTarget, ImageTargetAnchor, InstanceSlotComponent, InstancedMeshComponent, LightComponent, MaterialComponent, MeshComponent, MeshGeometryComponent, ModelComponent, Name, Object3DRef, ParentId, PhysicsComponent, Postprocessing, ScriptComponent, Selectable, SelectionTransformState, SpriteComponent, TransformComponent, VideoComponent, normalizePhysicsState } from '../chunk-FTIQQK62.js';
1
+ export { AnimationComponent, AudioComponent, CameraComponent, DEFAULT_COLLIDER_STATE, DEFAULT_ENVIRONMENT_STATE, DEFAULT_INSTANCED_MESH_PARAMETERS, DEFAULT_INSTANCE_TRANSFORM, DEFAULT_MESH_GEOMETRY_PARAMETERS, DEFAULT_PHYSICS_STATE, DEFAULT_POSTPROCESSING_STATE, DEFAULT_RIGIDBODY_STATE, EntityType, Environment, ImageComponent, ImageTarget, ImageTargetAnchor, InstanceSlotComponent, InstancedMeshComponent, LightComponent, MaterialComponent, MeshComponent, MeshGeometryComponent, ModelComponent, Name, Object3DRef, ParentId, PhysicsComponent, Postprocessing, PrefabInstance, PrefabNode, ScriptComponent, Selectable, SelectionTransformState, SpriteComponent, TransformComponent, VideoComponent, defaultMaterialValue, normalizePhysicsState } from '../chunk-Z6OMYEXU.js';
2
2
  import '../chunk-OY7ZODZP.js';
3
3
  //# sourceMappingURL=index.js.map
4
4
  //# sourceMappingURL=index.js.map
@@ -10,7 +10,7 @@ getComponent<T extends Component>(componentClass: ComponentClass<T>): T | null
10
10
 
11
11
  Returns the component instance of the requested type attached to the **same entity** as the script, or `null` if the entity does not have that component. Always null-check the result — components are optional and may not be present on every object.
12
12
 
13
- The recommended pattern is to cache the reference in `onInit` so you avoid the lookup overhead every frame:
13
+ The recommended pattern is to cache the reference in `init()` (called once before the first `onUpdate`) so you avoid the lookup overhead every frame:
14
14
 
15
15
  ```ts
16
16
  import { Behaviour } from "@relu-interactives/spatial-ecs";
@@ -19,7 +19,7 @@ import { PhysicsComponent } from "@relu-interactives/spatial-ecs/components";
19
19
  export default class Example extends Behaviour {
20
20
  private physics: PhysicsComponent | null = null;
21
21
 
22
- onInit() {
22
+ protected init() {
23
23
  this.physics = this.getComponent(PhysicsComponent);
24
24
  }
25
25
 
@@ -76,7 +76,7 @@ export default class Jumper extends Behaviour {
76
76
 
77
77
  private physics: PhysicsComponent | null = null;
78
78
 
79
- onInit() {
79
+ protected init() {
80
80
  this.physics = this.getComponent(PhysicsComponent);
81
81
  }
82
82
 
@@ -124,7 +124,7 @@ export default class SoundController extends Behaviour {
124
124
 
125
125
  private audio: AudioComponent | null = null;
126
126
 
127
- onInit() {
127
+ protected init() {
128
128
  this.audio = this.getComponent(AudioComponent);
129
129
  }
130
130
 
@@ -180,7 +180,7 @@ export default class AnimationController extends Behaviour {
180
180
 
181
181
  private anim: AnimationComponent | null = null;
182
182
 
183
- onInit() {
183
+ protected init() {
184
184
  this.anim = this.getComponent(AnimationComponent);
185
185
  if (this.anim) {
186
186
  this.anim.switchState(this.data.state.value);
@@ -234,7 +234,7 @@ export default class LightPulse extends Behaviour {
234
234
  private light: LightComponent | null = null;
235
235
  private t = 0;
236
236
 
237
- onInit() {
237
+ protected init() {
238
238
  this.light = this.getComponent(LightComponent);
239
239
  if (this.light) {
240
240
  this.light.setValues({ color: this.data.color });
@@ -288,7 +288,7 @@ export default class MaterialSwap extends Behaviour {
288
288
 
289
289
  private mat: MaterialComponent | null = null;
290
290
 
291
- onInit() {
291
+ protected init() {
292
292
  this.mat = this.getComponent(MaterialComponent);
293
293
  }
294
294
 
@@ -332,7 +332,7 @@ import { VideoComponent } from "@relu-interactives/spatial-ecs/components";
332
332
  export default class VideoController extends Behaviour {
333
333
  private video: VideoComponent | null = null;
334
334
 
335
- onInit() {
335
+ protected init() {
336
336
  this.video = this.getComponent(VideoComponent);
337
337
  }
338
338
 
@@ -380,7 +380,7 @@ export default class RemoteControl extends Behaviour {
380
380
  const entity = this.world.getEntityByName("Sun");
381
381
  if (!entity) return;
382
382
 
383
- const light = this.world.getComponent(entity.id, LightComponent);
383
+ const light = this.world.getComponent(entity.entityId, LightComponent);
384
384
  if (light) light.setValues({ intensity: 2 });
385
385
  }
386
386
  }
@@ -0,0 +1,525 @@
1
+ # Camera Rigs
2
+
3
+ Recipes for the three camera setups almost every experience needs: **orbit** (rotate around a target), **third-person follow**, and **first-person**. Each is a complete, working `Behaviour` script — copy it, attach it, and tune the inspector fields.
4
+
5
+ ## The pattern
6
+
7
+ All camera rigs follow the same rules:
8
+
9
+ 1. **Attach the rig script to the camera entity** and drive `this.transform` — `CameraSyncSystem` keeps the live camera in sync. Never mutate `this.threeRef.object.position/rotation` on transform-synced entities; `TransformSyncSystem` overwrites raw Object3D transforms every frame.
10
+ 2. **Input comes from `this.world`** — `getKey` / `getKeyDown` (import `KeyboardKeys`), `getMouse("left")` / `getMouseDown`, `consumeMouseDelta()`, `consumeScrollDelta()`, `lockMousePointer()` / `unlockMousePointer()` / `isMousePointerLocked()`. There is no `world.input` object.
11
+ 3. **Angles are radians.** Own your yaw/pitch as private numbers and build positions/rotations from them — never read angles back out of a quaternion each frame (Euler extraction can gimbal-flip).
12
+ 4. **Position a camera on a sphere around its target** with spherical coordinates:
13
+ ```ts
14
+ x = target.x + distance * Math.sin(yaw) * Math.cos(pitch);
15
+ y = target.y + distance * Math.sin(pitch);
16
+ z = target.z + distance * Math.cos(yaw) * Math.cos(pitch);
17
+ ```
18
+ then `this.lookAt(targetPosition)`.
19
+ 5. **Smooth with framerate-independent damping:** `alpha = Math.min(1, smoothSpeed * this.deltaTime)`, then `pos += (desired - pos) * alpha`.
20
+ 6. **Resolve targets lazily.** Look the target up in `init()` AND retry in `onUpdate()` when it is missing — this survives hot reloads and late model/physics initialization.
21
+
22
+ ---
23
+
24
+ ## Recipe 1 — Orbit camera (rotate around a target)
25
+
26
+ Orbits a target entity: optional auto-rotate, drag with the left mouse button to orbit manually, scroll to zoom. Falls back to orbiting the world origin when no target is set, so it always does something visible.
27
+
28
+ **Setup:** attach to the **camera entity**. Pick the target with the `target` entity field in the inspector (or type its name into `targetName`).
29
+
30
+ ```ts
31
+ import { Behaviour } from "@relu-interactives/spatial-ecs";
32
+ import type {
33
+ EntityInput,
34
+ FloatInput,
35
+ TextInput,
36
+ ToggleInput,
37
+ WorldEntityView,
38
+ } from "@relu-interactives/spatial-ecs";
39
+ import { TransformComponent } from "@relu-interactives/spatial-ecs/components";
40
+ import { Vector3 } from "three";
41
+
42
+ export default class OrbitCamera extends Behaviour {
43
+ data = {
44
+ // Pick the entity to orbit in the inspector…
45
+ target: { type: "entity", value: null } as unknown as EntityInput,
46
+ // …or find it by name (used when no entity is picked).
47
+ targetName: "" as TextInput,
48
+
49
+ distance: 6 as FloatInput,
50
+ minDistance: 2 as FloatInput,
51
+ maxDistance: 20 as FloatInput,
52
+ /** Vertical offset of the orbit centre above the target's origin. */
53
+ height: 0.5 as FloatInput,
54
+
55
+ autoRotate: true as ToggleInput,
56
+ /** Radians per second when autoRotate is on. */
57
+ autoRotateSpeed: 0.4 as FloatInput,
58
+
59
+ mouseSensitivity: 0.005 as FloatInput,
60
+ minPitch: -0.2 as FloatInput,
61
+ maxPitch: 1.3 as FloatInput,
62
+ smoothSpeed: 8 as FloatInput,
63
+ };
64
+
65
+ private yaw = 0;
66
+ private pitch = 0.5;
67
+ private currentDistance = 6;
68
+ private targetTransform: TransformComponent | null = null;
69
+ private orbitCentre = new Vector3();
70
+
71
+ protected init() {
72
+ this.currentDistance = this.data.distance;
73
+ this.resolveTarget();
74
+ }
75
+
76
+ /** Prefer the inspector-picked entity; fall back to lookup by name. */
77
+ private resolveTarget() {
78
+ const picked = this.data.target.value as WorldEntityView | null;
79
+ if (picked) {
80
+ this.targetTransform = this.world.getComponent(
81
+ picked.entityId,
82
+ TransformComponent,
83
+ ) as TransformComponent | null;
84
+ if (this.targetTransform) return;
85
+ }
86
+ if (this.data.targetName) {
87
+ const byName = this.world.getEntityByName(this.data.targetName);
88
+ if (byName) {
89
+ this.targetTransform = this.world.getComponent(
90
+ byName.entityId,
91
+ TransformComponent,
92
+ ) as TransformComponent | null;
93
+ }
94
+ }
95
+ }
96
+
97
+ protected onUpdate() {
98
+ // Lazy re-resolve so the rig survives hot reloads / late target creation.
99
+ if (!this.targetTransform) this.resolveTarget();
100
+
101
+ // Consume the mouse delta EVERY frame (deltas accumulate until consumed —
102
+ // skipping frames would cause a jump when the drag starts) but only apply
103
+ // it while the left button is held.
104
+ const delta = this.world.consumeMouseDelta();
105
+ const dragging = this.world.getMouse("left");
106
+ if (dragging) {
107
+ this.yaw -= delta.x * this.data.mouseSensitivity;
108
+ this.pitch += delta.y * this.data.mouseSensitivity;
109
+ this.pitch = Math.max(
110
+ this.data.minPitch,
111
+ Math.min(this.data.maxPitch, this.pitch),
112
+ );
113
+ } else if (this.data.autoRotate) {
114
+ this.yaw += this.data.autoRotateSpeed * this.deltaTime;
115
+ }
116
+
117
+ // Scroll to zoom.
118
+ const scroll = this.world.consumeScrollDelta();
119
+ this.currentDistance += scroll.y * 0.01;
120
+ this.currentDistance = Math.max(
121
+ this.data.minDistance,
122
+ Math.min(this.data.maxDistance, this.currentDistance),
123
+ );
124
+
125
+ // Orbit centre: the target's position (world origin when no target).
126
+ if (this.targetTransform) {
127
+ this.orbitCentre.set(
128
+ this.targetTransform.position.x,
129
+ this.targetTransform.position.y + this.data.height,
130
+ this.targetTransform.position.z,
131
+ );
132
+ } else {
133
+ this.orbitCentre.set(0, this.data.height, 0);
134
+ }
135
+
136
+ // Desired position on the orbit sphere.
137
+ const desiredX =
138
+ this.orbitCentre.x +
139
+ this.currentDistance * Math.sin(this.yaw) * Math.cos(this.pitch);
140
+ const desiredY =
141
+ this.orbitCentre.y + this.currentDistance * Math.sin(this.pitch);
142
+ const desiredZ =
143
+ this.orbitCentre.z +
144
+ this.currentDistance * Math.cos(this.yaw) * Math.cos(this.pitch);
145
+
146
+ // Framerate-independent smoothing, then aim at the centre.
147
+ const alpha = Math.min(1, this.data.smoothSpeed * this.deltaTime);
148
+ this.transform.position.x += (desiredX - this.transform.position.x) * alpha;
149
+ this.transform.position.y += (desiredY - this.transform.position.y) * alpha;
150
+ this.transform.position.z += (desiredZ - this.transform.position.z) * alpha;
151
+ this.lookAt(this.orbitCentre);
152
+ }
153
+ }
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Recipe 2 — Third-person camera + character controller
159
+
160
+ Two cooperating scripts: the **camera** orbits behind the character under pointer-lock mouse control, and the **controller** moves the character relative to the camera. This is the classic third-person setup.
161
+
162
+ **Setup:**
163
+ - Attach `ThirdPersonCamera` to the **camera entity**; set `targetName` to the character entity's name.
164
+ - Attach `CharacterController` to the **character entity** (must have a `PhysicsComponent` — dynamic rigid body + capsule collider); pick the camera entity in its `cameraEntity` field.
165
+ - The character's `AnimationComponent` state names are configurable through the `animStates` fields.
166
+
167
+ ```ts
168
+ // ThirdPersonCamera.ts — attach to the CAMERA entity
169
+ import { Behaviour } from "@relu-interactives/spatial-ecs";
170
+ import type { FloatInput, TextInput } from "@relu-interactives/spatial-ecs";
171
+ import {
172
+ PhysicsComponent,
173
+ TransformComponent,
174
+ } from "@relu-interactives/spatial-ecs/components";
175
+ import { Vector3 } from "three";
176
+
177
+ export default class ThirdPersonCamera extends Behaviour {
178
+ data = {
179
+ targetName: "Player" as TextInput,
180
+ distance: 5 as FloatInput,
181
+ /** Look-at height above the character origin (aim at the upper body). */
182
+ height: 1.5 as FloatInput,
183
+ smoothSpeed: 10 as FloatInput,
184
+ mouseSensitivity: 0.003 as FloatInput,
185
+ minPitch: -0.4 as FloatInput,
186
+ maxPitch: 1.2 as FloatInput,
187
+ minDistance: 1 as FloatInput,
188
+ maxDistance: 15 as FloatInput,
189
+ };
190
+
191
+ private targetPhysics: PhysicsComponent | null = null;
192
+ private targetTransform: TransformComponent | null = null;
193
+ private yaw = 0;
194
+ private pitch = 0.4;
195
+ private currentDistance = 5;
196
+
197
+ protected init() {
198
+ this.currentDistance = this.data.distance;
199
+ this.findTarget();
200
+ }
201
+
202
+ private findTarget() {
203
+ const target = this.world.getEntityByName(this.data.targetName);
204
+ if (!target) return;
205
+ this.targetPhysics = this.world.getComponent(
206
+ target.entityId,
207
+ PhysicsComponent,
208
+ ) as PhysicsComponent | null;
209
+ this.targetTransform = this.world.getComponent(
210
+ target.entityId,
211
+ TransformComponent,
212
+ ) as TransformComponent | null;
213
+ }
214
+
215
+ protected onUpdate() {
216
+ if (!this.targetTransform) {
217
+ this.findTarget();
218
+ if (!this.targetTransform) return;
219
+ }
220
+
221
+ // Pointer lock on click; Escape releases it.
222
+ if (this.world.getMouseDown("left")) this.world.lockMousePointer();
223
+ if (this.world.getKeyDown("Escape")) this.world.unlockMousePointer();
224
+
225
+ // Orbit with the mouse while locked.
226
+ if (this.world.isMousePointerLocked()) {
227
+ const delta = this.world.consumeMouseDelta();
228
+ this.yaw -= delta.x * this.data.mouseSensitivity;
229
+ this.pitch -= delta.y * this.data.mouseSensitivity;
230
+ this.pitch = Math.max(
231
+ this.data.minPitch,
232
+ Math.min(this.data.maxPitch, this.pitch),
233
+ );
234
+ }
235
+
236
+ // Scroll to zoom.
237
+ const scroll = this.world.consumeScrollDelta();
238
+ this.currentDistance -= scroll.y * 0.01;
239
+ this.currentDistance = Math.max(
240
+ this.data.minDistance,
241
+ Math.min(this.data.maxDistance, this.currentDistance),
242
+ );
243
+
244
+ // Follow the live rigid body when present (avoids one-frame physics lag).
245
+ let tx: number, ty: number, tz: number;
246
+ if (this.targetPhysics?.rigidBody) {
247
+ const t = this.targetPhysics.rigidBody.translation();
248
+ tx = t.x; ty = t.y; tz = t.z;
249
+ } else {
250
+ tx = this.targetTransform.position.x;
251
+ ty = this.targetTransform.position.y;
252
+ tz = this.targetTransform.position.z;
253
+ }
254
+
255
+ const lookAtY = ty + this.data.height;
256
+ const desiredX =
257
+ tx + this.currentDistance * Math.sin(this.yaw) * Math.cos(this.pitch);
258
+ const desiredY = lookAtY + this.currentDistance * Math.sin(this.pitch);
259
+ const desiredZ =
260
+ tz + this.currentDistance * Math.cos(this.yaw) * Math.cos(this.pitch);
261
+
262
+ const alpha = Math.min(1, this.data.smoothSpeed * this.deltaTime);
263
+ this.transform.position.x += (desiredX - this.transform.position.x) * alpha;
264
+ this.transform.position.y += (desiredY - this.transform.position.y) * alpha;
265
+ this.transform.position.z += (desiredZ - this.transform.position.z) * alpha;
266
+
267
+ this.lookAt(new Vector3(tx, lookAtY, tz));
268
+ }
269
+
270
+ dispose() {
271
+ this.world.unlockMousePointer();
272
+ }
273
+ }
274
+ ```
275
+
276
+ ```ts
277
+ // CharacterController.ts — attach to the CHARACTER entity (needs PhysicsComponent)
278
+ import { Behaviour, KeyboardKeys } from "@relu-interactives/spatial-ecs";
279
+ import type {
280
+ EntityInput,
281
+ FloatInput,
282
+ TextInput,
283
+ WorldEntityView,
284
+ } from "@relu-interactives/spatial-ecs";
285
+ import {
286
+ AnimationComponent,
287
+ PhysicsComponent,
288
+ TransformComponent,
289
+ } from "@relu-interactives/spatial-ecs/components";
290
+ import { Euler, Quaternion } from "three";
291
+
292
+ export default class CharacterController extends Behaviour {
293
+ data = {
294
+ walkSpeed: 4 as FloatInput,
295
+ sprintSpeed: 8 as FloatInput,
296
+ rotationSpeed: 8 as FloatInput,
297
+ /** Pick the camera entity in the inspector. */
298
+ cameraEntity: { type: "entity", value: null } as unknown as EntityInput,
299
+ // Animation state names (must match states on the AnimationComponent).
300
+ idleState: "idle" as TextInput,
301
+ runState: "run_forward" as TextInput,
302
+ sprintState: "sprint" as TextInput,
303
+ };
304
+
305
+ // Own the Y facing angle — never read it back from the rigid-body
306
+ // quaternion (Euler extraction gimbal-flips at some orientations).
307
+ private facingY = 0;
308
+
309
+ protected init() {
310
+ const physics = this.getComponent(PhysicsComponent);
311
+ // Lock physical tipping; we drive yaw manually.
312
+ physics?.rigidBody?.setEnabledRotations(false, true, false, true);
313
+ this.facingY = this.transform.rotation.y;
314
+ }
315
+
316
+ protected onUpdate() {
317
+ const rb = this.getComponent(PhysicsComponent)?.rigidBody;
318
+ if (!rb) return;
319
+
320
+ // Camera-relative forward/right on the XZ plane.
321
+ const camEntity = this.data.cameraEntity.value as WorldEntityView | null;
322
+ if (!camEntity) return;
323
+ const camTransform = this.world.getComponent(
324
+ camEntity.entityId,
325
+ TransformComponent,
326
+ ) as TransformComponent | null;
327
+ if (!camTransform) return;
328
+
329
+ const myPos = rb.translation();
330
+ let fwdX = 0, fwdZ = -1, rightX = 1, rightZ = 0;
331
+ const dx = myPos.x - camTransform.position.x;
332
+ const dz = myPos.z - camTransform.position.z;
333
+ const len = Math.sqrt(dx * dx + dz * dz);
334
+ if (len > 0.001) {
335
+ fwdX = dx / len; fwdZ = dz / len;
336
+ rightX = -fwdZ; rightZ = fwdX;
337
+ }
338
+
339
+ const w = this.world.getKey(KeyboardKeys.W);
340
+ const s = this.world.getKey(KeyboardKeys.S);
341
+ const a = this.world.getKey(KeyboardKeys.A);
342
+ const d = this.world.getKey(KeyboardKeys.D);
343
+ const sprint = this.world.getKey(KeyboardKeys.ShiftLeft);
344
+
345
+ let moveX = 0, moveZ = 0;
346
+ if (w) { moveX += fwdX; moveZ += fwdZ; }
347
+ if (s) { moveX -= fwdX; moveZ -= fwdZ; }
348
+ if (d) { moveX += rightX; moveZ += rightZ; }
349
+ if (a) { moveX -= rightX; moveZ -= rightZ; }
350
+
351
+ const moveLen = Math.sqrt(moveX * moveX + moveZ * moveZ);
352
+ const isMoving = moveLen > 0.001;
353
+ if (isMoving) { moveX /= moveLen; moveZ /= moveLen; }
354
+
355
+ // Horizontal velocity; PRESERVE vel.y so gravity keeps working.
356
+ const speed = sprint && w && !s ? this.data.sprintSpeed : this.data.walkSpeed;
357
+ const vel = rb.linvel();
358
+ rb.setLinvel(
359
+ { x: isMoving ? moveX * speed : 0, y: vel.y, z: isMoving ? moveZ * speed : 0 },
360
+ true,
361
+ );
362
+
363
+ // Smoothly rotate to face camera-forward (shortest way around).
364
+ const targetAngle = Math.atan2(fwdX, fwdZ);
365
+ let diff = targetAngle - this.facingY;
366
+ while (diff > Math.PI) diff -= 2 * Math.PI;
367
+ while (diff < -Math.PI) diff += 2 * Math.PI;
368
+ this.facingY += diff * Math.min(1, this.data.rotationSpeed * this.deltaTime);
369
+ const q = new Quaternion().setFromEuler(new Euler(0, this.facingY, 0));
370
+ rb.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, false);
371
+
372
+ // Animation — retry switchState until external clips finish loading.
373
+ const anim = this.getComponent(AnimationComponent);
374
+ let stateName = this.data.idleState;
375
+ if (isMoving) stateName = sprint && w && !s ? this.data.sprintState : this.data.runState;
376
+ if (anim && anim.activeStateName !== stateName) anim.switchState(stateName);
377
+ }
378
+ }
379
+ ```
380
+
381
+ ---
382
+
383
+ ## Recipe 3 — First-person controller
384
+
385
+ Mouse-look with pointer lock, WASD movement on a physics body, jump, and the camera riding at eye height. The **camera entity is the player's head**; a separate body entity carries the physics.
386
+
387
+ **Setup:** attach to the **camera entity**. Create a body entity (e.g. an invisible capsule) named per `bodyName` with a `PhysicsComponent` (dynamic rigid body + capsule collider). Without a body the script falls back to direct (fly-style) movement.
388
+
389
+ ```ts
390
+ import { Behaviour, KeyboardKeys } from "@relu-interactives/spatial-ecs";
391
+ import type { FloatInput, TextInput } from "@relu-interactives/spatial-ecs";
392
+ import { PhysicsComponent } from "@relu-interactives/spatial-ecs/components";
393
+ import * as THREE from "three";
394
+
395
+ export default class FirstPersonController extends Behaviour {
396
+ data = {
397
+ bodyName: "Player" as TextInput,
398
+ moveSpeed: 4 as FloatInput,
399
+ sprintSpeed: 6.5 as FloatInput,
400
+ lookSensitivity: 2 as FloatInput,
401
+ jumpForce: 8 as FloatInput,
402
+ /** Y of the capsule centre when standing on the ground (for the ground check). */
403
+ groundLevel: 0.85 as FloatInput,
404
+ /** Camera height above the capsule centre. */
405
+ eyeHeight: 0.85 as FloatInput,
406
+ };
407
+
408
+ private isLocked = false;
409
+ // YXZ order: yaw first, then pitch — the FPS standard (no roll drift).
410
+ private euler = new THREE.Euler(0, 0, 0, "YXZ");
411
+ private forward = new THREE.Vector3();
412
+ private right = new THREE.Vector3();
413
+ private camera: THREE.PerspectiveCamera | null = null;
414
+ private bodyPhysics: PhysicsComponent | null = null;
415
+
416
+ protected init() {
417
+ this.camera = this.world.getCamera() as THREE.PerspectiveCamera;
418
+ this.camera.rotation.order = "YXZ";
419
+ this.euler.setFromQuaternion(this.camera.quaternion);
420
+ this.transform.rotation.x = this.euler.x;
421
+ this.transform.rotation.y = this.euler.y;
422
+ this.transform.rotation.z = this.euler.z;
423
+ }
424
+
425
+ protected onUpdate() {
426
+ // Pointer lock on click; detect external unlock (Escape).
427
+ if (!this.isLocked && this.world.getMouseDown("left")) {
428
+ this.world.lockMousePointer();
429
+ this.isLocked = true;
430
+ }
431
+ if (this.isLocked && !this.world.isMousePointerLocked()) this.isLocked = false;
432
+ if (!this.isLocked) return;
433
+
434
+ // Lazy-resolve the physics body.
435
+ if (!this.bodyPhysics) {
436
+ const body = this.world.getEntityByName(this.data.bodyName);
437
+ if (body) {
438
+ this.bodyPhysics = this.world.getComponent(
439
+ body.entityId,
440
+ PhysicsComponent,
441
+ ) as PhysicsComponent | null;
442
+ }
443
+ }
444
+ const rb = this.bodyPhysics?.rigidBody ?? null;
445
+
446
+ // Mouse look — yaw + pitch, pitch clamped just short of straight up/down.
447
+ const delta = this.world.consumeMouseDelta();
448
+ if (delta.x !== 0 || delta.y !== 0) {
449
+ this.euler.y -= delta.x * 0.002 * this.data.lookSensitivity;
450
+ this.euler.x -= delta.y * 0.002 * this.data.lookSensitivity;
451
+ this.euler.x = Math.max(
452
+ -Math.PI / 2 + 0.01,
453
+ Math.min(Math.PI / 2 - 0.01, this.euler.x),
454
+ );
455
+ this.transform.rotation.x = this.euler.x;
456
+ this.transform.rotation.y = this.euler.y;
457
+ this.transform.rotation.z = this.euler.z;
458
+ }
459
+
460
+ // Movement basis from camera yaw only — strafing stays horizontal.
461
+ this.forward.set(0, 0, -1).applyQuaternion(this.camera!.quaternion);
462
+ this.forward.y = 0;
463
+ this.forward.normalize();
464
+ this.right.set(1, 0, 0).applyQuaternion(this.camera!.quaternion);
465
+ this.right.y = 0;
466
+ this.right.normalize();
467
+
468
+ let mx = 0, mz = 0;
469
+ if (this.world.getKey(KeyboardKeys.W)) { mx += this.forward.x; mz += this.forward.z; }
470
+ if (this.world.getKey(KeyboardKeys.S)) { mx -= this.forward.x; mz -= this.forward.z; }
471
+ if (this.world.getKey(KeyboardKeys.A)) { mx -= this.right.x; mz -= this.right.z; }
472
+ if (this.world.getKey(KeyboardKeys.D)) { mx += this.right.x; mz += this.right.z; }
473
+ const len = Math.sqrt(mx * mx + mz * mz);
474
+ if (len > 0) { mx /= len; mz /= len; }
475
+
476
+ const sprinting =
477
+ this.world.getKey(KeyboardKeys.ShiftLeft) ||
478
+ this.world.getKey(KeyboardKeys.ShiftRight);
479
+ const speed = sprinting ? this.data.sprintSpeed : this.data.moveSpeed;
480
+
481
+ if (rb) {
482
+ const vel = rb.linvel();
483
+ const pos = rb.translation();
484
+
485
+ // Horizontal velocity; preserve vel.y so gravity keeps working.
486
+ rb.setLinvel({ x: mx * speed, y: vel.y, z: mz * speed }, true);
487
+
488
+ // Grounded: capsule centre near standing height and not rising.
489
+ const isGrounded = pos.y <= this.data.groundLevel + 0.08 && vel.y < 0.5;
490
+ if (isGrounded && this.world.getKeyDown(KeyboardKeys.Space)) {
491
+ rb.setLinvel({ x: vel.x, y: this.data.jumpForce, z: vel.z }, true);
492
+ }
493
+
494
+ // Camera rides the body at eye height.
495
+ this.transform.position.x = pos.x;
496
+ this.transform.position.y = pos.y + this.data.eyeHeight;
497
+ this.transform.position.z = pos.z;
498
+ } else {
499
+ // No body: direct fly-style movement.
500
+ this.transform.position.x += mx * speed * this.deltaTime;
501
+ this.transform.position.z += mz * speed * this.deltaTime;
502
+ }
503
+ }
504
+
505
+ dispose() {
506
+ if (this.isLocked) this.world.unlockMousePointer();
507
+ }
508
+ }
509
+ ```
510
+
511
+ **Optional additions:** a crosshair can be parented to the camera object (`this.world.getCamera().add(mesh)` with `depthTest: false`, positioned at `z: -0.5`) — remove it in `dispose()`. One-shot sounds can be spawned with `getObjectManagementSystem().requestCreate("audio", { path, options: { autoplay: true } }, …)`.
512
+
513
+ ---
514
+
515
+ ## Common mistakes
516
+
517
+ | Mistake | Why it fails | Do instead |
518
+ |---|---|---|
519
+ | Mutating `this.threeRef.object.position/rotation` | `TransformSyncSystem` overwrites it every frame | Mutate `this.transform` |
520
+ | `world.input.getKey(...)` | There is no `world.input` | `this.world.getKey(KeyboardKeys.W)` |
521
+ | Reading yaw back from a quaternion each frame | Euler extraction gimbal-flips | Own yaw/pitch as private numbers |
522
+ | `rb.setLinvel({ x, y: 0, z })` | Kills gravity — the body floats | Preserve `vel.y` |
523
+ | Degrees for rotation | Everything is radians | `Math.PI / 2`, not `90` |
524
+ | Consuming mouse delta only while dragging | Deltas accumulate → jump on click | `consumeMouseDelta()` every frame, apply conditionally |
525
+ | `onInit()` lifecycle hook | Never called — the hook is `init()` | `protected init() { … }` |
@@ -53,12 +53,12 @@ Transition between an idle and a walk state based on arrow-key input. The cross-
53
53
 
54
54
  ```typescript
55
55
  import { Behaviour, AnimationComponent, KeyboardKeys } from "@relu-interactives/spatial-ecs";
56
- import type { StringInput, FloatInput } from "@relu-interactives/spatial-ecs";
56
+ import type { TextInput, FloatInput } from "@relu-interactives/spatial-ecs";
57
57
 
58
58
  export default class AnimationController extends Behaviour {
59
59
  data = {
60
- idleState: "Idle" as StringInput,
61
- walkState: "Walk" as StringInput,
60
+ idleState: "Idle" as TextInput,
61
+ walkState: "Walk" as TextInput,
62
62
  crossFadeDuration: 0.3 as FloatInput,
63
63
  };
64
64
 
@@ -91,12 +91,12 @@ Drive `timeScale` and `paused` live from inspector fields, useful for slow-motio
91
91
 
92
92
  ```typescript
93
93
  import { Behaviour, AnimationComponent } from "@relu-interactives/spatial-ecs";
94
- import type { FloatInput, BooleanInput } from "@relu-interactives/spatial-ecs";
94
+ import type { FloatInput, ToggleInput } from "@relu-interactives/spatial-ecs";
95
95
 
96
96
  export default class AnimationSpeedDriver extends Behaviour {
97
97
  data = {
98
98
  timeScale: 1 as FloatInput,
99
- paused: false as BooleanInput,
99
+ paused: false as ToggleInput,
100
100
  };
101
101
 
102
102
  protected onUpdate() {