@lemoncat7/dsh-theme-xiaohei 0.3.1 → 0.3.3
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 +7 -5
- package/lib/bloub-heixiu/runtime.js +17 -3
- package/lib/chrome/conversation-messages.js +8 -8
- package/lib/chrome/sidebar.d.ts +1 -1
- package/lib/chrome/sidebar.js +16 -0
- package/lib/client.js +433 -43
- package/lib/gaze.js +15 -2
- package/lib/generated-character-motion.d.ts +4 -0
- package/lib/generated-character-motion.js +2 -0
- package/lib/metallic-brand-mark.js +20 -33
- package/lib/scene/character-motion-clock.d.ts +18 -0
- package/lib/scene/character-motion-clock.js +57 -0
- package/lib/scene/character-motion-types.d.ts +12 -0
- package/lib/scene/character-motion-types.js +2 -0
- package/lib/scene/character-motion.d.ts +6 -0
- package/lib/scene/character-motion.js +126 -0
- package/lib/scene/character-rig-renderer.d.ts +4 -0
- package/lib/scene/character-rig-renderer.js +85 -0
- package/lib/scene/character-rig.d.ts +20 -0
- package/lib/scene/character-rig.js +53 -0
- package/lib/scene/wallpaper-character-styles.js +10 -0
- package/lib/scene/wallpaper-character.d.ts +1 -1
- package/lib/scene/wallpaper-character.js +19 -1
- package/lib/sidebar-glass.js +13 -3
- package/lib/sidebar-reveal.d.ts +8 -0
- package/lib/sidebar-reveal.js +51 -0
- package/package.json +1 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { XIAOHEI_BRAND_AVATAR } from './generated-identity.js';
|
|
4
|
+
// Native wide/rail switches remount this slot. A tiny navigation mark does not
|
|
5
|
+
// need a fresh GPU context and shader compilation on every toggle.
|
|
6
|
+
const metallicSnapshots = new Map();
|
|
4
7
|
const VERTEX_SHADER = `#version 300 es
|
|
5
8
|
precision highp float;
|
|
6
9
|
in vec2 a_position;
|
|
@@ -167,13 +170,16 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
167
170
|
const [ready, setReady] = useState(false);
|
|
168
171
|
const visualSize = Math.max(24, size + 4);
|
|
169
172
|
const context = size >= 32 ? 'hero' : 'sidebar';
|
|
173
|
+
const [snapshot, setSnapshot] = useState(metallicSnapshots.get(visualSize));
|
|
170
174
|
useEffect(() => {
|
|
175
|
+
if (snapshot !== undefined)
|
|
176
|
+
return;
|
|
171
177
|
const canvas = canvasRef.current;
|
|
172
178
|
if (canvas === null)
|
|
173
179
|
return;
|
|
174
180
|
const gl = canvas.getContext('webgl2', {
|
|
175
181
|
alpha: true,
|
|
176
|
-
antialias:
|
|
182
|
+
antialias: false,
|
|
177
183
|
powerPreference: 'low-power',
|
|
178
184
|
premultipliedAlpha: true,
|
|
179
185
|
});
|
|
@@ -207,7 +213,7 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
207
213
|
}
|
|
208
214
|
const uniforms = Object.fromEntries(UNIFORM_NAMES.map(name => [name, gl.getUniformLocation(program, name)]));
|
|
209
215
|
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
|
210
|
-
const renderSize = Math.max(
|
|
216
|
+
const renderSize = Math.max(64, Math.ceil(visualSize * pixelRatio));
|
|
211
217
|
canvas.width = renderSize;
|
|
212
218
|
canvas.height = renderSize;
|
|
213
219
|
gl.viewport(0, 0, renderSize, renderSize);
|
|
@@ -238,28 +244,13 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
238
244
|
setRgb(gl, uniforms.u_darkColor, [0.025, 0.04, 0.045]);
|
|
239
245
|
setRgb(gl, uniforms.u_tint, [0.84, 0.92, 0.89]);
|
|
240
246
|
let disposed = false;
|
|
241
|
-
|
|
242
|
-
let animationFrame;
|
|
243
|
-
let lastFrame = 0;
|
|
244
|
-
let metallicTime = 1450;
|
|
245
|
-
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
247
|
+
const metallicTime = 1450;
|
|
246
248
|
const texture = gl.createTexture();
|
|
247
249
|
const image = new Image();
|
|
248
250
|
const draw = () => {
|
|
249
251
|
gl.uniform1f(uniforms.u_time, metallicTime);
|
|
250
252
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
251
253
|
};
|
|
252
|
-
const render = (now) => {
|
|
253
|
-
if (disposed)
|
|
254
|
-
return;
|
|
255
|
-
if (visible && document.visibilityState !== 'hidden' && now - lastFrame >= 40) {
|
|
256
|
-
const delta = lastFrame === 0 ? 0 : Math.min(80, now - lastFrame);
|
|
257
|
-
lastFrame = now;
|
|
258
|
-
metallicTime += delta * 0.11;
|
|
259
|
-
draw();
|
|
260
|
-
}
|
|
261
|
-
animationFrame = window.requestAnimationFrame(render);
|
|
262
|
-
};
|
|
263
254
|
image.onload = () => {
|
|
264
255
|
if (disposed || texture === null)
|
|
265
256
|
return;
|
|
@@ -273,34 +264,30 @@ export function XiaoheiMetallicBrandMark({ size, className }) {
|
|
|
273
264
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
274
265
|
gl.uniform1i(uniforms.u_tex, 0);
|
|
275
266
|
draw();
|
|
267
|
+
// Preserve the metallic appearance without a permanent GPU animation.
|
|
268
|
+
// Updating snapshot unmounts the canvas and runs the resource cleanup.
|
|
269
|
+
const result = canvas.toDataURL();
|
|
270
|
+
metallicSnapshots.set(visualSize, result);
|
|
271
|
+
setSnapshot(result);
|
|
276
272
|
setReady(true);
|
|
277
|
-
if (!reduceMotion.matches)
|
|
278
|
-
animationFrame = window.requestAnimationFrame(render);
|
|
279
273
|
};
|
|
280
274
|
image.src = XIAOHEI_BRAND_AVATAR;
|
|
281
|
-
const visibilityObserver = typeof IntersectionObserver === 'function'
|
|
282
|
-
? new IntersectionObserver(entries => {
|
|
283
|
-
visible = entries.some(entry => entry.isIntersecting);
|
|
284
|
-
if (visible)
|
|
285
|
-
lastFrame = 0;
|
|
286
|
-
})
|
|
287
|
-
: undefined;
|
|
288
|
-
visibilityObserver?.observe(canvas);
|
|
289
275
|
return () => {
|
|
290
276
|
disposed = true;
|
|
291
|
-
|
|
292
|
-
window.cancelAnimationFrame(animationFrame);
|
|
293
|
-
visibilityObserver?.disconnect();
|
|
277
|
+
image.onload = null;
|
|
294
278
|
if (texture !== null)
|
|
295
279
|
gl.deleteTexture(texture);
|
|
296
280
|
gl.deleteBuffer(buffer);
|
|
297
281
|
gl.deleteProgram(program);
|
|
282
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
|
298
283
|
};
|
|
299
|
-
}, [visualSize]);
|
|
284
|
+
}, [visualSize, context, snapshot]);
|
|
300
285
|
const geometry = {
|
|
301
286
|
'--xiaohei-brand-mark-size': `${visualSize}px`,
|
|
302
287
|
};
|
|
303
|
-
return (_jsxs("span", { className: className === undefined ? 'xiaohei-brand-mark' : `${className} xiaohei-brand-mark`, "data-brand-context": context, "data-metallic-ready": ready ? 'true' : 'false', style: geometry, "aria-hidden": "true", children: [_jsx("img", { className: "xiaohei-brand-mark__fallback", src: XIAOHEI_BRAND_AVATAR, alt: "" }),
|
|
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
|
|
289
|
+
? _jsx("img", { className: "xiaohei-brand-mark__metal", src: snapshot, alt: "" })
|
|
290
|
+
: _jsx("canvas", { className: "xiaohei-brand-mark__metal", ref: canvasRef })] }));
|
|
304
291
|
}
|
|
305
292
|
/** Official sidebar brand-name occupant paired with the Xiaohei mark. */
|
|
306
293
|
export function XiaoheiBrandName() {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface Clock {
|
|
2
|
+
setTimeout(callback: () => void, delay: number): number;
|
|
3
|
+
clearTimeout(handle: number): void;
|
|
4
|
+
performance: {
|
|
5
|
+
now(): number;
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export interface CharacterMotionClip {
|
|
9
|
+
duration: number;
|
|
10
|
+
}
|
|
11
|
+
/** One deadline; elapsed-time continuous sampling, capped at 30 fps. */
|
|
12
|
+
export declare function createCharacterMotionClock(clock: Clock, changed: (action: string | undefined, progress: number) => void, random?: () => number): {
|
|
13
|
+
setClips(next: Readonly<Record<string, CharacterMotionClip>> | undefined): void;
|
|
14
|
+
attention(): void;
|
|
15
|
+
dispose(): void;
|
|
16
|
+
};
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=character-motion-clock.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** One deadline; elapsed-time continuous sampling, capped at 30 fps. */
|
|
2
|
+
export function createCharacterMotionClock(clock, changed, random = Math.random) {
|
|
3
|
+
let timer, disposed = false, turn = 0, active = '';
|
|
4
|
+
let clips;
|
|
5
|
+
const clear = () => { if (timer !== undefined)
|
|
6
|
+
clock.clearTimeout(timer); timer = undefined; };
|
|
7
|
+
function rest() {
|
|
8
|
+
active = '';
|
|
9
|
+
changed(undefined, 0);
|
|
10
|
+
if (clips && !disposed)
|
|
11
|
+
timer = clock.setTimeout(start, 2800 + random() * 2800);
|
|
12
|
+
}
|
|
13
|
+
function play(action) {
|
|
14
|
+
const clip = clips?.[action];
|
|
15
|
+
if (!clip || disposed)
|
|
16
|
+
return;
|
|
17
|
+
clear();
|
|
18
|
+
active = action;
|
|
19
|
+
const started = clock.performance.now();
|
|
20
|
+
const step = () => {
|
|
21
|
+
timer = undefined;
|
|
22
|
+
if (!clips || disposed)
|
|
23
|
+
return;
|
|
24
|
+
const t = Math.min(1, (clock.performance.now() - started) / clip.duration);
|
|
25
|
+
changed(active, t);
|
|
26
|
+
if (t >= 1) {
|
|
27
|
+
rest();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
timer = clock.setTimeout(step, 1000 / 30);
|
|
31
|
+
};
|
|
32
|
+
step();
|
|
33
|
+
}
|
|
34
|
+
function start() {
|
|
35
|
+
timer = undefined;
|
|
36
|
+
if (!clips || disposed)
|
|
37
|
+
return;
|
|
38
|
+
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
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
setClips(next) {
|
|
43
|
+
if (disposed || next === clips)
|
|
44
|
+
return;
|
|
45
|
+
clear();
|
|
46
|
+
clips = next;
|
|
47
|
+
active = '';
|
|
48
|
+
changed(undefined, 0);
|
|
49
|
+
if (clips)
|
|
50
|
+
timer = clock.setTimeout(start, 1200 + random() * 800);
|
|
51
|
+
},
|
|
52
|
+
attention() { if (!active)
|
|
53
|
+
play('attention'); },
|
|
54
|
+
dispose() { disposed = true; clear(); clips = undefined; changed(undefined, 0); },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=character-motion-clock.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Point = readonly [number, number];
|
|
2
|
+
export interface CharacterLayer {
|
|
3
|
+
readonly src: string;
|
|
4
|
+
readonly box: readonly [number, number, number, number];
|
|
5
|
+
}
|
|
6
|
+
export interface CharacterRig {
|
|
7
|
+
readonly layers: Readonly<Record<string, CharacterLayer>>;
|
|
8
|
+
readonly ears: readonly Point[];
|
|
9
|
+
readonly size: Point;
|
|
10
|
+
}
|
|
11
|
+
export type CharacterMotionCatalog = Readonly<Record<string, Readonly<Record<'light' | 'dark', CharacterRig>>>>;
|
|
12
|
+
//# sourceMappingURL=character-motion-types.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Owns loading and lifecycle only. Rig math and painting stay independent. */
|
|
2
|
+
export declare function createCharacterMotion(doc: Document): {
|
|
3
|
+
setPose(nextPart: HTMLElement | undefined, nextPose: string): void;
|
|
4
|
+
dispose(): void;
|
|
5
|
+
};
|
|
6
|
+
//# sourceMappingURL=character-motion.d.ts.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { CHARACTER_MOTION } from '../generated-character-motion.js';
|
|
2
|
+
import { createCharacterMotionClock } from './character-motion-clock.js';
|
|
3
|
+
import { createRigRenderer } from './character-rig-renderer.js';
|
|
4
|
+
/** Owns loading and lifecycle only. Rig math and painting stay independent. */
|
|
5
|
+
export function createCharacterMotion(doc) {
|
|
6
|
+
const win = doc.defaultView, reduced = win.matchMedia('(prefers-reduced-motion: reduce)');
|
|
7
|
+
const images = new Map(), loading = new Map();
|
|
8
|
+
let disposed = false, part, rig;
|
|
9
|
+
let canvas, render;
|
|
10
|
+
let pose = '', appearance = '', revision = 0, nearby = false, lastPointer = 0;
|
|
11
|
+
function decode(src) {
|
|
12
|
+
if (loading.has(src))
|
|
13
|
+
return loading.get(src);
|
|
14
|
+
if (images.has(src))
|
|
15
|
+
return Promise.resolve();
|
|
16
|
+
const image = new win.Image();
|
|
17
|
+
image.src = src;
|
|
18
|
+
const pending = image.decode().then(() => { if (!disposed)
|
|
19
|
+
images.set(src, image); }, () => { })
|
|
20
|
+
.finally(() => loading.delete(src));
|
|
21
|
+
loading.set(src, pending);
|
|
22
|
+
return pending;
|
|
23
|
+
}
|
|
24
|
+
const clock = createCharacterMotionClock(win, (action, progress) => {
|
|
25
|
+
if (!canvas || !part || !render)
|
|
26
|
+
return;
|
|
27
|
+
// Keep the same layered rest drawing after an action: no plate swap/pop.
|
|
28
|
+
render(action, progress);
|
|
29
|
+
part.dataset.motion = action ?? 'rig-rest';
|
|
30
|
+
});
|
|
31
|
+
function update() {
|
|
32
|
+
if (disposed)
|
|
33
|
+
return;
|
|
34
|
+
const mode = doc.documentElement.dataset.xiaoheiAppearance === 'light' ? 'light' : 'dark';
|
|
35
|
+
const moving = doc.documentElement.hasAttribute('data-xiaohei-sidebar-resizing');
|
|
36
|
+
const next = !doc.hidden && !reduced.matches && !moving && part ? CHARACTER_MOTION[pose]?.[mode] : undefined;
|
|
37
|
+
if (next === rig && appearance === mode)
|
|
38
|
+
return;
|
|
39
|
+
clock.setClips(undefined);
|
|
40
|
+
render = undefined;
|
|
41
|
+
const current = ++revision;
|
|
42
|
+
canvas?.remove();
|
|
43
|
+
canvas = undefined;
|
|
44
|
+
if (part)
|
|
45
|
+
part.dataset.motion = 'rest';
|
|
46
|
+
rig = next;
|
|
47
|
+
appearance = mode;
|
|
48
|
+
nearby = false;
|
|
49
|
+
if (!next || !part)
|
|
50
|
+
return;
|
|
51
|
+
const currentPart = part;
|
|
52
|
+
void Promise.all(Object.values(next.layers).map(layer => decode(layer.src))).then(() => {
|
|
53
|
+
if (disposed || current !== revision || Object.values(next.layers).some(layer => !images.get(layer.src)))
|
|
54
|
+
return;
|
|
55
|
+
canvas = doc.createElement('canvas');
|
|
56
|
+
canvas.className = 'xiaohei-character-motion';
|
|
57
|
+
canvas.width = next.size[0];
|
|
58
|
+
canvas.height = next.size[1];
|
|
59
|
+
const ctx = canvas.getContext('2d');
|
|
60
|
+
if (!ctx) {
|
|
61
|
+
canvas = undefined;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
currentPart.append(canvas);
|
|
65
|
+
render = createRigRenderer(ctx, next, images);
|
|
66
|
+
clock.setClips({
|
|
67
|
+
blink: { duration: 330 },
|
|
68
|
+
...Object.fromEntries(next.ears.map((_, i) => ['ear' + i, { duration: 1050 }])),
|
|
69
|
+
...(next.layers.tail ? { tail: { duration: 2400 } } : {}),
|
|
70
|
+
attention: { duration: 2400 },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const pointer = (event) => {
|
|
75
|
+
if (!part || !render || event.pointerType !== 'mouse')
|
|
76
|
+
return;
|
|
77
|
+
const now = win.performance.now();
|
|
78
|
+
if (now - lastPointer < 100)
|
|
79
|
+
return;
|
|
80
|
+
lastPointer = now;
|
|
81
|
+
const rect = part.getBoundingClientRect();
|
|
82
|
+
const near = event.clientX >= rect.left - 70 && event.clientX <= rect.right + 70
|
|
83
|
+
&& event.clientY >= rect.top - 50 && event.clientY <= rect.bottom + 50;
|
|
84
|
+
if (near && !nearby)
|
|
85
|
+
clock.attention();
|
|
86
|
+
nearby = near;
|
|
87
|
+
};
|
|
88
|
+
reduced.addEventListener('change', update);
|
|
89
|
+
doc.addEventListener('visibilitychange', update);
|
|
90
|
+
doc.addEventListener('pointermove', pointer, { passive: true });
|
|
91
|
+
const observer = new win.MutationObserver(update);
|
|
92
|
+
observer.observe(doc.documentElement, { attributes: true, attributeFilter: ['data-xiaohei-appearance', 'data-xiaohei-sidebar-resizing'] });
|
|
93
|
+
return {
|
|
94
|
+
setPose(nextPart, nextPose) {
|
|
95
|
+
if (nextPart !== part || pose !== nextPose) {
|
|
96
|
+
revision++;
|
|
97
|
+
clock.setClips(undefined);
|
|
98
|
+
render = undefined;
|
|
99
|
+
canvas?.remove();
|
|
100
|
+
canvas = undefined;
|
|
101
|
+
rig = undefined;
|
|
102
|
+
if (part)
|
|
103
|
+
part.dataset.motion = 'rest';
|
|
104
|
+
}
|
|
105
|
+
part = nextPart;
|
|
106
|
+
pose = nextPose;
|
|
107
|
+
update();
|
|
108
|
+
},
|
|
109
|
+
dispose() {
|
|
110
|
+
disposed = true;
|
|
111
|
+
revision++;
|
|
112
|
+
clock.dispose();
|
|
113
|
+
canvas?.remove();
|
|
114
|
+
render = undefined;
|
|
115
|
+
images.clear();
|
|
116
|
+
loading.clear();
|
|
117
|
+
if (part)
|
|
118
|
+
part.dataset.motion = 'rest';
|
|
119
|
+
reduced.removeEventListener('change', update);
|
|
120
|
+
observer.disconnect();
|
|
121
|
+
doc.removeEventListener('visibilitychange', update);
|
|
122
|
+
doc.removeEventListener('pointermove', pointer);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=character-motion.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CharacterRig } from './character-motion-types.js';
|
|
2
|
+
/** 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
|
+
//# sourceMappingURL=character-rig-renderer.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { bindVertex, rigAngles, skinVertex, solveBones, TAIL_JOINTS } from './character-rig.js';
|
|
2
|
+
/** Small CPU-skinned tail only; body and ears use normal image draws. */
|
|
3
|
+
export function createRigRenderer(ctx, rig, images) {
|
|
4
|
+
const tail = rig.layers.tail;
|
|
5
|
+
const vertices = [], triangles = [];
|
|
6
|
+
if (tail) {
|
|
7
|
+
const [x, y, r, b] = tail.box, cols = Math.ceil((r - x) / 10), rows = Math.ceil((b - y) / 10);
|
|
8
|
+
for (let row = 0; row <= rows; row++)
|
|
9
|
+
for (let col = 0; col <= cols; col++)
|
|
10
|
+
vertices.push(bindVertex(x + (r - x) * col / cols, y + (b - y) * row / rows));
|
|
11
|
+
for (let row = 0; row < rows; row++)
|
|
12
|
+
for (let col = 0; col < cols; col++) {
|
|
13
|
+
const i = row * (cols + 1) + col;
|
|
14
|
+
triangles.push([i, i + 1, i + cols + 1], [i + 1, i + cols + 2, i + cols + 1]);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function layer(name) {
|
|
18
|
+
const layer = rig.layers[name], image = layer && images.get(layer.src);
|
|
19
|
+
if (layer && image) {
|
|
20
|
+
const [x, y, r, b] = layer.box;
|
|
21
|
+
ctx.drawImage(image, x, y, r - x, b - y);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function drawTail(progress) {
|
|
25
|
+
if (!tail)
|
|
26
|
+
return;
|
|
27
|
+
const image = images.get(tail.src);
|
|
28
|
+
if (!image)
|
|
29
|
+
return;
|
|
30
|
+
if (progress <= 0 || progress >= 1) {
|
|
31
|
+
layer('tail');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const joints = solveBones(TAIL_JOINTS, rigAngles(progress, TAIL_JOINTS.length, true));
|
|
35
|
+
const posed = vertices.map(v => skinVertex(v, joints));
|
|
36
|
+
const [left, top] = tail.box;
|
|
37
|
+
for (const indices of triangles) {
|
|
38
|
+
const [ia, ib, ic] = indices;
|
|
39
|
+
const a = vertices[ia], b = vertices[ib], c = vertices[ic];
|
|
40
|
+
const p = posed[ia], q = posed[ib], r = posed[ic];
|
|
41
|
+
const ux = b.x - a.x, uy = b.y - a.y, vx = c.x - a.x, vy = c.y - a.y, det = ux * vy - uy * vx;
|
|
42
|
+
const aa = ((q[0] - p[0]) * vy - (r[0] - p[0]) * uy) / det;
|
|
43
|
+
const bb = ((q[1] - p[1]) * vy - (r[1] - p[1]) * uy) / det;
|
|
44
|
+
const cc = ((r[0] - p[0]) * ux - (q[0] - p[0]) * vx) / det;
|
|
45
|
+
const dd = ((r[1] - p[1]) * ux - (q[1] - p[1]) * vx) / det;
|
|
46
|
+
ctx.save();
|
|
47
|
+
ctx.beginPath();
|
|
48
|
+
// Slightly overlap the interior triangles to avoid antialias hairlines.
|
|
49
|
+
const cx = (p[0] + q[0] + r[0]) / 3, cy = (p[1] + q[1] + r[1]) / 3;
|
|
50
|
+
[p, q, r].forEach((v, i) => {
|
|
51
|
+
const dx = v[0] - cx, dy = v[1] - cy, scale = 1 + 1.2 / Math.max(1, Math.hypot(dx, dy));
|
|
52
|
+
if (i)
|
|
53
|
+
ctx.lineTo(cx + dx * scale, cy + dy * scale);
|
|
54
|
+
else
|
|
55
|
+
ctx.moveTo(cx + dx * scale, cy + dy * scale);
|
|
56
|
+
});
|
|
57
|
+
ctx.closePath();
|
|
58
|
+
ctx.clip();
|
|
59
|
+
ctx.transform(aa, bb, cc, dd, p[0] - aa * (a.x - left) - cc * (a.y - top), p[1] - bb * (a.x - left) - dd * (a.y - top));
|
|
60
|
+
ctx.drawImage(image, 0, 0);
|
|
61
|
+
ctx.restore();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return (action, progress) => {
|
|
65
|
+
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();
|
|
74
|
+
});
|
|
75
|
+
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
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=character-rig-renderer.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Point } from './character-motion-types.js';
|
|
2
|
+
/** Bind-space joint positions. Each child inherits its parent's rotation. */
|
|
3
|
+
export declare const TAIL_JOINTS: readonly Point[];
|
|
4
|
+
export interface Joint {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
angle: number;
|
|
8
|
+
}
|
|
9
|
+
export interface Vertex {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
bone: number;
|
|
13
|
+
weight: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function solveBones(rest: readonly Point[], angles: readonly number[]): Joint[];
|
|
16
|
+
export declare function bindVertex(x: number, y: number, rest?: readonly Point[]): Vertex;
|
|
17
|
+
/** Linear blend skinning, inverse bind translation followed by posed rotation. */
|
|
18
|
+
export declare function skinVertex(v: Vertex, joints: readonly Joint[], rest?: readonly Point[]): Point;
|
|
19
|
+
export declare function rigAngles(progress: number, count: number, tail: boolean): number[];
|
|
20
|
+
//# sourceMappingURL=character-rig.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Bind-space joint positions. Each child inherits its parent's rotation. */
|
|
2
|
+
export const TAIL_JOINTS = [
|
|
3
|
+
[284, 328], [310, 347], [324, 373], [319, 398],
|
|
4
|
+
[307, 423], [307, 448], [319, 464],
|
|
5
|
+
];
|
|
6
|
+
export function solveBones(rest, angles) {
|
|
7
|
+
return rest.reduce((out, p, i) => {
|
|
8
|
+
const parent = out[i - 1];
|
|
9
|
+
const angle = (parent?.angle ?? 0) + (angles[i] ?? 0);
|
|
10
|
+
if (!parent)
|
|
11
|
+
out.push({ x: p[0], y: p[1], angle });
|
|
12
|
+
else {
|
|
13
|
+
const prev = rest[i - 1], dx = p[0] - prev[0], dy = p[1] - prev[1];
|
|
14
|
+
const c = Math.cos(parent.angle), s = Math.sin(parent.angle);
|
|
15
|
+
out.push({ x: parent.x + dx * c - dy * s, y: parent.y + dx * s + dy * c, angle });
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}, []);
|
|
19
|
+
}
|
|
20
|
+
export function bindVertex(x, y, rest = TAIL_JOINTS) {
|
|
21
|
+
let distance = Infinity, bone = 0, weight = 0;
|
|
22
|
+
for (let i = 0; i < rest.length - 1; i++) {
|
|
23
|
+
const a = rest[i], b = rest[i + 1], dx = b[0] - a[0], dy = b[1] - a[1];
|
|
24
|
+
const t = Math.max(0, Math.min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy)));
|
|
25
|
+
const d = (x - a[0] - dx * t) ** 2 + (y - a[1] - dy * t) ** 2;
|
|
26
|
+
if (d < distance) {
|
|
27
|
+
distance = d;
|
|
28
|
+
bone = i;
|
|
29
|
+
weight = t;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { x, y, bone, weight };
|
|
33
|
+
}
|
|
34
|
+
/** Linear blend skinning, inverse bind translation followed by posed rotation. */
|
|
35
|
+
export function skinVertex(v, joints, rest = TAIL_JOINTS) {
|
|
36
|
+
let x = 0, y = 0;
|
|
37
|
+
for (let k = 0; k < 2; k++) {
|
|
38
|
+
const i = v.bone + k, p = rest[i], j = joints[i], w = k ? v.weight : 1 - v.weight;
|
|
39
|
+
const dx = v.x - p[0], dy = v.y - p[1], c = Math.cos(j.angle), s = Math.sin(j.angle);
|
|
40
|
+
x += w * (j.x + dx * c - dy * s);
|
|
41
|
+
y += w * (j.y + dx * s + dy * c);
|
|
42
|
+
}
|
|
43
|
+
return [x, y];
|
|
44
|
+
}
|
|
45
|
+
export function rigAngles(progress, count, tail) {
|
|
46
|
+
const t = Math.max(0, Math.min(1, progress));
|
|
47
|
+
const envelope = Math.sin(Math.PI * t) ** 2;
|
|
48
|
+
// Delayed distal joints create follow-through, with zero displacement/velocity
|
|
49
|
+
// at both ends; no discrete frame lookup and no idle simulation.
|
|
50
|
+
return Array.from({ length: count }, (_, i) => envelope * (tail ? (i ? .12 : 0) : .22)
|
|
51
|
+
* Math.sin(t * Math.PI * (tail ? 3 : 4) - i * (tail ? .65 : 1.1)));
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=character-rig.js.map
|
|
@@ -8,6 +8,9 @@ export const XIAOHEI_WALLPAPER_CHARACTER_CSS = `
|
|
|
8
8
|
pointer-events: none;
|
|
9
9
|
user-select: none;
|
|
10
10
|
}
|
|
11
|
+
html[data-xiaohei-sidebar-resizing] .xiaohei-wallpaper-character {
|
|
12
|
+
visibility: hidden;
|
|
13
|
+
}
|
|
11
14
|
html[data-xiaohei-appearance='light'] .xiaohei-wallpaper-character {
|
|
12
15
|
${CHARACTER_POSE_NAMES.map(pose => `--xiaohei-${pose}-art: url("${CHARACTER_POSES[pose].light}");`).join('\n ')}
|
|
13
16
|
}
|
|
@@ -21,6 +24,13 @@ html[data-xiaohei-appearance='light'] .xiaohei-wallpaper-character {
|
|
|
21
24
|
opacity: 0;
|
|
22
25
|
transition: opacity 220ms ease, transform 220ms cubic-bezier(.2,.8,.2,1);
|
|
23
26
|
}
|
|
27
|
+
.xiaohei-wallpaper-character .xiaohei-character-motion {
|
|
28
|
+
position: absolute; inset: 0; width: 100%; height: 100%;
|
|
29
|
+
object-fit: contain; object-position: left center; pointer-events: none;
|
|
30
|
+
}
|
|
31
|
+
.xiaohei-wallpaper-character > span[data-motion]:not([data-motion='rest']) {
|
|
32
|
+
background-image: none;
|
|
33
|
+
}
|
|
24
34
|
${CHARACTER_POSE_NAMES.map(pose => `
|
|
25
35
|
.xiaohei-wallpaper-character__${pose}[data-ready] {
|
|
26
36
|
background-image: var(--xiaohei-${pose}-art);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export declare const XIAOHEI_WALLPAPER_CHARACTER_ID = "dsh-theme-xiaohei/wallpaper-character";
|
|
2
|
-
/**
|
|
2
|
+
/** Places the layered character only when the host geometry is settled. */
|
|
3
3
|
export declare function installXiaoheiWallpaperCharacter(doc?: Document | undefined): () => void;
|
|
4
4
|
//# sourceMappingURL=wallpaper-character.d.ts.map
|
|
@@ -4,8 +4,9 @@ import { XIAOHEI_SCENE_LAYER_ID } from './styles.js';
|
|
|
4
4
|
import { resolveCharacterPlacement } from './character-layout.js';
|
|
5
5
|
import { createCharacterIdleController } from './character-idle.js';
|
|
6
6
|
import { CHARACTER_POSES, CHARACTER_POSE_NAMES } from './character-poses.js';
|
|
7
|
+
import { createCharacterMotion } from './character-motion.js';
|
|
7
8
|
export const XIAOHEI_WALLPAPER_CHARACTER_ID = 'dsh-theme-xiaohei/wallpaper-character';
|
|
8
|
-
/**
|
|
9
|
+
/** Places the layered character only when the host geometry is settled. */
|
|
9
10
|
export function installXiaoheiWallpaperCharacter(doc = typeof document === 'undefined' ? undefined : document) {
|
|
10
11
|
const win = doc?.defaultView;
|
|
11
12
|
if (!doc || !win)
|
|
@@ -20,6 +21,7 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
|
|
|
20
21
|
const ready = new Set();
|
|
21
22
|
const loading = new Set();
|
|
22
23
|
const failed = new Set();
|
|
24
|
+
const motion = typeof win.matchMedia === 'function' ? createCharacterMotion(doc) : undefined;
|
|
23
25
|
const idle = createCharacterIdleController({ now: () => win.performance.now(),
|
|
24
26
|
setTimeout: (fn, delay) => win.setTimeout(fn, delay), clearTimeout: handle => win.clearTimeout(handle),
|
|
25
27
|
}, () => schedule());
|
|
@@ -43,6 +45,17 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
|
|
|
43
45
|
function reconcile() {
|
|
44
46
|
if (disposed || !doc)
|
|
45
47
|
return;
|
|
48
|
+
// The glass owner already tracks the native column's animated geometry.
|
|
49
|
+
// Do not measure controls or rebuild a different rig at intermediate widths.
|
|
50
|
+
if (doc.documentElement?.hasAttribute('data-xiaohei-sidebar-resizing')) {
|
|
51
|
+
dirty = true;
|
|
52
|
+
if (host && host.dataset.pose !== 'hidden')
|
|
53
|
+
host.dataset.pose = 'hidden';
|
|
54
|
+
idle.setPaused(true);
|
|
55
|
+
motion?.setPose(undefined, 'hidden');
|
|
56
|
+
scheduleSettled();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
46
59
|
const layer = doc.getElementById(XIAOHEI_SCENE_LAYER_ID);
|
|
47
60
|
const next = [
|
|
48
61
|
doc.querySelector(XIAOHEI_HOST_SELECTORS.sidebarShell),
|
|
@@ -62,6 +75,7 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
|
|
|
62
75
|
host?.remove();
|
|
63
76
|
host = undefined;
|
|
64
77
|
idle.setPaused(true);
|
|
78
|
+
motion?.setPose(undefined, 'hidden');
|
|
65
79
|
return;
|
|
66
80
|
}
|
|
67
81
|
if (!host || host.parentElement !== layer) {
|
|
@@ -115,9 +129,11 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
|
|
|
115
129
|
if (pose === 'hidden') {
|
|
116
130
|
if (host.dataset.pose !== 'hidden')
|
|
117
131
|
host.dataset.pose = 'hidden';
|
|
132
|
+
motion?.setPose(undefined, 'hidden');
|
|
118
133
|
return;
|
|
119
134
|
}
|
|
120
135
|
if (!ready.has(pose)) {
|
|
136
|
+
motion?.setPose(undefined, 'hidden');
|
|
121
137
|
// Do not retain the old location across a layout change: it could now
|
|
122
138
|
// overlap a message. Idle changes keep the already decoded seated pose.
|
|
123
139
|
if (placement.pose !== 'seated' || !['seated', 'chin', 'doze'].includes(host.dataset.pose ?? ''))
|
|
@@ -152,6 +168,7 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
|
|
|
152
168
|
part.dataset.ready = 'true';
|
|
153
169
|
if (host.dataset.pose !== pose)
|
|
154
170
|
host.dataset.pose = pose;
|
|
171
|
+
motion?.setPose(part, pose);
|
|
155
172
|
}
|
|
156
173
|
function schedule() {
|
|
157
174
|
if (disposed || frame !== undefined)
|
|
@@ -185,6 +202,7 @@ export function installXiaoheiWallpaperCharacter(doc = typeof document === 'unde
|
|
|
185
202
|
unsubscribe();
|
|
186
203
|
observer?.disconnect();
|
|
187
204
|
idle.dispose();
|
|
205
|
+
motion?.dispose();
|
|
188
206
|
for (const event of events)
|
|
189
207
|
doc.removeEventListener(event, activity, true);
|
|
190
208
|
doc.removeEventListener('visibilitychange', visibility);
|