@jgengine/core 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -2
- package/dist/ai/decisionGraph.d.ts +52 -0
- package/dist/ai/decisionGraph.js +82 -0
- package/dist/ai/mobBrainGraph.d.ts +3 -0
- package/dist/ai/mobBrainGraph.js +25 -0
- package/dist/ai/tacticalQueries.d.ts +30 -0
- package/dist/ai/tacticalQueries.js +45 -0
- package/dist/anim/animGraph.d.ts +144 -0
- package/dist/anim/animGraph.js +286 -0
- package/dist/anim/ikSolver.d.ts +51 -0
- package/dist/anim/ikSolver.js +124 -0
- package/dist/anim/locomotionGraph.d.ts +26 -0
- package/dist/anim/locomotionGraph.js +33 -0
- package/dist/assets/spriteAtlas.d.ts +21 -0
- package/dist/assets/spriteAtlas.js +103 -0
- package/dist/audio/audioFalloff.d.ts +10 -0
- package/dist/economy/currency.d.ts +16 -2
- package/dist/economy/currency.js +55 -14
- package/dist/economy/wallet.d.ts +5 -4
- package/dist/economy/wallet.js +8 -4
- package/dist/editor/commandApply.js +1 -0
- package/dist/editor/commands.d.ts +4 -1
- package/dist/editor/document.d.ts +2 -1
- package/dist/editor/document.js +89 -0
- package/dist/editor/environment.d.ts +3 -0
- package/dist/editor/environment.js +16 -0
- package/dist/editor/index.d.ts +4 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +16 -0
- package/dist/game/cameraConfig.d.ts +8 -0
- package/dist/game/chat.d.ts +22 -1
- package/dist/game/chat.js +48 -19
- package/dist/game/controlGate.d.ts +6 -0
- package/dist/game/controlGate.js +30 -1
- package/dist/game/defineGame.d.ts +10 -1
- package/dist/game/events.d.ts +8 -1
- package/dist/game/pixelPerfect.d.ts +25 -0
- package/dist/game/pixelPerfect.js +29 -0
- package/dist/game/playableGame.d.ts +97 -3
- package/dist/game/systemRuntime.js +6 -1
- package/dist/input/actionBindings.d.ts +2 -1
- package/dist/input/actionBindings.js +10 -8
- package/dist/input/actionContexts.d.ts +21 -0
- package/dist/input/actionContexts.js +56 -0
- package/dist/input/gamepadModel.d.ts +45 -0
- package/dist/input/gamepadModel.js +69 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +85 -1
- package/dist/movement/characterController.d.ts +76 -0
- package/dist/movement/characterController.js +216 -0
- package/dist/movement/freeFlight.d.ts +119 -0
- package/dist/movement/freeFlight.js +294 -0
- package/dist/movement/playerMovement.d.ts +10 -2
- package/dist/movement/playerMovement.js +218 -4
- package/dist/movement/serverStep.d.ts +5 -0
- package/dist/movement/serverStep.js +6 -0
- package/dist/movement/solidObstacles.d.ts +30 -7
- package/dist/movement/solidObstacles.js +27 -4
- package/dist/multiplayer/poseSyncGate.d.ts +3 -1
- package/dist/multiplayer/poseSyncGate.js +9 -1
- package/dist/multiplayer/presenceContract.d.ts +6 -0
- package/dist/multiplayer/presenceModel.d.ts +2 -0
- package/dist/multiplayer/presenceModel.js +11 -0
- package/dist/nav/navMesh.d.ts +33 -0
- package/dist/nav/navMesh.js +164 -0
- package/dist/physics/flightDynamics.d.ts +8 -0
- package/dist/physics/kinematicVehicle.d.ts +6 -1
- package/dist/physics/physicsBackend.d.ts +191 -0
- package/dist/physics/physicsBackend.js +49 -0
- package/dist/physics/physicsBackendConformance.d.ts +27 -0
- package/dist/physics/physicsBackendConformance.js +117 -0
- package/dist/physics/physicsWorldBackend.d.ts +44 -0
- package/dist/physics/physicsWorldBackend.js +485 -0
- package/dist/physics/worldColliders.d.ts +13 -0
- package/dist/physics/worldColliders.js +96 -0
- package/dist/random/rng.d.ts +12 -1
- package/dist/random/rng.js +20 -3
- package/dist/render/environment.d.ts +23 -0
- package/dist/render/environment.js +1 -0
- package/dist/render/lookPreset.d.ts +11 -5
- package/dist/render/lookPreset.js +29 -4
- package/dist/render/postProcessing.d.ts +10 -1
- package/dist/render/sprite2d.d.ts +38 -0
- package/dist/render/sprite2d.js +40 -0
- package/dist/runtime/adapter.d.ts +20 -1
- package/dist/runtime/adapter.js +8 -1
- package/dist/runtime/commandInput.d.ts +15 -0
- package/dist/runtime/commandInput.js +13 -0
- package/dist/runtime/commandRunner.d.ts +12 -7
- package/dist/runtime/commandRunner.js +7 -5
- package/dist/runtime/gameContext.js +11 -1
- package/dist/runtime/gameContextTypes.d.ts +14 -4
- package/dist/runtime/gameRuntime.d.ts +20 -4
- package/dist/runtime/gameRuntime.js +17 -4
- package/dist/runtime/headlessRunner.d.ts +10 -0
- package/dist/runtime/headlessRunner.js +26 -8
- package/dist/runtime/hostPolicy.d.ts +3 -2
- package/dist/runtime/hostPolicy.js +8 -10
- package/dist/runtime/hostedGameRunner.js +34 -6
- package/dist/runtime/hostedWorldSession.d.ts +12 -16
- package/dist/runtime/hostedWorldSession.js +29 -6
- package/dist/runtime/hostedWorldStore.d.ts +20 -0
- package/dist/runtime/hostedWorldStore.js +1 -0
- package/dist/runtime/inputRecorder.d.ts +30 -0
- package/dist/runtime/inputRecorder.js +59 -0
- package/dist/runtime/inputSnapshot.d.ts +8 -0
- package/dist/runtime/inputSnapshot.js +1 -0
- package/dist/runtime/poseInterpolation.d.ts +36 -0
- package/dist/runtime/poseInterpolation.js +91 -0
- package/dist/runtime/prediction.d.ts +45 -0
- package/dist/runtime/prediction.js +62 -0
- package/dist/runtime/simContext.d.ts +45 -0
- package/dist/runtime/simContext.js +65 -0
- package/dist/runtime/simLoop.d.ts +52 -0
- package/dist/runtime/simLoop.js +72 -0
- package/dist/runtime/simSnapshot.d.ts +32 -0
- package/dist/runtime/simSnapshot.js +60 -0
- package/dist/runtime/snapshot.d.ts +8 -0
- package/dist/runtime/snapshotBuffer.d.ts +33 -0
- package/dist/runtime/snapshotBuffer.js +58 -0
- package/dist/runtime/transport.d.ts +32 -2
- package/dist/runtime/worldChunks.d.ts +2 -0
- package/dist/runtime/worldChunks.js +14 -0
- package/dist/runtime/worldProjection.d.ts +9 -0
- package/dist/runtime/worldProjection.js +17 -1
- package/dist/runtime/worldSnapshot.d.ts +6 -0
- package/dist/runtime/worldSnapshot.js +3 -0
- package/dist/scene/behaviorRuntime.d.ts +19 -1
- package/dist/scene/behaviorRuntime.js +55 -4
- package/dist/scene/behaviors.d.ts +8 -1
- package/dist/scene/builtinSceneKinds.js +10 -0
- package/dist/sensor/perception.d.ts +60 -0
- package/dist/sensor/perception.js +142 -0
- package/dist/settings/graphicsProfile.d.ts +19 -0
- package/dist/settings/graphicsProfile.js +36 -0
- package/dist/time/accrueSince.d.ts +10 -0
- package/dist/time/accrueSince.js +9 -0
- package/dist/time/rateWindow.d.ts +16 -0
- package/dist/time/rateWindow.js +14 -0
- package/dist/time/serverTick.d.ts +21 -3
- package/dist/time/serverTick.js +14 -13
- package/dist/vfx/particleDirector.d.ts +68 -0
- package/dist/vfx/particleDirector.js +81 -0
- package/dist/world/buildings.d.ts +12 -0
- package/dist/world/buildings.js +43 -3
- package/dist/world/cityContent.js +103 -25
- package/dist/world/cityKind.d.ts +132 -0
- package/dist/world/cityKind.js +315 -15
- package/dist/world/features.d.ts +27 -1
- package/dist/world/features.js +7 -2
- package/dist/world/placement.d.ts +7 -0
- package/dist/world/placement.js +2 -0
- package/dist/world/placementController.d.ts +1 -1
- package/dist/world/placementController.js +7 -0
- package/dist/world/raceCircuit.d.ts +168 -0
- package/dist/world/raceCircuit.js +797 -0
- package/dist/world/terrain.d.ts +5 -1
- package/dist/world/terrain.js +5 -0
- package/dist/world/territory.d.ts +97 -0
- package/dist/world/territory.js +182 -0
- package/dist/world.d.ts +5 -3
- package/dist/world.js +4 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +2 -0
- package/skills/jgengine/api.md +94 -19
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-gameplay/SKILL.md +10 -0
- package/skills/jgengine-gameplay/api.md +40 -18
- package/skills/jgengine-gameplay/capabilities.md +12 -0
- package/skills/jgengine-world/SKILL.md +6 -0
- package/skills/jgengine-world/api.md +312 -12
- package/skills/jgengine-world/capabilities.md +102 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
const DEFAULT_FADE = 0.2;
|
|
2
|
+
/** Entity blackboard key the shell reads extra graph parameters from: `ctx.scene.entity.blackboard.set(id, ANIM_PARAMS_KEY, { aiming: true })`. */
|
|
3
|
+
export const ANIM_PARAMS_KEY = "anim.params";
|
|
4
|
+
function compare(actual, op, value) {
|
|
5
|
+
const a = actual === undefined ? 0 : Number(actual);
|
|
6
|
+
const b = Number(value);
|
|
7
|
+
switch (op) {
|
|
8
|
+
case ">":
|
|
9
|
+
return a > b;
|
|
10
|
+
case "<":
|
|
11
|
+
return a < b;
|
|
12
|
+
case ">=":
|
|
13
|
+
return a >= b;
|
|
14
|
+
case "<=":
|
|
15
|
+
return a <= b;
|
|
16
|
+
case "==":
|
|
17
|
+
return a === b;
|
|
18
|
+
case "!=":
|
|
19
|
+
return a !== b;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Static clip weights of a state at `params`, before any crossfade. */
|
|
23
|
+
export function stateClipWeights(state, params) {
|
|
24
|
+
const out = {};
|
|
25
|
+
if (state.kind === "clip") {
|
|
26
|
+
out[state.clip] = 1;
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
if (state.kind === "blend1D") {
|
|
30
|
+
const points = [...state.points].sort((x, y) => x.at - y.at);
|
|
31
|
+
if (points.length === 0)
|
|
32
|
+
return out;
|
|
33
|
+
const value = Number(params[state.param] ?? 0);
|
|
34
|
+
if (value <= points[0].at) {
|
|
35
|
+
out[points[0].clip] = 1;
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
const last = points[points.length - 1];
|
|
39
|
+
if (value >= last.at) {
|
|
40
|
+
out[last.clip] = 1;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
for (let i = 0; i + 1 < points.length; i += 1) {
|
|
44
|
+
const lo = points[i];
|
|
45
|
+
const hi = points[i + 1];
|
|
46
|
+
if (value >= lo.at && value <= hi.at) {
|
|
47
|
+
const t = hi.at === lo.at ? 0 : (value - lo.at) / (hi.at - lo.at);
|
|
48
|
+
out[lo.clip] = (out[lo.clip] ?? 0) + (1 - t);
|
|
49
|
+
out[hi.clip] = (out[hi.clip] ?? 0) + t;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
const x = Number(params[state.params[0]] ?? 0);
|
|
56
|
+
const y = Number(params[state.params[1]] ?? 0);
|
|
57
|
+
let total = 0;
|
|
58
|
+
const raw = [];
|
|
59
|
+
for (const point of state.points) {
|
|
60
|
+
const dx = point.at[0] - x;
|
|
61
|
+
const dy = point.at[1] - y;
|
|
62
|
+
const d2 = dx * dx + dy * dy;
|
|
63
|
+
if (d2 < 1e-9) {
|
|
64
|
+
out[point.clip] = 1;
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
const w = 1 / d2;
|
|
68
|
+
raw.push({ clip: point.clip, w });
|
|
69
|
+
total += w;
|
|
70
|
+
}
|
|
71
|
+
for (const entry of raw)
|
|
72
|
+
out[entry.clip] = (out[entry.clip] ?? 0) + entry.w / total;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
function stateSpeed(state) {
|
|
76
|
+
return state.speed ?? 1;
|
|
77
|
+
}
|
|
78
|
+
function stateLoops(state) {
|
|
79
|
+
return state.loop !== false;
|
|
80
|
+
}
|
|
81
|
+
/** Longest clip in a state at these weights; the state's timeline length for exit times and events. */
|
|
82
|
+
function stateDuration(weights, clips) {
|
|
83
|
+
let duration = 0;
|
|
84
|
+
for (const clip of Object.keys(weights))
|
|
85
|
+
duration = Math.max(duration, clipDuration(clips[clip]));
|
|
86
|
+
return duration;
|
|
87
|
+
}
|
|
88
|
+
function clipDuration(info) {
|
|
89
|
+
return typeof info === "number" ? info : info?.duration ?? 0;
|
|
90
|
+
}
|
|
91
|
+
function rootPosition(track, time, duration, loop) {
|
|
92
|
+
const count = Math.min(track.times.length, Math.floor(track.values.length / 3));
|
|
93
|
+
if (count === 0)
|
|
94
|
+
return [0, 0, 0];
|
|
95
|
+
const first = track.times[0] ?? 0;
|
|
96
|
+
const last = track.times[count - 1] ?? first;
|
|
97
|
+
const cycle = loop && duration > 0 ? Math.floor(time / duration) : 0;
|
|
98
|
+
const local = loop && duration > 0 ? ((time % duration) + duration) % duration : Math.min(Math.max(time, first), last);
|
|
99
|
+
let i = 0;
|
|
100
|
+
while (i + 1 < count && (track.times[i + 1] ?? 0) <= local)
|
|
101
|
+
i += 1;
|
|
102
|
+
const next = Math.min(i + 1, count - 1);
|
|
103
|
+
const span = (track.times[next] ?? 0) - (track.times[i] ?? 0);
|
|
104
|
+
const t = next === i || span <= 0 ? 0 : (local - (track.times[i] ?? 0)) / span;
|
|
105
|
+
const out = [0, 0, 0];
|
|
106
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
107
|
+
const a = track.values[i * 3 + axis] ?? 0;
|
|
108
|
+
const b = track.values[next * 3 + axis] ?? a;
|
|
109
|
+
const cycleOffset = loop && duration > 0 ? ((track.values[(count - 1) * 3 + axis] ?? 0) - (track.values[axis] ?? 0)) * cycle : 0;
|
|
110
|
+
out[axis] = a + (b - a) * t + cycleOffset;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
function clipTimeFor(time, duration, loop) {
|
|
115
|
+
if (!(duration > 0))
|
|
116
|
+
return 0;
|
|
117
|
+
if (loop)
|
|
118
|
+
return time % duration;
|
|
119
|
+
return Math.min(time, duration);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Headless animation state machine and blend evaluator. It owns every clip's playback time and weight, so the
|
|
123
|
+
* renderer only seeks and weights actions on a mixer, and headless hosts, replays, and tests advance the same
|
|
124
|
+
* graph without three.js. Transitions are data (parameter comparisons and consumed triggers), layers can be
|
|
125
|
+
* masked or additive, and events fire by clip time, including across loop wraps.
|
|
126
|
+
*
|
|
127
|
+
* @capability anim-graph data-first animation state machine with blend trees, crossfades, layers, and clip events
|
|
128
|
+
*/
|
|
129
|
+
export function createAnimGraphRuntime(initial) {
|
|
130
|
+
let graph = initial;
|
|
131
|
+
let triggers = new Set();
|
|
132
|
+
let layers = {};
|
|
133
|
+
function reset() {
|
|
134
|
+
layers = {};
|
|
135
|
+
for (const layer of graph.layers)
|
|
136
|
+
layers[layer.id] = { current: layer.entry, time: 0, transition: null };
|
|
137
|
+
}
|
|
138
|
+
reset();
|
|
139
|
+
function pickTransition(layer, state, params, normalized) {
|
|
140
|
+
for (const transition of layer.transitions) {
|
|
141
|
+
if (transition.from !== "*" && transition.from !== state.current)
|
|
142
|
+
continue;
|
|
143
|
+
if (transition.to === state.current && transition.from === "*")
|
|
144
|
+
continue;
|
|
145
|
+
if (transition.trigger !== undefined && !triggers.has(transition.trigger))
|
|
146
|
+
continue;
|
|
147
|
+
if (transition.exitTime !== undefined && normalized < transition.exitTime)
|
|
148
|
+
continue;
|
|
149
|
+
if (transition.when !== undefined && !transition.when.every((c) => compare(params[c.param], c.op, c.value)))
|
|
150
|
+
continue;
|
|
151
|
+
if (transition.trigger !== undefined)
|
|
152
|
+
triggers.delete(transition.trigger);
|
|
153
|
+
return transition;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
function collectEvents(out, clip, before, after, duration, loop) {
|
|
158
|
+
if (graph.events === undefined || !(duration > 0))
|
|
159
|
+
return;
|
|
160
|
+
for (const event of graph.events) {
|
|
161
|
+
if (event.clip !== clip)
|
|
162
|
+
continue;
|
|
163
|
+
const at = event.atSec;
|
|
164
|
+
if (loop) {
|
|
165
|
+
const wrapped = after < before;
|
|
166
|
+
const hit = wrapped ? at > before || at <= after : at > before && at <= after;
|
|
167
|
+
if (hit)
|
|
168
|
+
out.push({ name: event.name, clip });
|
|
169
|
+
}
|
|
170
|
+
else if (at > before && at <= after) {
|
|
171
|
+
out.push({ name: event.name, clip });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
graph: () => graph,
|
|
177
|
+
retune(next) {
|
|
178
|
+
graph = next;
|
|
179
|
+
const previous = layers;
|
|
180
|
+
reset();
|
|
181
|
+
for (const [id, state] of Object.entries(previous)) {
|
|
182
|
+
const layer = graph.layers.find((l) => l.id === id);
|
|
183
|
+
if (layer !== undefined && layer.states[state.current] !== undefined)
|
|
184
|
+
layers[id] = state;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
state: () => ({ layers, triggers: [...triggers] }),
|
|
188
|
+
snapshot: () => ({ layers: structuredClone(layers), triggers: [...triggers] }),
|
|
189
|
+
restore(next) {
|
|
190
|
+
layers = structuredClone(next.layers);
|
|
191
|
+
triggers = new Set(next.triggers);
|
|
192
|
+
},
|
|
193
|
+
trigger(name) {
|
|
194
|
+
triggers.add(name);
|
|
195
|
+
},
|
|
196
|
+
stateOf: (layerId) => layers[layerId]?.current ?? null,
|
|
197
|
+
advance(dt, params, clips) {
|
|
198
|
+
const output = { clips: [], events: [] };
|
|
199
|
+
const rootDelta = [0, 0, 0];
|
|
200
|
+
for (const layer of graph.layers) {
|
|
201
|
+
const state = layers[layer.id];
|
|
202
|
+
if (state === undefined)
|
|
203
|
+
continue;
|
|
204
|
+
const def = layer.states[state.current];
|
|
205
|
+
if (def === undefined)
|
|
206
|
+
continue;
|
|
207
|
+
const layerWeight = layer.weight ?? 1;
|
|
208
|
+
const before = state.time;
|
|
209
|
+
state.time += dt * stateSpeed(def);
|
|
210
|
+
const weights = stateClipWeights(def, params);
|
|
211
|
+
const duration = stateDuration(weights, clips);
|
|
212
|
+
const loop = stateLoops(def);
|
|
213
|
+
const normalized = duration > 0 ? (loop ? (state.time % duration) / duration : Math.min(1, state.time / duration)) : 1;
|
|
214
|
+
for (const clip of Object.keys(weights)) {
|
|
215
|
+
const duration = clipDuration(clips[clip]);
|
|
216
|
+
collectEvents(output.events, clip, clipTimeFor(before, duration, loop), clipTimeFor(state.time, duration, loop), duration, loop);
|
|
217
|
+
if (def.rootMotion) {
|
|
218
|
+
const info = clips[clip];
|
|
219
|
+
if (typeof info !== "number" && info?.rootTrack !== undefined) {
|
|
220
|
+
const from = rootPosition(info.rootTrack, before, duration, loop);
|
|
221
|
+
const to = rootPosition(info.rootTrack, state.time, duration, loop);
|
|
222
|
+
for (let axis = 0; axis < 3; axis += 1)
|
|
223
|
+
rootDelta[axis] += (to[axis] - from[axis]) * weights[clip] * layerWeight;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const transition = state.transition;
|
|
228
|
+
if (transition !== null) {
|
|
229
|
+
transition.elapsed += dt;
|
|
230
|
+
const t = transition.duration > 0 ? Math.min(1, transition.elapsed / transition.duration) : 1;
|
|
231
|
+
const merged = {};
|
|
232
|
+
const times = {};
|
|
233
|
+
for (const [clip, w] of Object.entries(transition.fromWeights)) {
|
|
234
|
+
merged[clip] = (merged[clip] ?? 0) + w * (1 - t);
|
|
235
|
+
times[clip] = transition.fromTimes[clip] ?? 0;
|
|
236
|
+
}
|
|
237
|
+
for (const [clip, w] of Object.entries(weights)) {
|
|
238
|
+
merged[clip] = (merged[clip] ?? 0) + w * t;
|
|
239
|
+
times[clip] = clipTimeFor(state.time, clipDuration(clips[clip]), loop);
|
|
240
|
+
}
|
|
241
|
+
for (const [clip, w] of Object.entries(merged)) {
|
|
242
|
+
if (w <= 0)
|
|
243
|
+
continue;
|
|
244
|
+
output.clips.push({ clip, weight: w * layerWeight, time: times[clip] ?? 0, layer: layer.id });
|
|
245
|
+
}
|
|
246
|
+
if (t >= 1)
|
|
247
|
+
state.transition = null;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
for (const [clip, w] of Object.entries(weights)) {
|
|
251
|
+
if (w <= 0)
|
|
252
|
+
continue;
|
|
253
|
+
output.clips.push({ clip, weight: w * layerWeight, time: clipTimeFor(state.time, clipDuration(clips[clip]), loop), layer: layer.id });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const next = pickTransition(layer, state, params, normalized);
|
|
257
|
+
if (next !== null && next.to !== state.current) {
|
|
258
|
+
const fromWeights = {};
|
|
259
|
+
const fromTimes = {};
|
|
260
|
+
for (const entry of output.clips) {
|
|
261
|
+
if (entry.layer !== layer.id)
|
|
262
|
+
continue;
|
|
263
|
+
fromWeights[entry.clip] = (fromWeights[entry.clip] ?? 0) + entry.weight / (layerWeight || 1);
|
|
264
|
+
fromTimes[entry.clip] = entry.time;
|
|
265
|
+
}
|
|
266
|
+
state.current = next.to;
|
|
267
|
+
state.time = 0;
|
|
268
|
+
state.transition = {
|
|
269
|
+
to: next.to,
|
|
270
|
+
elapsed: 0,
|
|
271
|
+
duration: next.duration ?? DEFAULT_FADE,
|
|
272
|
+
fromWeights,
|
|
273
|
+
fromTimes,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
else if (next !== null && next.to === state.current) {
|
|
277
|
+
state.time = 0;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
triggers.clear();
|
|
281
|
+
if (rootDelta[0] !== 0 || rootDelta[1] !== 0 || rootDelta[2] !== 0)
|
|
282
|
+
output.rootDelta = rootDelta;
|
|
283
|
+
return output;
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** A three-dimensional point or direction represented without a renderer dependency. */
|
|
2
|
+
export type Vec3 = readonly [number, number, number];
|
|
3
|
+
/** A mutable three-dimensional tuple used by allocation-aware solver outputs. */
|
|
4
|
+
export type MutableVec3 = [number, number, number];
|
|
5
|
+
/** A quaternion in `(x, y, z, w)` order. */
|
|
6
|
+
export type Quat = readonly [number, number, number, number];
|
|
7
|
+
/** Inputs for {@link solveTwoBone}. */
|
|
8
|
+
export interface TwoBoneInput {
|
|
9
|
+
root: Vec3;
|
|
10
|
+
mid: Vec3;
|
|
11
|
+
tip: Vec3;
|
|
12
|
+
target: Vec3;
|
|
13
|
+
pole: Vec3;
|
|
14
|
+
}
|
|
15
|
+
/** Reused output buffers filled by {@link solveTwoBone}. */
|
|
16
|
+
export interface TwoBoneOutput {
|
|
17
|
+
mid: MutableVec3;
|
|
18
|
+
tip: MutableVec3;
|
|
19
|
+
}
|
|
20
|
+
/** Iteration budget and convergence threshold for {@link solveFabrik}. */
|
|
21
|
+
export interface FabrikOptions {
|
|
22
|
+
iterations?: number;
|
|
23
|
+
tolerance?: number;
|
|
24
|
+
}
|
|
25
|
+
/** Inputs for {@link lookAt}. Angles are in radians. */
|
|
26
|
+
export interface LookAtInput {
|
|
27
|
+
from: Vec3;
|
|
28
|
+
target: Vec3;
|
|
29
|
+
up: Vec3;
|
|
30
|
+
maxYaw?: number;
|
|
31
|
+
maxPitch?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Solves a two-segment chain analytically, placing the middle and tip joints toward `target` and `pole`.
|
|
35
|
+
* The root remains fixed, and unreachable targets are clamped to the chain's maximum reach.
|
|
36
|
+
*
|
|
37
|
+
* @capability animation inverse kinematics solve a two-bone chain from a target and bend pole
|
|
38
|
+
*/
|
|
39
|
+
export declare function solveTwoBone(input: TwoBoneInput, out: TwoBoneOutput): TwoBoneOutput;
|
|
40
|
+
/**
|
|
41
|
+
* Solves a chain with the FABRIK forward-and-backward reaching algorithm.
|
|
42
|
+
* Returned tuples are new values; the input chain is not modified.
|
|
43
|
+
*
|
|
44
|
+
* @capability animation inverse kinematics solve an arbitrary tuple chain toward a target
|
|
45
|
+
*/
|
|
46
|
+
export declare function solveFabrik(chain: readonly Vec3[], target: Vec3, options?: FabrikOptions): MutableVec3[];
|
|
47
|
+
/** Returns a quaternion that points a +Z bone from `from` toward `target`, with optional yaw and pitch limits.
|
|
48
|
+
*
|
|
49
|
+
* @capability animation inverse kinematics orient a bone toward a target with constrained yaw and pitch
|
|
50
|
+
*/
|
|
51
|
+
export declare function lookAt(input: LookAtInput): Quat;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const EPSILON = 1e-8;
|
|
2
|
+
function distance(a, b) {
|
|
3
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
4
|
+
}
|
|
5
|
+
function normalize(x, y, z) {
|
|
6
|
+
const length = Math.hypot(x, y, z);
|
|
7
|
+
return length > EPSILON ? [x / length, y / length, z / length] : [0, 0, 0];
|
|
8
|
+
}
|
|
9
|
+
function cross(a, b) {
|
|
10
|
+
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
11
|
+
}
|
|
12
|
+
function addScaled(origin, direction, amount) {
|
|
13
|
+
return [origin[0] + direction[0] * amount, origin[1] + direction[1] * amount, origin[2] + direction[2] * amount];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Solves a two-segment chain analytically, placing the middle and tip joints toward `target` and `pole`.
|
|
17
|
+
* The root remains fixed, and unreachable targets are clamped to the chain's maximum reach.
|
|
18
|
+
*
|
|
19
|
+
* @capability animation inverse kinematics solve a two-bone chain from a target and bend pole
|
|
20
|
+
*/
|
|
21
|
+
export function solveTwoBone(input, out) {
|
|
22
|
+
const firstLength = distance(input.root, input.mid);
|
|
23
|
+
const secondLength = distance(input.mid, input.tip);
|
|
24
|
+
const toTarget = [input.target[0] - input.root[0], input.target[1] - input.root[1], input.target[2] - input.root[2]];
|
|
25
|
+
const targetDistance = Math.hypot(...toTarget);
|
|
26
|
+
const direction = targetDistance > EPSILON ? normalize(...toTarget) : normalize(input.mid[0] - input.root[0], input.mid[1] - input.root[1], input.mid[2] - input.root[2]);
|
|
27
|
+
const minReach = Math.abs(firstLength - secondLength);
|
|
28
|
+
const reach = Math.min(Math.max(targetDistance, minReach), firstLength + secondLength);
|
|
29
|
+
const clampedTarget = addScaled(input.root, direction, reach);
|
|
30
|
+
let bendNormal = normalize(...cross(direction, [input.pole[0] - input.root[0], input.pole[1] - input.root[1], input.pole[2] - input.root[2]]));
|
|
31
|
+
if (Math.hypot(...bendNormal) <= EPSILON) {
|
|
32
|
+
const fallback = Math.abs(direction[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
|
|
33
|
+
bendNormal = normalize(...cross(direction, fallback));
|
|
34
|
+
}
|
|
35
|
+
const bendDirection = normalize(...cross(bendNormal, direction));
|
|
36
|
+
const along = (firstLength * firstLength - secondLength * secondLength + reach * reach) / (2 * reach || 1);
|
|
37
|
+
const height = Math.sqrt(Math.max(0, firstLength * firstLength - along * along));
|
|
38
|
+
const solvedMid = addScaled(addScaled(input.root, direction, along), bendDirection, height);
|
|
39
|
+
out.mid[0] = solvedMid[0];
|
|
40
|
+
out.mid[1] = solvedMid[1];
|
|
41
|
+
out.mid[2] = solvedMid[2];
|
|
42
|
+
out.tip[0] = clampedTarget[0];
|
|
43
|
+
out.tip[1] = clampedTarget[1];
|
|
44
|
+
out.tip[2] = clampedTarget[2];
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Solves a chain with the FABRIK forward-and-backward reaching algorithm.
|
|
49
|
+
* Returned tuples are new values; the input chain is not modified.
|
|
50
|
+
*
|
|
51
|
+
* @capability animation inverse kinematics solve an arbitrary tuple chain toward a target
|
|
52
|
+
*/
|
|
53
|
+
export function solveFabrik(chain, target, options = {}) {
|
|
54
|
+
if (chain.length === 0)
|
|
55
|
+
return [];
|
|
56
|
+
if (chain.length === 1)
|
|
57
|
+
return [[chain[0][0], chain[0][1], chain[0][2]]];
|
|
58
|
+
const points = chain.map((point) => [point[0], point[1], point[2]]);
|
|
59
|
+
const lengths = chain.slice(1).map((point, index) => distance(chain[index], point));
|
|
60
|
+
const root = points[0];
|
|
61
|
+
const totalLength = lengths.reduce((sum, length) => sum + length, 0);
|
|
62
|
+
const rootDistance = distance(root, target);
|
|
63
|
+
const iterations = Math.max(0, Math.floor(options.iterations ?? 10));
|
|
64
|
+
const tolerance = Math.max(0, options.tolerance ?? 1e-3);
|
|
65
|
+
if (rootDistance >= totalLength) {
|
|
66
|
+
const direction = normalize(target[0] - root[0], target[1] - root[1], target[2] - root[2]);
|
|
67
|
+
for (let i = 1; i < points.length; i += 1)
|
|
68
|
+
points[i] = addScaled(points[i - 1], direction, lengths[i - 1]);
|
|
69
|
+
return points;
|
|
70
|
+
}
|
|
71
|
+
for (let iteration = 0; iteration < iterations; iteration += 1) {
|
|
72
|
+
points[points.length - 1] = [target[0], target[1], target[2]];
|
|
73
|
+
for (let i = points.length - 2; i >= 0; i -= 1) {
|
|
74
|
+
const direction = normalize(points[i][0] - points[i + 1][0], points[i][1] - points[i + 1][1], points[i][2] - points[i + 1][2]);
|
|
75
|
+
points[i] = addScaled(points[i + 1], direction, lengths[i]);
|
|
76
|
+
}
|
|
77
|
+
points[0] = [root[0], root[1], root[2]];
|
|
78
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
79
|
+
const direction = normalize(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1], points[i][2] - points[i - 1][2]);
|
|
80
|
+
points[i] = addScaled(points[i - 1], direction, lengths[i - 1]);
|
|
81
|
+
}
|
|
82
|
+
if (distance(points[points.length - 1], target) <= tolerance)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
return points;
|
|
86
|
+
}
|
|
87
|
+
function quaternionFromBasis(right, up, forward) {
|
|
88
|
+
const trace = right[0] + up[1] + forward[2];
|
|
89
|
+
if (trace > 0) {
|
|
90
|
+
const s = Math.sqrt(trace + 1) * 2;
|
|
91
|
+
return [(up[2] - forward[1]) / s, (forward[0] - right[2]) / s, (right[1] - up[0]) / s, 0.25 * s];
|
|
92
|
+
}
|
|
93
|
+
if (right[0] > up[1] && right[0] > forward[2]) {
|
|
94
|
+
const s = Math.sqrt(1 + right[0] - up[1] - forward[2]) * 2;
|
|
95
|
+
return [0.25 * s, (right[1] + up[0]) / s, (right[2] + forward[0]) / s, (up[2] - forward[1]) / s];
|
|
96
|
+
}
|
|
97
|
+
if (up[1] > forward[2]) {
|
|
98
|
+
const s = Math.sqrt(1 + up[1] - right[0] - forward[2]) * 2;
|
|
99
|
+
return [(right[1] + up[0]) / s, 0.25 * s, (up[2] + forward[1]) / s, (forward[0] - right[2]) / s];
|
|
100
|
+
}
|
|
101
|
+
const s = Math.sqrt(1 + forward[2] - right[0] - up[1]) * 2;
|
|
102
|
+
return [(right[2] + forward[0]) / s, (up[2] + forward[1]) / s, 0.25 * s, (right[1] - up[0]) / s];
|
|
103
|
+
}
|
|
104
|
+
/** Returns a quaternion that points a +Z bone from `from` toward `target`, with optional yaw and pitch limits.
|
|
105
|
+
*
|
|
106
|
+
* @capability animation inverse kinematics orient a bone toward a target with constrained yaw and pitch
|
|
107
|
+
*/
|
|
108
|
+
export function lookAt(input) {
|
|
109
|
+
let direction = normalize(input.target[0] - input.from[0], input.target[1] - input.from[1], input.target[2] - input.from[2]);
|
|
110
|
+
if (Math.hypot(...direction) <= EPSILON)
|
|
111
|
+
return [0, 0, 0, 1];
|
|
112
|
+
let yaw = Math.atan2(direction[0], direction[2]);
|
|
113
|
+
let pitch = Math.atan2(direction[1], Math.hypot(direction[0], direction[2]));
|
|
114
|
+
if (input.maxYaw !== undefined)
|
|
115
|
+
yaw = Math.max(-Math.abs(input.maxYaw), Math.min(Math.abs(input.maxYaw), yaw));
|
|
116
|
+
if (input.maxPitch !== undefined)
|
|
117
|
+
pitch = Math.max(-Math.abs(input.maxPitch), Math.min(Math.abs(input.maxPitch), pitch));
|
|
118
|
+
direction = [Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)];
|
|
119
|
+
let right = normalize(...cross(input.up, direction));
|
|
120
|
+
if (Math.hypot(...right) <= EPSILON)
|
|
121
|
+
right = normalize(...cross([0, 1, 0], direction));
|
|
122
|
+
const up = cross(direction, right);
|
|
123
|
+
return quaternionFromBasis(right, up, direction);
|
|
124
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AnimGraph } from "./animGraph.js";
|
|
2
|
+
/** Inputs for {@link locomotionGraph}: the idle/walk/run clip names and the one-shot table a rig config already carries. */
|
|
3
|
+
export interface LocomotionGraphInput {
|
|
4
|
+
idle: string;
|
|
5
|
+
walk: string;
|
|
6
|
+
run?: string;
|
|
7
|
+
/** Speed (world units/sec) above which the character walks. Default 0.5. */
|
|
8
|
+
walkSpeed?: number;
|
|
9
|
+
/** Speed above which it runs. Default 6. */
|
|
10
|
+
runSpeed?: number;
|
|
11
|
+
/** Crossfade seconds between locomotion states. Default 0.2. */
|
|
12
|
+
fadeSec?: number;
|
|
13
|
+
/** One-shot clips keyed by trigger name; `death` holds its last frame instead of returning. */
|
|
14
|
+
oneShots?: Readonly<Record<string, string>>;
|
|
15
|
+
}
|
|
16
|
+
/** The parameter name the shell feeds with the entity's smoothed ground speed. */
|
|
17
|
+
export declare const LOCOMOTION_SPEED_PARAM = "speed";
|
|
18
|
+
/** Layer id the locomotion graph uses; query `runtime.stateOf(LOCOMOTION_LAYER)`. */
|
|
19
|
+
export declare const LOCOMOTION_LAYER = "base";
|
|
20
|
+
/**
|
|
21
|
+
* The engine's default locomotion as an authored graph: a speed-driven blend between idle, walk, and run, plus a
|
|
22
|
+
* state per one-shot that plays once and returns (or clamps for `death`). What `useModelAnimation` used to hardcode.
|
|
23
|
+
*
|
|
24
|
+
* @capability locomotion-graph build the default idle/walk/run plus one-shot graph from clip names
|
|
25
|
+
*/
|
|
26
|
+
export declare function locomotionGraph(input: LocomotionGraphInput): AnimGraph;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The parameter name the shell feeds with the entity's smoothed ground speed. */
|
|
2
|
+
export const LOCOMOTION_SPEED_PARAM = "speed";
|
|
3
|
+
/** Layer id the locomotion graph uses; query `runtime.stateOf(LOCOMOTION_LAYER)`. */
|
|
4
|
+
export const LOCOMOTION_LAYER = "base";
|
|
5
|
+
/**
|
|
6
|
+
* The engine's default locomotion as an authored graph: a speed-driven blend between idle, walk, and run, plus a
|
|
7
|
+
* state per one-shot that plays once and returns (or clamps for `death`). What `useModelAnimation` used to hardcode.
|
|
8
|
+
*
|
|
9
|
+
* @capability locomotion-graph build the default idle/walk/run plus one-shot graph from clip names
|
|
10
|
+
*/
|
|
11
|
+
export function locomotionGraph(input) {
|
|
12
|
+
const walkSpeed = input.walkSpeed ?? 0.5;
|
|
13
|
+
const runSpeed = input.runSpeed ?? 6;
|
|
14
|
+
const fade = input.fadeSec ?? 0.2;
|
|
15
|
+
const points = [
|
|
16
|
+
{ at: 0, clip: input.idle },
|
|
17
|
+
{ at: walkSpeed, clip: input.walk },
|
|
18
|
+
...(input.run === undefined ? [] : [{ at: runSpeed, clip: input.run }]),
|
|
19
|
+
];
|
|
20
|
+
const states = {
|
|
21
|
+
locomotion: { kind: "blend1D", param: LOCOMOTION_SPEED_PARAM, points },
|
|
22
|
+
};
|
|
23
|
+
const transitions = [];
|
|
24
|
+
for (const [name, clip] of Object.entries(input.oneShots ?? {})) {
|
|
25
|
+
states[name] = { kind: "clip", clip, loop: false };
|
|
26
|
+
transitions.push({ from: "*", to: name, trigger: name, duration: 0.1 });
|
|
27
|
+
if (name !== "death")
|
|
28
|
+
transitions.push({ from: name, to: "locomotion", exitTime: 1, duration: fade });
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
layers: [{ id: LOCOMOTION_LAYER, entry: "locomotion", states, transitions }],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Portable image atlas data shared by renderers and headless game logic. */
|
|
2
|
+
export interface SpriteAtlas {
|
|
3
|
+
image: string;
|
|
4
|
+
size: [number, number];
|
|
5
|
+
frames: Record<string, {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
w: number;
|
|
9
|
+
h: number;
|
|
10
|
+
pivot?: [number, number];
|
|
11
|
+
}>;
|
|
12
|
+
animations: Record<string, {
|
|
13
|
+
frames: string[];
|
|
14
|
+
fps: number;
|
|
15
|
+
loop?: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
/** Convert Aseprite JSON exports (hash or array frame layouts) to the portable atlas format. */
|
|
19
|
+
export declare function fromAseprite(json: unknown): SpriteAtlas;
|
|
20
|
+
/** Convert TexturePacker JSON exports to the portable atlas format. */
|
|
21
|
+
export declare function fromTexturePacker(json: unknown): SpriteAtlas;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
function object(value, label) {
|
|
2
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3
|
+
throw new Error(`${label} must be an object.`);
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
function number(value, label) {
|
|
7
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
8
|
+
throw new Error(`${label} must be a finite number.`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function frame(value, label) {
|
|
12
|
+
const data = object(value, label);
|
|
13
|
+
const x = number(data.x, `${label}.x`);
|
|
14
|
+
const y = number(data.y, `${label}.y`);
|
|
15
|
+
const w = number(data.w ?? data.width, `${label}.w`);
|
|
16
|
+
const h = number(data.h ?? data.height, `${label}.h`);
|
|
17
|
+
if (w <= 0 || h <= 0 || x < 0 || y < 0)
|
|
18
|
+
throw new Error(`${label} has invalid bounds.`);
|
|
19
|
+
const pivotValue = data.pivot;
|
|
20
|
+
const pivot = pivotValue === undefined ? undefined : [number(pivotValue[0], `${label}.pivot[0]`), number(pivotValue[1], `${label}.pivot[1]`)];
|
|
21
|
+
return pivot ? { x, y, w, h, pivot } : { x, y, w, h };
|
|
22
|
+
}
|
|
23
|
+
function imageAndSize(root) {
|
|
24
|
+
const meta = object(root.meta, "meta");
|
|
25
|
+
const image = typeof meta.image === "string" ? meta.image : typeof root.image === "string" ? root.image : undefined;
|
|
26
|
+
if (!image)
|
|
27
|
+
throw new Error("Sprite atlas image is required.");
|
|
28
|
+
const rawSize = object(meta.size ?? root.size, "size");
|
|
29
|
+
const size = [number(rawSize.w ?? rawSize.width, "size[0]"), number(rawSize.h ?? rawSize.height, "size[1]")];
|
|
30
|
+
if (size[0] <= 0 || size[1] <= 0)
|
|
31
|
+
throw new Error("Sprite atlas size must be positive.");
|
|
32
|
+
return { image, size };
|
|
33
|
+
}
|
|
34
|
+
function animationFrames(names, fps = 10, loop = true) {
|
|
35
|
+
if (!names.length || !Number.isFinite(fps) || fps <= 0)
|
|
36
|
+
throw new Error("Animation requires frames and a positive fps.");
|
|
37
|
+
return { frames: names, fps, ...(loop ? {} : { loop: false }) };
|
|
38
|
+
}
|
|
39
|
+
/** Convert Aseprite JSON exports (hash or array frame layouts) to the portable atlas format. */
|
|
40
|
+
export function fromAseprite(json) {
|
|
41
|
+
const root = object(json, "Aseprite export");
|
|
42
|
+
const framesRoot = root.frames;
|
|
43
|
+
const frames = {};
|
|
44
|
+
const durations = {};
|
|
45
|
+
const names = [];
|
|
46
|
+
if (Array.isArray(framesRoot)) {
|
|
47
|
+
for (const [index, entry] of framesRoot.entries()) {
|
|
48
|
+
const e = object(entry, `frames[${index}]`);
|
|
49
|
+
const name = typeof e.filename === "string" ? e.filename : String(index);
|
|
50
|
+
frames[name] = frame(e.frame, `frames[${index}].frame`);
|
|
51
|
+
if (e.duration !== undefined)
|
|
52
|
+
durations[name] = number(e.duration, `frames[${index}].duration`);
|
|
53
|
+
names.push(name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
for (const [name, entry] of Object.entries(object(framesRoot, "frames"))) {
|
|
58
|
+
const e = object(entry, `frames.${name}`);
|
|
59
|
+
frames[name] = frame(e.frame ?? e, `frames.${name}`);
|
|
60
|
+
if (e.duration !== undefined)
|
|
61
|
+
durations[name] = number(e.duration, `frames.${name}.duration`);
|
|
62
|
+
names.push(name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!names.length)
|
|
66
|
+
throw new Error("Aseprite export must contain frames.");
|
|
67
|
+
const size = imageAndSize(root);
|
|
68
|
+
const animations = {};
|
|
69
|
+
const tags = Array.isArray(root.meta?.frameTags) ? root.meta.frameTags : [];
|
|
70
|
+
for (const [index, tagValue] of tags.entries()) {
|
|
71
|
+
const tag = object(tagValue, `meta.frameTags[${index}]`);
|
|
72
|
+
const name = String(tag.name ?? "");
|
|
73
|
+
const from = Number(tag.from);
|
|
74
|
+
const to = Number(tag.to);
|
|
75
|
+
if (!name || !Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to >= names.length || from > to)
|
|
76
|
+
throw new Error(`Invalid animation tag at index ${index}.`);
|
|
77
|
+
const direction = tag.direction === "reverse" ? -1 : 1;
|
|
78
|
+
const ordered = names.slice(from, to + 1);
|
|
79
|
+
if (direction < 0)
|
|
80
|
+
ordered.reverse();
|
|
81
|
+
const totalDuration = ordered.reduce((sum, frameName) => sum + (durations[frameName] ?? 100), 0);
|
|
82
|
+
animations[name] = animationFrames(ordered, ordered.length * 1000 / totalDuration, tag.direction !== "pingpong");
|
|
83
|
+
}
|
|
84
|
+
if (!Object.keys(animations).length)
|
|
85
|
+
animations.default = animationFrames(names);
|
|
86
|
+
return { image: size.image, size: size.size, frames, animations };
|
|
87
|
+
}
|
|
88
|
+
/** Convert TexturePacker JSON exports to the portable atlas format. */
|
|
89
|
+
export function fromTexturePacker(json) {
|
|
90
|
+
const root = object(json, "TexturePacker export");
|
|
91
|
+
const size = imageAndSize(root);
|
|
92
|
+
const frames = {};
|
|
93
|
+
const source = Array.isArray(root.frames) ? root.frames.map((entry, index) => [String((object(entry, `frames[${index}]`).filename ?? index)), entry]) : Object.entries(object(root.frames, "frames"));
|
|
94
|
+
for (const [name, value] of source) {
|
|
95
|
+
const e = object(value, `frames.${name}`);
|
|
96
|
+
frames[name] = frame(e.frame ?? e, `frames.${name}.frame`);
|
|
97
|
+
}
|
|
98
|
+
const names = Object.keys(frames);
|
|
99
|
+
if (!names.length)
|
|
100
|
+
throw new Error("TexturePacker export must contain frames.");
|
|
101
|
+
const animations = { default: animationFrames(names) };
|
|
102
|
+
return { image: size.image, size: size.size, frames, animations };
|
|
103
|
+
}
|
|
@@ -27,6 +27,16 @@ export interface SoundDef {
|
|
|
27
27
|
/** Positional emitters attenuate by distance from the listener; non-positional sounds (UI, music) play at flat gain. Default true. */
|
|
28
28
|
positional?: boolean;
|
|
29
29
|
falloff?: AudioFalloffConfig;
|
|
30
|
+
/** Web Audio spatial panning configuration. When present, the shell uses a PannerNode. */
|
|
31
|
+
spatial?: {
|
|
32
|
+
panning: "hrtf" | "equalpower";
|
|
33
|
+
refDistance?: number;
|
|
34
|
+
maxDistance?: number;
|
|
35
|
+
rolloff?: number;
|
|
36
|
+
coneInner?: number;
|
|
37
|
+
coneOuter?: number;
|
|
38
|
+
coneOuterGain?: number;
|
|
39
|
+
};
|
|
30
40
|
}
|
|
31
41
|
export declare function computeFalloffGain(distance: number, config?: AudioFalloffConfig): number;
|
|
32
42
|
export declare function resolveEmitterGain(distance: number, sound: Pick<SoundDef, "gain" | "positional" | "falloff">, busGain: number): number;
|