@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
@@ -6,6 +6,8 @@
6
6
  * 本组件**不碰引擎**:装配在 `src/main.tsx`(唯一组合根),交互监听由骨架的
7
7
  * `src/host/` 用原生事件绑定——React 在这里只负责把节点渲染出来。
8
8
  */
9
+ import { UiHost } from "./UiHost.tsx";
10
+
9
11
  export function App() {
10
12
  return (
11
13
  // 舞台根:屏幕震动(shake)施于此;各层 z 由 main.tsx 按 shell.layers 写入
@@ -25,6 +27,16 @@ export function App() {
25
27
  <button id="save">存</button>
26
28
  <button id="load">读</button>
27
29
  </div>
30
+ {/* 历史面板槽位:可选节点(本层不给也能跑,只是没有回看与回溯) */}
31
+ <div id="history"></div>
32
+ {/* 偏好面板槽位:可选节点(本层不给也能跑,只是没有音量/字速面板) */}
33
+ <div id="prefs"></div>
34
+ {/* UI 挂载层:`mount` 命令显示/隐藏的组件挂在这里(可选节点,缺了只是没有 UI)。
35
+ 位置在对话与工具条之后、转场遮罩之前:UI 盖住对话层,但盖不住转场与错误条。
36
+ 内容由 `UiHost` 渲染——父子关系归 React,组合根不再往这个容器里写节点。 */}
37
+ <div id="mounts">
38
+ <UiHost />
39
+ </div>
28
40
  <div id="transition"></div>
29
41
  <div id="error"></div>
30
42
  </div>
@@ -0,0 +1,62 @@
1
+ /**
2
+ * UI 挂载层(React 形态):把 `SYS.mounts` 里可见的 UI 铺成子组件。
3
+ *
4
+ * 与 Vue 形态逐条对等,只是取值的接缝换了:
5
+ * 1. **值从哪来**:`mountStore` 由组合根喂(`bootHost` 的 `onMounts`),这里用
6
+ * `useSyncExternalStore` 订阅——React 的并发渲染要求外部值的读取是「快照式」的,
7
+ * 自己 `useState` + `useEffect` 会在首帧读到旧值。
8
+ * 2. **表从哪来**:`uis` 来自生成物(`src/gen/ui_register.g.ts`),键就是目录名,
9
+ * 不做 `import.meta.glob`——那会把 `src/ui/` 下的文件全部静态拉进包。
10
+ * 3. **隐藏 = 不渲染**:列表就是可见集合,不可见的组件不在渲染树上,它的副作用与
11
+ * 定时器随卸载一起清(不是 `display:none`)。
12
+ *
13
+ * 组件由 React 自己创建,所以挂载点的父子关系归 React——组合根不该再往这个容器里写节点。
14
+ */
15
+ import { createElement, useEffect, useMemo, useSyncExternalStore } from "react";
16
+ import { missingMounts, selectMounts } from "@lingfan-engine/ui";
17
+ import { controlsStore } from "./controls";
18
+ import { mountStore, uis } from "./mounts";
19
+
20
+ /** 生成物里没有的名字只报一次:按帧重渲染会把它刷成噪音,反而看不见真问题 */
21
+ const reported = new Set<string>();
22
+
23
+ export function UiHost() {
24
+ const raw = useSyncExternalStore(
25
+ mountStore.subscribe,
26
+ mountStore.snapshot,
27
+ mountStore.snapshot,
28
+ );
29
+ /**
30
+ * 操作句柄:来自组合根而不是故事值。
31
+ *
32
+ * 它与 `props` 分属两条通道——`props` 是故事命令写下的值(随存档、必须 JSON 安全),
33
+ * 句柄含函数、不进快照,所以单独订阅一格再作为 prop 发给每个挂载组件。
34
+ */
35
+ const controls = useSyncExternalStore(
36
+ controlsStore.subscribe,
37
+ controlsStore.snapshot,
38
+ controlsStore.snapshot,
39
+ );
40
+ const items = useMemo(() => selectMounts(raw, uis), [raw]);
41
+
42
+ useEffect(() => {
43
+ for (const name of missingMounts(raw, uis)) {
44
+ if (reported.has(name)) continue;
45
+ reported.add(name);
46
+ console.error(
47
+ `[ui] 生成物 uis 里没有 ${name},这条挂载被跳过——构建期本应拦住越界的 UI 名`,
48
+ );
49
+ }
50
+ }, [raw]);
51
+
52
+ return createElement(
53
+ null,
54
+ ...items.map((item) =>
55
+ createElement(item.component as never, {
56
+ key: item.name,
57
+ ...item.props,
58
+ controls,
59
+ }),
60
+ ),
61
+ );
62
+ }
@@ -10,17 +10,26 @@
10
10
  */
11
11
  import { createElement, StrictMode } from "react";
12
12
  import { createRoot } from "react-dom/client";
13
+ import { MANIFEST_FILE } from "@lingfan-engine/core";
13
14
  import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
14
- import { bootHost, must, reportBootFailure, type StageDom } from "./host";
15
+ import {
16
+ bootHost,
17
+ must,
18
+ optional,
19
+ reportBootFailure,
20
+ type StageDom,
21
+ } from "./host";
15
22
  import { STORIES } from "./stories";
23
+ import { controlsStore } from "./controls";
24
+ import { mountStore } from "./mounts";
16
25
  import { App } from "./App.tsx";
17
26
 
18
27
  /**
19
28
  * 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
20
- * 宿主把 `Resources` 作为静态根,故逻辑路径就是 `project.json` / `Stories/**`。
21
- * 故事表在 `./stories`(唯一声明点,各形态共用)。
29
+ * 宿主把 `Resources` 作为静态根,故逻辑路径就是清单名 / `Stories/**`。
30
+ * 清单名取引擎契约里的 `MANIFEST_FILE`(组装、写回、定位资源根共用同一个声明);
31
+ * 故事表在 `./stories`(唯一出口,各形态共用),内容由构建期扫描资源根得出。
22
32
  */
23
- const MANIFEST = "project.json";
24
33
 
25
34
  /** 页面节点句柄表:每个 id 都必须由 `App.tsx` 渲染(缺一个 `must` 当场抛) */
26
35
  function readDom(): StageDom {
@@ -40,6 +49,12 @@ function readDom(): StageDom {
40
49
  load: must<HTMLButtonElement>("#load"),
41
50
  transition: must("#transition"),
42
51
  error: must("#error"),
52
+ // 历史面板槽位(可选):本层 App 没给就 null,回看与回溯不装、游戏照跑
53
+ history: optional("#history"),
54
+ // 偏好面板槽位(可选):本层 App 没给就 null,面板不装、游戏照跑
55
+ prefs: optional("#prefs"),
56
+ // UI 挂载层槽位(可选):本层不给就 null,`mount` 命令只改状态、没有组件出现
57
+ mounts: optional("#mounts"),
43
58
  };
44
59
  }
45
60
 
@@ -63,10 +78,17 @@ const dom = mountApp();
63
78
  if (dom !== undefined) {
64
79
  void bootHost({
65
80
  dom,
66
- manifestFile: MANIFEST,
81
+ manifestFile: MANIFEST_FILE,
67
82
  stories: STORIES,
68
83
  defaultCps: DEFAULT_TEXT_CPS,
69
84
  toastDurationMs: DEFAULT_TOAST_MS,
85
+ historyContainer: dom.history,
86
+ prefsContainer: dom.prefs,
87
+ // UI 挂载落法由 React 接管:`#mounts` 的子节点归 `UiHost` 渲染,
88
+ // 宿主再往里写节点就会与 React 的渲染树打架(所以不传 mountsContainer)
89
+ onMounts: (raw) => mountStore.set(raw),
90
+ // 操作句柄另走一条路:值进 mountStore(随存档),句柄进 controlsStore(不进快照)
91
+ onControls: (controls) => controlsStore.set(controls),
70
92
  }).catch((error: unknown) => {
71
93
  reportBootFailure(dom, error);
72
94
  });
@@ -12,3 +12,15 @@ 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
+ * 本层带自己的 `vite-env.d.ts` 是为了追加 JSX 相关的类型,但它**整体覆盖**骨架的同名文件,
21
+ * 所以骨架里已声明的虚拟模块必须原样在此再声明一次,否则生成工程必然 `TS2307`。
22
+ */
23
+ declare module "virtual:story-list" {
24
+ /** 按路径码元序排列的故事文件逻辑路径 */
25
+ export const STORIES: readonly string[];
26
+ }
@@ -3,56 +3,13 @@
3
3
  *
4
4
  * 资源根仍是无条件镜像 `Resources`——纯 Web 工程就是「浏览器游戏模式」的交付正例,
5
5
  * 不引入壳侧的开关判定(那属于 Tauri 壳的交付形态,见引擎仓库 `apps/playground`)。
6
+ *
7
+ * 字体清单与故事清单的扫描实现在骨架的 `vite.plugins.ts`,本层只多挂一个框架插件:
8
+ * 清单规则一处维护,换框架不该换掉「清单来自磁盘」这件事。
6
9
  */
7
- import { existsSync, readdirSync } from "node:fs";
8
- import { fileURLToPath } from "node:url";
9
10
  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
- }
11
+ import { defineConfig } from "vite";
12
+ import { storyFontsVirtual, storyListVirtual } from "./vite.plugins.ts";
56
13
 
57
14
  /** 脚手架工程的 Vite 配置(React 形态) */
58
15
  export default defineConfig({
@@ -63,5 +20,5 @@ export default defineConfig({
63
20
  // 宿主端口与 Tauri 约定一致;换平台壳时只改这里
64
21
  server: { port: 1420, strictPort: true },
65
22
 
66
- plugins: [react(), storyFontsVirtual()],
23
+ plugins: [react(), storyFontsVirtual(), storyListVirtual()],
67
24
  });
@@ -17,20 +17,28 @@
17
17
  *
18
18
  * 页面结构沿用骨架的 `index.html`(本层不带自己的),故节点解析与骨架同源。
19
19
  */
20
+ import { MANIFEST_FILE } from "@lingfan-engine/core";
20
21
  import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
21
22
  import {
22
23
  createTauriSavePort,
23
24
  installTauriBridge,
24
25
  } from "@lingfan-engine/adapters";
25
- import { bootHost, must, reportBootFailure, type StageDom } from "./host";
26
+ import {
27
+ bootHost,
28
+ must,
29
+ optional,
30
+ reportBootFailure,
31
+ type StageDom,
32
+ } from "./host";
26
33
  import { STORIES } from "./stories";
34
+ import { uis } from "./mounts";
27
35
 
28
36
  /**
29
37
  * 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
30
38
  * 打包后它们位于 `dist/` 顶层,路径与开发期一致。
31
- * 故事表在 `./stories`(唯一声明点,各形态共用)。
39
+ * 清单名取引擎契约里的 `MANIFEST_FILE`(组装、写回、定位资源根共用同一个声明);
40
+ * 故事表在 `./stories`(唯一出口,各形态共用),内容由构建期扫描资源根得出。
32
41
  */
33
- const MANIFEST = "project.json";
34
42
 
35
43
  /** 页面节点句柄表:每个 id 都必须存在于 index.html(缺一个 `must` 当场抛) */
36
44
  const dom: StageDom = {
@@ -49,6 +57,12 @@ const dom: StageDom = {
49
57
  load: must<HTMLButtonElement>("#load"),
50
58
  transition: must("#transition"),
51
59
  error: must("#error"),
60
+ // 历史面板槽位(可选):骨架 index.html 里有,缺了也不影响启动
61
+ history: optional("#history"),
62
+ // 偏好面板槽位(可选):骨架 index.html 里有,缺了也不影响启动
63
+ prefs: optional("#prefs"),
64
+ // UI 挂载层槽位(可选):本层不给就 null,`mount` 命令只改状态、没有组件出现
65
+ mounts: optional("#mounts"),
52
66
  };
53
67
 
54
68
  /**
@@ -66,10 +80,15 @@ async function boot(): Promise<void> {
66
80
 
67
81
  await bootHost({
68
82
  dom,
69
- manifestFile: MANIFEST,
83
+ manifestFile: MANIFEST_FILE,
70
84
  stories: STORIES,
71
85
  defaultCps: DEFAULT_TEXT_CPS,
72
86
  toastDurationMs: DEFAULT_TOAST_MS,
87
+ historyContainer: dom.history,
88
+ prefsContainer: dom.prefs,
89
+ // UI 挂载槽位:壳层用骨架的原生渲染器(本层不引框架,`#mounts` 的子节点由宿主写)
90
+ mountsContainer: dom.mounts,
91
+ mountsTable: uis,
73
92
  ports: {
74
93
  // 存档落到应用数据目录的 saves/ 下,一槽一文件(加密与回档保护在 Rust 侧扩展)
75
94
  saves: createTauriSavePort(),
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "devDependencies": {
3
- "@lingfan-engine/editor": "^0.1.0",
4
- "@lingfan-engine/stories-build": "^0.1.0"
3
+ "@lingfan-engine/editor": "^0.3.0"
5
4
  },
6
5
  "scripts": {
7
6
  "stories:build": "lingfan-stories-build .",
@@ -11,6 +11,12 @@
11
11
  */
12
12
  import { script } from "@lingfan-engine/editor";
13
13
 
14
+ /**
15
+ * 本列是界面列(`ui`):界面列不参与历史与存档,其余与 `game` 列一样在舞台上渲染。
16
+ * 缺省不写即 `game`——「是不是界面列」是数据,不是约定。
17
+ */
18
+ export const columnType = "ui";
19
+
14
20
  const { menu, option, say } = script;
15
21
 
16
22
  export default function title_main(): void {
@@ -21,5 +21,5 @@
21
21
  "noUnusedParameters": true,
22
22
  "noFallthroughCasesInSwitch": true
23
23
  },
24
- "include": ["Stories.src/**/*.ts"]
24
+ "include": ["src/Stories.src/**/*.ts"]
25
25
  }
@@ -6,6 +6,9 @@
6
6
  本组件**不碰引擎**:装配在 `src/main.ts`(唯一组合根),交互监听由骨架的
7
7
  `src/host/` 用原生事件绑定——Vue 在这里只负责把节点渲染出来。
8
8
  -->
9
+ <script setup lang="ts">
10
+ import UiHost from "./UiHost.vue";
11
+ </script>
9
12
  <template>
10
13
  <!-- 舞台根:屏幕震动(shake)施于此;各层 z 由 main.ts 按 shell.layers 写入 -->
11
14
  <div id="stage-root">
@@ -24,6 +27,16 @@
24
27
  <button id="save">存</button>
25
28
  <button id="load">读</button>
26
29
  </div>
30
+ <!-- 历史面板槽位:可选节点(本层不给也能跑,只是没有回看与回溯) -->
31
+ <div id="history"></div>
32
+ <!-- 偏好面板槽位:可选节点(本层不给也能跑,只是没有音量/字速面板) -->
33
+ <div id="prefs"></div>
34
+ <!-- UI 挂载层:`mount` 命令显示/隐藏的组件挂在这里(可选节点,缺了只是没有 UI)。
35
+ 位置在对话与工具条之后、转场遮罩之前:UI 盖住对话层,但盖不住转场与错误条。
36
+ 内容由 `UiHost` 渲染——父子关系归 Vue,组合根不再往这个容器里写节点。 -->
37
+ <div id="mounts">
38
+ <UiHost />
39
+ </div>
27
40
  <div id="transition"></div>
28
41
  <div id="error"></div>
29
42
  </div>
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * UI 挂载层(Vue 形态):把 `SYS.mounts` 里可见的 UI 铺成子组件。
4
+ *
5
+ * 三条边界:
6
+ * 1. **值从哪来**:`mountStore` 由组合根喂(`bootHost` 的 `onMounts`),这里只订阅。
7
+ * 自己再订阅一次引擎的话,退订就有两份实现,漏一份会在拆掉页面后继续写渲染树。
8
+ * 2. **表从哪来**:`uis` 来自生成物(`src/gen/ui_register.g.ts`),键就是目录名,
9
+ * 不做 `import.meta.glob`——那会把 `src/ui/` 下的文件全部静态拉进包。
10
+ * 3. **隐藏 = 不渲染**:`v-for` 的集合就是可见集合,不可见的组件不在渲染树上,
11
+ * 它的响应式与定时器随卸载一起停(不是 `display:none`)。
12
+ *
13
+ * 组件由 Vue 自己创建,所以挂载点的父子关系归 Vue——组合根不该再往这个容器里写节点。
14
+ */
15
+ import { computed, onUnmounted, ref, watch } from "vue";
16
+ import { missingMounts, selectMounts } from "@lingfan-engine/ui";
17
+ import type { HostControls } from "@lingfan-engine/core";
18
+ import { controlsStore } from "./controls";
19
+ import { mountStore, uis } from "./mounts";
20
+
21
+ /** 当前 `SYS.mounts` 原始值:订阅前先读一次快照(订阅不回放历史) */
22
+ const raw = ref<unknown>(mountStore.snapshot());
23
+ const stop = mountStore.subscribe((next) => {
24
+ raw.value = next;
25
+ });
26
+ onUnmounted(stop);
27
+
28
+ /**
29
+ * 操作句柄:来自组合根而不是故事值。
30
+ *
31
+ * 它与 `props` 分属两条通道——`props` 是故事命令写下的值(随存档、必须 JSON 安全),
32
+ * 句柄含函数、不进快照,所以单独订阅一格再作为 prop 发给每个挂载组件。
33
+ */
34
+ const controls = ref<HostControls | null>(controlsStore.snapshot());
35
+ const stopControls = controlsStore.subscribe((next) => {
36
+ controls.value = next;
37
+ });
38
+ onUnmounted(stopControls);
39
+
40
+ /** 这一帧该渲染的 UI:取不到组件的名字被跳过并单独上报 */
41
+ const items = computed(() => selectMounts(raw.value, uis));
42
+ const missing = computed(() => missingMounts(raw.value, uis));
43
+
44
+ // 生成物里没有的名字只报一次:按帧重渲染会把它刷成噪音,反而看不见真问题
45
+ const reported = new Set<string>();
46
+ watch(missing, (names) => {
47
+ for (const name of names) {
48
+ if (reported.has(name)) continue;
49
+ reported.add(name);
50
+ console.error(
51
+ `[ui] 生成物 uis 里没有 ${name},这条挂载被跳过——构建期本应拦住越界的 UI 名`,
52
+ );
53
+ }
54
+ });
55
+ </script>
56
+
57
+ <template>
58
+ <component
59
+ :is="item.component"
60
+ v-for="item in items"
61
+ :key="item.name"
62
+ v-bind="item.props"
63
+ :controls="controls"
64
+ />
65
+ </template>
@@ -9,17 +9,26 @@
9
9
  * 本层只替换入口与组合根)。
10
10
  */
11
11
  import { createApp } from "vue";
12
+ import { MANIFEST_FILE } from "@lingfan-engine/core";
12
13
  import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
13
- import { bootHost, must, reportBootFailure, type StageDom } from "./host";
14
+ import {
15
+ bootHost,
16
+ must,
17
+ optional,
18
+ reportBootFailure,
19
+ type StageDom,
20
+ } from "./host";
14
21
  import { STORIES } from "./stories";
22
+ import { controlsStore } from "./controls";
23
+ import { mountStore } from "./mounts";
15
24
  import App from "./App.vue";
16
25
 
17
26
  /**
18
27
  * 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
19
- * 宿主把 `Resources` 作为静态根,故逻辑路径就是 `project.json` / `Stories/**`。
20
- * 故事表在 `./stories`(唯一声明点,各形态共用)。
28
+ * 宿主把 `Resources` 作为静态根,故逻辑路径就是清单名 / `Stories/**`。
29
+ * 清单名取引擎契约里的 `MANIFEST_FILE`(组装、写回、定位资源根共用同一个声明);
30
+ * 故事表在 `./stories`(唯一出口,各形态共用),内容由构建期扫描资源根得出。
21
31
  */
22
- const MANIFEST = "project.json";
23
32
 
24
33
  /** 页面节点句柄表:每个 id 都必须由 `App.vue` 渲染(缺一个 `must` 当场抛) */
25
34
  function readDom(): StageDom {
@@ -39,6 +48,12 @@ function readDom(): StageDom {
39
48
  load: must<HTMLButtonElement>("#load"),
40
49
  transition: must("#transition"),
41
50
  error: must("#error"),
51
+ // 偏好面板槽位(可选):本层 App 没给就 null,面板不装、游戏照跑
52
+ prefs: optional("#prefs"),
53
+ // 历史面板槽位(可选):本层 App 没给就 null,没有「历史」按钮
54
+ history: optional("#history"),
55
+ // UI 挂载层槽位(可选):本层不给就 null,`mount` 命令只改状态、没有组件出现
56
+ mounts: optional("#mounts"),
42
57
  };
43
58
  }
44
59
 
@@ -57,10 +72,18 @@ const dom = mountApp();
57
72
  if (dom !== undefined) {
58
73
  void bootHost({
59
74
  dom,
60
- manifestFile: MANIFEST,
75
+ manifestFile: MANIFEST_FILE,
61
76
  stories: STORIES,
62
77
  defaultCps: DEFAULT_TEXT_CPS,
63
78
  toastDurationMs: DEFAULT_TOAST_MS,
79
+ prefsContainer: dom.prefs,
80
+ // 历史面板槽位:给了就有「历史」按钮与回溯
81
+ historyContainer: dom.history,
82
+ // UI 挂载落法由 Vue 接管:`#mounts` 的子节点归 `UiHost` 渲染,
83
+ // 宿主再往里写节点就会与 Vue 的渲染树打架(所以不传 mountsContainer)
84
+ onMounts: (raw) => mountStore.set(raw),
85
+ // 操作句柄另走一条路:值进 mountStore(随存档),句柄进 controlsStore(不进快照)
86
+ onControls: (controls) => controlsStore.set(controls),
64
87
  }).catch((error: unknown) => {
65
88
  reportBootFailure(dom, error);
66
89
  });
@@ -13,6 +13,18 @@ declare module "virtual:story-fonts" {
13
13
  export const storyFonts: readonly StoryFontEntry[];
14
14
  }
15
15
 
16
+ /**
17
+ * 故事清单的宿主侧声明:构建期递归扫描 `Resources/Stories/` 得到的逻辑路径表
18
+ * (相对资源根,如 `Stories/title/title_main.story`)。
19
+ *
20
+ * 本层带自己的 `vite-env.d.ts` 是为了追加 `.vue` 模块声明,但它**整体覆盖**骨架的同名文件,
21
+ * 所以骨架里已声明的虚拟模块必须原样在此再声明一次,否则生成工程必然 `TS2307`。
22
+ */
23
+ declare module "virtual:story-list" {
24
+ /** 按路径码元序排列的故事文件逻辑路径 */
25
+ export const STORIES: readonly string[];
26
+ }
27
+
16
28
  /** 单文件组件的模块声明:`vue-tsc` 之外的工具读到 `.vue` 时不至于整片报错 */
17
29
  declare module "*.vue" {
18
30
  import type { DefineComponent } from "vue";
@@ -3,56 +3,13 @@
3
3
  *
4
4
  * 资源根仍是无条件镜像 `Resources`——纯 Web 工程就是「浏览器游戏模式」的交付正例,
5
5
  * 不引入壳侧的开关判定(那属于 Tauri 壳的交付形态,见引擎仓库 `apps/playground`)。
6
+ *
7
+ * 字体清单与故事清单的扫描实现在骨架的 `vite.plugins.ts`,本层只多挂一个框架插件:
8
+ * 清单规则一处维护,换框架不该换掉「清单来自磁盘」这件事。
6
9
  */
7
- import { existsSync, readdirSync } from "node:fs";
8
- import { fileURLToPath } from "node:url";
9
10
  import vue from "@vitejs/plugin-vue";
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
- }
11
+ import { defineConfig } from "vite";
12
+ import { storyFontsVirtual, storyListVirtual } from "./vite.plugins.ts";
56
13
 
57
14
  /** 脚手架工程的 Vite 配置(Vue 形态) */
58
15
  export default defineConfig({
@@ -63,5 +20,5 @@ export default defineConfig({
63
20
  // 宿主端口与 Tauri 约定一致;换平台壳时只改这里
64
21
  server: { port: 1420, strictPort: true },
65
22
 
66
- plugins: [vue(), storyFontsVirtual()],
23
+ plugins: [vue(), storyFontsVirtual(), storyListVirtual()],
67
24
  });
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.7.0",
3
- "minEngineVersion": "0.1.0",
2
+ "version": "1.8.1",
3
+ "minEngineVersion": "0.3.0",
4
4
  "placeholder": "__PROJECT__",
5
5
  "frameworks": [
6
6
  {
@@ -37,16 +37,16 @@
37
37
  }
38
38
  ],
39
39
  "sources": [
40
- {
41
- "id": "story",
42
- "label": "故事文件(.story)",
43
- "hint": "直接写引擎自带的文本故事格式,改完保存即生效"
44
- },
45
40
  {
46
41
  "id": "ts",
47
42
  "label": "TypeScript 源(Stories.src)",
48
43
  "hint": "用 TS 写故事列:有类型补全与重构,构建时编译成故事文件"
44
+ },
45
+ {
46
+ "id": "story",
47
+ "label": "故事文件(.story)",
48
+ "hint": "直接写引擎自带的文本故事格式,改完保存即生效"
49
49
  }
50
50
  ],
51
- "_comment": "模板版本唯一来源,独立于引擎版本:仅当 template/v1 内容真正变化时才手动 bump。placeholder 是目录/文件内的工程名占位符,生成时整体替换为工程名(对齐 _LingFanEngineTemplateTitle_ 占位符语义)。minEngineVersion 表示此模板要求的最低引擎版本。frameworks / shells / sources 决定生成器会问什么、能叠哪些层,各自首项为缺省值;每一项的 id 对应 template/v1/overlays/<id> 目录(缺省层可以没有目录)。合并顺序为「骨架 → 框架 → 故事源 → 壳」,同路径文件由后一层覆盖前一层。"
51
+ "_comment": "模板版本唯一来源,独立于引擎版本:仅当 template/v1 内容真正变化时才手动 bump。placeholder 是目录/文件内的工程名占位符,生成时整体替换为工程名(对齐 _LingFanEngineTemplateTitle_ 占位符语义)。minEngineVersion 表示此模板要求的最低引擎版本——生成期会拿它跟 registry 上的实际版本比对,不达标即失败(离线查不到版本时跳过判定)。frameworks / shells / sources 决定生成器会问什么、能叠哪些层,各自首项为缺省值;每一项的 id 对应 template/v1/overlays/<id> 目录(缺省层可以没有目录)。合并顺序为「骨架 → 框架 → 故事源 → 壳」,同路径文件由后一层覆盖前一层。"
52
52
  }
@@ -1,11 +0,0 @@
1
- /**
2
- * 本工程的故事文件表(相对资源根 `Resources/`)——**全工程唯一声明点**。
3
- *
4
- * Web 形态按显式清单取文件(不做目录扫描:扫描会让「多一个文件」变成静默行为,
5
- * 而打包产物里到底带了哪些故事必须一眼可见),因此新增故事文件要改这里。
6
- * 各框架/壳的组合根都从这里取,不在自己的入口里各写一份。
7
- *
8
- * 走 TS 创作链的工程会整体替换本文件(构建产物是 `.json`,清单随之指向它),
9
- * 入口与宿主一行不用改。
10
- */
11
- export const STORIES: readonly string[] = ["Stories/title/title_main.story"];
@@ -1,10 +0,0 @@
1
- /**
2
- * 本工程的故事文件表(相对资源根 `Resources/`)——**全工程唯一声明点**。
3
- *
4
- * 本工程走 TS 创作链:这里列的是 `Stories.src/` 编译出来的产物,
5
- * 不要直接改 `Resources/Stories/**` 下的文件——它们是生成物,下次构建会被覆盖,
6
- * 源里没有的旧列还会被清理掉。改故事请改 `Stories.src/`,然后 `pnpm stories:build`。
7
- *
8
- * 新增一列后记得回来把产物路径加进本表(Web 形态按显式清单取文件,不扫目录)。
9
- */
10
- export const STORIES: readonly string[] = ["Stories/title/title_main.json"];