@lingfan-engine/create 0.0.0-stage → 0.1.4

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,299 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * 页面装配:把宿主演出的数据挂到界面上,再把玩家的动作转回叙事。
4
+ *
5
+ * 这一层刻意很薄:装载、布局、渲染数据都归 `mp/`,页面只做三件事——取画布、把补丁写进
6
+ * 页面状态、把点击与输入交给覆盖层。别在页面里写叙事逻辑。
7
+ *
8
+ * 层次口径与桌面宿主一致:舞台层是唯一带基准尺寸与变换的容器(画布等比缩放),对话、
9
+ * 选择、通知各层住在窗口坐标系里,尺寸只跟窗口走。各层的模板与样式归各自的组件,
10
+ * 页面只负责编排——这样单文件留在可读尺度内,改一层的形态不必翻整页。
11
+ *
12
+ * 差别只在单位——桌面用 px + clamp 跟窗口,小程序页面按平台惯例用 rpx(宽度基准 750),
13
+ * 比例同源、绝对尺寸按 750 宽度折算。
14
+ */
15
+ import { onReady, onUnload } from "@dcloudio/uni-app";
16
+ import { ref } from "vue";
17
+ import ChoiceLayer from "../../components/ChoiceLayer.vue";
18
+ import DialogueLayer from "../../components/DialogueLayer.vue";
19
+ import NoticeLayer from "../../components/NoticeLayer.vue";
20
+ import SaveLoadBar from "../../components/SaveLoadBar.vue";
21
+ import StageView from "../../components/StageView.vue";
22
+ import {
23
+ chooseHostSlot,
24
+ loadHostSlotViews,
25
+ queryCanvasNode,
26
+ startMiniprogramHost,
27
+ watchWindowResize,
28
+ type MiniprogramHostHandle,
29
+ type MiniprogramHostPatch,
30
+ type MiniprogramNvlData,
31
+ type MiniprogramSurfaceData,
32
+ type MiniprogramVideoData,
33
+ type SlotPanelMode,
34
+ type SlotView,
35
+ } from "../../mp/index.ts";
36
+
37
+ /** 帧时钟画布的节点选择器 */
38
+ const CANVAS_SELECTOR = "#stage-canvas";
39
+
40
+ const stage = ref<MiniprogramSurfaceData["stage"]>([]);
41
+ const elements = ref<MiniprogramSurfaceData["elements"]>([]);
42
+ const dialogue = ref<MiniprogramSurfaceData["dialogue"]>(null);
43
+ const nvl = ref<MiniprogramNvlData | null>(null);
44
+ const choices = ref<MiniprogramSurfaceData["choices"]>(null);
45
+ const notices = ref<MiniprogramSurfaceData["notices"]>([]);
46
+ const layers = ref<MiniprogramSurfaceData["layers"]>({});
47
+ const stageBox = ref("");
48
+ const video = ref<MiniprogramVideoData | null>(null);
49
+ const error = ref("");
50
+ const inputValue = ref("");
51
+ /** 存读面板模式与逐槽视图:null = 收起(只留入口条) */
52
+ const slotMode = ref<SlotPanelMode | null>(null);
53
+ const slotViews = ref<SlotView[]>([]);
54
+
55
+ let host: MiniprogramHostHandle | null = null;
56
+ let stopResizeWatch: (() => void) | null = null;
57
+
58
+ /** 把一次补丁写进页面状态:字段缺省即不动(`null` 是「清空」而不是「不动」) */
59
+ function applyPatch(patch: MiniprogramHostPatch): void {
60
+ if (patch.stage !== undefined) stage.value = patch.stage;
61
+ if (patch.elements !== undefined) elements.value = patch.elements;
62
+ if (patch.dialogue !== undefined) dialogue.value = patch.dialogue;
63
+ if (patch.nvl !== undefined) nvl.value = patch.nvl;
64
+ if (patch.choices !== undefined) choices.value = patch.choices;
65
+ if (patch.notices !== undefined) notices.value = patch.notices;
66
+ if (patch.layers !== undefined) layers.value = patch.layers;
67
+ if (patch.stageBox !== undefined) stageBox.value = patch.stageBox;
68
+ if (patch.video !== undefined) video.value = patch.video;
69
+ if (patch.error !== undefined) error.value = patch.error;
70
+ }
71
+
72
+ /** 层序:核心下发的 z 表转成行内 z-index(小程序渲染层就是 WebView,z-index 可用) */
73
+ function layerZ(layer: string): number {
74
+ return layers.value[layer] ?? 0;
75
+ }
76
+
77
+ /** 启动宿主:取画布(拿不到就退回定时器帧源),接上数据发布口与尺寸变化监听 */
78
+ async function boot(): Promise<void> {
79
+ try {
80
+ const canvas = await queryCanvasNode(CANVAS_SELECTOR);
81
+ const started = await startMiniprogramHost({ canvas, publish: applyPatch });
82
+ host = started;
83
+ stopResizeWatch = watchWindowResize(() => {
84
+ started.resize();
85
+ });
86
+ } catch (cause: unknown) {
87
+ error.value = String(cause);
88
+ }
89
+ }
90
+
91
+ onReady(() => {
92
+ void boot();
93
+ });
94
+
95
+ onUnload(() => {
96
+ stopResizeWatch?.();
97
+ stopResizeWatch = null;
98
+ host?.dispose();
99
+ host = null;
100
+ });
101
+
102
+ /** 点击舞台或对话层 = 推进(打字中则先补完本句,归覆盖层判据) */
103
+ function onAdvance(): void {
104
+ host?.overlay.advance();
105
+ }
106
+
107
+ /** 选择菜单:选项带回核心的列 id */
108
+ function onChoose(target: string): void {
109
+ if (target !== "") host?.overlay.choose(target);
110
+ }
111
+
112
+ /** 输入框:值先落在页面状态里,提交时一次性交回核心 */
113
+ function onInput(value: string): void {
114
+ inputValue.value = value;
115
+ }
116
+
117
+ /** 提交输入:清空输入框再交回,免得下一句沿用上一句的字 */
118
+ function onSubmitInput(): void {
119
+ const value = inputValue.value;
120
+ inputValue.value = "";
121
+ host?.overlay.submitInput(value);
122
+ }
123
+
124
+ /** 视频自然播完:交回宿主,由它解除引擎的 video 等待 */
125
+ function onVideoEnded(): void {
126
+ host?.videoEnded();
127
+ }
128
+
129
+ /** 打开存读面板:槽位视图从宿主取(槽数来自工程清单),取不到就把原因摆到横幅上 */
130
+ async function openSlots(mode: SlotPanelMode): Promise<void> {
131
+ const current = host;
132
+ if (current === null) return;
133
+ slotMode.value = mode;
134
+ try {
135
+ slotViews.value = await loadHostSlotViews({
136
+ savePort: current.ports.save,
137
+ slots: current.saves.slots,
138
+ });
139
+ } catch (cause: unknown) {
140
+ error.value = String(cause);
141
+ }
142
+ }
143
+
144
+ /** 点槽位:面板先收起(存读结果由引擎发通知,宿主不弹第二层) */
145
+ function onChooseSlot(view: SlotView): void {
146
+ const current = host;
147
+ const mode = slotMode.value;
148
+ slotMode.value = null;
149
+ if (current === null || mode === null) return;
150
+ chooseHostSlot({
151
+ mode,
152
+ view,
153
+ save: (slot) => current.saveGame(slot),
154
+ load: (slot) => current.loadGame(slot),
155
+ });
156
+ }
157
+
158
+ /** 收起面板:不动任何叙事状态,玩家可以继续点舞台推进 */
159
+ function closeSlots(): void {
160
+ slotMode.value = null;
161
+ }
162
+
163
+ defineOptions({ name: "PageIndex" });
164
+ </script>
165
+
166
+ <template>
167
+ <view class="page">
168
+ <!-- 舞台层:视口 → 舞台盒(按宿主算好的缩放与平移变换)→ 元素层,见 StageView -->
169
+ <view class="viewport" @tap="onAdvance">
170
+ <!-- 帧时钟:本宿主的帧源取自这块画布(2d 节点带 requestAnimationFrame) -->
171
+ <canvas class="frame-clock" type="2d" id="stage-canvas"></canvas>
172
+ <StageView :stage="stage" :elements="elements" :stage-box="stageBox" />
173
+ </view>
174
+
175
+ <!-- 视频层:声明式组件——起播与换片靠这里的数据,控制动作由宿主的 videoContext 驱动 -->
176
+ <video
177
+ v-if="video && video.visible"
178
+ id="stage-video"
179
+ class="video"
180
+ :src="video.url"
181
+ :autoplay="true"
182
+ :loop="video.loop"
183
+ :volume="video.volume"
184
+ :controls="false"
185
+ :show-center-play-btn="false"
186
+ :enable-progress-gesture="false"
187
+ :style="{ zIndex: layerZ('video') }"
188
+ @ended="onVideoEnded"
189
+ />
190
+
191
+ <!-- 对话层:普通形态与 NVL 形态,见 DialogueLayer -->
192
+ <DialogueLayer
193
+ v-if="dialogue && dialogue.visible"
194
+ :dialogue="dialogue"
195
+ :nvl="nvl"
196
+ :z-index="layerZ('dialogue')"
197
+ @advance="onAdvance"
198
+ />
199
+
200
+ <!-- 选择层:菜单与输入两形态互斥,见 ChoiceLayer -->
201
+ <ChoiceLayer
202
+ v-if="choices"
203
+ :choices="choices"
204
+ :input-value="inputValue"
205
+ :z-index="layerZ('choices')"
206
+ @choose="onChoose"
207
+ @input="onInput"
208
+ @submit="onSubmitInput"
209
+ />
210
+
211
+ <!-- 通知层:贴右上角,不吃点击,见 NoticeLayer -->
212
+ <NoticeLayer :notices="notices" :z-index="layerZ('notifications')" />
213
+
214
+ <!-- 失败横幅:只显示信息,不参与交互 -->
215
+ <view v-if="error" class="error">{{ error }}</view>
216
+
217
+ <!--
218
+ 宿主界面层:存读入口与槽位面板。层序跟通知层同档(都属操作面),
219
+ 容器本身不吃点击,只有入口条与面板本体接手势——舞台推进照常。
220
+ -->
221
+ <view class="ui-chrome" :style="{ zIndex: layerZ('notifications') }">
222
+ <SaveLoadBar
223
+ :mode="slotMode"
224
+ :views="slotViews"
225
+ @open="openSlots"
226
+ @choose="onChooseSlot"
227
+ @close="closeSlots"
228
+ />
229
+ </view>
230
+ </view>
231
+ </template>
232
+
233
+ <style>
234
+ /* 页面壳:小程序的页面根节点默认不撑满,显式给块级尺寸 */
235
+ .page {
236
+ display: block;
237
+ }
238
+
239
+ /* 视口:舞台与各层的公共坐标系,铺满窗口且裁掉溢出(舞台按基准尺寸缩放,会超出) */
240
+ .viewport {
241
+ position: fixed;
242
+ left: 0;
243
+ top: 0;
244
+ width: 100%;
245
+ height: 100%;
246
+ overflow: hidden;
247
+ background: #0a0a10;
248
+ }
249
+
250
+ /* 帧时钟画布:只用来拿 requestAnimationFrame,不可见也不吃点击 */
251
+ .frame-clock {
252
+ position: absolute;
253
+ left: 0;
254
+ top: 0;
255
+ width: 100%;
256
+ height: 100%;
257
+ pointer-events: none;
258
+ }
259
+
260
+ /* 视频层:铺满窗口,层序由宿主下发的 z 决定(要压在舞台之上、对话之下) */
261
+ .video {
262
+ position: fixed;
263
+ left: 0;
264
+ top: 0;
265
+ width: 100%;
266
+ height: 100%;
267
+ background: #000;
268
+ }
269
+
270
+ /* 宿主界面层:容器不吃点击,只有其中的入口与面板接手势(舞台推进不被挡住) */
271
+ .ui-chrome {
272
+ position: absolute;
273
+ left: 0;
274
+ top: 0;
275
+ width: 100%;
276
+ pointer-events: none;
277
+ }
278
+
279
+ /* 失败横幅:压在一切之上,只读 */
280
+ .error {
281
+ position: fixed;
282
+ left: 0;
283
+ right: 0;
284
+ top: 0;
285
+ padding: 16rpx 24rpx;
286
+ background: #8a2233;
287
+ color: #ffffff;
288
+ font-size: 24rpx;
289
+ pointer-events: none;
290
+ z-index: 9999;
291
+ }
292
+
293
+ @media (orientation: landscape) {
294
+ .error {
295
+ padding: 8px 12px;
296
+ font-size: 12px;
297
+ }
298
+ }
299
+ </style>
@@ -0,0 +1,18 @@
1
+ {
2
+ "pages": [
3
+ {
4
+ "path": "pages/index/index",
5
+ "style": {
6
+ "navigationStyle": "custom",
7
+ "backgroundColor": "#0a0a10",
8
+ "disableScroll": true,
9
+ "pageOrientation": "auto"
10
+ }
11
+ }
12
+ ],
13
+ "globalStyle": {
14
+ "navigationStyle": "custom",
15
+ "backgroundColor": "#0a0a10",
16
+ "backgroundTextStyle": "dark"
17
+ }
18
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "moduleResolution": "bundler",
7
+ "types": ["vite/client", "@dcloudio/types"],
8
+ "allowImportingTsExtensions": true,
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "noEmit": true,
12
+ "skipLibCheck": true,
13
+ "strict": true,
14
+ "noUnusedLocals": true,
15
+ "noUnusedParameters": true,
16
+ "noFallthroughCasesInSwitch": true
17
+ },
18
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"],
19
+ "exclude": ["src/host/**", "src/styles/**"]
20
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 构建配置(小程序形态):UniApp 的 Vite 插件,加上骨架那份字体扫描插件。
3
+ *
4
+ * 这里不能沿用骨架的 `vite ^8`:`@dcloudio/vite-plugin-uni` 的 peer 精确钉住
5
+ * `vite 5.2.8` 与 `@vue/shared 3.4.21`,与仓库根的那一代不兼容。所以本层自带一份
6
+ * vite 与 vue,靠包管理器的隔离安装各用各的——版本写在 `package.patch.json` 里,
7
+ * 并列入 `create.pinned`,生成器才不会把它改写成 `^最新`。
8
+ *
9
+ * `publicDir` 仍是无条件的 `Resources`:开发期的 H5 形态与骨架共用同一个资源根。
10
+ * `copyPublicDir: false` 是有意的——小程序包体里不装故事与素材(故事走工程接口
11
+ * `GET <基址>/project` 一次性返回 `{manifest, stories}`,素材走资源端口按 id 取),
12
+ * 构建产物因此只有代码。
13
+ */
14
+ import { existsSync, readdirSync } from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+ import uniModule, {
17
+ type VitePluginUniOptions,
18
+ } from "@dcloudio/vite-plugin-uni";
19
+ 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
+ }
63
+
64
+ type UniPluginFactory = (options?: VitePluginUniOptions) => Plugin[];
65
+
66
+ /**
67
+ * 插件包是 CommonJS 构建:ESM 默认导入在不同加载路径下可能拿到命名空间对象
68
+ * (`{ runDev, runBuild, default }`)而不是那个工厂,所以两种形态都认。
69
+ */
70
+ const uni: UniPluginFactory =
71
+ (uniModule as unknown as { default?: UniPluginFactory }).default ??
72
+ (uniModule as unknown as UniPluginFactory);
73
+
74
+ export default defineConfig({
75
+ // 资源根:与骨架同名同位,H5 形态直接从磁盘取,小程序形态不随包体分发
76
+ publicDir: "Resources",
77
+ build: {
78
+ // 包体不装故事与素材:它们由工程接口与资源端口在运行期取
79
+ copyPublicDir: false,
80
+ },
81
+ server: {
82
+ // 与骨架同一个端口:两种形态的本地约定一致,切换形态不必改文档与脚本
83
+ port: 1420,
84
+ strictPort: true,
85
+ },
86
+ plugins: [uni(), storyFontsVirtual()],
87
+ });
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>__PROJECT__</title>
7
+ <!-- 页面外观与层样式:与「页面结构」分开,框架叠加层可复用同一份样式表 -->
8
+ <link rel="stylesheet" href="/src/styles/stage.css" />
9
+ </head>
10
+
11
+ <body>
12
+ <!-- React 的挂载点:层结构在 src/App.tsx 里声明,样式仍在 src/styles/stage.css -->
13
+ <div id="app"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,18 @@
1
+ {
2
+ "dependencies": {
3
+ "react": "^19.3.1",
4
+ "react-dom": "^19.3.1"
5
+ },
6
+ "devDependencies": {
7
+ "@types/node": "^26.6.3",
8
+ "@types/react": "^19.3.0",
9
+ "@types/react-dom": "^19.3.0",
10
+ "@vitejs/plugin-react": "^6.1.2"
11
+ },
12
+ "scripts": {
13
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json"
14
+ },
15
+ "create": {
16
+ "pinned": ["typescript"]
17
+ }
18
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 页面结构(React 形态):与骨架的 index.html 逐层对齐——引擎与适配器不认识它,
3
+ * 换框架只改这份模板与组合根。所有 id 都是与引擎侧共用的契约,改名必须同步
4
+ * `src/main.tsx` 里的句柄表与 `src/styles/stage.css`。
5
+ *
6
+ * 本组件**不碰引擎**:装配在 `src/main.tsx`(唯一组合根),交互监听由骨架的
7
+ * `src/host/` 用原生事件绑定——React 在这里只负责把节点渲染出来。
8
+ */
9
+ export function App() {
10
+ return (
11
+ // 舞台根:屏幕震动(shake)施于此;各层 z 由 main.tsx 按 shell.layers 写入
12
+ <div id="stage-root">
13
+ <div id="stage"></div>
14
+ <div id="nvl"></div>
15
+ <div id="dialogue">
16
+ <div id="speaker"></div>
17
+ <div id="text">加载中……</div>
18
+ <div id="hint"></div>
19
+ </div>
20
+ <div id="choices"></div>
21
+ <div id="notifications"></div>
22
+ <div id="toolbar">
23
+ <span>存档</span>
24
+ <select id="slot"></select>
25
+ <button id="save">存</button>
26
+ <button id="load">读</button>
27
+ </div>
28
+ <div id="transition"></div>
29
+ <div id="error"></div>
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 组合根(React 形态):装配适配器 → 加载组装工程 → 建引擎 → 挂载展示层。
3
+ *
4
+ * 与骨架的差别只有「谁渲染页面结构」:这里先把 `App` 挂到 `#app`,再读节点句柄——
5
+ * 引擎要的 15 个节点由 `App.tsx` 渲染出来,挂载前 `must` 只会抛。骨架的 `src/host/`
6
+ * 一行未改:它操作的是 `StageDom` 句柄表,不认识 React。
7
+ *
8
+ * 页面的层结构在 `src/App.tsx`,样式在 `src/styles/stage.css`(两者都来自模板骨架,
9
+ * 本层只替换入口与组合根)。
10
+ */
11
+ import { createElement, StrictMode } from "react";
12
+ import { createRoot } from "react-dom/client";
13
+ import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
14
+ import { bootHost, must, reportBootFailure, type StageDom } from "./host";
15
+ import { App } from "./App.tsx";
16
+
17
+ /**
18
+ * 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
19
+ * 宿主把 `Resources` 作为静态根,故逻辑路径就是 `project.json` / `Stories/**`。
20
+ * **新增故事文件必须同步此表**(Web 形态按显式清单取文件)。
21
+ */
22
+ const MANIFEST = "project.json";
23
+ /** 本工程的故事文件表(相对资源根;新增故事必须同步此表) */
24
+ const STORIES = ["Stories/title/title_main.story"];
25
+
26
+ /** 页面节点句柄表:每个 id 都必须由 `App.tsx` 渲染(缺一个 `must` 当场抛) */
27
+ function readDom(): StageDom {
28
+ return {
29
+ root: must("#stage-root"),
30
+ stage: must("#stage"),
31
+ dialogue: must("#dialogue"),
32
+ speaker: must("#speaker"),
33
+ text: must("#text"),
34
+ hint: must("#hint"),
35
+ nvl: must("#nvl"),
36
+ choices: must("#choices"),
37
+ notifications: must("#notifications"),
38
+ toolbar: must("#toolbar"),
39
+ slot: must<HTMLSelectElement>("#slot"),
40
+ save: must<HTMLButtonElement>("#save"),
41
+ load: must<HTMLButtonElement>("#load"),
42
+ transition: must("#transition"),
43
+ error: must("#error"),
44
+ };
45
+ }
46
+
47
+ /** 挂载页面结构;失败(含节点缺失)写进页面错误条,不留白屏 */
48
+ function mountApp(): StageDom | undefined {
49
+ try {
50
+ const container = document.querySelector("#app");
51
+ if (container === null) throw new Error("宿主缺少节点:#app");
52
+ // StrictMode 只影响开发期的重复渲染提示,不改变侧建行为:引擎在下面单独启动
53
+ createRoot(container).render(
54
+ createElement(StrictMode, null, createElement(App)),
55
+ );
56
+ return readDom();
57
+ } catch (error) {
58
+ console.error(error);
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ const dom = mountApp();
64
+ if (dom !== undefined) {
65
+ void bootHost({
66
+ dom,
67
+ manifestFile: MANIFEST,
68
+ stories: STORIES,
69
+ defaultCps: DEFAULT_TEXT_CPS,
70
+ toastDurationMs: DEFAULT_TOAST_MS,
71
+ }).catch((error: unknown) => {
72
+ reportBootFailure(dom, error);
73
+ });
74
+ }
@@ -0,0 +1,14 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ /**
4
+ * 字体清单的宿主侧声明(脚手架工程自带,与 vite.config 的虚拟模块对应)。
5
+ */
6
+ declare module "virtual:story-fonts" {
7
+ /** 字体清单条目:file = 资源文件名,name = 字体名(文件名去扩展名) */
8
+ export interface StoryFontEntry {
9
+ file: string;
10
+ name: string;
11
+ }
12
+ /** 构建期扫描 Resources/Fonts 的字体清单(与 stories:build 注册表同源) */
13
+ export const storyFonts: readonly StoryFontEntry[];
14
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "moduleResolution": "bundler",
7
+ "types": ["vite/client"],
8
+ "jsx": "react-jsx",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "skipLibCheck": true,
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true
18
+ },
19
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
20
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Vite 配置(React 形态):在骨架配置上多一个 React 插件,其余逐项保留。
3
+ *
4
+ * 资源根仍是无条件镜像 `Resources`——纯 Web 工程就是「浏览器游戏模式」的交付正例,
5
+ * 不引入壳侧的开关判定(那属于 Tauri 壳的交付形态,见引擎仓库 `apps/playground`)。
6
+ */
7
+ import { existsSync, readdirSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import react from "@vitejs/plugin-react";
10
+ import { defineConfig, type Plugin } from "vite";
11
+
12
+ /** 字体文件扩展名集合(与 stories-build 的字体名注册表同口径) */
13
+ const FONT_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"] as const;
14
+
15
+ /** 字体清单条目:file = 资源文件名,name = 字体名(文件名去扩展名) */
16
+ interface StoryFontEntry {
17
+ file: string;
18
+ name: string;
19
+ }
20
+
21
+ /** Resources/Fonts 扫描:点文件与未知扩展名跳过,文件名字典序稳定 */
22
+ function scanFonts(fontsDir: string): readonly StoryFontEntry[] {
23
+ if (!existsSync(fontsDir)) return [];
24
+ return readdirSync(fontsDir)
25
+ .filter((file) => {
26
+ const dot = file.lastIndexOf(".");
27
+ return (
28
+ dot > 0 &&
29
+ (FONT_EXTENSIONS as readonly string[]).includes(
30
+ file.slice(dot).toLowerCase(),
31
+ )
32
+ );
33
+ })
34
+ .sort()
35
+ .map((file) => ({ file, name: file.slice(0, file.lastIndexOf(".")) }));
36
+ }
37
+
38
+ /** 字体清单虚拟模块:构建期扫描 Resources/Fonts,与 stories:build 的注册表同源 */
39
+ function storyFontsVirtual(): Plugin {
40
+ const moduleId = "virtual:story-fonts";
41
+ const fontsDir = fileURLToPath(new URL("./Resources/Fonts", import.meta.url));
42
+ return {
43
+ name: "story-fonts",
44
+ resolveId(id) {
45
+ if (id === moduleId) return `\0${moduleId}`;
46
+ return null;
47
+ },
48
+ load(id) {
49
+ if (id === `\0${moduleId}`) {
50
+ return `export const storyFonts = ${JSON.stringify(scanFonts(fontsDir))};`;
51
+ }
52
+ return null;
53
+ },
54
+ };
55
+ }
56
+
57
+ /** 脚手架工程的 Vite 配置(React 形态) */
58
+ export default defineConfig({
59
+ // 项目资源根即静态根:故事里的逻辑路径 `Audio/x.mp3` 解析为 `/Audio/x.mp3`,
60
+ // 开发与打包产物一致,不依赖进程工作目录。资源根之外无隐式查找路径。
61
+ publicDir: "Resources",
62
+
63
+ // 宿主端口与 Tauri 约定一致;换平台壳时只改这里
64
+ server: { port: 1420, strictPort: true },
65
+
66
+ plugins: [react(), storyFontsVirtual()],
67
+ });