@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.
- package/bin/create.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/cli.ts +11 -0
- package/src/generate/project.ts +13 -6
- package/src/index.ts +7 -0
- package/src/registry/compat.ts +49 -0
- package/src/registry/index.ts +2 -1
- package/src/shared/index.ts +8 -1
- package/src/shared/semver.ts +115 -0
- package/src/shared/texts.ts +1 -1
- package/template/README.md +59 -21
- package/template/__PROJECT__/Resources/Stories/title/title_main.story +15 -4
- package/template/__PROJECT__/index.html +7 -0
- package/template/__PROJECT__/package.json +5 -3
- package/template/__PROJECT__/src/controls/index.ts +7 -0
- package/template/__PROJECT__/src/controls/store.ts +32 -0
- package/template/__PROJECT__/src/gen/ui_register.g.ts +5 -0
- package/template/__PROJECT__/src/host/boot.ts +200 -5
- package/template/__PROJECT__/src/host/controls.ts +77 -0
- package/template/__PROJECT__/src/host/engine-wiring.ts +75 -0
- package/template/__PROJECT__/src/host/index.ts +37 -1
- package/template/__PROJECT__/src/host/input-map.ts +18 -3
- package/template/__PROJECT__/src/host/layer-z.ts +6 -0
- package/template/__PROJECT__/src/host/mount/index.ts +18 -0
- package/template/__PROJECT__/src/host/mount/renderer.ts +128 -0
- package/template/__PROJECT__/src/host/mount/table.ts +52 -0
- package/template/__PROJECT__/src/host/panels/history.ts +193 -0
- package/template/__PROJECT__/src/host/panels/preferences.ts +216 -0
- package/template/__PROJECT__/src/host/save-slots.ts +52 -4
- package/template/__PROJECT__/src/host/stage-dom.ts +36 -0
- package/template/__PROJECT__/src/host/thumbnails.ts +101 -0
- package/template/__PROJECT__/src/main.ts +30 -8
- package/template/__PROJECT__/src/mounts.ts +22 -0
- package/template/__PROJECT__/src/stories/index.ts +10 -0
- package/template/__PROJECT__/src/styles/stage.css +114 -0
- package/template/__PROJECT__/src/vite-env.d.ts +9 -0
- package/template/__PROJECT__/tsconfig.node.json +2 -1
- package/template/__PROJECT__/vite.config.ts +7 -52
- package/template/__PROJECT__/vite.plugins.ts +49 -0
- package/template/overlays/README.md +9 -3
- package/template/overlays/miniprogram/package.patch.json +1 -1
- package/template/overlays/miniprogram/src/mp/host.ts +5 -1
- package/template/overlays/miniprogram/vite.config.ts +8 -47
- package/template/overlays/react/src/App.tsx +12 -0
- package/template/overlays/react/src/UiHost.tsx +62 -0
- package/template/overlays/react/src/main.tsx +27 -5
- package/template/overlays/react/src/vite-env.d.ts +12 -0
- package/template/overlays/react/vite.config.ts +6 -49
- package/template/overlays/tauri/src/main.ts +23 -4
- package/template/overlays/ts/package.patch.json +1 -2
- package/template/overlays/ts/{Stories.src → src/Stories.src}/title/title_main.ts +6 -0
- package/template/overlays/ts/tsconfig.stories.json +1 -1
- package/template/overlays/vue/src/App.vue +13 -0
- package/template/overlays/vue/src/UiHost.vue +65 -0
- package/template/overlays/vue/src/main.ts +28 -5
- package/template/overlays/vue/src/vite-env.d.ts +12 -0
- package/template/overlays/vue/vite.config.ts +6 -49
- package/template/template-meta.json +8 -8
- package/template/__PROJECT__/src/stories.ts +0 -11
- package/template/overlays/ts/src/stories.ts +0 -10
- /package/template/overlays/ts/{Stories.src → src/Stories.src}/config.ts +0 -0
- /package/template/overlays/ts/{Stories.src → src/Stories.src}/title/look_around.ts +0 -0
- /package/template/overlays/ts/{Stories.src → src}/gen/font.gen.d.ts +0 -0
- /package/template/overlays/ts/{Stories.src → src}/gen/script.gen.d.ts +0 -0
- /package/template/overlays/ts/{Stories.src → src}/gen/state.gen.d.ts +0 -0
- /package/template/overlays/ts/{Stories.src → src}/gen/stories.gen.d.ts +0 -0
|
@@ -13,9 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
SYS,
|
|
16
|
+
PlayerPreferences,
|
|
16
17
|
StoryEngine,
|
|
18
|
+
resolveAudioConfig,
|
|
17
19
|
resolveLayerZ,
|
|
18
20
|
type AudioPort,
|
|
21
|
+
type HostControls,
|
|
22
|
+
type PreferencesPort,
|
|
19
23
|
type ProjectFilesPort,
|
|
20
24
|
type ResourcePort,
|
|
21
25
|
type SavePort,
|
|
@@ -26,6 +30,7 @@ import {
|
|
|
26
30
|
createFetchProjectFilesPort,
|
|
27
31
|
createStaticResourcePort,
|
|
28
32
|
createWebAudioPort,
|
|
33
|
+
createWebStoragePreferencesPort,
|
|
29
34
|
createWebStorageSavePort,
|
|
30
35
|
createWebVideoPort,
|
|
31
36
|
loadProject,
|
|
@@ -47,6 +52,10 @@ import { wireEngine } from "./engine-wiring";
|
|
|
47
52
|
import { startHostFrameLoop } from "./frame-loop";
|
|
48
53
|
import { wireInput } from "./input-map";
|
|
49
54
|
import { installStoryFonts } from "./theme-fonts";
|
|
55
|
+
import { createPreferencesPanel } from "./panels/preferences";
|
|
56
|
+
import { createHistoryPanel } from "./panels/history";
|
|
57
|
+
import { createNativeMountRenderer } from "./mount/index";
|
|
58
|
+
import { createHostControls } from "./controls";
|
|
50
59
|
|
|
51
60
|
/**
|
|
52
61
|
* 装配输入:节点句柄表、资源根内的清单与故事表,以及两项可配置节律。
|
|
@@ -68,11 +77,58 @@ export interface HostBootOptions {
|
|
|
68
77
|
* 缺省走 Web 形态(静态资源根 + localStorage 存档 + fetch 取工程文件)。
|
|
69
78
|
*/
|
|
70
79
|
ports?: BootPortOverrides;
|
|
80
|
+
/**
|
|
81
|
+
* 偏好面板的挂载容器(可选):给了就装配四通道音量 / 静音 / 字速面板,
|
|
82
|
+
* 不给就不装——页面结构归组合根,宿主不自己造节点,也不往 `body` 里塞。
|
|
83
|
+
*
|
|
84
|
+
* 谁给容器谁负责它的 z 序与显隐;面板根自带 `data-ui-zone`(否则滚轮会连带回溯)。
|
|
85
|
+
*/
|
|
86
|
+
prefsContainer?: HTMLElement | null;
|
|
87
|
+
/**
|
|
88
|
+
* 历史面板的挂载容器(可选):给了就装配「已走过对话」的面板与回溯按钮,
|
|
89
|
+
* 不给就不装——与 `prefsContainer` 同口径(页面结构归组合根,宿主不造节点)。
|
|
90
|
+
*
|
|
91
|
+
* 面板点一条即 `engine.rollbackTo(检查点)`;回溯后媒体位置由本文件接的
|
|
92
|
+
* `onRollback` 显式对齐(引擎只回状态,不下发端口动作)。
|
|
93
|
+
*/
|
|
94
|
+
historyContainer?: HTMLElement | null;
|
|
95
|
+
/**
|
|
96
|
+
* UI 挂载层容器(可选):给了就按 `SYS.mounts` 把 `src/ui/` 下的 UI 挂进来。
|
|
97
|
+
*
|
|
98
|
+
* 容器由组合根提供并常驻(宿主不新建也不摘除它,变化只动它的子节点)。UI 表来自
|
|
99
|
+
* 生成物 `src/gen/ui_register.g.ts`——不是 `import.meta.glob`:模板宿主不该绑死构建工具,
|
|
100
|
+
* 而 glob 会把 `src/ui/` 下的文件全部静态拉进包,删了 UI 忘删文件它就还在。
|
|
101
|
+
*/
|
|
102
|
+
mountsContainer?: HTMLElement | null;
|
|
103
|
+
/**
|
|
104
|
+
* 「名字 → UI 工厂」表(可选):缺省空表,即 `mount` 只改状态、没有组件出现。
|
|
105
|
+
*
|
|
106
|
+
* 表由组合根从生成物取(`./mounts` 是那份生成物在 `src/**` 里的唯一引用点):
|
|
107
|
+
* 各框架形态的 UI 是组件对象、原生形态是工厂函数,所以这张表**由形态自己给**,
|
|
108
|
+
* 宿主只负责把它的项挂进容器。
|
|
109
|
+
*/
|
|
110
|
+
mountsTable?: Readonly<Record<string, unknown>>;
|
|
111
|
+
/**
|
|
112
|
+
* 自己接管 UI 挂载的落法(可选):给了就不建原生渲染器,只把 `SYS.mounts` 的每个新值
|
|
113
|
+
* 交给它。框架形态用这条——挂载点的父子关系归框架管,宿主再往同一个容器里写子节点
|
|
114
|
+
* 会与框架的渲染树打架。
|
|
115
|
+
*
|
|
116
|
+
* 与 `mountsContainer` 互斥,容器优先。两样都不给即 `mount` 命令只改状态。
|
|
117
|
+
*/
|
|
118
|
+
onMounts?: (raw: unknown) => void;
|
|
119
|
+
/**
|
|
120
|
+
* 取走宿主控制面(可选):整局暂停与恢复、媒体暂停与恢复、换栏、存读档、拆除。
|
|
121
|
+
*
|
|
122
|
+
* 与 `onMounts` 是两条通道:挂载 UI 的**值**走 `SYS.mounts`(存档随行、必须 JSON 安全),
|
|
123
|
+
* 操作**句柄**走这里(含函数、运行期注入、不进快照)。框架形态把它存进自己的容器
|
|
124
|
+
* 再发给挂载组件;原生形态由渲染器直接作为工厂的第二参传下去,不需要这条回调。
|
|
125
|
+
*/
|
|
126
|
+
onControls?: (controls: HostControls) => void;
|
|
71
127
|
}
|
|
72
128
|
|
|
73
129
|
/**
|
|
74
130
|
* 端口覆盖表:换平台形态时由组合根传入原生实现,装配顺序与接线一行不改。
|
|
75
|
-
*
|
|
131
|
+
* 各端口都可单独覆盖——缺哪一个就用该端口的 Web 形态。
|
|
76
132
|
*/
|
|
77
133
|
export interface BootPortOverrides {
|
|
78
134
|
/** 工程文件供给(缺省走 fetch 取资源根下的静态文件) */
|
|
@@ -81,13 +137,51 @@ export interface BootPortOverrides {
|
|
|
81
137
|
resources?: ResourcePort;
|
|
82
138
|
/** 存档(桌面壳换成 Rust 命令:WebView 的 localStorage 会被清缓存带走) */
|
|
83
139
|
saves?: SavePort;
|
|
140
|
+
/** 玩家偏好持久化(缺省走 localStorage;桌面壳换 app_data JSON 文件) */
|
|
141
|
+
preferences?: PreferencesPort;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 宿主句柄:装配完成后交给组合根的那几样东西。
|
|
146
|
+
*
|
|
147
|
+
* **为什么交出引擎**:组合根常需要在装配之外读引擎(调试面板、外部玩法系统、
|
|
148
|
+
* 宿主自己的页面切换),而引擎是 `bootHost` 的内部变量。让调用方自己 `new StoryEngine`
|
|
149
|
+
* 等于起第二个引擎,两个引擎订阅同一份存档端口必然互相踩。这里交出**同一个实例**。
|
|
150
|
+
*
|
|
151
|
+
* **为什么句柄可省**:现有组合根写的是 `void bootHost(...)` / `await bootHost(...)`,
|
|
152
|
+
* 不取返回值——返回类型从 `void` 变成句柄不会破坏它们,它们照旧不关心句柄。
|
|
153
|
+
* 需要句柄的宿主自己接住返回值即可。
|
|
154
|
+
*
|
|
155
|
+
* `preferences`:本宿主总是接线偏好层(缺省 localStorage,桌面壳经 `ports.preferences`
|
|
156
|
+
* 换成 app_data 文件),所以句柄里恒定有它——调用方不需要判空。偏好不进存档,
|
|
157
|
+
* 整局稳定,宿主的设置页直接读写这个实例即可。
|
|
158
|
+
*/
|
|
159
|
+
export interface HostHandle {
|
|
160
|
+
/**
|
|
161
|
+
* 引擎实例(与宿主内部订阅的是同一个)。
|
|
162
|
+
*
|
|
163
|
+
* `readonly`:宿主另建一个实例顶替它,两个引擎订阅同一份存档端口必然互相踩。
|
|
164
|
+
* 句柄交出的是**那一个**实例,不可替换。
|
|
165
|
+
*/
|
|
166
|
+
readonly engine: StoryEngine;
|
|
167
|
+
/** 玩家偏好(已 hydrate;读写与订阅都走它)。同为 `readonly`:换实例等于丢掉既有订阅 */
|
|
168
|
+
readonly preferences: PlayerPreferences;
|
|
169
|
+
/**
|
|
170
|
+
* 拆除本宿主:按依赖反序释放渲染器,再销毁引擎。
|
|
171
|
+
*
|
|
172
|
+
* 顺序是约束:渲染器订阅引擎状态,先拆引擎会让订阅回调打到已销毁的实例上。
|
|
173
|
+
* 幂等——重复调用只生效第一次(渲染器的 `dispose` 自身也幂等)。
|
|
174
|
+
*/
|
|
175
|
+
dispose(): void;
|
|
84
176
|
}
|
|
85
177
|
|
|
86
178
|
/**
|
|
87
179
|
* 装配并启动本宿主。失败时抛出(组合根负责把失败写进页面),
|
|
88
180
|
* 中途失败不会留下半启动的宿主:所有接线都在本函数内完成,抛出即放弃。
|
|
181
|
+
*
|
|
182
|
+
* 返回值是宿主句柄(见 {@link HostHandle});不需要句柄的调用方照旧忽略即可。
|
|
89
183
|
*/
|
|
90
|
-
export async function bootHost(options: HostBootOptions): Promise<
|
|
184
|
+
export async function bootHost(options: HostBootOptions): Promise<HostHandle> {
|
|
91
185
|
const { dom } = options;
|
|
92
186
|
|
|
93
187
|
// 字体装配(纯展示,boot 一次性):@font-face 注入 + 项目默认字体主题面,
|
|
@@ -111,6 +205,13 @@ export async function bootHost(options: HostBootOptions): Promise<void> {
|
|
|
111
205
|
const audioPort: AudioPort = createWebAudioPort({ onError: reportError });
|
|
112
206
|
const savePort: SavePort = options.ports?.saves ?? createWebStorageSavePort();
|
|
113
207
|
|
|
208
|
+
// —— 玩家偏好:独立于存档的整局设置(音量 / 静音 / 字速)——
|
|
209
|
+
// hydrate 失败 = 默认值起航(端口实现负责吞掉畸形数据,绝不因偏好坏掉挡住启动)
|
|
210
|
+
const preferences = new PlayerPreferences(
|
|
211
|
+
options.ports?.preferences ?? createWebStoragePreferencesPort(),
|
|
212
|
+
);
|
|
213
|
+
await preferences.hydrate();
|
|
214
|
+
|
|
114
215
|
// —— 层级(z 序):内建默认 × 工程覆盖(project.json 的 shell.layers)——
|
|
115
216
|
const layerZ = resolveLayerZ(manifest);
|
|
116
217
|
// 视频端口的 z 在端口内部(与 DOM 层不同),故端口晚于首次落 DOM 建立,
|
|
@@ -131,13 +232,22 @@ export async function bootHost(options: HostBootOptions): Promise<void> {
|
|
|
131
232
|
// —— 存档壳配置:槽位(project.json 的 shell.saves.slots 可覆盖)——
|
|
132
233
|
fillSaveSlots(dom, manifest);
|
|
133
234
|
|
|
134
|
-
|
|
235
|
+
// 工程默认音量:shell.audio.volumes 是音量的第二级(命令没写 volume 时取它),
|
|
236
|
+
// 玩家偏好仍是最外层乘算——两者不同层,缺了它「工程统一音量」就只能靠每条命令手写。
|
|
237
|
+
const engine = new StoryEngine(story, {
|
|
238
|
+
historyLimit: 200,
|
|
239
|
+
savePort,
|
|
240
|
+
audioConfig: resolveAudioConfig(manifest),
|
|
241
|
+
});
|
|
135
242
|
const audio = createAudioRenderer(engine, audioPort, resourcePort, {
|
|
136
243
|
onError: reportError,
|
|
244
|
+
// 偏好接线:通道音量 = op 音量 × 通道偏好,偏好变化即时重规划(不重头播)
|
|
245
|
+
preferences,
|
|
137
246
|
});
|
|
138
247
|
const video = createVideoRenderer(engine, videoPort, resourcePort, {
|
|
139
248
|
onError: reportError,
|
|
140
249
|
onVideoFinished: () => engine.videoFinished(), // 播放结束 → 引擎解除 video 等待
|
|
250
|
+
preferences,
|
|
141
251
|
});
|
|
142
252
|
|
|
143
253
|
const toast = createToast({
|
|
@@ -150,6 +260,38 @@ export async function bootHost(options: HostBootOptions): Promise<void> {
|
|
|
150
260
|
defaultCps: options.defaultCps ?? DEFAULT_TEXT_CPS,
|
|
151
261
|
});
|
|
152
262
|
|
|
263
|
+
// —— 宿主控制面:引擎与媒体端口就绪后造一次,交出去(挂载 UI 与宿主菜单共用)——
|
|
264
|
+
// 取用函数每次都读当下实例:`videoPort` 在层级建立后才赋值,故先收窄成常量再交出。
|
|
265
|
+
const videoReady: VideoPort = videoPort;
|
|
266
|
+
const controls = createHostControls({
|
|
267
|
+
engine: () => engine,
|
|
268
|
+
audio: () => audioPort,
|
|
269
|
+
video: () => videoReady,
|
|
270
|
+
});
|
|
271
|
+
options.onControls?.(controls);
|
|
272
|
+
|
|
273
|
+
// —— UI 挂载:`SYS.mounts` → 容器的子节点(不给容器 = mount 只改状态)——
|
|
274
|
+
// 容器与表都由组合根给:宿主不造节点,也不猜 UI 长什么样(原生是工厂、框架是组件)。
|
|
275
|
+
// 隐藏即真卸载(不是 display:none):组件自己的定时器、订阅、媒体元素随拆除一起停。
|
|
276
|
+
// 框架形态自己接管落法(`onMounts`):往框架管的容器里写子节点会与它的渲染树打架。
|
|
277
|
+
const nativeMounts =
|
|
278
|
+
options.onMounts !== undefined ||
|
|
279
|
+
options.mountsContainer === undefined ||
|
|
280
|
+
options.mountsContainer === null
|
|
281
|
+
? null
|
|
282
|
+
: createNativeMountRenderer({
|
|
283
|
+
container: options.mountsContainer,
|
|
284
|
+
table: options.mountsTable ?? {},
|
|
285
|
+
// 值走 props、操作走 controls:原生形态没有响应式系统,工厂直接收下这两样
|
|
286
|
+
controls,
|
|
287
|
+
reportError,
|
|
288
|
+
});
|
|
289
|
+
const onMounts =
|
|
290
|
+
options.onMounts ??
|
|
291
|
+
(nativeMounts === null
|
|
292
|
+
? undefined
|
|
293
|
+
: (raw: unknown) => nativeMounts.sync(raw));
|
|
294
|
+
|
|
153
295
|
wireEngine({
|
|
154
296
|
dom,
|
|
155
297
|
engine,
|
|
@@ -166,16 +308,69 @@ export async function bootHost(options: HostBootOptions): Promise<void> {
|
|
|
166
308
|
audio.sync();
|
|
167
309
|
video.sync();
|
|
168
310
|
},
|
|
311
|
+
// 元素回写落点:音量走偏好、玩法状态走 game. 命名空间(都经既有闸门)
|
|
312
|
+
preferences,
|
|
313
|
+
gameState: engine.gameState,
|
|
314
|
+
// UI 挂载落点:没容器也不接管就不订阅(`mount` 命令照常改状态,只是没有组件出现)
|
|
315
|
+
mounts: onMounts,
|
|
169
316
|
});
|
|
170
317
|
|
|
171
318
|
const driveEffects = createHostEffects({ dom, engine });
|
|
172
|
-
|
|
319
|
+
|
|
320
|
+
// —— 偏好面板:只有组合根给了容器才装(页面结构归组合根,宿主不造节点)——
|
|
321
|
+
// 面板只读写偏好层,不碰引擎与存档;`destroy` 在拆除时退订并清空容器。
|
|
322
|
+
const prefsPanel =
|
|
323
|
+
options.prefsContainer === undefined || options.prefsContainer === null
|
|
324
|
+
? null
|
|
325
|
+
: createPreferencesPanel({
|
|
326
|
+
preferences,
|
|
327
|
+
container: options.prefsContainer,
|
|
328
|
+
});
|
|
329
|
+
prefsPanel?.mount();
|
|
330
|
+
|
|
331
|
+
// —— 历史面板:同样只有组合根给了容器才装 ——
|
|
332
|
+
// 数据整形在 `@lingfan-engine/shell`(块聚合),本面板只管画与点击;回溯后的媒体
|
|
333
|
+
// 对齐交回这里——`wireEngine` 的 `syncMedia` 同一件事,引擎不会替宿主跳播放位置。
|
|
334
|
+
const historyPanel =
|
|
335
|
+
options.historyContainer === undefined || options.historyContainer === null
|
|
336
|
+
? null
|
|
337
|
+
: createHistoryPanel({
|
|
338
|
+
engine,
|
|
339
|
+
container: options.historyContainer,
|
|
340
|
+
onRollback: () => {
|
|
341
|
+
audio.sync();
|
|
342
|
+
video.sync();
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
historyPanel?.mount();
|
|
346
|
+
|
|
347
|
+
const frameLoop = startHostFrameLoop({
|
|
173
348
|
dialogue,
|
|
174
349
|
onMediaTick: () => audio.pollPosition(), // 媒体位置帧级回写
|
|
175
350
|
onFrame: driveEffects,
|
|
176
351
|
});
|
|
177
352
|
wireInput({ dom, engine, dialogue });
|
|
178
|
-
wireSaveSlots(dom, engine);
|
|
353
|
+
wireSaveSlots(dom, engine, manifest);
|
|
179
354
|
|
|
180
355
|
engine.start();
|
|
356
|
+
|
|
357
|
+
// 拆除顺序与装配相反:先停帧(不再有回调进来),再释放订阅引擎的渲染器,最后销毁引擎
|
|
358
|
+
let disposed = false;
|
|
359
|
+
const dispose = (): void => {
|
|
360
|
+
if (disposed) return;
|
|
361
|
+
disposed = true;
|
|
362
|
+
frameLoop.stop();
|
|
363
|
+
// UI 先拆:它可能持有自己那套框架实例或定时器,晚拆会让卸载跑在引擎销毁之后
|
|
364
|
+
nativeMounts?.dispose();
|
|
365
|
+
// 面板先拆:它订阅偏好层,偏好 dispose 之后再退订会漏掉末次回填
|
|
366
|
+
prefsPanel?.destroy();
|
|
367
|
+
historyPanel?.destroy();
|
|
368
|
+
audio.dispose();
|
|
369
|
+
video.dispose();
|
|
370
|
+
engine.dispose();
|
|
371
|
+
// 偏好最后收尾:补发防抖窗内未落盘的末次修改(引擎已停,没人再改它了)
|
|
372
|
+
preferences.dispose();
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
return { engine, preferences, dispose };
|
|
181
376
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主控制面:把引擎与媒体端口收窄成挂载 UI 能拿的那几件事。
|
|
3
|
+
*
|
|
4
|
+
* 入参是取用函数而不是实例,因为引擎与媒体端口都可能被整体重建(换故事、重启对局),
|
|
5
|
+
* 重建后旧实例作废;取用函数每次调用都读当下那一个,控制面对象本身不必跟着换。
|
|
6
|
+
*
|
|
7
|
+
* 只在组合根出现:它把「引擎、音频端口、视频端口」这三样宿主装配知识收进一个对象,
|
|
8
|
+
* 挂载层因此不必知道引擎长什么样。
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
PERSISTENT_AUDIO_CHANNELS,
|
|
12
|
+
type AudioChannel,
|
|
13
|
+
type AudioPort,
|
|
14
|
+
type HostControls,
|
|
15
|
+
type SaveOptions,
|
|
16
|
+
type StoryEngine,
|
|
17
|
+
type VideoPort,
|
|
18
|
+
} from "@lingfan-engine/core";
|
|
19
|
+
|
|
20
|
+
/** 造一个控制面:方法逐个转调,自身不持有任何状态 */
|
|
21
|
+
export function createHostControls(options: {
|
|
22
|
+
/** 取当下的引擎实例(重建后指向新实例) */
|
|
23
|
+
engine: () => StoryEngine;
|
|
24
|
+
/** 取当下的音频端口 */
|
|
25
|
+
audio: () => AudioPort;
|
|
26
|
+
/** 取当下的视频端口 */
|
|
27
|
+
video: () => VideoPort;
|
|
28
|
+
/** 批量暂停与恢复作用于哪些音频通道;缺省三条常驻通道 */
|
|
29
|
+
audioChannels?: readonly AudioChannel[];
|
|
30
|
+
}): HostControls {
|
|
31
|
+
const { engine, audio, video } = options;
|
|
32
|
+
// `se` 是一次性音效,没有「暂停后从原位置续播」的语义,批量口径里一律剔除
|
|
33
|
+
const persistent = (
|
|
34
|
+
options.audioChannels ?? PERSISTENT_AUDIO_CHANNELS
|
|
35
|
+
).filter((channel) => channel !== "se");
|
|
36
|
+
return {
|
|
37
|
+
pause(): void {
|
|
38
|
+
engine().pause();
|
|
39
|
+
},
|
|
40
|
+
resume(): void {
|
|
41
|
+
engine().resume();
|
|
42
|
+
},
|
|
43
|
+
paused(): boolean {
|
|
44
|
+
return engine().paused();
|
|
45
|
+
},
|
|
46
|
+
pauseMedia(channel?: AudioChannel): void {
|
|
47
|
+
if (channel === undefined) {
|
|
48
|
+
for (const each of persistent) audio().pause(each);
|
|
49
|
+
video().pause();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (channel === "se") return;
|
|
53
|
+
audio().pause(channel);
|
|
54
|
+
},
|
|
55
|
+
resumeMedia(channel?: AudioChannel): void {
|
|
56
|
+
if (channel === undefined) {
|
|
57
|
+
for (const each of persistent) audio().resume(each);
|
|
58
|
+
video().resume();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (channel === "se") return;
|
|
62
|
+
audio().resume(channel);
|
|
63
|
+
},
|
|
64
|
+
navigate(columnId: string): void {
|
|
65
|
+
engine().navigate(columnId);
|
|
66
|
+
},
|
|
67
|
+
save(slot: string, options?: SaveOptions): void {
|
|
68
|
+
engine().save(slot, options);
|
|
69
|
+
},
|
|
70
|
+
load(slot: string): void {
|
|
71
|
+
engine().load(slot);
|
|
72
|
+
},
|
|
73
|
+
dispose(): void {
|
|
74
|
+
engine().dispose();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
SYS,
|
|
17
17
|
instanceZLayer,
|
|
18
18
|
type ElementInstance,
|
|
19
|
+
type GameStateWriter,
|
|
19
20
|
type OutboundPayload,
|
|
21
|
+
type PlayerPreferences,
|
|
20
22
|
type ResourcePort,
|
|
21
23
|
type StoryEngine,
|
|
22
24
|
} from "@lingfan-engine/core";
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
registerBuiltinElementRenderers,
|
|
30
32
|
renderElementTree,
|
|
31
33
|
resolveElementAction,
|
|
34
|
+
routeElementValueChange,
|
|
32
35
|
type LayerView,
|
|
33
36
|
type StateIntent,
|
|
34
37
|
type TypingSetting,
|
|
@@ -74,6 +77,30 @@ export interface HostWiringOptions {
|
|
|
74
77
|
readTypingSetting(): TypingSetting | undefined;
|
|
75
78
|
/** 存档 / 读档完成后的显式对齐(音频与视频渲染器) */
|
|
76
79
|
syncMedia(): void;
|
|
80
|
+
/**
|
|
81
|
+
* 玩家偏好:元素 `cmd="volume:<通道>"` 的回写落点。
|
|
82
|
+
*
|
|
83
|
+
* 可选——宿主没接偏好层时音量回写无落点(上报诊断,不静默吞),
|
|
84
|
+
* 与「宿主没接回写通道,控件仍可拖但值不落地」同一口径。
|
|
85
|
+
*/
|
|
86
|
+
preferences?: PlayerPreferences;
|
|
87
|
+
/**
|
|
88
|
+
* 外部玩法状态写入面(引擎实例的 `gameState`):元素 `cmd="game.<系统>.<键>"` 的落点。
|
|
89
|
+
*
|
|
90
|
+
* 只经 `setScoped` 走 `game.` 命名空间——保留键由引擎写入契约物理挡住,
|
|
91
|
+
* 元素层不可能靠这条通道碰到 `__` 系统状态。
|
|
92
|
+
*/
|
|
93
|
+
gameState?: GameStateWriter;
|
|
94
|
+
/**
|
|
95
|
+
* UI 挂载落点(可选):`SYS.mounts` 的每次变化都会推给它。
|
|
96
|
+
*
|
|
97
|
+
* 与元素层同属「同一路状态变更的另一个落点」,但两者不是一回事:元素是引擎排布的
|
|
98
|
+
* 舞台节点,UI 是宿主渲染树上的组件。这里只给一个**写入回调**而不是渲染器,
|
|
99
|
+
* 是因为三种宿主形态的落法完全不同(原生 DOM 自己算差量,Vue / React 进各自的
|
|
100
|
+
* 响应式系统)——接线的共同点只有「值从哪来」,落法留给装配处。
|
|
101
|
+
* 不给则 `mount` 命令只改状态,没有任何组件出现——降级是「少功能」,不是错误。
|
|
102
|
+
*/
|
|
103
|
+
mounts?: (raw: unknown) => void;
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
/** 接线状态订阅、元素层与出站事件 */
|
|
@@ -112,12 +139,49 @@ export function wireEngine(options: HostWiringOptions): void {
|
|
|
112
139
|
);
|
|
113
140
|
}
|
|
114
141
|
|
|
142
|
+
/**
|
|
143
|
+
* 控件值回写:元素层只交 `(element, value)`,落点判定在 `element-writeback`。
|
|
144
|
+
*
|
|
145
|
+
* **本函数不碰状态**——它把两件既有闸门(偏好 `setVolume` / `gameState.setScoped`)
|
|
146
|
+
* 交给落点路由,保留键与 JSON 安全仍由引擎写入契约把关。
|
|
147
|
+
*/
|
|
148
|
+
function writeElementValue(
|
|
149
|
+
element: ElementInstance,
|
|
150
|
+
value: number | string | boolean,
|
|
151
|
+
): void {
|
|
152
|
+
const preferences = options.preferences;
|
|
153
|
+
const gameState = options.gameState;
|
|
154
|
+
routeElementValueChange(element, value, {
|
|
155
|
+
setVolume: (channel, next) => {
|
|
156
|
+
// 偏好层没接时音量回写无处可落:宁可报一行诊断,也不假装写成功
|
|
157
|
+
if (preferences === undefined) {
|
|
158
|
+
reportError(
|
|
159
|
+
`元素 ${String(element.id)} 的音量回写没有偏好层:宿主未接线 PlayerPreferences`,
|
|
160
|
+
);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
preferences.setVolume(channel, next);
|
|
164
|
+
},
|
|
165
|
+
setScoped: (systemId, key, next) => {
|
|
166
|
+
if (gameState === undefined) {
|
|
167
|
+
reportError(
|
|
168
|
+
`元素 ${String(element.id)} 的状态回写没有写入面:宿主未接线 gameState`,
|
|
169
|
+
);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return gameState.setScoped(systemId, key, next);
|
|
173
|
+
},
|
|
174
|
+
reportError,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
115
178
|
function renderElements(): void {
|
|
116
179
|
renderElementTree({
|
|
117
180
|
registry: elementRegistry,
|
|
118
181
|
port: createDomRenderPort(dom.stage),
|
|
119
182
|
elements,
|
|
120
183
|
activate: activateElement,
|
|
184
|
+
onElementValueChange: writeElementValue,
|
|
121
185
|
resolveResource: elementResources.resolveForElement,
|
|
122
186
|
onUnknownType: (type) => reportError(`元素类型未注册:${type}`),
|
|
123
187
|
});
|
|
@@ -177,6 +241,17 @@ export function wireEngine(options: HostWiringOptions): void {
|
|
|
177
241
|
applyIntent(toIntent(key, value));
|
|
178
242
|
});
|
|
179
243
|
|
|
244
|
+
// UI 挂载单独一路:`SYS.mounts` 不在状态分发器的键表里——那张表是「引擎状态 → 视图意图」
|
|
245
|
+
// 的翻译,而挂载没有统一的视图意图可译(三形态落法完全不同),只把值原样交出去。
|
|
246
|
+
// 首帧主动喂一次:订阅不回放历史,引擎可能在接线之前就写过。
|
|
247
|
+
const onMounts = options.mounts;
|
|
248
|
+
if (onMounts !== undefined) {
|
|
249
|
+
onMounts(engine.get(SYS.mounts));
|
|
250
|
+
engine.onStateChanged(({ key, value }) => {
|
|
251
|
+
if (key === SYS.mounts) onMounts(value);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
180
255
|
/** 出站事件:致命错误 / 提示 / 小游戏挂载 / 存档回执 */
|
|
181
256
|
function applyEvent(payload: OutboundPayload): void {
|
|
182
257
|
switch (payload.kind) {
|
|
@@ -7,12 +7,19 @@
|
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
9
|
must,
|
|
10
|
+
optional,
|
|
10
11
|
createErrorReporter,
|
|
11
12
|
reportBootFailure,
|
|
12
13
|
type StageDom,
|
|
13
14
|
} from "./stage-dom";
|
|
14
15
|
export { createHostLayerZ, type HostLayerZOptions } from "./layer-z";
|
|
15
16
|
export { fillSaveSlots, wireSaveSlots } from "./save-slots";
|
|
17
|
+
export {
|
|
18
|
+
captureSaveThumbnail,
|
|
19
|
+
drawTextOverlay,
|
|
20
|
+
stripHtml,
|
|
21
|
+
type SaveThumbnailInput,
|
|
22
|
+
} from "./thumbnails";
|
|
16
23
|
export { createToast, type ToastOptions } from "./toast";
|
|
17
24
|
export {
|
|
18
25
|
createDialogueView,
|
|
@@ -21,7 +28,36 @@ export {
|
|
|
21
28
|
type DialogueViewOptions,
|
|
22
29
|
} from "./dialogue-view";
|
|
23
30
|
export { createHostEffects, type HostEffectsOptions } from "./effects";
|
|
31
|
+
export { createHostControls } from "./controls";
|
|
32
|
+
export {
|
|
33
|
+
createNativeMountRenderer,
|
|
34
|
+
resolveNativeFactory,
|
|
35
|
+
type NativeMountRenderer,
|
|
36
|
+
type NativeMountRendererOptions,
|
|
37
|
+
type NativeUiFactory,
|
|
38
|
+
type NativeUiInstance,
|
|
39
|
+
type NativeUiTable,
|
|
40
|
+
} from "./mount/index";
|
|
24
41
|
export { wireEngine, type HostWiringOptions } from "./engine-wiring";
|
|
25
42
|
export { startHostFrameLoop, type HostFrameLoopOptions } from "./frame-loop";
|
|
26
43
|
export { wireInput, type HostInputOptions } from "./input-map";
|
|
27
|
-
export {
|
|
44
|
+
export {
|
|
45
|
+
bootHost,
|
|
46
|
+
type BootPortOverrides,
|
|
47
|
+
type HostBootOptions,
|
|
48
|
+
type HostHandle,
|
|
49
|
+
} from "./boot";
|
|
50
|
+
export {
|
|
51
|
+
createPreferencesPanel,
|
|
52
|
+
PREF_CHANNELS,
|
|
53
|
+
type PreferencesPanel,
|
|
54
|
+
type PreferencesPanelOptions,
|
|
55
|
+
type PreferencesPanelView,
|
|
56
|
+
} from "./panels/preferences";
|
|
57
|
+
export {
|
|
58
|
+
createHistoryPanel,
|
|
59
|
+
type HistoryEngine,
|
|
60
|
+
type HistoryPanel,
|
|
61
|
+
type HistoryPanelOptions,
|
|
62
|
+
type HistoryPanelView,
|
|
63
|
+
} from "./panels/history";
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 输入映射:点击与键盘(空格 / 回车)都汇到同一个 `advance`。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 「点击能否推进」按等待态判定:只有对话 / 可跳过的等待 / 可跳过的过场这三态把点击
|
|
5
|
+
* 映射成 `advance`;菜单、输入、小游戏等交互等待期**不映射**——那些状态的推进出口是
|
|
6
|
+
* 各自的选择与提交,舞台空白处的点击不该被解释成「继续」。
|
|
7
|
+
*
|
|
8
|
+
* 这层判定不能省:引擎对非对话等待的 `advance` 是 fail-closed(发 `advance-invalid`
|
|
9
|
+
* 并写进错误条),而错误条一旦出现就不会自己消失——玩家在菜单上多点两下舞台就会看到
|
|
10
|
+
* 一条红字。是否可跳过(`wait` / `video` 的 skipable)仍归引擎裁决,这里只按等待态
|
|
11
|
+
* 放行,不放行就什么也不做。
|
|
6
12
|
*
|
|
7
13
|
* 两处刻意的不推进:交互层(选项 / 工具条 / 通知)内的点击不推进故事(元素可交互节点
|
|
8
14
|
* 在渲染器内自行 `stopPropagation`,这里再兜一层),输入框内的空格 / 回车不吞键
|
|
9
15
|
* (那是玩家在打字)。
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
|
-
import type
|
|
18
|
+
import { SYS, type StoryEngine } from "@lingfan-engine/core";
|
|
13
19
|
import type { StageDom } from "./stage-dom";
|
|
14
20
|
import type { DialogueView } from "./dialogue-view";
|
|
15
21
|
|
|
@@ -23,6 +29,13 @@ export interface HostInputOptions {
|
|
|
23
29
|
dialogue: DialogueView;
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/** 把点击映射成 `advance` 的等待态:对话、可跳过的等待、可跳过的过场 */
|
|
33
|
+
const ADVANCEABLE_WAITING: ReadonlySet<unknown> = new Set([
|
|
34
|
+
"dialog",
|
|
35
|
+
"wait",
|
|
36
|
+
"video",
|
|
37
|
+
]);
|
|
38
|
+
|
|
26
39
|
/** 接线点击与键盘 */
|
|
27
40
|
export function wireInput(options: HostInputOptions): void {
|
|
28
41
|
const { dom, engine, dialogue } = options;
|
|
@@ -34,6 +47,8 @@ export function wireInput(options: HostInputOptions): void {
|
|
|
34
47
|
typewriter.click();
|
|
35
48
|
return;
|
|
36
49
|
}
|
|
50
|
+
// 非可推进等待态直接返回:引擎会 fail-closed 并在错误条上留一条永不消失的红字
|
|
51
|
+
if (!ADVANCEABLE_WAITING.has(engine.get(SYS.waiting))) return;
|
|
37
52
|
engine.advance();
|
|
38
53
|
}
|
|
39
54
|
|
|
@@ -36,6 +36,12 @@ function writeLayerZ(dom: StageDom, layer: LayerId, z: number): void {
|
|
|
36
36
|
} else if (layer === "choices") dom.choices.style.zIndex = value;
|
|
37
37
|
else if (layer === "notifications") dom.notifications.style.zIndex = value;
|
|
38
38
|
else if (layer === "toolbar") dom.toolbar.style.zIndex = value;
|
|
39
|
+
// 历史与偏好两个面板槽位都是可选节点:叠加层可以不给(那时面板本就没装),所以判空
|
|
40
|
+
else if (layer === "history") {
|
|
41
|
+
if (dom.history !== null) dom.history.style.zIndex = value;
|
|
42
|
+
} else if (layer === "prefs") {
|
|
43
|
+
if (dom.prefs !== null) dom.prefs.style.zIndex = value;
|
|
44
|
+
}
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/** 造本宿主的层级视图:DOM 层写节点,视频层的 z 下发到端口内部 */
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原生形态挂载域出口(唯一出口):组合根只从这里取渲染器与表形状。
|
|
3
|
+
*
|
|
4
|
+
* 与 `packages/ui` 的 mount 域分工:那边是框架无关的投影与差量(三形态共用),
|
|
5
|
+
* 这边是「原生 DOM 怎么落」——一个渲染器、一份工厂表契约。
|
|
6
|
+
* 框架叠加层不 import 本目录:它们的渲染交给各自的响应式系统。
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
createNativeMountRenderer,
|
|
10
|
+
type NativeMountRenderer,
|
|
11
|
+
type NativeMountRendererOptions,
|
|
12
|
+
} from "./renderer";
|
|
13
|
+
export {
|
|
14
|
+
resolveNativeFactory,
|
|
15
|
+
type NativeUiFactory,
|
|
16
|
+
type NativeUiInstance,
|
|
17
|
+
type NativeUiTable,
|
|
18
|
+
} from "./table";
|