@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
@@ -17,6 +17,18 @@ export function must<T extends HTMLElement>(selector: string): T {
17
17
  return el;
18
18
  }
19
19
 
20
+ /**
21
+ * 按选择器取**可选**节点:缺失返回 `null`,不抛。
22
+ *
23
+ * 只给「有则增强、无则退化成没有该功能」的节点用——目前三个:历史面板、偏好面板
24
+ * 与 UI 挂载层的容器。它们和 `must()` 的区别是**契约强度**:页面结构由叠加层重写时
25
+ * (Vue / React / 小程序各自的 `App`),那份页面可以选择不提供这些槽位,
26
+ * 此时对应功能不启用,游戏照常跑;而 `must()` 守的节点缺一个就是装配错误,必须当场抛。
27
+ */
28
+ export function optional<T extends HTMLElement>(selector: string): T | null {
29
+ return document.querySelector<T>(selector);
30
+ }
31
+
20
32
  /** 常驻节点句柄表:装配期就要用到、且页面里始终存在的那些 */
21
33
  export interface StageDom {
22
34
  /** 舞台根:屏幕震动施加位移的对象 */
@@ -39,6 +51,30 @@ export interface StageDom {
39
51
  notifications: HTMLElement;
40
52
  /** 工具条:存档槽与存 / 读按钮 */
41
53
  toolbar: HTMLElement;
54
+ /**
55
+ * 历史面板槽位(**可选**:`null` = 本页面没给这个槽位)。
56
+ *
57
+ * 给了就有「历史」按钮与已走过对话的列表,点一条即回退到那个检查点;
58
+ * 不给则只是回看/回溯要另想办法,游戏照常跑——与 `prefs` 同属「有则增强」。
59
+ * 取用点:`optional("#history")`;`layer-z.ts` 写 `history` 层 z 时会判空。
60
+ */
61
+ history: HTMLElement | null;
62
+ /**
63
+ * 偏好面板槽位(**可选**:`null` = 本页面没给这个槽位)。
64
+ *
65
+ * 与上面 15 个 `must()` 节点的区别:那些是引擎渲染的落点,缺一个就是装配错误;
66
+ * 这个只是「没有面板就没有音量/字速 UI」,游戏照常跑,所以按可选处理。
67
+ * 取用点:`optional("#prefs")`;`layer-z.ts` 写 `prefs` 层 z 时会判空。
68
+ */
69
+ prefs: HTMLElement | null;
70
+ /**
71
+ * UI 挂载层容器(**可选**:`null` = 本页面不给 UI 挂载留槽位)。
72
+ *
73
+ * 给了就按 `SYS.mounts` 把 `src/ui/` 下的 UI 挂进来(`mount` op 控制显隐),
74
+ * 不给则 `mount` 只改状态、没有任何组件出现——降级是「少功能」,不是错误。
75
+ * 与 `prefs` 同属「有则增强」的槽位:取用点 `optional("#mounts")`。
76
+ */
77
+ mounts: HTMLElement | null;
42
78
  /** 存档槽下拉 */
43
79
  slot: HTMLSelectElement;
44
80
  /** 存按钮 */
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 存档缩略图:合成一张「存档卡」,而不是对舞台做像素级截屏。
3
+ *
4
+ * 为什么是合成卡:WebView 没有跨端的截图 API(wry 未暴露 WebView2 的 CapturePreview,
5
+ * WKWebView 的 takeSnapshot 也未接),宿主能拿到的只有 DOM,不是像素。合成卡把
6
+ * 「背景 + 说话人 + 正文片段 + 时间戳」画进 canvas,确定性与可配性都更好,
7
+ * 也不受舞台适配(缩放/裁切)影响。
8
+ *
9
+ * 尺寸/质量/是否带文本全部由工程清单的 `shell.saves.thumbnail` 决定(解析在
10
+ * `resolveSavesConfig`),本模块只吃解析后的配置——宿主不自己读 JSON,避免
11
+ * 「改工程配置却不生效」这类静默失败。
12
+ *
13
+ * 失败即降级:没有 canvas 2d 上下文(老 WebView / 内存紧张)时抛错,由调用方
14
+ * 存一张**没有缩略图**的档——存档可用性优先于画面装饰。
15
+ */
16
+
17
+ /** 合成卡参数:尺寸/质量/是否带文本来自 `shell.saves.thumbnail`,文本字段取当前对话。 */
18
+ export interface SaveThumbnailInput {
19
+ width: number;
20
+ height: number;
21
+ quality: number;
22
+ showText: boolean;
23
+ /** 存档标题(缺省用工程名) */
24
+ title?: string;
25
+ /** 当前说话人(纯文本) */
26
+ speaker?: string;
27
+ /** 当前正文(纯文本,已剥离标签) */
28
+ text?: string;
29
+ timestamp: number;
30
+ }
31
+
32
+ /** 剥离富文本标签(对话渲染产物是受控 HTML;此处仅作缩略图纯文本投影) */
33
+ export function stripHtml(html: string): string {
34
+ return html.replace(/<[^>]*>/g, "");
35
+ }
36
+
37
+ /** 合成「存档卡」:背景渐变 + 说话人/正文片段 + 时间戳;无 canvas 上下文抛错。 */
38
+ export function captureSaveThumbnail(input: SaveThumbnailInput): string {
39
+ const canvas = document.createElement("canvas");
40
+ canvas.width = input.width;
41
+ canvas.height = input.height;
42
+ const context = canvas.getContext("2d");
43
+ if (context === null) throw new Error("canvas 2d 上下文不可用");
44
+
45
+ // 背景:纵向渐变,与舞台同色系(深色底,缩略图里不刺眼)
46
+ const gradient = context.createLinearGradient(0, 0, 0, input.height);
47
+ gradient.addColorStop(0, "#1e1e2e");
48
+ gradient.addColorStop(1, "#12121a");
49
+ context.fillStyle = gradient;
50
+ context.fillRect(0, 0, input.width, input.height);
51
+
52
+ drawTextOverlay(context, input);
53
+
54
+ return canvas.toDataURL("image/jpeg", input.quality);
55
+ }
56
+
57
+ /** 文字投影:标题 + 说话人 + 正文逐字符折行 + 时间戳 */
58
+ export function drawTextOverlay(
59
+ context: CanvasRenderingContext2D,
60
+ input: SaveThumbnailInput,
61
+ ): void {
62
+ if (!input.showText) return;
63
+ const pad = Math.round(input.width * 0.06);
64
+
65
+ context.fillStyle = "#7aa2f7";
66
+ context.font = `bold ${Math.round(input.height * 0.09)}px sans-serif`;
67
+ context.fillText(input.title ?? "", pad, Math.round(input.height * 0.22));
68
+
69
+ const speakerY = Math.round(input.height * 0.46);
70
+ if (input.speaker) {
71
+ context.fillStyle = "#9ece6a";
72
+ context.font = `${Math.round(input.height * 0.075)}px sans-serif`;
73
+ context.fillText(input.speaker, pad, speakerY);
74
+ }
75
+
76
+ const line = (input.text ?? "").replace(/\s+/g, " ").trim();
77
+ const maxWidth = input.width - pad * 2;
78
+ const charSize = Math.round(input.height * 0.07);
79
+ context.fillStyle = "#e6e6f0";
80
+ context.font = `${charSize}px sans-serif`;
81
+ // 逐字符折行:缩略图场景够用,CJK 也没有断词问题
82
+ let cursorX = pad;
83
+ let cursorY = speakerY + (input.speaker ? Math.round(charSize * 1.8) : 0);
84
+ for (const char of line) {
85
+ if (cursorX + charSize > maxWidth) {
86
+ cursorX = pad;
87
+ cursorY += Math.round(charSize * 1.4);
88
+ if (cursorY > input.height - pad) break;
89
+ }
90
+ context.fillText(char, cursorX, cursorY);
91
+ cursorX += charSize;
92
+ }
93
+
94
+ context.fillStyle = "#565f89";
95
+ context.font = `${Math.round(input.height * 0.06)}px sans-serif`;
96
+ context.fillText(
97
+ new Date(input.timestamp).toLocaleString(),
98
+ pad,
99
+ input.height - Math.round(pad * 0.8),
100
+ );
101
+ }
@@ -13,22 +13,31 @@
13
13
  *
14
14
  * 覆盖范围(本宿主**保证不卡死**:引擎能进入的每个等待态都有 UI 可推进):
15
15
  * dialogue / menu / input / wait / video / minigame(可见 fail-closed 提示)+
16
- * 舞台元素层 + 层级表(shell.layers)+ 存档(shell.saves.slots)。
17
- * 有意未做(属「少功能」而非「卡死」,见 README 边界表):历史面板与回溯 UI、
18
- * 玩家偏好面板、I18N 供给、对话框模板注册、存档缩略图合成。
16
+ * 舞台元素层 + 层级表(shell.layers)+ 存档(shell.saves.slots)+
17
+ * 玩家偏好面板 + 历史面板与回溯(点一条即回到那个检查点)。
18
+ * 有意未做(属「少功能」而非「卡死」,见 README 边界表):I18N 供给、
19
+ * 对话框模板注册、存档缩略图合成。
19
20
  */
20
21
 
22
+ import { MANIFEST_FILE } from "@lingfan-engine/core";
21
23
  import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
22
- import { bootHost, must, reportBootFailure, type StageDom } from "./host";
24
+ import {
25
+ bootHost,
26
+ must,
27
+ optional,
28
+ reportBootFailure,
29
+ type StageDom,
30
+ } from "./host";
23
31
  import { STORIES } from "./stories";
32
+ import { uis } from "./mounts";
24
33
 
25
34
  /**
26
35
  * 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
27
- * 宿主把 `Resources` 作为静态根,故逻辑路径就是 `project.json` / `Stories/**`。
36
+ * 宿主把 `Resources` 作为静态根,故逻辑路径就是清单名 / `Stories/**`。
28
37
  * 清单放在资源根内是有意为之——只有资源根内的文件才会进打包产物,dev 与 prod 同机制。
29
- * 故事表在 `./stories`(唯一声明点,各形态共用)。
38
+ * 清单名取引擎契约里的 `MANIFEST_FILE`(组装、写回、定位资源根共用同一个声明);
39
+ * 故事表在 `./stories`(唯一出口,各形态共用),内容由构建期扫描资源根得出。
30
40
  */
31
- const MANIFEST = "project.json";
32
41
 
33
42
  /** 页面节点句柄表:每个 id 都必须存在于 index.html(缺一个 `must` 当场抛) */
34
43
  const dom: StageDom = {
@@ -47,14 +56,27 @@ const dom: StageDom = {
47
56
  load: must<HTMLButtonElement>("#load"),
48
57
  transition: must("#transition"),
49
58
  error: must("#error"),
59
+ prefs: optional("#prefs"),
60
+ // 历史面板槽位(可选):本层不给就 null,没有「历史」按钮,其余照常
61
+ history: optional("#history"),
62
+ // UI 挂载层槽位(可选):本层不给就 null,`mount` 命令只改状态、没有组件出现
63
+ mounts: optional("#mounts"),
50
64
  };
51
65
 
52
66
  void bootHost({
53
67
  dom,
54
- manifestFile: MANIFEST,
68
+ manifestFile: MANIFEST_FILE,
55
69
  stories: STORIES,
56
70
  defaultCps: DEFAULT_TEXT_CPS,
57
71
  toastDurationMs: DEFAULT_TOAST_MS,
72
+ // 偏好面板槽位:页面给了就装(`optional` 已消化「这层不给」的情况)
73
+ prefsContainer: dom.prefs,
74
+ // 历史面板槽位:给了就有「历史」按钮与回溯(点一条即回到那个检查点)
75
+ historyContainer: dom.history,
76
+ // UI 挂载槽位:给了就把 `mount` 命令控制显隐的组件挂进来
77
+ mountsContainer: dom.mounts,
78
+ // 「名字 → UI 工厂」表:来自生成物,键就是 `SYS.mounts` 里的名字
79
+ mountsTable: uis,
58
80
  }).catch((error: unknown) => {
59
81
  reportBootFailure(dom, error);
60
82
  });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * UI 注册表的宿主侧出口:`src/**` 内引用生成物的唯一位置。
3
+ *
4
+ * 表由 `stories:build` 从 `src/ui/<名>/` 扫描产出到 `src/gen/`,键就是 `SYS.mounts`
5
+ * 里的名字(= 目录名),因此宿主侧不需要再归一、也不需要手写注册表——手写名单在
6
+ * 「加了 UI 却忘了登记」时只会表现成运行期「这个 UI 不出现」,而磁盘事实不会漏。
7
+ *
8
+ * 三形态(原生 / Vue / React)都从这里取,好让生成物的路径只出现一处:
9
+ * 换生成器输出名时只改本文件。故事源不经过这里——它按 `*.vue` 的具名导入拿句柄。
10
+ */
11
+ import { createMountStore } from "@lingfan-engine/ui";
12
+
13
+ export { uis } from "./gen/ui_register.g";
14
+
15
+ /**
16
+ * 挂载状态的宿主侧副本(框架无关)。
17
+ *
18
+ * 引擎每写一次 `SYS.mounts`,`bootHost` 就把新值放进来;框架形态的渲染组件订阅它
19
+ * 拿最新值,原生形态不订阅(渲染器自己算差量)。放在这里而不是各形态一份,是因为
20
+ * 「值从哪来」三形态完全一样,只有「怎么落成 DOM」不同。
21
+ */
22
+ export const mountStore = createMountStore();
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 故事清单的宿主侧出口。
3
+ *
4
+ * 清单由构建期扫描 `Resources/Stories/` 得出(`virtual:story-list`),不是手写名单:
5
+ * 手写名单在「加了文件却忘了登记」时只会表现成运行期的 `unknown-column`,
6
+ * 而磁盘事实不会漏。TS 创作链的产物与手写 `.story` 落在同一棵树,共用这一条扫描。
7
+ *
8
+ * 组合根与各框架/壳入口都从这里取,不在自己的入口里各写一份。
9
+ */
10
+ export { STORIES } from "virtual:story-list";
@@ -160,6 +160,120 @@ input {
160
160
  button {
161
161
  cursor: pointer;
162
162
  }
163
+ /* 历史面板槽位:z 由 shell.layers.history 写入(缺省 1500,与偏好面板同级);
164
+ 槽位本身不吃事件,只有里面的控件吃——这层没给(或面板收起)时点击直接穿透 */
165
+ #history {
166
+ position: absolute;
167
+ top: 12px;
168
+ left: 12px;
169
+ max-width: 360px;
170
+ pointer-events: none;
171
+ }
172
+ #history > * {
173
+ pointer-events: auto;
174
+ }
175
+ .history-panel {
176
+ display: flex;
177
+ flex-direction: column;
178
+ gap: 6px;
179
+ border: 1px solid #2f3a4d;
180
+ border-radius: 8px;
181
+ background: rgba(27, 33, 48, 0.94);
182
+ padding: 10px 12px;
183
+ font-size: 13px;
184
+ }
185
+ .history-list {
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: 4px;
189
+ /* 历史可能很长:给个上限自己滚,别把整屏顶开 */
190
+ max-height: 50vh;
191
+ overflow-y: auto;
192
+ }
193
+ .history-item {
194
+ display: flex;
195
+ flex-direction: column;
196
+ gap: 2px;
197
+ text-align: left;
198
+ background: transparent;
199
+ border: none;
200
+ border-radius: 4px;
201
+ padding: 4px 6px;
202
+ }
203
+ .history-item:hover {
204
+ background: rgba(122, 162, 247, 0.14);
205
+ }
206
+ .history-speaker {
207
+ color: #9ece6a;
208
+ font-size: 12px;
209
+ }
210
+ .history-text {
211
+ color: #c0caf5;
212
+ line-height: 1.4;
213
+ }
214
+ /* NVL 块里的行:连续 NVL 聚成一块,逐行缩进以示「同屏累积」 */
215
+ .history-nvl {
216
+ padding-left: 0.8em;
217
+ }
218
+ /* 偏好面板槽位:z 由 shell.layers.prefs 写入(缺省 1500,与历史面板同级);
219
+ 槽位本身不吃事件,只有里面的控件吃——面板空着(这层没装)时点击直接穿透 */
220
+ #prefs {
221
+ position: absolute;
222
+ top: 12px;
223
+ right: 12px;
224
+ max-width: 300px;
225
+ pointer-events: none;
226
+ }
227
+ #prefs > * {
228
+ pointer-events: auto;
229
+ }
230
+ .prefs-panel {
231
+ display: flex;
232
+ flex-direction: column;
233
+ gap: 6px;
234
+ border: 1px solid #2f3a4d;
235
+ border-radius: 8px;
236
+ background: rgba(27, 33, 48, 0.94);
237
+ padding: 10px 12px;
238
+ font-size: 13px;
239
+ }
240
+ .prefs-row {
241
+ display: flex;
242
+ align-items: center;
243
+ gap: 8px;
244
+ }
245
+ .prefs-label {
246
+ flex: 0 0 4em;
247
+ color: #a9b1d6;
248
+ }
249
+ .prefs-range {
250
+ flex: 1 1 auto;
251
+ min-width: 100px;
252
+ padding: 0;
253
+ border: none;
254
+ background: transparent;
255
+ }
256
+ .prefs-checkbox {
257
+ padding: 0;
258
+ }
259
+ .prefs-value {
260
+ flex: 0 0 4em;
261
+ text-align: right;
262
+ color: #a9b1d6;
263
+ font-variant-numeric: tabular-nums;
264
+ }
265
+ /* UI 挂载层:`mount` 命令显示/隐藏的组件挂这里(见 src/host/mount/)。
266
+ 隐藏 = 从渲染树移除,所以这里没有 display:none 的分支——容器空着就是没有 UI。
267
+ 槽位本身不吃事件,只有里面的组件吃:UI 覆盖整屏时不该顺带吃掉对话层的点击,
268
+ 而组件要交互时自己声明(与 #prefs 同一口径)。 */
269
+ #mounts {
270
+ position: absolute;
271
+ inset: 0;
272
+ pointer-events: none;
273
+ }
274
+ #mounts > * {
275
+ pointer-events: auto;
276
+ }
163
277
  /* 全屏转场遮罩(transition):屏幕级,恒在最上;透明度由帧驱动写入 */
164
278
  #transition {
165
279
  position: absolute;
@@ -12,3 +12,12 @@ declare module "virtual:story-fonts" {
12
12
  /** 构建期扫描 Resources/Fonts 的字体清单(与 stories:build 注册表同源) */
13
13
  export const storyFonts: readonly StoryFontEntry[];
14
14
  }
15
+
16
+ /**
17
+ * 故事清单的宿主侧声明:构建期递归扫描 `Resources/Stories/` 得到的逻辑路径表
18
+ * (相对资源根,如 `Stories/title/title_main.story`)。
19
+ */
20
+ declare module "virtual:story-list" {
21
+ /** 按路径码元序排列的故事文件逻辑路径 */
22
+ export const STORIES: readonly string[];
23
+ }
@@ -3,6 +3,7 @@
3
3
  "target": "ES2022",
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
+ "allowImportingTsExtensions": true,
6
7
  "types": ["node"],
7
8
  "strict": true,
8
9
  "noEmit": true,
@@ -10,5 +11,5 @@
10
11
  "noUnusedLocals": true,
11
12
  "noUnusedParameters": true
12
13
  },
13
- "include": ["vite.config.ts"]
14
+ "include": ["vite.config.ts", "vite.plugins.ts"]
14
15
  }
@@ -1,57 +1,12 @@
1
- import { existsSync, readdirSync } from "node:fs";
2
- import { fileURLToPath } from "node:url";
3
- import { defineConfig, type Plugin } from "vite";
4
-
5
- /** 字体文件扩展名集合(与 stories-build 的字体名注册表同口径) */
6
- const FONT_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"] as const;
7
-
8
- /** 字体清单条目:file = 资源文件名,name = 字体名(文件名去扩展名) */
9
- interface StoryFontEntry {
10
- file: string;
11
- name: string;
12
- }
13
-
14
- /** Resources/Fonts 扫描:点文件与未知扩展名跳过,文件名字典序稳定 */
15
- function scanFonts(fontsDir: string): readonly StoryFontEntry[] {
16
- if (!existsSync(fontsDir)) return [];
17
- return readdirSync(fontsDir)
18
- .filter((file) => {
19
- const dot = file.lastIndexOf(".");
20
- return (
21
- dot > 0 &&
22
- (FONT_EXTENSIONS as readonly string[]).includes(
23
- file.slice(dot).toLowerCase(),
24
- )
25
- );
26
- })
27
- .sort()
28
- .map((file) => ({ file, name: file.slice(0, file.lastIndexOf(".")) }));
29
- }
1
+ import { defineConfig } from "vite";
2
+ import { storyFontsVirtual, storyListVirtual } from "./vite.plugins.ts";
30
3
 
31
4
  /**
32
- * 字体清单虚拟模块:构建期扫描 Resources/Fonts 产出字体名清单,
33
- * 宿主据此注入 @font-face 与项目默认字体——字体名注册表与 stories:build 同源,
34
- * 零新增依赖(脚手架工程保持可独立安装)。
5
+ * 脚手架工程的 Vite 配置:约定资源根、宿主端口与两个清单虚拟模块。
6
+ *
7
+ * 字体清单与故事清单都由构建期扫描资源根得出(实现见 `vite.plugins.ts`)——
8
+ * 加一个故事文件、放一个字体,不需要回来改任何名单。
35
9
  */
36
- function storyFontsVirtual(): Plugin {
37
- const moduleId = "virtual:story-fonts";
38
- const fontsDir = fileURLToPath(new URL("./Resources/Fonts", import.meta.url));
39
- return {
40
- name: "story-fonts",
41
- resolveId(id) {
42
- if (id === moduleId) return `\0${moduleId}`;
43
- return null;
44
- },
45
- load(id) {
46
- if (id === `\0${moduleId}`) {
47
- return `export const storyFonts = ${JSON.stringify(scanFonts(fontsDir))};`;
48
- }
49
- return null;
50
- },
51
- };
52
- }
53
-
54
- /** 脚手架工程的 Vite 配置:仅约定资源根与宿主端口,其余随宿主需要自行扩展。 */
55
10
  export default defineConfig({
56
11
  // 项目资源根即静态根 —— 故事里的逻辑路径 `Audio/x.mp3` 解析为 `/Audio/x.mp3`,
57
12
  // 开发与打包产物一致,不依赖进程工作目录。资源根之外无隐式查找路径。
@@ -60,5 +15,5 @@ export default defineConfig({
60
15
  // 宿主端口与 Tauri 约定一致;换平台壳时只改这里
61
16
  server: { port: 1420, strictPort: true },
62
17
 
63
- plugins: [storyFontsVirtual()],
18
+ plugins: [storyFontsVirtual(), storyListVirtual()],
64
19
  });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 构建期的两个虚拟模块:字体清单与故事清单。
3
+ *
4
+ * 两个清单都是**磁盘事实的投影**,不是需要作者维护的声明文件。让作者手抄名单的代价
5
+ * 不是「多写一行」,而是漏抄时构建期毫无察觉——漏登记的那一列要到运行期才以
6
+ * `unknown-column` 现形。
7
+ *
8
+ * 扫描实现不在这里:它属于 `@lingfan-engine/stories-build`(构建期清单的权威实现,
9
+ * 与 `stories:build` 的字体名/故事清单同源)。本文件只负责把「资源根在哪」告诉它——
10
+ * 缺省是工程资源根,改工程布局时改 `vite.config.ts` 的传参,不改扫描逻辑。
11
+ */
12
+ import {
13
+ storyFontsVirtual as sharedStoryFontsVirtual,
14
+ storyListVirtual as sharedStoryListVirtual,
15
+ type HostPlugin,
16
+ type ResourceRootOptions,
17
+ } from "@lingfan-engine/stories-build/host-plugins";
18
+
19
+ /** 字体清单条目:file = 资源文件名,name = 字体名(文件名去扩展名) */
20
+ export interface StoryFontEntry {
21
+ file: string;
22
+ name: string;
23
+ }
24
+
25
+ /**
26
+ * 字体清单虚拟模块:构建期扫描资源根下的字体目录产出字体名清单,
27
+ * 宿主据此注入 @font-face 与项目默认字体。
28
+ *
29
+ * @param options 资源根覆盖;缺省 = 本文件同级的 `<RESOURCES_DIR>`
30
+ */
31
+ export function storyFontsVirtual(
32
+ options: ResourceRootOptions = {},
33
+ ): HostPlugin {
34
+ return sharedStoryFontsVirtual(options, import.meta.url);
35
+ }
36
+
37
+ /**
38
+ * 故事清单虚拟模块:构建期扫描资源根下的故事目录得出要装载的故事文件表。
39
+ *
40
+ * TS 创作链的产物(`Stories.src/` 编译出的 `.json`)与手写 `.story` 落在同一棵树,
41
+ * 共用这一条扫描——创作形态不改变清单的来源。
42
+ *
43
+ * @param options 资源根覆盖;缺省 = 本文件同级的 `<RESOURCES_DIR>`
44
+ */
45
+ export function storyListVirtual(
46
+ options: ResourceRootOptions = {},
47
+ ): HostPlugin {
48
+ return sharedStoryListVirtual(options, import.meta.url);
49
+ }
@@ -10,11 +10,17 @@
10
10
 
11
11
  ## 现有层
12
12
 
13
- - `ts/` —— 故事源层,只换故事素材、不碰页面:新增 `Stories.src/`(`config.ts` + 两个示例列)、
14
- 构建产物 `Resources/Stories/title/*.json` 与 `Stories.src/gen/*.gen.d.ts`、覆盖骨架的
15
- `src/stories.ts`(清单改指 `.json` 产物)与独立的 `tsconfig.stories.json`。
13
+ - `ts/` —— 故事源层,只换故事素材、不碰页面:新增 `src/Stories.src/`(`config.ts` + 两个示例列)、
14
+ 构建产物 `Resources/Stories/title/*.json` 与 `src/gen/*.gen.d.ts`、独立的 `tsconfig.stories.json`。
15
+ 故事源与类型面生成物都在 `src/` 下,两者都不进最终包:`src/Stories.src/**` 由 `tsconfig.json`
16
+ 排除在类型检查之外,`src/gen/**` 只有 `fun_register.g.ts` 带运行期值。
17
+ 故事里可以写工程 `tsconfig.json` 声明的任意别名(`@/ui/hub/hub.vue`、`@ui/...`),stories-build
18
+ 会读那份 `paths` 自行解析——宿主不必配任何别名就能构建;若还想让 Vite / tsc 也认同一套别名,
19
+ 在 `vite.config.ts` 里用正则 `find: /^@\//`(字符串 `"@"` 是前缀匹配,会把 `@lingfan-engine/*` 一起吞掉)。
16
20
  `package.patch.json` 补 `@lingfan-engine/stories-build` / `@lingfan-engine/editor` 与三条脚本,
17
21
  并用 `create.remove` 删掉骨架那份手写故事(产物目录由构建全量管理,两套并存会把 `.story` 当陈旧列清掉)。
22
+ 故事清单不归本层管:骨架的 `vite.plugins.ts` 扫 `Resources/Stories/`,产物 `.json` 落在同一棵树里,
23
+ 自动进清单——层不需要也不应该覆盖 `src/stories/index.ts`。
18
24
  - `vue/` —— 页面结构换成单文件组件:`index.html` 变成挂载点,新增 `src/App.vue` 与 Vue 版组合根,
19
25
  `package.patch.json` 补 `vue` / `@vitejs/plugin-vue` / `vue-tsc`,并把 `typecheck` 换成
20
26
  `vue-tsc --noEmit && tsc --noEmit -p tsconfig.node.json`。`src/host/**` 一行不改,
@@ -4,7 +4,7 @@
4
4
  "@dcloudio/uni-components": "3.0.0-4080720251210001",
5
5
  "@dcloudio/uni-h5": "3.0.0-4080720251210001",
6
6
  "@dcloudio/uni-mp-weixin": "3.0.0-4080720251210001",
7
- "@lingfan-engine/shell": "^0.1.0",
7
+ "@lingfan-engine/shell": "^0.3.0",
8
8
  "vue": "3.4.21"
9
9
  },
10
10
  "devDependencies": {
@@ -13,6 +13,7 @@ import {
13
13
  defaultEngineTexts,
14
14
  findMissingGuards,
15
15
  PlayerPreferences,
16
+ resolveAudioConfig,
16
17
  resolveSavesConfig,
17
18
  slotIds,
18
19
  StoryEngine,
@@ -129,7 +130,8 @@ export async function startMiniprogramHost(
129
130
 
130
131
  // 存档槽数与缩略图参数来自同一份工程清单(`shell.saves` 逐键覆盖):清单与故事是同一次
131
132
  // 装载(端口内 memo),这里再取一次不产生第二个请求。
132
- const saves: SavesConfig = resolveSavesConfig(await filesPort.manifest());
133
+ const manifest = await filesPort.manifest();
134
+ const saves: SavesConfig = resolveSavesConfig(manifest);
133
135
 
134
136
  // 内容是包外的(故事走工程接口,改故事不必发版),守卫却在包里:装载完成先对名字,
135
137
  // 缺谁一次报全——错误停在起播之前,而不是播到那条命令才弹「未注册的守卫」。
@@ -145,6 +147,8 @@ export async function startMiniprogramHost(
145
147
  });
146
148
  const ports = createMiniprogramHostPorts({ videoSurface, projectBaseUrl });
147
149
  const engine = new StoryEngine(story, {
150
+ // 工程默认音量(project.json shell.audio.volumes):音量的第二级
151
+ audioConfig: resolveAudioConfig(manifest),
148
152
  savePort: ports.save,
149
153
  texts: withSlotLabels(defaultEngineTexts),
150
154
  // 守卫实现由宿主随代码带上:故事 JSON 只写名字(装载期已校验名字齐全)
@@ -1,65 +1,26 @@
1
1
  /**
2
- * 构建配置(小程序形态):UniApp 的 Vite 插件,加上骨架那份字体扫描插件。
2
+ * 构建配置(小程序形态):UniApp 的 Vite 插件,加上骨架那份清单插件。
3
3
  *
4
4
  * 这里不能沿用骨架的 `vite ^8`:`@dcloudio/vite-plugin-uni` 的 peer 精确钉住
5
5
  * `vite 5.2.8` 与 `@vue/shared 3.4.21`,与仓库根的那一代不兼容。所以本层自带一份
6
6
  * vite 与 vue,靠包管理器的隔离安装各用各的——版本写在 `package.patch.json` 里,
7
7
  * 并列入 `create.pinned`,生成器才不会把它改写成 `^最新`。
8
8
  *
9
+ * 清单扫描不再在本层另写一份:字体清单与故事清单都取自骨架的 `./vite.plugins`
10
+ * (实现属 `@lingfan-engine/stories-build`,与 `stories:build` 同源)。层与层之间
11
+ * 只差插件数组,扫描规则一处维护——小程序形态同样需要故事清单,否则它只能装载
12
+ * 骨架里手写的那几列。
13
+ *
9
14
  * `publicDir` 仍是无条件的 `Resources`:开发期的 H5 形态与骨架共用同一个资源根。
10
15
  * `copyPublicDir: false` 是有意的——小程序包体里不装故事与素材(故事走工程接口
11
16
  * `GET <基址>/project` 一次性返回 `{manifest, stories}`,素材走资源端口按 id 取),
12
17
  * 构建产物因此只有代码。
13
18
  */
14
- import { existsSync, readdirSync } from "node:fs";
15
- import { fileURLToPath } from "node:url";
16
19
  import uniModule, {
17
20
  type VitePluginUniOptions,
18
21
  } from "@dcloudio/vite-plugin-uni";
19
22
  import { defineConfig, type Plugin } from "vite";
20
-
21
- /** 字体扩展名:只有这些后缀会被当作可注册的字体 */
22
- const FONT_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"] as const;
23
-
24
- /** 一份字体条目:`file` 是资源根下的相对路径,`name` 是字体族名(去扩展名的文件名) */
25
- interface StoryFontEntry {
26
- readonly file: string;
27
- readonly name: string;
28
- }
29
-
30
- /** 扫描字体目录:目录不存在就是空表,不抛错 */
31
- function scanFonts(fontsDir: string): StoryFontEntry[] {
32
- if (!existsSync(fontsDir)) return [];
33
- return readdirSync(fontsDir)
34
- .filter((entry) =>
35
- FONT_EXTENSIONS.some((ext) => entry.toLowerCase().endsWith(ext)),
36
- )
37
- .sort()
38
- .map((entry) => ({
39
- file: `Fonts/${entry}`,
40
- name: entry.slice(0, entry.lastIndexOf(".")),
41
- }));
42
- }
43
-
44
- /**
45
- * 字体清单虚拟模块:把「资源根里有哪些字体」以数据形态交给运行期注册。
46
- *
47
- * 用虚拟模块而不是构建期 import:字体是资源根里的文件,走资源端口寻址;
48
- * 构建期 import 在加密资源形态下无文件可指。
49
- */
50
- function storyFontsVirtual(): Plugin {
51
- const moduleId = "virtual:story-fonts";
52
- const resolved = `\0${moduleId}`;
53
- const fontsDir = fileURLToPath(new URL("./Resources/Fonts", import.meta.url));
54
- return {
55
- name: "lingfan:story-fonts",
56
- resolveId: (id) => (id === moduleId ? resolved : undefined),
57
- load: (id) =>
58
- id === resolved
59
- ? `export const storyFonts = ${JSON.stringify(scanFonts(fontsDir))};`
60
- : undefined,
61
- };
62
- }
23
+ import { storyFontsVirtual, storyListVirtual } from "./vite.plugins.ts";
63
24
 
64
25
  type UniPluginFactory = (options?: VitePluginUniOptions) => Plugin[];
65
26
 
@@ -83,5 +44,5 @@ export default defineConfig({
83
44
  port: 1420,
84
45
  strictPort: true,
85
46
  },
86
- plugins: [uni(), storyFontsVirtual()],
47
+ plugins: [uni(), storyFontsVirtual(), storyListVirtual()],
87
48
  });