@waica/engine 0.25.0 → 0.27.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 (114) hide show
  1. package/README.md +77 -1
  2. package/dist/anchored-pieces.d.ts +3 -2
  3. package/dist/anchored-pieces.js +12 -8
  4. package/dist/animation/clip-player.d.ts +7 -0
  5. package/dist/animation/clip-player.js +9 -0
  6. package/dist/animation/clip-target.d.ts +38 -0
  7. package/dist/animation/clip-target.js +9 -0
  8. package/dist/animation/pose-hash.d.ts +10 -0
  9. package/dist/animation/pose-hash.js +43 -0
  10. package/dist/archetype.d.ts +2 -2
  11. package/dist/assets/asset-loader.d.ts +28 -5
  12. package/dist/assets/asset-loader.js +63 -7
  13. package/dist/assets/model-backend.d.ts +21 -0
  14. package/dist/assets/model-backend.js +21 -0
  15. package/dist/assets/model-cache.d.ts +47 -0
  16. package/dist/assets/model-cache.js +117 -0
  17. package/dist/audio/audio-subsystem.d.ts +6 -1
  18. package/dist/audio/audio-subsystem.js +8 -15
  19. package/dist/audio/placement.d.ts +41 -0
  20. package/dist/audio/placement.js +21 -0
  21. package/dist/audio/spatial.d.ts +20 -0
  22. package/dist/audio/spatial.js +26 -0
  23. package/dist/audio/types.d.ts +7 -1
  24. package/dist/camera-projection.d.ts +38 -0
  25. package/dist/camera-projection.js +44 -0
  26. package/dist/camera-rig.d.ts +48 -0
  27. package/dist/camera-rig.js +98 -0
  28. package/dist/camera.d.ts +20 -3
  29. package/dist/camera.js +3 -1
  30. package/dist/component.d.ts +38 -2
  31. package/dist/component.js +3 -0
  32. package/dist/components/animated-sprite.d.ts +14 -3
  33. package/dist/components/animated-sprite.js +16 -2
  34. package/dist/components/animator.d.ts +98 -0
  35. package/dist/components/animator.js +197 -0
  36. package/dist/components/collider.d.ts +89 -0
  37. package/dist/components/collider.js +57 -0
  38. package/dist/components/dynamic-body.d.ts +2 -1
  39. package/dist/components/dynamic-body.js +1 -0
  40. package/dist/components/hitbox.d.ts +2 -1
  41. package/dist/components/hitbox.js +1 -0
  42. package/dist/components/light.d.ts +2 -1
  43. package/dist/components/light.js +1 -0
  44. package/dist/components/model.d.ts +71 -0
  45. package/dist/components/model.js +128 -0
  46. package/dist/components/particle-emitter.d.ts +2 -1
  47. package/dist/components/particle-emitter.js +1 -0
  48. package/dist/components/point-light.d.ts +75 -0
  49. package/dist/components/point-light.js +102 -0
  50. package/dist/components/rigid-body.d.ts +114 -0
  51. package/dist/components/rigid-body.js +83 -0
  52. package/dist/components/solid.d.ts +2 -1
  53. package/dist/components/solid.js +1 -0
  54. package/dist/components/sprite.d.ts +2 -1
  55. package/dist/components/sprite.js +1 -0
  56. package/dist/components/sun.d.ts +46 -0
  57. package/dist/components/sun.js +74 -0
  58. package/dist/components/tilemap.d.ts +2 -1
  59. package/dist/components/tilemap.js +1 -0
  60. package/dist/entity-transform.d.ts +18 -0
  61. package/dist/entity-transform.js +17 -0
  62. package/dist/game.d.ts +51 -15
  63. package/dist/game.js +83 -70
  64. package/dist/index.d.ts +34 -4
  65. package/dist/index.js +15 -1
  66. package/dist/lit-frame.js +10 -3
  67. package/dist/physics-3d/body-contacts.d.ts +10 -0
  68. package/dist/physics-3d/body-contacts.js +59 -0
  69. package/dist/physics-3d/body-sync.d.ts +52 -0
  70. package/dist/physics-3d/body-sync.js +153 -0
  71. package/dist/physics-3d/character-controller.d.ts +44 -0
  72. package/dist/physics-3d/character-controller.js +126 -0
  73. package/dist/physics-3d/physics-host.d.ts +86 -0
  74. package/dist/physics-3d/physics-host.js +174 -0
  75. package/dist/physics-3d/physics-world.d.ts +65 -0
  76. package/dist/physics-3d/physics-world.js +158 -0
  77. package/dist/physics-3d/rapier-module.d.ts +17 -0
  78. package/dist/physics-3d/rapier-module.js +19 -0
  79. package/dist/physics-3d/sensor-dispatch.d.ts +10 -0
  80. package/dist/physics-3d/sensor-dispatch.js +77 -0
  81. package/dist/physics-3d/world-queries.d.ts +39 -0
  82. package/dist/physics-3d/world-queries.js +63 -0
  83. package/dist/pointer-raycast.d.ts +17 -0
  84. package/dist/pointer-raycast.js +48 -0
  85. package/dist/pointer.d.ts +27 -6
  86. package/dist/pointer.js +43 -17
  87. package/dist/runtime-bridge.d.ts +1 -1
  88. package/dist/runtime-inspection.d.ts +9 -0
  89. package/dist/runtime-inspection.js +8 -2
  90. package/dist/runtime-lighting-snapshot.d.ts +27 -1
  91. package/dist/runtime-lighting-snapshot.js +31 -0
  92. package/dist/runtime-physics-snapshot.d.ts +41 -0
  93. package/dist/runtime-physics-snapshot.js +45 -0
  94. package/dist/runtime-view-snapshot.d.ts +25 -0
  95. package/dist/runtime-view-snapshot.js +30 -0
  96. package/dist/scene-camera-3d.d.ts +66 -0
  97. package/dist/scene-camera-3d.js +117 -0
  98. package/dist/scene-lights-3d.d.ts +17 -0
  99. package/dist/scene-lights-3d.js +27 -0
  100. package/dist/scene-simulation.d.ts +22 -0
  101. package/dist/scene-simulation.js +29 -0
  102. package/dist/scene-space.d.ts +50 -0
  103. package/dist/scene-space.js +103 -0
  104. package/dist/scene.d.ts +12 -2
  105. package/dist/scene.js +11 -4
  106. package/dist/simulation-phases.d.ts +9 -0
  107. package/dist/simulation-phases.js +19 -0
  108. package/dist/spatial-query-3d.d.ts +48 -0
  109. package/dist/spatial-query-3d.js +96 -0
  110. package/dist/spatial-query.d.ts +18 -4
  111. package/dist/spatial-query.js +57 -1
  112. package/dist/state/state-machine.d.ts +32 -8
  113. package/dist/state/state-machine.js +88 -27
  114. package/package.json +2 -1
package/dist/camera.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as THREE from 'three/webgpu';
2
+ import { resolvePerspectiveCamera, } from './scene-camera-3d.js';
2
3
  export const CAMERA_DEFAULTS = {
3
4
  position: [0, 0],
4
5
  zoom: 12,
@@ -9,8 +10,9 @@ export const CAMERA_DEFAULTS = {
9
10
  lookaheadY: 0,
10
11
  smoothing: 6,
11
12
  };
12
- /** Fills a scene's camera block with the engine defaults. */
13
13
  export function resolveSceneCamera(json) {
14
+ if (json?.kind === 'perspective')
15
+ return resolvePerspectiveCamera(json);
14
16
  return {
15
17
  position: json?.position ?? CAMERA_DEFAULTS.position,
16
18
  zoom: json?.zoom ?? CAMERA_DEFAULTS.zoom,
@@ -8,12 +8,21 @@ export interface ParamSpec {
8
8
  max?: number;
9
9
  step?: number;
10
10
  /** Specialized editor control for values that are otherwise plain JSON. */
11
- kind?: 'string-list' | 'vector2' | 'color' | 'texture';
11
+ kind?: 'string-list' | 'vector2' | 'vector3' | 'color' | 'texture' | 'model';
12
12
  /** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
13
13
  options?: string[];
14
14
  /** Project value this string param names; rendered and validated as a typed reference. */
15
15
  ref?: 'prefab' | 'stat' | 'action' | 'clip' | 'sound' | 'ui';
16
16
  }
17
+ /**
18
+ * The scene space a component belongs to (ADR 0027): `'2d'` components draw,
19
+ * collide or light the orthographic world, move or query over its x/y plane,
20
+ * or need a sibling that does; `'3d'` ones need a perspective camera;
21
+ * `'both'` run anywhere. A component that declares nothing is `'both'`.
22
+ */
23
+ export type ComponentSpace = '2d' | '3d' | 'both';
24
+ /** The values of `ComponentSpace`, for the checks that read a marker from data (the project component protocol, conformance). */
25
+ export declare const COMPONENT_SPACES: readonly ComponentSpace[];
17
26
  export interface ComponentClass<T extends Component = Component> {
18
27
  new (): T;
19
28
  /**
@@ -27,6 +36,8 @@ export interface ComponentClass<T extends Component = Component> {
27
36
  params?: Record<string, ParamSpec>;
28
37
  /** Sibling component updates that must complete before this one when present. */
29
38
  updateAfter?: readonly string[];
39
+ /** The scene space this component belongs to; absent means both. */
40
+ space?: ComponentSpace;
30
41
  /**
31
42
  * Instance fields holding runtime state rather than authorable defaults.
32
43
  * Excluded from authoringDefaults(); a subclass that does not redeclare
@@ -47,6 +58,25 @@ export interface SolidContact {
47
58
  readonly axis: 'x' | 'y';
48
59
  readonly normal: ContactNormal;
49
60
  }
61
+ /** A 3D contact between two entities' solid Colliders, read after the physics step. */
62
+ export interface BodyContact {
63
+ /** The entity whose component receives the hook. */
64
+ readonly entity: Entity;
65
+ /** The entity it is in contact with. */
66
+ readonly other: Entity;
67
+ /** Unit normal in world space, pointing from `entity` to `other`. */
68
+ readonly normal: {
69
+ readonly x: number;
70
+ readonly y: number;
71
+ readonly z: number;
72
+ };
73
+ /** A world-space point on the contact. */
74
+ readonly point: {
75
+ readonly x: number;
76
+ readonly y: number;
77
+ readonly z: number;
78
+ };
79
+ }
50
80
  /**
51
81
  * A pluggable piece of an entity. User behaviors and engine ones are the
52
82
  * same thing: Component subclasses with public props.
@@ -56,6 +86,7 @@ export declare abstract class Component {
56
86
  static displayName?: string;
57
87
  static params?: Record<string, ParamSpec>;
58
88
  static updateAfter?: readonly string[];
89
+ static space?: ComponentSpace;
59
90
  static transient?: readonly string[];
60
91
  entity: Entity;
61
92
  game: Game;
@@ -67,8 +98,13 @@ export declare abstract class Component {
67
98
  onUpdate?(dt: number): void;
68
99
  /** Runs after the scene changes between identity and projected rendering. */
69
100
  onProjectionChange?(projection: 'isometric' | null): void;
70
- /** Runs on overlap when this entity's Hitbox mask names the other's layer. */
101
+ /**
102
+ * Runs on overlap when this entity's Hitbox (2D) or sensor Collider (3D)
103
+ * mask names the other's layer.
104
+ */
71
105
  onCollide?(other: Entity): void;
106
+ /** Runs once per Simulation Step for each entity this entity's solid Collider is in contact with (3D, issue #159). */
107
+ onBodyContact?(contact: BodyContact): void;
72
108
  /** Runs when this entity's DynamicBody physically contacts a Solid. */
73
109
  onContact?(contact: SolidContact): void;
74
110
  /**
package/dist/component.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** The values of `ComponentSpace`, for the checks that read a marker from data (the project component protocol, conformance). */
2
+ export const COMPONENT_SPACES = ['2d', '3d', 'both'];
1
3
  /**
2
4
  * A pluggable piece of an entity. User behaviors and engine ones are the
3
5
  * same thing: Component subclasses with public props.
@@ -7,6 +9,7 @@ export class Component {
7
9
  static displayName;
8
10
  static params;
9
11
  static updateAfter;
12
+ static space;
10
13
  static transient;
11
14
  entity;
12
15
  game;
@@ -1,5 +1,6 @@
1
- import { Component } from '../component.js';
1
+ import { Component, type ComponentSpace } from '../component.js';
2
2
  import { type ClipDef } from '../animation/clip-player.js';
3
+ import type { ClipTarget } from '../animation/clip-target.js';
3
4
  import { type SheetCell, type SheetDef } from '../animation/sheet.js';
4
5
  import type { YSortParticipant } from '../render-sort.js';
5
6
  /**
@@ -12,8 +13,9 @@ import type { YSortParticipant } from '../render-sort.js';
12
13
  * per frame, anchored bottom-center — width/height size the sheet's largest
13
14
  * frame and smaller ones keep their feet planted.
14
15
  */
15
- export declare class AnimatedSprite extends Component implements YSortParticipant {
16
+ export declare class AnimatedSprite extends Component implements YSortParticipant, ClipTarget {
16
17
  static componentName: string;
18
+ static space: ComponentSpace;
17
19
  static updateAfter: readonly string[];
18
20
  static params: {
19
21
  offsetX: {
@@ -95,6 +97,10 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
95
97
  initialClip?: string;
96
98
  /** Clip currently playing. */
97
99
  current?: string;
100
+ /** The names a StateMachine can ask for (the Clip Target seam, issue #166). */
101
+ get clipNames(): readonly string[];
102
+ /** True once a non-looping clip has held its last frame for its whole duration; false after `play`. */
103
+ get clipFinished(): boolean;
98
104
  private readonly player;
99
105
  private sheets;
100
106
  private texs;
@@ -104,7 +110,12 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
104
110
  private frameScaleX;
105
111
  private frameScaleY;
106
112
  onReady(): void;
107
- /** Switches clip; repeating the same name doesn't restart the animation. */
113
+ /**
114
+ * Switches clip. Repeating the running clip's name doesn't restart it; once
115
+ * a non-looping clip has finished it does (issue #166 CA-3), so a state
116
+ * re-entered after its one-shot clip ended replays it instead of reporting
117
+ * `clip:end` on every step.
118
+ */
108
119
  play(name: string): void;
109
120
  onUpdate(dt: number): void;
110
121
  onDestroy(): void;
@@ -21,6 +21,7 @@ const clampAnchor = (value) => Math.min(1, Math.max(0, value));
21
21
  */
22
22
  export class AnimatedSprite extends Component {
23
23
  static componentName = 'AnimatedSprite';
24
+ static space = '2d';
24
25
  static updateAfter = ['StateMachine'];
25
26
  static params = {
26
27
  offsetX: { label: 'x offset' },
@@ -150,6 +151,14 @@ export class AnimatedSprite extends Component {
150
151
  initialClip;
151
152
  /** Clip currently playing. */
152
153
  current;
154
+ /** The names a StateMachine can ask for (the Clip Target seam, issue #166). */
155
+ get clipNames() {
156
+ return Object.keys(this.clips);
157
+ }
158
+ /** True once a non-looping clip has held its last frame for its whole duration; false after `play`. */
159
+ get clipFinished() {
160
+ return this.player.finished;
161
+ }
153
162
  player = new ClipPlayer();
154
163
  sheets = [];
155
164
  texs = [];
@@ -205,9 +214,14 @@ export class AnimatedSprite extends Component {
205
214
  if (this.initialClip)
206
215
  this.play(this.initialClip);
207
216
  }
208
- /** Switches clip; repeating the same name doesn't restart the animation. */
217
+ /**
218
+ * Switches clip. Repeating the running clip's name doesn't restart it; once
219
+ * a non-looping clip has finished it does (issue #166 CA-3), so a state
220
+ * re-entered after its one-shot clip ended replays it instead of reporting
221
+ * `clip:end` on every step.
222
+ */
209
223
  play(name) {
210
- if (this.current === name)
224
+ if (this.current === name && !this.player.finished)
211
225
  return;
212
226
  const clip = this.clips[name];
213
227
  if (!clip)
@@ -0,0 +1,98 @@
1
+ import type { ClipTarget } from '../animation/clip-target.js';
2
+ import { Component, type ComponentSpace } from '../component.js';
3
+ /** Per-clip overrides, keyed by the glTF's clip name: a one-shot (`loop: false`) and a playback rate. */
4
+ export interface AnimatorClipOverride {
5
+ /** Defaults to true; false plays the clip once and holds its last pose. */
6
+ loop?: boolean;
7
+ /** Playback rate; 1 by default, 2 finishes in half the time. */
8
+ speed?: number;
9
+ }
10
+ /** What `inspect_runtime` shows of an Animator. */
11
+ export interface AnimatorState {
12
+ loaded: boolean;
13
+ /** The playing clip; null before the first play (an undefined would project as an unsupported marker). */
14
+ current: string | null;
15
+ /** The current action's time in seconds (0 with none). */
16
+ time: number;
17
+ /** False before the model loaded or with no clip playing. */
18
+ playing: boolean;
19
+ /** The pose fingerprint of the rig (see poseHash); empty with no skeleton. */
20
+ poseHash: string;
21
+ }
22
+ /**
23
+ * Plays the glTF clips of the Model beside it (issue #166, ADR 0029): the
24
+ * 3D Clip Target. One AnimationMixer over the Model's own clone, one action
25
+ * per clip, advanced by the Simulation Step in onUpdate — nothing runs per
26
+ * render frame, so the pose is deterministic and visible step by step. A
27
+ * StateMachine finds it like it finds an AnimatedSprite and plays the clip of
28
+ * each state; `clip:end` fires when a `loop: false` clip finishes. A `play`
29
+ * before the model loaded is remembered and applied when it settles;
30
+ * otherwise `initialClip` plays then. In a 2D scene it creates nothing.
31
+ */
32
+ export declare class Animator extends Component implements ClipTarget {
33
+ static componentName: string;
34
+ static space: ComponentSpace;
35
+ static updateAfter: readonly string[];
36
+ static params: {
37
+ initialClip: {
38
+ label: string;
39
+ };
40
+ fade: {
41
+ label: string;
42
+ min: number;
43
+ max: number;
44
+ step: number;
45
+ };
46
+ };
47
+ static transient: string[];
48
+ /** The clip that plays once the model settles, when nothing asked for one before. */
49
+ initialClip: string;
50
+ /** Crossfade between clips, in seconds; 0 cuts. */
51
+ fade: number;
52
+ /**
53
+ * Per-clip overrides by glTF clip name (authored in JSON, like
54
+ * AnimatedSprite.clips). Read when the actions are built, as the model
55
+ * settles: a change after that applies on the next build (a Model rebuild).
56
+ */
57
+ clips: Record<string, AnimatorClipOverride>;
58
+ /** The clip playing now; undefined before the first play. */
59
+ current: string | undefined;
60
+ private mixer;
61
+ private readonly actions;
62
+ private active;
63
+ /** A play asked for before the model loaded. */
64
+ private requested;
65
+ private finished;
66
+ private root;
67
+ private readonly warned;
68
+ private attached;
69
+ /** The Model handle the mixer was built over, or is waiting on: a Model rebuild replaces it (review of PR #168). */
70
+ private handle;
71
+ /** The clip names, computed once per build: the StateMachine reads them every step. */
72
+ private names;
73
+ /** True while clips are expected but not listed yet (the glb is loading); false once built, or once no Model can provide them. */
74
+ private pending;
75
+ get clipNames(): readonly string[];
76
+ get clipFinished(): boolean;
77
+ get clipsPending(): boolean;
78
+ onReady(): void;
79
+ /** Plays a clip: a no-op for the running one, a restart for a finished one, a crossfade from any other over `fade` seconds. */
80
+ play(name: string): void;
81
+ onUpdate(dt: number): void;
82
+ onDestroy(): void;
83
+ inspectState(): AnimatorState;
84
+ /**
85
+ * Takes the sibling Model's handle and builds the mixer when it settles. A
86
+ * Model later in the component order is not there at onReady, so the first
87
+ * onUpdate tries once more (`final`); a primitive Model warns at once, a
88
+ * missing one once it is certain, and either way exactly once.
89
+ */
90
+ private attach;
91
+ /** Forgets the Model's old clone and keeps the clip it played as the request for the next build. */
92
+ private detach;
93
+ /** Stops and drops the mixer, its actions and the clone they ran over. */
94
+ private release;
95
+ private build;
96
+ private configure;
97
+ private warnOnce;
98
+ }
@@ -0,0 +1,197 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import { poseHash } from '../animation/pose-hash.js';
3
+ import { Component } from '../component.js';
4
+ import { reportRejection } from '../report-rejection.js';
5
+ import { Model } from './model.js';
6
+ /**
7
+ * Plays the glTF clips of the Model beside it (issue #166, ADR 0029): the
8
+ * 3D Clip Target. One AnimationMixer over the Model's own clone, one action
9
+ * per clip, advanced by the Simulation Step in onUpdate — nothing runs per
10
+ * render frame, so the pose is deterministic and visible step by step. A
11
+ * StateMachine finds it like it finds an AnimatedSprite and plays the clip of
12
+ * each state; `clip:end` fires when a `loop: false` clip finishes. A `play`
13
+ * before the model loaded is remembered and applied when it settles;
14
+ * otherwise `initialClip` plays then. In a 2D scene it creates nothing.
15
+ */
16
+ export class Animator extends Component {
17
+ static componentName = 'Animator';
18
+ static space = '3d';
19
+ static updateAfter = ['StateMachine'];
20
+ static params = {
21
+ initialClip: { label: 'Initial clip' },
22
+ fade: { label: 'Fade (s)', min: 0, max: 1, step: 0.05 },
23
+ };
24
+ static transient = ['current', 'mixer', 'actions', 'active', 'requested', 'finished', 'root', 'warned', 'attached', 'handle', 'names', 'pending'];
25
+ /** The clip that plays once the model settles, when nothing asked for one before. */
26
+ initialClip = '';
27
+ /** Crossfade between clips, in seconds; 0 cuts. */
28
+ fade = 0.15;
29
+ /**
30
+ * Per-clip overrides by glTF clip name (authored in JSON, like
31
+ * AnimatedSprite.clips). Read when the actions are built, as the model
32
+ * settles: a change after that applies on the next build (a Model rebuild).
33
+ */
34
+ clips = {};
35
+ /** The clip playing now; undefined before the first play. */
36
+ current;
37
+ mixer = null;
38
+ actions = new Map();
39
+ active = null;
40
+ /** A play asked for before the model loaded. */
41
+ requested;
42
+ finished = false;
43
+ root = null;
44
+ warned = new Set();
45
+ attached = false;
46
+ /** The Model handle the mixer was built over, or is waiting on: a Model rebuild replaces it (review of PR #168). */
47
+ handle = null;
48
+ /** The clip names, computed once per build: the StateMachine reads them every step. */
49
+ names = [];
50
+ /** True while clips are expected but not listed yet (the glb is loading); false once built, or once no Model can provide them. */
51
+ pending = false;
52
+ get clipNames() {
53
+ return this.names;
54
+ }
55
+ get clipFinished() {
56
+ return this.finished;
57
+ }
58
+ get clipsPending() {
59
+ return this.pending;
60
+ }
61
+ onReady() {
62
+ if (this.game.space !== '3d')
63
+ return;
64
+ this.pending = true;
65
+ this.attach();
66
+ }
67
+ /** Plays a clip: a no-op for the running one, a restart for a finished one, a crossfade from any other over `fade` seconds. */
68
+ play(name) {
69
+ if (this.game.space !== '3d')
70
+ return;
71
+ if (!this.mixer) {
72
+ this.requested = name;
73
+ return;
74
+ }
75
+ const action = this.actions.get(name);
76
+ if (!action) {
77
+ this.warnOnce(name, `[waica] "${this.entity.name}": no clip "${name}"`);
78
+ return;
79
+ }
80
+ if (action === this.active && !this.finished)
81
+ return;
82
+ const previous = this.active;
83
+ this.active = action;
84
+ this.current = name;
85
+ this.finished = false;
86
+ action.reset().setEffectiveWeight(1).play();
87
+ if (!previous || previous === action)
88
+ return;
89
+ if (this.fade <= 0)
90
+ previous.stop();
91
+ else
92
+ previous.crossFadeTo(action, this.fade, false);
93
+ }
94
+ onUpdate(dt) {
95
+ if (this.game.space !== '3d')
96
+ return;
97
+ // A rebuilt Model (a new src or shape) draws a new clone: the mixer over
98
+ // the old one would animate a graph nobody draws, so it is torn down and
99
+ // the clip it was playing is asked for again on the new one.
100
+ if (this.attached && (this.entity.get(Model)?.handle ?? null) !== this.handle)
101
+ this.detach();
102
+ if (!this.attached)
103
+ this.attach(true);
104
+ this.mixer?.update(dt);
105
+ }
106
+ onDestroy() {
107
+ this.release();
108
+ this.pending = false;
109
+ }
110
+ inspectState() {
111
+ return {
112
+ loaded: this.mixer !== null,
113
+ current: this.current ?? null,
114
+ time: this.active?.time ?? 0,
115
+ playing: this.active !== null && this.active.isRunning(),
116
+ poseHash: this.root ? poseHash(this.root) : '',
117
+ };
118
+ }
119
+ /**
120
+ * Takes the sibling Model's handle and builds the mixer when it settles. A
121
+ * Model later in the component order is not there at onReady, so the first
122
+ * onUpdate tries once more (`final`); a primitive Model warns at once, a
123
+ * missing one once it is certain, and either way exactly once.
124
+ */
125
+ attach(final = false) {
126
+ const model = this.entity.get(Model);
127
+ const handle = model?.handle;
128
+ if (!handle) {
129
+ if ((model && !model.src) || final) {
130
+ this.attached = true;
131
+ this.pending = false;
132
+ this.warnOnce('model', `[waica] "${this.entity.name}": Animator needs a Model with a src beside it; nothing plays`);
133
+ }
134
+ return;
135
+ }
136
+ this.attached = true;
137
+ this.handle = handle;
138
+ reportRejection(handle.settled.then((outcome) => {
139
+ // Only the handle still attached builds: a Model rebuilt meanwhile has a newer one.
140
+ if (outcome === 'loaded' && this.entity.alive && !this.mixer && this.handle === handle)
141
+ this.build(handle);
142
+ }), 'animator model settle');
143
+ }
144
+ /** Forgets the Model's old clone and keeps the clip it played as the request for the next build. */
145
+ detach() {
146
+ this.requested = this.current ?? this.requested;
147
+ this.release();
148
+ this.attached = false;
149
+ this.pending = true;
150
+ }
151
+ /** Stops and drops the mixer, its actions and the clone they ran over. */
152
+ release() {
153
+ this.mixer?.stopAllAction();
154
+ if (this.mixer && this.root)
155
+ this.mixer.uncacheRoot(this.root);
156
+ this.mixer = null;
157
+ this.active = null;
158
+ this.current = undefined;
159
+ this.finished = false;
160
+ this.actions.clear();
161
+ this.names = [];
162
+ this.root = null;
163
+ this.handle = null;
164
+ }
165
+ build(handle) {
166
+ const mixer = new THREE.AnimationMixer(handle.root);
167
+ this.mixer = mixer;
168
+ this.root = handle.root;
169
+ for (const clip of handle.animations)
170
+ this.actions.set(clip.name, this.configure(mixer.clipAction(clip), clip.name));
171
+ this.names = [...this.actions.keys()];
172
+ this.pending = false;
173
+ mixer.addEventListener('finished', (event) => {
174
+ if (event.action === this.active)
175
+ this.finished = true;
176
+ });
177
+ const first = this.requested ?? this.initialClip;
178
+ this.requested = undefined;
179
+ if (first)
180
+ this.play(first);
181
+ }
182
+ configure(action, name) {
183
+ const override = this.clips[name];
184
+ if (override?.loop === false) {
185
+ action.setLoop(THREE.LoopOnce, 1);
186
+ action.clampWhenFinished = true;
187
+ }
188
+ action.timeScale = override?.speed ?? 1;
189
+ return action;
190
+ }
191
+ warnOnce(key, message) {
192
+ if (this.warned.has(key))
193
+ return;
194
+ this.warned.add(key);
195
+ console.warn(message);
196
+ }
197
+ }
@@ -0,0 +1,89 @@
1
+ import { Component, type ComponentSpace } from '../component.js';
2
+ import type { Vec3Json } from '../scene-camera-3d.js';
3
+ export type ColliderShape = 'box' | 'sphere' | 'capsule';
4
+ export declare const COLLIDER_SHAPES: readonly ColliderShape[];
5
+ /**
6
+ * The 3D scene's collision shape (ADR 0028), Rapier's model: alone on an
7
+ * entity it is a fixed wall or floor, created at the entity's position and
8
+ * rotation scaled by `entity.scale`, and moving the entity later does not
9
+ * move it. With a `RigidBody` beside it, it is the body that falls, bounces
10
+ * or walks. `sensor: true` makes it a trigger instead — it detects and never
11
+ * pushes — with the 2D Hitbox's `layer` and `collidesWith` semantics (ADR
12
+ * 0016); with the default mask (`['*']`) a sensor is not told about fixed
13
+ * solid colliders (the level's walls and floors), as a Hitbox never sees a
14
+ * Solid — a mask that names their layer still is. `size` is a box's full
15
+ * extents; `radius` a sphere's or capsule's; `height` a capsule's total
16
+ * end-to-end height, at least twice its radius; `offset` is in the entity's
17
+ * local frame. A Collider in a 2D scene creates nothing; one whose params
18
+ * Rapier cannot take creates no body and warns once (`validate_project`
19
+ * reports them as `invalid-collider-param`).
20
+ */
21
+ export declare class Collider extends Component {
22
+ static componentName: string;
23
+ static space: ComponentSpace;
24
+ static params: {
25
+ shape: {
26
+ label: string;
27
+ options: ColliderShape[];
28
+ };
29
+ size: {
30
+ label: string;
31
+ kind: "vector3";
32
+ };
33
+ radius: {
34
+ label: string;
35
+ min: number;
36
+ step: number;
37
+ };
38
+ height: {
39
+ label: string;
40
+ min: number;
41
+ step: number;
42
+ };
43
+ offset: {
44
+ label: string;
45
+ kind: "vector3";
46
+ };
47
+ sensor: {
48
+ label: string;
49
+ };
50
+ layer: {
51
+ label: string;
52
+ };
53
+ collidesWith: {
54
+ label: string;
55
+ kind: "string-list";
56
+ };
57
+ friction: {
58
+ label: string;
59
+ min: number;
60
+ max: number;
61
+ step: number;
62
+ };
63
+ restitution: {
64
+ label: string;
65
+ min: number;
66
+ max: number;
67
+ step: number;
68
+ };
69
+ };
70
+ shape: ColliderShape;
71
+ /** A box's full extents `[x, y, z]`. */
72
+ size: Vec3Json;
73
+ /** A sphere's or capsule's radius. */
74
+ radius: number;
75
+ /** A capsule's total end-to-end height. */
76
+ height: number;
77
+ /** Where the shape sits in the entity's local frame. */
78
+ offset: Vec3Json;
79
+ /** A trigger: detects overlaps, never pushes or is pushed. */
80
+ sensor: boolean;
81
+ /** One exact, case-sensitive `^[a-z][a-z0-9-]*$` membership name. */
82
+ layer: string;
83
+ /** Outgoing layer interest for `onCollide`; `'*'` means every valid layer and `[]` means none. */
84
+ collidesWith: string[];
85
+ friction: number;
86
+ restitution: number;
87
+ onReady(): void;
88
+ onDestroy(): void;
89
+ }
@@ -0,0 +1,57 @@
1
+ import { Component } from '../component.js';
2
+ export const COLLIDER_SHAPES = ['box', 'sphere', 'capsule'];
3
+ /**
4
+ * The 3D scene's collision shape (ADR 0028), Rapier's model: alone on an
5
+ * entity it is a fixed wall or floor, created at the entity's position and
6
+ * rotation scaled by `entity.scale`, and moving the entity later does not
7
+ * move it. With a `RigidBody` beside it, it is the body that falls, bounces
8
+ * or walks. `sensor: true` makes it a trigger instead — it detects and never
9
+ * pushes — with the 2D Hitbox's `layer` and `collidesWith` semantics (ADR
10
+ * 0016); with the default mask (`['*']`) a sensor is not told about fixed
11
+ * solid colliders (the level's walls and floors), as a Hitbox never sees a
12
+ * Solid — a mask that names their layer still is. `size` is a box's full
13
+ * extents; `radius` a sphere's or capsule's; `height` a capsule's total
14
+ * end-to-end height, at least twice its radius; `offset` is in the entity's
15
+ * local frame. A Collider in a 2D scene creates nothing; one whose params
16
+ * Rapier cannot take creates no body and warns once (`validate_project`
17
+ * reports them as `invalid-collider-param`).
18
+ */
19
+ export class Collider extends Component {
20
+ static componentName = 'Collider';
21
+ static space = '3d';
22
+ static params = {
23
+ shape: { label: 'Shape', options: [...COLLIDER_SHAPES] },
24
+ size: { label: 'Size', kind: 'vector3' },
25
+ radius: { label: 'Radius', min: 0.01, step: 0.05 },
26
+ height: { label: 'Height', min: 0.01, step: 0.05 },
27
+ offset: { label: 'Offset', kind: 'vector3' },
28
+ sensor: { label: 'Sensor' },
29
+ layer: { label: 'Collision Layer' },
30
+ collidesWith: { label: 'Collision Mask', kind: 'string-list' },
31
+ friction: { label: 'Friction', min: 0, max: 1, step: 0.05 },
32
+ restitution: { label: 'Restitution', min: 0, max: 1, step: 0.05 },
33
+ };
34
+ shape = 'box';
35
+ /** A box's full extents `[x, y, z]`. */
36
+ size = [1, 1, 1];
37
+ /** A sphere's or capsule's radius. */
38
+ radius = 0.5;
39
+ /** A capsule's total end-to-end height. */
40
+ height = 1.8;
41
+ /** Where the shape sits in the entity's local frame. */
42
+ offset = [0, 0, 0];
43
+ /** A trigger: detects overlaps, never pushes or is pushed. */
44
+ sensor = false;
45
+ /** One exact, case-sensitive `^[a-z][a-z0-9-]*$` membership name. */
46
+ layer = 'default';
47
+ /** Outgoing layer interest for `onCollide`; `'*'` means every valid layer and `[]` means none. */
48
+ collidesWith = ['*'];
49
+ friction = 0.5;
50
+ restitution = 0;
51
+ onReady() {
52
+ this.game.physics.attach(this.entity);
53
+ }
54
+ onDestroy() {
55
+ this.game.physics.detach(this.entity);
56
+ }
57
+ }
@@ -1,11 +1,12 @@
1
1
  import { type CollisionPoint, type CollisionShape } from '../collision-shape.js';
2
- import { Component } from '../component.js';
2
+ import { Component, type ComponentSpace } from '../component.js';
3
3
  /**
4
4
  * Reusable linear body: integrates its public velocity and resolves only
5
5
  * against static Solid geometry. Hitbox trigger overlaps remain independent.
6
6
  */
7
7
  export declare class DynamicBody extends Component {
8
8
  static componentName: string;
9
+ static space: ComponentSpace;
9
10
  static params: {
10
11
  vx: {
11
12
  label: string;
@@ -17,6 +17,7 @@ const RECOVERY_ITERATIONS = 24;
17
17
  */
18
18
  export class DynamicBody extends Component {
19
19
  static componentName = 'DynamicBody';
20
+ static space = '2d';
20
21
  static params = {
21
22
  vx: { label: 'x velocity', step: 0.1 },
22
23
  vy: { label: 'y velocity', step: 0.1 },
@@ -1,4 +1,4 @@
1
- import { Component } from '../component.js';
1
+ import { Component, type ComponentSpace } from '../component.js';
2
2
  import { type CollisionPoint, type CollisionShape } from '../collision-shape.js';
3
3
  /**
4
4
  * Trigger collider. A pair is tested when either Hitbox's Collision Mask
@@ -8,6 +8,7 @@ import { type CollisionPoint, type CollisionShape } from '../collision-shape.js'
8
8
  */
9
9
  export declare class Hitbox extends Component {
10
10
  static componentName: string;
11
+ static space: ComponentSpace;
11
12
  static params: {
12
13
  layer: {
13
14
  label: string;