@lemoncat7/dsh-theme-xiaohei 0.3.3 → 0.3.4
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 +5 -5
- package/lib/chrome/sidebar.d.ts +1 -1
- package/lib/chrome/sidebar.js +13 -15
- package/lib/chrome/tokens.d.ts +1 -1
- package/lib/chrome/tokens.js +4 -4
- package/lib/client.js +383 -55
- package/lib/metallic-brand-mark.js +2 -2
- package/lib/scene/character-expression.d.ts +4 -0
- package/lib/scene/character-expression.js +15 -0
- package/lib/scene/character-eye-material.d.ts +13 -0
- package/lib/scene/character-eye-material.js +88 -0
- package/lib/scene/character-eye-renderer.d.ts +5 -0
- package/lib/scene/character-eye-renderer.js +80 -0
- package/lib/scene/character-eye-rig.d.ts +20 -0
- package/lib/scene/character-eye-rig.js +35 -0
- package/lib/scene/character-gaze.d.ts +16 -0
- package/lib/scene/character-gaze.js +36 -0
- package/lib/scene/character-motion-clock.d.ts +1 -1
- package/lib/scene/character-motion-clock.js +17 -4
- package/lib/scene/character-motion.js +66 -12
- package/lib/scene/character-rig-renderer.d.ts +3 -2
- package/lib/scene/character-rig-renderer.js +28 -18
- package/lib/scene/character-rig.d.ts +2 -0
- package/lib/scene/character-rig.js +9 -1
- package/lib/sidebar-reveal.js +11 -3
- package/package.json +1 -1
|
@@ -285,12 +285,12 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
285
285
|
const geometry = {
|
|
286
286
|
'--xiaohei-brand-mark-size': `${visualSize}px`,
|
|
287
287
|
};
|
|
288
|
-
return (_jsxs("span", { className: className === undefined ? 'xiaohei-brand-mark' : `${className} xiaohei-brand-mark`, "data-brand-context": context, "data-metallic-ready": ready || snapshot !== undefined ? 'true' : 'false', style: geometry, "aria-hidden": "true", children: [_jsx("img", { className: "xiaohei-brand-mark__fallback", src: XIAOHEI_BRAND_AVATAR, alt: "" }), snapshot !== undefined
|
|
288
|
+
return (_jsxs("span", { className: className === undefined ? 'xiaohei-brand-mark' : `${className} xiaohei-brand-mark`, "data-brand-context": context, "data-xiaohei-sidebar-brand": context === 'sidebar' ? '' : undefined, "data-metallic-ready": ready || snapshot !== undefined ? 'true' : 'false', style: geometry, "aria-hidden": "true", children: [_jsx("img", { className: "xiaohei-brand-mark__fallback", src: XIAOHEI_BRAND_AVATAR, alt: "" }), snapshot !== undefined
|
|
289
289
|
? _jsx("img", { className: "xiaohei-brand-mark__metal", src: snapshot, alt: "" })
|
|
290
290
|
: _jsx("canvas", { className: "xiaohei-brand-mark__metal", ref: canvasRef })] }));
|
|
291
291
|
}
|
|
292
292
|
/** Official sidebar brand-name occupant paired with the Xiaohei mark. */
|
|
293
293
|
export function XiaoheiBrandName() {
|
|
294
|
-
return _jsx("span", { className: "xiaohei-brand-name", children: "\u7F57\u5C0F\u9ED1 \u00B7 \u5996\u7075\u4F1A\u9986" });
|
|
294
|
+
return _jsx("span", { className: "xiaohei-brand-name", "data-xiaohei-sidebar-brand": "", children: "\u7F57\u5C0F\u9ED1 \u00B7 \u5996\u7075\u4F1A\u9986" });
|
|
295
295
|
}
|
|
296
296
|
//# sourceMappingURL=metallic-brand-mark.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const smooth = (t) => { const x = Math.max(0, Math.min(1, t)); return x * x * (3 - 2 * x); };
|
|
2
|
+
/** Fast closure, short hold, relaxed opening; zero velocity at each join. */
|
|
3
|
+
export function blinkClosure(progress) {
|
|
4
|
+
if (progress <= 0 || progress >= 1)
|
|
5
|
+
return 0;
|
|
6
|
+
if (progress < .3)
|
|
7
|
+
return smooth(progress / .3);
|
|
8
|
+
if (progress < .4)
|
|
9
|
+
return 1;
|
|
10
|
+
return 1 - smooth((progress - .4) / .6);
|
|
11
|
+
}
|
|
12
|
+
export function attentionBlink(progress) {
|
|
13
|
+
return blinkClosure((progress - .12) / .16);
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=character-expression.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EyeRig } from './character-eye-rig.js';
|
|
2
|
+
/** Quadratic lid ordinate at a horizontal art-space coordinate. */
|
|
3
|
+
export declare function lidY(eye: EyeRig, control: readonly [number, number], x: number): number;
|
|
4
|
+
/** Cache original ink and a locally textured skin plate, never read pixels per frame. */
|
|
5
|
+
export declare function prepareEyeMaterial(doc: Document, source: CanvasRenderingContext2D, eye: EyeRig): {
|
|
6
|
+
ink: HTMLCanvasElement;
|
|
7
|
+
skin: HTMLCanvasElement;
|
|
8
|
+
left: number;
|
|
9
|
+
top: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=character-eye-material.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Quadratic lid ordinate at a horizontal art-space coordinate. */
|
|
2
|
+
export function lidY(eye, control, x) {
|
|
3
|
+
let low = 0, high = 1;
|
|
4
|
+
for (let i = 0; i < 14; i++) {
|
|
5
|
+
const t = (low + high) / 2, s = 1 - t;
|
|
6
|
+
if (s * s * eye.left[0] + 2 * s * t * control[0] + t * t * eye.right[0] < x)
|
|
7
|
+
low = t;
|
|
8
|
+
else
|
|
9
|
+
high = t;
|
|
10
|
+
}
|
|
11
|
+
const t = (low + high) / 2, s = 1 - t;
|
|
12
|
+
return s * s * eye.left[1] + 2 * s * t * control[1] + t * t * eye.right[1];
|
|
13
|
+
}
|
|
14
|
+
/** Cache original ink and a locally textured skin plate, never read pixels per frame. */
|
|
15
|
+
export function prepareEyeMaterial(doc, source, eye) {
|
|
16
|
+
const left = Math.floor(eye.left[0] - 9), right = Math.ceil(eye.right[0] + 9);
|
|
17
|
+
const top = Math.floor(Math.min(eye.upper[1], eye.left[1], eye.right[1]) - 5);
|
|
18
|
+
const bottom = Math.ceil(Math.max(eye.lower[1], eye.left[1], eye.right[1]) + 5);
|
|
19
|
+
const width = right - left, height = bottom - top;
|
|
20
|
+
const canvas = () => { const c = doc.createElement('canvas'); c.width = width; c.height = height; return c; };
|
|
21
|
+
const ink = canvas(), skin = canvas(), mask = canvas();
|
|
22
|
+
const ic = ink.getContext('2d'), sc = skin.getContext('2d'), mc = mask.getContext('2d');
|
|
23
|
+
const pixels = source.getImageData(0, 0, source.canvas.width, source.canvas.height);
|
|
24
|
+
const pixel = (x, y) => {
|
|
25
|
+
const offset = (Math.max(0, Math.min(pixels.height - 1, Math.round(y / 2))) * pixels.width + Math.max(0, Math.min(pixels.width - 1, Math.round(x / 2)))) * 4;
|
|
26
|
+
return [pixels.data[offset], pixels.data[offset + 1], pixels.data[offset + 2]];
|
|
27
|
+
};
|
|
28
|
+
const ref = pixel(...eye.skin), output = ic.createImageData(width, height), plate = sc.createImageData(width, height);
|
|
29
|
+
const isSkin = (c) => c.every((v, i) => Math.abs(v - ref[i]) < 25);
|
|
30
|
+
const skinAt = (x, y, c) => {
|
|
31
|
+
if (isSkin(c))
|
|
32
|
+
return c;
|
|
33
|
+
const sum = [ref[0] * .05, ref[1] * .05, ref[2] * .05];
|
|
34
|
+
let weight = .05;
|
|
35
|
+
// Inpaint from nearby matching face pixels, preserving local lighting.
|
|
36
|
+
// Source hair, green iris and dark ink cannot bleed into the skin plate.
|
|
37
|
+
for (const radius of [6, 12, 20, 30])
|
|
38
|
+
for (let i = 0; i < 8; i++) {
|
|
39
|
+
const p = pixel(x + Math.cos(i * Math.PI / 4) * radius, y + Math.sin(i * Math.PI / 4) * radius);
|
|
40
|
+
if (!isSkin(p))
|
|
41
|
+
continue;
|
|
42
|
+
const w = 1 / radius;
|
|
43
|
+
weight += w;
|
|
44
|
+
for (let k = 0; k < 3; k++)
|
|
45
|
+
sum[k] += p[k] * w;
|
|
46
|
+
}
|
|
47
|
+
return sum.map(v => Math.round(v / weight));
|
|
48
|
+
};
|
|
49
|
+
for (let x = 0; x < width; x++) {
|
|
50
|
+
const ax = left + x, upper = lidY(eye, eye.upper, ax);
|
|
51
|
+
// Restrict extraction to the upper lash: exclude eyebrow, pupil and hair.
|
|
52
|
+
for (let y = 0; y < height; y++) {
|
|
53
|
+
const ay = top + y, c = pixel(ax, ay), index = (y * width + x) * 4;
|
|
54
|
+
const darkness = 1 - (c[0] + c[1] + c[2]) / Math.max(1, ref[0] + ref[1] + ref[2]);
|
|
55
|
+
const green = c[1] > c[0] * 1.15 && c[1] > c[2] * 1.12;
|
|
56
|
+
const inBand = ay >= upper - 12 && ay <= upper + 3;
|
|
57
|
+
const alpha = inBand && !green ? Math.max(0, Math.min(1, (darkness - .3) / .35)) : 0;
|
|
58
|
+
output.data.set([...c, Math.round(alpha * 255)], index);
|
|
59
|
+
plate.data.set([...skinAt(ax, ay, c), 255], index);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
ic.putImageData(output, 0, 0);
|
|
63
|
+
sc.putImageData(plate, 0, 0);
|
|
64
|
+
// Opaque coverage of extracted ink, including its antialiased fringe. Using
|
|
65
|
+
// the ink's original alpha here leaves a second, stationary lash behind.
|
|
66
|
+
const inkMask = mc.createImageData(width, height);
|
|
67
|
+
for (let i = 0; i < output.data.length; i += 4)
|
|
68
|
+
inkMask.data[i + 3] = output.data[i + 3] > 4 ? 255 : 0;
|
|
69
|
+
mc.putImageData(inkMask, 0, 0);
|
|
70
|
+
const expanded = canvas(), ec = expanded.getContext('2d');
|
|
71
|
+
for (const [dx, dy] of [[0, 0], [-1, 0], [1, 0], [0, -1], [0, 1]])
|
|
72
|
+
ec.drawImage(mask, dx, dy);
|
|
73
|
+
mc.drawImage(expanded, 0, 0);
|
|
74
|
+
mc.translate(-left, -top);
|
|
75
|
+
mc.beginPath();
|
|
76
|
+
mc.moveTo(eye.left[0] - 7, eye.left[1]);
|
|
77
|
+
mc.quadraticCurveTo(eye.upper[0], eye.upper[1] - 4, eye.right[0] + 7, eye.right[1]);
|
|
78
|
+
mc.quadraticCurveTo(eye.lower[0], eye.lower[1] + 20, eye.left[0] - 7, eye.left[1]);
|
|
79
|
+
mc.closePath();
|
|
80
|
+
mc.fill();
|
|
81
|
+
// Feather only a subpixel edge, not the eye or the whole character.
|
|
82
|
+
sc.globalCompositeOperation = 'destination-in';
|
|
83
|
+
sc.filter = 'blur(0.65px)';
|
|
84
|
+
sc.drawImage(mask, 0, 0);
|
|
85
|
+
sc.filter = 'none';
|
|
86
|
+
return { ink, skin, left, top, width, height };
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=character-eye-material.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type EyeRig } from './character-eye-rig.js';
|
|
2
|
+
import type { Point } from './character-motion-types.js';
|
|
3
|
+
/** Prepare tiny iris surfaces once. Never shift a rectangular face/eye patch. */
|
|
4
|
+
export declare function createEyeRenderer(ctx: CanvasRenderingContext2D, body: HTMLImageElement, eyes: readonly EyeRig[]): (gaze: Point, closure: number) => void;
|
|
5
|
+
//# sourceMappingURL=character-eye-renderer.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { clampGaze, eyelidControls } from './character-eye-rig.js';
|
|
2
|
+
import { lidY, prepareEyeMaterial } from './character-eye-material.js';
|
|
3
|
+
/** Prepare tiny iris surfaces once. Never shift a rectangular face/eye patch. */
|
|
4
|
+
export function createEyeRenderer(ctx, body, eyes) {
|
|
5
|
+
const doc = ctx.canvas.ownerDocument;
|
|
6
|
+
const source = doc.createElement('canvas');
|
|
7
|
+
source.width = body.width;
|
|
8
|
+
source.height = body.height;
|
|
9
|
+
const sample = source.getContext('2d', { willReadFrequently: true });
|
|
10
|
+
sample.drawImage(body, 0, 0);
|
|
11
|
+
const color = (p) => {
|
|
12
|
+
const data = sample.getImageData(Math.round(p[0] / 2), Math.round(p[1] / 2), 1, 1).data;
|
|
13
|
+
return [data[0], data[1], data[2]];
|
|
14
|
+
};
|
|
15
|
+
const rgb = (c) => `rgb(${c.map(v => Math.round(v)).join(' ')})`;
|
|
16
|
+
const prepared = eyes.map(eye => {
|
|
17
|
+
const [cx, cy, rx, ry, angle] = eye.iris;
|
|
18
|
+
// Full-size local surface preserves source texels when moved fractionally.
|
|
19
|
+
const iris = doc.createElement('canvas');
|
|
20
|
+
iris.width = Math.ceil(rx * 2 + 4);
|
|
21
|
+
iris.height = Math.ceil(ry * 2 + 4);
|
|
22
|
+
const ic = iris.getContext('2d');
|
|
23
|
+
ic.translate(iris.width / 2, iris.height / 2);
|
|
24
|
+
ic.rotate(angle);
|
|
25
|
+
ic.beginPath();
|
|
26
|
+
ic.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
|
|
27
|
+
ic.clip();
|
|
28
|
+
ic.rotate(-angle);
|
|
29
|
+
ic.drawImage(body, 0, 0, body.width, body.height, -cx, -cy, body.width * 2, body.height * 2);
|
|
30
|
+
return { eye, iris, skin: color(eye.skin), material: prepareEyeMaterial(doc, sample, eye) };
|
|
31
|
+
});
|
|
32
|
+
function aperture(eye, closure) {
|
|
33
|
+
const { upper, lower, left, right } = eyelidControls(eye, closure);
|
|
34
|
+
ctx.beginPath();
|
|
35
|
+
ctx.moveTo(...left);
|
|
36
|
+
ctx.quadraticCurveTo(...upper, ...right);
|
|
37
|
+
ctx.quadraticCurveTo(...lower, ...left);
|
|
38
|
+
ctx.closePath();
|
|
39
|
+
}
|
|
40
|
+
return (gaze, closure) => {
|
|
41
|
+
const [gx, gy] = clampGaze(...gaze);
|
|
42
|
+
ctx.save();
|
|
43
|
+
ctx.scale(.5, .5);
|
|
44
|
+
for (const { eye, iris, skin, material } of prepared) {
|
|
45
|
+
const [cx, cy, rx, ry] = eye.iris;
|
|
46
|
+
ctx.save();
|
|
47
|
+
ctx.drawImage(material.skin, material.left, material.top);
|
|
48
|
+
ctx.save();
|
|
49
|
+
aperture(eye, 0);
|
|
50
|
+
ctx.clip();
|
|
51
|
+
if (closure < .999) {
|
|
52
|
+
ctx.save();
|
|
53
|
+
aperture(eye, closure);
|
|
54
|
+
ctx.clip();
|
|
55
|
+
const shade = ctx.createLinearGradient(0, cy - ry, 0, cy + ry);
|
|
56
|
+
const brightness = (skin[0] + skin[1] + skin[2]) / 765;
|
|
57
|
+
const white = brightness > .65 ? [255, 253, 241] : [166, 185, 193];
|
|
58
|
+
shade.addColorStop(0, rgb(white.map(c => c * .83)));
|
|
59
|
+
shade.addColorStop(.5, rgb(white));
|
|
60
|
+
ctx.fillStyle = shade;
|
|
61
|
+
ctx.fillRect(cx - 70, cy - 70, 140, 140);
|
|
62
|
+
const dx = gx * rx * .26, dy = gy * ry * .13;
|
|
63
|
+
ctx.drawImage(iris, cx - iris.width / 2 + dx, cy - iris.height / 2 + dy);
|
|
64
|
+
ctx.restore();
|
|
65
|
+
}
|
|
66
|
+
ctx.restore();
|
|
67
|
+
const lids = eyelidControls(eye, closure);
|
|
68
|
+
// Move narrow strips of the original brushwork, preserving lash thickness.
|
|
69
|
+
// Neither a replacement stroke nor an opacity swap is used for blinking.
|
|
70
|
+
for (let x = 0; x < material.width; x++) {
|
|
71
|
+
const ax = material.left + x;
|
|
72
|
+
const dy = lidY({ ...eye, left: lids.left, right: lids.right }, lids.upper, ax) - lidY(eye, eye.upper, ax);
|
|
73
|
+
ctx.drawImage(material.ink, x, 0, 1, material.height, ax, material.top + dy, 1, material.height);
|
|
74
|
+
}
|
|
75
|
+
ctx.restore();
|
|
76
|
+
}
|
|
77
|
+
ctx.restore();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=character-eye-renderer.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Point } from './character-motion-types.js';
|
|
2
|
+
/** Landmarks in the approved full-resolution artwork, not screen coordinates. */
|
|
3
|
+
export interface EyeRig {
|
|
4
|
+
left: Point;
|
|
5
|
+
right: Point;
|
|
6
|
+
upper: Point;
|
|
7
|
+
lower: Point;
|
|
8
|
+
iris: readonly [number, number, number, number, number];
|
|
9
|
+
skin: Point;
|
|
10
|
+
}
|
|
11
|
+
export declare const EYE_RIGS: Readonly<Record<string, readonly EyeRig[]>>;
|
|
12
|
+
export declare function clampGaze(x: number, y: number): Point;
|
|
13
|
+
/** Both lids meet on the same gently curved lash line, never a rectangular wipe. */
|
|
14
|
+
export declare function eyelidControls(eye: EyeRig, closure: number): {
|
|
15
|
+
upper: Point;
|
|
16
|
+
lower: Point;
|
|
17
|
+
left: Point;
|
|
18
|
+
right: Point;
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=character-eye-rig.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const EYE_RIGS = {
|
|
2
|
+
seated: [
|
|
3
|
+
{ left: [285, 223], right: [326, 224], upper: [302, 188], lower: [310, 276], iris: [309, 230, 13, 20, 0], skin: [315, 258] },
|
|
4
|
+
{ left: [373, 221], right: [421, 226], upper: [387, 186], lower: [393, 277], iris: [393, 228, 15, 22, 0], skin: [391, 261] },
|
|
5
|
+
],
|
|
6
|
+
peek: [
|
|
7
|
+
{ left: [80, 214], right: [119, 221], upper: [103, 186], lower: [91, 267], iris: [102, 225, 14, 20, .25], skin: [110, 253] },
|
|
8
|
+
{ left: [171, 252], right: [210, 265], upper: [195, 221], lower: [175, 300], iris: [186, 260, 15, 20, .4], skin: [186, 288] },
|
|
9
|
+
],
|
|
10
|
+
chin: [
|
|
11
|
+
{ left: [252, 252], right: [290, 247], upper: [266, 223], lower: [287, 297], iris: [278, 258, 14, 19, -.3], skin: [291, 285] },
|
|
12
|
+
{ left: [339, 234], right: [387, 227], upper: [358, 196], lower: [365, 280], iris: [361, 235, 15, 21, -.25], skin: [367, 269] },
|
|
13
|
+
],
|
|
14
|
+
halfpeek: [
|
|
15
|
+
{ left: [89, 390], right: [159, 395], upper: [116, 337], lower: [110, 471], iris: [116, 401, 23, 30, .22], skin: [117, 445] },
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
export function clampGaze(x, y) {
|
|
19
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
20
|
+
return [0, 0];
|
|
21
|
+
const length = Math.max(1, Math.hypot(x, y));
|
|
22
|
+
return [x / length, y / length];
|
|
23
|
+
}
|
|
24
|
+
/** Both lids meet on the same gently curved lash line, never a rectangular wipe. */
|
|
25
|
+
export function eyelidControls(eye, closure) {
|
|
26
|
+
const t = Math.max(0, Math.min(1, closure));
|
|
27
|
+
// Upper lid does most of the travel. Closing at the eye's centre looked like
|
|
28
|
+
// a squint; the resting lash sits near the lower rim instead.
|
|
29
|
+
const cornerY = (eye.left[1] + eye.right[1]) / 2;
|
|
30
|
+
const drop = (eye.lower[1] - cornerY) * .24;
|
|
31
|
+
const middle = [(eye.left[0] + eye.right[0]) / 2, cornerY + drop + (eye.right[0] - eye.left[0]) * .18];
|
|
32
|
+
const lerp = (p) => [p[0] + (middle[0] - p[0]) * t, p[1] + (middle[1] - p[1]) * t];
|
|
33
|
+
return { upper: lerp(eye.upper), lower: lerp(eye.lower), left: [eye.left[0], eye.left[1] + drop * t], right: [eye.right[0], eye.right[1] + drop * t] };
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=character-eye-rig.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Point } from './character-motion-types.js';
|
|
2
|
+
interface GazeClock {
|
|
3
|
+
requestAnimationFrame(callback: (time: number) => void): number;
|
|
4
|
+
cancelAnimationFrame(handle: number): void;
|
|
5
|
+
performance: {
|
|
6
|
+
now(): number;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Event-driven damping. At rest there are no queued frames or polling. */
|
|
10
|
+
export declare function createCharacterGaze(clock: GazeClock, changed: (point: Point) => void): {
|
|
11
|
+
target(x: number, y: number): void;
|
|
12
|
+
reset(): void;
|
|
13
|
+
dispose(): void;
|
|
14
|
+
};
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=character-gaze.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { clampGaze } from './character-eye-rig.js';
|
|
2
|
+
/** Event-driven damping. At rest there are no queued frames or polling. */
|
|
3
|
+
export function createCharacterGaze(clock, changed) {
|
|
4
|
+
let value = [0, 0], target = [0, 0], frame, last = 0, disposed = false;
|
|
5
|
+
function step(now) {
|
|
6
|
+
frame = undefined;
|
|
7
|
+
if (disposed)
|
|
8
|
+
return;
|
|
9
|
+
const dt = Math.min(64, Math.max(0, now - last));
|
|
10
|
+
last = now;
|
|
11
|
+
const amount = 1 - Math.exp(-dt / 95);
|
|
12
|
+
value = [value[0] + (target[0] - value[0]) * amount, value[1] + (target[1] - value[1]) * amount];
|
|
13
|
+
const settled = Math.hypot(value[0] - target[0], value[1] - target[1]) < .001;
|
|
14
|
+
if (settled)
|
|
15
|
+
value = target;
|
|
16
|
+
changed(value);
|
|
17
|
+
if (!settled)
|
|
18
|
+
frame = clock.requestAnimationFrame(step);
|
|
19
|
+
}
|
|
20
|
+
function stop() { if (frame !== undefined)
|
|
21
|
+
clock.cancelAnimationFrame(frame); frame = undefined; }
|
|
22
|
+
return {
|
|
23
|
+
target(x, y) {
|
|
24
|
+
if (disposed)
|
|
25
|
+
return;
|
|
26
|
+
target = clampGaze(x, y);
|
|
27
|
+
if (frame === undefined && Math.hypot(value[0] - target[0], value[1] - target[1]) >= .001) {
|
|
28
|
+
last = clock.performance.now();
|
|
29
|
+
frame = clock.requestAnimationFrame(step);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
reset() { stop(); target = value = [0, 0]; changed(value); },
|
|
33
|
+
dispose() { disposed = true; stop(); },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=character-gaze.js.map
|
|
@@ -8,7 +8,7 @@ interface Clock {
|
|
|
8
8
|
export interface CharacterMotionClip {
|
|
9
9
|
duration: number;
|
|
10
10
|
}
|
|
11
|
-
/** One deadline;
|
|
11
|
+
/** One deadline; 60 fps only during a short gesture, no idle frame loop. */
|
|
12
12
|
export declare function createCharacterMotionClock(clock: Clock, changed: (action: string | undefined, progress: number) => void, random?: () => number): {
|
|
13
13
|
setClips(next: Readonly<Record<string, CharacterMotionClip>> | undefined): void;
|
|
14
14
|
attention(): void;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/** One deadline;
|
|
1
|
+
/** One deadline; 60 fps only during a short gesture, no idle frame loop. */
|
|
2
2
|
export function createCharacterMotionClock(clock, changed, random = Math.random) {
|
|
3
|
-
let timer, disposed = false,
|
|
3
|
+
let timer, disposed = false, active = '';
|
|
4
4
|
let clips;
|
|
5
|
+
let blinksSinceEar = 0, nextEar = 0;
|
|
5
6
|
const clear = () => { if (timer !== undefined)
|
|
6
7
|
clock.clearTimeout(timer); timer = undefined; };
|
|
7
8
|
function rest() {
|
|
@@ -27,7 +28,7 @@ export function createCharacterMotionClock(clock, changed, random = Math.random)
|
|
|
27
28
|
rest();
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
30
|
-
timer = clock.setTimeout(step, 1000 /
|
|
31
|
+
timer = clock.setTimeout(step, 1000 / 60);
|
|
31
32
|
};
|
|
32
33
|
step();
|
|
33
34
|
}
|
|
@@ -36,7 +37,17 @@ export function createCharacterMotionClock(clock, changed, random = Math.random)
|
|
|
36
37
|
if (!clips || disposed)
|
|
37
38
|
return;
|
|
38
39
|
const gestures = Object.keys(clips).filter(name => name !== 'blink' && name !== 'attention');
|
|
39
|
-
|
|
40
|
+
const ears = gestures.filter(name => name.startsWith('ear'));
|
|
41
|
+
// Random selection alone can starve ear motion indefinitely. Keep a quiet,
|
|
42
|
+
// bounded cadence, alternating ears instead of twitching both in lockstep.
|
|
43
|
+
const action = ears.length && blinksSinceEar >= 2 ? ears[nextEar++ % ears.length]
|
|
44
|
+
: (random() < .72 && clips.blink) || !gestures.length ? 'blink'
|
|
45
|
+
: gestures[Math.floor(random() * gestures.length)];
|
|
46
|
+
if (action.startsWith('ear'))
|
|
47
|
+
blinksSinceEar = 0;
|
|
48
|
+
else
|
|
49
|
+
blinksSinceEar++;
|
|
50
|
+
play(action);
|
|
40
51
|
}
|
|
41
52
|
return {
|
|
42
53
|
setClips(next) {
|
|
@@ -45,6 +56,8 @@ export function createCharacterMotionClock(clock, changed, random = Math.random)
|
|
|
45
56
|
clear();
|
|
46
57
|
clips = next;
|
|
47
58
|
active = '';
|
|
59
|
+
blinksSinceEar = 0;
|
|
60
|
+
nextEar = 0;
|
|
48
61
|
changed(undefined, 0);
|
|
49
62
|
if (clips)
|
|
50
63
|
timer = clock.setTimeout(start, 1200 + random() * 800);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CHARACTER_MOTION } from '../generated-character-motion.js';
|
|
2
2
|
import { createCharacterMotionClock } from './character-motion-clock.js';
|
|
3
3
|
import { createRigRenderer } from './character-rig-renderer.js';
|
|
4
|
+
import { EYE_RIGS } from './character-eye-rig.js';
|
|
5
|
+
import { createCharacterGaze } from './character-gaze.js';
|
|
4
6
|
/** Owns loading and lifecycle only. Rig math and painting stay independent. */
|
|
5
7
|
export function createCharacterMotion(doc) {
|
|
6
8
|
const win = doc.defaultView, reduced = win.matchMedia('(prefers-reduced-motion: reduce)');
|
|
@@ -8,6 +10,16 @@ export function createCharacterMotion(doc) {
|
|
|
8
10
|
let disposed = false, part, rig;
|
|
9
11
|
let canvas, render;
|
|
10
12
|
let pose = '', appearance = '', revision = 0, nearby = false, lastPointer = 0;
|
|
13
|
+
let attentionTimer, lastAttention = -Infinity;
|
|
14
|
+
let gazePoint = [0, 0], activeAction, activeProgress = 0;
|
|
15
|
+
let bounds;
|
|
16
|
+
const paint = () => { if (render && part) {
|
|
17
|
+
render(activeAction, activeProgress, gazePoint);
|
|
18
|
+
part.dataset.motion = activeAction ?? 'rig-rest';
|
|
19
|
+
} };
|
|
20
|
+
const gaze = createCharacterGaze(win, point => { gazePoint = point; paint(); });
|
|
21
|
+
const cancelAttention = () => { if (attentionTimer !== undefined)
|
|
22
|
+
win.clearTimeout(attentionTimer); attentionTimer = undefined; };
|
|
11
23
|
function decode(src) {
|
|
12
24
|
if (loading.has(src))
|
|
13
25
|
return loading.get(src);
|
|
@@ -25,8 +37,9 @@ export function createCharacterMotion(doc) {
|
|
|
25
37
|
if (!canvas || !part || !render)
|
|
26
38
|
return;
|
|
27
39
|
// Keep the same layered rest drawing after an action: no plate swap/pop.
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
activeAction = action;
|
|
41
|
+
activeProgress = progress;
|
|
42
|
+
paint();
|
|
30
43
|
});
|
|
31
44
|
function update() {
|
|
32
45
|
if (disposed)
|
|
@@ -36,8 +49,13 @@ export function createCharacterMotion(doc) {
|
|
|
36
49
|
const next = !doc.hidden && !reduced.matches && !moving && part ? CHARACTER_MOTION[pose]?.[mode] : undefined;
|
|
37
50
|
if (next === rig && appearance === mode)
|
|
38
51
|
return;
|
|
39
|
-
|
|
52
|
+
cancelAttention();
|
|
40
53
|
render = undefined;
|
|
54
|
+
clock.setClips(undefined);
|
|
55
|
+
gaze.reset();
|
|
56
|
+
bounds = undefined;
|
|
57
|
+
activeAction = undefined;
|
|
58
|
+
activeProgress = 0;
|
|
41
59
|
const current = ++revision;
|
|
42
60
|
canvas?.remove();
|
|
43
61
|
canvas = undefined;
|
|
@@ -62,9 +80,9 @@ export function createCharacterMotion(doc) {
|
|
|
62
80
|
return;
|
|
63
81
|
}
|
|
64
82
|
currentPart.append(canvas);
|
|
65
|
-
render = createRigRenderer(ctx, next, images);
|
|
83
|
+
render = createRigRenderer(ctx, next, images, EYE_RIGS[pose]);
|
|
66
84
|
clock.setClips({
|
|
67
|
-
blink: { duration:
|
|
85
|
+
blink: { duration: 280 },
|
|
68
86
|
...Object.fromEntries(next.ears.map((_, i) => ['ear' + i, { duration: 1050 }])),
|
|
69
87
|
...(next.layers.tail ? { tail: { duration: 2400 } } : {}),
|
|
70
88
|
attention: { duration: 2400 },
|
|
@@ -75,17 +93,45 @@ export function createCharacterMotion(doc) {
|
|
|
75
93
|
if (!part || !render || event.pointerType !== 'mouse')
|
|
76
94
|
return;
|
|
77
95
|
const now = win.performance.now();
|
|
78
|
-
if (now - lastPointer
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
96
|
+
if (!bounds || now - lastPointer >= 100) {
|
|
97
|
+
bounds = part.getBoundingClientRect();
|
|
98
|
+
lastPointer = now;
|
|
99
|
+
}
|
|
100
|
+
const rect = bounds, eyes = EYE_RIGS[pose];
|
|
101
|
+
if (rig && eyes?.length) {
|
|
102
|
+
const scale = Math.min(rect.width / rig.size[0], rect.height / rig.size[1]);
|
|
103
|
+
const eyeX = eyes.reduce((sum, eye) => sum + eye.iris[0], 0) / eyes.length / 2;
|
|
104
|
+
const eyeY = eyes.reduce((sum, eye) => sum + eye.iris[1], 0) / eyes.length / 2;
|
|
105
|
+
const cx = rect.left + eyeX * scale;
|
|
106
|
+
const cy = rect.top + (rect.height - rig.size[1] * scale) / 2 + eyeY * scale;
|
|
107
|
+
gaze.target((event.clientX - cx) / Math.max(220, win.innerWidth * .32), (event.clientY - cy) / Math.max(180, win.innerHeight * .32));
|
|
108
|
+
}
|
|
82
109
|
const near = event.clientX >= rect.left - 70 && event.clientX <= rect.right + 70
|
|
83
110
|
&& event.clientY >= rect.top - 50 && event.clientY <= rect.bottom + 50;
|
|
84
|
-
if (near
|
|
85
|
-
|
|
111
|
+
if (!near)
|
|
112
|
+
cancelAttention();
|
|
113
|
+
if (near && !nearby && now - lastAttention > 6000) {
|
|
114
|
+
const current = revision;
|
|
115
|
+
// A passing pointer should not trigger a performance. Brief dwell gives
|
|
116
|
+
// an intentional approach one quiet, coordinated response.
|
|
117
|
+
attentionTimer = win.setTimeout(() => {
|
|
118
|
+
attentionTimer = undefined;
|
|
119
|
+
if (!disposed && nearby && current === revision) {
|
|
120
|
+
lastAttention = win.performance.now();
|
|
121
|
+
clock.attention();
|
|
122
|
+
}
|
|
123
|
+
}, 180);
|
|
124
|
+
}
|
|
86
125
|
nearby = near;
|
|
87
126
|
};
|
|
88
127
|
reduced.addEventListener('change', update);
|
|
128
|
+
const neutral = () => { gaze.target(0, 0); nearby = false; cancelAttention(); };
|
|
129
|
+
const leave = (event) => { if (!event.relatedTarget)
|
|
130
|
+
neutral(); };
|
|
131
|
+
const resize = () => { bounds = undefined; };
|
|
132
|
+
doc.addEventListener('pointerout', leave);
|
|
133
|
+
win.addEventListener('blur', neutral);
|
|
134
|
+
win.addEventListener('resize', resize);
|
|
89
135
|
doc.addEventListener('visibilitychange', update);
|
|
90
136
|
doc.addEventListener('pointermove', pointer, { passive: true });
|
|
91
137
|
const observer = new win.MutationObserver(update);
|
|
@@ -93,9 +139,12 @@ export function createCharacterMotion(doc) {
|
|
|
93
139
|
return {
|
|
94
140
|
setPose(nextPart, nextPose) {
|
|
95
141
|
if (nextPart !== part || pose !== nextPose) {
|
|
142
|
+
cancelAttention();
|
|
96
143
|
revision++;
|
|
97
|
-
clock.setClips(undefined);
|
|
98
144
|
render = undefined;
|
|
145
|
+
clock.setClips(undefined);
|
|
146
|
+
gaze.reset();
|
|
147
|
+
bounds = undefined;
|
|
99
148
|
canvas?.remove();
|
|
100
149
|
canvas = undefined;
|
|
101
150
|
rig = undefined;
|
|
@@ -108,8 +157,10 @@ export function createCharacterMotion(doc) {
|
|
|
108
157
|
},
|
|
109
158
|
dispose() {
|
|
110
159
|
disposed = true;
|
|
160
|
+
cancelAttention();
|
|
111
161
|
revision++;
|
|
112
162
|
clock.dispose();
|
|
163
|
+
gaze.dispose();
|
|
113
164
|
canvas?.remove();
|
|
114
165
|
render = undefined;
|
|
115
166
|
images.clear();
|
|
@@ -120,6 +171,9 @@ export function createCharacterMotion(doc) {
|
|
|
120
171
|
observer.disconnect();
|
|
121
172
|
doc.removeEventListener('visibilitychange', update);
|
|
122
173
|
doc.removeEventListener('pointermove', pointer);
|
|
174
|
+
doc.removeEventListener('pointerout', leave);
|
|
175
|
+
win.removeEventListener('blur', neutral);
|
|
176
|
+
win.removeEventListener('resize', resize);
|
|
123
177
|
},
|
|
124
178
|
};
|
|
125
179
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { CharacterRig } from './character-motion-types.js';
|
|
1
|
+
import type { CharacterRig, Point } from './character-motion-types.js';
|
|
2
|
+
import type { EyeRig } from './character-eye-rig.js';
|
|
2
3
|
/** Small CPU-skinned tail only; body and ears use normal image draws. */
|
|
3
|
-
export declare function createRigRenderer(ctx: CanvasRenderingContext2D, rig: CharacterRig, images: ReadonlyMap<string, HTMLImageElement | null
|
|
4
|
+
export declare function createRigRenderer(ctx: CanvasRenderingContext2D, rig: CharacterRig, images: ReadonlyMap<string, HTMLImageElement | null>, eyes?: readonly EyeRig[]): (action: string | undefined, progress: number, gaze?: Point) => void;
|
|
4
5
|
//# sourceMappingURL=character-rig-renderer.d.ts.map
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { attentionBlink, blinkClosure } from './character-expression.js';
|
|
2
|
+
import { createEyeRenderer } from './character-eye-renderer.js';
|
|
3
|
+
import { bindVertex, earOffset, rigAngles, skinVertex, solveBones, TAIL_JOINTS } from './character-rig.js';
|
|
2
4
|
/** Small CPU-skinned tail only; body and ears use normal image draws. */
|
|
3
|
-
export function createRigRenderer(ctx, rig, images) {
|
|
5
|
+
export function createRigRenderer(ctx, rig, images, eyes = []) {
|
|
6
|
+
const body = images.get(rig.layers.body.src);
|
|
7
|
+
const drawEyes = body ? createEyeRenderer(ctx, body, eyes) : undefined;
|
|
4
8
|
const tail = rig.layers.tail;
|
|
5
9
|
const vertices = [], triangles = [];
|
|
6
10
|
if (tail) {
|
|
@@ -61,25 +65,31 @@ export function createRigRenderer(ctx, rig, images) {
|
|
|
61
65
|
ctx.restore();
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
|
-
return (action, progress) => {
|
|
68
|
+
return (action, progress, gaze = [0, 0]) => {
|
|
65
69
|
ctx.clearRect(0, 0, rig.size[0], rig.size[1]);
|
|
66
|
-
const
|
|
67
|
-
rig.ears.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
const earProgress = action === 'attention' ? Math.max(0, Math.min(1, (progress - .03) / .65)) : progress;
|
|
71
|
+
const ears = rigAngles(earProgress, rig.ears.length, false);
|
|
72
|
+
rig.ears.forEach(([, root], i) => {
|
|
73
|
+
const part = rig.layers['ear' + i], image = part && images.get(part.src);
|
|
74
|
+
if (!part || !image)
|
|
75
|
+
return;
|
|
76
|
+
const angle = action === 'attention' || action === 'ear' + i ? ears[i] : 0;
|
|
77
|
+
if (Math.abs(angle) < 1e-8) {
|
|
78
|
+
layer('ear' + i);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const [x, top, right, bottom] = part.box;
|
|
82
|
+
// Non-overlapping source rows preserve alpha and never rotate a rectangle
|
|
83
|
+
// of hair out from behind the head. The lower attachment stays identical.
|
|
84
|
+
for (let row = 0; row < image.height; row++) {
|
|
85
|
+
const y = top + row * (bottom - top) / image.height;
|
|
86
|
+
ctx.drawImage(image, 0, row, image.width, 1, x + earOffset(y, top, root, angle), y, right - x, (bottom - top) / image.height);
|
|
87
|
+
}
|
|
74
88
|
});
|
|
75
89
|
layer('body');
|
|
76
|
-
drawTail(action === '
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
ctx.globalAlpha = Math.min(1, Math.sin(Math.PI * progress) * 2);
|
|
80
|
-
layer('blink');
|
|
81
|
-
ctx.restore();
|
|
82
|
-
}
|
|
90
|
+
drawTail(action === 'attention' ? Math.max(0, Math.min(1, (progress - .22) / .78)) : action === 'tail' ? progress : 0);
|
|
91
|
+
const closure = action === 'blink' ? blinkClosure(progress) : action === 'attention' ? attentionBlink(progress) : 0;
|
|
92
|
+
drawEyes?.(gaze, closure);
|
|
83
93
|
};
|
|
84
94
|
}
|
|
85
95
|
//# sourceMappingURL=character-rig-renderer.js.map
|
|
@@ -12,6 +12,8 @@ export interface Vertex {
|
|
|
12
12
|
bone: number;
|
|
13
13
|
weight: number;
|
|
14
14
|
}
|
|
15
|
+
/** Keep the ear root (and any hair below it) fixed; only flex the upper tip. */
|
|
16
|
+
export declare function earOffset(y: number, top: number, root: number, angle: number): number;
|
|
15
17
|
export declare function solveBones(rest: readonly Point[], angles: readonly number[]): Joint[];
|
|
16
18
|
export declare function bindVertex(x: number, y: number, rest?: readonly Point[]): Vertex;
|
|
17
19
|
/** Linear blend skinning, inverse bind translation followed by posed rotation. */
|