@volter/editor-model-play 0.5.196 → 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 +87 -16
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.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.196"
31
+ "@volter/editor-sdk": "0.5.197"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^19.0.0",
@@ -168,14 +168,46 @@ export interface ModelPlayContext {
168
168
  * `null` fades it out. Actions run on the game's clock. Each change is an `action` entry in the
169
169
  * play log; a name the armature lacks is `action-unknown` once 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.
175
+ *
171
176
  * play.setAction('Hero', moving ? 'Run' : 'Idle');
177
+ * play.setAction('Hero', 'Wave', { layer: 'upper', from: 'spine_01' });
172
178
  */
173
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;
174
202
  /** The actions an object's armature can play, by name (empty without an armature). */
175
203
  actions(object: THREE.Object3D | string): readonly string[];
176
204
  }
177
205
 
178
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;
179
211
  readonly loop?: boolean;
180
212
  readonly fade?: number;
181
213
  readonly speed?: number;
@@ -183,6 +215,17 @@ export interface ModelPlayActionOptions {
183
215
  readonly restart?: boolean;
184
216
  }
185
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]));
227
+ }
228
+
186
229
  /** What the bot is handed before each `update` it drives. */
187
230
  export interface ModelPlayAutoplayInput {
188
231
  /** The behaviour driving (`play autoplay on <behaviour>`), for a controller shared by several. */
@@ -387,6 +430,21 @@ export function runPlayScript(options: {
387
430
  options.container.style.opacity = '0';
388
431
  /** One script's context: its log, tint, opacity and bot do nothing once that script is gone
389
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
+ };
390
448
  const contextFor = (alive: Script): ModelPlayContext => ({
391
449
  root,
392
450
  find(name) {
@@ -413,27 +471,40 @@ export function runPlayScript(options: {
413
471
  if (alive.value) alive.bot = behaviors;
414
472
  },
415
473
  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);
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);
427
479
  if (action === null) {
428
- options_.animation.stop(target, options?.fade);
429
- if (before !== null) run.append('play', 'action', { object: target.name, action: null, from: before });
480
+ animation.stop(target, options?.fade, layer);
481
+ if (before !== null) run.append('play', 'action', { object: target.name, layer, action: null, from: before });
430
482
  return true;
431
483
  }
432
- const answer = options_.animation.play(target, action, options);
433
- if (!answer.ok) return unknown(answer.why);
434
- if (before !== action || options?.restart) run.append('play', 'action', { armature: answer.armature, action, from: before });
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) });
435
500
  return true;
436
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
+ },
437
508
  actions(object) {
438
509
  const target = typeof object === 'string' ? root.getObjectByName(object) ?? null : object;
439
510
  return target && options_.animation ? options_.animation.clips(target) : [];