@drawcall/acta 0.1.7 → 0.1.8

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,10 @@ 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
+ 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
56
  private init;
53
57
  private loadMoveAnimations;
54
58
  private loadBaseAimAnimation;
package/dist/behavior.js CHANGED
@@ -9,6 +9,10 @@ const ZeroVector = new Vector3();
9
9
  const ForwardVector = new Vector3(0, 0, 1);
10
10
  const UpVector = new Vector3(0, 1, 0);
11
11
  const SpineAimYawOffset = -0.5;
12
+ // Lerp speed constants for smooth rotation interpolation
13
+ const BodyRotationSpeed = 10; // radians/sec
14
+ const SpineRotationSpeed = 8; // factor for slerp
15
+ const NeckRotationSpeed = 8; // factor for slerp
12
16
  const quaternionHelper = new Quaternion();
13
17
  const parentQuaternionInvHelper = new Quaternion();
14
18
  const localQuaternionHelper = new Quaternion();
@@ -16,11 +20,10 @@ const eulerHelper = new Euler();
16
20
  const directionHelper = new Vector3();
17
21
  const vectorHelper = new Vector3();
18
22
  //TODO: support direction AND position targets for head and aim
19
- //TODO: lerp everything (spine rotation etc)
20
- //TODO: jumping support
21
23
  export class CharacterBehavior extends EventDispatcher {
22
24
  model;
23
25
  applyMove;
26
+ applyJump;
24
27
  properties = {};
25
28
  computedProperties = {
26
29
  isMoving: () => this.worldMoveVelocity.length() > 0,
@@ -49,10 +52,15 @@ export class CharacterBehavior extends EventDispatcher {
49
52
  gltfLoader;
50
53
  moveStatus = 'none';
51
54
  isAiming = false;
52
- constructor(behavior, animationUrls, audioUrls, model, applyMove, loadingManager) {
55
+ // Current rotation state for lerping
56
+ currentBodyYaw = 0;
57
+ currentSpineQuat = new Quaternion();
58
+ currentNeckEuler = new Euler(0, 0, 0, 'YXZ');
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,12 +170,18 @@ 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
186
  this.applyMove?.(this.moveStatus === 'none' ? ZeroVector : this.worldMoveVelocity, delta);
165
187
  }
@@ -324,17 +346,33 @@ export class CharacterBehavior extends EventDispatcher {
324
346
  const layer = computeAnimationLayers(mask);
325
347
  const self = this;
326
348
  return async function* () {
327
- if (name)
328
- self.dispatchEvent({ type: 'animationStarted', name });
349
+ if ((name === 'Jump_Loop' || name === 'Jump_Start' || name === 'Jump_Land') && mask != 'upperbody') {
350
+ self.moveStatus = mask ?? 'fullbody';
351
+ }
352
+ self.dispatchEvent({ type: 'animationStarted', name });
329
353
  try {
330
354
  yield* action({
331
- init: () => startAnimation(animationAction, self.model.currentAnimations, { layer }),
332
- until: animationFinished(animationAction),
355
+ init: () => startAnimation(animationAction, self.model.currentAnimations, { layer, paused: name === 'Jump_Start' }),
356
+ until: name === 'Jump_Land' ? timePassed(150, 'milliseconds') : animationFinished(animationAction),
357
+ update: name === 'Jump_Start'
358
+ ? (_, _clock, actionTime) => {
359
+ if (actionTime > 0.2 && animationAction.paused) {
360
+ animationAction.paused = false;
361
+ self.applyJump?.();
362
+ }
363
+ if (actionTime > 0.5 && self.properties['isGrounded'] === true) {
364
+ //canceling
365
+ return false;
366
+ }
367
+ }
368
+ : undefined,
333
369
  });
334
370
  }
335
371
  finally {
336
- if (name)
337
- self.dispatchEvent({ type: 'animationFinished', name });
372
+ if ((name === 'Jump_Loop' || name === 'Jump_Start' || name === 'Jump_Land') && mask != 'upperbody') {
373
+ self.moveStatus = 'none';
374
+ }
375
+ self.dispatchEvent({ type: 'animationFinished', name });
338
376
  }
339
377
  };
340
378
  }
@@ -363,8 +401,7 @@ export class CharacterBehavior extends EventDispatcher {
363
401
  const layer = computeAnimationLayers(mask, false);
364
402
  const self = this;
365
403
  return async function* () {
366
- if (name)
367
- self.dispatchEvent({ type: 'animationStarted', name });
404
+ self.dispatchEvent({ type: 'animationStarted', name });
368
405
  self.isAiming = true;
369
406
  try {
370
407
  yield* action({
@@ -394,8 +431,7 @@ export class CharacterBehavior extends EventDispatcher {
394
431
  }
395
432
  finally {
396
433
  self.isAiming = false;
397
- if (name)
398
- self.dispatchEvent({ type: 'animationFinished', name });
434
+ self.dispatchEvent({ type: 'animationFinished', name });
399
435
  }
400
436
  };
401
437
  }
@@ -444,6 +480,14 @@ function computeAnimationLayers(mask, includeAim = true) {
444
480
  result.push('lowerbody');
445
481
  return result;
446
482
  }
483
+ function lerpAngle(current, target, t) {
484
+ let diff = target - current;
485
+ while (diff > Math.PI)
486
+ diff -= 2 * Math.PI;
487
+ while (diff < -Math.PI)
488
+ diff += 2 * Math.PI;
489
+ return current + diff * Math.min(1, t);
490
+ }
447
491
  function evaluateCondition(condition, properties, computedProperties) {
448
492
  switch (condition.type) {
449
493
  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' && condition.property != 'isGrounded') {
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.8",
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": "*"