@volter/editor-model-play 0.5.197 → 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 +64 -58
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/editor-model-play",
3
- "version": "0.5.197",
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.197"
31
+ "@volter/editor-sdk": "0.5.198"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^19.0.0",
@@ -160,70 +160,64 @@ 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
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.
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).
175
173
  *
176
174
  * play.setAction('Hero', moving ? 'Run' : 'Idle');
177
- * play.setAction('Hero', 'Wave', { layer: 'upper', from: 'spine_01' });
178
175
  */
179
176
  setAction(object: THREE.Object3D | string, action: string | null, options?: ModelPlayActionOptions): boolean;
180
177
  /**
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.
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).
185
184
  *
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' });
185
+ * play.setTrack('Hero', 'Aim', { action: 'Aim_Upper' });
186
+ * play.setTrack('Hero', 'AimUp', { action: 'Aim_Up_Upper', influence: Math.max(0, pitch) });
188
187
  */
189
- blend(object: THREE.Object3D | string, weights: Readonly<Record<string, number>>, options?: ModelPlayActionOptions): boolean;
188
+ setTrack(object: THREE.Object3D | string, track: string, options: ModelPlayTrackOptions | null): boolean;
190
189
  /**
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).
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.
198
194
  *
199
- * play.lookAt('Hero', 'head', player);
195
+ * play.setConstraint('Hero', 'Head', 'Look', { influence: alert ? 1 : 0 });
200
196
  */
201
- lookAt(object: THREE.Object3D | string, bone: string, target: THREE.Object3D | THREE.Vector3 | null, options?: ModelPlayLookAtOptions): boolean;
197
+ setConstraint(object: THREE.Object3D | string, bone: string, constraint: string, options: ModelPlayConstraintOptions): boolean;
202
198
  /** The actions an object's armature can play, by name (empty without an armature). */
203
199
  actions(object: THREE.Object3D | string): readonly string[];
204
200
  }
205
201
 
206
202
  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
203
  readonly loop?: boolean;
212
204
  readonly fade?: number;
213
205
  readonly speed?: number;
214
- /** 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. */
215
207
  readonly restart?: boolean;
216
208
  }
217
209
 
218
- export interface ModelPlayLookAtOptions {
219
- readonly weight?: number;
220
- readonly axis?: 'forward' | 'x' | 'y' | 'z' | '-x' | '-y' | '-z';
221
- readonly limit?: number;
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;
222
215
  }
223
216
 
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]));
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;
227
221
  }
228
222
 
229
223
  /** What the bot is handed before each `update` it drives. */
@@ -445,6 +439,8 @@ export function runPlayScript(options: {
445
439
  if (!options_.animation) return { ok: unknown('this document lends no animation') };
446
440
  return { ok: true as const, target, animation: options_.animation, unknown };
447
441
  };
442
+ /** What each NLA track was last set to, so the log says a change once. */
443
+ const tracks = new Map<string, string>();
448
444
  const contextFor = (alive: Script): ModelPlayContext => ({
449
445
  root,
450
446
  find(name) {
@@ -474,36 +470,46 @@ export function runPlayScript(options: {
474
470
  const found = animated(alive, object, action, 'setAction');
475
471
  if (!found.ok) return false;
476
472
  const { target, animation } = found;
477
- const layer = options?.layer ?? 'base';
478
- const before = animation.playing(target, layer);
473
+ const before = animation.playing(target);
479
474
  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 });
475
+ animation.stop(target, options?.fade);
476
+ if (before !== null) run.append('play', 'action', { object: target.name, action: null, from: before });
482
477
  return true;
483
478
  }
484
479
  const answer = animation.play(target, action, options);
485
480
  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 });
481
+ if (before !== action || options?.restart) run.append('play', 'action', { armature: answer.armature, action, from: before });
487
482
  return true;
488
483
  },
489
- blend(object, weights, options) {
490
- const found = animated(alive, object, Object.keys(weights).join('+'), 'blend');
484
+ setTrack(object, track, options) {
485
+ const found = animated(alive, object, track, 'setTrack');
491
486
  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);
487
+ const answer = found.animation.track(found.target, track, options);
496
488
  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) });
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
+ }
500
496
  return true;
501
497
  },
502
- lookAt(object, bone, at, options) {
503
- const found = animated(alive, object, bone, 'lookAt');
498
+ setConstraint(object, bone, constraint, options) {
499
+ const found = animated(alive, object, `${bone}/${constraint}`, 'setConstraint');
504
500
  if (!found.ok) return false;
505
- const answer = found.animation.lookAt(found.target, bone, at, options);
506
- return answer.ok || found.unknown(answer.why);
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;
507
513
  },
508
514
  actions(object) {
509
515
  const target = typeof object === 'string' ? root.getObjectByName(object) ?? null : object;