@drawcall/acta 0.1.7 → 0.1.9

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.
@@ -23,6 +23,7 @@ export type CharacterBehaviorEvents = {
23
23
  export declare class CharacterBehavior extends EventDispatcher<CharacterBehaviorEvents> {
24
24
  private readonly model;
25
25
  applyMove?: ((worldMoveVelocity: Vector3, delta: number) => void) | undefined;
26
+ applyJump?: (() => void) | undefined;
26
27
  properties: Partial<Record<string, boolean>>;
27
28
  private readonly computedProperties;
28
29
  worldHeadDirection: Vector3 | undefined;
@@ -48,7 +49,11 @@ export declare class CharacterBehavior extends EventDispatcher<CharacterBehavior
48
49
  private readonly gltfLoader;
49
50
  private moveStatus;
50
51
  private isAiming;
51
- constructor(behavior: Behavior, animationUrls: Record<string, string>, audioUrls: Record<string, string>, model: CharacterModel, applyMove?: ((worldMoveVelocity: Vector3, delta: number) => void) | undefined, loadingManager?: LoadingManager);
52
+ private currentBodyYaw;
53
+ private readonly currentSpineQuat;
54
+ private readonly currentNeckEuler;
55
+ private speedMultiplier;
56
+ constructor(behavior: Behavior, animationUrls: Record<string, string>, audioUrls: Record<string, string>, model: CharacterModel, applyMove?: ((worldMoveVelocity: Vector3, delta: number) => void) | undefined, applyJump?: (() => void) | undefined, loadingManager?: LoadingManager);
52
57
  private init;
53
58
  private loadMoveAnimations;
54
59
  private loadBaseAimAnimation;
package/dist/behavior.js CHANGED
@@ -5,10 +5,13 @@ import { makeClipAdditive } from 'three/src/animation/AnimationUtils.js';
5
5
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
6
6
  import { VRM } from '@pixiv/three-vrm';
7
7
  import { clamp } from 'three/src/math/MathUtils.js';
8
- const ZeroVector = new Vector3();
9
8
  const ForwardVector = new Vector3(0, 0, 1);
10
9
  const UpVector = new Vector3(0, 1, 0);
11
10
  const SpineAimYawOffset = -0.5;
11
+ // Lerp speed constants for smooth rotation interpolation
12
+ const BodyRotationSpeed = 10; // radians/sec
13
+ const SpineRotationSpeed = 8; // factor for slerp
14
+ const NeckRotationSpeed = 8; // factor for slerp
12
15
  const quaternionHelper = new Quaternion();
13
16
  const parentQuaternionInvHelper = new Quaternion();
14
17
  const localQuaternionHelper = new Quaternion();
@@ -16,11 +19,10 @@ const eulerHelper = new Euler();
16
19
  const directionHelper = new Vector3();
17
20
  const vectorHelper = new Vector3();
18
21
  //TODO: support direction AND position targets for head and aim
19
- //TODO: lerp everything (spine rotation etc)
20
- //TODO: jumping support
21
22
  export class CharacterBehavior extends EventDispatcher {
22
23
  model;
23
24
  applyMove;
25
+ applyJump;
24
26
  properties = {};
25
27
  computedProperties = {
26
28
  isMoving: () => this.worldMoveVelocity.length() > 0,
@@ -49,10 +51,16 @@ export class CharacterBehavior extends EventDispatcher {
49
51
  gltfLoader;
50
52
  moveStatus = 'none';
51
53
  isAiming = false;
52
- constructor(behavior, animationUrls, audioUrls, model, applyMove, loadingManager) {
54
+ // Current rotation state for lerping
55
+ currentBodyYaw = 0;
56
+ currentSpineQuat = new Quaternion();
57
+ currentNeckEuler = new Euler(0, 0, 0, 'YXZ');
58
+ speedMultiplier = 1;
59
+ constructor(behavior, animationUrls, audioUrls, model, applyMove, applyJump, loadingManager) {
53
60
  super();
54
61
  this.model = model;
55
62
  this.applyMove = applyMove;
63
+ this.applyJump = applyJump;
56
64
  if (model instanceof VRM) {
57
65
  throw new Error(`not supported yet`);
58
66
  }
@@ -123,16 +131,21 @@ export class CharacterBehavior extends EventDispatcher {
123
131
  }
124
132
  async loadAudio(name, url) { }
125
133
  update(delta) {
134
+ // Compute target body yaw based on movement/aim/head direction
135
+ let targetBodyYaw = this.currentBodyYaw;
126
136
  if (this.moveStatus === 'fullbody' && this.worldMoveVelocity.lengthSq() > 0) {
127
137
  // Signed angle from forward (0,0,1) to move direction on XZ plane
128
- this.model.scene.rotation.y = Math.PI + Math.atan2(this.worldMoveVelocity.x, this.worldMoveVelocity.z);
138
+ targetBodyYaw = Math.PI + Math.atan2(this.worldMoveVelocity.x, this.worldMoveVelocity.z);
129
139
  }
130
140
  else if (this.worldAimDirection != null && this.isAiming) {
131
- this.model.scene.rotation.y = Math.PI + Math.atan2(this.worldAimDirection.x, this.worldAimDirection.z);
141
+ targetBodyYaw = Math.PI + Math.atan2(this.worldAimDirection.x, this.worldAimDirection.z);
132
142
  }
133
143
  else if (this.worldHeadDirection != null) {
134
- this.model.scene.rotation.y = Math.PI + Math.atan2(this.worldHeadDirection.x, this.worldHeadDirection.z);
144
+ targetBodyYaw = Math.PI + Math.atan2(this.worldHeadDirection.x, this.worldHeadDirection.z);
135
145
  }
146
+ // Lerp body yaw with angle wrapping
147
+ this.currentBodyYaw = lerpAngle(this.currentBodyYaw, targetBodyYaw, BodyRotationSpeed * delta);
148
+ this.model.scene.rotation.y = this.currentBodyYaw;
136
149
  this.updateTimeline?.(undefined, delta);
137
150
  this.model.mixer.update(delta);
138
151
  // Stabilize spine during aiming to face the aim direction
@@ -140,13 +153,16 @@ export class CharacterBehavior extends EventDispatcher {
140
153
  const spineBone = this.model.scene.getObjectByName('spine');
141
154
  // Extract yaw from aim direction and add PI offset for model facing
142
155
  const aimYaw = this.model.scene.rotation.y + Math.PI + SpineAimYawOffset;
143
- // Create quaternion with just the yaw (no pitch/roll)
156
+ // Create target quaternion with just the yaw (no pitch/roll)
144
157
  eulerHelper.set(0, aimYaw, 0, 'YXZ');
145
158
  quaternionHelper.setFromEuler(eulerHelper);
146
159
  // Transform to spine's local space
147
160
  spineBone.parent.getWorldQuaternion(parentQuaternionInvHelper).invert();
148
161
  localQuaternionHelper.copy(parentQuaternionInvHelper).multiply(quaternionHelper);
149
- spineBone.quaternion.copy(localQuaternionHelper);
162
+ // Slerp toward target (frame-rate independent)
163
+ const slerpFactor = 1 - Math.exp(-SpineRotationSpeed * delta);
164
+ this.currentSpineQuat.slerp(localQuaternionHelper, slerpFactor);
165
+ spineBone.quaternion.copy(this.currentSpineQuat);
150
166
  spineBone.updateMatrixWorld();
151
167
  }
152
168
  if (this.worldHeadDirection != null) {
@@ -154,14 +170,22 @@ export class CharacterBehavior extends EventDispatcher {
154
170
  // Transform worldHeadDirection to neck's parent local space
155
171
  neckBone.parent.getWorldQuaternion(quaternionHelper).invert();
156
172
  directionHelper.copy(this.worldHeadDirection).applyQuaternion(quaternionHelper);
157
- // Compute the local rotation that aligns (0,0,1) with the local target direction
173
+ // Compute target rotation that aligns (0,0,1) with the local target direction
158
174
  quaternionHelper.setFromUnitVectors(ForwardVector, directionHelper);
159
- neckBone.rotation.setFromQuaternion(quaternionHelper, 'YXZ');
160
- neckBone.rotation.z = 0;
161
- neckBone.rotation.y = clamp(neckBone.rotation.y, -Math.PI / 2, Math.PI / 2);
162
- neckBone.rotation.x = clamp(neckBone.rotation.x, -Math.PI / 2, Math.PI / 2);
175
+ eulerHelper.setFromQuaternion(quaternionHelper, 'YXZ');
176
+ // Compute target neck euler with clamping
177
+ const targetNeckY = clamp(eulerHelper.y, -Math.PI / 2, Math.PI / 2);
178
+ const targetNeckX = clamp(eulerHelper.x, -Math.PI / 2, Math.PI / 2);
179
+ // Lerp each axis independently (frame-rate independent)
180
+ const lerpFactor = 1 - Math.exp(-NeckRotationSpeed * delta);
181
+ this.currentNeckEuler.x += (targetNeckX - this.currentNeckEuler.x) * lerpFactor;
182
+ this.currentNeckEuler.y += (targetNeckY - this.currentNeckEuler.y) * lerpFactor;
183
+ this.currentNeckEuler.z = 0;
184
+ neckBone.rotation.copy(this.currentNeckEuler);
163
185
  }
164
- this.applyMove?.(this.moveStatus === 'none' ? ZeroVector : this.worldMoveVelocity, delta);
186
+ this.applyMove?.(this.moveStatus === 'none'
187
+ ? vectorHelper.set(0, 0, 0)
188
+ : vectorHelper.copy(this.worldMoveVelocity).multiplyScalar(this.speedMultiplier), delta);
165
189
  }
166
190
  destroy() {
167
191
  this.abortController.abort();
@@ -324,17 +348,40 @@ export class CharacterBehavior extends EventDispatcher {
324
348
  const layer = computeAnimationLayers(mask);
325
349
  const self = this;
326
350
  return async function* () {
327
- if (name)
328
- self.dispatchEvent({ type: 'animationStarted', name });
351
+ if ((name === 'Jump_Loop' || name === 'Jump_Start' || name === 'Jump_Land') && mask != 'upperbody') {
352
+ self.moveStatus = mask ?? 'fullbody';
353
+ }
354
+ self.dispatchEvent({ type: 'animationStarted', name });
329
355
  try {
330
356
  yield* action({
331
- init: () => startAnimation(animationAction, self.model.currentAnimations, { layer }),
332
- until: animationFinished(animationAction),
357
+ init: () => startAnimation(animationAction, self.model.currentAnimations, { layer, paused: name === 'Jump_Start' }),
358
+ until: name === 'Jump_Land' ? timePassed(150, 'milliseconds') : animationFinished(animationAction),
359
+ update: name === 'Jump_Start'
360
+ ? (_, _clock, actionTime) => {
361
+ if (actionTime <= 0.2) {
362
+ self.speedMultiplier = 0.2;
363
+ }
364
+ else {
365
+ self.speedMultiplier = 1;
366
+ }
367
+ if (actionTime > 0.2 && animationAction.paused) {
368
+ animationAction.paused = false;
369
+ self.applyJump?.();
370
+ }
371
+ if (actionTime > 0.5 && self.properties['isGrounded'] === true) {
372
+ //canceling
373
+ return false;
374
+ }
375
+ }
376
+ : undefined,
333
377
  });
334
378
  }
335
379
  finally {
336
- if (name)
337
- self.dispatchEvent({ type: 'animationFinished', name });
380
+ self.speedMultiplier = 1;
381
+ if ((name === 'Jump_Loop' || name === 'Jump_Start' || name === 'Jump_Land') && mask != 'upperbody') {
382
+ self.moveStatus = 'none';
383
+ }
384
+ self.dispatchEvent({ type: 'animationFinished', name });
338
385
  }
339
386
  };
340
387
  }
@@ -363,8 +410,7 @@ export class CharacterBehavior extends EventDispatcher {
363
410
  const layer = computeAnimationLayers(mask, false);
364
411
  const self = this;
365
412
  return async function* () {
366
- if (name)
367
- self.dispatchEvent({ type: 'animationStarted', name });
413
+ self.dispatchEvent({ type: 'animationStarted', name });
368
414
  self.isAiming = true;
369
415
  try {
370
416
  yield* action({
@@ -394,8 +440,7 @@ export class CharacterBehavior extends EventDispatcher {
394
440
  }
395
441
  finally {
396
442
  self.isAiming = false;
397
- if (name)
398
- self.dispatchEvent({ type: 'animationFinished', name });
443
+ self.dispatchEvent({ type: 'animationFinished', name });
399
444
  }
400
445
  };
401
446
  }
@@ -444,6 +489,14 @@ function computeAnimationLayers(mask, includeAim = true) {
444
489
  result.push('lowerbody');
445
490
  return result;
446
491
  }
492
+ function lerpAngle(current, target, t) {
493
+ let diff = target - current;
494
+ while (diff > Math.PI)
495
+ diff -= 2 * Math.PI;
496
+ while (diff < -Math.PI)
497
+ diff += 2 * Math.PI;
498
+ return current + diff * Math.min(1, t);
499
+ }
447
500
  function evaluateCondition(condition, properties, computedProperties) {
448
501
  switch (condition.type) {
449
502
  case 'isTrue':
package/dist/types.d.ts CHANGED
@@ -17,6 +17,12 @@ export type Condition = {
17
17
  * @returns Array of unique property names
18
18
  */
19
19
  export declare function getProperties(behavior: Behavior): string[];
20
+ /**
21
+ * Extracts all animation names used in a behavior tree.
22
+ * @param behavior - The behavior to extract animations from
23
+ * @returns Set of animation names (excluding 'Move' which is handled separately)
24
+ */
25
+ export declare function extractUsedAnimations(behavior: Behavior): Set<string>;
20
26
  export type Behavior = {
21
27
  type: 'animation';
22
28
  name: string;
package/dist/types.js CHANGED
@@ -8,10 +8,9 @@ export function getProperties(behavior) {
8
8
  function collectFromCondition(condition) {
9
9
  switch (condition.type) {
10
10
  case 'isTrue':
11
- if (condition.property === 'isMoving') {
12
- break;
11
+ if (condition.property != 'isMoving') {
12
+ properties.add(condition.property);
13
13
  }
14
- properties.add(condition.property);
15
14
  break;
16
15
  case 'not':
17
16
  collectFromCondition(condition.condition);
@@ -48,3 +47,37 @@ export function getProperties(behavior) {
48
47
  collectFromBehavior(behavior);
49
48
  return Array.from(properties);
50
49
  }
50
+ /**
51
+ * Extracts all animation names used in a behavior tree.
52
+ * @param behavior - The behavior to extract animations from
53
+ * @returns Set of animation names (excluding 'Move' which is handled separately)
54
+ */
55
+ export function extractUsedAnimations(behavior) {
56
+ const used = new Set();
57
+ function traverse(b) {
58
+ switch (b.type) {
59
+ case 'animation':
60
+ if (b.name === 'Move') {
61
+ break;
62
+ }
63
+ used.add(b.name);
64
+ break;
65
+ case 'loop':
66
+ traverse(b.behavior);
67
+ break;
68
+ case 'split':
69
+ traverse(b.upperBodyBehavior);
70
+ traverse(b.lowerBodyBehavior);
71
+ break;
72
+ case 'sequential':
73
+ case 'parallel':
74
+ b.behaviors.forEach(traverse);
75
+ break;
76
+ case 'graph':
77
+ Object.values(b.states).forEach((state) => traverse(state.behavior));
78
+ break;
79
+ }
80
+ }
81
+ traverse(behavior);
82
+ return used;
83
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@drawcall/acta",
3
3
  "type": "module",
4
4
  "main": "dist/index.js",
5
- "version": "0.1.7",
5
+ "version": "0.1.9",
6
6
  "author": "Bela Bohlender",
7
7
  "license": "SEE LICENSE IN LICENSE",
8
8
  "homepage": "https://drawcall.ai",
@@ -15,7 +15,7 @@
15
15
  "dependencies": {
16
16
  "@pixiv/three-vrm": "3.4.4",
17
17
  "@pmndrs/timeline": "^0.3.7",
18
- "@pmndrs/viverse": "^0.2.12"
18
+ "@pmndrs/viverse": "^0.2.16"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "three": "*"