@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.
- package/dist/behavior.d.ts +5 -1
- package/dist/behavior.js +67 -23
- package/dist/types.d.ts +6 -0
- package/dist/types.js +36 -3
- package/package.json +2 -2
package/dist/behavior.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
+
targetBodyYaw = Math.PI + Math.atan2(this.worldMoveVelocity.x, this.worldMoveVelocity.z);
|
|
129
139
|
}
|
|
130
140
|
else if (this.worldAimDirection != null && this.isAiming) {
|
|
131
|
-
|
|
141
|
+
targetBodyYaw = Math.PI + Math.atan2(this.worldAimDirection.x, this.worldAimDirection.z);
|
|
132
142
|
}
|
|
133
143
|
else if (this.worldHeadDirection != null) {
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
173
|
+
// Compute target rotation that aligns (0,0,1) with the local target direction
|
|
158
174
|
quaternionHelper.setFromUnitVectors(ForwardVector, directionHelper);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
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.
|
|
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.
|
|
18
|
+
"@pmndrs/viverse": "^0.2.16"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"three": "*"
|