@waica/engine 0.28.0 → 0.29.1

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 (39) hide show
  1. package/README.md +29 -8
  2. package/dist/anchored-pieces.js +21 -5
  3. package/dist/archetype.d.ts +15 -0
  4. package/dist/archetype.js +41 -1
  5. package/dist/camera-projection.d.ts +6 -5
  6. package/dist/camera-projection.js +8 -7
  7. package/dist/camera-rig.d.ts +32 -3
  8. package/dist/camera-rig.js +75 -1
  9. package/dist/components/animator.d.ts +6 -0
  10. package/dist/components/animator.js +12 -0
  11. package/dist/entity-update-schedules.d.ts +21 -0
  12. package/dist/entity-update-schedules.js +67 -0
  13. package/dist/game.d.ts +13 -11
  14. package/dist/game.js +40 -74
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.js +2 -0
  17. package/dist/input-mouse.d.ts +83 -0
  18. package/dist/input-mouse.js +205 -0
  19. package/dist/input.d.ts +26 -5
  20. package/dist/input.js +58 -10
  21. package/dist/orbit-camera.d.ts +68 -0
  22. package/dist/orbit-camera.js +92 -0
  23. package/dist/physics-3d/world-queries.d.ts +10 -1
  24. package/dist/physics-3d/world-queries.js +6 -2
  25. package/dist/runtime-bridge.d.ts +12 -2
  26. package/dist/runtime-bridge.js +12 -1
  27. package/dist/runtime-inspection.js +3 -2
  28. package/dist/runtime-view-snapshot.d.ts +5 -8
  29. package/dist/runtime-view-snapshot.js +13 -10
  30. package/dist/scene-camera-3d.d.ts +14 -15
  31. package/dist/scene-camera-3d.js +14 -10
  32. package/dist/scene-camera-orbit.d.ts +72 -0
  33. package/dist/scene-camera-orbit.js +155 -0
  34. package/dist/scene-field-issues.d.ts +16 -0
  35. package/dist/scene-field-issues.js +11 -0
  36. package/dist/scene.js +5 -3
  37. package/dist/spatial-query-3d.d.ts +2 -2
  38. package/dist/spatial-query-3d.js +13 -4
  39. package/package.json +1 -1
package/dist/input.d.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { type MouseInputOptions, type MouseMovement } from './input-mouse.js';
1
2
  export type ActionName = string;
2
3
  export type InjectedActionOperation = 'press' | 'hold' | 'release';
3
- /** Action → source codes: `KeyboardEvent.code` values and `Gamepad:<control>` codes (ADR 0023). */
4
+ /** Action → source codes: `KeyboardEvent.code` values, `Gamepad:<control>` codes (ADR 0023) and `Mouse:Left`/`Mouse:Right`. */
4
5
  export type InputBindings = Record<string, string[]>;
5
6
  /** Neutral engine baseline; archetypes own their action vocabulary. */
6
7
  export declare const DEFAULT_BINDINGS: Readonly<InputBindings>;
@@ -15,14 +16,16 @@ export type InjectedValueCheck = {
15
16
  * takes a value, a finite number in (0, 1]; an absent value always passes.
16
17
  */
17
18
  export declare function checkInjectedValue(operation: InjectedActionOperation, value: unknown): InjectedValueCheck;
18
- export interface InputOptions {
19
+ export interface InputOptions extends MouseInputOptions {
19
20
  /** Radial stick dead zone in [0, 1); absent or invalid means 0.2. */
20
21
  gamepadDeadZone?: number;
21
22
  }
22
23
  /**
23
- * Action-based input with archetype default bindings. Sources are keys and
24
- * player 1's standard gamepad (ADR 0023); every Action has a 0..1 value, the
25
- * max across its sources, and is held at 0.5 or above. Touch is still TODO.
24
+ * Action-based input with archetype default bindings. Sources are keys,
25
+ * player 1's standard gamepad (ADR 0023) and the mouse buttons on the game
26
+ * canvas; every Action has a 0..1 value, the max across its sources, and is
27
+ * held at 0.5 or above. The mouse also reports its locked movement and the
28
+ * wheel per Simulation Step (issue #181). Touch is still TODO.
26
29
  */
27
30
  export declare class Input {
28
31
  private readonly bindings;
@@ -40,6 +43,8 @@ export declare class Input {
40
43
  private readonly disarmed;
41
44
  /** Actions held when the last step closed: a press is a step that starts from none of them (CA-5). */
42
45
  private heldAtStepEnd;
46
+ /** Mouse buttons press `Mouse:` codes into the same sets keys use, so they behave exactly as keys. */
47
+ private readonly mouse;
43
48
  /** Installs exactly the action map supplied by the active archetype/project. */
44
49
  constructor(bindings?: Readonly<InputBindings>, options?: InputOptions);
45
50
  /** Is the action held this frame (some source at 0.5 or above)? */
@@ -84,6 +89,20 @@ export declare class Input {
84
89
  * before component updates.
85
90
  */
86
91
  pollGamepad(): void;
92
+ /** Freezes the mouse movement and wheel since the last step; the Game calls this beside pollGamepad(). */
93
+ pollMouse(): void;
94
+ /** The mouse movement of this Simulation Step while the pointer is locked to the canvas, in CSS px; zero between steps. */
95
+ mouseMovement(): MouseMovement;
96
+ /** The wheel delta of this Simulation Step over the canvas, in CSS px (positive scrolls down); zero between steps. */
97
+ wheelDelta(): number;
98
+ /** Adds a mouse movement to the next step as if the pointer were locked (the Runtime Bridge's `look`). */
99
+ injectLook(dx: number, dy: number): void;
100
+ /** Gives the pointer back if it is locked to the game canvas; a lock elsewhere is not the game's. */
101
+ releasePointerLock(): void;
102
+ /** Asks the browser to lock the pointer to the game canvas. */
103
+ requestPointerLock(): void;
104
+ /** Is the pointer locked to the game canvas? */
105
+ get pointerLocked(): boolean;
87
106
  /**
88
107
  * Marks this frame's press of the action as spent, so consumers that
89
108
  * honor consumption (state-machine 'input:' triggers) ignore it. One
@@ -102,5 +121,7 @@ export declare class Input {
102
121
  private releaseAll;
103
122
  private onVisibilityChange;
104
123
  private onKeyDown;
124
+ /** A key or mouse button went down. */
125
+ private press;
105
126
  private onKeyUp;
106
127
  }
package/dist/input.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ACTION_HELD_THRESHOLD, connectedGamepads, GAMEPAD_CODE_PREFIX, gamepadControl, gamepadValues, GamepadSlot, resolveGamepadDeadZone, } from './gamepad.js';
2
+ import { isUnknownMouseCode, MouseInput } from './input-mouse.js';
2
3
  /** Neutral engine baseline; archetypes own their action vocabulary. */
3
4
  export const DEFAULT_BINDINGS = {};
4
5
  /**
@@ -16,9 +17,11 @@ export function checkInjectedValue(operation, value) {
16
17
  return { value };
17
18
  }
18
19
  /**
19
- * Action-based input with archetype default bindings. Sources are keys and
20
- * player 1's standard gamepad (ADR 0023); every Action has a 0..1 value, the
21
- * max across its sources, and is held at 0.5 or above. Touch is still TODO.
20
+ * Action-based input with archetype default bindings. Sources are keys,
21
+ * player 1's standard gamepad (ADR 0023) and the mouse buttons on the game
22
+ * canvas; every Action has a 0..1 value, the max across its sources, and is
23
+ * held at 0.5 or above. The mouse also reports its locked movement and the
24
+ * wheel per Simulation Step (issue #181). Touch is still TODO.
22
25
  */
23
26
  export class Input {
24
27
  bindings = new Map();
@@ -36,17 +39,25 @@ export class Input {
36
39
  disarmed = new Set();
37
40
  /** Actions held when the last step closed: a press is a step that starts from none of them (CA-5). */
38
41
  heldAtStepEnd = new Set();
42
+ /** Mouse buttons press `Mouse:` codes into the same sets keys use, so they behave exactly as keys. */
43
+ mouse;
39
44
  /** Installs exactly the action map supplied by the active archetype/project. */
40
45
  constructor(bindings = DEFAULT_BINDINGS, options = {}) {
41
46
  const warned = new Set();
42
47
  for (const [action, codes] of Object.entries(bindings)) {
43
48
  this.bindings.set(action, new Set(codes));
44
49
  for (const code of codes)
45
- warnUnknownPadCode(code, warned);
50
+ warnUnknownCode(code, warned);
46
51
  }
47
52
  this.deadZone = resolveGamepadDeadZone(options.gamepadDeadZone, (message) => {
48
53
  console.warn(message);
49
54
  });
55
+ const keyLike = {
56
+ press: (code) => this.press(code),
57
+ release: (code) => this.down.delete(code),
58
+ binds: (code) => [...this.bindings.values()].some((codes) => codes.has(code)),
59
+ };
60
+ this.mouse = new MouseInput(keyLike, options);
50
61
  window.addEventListener('keydown', this.onKeyDown);
51
62
  window.addEventListener('keyup', this.onKeyUp);
52
63
  window.addEventListener('blur', this.releaseAll);
@@ -161,6 +172,34 @@ export class Input {
161
172
  }
162
173
  this.padValues = raw;
163
174
  }
175
+ /** Freezes the mouse movement and wheel since the last step; the Game calls this beside pollGamepad(). */
176
+ pollMouse() {
177
+ this.mouse.poll();
178
+ }
179
+ /** The mouse movement of this Simulation Step while the pointer is locked to the canvas, in CSS px; zero between steps. */
180
+ mouseMovement() {
181
+ return this.mouse.movement();
182
+ }
183
+ /** The wheel delta of this Simulation Step over the canvas, in CSS px (positive scrolls down); zero between steps. */
184
+ wheelDelta() {
185
+ return this.mouse.wheelDelta();
186
+ }
187
+ /** Adds a mouse movement to the next step as if the pointer were locked (the Runtime Bridge's `look`). */
188
+ injectLook(dx, dy) {
189
+ this.mouse.inject(dx, dy);
190
+ }
191
+ /** Gives the pointer back if it is locked to the game canvas; a lock elsewhere is not the game's. */
192
+ releasePointerLock() {
193
+ this.mouse.releaseLock();
194
+ }
195
+ /** Asks the browser to lock the pointer to the game canvas. */
196
+ requestPointerLock() {
197
+ this.mouse.requestLock();
198
+ }
199
+ /** Is the pointer locked to the game canvas? */
200
+ get pointerLocked() {
201
+ return this.mouse.locked;
202
+ }
164
203
  /**
165
204
  * Marks this frame's press of the action as spent, so consumers that
166
205
  * honor consumption (state-machine 'input:' triggers) ignore it. One
@@ -180,12 +219,14 @@ export class Input {
180
219
  this.injectedJustDown.clear();
181
220
  this.injectedPresses.clear();
182
221
  this.used.clear();
222
+ this.mouse.endStep();
183
223
  this.heldAtStepEnd = new Set(this.heldActions());
184
224
  }
185
225
  dispose() {
186
226
  this.injectedHolds.clear();
187
227
  this.injectedJustDown.clear();
188
228
  this.injectedPresses.clear();
229
+ this.mouse.dispose();
189
230
  window.removeEventListener('keydown', this.onKeyDown);
190
231
  window.removeEventListener('keyup', this.onKeyUp);
191
232
  window.removeEventListener('blur', this.releaseAll);
@@ -220,17 +261,24 @@ export class Input {
220
261
  onKeyDown = (e) => {
221
262
  if (e.repeat)
222
263
  return;
223
- this.down.add(e.code);
224
- this.justDown.add(e.code);
264
+ this.press(e.code);
225
265
  };
266
+ /** A key or mouse button went down. */
267
+ press(code) {
268
+ this.down.add(code);
269
+ this.justDown.add(code);
270
+ }
226
271
  onKeyUp = (e) => {
227
272
  this.down.delete(e.code);
228
273
  };
229
274
  }
230
- /** An unknown `Gamepad:` code never matches; it is reported once per code per Input. */
231
- function warnUnknownPadCode(code, warned) {
232
- if (!code.startsWith(GAMEPAD_CODE_PREFIX) || warned.has(code) || gamepadControl(code))
275
+ /** An unknown `Gamepad:` or `Mouse:` code never matches; it is reported once per code per Input. */
276
+ function warnUnknownCode(code, warned) {
277
+ if (warned.has(code))
278
+ return;
279
+ const unknownPad = code.startsWith(GAMEPAD_CODE_PREFIX) && !gamepadControl(code);
280
+ if (!unknownPad && !isUnknownMouseCode(code))
233
281
  return;
234
282
  warned.add(code);
235
- console.warn(`[waica] unknown gamepad binding "${code}": it never matches.`);
283
+ console.warn(`[waica] unknown ${unknownPad ? 'gamepad' : 'mouse'} binding "${code}": it never matches.`);
236
284
  }
@@ -0,0 +1,68 @@
1
+ import type * as THREE from 'three/webgpu';
2
+ import type { Entity } from './entity.js';
3
+ import type { Input } from './input.js';
4
+ import type { ResolvedOrbit } from './scene-camera-orbit.js';
5
+ /** How far in front of what blocks the view the camera stops, and how close to the look point it may come (CA-9). */
6
+ export declare const ORBIT_COLLISION_MARGIN = 0.2;
7
+ export declare const ORBIT_MIN_DISTANCE = 0.5;
8
+ /** World units of distance per CSS pixel of wheel (one ~100 px notch is one unit). */
9
+ export declare const ORBIT_WHEEL_ZOOM = 0.01;
10
+ /** A point or direction in world space. */
11
+ export interface OrbitPoint {
12
+ x: number;
13
+ y: number;
14
+ z: number;
15
+ }
16
+ /** The ray from the look point toward the camera, which skips the followed entity's own colliders. */
17
+ export interface OrbitRay {
18
+ origin: OrbitPoint;
19
+ direction: OrbitPoint;
20
+ maxDistance: number;
21
+ exclude: Entity;
22
+ }
23
+ /** What orbiting reads from the Game each Simulation Step. */
24
+ export interface OrbitHost {
25
+ find(name: string): Entity | undefined;
26
+ input(): Input;
27
+ /** The distance to the first fixed collider along the ray (level geometry: sensors, characters and dynamic bodies never block it); null when nothing is hit. */
28
+ rayDistance(ray: OrbitRay): number | null;
29
+ }
30
+ /** The orbit's state as the Runtime Snapshot's `view` reports it (CA-10). */
31
+ export interface OrbitView {
32
+ follow: string;
33
+ yaw: number;
34
+ pitch: number;
35
+ distance: number;
36
+ distanceActual: number;
37
+ }
38
+ /**
39
+ * A perspective camera orbiting a followed entity (issue #181, ADR 0031): the
40
+ * locked mouse, the orbit Actions and the wheel turn and zoom it once per
41
+ * Simulation Step, and a ray from the look point pulls it in front of a wall
42
+ * without changing the chosen distance. With no input the orbit holds.
43
+ */
44
+ export declare class OrbitCamera {
45
+ readonly settings: ResolvedOrbit;
46
+ yaw: number;
47
+ pitch: number;
48
+ /** The chosen distance: the wheel's, clamped to [minDistance, maxDistance]. */
49
+ distance: number;
50
+ /** Where the camera actually sits: the chosen distance, or less in front of a wall. */
51
+ distanceActual: number;
52
+ constructor(settings: ResolvedOrbit);
53
+ /** One Simulation Step: turn and zoom by this step's input, then place `camera`. No followed entity: nothing moves, and false. */
54
+ step(camera: THREE.PerspectiveCamera, host: OrbitHost, dt: number): boolean;
55
+ /** Places `camera` on the orbit as it stands, with no input (a scene load); false, and nothing moves, with no followed entity. */
56
+ place(camera: THREE.PerspectiveCamera, host: OrbitHost): boolean;
57
+ /** The camera at the chosen distance from the look point, or in front of what the ray hits (CA-9). */
58
+ private placeAround;
59
+ /** The orbit for the Runtime Snapshot's view. */
60
+ view(): OrbitView;
61
+ /** The step's locked mouse movement times `sensitivity`, plus the orbit Actions at `orbitSpeed`; pitch clamped. */
62
+ private turn;
63
+ /** The orbit Actions as -1..1 turns: right minus left for yaw, down minus up for pitch. */
64
+ private actionTurn;
65
+ private lookPoint;
66
+ /** The unit direction from the look point to the camera: yaw 0 puts it on +z, yaw 90 on -x. */
67
+ private awayFromLook;
68
+ }
@@ -0,0 +1,92 @@
1
+ /** How far in front of what blocks the view the camera stops, and how close to the look point it may come (CA-9). */
2
+ export const ORBIT_COLLISION_MARGIN = 0.2;
3
+ export const ORBIT_MIN_DISTANCE = 0.5;
4
+ /** World units of distance per CSS pixel of wheel (one ~100 px notch is one unit). */
5
+ export const ORBIT_WHEEL_ZOOM = 0.01;
6
+ const RAD = Math.PI / 180;
7
+ const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
8
+ /** A yaw in degrees wrapped to (-180, 180], so two equal views report the same number. */
9
+ const wrapYaw = (degrees) => degrees - 360 * Math.ceil((degrees - 180) / 360);
10
+ /**
11
+ * A perspective camera orbiting a followed entity (issue #181, ADR 0031): the
12
+ * locked mouse, the orbit Actions and the wheel turn and zoom it once per
13
+ * Simulation Step, and a ray from the look point pulls it in front of a wall
14
+ * without changing the chosen distance. With no input the orbit holds.
15
+ */
16
+ export class OrbitCamera {
17
+ settings;
18
+ yaw;
19
+ pitch;
20
+ /** The chosen distance: the wheel's, clamped to [minDistance, maxDistance]. */
21
+ distance;
22
+ /** Where the camera actually sits: the chosen distance, or less in front of a wall. */
23
+ distanceActual;
24
+ constructor(settings) {
25
+ this.settings = settings;
26
+ this.yaw = wrapYaw(settings.yaw);
27
+ this.pitch = clamp(settings.pitch, settings.minPitch, settings.maxPitch);
28
+ this.distance = clamp(settings.distance, settings.minDistance, settings.maxDistance);
29
+ this.distanceActual = this.distance;
30
+ }
31
+ /** One Simulation Step: turn and zoom by this step's input, then place `camera`. No followed entity: nothing moves, and false. */
32
+ step(camera, host, dt) {
33
+ const followed = host.find(this.settings.follow);
34
+ if (!followed)
35
+ return false;
36
+ this.turn(host.input(), dt);
37
+ this.distance = clamp(this.distance + host.input().wheelDelta() * ORBIT_WHEEL_ZOOM, this.settings.minDistance, this.settings.maxDistance);
38
+ this.placeAround(camera, host, followed);
39
+ return true;
40
+ }
41
+ /** Places `camera` on the orbit as it stands, with no input (a scene load); false, and nothing moves, with no followed entity. */
42
+ place(camera, host) {
43
+ const followed = host.find(this.settings.follow);
44
+ if (!followed)
45
+ return false;
46
+ this.placeAround(camera, host, followed);
47
+ return true;
48
+ }
49
+ /** The camera at the chosen distance from the look point, or in front of what the ray hits (CA-9). */
50
+ placeAround(camera, host, followed) {
51
+ const look = this.lookPoint(followed);
52
+ const away = this.awayFromLook();
53
+ const hit = host.rayDistance({ origin: look, direction: away, maxDistance: this.distance, exclude: followed });
54
+ this.distanceActual =
55
+ hit === null ? this.distance : Math.min(this.distance, Math.max(hit - ORBIT_COLLISION_MARGIN, ORBIT_MIN_DISTANCE));
56
+ camera.position.set(look.x + away.x * this.distanceActual, look.y + away.y * this.distanceActual, look.z + away.z * this.distanceActual);
57
+ camera.lookAt(look.x, look.y, look.z);
58
+ camera.updateMatrixWorld();
59
+ }
60
+ /** The orbit for the Runtime Snapshot's view. */
61
+ view() {
62
+ const { yaw, pitch, distance, distanceActual } = this;
63
+ return { follow: this.settings.follow, yaw, pitch, distance, distanceActual };
64
+ }
65
+ /** The step's locked mouse movement times `sensitivity`, plus the orbit Actions at `orbitSpeed`; pitch clamped. */
66
+ turn(input, dt) {
67
+ const { sensitivity, orbitSpeed, invertY, minPitch, maxPitch } = this.settings;
68
+ const mouse = input.mouseMovement();
69
+ const actions = this.actionTurn(input);
70
+ this.yaw = wrapYaw(this.yaw + mouse.x * sensitivity + actions.yaw * orbitSpeed * dt);
71
+ const mousePitch = (invertY ? -mouse.y : mouse.y) * sensitivity;
72
+ this.pitch = clamp(this.pitch + mousePitch + actions.pitch * orbitSpeed * dt, minPitch, maxPitch);
73
+ }
74
+ /** The orbit Actions as -1..1 turns: right minus left for yaw, down minus up for pitch. */
75
+ actionTurn(input) {
76
+ const { orbitLeftAction, orbitRightAction, orbitUpAction, orbitDownAction } = this.settings;
77
+ return {
78
+ yaw: input.value(orbitRightAction) - input.value(orbitLeftAction),
79
+ pitch: input.value(orbitDownAction) - input.value(orbitUpAction),
80
+ };
81
+ }
82
+ lookPoint(followed) {
83
+ const { x, y, z } = followed.position;
84
+ return { x, y: y + this.settings.lookHeight, z };
85
+ }
86
+ /** The unit direction from the look point to the camera: yaw 0 puts it on +z, yaw 90 on -x. */
87
+ awayFromLook() {
88
+ const yaw = this.yaw * RAD;
89
+ const pitch = this.pitch * RAD;
90
+ return { x: -Math.sin(yaw) * Math.cos(pitch), y: Math.sin(pitch), z: Math.cos(yaw) * Math.cos(pitch) };
91
+ }
92
+ }
@@ -6,15 +6,24 @@ export interface Point3d {
6
6
  y: number;
7
7
  z: number;
8
8
  }
9
- /** A volume `game.query.area` tests sensor colliders against. */
9
+ /**
10
+ * Which colliders `game.query.area` reads: the sensors (the default, ADR
11
+ * 0015's trigger domain) or every collider, solid ones included — what a
12
+ * melee strike hits (issue #181).
13
+ */
14
+ export type QueryColliders3d = 'sensors' | 'all';
15
+ /** A volume `game.query.area` tests colliders against; a box may turn by `yaw` degrees about +y. */
10
16
  export type QueryVolume3d = {
11
17
  shape: 'box';
12
18
  center: Point3d;
13
19
  size: Point3d;
20
+ yaw?: number;
21
+ colliders?: QueryColliders3d;
14
22
  } | {
15
23
  shape: 'sphere';
16
24
  center: Point3d;
17
25
  radius: number;
26
+ colliders?: QueryColliders3d;
18
27
  };
19
28
  /** One collider a ray reaches: whose it is, how far, where and which way its surface faces. */
20
29
  export interface WorldRayHit {
@@ -1,4 +1,8 @@
1
- const IDENTITY = { x: 0, y: 0, z: 0, w: 1 };
1
+ /** The rotation of a box turned `yaw` degrees about +y (none for a sphere or an unturned box). */
2
+ function rotationOf(volume) {
3
+ const half = volume.shape === 'box' ? ((volume.yaw ?? 0) * Math.PI) / 360 : 0;
4
+ return { x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) };
5
+ }
2
6
  /**
3
7
  * Rapier's predicate for a query: only colliders whose body record `accepts`.
4
8
  * It runs inside Rapier's borrow of the world, so `accepts` must be a plain
@@ -42,7 +46,7 @@ export function recordsInVolume(world, volume, accepts) {
42
46
  ? new R.Cuboid(volume.size.x / 2, volume.size.y / 2, volume.size.z / 2)
43
47
  : new R.Ball(volume.radius);
44
48
  const found = [];
45
- raw.intersectionsWithShape(volume.center, IDENTITY, shape, (collider) => {
49
+ raw.intersectionsWithShape(volume.center, rotationOf(volume), shape, (collider) => {
46
50
  const record = world.recordOfCollider(collider.handle);
47
51
  if (record)
48
52
  found.push(record);
@@ -25,9 +25,10 @@ export type RuntimeMode = 'paused' | 'real-time';
25
25
  * 0025) announces that every metadata carries `backend`: the Render Backend
26
26
  * once the renderer is ready, null before — a Run Session waits for it — and
27
27
  * that a renderer that fails to initialize reports through the activation's
28
- * `fail`.
28
+ * `fail`. `look` (issue #181) announces the `look` operation: a mouse delta
29
+ * the next Simulation Step reads as locked mouse movement.
29
30
  */
30
- export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets', 'camera-effects', 'analog-actions', 'render-backend'];
31
+ export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets', 'camera-effects', 'analog-actions', 'render-backend', 'look'];
31
32
  export interface RuntimeMetadata {
32
33
  bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
33
34
  engineVersion: string;
@@ -63,6 +64,12 @@ export type RuntimeControlRequest = {
63
64
  } | {
64
65
  operation: 'scene';
65
66
  scene: string;
67
+ }
68
+ /** Adds `dx`, `dy` CSS px of mouse movement to the next Simulation Step, as if the pointer were locked. */
69
+ | {
70
+ operation: 'look';
71
+ dx: number;
72
+ dy: number;
66
73
  };
67
74
  export interface RuntimeControlResult extends RuntimeMetadata {
68
75
  heldActions: string[];
@@ -108,6 +115,8 @@ export interface RuntimeBridgeHost {
108
115
  resume(onStep: () => void): void;
109
116
  pause(): void;
110
117
  injectAction(action: string, operation: 'press' | 'hold' | 'release', value?: number): boolean;
118
+ /** Adds a mouse movement to the next Simulation Step, as if the pointer were locked. */
119
+ look(dx: number, dy: number): void;
111
120
  availableActions(): string[];
112
121
  heldActions(): string[];
113
122
  actionValues(): Record<string, number>;
@@ -137,6 +146,7 @@ export declare class EngineRuntimeBridge implements RuntimeBridge {
137
146
  private injectAction;
138
147
  /** Advances whole Simulation Steps while paused. */
139
148
  private step;
149
+ private look;
140
150
  private loadScene;
141
151
  /** Counts one Simulation Step, whoever ran it (paused stepping or real-time playback). */
142
152
  private advance;
@@ -24,7 +24,8 @@ export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
24
24
  * 0025) announces that every metadata carries `backend`: the Render Backend
25
25
  * once the renderer is ready, null before — a Run Session waits for it — and
26
26
  * that a renderer that fails to initialize reports through the activation's
27
- * `fail`.
27
+ * `fail`. `look` (issue #181) announces the `look` operation: a mouse delta
28
+ * the next Simulation Step reads as locked mouse movement.
28
29
  */
29
30
  export const RUNTIME_BRIDGE_CAPABILITIES = [
30
31
  'click',
@@ -34,6 +35,7 @@ export const RUNTIME_BRIDGE_CAPABILITIES = [
34
35
  'camera-effects',
35
36
  'analog-actions',
36
37
  'render-backend',
38
+ 'look',
37
39
  ];
38
40
  export class RuntimeBridgeOperationError extends Error {
39
41
  code;
@@ -122,6 +124,9 @@ export class EngineRuntimeBridge {
122
124
  case 'scene':
123
125
  this.loadScene(request.scene);
124
126
  break;
127
+ case 'look':
128
+ this.look(request);
129
+ break;
125
130
  default: {
126
131
  const unsupported = request;
127
132
  throw new RuntimeBridgeOperationError('runtime-operation-failed', `Unsupported runtime control operation "${unsupported.operation}".`);
@@ -160,6 +165,12 @@ export class EngineRuntimeBridge {
160
165
  this.host.step(() => this.advance());
161
166
  }
162
167
  }
168
+ look(request) {
169
+ if (!Number.isFinite(request.dx) || !Number.isFinite(request.dy)) {
170
+ throw new RuntimeBridgeOperationError('runtime-operation-failed', 'dx and dy must be finite numbers.');
171
+ }
172
+ this.host.look(request.dx, request.dy);
173
+ }
163
174
  loadScene(scene) {
164
175
  if (this.host.loadScene(scene))
165
176
  return;
@@ -1,4 +1,4 @@
1
- import { cameraEffectsSnapshot, viewSnapshot } from './runtime-view-snapshot.js';
1
+ import { viewSnapshot } from './runtime-view-snapshot.js';
2
2
  import { anchoredPiecesOf } from './ui.js';
3
3
  import { componentClassOf } from './component-registry.js';
4
4
  import { capLights, lightingSnapshot, postSnapshot, } from './runtime-lighting-snapshot.js';
@@ -295,7 +295,8 @@ export class RuntimeInspector {
295
295
  audio: this.audioSnapshot(),
296
296
  time: this.timeSnapshot(),
297
297
  ui: this.uiSnapshot(projectionIssues),
298
- camera: cameraEffectsSnapshot(this.game),
298
+ // The shake offset applies in both spaces since a perspective camera shakes too (issue #181 CA-11).
299
+ camera: this.game.cameraEffects.state,
299
300
  space: this.game.space,
300
301
  view: viewSnapshot(this.game),
301
302
  lighting: lightingSnapshot(this.game, (entity) => this.idFor(entity)),
@@ -1,5 +1,5 @@
1
- import type { CameraEffectsState } from './camera-effects.js';
2
1
  import type { Game } from './game.js';
2
+ import type { OrbitView } from './orbit-camera.js';
3
3
  import type { Vec3Json } from './scene-camera-3d.js';
4
4
  /**
5
5
  * The view the scene is drawn from (issue #154 CA-16), beside the Camera
@@ -10,16 +10,13 @@ export type RuntimeSnapshotView = {
10
10
  kind: 'orthographic';
11
11
  position: [number, number];
12
12
  zoom: number;
13
- } | {
13
+ } | PerspectiveView | (PerspectiveView & OrbitView);
14
+ /** A perspective camera's pose; a following one adds its whole orbit (issue #181 CA-10), so `'follow' in view` narrows to it. */
15
+ type PerspectiveView = {
14
16
  kind: 'perspective';
15
17
  position: Vec3Json;
16
18
  target: Vec3Json;
17
19
  fov: number;
18
20
  };
19
21
  export declare function viewSnapshot(game: Game): RuntimeSnapshotView;
20
- /**
21
- * `game.cameraEffects.state` as the snapshot reports it. A perspective camera
22
- * does not shake (spec decision 24: shake is a no-op in a 3D scene), so a 3D
23
- * scene reports no offset even though the effect's own state keeps decaying.
24
- */
25
- export declare function cameraEffectsSnapshot(game: Game): CameraEffectsState;
22
+ export {};
@@ -13,19 +13,22 @@ export function viewSnapshot(game) {
13
13
  }
14
14
  camera.updateMatrixWorld();
15
15
  const ahead = camera.getWorldDirection(new THREE.Vector3()).multiplyScalar(game.cameraRig.lookDistance);
16
- return {
16
+ const pose = {
17
17
  kind: 'perspective',
18
18
  position: vector(camera.position),
19
19
  target: vector(camera.position.clone().add(ahead)),
20
20
  fov: camera.fov,
21
21
  };
22
- }
23
- /**
24
- * `game.cameraEffects.state` as the snapshot reports it. A perspective camera
25
- * does not shake (spec decision 24: shake is a no-op in a 3D scene), so a 3D
26
- * scene reports no offset even though the effect's own state keeps decaying.
27
- */
28
- export function cameraEffectsSnapshot(game) {
29
- const state = game.cameraEffects.state;
30
- return game.space === '3d' ? { ...state, shake: { x: 0, y: 0 } } : state;
22
+ const orbit = game.cameraRig.orbitView;
23
+ if (!orbit)
24
+ return pose;
25
+ // A following camera's orbit (issue #181 CA-10): the chosen distance and the placed one.
26
+ return {
27
+ ...pose,
28
+ follow: orbit.follow,
29
+ yaw: clean(orbit.yaw),
30
+ pitch: clean(orbit.pitch),
31
+ distance: clean(orbit.distance),
32
+ distanceActual: clean(orbit.distanceActual),
33
+ };
31
34
  }
@@ -1,11 +1,15 @@
1
1
  import type * as THREE from 'three/webgpu';
2
+ import { type OrbitCameraJson, type ResolvedOrbit } from './scene-camera-orbit.js';
3
+ import { type SceneFieldIssue } from './scene-field-issues.js';
2
4
  /** A world-space point or direction as scene JSON stores it. */
3
5
  export type Vec3Json = [number, number, number];
4
6
  /**
5
- * A scene's perspective camera (3D scenes, ADR 0027). Fixed by the scene; game
6
- * code may move it. Y is up and the camera looks from `position` at `target`.
7
+ * A scene's perspective camera (3D scenes, ADR 0027). Without `follow` it is
8
+ * fixed by the scene and game code may move it: Y is up and the camera looks
9
+ * from `position` at `target`. With `follow` it orbits that entity once per
10
+ * Simulation Step (issue #181, ADR 0031; the orbit fields are OrbitCameraJson's).
7
11
  */
8
- export interface PerspectiveSceneCameraJson {
12
+ export interface PerspectiveSceneCameraJson extends OrbitCameraJson {
9
13
  kind: 'perspective';
10
14
  /** Where the camera sits. Default `[0, 5, 10]`. */
11
15
  position?: Vec3Json;
@@ -18,7 +22,6 @@ export interface PerspectiveSceneCameraJson {
18
22
  /** Far clipping plane, above `near`. Default 1000. */
19
23
  far?: number;
20
24
  zoom?: never;
21
- follow?: never;
22
25
  deadzoneWidth?: never;
23
26
  deadzoneHeight?: never;
24
27
  lookahead?: never;
@@ -33,6 +36,8 @@ export interface ResolvedPerspectiveCamera {
33
36
  fov: number;
34
37
  near: number;
35
38
  far: number;
39
+ /** The orbit around `follow`; absent for a fixed camera. */
40
+ orbit?: ResolvedOrbit;
36
41
  }
37
42
  export declare const PERSPECTIVE_DEFAULTS: {
38
43
  readonly position: Vec3Json;
@@ -49,18 +54,12 @@ export declare function isPerspectiveCameraJson(json: {
49
54
  export declare function resolvePerspectiveCamera(json?: PerspectiveSceneCameraJson): ResolvedPerspectiveCamera;
50
55
  /** Points `camera` as the resolved block says, at the given aspect ratio. */
51
56
  export declare function placePerspectiveCamera(camera: THREE.PerspectiveCamera, resolved: ResolvedPerspectiveCamera, aspect: number): void;
52
- /** One field of a scene's render, camera or entity transform that is invalid, for validate_project. */
53
- export interface SceneFieldIssue {
54
- field: string;
55
- message: string;
56
- }
57
- /** A JSON value as a message quotes it: strings in quotes, everything else as `String()` shows it. */
58
- export declare const shown: (value: unknown) => string;
59
- export declare function isFiniteNumber(value: unknown): value is number;
57
+ export { isFiniteNumber, shown, type SceneFieldIssue } from './scene-field-issues.js';
60
58
  /**
61
59
  * Every field of a perspective camera block outside its range: `fov` in
62
- * (0, 180), `near` above 0, `far` above `near`, and `position` and `target`
63
- * three finite numbers, and none of the orthographic block's fields. Anything
64
- * that is not a perspective block reports none.
60
+ * (0, 180), `near` above 0, `far` above `near`, `position` and `target`
61
+ * three finite numbers, the orbit fields (orbitCameraIssues), and none of the
62
+ * orthographic block's other fields. Anything that is not a perspective block
63
+ * reports none.
65
64
  */
66
65
  export declare function perspectiveCameraIssues(camera: unknown): SceneFieldIssue[];