@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.
- package/dist/behavior.d.ts +6 -1
- package/dist/behavior.js +78 -25
- 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,11 @@ 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
|
+
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
|
-
|
|
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
|
-
|
|
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,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
|
|
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
|
-
this.applyMove?.(this.moveStatus === 'none'
|
|
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.
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
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.
|
|
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.
|
|
18
|
+
"@pmndrs/viverse": "^0.2.16"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"three": "*"
|