@volter/editor-model-play 0.5.196 → 0.5.198

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 +99 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/editor-model-play",
3
- "version": "0.5.196",
3
+ "version": "0.5.198",
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.196"
31
+ "@volter/editor-sdk": "0.5.198"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^19.0.0",
@@ -160,17 +160,41 @@ export interface ModelPlayContext {
160
160
  autoplay(bot: ModelPlayAutoplayController | Readonly<Record<string, ModelPlayAutoplayController>> | null): void;
161
161
  /**
162
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.
163
+ * action in the file, by name. `object` is the armature, its skinned mesh, or an object above or
164
+ * below them, or its name. Each armature starts a run playing the action the file assigns it.
165
+ * Setting another crossfades over `fade` seconds (0.2), repeats unless `loop: false` (which
166
+ * holds the last frame), and plays at `speed`; setting the action already playing does nothing,
167
+ * so a script may set it every update from the character's state. `null` fades it out. Each
168
+ * change is an `action` entry in the play log; a name the file lacks is `action-unknown` once
169
+ * and returns false.
170
+ *
171
+ * The armature plays as Blender plays it: its NLA tracks, then this action over them, then its
172
+ * bone constraints, all as the file sets them (and as the Timeline shows them).
170
173
  *
171
174
  * play.setAction('Hero', moving ? 'Run' : 'Idle');
172
175
  */
173
176
  setAction(object: THREE.Object3D | string, action: string | null, options?: ModelPlayActionOptions): boolean;
177
+ /**
178
+ * Set one of the armature's NLA tracks, by name: the `action` it plays from now (a track the
179
+ * file lacks is made, over the file's), its `influence` (0 to 1, reached over `fade`), or
180
+ * `mute`. `null` gives the track back to the file. As in Blender, an action changes only the
181
+ * bones it keys: an action keyed on the upper body, on a track over a running action, is an
182
+ * upper body that aims while the legs run. Influences set every update blend poses between
183
+ * authored ones (aim up, level and down by the aim's angle).
184
+ *
185
+ * play.setTrack('Hero', 'Aim', { action: 'Aim_Upper' });
186
+ * play.setTrack('Hero', 'AimUp', { action: 'Aim_Up_Upper', influence: Math.max(0, pitch) });
187
+ */
188
+ setTrack(object: THREE.Object3D | string, track: string, options: ModelPlayTrackOptions | null): boolean;
189
+ /**
190
+ * Set one bone constraint the file gives the armature, by bone and constraint name: its
191
+ * `influence`, or the `target` object it aims at. A constraint aims at the game's copy of its
192
+ * file target, so moving that object in the game moves the aim with no call at all. A game
193
+ * plays Damped Track; another type is refused with the reason.
194
+ *
195
+ * play.setConstraint('Hero', 'Head', 'Look', { influence: alert ? 1 : 0 });
196
+ */
197
+ setConstraint(object: THREE.Object3D | string, bone: string, constraint: string, options: ModelPlayConstraintOptions): boolean;
174
198
  /** The actions an object's armature can play, by name (empty without an armature). */
175
199
  actions(object: THREE.Object3D | string): readonly string[];
176
200
  }
@@ -179,10 +203,23 @@ export interface ModelPlayActionOptions {
179
203
  readonly loop?: boolean;
180
204
  readonly fade?: number;
181
205
  readonly speed?: number;
182
- /** Start again from the first frame when this clip is already playing. */
206
+ /** Start again from the first frame when this action is already playing. */
183
207
  readonly restart?: boolean;
184
208
  }
185
209
 
210
+ export interface ModelPlayTrackOptions extends ModelPlayActionOptions {
211
+ /** The action the track plays from now; absent, what the file gives it. */
212
+ readonly action?: string;
213
+ readonly influence?: number;
214
+ readonly mute?: boolean;
215
+ }
216
+
217
+ export interface ModelPlayConstraintOptions {
218
+ readonly influence?: number;
219
+ /** The object it aims at (or its name) instead of the file's target. */
220
+ readonly target?: THREE.Object3D | string | null;
221
+ }
222
+
186
223
  /** What the bot is handed before each `update` it drives. */
187
224
  export interface ModelPlayAutoplayInput {
188
225
  /** The behaviour driving (`play autoplay on <behaviour>`), for a controller shared by several. */
@@ -387,6 +424,23 @@ export function runPlayScript(options: {
387
424
  options.container.style.opacity = '0';
388
425
  /** One script's context: its log, tint, opacity and bot do nothing once that script is gone
389
426
  * (replaced, failed, or the run stopped), so a stale timer cannot reach a later one. */
427
+ /** The object an animation call names, and the document's animation; `action-unknown` once
428
+ * per object and name when either is missing. */
429
+ const animated = (alive: Script, object: THREE.Object3D | string, what: string | null, call: string) => {
430
+ const name = typeof object === 'string' ? object : object.name;
431
+ const unknown = (why: string): false => {
432
+ const key = `${name}\u0000${call}\u0000${what}`;
433
+ if (!alive.unknown.has(key)) { alive.unknown.add(key); run.append('play', 'action-unknown', { object: name, call, action: what, why }); }
434
+ return false;
435
+ };
436
+ if (!alive.value) return { ok: false as const };
437
+ const target = typeof object === 'string' ? root.getObjectByName(object) ?? null : object;
438
+ if (!target) return { ok: unknown('the model has no such object') };
439
+ if (!options_.animation) return { ok: unknown('this document lends no animation') };
440
+ return { ok: true as const, target, animation: options_.animation, unknown };
441
+ };
442
+ /** What each NLA track was last set to, so the log says a change once. */
443
+ const tracks = new Map<string, string>();
390
444
  const contextFor = (alive: Script): ModelPlayContext => ({
391
445
  root,
392
446
  find(name) {
@@ -413,27 +467,50 @@ export function runPlayScript(options: {
413
467
  if (alive.value) alive.bot = behaviors;
414
468
  },
415
469
  setAction(object, action, options) {
416
- if (!alive.value) return false;
417
- const name = typeof object === 'string' ? object : object.name;
418
- const target = typeof object === 'string' ? root.getObjectByName(object) ?? null : object;
419
- const unknown = (why: string): false => {
420
- const key = `${name}\u0000${action}`;
421
- if (!alive.unknown.has(key)) { alive.unknown.add(key); run.append('play', 'action-unknown', { object: name, action, why }); }
422
- return false;
423
- };
424
- if (!target) return unknown('the model has no such object');
425
- if (!options_.animation) return unknown('this document lends no animation');
426
- const before = options_.animation.playing(target);
470
+ const found = animated(alive, object, action, 'setAction');
471
+ if (!found.ok) return false;
472
+ const { target, animation } = found;
473
+ const before = animation.playing(target);
427
474
  if (action === null) {
428
- options_.animation.stop(target, options?.fade);
475
+ animation.stop(target, options?.fade);
429
476
  if (before !== null) run.append('play', 'action', { object: target.name, action: null, from: before });
430
477
  return true;
431
478
  }
432
- const answer = options_.animation.play(target, action, options);
433
- if (!answer.ok) return unknown(answer.why);
479
+ const answer = animation.play(target, action, options);
480
+ if (!answer.ok) return found.unknown(answer.why);
434
481
  if (before !== action || options?.restart) run.append('play', 'action', { armature: answer.armature, action, from: before });
435
482
  return true;
436
483
  },
484
+ setTrack(object, track, options) {
485
+ const found = animated(alive, object, track, 'setTrack');
486
+ if (!found.ok) return false;
487
+ const answer = found.animation.track(found.target, track, options);
488
+ if (!answer.ok) return found.unknown(answer.why);
489
+ // Influences change every update; the log keeps what the track plays and whether it is muted.
490
+ const key = `${answer.armature}${track}`;
491
+ const now = options === null ? 'file' : JSON.stringify([options.action ?? null, options.mute ?? false]);
492
+ if (tracks.get(key) !== now) {
493
+ tracks.set(key, now);
494
+ run.append('play', 'track', { armature: answer.armature, track, ...(options === null ? { file: true } : { action: options.action ?? null, mute: options.mute ?? false }) });
495
+ }
496
+ return true;
497
+ },
498
+ setConstraint(object, bone, constraint, options) {
499
+ const found = animated(alive, object, `${bone}/${constraint}`, 'setConstraint');
500
+ if (!found.ok) return false;
501
+ let aim: THREE.Object3D | null | undefined = undefined;
502
+ if (typeof options.target === 'string') {
503
+ aim = root.getObjectByName(options.target) ?? null;
504
+ if (!aim) return found.unknown(`the model has no object "${options.target}" to aim at`);
505
+ } else aim = options.target;
506
+ const answer = found.animation.constraint(found.target, bone, constraint, {
507
+ ...(options.influence === undefined ? {} : { influence: options.influence }),
508
+ ...(aim === undefined ? {} : { target: aim }),
509
+ });
510
+ if (!answer.ok) return found.unknown(answer.why);
511
+ if (aim !== undefined) run.append('play', 'constraint', { armature: answer.armature, bone, constraint, target: aim?.name ?? null });
512
+ return true;
513
+ },
437
514
  actions(object) {
438
515
  const target = typeof object === 'string' ? root.getObjectByName(object) ?? null : object;
439
516
  return target && options_.animation ? options_.animation.clips(target) : [];