@waica/engine 0.27.0 → 0.29.0

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 (43) hide show
  1. package/README.md +30 -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/assets/glb-clip-names.d.ts +8 -0
  6. package/dist/assets/glb-clip-names.js +49 -0
  7. package/dist/camera-projection.d.ts +6 -5
  8. package/dist/camera-projection.js +8 -7
  9. package/dist/camera-rig.d.ts +54 -4
  10. package/dist/camera-rig.js +116 -6
  11. package/dist/components/animator.d.ts +6 -0
  12. package/dist/components/animator.js +12 -0
  13. package/dist/entity-update-schedules.d.ts +21 -0
  14. package/dist/entity-update-schedules.js +67 -0
  15. package/dist/game.d.ts +14 -12
  16. package/dist/game.js +41 -75
  17. package/dist/index.d.ts +10 -2
  18. package/dist/index.js +4 -0
  19. package/dist/input-mouse.d.ts +83 -0
  20. package/dist/input-mouse.js +205 -0
  21. package/dist/input.d.ts +26 -5
  22. package/dist/input.js +58 -10
  23. package/dist/orbit-camera.d.ts +68 -0
  24. package/dist/orbit-camera.js +92 -0
  25. package/dist/physics-3d/world-queries.d.ts +10 -1
  26. package/dist/physics-3d/world-queries.js +6 -2
  27. package/dist/pointer-raycast.d.ts +24 -6
  28. package/dist/pointer-raycast.js +44 -14
  29. package/dist/runtime-bridge.d.ts +12 -2
  30. package/dist/runtime-bridge.js +12 -1
  31. package/dist/runtime-inspection.js +3 -2
  32. package/dist/runtime-view-snapshot.d.ts +5 -8
  33. package/dist/runtime-view-snapshot.js +15 -11
  34. package/dist/scene-camera-3d.d.ts +14 -15
  35. package/dist/scene-camera-3d.js +14 -10
  36. package/dist/scene-camera-orbit.d.ts +72 -0
  37. package/dist/scene-camera-orbit.js +155 -0
  38. package/dist/scene-field-issues.d.ts +16 -0
  39. package/dist/scene-field-issues.js +11 -0
  40. package/dist/scene.js +5 -3
  41. package/dist/spatial-query-3d.d.ts +2 -2
  42. package/dist/spatial-query-3d.js +13 -4
  43. package/package.json +1 -1
@@ -0,0 +1,205 @@
1
+ /** The prefix of a mouse button's binding code, beside keys and `Gamepad:` codes (issue #181). */
2
+ export const MOUSE_CODE_PREFIX = 'Mouse:';
3
+ /** `PointerEvent.button` → the binding code of the buttons an Action can bind. */
4
+ const MOUSE_BUTTON_CODES = new Map([
5
+ [0, 'Mouse:Left'],
6
+ [2, 'Mouse:Right'],
7
+ ]);
8
+ const KNOWN_MOUSE_CODES = new Set(MOUSE_BUTTON_CODES.values());
9
+ /** `PointerEvent.button` → its bit in `PointerEvent.buttons`, which tells a chorded press from a chorded release. */
10
+ const MOUSE_BUTTON_BITS = new Map([
11
+ [0, 1],
12
+ [2, 2],
13
+ ]);
14
+ /** Wheel deltas reported in lines or pages, in CSS pixels (the units of `deltaMode` 0). */
15
+ const WHEEL_LINE_PX = 16;
16
+ const WHEEL_PAGE_PX = 800;
17
+ /** Touch and pen pointers are not the mouse: `Mouse:` codes ignore them (no mobile support, issue #181). */
18
+ const fromMouse = (event) => event.pointerType !== 'touch' && event.pointerType !== 'pen';
19
+ /** True for a `Mouse:` code no button reports, which therefore never matches. */
20
+ export function isUnknownMouseCode(code) {
21
+ return code.startsWith(MOUSE_CODE_PREFIX) && !KNOWN_MOUSE_CODES.has(code);
22
+ }
23
+ function wheelPixels(event) {
24
+ if (event.deltaMode === 1)
25
+ return event.deltaY * WHEEL_LINE_PX;
26
+ if (event.deltaMode === 2)
27
+ return event.deltaY * WHEEL_PAGE_PX;
28
+ return event.deltaY;
29
+ }
30
+ /**
31
+ * The mouse as an Input source (issue #181): its buttons as `Mouse:` binding
32
+ * codes, its movement while the pointer is locked to the game canvas, the
33
+ * wheel over the canvas, and the pointer lock itself. Movement and wheel
34
+ * accumulate between Simulation Steps, are frozen by `poll()` at a step's
35
+ * start and read zero again after `endStep()`.
36
+ */
37
+ export class MouseInput {
38
+ buttons;
39
+ pendingMove = { x: 0, y: 0 };
40
+ pendingWheel = 0;
41
+ move = { x: 0, y: 0 };
42
+ wheel = 0;
43
+ preventsWheelScroll;
44
+ canvas;
45
+ lockOnClick;
46
+ /** Set by dispose(): a lock the browser grants after it is given straight back. */
47
+ disposed = false;
48
+ /** The codes whose press asked for the lock, awaiting the browser's answer: true while still held. */
49
+ askedLock = new Map();
50
+ constructor(buttons, options = {}) {
51
+ this.buttons = buttons;
52
+ this.canvas = options.canvas ?? null;
53
+ this.lockOnClick = options.lockOnClick ?? (() => false);
54
+ this.preventsWheelScroll = options.preventsWheelScroll ?? (() => false);
55
+ this.canvas?.addEventListener('pointerdown', this.onPointerDown);
56
+ this.canvas?.addEventListener('wheel', this.onWheel, { passive: false });
57
+ this.canvas?.addEventListener('contextmenu', this.onContextMenu);
58
+ // A release anywhere counts, so a button let go off the canvas never stays held.
59
+ window.addEventListener('pointerup', this.onPointerUp);
60
+ window.addEventListener('pointermove', this.onPointerChord);
61
+ document.addEventListener('mousemove', this.onMouseMove);
62
+ }
63
+ /** Is the pointer locked to the game canvas right now? */
64
+ get locked() {
65
+ return this.canvas !== null && this.lockElement() === this.canvas;
66
+ }
67
+ /** The locked movement of the current Simulation Step; zero between steps. */
68
+ movement() {
69
+ return this.move;
70
+ }
71
+ /** The wheel delta of the current Simulation Step, in CSS px (positive scrolls down); zero between steps. */
72
+ wheelDelta() {
73
+ return this.wheel;
74
+ }
75
+ /** Freezes what accumulated since the last step as this step's movement and wheel. */
76
+ poll() {
77
+ this.move = { ...this.pendingMove };
78
+ this.wheel = this.pendingWheel;
79
+ this.pendingMove = { x: 0, y: 0 };
80
+ this.pendingWheel = 0;
81
+ }
82
+ /** The step is over: its movement and wheel read zero until the next poll. */
83
+ endStep() {
84
+ this.move = { x: 0, y: 0 };
85
+ this.wheel = 0;
86
+ }
87
+ /** Adds a movement as if the pointer were locked (the Runtime Bridge's `look`, CA-4). */
88
+ inject(dx, dy) {
89
+ this.pendingMove.x += dx;
90
+ this.pendingMove.y += dy;
91
+ }
92
+ /** Exits the pointer lock when the game canvas holds it; a lock on any other element is left alone. */
93
+ releaseLock() {
94
+ if (this.locked && 'exitPointerLock' in document)
95
+ document.exitPointerLock();
96
+ }
97
+ /** Asks the browser to lock the pointer to the game canvas; a refusal (or no Pointer Lock API) leaves it unlocked. */
98
+ requestLock() {
99
+ const canvas = this.canvas;
100
+ if (!canvas || !('requestPointerLock' in canvas)) {
101
+ this.pressRefused();
102
+ return;
103
+ }
104
+ try {
105
+ Promise.resolve(canvas.requestPointerLock()).then(() => {
106
+ // The press that asked took the lock, and presses nothing (CA-3).
107
+ this.askedLock.clear();
108
+ // Granted after this Input went away (the editor's Play stopped meanwhile): the lock is no longer the game's.
109
+ if (this.disposed)
110
+ this.releaseLock();
111
+ }, () => {
112
+ // Refused (no user gesture, a sandboxed iframe, the cooldown after Esc): the press goes through; the next click asks again.
113
+ this.pressRefused();
114
+ });
115
+ }
116
+ catch {
117
+ // Older browsers throw synchronously instead of rejecting; same outcome.
118
+ this.pressRefused();
119
+ }
120
+ }
121
+ /**
122
+ * The presses that asked for a lock the browser refused (or cannot give)
123
+ * reach their `Mouse:` codes after all (maintainer decision on issue #181):
124
+ * still held, or as a tap when the button was let go before the answer.
125
+ */
126
+ pressRefused() {
127
+ for (const [code, held] of this.askedLock) {
128
+ this.buttons.press(code);
129
+ if (!held)
130
+ this.buttons.release(code);
131
+ }
132
+ this.askedLock.clear();
133
+ }
134
+ /** Stops listening, and releases the pointer lock if this canvas holds it (a Game that goes away, the editor's Play stopping). */
135
+ dispose() {
136
+ this.disposed = true;
137
+ this.releaseLock();
138
+ this.canvas?.removeEventListener('pointerdown', this.onPointerDown);
139
+ this.canvas?.removeEventListener('wheel', this.onWheel);
140
+ window.removeEventListener('pointerup', this.onPointerUp);
141
+ document.removeEventListener('mousemove', this.onMouseMove);
142
+ this.canvas?.removeEventListener('contextmenu', this.onContextMenu);
143
+ window.removeEventListener('pointermove', this.onPointerChord);
144
+ }
145
+ /** `document.pointerLockElement`, which a browser without the Pointer Lock API (or a test DOM) lacks. */
146
+ lockElement() {
147
+ return 'pointerLockElement' in document ? document.pointerLockElement : null;
148
+ }
149
+ onPointerDown = (event) => {
150
+ if (!fromMouse(event))
151
+ return;
152
+ const code = MOUSE_BUTTON_CODES.get(event.button);
153
+ if (!this.locked && this.lockOnClick()) {
154
+ // This press asks for the lock (G-17): it reaches its code only if the browser refuses.
155
+ if (code)
156
+ this.askedLock.set(code, true);
157
+ this.requestLock();
158
+ return;
159
+ }
160
+ if (code)
161
+ this.buttons.press(code);
162
+ };
163
+ onPointerUp = (event) => {
164
+ if (!fromMouse(event))
165
+ return;
166
+ const code = MOUSE_BUTTON_CODES.get(event.button);
167
+ if (!code)
168
+ return;
169
+ // Let go before the lock's answer: a refusal then delivers a tap.
170
+ if (this.askedLock.has(code))
171
+ this.askedLock.set(code, false);
172
+ this.buttons.release(code);
173
+ };
174
+ /**
175
+ * A button going down or up while another is held fires `pointermove`, not
176
+ * `pointerdown`/`pointerup` (Pointer Events' chorded buttons). Its bit in
177
+ * `buttons` tells which; a press counts over the canvas only, like the first.
178
+ */
179
+ onPointerChord = (event) => {
180
+ const bit = MOUSE_BUTTON_BITS.get(event.button);
181
+ if (bit === undefined || !fromMouse(event))
182
+ return;
183
+ if ((event.buttons & bit) === 0)
184
+ this.onPointerUp(event);
185
+ else if (event.target === this.canvas)
186
+ this.onPointerDown(event);
187
+ };
188
+ /** The browser menu would swallow the release of a bound `Mouse:Right`; other games keep their menu. */
189
+ onContextMenu = (event) => {
190
+ if (this.buttons.binds('Mouse:Right'))
191
+ event.preventDefault();
192
+ };
193
+ onWheel = (event) => {
194
+ // Over the game canvas only, and only while something zooms with it (an orbit camera, issue #181): otherwise the page still scrolls.
195
+ if (this.preventsWheelScroll())
196
+ event.preventDefault();
197
+ this.pendingWheel += wheelPixels(event);
198
+ };
199
+ onMouseMove = (event) => {
200
+ if (!this.locked)
201
+ return;
202
+ this.pendingMove.x += event.movementX;
203
+ this.pendingMove.y += event.movementY;
204
+ };
205
+ }
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);
@@ -1,17 +1,35 @@
1
1
  import * as THREE from 'three/webgpu';
2
2
  import type { Entity } from './entity.js';
3
- import type { PointerPick } from './pointer.js';
4
3
  /** A position inside the letterboxed game viewport, as fractions of its width and height. */
5
4
  export interface ViewportFraction {
6
5
  nx: number;
7
6
  ny: number;
8
7
  }
8
+ /** What an extra target picks: an entity (an editor's icon for it), or the scene camera's icon. */
9
+ export type PickedTarget = Entity | 'camera';
10
+ /** An object a ray is also tested against, and what a hit on it picks (issue #173 CA-2). */
11
+ export interface PickTarget<Picked extends PickedTarget = PickedTarget> {
12
+ readonly object: THREE.Object3D;
13
+ readonly entity: Picked;
14
+ }
15
+ /** A 3D pick: the hit's world point, and the entity (or extra target) hit, or null on the ground. */
16
+ export interface RaycastPick<Picked extends PickedTarget = Entity> {
17
+ readonly point: {
18
+ readonly x: number;
19
+ readonly y: number;
20
+ readonly z: number;
21
+ };
22
+ readonly entity: Entity | Picked | null;
23
+ }
9
24
  /**
10
25
  * The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
11
26
  * letterboxed point, intersected with the roots of every live Model
12
- * (recursively, so a glTF's meshes count and a glTF still loading does not).
13
- * The nearest hit wins, with its world-space point. With no hit the pick has
14
- * no entity and the point where the ray meets the ground plane y = 0; a ray
15
- * that never meets it (it looks at the sky) yields no pick.
27
+ * (recursively, so a glTF's meshes count, posed where its skeleton is, and a
28
+ * glTF still loading does not) and with the `extra` targets a host adds (the
29
+ * editor's icons, issue #173 CA-2; one that is hidden, or whose entity is
30
+ * dead, is skipped). The nearest hit wins, with its
31
+ * world-space point. With no hit the pick has no entity and the point where
32
+ * the ray meets the ground plane y = 0; a ray that never meets it (it looks
33
+ * at the sky) yields no pick.
16
34
  */
17
- export declare function raycastPick(camera: THREE.PerspectiveCamera, at: ViewportFraction, entities: readonly Entity[]): PointerPick | null;
35
+ export declare function raycastPick<Picked extends PickedTarget = Entity>(camera: THREE.PerspectiveCamera, at: ViewportFraction, entities: readonly Entity[], extra?: readonly PickTarget<Picked>[]): RaycastPick<Picked> | null;