@lemoncat7/dsh-theme-xiaohei 0.3.3 → 0.3.5

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.
@@ -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,4 @@
1
+ /** Fast closure, short hold, relaxed opening; zero velocity at each join. */
2
+ export declare function blinkClosure(progress: number): number;
3
+ export declare function attentionBlink(progress: number): number;
4
+ //# sourceMappingURL=character-expression.d.ts.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; elapsed-time continuous sampling, capped at 30 fps. */
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; elapsed-time continuous sampling, capped at 30 fps. */
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, turn = 0, active = '';
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 / 30);
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
- play((turn++ % 2 === 0 && clips.blink) || !gestures.length ? 'blink' : gestures[Math.floor(random() * gestures.length)]);
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
- render(action, progress);
29
- part.dataset.motion = action ?? 'rig-rest';
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
- clock.setClips(undefined);
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: 330 },
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 < 100)
79
- return;
80
- lastPointer = now;
81
- const rect = part.getBoundingClientRect();
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 && !nearby)
85
- clock.attention();
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>): (action: string | undefined, progress: number) => void;
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 { bindVertex, rigAngles, skinVertex, solveBones, TAIL_JOINTS } from './character-rig.js';
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 ears = rigAngles(progress, rig.ears.length, false);
67
- rig.ears.forEach(([x, y], i) => {
68
- ctx.save();
69
- ctx.translate(x, y);
70
- ctx.rotate(action === 'attention' || action === 'ear' + i ? ears[i] : 0);
71
- ctx.translate(-x, -y);
72
- layer('ear' + i);
73
- ctx.restore();
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 === 'tail' || action === 'attention' ? progress : 0);
77
- if (action === 'blink') {
78
- ctx.save();
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. */