@lingfan-engine/create 0.2.0 → 0.3.1

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.
Files changed (66) hide show
  1. package/bin/create.mjs +1 -1
  2. package/package.json +1 -1
  3. package/src/cli/cli.ts +11 -0
  4. package/src/generate/project.ts +13 -6
  5. package/src/index.ts +7 -0
  6. package/src/registry/compat.ts +49 -0
  7. package/src/registry/index.ts +2 -1
  8. package/src/shared/index.ts +8 -1
  9. package/src/shared/semver.ts +115 -0
  10. package/src/shared/texts.ts +1 -1
  11. package/template/README.md +59 -21
  12. package/template/__PROJECT__/Resources/Stories/title/title_main.story +15 -4
  13. package/template/__PROJECT__/index.html +7 -0
  14. package/template/__PROJECT__/package.json +5 -3
  15. package/template/__PROJECT__/src/controls/index.ts +7 -0
  16. package/template/__PROJECT__/src/controls/store.ts +32 -0
  17. package/template/__PROJECT__/src/gen/ui_register.g.ts +5 -0
  18. package/template/__PROJECT__/src/host/boot.ts +200 -5
  19. package/template/__PROJECT__/src/host/controls.ts +77 -0
  20. package/template/__PROJECT__/src/host/engine-wiring.ts +75 -0
  21. package/template/__PROJECT__/src/host/index.ts +37 -1
  22. package/template/__PROJECT__/src/host/input-map.ts +18 -3
  23. package/template/__PROJECT__/src/host/layer-z.ts +6 -0
  24. package/template/__PROJECT__/src/host/mount/index.ts +18 -0
  25. package/template/__PROJECT__/src/host/mount/renderer.ts +128 -0
  26. package/template/__PROJECT__/src/host/mount/table.ts +52 -0
  27. package/template/__PROJECT__/src/host/panels/history.ts +193 -0
  28. package/template/__PROJECT__/src/host/panels/preferences.ts +216 -0
  29. package/template/__PROJECT__/src/host/save-slots.ts +52 -4
  30. package/template/__PROJECT__/src/host/stage-dom.ts +36 -0
  31. package/template/__PROJECT__/src/host/thumbnails.ts +101 -0
  32. package/template/__PROJECT__/src/main.ts +30 -8
  33. package/template/__PROJECT__/src/mounts.ts +22 -0
  34. package/template/__PROJECT__/src/stories/index.ts +10 -0
  35. package/template/__PROJECT__/src/styles/stage.css +114 -0
  36. package/template/__PROJECT__/src/vite-env.d.ts +9 -0
  37. package/template/__PROJECT__/tsconfig.node.json +2 -1
  38. package/template/__PROJECT__/vite.config.ts +7 -52
  39. package/template/__PROJECT__/vite.plugins.ts +49 -0
  40. package/template/overlays/README.md +9 -3
  41. package/template/overlays/miniprogram/package.patch.json +1 -1
  42. package/template/overlays/miniprogram/src/mp/host.ts +5 -1
  43. package/template/overlays/miniprogram/vite.config.ts +8 -47
  44. package/template/overlays/react/src/App.tsx +12 -0
  45. package/template/overlays/react/src/UiHost.tsx +62 -0
  46. package/template/overlays/react/src/main.tsx +27 -5
  47. package/template/overlays/react/src/vite-env.d.ts +12 -0
  48. package/template/overlays/react/vite.config.ts +6 -49
  49. package/template/overlays/tauri/src/main.ts +23 -4
  50. package/template/overlays/ts/package.patch.json +1 -2
  51. package/template/overlays/ts/{Stories.src → src/Stories.src}/title/title_main.ts +6 -0
  52. package/template/overlays/ts/tsconfig.stories.json +1 -1
  53. package/template/overlays/vue/src/App.vue +13 -0
  54. package/template/overlays/vue/src/UiHost.vue +65 -0
  55. package/template/overlays/vue/src/main.ts +28 -5
  56. package/template/overlays/vue/src/vite-env.d.ts +12 -0
  57. package/template/overlays/vue/vite.config.ts +6 -49
  58. package/template/template-meta.json +8 -8
  59. package/template/__PROJECT__/src/stories.ts +0 -11
  60. package/template/overlays/ts/src/stories.ts +0 -10
  61. /package/template/overlays/ts/{Stories.src → src/Stories.src}/config.ts +0 -0
  62. /package/template/overlays/ts/{Stories.src → src/Stories.src}/title/look_around.ts +0 -0
  63. /package/template/overlays/ts/{Stories.src → src}/gen/font.gen.d.ts +0 -0
  64. /package/template/overlays/ts/{Stories.src → src}/gen/script.gen.d.ts +0 -0
  65. /package/template/overlays/ts/{Stories.src → src}/gen/state.gen.d.ts +0 -0
  66. /package/template/overlays/ts/{Stories.src → src}/gen/stories.gen.d.ts +0 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 原生形态的挂载渲染器:把 `SYS.mounts` 落到真实 DOM 上。
3
+ *
4
+ * 框架形态靠各自的响应式系统重渲染,原生形态没有那层——它必须自己算差量:
5
+ * 谁新出现就造节点挂上,谁消失了就拆掉,谁只是换了入参就**整份重挂**(不是改属性)。
6
+ * 差量与投影都不在这里(`@lingfan-engine/ui` 的 mount 域),本文件只做三件 DOM 事:
7
+ * 追加、移除、调拆除钩子。
8
+ *
9
+ * 隐藏 = 从渲染树移除,不是 `display: none`:容器里那一项真的不在了,
10
+ * 组件自己的定时器、订阅、媒体元素随拆除钩子一起停掉。
11
+ *
12
+ * 挂载失败一律 fail-closed 上报、不渲染那一条:一个 UI 写错不该黑掉整屏,
13
+ * 也不该静默消失——作者要能看见「哪个名字没挂上」。
14
+ */
15
+ import {
16
+ mountProps,
17
+ planMounts,
18
+ visibleMounts,
19
+ type MountPlan,
20
+ } from "@lingfan-engine/ui";
21
+ import type { HostControls } from "@lingfan-engine/core";
22
+ import { resolveNativeFactory, type NativeUiInstance } from "./table";
23
+
24
+ /** 渲染器输入:容器、生成物表、控制面、诊断出口 */
25
+ export interface NativeMountRendererOptions {
26
+ /** 挂载容器:子节点就是当前可见的 UI(按挂载次序排列) */
27
+ container: HTMLElement;
28
+ /** 「名字 → 工厂」表(生成物 `uis`) */
29
+ table: Readonly<Record<string, unknown>>;
30
+ /**
31
+ * 交给每个 UI 的操作句柄(暂停、换栏、存读档)。
32
+ *
33
+ * 与 `props` 分属两条通道:`props` 是故事值(存档随行、必须 JSON 安全),
34
+ * 这里这个含函数、运行期注入、不进快照,所以从选项走而不是塞进 props。
35
+ */
36
+ controls: HostControls;
37
+ /** 诊断出口:挂不上、拆除失败都从这里出去,不吞 */
38
+ reportError(message: string): void;
39
+ }
40
+
41
+ /** 渲染器:喂状态、拆干净 */
42
+ export interface NativeMountRenderer {
43
+ /** 推进到新状态(首帧传 `undefined` 也可) */
44
+ sync(raw: unknown): void;
45
+ /** 拆掉全部已挂 UI 并退订钩子;之后 `sync` 不再有效 */
46
+ dispose(): void;
47
+ }
48
+
49
+ /**
50
+ * 造一个原生挂载渲染器。
51
+ *
52
+ * 已挂的节点**不重排**:顺序由首次挂载决定。重排会让组件内部状态、滚动位置与焦点
53
+ * 在每次状态变化时抖一下——那份收益(与框架形态顺序完全一致)不值这个代价。
54
+ */
55
+ export function createNativeMountRenderer(
56
+ options: NativeMountRendererOptions,
57
+ ): NativeMountRenderer {
58
+ const mounted = new Map<string, NativeUiInstance>();
59
+ /** 已上报过的「挂不上」名字:同一条诊断不每帧刷屏,名字消失后再出现会重新报 */
60
+ const reported = new Set<string>();
61
+ let previous: unknown = undefined;
62
+ let disposed = false;
63
+
64
+ function unmountOne(name: string): void {
65
+ const instance = mounted.get(name);
66
+ if (instance === undefined) return;
67
+ mounted.delete(name);
68
+ try {
69
+ instance.dispose?.();
70
+ } catch (error) {
71
+ options.reportError(`UI ${name} 拆除失败:${String(error)}`);
72
+ }
73
+ if (instance.element.parentNode === options.container) {
74
+ options.container.removeChild(instance.element);
75
+ }
76
+ }
77
+
78
+ function mountOne(name: string, raw: unknown): void {
79
+ const factory = resolveNativeFactory(options.table, name);
80
+ if (factory === undefined) {
81
+ if (!reported.has(name)) {
82
+ reported.add(name);
83
+ options.reportError(
84
+ `找不到 UI 的工厂:${name}(生成物 uis 里没有这个名字,或它不是函数——原生形态的 UI 必须导出工厂)`,
85
+ );
86
+ }
87
+ return;
88
+ }
89
+ let instance: NativeUiInstance;
90
+ try {
91
+ instance = factory(mountProps(raw, name) ?? {}, options.controls);
92
+ } catch (error) {
93
+ options.reportError(`UI ${name} 挂载失败:${String(error)}`);
94
+ return;
95
+ }
96
+ reported.delete(name);
97
+ mounted.set(name, instance);
98
+ options.container.appendChild(instance.element);
99
+ }
100
+
101
+ function apply(plan: MountPlan, raw: unknown): void {
102
+ // 先卸再挂:同时存在新旧两份时,焦点、滚动位置与无障碍树都会出现两个答案
103
+ for (const name of plan.unmount) unmountOne(name);
104
+ for (const name of plan.update) unmountOne(name);
105
+ for (const name of plan.update) mountOne(name, raw);
106
+ for (const name of plan.mount) mountOne(name, raw);
107
+ // 已上报过的名字若已不可见,忘掉它:下次再挂同名的失败要能重新出声
108
+ const visible = new Set(visibleMounts(raw));
109
+ for (const name of [...reported]) {
110
+ if (!visible.has(name)) reported.delete(name);
111
+ }
112
+ }
113
+
114
+ return {
115
+ sync(raw: unknown): void {
116
+ if (disposed) return;
117
+ const plan = planMounts(previous, raw);
118
+ previous = raw;
119
+ apply(plan, raw);
120
+ },
121
+ dispose(): void {
122
+ if (disposed) return;
123
+ disposed = true;
124
+ for (const name of [...mounted.keys()]) unmountOne(name);
125
+ reported.clear();
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 原生形态的「名字 → UI 工厂」表。
3
+ *
4
+ * 框架形态里 UI 就是组件对象,原生形态没有组件这个概念——UI 是一个**函数**:
5
+ * 拿入参、造出一棵 DOM 子树、返回节点与拆除钩子。表由生成物 `src/gen/ui_register.g.ts`
6
+ * 提供(名字即 `src/ui/<名>/` 目录名),本文件只负责**取表项并验形**。
7
+ *
8
+ * 为什么不在渲染器里直接 `table[name]`:生成物是 `Record<string, unknown>`,值可能因为
9
+ * 作者把 `.vue` 写进原生工程而变成组件对象。那种情况必须 fail-closed 并说清原因,
10
+ * 而不是让渲染器在 `factory is not a function` 上崩掉整屏。
11
+ */
12
+ import type { HostControls } from "@lingfan-engine/core";
13
+
14
+ /** 一份已挂载的原生 UI:一个根节点 + 可选拆除钩子 */
15
+ export interface NativeUiInstance {
16
+ /** 挂进容器的根节点;渲染器只 append / remove 它,不看它内部 */
17
+ readonly element: HTMLElement;
18
+ /** 拆除:退订、清定时器、释放资源;渲染器在卸载与整份重挂时调用一次 */
19
+ dispose?(): void;
20
+ }
21
+
22
+ /**
23
+ * 原生形态的 UI:入参进,节点出。
24
+ *
25
+ * 两个参数是两条不同的通道:`props` 是故事命令给出的值(JSON 安全、随快照存档),
26
+ * `controls` 是宿主给的操作句柄(含函数,不存档)。UI 要显示什么读前者,
27
+ * 要操作引擎(暂停、换栏、存读档)用后者。
28
+ */
29
+ export type NativeUiFactory = (
30
+ props: Readonly<Record<string, unknown>>,
31
+ controls: HostControls,
32
+ ) => NativeUiInstance;
33
+
34
+ /** 「名字 → 工厂」表(生成物 `uis` 的原生形态投影) */
35
+ export type NativeUiTable = Readonly<Record<string, NativeUiFactory>>;
36
+
37
+ /**
38
+ * 取某个名字的工厂;取不到或形态不对时返回 `undefined`(由调用方决定怎么报)。
39
+ *
40
+ * 形态判据是「函数」而不是「对象」:原生形态的 UI 是工厂,Vue / React 的组件对象
41
+ * 也是函数或带 `setup` 的对象——两者在 `typeof` 上都可能像函数,所以这里不猜,
42
+ * 只认调用结果:真调不通时由渲染器捕获并报一条带名字的诊断。
43
+ */
44
+ export function resolveNativeFactory(
45
+ table: Readonly<Record<string, unknown>>,
46
+ name: string,
47
+ ): NativeUiFactory | undefined {
48
+ const candidate = table[name];
49
+ return typeof candidate === "function"
50
+ ? (candidate as NativeUiFactory)
51
+ : undefined;
52
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * 历史面板(纯 DOM):已走过的对话按「呈现块」列出,点一块就回退到那个检查点。
3
+ *
4
+ * 两件事分开,与偏好面板同一口径:
5
+ * - **数据整形**归 `@lingfan-engine/shell` 的 `filterHistoryEntries` / `buildHistoryBlocks`
6
+ * (连续 NVL 聚成一块,回溯粒度仍是逐检查点)。本模块不自己聚合——那套规则若在
7
+ * 宿主里另写一份,「面板显示的行」与「回溯落点」迟早对不上。
8
+ * - **本模块只管**:把块列表画进容器、把点击翻译成 `engine.rollbackTo(index)`。
9
+ *
10
+ * 回溯本身是引擎命令面(`rollbackTo` / `back` / `forward`),宿主不重放、不改状态。
11
+ * 回溯完成后的媒体对齐不在引擎里(它只回状态、不下发端口动作),所以本模块留了一个
12
+ * `onRollback` 出口给组合根接媒体同步——这是宿主的职责,不是面板的。
13
+ *
14
+ * 面板自带开关按钮:容器是可选的(叠加层可以不给 `#history`),若把开关放在工具条里,
15
+ * 那道槽位就变强制了。放在容器内则「有容器才有历史 UI」,与 `#prefs` 同口径。
16
+ */
17
+
18
+ import {
19
+ buildHistoryBlocks,
20
+ filterHistoryEntries,
21
+ type HistoryBlock,
22
+ type HistoryEntry,
23
+ } from "@lingfan-engine/shell";
24
+
25
+ /** 历史面板需要的引擎能力(只读历史 + 坐标回溯,不含推进) */
26
+ export interface HistoryEngine {
27
+ /** 逐检查点的历史条目 */
28
+ historyView(): readonly HistoryEntry[];
29
+ /** 回溯到第 index 个检查点 */
30
+ rollbackTo(target: number): void;
31
+ }
32
+
33
+ /** 面板当前应显示的视图值(纯数据,测试断言这一层) */
34
+ export interface HistoryPanelView {
35
+ /** 面板是否展开 */
36
+ open: boolean;
37
+ /** 呈现块(连续 NVL 已聚合) */
38
+ blocks: HistoryBlock[];
39
+ }
40
+
41
+ /** 历史面板的输入 */
42
+ export interface HistoryPanelOptions {
43
+ /** 引擎(读历史、执行回溯) */
44
+ engine: HistoryEngine;
45
+ /** 面板容器(由调用方给,本模块不自己 append 到 body) */
46
+ container: HTMLElement;
47
+ /**
48
+ * 回溯完成后的宿主侧收尾(可选):媒体位置需显式对齐——引擎 `rollback.done`
49
+ * 只带坐标,播放器不会自己跳回去。与 `wireEngine` 的 `syncMedia` 是同一件事。
50
+ */
51
+ onRollback?(): void;
52
+ }
53
+
54
+ /** 历史面板出口 */
55
+ export interface HistoryPanel {
56
+ /** 面板当前应显示的视图值;每次现读引擎历史 */
57
+ view(): HistoryPanelView;
58
+ /** 展开并重画(重复调用幂等) */
59
+ open(): void;
60
+ /** 收起 */
61
+ close(): void;
62
+ /** 展开 ↔ 收起 */
63
+ toggle(): void;
64
+ /** 回退到某个检查点:回溯 + 收起 + 宿主收尾 */
65
+ rollbackTo(index: number): void;
66
+ /** 挂载控件与监听(重复调用幂等,不叠控件) */
67
+ mount(): void;
68
+ /** 卸载:清空容器并断开引用;之后可再次 `mount()` */
69
+ destroy(): void;
70
+ }
71
+
72
+ /**
73
+ * 创建历史面板。构造只持有引擎与容器,不碰 DOM——`mount()` 才建控件,
74
+ * 因此未挂载的面板可以安全地只调 `view()`(视图计算与 DOM 解耦)。
75
+ */
76
+ export function createHistoryPanel(options: HistoryPanelOptions): HistoryPanel {
77
+ const { engine, container } = options;
78
+
79
+ /** 非 null = 已挂载;同时充当容器根节点句柄 */
80
+ let root: HTMLElement | null = null;
81
+ let list: HTMLElement | null = null;
82
+ let toggleButton: HTMLButtonElement | null = null;
83
+ let openState = false;
84
+
85
+ function view(): HistoryPanelView {
86
+ const blocks = buildHistoryBlocks(
87
+ filterHistoryEntries(engine.historyView()),
88
+ );
89
+ return { open: openState, blocks };
90
+ }
91
+
92
+ /** 一次块渲染:一级按钮 = 一个块,点它即回溯到块尾检查点 */
93
+ function renderBlocks(): void {
94
+ if (list === null) return;
95
+ list.textContent = ""; // 整体重建:块数不多,重建比逐块 diff 简单且不会残留
96
+ const { blocks } = view();
97
+ for (const block of blocks) {
98
+ const item = document.createElement("button");
99
+ item.type = "button";
100
+ item.className = "history-item";
101
+ // 回溯目标是**块尾**检查点,标在节点上便于宿主排查与测试取证
102
+ item.dataset.index = String(block.index);
103
+ if (block.speaker !== "") {
104
+ const speaker = document.createElement("span");
105
+ speaker.className = "history-speaker";
106
+ speaker.textContent = block.speaker;
107
+ item.appendChild(speaker);
108
+ }
109
+ for (const line of block.lines) {
110
+ const text = document.createElement("span");
111
+ text.className = block.nvl
112
+ ? "history-text history-nvl"
113
+ : "history-text";
114
+ text.textContent = line;
115
+ item.appendChild(text);
116
+ }
117
+ item.addEventListener("click", () => {
118
+ rollbackTo(block.index);
119
+ });
120
+ list.appendChild(item);
121
+ }
122
+ }
123
+
124
+ /** 展开态落到 DOM:列表显隐 + 按钮文案 */
125
+ function applyOpen(): void {
126
+ if (list !== null) list.style.display = openState ? "" : "none";
127
+ if (toggleButton !== null)
128
+ toggleButton.textContent = openState ? "关闭历史" : "历史";
129
+ }
130
+
131
+ function open(): void {
132
+ openState = true;
133
+ renderBlocks();
134
+ applyOpen();
135
+ }
136
+
137
+ function close(): void {
138
+ openState = false;
139
+ applyOpen();
140
+ }
141
+
142
+ function toggle(): void {
143
+ if (openState) close();
144
+ else open();
145
+ }
146
+
147
+ function rollbackTo(index: number): void {
148
+ // 引擎 fail-closed:越界、重放中都会自己报错,这里只管把面板收起来
149
+ engine.rollbackTo(index);
150
+ // 回溯改了状态,但面板若还开着,接下来点哪一块都基于旧列表——收起最省事
151
+ close();
152
+ options.onRollback?.();
153
+ }
154
+
155
+ function mount(): void {
156
+ if (root !== null) return; // 已挂载:幂等,重复调用不重复建控件
157
+ container.dataset.uiZone = "";
158
+ const section = document.createElement("section");
159
+ section.className = "history-panel";
160
+ section.dataset.uiZone = "";
161
+
162
+ const button = document.createElement("button");
163
+ button.type = "button";
164
+ button.className = "history-toggle";
165
+ button.textContent = "历史";
166
+ button.addEventListener("click", () => {
167
+ toggle();
168
+ });
169
+ section.appendChild(button);
170
+
171
+ const blockList = document.createElement("div");
172
+ blockList.className = "history-list";
173
+ blockList.style.display = "none";
174
+ section.appendChild(blockList);
175
+
176
+ container.appendChild(section);
177
+ root = section;
178
+ list = blockList;
179
+ toggleButton = button;
180
+ openState = false;
181
+ applyOpen();
182
+ }
183
+
184
+ function destroy(): void {
185
+ container.textContent = "";
186
+ root = null;
187
+ list = null;
188
+ toggleButton = null;
189
+ openState = false;
190
+ }
191
+
192
+ return { view, open, close, toggle, rollbackTo, mount, destroy };
193
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * 偏好面板(纯 DOM):四通道音量 + 静音 + 文字速度。
3
+ *
4
+ * 读写一律经 `PlayerPreferences`——钳制、非法值忽略都在偏好层,本模块只做两件事:
5
+ * 把偏好快照画到控件上,把控件事件翻译成偏好写入。所以换掉这套原生 DOM 不影响偏好语义。
6
+ *
7
+ * 用法:调用方给一个容器(通常来自 `index.html`),`mount()` 建控件并订阅偏好变化,
8
+ * `destroy()` 清空容器并退订。原生 DOM 没有框架卸载钩子,解绑必须自己成对做,
9
+ * 否则面板关掉后每次改偏好还会去写已卸下的节点。
10
+ *
11
+ * 注意:面板必须落在 `data-ui-zone` 里——输入作用域靠这个属性把面板内的滚轮/按键
12
+ * 判成「控件输入」,否则滚动音量滑杆会连带触发故事回溯。容器由调用方提供,
13
+ * 不能假定它已标记,故 `mount()` 给容器与面板根节点都补上。
14
+ */
15
+
16
+ import type { AudioChannel, PlayerPreferences } from "@lingfan-engine/core";
17
+
18
+ /** 音量通道与显示名;顺序即面板行序 */
19
+ export const PREF_CHANNELS: ReadonlyArray<{
20
+ channel: AudioChannel;
21
+ label: string;
22
+ }> = [
23
+ { channel: "bgm", label: "BGM" },
24
+ { channel: "se", label: "音效" },
25
+ { channel: "ambient", label: "环境" },
26
+ { channel: "voice", label: "语音" },
27
+ ];
28
+
29
+ /** 文字速度滑杆的上下限(字/秒;下限 1 与偏好层的下限一致) */
30
+ const TEXT_SPEED_MIN = 1;
31
+ const TEXT_SPEED_MAX = 80;
32
+
33
+ /** 面板当前应显示的视图值(纯数据,测试断言这一层) */
34
+ export interface PreferencesPanelView {
35
+ volumes: Record<AudioChannel, number>;
36
+ muted: boolean;
37
+ textSpeed: number;
38
+ /** 固定四通道的展示序 */
39
+ channels: readonly AudioChannel[];
40
+ }
41
+
42
+ /** 偏好面板的输入 */
43
+ export interface PreferencesPanelOptions {
44
+ /** 偏好端口(读写与订阅都走它) */
45
+ preferences: PlayerPreferences;
46
+ /** 面板根节点(由调用方给,本模块不自己 append 到 body) */
47
+ container: HTMLElement;
48
+ }
49
+
50
+ /** 偏好面板出口 */
51
+ export interface PreferencesPanel {
52
+ /** 面板当前应显示的视图值;每次现读偏好快照 */
53
+ view(): PreferencesPanelView;
54
+ /** 挂载控件与监听(重复调用幂等,不叠控件) */
55
+ mount(): void;
56
+ /** 卸载:退订并清空容器;之后可再次 `mount()` */
57
+ destroy(): void;
58
+ }
59
+
60
+ /** 单行音量控件的引用:同步视图时按通道回填 */
61
+ interface VolumeRow {
62
+ channel: AudioChannel;
63
+ input: HTMLInputElement;
64
+ readout: HTMLElement;
65
+ }
66
+
67
+ /**
68
+ * 读滑杆数值:空串按无效处理。
69
+ * `Number("")` 是 0,直接写会把通道误调到静音;返回 NaN 时偏好层忽略该次写入。
70
+ */
71
+ function sliderValue(input: HTMLInputElement): number {
72
+ const raw = input.value;
73
+ return raw === "" ? Number.NaN : Number(raw);
74
+ }
75
+
76
+ /**
77
+ * 创建偏好面板。构造只持有端口,不碰 DOM——`mount()` 才建控件,
78
+ * 因此未挂载的面板可以安全地只调 `view()`(视图计算与 DOM 解耦)。
79
+ */
80
+ export function createPreferencesPanel(
81
+ options: PreferencesPanelOptions,
82
+ ): PreferencesPanel {
83
+ const preferences = options.preferences;
84
+ const container = options.container;
85
+
86
+ /** 非 null = 已挂载;同时充当退订句柄(挂载判定与退订成对,避免重复订阅) */
87
+ let off: (() => void) | null = null;
88
+ let volumeRows: VolumeRow[] = [];
89
+ let mutedInput: HTMLInputElement | null = null;
90
+ let textSpeedInput: HTMLInputElement | null = null;
91
+ let textSpeedReadout: HTMLElement | null = null;
92
+
93
+ function view(): PreferencesPanelView {
94
+ const snapshot = preferences.snapshot();
95
+ return {
96
+ volumes: snapshot.volumes,
97
+ muted: snapshot.muted,
98
+ textSpeed: snapshot.textSpeed,
99
+ channels: PREF_CHANNELS.map((entry) => entry.channel),
100
+ };
101
+ }
102
+
103
+ /** 把当前偏好画到控件上(挂载时与每次偏好变化时调用) */
104
+ function sync(): void {
105
+ const snapshot = preferences.snapshot();
106
+ for (const row of volumeRows) {
107
+ const volume = snapshot.volumes[row.channel];
108
+ row.input.value = String(volume);
109
+ // 静音时滑杆禁用:此时通道偏好仍保留,只是合成末端归零,拖它看不出效果
110
+ row.input.disabled = snapshot.muted;
111
+ row.readout.textContent = `${Math.round(volume * 100)}%`;
112
+ }
113
+ if (mutedInput !== null) mutedInput.checked = snapshot.muted;
114
+ if (textSpeedInput !== null) {
115
+ textSpeedInput.value = String(snapshot.textSpeed);
116
+ }
117
+ if (textSpeedReadout !== null) {
118
+ textSpeedReadout.textContent = `${Math.round(snapshot.textSpeed)} 字/秒`;
119
+ }
120
+ }
121
+
122
+ /** 一行 = 标签 + 控件 +(可选)数值读数 */
123
+ function row(
124
+ labelText: string,
125
+ control: HTMLElement,
126
+ readout?: HTMLElement,
127
+ ): HTMLElement {
128
+ const wrapper = document.createElement("label");
129
+ wrapper.className = "prefs-row";
130
+ const label = document.createElement("span");
131
+ label.className = "prefs-label";
132
+ label.textContent = labelText;
133
+ wrapper.appendChild(label);
134
+ wrapper.appendChild(control);
135
+ if (readout !== undefined) wrapper.appendChild(readout);
136
+ return wrapper;
137
+ }
138
+
139
+ function buildVolumeRows(root: HTMLElement): void {
140
+ for (const entry of PREF_CHANNELS) {
141
+ const input = document.createElement("input");
142
+ input.type = "range";
143
+ input.className = "prefs-range";
144
+ // 通道标在节点上:样式与后续宿主接线都按通道取控件,不靠子节点下标
145
+ input.dataset.channel = entry.channel;
146
+ input.min = "0";
147
+ input.max = "1";
148
+ input.step = "0.05";
149
+ input.addEventListener("input", () => {
150
+ preferences.setVolume(entry.channel, sliderValue(input));
151
+ });
152
+ const readout = document.createElement("span");
153
+ readout.className = "prefs-value";
154
+ root.appendChild(row(entry.label, input, readout));
155
+ volumeRows.push({ channel: entry.channel, input, readout });
156
+ }
157
+ }
158
+
159
+ function buildMutedRow(root: HTMLElement): void {
160
+ const input = document.createElement("input");
161
+ input.type = "checkbox";
162
+ input.className = "prefs-checkbox";
163
+ input.dataset.setting = "muted";
164
+ input.addEventListener("change", () => {
165
+ preferences.setMuted(input.checked);
166
+ });
167
+ root.appendChild(row("静音", input));
168
+ mutedInput = input;
169
+ }
170
+
171
+ function buildTextSpeedRow(root: HTMLElement): void {
172
+ const input = document.createElement("input");
173
+ input.type = "range";
174
+ input.className = "prefs-range";
175
+ input.dataset.setting = "text-speed";
176
+ input.min = String(TEXT_SPEED_MIN);
177
+ input.max = String(TEXT_SPEED_MAX);
178
+ input.step = "1";
179
+ input.addEventListener("input", () => {
180
+ preferences.setTextSpeed(sliderValue(input));
181
+ });
182
+ const readout = document.createElement("span");
183
+ readout.className = "prefs-value";
184
+ root.appendChild(row("文字速度", input, readout));
185
+ textSpeedInput = input;
186
+ textSpeedReadout = readout;
187
+ }
188
+
189
+ function mount(): void {
190
+ if (off !== null) return; // 已挂载:幂等,重复调用不重复建控件
191
+ container.dataset.uiZone = "";
192
+ const root = document.createElement("section");
193
+ root.className = "prefs-panel";
194
+ root.dataset.uiZone = "";
195
+ buildVolumeRows(root);
196
+ buildMutedRow(root);
197
+ buildTextSpeedRow(root);
198
+ container.appendChild(root);
199
+ sync(); // 先按当前偏好铺初值,再订阅——避免挂载瞬间被一次多余通知重画
200
+ off = preferences.onChange(sync);
201
+ }
202
+
203
+ function destroy(): void {
204
+ if (off !== null) {
205
+ off();
206
+ off = null;
207
+ }
208
+ container.textContent = "";
209
+ volumeRows = [];
210
+ mutedInput = null;
211
+ textSpeedInput = null;
212
+ textSpeedReadout = null;
213
+ }
214
+
215
+ return { view, mount, destroy };
216
+ }
@@ -1,20 +1,26 @@
1
1
  /**
2
- * 存档接线:槽位下拉与存 / 读两个按钮。
2
+ * 存档接线:槽位下拉、存 / 读两个按钮,以及存档缩略图。
3
3
  *
4
4
  * 槽位有几个、叫什么,由引擎按工程配置判定(`project.json` 的 `shell.saves.slots`),
5
5
  * 宿主只负责把 id 放进 `<option>`。槽位口径若搬进宿主,「改工程配置却不生效」就会变成
6
- * 难查的静默失败。
6
+ * 难查的静默失败。缩略图的尺寸/质量/是否带文本同理,全部来自 `shell.saves.thumbnail`。
7
7
  *
8
8
  * 存 / 读都走引擎既有命令面:宿主不自己拼存档数据;写档失败的可见诊断由引擎出站事件
9
9
  * 统一给出(见 `engine-wiring.ts`)。
10
+ *
11
+ * 缩略图是**可选增强**:合成失败(老 WebView 没有 canvas 2d 上下文)就存一张没有图的档,
12
+ * 而不是让这次存档整体失败——存档能用优先于卡片好看。
10
13
  */
11
14
 
12
15
  import {
16
+ SYS,
17
+ resolveProjectName,
13
18
  resolveSavesConfig,
14
19
  slotIds,
15
20
  type StoryEngine,
16
21
  } from "@lingfan-engine/core";
17
22
  import type { StageDom } from "./stage-dom";
23
+ import { captureSaveThumbnail, stripHtml } from "./thumbnails";
18
24
 
19
25
  /** 按工程清单里的存档壳配置填充槽位下拉(调用方保证只填一次) */
20
26
  export function fillSaveSlots(dom: StageDom, manifest: unknown): void {
@@ -27,10 +33,52 @@ export function fillSaveSlots(dom: StageDom, manifest: unknown): void {
27
33
  }
28
34
  }
29
35
 
36
+ /** 从引擎状态取一个字符串(缺键 / 非字符串都当空串,避免把 undefined 画进卡片) */
37
+ function readText(engine: StoryEngine, key: string): string {
38
+ const value = engine.get(key);
39
+ return typeof value === "string" ? value : "";
40
+ }
41
+
42
+ /**
43
+ * 合成当前存档的缩略图(data URL);失败返回 undefined 让存档照常落盘。
44
+ *
45
+ * 取的是**当前对话帧**:此刻玩家点的「存」,看到的就是这一句,卡片理应也是这一句。
46
+ * 标题用工程展示名——存档列表里认出「这是哪个工程」比认出「这是第几槽」更有用。
47
+ */
48
+ function captureThumbnail(
49
+ engine: StoryEngine,
50
+ manifest: unknown,
51
+ ): string | undefined {
52
+ const { thumbnail } = resolveSavesConfig(manifest);
53
+ try {
54
+ return captureSaveThumbnail({
55
+ width: thumbnail.width,
56
+ height: thumbnail.height,
57
+ quality: thumbnail.quality,
58
+ showText: thumbnail.showText,
59
+ title: resolveProjectName(manifest),
60
+ speaker: readText(engine, SYS.currentDialogSpeaker),
61
+ text: stripHtml(readText(engine, SYS.currentDialogText)),
62
+ timestamp: Date.now(),
63
+ });
64
+ } catch {
65
+ // 没有 canvas 2d 上下文:降级为无图存档,不阻断存盘
66
+ return undefined;
67
+ }
68
+ }
69
+
30
70
  /** 接线存 / 读按钮:槽位取下拉当前选中值 */
31
- export function wireSaveSlots(dom: StageDom, engine: StoryEngine): void {
71
+ export function wireSaveSlots(
72
+ dom: StageDom,
73
+ engine: StoryEngine,
74
+ manifest: unknown,
75
+ ): void {
32
76
  dom.save.addEventListener("click", () => {
33
- engine.save(dom.slot.value);
77
+ const screenshot = captureThumbnail(engine, manifest);
78
+ engine.save(
79
+ dom.slot.value,
80
+ screenshot === undefined ? undefined : { screenshot },
81
+ );
34
82
  });
35
83
  dom.load.addEventListener("click", () => {
36
84
  engine.load(dom.slot.value);