@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.
Files changed (2) hide show
  1. package/package.json +2 -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.195",
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.195"
31
+ "@volter/editor-sdk": "0.5.197"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^19.0.0",
@@ -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;