@volter/editor-model-play 0.5.195 → 0.5.197
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/package.json +2 -2
- package/src/play-script.ts +126 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/editor-model-play",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.197",
|
|
4
4
|
"author": "Volter AI, Inc.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
]
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@volter/editor-sdk": "0.5.
|
|
31
|
+
"@volter/editor-sdk": "0.5.197"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"react": "^19.0.0",
|
package/src/play-script.ts
CHANGED
|
@@ -86,6 +86,7 @@ import {
|
|
|
86
86
|
takeModelPlayStep,
|
|
87
87
|
} from './model-play';
|
|
88
88
|
import { beginModelPlayLog } from './play-log';
|
|
89
|
+
import type { DocumentPlayAnimation } from '@volter/editor-sdk/kit/document-play-extension';
|
|
89
90
|
import { materialOverrides } from './play-materials';
|
|
90
91
|
interface PlayComposition {
|
|
91
92
|
readonly entries: readonly string[];
|
|
@@ -157,6 +158,72 @@ export interface ModelPlayContext {
|
|
|
157
158
|
* });
|
|
158
159
|
*/
|
|
159
160
|
autoplay(bot: ModelPlayAutoplayController | Readonly<Record<string, ModelPlayAutoplayController>> | null): void;
|
|
161
|
+
/**
|
|
162
|
+
* Set the action an object's armature plays, as Blender's `animation_data.action` does: any
|
|
163
|
+
* action in the file that animates that armature's bones, by name. `object` is the armature, its
|
|
164
|
+
* skinned mesh, or an object above or below them, or its name. Each armature starts a run playing
|
|
165
|
+
* the action the file assigns it. Setting another crossfades over `fade` seconds (0.2), loops
|
|
166
|
+
* unless `loop: false` (which holds the last frame), and plays at `speed`; setting the action
|
|
167
|
+
* already playing does nothing, so a script may set it every update from the character's state.
|
|
168
|
+
* `null` fades it out. Actions run on the game's clock. Each change is an `action` entry in the
|
|
169
|
+
* play log; a name the armature lacks is `action-unknown` once and returns false.
|
|
170
|
+
*
|
|
171
|
+
* LAYERS: an action plays on the `base` layer, every bone, unless `layer` names another. A named
|
|
172
|
+
* layer plays from one bone down (`from`, given the first time) and takes those bones from the
|
|
173
|
+
* base, so the legs can run while the upper body does something else. Blender has no layers at
|
|
174
|
+
* run time; its NLA tracks are the nearest thing, and they are not read here.
|
|
175
|
+
*
|
|
176
|
+
* play.setAction('Hero', moving ? 'Run' : 'Idle');
|
|
177
|
+
* play.setAction('Hero', 'Wave', { layer: 'upper', from: 'spine_01' });
|
|
178
|
+
*/
|
|
179
|
+
setAction(object: THREE.Object3D | string, action: string | null, options?: ModelPlayActionOptions): boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Play several actions on one layer at once, at weights (0 to 1), blended: poses between authored
|
|
182
|
+
* ones, such as aiming up, level and down by the aim's angle. An action left out fades to nothing.
|
|
183
|
+
* Weights take effect at once unless `fade` is given, so a script may set them every update.
|
|
184
|
+
* Returns false, with `action-unknown` in the log, as `setAction` does.
|
|
185
|
+
*
|
|
186
|
+
* const up = Math.max(0, pitch), down = Math.max(0, -pitch);
|
|
187
|
+
* play.blend('Hero', { Aim_Up: up, Aim_Level: 1 - up - down, Aim_Down: down }, { layer: 'upper', from: 'spine_01' });
|
|
188
|
+
*/
|
|
189
|
+
blend(object: THREE.Object3D | string, weights: Readonly<Record<string, number>>, options?: ModelPlayActionOptions): boolean;
|
|
190
|
+
/**
|
|
191
|
+
* Turn one bone of an object's armature toward a world point or an object, every update after
|
|
192
|
+
* its actions, until set to `null`. It is the game's own: Blender's constraints and IK are not run
|
|
193
|
+
* in a game (an action holds what they made); this aims at what exists only while the game runs.
|
|
194
|
+
* What points at it is the bone's forward: the way it faced when the character faced its
|
|
195
|
+
* armature's -Y (Blender's front) in the file, so a head looks with its face. `axis` names one
|
|
196
|
+
* of the bone's own axes instead (`y` runs along a Blender bone). `weight` is how far (1),
|
|
197
|
+
* `limit` the most it turns from the animated pose (75 degrees).
|
|
198
|
+
*
|
|
199
|
+
* play.lookAt('Hero', 'head', player);
|
|
200
|
+
*/
|
|
201
|
+
lookAt(object: THREE.Object3D | string, bone: string, target: THREE.Object3D | THREE.Vector3 | null, options?: ModelPlayLookAtOptions): boolean;
|
|
202
|
+
/** The actions an object's armature can play, by name (empty without an armature). */
|
|
203
|
+
actions(object: THREE.Object3D | string): readonly string[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface ModelPlayActionOptions {
|
|
207
|
+
/** The layer: `base` (every bone, the default) or a name of the script's choosing. */
|
|
208
|
+
readonly layer?: string;
|
|
209
|
+
/** A named layer's first bone: it plays on that bone and every bone below it. */
|
|
210
|
+
readonly from?: string;
|
|
211
|
+
readonly loop?: boolean;
|
|
212
|
+
readonly fade?: number;
|
|
213
|
+
readonly speed?: number;
|
|
214
|
+
/** Start again from the first frame when this clip is already playing. */
|
|
215
|
+
readonly restart?: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ModelPlayLookAtOptions {
|
|
219
|
+
readonly weight?: number;
|
|
220
|
+
readonly axis?: 'forward' | 'x' | 'y' | 'z' | '-x' | '-y' | '-z';
|
|
221
|
+
readonly limit?: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Weights as a log shows them: two decimals. */
|
|
225
|
+
function rounded(weights: Readonly<Record<string, number>>): Record<string, number> {
|
|
226
|
+
return Object.fromEntries(Object.entries(weights).map(([name, weight]) => [name, Math.round(weight * 100) / 100]));
|
|
160
227
|
}
|
|
161
228
|
|
|
162
229
|
/** What the bot is handed before each `update` it drives. */
|
|
@@ -290,8 +357,11 @@ export function runPlayScript(options: {
|
|
|
290
357
|
readonly ready: () => void;
|
|
291
358
|
readonly returning: () => void;
|
|
292
359
|
readonly ownMaterial?: ((material: THREE.Material) => THREE.Material | null) | undefined;
|
|
360
|
+
/** The document's skins and clips bound to `root`; absent, characters stand in their exported pose. */
|
|
361
|
+
readonly animation?: DocumentPlayAnimation | undefined;
|
|
293
362
|
}): () => void {
|
|
294
363
|
const { blend, root, camera, onFrame } = options;
|
|
364
|
+
const options_ = options;
|
|
295
365
|
const modulePath = playScriptPath(blend);
|
|
296
366
|
// A fresh copy is a fresh run: its log starts empty, its clock at zero. Writes go through
|
|
297
367
|
// this run's handle, which is inert once the run has ended.
|
|
@@ -360,6 +430,21 @@ export function runPlayScript(options: {
|
|
|
360
430
|
options.container.style.opacity = '0';
|
|
361
431
|
/** One script's context: its log, tint, opacity and bot do nothing once that script is gone
|
|
362
432
|
* (replaced, failed, or the run stopped), so a stale timer cannot reach a later one. */
|
|
433
|
+
/** The object an animation call names, and the document's animation; `action-unknown` once
|
|
434
|
+
* per object and name when either is missing. */
|
|
435
|
+
const animated = (alive: Script, object: THREE.Object3D | string, what: string | null, call: string) => {
|
|
436
|
+
const name = typeof object === 'string' ? object : object.name;
|
|
437
|
+
const unknown = (why: string): false => {
|
|
438
|
+
const key = `${name}\u0000${call}\u0000${what}`;
|
|
439
|
+
if (!alive.unknown.has(key)) { alive.unknown.add(key); run.append('play', 'action-unknown', { object: name, call, action: what, why }); }
|
|
440
|
+
return false;
|
|
441
|
+
};
|
|
442
|
+
if (!alive.value) return { ok: false as const };
|
|
443
|
+
const target = typeof object === 'string' ? root.getObjectByName(object) ?? null : object;
|
|
444
|
+
if (!target) return { ok: unknown('the model has no such object') };
|
|
445
|
+
if (!options_.animation) return { ok: unknown('this document lends no animation') };
|
|
446
|
+
return { ok: true as const, target, animation: options_.animation, unknown };
|
|
447
|
+
};
|
|
363
448
|
const contextFor = (alive: Script): ModelPlayContext => ({
|
|
364
449
|
root,
|
|
365
450
|
find(name) {
|
|
@@ -385,6 +470,45 @@ export function runPlayScript(options: {
|
|
|
385
470
|
const behaviors = botBehaviors(bot);
|
|
386
471
|
if (alive.value) alive.bot = behaviors;
|
|
387
472
|
},
|
|
473
|
+
setAction(object, action, options) {
|
|
474
|
+
const found = animated(alive, object, action, 'setAction');
|
|
475
|
+
if (!found.ok) return false;
|
|
476
|
+
const { target, animation } = found;
|
|
477
|
+
const layer = options?.layer ?? 'base';
|
|
478
|
+
const before = animation.playing(target, layer);
|
|
479
|
+
if (action === null) {
|
|
480
|
+
animation.stop(target, options?.fade, layer);
|
|
481
|
+
if (before !== null) run.append('play', 'action', { object: target.name, layer, action: null, from: before });
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
const answer = animation.play(target, action, options);
|
|
485
|
+
if (!answer.ok) return found.unknown(answer.why);
|
|
486
|
+
if (before !== action || options?.restart) run.append('play', 'action', { armature: answer.armature, layer, action, from: before });
|
|
487
|
+
return true;
|
|
488
|
+
},
|
|
489
|
+
blend(object, weights, options) {
|
|
490
|
+
const found = animated(alive, object, Object.keys(weights).join('+'), 'blend');
|
|
491
|
+
if (!found.ok) return false;
|
|
492
|
+
const { target, animation } = found;
|
|
493
|
+
const layer = options?.layer ?? 'base';
|
|
494
|
+
const before = animation.playing(target, layer);
|
|
495
|
+
const answer = animation.blend(target, weights, options);
|
|
496
|
+
if (!answer.ok) return found.unknown(answer.why);
|
|
497
|
+
// Weights change every update; the log keeps only a change of the action that leads.
|
|
498
|
+
const now = animation.playing(target, layer);
|
|
499
|
+
if (before !== now) run.append('play', 'action', { armature: answer.armature, layer, action: now, from: before, blend: rounded(weights) });
|
|
500
|
+
return true;
|
|
501
|
+
},
|
|
502
|
+
lookAt(object, bone, at, options) {
|
|
503
|
+
const found = animated(alive, object, bone, 'lookAt');
|
|
504
|
+
if (!found.ok) return false;
|
|
505
|
+
const answer = found.animation.lookAt(found.target, bone, at, options);
|
|
506
|
+
return answer.ok || found.unknown(answer.why);
|
|
507
|
+
},
|
|
508
|
+
actions(object) {
|
|
509
|
+
const target = typeof object === 'string' ? root.getObjectByName(object) ?? null : object;
|
|
510
|
+
return target && options_.animation ? options_.animation.clips(target) : [];
|
|
511
|
+
},
|
|
388
512
|
});
|
|
389
513
|
const scripts = new WeakMap<ModelPlayGame, Script>();
|
|
390
514
|
let stopped = false;
|
|
@@ -566,6 +690,8 @@ export function runPlayScript(options: {
|
|
|
566
690
|
// A paused frame that runs an update is a Step; its entry carries the step's own tick.
|
|
567
691
|
const stepping = modelPlayClock(options.documentId).paused;
|
|
568
692
|
const tick = (dt: number): void => {
|
|
693
|
+
// The characters' clips move on the game's clock, one update's `dt` at a time.
|
|
694
|
+
options.animation?.update(dt);
|
|
569
695
|
run.advance(dt);
|
|
570
696
|
ran += 1;
|
|
571
697
|
simulated += dt;
|