mochi-avatar 1.0.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/BRAND.md +85 -0
- package/LICENSE.md +104 -0
- package/README.md +135 -0
- package/dist/canvas2d/avatar.d.ts +264 -0
- package/dist/canvas2d/avatar.d.ts.map +1 -0
- package/dist/canvas2d/avatar.js +811 -0
- package/dist/canvas2d/avatar.js.map +1 -0
- package/dist/canvas2d/face.d.ts +74 -0
- package/dist/canvas2d/face.d.ts.map +1 -0
- package/dist/canvas2d/face.js +298 -0
- package/dist/canvas2d/face.js.map +1 -0
- package/dist/canvas2d/paths.d.ts +13 -0
- package/dist/canvas2d/paths.d.ts.map +1 -0
- package/dist/canvas2d/paths.js +24 -0
- package/dist/canvas2d/paths.js.map +1 -0
- package/dist/characters/colourways.d.ts +78 -0
- package/dist/characters/colourways.d.ts.map +1 -0
- package/dist/characters/colourways.js +76 -0
- package/dist/characters/colourways.js.map +1 -0
- package/dist/characters/index.d.ts +8 -0
- package/dist/characters/index.d.ts.map +1 -0
- package/dist/characters/index.js +8 -0
- package/dist/characters/index.js.map +1 -0
- package/dist/characters/mochi.d.ts +19 -0
- package/dist/characters/mochi.d.ts.map +1 -0
- package/dist/characters/mochi.js +66 -0
- package/dist/characters/mochi.js.map +1 -0
- package/dist/core/colour.d.ts +25 -0
- package/dist/core/colour.d.ts.map +1 -0
- package/dist/core/colour.js +37 -0
- package/dist/core/colour.js.map +1 -0
- package/dist/core/envelope.d.ts +153 -0
- package/dist/core/envelope.d.ts.map +1 -0
- package/dist/core/envelope.js +141 -0
- package/dist/core/envelope.js.map +1 -0
- package/dist/core/geometry.d.ts +106 -0
- package/dist/core/geometry.d.ts.map +1 -0
- package/dist/core/geometry.js +157 -0
- package/dist/core/geometry.js.map +1 -0
- package/dist/core/idle.d.ts +163 -0
- package/dist/core/idle.d.ts.map +1 -0
- package/dist/core/idle.js +262 -0
- package/dist/core/idle.js.map +1 -0
- package/dist/core/layout.d.ts +142 -0
- package/dist/core/layout.d.ts.map +1 -0
- package/dist/core/layout.js +172 -0
- package/dist/core/layout.js.map +1 -0
- package/dist/core/lens.d.ts +47 -0
- package/dist/core/lens.d.ts.map +1 -0
- package/dist/core/lens.js +67 -0
- package/dist/core/lens.js.map +1 -0
- package/dist/core/looks.d.ts +77 -0
- package/dist/core/looks.d.ts.map +1 -0
- package/dist/core/looks.js +168 -0
- package/dist/core/looks.js.map +1 -0
- package/dist/core/motion.d.ts +168 -0
- package/dist/core/motion.d.ts.map +1 -0
- package/dist/core/motion.js +527 -0
- package/dist/core/motion.js.map +1 -0
- package/dist/core/mouth.d.ts +93 -0
- package/dist/core/mouth.d.ts.map +1 -0
- package/dist/core/mouth.js +92 -0
- package/dist/core/mouth.js.map +1 -0
- package/dist/core/plain.d.ts +23 -0
- package/dist/core/plain.d.ts.map +1 -0
- package/dist/core/plain.js +63 -0
- package/dist/core/plain.js.map +1 -0
- package/dist/core/spec.d.ts +156 -0
- package/dist/core/spec.d.ts.map +1 -0
- package/dist/core/spec.js +236 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/spring.d.ts +53 -0
- package/dist/core/spring.d.ts.map +1 -0
- package/dist/core/spring.js +68 -0
- package/dist/core/spring.js.map +1 -0
- package/dist/core/vocabulary.d.ts +182 -0
- package/dist/core/vocabulary.d.ts.map +1 -0
- package/dist/core/vocabulary.js +66 -0
- package/dist/core/vocabulary.js.map +1 -0
- package/dist/element/dough-avatar.d.ts +34 -0
- package/dist/element/dough-avatar.d.ts.map +1 -0
- package/dist/element/dough-avatar.js +189 -0
- package/dist/element/dough-avatar.js.map +1 -0
- package/dist/element/index.d.ts +3 -0
- package/dist/element/index.d.ts.map +1 -0
- package/dist/element/index.js +35 -0
- package/dist/element/index.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/svg/silhouette.d.ts +97 -0
- package/dist/svg/silhouette.d.ts.map +1 -0
- package/dist/svg/silhouette.js +143 -0
- package/dist/svg/silhouette.js.map +1 -0
- package/package.json +90 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single writer to her mouth.
|
|
3
|
+
*
|
|
4
|
+
* One object owns `setMouthOpen`, and that is the whole point rather than a
|
|
5
|
+
* tidiness preference. The mouth is layer 4 and is written last, so nothing
|
|
6
|
+
* above it can arbitrate between two sources -- two writers produce a mouth
|
|
7
|
+
* flickering between them at whatever rate they happen to disagree, and no
|
|
8
|
+
* layer ordering can fix it.
|
|
9
|
+
*
|
|
10
|
+
* ## Where the viseme path goes
|
|
11
|
+
*
|
|
12
|
+
* The mouth has two paths and they are not a ladder: cloud speech-to-speech
|
|
13
|
+
* carries no phoneme timings, so the envelope is the only thing available
|
|
14
|
+
* there, not a fallback. The five-vowel path belongs to a local TTS front end,
|
|
15
|
+
* whose G2P stage produces timings for free.
|
|
16
|
+
*
|
|
17
|
+
* There is deliberately no second implementation and no factory to choose
|
|
18
|
+
* between them YET. Nothing emits phoneme timings on the current road and
|
|
19
|
+
* `MochiAvatar.caps.visemes` is false, so a `VisemeMouth` would be an
|
|
20
|
+
* abstraction over one caller and a capability nobody can satisfy. When a
|
|
21
|
+
* provider reports `phonemeTimings`, it arrives as a second class behind this
|
|
22
|
+
* same interface and the choice is made ONCE -- by picking an object, never by
|
|
23
|
+
* a flag read inside a callback, which would be a branch in every frame and a
|
|
24
|
+
* place for the wrong path to be taken.
|
|
25
|
+
*
|
|
26
|
+
* ## The gate is THREE conditions, not one
|
|
27
|
+
*
|
|
28
|
+
* When that day comes, the precise path requires all of:
|
|
29
|
+
*
|
|
30
|
+
* 1. the provider emits phoneme timings,
|
|
31
|
+
* 2. the backend can render visemes (`caps.visemes`), and
|
|
32
|
+
* 3. THE LOADED MODEL carries every one of the five vowel presets.
|
|
33
|
+
*
|
|
34
|
+
* The third is the one that gets dropped, and it is the expensive one. VRM's
|
|
35
|
+
* vowel presets are optional per model, so a capable backend can load a model
|
|
36
|
+
* carrying none: routing on caps alone sends weights nowhere and leaves the
|
|
37
|
+
* mouth shut mid-word, intermittently, with every flag reporting success. RMS
|
|
38
|
+
* over the whole utterance is visibly better than four fifths of a mouth.
|
|
39
|
+
*
|
|
40
|
+
* mochi locked all three into `canUseVisemes()` in shared/avatar.ts. It is not
|
|
41
|
+
* ported here because there is nothing yet to call it -- port it together with
|
|
42
|
+
* the second driver, not before.
|
|
43
|
+
*/
|
|
44
|
+
import { DEFAULT_ENVELOPE, SILENT, advanceEnvelope } from './envelope.js';
|
|
45
|
+
/**
|
|
46
|
+
* The RMS path.
|
|
47
|
+
*
|
|
48
|
+
* Driven once per rendered frame rather than per audio callback. Under a peer
|
|
49
|
+
* connection her voice is a remote MediaStream the browser plays itself -- no
|
|
50
|
+
* PCM reaches this process at all -- so the level arrives by sampling an
|
|
51
|
+
* analyser, which is naturally a per-frame operation. At 60Hz that is a 16ms
|
|
52
|
+
* sampling interval against a ~4Hz syllable rate, which is ample.
|
|
53
|
+
*/
|
|
54
|
+
export class EnvelopeMouth {
|
|
55
|
+
avatar;
|
|
56
|
+
settings;
|
|
57
|
+
state = SILENT;
|
|
58
|
+
constructor(avatar, settings = DEFAULT_ENVELOPE) {
|
|
59
|
+
this.avatar = avatar;
|
|
60
|
+
this.settings = settings;
|
|
61
|
+
}
|
|
62
|
+
observe(level, dtSeconds) {
|
|
63
|
+
this.state = advanceEnvelope(level, this.state, dtSeconds, this.settings);
|
|
64
|
+
this.avatar.setMouthOpen(this.state.mouthOpen);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether the envelope currently judges the signal to be speech.
|
|
68
|
+
*
|
|
69
|
+
* The one place anything outside the rig should ask that question. Reading it
|
|
70
|
+
* here rather than re-deriving it from a level means the mouth and the
|
|
71
|
+
* "is she still talking" decision can never disagree -- and they did, when
|
|
72
|
+
* the caller compared a raw RMS against a constant while this compared a peak
|
|
73
|
+
* against a learned floor.
|
|
74
|
+
*/
|
|
75
|
+
get speaking() {
|
|
76
|
+
return this.state.speaking;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Shut, and forget the level -- but KEEP the learned floor and peak.
|
|
80
|
+
*
|
|
81
|
+
* The references are what make the envelope level-independent, and they are
|
|
82
|
+
* properties of this voice on this connection, not of this turn. Discarding
|
|
83
|
+
* them at every turn boundary would make her first syllable back a
|
|
84
|
+
* recalibration, which is visible: the mouth either slams or sits closed
|
|
85
|
+
* while the estimate catches up.
|
|
86
|
+
*/
|
|
87
|
+
end() {
|
|
88
|
+
this.state = { ...this.state, mouthOpen: 0 };
|
|
89
|
+
this.avatar.setMouthOpen(0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=mouth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mouth.js","sourceRoot":"","sources":["../../src/core/mouth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAGH,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,eAAe,EAAyB,MAAM,eAAe,CAAA;AAehG;;;;;;;;GAQG;AACH,MAAM,OAAO,aAAa;IAIL,MAAM;IACN,QAAQ;IAJnB,KAAK,GAAG,MAAM,CAAA;IAEtB,YACmB,MAAiB,EACjB,QAAQ,GAAqB,gBAAgB;sBAD7C,MAAM;wBACN,QAAQ;IACxB,CAAC;IAEJ,OAAO,CAAC,KAAa,EAAE,SAAiB;QACtC,IAAI,CAAC,KAAK,GAAG,eAAe,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAA;QACzE,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAA;IAChD,CAAC;IAED;;;;;;;;OAQG;IACH,IAAI,QAAQ;QACV,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAA;IAC5B,CAAC;IAED;;;;;;;;OAQG;IACH,GAAG;QACD,IAAI,CAAC,KAAK,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,CAAC,EAAE,CAAA;QAC5C,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;IAC7B,CAAC;CACF"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FaceSpec } from './spec.js';
|
|
2
|
+
/**
|
|
3
|
+
* The default face, and deliberately NOT a good one.
|
|
4
|
+
*
|
|
5
|
+
* An engine that renders nothing until you hand it thirty-nine numbers is an
|
|
6
|
+
* engine nobody evaluates, so there has to be a default. But the character this
|
|
7
|
+
* engine was extracted from is reserved, and shipping her as the default would
|
|
8
|
+
* hand her out with the library — so `PLAIN` is built to be visibly a different
|
|
9
|
+
* creature, along exactly the axes that identify her:
|
|
10
|
+
*
|
|
11
|
+
* | | Mochi | PLAIN |
|
|
12
|
+
* |---|---|---|
|
|
13
|
+
* | waist | 0.295, low and distinctive | 0.44, a near-symmetric egg |
|
|
14
|
+
* | shoulders | 1.86 / 2.58, asymmetric | 2.2 / 2.2, a plain ellipse |
|
|
15
|
+
* | shading | two tones, lit copy displaced | one flat tone, no displacement |
|
|
16
|
+
* | cheeks | a coral blush at 0.34 | none |
|
|
17
|
+
* | face | low on the body, small eyes | centred, larger eyes |
|
|
18
|
+
*
|
|
19
|
+
* Every one of those is a choice you are expected to replace. Start here, move
|
|
20
|
+
* the numbers, and the result is yours — that is what the format is for.
|
|
21
|
+
*/
|
|
22
|
+
export declare const PLAIN: FaceSpec;
|
|
23
|
+
//# sourceMappingURL=plain.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plain.d.ts","sourceRoot":"","sources":["../../src/core/plain.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AAEzC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,KAAK,EAAE,QA+CnB,CAAA"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default face, and deliberately NOT a good one.
|
|
3
|
+
*
|
|
4
|
+
* An engine that renders nothing until you hand it thirty-nine numbers is an
|
|
5
|
+
* engine nobody evaluates, so there has to be a default. But the character this
|
|
6
|
+
* engine was extracted from is reserved, and shipping her as the default would
|
|
7
|
+
* hand her out with the library — so `PLAIN` is built to be visibly a different
|
|
8
|
+
* creature, along exactly the axes that identify her:
|
|
9
|
+
*
|
|
10
|
+
* | | Mochi | PLAIN |
|
|
11
|
+
* |---|---|---|
|
|
12
|
+
* | waist | 0.295, low and distinctive | 0.44, a near-symmetric egg |
|
|
13
|
+
* | shoulders | 1.86 / 2.58, asymmetric | 2.2 / 2.2, a plain ellipse |
|
|
14
|
+
* | shading | two tones, lit copy displaced | one flat tone, no displacement |
|
|
15
|
+
* | cheeks | a coral blush at 0.34 | none |
|
|
16
|
+
* | face | low on the body, small eyes | centred, larger eyes |
|
|
17
|
+
*
|
|
18
|
+
* Every one of those is a choice you are expected to replace. Start here, move
|
|
19
|
+
* the numbers, and the result is yours — that is what the format is for.
|
|
20
|
+
*/
|
|
21
|
+
export const PLAIN = {
|
|
22
|
+
size: 100,
|
|
23
|
+
bodyW: 100,
|
|
24
|
+
bodyH: 92,
|
|
25
|
+
waist: 0.44,
|
|
26
|
+
upperShoulder: 2.2,
|
|
27
|
+
lowerShoulder: 2.2,
|
|
28
|
+
gripX: 0.7,
|
|
29
|
+
gripY: 0.7,
|
|
30
|
+
eyeX: 0.34,
|
|
31
|
+
eyeY: 0.55,
|
|
32
|
+
eyeHw: 5.5,
|
|
33
|
+
eyeUpper: 5.5,
|
|
34
|
+
eyeLower: 5.5,
|
|
35
|
+
eyeTilt: 0,
|
|
36
|
+
eyeRound: 2,
|
|
37
|
+
eyeGlint: 1.8,
|
|
38
|
+
gazeTravel: 0.4,
|
|
39
|
+
mouthY: 0.36,
|
|
40
|
+
mouthHw: 7,
|
|
41
|
+
mouthUpper: 0,
|
|
42
|
+
mouthLower: 2.6,
|
|
43
|
+
mouthRound: 2,
|
|
44
|
+
mouthOpenGain: 8,
|
|
45
|
+
cheekAlpha: 0,
|
|
46
|
+
cheekX: 0.6,
|
|
47
|
+
cheekY: 0.4,
|
|
48
|
+
cheekR: 10,
|
|
49
|
+
breathAmp: 0.03,
|
|
50
|
+
breathMs: 3000,
|
|
51
|
+
stiffness: 180,
|
|
52
|
+
damping: 22,
|
|
53
|
+
// Zero: the lit copy sits exactly on the shadow, so the body is one flat
|
|
54
|
+
// tone. The displaced two-tone shading is part of the reserved character.
|
|
55
|
+
shadowX: 0,
|
|
56
|
+
shadowY: 0,
|
|
57
|
+
colBody: '#b9c2cc',
|
|
58
|
+
colShadow: '#a9b3bf',
|
|
59
|
+
colInk: '#333b44',
|
|
60
|
+
colCheek: '#dd9999',
|
|
61
|
+
colGlint: '#ffffff',
|
|
62
|
+
};
|
|
63
|
+
//# sourceMappingURL=plain.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plain.js","sourceRoot":"","sources":["../../src/core/plain.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,MAAM,KAAK,GAAa;IAC7B,IAAI,EAAE,GAAG;IACT,KAAK,EAAE,GAAG;IACV,KAAK,EAAE,EAAE;IACT,KAAK,EAAE,IAAI;IACX,aAAa,EAAE,GAAG;IAClB,aAAa,EAAE,GAAG;IAClB,KAAK,EAAE,GAAG;IACV,KAAK,EAAE,GAAG;IAEV,IAAI,EAAE,IAAI;IACV,IAAI,EAAE,IAAI;IACV,KAAK,EAAE,GAAG;IACV,QAAQ,EAAE,GAAG;IACb,QAAQ,EAAE,GAAG;IACb,OAAO,EAAE,CAAC;IACV,QAAQ,EAAE,CAAC;IACX,QAAQ,EAAE,GAAG;IACb,UAAU,EAAE,GAAG;IAEf,MAAM,EAAE,IAAI;IACZ,OAAO,EAAE,CAAC;IACV,UAAU,EAAE,CAAC;IACb,UAAU,EAAE,GAAG;IACf,UAAU,EAAE,CAAC;IACb,aAAa,EAAE,CAAC;IAEhB,UAAU,EAAE,CAAC;IACb,MAAM,EAAE,GAAG;IACX,MAAM,EAAE,GAAG;IACX,MAAM,EAAE,EAAE;IAEV,SAAS,EAAE,IAAI;IACf,QAAQ,EAAE,IAAI;IACd,SAAS,EAAE,GAAG;IACd,OAAO,EAAE,EAAE;IAEX,yEAAyE;IACzE,0EAA0E;IAC1E,OAAO,EAAE,CAAC;IACV,OAAO,EAAE,CAAC;IAEV,OAAO,EAAE,SAAS;IAClB,SAAS,EAAE,SAAS;IACpB,MAAM,EAAE,SAAS;IACjB,QAAQ,EAAE,SAAS;IACnB,QAAQ,EAAE,SAAS;CACpB,CAAA"}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A face, as data.
|
|
3
|
+
*
|
|
4
|
+
* This is the plugin format. A designed avatar is a JSON file of these numbers
|
|
5
|
+
* -- no code, no assets, nothing executable. Design a face you like, save the
|
|
6
|
+
* file, and any host that embeds this engine can load it.
|
|
7
|
+
*
|
|
8
|
+
* ## Why data and not a code plugin
|
|
9
|
+
*
|
|
10
|
+
* The obvious "plugin" is a JS module implementing `AvatarBackend`, and that
|
|
11
|
+
* seam does exist (see `AvatarBackend` in `core/vocabulary`). It is not what a downloaded
|
|
12
|
+
* avatar gets to be. The renderer holds the IPC bridge that moves her window
|
|
13
|
+
* and disables click-through, and will later hold her audio stream; handing
|
|
14
|
+
* arbitrary downloaded code into that process trades a very large amount of
|
|
15
|
+
* authority for a different-shaped blob. Data cannot escalate.
|
|
16
|
+
*
|
|
17
|
+
* ## Why every field is bounded, not merely typed
|
|
18
|
+
*
|
|
19
|
+
* These numbers arrive from a file somebody else wrote. Type-checking alone
|
|
20
|
+
* accepts `waist: 50` or `bodyW: -1`, which do not throw -- they render a shape
|
|
21
|
+
* that is wrong in a way no error mentions, or divide by something near zero
|
|
22
|
+
* and produce a mochi the size of the screen. Every field therefore declares a
|
|
23
|
+
* range, and the range is checked at the boundary rather than defended against
|
|
24
|
+
* in the geometry.
|
|
25
|
+
*/
|
|
26
|
+
export interface FaceSpec {
|
|
27
|
+
/**
|
|
28
|
+
* How big she is on screen, as a percentage of the base scale.
|
|
29
|
+
*
|
|
30
|
+
* Part of the FACE, because "how she looks" includes how much room she takes
|
|
31
|
+
* — and because leaving it out made it a code change: `showFace` hardcoded
|
|
32
|
+
* `size: 100`, so somebody who wanted a smaller mochi had to edit
|
|
33
|
+
* TypeScript, rebuild, and hope. That is the exact thing this format exists
|
|
34
|
+
* to stop.
|
|
35
|
+
*
|
|
36
|
+
* 100 puts her `bodyW` of 100 units at `BASE_UNIT_SCALE` — about 94 CSS
|
|
37
|
+
* pixels, which is also the size v1's design handoff specified for her
|
|
38
|
+
* window.
|
|
39
|
+
*/
|
|
40
|
+
readonly size: number;
|
|
41
|
+
/** FULL width, in design units. */
|
|
42
|
+
readonly bodyW: number;
|
|
43
|
+
/** FULL height. She rests on the surface, so this is not a half-extent. */
|
|
44
|
+
readonly bodyH: number;
|
|
45
|
+
/** Height of the widest point, 0..0.95. Measured 0.295 on the icon. */
|
|
46
|
+
readonly waist: number;
|
|
47
|
+
/** Superellipse exponent above the waist. Below 2 is pointier than an ellipse. */
|
|
48
|
+
readonly upperShoulder: number;
|
|
49
|
+
/** Superellipse exponent below the waist. Above 2 is fuller than an ellipse. */
|
|
50
|
+
readonly lowerShoulder: number;
|
|
51
|
+
/** How much the face follows the body's deformation. Under 1 resists it. */
|
|
52
|
+
readonly gripX: number;
|
|
53
|
+
readonly gripY: number;
|
|
54
|
+
readonly eyeX: number;
|
|
55
|
+
readonly eyeY: number;
|
|
56
|
+
readonly eyeHw: number;
|
|
57
|
+
readonly eyeUpper: number;
|
|
58
|
+
readonly eyeLower: number;
|
|
59
|
+
readonly eyeTilt: number;
|
|
60
|
+
/** Superellipse exponent for the eyes. 2 is a round dot; below sharpens. */
|
|
61
|
+
readonly eyeRound: number;
|
|
62
|
+
/**
|
|
63
|
+
* Half-extent of the catchlight -- the white block that makes an eye look lit.
|
|
64
|
+
*
|
|
65
|
+
* 0 draws none, which is the honest way to opt out: a face with no highlight
|
|
66
|
+
* is a design choice, and expressing it as a size means it needs no flag.
|
|
67
|
+
*/
|
|
68
|
+
readonly eyeGlint: number;
|
|
69
|
+
readonly gazeTravel: number;
|
|
70
|
+
readonly mouthY: number;
|
|
71
|
+
readonly mouthHw: number;
|
|
72
|
+
readonly mouthUpper: number;
|
|
73
|
+
readonly mouthLower: number;
|
|
74
|
+
readonly mouthRound: number;
|
|
75
|
+
/** How far a mouthOpen of 1 pushes the lower arc down. */
|
|
76
|
+
readonly mouthOpenGain: number;
|
|
77
|
+
readonly cheekAlpha: number;
|
|
78
|
+
readonly cheekX: number;
|
|
79
|
+
readonly cheekY: number;
|
|
80
|
+
readonly cheekR: number;
|
|
81
|
+
/**
|
|
82
|
+
* How far a full inhale spreads her, on the squash channel.
|
|
83
|
+
*
|
|
84
|
+
* ONE-SIDED: the breath runs 0..1, so this is the whole excursion rather than
|
|
85
|
+
* a half-amplitude, and it only ever spreads her. It cannot stretch her
|
|
86
|
+
* taller than her resting silhouette -- see `core/idle.ts`, `breathAt`.
|
|
87
|
+
*/
|
|
88
|
+
readonly breathAmp: number;
|
|
89
|
+
readonly breathMs: number;
|
|
90
|
+
readonly stiffness: number;
|
|
91
|
+
readonly damping: number;
|
|
92
|
+
/** Displacement of the LIT copy of the silhouette, as a fraction of her size. */
|
|
93
|
+
readonly shadowX: number;
|
|
94
|
+
readonly shadowY: number;
|
|
95
|
+
readonly colBody: string;
|
|
96
|
+
readonly colShadow: string;
|
|
97
|
+
readonly colInk: string;
|
|
98
|
+
readonly colCheek: string;
|
|
99
|
+
/** The catchlight. Not hardcoded white: an ink that is not near-black wants its own. */
|
|
100
|
+
readonly colGlint: string;
|
|
101
|
+
}
|
|
102
|
+
type NumericKey = {
|
|
103
|
+
[K in keyof FaceSpec]: FaceSpec[K] extends number ? K : never;
|
|
104
|
+
}[keyof FaceSpec];
|
|
105
|
+
export type ColourKey = {
|
|
106
|
+
[K in keyof FaceSpec]: FaceSpec[K] extends string ? K : never;
|
|
107
|
+
}[keyof FaceSpec];
|
|
108
|
+
export interface Bound {
|
|
109
|
+
readonly min: number;
|
|
110
|
+
readonly max: number;
|
|
111
|
+
/** Granularity a slider should offer. Not enforced -- a spec may be finer. */
|
|
112
|
+
readonly step: number;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The permitted range of every numeric field.
|
|
116
|
+
*
|
|
117
|
+
* One table, read by BOTH the validator and the tuner's sliders. Two copies
|
|
118
|
+
* would let the editor offer a value the loader rejects, which presents to a
|
|
119
|
+
* user as "I designed this and the app ignored it".
|
|
120
|
+
*
|
|
121
|
+
* Wider than the tuner's comfortable range in places: these are the bounds of
|
|
122
|
+
* what is safe to render, not of what looks good. Taste is the designer's.
|
|
123
|
+
*/
|
|
124
|
+
export declare const FACE_BOUNDS: Readonly<Record<NumericKey, Bound>>;
|
|
125
|
+
export declare const COLOUR_KEYS: readonly ColourKey[];
|
|
126
|
+
/**
|
|
127
|
+
* `#rgb` and `#rrggbb`, with or without alpha. Nothing else — see parseFaceSpec.
|
|
128
|
+
*
|
|
129
|
+
* Exported so `core/colour` can validate against the SAME pattern rather than
|
|
130
|
+
* its own copy. Two copies had already drifted apart once: one trimmed its
|
|
131
|
+
* input and the other did not, so a colour could pass validation here and be
|
|
132
|
+
* unreadable there, or the reverse.
|
|
133
|
+
*/
|
|
134
|
+
export declare const HEX_COLOUR: RegExp;
|
|
135
|
+
export type ParseResult = {
|
|
136
|
+
readonly ok: true;
|
|
137
|
+
readonly face: FaceSpec;
|
|
138
|
+
} | {
|
|
139
|
+
readonly ok: false;
|
|
140
|
+
readonly problems: readonly string[];
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* Turn something read off disk into a face, or say exactly what is wrong.
|
|
144
|
+
*
|
|
145
|
+
* Reports EVERY problem rather than the first. Someone hand-editing an avatar
|
|
146
|
+
* file wants the whole list; failing on the first field turns one round of
|
|
147
|
+
* fixing into five, and they have no way to see the rest until each is cleared.
|
|
148
|
+
*
|
|
149
|
+
* Colours are restricted to hex. CSS accepts a great deal more -- `url(...)`,
|
|
150
|
+
* `image-set(...)`, custom properties -- and a colour string reaches
|
|
151
|
+
* `fillStyle`, which is the one place in this format where a value is
|
|
152
|
+
* interpreted rather than measured.
|
|
153
|
+
*/
|
|
154
|
+
export declare function parseFaceSpec(value: unknown): ParseResult;
|
|
155
|
+
export {};
|
|
156
|
+
//# sourceMappingURL=spec.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spec.d.ts","sourceRoot":"","sources":["../../src/core/spec.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A face, as data.
|
|
3
|
+
*
|
|
4
|
+
* This is the plugin format. A designed avatar is a JSON file of these numbers
|
|
5
|
+
* -- no code, no assets, nothing executable. Design a face you like, save the
|
|
6
|
+
* file, and any host that embeds this engine can load it.
|
|
7
|
+
*
|
|
8
|
+
* ## Why data and not a code plugin
|
|
9
|
+
*
|
|
10
|
+
* The obvious "plugin" is a JS module implementing `AvatarBackend`, and that
|
|
11
|
+
* seam does exist (see `AvatarBackend` in `core/vocabulary`). It is not what a downloaded
|
|
12
|
+
* avatar gets to be. The renderer holds the IPC bridge that moves her window
|
|
13
|
+
* and disables click-through, and will later hold her audio stream; handing
|
|
14
|
+
* arbitrary downloaded code into that process trades a very large amount of
|
|
15
|
+
* authority for a different-shaped blob. Data cannot escalate.
|
|
16
|
+
*
|
|
17
|
+
* ## Why every field is bounded, not merely typed
|
|
18
|
+
*
|
|
19
|
+
* These numbers arrive from a file somebody else wrote. Type-checking alone
|
|
20
|
+
* accepts `waist: 50` or `bodyW: -1`, which do not throw -- they render a shape
|
|
21
|
+
* that is wrong in a way no error mentions, or divide by something near zero
|
|
22
|
+
* and produce a mochi the size of the screen. Every field therefore declares a
|
|
23
|
+
* range, and the range is checked at the boundary rather than defended against
|
|
24
|
+
* in the geometry.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The permitted range of every numeric field.
|
|
28
|
+
*
|
|
29
|
+
* One table, read by BOTH the validator and the tuner's sliders. Two copies
|
|
30
|
+
* would let the editor offer a value the loader rejects, which presents to a
|
|
31
|
+
* user as "I designed this and the app ignored it".
|
|
32
|
+
*
|
|
33
|
+
* Wider than the tuner's comfortable range in places: these are the bounds of
|
|
34
|
+
* what is safe to render, not of what looks good. Taste is the designer's.
|
|
35
|
+
*/
|
|
36
|
+
export const FACE_BOUNDS = {
|
|
37
|
+
// The same band `clampSizePercent` enforces in `core/layout`, stated here
|
|
38
|
+
// because this is where a user-supplied value is refused.
|
|
39
|
+
size: { min: 50, max: 200, step: 5 },
|
|
40
|
+
bodyW: { min: 20, max: 400, step: 1 },
|
|
41
|
+
bodyH: { min: 20, max: 400, step: 1 },
|
|
42
|
+
// Not 1: at the very top the shape degenerates to a spike with no underside.
|
|
43
|
+
waist: { min: 0, max: 0.95, step: 0.005 },
|
|
44
|
+
// Not below 1: the superellipse becomes concave and self-intersects.
|
|
45
|
+
upperShoulder: { min: 1, max: 8, step: 0.02 },
|
|
46
|
+
lowerShoulder: { min: 1, max: 8, step: 0.02 },
|
|
47
|
+
gripX: { min: 0, max: 1, step: 0.01 },
|
|
48
|
+
gripY: { min: 0, max: 1, step: 0.01 },
|
|
49
|
+
eyeX: { min: 0, max: 1, step: 0.005 },
|
|
50
|
+
eyeY: { min: 0, max: 1, step: 0.005 },
|
|
51
|
+
eyeHw: { min: 0, max: 60, step: 0.5 },
|
|
52
|
+
eyeUpper: { min: -60, max: 60, step: 0.5 },
|
|
53
|
+
eyeLower: { min: -60, max: 60, step: 0.5 },
|
|
54
|
+
eyeTilt: { min: -1.5, max: 1.5, step: 0.01 },
|
|
55
|
+
eyeRound: { min: 0.5, max: 8, step: 0.05 },
|
|
56
|
+
// Unbounded above by the eye on purpose: the highlight is CLIPPED to the eye
|
|
57
|
+
// it sits in, so an oversized one turns the whole eye white rather than
|
|
58
|
+
// escaping it. That is a legitimate look, and no range check has to know the
|
|
59
|
+
// eye's size to permit it.
|
|
60
|
+
eyeGlint: { min: 0, max: 30, step: 0.1 },
|
|
61
|
+
gazeTravel: { min: 0, max: 2, step: 0.01 },
|
|
62
|
+
mouthY: { min: 0, max: 1, step: 0.005 },
|
|
63
|
+
mouthHw: { min: 0, max: 80, step: 0.5 },
|
|
64
|
+
mouthUpper: { min: -60, max: 60, step: 0.25 },
|
|
65
|
+
mouthLower: { min: -60, max: 60, step: 0.25 },
|
|
66
|
+
mouthRound: { min: 0.5, max: 8, step: 0.05 },
|
|
67
|
+
mouthOpenGain: { min: 0, max: 100, step: 0.5 },
|
|
68
|
+
cheekAlpha: { min: 0, max: 1, step: 0.01 },
|
|
69
|
+
cheekX: { min: 0, max: 1.5, step: 0.01 },
|
|
70
|
+
cheekY: { min: 0, max: 1, step: 0.01 },
|
|
71
|
+
cheekR: { min: 0, max: 80, step: 0.5 },
|
|
72
|
+
breathAmp: { min: 0, max: 0.5, step: 0.005 },
|
|
73
|
+
// Not 0: the breath divides by the period.
|
|
74
|
+
breathMs: { min: 200, max: 20_000, step: 50 },
|
|
75
|
+
// Not 0: a spring with no stiffness never returns.
|
|
76
|
+
stiffness: { min: 1, max: 2000, step: 5 },
|
|
77
|
+
damping: { min: 0.1, max: 200, step: 1 },
|
|
78
|
+
shadowX: { min: -1, max: 1, step: 0.002 },
|
|
79
|
+
shadowY: { min: -1, max: 1, step: 0.002 },
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Every colour field, as an exhaustive table rather than a list.
|
|
83
|
+
*
|
|
84
|
+
* `Record<ColourKey, true>` is the whole point: a list typed `ColourKey[]` is
|
|
85
|
+
* satisfied by ANY subset, so adding a sixth colour to `FaceSpec` compiled
|
|
86
|
+
* cleanly while the parser stayed unaware of it -- and the parser then reported
|
|
87
|
+
* the new field as "not a field of an avatar" and cast a face missing it to
|
|
88
|
+
* `FaceSpec` anyway. The built-in itself would have failed to load, and the
|
|
89
|
+
* tests would not have caught it because they derive their expectations from
|
|
90
|
+
* this same array.
|
|
91
|
+
*
|
|
92
|
+
* `FACE_BOUNDS` above is exhaustive over the numeric keys for exactly this
|
|
93
|
+
* reason. The colours were the half of the format that had no such guard.
|
|
94
|
+
*/
|
|
95
|
+
const COLOUR_FIELDS = {
|
|
96
|
+
colBody: true,
|
|
97
|
+
colShadow: true,
|
|
98
|
+
colInk: true,
|
|
99
|
+
colCheek: true,
|
|
100
|
+
colGlint: true,
|
|
101
|
+
};
|
|
102
|
+
export const COLOUR_KEYS = Object.keys(COLOUR_FIELDS);
|
|
103
|
+
/**
|
|
104
|
+
* `#rgb` and `#rrggbb`, with or without alpha. Nothing else — see parseFaceSpec.
|
|
105
|
+
*
|
|
106
|
+
* Exported so `core/colour` can validate against the SAME pattern rather than
|
|
107
|
+
* its own copy. Two copies had already drifted apart once: one trimmed its
|
|
108
|
+
* input and the other did not, so a colour could pass validation here and be
|
|
109
|
+
* unreadable there, or the reverse.
|
|
110
|
+
*/
|
|
111
|
+
export const HEX_COLOUR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
112
|
+
/** Long enough for any label a person writes, short enough not to be a payload. */
|
|
113
|
+
const NAME_MAX = 80;
|
|
114
|
+
/**
|
|
115
|
+
* A value, rendered for an error message, without throwing.
|
|
116
|
+
*
|
|
117
|
+
* `JSON.stringify` throws on a BigInt -- so the error path itself could throw,
|
|
118
|
+
* and `parseFaceSpec` would break the one promise its signature makes: that it
|
|
119
|
+
* returns a verdict. Disk JSON cannot carry a BigInt, but the parameter is
|
|
120
|
+
* `unknown` and the export is public, so the contract has to hold for whatever
|
|
121
|
+
* arrives, not for what is expected to.
|
|
122
|
+
*/
|
|
123
|
+
function describe(value) {
|
|
124
|
+
if (typeof value === 'string')
|
|
125
|
+
return JSON.stringify(value);
|
|
126
|
+
if (typeof value === 'bigint')
|
|
127
|
+
return `${value}n`;
|
|
128
|
+
if (typeof value === 'object' && value !== null)
|
|
129
|
+
return Array.isArray(value) ? 'an array' : 'an object';
|
|
130
|
+
return String(value);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* An error, as a string, without throwing a second time.
|
|
134
|
+
*
|
|
135
|
+
* `String(error)` is not safe on arbitrary input: a null-prototype object has
|
|
136
|
+
* no `toString`, and a Proxy can throw from one. That made the whole point of
|
|
137
|
+
* the catch below reachable-but-broken — the one function whose contract is
|
|
138
|
+
* "always returns a verdict" could still take its caller down.
|
|
139
|
+
*/
|
|
140
|
+
function describeError(error) {
|
|
141
|
+
try {
|
|
142
|
+
return String(error);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return 'an error that could not be described';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Turn something read off disk into a face, or say exactly what is wrong.
|
|
150
|
+
*
|
|
151
|
+
* Reports EVERY problem rather than the first. Someone hand-editing an avatar
|
|
152
|
+
* file wants the whole list; failing on the first field turns one round of
|
|
153
|
+
* fixing into five, and they have no way to see the rest until each is cleared.
|
|
154
|
+
*
|
|
155
|
+
* Colours are restricted to hex. CSS accepts a great deal more -- `url(...)`,
|
|
156
|
+
* `image-set(...)`, custom properties -- and a colour string reaches
|
|
157
|
+
* `fillStyle`, which is the one place in this format where a value is
|
|
158
|
+
* interpreted rather than measured.
|
|
159
|
+
*/
|
|
160
|
+
export function parseFaceSpec(value) {
|
|
161
|
+
try {
|
|
162
|
+
return readFaceSpec(value);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
// The signature promises a VERDICT for any `unknown`, and reading a
|
|
166
|
+
// property is not guaranteed to be safe: a getter or a Proxy can throw, and
|
|
167
|
+
// then the one function whose job is to decide whether something is a face
|
|
168
|
+
// takes the caller down with it. Today's inputs are `JSON.parse` output and
|
|
169
|
+
// structured-cloned IPC payloads, neither of which can carry a getter --
|
|
170
|
+
// which is exactly why nothing would have found this until the day some
|
|
171
|
+
// caller passed a live object.
|
|
172
|
+
return { ok: false, problems: [`the avatar could not be read: ${describeError(error)}`] };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function readFaceSpec(value) {
|
|
176
|
+
const problems = [];
|
|
177
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
178
|
+
return { ok: false, problems: ['the avatar file must contain a JSON object'] };
|
|
179
|
+
}
|
|
180
|
+
const source = value;
|
|
181
|
+
const face = {};
|
|
182
|
+
for (const [key, bound] of Object.entries(FACE_BOUNDS)) {
|
|
183
|
+
const raw = source[key];
|
|
184
|
+
if (raw === undefined) {
|
|
185
|
+
problems.push(`${key} is missing`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
|
189
|
+
problems.push(`${key} must be a finite number, got ${describe(raw)}`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (raw < bound.min || raw > bound.max) {
|
|
193
|
+
problems.push(`${key} must be between ${bound.min} and ${bound.max}, got ${raw}`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
face[key] = raw;
|
|
197
|
+
}
|
|
198
|
+
for (const key of COLOUR_KEYS) {
|
|
199
|
+
const raw = source[key];
|
|
200
|
+
if (raw === undefined) {
|
|
201
|
+
problems.push(`${key} is missing`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (typeof raw !== 'string' || !HEX_COLOUR.test(raw)) {
|
|
205
|
+
problems.push(`${key} must be a hex colour like #8ec8a8, got ${describe(raw)}`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
face[key] = raw;
|
|
209
|
+
}
|
|
210
|
+
// `name` is a label for whoever is editing the file. It is deliberately NOT
|
|
211
|
+
// part of `FaceSpec` -- nothing renders it, and `resolveAvatar` identifies an
|
|
212
|
+
// avatar by filename -- but it is still validated, because "accepted and
|
|
213
|
+
// discarded" and "accepted, wrong, and discarded" look identical from the
|
|
214
|
+
// outside. A `name` of `12345` used to pass silently; now it is a problem
|
|
215
|
+
// reported alongside every other, which is the promise this parser makes.
|
|
216
|
+
if ('name' in source) {
|
|
217
|
+
const name = source['name'];
|
|
218
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
219
|
+
problems.push(`name must be a non-empty label, got ${describe(name)}`);
|
|
220
|
+
}
|
|
221
|
+
else if (name.length > NAME_MAX) {
|
|
222
|
+
problems.push(`name must be at most ${NAME_MAX} characters`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Unknown keys are reported, not rejected. A typo like `eyeWith` is silently
|
|
226
|
+
// ignored otherwise, and the designer sees their change do nothing.
|
|
227
|
+
const known = new Set([...Object.keys(FACE_BOUNDS), ...COLOUR_KEYS]);
|
|
228
|
+
for (const key of Object.keys(source)) {
|
|
229
|
+
if (!known.has(key) && key !== 'name')
|
|
230
|
+
problems.push(`${key} is not a field of an avatar`);
|
|
231
|
+
}
|
|
232
|
+
if (problems.length > 0)
|
|
233
|
+
return { ok: false, problems };
|
|
234
|
+
return { ok: true, face: face };
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=spec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spec.js","sourceRoot":"","sources":["../../src/core/spec.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A second-order spring.
|
|
3
|
+
*
|
|
4
|
+
* The reason this exists rather than the one-pole filter used elsewhere: a
|
|
5
|
+
* first-order system approaches its target and stops. It CANNOT overshoot, and
|
|
6
|
+
* overshoot is what reads as mass. A mochi that gets squashed should wobble
|
|
7
|
+
* past centre and settle; one that eases back looks like a scaling picture.
|
|
8
|
+
*
|
|
9
|
+
* Pure arithmetic, no clock. The caller owns time, which is what lets the same
|
|
10
|
+
* code run in a render loop and in a test that advances a number.
|
|
11
|
+
*/
|
|
12
|
+
export interface SpringConfig {
|
|
13
|
+
/** Pull toward the target. Higher is snappier. */
|
|
14
|
+
readonly stiffness: number;
|
|
15
|
+
/** Resistance. Below ~2*sqrt(stiffness) the spring is underdamped and overshoots. */
|
|
16
|
+
readonly damping: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const DEFAULT_SPRING: SpringConfig;
|
|
19
|
+
/**
|
|
20
|
+
* Longest step the integrator will take, in seconds.
|
|
21
|
+
*
|
|
22
|
+
* Semi-implicit Euler is only conditionally stable: past roughly 2/sqrt(k) the
|
|
23
|
+
* correction overshoots further than the error it was correcting and the value
|
|
24
|
+
* diverges to infinity within a few frames. A backgrounded window, a breakpoint
|
|
25
|
+
* or a laptop waking from sleep all deliver exactly that -- a dt of seconds.
|
|
26
|
+
*
|
|
27
|
+
* Clamping means a long gap is under-integrated (she resumes from where she
|
|
28
|
+
* was, a little behind) rather than exploding. Under-integration is invisible;
|
|
29
|
+
* divergence paints a mochi the size of the screen and then NaN, after which
|
|
30
|
+
* every subsequent frame is blank with nothing in the log to say why.
|
|
31
|
+
*/
|
|
32
|
+
export declare const MAX_STEP_SECONDS: number;
|
|
33
|
+
export declare class Spring {
|
|
34
|
+
private position;
|
|
35
|
+
private velocity;
|
|
36
|
+
constructor(initial?: number);
|
|
37
|
+
/** The current value, without advancing time. */
|
|
38
|
+
get value(): number;
|
|
39
|
+
/**
|
|
40
|
+
* Advance by `dtSeconds` toward `target` and return the new value.
|
|
41
|
+
*
|
|
42
|
+
* Non-finite inputs are ignored rather than propagated: one NaN reaching the
|
|
43
|
+
* velocity would poison the spring permanently, and the symptom -- she simply
|
|
44
|
+
* stops moving, forever, from an arbitrary later frame -- points nowhere near
|
|
45
|
+
* the frame that caused it.
|
|
46
|
+
*/
|
|
47
|
+
step(dtSeconds: number, target: number, config?: SpringConfig): number;
|
|
48
|
+
/** Jump to a value and kill the velocity. For teleports, not for animation. */
|
|
49
|
+
snap(value: number): void;
|
|
50
|
+
/** True once the spring has effectively stopped, so a caller can skip work. */
|
|
51
|
+
isAtRest(target: number, epsilon?: number): boolean;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=spring.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spring.d.ts","sourceRoot":"","sources":["../../src/core/spring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,YAAY;IAC3B,kDAAkD;IAClD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,qFAAqF;IACrF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED,eAAO,MAAM,cAAc,EAAE,YAA8C,CAAA;AAE3E;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,QAAS,CAAA;AAEtC,qBAAa,MAAM;IACjB,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,QAAQ,CAAI;IAEpB,YAAY,OAAO,SAAI,EAEtB;IAED,iDAAiD;IACjD,IAAI,KAAK,IAAI,MAAM,CAElB;IAED;;;;;;;OAOG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAE,YAA6B,GAAG,MAAM,CAWrF;IAED,+EAA+E;IAC/E,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAIxB;IAED,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,SAAO,GAAG,OAAO,CAEhD;CACF"}
|