@umicat/three-sdk 0.2.0 → 0.3.0
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/README.md +21 -0
- package/dist/CharacterAnimator.d.ts +58 -0
- package/dist/CharacterAnimator.js +103 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,6 +96,27 @@ One real bug was fixed while chasing it and is unrelated but worth having: a
|
|
|
96
96
|
bones, so three.js culls a character against a stale volume once it moves.
|
|
97
97
|
`loadScene3D` sets `frustumCulled = false` on skinned meshes.
|
|
98
98
|
|
|
99
|
+
## 0.3.0 — actions, not just locomotion
|
|
100
|
+
|
|
101
|
+
`CharacterAnimator` owns the two things every game was writing by hand, and
|
|
102
|
+
both are character behaviour rather than game logic (ADR-034).
|
|
103
|
+
|
|
104
|
+
**Locomotion follows `character.state`**, not the keys held — a clip chosen
|
|
105
|
+
from input leaves a character walking in mid-air.
|
|
106
|
+
|
|
107
|
+
**An action is a one-shot that interrupts and returns.** Attacking is not a
|
|
108
|
+
state you enter, it is a thing that happens: `play('attack')` runs the clip
|
|
109
|
+
once, holds its last frame rather than snapping to the bind pose in the gap,
|
|
110
|
+
and hands control back. `busy` is there so a game can refuse a second swing
|
|
111
|
+
from one press — firing twice is the usual reason an attack feels broken.
|
|
112
|
+
|
|
113
|
+
Changing locomotion mid-action does **not** blend a walk cycle into the middle
|
|
114
|
+
of a sword swing: the new locomotion clip is swapped in underneath at zero
|
|
115
|
+
weight and faded up only when the action releases.
|
|
116
|
+
|
|
117
|
+
Names resolve through the manifest's map first (`attack` -> `attack-melee-right`)
|
|
118
|
+
and then by raw clip name, so a game can reach a clip nobody mapped.
|
|
119
|
+
|
|
99
120
|
## 0.2.0 — the character's jump and controls belong to the platform
|
|
100
121
|
|
|
101
122
|
ADR-034 makes the platform the owner of the character, so two things that used
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type { CharacterState } from './CharacterController3D.js';
|
|
3
|
+
/** Semantic name -> the clip actually inside the model, as authored in the
|
|
4
|
+
* manifest. Never guess a clip name: this character calls its run `sprint`. */
|
|
5
|
+
export type ClipMap = Record<string, string>;
|
|
6
|
+
export interface CharacterAnimatorOptions {
|
|
7
|
+
/** Cross-fade between locomotion clips, seconds. */
|
|
8
|
+
blend?: number;
|
|
9
|
+
/** Cross-fade in and out of a one-shot action, seconds. */
|
|
10
|
+
actionBlend?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Locomotion plus one-shot actions, for a character whose clips all sit on one
|
|
14
|
+
* rig.
|
|
15
|
+
*
|
|
16
|
+
* Two things were being written by hand in every game, and both are character
|
|
17
|
+
* behaviour rather than game logic (ADR-034), so they live here.
|
|
18
|
+
*
|
|
19
|
+
* **Locomotion follows the CONTROLLER's state, not the keys held.** A clip
|
|
20
|
+
* chosen from input leaves a character walking in mid-air.
|
|
21
|
+
*
|
|
22
|
+
* **An action is a one-shot that interrupts and returns.** Attacking is not a
|
|
23
|
+
* state you enter, it is a thing that happens: it plays once, holds its last
|
|
24
|
+
* frame rather than snapping, and hands control back. Without that, a game
|
|
25
|
+
* either cannot attack at all or leaves the character stuck mid-swing.
|
|
26
|
+
*/
|
|
27
|
+
export declare class CharacterAnimator {
|
|
28
|
+
private readonly mixer;
|
|
29
|
+
private readonly map;
|
|
30
|
+
private readonly actions;
|
|
31
|
+
private locomotion;
|
|
32
|
+
private locomotionName;
|
|
33
|
+
private oneShot;
|
|
34
|
+
private oneShotName;
|
|
35
|
+
private readonly blend;
|
|
36
|
+
private readonly actionBlend;
|
|
37
|
+
private readonly onFinished;
|
|
38
|
+
constructor(mixer: THREE.AnimationMixer, clips: THREE.AnimationClip[], map?: ClipMap, options?: CharacterAnimatorOptions);
|
|
39
|
+
/** Is a one-shot still playing? Games gate input on this — swinging twice
|
|
40
|
+
* from one press is the usual reason an attack feels broken. */
|
|
41
|
+
get busy(): boolean;
|
|
42
|
+
/** Which one-shot, if any. */
|
|
43
|
+
get action(): string;
|
|
44
|
+
/** Look a semantic name up through the manifest's map, then by raw clip name
|
|
45
|
+
* so a game can reach a clip the manifest never mapped. */
|
|
46
|
+
private find;
|
|
47
|
+
has(name: string): boolean;
|
|
48
|
+
/** Drive locomotion. Pass `character.state` every frame. */
|
|
49
|
+
update(state: CharacterState | string): void;
|
|
50
|
+
/**
|
|
51
|
+
* Play a one-shot. Returns false if there is no such clip, or if one is
|
|
52
|
+
* already running and `interrupt` was not asked for.
|
|
53
|
+
*/
|
|
54
|
+
play(name: string, opts?: {
|
|
55
|
+
interrupt?: boolean;
|
|
56
|
+
}): boolean;
|
|
57
|
+
dispose(): void;
|
|
58
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
/**
|
|
3
|
+
* Locomotion plus one-shot actions, for a character whose clips all sit on one
|
|
4
|
+
* rig.
|
|
5
|
+
*
|
|
6
|
+
* Two things were being written by hand in every game, and both are character
|
|
7
|
+
* behaviour rather than game logic (ADR-034), so they live here.
|
|
8
|
+
*
|
|
9
|
+
* **Locomotion follows the CONTROLLER's state, not the keys held.** A clip
|
|
10
|
+
* chosen from input leaves a character walking in mid-air.
|
|
11
|
+
*
|
|
12
|
+
* **An action is a one-shot that interrupts and returns.** Attacking is not a
|
|
13
|
+
* state you enter, it is a thing that happens: it plays once, holds its last
|
|
14
|
+
* frame rather than snapping, and hands control back. Without that, a game
|
|
15
|
+
* either cannot attack at all or leaves the character stuck mid-swing.
|
|
16
|
+
*/
|
|
17
|
+
export class CharacterAnimator {
|
|
18
|
+
constructor(mixer, clips, map = {}, options = {}) {
|
|
19
|
+
this.mixer = mixer;
|
|
20
|
+
this.map = map;
|
|
21
|
+
this.actions = new Map();
|
|
22
|
+
this.locomotion = null;
|
|
23
|
+
this.locomotionName = '';
|
|
24
|
+
this.oneShot = null;
|
|
25
|
+
this.oneShotName = '';
|
|
26
|
+
this.blend = options.blend ?? 0.15;
|
|
27
|
+
this.actionBlend = options.actionBlend ?? 0.08;
|
|
28
|
+
for (const clip of clips)
|
|
29
|
+
this.actions.set(clip.name, mixer.clipAction(clip));
|
|
30
|
+
this.onFinished = (e) => {
|
|
31
|
+
if (this.oneShot && e.action === this.oneShot) {
|
|
32
|
+
this.oneShot.fadeOut(this.actionBlend);
|
|
33
|
+
this.oneShot = null;
|
|
34
|
+
this.oneShotName = '';
|
|
35
|
+
// Bring locomotion back explicitly — it was faded down, not stopped,
|
|
36
|
+
// so it is still running underneath at weight ~0.
|
|
37
|
+
this.locomotion?.reset().fadeIn(this.actionBlend).play();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
mixer.addEventListener('finished', this.onFinished);
|
|
41
|
+
}
|
|
42
|
+
/** Is a one-shot still playing? Games gate input on this — swinging twice
|
|
43
|
+
* from one press is the usual reason an attack feels broken. */
|
|
44
|
+
get busy() { return this.oneShot !== null; }
|
|
45
|
+
/** Which one-shot, if any. */
|
|
46
|
+
get action() { return this.oneShotName; }
|
|
47
|
+
/** Look a semantic name up through the manifest's map, then by raw clip name
|
|
48
|
+
* so a game can reach a clip the manifest never mapped. */
|
|
49
|
+
find(name) {
|
|
50
|
+
return this.actions.get(this.map[name] ?? name) ?? this.actions.get(name) ?? null;
|
|
51
|
+
}
|
|
52
|
+
has(name) { return this.find(name) !== null; }
|
|
53
|
+
/** Drive locomotion. Pass `character.state` every frame. */
|
|
54
|
+
update(state) {
|
|
55
|
+
if (state === this.locomotionName)
|
|
56
|
+
return;
|
|
57
|
+
const next = this.find(state) ?? this.find('idle');
|
|
58
|
+
if (!next)
|
|
59
|
+
return;
|
|
60
|
+
this.locomotionName = state;
|
|
61
|
+
if (next === this.locomotion)
|
|
62
|
+
return;
|
|
63
|
+
next.reset().setLoop(THREE.LoopRepeat, Infinity).setEffectiveWeight(1);
|
|
64
|
+
// Under a one-shot, swap the underlying clip silently — fading it in here
|
|
65
|
+
// would blend a walk cycle into the middle of a sword swing.
|
|
66
|
+
if (this.busy) {
|
|
67
|
+
next.play().setEffectiveWeight(0);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
next.fadeIn(this.blend).play();
|
|
71
|
+
this.locomotion?.fadeOut(this.blend);
|
|
72
|
+
}
|
|
73
|
+
this.locomotion = next;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Play a one-shot. Returns false if there is no such clip, or if one is
|
|
77
|
+
* already running and `interrupt` was not asked for.
|
|
78
|
+
*/
|
|
79
|
+
play(name, opts = {}) {
|
|
80
|
+
const next = this.find(name);
|
|
81
|
+
if (!next)
|
|
82
|
+
return false;
|
|
83
|
+
if (this.busy && !opts.interrupt)
|
|
84
|
+
return false;
|
|
85
|
+
if (this.oneShot && this.oneShot !== next)
|
|
86
|
+
this.oneShot.fadeOut(this.actionBlend);
|
|
87
|
+
this.locomotion?.fadeOut(this.actionBlend);
|
|
88
|
+
next.reset()
|
|
89
|
+
.setLoop(THREE.LoopOnce, 1)
|
|
90
|
+
.setEffectiveWeight(1)
|
|
91
|
+
.fadeIn(this.actionBlend)
|
|
92
|
+
.play();
|
|
93
|
+
// Hold the last frame instead of snapping back to the bind pose in the
|
|
94
|
+
// gap before locomotion fades in.
|
|
95
|
+
next.clampWhenFinished = true;
|
|
96
|
+
this.oneShot = next;
|
|
97
|
+
this.oneShotName = name;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
dispose() {
|
|
101
|
+
this.mixer.removeEventListener('finished', this.onFinished);
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export * from './scene3d.js';
|
|
|
4
4
|
export { loadScene3D } from './SceneLoader3D.js';
|
|
5
5
|
export { CharacterController3D } from './CharacterController3D.js';
|
|
6
6
|
export type { CharacterOptions, CharacterState, CharacterInput } from './CharacterController3D.js';
|
|
7
|
+
export { CharacterAnimator } from './CharacterAnimator.js';
|
|
8
|
+
export type { ClipMap, CharacterAnimatorOptions } from './CharacterAnimator.js';
|
|
7
9
|
export { Input3D } from './Input3D.js';
|
|
8
10
|
export type { Input3DOptions } from './Input3D.js';
|
|
9
11
|
export type { LoadedScene3D, LoadSceneOptions } from './SceneLoader3D.js';
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { ThreeUmicat } from './ThreeUmicat.js';
|
|
|
7
7
|
export * from './scene3d.js';
|
|
8
8
|
export { loadScene3D } from './SceneLoader3D.js';
|
|
9
9
|
export { CharacterController3D } from './CharacterController3D.js';
|
|
10
|
+
export { CharacterAnimator } from './CharacterAnimator.js';
|
|
10
11
|
export { Input3D } from './Input3D.js';
|
|
11
12
|
// Re-exported so a game imports one package for the common case. A game should
|
|
12
13
|
// not have to know that identity and saves come from a different package than
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umicat/three-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|