@doki-land/live2d-widget 0.0.0 → 0.0.11

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 CHANGED
@@ -1,3 +1,3 @@
1
1
  # @doki-land/live2d-widget
2
2
 
3
- Placeholder package (0.0.0). Reserved for doki-land/live2d.ts.
3
+ live2d.ts package 0.0.11.
@@ -0,0 +1,86 @@
1
+ import { CreateLive2DOptions, Live2DRuntime } from '@doki-land/live2d';
2
+
3
+ /**
4
+ * Optional page chrome for `@doki-land/live2d-widget`:
5
+ * speech bubble, hitokoto, and a small toolbar.
6
+ *
7
+ * Lives in the widget package so Vue / Hexo / other hosts share one shell.
8
+ * Host adaptors must only pass config — do not reimplement UI there.
9
+ */
10
+ type WidgetToolId = "hitokoto" | "photo" | "quit";
11
+ interface WidgetChromeOptions {
12
+ /** Speech bubble + page event lines. Default true when chrome is enabled. */
13
+ tips?: boolean;
14
+ /** Toolbar buttons. Default `["hitokoto", "photo", "quit"]`. */
15
+ tools?: WidgetToolId[];
16
+ /** Opening line(s). */
17
+ welcome?: string | string[];
18
+ /** Hitokoto API endpoint. Default `https://v1.hitokoto.cn`. */
19
+ hitokotoApi?: string;
20
+ /** Called after the quit tool hides the widget host. */
21
+ onQuit?: () => void;
22
+ }
23
+ interface TipMessage {
24
+ show(text: string | string[], timeoutMs?: number, priority?: number): void;
25
+ clear(): void;
26
+ destroy(): void;
27
+ }
28
+ declare const DEFAULT_WELCOME: string[];
29
+ declare function ensureChromeStyles(doc?: Document): void;
30
+ declare function createTipMessage(root: HTMLElement): TipMessage;
31
+ interface ChromeSession {
32
+ root: HTMLElement;
33
+ tips: TipMessage | null;
34
+ destroy(): void;
35
+ }
36
+ declare function mountChrome(options: {
37
+ host: HTMLElement;
38
+ canvas: HTMLCanvasElement;
39
+ chrome: boolean | WidgetChromeOptions;
40
+ getCanvas: () => HTMLCanvasElement | null;
41
+ }): ChromeSession | null;
42
+
43
+ interface WidgetOptions extends CreateLive2DOptions {
44
+ /** CSS selector or element to host the canvas. */
45
+ target: string | HTMLElement;
46
+ /** Initial model URL (model.json / model3.json / npm:…). */
47
+ model?: string;
48
+ width?: number;
49
+ height?: number;
50
+ /** Drive PARAM_ANGLE_X with a sine while playing. Default true. */
51
+ autoSway?: boolean;
52
+ /** Start the RAF update loop after mount. Default true. */
53
+ autoplay?: boolean;
54
+ /**
55
+ * Optional tips bubble + toolbar (hitokoto / photo / quit).
56
+ * Pass `true` for defaults, or a config object.
57
+ */
58
+ chrome?: boolean | WidgetChromeOptions;
59
+ /** Fired when canvas hit-test finds a drawable. */
60
+ onHit?: (payload: {
61
+ area: string;
62
+ x: number;
63
+ y: number;
64
+ }) => void;
65
+ }
66
+ /**
67
+ * Page widget shell over `@doki-land/live2d`.
68
+ * Renderer fallback is `createRenderer({ prefer })` (webgpu → webgl2 → canvas2d).
69
+ */
70
+ declare class Live2DWidget {
71
+ #private;
72
+ mount(options: WidgetOptions): Promise<void>;
73
+ destroy(): void;
74
+ getRuntime(): Live2DRuntime | null;
75
+ /** Show a tips bubble when chrome tips are enabled. */
76
+ showMessage(text: string | string[], timeoutMs?: number, priority?: number): void;
77
+ }
78
+ declare function mountWidget(options: WidgetOptions): Promise<Live2DWidget>;
79
+
80
+ /**
81
+ * `@doki-land/live2d-widget` — browser embed helper.
82
+ */
83
+
84
+ declare const LIVE2D_WIDGET_VERSION: "0.0.0";
85
+
86
+ export { DEFAULT_WELCOME, LIVE2D_WIDGET_VERSION, Live2DWidget, type WidgetChromeOptions, type WidgetOptions, type WidgetToolId, createTipMessage, ensureChromeStyles, mountChrome, mountWidget };
package/dist/index.js ADDED
@@ -0,0 +1,399 @@
1
+ // src/chrome.ts
2
+ var DEFAULT_WELCOME = [
3
+ "\u4F60\u597D\uFF01\u6211\u662F\u770B\u677F\u5A18\uFF0C\u4ECA\u5929\u4E5F\u8981\u52A0\u6CB9\u54E6\u3002",
4
+ "\u6B22\u8FCE\u56DE\u6765\u2014\u2014\u70B9\u6211\u6216\u8005\u7528\u5DE5\u5177\u680F\u8DDF\u6211\u4E92\u52A8\u5427\u3002"
5
+ ];
6
+ function pick(text) {
7
+ if (Array.isArray(text)) {
8
+ if (text.length === 0) return "";
9
+ return text[Math.floor(Math.random() * text.length)] ?? "";
10
+ }
11
+ return text;
12
+ }
13
+ var CHROME_STYLE_ID = "doki-live2d-widget-chrome-style";
14
+ var CHROME_CSS = `
15
+ .doki-live2d-chrome {
16
+ position: relative;
17
+ display: inline-block;
18
+ line-height: 0;
19
+ pointer-events: none;
20
+ }
21
+ .doki-live2d-chrome__tips {
22
+ position: absolute;
23
+ left: 12px;
24
+ right: 12px;
25
+ bottom: calc(100% - 12px);
26
+ z-index: 2;
27
+ box-sizing: border-box;
28
+ min-height: 2.5rem;
29
+ padding: 0.45rem 0.65rem;
30
+ border-radius: 0.65rem;
31
+ border: 1px solid color-mix(in srgb, #c4a574 55%, transparent);
32
+ background: color-mix(in srgb, #f3e6d0 82%, transparent);
33
+ color: #3a2a1a;
34
+ font: 0.82rem/1.45 ui-sans-serif, system-ui, sans-serif;
35
+ word-break: break-word;
36
+ opacity: 0;
37
+ pointer-events: none;
38
+ transition: opacity 0.2s ease;
39
+ }
40
+ .doki-live2d-chrome__tips.is-active {
41
+ opacity: 1;
42
+ }
43
+ .doki-live2d-chrome__tools {
44
+ position: absolute;
45
+ right: 0;
46
+ bottom: 12px;
47
+ z-index: 2;
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 0.35rem;
51
+ pointer-events: auto;
52
+ }
53
+ .doki-live2d-chrome__tool {
54
+ width: 2rem;
55
+ height: 2rem;
56
+ border: 0;
57
+ border-radius: 999px;
58
+ background: color-mix(in srgb, #2a2118 78%, transparent);
59
+ color: #f7efe4;
60
+ font: 0.72rem/1 ui-sans-serif, system-ui, sans-serif;
61
+ cursor: pointer;
62
+ }
63
+ .doki-live2d-chrome__tool:hover {
64
+ background: #2a2118;
65
+ }
66
+ .doki-live2d-chrome canvas {
67
+ pointer-events: auto;
68
+ touch-action: none;
69
+ cursor: grab;
70
+ }
71
+ `;
72
+ function ensureChromeStyles(doc = document) {
73
+ if (doc.getElementById(CHROME_STYLE_ID)) return;
74
+ const style = doc.createElement("style");
75
+ style.id = CHROME_STYLE_ID;
76
+ style.textContent = CHROME_CSS;
77
+ doc.head.appendChild(style);
78
+ }
79
+ function createTipMessage(root) {
80
+ const tips = document.createElement("div");
81
+ tips.className = "doki-live2d-chrome__tips";
82
+ tips.setAttribute("role", "status");
83
+ root.appendChild(tips);
84
+ let timer = 0;
85
+ let priority = 0;
86
+ return {
87
+ show(text, timeoutMs = 4e3, nextPriority = 1) {
88
+ const line = pick(text);
89
+ if (!line) return;
90
+ if (tips.classList.contains("is-active") && nextPriority < priority) {
91
+ return;
92
+ }
93
+ priority = nextPriority;
94
+ tips.textContent = line;
95
+ tips.classList.add("is-active");
96
+ if (timer) window.clearTimeout(timer);
97
+ timer = window.setTimeout(() => {
98
+ tips.classList.remove("is-active");
99
+ priority = 0;
100
+ timer = 0;
101
+ }, timeoutMs);
102
+ },
103
+ clear() {
104
+ if (timer) window.clearTimeout(timer);
105
+ timer = 0;
106
+ priority = 0;
107
+ tips.classList.remove("is-active");
108
+ tips.textContent = "";
109
+ },
110
+ destroy() {
111
+ this.clear();
112
+ tips.remove();
113
+ }
114
+ };
115
+ }
116
+ function bindPageTips(tips) {
117
+ const onCopy = () => {
118
+ tips.show("\u590D\u5236\u6210\u529F\u2014\u2014\u8BB0\u5F97\u6CE8\u660E\u51FA\u5904\u5440\u3002", 4e3, 2);
119
+ };
120
+ const onVisibility = () => {
121
+ if (document.visibilityState === "visible") {
122
+ tips.show("\u4F60\u56DE\u6765\u5566\uFF0C\u60F3\u6211\u4E86\u5417\uFF1F", 4e3, 2);
123
+ }
124
+ };
125
+ document.addEventListener("copy", onCopy);
126
+ document.addEventListener("visibilitychange", onVisibility);
127
+ return () => {
128
+ document.removeEventListener("copy", onCopy);
129
+ document.removeEventListener("visibilitychange", onVisibility);
130
+ };
131
+ }
132
+ async function fetchHitokoto(api) {
133
+ const res = await fetch(api);
134
+ if (!res.ok) throw new Error(`hitokoto HTTP ${res.status}`);
135
+ const data = await res.json();
136
+ if (!data.hitokoto) throw new Error("hitokoto empty");
137
+ return data.from ? `${data.hitokoto} \u2014\u2014 ${data.from}` : data.hitokoto;
138
+ }
139
+ function toolLabel(id) {
140
+ switch (id) {
141
+ case "hitokoto":
142
+ return "\u8A00";
143
+ case "photo":
144
+ return "\u62CD";
145
+ case "quit":
146
+ return "\xD7";
147
+ }
148
+ }
149
+ function mountChrome(options) {
150
+ if (!options.chrome) return null;
151
+ const cfg = options.chrome === true ? {} : options.chrome;
152
+ const tipsEnabled = cfg.tips !== false;
153
+ const tools = cfg.tools ?? ["hitokoto", "photo", "quit"];
154
+ ensureChromeStyles();
155
+ const root = document.createElement("div");
156
+ root.className = "doki-live2d-chrome";
157
+ options.host.replaceChildren(root);
158
+ root.appendChild(options.canvas);
159
+ const tips = tipsEnabled ? createTipMessage(root) : null;
160
+ const unbindTips = tips ? bindPageTips(tips) : () => {
161
+ };
162
+ if (tips) {
163
+ tips.show(cfg.welcome ?? DEFAULT_WELCOME, 5e3, 3);
164
+ }
165
+ let toolsEl = null;
166
+ if (tools.length > 0) {
167
+ toolsEl = document.createElement("div");
168
+ toolsEl.className = "doki-live2d-chrome__tools";
169
+ for (const id of tools) {
170
+ const btn = document.createElement("button");
171
+ btn.type = "button";
172
+ btn.className = "doki-live2d-chrome__tool";
173
+ btn.dataset.tool = id;
174
+ btn.title = id;
175
+ btn.textContent = toolLabel(id);
176
+ btn.addEventListener("click", () => {
177
+ void (async () => {
178
+ if (id === "hitokoto") {
179
+ try {
180
+ const line = await fetchHitokoto(
181
+ cfg.hitokotoApi ?? "https://v1.hitokoto.cn"
182
+ );
183
+ tips?.show(line, 6e3, 9);
184
+ } catch {
185
+ tips?.show("\u4E00\u8A00\u83B7\u53D6\u5931\u8D25\uFF0C\u7A0D\u540E\u518D\u8BD5\u3002", 3e3, 9);
186
+ }
187
+ return;
188
+ }
189
+ if (id === "photo") {
190
+ const canvas = options.getCanvas();
191
+ if (!canvas) return;
192
+ tips?.show("\u5494\u5693\u2014\u2014\u7167\u7247\u4FDD\u5B58\u4E2D\u3002", 3e3, 8);
193
+ const url = canvas.toDataURL("image/png");
194
+ const a = document.createElement("a");
195
+ a.href = url;
196
+ a.download = "live2d-photo.png";
197
+ a.click();
198
+ return;
199
+ }
200
+ if (id === "quit") {
201
+ tips?.show("\u518D\u89C1\u5566\uFF0C\u60F3\u6211\u7684\u65F6\u5019\u518D\u53EB\u6211\u3002", 2e3, 9);
202
+ options.host.style.display = "none";
203
+ cfg.onQuit?.();
204
+ }
205
+ })();
206
+ });
207
+ toolsEl.appendChild(btn);
208
+ }
209
+ root.appendChild(toolsEl);
210
+ }
211
+ return {
212
+ root,
213
+ tips,
214
+ destroy() {
215
+ unbindTips();
216
+ tips?.destroy();
217
+ toolsEl?.remove();
218
+ root.remove();
219
+ }
220
+ };
221
+ }
222
+
223
+ // src/widget.ts
224
+ import {
225
+ createLive2D,
226
+ focusParameterUpdates
227
+ } from "@doki-land/live2d";
228
+ var Live2DWidget = class {
229
+ #canvas = null;
230
+ #runtime = null;
231
+ #chrome = null;
232
+ #raf = 0;
233
+ #lastTs = 0;
234
+ #autoSway = true;
235
+ #onHit;
236
+ async mount(options) {
237
+ const host = typeof options.target === "string" ? document.querySelector(options.target) : options.target;
238
+ if (!host) {
239
+ throw new Error(
240
+ `@doki-land/live2d-widget: target not found: ${String(options.target)}`
241
+ );
242
+ }
243
+ this.destroy();
244
+ const width = options.width ?? 280;
245
+ const height = options.height ?? 400;
246
+ const canvas = document.createElement("canvas");
247
+ canvas.width = width;
248
+ canvas.height = height;
249
+ canvas.style.cssText = "display:block;width:100%;height:auto;pointer-events:auto;touch-action:none;background:transparent;cursor:grab;";
250
+ canvas.addEventListener("pointermove", this.#onPointerMove);
251
+ canvas.addEventListener("pointerdown", this.#onPointerDown);
252
+ const prefer = normalizePrefer(options.prefer);
253
+ const runtime = createLive2D({
254
+ backends: options.backends,
255
+ renderer: options.renderer,
256
+ prefer
257
+ });
258
+ runtime.mount(canvas);
259
+ this.#canvas = canvas;
260
+ this.#runtime = runtime;
261
+ this.#autoSway = options.autoSway !== false;
262
+ this.#onHit = options.onHit;
263
+ this.#chrome = mountChrome({
264
+ host,
265
+ canvas,
266
+ chrome: options.chrome ?? false,
267
+ getCanvas: () => this.#canvas
268
+ });
269
+ if (!this.#chrome) {
270
+ host.replaceChildren(canvas);
271
+ }
272
+ if (options.model) {
273
+ await runtime.loadModel(options.model);
274
+ }
275
+ if (options.autoplay !== false) {
276
+ this.#startLoop();
277
+ } else {
278
+ runtime.update(0);
279
+ }
280
+ }
281
+ destroy() {
282
+ this.#stopLoop();
283
+ if (this.#canvas) {
284
+ this.#canvas.removeEventListener(
285
+ "pointermove",
286
+ this.#onPointerMove
287
+ );
288
+ this.#canvas.removeEventListener(
289
+ "pointerdown",
290
+ this.#onPointerDown
291
+ );
292
+ }
293
+ this.#chrome?.destroy();
294
+ this.#chrome = null;
295
+ this.#runtime?.destroy();
296
+ this.#canvas?.remove();
297
+ this.#runtime = null;
298
+ this.#canvas = null;
299
+ this.#onHit = void 0;
300
+ }
301
+ getRuntime() {
302
+ return this.#runtime;
303
+ }
304
+ /** Show a tips bubble when chrome tips are enabled. */
305
+ showMessage(text, timeoutMs, priority) {
306
+ this.#chrome?.tips?.show(text, timeoutMs, priority);
307
+ }
308
+ #parameterFromNormalized(id, normalized) {
309
+ const binding = this.#runtime?.listParameters().find((p) => p.id === id);
310
+ if (!binding) return normalized;
311
+ return normalized >= 0 ? binding.defaultValue + (binding.max - binding.defaultValue) * normalized : binding.defaultValue + (binding.defaultValue - binding.min) * normalized;
312
+ }
313
+ #modelPoint(event) {
314
+ const canvas = this.#canvas;
315
+ if (!canvas) return null;
316
+ const rect = canvas.getBoundingClientRect();
317
+ if (!rect.width || !rect.height) return null;
318
+ return {
319
+ x: (event.clientX - rect.left) / rect.width * 2 - 1,
320
+ y: 1 - (event.clientY - rect.top) / rect.height * 2
321
+ };
322
+ }
323
+ #onPointerMove = (event) => {
324
+ const p = this.#modelPoint(event);
325
+ const runtime = this.#runtime;
326
+ if (!p || !runtime) return;
327
+ this.#autoSwayPausedByPointer = true;
328
+ for (const { id, value } of focusParameterUpdates(
329
+ runtime.listParameters(),
330
+ p.x,
331
+ p.y
332
+ )) {
333
+ runtime.setParameter(id, value);
334
+ }
335
+ };
336
+ #onPointerDown = (event) => {
337
+ const p = this.#modelPoint(event);
338
+ const runtime = this.#runtime;
339
+ if (!p || !runtime) return;
340
+ const area = runtime.hitTest(p.x, p.y);
341
+ if (area) {
342
+ this.#onHit?.({ area, x: p.x, y: p.y });
343
+ this.#chrome?.tips?.show("\u78B0\u5230\u6211\u5566\uFF5E", 2500, 4);
344
+ }
345
+ };
346
+ #autoSwayPausedByPointer = false;
347
+ #startLoop() {
348
+ this.#stopLoop();
349
+ const tick = (ts) => {
350
+ const runtime = this.#runtime;
351
+ if (!runtime) return;
352
+ const dt = this.#lastTs ? (ts - this.#lastTs) / 1e3 : 0;
353
+ this.#lastTs = ts;
354
+ if (this.#autoSway && !this.#autoSwayPausedByPointer) {
355
+ runtime.setParameter(
356
+ "PARAM_ANGLE_X",
357
+ this.#parameterFromNormalized(
358
+ "PARAM_ANGLE_X",
359
+ Math.sin(ts / 1e3) * 0.25
360
+ )
361
+ );
362
+ }
363
+ if (this.#autoSwayPausedByPointer) {
364
+ this.#autoSwayPausedByPointer = false;
365
+ }
366
+ runtime.update(dt);
367
+ this.#raf = requestAnimationFrame(tick);
368
+ };
369
+ this.#raf = requestAnimationFrame(tick);
370
+ }
371
+ #stopLoop() {
372
+ if (this.#raf) cancelAnimationFrame(this.#raf);
373
+ this.#raf = 0;
374
+ this.#lastTs = 0;
375
+ }
376
+ };
377
+ function normalizePrefer(prefer) {
378
+ if (!prefer?.length) return void 0;
379
+ const allowed = /* @__PURE__ */ new Set(["webgpu", "webgl2", "canvas2d"]);
380
+ const out = prefer.filter((k) => allowed.has(k));
381
+ return out.length ? out : void 0;
382
+ }
383
+ async function mountWidget(options) {
384
+ const widget = new Live2DWidget();
385
+ await widget.mount(options);
386
+ return widget;
387
+ }
388
+
389
+ // src/index.ts
390
+ var LIVE2D_WIDGET_VERSION = "0.0.0";
391
+ export {
392
+ DEFAULT_WELCOME,
393
+ LIVE2D_WIDGET_VERSION,
394
+ Live2DWidget,
395
+ createTipMessage,
396
+ ensureChromeStyles,
397
+ mountChrome,
398
+ mountWidget
399
+ };
package/package.json CHANGED
@@ -1,7 +1,46 @@
1
1
  {
2
2
  "name": "@doki-land/live2d-widget",
3
- "version": "0.0.0",
4
- "description": "live2d.ts placeholder not for production use.",
3
+ "version": "0.0.11",
4
+ "description": "Browser canvas widget for @doki-land/live2d",
5
+ "type": "module",
5
6
  "license": "MIT",
6
- "private": false
7
+ "author": "Doki Land",
8
+ "keywords": [
9
+ "live2d",
10
+ "widget",
11
+ "doki-land"
12
+ ],
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ }
32
+ },
33
+ "scripts": {
34
+ "build": "tsup src/index.ts --format esm --dts",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run --passWithNoTests"
37
+ },
38
+ "dependencies": {
39
+ "@doki-land/live2d": "0.0.11"
40
+ },
41
+ "sideEffects": false,
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/doki-land/live2d.ts.git"
45
+ }
7
46
  }
package/src/chrome.ts ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Optional page chrome for `@doki-land/live2d-widget`:
3
+ * speech bubble, hitokoto, and a small toolbar.
4
+ *
5
+ * Lives in the widget package so Vue / Hexo / other hosts share one shell.
6
+ * Host adaptors must only pass config — do not reimplement UI there.
7
+ */
8
+
9
+ export type WidgetToolId = "hitokoto" | "photo" | "quit";
10
+
11
+ export interface WidgetChromeOptions {
12
+ /** Speech bubble + page event lines. Default true when chrome is enabled. */
13
+ tips?: boolean;
14
+ /** Toolbar buttons. Default `["hitokoto", "photo", "quit"]`. */
15
+ tools?: WidgetToolId[];
16
+ /** Opening line(s). */
17
+ welcome?: string | string[];
18
+ /** Hitokoto API endpoint. Default `https://v1.hitokoto.cn`. */
19
+ hitokotoApi?: string;
20
+ /** Called after the quit tool hides the widget host. */
21
+ onQuit?: () => void;
22
+ }
23
+
24
+ export interface TipMessage {
25
+ show(text: string | string[], timeoutMs?: number, priority?: number): void;
26
+ clear(): void;
27
+ destroy(): void;
28
+ }
29
+
30
+ const DEFAULT_WELCOME = [
31
+ "你好!我是看板娘,今天也要加油哦。",
32
+ "欢迎回来——点我或者用工具栏跟我互动吧。",
33
+ ];
34
+
35
+ function pick(text: string | string[]): string {
36
+ if (Array.isArray(text)) {
37
+ if (text.length === 0) return "";
38
+ return text[Math.floor(Math.random() * text.length)] ?? "";
39
+ }
40
+ return text;
41
+ }
42
+
43
+ const CHROME_STYLE_ID = "doki-live2d-widget-chrome-style";
44
+
45
+ const CHROME_CSS = `
46
+ .doki-live2d-chrome {
47
+ position: relative;
48
+ display: inline-block;
49
+ line-height: 0;
50
+ pointer-events: none;
51
+ }
52
+ .doki-live2d-chrome__tips {
53
+ position: absolute;
54
+ left: 12px;
55
+ right: 12px;
56
+ bottom: calc(100% - 12px);
57
+ z-index: 2;
58
+ box-sizing: border-box;
59
+ min-height: 2.5rem;
60
+ padding: 0.45rem 0.65rem;
61
+ border-radius: 0.65rem;
62
+ border: 1px solid color-mix(in srgb, #c4a574 55%, transparent);
63
+ background: color-mix(in srgb, #f3e6d0 82%, transparent);
64
+ color: #3a2a1a;
65
+ font: 0.82rem/1.45 ui-sans-serif, system-ui, sans-serif;
66
+ word-break: break-word;
67
+ opacity: 0;
68
+ pointer-events: none;
69
+ transition: opacity 0.2s ease;
70
+ }
71
+ .doki-live2d-chrome__tips.is-active {
72
+ opacity: 1;
73
+ }
74
+ .doki-live2d-chrome__tools {
75
+ position: absolute;
76
+ right: 0;
77
+ bottom: 12px;
78
+ z-index: 2;
79
+ display: flex;
80
+ flex-direction: column;
81
+ gap: 0.35rem;
82
+ pointer-events: auto;
83
+ }
84
+ .doki-live2d-chrome__tool {
85
+ width: 2rem;
86
+ height: 2rem;
87
+ border: 0;
88
+ border-radius: 999px;
89
+ background: color-mix(in srgb, #2a2118 78%, transparent);
90
+ color: #f7efe4;
91
+ font: 0.72rem/1 ui-sans-serif, system-ui, sans-serif;
92
+ cursor: pointer;
93
+ }
94
+ .doki-live2d-chrome__tool:hover {
95
+ background: #2a2118;
96
+ }
97
+ .doki-live2d-chrome canvas {
98
+ pointer-events: auto;
99
+ touch-action: none;
100
+ cursor: grab;
101
+ }
102
+ `;
103
+
104
+ export function ensureChromeStyles(doc: Document = document): void {
105
+ if (doc.getElementById(CHROME_STYLE_ID)) return;
106
+ const style = doc.createElement("style");
107
+ style.id = CHROME_STYLE_ID;
108
+ style.textContent = CHROME_CSS;
109
+ doc.head.appendChild(style);
110
+ }
111
+
112
+ export function createTipMessage(root: HTMLElement): TipMessage {
113
+ const tips = document.createElement("div");
114
+ tips.className = "doki-live2d-chrome__tips";
115
+ tips.setAttribute("role", "status");
116
+ root.appendChild(tips);
117
+
118
+ let timer = 0;
119
+ let priority = 0;
120
+
121
+ return {
122
+ show(text, timeoutMs = 4000, nextPriority = 1) {
123
+ const line = pick(text);
124
+ if (!line) return;
125
+ if (
126
+ tips.classList.contains("is-active") &&
127
+ nextPriority < priority
128
+ ) {
129
+ return;
130
+ }
131
+ priority = nextPriority;
132
+ tips.textContent = line;
133
+ tips.classList.add("is-active");
134
+ if (timer) window.clearTimeout(timer);
135
+ timer = window.setTimeout(() => {
136
+ tips.classList.remove("is-active");
137
+ priority = 0;
138
+ timer = 0;
139
+ }, timeoutMs);
140
+ },
141
+ clear() {
142
+ if (timer) window.clearTimeout(timer);
143
+ timer = 0;
144
+ priority = 0;
145
+ tips.classList.remove("is-active");
146
+ tips.textContent = "";
147
+ },
148
+ destroy() {
149
+ this.clear();
150
+ tips.remove();
151
+ },
152
+ };
153
+ }
154
+
155
+ export interface ChromeSession {
156
+ root: HTMLElement;
157
+ tips: TipMessage | null;
158
+ destroy(): void;
159
+ }
160
+
161
+ function bindPageTips(tips: TipMessage): () => void {
162
+ const onCopy = () => {
163
+ tips.show("复制成功——记得注明出处呀。", 4000, 2);
164
+ };
165
+ const onVisibility = () => {
166
+ if (document.visibilityState === "visible") {
167
+ tips.show("你回来啦,想我了吗?", 4000, 2);
168
+ }
169
+ };
170
+ document.addEventListener("copy", onCopy);
171
+ document.addEventListener("visibilitychange", onVisibility);
172
+ return () => {
173
+ document.removeEventListener("copy", onCopy);
174
+ document.removeEventListener("visibilitychange", onVisibility);
175
+ };
176
+ }
177
+
178
+ async function fetchHitokoto(api: string): Promise<string> {
179
+ const res = await fetch(api);
180
+ if (!res.ok) throw new Error(`hitokoto HTTP ${res.status}`);
181
+ const data = (await res.json()) as { hitokoto?: string; from?: string };
182
+ if (!data.hitokoto) throw new Error("hitokoto empty");
183
+ return data.from ? `${data.hitokoto} —— ${data.from}` : data.hitokoto;
184
+ }
185
+
186
+ function toolLabel(id: WidgetToolId): string {
187
+ switch (id) {
188
+ case "hitokoto":
189
+ return "言";
190
+ case "photo":
191
+ return "拍";
192
+ case "quit":
193
+ return "×";
194
+ }
195
+ }
196
+
197
+ export function mountChrome(options: {
198
+ host: HTMLElement;
199
+ canvas: HTMLCanvasElement;
200
+ chrome: boolean | WidgetChromeOptions;
201
+ getCanvas: () => HTMLCanvasElement | null;
202
+ }): ChromeSession | null {
203
+ if (!options.chrome) return null;
204
+ const cfg: WidgetChromeOptions =
205
+ options.chrome === true ? {} : options.chrome;
206
+ const tipsEnabled = cfg.tips !== false;
207
+ const tools =
208
+ cfg.tools ?? (["hitokoto", "photo", "quit"] as WidgetToolId[]);
209
+
210
+ ensureChromeStyles();
211
+
212
+ const root = document.createElement("div");
213
+ root.className = "doki-live2d-chrome";
214
+ options.host.replaceChildren(root);
215
+ root.appendChild(options.canvas);
216
+
217
+ const tips = tipsEnabled ? createTipMessage(root) : null;
218
+ const unbindTips = tips ? bindPageTips(tips) : () => {};
219
+
220
+ if (tips) {
221
+ tips.show(cfg.welcome ?? DEFAULT_WELCOME, 5000, 3);
222
+ }
223
+
224
+ let toolsEl: HTMLElement | null = null;
225
+ if (tools.length > 0) {
226
+ toolsEl = document.createElement("div");
227
+ toolsEl.className = "doki-live2d-chrome__tools";
228
+ for (const id of tools) {
229
+ const btn = document.createElement("button");
230
+ btn.type = "button";
231
+ btn.className = "doki-live2d-chrome__tool";
232
+ btn.dataset.tool = id;
233
+ btn.title = id;
234
+ btn.textContent = toolLabel(id);
235
+ btn.addEventListener("click", () => {
236
+ void (async () => {
237
+ if (id === "hitokoto") {
238
+ try {
239
+ const line = await fetchHitokoto(
240
+ cfg.hitokotoApi ?? "https://v1.hitokoto.cn",
241
+ );
242
+ tips?.show(line, 6000, 9);
243
+ } catch {
244
+ tips?.show("一言获取失败,稍后再试。", 3000, 9);
245
+ }
246
+ return;
247
+ }
248
+ if (id === "photo") {
249
+ const canvas = options.getCanvas();
250
+ if (!canvas) return;
251
+ tips?.show("咔嚓——照片保存中。", 3000, 8);
252
+ const url = canvas.toDataURL("image/png");
253
+ const a = document.createElement("a");
254
+ a.href = url;
255
+ a.download = "live2d-photo.png";
256
+ a.click();
257
+ return;
258
+ }
259
+ if (id === "quit") {
260
+ tips?.show("再见啦,想我的时候再叫我。", 2000, 9);
261
+ options.host.style.display = "none";
262
+ cfg.onQuit?.();
263
+ }
264
+ })();
265
+ });
266
+ toolsEl.appendChild(btn);
267
+ }
268
+ root.appendChild(toolsEl);
269
+ }
270
+
271
+ return {
272
+ root,
273
+ tips,
274
+ destroy() {
275
+ unbindTips();
276
+ tips?.destroy();
277
+ toolsEl?.remove();
278
+ root.remove();
279
+ },
280
+ };
281
+ }
282
+
283
+ export { DEFAULT_WELCOME };
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@doki-land/live2d-widget` — browser embed helper.
3
+ */
4
+
5
+ export {
6
+ createTipMessage,
7
+ DEFAULT_WELCOME,
8
+ ensureChromeStyles,
9
+ mountChrome,
10
+ } from "./chrome.js";
11
+ export {
12
+ Live2DWidget,
13
+ mountWidget,
14
+ type WidgetChromeOptions,
15
+ type WidgetOptions,
16
+ type WidgetToolId,
17
+ } from "./widget.js";
18
+
19
+ export const LIVE2D_WIDGET_VERSION = "0.0.0" as const;
package/src/widget.ts ADDED
@@ -0,0 +1,238 @@
1
+ import {
2
+ type CreateLive2DOptions,
3
+ createLive2D,
4
+ focusParameterUpdates,
5
+ type Live2DRuntime,
6
+ type RendererKind,
7
+ } from "@doki-land/live2d";
8
+ import {
9
+ type ChromeSession,
10
+ mountChrome,
11
+ type WidgetChromeOptions,
12
+ } from "./chrome.js";
13
+
14
+ export type { WidgetChromeOptions, WidgetToolId } from "./chrome.js";
15
+
16
+ export interface WidgetOptions extends CreateLive2DOptions {
17
+ /** CSS selector or element to host the canvas. */
18
+ target: string | HTMLElement;
19
+ /** Initial model URL (model.json / model3.json / npm:…). */
20
+ model?: string;
21
+ width?: number;
22
+ height?: number;
23
+ /** Drive PARAM_ANGLE_X with a sine while playing. Default true. */
24
+ autoSway?: boolean;
25
+ /** Start the RAF update loop after mount. Default true. */
26
+ autoplay?: boolean;
27
+ /**
28
+ * Optional tips bubble + toolbar (hitokoto / photo / quit).
29
+ * Pass `true` for defaults, or a config object.
30
+ */
31
+ chrome?: boolean | WidgetChromeOptions;
32
+ /** Fired when canvas hit-test finds a drawable. */
33
+ onHit?: (payload: { area: string; x: number; y: number }) => void;
34
+ }
35
+
36
+ /**
37
+ * Page widget shell over `@doki-land/live2d`.
38
+ * Renderer fallback is `createRenderer({ prefer })` (webgpu → webgl2 → canvas2d).
39
+ */
40
+ export class Live2DWidget {
41
+ #canvas: HTMLCanvasElement | null = null;
42
+ #runtime: Live2DRuntime | null = null;
43
+ #chrome: ChromeSession | null = null;
44
+ #raf = 0;
45
+ #lastTs = 0;
46
+ #autoSway = true;
47
+ #onHit: WidgetOptions["onHit"];
48
+
49
+ async mount(options: WidgetOptions): Promise<void> {
50
+ const host =
51
+ typeof options.target === "string"
52
+ ? document.querySelector<HTMLElement>(options.target)
53
+ : options.target;
54
+ if (!host) {
55
+ throw new Error(
56
+ `@doki-land/live2d-widget: target not found: ${String(options.target)}`,
57
+ );
58
+ }
59
+
60
+ this.destroy();
61
+
62
+ const width = options.width ?? 280;
63
+ const height = options.height ?? 400;
64
+ const canvas = document.createElement("canvas");
65
+ canvas.width = width;
66
+ canvas.height = height;
67
+ canvas.style.cssText =
68
+ "display:block;width:100%;height:auto;pointer-events:auto;touch-action:none;background:transparent;cursor:grab;";
69
+ canvas.addEventListener("pointermove", this.#onPointerMove);
70
+ canvas.addEventListener("pointerdown", this.#onPointerDown);
71
+
72
+ const prefer = normalizePrefer(options.prefer);
73
+ const runtime = createLive2D({
74
+ backends: options.backends,
75
+ renderer: options.renderer,
76
+ prefer,
77
+ });
78
+ runtime.mount(canvas);
79
+
80
+ this.#canvas = canvas;
81
+ this.#runtime = runtime;
82
+ this.#autoSway = options.autoSway !== false;
83
+ this.#onHit = options.onHit;
84
+ this.#chrome = mountChrome({
85
+ host,
86
+ canvas,
87
+ chrome: options.chrome ?? false,
88
+ getCanvas: () => this.#canvas,
89
+ });
90
+ if (!this.#chrome) {
91
+ host.replaceChildren(canvas);
92
+ }
93
+
94
+ if (options.model) {
95
+ await runtime.loadModel(options.model);
96
+ }
97
+
98
+ if (options.autoplay !== false) {
99
+ this.#startLoop();
100
+ } else {
101
+ runtime.update(0);
102
+ }
103
+ }
104
+
105
+ destroy(): void {
106
+ this.#stopLoop();
107
+ if (this.#canvas) {
108
+ this.#canvas.removeEventListener(
109
+ "pointermove",
110
+ this.#onPointerMove,
111
+ );
112
+ this.#canvas.removeEventListener(
113
+ "pointerdown",
114
+ this.#onPointerDown,
115
+ );
116
+ }
117
+ this.#chrome?.destroy();
118
+ this.#chrome = null;
119
+ this.#runtime?.destroy();
120
+ this.#canvas?.remove();
121
+ this.#runtime = null;
122
+ this.#canvas = null;
123
+ this.#onHit = undefined;
124
+ }
125
+
126
+ getRuntime(): Live2DRuntime | null {
127
+ return this.#runtime;
128
+ }
129
+
130
+ /** Show a tips bubble when chrome tips are enabled. */
131
+ showMessage(
132
+ text: string | string[],
133
+ timeoutMs?: number,
134
+ priority?: number,
135
+ ): void {
136
+ this.#chrome?.tips?.show(text, timeoutMs, priority);
137
+ }
138
+
139
+ #parameterFromNormalized(id: string, normalized: number): number {
140
+ const binding = this.#runtime
141
+ ?.listParameters()
142
+ .find((p) => p.id === id);
143
+ if (!binding) return normalized;
144
+ return normalized >= 0
145
+ ? binding.defaultValue +
146
+ (binding.max - binding.defaultValue) * normalized
147
+ : binding.defaultValue +
148
+ (binding.defaultValue - binding.min) * normalized;
149
+ }
150
+
151
+ #modelPoint(event: PointerEvent): { x: number; y: number } | null {
152
+ const canvas = this.#canvas;
153
+ if (!canvas) return null;
154
+ const rect = canvas.getBoundingClientRect();
155
+ if (!rect.width || !rect.height) return null;
156
+ return {
157
+ x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
158
+ y: 1 - ((event.clientY - rect.top) / rect.height) * 2,
159
+ };
160
+ }
161
+
162
+ #onPointerMove = (event: PointerEvent): void => {
163
+ const p = this.#modelPoint(event);
164
+ const runtime = this.#runtime;
165
+ if (!p || !runtime) return;
166
+ // Pointer tracking overrides auto-sway for ANGLE_X while moving.
167
+ this.#autoSwayPausedByPointer = true;
168
+ for (const { id, value } of focusParameterUpdates(
169
+ runtime.listParameters(),
170
+ p.x,
171
+ p.y,
172
+ )) {
173
+ runtime.setParameter(id, value);
174
+ }
175
+ };
176
+
177
+ #onPointerDown = (event: PointerEvent): void => {
178
+ const p = this.#modelPoint(event);
179
+ const runtime = this.#runtime;
180
+ if (!p || !runtime) return;
181
+ const area = runtime.hitTest(p.x, p.y);
182
+ if (area) {
183
+ this.#onHit?.({ area, x: p.x, y: p.y });
184
+ this.#chrome?.tips?.show("碰到我啦~", 2500, 4);
185
+ }
186
+ };
187
+
188
+ #autoSwayPausedByPointer = false;
189
+
190
+ #startLoop(): void {
191
+ this.#stopLoop();
192
+ const tick = (ts: number) => {
193
+ const runtime = this.#runtime;
194
+ if (!runtime) return;
195
+ const dt = this.#lastTs ? (ts - this.#lastTs) / 1000 : 0;
196
+ this.#lastTs = ts;
197
+ if (this.#autoSway && !this.#autoSwayPausedByPointer) {
198
+ runtime.setParameter(
199
+ "PARAM_ANGLE_X",
200
+ this.#parameterFromNormalized(
201
+ "PARAM_ANGLE_X",
202
+ Math.sin(ts / 1000) * 0.25,
203
+ ),
204
+ );
205
+ }
206
+ // Resume sway shortly after the last pointer sample.
207
+ if (this.#autoSwayPausedByPointer) {
208
+ this.#autoSwayPausedByPointer = false;
209
+ }
210
+ runtime.update(dt);
211
+ this.#raf = requestAnimationFrame(tick);
212
+ };
213
+ this.#raf = requestAnimationFrame(tick);
214
+ }
215
+
216
+ #stopLoop(): void {
217
+ if (this.#raf) cancelAnimationFrame(this.#raf);
218
+ this.#raf = 0;
219
+ this.#lastTs = 0;
220
+ }
221
+ }
222
+
223
+ function normalizePrefer(
224
+ prefer: RendererKind[] | undefined,
225
+ ): RendererKind[] | undefined {
226
+ if (!prefer?.length) return undefined;
227
+ const allowed = new Set<RendererKind>(["webgpu", "webgl2", "canvas2d"]);
228
+ const out = prefer.filter((k): k is RendererKind => allowed.has(k));
229
+ return out.length ? out : undefined;
230
+ }
231
+
232
+ export async function mountWidget(
233
+ options: WidgetOptions,
234
+ ): Promise<Live2DWidget> {
235
+ const widget = new Live2DWidget();
236
+ await widget.mount(options);
237
+ return widget;
238
+ }