@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,32 @@
1
+ /**
2
+ * 通知层视图模型:把引擎的 `notify` 事件算成一条 toast 的模型(**不碰节点、不管生命周期**)。
3
+ *
4
+ * 停留时长归端口调用的参数(`OverlaySurface.pushNotification(item, durationMs)`)——
5
+ * 本模块只决定「长什么样」:模板给出语气类名与正文片段。
6
+ */
7
+ import { builtinNotifyTemplate, toNotifyTone } from "../notify/templates";
8
+ import type { NotifyTemplateRegistry } from "../notify/templates";
9
+ import type { NotifyPayload } from "@lingfan-engine/core";
10
+ import type { NotifyLayerItem } from "./surface";
11
+
12
+ /** 通知层的装配输入:模板注册表可选 */
13
+ export interface NotifyViewDeps {
14
+ templates?: NotifyTemplateRegistry;
15
+ }
16
+
17
+ /**
18
+ * 算出一条通知模型:正文交给模板上色,本函数只把模板产物搬成模型。
19
+ *
20
+ * 与旧版不同的是:这里不再持有定时器——到时移除由宿主的渲染体系负责,
21
+ * 因此 `clear()`(撤销在排定时器)也一并移到端口(`OverlaySurface.clear()`)。
22
+ */
23
+ export function notifyLayerItem(
24
+ payload: NotifyPayload,
25
+ deps: NotifyViewDeps = {},
26
+ ): NotifyLayerItem {
27
+ const built = (deps.templates?.resolve(null) ?? builtinNotifyTemplate)({
28
+ text: payload.text,
29
+ tone: toNotifyTone(payload.notifyType),
30
+ });
31
+ return { rootClass: built.rootClass, bodyHtml: built.bodyHtml };
32
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * 渲染状态投影:把引擎的状态读成一份界面直接可用的渲染态。
3
+ *
4
+ * **为什么要有这一层**:界面不持有叙事真相,所有渲染态都是引擎状态经读口投影出来的。
5
+ * 投影分两条入口——引擎的 `ValueChanged` 事件(增量,一次一个键)与 `sync()`
6
+ * (全量,读档/回溯后整体对齐)——两条入口读的是同一批字段,因此取值口径集中在这里,
7
+ * 不各写一份。
8
+ *
9
+ * 每份投影状态都归实例所有(本模块只导出工厂,不持有任何跨实例的共享变量):
10
+ * 两个覆盖层实例各有自己的说话人、等待态与打字机,互不串扰。
11
+ */
12
+ import { instanceZLayer, SYS } from "@lingfan-engine/core";
13
+ import type {
14
+ ElementInstance,
15
+ StateReader,
16
+ StoryEngine,
17
+ } from "@lingfan-engine/core";
18
+ import { renderDialogueLine } from "../dialogue/textView";
19
+ import { Typewriter } from "../dialogue/typewriter";
20
+ import { setLayerOverride } from "./layer-z";
21
+ import type { LayerZController } from "./layer-z";
22
+ import type { NarrativeOverlayView } from "./types";
23
+
24
+ /** 需要重渲的渲染目标(顺序即重渲顺序) */
25
+ export type RenderTarget = "dialogue" | "choices" | "elements";
26
+
27
+ /** 一次状态变更的处理结果:层级键走层级重写,其余键走渲染目标列表 */
28
+ export type ChangeEffect =
29
+ { kind: "layers" } | { kind: "render"; targets: readonly RenderTarget[] };
30
+
31
+ /**
32
+ * 渲染态:全部字段都只由状态投影写入,
33
+ * 除打字进度(`shownText` / `typewriter`)外一律直接来自读口。
34
+ */
35
+ export interface RenderState {
36
+ speaker: string;
37
+ speakerColor: string;
38
+ /** 本句对话字体(引擎分辨链解析结果;空串 = 无声明,宿主回落项目默认/浏览器回退) */
39
+ dialogFont: string;
40
+ text: string;
41
+ canAdvance: boolean;
42
+ waiting: string;
43
+ menuPrompt: string;
44
+ menuOptions: Array<{ text: string; target: string }>;
45
+ inputPrompt: string;
46
+ nvlMode: string;
47
+ nvlBuffer: string[];
48
+ elementList: ElementInstance[];
49
+ dialogHidden: boolean;
50
+ typewriter: Typewriter | null;
51
+ /** 打字机当前可见前缀(与 `text` 一并决定正文 HTML) */
52
+ shownText: string;
53
+ /** 实例级层级 z(每实例一份) */
54
+ layerZ: LayerZController;
55
+ }
56
+
57
+ /**
58
+ * 投影器:持有**一个实例**的渲染态,并把状态变更翻译成「该重渲什么」。
59
+ *
60
+ * 两条入口(增量 `applyChange` / 全量 `sync`)读同一批字段,因此不会出现
61
+ * 「读档后与逐条变更后长得不一样」。渲染目标集也是这里算出来的——视图层只负责
62
+ * 按 `RenderTarget` 重画,不自判该不该画。
63
+ */
64
+ export interface Projector {
65
+ readonly state: RenderState;
66
+ /** 应用一次增量状态变更;层级键返回 `layers`,其余返回需要重渲的目标 */
67
+ applyChange(key: string, value: unknown): ChangeEffect;
68
+ /** 全量对齐(读档 / 回溯完成后调用);返回需要重渲的目标 */
69
+ sync(): readonly RenderTarget[];
70
+ /** 当前正文 HTML(打字中 = 可见前缀;打完 = 整句) */
71
+ lineHtml(): string;
72
+ /** 渲染投影(只读快照) */
73
+ view(): NarrativeOverlayView;
74
+ /** 重建打字机(新句子到达时) */
75
+ resetTypewriter(): void;
76
+ /** 推进打字机一帧;返回可见前缀是否变化(变了才需要重渲) */
77
+ tickTypewriter(dt: number): boolean;
78
+ /** 瞬间打完当前句;返回是否确实有未完成的打字(false = 直接转推进) */
79
+ finishTypewriter(): boolean;
80
+ }
81
+
82
+ /**
83
+ * 造一个投影器:状态从空投影起步,需调 `sync()` 做首次全量对齐。
84
+ *
85
+ * 打字机随句子重建,`textSpeed` 在每次重建时读取(改它不影响正在打的这句)。
86
+ * 层级 z 控制器由调用方注入——同一份控制器可被多个投影器共享(层级属覆盖层而非投影)。
87
+ */
88
+ export function createProjector(deps: {
89
+ reader: StateReader;
90
+ engine: StoryEngine;
91
+ layerZ: LayerZController;
92
+ textSpeed: number;
93
+ }): Projector {
94
+ const { reader, engine } = deps;
95
+
96
+ const state: RenderState = {
97
+ speaker: "",
98
+ speakerColor: "",
99
+ dialogFont: "",
100
+ text: "",
101
+ canAdvance: false,
102
+ waiting: "none",
103
+ menuPrompt: "",
104
+ menuOptions: [],
105
+ inputPrompt: "",
106
+ nvlMode: "none",
107
+ nvlBuffer: [],
108
+ elementList: [],
109
+ dialogHidden: false,
110
+ typewriter: null,
111
+ shownText: "",
112
+ layerZ: deps.layerZ,
113
+ };
114
+
115
+ /**
116
+ * 读菜单选项:文本与目标列成对。
117
+ * 两个键分属不同 SSOT 条目,任一到达都重读**成对数据**(避免只更新一半)。
118
+ */
119
+ function readMenuOptions(): void {
120
+ state.menuOptions = reader.menuChoices();
121
+ }
122
+
123
+ /** 说话人颜色派生(幂等):命令覆盖色 > 角色定义色 > 空串 */
124
+ function refreshSpeakerColor(): void {
125
+ const override = reader.dialogColor();
126
+ const def = engine.getCharacter(state.speaker);
127
+ state.speakerColor = (override !== "" ? override : def?.color) ?? "";
128
+ }
129
+
130
+ /**
131
+ * 重建打字机(新句子到达、或全量对齐时)。
132
+ * 覆盖色与说话人色同源刷新:引擎写 `color` 与 `speaker` 是两个独立条目,
133
+ * 派生值不依赖事件到达顺序,重算幂等。
134
+ */
135
+ function resetTypewriter(): void {
136
+ state.typewriter = new Typewriter(state.text, deps.textSpeed);
137
+ state.shownText = state.typewriter.visible;
138
+ }
139
+
140
+ function applyChange(key: string, value: unknown): ChangeEffect {
141
+ const zLayer = instanceZLayer(key);
142
+ if (zLayer !== undefined) {
143
+ setLayerOverride(
144
+ state.layerZ,
145
+ zLayer,
146
+ typeof value === "number" ? value : undefined,
147
+ );
148
+ state.layerZ.apply();
149
+ return { kind: "layers" };
150
+ }
151
+
152
+ if (key === SYS.currentDialogSpeaker) {
153
+ state.speaker = reader.dialogSpeaker();
154
+ refreshSpeakerColor();
155
+ return { kind: "render", targets: ["dialogue"] };
156
+ }
157
+ if (key === SYS.currentDialogColor) {
158
+ // 覆盖色与说话人色同源刷新(派生值不依赖事件到达顺序,重算幂等)
159
+ refreshSpeakerColor();
160
+ return { kind: "render", targets: ["dialogue"] };
161
+ }
162
+ if (key === SYS.dialogFont) {
163
+ state.dialogFont = reader.dialogFont();
164
+ return { kind: "render", targets: ["dialogue"] };
165
+ }
166
+ if (key === SYS.currentDialogText) {
167
+ state.text = reader.dialogText();
168
+ resetTypewriter();
169
+ return { kind: "render", targets: ["dialogue"] };
170
+ }
171
+ if (key === SYS.waiting) {
172
+ const next = reader.waiting();
173
+ state.waiting = next === "" ? "none" : next;
174
+ state.canAdvance = state.waiting === "dialog";
175
+ return { kind: "render", targets: ["dialogue", "choices"] };
176
+ }
177
+ if (key === SYS.dialogVisible) {
178
+ state.dialogHidden = reader.dialogHidden();
179
+ return { kind: "render", targets: ["dialogue"] };
180
+ }
181
+ if (key === SYS.menuPrompt) {
182
+ state.menuPrompt = reader.menuPrompt();
183
+ return { kind: "render", targets: ["choices"] };
184
+ }
185
+ if (key === SYS.menuOptions || key === SYS.menuTargets) {
186
+ readMenuOptions();
187
+ return { kind: "render", targets: ["choices"] };
188
+ }
189
+ if (key === SYS.inputPrompt) {
190
+ state.inputPrompt = reader.inputPrompt();
191
+ return { kind: "render", targets: ["choices"] };
192
+ }
193
+ if (key === SYS.nvlMode) {
194
+ const next = reader.nvlMode();
195
+ state.nvlMode = next === "" ? "none" : next;
196
+ return { kind: "render", targets: [] };
197
+ }
198
+ if (key === SYS.nvlBuffer) {
199
+ state.nvlBuffer = reader.nvlLines();
200
+ return { kind: "render", targets: [] };
201
+ }
202
+ if (key === SYS.elements) {
203
+ state.elementList = reader.elements();
204
+ return { kind: "render", targets: ["elements"] };
205
+ }
206
+ // 与叙事渲染无关的键:仍通知宿主投影变化,但不触发任何层重渲
207
+ return { kind: "render", targets: [] };
208
+ }
209
+
210
+ function sync(): readonly RenderTarget[] {
211
+ state.speaker = reader.dialogSpeaker();
212
+ refreshSpeakerColor();
213
+ state.dialogFont = reader.dialogFont();
214
+ state.text = reader.dialogText();
215
+ const waitState = reader.waiting();
216
+ state.waiting = waitState === "" ? "none" : waitState;
217
+ state.canAdvance = state.waiting === "dialog";
218
+ state.menuPrompt = reader.menuPrompt();
219
+ readMenuOptions();
220
+ state.inputPrompt = reader.inputPrompt();
221
+ const mode = reader.nvlMode();
222
+ state.nvlMode = mode === "" ? "none" : mode;
223
+ state.nvlBuffer = reader.nvlLines();
224
+ state.dialogHidden = reader.dialogHidden();
225
+ state.elementList = reader.elements();
226
+ resetTypewriter();
227
+ state.layerZ.overrides = reader.instanceZOverrides();
228
+ state.layerZ.apply();
229
+ return ["dialogue", "choices", "elements"];
230
+ }
231
+
232
+ function lineHtml(): string {
233
+ return renderDialogueLine({
234
+ text: state.text,
235
+ typed:
236
+ state.typewriter !== null && !state.typewriter.done
237
+ ? state.shownText
238
+ : undefined,
239
+ }).html;
240
+ }
241
+
242
+ function view(): NarrativeOverlayView {
243
+ return {
244
+ speaker: state.speaker,
245
+ text: state.text,
246
+ lineHtml: lineHtml(),
247
+ canAdvance: state.canAdvance,
248
+ waiting: state.waiting,
249
+ menuPrompt: state.menuPrompt,
250
+ menuOptions: state.menuOptions,
251
+ inputPrompt: state.inputPrompt,
252
+ nvlMode: state.nvlMode,
253
+ nvlLines: state.nvlBuffer,
254
+ hasElements: state.elementList.length > 0,
255
+ };
256
+ }
257
+
258
+ function tickTypewriter(dt: number): boolean {
259
+ const tw = state.typewriter;
260
+ if (tw === null || tw.done) return false;
261
+ tw.tick(dt);
262
+ const next = tw.visible;
263
+ if (next === state.shownText) return false;
264
+ state.shownText = next;
265
+ return true;
266
+ }
267
+
268
+ function finishTypewriter(): boolean {
269
+ const tw = state.typewriter;
270
+ if (tw === null || tw.done) return false;
271
+ tw.click();
272
+ state.shownText = tw.visible;
273
+ return true;
274
+ }
275
+
276
+ return {
277
+ state,
278
+ applyChange,
279
+ sync,
280
+ lineHtml,
281
+ view,
282
+ resetTypewriter,
283
+ tickTypewriter,
284
+ finishTypewriter,
285
+ };
286
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 覆盖层表面端口:中立面只描述「每一层要显示什么」,落到哪个渲染体系由宿主决定。
3
+ *
4
+ * **解决的问题**:覆盖层的五层(舞台 / 元素 / 转场 / 对话 / 选择 / 通知 / 接管)原先直接由
5
+ * 展示层建 `HTMLElement` 并写 `style` / `innerHTML`,于是「叙事能力嵌进已有游戏」这条路上,
6
+ * 任何非 Web 宿主都得自己重写一份渲染接线。
7
+ *
8
+ * **为什么不是元素层那种描述树**:元素层每帧整棵重建即可(`RenderSpec` 无状态、可序列化);
9
+ * 覆盖层不是——对话层有打字机**逐帧改同一处文本**的高频写,通知层有**生命周期**(到时移除),
10
+ * 输入形态有**焦点与光标**(整层重建会丢正在输入的字)。因此这里传的是**每层的视图模型**
11
+ * (纯数据)+ 一次整层提交:模型怎么算归中立核心,怎么落到节点/组件/`setData` 归宿主。
12
+ *
13
+ * **契约边界**:本文件不得出现任何平台类型(`HTMLElement` / `document` / `window` …)。
14
+ * DOM 实现见 `packages/ui/src/dom/overlay-surface.ts`(`createDomOverlaySurface`)。
15
+ */
16
+ import type { RenderPort } from "../element/spec";
17
+ import type { FrameSource } from "../host/frame-loop";
18
+
19
+ /** 覆盖层的层位(中立词表;与 DOM 无关,宿主按自己的层级体系落地) */
20
+ export type OverlayLayerId =
21
+ | "stage"
22
+ | "element"
23
+ | "transition"
24
+ | "dialogue"
25
+ | "choices"
26
+ | "takeover"
27
+ | "notifications";
28
+
29
+ /**
30
+ * 对话层的一次整层提交。
31
+ *
32
+ * 骨架固定为「说话人 / 正文 / 推进提示」三段,皮肤由 `rootClass` 与三个 HTML 片段表达;
33
+ * `visible` 为假时**隐藏而非清空**(恢复时不闪)——隐藏与清空是两种语义,故显式入模型。
34
+ */
35
+ export interface DialogueLayerView {
36
+ /** 皮肤类名(模板产出;DOM 侧拼在基类之后) */
37
+ rootClass: string;
38
+ /** 本层是否可见(假 = 让位给菜单/输入/视频层) */
39
+ visible: boolean;
40
+ /** 说话人片段 HTML(空串 = 本句无说话人) */
41
+ speakerHtml: string;
42
+ /** 说话人配色(空串 = 用皮肤默认) */
43
+ speakerColor: string;
44
+ /** 正文片段 HTML(打字中 = 可见前缀) */
45
+ bodyHtml: string;
46
+ /** 正文整句字体族(已过白名单校验;空串 = 不设,回落宿主 CSS/浏览器默认) */
47
+ bodyFont: string;
48
+ /** 推进提示片段 HTML(空串 = 本句无提示) */
49
+ hintHtml: string;
50
+ }
51
+
52
+ /** 选择层的一个选项(`html` 是呈现片段,`label` 是可访问名,`target` 是回核心的列 id) */
53
+ export interface ChoicesLayerOption {
54
+ html: string;
55
+ label: string;
56
+ target: string;
57
+ }
58
+
59
+ /** 选择层的输入形态参数(表单提交回核心) */
60
+ export interface ChoicesLayerInput {
61
+ /** 输入提示(同时作为输入框的可访问名;空串 = 用宿主文案「输入」) */
62
+ prompt: string;
63
+ /** 提交按钮显示文本 */
64
+ submitText: string;
65
+ }
66
+
67
+ /**
68
+ * 选择层的一次整层提交(菜单与输入两形态互斥,用哪个由哪个字段非空表达)。
69
+ *
70
+ * 层内不存在需要保留的局部状态(原文如此),因此每次重渲都整体重建;
71
+ * 点击与提交不是模型的一部分——它们是端口上的两条回调(见 `OverlaySurface`)。
72
+ */
73
+ export interface ChoicesLayerView {
74
+ /** 皮肤类名(菜单形态的模板产出;输入形态为空串) */
75
+ rootClass: string;
76
+ /** 菜单提示片段 HTML(空串 = 无提示) */
77
+ promptHtml: string;
78
+ /** 菜单选项(顺序即呈现顺序;输入形态为空数组) */
79
+ options: readonly ChoicesLayerOption[];
80
+ /** 输入形态参数(菜单形态为 `null`) */
81
+ input: ChoicesLayerInput | null;
82
+ }
83
+
84
+ /** 通知层的一条 toast(停留时长不在这里——它由端口调用的参数给出的毫秒数决定) */
85
+ export interface NotifyLayerItem {
86
+ /** 语气类名(模板产出) */
87
+ rootClass: string;
88
+ /** 正文片段 HTML */
89
+ bodyHtml: string;
90
+ }
91
+
92
+ /**
93
+ * 覆盖层表面端口:宿主实现它,把每层模型落到自己的渲染体系。
94
+ *
95
+ * 两个 `RenderPort`(舞台 / 元素)复用元素层的描述树词表;其余层各有自己的整层提交方法。
96
+ * 时钟与定时器不进中立面:帧能力经 `frame` 注入,通知的到时移除归宿主实现。
97
+ */
98
+ export interface OverlaySurface {
99
+ /** 帧源(宿主平台的「下一帧」能力;无帧环境返回一个永不回调的帧源) */
100
+ readonly frame: FrameSource;
101
+ /** 舞台层:背景 / 立绘(宿主也可自行往这里提描述树) */
102
+ readonly stage: RenderPort;
103
+ /** 元素层:元素树渲染目标 */
104
+ readonly elements: RenderPort;
105
+ /** 层级 z 下发(幂等,可反复调用;未显式指定的层走宿主默认) */
106
+ setLayerZ(layer: OverlayLayerId, z: number): void;
107
+ /** 提交对话层(整层重建) */
108
+ setDialogue(view: DialogueLayerView): void;
109
+ /** 提交选择层;`null` = 整层隐藏(菜单/输入都结束) */
110
+ setChoices(view: ChoicesLayerView | null): void;
111
+ /** 上屏一条通知,`durationMs` 毫秒后由宿主移除 */
112
+ pushNotification(item: NotifyLayerItem, durationMs: number): void;
113
+ /**
114
+ * 卸载收尾:清空全部层与在排定时器。
115
+ *
116
+ * 注意**接管层与转场遮罩不在这里**:它们是宿主自己的层(外部玩法系统 / 转场动画),
117
+ * 中立核心从不驱动它们(引擎只把它们的 z 交下来)。Web 宿主用 DOM 实现暴露的 `nodes`
118
+ * 或 `stage` 端口自行落地;非 Web 宿主用自己的渲染体系,不需要本端口提供方法。
119
+ */
120
+ clear(): void;
121
+ /** 选择出口(装配器在构造时挂上;点是玩家的动作,不是模型的一部分) */
122
+ onChoose?: (target: string) => void;
123
+ /** 输入提交出口(装配器在构造时挂上) */
124
+ onSubmitInput?: (value: string) => void;
125
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 叙事覆盖层的对外契约:表面端口、渲染投影、装配选项与实例方法。
3
+ *
4
+ * 这些类型是宿主与覆盖层之间的边界——实现可以按职责拆开,边界形状不动;
5
+ * 宿主只依赖这里声明的名字,不依赖覆盖层内部的模块划分。
6
+ *
7
+ * **渲染落点不在本文件**:覆盖层只产出每层的视图模型,落到哪个渲染体系由宿主实现的
8
+ * `OverlaySurface` 决定(Web 实现见 `packages/ui/src/dom/overlay-surface.ts`)。
9
+ * 因此本文件不出现任何平台类型。
10
+ */
11
+ import type {
12
+ ElementInstance,
13
+ LayerZTable,
14
+ StoryEngine,
15
+ } from "@lingfan-engine/core";
16
+ import type { ChoiceTemplateRegistry } from "../choices/templates";
17
+ import type { ChoicesTexts } from "./choices-view";
18
+ import type { DialogueTemplateRegistry } from "../dialogue/templates";
19
+ import type { ElementRegistry } from "../element/registry";
20
+ import type { NotifyTemplateRegistry } from "../notify/templates";
21
+ import type { OverlaySurface } from "./surface";
22
+
23
+ /** 渲染投影(只读快照;宿主诊断与自定义渲染可读) */
24
+ export interface NarrativeOverlayView {
25
+ speaker: string;
26
+ text: string;
27
+ /** 当前正文 HTML(打字中 = 可见前缀) */
28
+ lineHtml: string;
29
+ canAdvance: boolean;
30
+ /** 当前等待态(`none` = 无等待) */
31
+ waiting: string;
32
+ menuPrompt: string;
33
+ /** 选项(文本 + 目标列,顺序即呈现顺序) */
34
+ menuOptions: readonly { text: string; target: string }[];
35
+ inputPrompt: string;
36
+ nvlMode: string;
37
+ nvlLines: readonly string[];
38
+ /** 元素树是否非空(宿主可据此决定是否显示自己的背景层) */
39
+ hasElements: boolean;
40
+ }
41
+
42
+ /**
43
+ * 装配覆盖层的全部输入:一个覆盖层表面 + 一个已构造的引擎,其余都可省。
44
+ *
45
+ * 省掉的每一项都有明确后果(用内建默认或不做)——例如不传 `resolveResource`
46
+ * 则元素里的图片一律显示替代文本。逐字段说明见下。
47
+ */
48
+ export interface NarrativeOverlayOptions {
49
+ /**
50
+ * 覆盖层表面:宿主把「每一层要显示什么」落到自己渲染体系的出口。
51
+ * Web 宿主用 `createDomOverlaySurface(container)`(层骨架由它建)。
52
+ */
53
+ surface: OverlaySurface;
54
+ /** 引擎实例(已构造;`start()` 与平台端口归宿主) */
55
+ engine: StoryEngine;
56
+ /**
57
+ * 每帧回调(宿主游戏主循环钩子):本装配器每帧调用一次,
58
+ * 宿主据此把覆盖层与自己引擎的位置对齐(如气泡跟随角色)。
59
+ */
60
+ onFrame?: (dtSeconds: number) => void;
61
+ /** 打字速度(字符/秒;缺省 30,与内建默认一致) */
62
+ textSpeed?: number;
63
+ /** 渲染投影变化通知(宿主可作状态指示;每帧打字推进也会触发) */
64
+ onView?: (view: NarrativeOverlayView) => void;
65
+ /** 对话模板注册表(不传 = 内建) */
66
+ dialogueTemplates?: DialogueTemplateRegistry;
67
+ /** 选择模板注册表(不传 = 内建) */
68
+ choiceTemplates?: ChoiceTemplateRegistry;
69
+ /** 选择层文案覆盖(输入提交按钮等;不传 = 内建缺省) */
70
+ choiceTexts?: Partial<ChoicesTexts>;
71
+ /** 通知模板注册表(不传 = 内建) */
72
+ notifyTemplates?: NotifyTemplateRegistry;
73
+ /** 元素渲染器注册表(不传 = 内建全集) */
74
+ elementRegistry?: ElementRegistry;
75
+ /**
76
+ * 层级 z 表(不传 = 内建默认)。
77
+ * 宿主若从 `project.json shell.layers` 解析过覆盖表,经 `resolveLayerZ` 后注入。
78
+ */
79
+ layerZ?: LayerZTable;
80
+ /** 元素 `cmd` 命令处理器(未注册 fail-closed 上报) */
81
+ commands?: Map<
82
+ string,
83
+ (value: string | undefined, element: ElementInstance) => void
84
+ >;
85
+ /** 资源解析(元素 source/src/path → URL) */
86
+ resolveResource?: (path: string) => string | undefined;
87
+ /** 错误上报(引擎错误、未注册类型/命令;缺省 = 控制台) */
88
+ onError?: (message: string) => void;
89
+ }
90
+
91
+ /**
92
+ * 装配出的覆盖层实例:一面是宿主给进来的表面(`surface`,宿主可自行往舞台/接管层挂内容),
93
+ * 一面是交给宿主的动作。
94
+ *
95
+ * 引擎的推进权仍在宿主手里——`advance` / `choose` / `submitInput` 是宿主根据
96
+ * 自己的输入路由决定要不要调的,覆盖层不代宿主决定。
97
+ */
98
+ export interface NarrativeOverlay {
99
+ /** 覆盖层表面(宿主可进一步定制,如往 `stage` / 接管层挂自己的内容) */
100
+ readonly surface: OverlaySurface;
101
+ /** 当前渲染投影(只读) */
102
+ view(): NarrativeOverlayView;
103
+ /** 与引擎状态对齐(读档 / 回溯完成后调用) */
104
+ sync(): void;
105
+ /** 帧循环开关(宿主游戏暂停时可关,避免空转) */
106
+ setRunning(running: boolean): void;
107
+ /**
108
+ * 输入是否归叙事层消费(**只判事件目标**:控件/面板内不吃)。
109
+ * 模式层面的让位(外部玩法系统接管)由宿主按自己的域状态决定——
110
+ * 那是宿主游戏的路由权,覆盖层不代宿主决定。
111
+ *
112
+ * 参数是**事件目标**(DOM 宿主传 `event.target`),故类型为 `unknown`:
113
+ * 判据在展示层唯一的 `isGameInputTarget` 里,本层不引入平台事件类型。
114
+ */
115
+ shouldConsumeInput(target: unknown): boolean;
116
+ /** 推进(二段式已处理:打字中先瞬间完成) */
117
+ advance(): void;
118
+ /** 选择选项(`menu` 等待) */
119
+ choose(target: string): void;
120
+ /** 提交输入(`input` 等待) */
121
+ submitInput(value: string): void;
122
+ /** 卸载:停帧循环、退订、清空挂载点 */
123
+ dispose(): void;
124
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 资源域出口(UI 侧单一入口):逻辑资源路径 → 已解析 URL 的缓存。
3
+ *
4
+ * 音频 / 视频 / 元素三处渲染路径共用同一份实现,避免各自维护一份「查表 + 在途去重」。
5
+ */
6
+ export {
7
+ createResourceUrlCache,
8
+ type ResourceUrlCache,
9
+ type ResourceUrlCacheOptions,
10
+ } from "./urlCache";
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 资源 URL 缓存:逻辑资源路径 → 已解析 URL(UI 侧单一实现)。
3
+ *
4
+ * 元素渲染是同步的(`renderElementTree` 直接产 DOM),而资源解析是异步的——
5
+ * 这个接缝把两者对上:同步查表 → 在途去重 → 命中即返回 URL;
6
+ * 未命中**只启动一次**异步解析,落地后回调宿主;失败不粘滞(资源补上后仍可再试),
7
+ * 也不触发回调——不伪造 URL,诊断归端口/宿主。
8
+ *
9
+ * 释放策略归**消费方**:本模块只提供「已落地 URL」的枚举与清空,
10
+ * 何时释放(以及释放几次)由消费方在自己的销毁路径上决定。
11
+ */
12
+ export interface ResourceUrlCacheOptions {
13
+ /** 解析落地后的回调(宿主据此重渲染表层);失败不触发 */
14
+ onResolved?: () => void;
15
+ }
16
+
17
+ /**
18
+ * 缓存实例的两条取用路径:`get` 是同步的(拿不到就先别用),`resolve` 是异步的
19
+ * (一定会给一个结果,或在失败时抛)。
20
+ *
21
+ * `values` / `clear` 是留给消费方做释放的——缓存自己不认识「释放」这个概念,
22
+ * 因为 URL 怎么建、怎么撤销只有消费方的端口知道。
23
+ */
24
+ export interface ResourceUrlCache {
25
+ /** 同步查表:已落地返回 URL;未落地(未请求或在途)返回 `undefined` */
26
+ get(path: string): string | undefined;
27
+ /** 取用:命中即返回;未命中只发起一次解析(同路径在途去重);失败不粘滞 */
28
+ resolve(path: string): Promise<string>;
29
+ /** 已落地 URL(消费方据此统一释放) */
30
+ values(): IterableIterator<string>;
31
+ /** 清空缓存(可重复调用) */
32
+ clear(): void;
33
+ }
34
+
35
+ /**
36
+ * 造一个缓存:`resolveUrl` 负责真正取源,本模块负责「同一路径只解析一次」。
37
+ *
38
+ * 每个实例一张表,多个消费方共用一个实例才能共享解析结果;要隔离就各造各的。
39
+ * 失败不写进表,因此坏路径会重试,而 `onResolved` 只在真正落地时触发。
40
+ */
41
+ export function createResourceUrlCache(
42
+ resolveUrl: (path: string) => Promise<string>,
43
+ options: ResourceUrlCacheOptions = {},
44
+ ): ResourceUrlCache {
45
+ const urls = new Map<string, string>();
46
+ /** 在途解析:同一路径的并发取用共用同一次解析(失败即移除,不粘滞) */
47
+ const inFlight = new Map<string, Promise<string>>();
48
+
49
+ return {
50
+ get(path: string): string | undefined {
51
+ return urls.get(path);
52
+ },
53
+ resolve(path: string): Promise<string> {
54
+ const cached = urls.get(path);
55
+ if (cached !== undefined) return Promise.resolve(cached);
56
+ const pending = inFlight.get(path);
57
+ if (pending !== undefined) return pending;
58
+ const promise = resolveUrl(path)
59
+ .then((url) => {
60
+ urls.set(path, url);
61
+ inFlight.delete(path);
62
+ options.onResolved?.();
63
+ return url;
64
+ })
65
+ .catch((error: unknown) => {
66
+ inFlight.delete(path); // 失败不粘滞:资源补上后仍可再试
67
+ throw error;
68
+ });
69
+ inFlight.set(path, promise);
70
+ return promise;
71
+ },
72
+ values(): IterableIterator<string> {
73
+ return urls.values();
74
+ },
75
+ clear(): void {
76
+ urls.clear();
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,7 @@
1
+ /** 挂载点模板域出口(通用注册表;各挂载点的具体模板各自成域)。 */
2
+ export {
3
+ TemplateRegistry,
4
+ createTemplateRegistry,
5
+ type TemplateFn,
6
+ type TemplateViewBase,
7
+ } from "./registry";