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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mochi's silhouette, as arithmetic.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately canvas-free: these functions return points, and the backend
|
|
5
|
+
* turns points into a Path2D. That is what makes the shape testable without a
|
|
6
|
+
* rasteriser, and it is also what makes hit testing exact -- the polygon that
|
|
7
|
+
* is filled and the polygon that is hit-tested are the same array.
|
|
8
|
+
*
|
|
9
|
+
* Local space throughout: origin at the point where she meets the surface,
|
|
10
|
+
* +y UP. The renderer flips. Heights read as positive numbers here, which is
|
|
11
|
+
* worth the one transform at the boundary.
|
|
12
|
+
*
|
|
13
|
+
* The shape is an OVOID, not a dome, and that came from measuring
|
|
14
|
+
* `rig/__fixtures__/mochi-icon.png` rather than from taste. The first version of this file
|
|
15
|
+
* was a single superellipse planted on a flat base, which put the widest point
|
|
16
|
+
* at the bottom -- and the icon's widest point is at 0.295 of its height, with
|
|
17
|
+
* a rounded underside. Those are different creatures, and the difference is
|
|
18
|
+
* exactly what makes one read as a dumpling and the other as a mochi.
|
|
19
|
+
*/
|
|
20
|
+
// Imported rather than declared: the window has to be sized for the widest
|
|
21
|
+
// frame she can reach, so main needs this number too. Two copies would let the
|
|
22
|
+
// window and the shape disagree about how far she leans.
|
|
23
|
+
//
|
|
24
|
+
// This was a RELATIVE, extension-suffixed import for a specific reason:
|
|
25
|
+
// `scripts/make-icons.ts` imported this module under bare `node`, which resolves
|
|
26
|
+
// no path aliases -- and switching to `@shared` once broke `pnpm icons` while
|
|
27
|
+
// every other gate stayed green, because nothing in `pnpm verify` ran the
|
|
28
|
+
// generator.
|
|
29
|
+
//
|
|
30
|
+
// That generator went to the archive with the v1 rig, so the constraint is gone
|
|
31
|
+
// and the alias is correct again. If an icon generator returns and runs outside
|
|
32
|
+
// the bundler, this line is the one that breaks first -- and it will break
|
|
33
|
+
// silently, so give it a gate that runs the generator rather than restoring the
|
|
34
|
+
// relative path and hoping.
|
|
35
|
+
import { SHEAR_GAIN } from './layout.js';
|
|
36
|
+
import { clamp01 } from './vocabulary.js';
|
|
37
|
+
/**
|
|
38
|
+
* Squash, preserving area.
|
|
39
|
+
*
|
|
40
|
+
* `halfWidth * factor` and `height / factor`: the ovoid's area is
|
|
41
|
+
* proportional to the product for fixed exponents, so this is exactly
|
|
42
|
+
* volume-preserving. That is the property that makes it read as dough being
|
|
43
|
+
* compressed rather than as a picture being scaled.
|
|
44
|
+
*
|
|
45
|
+
* The factor is floored well above zero. A squash of -1 would otherwise divide
|
|
46
|
+
* by zero and produce an infinitely tall mochi.
|
|
47
|
+
*/
|
|
48
|
+
export function squashed(shape, amount) {
|
|
49
|
+
const safe = Number.isFinite(amount) ? Math.max(-0.9, Math.min(0.9, amount)) : 0;
|
|
50
|
+
const factor = 1 + safe;
|
|
51
|
+
return { ...shape, halfWidth: shape.halfWidth * factor, height: shape.height / factor };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Horizontal displacement at a given height above the base.
|
|
55
|
+
*
|
|
56
|
+
* Zero at the base and maximal at the apex, which is what distinguishes a lean
|
|
57
|
+
* from a translation: her contact point stays where it was.
|
|
58
|
+
*/
|
|
59
|
+
export function shearAt(shape, heightAboveBase) {
|
|
60
|
+
if (shape.height <= 0)
|
|
61
|
+
return 0;
|
|
62
|
+
const t = Math.max(0, Math.min(1, heightAboveBase / shape.height));
|
|
63
|
+
return shape.lean * shape.halfWidth * SHEAR_GAIN * t;
|
|
64
|
+
}
|
|
65
|
+
/** Superellipse half-width at normalised distance `t` from the waist, 0..1. */
|
|
66
|
+
function profile(t, exponent) {
|
|
67
|
+
const n = Math.max(0.2, exponent);
|
|
68
|
+
return Math.pow(Math.max(0, 1 - Math.pow(Math.min(1, Math.max(0, t)), n)), 1 / n);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Half-width at a height, as a fraction of halfWidth.
|
|
72
|
+
*
|
|
73
|
+
* Exported because it is the cheapest way to compare this shape against the
|
|
74
|
+
* measured icon: the test walks heights and checks the profile, which pins the
|
|
75
|
+
* silhouette to the artwork rather than to whatever looked right on the day.
|
|
76
|
+
*/
|
|
77
|
+
export function widthAt(shape, heightFraction) {
|
|
78
|
+
const t = Math.min(1, Math.max(0, heightFraction));
|
|
79
|
+
const waist = Math.min(0.95, Math.max(0, shape.waist));
|
|
80
|
+
if (t >= waist) {
|
|
81
|
+
// No `waist >= 1` guard: the clamp above caps it at 0.95, so the divisor
|
|
82
|
+
// is at least 0.05 by construction. The guard implied a division by zero
|
|
83
|
+
// that this function has already made impossible.
|
|
84
|
+
return profile((t - waist) / (1 - waist), shape.upperShoulder);
|
|
85
|
+
}
|
|
86
|
+
const below = waist <= 0 ? 1 : (waist - t) / waist;
|
|
87
|
+
return profile(below, shape.lowerShoulder);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The closed outline, from the contact point up the right side and back down
|
|
91
|
+
* the left.
|
|
92
|
+
*
|
|
93
|
+
* The base sits exactly on y = 0 and the body occupies y > 0, so "resting on a
|
|
94
|
+
* surface" is a property of the coordinates rather than something the caller
|
|
95
|
+
* has to arrange. Squash therefore grows upward, which is how something resting
|
|
96
|
+
* on a table actually deforms.
|
|
97
|
+
*/
|
|
98
|
+
export function domeOutline(shape, steps = 96) {
|
|
99
|
+
const a = Math.max(1e-6, shape.halfWidth);
|
|
100
|
+
const b = Math.max(1e-6, shape.height);
|
|
101
|
+
const waist = Math.min(0.95, Math.max(0, shape.waist));
|
|
102
|
+
// Delegated rather than restated. This was a second copy of the lean formula
|
|
103
|
+
// with the same three factors in the same order, and two copies of an
|
|
104
|
+
// expression are two things that can be tuned apart. The guarded dimensions
|
|
105
|
+
// are what differed, so they go into the shape instead of into the formula.
|
|
106
|
+
const guarded = { ...shape, halfWidth: a, height: b };
|
|
107
|
+
const shear = (y) => shearAt(guarded, y);
|
|
108
|
+
// The right-hand profile, bottom to top. Sampled separately above and below
|
|
109
|
+
// the waist so neither half is starved of points when the waist sits low --
|
|
110
|
+
// and it does sit low, at 0.295.
|
|
111
|
+
const right = [];
|
|
112
|
+
const lowerSteps = Math.max(8, Math.round(steps * waist));
|
|
113
|
+
const upperSteps = Math.max(8, steps - lowerSteps);
|
|
114
|
+
for (let i = 0; i <= lowerSteps; i++) {
|
|
115
|
+
const t = waist * (i / lowerSteps);
|
|
116
|
+
const y = t * b;
|
|
117
|
+
right.push({ x: a * widthAt(shape, t) + shear(y), y });
|
|
118
|
+
}
|
|
119
|
+
for (let i = 1; i <= upperSteps; i++) {
|
|
120
|
+
const t = waist + (1 - waist) * (i / upperSteps);
|
|
121
|
+
const y = t * b;
|
|
122
|
+
right.push({ x: a * widthAt(shape, t) + shear(y), y });
|
|
123
|
+
}
|
|
124
|
+
// Mirror back down the left. Both endpoints are dropped: they sit on the
|
|
125
|
+
// axis, so keeping them would duplicate the apex and the contact point and
|
|
126
|
+
// leave a zero-length segment at each -- which some rasterisers render as a
|
|
127
|
+
// stray join.
|
|
128
|
+
const left = right
|
|
129
|
+
.slice(1, -1)
|
|
130
|
+
.reverse()
|
|
131
|
+
.map((point) => ({ x: -(point.x - shear(point.y)) + shear(point.y), y: point.y }));
|
|
132
|
+
return [...right, ...left];
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Where a feature sits, in body-local space.
|
|
136
|
+
*
|
|
137
|
+
* `grip` is the point of this function. At 1 the face deforms exactly as much
|
|
138
|
+
* as the body and looks printed on; at 0 it floats, unaffected. Real features
|
|
139
|
+
* on something soft resist deformation without escaping it, so the placement
|
|
140
|
+
* interpolates between the squashed and unsquashed frames. That single number
|
|
141
|
+
* is most of what makes it read as a face ON something rather than a decal.
|
|
142
|
+
*
|
|
143
|
+
* The lean is measured in that SAME interpolated frame. It used to be taken
|
|
144
|
+
* from the squashed one unconditionally, which quietly broke the contract: a
|
|
145
|
+
* zero-grip feature was placed at its undeformed width and then displaced by a
|
|
146
|
+
* shear computed from the wider, shorter squashed body -- so it moved
|
|
147
|
+
* horizontally during a squash it was supposed to be ignoring. At grip 1 the
|
|
148
|
+
* interpolated frame IS the squashed frame, so nothing about her changes.
|
|
149
|
+
*/
|
|
150
|
+
export function placeFeature(base, squashedShape, normalisedX, normalisedY, gripX, gripY) {
|
|
151
|
+
const width = base.halfWidth + (squashedShape.halfWidth - base.halfWidth) * clamp01(gripX);
|
|
152
|
+
const height = base.height + (squashedShape.height - base.height) * clamp01(gripY);
|
|
153
|
+
const y = normalisedY * height;
|
|
154
|
+
const frame = { ...squashedShape, halfWidth: width, height };
|
|
155
|
+
return { x: normalisedX * width + shearAt(frame, y), y };
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=geometry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"geometry.js","sourceRoot":"","sources":["../../src/core/geometry.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The idle layer: breathing, blinking, and the small motion that separates
|
|
3
|
+
* "alive" from "a picture that sometimes moves".
|
|
4
|
+
*
|
|
5
|
+
* Pure functions of time. The backend asks what the idle layer wants at a
|
|
6
|
+
* given instant and gets an answer; nothing here holds a frame loop, a clock,
|
|
7
|
+
* or a random number generator it did not receive. That is what makes a blink
|
|
8
|
+
* schedule testable at all -- the alternative is a test that waits.
|
|
9
|
+
*
|
|
10
|
+
* Ported unchanged from mochi. The asymmetric blink and the Poisson gap are
|
|
11
|
+
* both experiment, not preference, and re-deriving them would mean re-watching
|
|
12
|
+
* a metronome blink for thirty seconds to rediscover why it looks wrong.
|
|
13
|
+
*/
|
|
14
|
+
import type { MonotonicMs } from './vocabulary.js';
|
|
15
|
+
export declare const BREATH_PERIOD_MS = 3400;
|
|
16
|
+
/**
|
|
17
|
+
* Blink gap bounds.
|
|
18
|
+
*
|
|
19
|
+
* Drawn from an exponential distribution rather than picked uniformly: real
|
|
20
|
+
* blinks are a Poisson process, and a uniform gap reads as a metronome within
|
|
21
|
+
* about thirty seconds of watching. The bounds clamp the tail -- an exponential
|
|
22
|
+
* will happily produce a four-second gap or a ninety-second one, and both look
|
|
23
|
+
* wrong.
|
|
24
|
+
*/
|
|
25
|
+
export declare const BLINK_MIN_GAP_MS = 2200;
|
|
26
|
+
export declare const BLINK_MAX_GAP_MS = 6200;
|
|
27
|
+
/** How long the lid takes to close and open again. */
|
|
28
|
+
export declare const BLINK_DURATION_MS = 130;
|
|
29
|
+
export interface IdlePose {
|
|
30
|
+
/** 0..1, where 1 is fully shut. */
|
|
31
|
+
readonly blink: number;
|
|
32
|
+
/**
|
|
33
|
+
* 0..1, where 0 is resting and 1 is fully inhaled. Never negative.
|
|
34
|
+
*
|
|
35
|
+
* ONE-SIDED, and the sign is the whole of it -- see `breathAt`. The resting
|
|
36
|
+
* silhouette is a floor she returns to rather than a midpoint she oscillates
|
|
37
|
+
* about.
|
|
38
|
+
*/
|
|
39
|
+
readonly breath: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* How far the drift may take her, in fractions of her body.
|
|
43
|
+
*
|
|
44
|
+
* ## Small on purpose, and this is the whole point of it
|
|
45
|
+
*
|
|
46
|
+
* A companion who is alive is not a companion who goes anywhere. These are a
|
|
47
|
+
* few pixels at her drawn size -- `shift` is about 2.6px on a 94px body -- and
|
|
48
|
+
* that is the intended reading: somebody standing still, shifting their weight,
|
|
49
|
+
* rather than somebody pacing.
|
|
50
|
+
*
|
|
51
|
+
* The clips are the loud half of the vocabulary and they were tuned louder
|
|
52
|
+
* still after a first complaint that read as "too small". It was not: the
|
|
53
|
+
* motions were coarse because they were the ONLY thing moving, so she was a
|
|
54
|
+
* still image punctuated by gestures. Continuous micro-motion is what a still
|
|
55
|
+
* image is missing, and once it exists the gestures can be quiet again.
|
|
56
|
+
*/
|
|
57
|
+
export declare const DRIFT: {
|
|
58
|
+
readonly lean: 0.02;
|
|
59
|
+
readonly shift: 0.026;
|
|
60
|
+
readonly lift: 0.009;
|
|
61
|
+
};
|
|
62
|
+
/** Where in her small orbit she is, at `now`. */
|
|
63
|
+
export interface Drift {
|
|
64
|
+
readonly lean: number;
|
|
65
|
+
readonly shift: number;
|
|
66
|
+
readonly lift: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The continuous, going-nowhere motion of somebody who is simply present.
|
|
70
|
+
*
|
|
71
|
+
* `scale` is how much of it applies -- 1 awake, less asleep. Sleep keeps a
|
|
72
|
+
* trace rather than none for the reason the breath is kept: a companion who
|
|
73
|
+
* stops moving altogether reads as a crash, which is the one thing the resting
|
|
74
|
+
* state must not look like.
|
|
75
|
+
*/
|
|
76
|
+
export declare function driftAt(now: MonotonicMs, scale?: number): Drift;
|
|
77
|
+
/**
|
|
78
|
+
* The breath, as a fraction of a full inhale. 0 at rest, 1 fully inhaled.
|
|
79
|
+
*
|
|
80
|
+
* Raised cosines rather than ramps: the turnaround at each end is gradual, and
|
|
81
|
+
* a linear ramp gives the body a visible tick at the top. One cycle per period,
|
|
82
|
+
* starting and ending at rest, peaking at `BREATH_IN_FRACTION` of the way
|
|
83
|
+
* through.
|
|
84
|
+
*
|
|
85
|
+
* TWO half-cosines rather than one whole one, because the halves are different
|
|
86
|
+
* lengths -- see `BREATH_IN_FRACTION`. Each meets the other with zero slope, at
|
|
87
|
+
* the peak and at rest alike, so neither join is visible; at a fraction of 0.5
|
|
88
|
+
* the pair collapses exactly to the single cosine this replaced.
|
|
89
|
+
*
|
|
90
|
+
* ## ONE-SIDED, and that is the point of it
|
|
91
|
+
*
|
|
92
|
+
* This was a plain sine over -1..1, and the negative half was the defect. The
|
|
93
|
+
* breath is summed into `squash` (see `mochi.ts`), which is an area-preserving
|
|
94
|
+
* trade: positive spreads her, negative stretches her taller and NARROWER. So
|
|
95
|
+
* for half of every cycle she wore a fraction of the same deformation `sad`
|
|
96
|
+
* (-0.11) and `surprised` (-0.16) are made of -- measured at 42% of `sad`'s
|
|
97
|
+
* crown-sharpening at the extreme, on the same axis.
|
|
98
|
+
*
|
|
99
|
+
* That reads as her head going pointy on a 3.4-second loop, and it corresponds
|
|
100
|
+
* to nothing a body does: inhaling expands, exhaling returns to rest. Nothing
|
|
101
|
+
* makes you taller and thinner than resting.
|
|
102
|
+
*
|
|
103
|
+
* The crown is where it showed first because it sharpens FASTER than the body
|
|
104
|
+
* lengthens -- width a fixed distance below the apex scales as
|
|
105
|
+
* `f^(1 + 1/upperShoulder)`, about `f^1.54` at the tuned 1.86, against `f^-1`
|
|
106
|
+
* for the height. Halving the amplitude alone would therefore not have fixed
|
|
107
|
+
* it, only slowed it down; removing the negative half does, by construction.
|
|
108
|
+
*
|
|
109
|
+
* The consequence worth stating: the silhouette measured against
|
|
110
|
+
* `rig/__fixtures__/mochi-icon.png` is now a FLOOR she returns to once per
|
|
111
|
+
* cycle, rather than a midpoint she is above half the time.
|
|
112
|
+
*/
|
|
113
|
+
export declare function breathAt(now: MonotonicMs, periodMs?: number): number;
|
|
114
|
+
/**
|
|
115
|
+
* Lid closure during a blink that began at `startedAt`.
|
|
116
|
+
*
|
|
117
|
+
* Returns 0 outside the blink, so a caller can ask unconditionally. The
|
|
118
|
+
* envelope is continuous at every boundary -- at elapsed 0 the rising ramp is
|
|
119
|
+
* already 0, at `closeMs` both branches give 1, at `durationMs` the falling
|
|
120
|
+
* ramp has reached 0 -- so a blink cannot clip at its edges.
|
|
121
|
+
*/
|
|
122
|
+
export declare function blinkAt(now: MonotonicMs, startedAt: MonotonicMs, durationMs?: number): number;
|
|
123
|
+
/**
|
|
124
|
+
* The gap until the next blink.
|
|
125
|
+
*
|
|
126
|
+
* `random` is injected rather than taken from Math.random so a test can pin the
|
|
127
|
+
* schedule. An exponential mapped through the clamp: most gaps land in the
|
|
128
|
+
* first half of the range, which is what makes the rhythm feel unplanned.
|
|
129
|
+
*/
|
|
130
|
+
export declare function nextBlinkGap(random: () => number): number;
|
|
131
|
+
/**
|
|
132
|
+
* The idle layer, holding only the blink schedule.
|
|
133
|
+
*
|
|
134
|
+
* Deliberately not a frame loop: it is asked what it wants and answers. The
|
|
135
|
+
* backend owns time, which is the only way the same code can run in a render
|
|
136
|
+
* loop and in a test that advances a number.
|
|
137
|
+
*/
|
|
138
|
+
export declare class IdleLayer {
|
|
139
|
+
private readonly random;
|
|
140
|
+
private nextBlinkAt;
|
|
141
|
+
private blinkStartedAt;
|
|
142
|
+
constructor(startedAt: MonotonicMs, random?: () => number);
|
|
143
|
+
/**
|
|
144
|
+
* `periodMs` is a PARAMETER rather than a constant read from this module.
|
|
145
|
+
*
|
|
146
|
+
* `FaceSpec.breathMs` is a validated, bounded, tuner-exposed field, and for a
|
|
147
|
+
* while nothing read it: this method called `breathAt(now)` and took the
|
|
148
|
+
* module default. The two happened to agree at 3400, so every gate stayed
|
|
149
|
+
* green and the only symptom was a slider that did nothing -- which presents
|
|
150
|
+
* to a designer as "I set this and the app ignored it", the same failure the
|
|
151
|
+
* bounds table exists to prevent.
|
|
152
|
+
*/
|
|
153
|
+
pose(now: MonotonicMs, periodMs?: number): IdlePose;
|
|
154
|
+
/**
|
|
155
|
+
* Abandon any blink in progress and re-arm.
|
|
156
|
+
*
|
|
157
|
+
* Called when idle is switched off and on again. Without it a blink frozen
|
|
158
|
+
* half-closed at the moment idle stopped would resume from the middle, which
|
|
159
|
+
* looks like a twitch.
|
|
160
|
+
*/
|
|
161
|
+
reset(now: MonotonicMs): void;
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=idle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idle.d.ts","sourceRoot":"","sources":["../../src/core/idle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAElD,eAAO,MAAM,gBAAgB,OAAO,CAAA;AAEpC;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,OAAO,CAAA;AACpC,eAAO,MAAM,gBAAgB,OAAO,CAAA;AAEpC,sDAAsD;AACtD,eAAO,MAAM,iBAAiB,MAAM,CAAA;AA0BpC,MAAM,WAAW,QAAQ;IACvB,mCAAmC;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,KAAK;aAAK,IAAI,EAAE,IAAI;aAAE,KAAK,EAAE,KAAK;aAAE,IAAI,EAAE,KAAK;CAAW,CAAA;AAuBvE,iDAAiD;AACjD,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAaD;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,SAAI,GAAG,KAAK,CAU1D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,WAAW,EAAE,QAAQ,GAAE,MAAyB,GAAG,MAAM,CAYtF;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CACrB,GAAG,EAAE,WAAW,EAChB,SAAS,EAAE,WAAW,EACtB,UAAU,GAAE,MAA0B,GACrC,MAAM,CAQR;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,MAAM,GAAG,MAAM,CAMzD;AAED;;;;;;GAMG;AACH,qBAAa,SAAS;IAMlB,OAAO,CAAC,QAAQ,CAAC,MAAM;IALzB,OAAO,CAAC,WAAW,CAAa;IAChC,OAAO,CAAC,cAAc,CAA2B;IAEjD,YACE,SAAS,EAAE,WAAW,EACL,MAAM,GAAE,MAAM,MAAoB,EAMpD;IAED;;;;;;;;;OASG;IACH,IAAI,CAAC,GAAG,EAAE,WAAW,EAAE,QAAQ,GAAE,MAAyB,GAAG,QAAQ,CAsBpE;IAED;;;;;;OAMG;IACH,KAAK,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAG5B;CACF"}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The idle layer: breathing, blinking, and the small motion that separates
|
|
3
|
+
* "alive" from "a picture that sometimes moves".
|
|
4
|
+
*
|
|
5
|
+
* Pure functions of time. The backend asks what the idle layer wants at a
|
|
6
|
+
* given instant and gets an answer; nothing here holds a frame loop, a clock,
|
|
7
|
+
* or a random number generator it did not receive. That is what makes a blink
|
|
8
|
+
* schedule testable at all -- the alternative is a test that waits.
|
|
9
|
+
*
|
|
10
|
+
* Ported unchanged from mochi. The asymmetric blink and the Poisson gap are
|
|
11
|
+
* both experiment, not preference, and re-deriving them would mean re-watching
|
|
12
|
+
* a metronome blink for thirty seconds to rediscover why it looks wrong.
|
|
13
|
+
*/
|
|
14
|
+
export const BREATH_PERIOD_MS = 3400;
|
|
15
|
+
/**
|
|
16
|
+
* Blink gap bounds.
|
|
17
|
+
*
|
|
18
|
+
* Drawn from an exponential distribution rather than picked uniformly: real
|
|
19
|
+
* blinks are a Poisson process, and a uniform gap reads as a metronome within
|
|
20
|
+
* about thirty seconds of watching. The bounds clamp the tail -- an exponential
|
|
21
|
+
* will happily produce a four-second gap or a ninety-second one, and both look
|
|
22
|
+
* wrong.
|
|
23
|
+
*/
|
|
24
|
+
export const BLINK_MIN_GAP_MS = 2200;
|
|
25
|
+
export const BLINK_MAX_GAP_MS = 6200;
|
|
26
|
+
/** How long the lid takes to close and open again. */
|
|
27
|
+
export const BLINK_DURATION_MS = 130;
|
|
28
|
+
/**
|
|
29
|
+
* Fraction of the blink spent closing, the rest opening.
|
|
30
|
+
*
|
|
31
|
+
* Not symmetric: a real eyelid shuts faster than it opens, and matching that
|
|
32
|
+
* is most of what makes a blink read as one rather than as a flicker.
|
|
33
|
+
*/
|
|
34
|
+
const BLINK_CLOSE_FRACTION = 0.35;
|
|
35
|
+
/**
|
|
36
|
+
* Fraction of a breath spent inhaling, the rest exhaling.
|
|
37
|
+
*
|
|
38
|
+
* Not symmetric, for the reason `BLINK_CLOSE_FRACTION` is not, and the
|
|
39
|
+
* mechanism is the same shape: inspiration is muscular and expiration is
|
|
40
|
+
* elastic recoil, so a resting breath goes in faster than it comes out. Around
|
|
41
|
+
* 1:1.5 at rest, which is this number.
|
|
42
|
+
*
|
|
43
|
+
* The gentle end of the plausible range, deliberately. A blink is over in 130ms
|
|
44
|
+
* and has to be sharp to read at all; a breath runs for 3.4 SECONDS, so the
|
|
45
|
+
* asymmetry has all the time it needs, and pushing it further makes the inhale
|
|
46
|
+
* read as a gasp -- an event, which is exactly what an idle layer must not
|
|
47
|
+
* produce.
|
|
48
|
+
*/
|
|
49
|
+
const BREATH_IN_FRACTION = 0.4;
|
|
50
|
+
/**
|
|
51
|
+
* How far the drift may take her, in fractions of her body.
|
|
52
|
+
*
|
|
53
|
+
* ## Small on purpose, and this is the whole point of it
|
|
54
|
+
*
|
|
55
|
+
* A companion who is alive is not a companion who goes anywhere. These are a
|
|
56
|
+
* few pixels at her drawn size -- `shift` is about 2.6px on a 94px body -- and
|
|
57
|
+
* that is the intended reading: somebody standing still, shifting their weight,
|
|
58
|
+
* rather than somebody pacing.
|
|
59
|
+
*
|
|
60
|
+
* The clips are the loud half of the vocabulary and they were tuned louder
|
|
61
|
+
* still after a first complaint that read as "too small". It was not: the
|
|
62
|
+
* motions were coarse because they were the ONLY thing moving, so she was a
|
|
63
|
+
* still image punctuated by gestures. Continuous micro-motion is what a still
|
|
64
|
+
* image is missing, and once it exists the gestures can be quiet again.
|
|
65
|
+
*/
|
|
66
|
+
export const DRIFT = { lean: 0.02, shift: 0.026, lift: 0.009 };
|
|
67
|
+
/**
|
|
68
|
+
* Periods that do not divide each other, in milliseconds.
|
|
69
|
+
*
|
|
70
|
+
* Three sines summed per channel, at spans chosen to be mutually incommensurate
|
|
71
|
+
* -- their least common multiple is hours, so the pattern never visibly
|
|
72
|
+
* repeats. One sine per channel would be a metronome, which is the same defect
|
|
73
|
+
* `BLINK_MIN_GAP_MS` exists to avoid and reads the same way: after about half a
|
|
74
|
+
* minute the eye locks onto the period and she stops looking alive.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately NOT random. A random walk needs state, cannot be asked "where
|
|
77
|
+
* are you at time t", and makes a frame a function of every frame before it --
|
|
78
|
+
* so a throttled window comes back somewhere else entirely. Everything in this
|
|
79
|
+
* file is a pure function of the clock, and that is what makes it testable
|
|
80
|
+
* without waiting.
|
|
81
|
+
*/
|
|
82
|
+
const DRIFT_MS = {
|
|
83
|
+
lean: [11_300, 6_700, 19_900],
|
|
84
|
+
shift: [13_700, 8_300, 23_100],
|
|
85
|
+
lift: [5_900, 9_700, 17_300],
|
|
86
|
+
};
|
|
87
|
+
function wobble(now, periods) {
|
|
88
|
+
// Weighted so the longest span carries the movement and the shortest only
|
|
89
|
+
// roughens it. Equal weights read as a tremor rather than as a body.
|
|
90
|
+
const weights = [0.6, 0.28, 0.12];
|
|
91
|
+
let sum = 0;
|
|
92
|
+
for (const [index, period] of periods.entries()) {
|
|
93
|
+
sum += (weights[index] ?? 0) * Math.sin((2 * Math.PI * now) / period);
|
|
94
|
+
}
|
|
95
|
+
return sum;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The continuous, going-nowhere motion of somebody who is simply present.
|
|
99
|
+
*
|
|
100
|
+
* `scale` is how much of it applies -- 1 awake, less asleep. Sleep keeps a
|
|
101
|
+
* trace rather than none for the reason the breath is kept: a companion who
|
|
102
|
+
* stops moving altogether reads as a crash, which is the one thing the resting
|
|
103
|
+
* state must not look like.
|
|
104
|
+
*/
|
|
105
|
+
export function driftAt(now, scale = 1) {
|
|
106
|
+
if (!Number.isFinite(now) || !Number.isFinite(scale))
|
|
107
|
+
return { lean: 0, shift: 0, lift: 0 };
|
|
108
|
+
const k = Math.max(0, Math.min(1, scale));
|
|
109
|
+
return {
|
|
110
|
+
lean: DRIFT.lean * k * wobble(now, DRIFT_MS.lean),
|
|
111
|
+
shift: DRIFT.shift * k * wobble(now, DRIFT_MS.shift),
|
|
112
|
+
// Never below the ground: a bob that dipped would put her feet through the
|
|
113
|
+
// surface she is standing on. Half above the line, oscillating about it.
|
|
114
|
+
lift: Math.max(0, DRIFT.lift * k * (0.5 + 0.5 * wobble(now, DRIFT_MS.lift))),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The breath, as a fraction of a full inhale. 0 at rest, 1 fully inhaled.
|
|
119
|
+
*
|
|
120
|
+
* Raised cosines rather than ramps: the turnaround at each end is gradual, and
|
|
121
|
+
* a linear ramp gives the body a visible tick at the top. One cycle per period,
|
|
122
|
+
* starting and ending at rest, peaking at `BREATH_IN_FRACTION` of the way
|
|
123
|
+
* through.
|
|
124
|
+
*
|
|
125
|
+
* TWO half-cosines rather than one whole one, because the halves are different
|
|
126
|
+
* lengths -- see `BREATH_IN_FRACTION`. Each meets the other with zero slope, at
|
|
127
|
+
* the peak and at rest alike, so neither join is visible; at a fraction of 0.5
|
|
128
|
+
* the pair collapses exactly to the single cosine this replaced.
|
|
129
|
+
*
|
|
130
|
+
* ## ONE-SIDED, and that is the point of it
|
|
131
|
+
*
|
|
132
|
+
* This was a plain sine over -1..1, and the negative half was the defect. The
|
|
133
|
+
* breath is summed into `squash` (see `mochi.ts`), which is an area-preserving
|
|
134
|
+
* trade: positive spreads her, negative stretches her taller and NARROWER. So
|
|
135
|
+
* for half of every cycle she wore a fraction of the same deformation `sad`
|
|
136
|
+
* (-0.11) and `surprised` (-0.16) are made of -- measured at 42% of `sad`'s
|
|
137
|
+
* crown-sharpening at the extreme, on the same axis.
|
|
138
|
+
*
|
|
139
|
+
* That reads as her head going pointy on a 3.4-second loop, and it corresponds
|
|
140
|
+
* to nothing a body does: inhaling expands, exhaling returns to rest. Nothing
|
|
141
|
+
* makes you taller and thinner than resting.
|
|
142
|
+
*
|
|
143
|
+
* The crown is where it showed first because it sharpens FASTER than the body
|
|
144
|
+
* lengthens -- width a fixed distance below the apex scales as
|
|
145
|
+
* `f^(1 + 1/upperShoulder)`, about `f^1.54` at the tuned 1.86, against `f^-1`
|
|
146
|
+
* for the height. Halving the amplitude alone would therefore not have fixed
|
|
147
|
+
* it, only slowed it down; removing the negative half does, by construction.
|
|
148
|
+
*
|
|
149
|
+
* The consequence worth stating: the silhouette measured against
|
|
150
|
+
* `rig/__fixtures__/mochi-icon.png` is now a FLOOR she returns to once per
|
|
151
|
+
* cycle, rather than a midpoint she is above half the time.
|
|
152
|
+
*/
|
|
153
|
+
export function breathAt(now, periodMs = BREATH_PERIOD_MS) {
|
|
154
|
+
if (!Number.isFinite(now) || !(periodMs > 0))
|
|
155
|
+
return 0;
|
|
156
|
+
// Wrapped into 0..1 rather than fed straight to a trig function, because the
|
|
157
|
+
// curve is piecewise now and the branch is chosen by phase. `%` keeps the
|
|
158
|
+
// sign of its left operand in JS, so the second modulo is what stops a
|
|
159
|
+
// negative timestamp landing in the wrong half.
|
|
160
|
+
const phase = (((now % periodMs) + periodMs) % periodMs) / periodMs;
|
|
161
|
+
const rising = phase < BREATH_IN_FRACTION;
|
|
162
|
+
const t = rising
|
|
163
|
+
? phase / BREATH_IN_FRACTION
|
|
164
|
+
: (phase - BREATH_IN_FRACTION) / (1 - BREATH_IN_FRACTION);
|
|
165
|
+
return rising ? (1 - Math.cos(Math.PI * t)) / 2 : (1 + Math.cos(Math.PI * t)) / 2;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Lid closure during a blink that began at `startedAt`.
|
|
169
|
+
*
|
|
170
|
+
* Returns 0 outside the blink, so a caller can ask unconditionally. The
|
|
171
|
+
* envelope is continuous at every boundary -- at elapsed 0 the rising ramp is
|
|
172
|
+
* already 0, at `closeMs` both branches give 1, at `durationMs` the falling
|
|
173
|
+
* ramp has reached 0 -- so a blink cannot clip at its edges.
|
|
174
|
+
*/
|
|
175
|
+
export function blinkAt(now, startedAt, durationMs = BLINK_DURATION_MS) {
|
|
176
|
+
if (!Number.isFinite(now) || !Number.isFinite(startedAt) || !(durationMs > 0))
|
|
177
|
+
return 0;
|
|
178
|
+
const elapsed = now - startedAt;
|
|
179
|
+
if (elapsed < 0 || elapsed > durationMs)
|
|
180
|
+
return 0;
|
|
181
|
+
const closeMs = durationMs * BLINK_CLOSE_FRACTION;
|
|
182
|
+
if (elapsed <= closeMs)
|
|
183
|
+
return elapsed / closeMs;
|
|
184
|
+
return 1 - (elapsed - closeMs) / (durationMs - closeMs);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The gap until the next blink.
|
|
188
|
+
*
|
|
189
|
+
* `random` is injected rather than taken from Math.random so a test can pin the
|
|
190
|
+
* schedule. An exponential mapped through the clamp: most gaps land in the
|
|
191
|
+
* first half of the range, which is what makes the rhythm feel unplanned.
|
|
192
|
+
*/
|
|
193
|
+
export function nextBlinkGap(random) {
|
|
194
|
+
const uniform = random();
|
|
195
|
+
const bounded = Number.isFinite(uniform) ? Math.min(1, Math.max(0, uniform)) : 0.5;
|
|
196
|
+
const exponential = -Math.log(1 - bounded * 0.99);
|
|
197
|
+
const span = BLINK_MAX_GAP_MS - BLINK_MIN_GAP_MS;
|
|
198
|
+
return BLINK_MIN_GAP_MS + Math.min(span, (exponential / 4.6) * span);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The idle layer, holding only the blink schedule.
|
|
202
|
+
*
|
|
203
|
+
* Deliberately not a frame loop: it is asked what it wants and answers. The
|
|
204
|
+
* backend owns time, which is the only way the same code can run in a render
|
|
205
|
+
* loop and in a test that advances a number.
|
|
206
|
+
*/
|
|
207
|
+
export class IdleLayer {
|
|
208
|
+
random;
|
|
209
|
+
nextBlinkAt;
|
|
210
|
+
blinkStartedAt = null;
|
|
211
|
+
constructor(startedAt, random = Math.random) {
|
|
212
|
+
this.random = random;
|
|
213
|
+
// Scheduled from the caller's clock, not from zero: seeding from 0 would
|
|
214
|
+
// put the first blink in the past whenever startup takes longer than the
|
|
215
|
+
// minimum gap, and she would blink the instant she appeared.
|
|
216
|
+
this.nextBlinkAt = startedAt + nextBlinkGap(random);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* `periodMs` is a PARAMETER rather than a constant read from this module.
|
|
220
|
+
*
|
|
221
|
+
* `FaceSpec.breathMs` is a validated, bounded, tuner-exposed field, and for a
|
|
222
|
+
* while nothing read it: this method called `breathAt(now)` and took the
|
|
223
|
+
* module default. The two happened to agree at 3400, so every gate stayed
|
|
224
|
+
* green and the only symptom was a slider that did nothing -- which presents
|
|
225
|
+
* to a designer as "I set this and the app ignored it", the same failure the
|
|
226
|
+
* bounds table exists to prevent.
|
|
227
|
+
*/
|
|
228
|
+
pose(now, periodMs = BREATH_PERIOD_MS) {
|
|
229
|
+
if (!Number.isFinite(now))
|
|
230
|
+
return { blink: 0, breath: 0 };
|
|
231
|
+
if (this.blinkStartedAt === null && now >= this.nextBlinkAt) {
|
|
232
|
+
// From the SCHEDULED time, not from now. With sparse frames -- a
|
|
233
|
+
// backgrounded window renders at 1fps -- starting from `now` slides every
|
|
234
|
+
// blink later by however long the gap between frames was, and the rhythm
|
|
235
|
+
// drifts. Starting from the schedule means a blink whose whole duration
|
|
236
|
+
// fell between two frames is simply not seen, which is correct: you
|
|
237
|
+
// cannot render what you had no frames for.
|
|
238
|
+
this.blinkStartedAt = this.nextBlinkAt;
|
|
239
|
+
}
|
|
240
|
+
let blink = 0;
|
|
241
|
+
if (this.blinkStartedAt !== null) {
|
|
242
|
+
blink = blinkAt(now, this.blinkStartedAt);
|
|
243
|
+
if (now - this.blinkStartedAt > BLINK_DURATION_MS) {
|
|
244
|
+
this.blinkStartedAt = null;
|
|
245
|
+
this.nextBlinkAt = now + nextBlinkGap(this.random);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { blink, breath: breathAt(now, periodMs) };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Abandon any blink in progress and re-arm.
|
|
252
|
+
*
|
|
253
|
+
* Called when idle is switched off and on again. Without it a blink frozen
|
|
254
|
+
* half-closed at the moment idle stopped would resume from the middle, which
|
|
255
|
+
* looks like a twitch.
|
|
256
|
+
*/
|
|
257
|
+
reset(now) {
|
|
258
|
+
this.blinkStartedAt = null;
|
|
259
|
+
this.nextBlinkAt = (Number.isFinite(now) ? now : 0) + nextBlinkGap(this.random);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=idle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idle.js","sourceRoot":"","sources":["../../src/core/idle.ts"],"names":[],"mappings":"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"}
|