@relu-interactives/spatial-ecs 0.2.15 → 0.2.17

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 (35) hide show
  1. package/dist/{chunk-7QBONTBW.js → chunk-CJW6K3CL.js} +52 -39
  2. package/dist/chunk-CJW6K3CL.js.map +1 -0
  3. package/dist/{chunk-D3AKP6QQ.js → chunk-EPK6MA7X.js} +4 -5
  4. package/dist/chunk-EPK6MA7X.js.map +1 -0
  5. package/dist/{chunk-27RBPOVO.js → chunk-GRUK5TNL.js} +68 -12
  6. package/dist/chunk-GRUK5TNL.js.map +1 -0
  7. package/dist/docs/guide/accessing-components.md +9 -9
  8. package/dist/docs/guide/camera-rigs.md +525 -0
  9. package/dist/docs/guide/components/animation.md +5 -5
  10. package/dist/docs/guide/components/entity-type.md +2 -2
  11. package/dist/docs/guide/components/image.md +2 -2
  12. package/dist/docs/guide/components/instanced-mesh.md +3 -3
  13. package/dist/docs/guide/components/mesh-geometry.md +3 -3
  14. package/dist/docs/guide/components/mesh.md +3 -3
  15. package/dist/docs/guide/components/model.md +2 -2
  16. package/dist/docs/guide/components/name.md +4 -4
  17. package/dist/docs/guide/components/object3d-ref.md +2 -2
  18. package/dist/docs/guide/components/parent-id.md +2 -2
  19. package/dist/docs/guide/components/video.md +2 -2
  20. package/dist/docs/guide/world-api.md +3 -2
  21. package/dist/factories/index.cjs +196 -142
  22. package/dist/factories/index.cjs.map +1 -1
  23. package/dist/factories/index.js +2 -2
  24. package/dist/index.cjs +312 -237
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +32 -1
  27. package/dist/index.d.ts +32 -1
  28. package/dist/index.js +6 -6
  29. package/dist/systems/index.cjs +120 -118
  30. package/dist/systems/index.cjs.map +1 -1
  31. package/dist/systems/index.js +2 -2
  32. package/package.json +1 -1
  33. package/dist/chunk-27RBPOVO.js.map +0 -1
  34. package/dist/chunk-7QBONTBW.js.map +0 -1
  35. package/dist/chunk-D3AKP6QQ.js.map +0 -1
@@ -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() {
@@ -59,11 +59,11 @@ Iterate all entities in the scene and apply logic only to those of a specific ty
59
59
 
60
60
  ```typescript
61
61
  import { Behaviour, EntityType, TransformComponent } from "@relu-interactives/spatial-ecs";
62
- import type { StringInput, FloatInput } from "@relu-interactives/spatial-ecs";
62
+ import type { TextInput, FloatInput } from "@relu-interactives/spatial-ecs";
63
63
 
64
64
  export default class TypeFilter extends Behaviour {
65
65
  data = {
66
- targetKind: "cube" as StringInput,
66
+ targetKind: "cube" as TextInput,
67
67
  spinSpeed: 1 as FloatInput,
68
68
  };
69
69
 
@@ -70,11 +70,11 @@ Drive the quad's visibility from the inspector or from game logic via `Object3DR
70
70
 
71
71
  ```typescript
72
72
  import { Behaviour, ImageComponent, Object3DRef } from "@relu-interactives/spatial-ecs";
73
- import type { BooleanInput } from "@relu-interactives/spatial-ecs";
73
+ import type { ToggleInput } from "@relu-interactives/spatial-ecs";
74
74
 
75
75
  export default class ImageVisibility extends Behaviour {
76
76
  data = {
77
- visible: true as BooleanInput,
77
+ visible: true as ToggleInput,
78
78
  };
79
79
 
80
80
  protected onUpdate() {
@@ -63,12 +63,12 @@ export default class InstanceCounter extends Behaviour {
63
63
 
64
64
  ```typescript
65
65
  import { Behaviour, InstancedMeshComponent } from "@relu-interactives/spatial-ecs";
66
- import type { BooleanInput } from "@relu-interactives/spatial-ecs";
66
+ import type { ToggleInput } from "@relu-interactives/spatial-ecs";
67
67
 
68
68
  export default class ToggleShadows extends Behaviour {
69
69
  data = {
70
- castShadow: true as BooleanInput,
71
- receiveShadow: true as BooleanInput,
70
+ castShadow: true as ToggleInput,
71
+ receiveShadow: true as ToggleInput,
72
72
  };
73
73
 
74
74
  protected init() {
@@ -44,12 +44,12 @@ Let the inspector or game logic control whether the mesh casts and receives shad
44
44
 
45
45
  ```typescript
46
46
  import { Behaviour, MeshGeometryComponent } from "@relu-interactives/spatial-ecs";
47
- import type { BooleanInput } from "@relu-interactives/spatial-ecs";
47
+ import type { ToggleInput } from "@relu-interactives/spatial-ecs";
48
48
 
49
49
  export default class ShadowDriver extends Behaviour {
50
50
  data = {
51
- castShadow: true as BooleanInput,
52
- receiveShadow: true as BooleanInput,
51
+ castShadow: true as ToggleInput,
52
+ receiveShadow: true as ToggleInput,
53
53
  };
54
54
 
55
55
  protected onUpdate() {
@@ -40,12 +40,12 @@ Walk the mesh hierarchy and enable or disable shadow casting based on a boolean
40
40
 
41
41
  ```typescript
42
42
  import { Behaviour, MeshComponent } from "@relu-interactives/spatial-ecs";
43
- import type { BooleanInput } from "@relu-interactives/spatial-ecs";
43
+ import type { ToggleInput } from "@relu-interactives/spatial-ecs";
44
44
 
45
45
  export default class ShadowToggle extends Behaviour {
46
46
  data = {
47
- castShadow: true as BooleanInput,
48
- receiveShadow: true as BooleanInput,
47
+ castShadow: true as ToggleInput,
48
+ receiveShadow: true as ToggleInput,
49
49
  };
50
50
 
51
51
  protected onUpdate() {
@@ -73,11 +73,11 @@ Show or hide the loaded model at runtime using the `Object3DRef` visibility flag
73
73
 
74
74
  ```typescript
75
75
  import { Behaviour, ModelComponent, Object3DRef } from "@relu-interactives/spatial-ecs";
76
- import type { BooleanInput } from "@relu-interactives/spatial-ecs";
76
+ import type { ToggleInput } from "@relu-interactives/spatial-ecs";
77
77
 
78
78
  export default class ModelVisibility extends Behaviour {
79
79
  data = {
80
- visible: true as BooleanInput,
80
+ visible: true as ToggleInput,
81
81
  };
82
82
 
83
83
  protected onUpdate() {
@@ -40,11 +40,11 @@ Rename the entity at runtime from an inspector string field. Useful for dynamic
40
40
 
41
41
  ```typescript
42
42
  import { Behaviour, Name } from "@relu-interactives/spatial-ecs";
43
- import type { StringInput } from "@relu-interactives/spatial-ecs";
43
+ import type { TextInput } from "@relu-interactives/spatial-ecs";
44
44
 
45
45
  export default class EntityRenamer extends Behaviour {
46
46
  data = {
47
- displayName: "My Entity" as StringInput,
47
+ displayName: "My Entity" as TextInput,
48
48
  };
49
49
 
50
50
  protected onUpdate() {
@@ -62,11 +62,11 @@ Query all `Name` components to locate a sibling entity and read its position.
62
62
 
63
63
  ```typescript
64
64
  import { Behaviour, Name, TransformComponent } from "@relu-interactives/spatial-ecs";
65
- import type { StringInput } from "@relu-interactives/spatial-ecs";
65
+ import type { TextInput } from "@relu-interactives/spatial-ecs";
66
66
 
67
67
  export default class LookAtTarget extends Behaviour {
68
68
  data = {
69
- targetName: "Target" as StringInput,
69
+ targetName: "Target" as TextInput,
70
70
  };
71
71
 
72
72
  protected onUpdate() {