@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,50 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 舞台视图:舞台盒(基准尺寸 + 宿主下发的平移缩放)与其中的两层节点树。
4
+ *
5
+ * 画布不在这里——帧时钟画布留在页面上,页面的选择器查询才拿得到它(跨组件查询
6
+ * 在小程序里不可靠)。这里只负责把宿主算好的描述树铺成节点。
7
+ */
8
+ import NodeTree from "./NodeTree.vue";
9
+ import type { MiniNode } from "../mp/index.ts";
10
+
11
+ defineOptions({ name: "StageView" });
12
+
13
+ defineProps<{
14
+ /** 舞台层的节点树(基准坐标系) */
15
+ stage: MiniNode[];
16
+ /** 元素层的节点树(同上,叠在舞台层之上) */
17
+ elements: MiniNode[];
18
+ /** 舞台盒的行内样式:尺寸、平移与缩放,由宿主按视口算好 */
19
+ stageBox: string;
20
+ }>();
21
+ </script>
22
+
23
+ <template>
24
+ <view class="stage-box" :style="stageBox">
25
+ <NodeTree :nodes="stage" />
26
+ <view class="element-layer">
27
+ <NodeTree :nodes="elements" />
28
+ </view>
29
+ </view>
30
+ </template>
31
+
32
+ <style>
33
+ /* 舞台盒:基准尺寸 + 宿主下发的平移与缩放;原点取左上角,与布局口径一致 */
34
+ .stage-box {
35
+ position: absolute;
36
+ left: 0;
37
+ top: 0;
38
+ transform-origin: 0 0;
39
+ }
40
+
41
+ /* 元素层不吃点击:叙事元素暂不接管交互(元素交互走覆盖层的命令出口,属后续接线) */
42
+ .element-layer {
43
+ position: absolute;
44
+ left: 0;
45
+ top: 0;
46
+ right: 0;
47
+ bottom: 0;
48
+ pointer-events: none;
49
+ }
50
+ </style>
@@ -0,0 +1 @@
1
+ /// <reference types="@dcloudio/types" />
@@ -0,0 +1,12 @@
1
+ /**
2
+ * UniApp 约定入口:返回应用实例(小程序与 H5 共用同一份代码)。
3
+ *
4
+ * 页面装配在 `pages/index/index.vue`,宿主接线在 `mp/`——入口只负责建应用。
5
+ */
6
+ import { createSSRApp } from "vue";
7
+ import App from "./App.vue";
8
+
9
+ export function createApp(): { app: ReturnType<typeof createSSRApp> } {
10
+ const app = createSSRApp(App);
11
+ return { app };
12
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "__PROJECT__",
3
+ "appid": "",
4
+ "description": "灵泛叙事引擎的小程序形态:UniApp(Vue 3)页面层接住框架无关的端口装配",
5
+ "versionName": "0.1.0",
6
+ "versionCode": "0",
7
+ "mp-weixin": {
8
+ "appid": "",
9
+ "libVersion": "3.15.1",
10
+ "setting": {
11
+ "urlCheck": false,
12
+ "es6": true,
13
+ "minified": false
14
+ },
15
+ "usingComponents": true,
16
+ "lazyCodeLoading": "requiredComponents"
17
+ },
18
+ "h5": {
19
+ "title": "__PROJECT__"
20
+ }
21
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 帧源装配:叙事覆盖层每帧只要一个「下一帧」回调,帧从哪来由宿主决定。
3
+ *
4
+ * 有画布节点就用它的 requestAnimationFrame(与真机刷新同步、后台自动停),
5
+ * 没有就退回定时器(约 60 帧/秒)。两条路都满足同一个 `FrameSource` 形状,
6
+ * 上层装配器与帧循环不需要知道用的是哪条。
7
+ */
8
+ import {
9
+ createMiniprogramFrameSource,
10
+ type MiniprogramFrameSource,
11
+ } from "@lingfan-engine/adapters";
12
+ import { uniApi, type MiniprogramCanvasNode } from "./platform.ts";
13
+
14
+ /** 定时器帧源的间隔(毫秒):约 60 帧/秒,与常见屏幕刷新率一致 */
15
+ const TIMER_FRAME_MS = 16;
16
+
17
+ /** 造帧源:给画布就走画布,否则走定时器 */
18
+ export function createHostFrameSource(
19
+ canvas: MiniprogramCanvasNode | null,
20
+ ): MiniprogramFrameSource {
21
+ return canvas !== null
22
+ ? createMiniprogramFrameSource(canvas)
23
+ : createTimerFrameSource();
24
+ }
25
+
26
+ /**
27
+ * 定时器帧源:句柄从 1 起编(0 留给「无句柄」的既有约定),取消时清掉在排的定时器。
28
+ */
29
+ function createTimerFrameSource(): MiniprogramFrameSource {
30
+ const api = uniApi();
31
+ const pending = new Map<number, number>();
32
+ let nextHandle = 1;
33
+ return {
34
+ request(callback: (now: number) => void): number {
35
+ const handle = nextHandle;
36
+ nextHandle += 1;
37
+ const timer = api.setTimeout(() => {
38
+ pending.delete(handle);
39
+ callback(Date.now());
40
+ }, TIMER_FRAME_MS);
41
+ pending.set(handle, timer);
42
+ return handle;
43
+ },
44
+ cancel(handle: number): void {
45
+ const timer = pending.get(handle);
46
+ if (timer === undefined) return;
47
+ api.clearTimeout(timer);
48
+ pending.delete(handle);
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 演示工程的守卫实现:故事 JSON 里只写守卫名(`{"op":"guard","fn":"gold-non-negative"}`),
3
+ * 实现必须由宿主提供——引擎是「组合根注册制」,没登记的守卫名在运行期会 fail-closed。
4
+ *
5
+ * 内容是包外的、守卫是包内的,这是有意为之:守卫属于代码而不是内容,服务端换一份故事
6
+ * 也换不掉它。演示工程用到的两个守卫语义与 `apps/playground/Stories.src/lib/guards.ts`
7
+ * 的 cell 声明一致(那边由 `stories:build` 生成注册表,这边没有生成器,手写同一份语义)。
8
+ *
9
+ * 名字闭合靠 `tests/miniprogram/app-host.test.ts` 的互锁:`project/Stories/**` 里出现的
10
+ * 每个 `"fn"` 都必须在 `guards` 里登记,缺一个就是运行期的「未注册的守卫」。
11
+ */
12
+ import type { GuardFn } from "@lingfan-engine/core";
13
+
14
+ export const guards: Readonly<Record<string, GuardFn>> = {
15
+ /** 金币非负:`player.gold` 被改成负数即拦下,故事停在当前命令。 */
16
+ "gold-non-negative": (ctx) => {
17
+ const gold = ctx.get("player.gold");
18
+ if (typeof gold !== "number" || gold < 0) {
19
+ ctx.fail(`player.gold 非法:${String(gold)}`);
20
+ }
21
+ },
22
+ /** 巡礼守卫:站点自带的闸门演示——先 set 状态再 guard,守卫读的是 SSOT 事实。 */
23
+ "tour-open": (ctx) => {
24
+ if (ctx.get("tour.open") !== true) {
25
+ ctx.fail("巡礼尚未开始(tour.open 未置位)");
26
+ }
27
+ },
28
+ };
@@ -0,0 +1,252 @@
1
+ /**
2
+ * 小程序宿主装配:从远端工程到可跑的叙事覆盖层,一次接线到位。
3
+ *
4
+ * 接线顺序与嵌入演示一致——装载工程 → 建引擎(注入平台端口与文案)→ 建媒体渲染器 →
5
+ * 建表面 → 建覆盖层 → 起帧循环。差别只有三处:工程来自工程接口而不是包内文件;
6
+ * 表面把视图变成页面数据而不是 DOM 节点;舞台按视口与基准算出缩放再交给页面
7
+ * (小程序没有 vw/vh 之类整体缩放手段);媒体的位置回写借覆盖层的帧循环
8
+ * (与打字机同一条时间线),视频播完由页面回传。
9
+ *
10
+ * 本文件是组合根:允许它同时知道引擎、展示层与平台,别处不允许这样横跨。
11
+ */
12
+ import {
13
+ defaultEngineTexts,
14
+ findMissingGuards,
15
+ PlayerPreferences,
16
+ resolveSavesConfig,
17
+ slotIds,
18
+ StoryEngine,
19
+ type SavesConfig,
20
+ type Story,
21
+ } from "@lingfan-engine/core";
22
+ import { loadProject } from "@lingfan-engine/adapters";
23
+ import {
24
+ computeStageLayout,
25
+ DEFAULT_STAGE_FIT,
26
+ DEFAULT_STAGE_HEIGHT,
27
+ DEFAULT_STAGE_WIDTH,
28
+ withSlotLabels,
29
+ type StageConfig,
30
+ type StageLayout,
31
+ } from "@lingfan-engine/shell";
32
+ import {
33
+ createAudioRenderer,
34
+ createElementResourceResolver,
35
+ createNarrativeOverlay,
36
+ createVideoRenderer,
37
+ type AudioRenderer,
38
+ type NarrativeOverlay,
39
+ type VideoRenderer,
40
+ } from "@lingfan-engine/ui";
41
+ import { createHostFrameSource } from "./frame.ts";
42
+ import {
43
+ createRequestFetch,
44
+ readWindowSize,
45
+ type MiniprogramCanvasNode,
46
+ } from "./platform.ts";
47
+ import {
48
+ createMiniprogramHostPorts,
49
+ type MiniprogramHostPorts,
50
+ } from "./ports.ts";
51
+ import {
52
+ createRemoteProjectFilesPort,
53
+ resolveProjectBaseUrl,
54
+ } from "./project-source.ts";
55
+ import { guards } from "./guards.ts";
56
+ import {
57
+ createMiniprogramOverlaySurface,
58
+ type MiniprogramSurfacePatch,
59
+ } from "./surface.ts";
60
+ import {
61
+ createMiniprogramVideoSurface,
62
+ type MiniprogramVideoData,
63
+ type MiniprogramVideoSurface,
64
+ } from "./video-surface.ts";
65
+ import { stageStyle } from "./view.ts";
66
+
67
+ /** 覆盖层字速(字符/秒) */
68
+ const TEXT_SPEED = 30;
69
+
70
+ /** 舞台基准与装入方式:1280×720 等比装入视口(换基准改这里即可) */
71
+ const STAGE_CONFIG: StageConfig = {
72
+ width: DEFAULT_STAGE_WIDTH,
73
+ height: DEFAULT_STAGE_HEIGHT,
74
+ fit: DEFAULT_STAGE_FIT,
75
+ };
76
+
77
+ /** 页面数据补丁:覆盖层各层 + 视频状态 + 舞台尺寸串 + 错误横幅 */
78
+ export type MiniprogramHostPatch = MiniprogramSurfacePatch & {
79
+ stageBox?: string;
80
+ video?: MiniprogramVideoData;
81
+ error?: string;
82
+ };
83
+
84
+ /** 宿主装配参数 */
85
+ export interface MiniprogramHostDeps {
86
+ /** 页面数据发布口 */
87
+ publish(patch: MiniprogramHostPatch): void;
88
+ /** 画布节点(有则用它做帧源;无则退回定时器帧源) */
89
+ canvas?: MiniprogramCanvasNode | null;
90
+ /** 工程基址(故事与资源都从它取);缺省读运行期配置 */
91
+ projectBaseUrl?: string;
92
+ }
93
+
94
+ /** 宿主句柄:页面用它做重算、媒体回传、销毁与调试观测 */
95
+ export interface MiniprogramHostHandle {
96
+ engine: StoryEngine;
97
+ overlay: NarrativeOverlay;
98
+ ports: MiniprogramHostPorts;
99
+ /** 当前舞台布局(视口 + 基准算出的缩放与偏移) */
100
+ layout(): StageLayout;
101
+ /** 视口尺寸变化后重算布局并下发 */
102
+ resize(): void;
103
+ /** 视频自然播完(页面 `bindended` 转发):引擎据此解除 video 等待 */
104
+ videoEnded(): void;
105
+ /** 媒体与引擎状态对齐(读档/回溯完成后显式调用) */
106
+ syncMedia(): void;
107
+ /** 存档壳配置(槽数由工程清单 `shell.saves` 覆盖,缺省内建值) */
108
+ saves: SavesConfig;
109
+ /** 槽位 id 序列(面板呈现顺序) */
110
+ slotIds: string[];
111
+ /** 写入存档:回执来自引擎(失败文案由引擎通知,宿主不另编) */
112
+ saveGame(slot: string): boolean;
113
+ /** 读取存档:成功则媒体与投影各对齐一次 */
114
+ loadGame(slot: string): boolean;
115
+ /** 卸载:停帧循环、停媒体、退订状态、清空各层 */
116
+ dispose(): void;
117
+ }
118
+
119
+ /** 启动小程序宿主:从工程接口装载故事并起叙事循环 */
120
+ export async function startMiniprogramHost(
121
+ deps: MiniprogramHostDeps,
122
+ ): Promise<MiniprogramHostHandle> {
123
+ const projectBaseUrl = deps.projectBaseUrl ?? resolveProjectBaseUrl();
124
+ const filesPort = createRemoteProjectFilesPort({
125
+ baseUrl: projectBaseUrl,
126
+ fetch: createRequestFetch(),
127
+ });
128
+ const story: Story = await loadProject(filesPort);
129
+
130
+ // 存档槽数与缩略图参数来自同一份工程清单(`shell.saves` 逐键覆盖):清单与故事是同一次
131
+ // 装载(端口内 memo),这里再取一次不产生第二个请求。
132
+ const saves: SavesConfig = resolveSavesConfig(await filesPort.manifest());
133
+
134
+ // 内容是包外的(故事走工程接口,改故事不必发版),守卫却在包里:装载完成先对名字,
135
+ // 缺谁一次报全——错误停在起播之前,而不是播到那条命令才弹「未注册的守卫」。
136
+ const missingGuards = findMissingGuards(story, guards);
137
+ if (missingGuards.length > 0) {
138
+ throw new Error(
139
+ `故事引用了未注册的守卫:${missingGuards.join("、")}(宿主需在 src/mp/guards.ts 补齐实现)`,
140
+ );
141
+ }
142
+
143
+ const videoSurface: MiniprogramVideoSurface = createMiniprogramVideoSurface({
144
+ publish: (video) => deps.publish({ video }),
145
+ });
146
+ const ports = createMiniprogramHostPorts({ videoSurface, projectBaseUrl });
147
+ const engine = new StoryEngine(story, {
148
+ savePort: ports.save,
149
+ texts: withSlotLabels(defaultEngineTexts),
150
+ // 守卫实现由宿主随代码带上:故事 JSON 只写名字(装载期已校验名字齐全)
151
+ guards,
152
+ });
153
+
154
+ // 玩家偏好在渲染器之前水合:首帧起用的就是上次的音量,不必等下一次用户操作
155
+ const preferences = new PlayerPreferences(ports.preferences);
156
+ await preferences.hydrate();
157
+
158
+ /** 诊断出口:媒体装配失败与覆盖层运行时错误都落到同一条出口(fail-closed:不播,但不静默) */
159
+ const reportError = (message: string): void => {
160
+ deps.publish({ error: message });
161
+ };
162
+
163
+ // 音频渲染器与视频渲染器都按引擎状态做差量;构造即订阅,先于覆盖层建好不影响语义。
164
+ const audio: AudioRenderer = createAudioRenderer(
165
+ engine,
166
+ ports.audio,
167
+ ports.resources,
168
+ { onError: reportError, preferences },
169
+ );
170
+ const video: VideoRenderer = createVideoRenderer(
171
+ engine,
172
+ ports.video,
173
+ ports.resources,
174
+ {
175
+ onError: reportError,
176
+ // 组合根职责:cutscene 播完 → 引擎解除 video 等待
177
+ onVideoFinished: () => engine.videoFinished(),
178
+ },
179
+ );
180
+
181
+ const surface = createMiniprogramOverlaySurface({
182
+ frame: createHostFrameSource(deps.canvas ?? null),
183
+ publish: deps.publish,
184
+ });
185
+
186
+ // 元素资源解析是异步的而元素渲染是同步的:用现成的同步查表皮接上,
187
+ // 解析完成后要求覆盖层补一次重渲(回调延后读 overlay,故用闭包而非参数)。
188
+ const elementResources = createElementResourceResolver({
189
+ resolve: (path) => ports.resources.resolve(path),
190
+ onResolved: () => overlay.sync(),
191
+ });
192
+ const overlay: NarrativeOverlay = createNarrativeOverlay({
193
+ surface,
194
+ engine,
195
+ textSpeed: TEXT_SPEED,
196
+ resolveResource: elementResources.resolveForElement,
197
+ onError: reportError,
198
+ // 覆盖层的帧循环一直在跑:音频播放位置借它逐帧回写(与打字机同一条时间线)
199
+ onFrame: () => audio.pollPosition(),
200
+ // NVL 累积层读同一条渲染投影(桌面面板也是读它自己渲染):表面把它翻成页面数据
201
+ onView: (view) => surface.setNvl(view),
202
+ });
203
+
204
+ let layout = computeStageLayout(readWindowSize(), STAGE_CONFIG);
205
+
206
+ /** 读一次视口、重算布局并下发(初始化与尺寸变化共用) */
207
+ function resize(): void {
208
+ layout = computeStageLayout(readWindowSize(), STAGE_CONFIG);
209
+ deps.publish({ stageBox: stageStyle(layout) });
210
+ }
211
+
212
+ resize();
213
+ engine.start();
214
+
215
+ /** 媒体与投影对齐:读档/回溯后音频、视频与 NVL 要跟引擎状态重新对齐 */
216
+ function syncMedia(): void {
217
+ audio.sync();
218
+ video.sync();
219
+ // 覆盖层 sync 只重渲各层、不触发投影通知:NVL 也在这里补一次对齐
220
+ surface.setNvl(overlay.view());
221
+ }
222
+
223
+ // 读档真正落地晚于命令返回:对齐挂在完成信号上,别在 `loadGame` 里当场同步。
224
+ // 覆盖层 sync 是必须的——引擎重放只驱动「变化量」,读档后的整幅画面要靠一次全量重渲补齐。
225
+ const stopLoadEvents = engine.onEvent((event) => {
226
+ if (event.payload.kind !== "load.done") return;
227
+ overlay.sync();
228
+ syncMedia();
229
+ });
230
+
231
+ return {
232
+ engine,
233
+ overlay,
234
+ ports,
235
+ saves,
236
+ slotIds: slotIds(saves.slots),
237
+ layout: () => layout,
238
+ resize,
239
+ videoEnded: () => videoSurface.notifyEnded(),
240
+ syncMedia,
241
+ saveGame: (slot) => engine.save(slot),
242
+ loadGame: (slot) => engine.load(slot),
243
+ dispose: () => {
244
+ stopLoadEvents();
245
+ overlay.dispose();
246
+ audio.dispose();
247
+ video.dispose();
248
+ engine.dispose();
249
+ preferences.dispose();
250
+ },
251
+ };
252
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 小程序宿主的对外出口:页面与其它入口只从这里取件,别处不允许深引内部模块。
3
+ */
4
+ export * from "./platform.ts";
5
+ export * from "./view.ts";
6
+ export * from "./surface.ts";
7
+ export * from "./video-surface.ts";
8
+ export * from "./slots.ts";
9
+ export * from "./host.ts";
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 平台取用点:宿主工程里的 `uni` 全局只在本文件出现,其余模块一律经此取件。
3
+ *
4
+ * UniApp 的 `uni` 是各端 API 的统一面(微信端即 `wx` 的镜像),因此同一份页面代码既能跑
5
+ * 小程序,也能在浏览器里跑 H5 预览。把全局收在一处的原因有两条:其余模块要能在 Node 下被
6
+ * 类型检查与单测导入,把 `uni` 撒在各处会让它们只能在真机里跑;平台能力的形状集中在这里,
7
+ * 将来换端只需替换本文件。
8
+ */
9
+
10
+ import type {
11
+ HttpFetch,
12
+ MiniprogramAudioContextLike,
13
+ } from "@lingfan-engine/adapters";
14
+
15
+ /** 小程序同步存储面(值与键都是字符串;取不存在的键返回空串) */
16
+ export interface UniStorageApi {
17
+ setStorageSync(key: string, value: string): void;
18
+ getStorageSync(key: string): unknown;
19
+ removeStorageSync(key: string): void;
20
+ getStorageInfoSync(): { keys: string[] };
21
+ }
22
+
23
+ /** 画布节点面:只声明宿主用到的那一层(帧源与像素尺寸) */
24
+ export interface MiniprogramCanvasNode {
25
+ width: number;
26
+ height: number;
27
+ requestAnimationFrame(callback: (now: number) => void): number;
28
+ cancelAnimationFrame(handle: number): void;
29
+ }
30
+
31
+ /** 系统信息面:只声明窗口尺寸与像素比 */
32
+ export interface UniSystemInfo {
33
+ windowWidth: number;
34
+ windowHeight: number;
35
+ pixelRatio: number;
36
+ platform?: string;
37
+ }
38
+
39
+ /** 选择器查询面:只声明取画布节点用到的那两步 */
40
+ export interface UniSelectorQuery {
41
+ select(selector: string): UniSelectorQuery;
42
+ node(
43
+ callback: (result: { node?: MiniprogramCanvasNode }) => void,
44
+ ): UniSelectorQuery;
45
+ exec(): void;
46
+ }
47
+
48
+ /** 尺寸变化回执:小程序按窗口给了新的逻辑尺寸 */
49
+ export interface UniResizeResult {
50
+ size: { windowWidth: number; windowHeight: number };
51
+ }
52
+
53
+ /** 网络请求回执:只声明工程接口用到的那几个字段(`data` 可能已被平台解析) */
54
+ export interface UniRequestResult {
55
+ statusCode: number;
56
+ data: unknown;
57
+ }
58
+
59
+ /** 网络请求的失败回执:`errMsg` 是平台给的可读原因 */
60
+ export interface UniRequestFailure {
61
+ errMsg?: string;
62
+ }
63
+
64
+ /** 网络请求参数:只声明本工程用到的那一层 */
65
+ export interface UniRequestOptions {
66
+ url: string;
67
+ method?: "GET";
68
+ /** 返回体的处理方式:`"text"` = 不当 JSON 解析(故事要原文) */
69
+ dataType?: string;
70
+ responseType?: string;
71
+ success(result: UniRequestResult): void;
72
+ fail(error: UniRequestFailure): void;
73
+ }
74
+
75
+ /**
76
+ * 视频组件的控制柄(`uni.createVideoContext(id)` 返回值里用到的四个动作)。
77
+ * 小程序的 `video` 是声明式组件:起播靠组件属性,播放中控制靠这个柄。
78
+ */
79
+ export interface UniVideoContext {
80
+ play(): void;
81
+ pause(): void;
82
+ seek(seconds: number): void;
83
+ stop(): void;
84
+ }
85
+
86
+ /** 本工程用到的 `uni` 全局的一层 */
87
+ export interface UniApi {
88
+ getSystemInfoSync(): UniSystemInfo;
89
+ setStorageSync(key: string, value: string): void;
90
+ getStorageSync(key: string): unknown;
91
+ removeStorageSync(key: string): void;
92
+ getStorageInfoSync(): { keys: string[] };
93
+ createSelectorQuery(): UniSelectorQuery;
94
+ createInnerAudioContext(): MiniprogramAudioContextLike;
95
+ createVideoContext(id: string): UniVideoContext;
96
+ request(options: UniRequestOptions): void;
97
+ onWindowResize(callback: (result: UniResizeResult) => void): void;
98
+ offWindowResize(callback: (result: UniResizeResult) => void): void;
99
+ nextTick(callback: () => void): void;
100
+ setTimeout(callback: () => void, ms: number): number;
101
+ clearTimeout(handle: number): void;
102
+ }
103
+
104
+ /** 画布节点查询的等待上限:超时按「取不到」处理,退回定时器帧源 */
105
+ const CANVAS_QUERY_TIMEOUT_MS = 300;
106
+
107
+ /** 取 `uni` 全局(本工程唯一的取用点) */
108
+ export function uniApi(): UniApi {
109
+ return uni as unknown as UniApi;
110
+ }
111
+
112
+ /** 读窗口尺寸:小程序给的是逻辑像素,不需要再折算像素比 */
113
+ export function readWindowSize(): { width: number; height: number } {
114
+ const info = uniApi().getSystemInfoSync();
115
+ return { width: info.windowWidth, height: info.windowHeight };
116
+ }
117
+
118
+ /**
119
+ * 造一个音频实例(`bgm` / `ambient` / `voice` / `se` 四条通道各持一个)。
120
+ *
121
+ * 实例的 `src` 是包内路径或临时地址;`se` 通道播完即销毁,常驻通道由适配器回收。
122
+ */
123
+ export function createAudioContext(): MiniprogramAudioContextLike {
124
+ return uniApi().createInnerAudioContext();
125
+ }
126
+
127
+ /**
128
+ * 取页面上某个 `video` 组件的控制柄;组件还没渲染时调用方要自己兜住异常。
129
+ * @param id 页面里 `video` 组件的 id
130
+ */
131
+ export function createVideoContext(id: string): UniVideoContext {
132
+ return uniApi().createVideoContext(id);
133
+ }
134
+
135
+ /**
136
+ * 造一个取件面(HTTP):把 `uni.request` 包成适配器认的最小 `fetch` 形状。
137
+ *
138
+ * 工程接口要的是**原文**(故事的解析归引擎),所以返回体不交给平台解析;失败走 reject,
139
+ * 由装载方 fail-closed(不降级成空工程)。取件面在调用时才碰 `uni`,模块导入不依赖运行时。
140
+ */
141
+ export function createRequestFetch(): HttpFetch {
142
+ return (url) =>
143
+ new Promise((resolve, reject) => {
144
+ uniApi().request({
145
+ url,
146
+ method: "GET",
147
+ // 非 json 的处理方式:原样拿到文本,不在平台侧解析
148
+ dataType: "text",
149
+ responseType: "text",
150
+ success: (result) => {
151
+ resolve({
152
+ ok: result.statusCode >= 200 && result.statusCode < 300,
153
+ status: result.statusCode,
154
+ text: async () =>
155
+ typeof result.data === "string"
156
+ ? result.data
157
+ : JSON.stringify(result.data),
158
+ });
159
+ },
160
+ fail: (error) => {
161
+ reject(new Error(`${url} 请求失败:${error.errMsg ?? "未知原因"}`));
162
+ },
163
+ });
164
+ });
165
+ }
166
+
167
+ /**
168
+ * 按选择器取画布节点;取不到、或该端不支持节点查询时返回 null,由调用方决定退回哪条帧源。
169
+ *
170
+ * 两种取不到都要兜住:H5 的节点查询不回调,小程序上画布可能还没渲染完。
171
+ */
172
+ export function queryCanvasNode(
173
+ selector: string,
174
+ ): Promise<MiniprogramCanvasNode | null> {
175
+ return new Promise((resolve) => {
176
+ let settled = false;
177
+ const finish = (node: MiniprogramCanvasNode | null): void => {
178
+ if (settled) return;
179
+ settled = true;
180
+ resolve(node);
181
+ };
182
+ try {
183
+ uniApi()
184
+ .createSelectorQuery()
185
+ .select(selector)
186
+ .node((result) => {
187
+ finish(result.node ?? null);
188
+ })
189
+ .exec();
190
+ } catch {
191
+ finish(null);
192
+ return;
193
+ }
194
+ setTimeout(() => {
195
+ finish(null);
196
+ }, CANVAS_QUERY_TIMEOUT_MS);
197
+ });
198
+ }
199
+
200
+ /** 盯住窗口尺寸变化;返回退订函数(卸载时调用,避免页面销毁后仍被回调) */
201
+ export function watchWindowResize(handler: () => void): () => void {
202
+ const api = uniApi();
203
+ const callback = (): void => {
204
+ handler();
205
+ };
206
+ api.onWindowResize(callback);
207
+ return () => {
208
+ api.offWindowResize(callback);
209
+ };
210
+ }