@waica/engine 0.26.0 → 0.28.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.
- package/README.md +33 -3
- package/dist/animation/clip-player.d.ts +7 -0
- package/dist/animation/clip-player.js +9 -0
- package/dist/animation/clip-target.d.ts +38 -0
- package/dist/animation/clip-target.js +9 -0
- package/dist/animation/pose-hash.d.ts +10 -0
- package/dist/animation/pose-hash.js +43 -0
- package/dist/assets/glb-clip-names.d.ts +8 -0
- package/dist/assets/glb-clip-names.js +49 -0
- package/dist/camera-rig.d.ts +22 -1
- package/dist/camera-rig.js +41 -5
- package/dist/components/animated-sprite.d.ts +12 -2
- package/dist/components/animated-sprite.js +15 -2
- package/dist/components/animator.d.ts +98 -0
- package/dist/components/animator.js +197 -0
- package/dist/components/model.d.ts +8 -0
- package/dist/components/model.js +15 -4
- package/dist/game.d.ts +1 -1
- package/dist/game.js +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/pointer-raycast.d.ts +24 -6
- package/dist/pointer-raycast.js +44 -14
- package/dist/runtime-view-snapshot.js +2 -1
- package/dist/state/state-machine.d.ts +32 -8
- package/dist/state/state-machine.js +88 -27
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { ModelHandle } from '../assets/model-cache.js';
|
|
2
3
|
import { Component, type ComponentSpace } from '../component.js';
|
|
3
4
|
export type ModelShape = 'box' | 'sphere' | 'plane';
|
|
4
5
|
/**
|
|
@@ -37,6 +38,13 @@ export declare class Model extends Component {
|
|
|
37
38
|
static transient: string[];
|
|
38
39
|
/** What this Model draws, under the entity's node; null until it is ready. The Pointer ray-casts against it. */
|
|
39
40
|
root: THREE.Group | null;
|
|
41
|
+
/**
|
|
42
|
+
* The handle of the glTF it draws (issue #166 CA-6): `handle.root` is the
|
|
43
|
+
* child of `root`, `handle.animations` its clips once settled — what an
|
|
44
|
+
* Animator beside it plays. Null for a primitive, in a 2D scene and before
|
|
45
|
+
* the component is ready; replaced on every rebuild.
|
|
46
|
+
*/
|
|
47
|
+
handle: ModelHandle | null;
|
|
40
48
|
private _src;
|
|
41
49
|
private _shape;
|
|
42
50
|
private _color;
|
package/dist/components/model.js
CHANGED
|
@@ -32,9 +32,16 @@ export class Model extends Component {
|
|
|
32
32
|
color: { label: 'Color', kind: 'color' },
|
|
33
33
|
size: { label: 'Size', min: 0.01, step: 0.1 },
|
|
34
34
|
};
|
|
35
|
-
static transient = ['root'];
|
|
35
|
+
static transient = ['root', 'handle'];
|
|
36
36
|
/** What this Model draws, under the entity's node; null until it is ready. The Pointer ray-casts against it. */
|
|
37
37
|
root = null;
|
|
38
|
+
/**
|
|
39
|
+
* The handle of the glTF it draws (issue #166 CA-6): `handle.root` is the
|
|
40
|
+
* child of `root`, `handle.animations` its clips once settled — what an
|
|
41
|
+
* Animator beside it plays. Null for a primitive, in a 2D scene and before
|
|
42
|
+
* the component is ready; replaced on every rebuild.
|
|
43
|
+
*/
|
|
44
|
+
handle = null;
|
|
38
45
|
_src = '';
|
|
39
46
|
_shape = 'box';
|
|
40
47
|
_color = 0xffffff;
|
|
@@ -87,10 +94,13 @@ export class Model extends Component {
|
|
|
87
94
|
build() {
|
|
88
95
|
if (!this.root)
|
|
89
96
|
return;
|
|
90
|
-
if (this._src)
|
|
91
|
-
this.
|
|
92
|
-
|
|
97
|
+
if (this._src) {
|
|
98
|
+
this.handle = this.game.assets.model(this._src);
|
|
99
|
+
this.root.add(this.handle.root);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
93
102
|
this.buildPrimitive(this.root);
|
|
103
|
+
}
|
|
94
104
|
}
|
|
95
105
|
buildPrimitive(root) {
|
|
96
106
|
const material = new THREE.MeshStandardNodeMaterial({ color: this._color });
|
|
@@ -108,6 +118,7 @@ export class Model extends Component {
|
|
|
108
118
|
/** Empties the root and disposes the primitive this Model created; a glTF's clone only leaves the graph. */
|
|
109
119
|
clear() {
|
|
110
120
|
this.root?.clear();
|
|
121
|
+
this.handle = null;
|
|
111
122
|
if (!this._primitive)
|
|
112
123
|
return;
|
|
113
124
|
this._primitive.mesh.geometry.dispose();
|
package/dist/game.d.ts
CHANGED
|
@@ -255,7 +255,7 @@ export declare class Game {
|
|
|
255
255
|
get projection(): 'isometric' | null;
|
|
256
256
|
/** The live scene's space (`render.space`); `'2d'` with no scene (ADR 0027). */
|
|
257
257
|
get space(): SceneSpace;
|
|
258
|
-
/** The camera the live scene draws with: orthographic in 2D scenes, perspective in 3D ones. */
|
|
258
|
+
/** The camera the live scene draws with: orthographic in 2D scenes, perspective in 3D ones, or a host's view camera over a 3D one (`cameraRig.setViewCamera`). */
|
|
259
259
|
get camera(): GameCamera;
|
|
260
260
|
/**
|
|
261
261
|
* Where a logical point falls on the canvas, in CSS px from its top-left
|
package/dist/game.js
CHANGED
|
@@ -469,7 +469,7 @@ export class Game {
|
|
|
469
469
|
get space() {
|
|
470
470
|
return this.sceneSpace;
|
|
471
471
|
}
|
|
472
|
-
/** The camera the live scene draws with: orthographic in 2D scenes, perspective in 3D ones. */
|
|
472
|
+
/** The camera the live scene draws with: orthographic in 2D scenes, perspective in 3D ones, or a host's view camera over a 3D one (`cameraRig.setViewCamera`). */
|
|
473
473
|
get camera() {
|
|
474
474
|
return this.cameraRig.camera;
|
|
475
475
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { AssetLoader } from './assets/asset-loader.js';
|
|
|
4
4
|
export type { AssetStatus } from './assets/asset-loader.js';
|
|
5
5
|
export type { TextureBackend } from './assets/texture-backend.js';
|
|
6
6
|
export { GltfModelBackend } from './assets/model-backend.js';
|
|
7
|
+
export { glbClipNames } from './assets/glb-clip-names.js';
|
|
7
8
|
export type { LoadedModel, ModelBackend } from './assets/model-backend.js';
|
|
8
9
|
export type { ModelHandle, ModelOutcome } from './assets/model-cache.js';
|
|
9
10
|
export { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
@@ -12,6 +13,8 @@ export type { AudioBackend, AudioResource, BackendPlayHandle, BackendPlayOptions
|
|
|
12
13
|
export type { AudioChannelState, AudioPlayOptions, LiveSoundInfo, SoundHandle } from './audio/types.js';
|
|
13
14
|
export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
|
|
14
15
|
export type { AnimationFacingProvider, DirectionalAnimation, DirectionalFallback, ResolvedDirectionalClip, } from './animation/directional.js';
|
|
16
|
+
export { isClipTarget } from './animation/clip-target.js';
|
|
17
|
+
export type { ClipTarget } from './animation/clip-target.js';
|
|
15
18
|
export { isYSortBatchParticipant, isYSortParticipant, ySortZ } from './render-sort.js';
|
|
16
19
|
export type { YSortBatchParticipant, YSortEntry, YSortParticipant } from './render-sort.js';
|
|
17
20
|
export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
|
|
@@ -47,6 +50,8 @@ export type { ActionName, InjectedActionOperation, InputBindings, InputOptions }
|
|
|
47
50
|
export { ACTION_HELD_THRESHOLD, firstStandardPadValues, GAMEPAD_CODE_PREFIX, gamepadControl, } from './gamepad.js';
|
|
48
51
|
export type { GamepadControl } from './gamepad.js';
|
|
49
52
|
export { Pointer } from './pointer.js';
|
|
53
|
+
export { raycastPick } from './pointer-raycast.js';
|
|
54
|
+
export type { PickedTarget, PickTarget, RaycastPick, ViewportFraction } from './pointer-raycast.js';
|
|
50
55
|
export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from './pointer.js';
|
|
51
56
|
export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
|
|
52
57
|
export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeBridgeFailure, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
|
|
@@ -63,6 +68,9 @@ export { Sprite } from './components/sprite.js';
|
|
|
63
68
|
export { Light } from './components/light.js';
|
|
64
69
|
export { Model } from './components/model.js';
|
|
65
70
|
export type { ModelShape } from './components/model.js';
|
|
71
|
+
export { Animator } from './components/animator.js';
|
|
72
|
+
export type { AnimatorClipOverride, AnimatorState } from './components/animator.js';
|
|
73
|
+
export { poseHash } from './animation/pose-hash.js';
|
|
66
74
|
export { Sun } from './components/sun.js';
|
|
67
75
|
export { Collider, COLLIDER_SHAPES } from './components/collider.js';
|
|
68
76
|
export type { ColliderShape } from './components/collider.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { Game } from './game.js';
|
|
2
2
|
export { AssetLoader } from './assets/asset-loader.js';
|
|
3
3
|
export { GltfModelBackend } from './assets/model-backend.js';
|
|
4
|
+
export { glbClipNames } from './assets/glb-clip-names.js';
|
|
4
5
|
export { AudioSubsystem } from './audio/audio-subsystem.js';
|
|
5
6
|
export { installDirectionalAnimation, installedDirectionalAnimation, isAnimationFacingProvider, resolveDirectionalClip, } from './animation/directional.js';
|
|
7
|
+
export { isClipTarget } from './animation/clip-target.js';
|
|
6
8
|
export { isYSortBatchParticipant, isYSortParticipant, ySortZ } from './render-sort.js';
|
|
7
9
|
export { projectIsometric, screenInputToLogical, unprojectIsometric } from './projection.js';
|
|
8
10
|
export { spritePlacement } from './sprite-placement.js';
|
|
@@ -22,6 +24,7 @@ export { resolveComponentUpdateSchedule } from './component-update-schedule.js';
|
|
|
22
24
|
export { Input, DEFAULT_BINDINGS } from './input.js';
|
|
23
25
|
export { ACTION_HELD_THRESHOLD, firstStandardPadValues, GAMEPAD_CODE_PREFIX, gamepadControl, } from './gamepad.js';
|
|
24
26
|
export { Pointer } from './pointer.js';
|
|
27
|
+
export { raycastPick } from './pointer-raycast.js';
|
|
25
28
|
export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
|
|
26
29
|
export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
|
|
27
30
|
export { Stats } from './stats.js';
|
|
@@ -29,6 +32,8 @@ export { GameUi } from './ui.js';
|
|
|
29
32
|
export { Sprite } from './components/sprite.js';
|
|
30
33
|
export { Light } from './components/light.js';
|
|
31
34
|
export { Model } from './components/model.js';
|
|
35
|
+
export { Animator } from './components/animator.js';
|
|
36
|
+
export { poseHash } from './animation/pose-hash.js';
|
|
32
37
|
export { Sun } from './components/sun.js';
|
|
33
38
|
export { Collider, COLLIDER_SHAPES } from './components/collider.js';
|
|
34
39
|
export { RigidBody, RIGID_BODY_TYPES } from './components/rigid-body.js';
|
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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[]):
|
|
35
|
+
export declare function raycastPick<Picked extends PickedTarget = Entity>(camera: THREE.PerspectiveCamera, at: ViewportFraction, entities: readonly Entity[], extra?: readonly PickTarget<Picked>[]): RaycastPick<Picked> | null;
|
package/dist/pointer-raycast.js
CHANGED
|
@@ -16,33 +16,63 @@ function modelRoots(entities) {
|
|
|
16
16
|
}
|
|
17
17
|
return roots;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
function ownerOf(object,
|
|
19
|
+
/** What `object` picks: the target or Model root it hangs under, nearest first. */
|
|
20
|
+
function ownerOf(object, owners) {
|
|
21
21
|
for (let node = object; node; node = node.parent) {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
return
|
|
22
|
+
const owner = owners.get(node);
|
|
23
|
+
if (owner)
|
|
24
|
+
return owner;
|
|
25
25
|
}
|
|
26
26
|
return null;
|
|
27
27
|
}
|
|
28
|
+
/** Whether `target` is drawn and still picks something: itself and every ancestor visible, and its entity alive. */
|
|
29
|
+
function isPickable(target) {
|
|
30
|
+
if (target.entity !== 'camera' && !target.entity.alive)
|
|
31
|
+
return false;
|
|
32
|
+
for (let node = target.object; node; node = node.parent) {
|
|
33
|
+
if (!node.visible)
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/** The extra targets' hits: tested whatever their layers, since an editor overlay lives on the Emissive layer alone. */
|
|
39
|
+
function extraHits(targets) {
|
|
40
|
+
if (targets.length === 0)
|
|
41
|
+
return [];
|
|
42
|
+
const own = raycaster.layers.mask;
|
|
43
|
+
raycaster.layers.enableAll();
|
|
44
|
+
try {
|
|
45
|
+
return raycaster.intersectObjects(targets.filter(isPickable).map((target) => target.object), true);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
raycaster.layers.mask = own;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
28
51
|
/**
|
|
29
52
|
* The pick for a click on a 3D scene (issue #154 CA-12): a ray through the
|
|
30
53
|
* letterboxed point, intersected with the roots of every live Model
|
|
31
|
-
* (recursively, so a glTF's meshes count
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
54
|
+
* (recursively, so a glTF's meshes count, posed where its skeleton is, and a
|
|
55
|
+
* glTF still loading does not) and with the `extra` targets a host adds (the
|
|
56
|
+
* editor's icons, issue #173 CA-2; one that is hidden, or whose entity is
|
|
57
|
+
* dead, is skipped). The nearest hit wins, with its
|
|
58
|
+
* world-space point. With no hit the pick has no entity and the point where
|
|
59
|
+
* the ray meets the ground plane y = 0; a ray that never meets it (it looks
|
|
60
|
+
* at the sky) yields no pick.
|
|
35
61
|
*/
|
|
36
|
-
|
|
62
|
+
// eslint-disable-next-line max-params -- the signature spec #173 fixes: the Pointer's three inputs plus the optional extra targets; bundling them would be an unrelated bag
|
|
63
|
+
export function raycastPick(camera, at, entities, extra = []) {
|
|
37
64
|
camera.updateMatrixWorld();
|
|
38
65
|
raycaster.setFromCamera(new THREE.Vector2(at.nx * 2 - 1, 1 - at.ny * 2), camera);
|
|
39
66
|
const roots = modelRoots(entities);
|
|
67
|
+
const owners = new Map(roots);
|
|
68
|
+
for (const target of extra)
|
|
69
|
+
owners.set(target.object, target.entity);
|
|
40
70
|
// A click can land before the first frame has placed anything.
|
|
41
|
-
for (const
|
|
42
|
-
|
|
43
|
-
const [hit] = raycaster.intersectObjects([...roots.keys()], true);
|
|
71
|
+
for (const object of owners.keys())
|
|
72
|
+
object.updateWorldMatrix(true, true);
|
|
73
|
+
const [hit] = [...raycaster.intersectObjects([...roots.keys()], true), ...extraHits(extra)].sort((a, b) => a.distance - b.distance);
|
|
44
74
|
if (hit)
|
|
45
|
-
return { entity: ownerOf(hit.object,
|
|
75
|
+
return { entity: ownerOf(hit.object, owners), point: { x: hit.point.x, y: hit.point.y, z: hit.point.z } };
|
|
46
76
|
const ground = raycaster.ray.intersectPlane(GROUND, new THREE.Vector3());
|
|
47
77
|
return ground ? { entity: null, point: { x: ground.x, y: ground.y, z: ground.z } } : null;
|
|
48
78
|
}
|
|
@@ -6,7 +6,8 @@ function vector(value) {
|
|
|
6
6
|
return [clean(value.x), clean(value.y), clean(value.z)];
|
|
7
7
|
}
|
|
8
8
|
export function viewSnapshot(game) {
|
|
9
|
-
|
|
9
|
+
// The scene's own camera: a host's view camera is not part of the simulated game, and `lookDistance` is the scene camera's.
|
|
10
|
+
const camera = game.cameraRig.sceneCamera;
|
|
10
11
|
if (!isPerspectiveCamera(camera)) {
|
|
11
12
|
return { kind: 'orthographic', position: [clean(camera.position.x), clean(camera.position.y)], zoom: game.view };
|
|
12
13
|
}
|
|
@@ -3,7 +3,7 @@ import type { Entity } from '../entity.js';
|
|
|
3
3
|
import { type StateHooks } from './hooks.js';
|
|
4
4
|
/** One outgoing edge: when `on` fires, the machine moves to `to`. */
|
|
5
5
|
export interface StateTransitionJson {
|
|
6
|
-
/** Trigger: 'input:<action>' | 'timer:<seconds>' | 'signal:<name>'. */
|
|
6
|
+
/** Trigger: 'input:<action>' | 'timer:<seconds>' | 'signal:<name>' | 'clip:end'. */
|
|
7
7
|
on: string;
|
|
8
8
|
to: string;
|
|
9
9
|
}
|
|
@@ -13,7 +13,7 @@ export interface StateTransitionJson {
|
|
|
13
13
|
* transitions instead of a real state.
|
|
14
14
|
*/
|
|
15
15
|
export interface StateJson {
|
|
16
|
-
/** Clip reference resolved against the sibling AnimatedSprite; defaults to the state's name. */
|
|
16
|
+
/** Clip reference resolved against the sibling Clip Target (an AnimatedSprite or an Animator); defaults to the state's name. */
|
|
17
17
|
clip?: string;
|
|
18
18
|
transitions?: StateTransitionJson[];
|
|
19
19
|
}
|
|
@@ -24,6 +24,8 @@ export interface TriggerEnv {
|
|
|
24
24
|
/** Seconds spent in the current state. */
|
|
25
25
|
elapsed: number;
|
|
26
26
|
signals: ReadonlySet<string>;
|
|
27
|
+
/** Whether the Clip Target reports its non-looping clip finished (issue #166); false with no target. */
|
|
28
|
+
clipFinished: boolean;
|
|
27
29
|
}
|
|
28
30
|
/** Whether one trigger fires. Unknown or malformed triggers never fire. */
|
|
29
31
|
export declare function evaluateTrigger(on: string, env: TriggerEnv): boolean;
|
|
@@ -41,9 +43,10 @@ export declare function phaseHooks(hooks: StateHooks[], fallback: StateHooks | u
|
|
|
41
43
|
* logic — only the active state's code moves the body, so behaviors
|
|
42
44
|
* never fight over velocity. States and transitions are prefab data;
|
|
43
45
|
* state code lives in the role's logic set (defineRole / defineStates)
|
|
44
|
-
* picked by `role`. Entering a state plays the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
46
|
+
* picked by `role`. Entering a state plays the clip of the same name (or
|
|
47
|
+
* the state's `clip` override) through the entity's Clip Target — the
|
|
48
|
+
* first sibling that plays clips, an AnimatedSprite in 2D or an Animator
|
|
49
|
+
* in 3D (issue #166); a missing clip warns and keeps the previous one.
|
|
47
50
|
*/
|
|
48
51
|
export declare class StateMachine extends Component {
|
|
49
52
|
static componentName: string;
|
|
@@ -66,8 +69,17 @@ export declare class StateMachine extends Component {
|
|
|
66
69
|
private readonly instanceHooks;
|
|
67
70
|
private readonly signals;
|
|
68
71
|
private readonly warnedClips;
|
|
72
|
+
/** States already warned that their mirrored clip met a target that cannot flip. */
|
|
73
|
+
private readonly warnedFlips;
|
|
69
74
|
/** Facing last used to resolve a directional clip — detects a turn mid-state. */
|
|
70
75
|
private lastFacing;
|
|
76
|
+
/**
|
|
77
|
+
* The clip the current state asked its Clip Target for, undefined when it
|
|
78
|
+
* asked for nothing (no target, or a clip the target lacks). `clip:end`
|
|
79
|
+
* reads only this clip: a one-shot left finished by an earlier state never
|
|
80
|
+
* counts for a state that could not play its own.
|
|
81
|
+
*/
|
|
82
|
+
private requestedClip;
|
|
71
83
|
onReady(): void;
|
|
72
84
|
/** Adds instance-level hooks on top of the logic set — the escape hatch. */
|
|
73
85
|
on(state: string, hooks: StateHooks): void;
|
|
@@ -83,20 +95,32 @@ export declare class StateMachine extends Component {
|
|
|
83
95
|
private runCollision;
|
|
84
96
|
private enter;
|
|
85
97
|
private playClip;
|
|
98
|
+
/** The Clip Target: the first sibling that plays clips (duck-typed, like the facing provider). */
|
|
99
|
+
private clipTarget;
|
|
86
100
|
/**
|
|
87
101
|
* Directional resolution: with an installed contract AND a sibling that
|
|
88
102
|
* reports facing, plays state × facing (mirroring included). Returns false
|
|
89
103
|
* to keep the name-based path — no contract, no facing, no playable clip.
|
|
90
104
|
*/
|
|
91
105
|
private playDirectionalClip;
|
|
106
|
+
/** State × facing through the installed contract and the target's clips, remembering the facing used; undefined keeps the name-based path. */
|
|
107
|
+
private resolveFacingClip;
|
|
108
|
+
/**
|
|
109
|
+
* Applies the resolved mirror. A target without `setFlipX` (an Animator:
|
|
110
|
+
* 3D faces by turning) plays a mirrored clip unmirrored and warns once
|
|
111
|
+
* per state, so a 2D target that forgot its mirror is not silent.
|
|
112
|
+
*/
|
|
113
|
+
private mirror;
|
|
92
114
|
private facingProvider;
|
|
93
115
|
/**
|
|
94
116
|
* playDirectionalClip only runs at enter() time (via playClip), so a
|
|
95
117
|
* facing change that doesn't cross a state boundary — turning while
|
|
96
118
|
* still walking, say — never re-resolves the clip on its own. Catch that
|
|
97
|
-
* here every frame: same state, new facing, re-run the resolution.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
119
|
+
* here every frame: same state, new facing, re-run the resolution. A
|
|
120
|
+
* turn that only mirrors (west playing east flipped) re-applies the
|
|
121
|
+
* mirror and leaves the clip alone — a running one keeps its frame and a
|
|
122
|
+
* finished one-shot keeps its last — so only a turn onto another clip
|
|
123
|
+
* name plays, as re-entering the state would.
|
|
100
124
|
*/
|
|
101
125
|
private reresolveOnFacingChange;
|
|
102
126
|
}
|