@lingfan-engine/create 0.0.0-stage → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +201 -0
  2. package/bin/create.mjs +18 -0
  3. package/package.json +44 -3
  4. package/src/cli/cli.ts +302 -0
  5. package/src/cli/prompts.ts +115 -0
  6. package/src/generate/dotfiles.ts +27 -0
  7. package/src/generate/index.ts +20 -0
  8. package/src/generate/project.ts +279 -0
  9. package/src/generate/replace.ts +91 -0
  10. package/src/index.ts +46 -0
  11. package/src/overlay/index.ts +10 -0
  12. package/src/overlay/merge.ts +143 -0
  13. package/src/registry/index.ts +15 -0
  14. package/src/registry/template-meta.ts +107 -0
  15. package/src/registry/versions.ts +109 -0
  16. package/src/shared/guards.ts +85 -0
  17. package/src/shared/index.ts +7 -0
  18. package/src/shared/texts.ts +55 -0
  19. package/template/README.md +138 -0
  20. package/template/__PROJECT__/Resources/Audio/.gitkeep +0 -0
  21. package/template/__PROJECT__/Resources/Fonts/README.md +10 -0
  22. package/template/__PROJECT__/Resources/Images/.gitkeep +0 -0
  23. package/template/__PROJECT__/Resources/Lang/.gitkeep +0 -0
  24. package/template/__PROJECT__/Resources/Stories/title/title_main.story +24 -0
  25. package/template/__PROJECT__/Resources/Video/.gitkeep +0 -0
  26. package/template/__PROJECT__/Resources/project.json +7 -0
  27. package/template/__PROJECT__/gitignore +8 -0
  28. package/template/__PROJECT__/index.html +34 -0
  29. package/template/__PROJECT__/package.json +22 -0
  30. package/template/__PROJECT__/pnpm-workspace.yaml +8 -0
  31. package/template/__PROJECT__/src/host/boot.ts +181 -0
  32. package/template/__PROJECT__/src/host/dialogue-view.ts +235 -0
  33. package/template/__PROJECT__/src/host/effects.ts +68 -0
  34. package/template/__PROJECT__/src/host/engine-wiring.ts +206 -0
  35. package/template/__PROJECT__/src/host/frame-loop.ts +42 -0
  36. package/template/__PROJECT__/src/host/index.ts +27 -0
  37. package/template/__PROJECT__/src/host/input-map.ts +51 -0
  38. package/template/__PROJECT__/src/host/layer-z.ts +50 -0
  39. package/template/__PROJECT__/src/host/save-slots.ts +38 -0
  40. package/template/__PROJECT__/src/host/stage-dom.ts +74 -0
  41. package/template/__PROJECT__/src/host/theme-fonts.ts +60 -0
  42. package/template/__PROJECT__/src/host/toast.ts +29 -0
  43. package/template/__PROJECT__/src/main.ts +61 -0
  44. package/template/__PROJECT__/src/styles/stage.css +183 -0
  45. package/template/__PROJECT__/src/vite-env.d.ts +14 -0
  46. package/template/__PROJECT__/tsconfig.json +19 -0
  47. package/template/__PROJECT__/tsconfig.node.json +14 -0
  48. package/template/__PROJECT__/vite.config.ts +64 -0
  49. package/template/overlays/README.md +70 -0
  50. package/template/overlays/miniprogram/README.md +104 -0
  51. package/template/overlays/miniprogram/index.html +15 -0
  52. package/template/overlays/miniprogram/package.patch.json +40 -0
  53. package/template/overlays/miniprogram/scripts/serve-project.mjs +273 -0
  54. package/template/overlays/miniprogram/src/App.vue +23 -0
  55. package/template/overlays/miniprogram/src/components/ChoiceLayer.vue +208 -0
  56. package/template/overlays/miniprogram/src/components/DialogueLayer.vue +183 -0
  57. package/template/overlays/miniprogram/src/components/NodeTree.vue +44 -0
  58. package/template/overlays/miniprogram/src/components/NoticeLayer.vue +87 -0
  59. package/template/overlays/miniprogram/src/components/SaveLoadBar.vue +168 -0
  60. package/template/overlays/miniprogram/src/components/StageView.vue +50 -0
  61. package/template/overlays/miniprogram/src/env.d.ts +1 -0
  62. package/template/overlays/miniprogram/src/main.ts +12 -0
  63. package/template/overlays/miniprogram/src/manifest.json +21 -0
  64. package/template/overlays/miniprogram/src/mp/frame.ts +51 -0
  65. package/template/overlays/miniprogram/src/mp/guards.ts +28 -0
  66. package/template/overlays/miniprogram/src/mp/host.ts +252 -0
  67. package/template/overlays/miniprogram/src/mp/index.ts +9 -0
  68. package/template/overlays/miniprogram/src/mp/platform.ts +210 -0
  69. package/template/overlays/miniprogram/src/mp/ports.ts +84 -0
  70. package/template/overlays/miniprogram/src/mp/project-source.ts +77 -0
  71. package/template/overlays/miniprogram/src/mp/slots.ts +64 -0
  72. package/template/overlays/miniprogram/src/mp/surface.ts +239 -0
  73. package/template/overlays/miniprogram/src/mp/video-surface.ts +104 -0
  74. package/template/overlays/miniprogram/src/mp/view.ts +91 -0
  75. package/template/overlays/miniprogram/src/pages/index/index.vue +299 -0
  76. package/template/overlays/miniprogram/src/pages.json +18 -0
  77. package/template/overlays/miniprogram/tsconfig.json +20 -0
  78. package/template/overlays/miniprogram/vite.config.ts +87 -0
  79. package/template/overlays/react/index.html +16 -0
  80. package/template/overlays/react/package.patch.json +18 -0
  81. package/template/overlays/react/src/App.tsx +32 -0
  82. package/template/overlays/react/src/main.tsx +74 -0
  83. package/template/overlays/react/src/vite-env.d.ts +14 -0
  84. package/template/overlays/react/tsconfig.json +20 -0
  85. package/template/overlays/react/vite.config.ts +67 -0
  86. package/template/overlays/tauri/README.md +59 -0
  87. package/template/overlays/tauri/package.patch.json +14 -0
  88. package/template/overlays/tauri/src/main.ts +58 -0
  89. package/template/overlays/tauri/src-tauri/Cargo.toml +16 -0
  90. package/template/overlays/tauri/src-tauri/build.rs +6 -0
  91. package/template/overlays/tauri/src-tauri/capabilities/default.json +7 -0
  92. package/template/overlays/tauri/src-tauri/icons/128x128.png +0 -0
  93. package/template/overlays/tauri/src-tauri/icons/128x128@2x.png +0 -0
  94. package/template/overlays/tauri/src-tauri/icons/32x32.png +0 -0
  95. package/template/overlays/tauri/src-tauri/icons/Square107x107Logo.png +0 -0
  96. package/template/overlays/tauri/src-tauri/icons/Square142x142Logo.png +0 -0
  97. package/template/overlays/tauri/src-tauri/icons/Square150x150Logo.png +0 -0
  98. package/template/overlays/tauri/src-tauri/icons/Square284x284Logo.png +0 -0
  99. package/template/overlays/tauri/src-tauri/icons/Square30x30Logo.png +0 -0
  100. package/template/overlays/tauri/src-tauri/icons/Square310x310Logo.png +0 -0
  101. package/template/overlays/tauri/src-tauri/icons/Square44x44Logo.png +0 -0
  102. package/template/overlays/tauri/src-tauri/icons/Square71x71Logo.png +0 -0
  103. package/template/overlays/tauri/src-tauri/icons/Square89x89Logo.png +0 -0
  104. package/template/overlays/tauri/src-tauri/icons/StoreLogo.png +0 -0
  105. package/template/overlays/tauri/src-tauri/icons/icon.icns +0 -0
  106. package/template/overlays/tauri/src-tauri/icons/icon.ico +0 -0
  107. package/template/overlays/tauri/src-tauri/icons/icon.png +0 -0
  108. package/template/overlays/tauri/src-tauri/src/lib.rs +26 -0
  109. package/template/overlays/tauri/src-tauri/src/main.rs +7 -0
  110. package/template/overlays/tauri/src-tauri/src/preferences.rs +50 -0
  111. package/template/overlays/tauri/src-tauri/src/save.rs +178 -0
  112. package/template/overlays/tauri/src-tauri/tauri.conf.json +35 -0
  113. package/template/overlays/vue/index.html +16 -0
  114. package/template/overlays/vue/package.patch.json +16 -0
  115. package/template/overlays/vue/src/App.vue +30 -0
  116. package/template/overlays/vue/src/main.ts +68 -0
  117. package/template/overlays/vue/src/vite-env.d.ts +24 -0
  118. package/template/overlays/vue/tsconfig.json +19 -0
  119. package/template/overlays/vue/vite.config.ts +67 -0
  120. package/template/pack-template.ps1 +65 -0
  121. package/template/template-meta.json +40 -0
  122. package/README.md +0 -3
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 平台端口装配:把小程序能力接到引擎与展示层的端口上。
3
+ *
4
+ * 四件事:同步存储(存档与玩家偏好共用同一份面)、资源地址(内容在服务端:故事里的裸 id
5
+ * 由资源端口拼成 `<工程基址>/<id>`)、媒体端口(音频实例由平台造,视频呈现面由组合根注入——
6
+ * 它要写页面数据,属宿主形态而非平台能力)、以及存档键前缀(与参考宿主的前缀区分开,
7
+ * 同一台设备上不会串档)。
8
+ */
9
+ import {
10
+ createMiniprogramAudioPort,
11
+ createMiniprogramPreferencesPort,
12
+ createMiniprogramSavePort,
13
+ createMiniprogramVideoPort,
14
+ type MiniprogramStorageLike,
15
+ type MiniprogramVideoSurfaceLike,
16
+ } from "@lingfan-engine/adapters";
17
+ import type {
18
+ AudioPort,
19
+ PreferencesPort,
20
+ ResourcePort,
21
+ SavePort,
22
+ VideoPort,
23
+ } from "@lingfan-engine/core";
24
+ import { createAudioContext, uniApi } from "./platform.ts";
25
+ import { createRemoteResourcePort } from "./project-source.ts";
26
+
27
+ /** 本工程的存档键前缀(含高水位键,两者必须同前缀才互不干扰) */
28
+ const SAVE_KEY_PREFIX = "lingfan-miniprogram:";
29
+ /** 高水位键:低于已记录档数的读档会被拒绝,防误覆盖 */
30
+ const SAVE_HIGH_WATER_KEY = "lingfan-miniprogram:highwater";
31
+
32
+ /** 宿主给引擎与装配器用的端口 */
33
+ export interface MiniprogramHostPorts {
34
+ save: SavePort;
35
+ preferences: PreferencesPort;
36
+ resources: ResourcePort;
37
+ /** 音频端口:四条通道共用一套实例管理(实例由平台造) */
38
+ audio: AudioPort;
39
+ /** 视频端口:状态推给呈现面,播放中的控制走组件控制柄 */
40
+ video: VideoPort;
41
+ }
42
+
43
+ /** 端口装配参数:视频呈现面由组合根造(它要写页面数据) */
44
+ export interface MiniprogramPortsOptions {
45
+ /** 视频呈现面(宿主形态相关,不属于平台能力) */
46
+ videoSurface: MiniprogramVideoSurfaceLike;
47
+ /** 工程基址(故事与资源都从它取);缺省读运行期配置 */
48
+ projectBaseUrl?: string;
49
+ }
50
+
51
+ /** 装配平台端口 */
52
+ export function createMiniprogramHostPorts(
53
+ options: MiniprogramPortsOptions,
54
+ ): MiniprogramHostPorts {
55
+ const storage = createStorageFace();
56
+ return {
57
+ save: createMiniprogramSavePort(storage, {
58
+ keyPrefix: SAVE_KEY_PREFIX,
59
+ highWaterKey: SAVE_HIGH_WATER_KEY,
60
+ }),
61
+ preferences: createMiniprogramPreferencesPort(storage),
62
+ resources: createRemoteResourcePort(options.projectBaseUrl),
63
+ audio: createMiniprogramAudioPort(createAudioContext),
64
+ video: createMiniprogramVideoPort(options.videoSurface),
65
+ };
66
+ }
67
+
68
+ /** 把 `uni` 的同步存储包成适配器认识的存储面(取不到的键归一为空串) */
69
+ function createStorageFace(): MiniprogramStorageLike {
70
+ const api = uniApi();
71
+ return {
72
+ set: (key, value) => {
73
+ api.setStorageSync(key, value);
74
+ },
75
+ get: (key) => {
76
+ const value = api.getStorageSync(key);
77
+ return typeof value === "string" ? value : "";
78
+ },
79
+ remove: (key) => {
80
+ api.removeStorageSync(key);
81
+ },
82
+ keys: () => api.getStorageInfoSync().keys,
83
+ };
84
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 工程来源(远端):清单与故事从工程接口取,媒体资源按同一个基址拼地址。
3
+ *
4
+ * 小程序包体只装代码——故事与素材都是内容,走 HTTP 从服务端取,改内容不必重新提审发版。
5
+ * 基址只有一处(`DEFAULT_PROJECT_BASE_URL`,可用环境变量 `VITE_PROJECT_BASE_URL` 覆盖):
6
+ * - 工程接口 = 基址 + `project`:一次返回 `{ manifest, stories }`(故事原文,解析归引擎);
7
+ * - 资源地址 = 基址 + 资源 id:`Audio/x.mp3` → `<基址>/Audio/x.mp3`(静态托管形态)。
8
+ *
9
+ * 本文件不碰平台全局:取件面由组合根注入(小程序用 `uni.request` 包的那个),
10
+ * 所以 Node 下能直接单测。
11
+ */
12
+ import {
13
+ createHttpProjectFilesPort,
14
+ createStaticResourcePort,
15
+ type HttpFetch,
16
+ } from "@lingfan-engine/adapters";
17
+ import type { ProjectFilesPort, ResourcePort } from "@lingfan-engine/core";
18
+
19
+ /** 缺省基址:与本地开发服务(`scripts/serve-project.mjs`)的端口一致 */
20
+ export const DEFAULT_PROJECT_BASE_URL = "http://127.0.0.1:5273/";
21
+
22
+ /** 工程接口路径(相对基址) */
23
+ export const PROJECT_ENDPOINT_PATH = "project";
24
+
25
+ /**
26
+ * 规范化基址:必须是 http(s) 绝对地址、以 `/` 结尾(资源 id 直接拼上来)。
27
+ * 形状不对就抛——配错了要在启动时暴露,不能拼出一串坏地址到播放阶段。
28
+ */
29
+ export function normalizeProjectBaseUrl(baseUrl: string): string {
30
+ const trimmed = baseUrl.trim();
31
+ if (!/^https?:\/\//i.test(trimmed)) {
32
+ throw new Error(`工程基址必须是 http(s) 绝对地址:${baseUrl}`);
33
+ }
34
+ return trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
35
+ }
36
+
37
+ /** 读运行期基址:环境变量优先,缺省指本地开发服务 */
38
+ export function resolveProjectBaseUrl(): string {
39
+ const env = import.meta.env as
40
+ { VITE_PROJECT_BASE_URL?: unknown } | undefined;
41
+ const fromEnv = env?.VITE_PROJECT_BASE_URL;
42
+ const value = typeof fromEnv === "string" ? fromEnv.trim() : "";
43
+ return normalizeProjectBaseUrl(
44
+ value === "" ? DEFAULT_PROJECT_BASE_URL : value,
45
+ );
46
+ }
47
+
48
+ /** 工程接口地址 */
49
+ export function projectEndpointUrl(
50
+ baseUrl: string = resolveProjectBaseUrl(),
51
+ ): string {
52
+ return `${normalizeProjectBaseUrl(baseUrl)}${PROJECT_ENDPOINT_PATH}`;
53
+ }
54
+
55
+ /**
56
+ * 造远端工程供给端口:清单与故事都从工程接口取。
57
+ * @param options 取件面(必给,平台能力由组合根注入)与基址(缺省读运行期配置)
58
+ */
59
+ export function createRemoteProjectFilesPort(options: {
60
+ fetch: HttpFetch;
61
+ baseUrl?: string;
62
+ }): ProjectFilesPort {
63
+ return createHttpProjectFilesPort({
64
+ url: projectEndpointUrl(options.baseUrl),
65
+ fetch: options.fetch,
66
+ });
67
+ }
68
+
69
+ /**
70
+ * 造远端资源端口:故事里的裸资源 id 拼到基址下(`Audio/x.mp3` → `<基址>/Audio/x.mp3`)。
71
+ * 路径规范化沿用资源域的同一份规则(空段与 `..` 逃逸在信任边界上拒绝)。
72
+ */
73
+ export function createRemoteResourcePort(
74
+ baseUrl: string = resolveProjectBaseUrl(),
75
+ ): ResourcePort {
76
+ return createStaticResourcePort(normalizeProjectBaseUrl(baseUrl));
77
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 存档入口的宿主侧装配:槽位视图与存读点选。
3
+ *
4
+ * 槽位数、「空槽能不能点」这些判据都在壳层与引擎(`@lingfan-engine/shell` / `@lingfan-engine/core`),
5
+ * 本文件只做两件事:按工程清单的槽数拉一次逐槽视图,把面板上的点选翻成存/读命令。
6
+ * 规则不在这里另写一份——槽位 id 生成与桌面宿主同源,改规则只改那一处。
7
+ *
8
+ * 本文件不碰平台全局与框架:面板由页面渲染,这里只产出数据与回执,Node 下可直测。
9
+ */
10
+ import { slotIds, type SavePort } from "@lingfan-engine/core";
11
+ import {
12
+ canChooseSlot,
13
+ loadSlotViews,
14
+ type SlotPanelMode,
15
+ type SlotView,
16
+ } from "@lingfan-engine/shell";
17
+
18
+ export type { SlotPanelMode, SlotView };
19
+ /** 空槽判据随出口一起给页面用:槽位能不能点全仓只有壳层这一份规则 */
20
+ export { canChooseSlot };
21
+
22
+ /** 槽位视图的取数参数 */
23
+ export interface HostSlotViewsOptions {
24
+ /** 存档端口(清单与逐槽读取) */
25
+ savePort: SavePort;
26
+ /** 槽位数量(工程清单 `shell.saves.slots`,内建默认见引擎) */
27
+ slots: number;
28
+ }
29
+
30
+ /** 拉取面板要的逐槽视图:打开面板时调一次 */
31
+ export function loadHostSlotViews(
32
+ options: HostSlotViewsOptions,
33
+ ): Promise<SlotView[]> {
34
+ // 槽位 id 由引擎的唯一规则生成:`slot_1..slot_N`(与 Rust 侧存储命名兼容)
35
+ return loadSlotViews({
36
+ savePort: options.savePort,
37
+ slotIds: slotIds(options.slots),
38
+ });
39
+ }
40
+
41
+ /** 存读点选参数:命令回调由宿主句柄注入(引擎归组合根) */
42
+ export interface ChooseHostSlotOptions {
43
+ /** 面板当前模式(未打开时为 null) */
44
+ mode: SlotPanelMode | null;
45
+ /** 被点选的槽位 */
46
+ view: SlotView;
47
+ /** 写入存档(`engine.save`) */
48
+ save(slot: string): boolean;
49
+ /** 读取存档(`engine.load`) */
50
+ load(slot: string): boolean;
51
+ }
52
+
53
+ /**
54
+ * 按当前模式执行点选。
55
+ *
56
+ * 回执 = 命令是否被接受(读档真正落地晚于返回:媒体对齐挂在引擎的 `load.done` 事件上)。
57
+ * 空槽在读档模式下直接不执行——引擎读不到档也是 fail-closed,但让玩家先点一次再收一条
58
+ * 提示没有意义;判据与桌面面板同一份(`canChooseSlot`)。
59
+ */
60
+ export function chooseHostSlot(options: ChooseHostSlotOptions): boolean {
61
+ if (!canChooseSlot(options.view, options.mode)) return false;
62
+ if (options.mode === "save") return options.save(options.view.id);
63
+ return options.load(options.view.id);
64
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * 小程序覆盖层表面:把中立层的「每层显示什么」翻成页面数据。
3
+ *
4
+ * 中立核心只提交视图模型(对话/选择/通知)与两棵描述树(舞台/元素),落到节点这一端
5
+ * 归宿主;小程序没有 DOM,所以这一端的产物是**数据补丁**——一次 setData 就是一次整层
6
+ * 重建。通知的生命周期(到点移除)也在这里:中立面只给停留时长。
7
+ *
8
+ * NVL 累积层不在中立表面契约里:它读同一条渲染投影(`onView` 给的
9
+ * `nvlMode`/`nvlLines`/`lineHtml`),在这里翻成「已打完行 + 打字中一行」的 HTML。
10
+ * 可见性仍归对话层(NVL 只是对话容器的另一种内容形态,与桌面面板同口径)。
11
+ *
12
+ * 层级 z 与 DOM 侧的差别:WXML 没有 z-index 概念,层序由节点顺序决定。这里仍把 z 收下
13
+ * 并下发,由页面按 z 排层(DOM 宿主用 z-index 落地,语义不变)。
14
+ */
15
+ import type {
16
+ ChoicesLayerView,
17
+ DialogueLayerView,
18
+ NarrativeOverlayView,
19
+ NotifyLayerItem,
20
+ OverlayLayerId,
21
+ OverlaySurface,
22
+ } from "@lingfan-engine/ui";
23
+ import { isNvlActive, renderDialogueLine } from "@lingfan-engine/ui";
24
+ import { toMiniTree, type MiniNode } from "./view.ts";
25
+ import type { MiniprogramFrameSource } from "@lingfan-engine/adapters";
26
+
27
+ /** 对话层的小程序形态(字段与 WXML 绑定一致) */
28
+ export interface MiniprogramDialogueData {
29
+ visible: boolean;
30
+ rootClass: string;
31
+ speakerHtml: string;
32
+ speakerColor: string;
33
+ bodyHtml: string;
34
+ bodyFont: string;
35
+ hintHtml: string;
36
+ }
37
+
38
+ /**
39
+ * NVL 累积层的页面数据:已打完的行(HTML)+ 正在打字的最新一行(HTML)。
40
+ *
41
+ * NVL 不是独立一层——它住在对话层容器里(桌面面板同一口径:容器换宽高,内容换成累积行),
42
+ * 因此这里只给内容,可见性与容器形态由对话层决定。`null` = 不在 NVL(走普通三段式)。
43
+ */
44
+ export interface MiniprogramNvlData {
45
+ pastLines: string[];
46
+ typingHtml: string;
47
+ }
48
+
49
+ /** 选项的小程序形态:多一个稳定键供 wx:key 用 */
50
+ export interface MiniprogramChoiceOptionData {
51
+ key: string;
52
+ html: string;
53
+ label: string;
54
+ target: string;
55
+ }
56
+
57
+ /** 选择层的小程序形态(菜单与输入两形态互斥:`options` 与 `input` 只有一个非空) */
58
+ export interface MiniprogramChoicesData {
59
+ rootClass: string;
60
+ promptHtml: string;
61
+ options: MiniprogramChoiceOptionData[];
62
+ input: { prompt: string; submitText: string } | null;
63
+ }
64
+
65
+ /** 一条通知的小程序形态 */
66
+ export interface MiniprogramNoticeData {
67
+ key: string;
68
+ rootClass: string;
69
+ bodyHtml: string;
70
+ }
71
+
72
+ /** 页面持有的覆盖层数据(首次渲染前各层为空) */
73
+ export interface MiniprogramSurfaceData {
74
+ stage: MiniNode[];
75
+ elements: MiniNode[];
76
+ dialogue: MiniprogramDialogueData | null;
77
+ nvl: MiniprogramNvlData | null;
78
+ choices: MiniprogramChoicesData | null;
79
+ notices: MiniprogramNoticeData[];
80
+ layers: Record<string, number>;
81
+ }
82
+
83
+ /** 一次整层提交:字段缺省即不动 */
84
+ export type MiniprogramSurfacePatch = Partial<MiniprogramSurfaceData>;
85
+
86
+ /** 表面装配参数 */
87
+ export interface MiniprogramSurfaceDeps {
88
+ /** 帧源(覆盖层打字机与帧驱动表现都靠它) */
89
+ frame: MiniprogramFrameSource;
90
+ /** 数据发布口:页面把补丁送进 setData */
91
+ publish(patch: MiniprogramSurfacePatch): void;
92
+ }
93
+
94
+ /**
95
+ * 小程序覆盖层表面 = 中立面 + 一条 NVL 通道。
96
+ *
97
+ * NVL 不在 `OverlaySurface` 契约里——桌面宿主的做法是读渲染投影
98
+ * (`NarrativeOverlayView` 的 `nvlMode`/`nvlLines`/`lineHtml`)自己渲染累积层。
99
+ * 这里沿用同一条投影,只把「行文本 → HTML」这一步收进表面:页面拿到的就是
100
+ * 可直接绑定的字符串,不用再懂内联标记。
101
+ */
102
+ export type MiniprogramOverlaySurface = OverlaySurface & {
103
+ /** 按渲染投影刷新 NVL 累积层(`null` = 不在 NVL,走普通三段式) */
104
+ setNvl(view: NarrativeOverlayView): void;
105
+ };
106
+
107
+ /** 造小程序覆盖层表面 */
108
+ export function createMiniprogramOverlaySurface(
109
+ deps: MiniprogramSurfaceDeps,
110
+ ): MiniprogramOverlaySurface {
111
+ /** 通知键的自增序号 */
112
+ let noticeSeq = 0;
113
+ /** 在排的通知移除定时器 */
114
+ const timers = new Set<ReturnType<typeof setTimeout>>();
115
+ /** 当前在屏通知(顺序即呈现顺序) */
116
+ let notices: MiniprogramNoticeData[] = [];
117
+ /** 各层 z(由中立核心下发;页面按它排层) */
118
+ const layers: Record<string, number> = {};
119
+ let dialogue: MiniprogramDialogueData | null = null;
120
+ let choices: MiniprogramChoicesData | null = null;
121
+ let nvl: MiniprogramNvlData | null = null;
122
+ /**
123
+ * NVL 已打完行的 HTML 缓存:打字帧每帧都会来一次投影通知,而累积行不变——
124
+ * 按数组引用命中(投影器复用它自己的缓冲数组,打字帧下引用稳定)。
125
+ */
126
+ let nvlPastCache: {
127
+ lines: readonly string[];
128
+ html: string[];
129
+ } | null = null;
130
+
131
+ function publishStage(tree: MiniNode[]): void {
132
+ deps.publish({ stage: tree });
133
+ }
134
+
135
+ function publishElements(tree: MiniNode[]): void {
136
+ deps.publish({ elements: tree });
137
+ }
138
+
139
+ return {
140
+ frame: deps.frame,
141
+ stage: {
142
+ clear: () => publishStage([]),
143
+ mount: (spec) => publishStage(toMiniTree(spec)),
144
+ },
145
+ elements: {
146
+ clear: () => publishElements([]),
147
+ mount: (spec) => publishElements(toMiniTree(spec)),
148
+ },
149
+ setLayerZ(layer: OverlayLayerId, z: number): void {
150
+ layers[layer] = z;
151
+ deps.publish({ layers: { ...layers } });
152
+ },
153
+ setDialogue(view: DialogueLayerView): void {
154
+ dialogue = {
155
+ visible: view.visible,
156
+ rootClass: view.rootClass,
157
+ speakerHtml: view.speakerHtml,
158
+ speakerColor: view.speakerColor,
159
+ bodyHtml: view.bodyHtml,
160
+ bodyFont: view.bodyFont,
161
+ hintHtml: view.hintHtml,
162
+ };
163
+ deps.publish({ dialogue });
164
+ },
165
+ setChoices(view: ChoicesLayerView | null): void {
166
+ choices = view === null ? null : toChoicesData(view);
167
+ deps.publish({ choices });
168
+ },
169
+ setNvl(view: NarrativeOverlayView): void {
170
+ if (!isNvlActive(view.nvlMode, view.nvlLines)) {
171
+ if (nvl === null) return;
172
+ nvl = null;
173
+ deps.publish({ nvl });
174
+ return;
175
+ }
176
+ // 累积行只在缓冲变化时重算;打字帧只更新最新一行的可见前缀
177
+ if (nvlPastCache === null || nvlPastCache.lines !== view.nvlLines) {
178
+ nvlPastCache = {
179
+ lines: view.nvlLines,
180
+ html: view.nvlLines
181
+ .slice(0, -1)
182
+ .map((line) => renderDialogueLine({ text: line }).html),
183
+ };
184
+ }
185
+ nvl = {
186
+ pastLines: nvlPastCache.html,
187
+ typingHtml: view.lineHtml,
188
+ };
189
+ deps.publish({ nvl });
190
+ },
191
+ pushNotification(item: NotifyLayerItem, durationMs: number): void {
192
+ const key = `notice-${noticeSeq}`;
193
+ noticeSeq += 1;
194
+ notices = [
195
+ ...notices,
196
+ { key, rootClass: item.rootClass, bodyHtml: item.bodyHtml },
197
+ ];
198
+ deps.publish({ notices });
199
+ const timer = setTimeout(() => {
200
+ timers.delete(timer);
201
+ notices = notices.filter((notice) => notice.key !== key);
202
+ deps.publish({ notices });
203
+ }, durationMs);
204
+ timers.add(timer);
205
+ },
206
+ clear(): void {
207
+ for (const timer of timers) clearTimeout(timer);
208
+ timers.clear();
209
+ notices = [];
210
+ dialogue = null;
211
+ choices = null;
212
+ nvl = null;
213
+ nvlPastCache = null;
214
+ deps.publish({
215
+ stage: [],
216
+ elements: [],
217
+ dialogue: null,
218
+ nvl: null,
219
+ choices: null,
220
+ notices: [],
221
+ });
222
+ },
223
+ };
224
+ }
225
+
226
+ /** 选择层视图 → 页面数据(选项补稳定键) */
227
+ function toChoicesData(view: ChoicesLayerView): MiniprogramChoicesData {
228
+ return {
229
+ rootClass: view.rootClass,
230
+ promptHtml: view.promptHtml,
231
+ options: view.options.map((option, index) => ({
232
+ key: `option-${index}`,
233
+ html: option.html,
234
+ label: option.label,
235
+ target: option.target,
236
+ })),
237
+ input: view.input === null ? null : { ...view.input },
238
+ };
239
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * 视频呈现面(小程序):把视频端口的命令落成页面数据,播放中的控制走组件控制柄。
3
+ *
4
+ * 小程序的 `video` 是声明式组件——换片与显示靠页面数据(`src` / 是否可见),
5
+ * 播放中的暂停、续播、定位靠 `uni.createVideoContext(id)` 的控制柄;自然播完由页面
6
+ * 把组件的 `bindended` 转进本面的 `notifyEnded`,端口据此解除引擎的 video 等待。
7
+ *
8
+ * 控制柄按需取:组件还没渲染时取柄会抛错,此时动作直接丢弃(下一次动作再取),
9
+ * 不让一次时序问题炸掉整个宿主。
10
+ */
11
+
12
+ import type { MiniprogramVideoSurfaceLike } from "@lingfan-engine/adapters";
13
+ import { createVideoContext, type UniVideoContext } from "./platform.ts";
14
+
15
+ /** 页面数据里的视频状态(页面据此渲染 `video` 组件) */
16
+ export interface MiniprogramVideoData {
17
+ /** 资源地址(空串 = 无片可播) */
18
+ url: string;
19
+ /** 是否可见:不可见时组件不占位,也不盖住舞台 */
20
+ visible: boolean;
21
+ /** 0..1 */
22
+ volume: number;
23
+ loop: boolean;
24
+ }
25
+
26
+ /** 视频组件的 id:页面模板与呈现面必须一致 */
27
+ export const VIDEO_COMPONENT_ID = "stage-video";
28
+
29
+ /** 呈现面装配参数 */
30
+ export interface MiniprogramVideoSurfaceDeps {
31
+ /** 把视频状态落到页面数据 */
32
+ publish(video: MiniprogramVideoData): void;
33
+ }
34
+
35
+ /** 呈现面:端口契约 + 页面侧的播完回传口 */
36
+ export interface MiniprogramVideoSurface extends MiniprogramVideoSurfaceLike {
37
+ /** 播放自然结束(页面 `bindended` 转发) */
38
+ notifyEnded(): void;
39
+ }
40
+
41
+ /** 免于无人观看的视频状态(隐藏时页面数据归零) */
42
+ const HIDDEN: MiniprogramVideoData = {
43
+ url: "",
44
+ visible: false,
45
+ volume: 0,
46
+ loop: false,
47
+ };
48
+
49
+ /** 造一个视频呈现面 */
50
+ export function createMiniprogramVideoSurface(
51
+ deps: MiniprogramVideoSurfaceDeps,
52
+ ): MiniprogramVideoSurface {
53
+ /** 播完处理器(每次 play 换一次,由端口注册) */
54
+ let ended: (() => void) | null = null;
55
+ /** 当前是否有片在屏(控制柄只在可见时有意义) */
56
+ let visible = false;
57
+ /** 控制柄(懒取;取失败不缓存,下次再试) */
58
+ let context: UniVideoContext | null = null;
59
+
60
+ /** 取控制柄:组件未渲染时返回 null(动作丢弃) */
61
+ function readContext(): UniVideoContext | null {
62
+ if (context !== null) return context;
63
+ try {
64
+ context = createVideoContext(VIDEO_COMPONENT_ID);
65
+ } catch {
66
+ return null;
67
+ }
68
+ return context;
69
+ }
70
+
71
+ return {
72
+ show(request): void {
73
+ visible = true;
74
+ deps.publish({
75
+ url: request.url,
76
+ visible: true,
77
+ volume: request.volume,
78
+ loop: request.loop,
79
+ });
80
+ },
81
+ hide(): void {
82
+ visible = false;
83
+ deps.publish({ ...HIDDEN });
84
+ },
85
+ pause(): void {
86
+ if (!visible) return;
87
+ readContext()?.pause();
88
+ },
89
+ resume(): void {
90
+ if (!visible) return;
91
+ readContext()?.play();
92
+ },
93
+ seek(seconds): void {
94
+ if (!visible) return;
95
+ readContext()?.seek(Math.max(0, seconds));
96
+ },
97
+ onEnded(callback): void {
98
+ ended = callback;
99
+ },
100
+ notifyEnded(): void {
101
+ ended?.();
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 渲染树翻译:把 `@lingfan-engine/ui` 的元素描述翻成小程序能绑定的数据树。
3
+ *
4
+ * 小程序没有 DOM,WXML 只能绑数据,所以渲染端口在这里不是「造节点」而是「算数据」:
5
+ * 本文件把描述树算成 `MiniNode[]`,由页面把它送进 setData,再由 WXML 的递归模板
6
+ * 铺开。样式串也在这里拼好(camelCase 键 → CSS 声明),免得模板里再算一遍。
7
+ */
8
+ import type { StageLayout } from "@lingfan-engine/shell";
9
+ import type { RenderSpec } from "@lingfan-engine/ui";
10
+
11
+ /** 一个待渲染节点:字段与 WXML 模板一一对应 */
12
+ export interface MiniNode {
13
+ /** 同层内稳定键,供模板的 wx:key 用 */
14
+ key: string;
15
+ /** 元素种类(box / text / image / media / input / slider / checkbox / action) */
16
+ kind: string;
17
+ /** 语义类名(具体观感挂在工程样式表的类上) */
18
+ className: string;
19
+ /** 已拼好的行内 CSS 声明串 */
20
+ style: string;
21
+ /** 纯文本内容 */
22
+ text?: string;
23
+ /** 富文本内容(含标记的片段,交给 rich-text 渲染) */
24
+ html?: string;
25
+ /** 媒体地址(图与视频用) */
26
+ src?: string;
27
+ /** 子节点 */
28
+ children?: MiniNode[];
29
+ }
30
+
31
+ /** 把一棵描述树翻成节点树;`path` 是给子节点编键用的前缀 */
32
+ export function toMiniNode(spec: RenderSpec, path: string): MiniNode {
33
+ const node: MiniNode = {
34
+ key: spec.id ?? path,
35
+ kind: spec.kind,
36
+ className: spec.className ?? "",
37
+ style: styleText(spec.style),
38
+ };
39
+ if (spec.text !== undefined) node.text = spec.text;
40
+ if (spec.html !== undefined) node.html = spec.html;
41
+ const src = spec.props?.["src"];
42
+ if (typeof src === "string") node.src = src;
43
+ const children = spec.children ?? [];
44
+ if (children.length > 0) {
45
+ node.children = children.map((child, index) =>
46
+ toMiniNode(child, `${path}-${index}`),
47
+ );
48
+ }
49
+ return node;
50
+ }
51
+
52
+ /** 把一棵描述树翻成一层节点(无描述 = 空层) */
53
+ export function toMiniTree(spec: RenderSpec | null): MiniNode[] {
54
+ return spec === null ? [] : [toMiniNode(spec, "0")];
55
+ }
56
+
57
+ /**
58
+ * 把舞台布局算成内层容器的行内样式:先按基准尺寸定死舞台,再整体平移与缩放。
59
+ *
60
+ * 变换原点取左上角(见工程样式表),因此平移量与缩放量的口径与布局计算一致:
61
+ * 先缩放再平移,偏移就是缩放后内容与视口的左上角距离。
62
+ */
63
+ export function stageStyle(layout: StageLayout): string {
64
+ const translate = `translate(${round(layout.offsetX)}px,${round(layout.offsetY)}px)`;
65
+ const scale = `scale(${round(layout.scaleX)},${round(layout.scaleY)})`;
66
+ return [
67
+ `width:${layout.width}px`,
68
+ `height:${layout.height}px`,
69
+ `transform:${translate} ${scale}`,
70
+ ].join(";");
71
+ }
72
+
73
+ /** 小数截到三位:避免布局浮点数把样式串撑得过长 */
74
+ function round(value: number): number {
75
+ return Math.round(value * 1000) / 1000;
76
+ }
77
+
78
+ /** 拼行内样式:camelCase 键转 CSS 声明的连字符形态,值原样(数值已在元素样式层折算) */
79
+ function styleText(
80
+ style: Readonly<Record<string, string>> | undefined,
81
+ ): string {
82
+ if (style === undefined) return "";
83
+ return Object.entries(style)
84
+ .map(([property, value]) => `${toCssProperty(property)}:${value}`)
85
+ .join(";");
86
+ }
87
+
88
+ /** `maxWidth` → `max-width`(已是连字符形态的原样保留) */
89
+ function toCssProperty(property: string): string {
90
+ return property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
91
+ }