@lingfan-engine/ui 0.0.0-stage → 0.1.0

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 (63) hide show
  1. package/LICENSE +201 -0
  2. package/package.json +38 -4
  3. package/src/audio/audio.ts +331 -0
  4. package/src/audio/index.ts +11 -0
  5. package/src/choices/index.ts +10 -0
  6. package/src/choices/templates.ts +74 -0
  7. package/src/commands/index.ts +6 -0
  8. package/src/commands/registry.ts +54 -0
  9. package/src/dialogue/index.ts +24 -0
  10. package/src/dialogue/inline.ts +136 -0
  11. package/src/dialogue/templates.ts +70 -0
  12. package/src/dialogue/textView.ts +35 -0
  13. package/src/dialogue/typewriter.ts +181 -0
  14. package/src/dom/index.ts +7 -0
  15. package/src/dom/materialize.ts +88 -0
  16. package/src/dom/overlay-surface.ts +278 -0
  17. package/src/element/animation.ts +141 -0
  18. package/src/element/index.ts +38 -0
  19. package/src/element/interaction.ts +123 -0
  20. package/src/element/registry.ts +69 -0
  21. package/src/element/render.ts +77 -0
  22. package/src/element/renderers/base.ts +56 -0
  23. package/src/element/renderers/bindings.ts +178 -0
  24. package/src/element/renderers/container.ts +141 -0
  25. package/src/element/renderers/controls.ts +80 -0
  26. package/src/element/renderers/decoration.ts +28 -0
  27. package/src/element/renderers/index.ts +5 -0
  28. package/src/element/renderers/media.ts +71 -0
  29. package/src/element/renderers/register.ts +77 -0
  30. package/src/element/renderers/text.ts +20 -0
  31. package/src/element/resource.ts +55 -0
  32. package/src/element/spec.ts +77 -0
  33. package/src/element/style.ts +150 -0
  34. package/src/host/duration.ts +25 -0
  35. package/src/host/effects.ts +174 -0
  36. package/src/host/frame-loop.ts +84 -0
  37. package/src/host/index.ts +47 -0
  38. package/src/host/layer-view.ts +74 -0
  39. package/src/host/state-view.ts +190 -0
  40. package/src/index.ts +161 -0
  41. package/src/input/index.ts +11 -0
  42. package/src/input/scope.ts +100 -0
  43. package/src/minigame/index.ts +2 -0
  44. package/src/minigame/registry.ts +35 -0
  45. package/src/notify/index.ts +11 -0
  46. package/src/notify/templates.ts +60 -0
  47. package/src/overlay/choices-view.ts +76 -0
  48. package/src/overlay/create.ts +254 -0
  49. package/src/overlay/dialogue-view.ts +72 -0
  50. package/src/overlay/element-tree.ts +109 -0
  51. package/src/overlay/index.ts +16 -0
  52. package/src/overlay/layer-z.ts +73 -0
  53. package/src/overlay/notify-view.ts +32 -0
  54. package/src/overlay/projector.ts +286 -0
  55. package/src/overlay/surface.ts +125 -0
  56. package/src/overlay/types.ts +124 -0
  57. package/src/resources/index.ts +10 -0
  58. package/src/resources/urlCache.ts +79 -0
  59. package/src/templates/index.ts +7 -0
  60. package/src/templates/registry.ts +79 -0
  61. package/src/video/index.ts +6 -0
  62. package/src/video/videoView.ts +122 -0
  63. package/README.md +0 -3
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 通知层模板(挂载点模板的一例:单条 toast 的内容 + rootClass 皮肤)。
3
+ *
4
+ * 与 Ren'Py `notify` screen 的差别:它的 screen 里可挂任意控件(甚至写 `timer` 控制消失),
5
+ * 本模板只给「渲染一条通知所需的数据」,**消失时机归宿主**(`duration`)——
6
+ * 模板不得控制生命周期,否则回溯/并发通知的语义会漂移。
7
+ *
8
+ * 未知名 / null 回退默认(模板只影响展示)。
9
+ */
10
+ import {
11
+ TemplateRegistry,
12
+ createTemplateRegistry,
13
+ } from "../templates/registry";
14
+ import { renderDialogueLine } from "../dialogue/textView";
15
+
16
+ /** 通知层级(作者可选;模板据以换装/加图标) */
17
+ export type NotifyTone = "info" | "warning" | "error";
18
+
19
+ /** 通知模板输入:单条通知的渲染投影 */
20
+ export interface NotifyTemplateInput {
21
+ /** 通知文本(原文;经统一接缝渲染) */
22
+ text: string;
23
+ /** 通知类型(`notify` op 的 `type`;未声明 = info) */
24
+ tone: NotifyTone;
25
+ }
26
+
27
+ /** 通知模板输出:语义骨架挂点内容 */
28
+ export interface NotifyTemplateView {
29
+ /** 通知项根皮肤类 */
30
+ rootClass: string;
31
+ /** 通知正文 HTML(经统一接缝转义) */
32
+ bodyHtml: string;
33
+ }
34
+
35
+ /** 通知层模板:吃投影出的单条通知,吐骨架挂点内容。纯函数——不控消失时机、不挂定时器 */
36
+ export type NotifyTemplateFn = (
37
+ input: NotifyTemplateInput,
38
+ ) => NotifyTemplateView;
39
+
40
+ /** 通知层模板注册表:按名存 `NotifyTemplateFn`,按名解析,未知名回退默认 */
41
+ export type NotifyTemplateRegistry = TemplateRegistry<
42
+ NotifyTemplateInput,
43
+ NotifyTemplateView
44
+ >;
45
+
46
+ /** 造一张空的通知层模板注册表;宿主在装配期向它 `register` 模板(本函数不装内建默认) */
47
+ export function createNotifyTemplateRegistry(): NotifyTemplateRegistry {
48
+ return createTemplateRegistry<NotifyTemplateInput, NotifyTemplateView>();
49
+ }
50
+
51
+ /** 通知类型归一(未知/缺省 → info;不静默丢信息) */
52
+ export function toNotifyTone(raw: unknown): NotifyTone {
53
+ return raw === "warning" || raw === "error" ? raw : "info";
54
+ }
55
+
56
+ /** 内置默认模板:正文转义 + 按类型加皮肤类 */
57
+ export const builtinNotifyTemplate: NotifyTemplateFn = (input) => ({
58
+ rootClass: `tpl-notify tpl-notify-${input.tone}`,
59
+ bodyHtml: renderDialogueLine({ text: input.text }).html,
60
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 选择层视图模型:菜单选项与输入等待两个形态共用一个提交点,**不碰任何节点**。
3
+ *
4
+ * 两个形态互斥(`input` 等待渲染输入表单,`menu` 等待渲染选项列表),因此给出的是
5
+ * `ChoicesLayerView | null`:`null` 表示整层隐藏。点击一律交核心:选项目标是**列 id**
6
+ * (不是显示文本),输入值经宿主提交路径送核心。
7
+ */
8
+ import { builtinChoiceTemplate } from "../choices/templates";
9
+ import type { ChoiceTemplateRegistry } from "../choices/templates";
10
+ import type { ChoicesLayerView } from "./surface";
11
+
12
+ /** 一次选择层重渲所需的渲染态(全部来自状态投影) */
13
+ export interface ChoicesRenderInput {
14
+ waiting: string;
15
+ menuPrompt: string;
16
+ menuOptions: readonly { text: string; target: string }[];
17
+ inputPrompt: string;
18
+ }
19
+
20
+ /** 选择层文案(宿主可覆盖;缺省实现即内建文案) */
21
+ export interface ChoicesTexts {
22
+ /** 输入提交按钮的显示文本 */
23
+ inputSubmit: string;
24
+ }
25
+
26
+ /**
27
+ * 选择层的装配输入。`choose` / `submitInput` 不在模型里——它们是端口上的两条出口
28
+ * (见 `OverlaySurface.onChoose` / `onSubmitInput`),视图模型不认识引擎。
29
+ */
30
+ export interface ChoicesViewDeps {
31
+ /** 模板注册表(不传 = 内建模板) */
32
+ templates?: ChoiceTemplateRegistry;
33
+ /** 文案覆盖(不传 = 内建缺省) */
34
+ texts?: Partial<ChoicesTexts>;
35
+ }
36
+
37
+ /** 内建文案:不传 texts 时的缺省值 */
38
+ const DEFAULT_CHOICES_TEXTS: ChoicesTexts = { inputSubmit: "确定" };
39
+
40
+ /**
41
+ * 算出一层选择模型:等待态不是 `menu` / `input` 时返回 `null`(整层隐藏)。
42
+ *
43
+ * 输入形态不带皮肤类名(原文如此:`lf-choices` 基类由渲染侧拼,模板只服务菜单形态);
44
+ * 菜单形态连同每个选项的三个字段一起给出——`html` 是呈现片段,`label` 是可访问名,
45
+ * 因此「innerHTML 不进无障碍名计算」这条口径在宿主侧照样能落实。
46
+ */
47
+ export function choicesLayerView(
48
+ input: ChoicesRenderInput,
49
+ deps: ChoicesViewDeps = {},
50
+ ): ChoicesLayerView | null {
51
+ if (input.waiting === "input") {
52
+ const texts: ChoicesTexts = { ...DEFAULT_CHOICES_TEXTS, ...deps.texts };
53
+ return {
54
+ rootClass: "",
55
+ promptHtml: "",
56
+ options: [],
57
+ input: { prompt: input.inputPrompt, submitText: texts.inputSubmit },
58
+ };
59
+ }
60
+ if (input.waiting !== "menu") return null;
61
+
62
+ const built = (deps.templates?.resolve(null) ?? builtinChoiceTemplate)({
63
+ prompt: input.menuPrompt,
64
+ options: input.menuOptions,
65
+ });
66
+ return {
67
+ rootClass: built.rootClass,
68
+ promptHtml: built.promptHtml,
69
+ options: input.menuOptions.map((option, index) => ({
70
+ html: built.optionHtml[index] ?? "",
71
+ label: option.text,
72
+ target: option.target,
73
+ })),
74
+ input: null,
75
+ };
76
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * 叙事覆盖层装配器:把一个宿主的覆盖层表面变成可运行的叙事覆盖层。
3
+ *
4
+ * **解决的问题**:把叙事能力嵌入已有游戏(2D/3D 引擎)时,宿主需要自行实现一份
5
+ * 参考宿主的渲染接线(五层视图模型 + 状态投影 + 打字机 + 帧循环 + 输入判据)。
6
+ * 本装配器把这套接线收进展示层:宿主只给一个覆盖层表面(`OverlaySurface`)与一个引擎实例。
7
+ *
8
+ * **设计约束(与引擎架构一致)**:
9
+ * - 引擎核心**不驱动帧**(帧归宿主),本装配器就是那个宿主:它持帧循环,
10
+ * 只做插值,不逐帧写状态(帧级高频键由引擎自己的静默写通道负责)。
11
+ * - 渲染落点全部经端口(`surface.setDialogue` / `setChoices` / `pushNotification` /
12
+ * `setLayerZ`),本模块不出现任何平台类型;层骨架由宿主的实现建。
13
+ * - 模板注册表可选注入,不传即内建默认(模板只决定「长什么样」)。
14
+ * - **不自行绑定键盘**:宿主游戏的键位归宿主。本层只提供判据与命令入口,
15
+ * 并复用展示层唯一的输入判据(`isGameInputTarget`),不另立第二份口径。
16
+ *
17
+ * **不做什么**:不创建引擎、不装配平台端口(存档/资源/音频是平台能力,由宿主
18
+ * 经引擎选项注入)、不改叙事语义。
19
+ *
20
+ * 本模块只做**装配、接线与销毁编排**:各职责的实现分别在同目录的
21
+ * `projector` / `*-view`(视图模型)/ `element-tree` / `layer-z` 内,
22
+ * 帧循环则复用展示层的共享实现(`../host/frame-loop`,与各宿主的 `startHostFrameLoop` 同一份)。
23
+ * 所有状态都由各职责的工厂持有(每次调用装配器产出独立的一份),
24
+ * 因此两个覆盖层实例互不串扰。
25
+ */
26
+ import { createStateReader, DEFAULT_LAYER_Z } from "@lingfan-engine/core";
27
+ import type { LayerZTable } from "@lingfan-engine/core";
28
+ import { isGameInputTarget } from "../input/scope";
29
+ import { createElementRegistry } from "../element/registry";
30
+ import type { ElementRegistry } from "../element/registry";
31
+ import { registerBuiltinElementRenderers } from "../element";
32
+ import { DEFAULT_NOTIFY_MS } from "../host/duration";
33
+ import { choicesLayerView } from "./choices-view";
34
+ import type { ChoicesViewDeps } from "./choices-view";
35
+ import { dialogueLayerView } from "./dialogue-view";
36
+ import type { DialogueViewDeps } from "./dialogue-view";
37
+ import { createElementTree } from "./element-tree";
38
+ import { createFrameLoop } from "../host/frame-loop";
39
+ import { createLayerZController } from "./layer-z";
40
+ import { notifyLayerItem } from "./notify-view";
41
+ import type { NotifyViewDeps } from "./notify-view";
42
+ import { createProjector } from "./projector";
43
+ import type { RenderTarget } from "./projector";
44
+ import type { NarrativeOverlay, NarrativeOverlayOptions } from "./types";
45
+
46
+ /** 内建元素渲染器注册表(36 类型全集) */
47
+ function createBuiltinElementRegistry(): ElementRegistry {
48
+ const registry = createElementRegistry();
49
+ registerBuiltinElementRenderers(registry);
50
+ return registry;
51
+ }
52
+
53
+ /**
54
+ * 装配叙事覆盖层。
55
+ *
56
+ * ```ts
57
+ * const surface = createDomOverlaySurface(container);
58
+ * const overlay = createNarrativeOverlay({ surface, engine });
59
+ * engine.start();
60
+ * window.addEventListener("keydown", (e) => {
61
+ * if (e.key === " " && overlay.shouldConsumeInput(e.target)) overlay.advance();
62
+ * });
63
+ * // 卸载:overlay.dispose();
64
+ * ```
65
+ */
66
+ export function createNarrativeOverlay(
67
+ options: NarrativeOverlayOptions,
68
+ ): NarrativeOverlay {
69
+ const { surface, engine } = options;
70
+ const report = options.onError ?? ((m: string) => console.error(m));
71
+ const layerZ: LayerZTable = options.layerZ ?? DEFAULT_LAYER_Z;
72
+ const reader = createStateReader(engine);
73
+
74
+ const elements = options.elementRegistry ?? createBuiltinElementRegistry();
75
+ const textSpeed = options.textSpeed ?? 30;
76
+
77
+ const layerZController = createLayerZController(surface, layerZ);
78
+ // 表面可能由宿主先建(那时还不知道本实例的层默认表):构造末尾统一下发一次,
79
+ // 此后层默认表与本实例一致;下发幂等,不产生渲染目标。
80
+ layerZController.apply();
81
+
82
+ const projector = createProjector({
83
+ reader,
84
+ engine,
85
+ layerZ: layerZController,
86
+ textSpeed,
87
+ });
88
+
89
+ const dialogueDeps: DialogueViewDeps = {
90
+ templates: options.dialogueTemplates,
91
+ readTemplateName: () => reader.dialogTemplate(),
92
+ };
93
+ const choicesDeps: ChoicesViewDeps = {
94
+ templates: options.choiceTemplates,
95
+ texts: options.choiceTexts,
96
+ };
97
+ const notifyDeps: NotifyViewDeps = { templates: options.notifyTemplates };
98
+
99
+ const elementTree = createElementTree({
100
+ registry: elements,
101
+ port: surface.elements,
102
+ engine,
103
+ commands: options.commands,
104
+ resolveResource: options.resolveResource,
105
+ report,
106
+ });
107
+
108
+ // 两条交互出口(DOM 侧的按钮点击与表单提交就是调用点)
109
+ surface.onChoose = (target) => engine.choose(target);
110
+ surface.onSubmitInput = (value) => engine.input(value);
111
+
112
+ /** 重渲对话层(正文 HTML 每次现算:打字帧下它就是变化的那部分) */
113
+ function renderDialogueNow(): void {
114
+ const s = projector.state;
115
+ surface.setDialogue(
116
+ dialogueLayerView(
117
+ {
118
+ speaker: s.speaker,
119
+ speakerColor: s.speakerColor,
120
+ dialogFont: s.dialogFont,
121
+ canAdvance: s.canAdvance,
122
+ dialogHidden: s.dialogHidden,
123
+ waiting: s.waiting,
124
+ lineHtml: projector.lineHtml(),
125
+ },
126
+ dialogueDeps,
127
+ ),
128
+ );
129
+ }
130
+
131
+ /** 重渲选择层(`null` = 整层隐藏) */
132
+ function renderChoicesNow(): void {
133
+ const s = projector.state;
134
+ surface.setChoices(
135
+ choicesLayerView(
136
+ {
137
+ waiting: s.waiting,
138
+ menuPrompt: s.menuPrompt,
139
+ menuOptions: s.menuOptions,
140
+ inputPrompt: s.inputPrompt,
141
+ },
142
+ choicesDeps,
143
+ ),
144
+ );
145
+ }
146
+
147
+ /** 重渲元素树 */
148
+ function renderElementsNow(): void {
149
+ elementTree.render(projector.state.elementList);
150
+ }
151
+
152
+ /** 按投影给出的目标顺序重渲(顺序即层间重渲顺序) */
153
+ function renderTargets(targets: readonly RenderTarget[]): void {
154
+ for (const target of targets) {
155
+ if (target === "dialogue") renderDialogueNow();
156
+ else if (target === "choices") renderChoicesNow();
157
+ else renderElementsNow();
158
+ }
159
+ }
160
+
161
+ /** 打字进度推进后:只重渲正文,再把投影变化通知宿主 */
162
+ function onTyped(): void {
163
+ renderDialogueNow();
164
+ options.onView?.(projector.view());
165
+ }
166
+
167
+ const offState = engine.onStateChanged(({ key, value }) => {
168
+ const effect = projector.applyChange(key, value);
169
+ // 层级 z 只改样式,不产生渲染目标,也不通知投影变化(其余键都通知)
170
+ if (effect.kind === "layers") return;
171
+ renderTargets(effect.targets);
172
+ options.onView?.(projector.view());
173
+ });
174
+
175
+ const offEvent = engine.onEvent(({ payload }) => {
176
+ if (payload.kind === "notify") {
177
+ surface.pushNotification(
178
+ notifyLayerItem(payload, notifyDeps),
179
+ payload.duration ?? DEFAULT_NOTIFY_MS,
180
+ );
181
+ } else if (payload.kind === "engine.error") report(payload.message);
182
+ });
183
+
184
+ // 帧循环的帧能力来自端口:无帧环境下帧源不回调,循环静默不启、功能仍可用
185
+ // 每帧顺序固定——推进打字机(变了才重渲正文),再交宿主做帧驱动表现
186
+ const frameLoop = createFrameLoop({
187
+ frame: surface.frame,
188
+ onTick: (dt) => {
189
+ if (projector.tickTypewriter(dt)) onTyped();
190
+ options.onFrame?.(dt);
191
+ },
192
+ });
193
+ frameLoop.start();
194
+
195
+ function sync(): void {
196
+ renderTargets(projector.sync());
197
+ }
198
+
199
+ function advance(): void {
200
+ if (projector.finishTypewriter()) {
201
+ onTyped();
202
+ return;
203
+ }
204
+ // 只有「推进类」等待态才交引擎:menu 归 choose、input 归 input、
205
+ // 外部接管归 resolve。否则引擎会回一条无效调用错误——那是误报,不是玩家错。
206
+ const waiting = projector.state.waiting;
207
+ if (waiting === "dialog" || waiting === "wait" || waiting === "video") {
208
+ engine.advance();
209
+ }
210
+ }
211
+
212
+ function choose(target: string): void {
213
+ engine.choose(target);
214
+ }
215
+
216
+ function submitInput(value: string): void {
217
+ engine.input(value);
218
+ }
219
+
220
+ function shouldConsumeInput(target: unknown): boolean {
221
+ // 复用展示层唯一判据:控件与 UI 面板内的输入不归叙事
222
+ return isGameInputTarget(target);
223
+ }
224
+
225
+ let disposed = false;
226
+
227
+ function dispose(): void {
228
+ if (disposed) return;
229
+ disposed = true;
230
+ frameLoop.stop();
231
+ offState();
232
+ offEvent();
233
+ surface.clear();
234
+ }
235
+
236
+ function setRunning(next: boolean): void {
237
+ frameLoop.setRunning(next);
238
+ }
239
+
240
+ renderDialogueNow();
241
+ renderChoicesNow();
242
+
243
+ return {
244
+ surface,
245
+ view: () => projector.view(),
246
+ sync,
247
+ setRunning,
248
+ shouldConsumeInput,
249
+ advance,
250
+ choose,
251
+ submitInput,
252
+ dispose,
253
+ };
254
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 对话层视图模型:把投影出的对话渲染态经模板算成一层模型,**不碰任何节点**。
3
+ *
4
+ * 骨架固定(说话人 / 正文 / 推进提示),模板只决定「长什么样」——本模块负责把模板产物与
5
+ * 渲染态合成 `DialogueLayerView`;落到哪个渲染体系由宿主的 `OverlaySurface` 决定。
6
+ * 每次重渲都整层重算(模板输出很灵活,增量 diff 会引入第二份骨架知识)。
7
+ */
8
+ import { builtinBubbleTemplate } from "../dialogue/templates";
9
+ import { sanitizeFont } from "../dialogue/inline";
10
+ import type { DialogueTemplateRegistry } from "../dialogue/templates";
11
+ import type { DialogueLayerView } from "./surface";
12
+
13
+ /** 一次对话层重渲所需的渲染态(全部来自状态投影) */
14
+ export interface DialogueRenderInput {
15
+ speaker: string;
16
+ speakerColor: string;
17
+ /** 本句对话字体(空串 = 无声明;正文回落宿主 CSS 变量或浏览器默认) */
18
+ dialogFont: string;
19
+ canAdvance: boolean;
20
+ dialogHidden: boolean;
21
+ waiting: string;
22
+ /** 当前正文 HTML(打字中 = 可见前缀) */
23
+ lineHtml: string;
24
+ }
25
+
26
+ /**
27
+ * 对话层的装配输入。模板名不是参数而是回调——它逐帧从引擎状态读,
28
+ * 因此对话框换装(角色 screen / 逐句 template)不需要重建视图。
29
+ */
30
+ export interface DialogueViewDeps {
31
+ /** 模板注册表(不传 = 内建默认) */
32
+ templates?: DialogueTemplateRegistry;
33
+ /** 读当前模板名(`null` = 全局默认) */
34
+ readTemplateName: () => string | null;
35
+ }
36
+
37
+ /**
38
+ * 算出一层对话模型:骨架三段由模板给内容,本函数只做「合成 + 白名单校验 + 可见性判定」。
39
+ *
40
+ * 菜单、输入或视频等待期间整层不可见——是隐藏而非清空,避免恢复时闪一下。
41
+ * 字体只在非空且通过白名单时才写(同 `{font=}` 的口径),空串交给宿主 CSS 承接。
42
+ */
43
+ export function dialogueLayerView(
44
+ input: DialogueRenderInput,
45
+ deps: DialogueViewDeps,
46
+ ): DialogueLayerView {
47
+ const template =
48
+ deps.templates?.resolve(deps.readTemplateName()) ?? builtinBubbleTemplate;
49
+ const built = template({
50
+ speaker: input.speaker,
51
+ speakerColor: input.speakerColor,
52
+ lineHtml: input.lineHtml,
53
+ canAdvance: input.canAdvance,
54
+ });
55
+
56
+ const font = input.dialogFont === "" ? null : sanitizeFont(input.dialogFont);
57
+ const visible =
58
+ !input.dialogHidden &&
59
+ input.waiting !== "menu" &&
60
+ input.waiting !== "input" &&
61
+ input.waiting !== "video";
62
+
63
+ return {
64
+ rootClass: built.rootClass,
65
+ visible,
66
+ speakerHtml: built.speakerHtml,
67
+ speakerColor: input.speakerColor,
68
+ bodyHtml: built.bodyHtml,
69
+ bodyFont: font ?? "",
70
+ hintHtml: built.hintHtml,
71
+ };
72
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 元素树接线:把舞台元素渲染进元素层,并把元素的交互语义翻译成引擎调用。
3
+ *
4
+ * 元素渲染本身归元素域(`renderElementTree`);本模块只提供三样宿主侧能力:
5
+ * 交互激活(nav / ops / cmd 三条语义)、`disabled` 表达式求值、未注册类型上报。
6
+ * 求值一律经引擎的插值入口,本层不解析表达式语法——语法归核心层。
7
+ */
8
+ import type { ElementInstance, StoryEngine } from "@lingfan-engine/core";
9
+ import { resolveElementAction } from "../element/interaction";
10
+ import { renderElementTree } from "../element/render";
11
+ import type { ElementRegistry } from "../element/registry";
12
+ import type { RenderPort } from "../element/spec";
13
+
14
+ /** 元素 `cmd` 命令处理器(未注册 = fail-closed 上报) */
15
+ export type ElementCommandHandler = (
16
+ value: string | undefined,
17
+ element: ElementInstance,
18
+ ) => void;
19
+
20
+ /**
21
+ * 元素树渲染的装配输入。除 `engine` 外全部可选或可替换——
22
+ * `registry` 换掉即换元素类型全集,`commands` 不传则元素上的 `cmd` 一律走 fail-closed 上报。
23
+ */
24
+ export interface ElementTreeDeps {
25
+ /** 元素渲染器注册表(36 类型全集,或宿主替换的实现) */
26
+ registry: ElementRegistry;
27
+ /** 元素层的渲染端口(元素树的物化目标;Web 侧由 `createDomRenderPort` 就地包装) */
28
+ port: RenderPort;
29
+ engine: StoryEngine;
30
+ /** 宿主注册的元素命令表 */
31
+ commands?: Map<string, ElementCommandHandler>;
32
+ /** 资源解析(元素 source/src/path → URL) */
33
+ resolveResource?: (path: string) => string | undefined;
34
+ /** 上报(错误与 fail-closed 诊断) */
35
+ report: (message: string) => void;
36
+ }
37
+
38
+ /**
39
+ * 元素树接线器:宿主只需调 `render` 提交一帧元素列表,交互语义由本层消化。
40
+ * 两个方法都是无状态的——元素数据每次由调用方给全,本层不缓存上一帧。
41
+ */
42
+ export interface ElementTree {
43
+ /** `disabled` 表达式求值(返回 `null` = 求值失败,表达式语法归核心层) */
44
+ evalDisable(expression: string): boolean | null;
45
+ /** 整体重渲元素树(每次调用重建,不做增量 diff) */
46
+ render(elements: readonly ElementInstance[]): void;
47
+ }
48
+
49
+ /**
50
+ * 造一个元素树接线器。
51
+ *
52
+ * `evalDisable` 与 `activate` 共用同一套判定(同一组属性、同一个求值函数),
53
+ * 因此「看起来能点」与「点下去真的做了」不会分叉。元素列表逐帧传入、整体重渲,
54
+ * 本层不持有元素状态。
55
+ */
56
+ export function createElementTree(deps: ElementTreeDeps): ElementTree {
57
+ const { engine } = deps;
58
+
59
+ function evalDisable(expression: string): boolean | null {
60
+ const out = engine.interpolate(expression);
61
+ if (out === "true") return true;
62
+ if (out === "false") return false;
63
+ return null;
64
+ }
65
+
66
+ function activate(element: ElementInstance): void {
67
+ const action = resolveElementAction(element.props, { evalDisable });
68
+ if (action.kind === "nav") {
69
+ engine.navigate(action.target);
70
+ return;
71
+ }
72
+ if (action.kind === "ops") {
73
+ if (!engine.runElementOps(action.ops)) {
74
+ deps.report("元素动作执行失败(等待/位置类 op 与畸形负载会被拒绝)");
75
+ }
76
+ return;
77
+ }
78
+ if (action.kind !== "cmd") return;
79
+ const handler = deps.commands?.get(action.name);
80
+ if (handler === undefined) {
81
+ deps.report(
82
+ `元素命令未注册:${action.name}(fail-closed:需宿主经命令注册表提供)`,
83
+ );
84
+ return;
85
+ }
86
+ handler(
87
+ action.value === undefined ? undefined : engine.interpolate(action.value),
88
+ element,
89
+ );
90
+ }
91
+
92
+ return {
93
+ evalDisable,
94
+ render(elements: readonly ElementInstance[]): void {
95
+ // 交互元素的 pointer-events 由渲染器自行恢复(有交互动词的节点由绑定层
96
+ // 置 cursor:pointer + pointer-events:auto)——本层不重判第二遍。
97
+ renderElementTree({
98
+ registry: deps.registry,
99
+ port: deps.port,
100
+ elements,
101
+ activate,
102
+ evalDisable,
103
+ resolveResource: deps.resolveResource,
104
+ onUnknownType: (type) =>
105
+ deps.report(`元素类型未注册:${type}(fail-closed:不伪造渲染)`),
106
+ });
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,16 @@
1
+ export { createNarrativeOverlay } from "./create";
2
+ export type {
3
+ NarrativeOverlay,
4
+ NarrativeOverlayOptions,
5
+ NarrativeOverlayView,
6
+ } from "./types";
7
+ export type { ChoicesTexts } from "./choices-view";
8
+ export type {
9
+ ChoicesLayerInput,
10
+ ChoicesLayerOption,
11
+ ChoicesLayerView,
12
+ DialogueLayerView,
13
+ NotifyLayerItem,
14
+ OverlayLayerId,
15
+ OverlaySurface,
16
+ } from "./surface";
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 层级 z 控制器:持有实例级覆盖表,并负责把它写回各层(经覆盖层端口下发)。
3
+ *
4
+ * 三级链由引擎的 `resolveInstanceZ` 决定(**实例显式指定 > 工程层默认 > 内建默认**),
5
+ * 本控制器只负责「收下覆盖值 → 重算 → 经端口下发」这一段接线。
6
+ *
7
+ * 与宿主模板绑定的同名概念区分:本控制器直接把 z 下发到覆盖层端口;宿主若要拿响应式镜像
8
+ * 给模板绑定(`:style="{ zIndex: zOf(...) }"`),那是宿主自己的控制器(如 playground 的
9
+ * `HostLayerZController`),承载面与层表都不同。
10
+ */
11
+ import { resolveInstanceZ } from "@lingfan-engine/core";
12
+ import type {
13
+ LayerId,
14
+ LayerZOverrides,
15
+ LayerZTable,
16
+ } from "@lingfan-engine/core";
17
+ import type { OverlaySurface } from "./surface";
18
+
19
+ /** 层级 z 控制器(每实例一份覆盖表) */
20
+ export interface LayerZController {
21
+ /** 实例级覆盖:只含显式指定过的层;未指定即回层默认 */
22
+ overrides: LayerZOverrides;
23
+ /** 把当前覆盖表应用到各层(幂等,可反复调用) */
24
+ apply(): void;
25
+ }
26
+
27
+ /** 收藏一个实例 z 键的新值(`undefined` = 取消该层覆盖) */
28
+ export function setLayerOverride(
29
+ controller: LayerZController,
30
+ layer: LayerId,
31
+ instanceZ: number | undefined,
32
+ ): void {
33
+ controller.overrides = { ...controller.overrides, [layer]: instanceZ };
34
+ }
35
+
36
+ /**
37
+ * 造一个层级控制器:覆盖表初始为空(全部层走默认),需调 `apply()` 才下发 z。
38
+ *
39
+ * `layerZ` 是工程级的层默认表,控制器只在其上加一层「实例显式指定」的覆盖;
40
+ * 覆盖表逐实例持有,所以两个覆盖层不会互相改 z。下发是幂等的,重复调无副作用。
41
+ */
42
+ export function createLayerZController(
43
+ surface: OverlaySurface,
44
+ layerZ: LayerZTable,
45
+ ): LayerZController {
46
+ const controller: LayerZController = {
47
+ overrides: {},
48
+ apply(): void {
49
+ const z = controller.overrides;
50
+ surface.setLayerZ(
51
+ "dialogue",
52
+ resolveInstanceZ("dialogue", z.dialogue, layerZ),
53
+ );
54
+ surface.setLayerZ(
55
+ "choices",
56
+ resolveInstanceZ("choices", z.choices, layerZ),
57
+ );
58
+ surface.setLayerZ(
59
+ "notifications",
60
+ resolveInstanceZ("notifications", z.notifications, layerZ),
61
+ );
62
+ surface.setLayerZ(
63
+ "takeover",
64
+ resolveInstanceZ("minigame", z.minigame, layerZ),
65
+ );
66
+ surface.setLayerZ(
67
+ "transition",
68
+ resolveInstanceZ("video", z.video, layerZ),
69
+ );
70
+ },
71
+ };
72
+ return controller;
73
+ }