@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,54 @@
1
+ /**
2
+ * 宿主命名命令注册表 —— 通用命令扩展处理器的注册制。
3
+ *
4
+ * 用途:元素的 `cmd` 属性是**宿主业务命令**(打开设置面板、回标题、外部跳转…),
5
+ * 引擎核心不感知这些命令(输入语义归核心层——`nav` 走核心 `navigate`,
6
+ * `cmd` 走宿主注册表;未注册即 fail-closed,不静默忽略作者意图)。
7
+ *
8
+ * 与 minigame / 元素渲染器注册表同一口径:**无默认回退**。
9
+ */
10
+ import type { ElementInstance } from "@lingfan-engine/core";
11
+
12
+ /**
13
+ * 命令处理器。
14
+ * `value` 为**点击时**已求值的参数(宿主先用 `engine.interpolate` 插值,
15
+ * 取值语义 = 点击那一刻的变量值)。
16
+ */
17
+ export type NamedCommandHandler = (
18
+ value: string | undefined,
19
+ source: ElementInstance,
20
+ ) => void;
21
+
22
+ /**
23
+ * 宿主命令表:`register` 与 `get` 是配对的两端,`get` 未命中即 `undefined`
24
+ * (这里不兜底——命令没注册就是没注册,由调用方决定怎么上报)。
25
+ * 表本身不持有状态,命令处理器持有的状态与它无关。
26
+ */
27
+ export interface CommandRegistry {
28
+ /** 注册/覆盖(同名再注册 = 替换,宿主扩展与开发热替换语义) */
29
+ register(name: string, handler: NamedCommandHandler): void;
30
+ /** 解析处理器;未注册 = undefined(fail-closed,宿主必须显式上报) */
31
+ get(name: string): NamedCommandHandler | undefined;
32
+ has(name: string): boolean;
33
+ /** 已注册命令名(诊断 / 帮助面板用) */
34
+ names(): string[];
35
+ }
36
+
37
+ /** 造一张空的宿主命令表;命令由宿主在装配期注册,本函数不预置任何命令 */
38
+ export function createCommandRegistry(): CommandRegistry {
39
+ const handlers = new Map<string, NamedCommandHandler>();
40
+ return {
41
+ register(name: string, handler: NamedCommandHandler): void {
42
+ handlers.set(name, handler);
43
+ },
44
+ get(name: string): NamedCommandHandler | undefined {
45
+ return handlers.get(name);
46
+ },
47
+ has(name: string): boolean {
48
+ return handlers.has(name);
49
+ },
50
+ names(): string[] {
51
+ return [...handlers.keys()];
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 对话渲染域出口:内联标记 + 打字机二段式 +
3
+ * **统一文字渲染接缝**(textView——打字机/NVL/历史/模板共用的唯一管线)。
4
+ */
5
+ export {
6
+ configureStoryFonts,
7
+ renderInlineMarkup,
8
+ sanitizeFont,
9
+ type StoryFontGate,
10
+ } from "./inline";
11
+ export { Typewriter, tokenizeStream } from "./typewriter";
12
+ export {
13
+ renderDialogueLine,
14
+ type DialogueLineInput,
15
+ type DialogueLineView,
16
+ } from "./textView";
17
+ export {
18
+ builtinBubbleTemplate,
19
+ createDialogueTemplateRegistry,
20
+ type DialogueTemplateFn,
21
+ type DialogueTemplateInput,
22
+ type DialogueTemplateRegistry,
23
+ type DialogueTemplateView,
24
+ } from "./templates";
@@ -0,0 +1,136 @@
1
+ /**
2
+ * UI 职责:内联富文本标记解析渲染(核心层只透传,本模块负责上屏)。
3
+ * 标记集(单一真相源):
4
+ * - 样式:{b}{/b} {i}{/i} {u}{/u} {color=#xxx}{/color} {font=名}{/font} {size=N}{/size}
5
+ * - 控制符:{p}/{w} 段内停顿、{fast}——打字机阶段接管,当前渲染为空
6
+ * - 裸 {color}{font}{size} 按闭合处理
7
+ * - 未知 {…}(含用户字面量)按原文渲染;HTML 全量转义防注入
8
+ * - {font=名} 受字体名注册表约束(configureStoryFonts 宿主装配):未注册名回退默认字体
9
+ * 并回调诊断;未接入时保持字符集白名单的纯展示行为
10
+ */
11
+
12
+ /** HTML 特殊字符全量转义(`&` `<` `>` `"` `'`),防注入 */
13
+ function escapeHtml(s: string): string {
14
+ return s
15
+ .replaceAll("&", "&amp;")
16
+ .replaceAll("<", "&lt;")
17
+ .replaceAll(">", "&gt;")
18
+ .replaceAll('"', "&quot;")
19
+ .replaceAll("'", "&#39;");
20
+ }
21
+
22
+ /** color 值白名单:#hex 或 CSS 颜色名词汇(防 style 注入) */
23
+ function sanitizeColor(v: string): string | null {
24
+ return /^#[0-9a-fA-F]{3,8}$/.test(v) || /^[a-zA-Z]+$/.test(v) ? v : null;
25
+ }
26
+
27
+ /** size 值:纯数字 → px */
28
+ function sanitizeSize(v: string): string | null {
29
+ return /^\d+(\.\d+)?$/.test(v) ? `${v}px` : null;
30
+ }
31
+
32
+ /** font 值:字母数字/空格/中文/逗号(防引号逃逸与分号注入);导出供宿主整句字体落 DOM 前同口径过筛 */
33
+ export function sanitizeFont(v: string): string | null {
34
+ return /^[\w\u4e00-\u9fa5 ,]+$/.test(v) ? v : null;
35
+ }
36
+
37
+ /** 闭合标记集(`/b` `/i` `/u` `/color` `/font` `/size`);裸 `{color}` 这类也按闭合处理 */
38
+ const CLOSERS = new Set([
39
+ "/b",
40
+ "/i",
41
+ "/u",
42
+ "/color",
43
+ "/font",
44
+ "/size",
45
+ "color",
46
+ "font",
47
+ "size",
48
+ ]);
49
+
50
+ /** 字体名注册表接入点:宿主装配(合法名集合来自 Resources/Fonts);undefined = 未接入 */
51
+ let fontGate: StoryFontGate | undefined;
52
+
53
+ export interface StoryFontGate {
54
+ /** 合法字体名集合(文件名去扩展名;构建期检查用同一集合) */
55
+ readonly names: ReadonlySet<string>;
56
+ /** 未注册名回调(宿主接诊断输出;名字已按字符集白名单过筛,此处只报未注册) */
57
+ readonly onUnregistered?: (name: string) => void;
58
+ }
59
+
60
+ /**
61
+ * 装配/清除字体名注册表。未接入时 `{font=…}` 保持字符集白名单的旧行为(纯展示不校验);
62
+ * 接入后未注册名不上字体 span,文本照常渲染(回退默认字体),并回调 onUnregistered。
63
+ */
64
+ export function configureStoryFonts(gate: StoryFontGate | undefined): void {
65
+ fontGate = gate;
66
+ }
67
+
68
+ /** 渲染内联标记为安全 HTML(配 v-html 使用;所有文本内容均转义) */
69
+ export function renderInlineMarkup(text: string): string {
70
+ let html = "";
71
+ const stack: string[] = []; // 未闭合标记对应的闭合串
72
+ let i = 0;
73
+ while (i < text.length) {
74
+ const ch = text[i]!;
75
+ if (ch !== "{") {
76
+ html += escapeHtml(ch);
77
+ i += 1;
78
+ continue;
79
+ }
80
+ const end = text.indexOf("}", i);
81
+ if (end < 0) {
82
+ html += escapeHtml(text.slice(i));
83
+ break;
84
+ }
85
+ const content = text.slice(i + 1, end).trim();
86
+ i = end + 1;
87
+ if (content === "") continue;
88
+
89
+ if (CLOSERS.has(content)) {
90
+ const close = stack.pop();
91
+ if (close !== undefined) html += close;
92
+ continue;
93
+ }
94
+ if (content === "p" || content === "w" || content === "fast") continue; // 打字机阶段接管
95
+
96
+ if (content.startsWith("color=")) {
97
+ const color = sanitizeColor(content.slice(6).trim());
98
+ if (color !== null) {
99
+ stack.push("</span>");
100
+ html += `<span style="color:${color}">`;
101
+ }
102
+ continue;
103
+ }
104
+ if (content.startsWith("size=")) {
105
+ const size = sanitizeSize(content.slice(5).trim());
106
+ if (size !== null) {
107
+ stack.push("</span>");
108
+ html += `<span style="font-size:${size}">`;
109
+ }
110
+ continue;
111
+ }
112
+ if (content.startsWith("font=")) {
113
+ const font = sanitizeFont(content.slice(5).trim());
114
+ if (font !== null) {
115
+ // 注册表已接入时未注册名回退:不上字体 span,文本照常渲染(动态拼接名
116
+ // 无法静态检查,运行期档是它的唯一守门——编译期 fail-closed 只覆盖静态引用)
117
+ if (fontGate !== undefined && !fontGate.names.has(font)) {
118
+ fontGate.onUnregistered?.(font);
119
+ continue;
120
+ }
121
+ stack.push("</span>");
122
+ html += `<span style="font-family:'${font}'">`;
123
+ }
124
+ continue;
125
+ }
126
+ if (content === "b" || content === "i" || content === "u") {
127
+ stack.push(`</${content}>`);
128
+ html += `<${content}>`;
129
+ continue;
130
+ }
131
+ // 未知 {…}:变量插值已在核心层完成,这里按字面保留
132
+ html += escapeHtml(`{${content}}`);
133
+ }
134
+ while (stack.length > 0) html += stack.pop()!; // 未闭合标记容错补齐
135
+ return html;
136
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 对话框模板(挂载点模板的一例:语义骨架三挂点 + rootClass 皮肤)。
3
+ *
4
+ * 模板 = 对话框「语义骨架各挂点」的注册渲染函数(纯 TS 可测;作者纯 TS 创作与
5
+ * 未来编辑器可视化创作产出同构描述,装配到同一注册表)。
6
+ * 模板名由核心层解析三级优先级后写入 `__dialog_template`(say template >
7
+ * character screen > 全局默认),本表按名解析、未知名/null 回退默认。
8
+ * 性能硬约束:打字帧每帧重算——speaker/hint 输入不变时输出
9
+ * 为纯字符串派生(宿主 Vue diff 后不 patch);NVL 累积层按增量渲染约定保持宿主
10
+ * 固定骨架,不走模板全量重渲。统一渲染接缝(renderDialogueLine)即行渲染原语。
11
+ *
12
+ * 注册语义(注册 / 解析 / 默认回退)由通用 [`TemplateRegistry`] 承载——
13
+ * 本模块只定义对话挂载点的输入输出形状与内置模板。
14
+ */
15
+ import {
16
+ TemplateRegistry,
17
+ createTemplateRegistry,
18
+ } from "../templates/registry";
19
+ import { renderDialogueLine } from "./textView";
20
+
21
+ /** 模板输入:对话渲染状态投影(宿主从引擎状态/渲染态映射) */
22
+ export interface DialogueTemplateInput {
23
+ /** 插值后说话人(空 = 无说话人行) */
24
+ speaker: string;
25
+ /** 角色色(空 = 默认;内置模板经宿主 style 应用,自定义模板可自定消费) */
26
+ speakerColor: string;
27
+ /** 统一接缝产物:renderDialogueLine({ text, typed }).html(含打字前缀) */
28
+ lineHtml: string;
29
+ /** 对话等待推进指示(▼) */
30
+ canAdvance: boolean;
31
+ }
32
+
33
+ /** 模板输出:语义骨架各挂点内容(宿主骨架固定:speaker 行 / 正文 / 推进指示器) */
34
+ export interface DialogueTemplateView {
35
+ /** 对话框根皮肤类(宿主布局类叠加) */
36
+ rootClass: string;
37
+ /** 说话人行 HTML(空串 = 隐藏该行);经统一接缝转义 */
38
+ speakerHtml: string;
39
+ /** 正文 HTML(打字帧每帧变化) */
40
+ bodyHtml: string;
41
+ /** 推进指示器 HTML(空串 = 隐藏) */
42
+ hintHtml: string;
43
+ }
44
+
45
+ /** 对话层模板:吃投影出的对话状态,吐骨架三挂点内容。纯函数——打字帧每帧调用,不得有副作用 */
46
+ export type DialogueTemplateFn = (
47
+ input: DialogueTemplateInput,
48
+ ) => DialogueTemplateView;
49
+
50
+ /** 对话挂载点的模板注册表(通用注册表的具体化) */
51
+ export type DialogueTemplateRegistry = TemplateRegistry<
52
+ DialogueTemplateInput,
53
+ DialogueTemplateView
54
+ >;
55
+
56
+ /** 造一张空的对话层模板注册表;宿主在装配期向它 `register` 模板(本函数不装内建默认) */
57
+ export function createDialogueTemplateRegistry(): DialogueTemplateRegistry {
58
+ return createTemplateRegistry<DialogueTemplateInput, DialogueTemplateView>();
59
+ }
60
+
61
+ /** 内置 bubble 模板:参考视觉(speaker 行 + 统一接缝正文 + ▼ 推进) */
62
+ export const builtinBubbleTemplate: DialogueTemplateFn = (input) => ({
63
+ rootClass: "tpl-bubble",
64
+ speakerHtml:
65
+ input.speaker === ""
66
+ ? ""
67
+ : renderDialogueLine({ text: input.speaker }).html,
68
+ bodyHtml: input.lineHtml,
69
+ hintHtml: input.canAdvance ? "▼" : "",
70
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 统一文字渲染接缝(同一管线多容器):打字机前缀 / 整句 / NVL 行 / 历史条目 /
3
+ * 未来模板——全部经此一条管线(转义 + 内联标记 → HTML)。
4
+ * 容器(对话框 / NVL 累积层 / 历史面板 / 模板)只决定「放在哪、长什么样」,
5
+ * 不各自实现渲染——这是对话渲染的复用结论。
6
+ * 性能硬约束:**逐帧变化的只有打字前缀**;
7
+ * 静态行(NVL 已打完的行 / 历史条目)的 HTML 必须由调用方 memo(computed),
8
+ * 避免 O(全文) 每帧重建。
9
+ */
10
+ import { renderInlineMarkup } from "./inline";
11
+
12
+ /**
13
+ * 一行对话的渲染请求。`typed` 与 `text` 二选一即可——给了 `typed` 就按它渲染,
14
+ * 这是「打字中每帧变、打完不变」两种调用形态共用同一入口的方式。
15
+ */
16
+ export interface DialogueLineInput {
17
+ /** 原始标记文本(`{b}{color=#…}` 等,核心层透传) */
18
+ text: string;
19
+ /** 打字机可见前缀(打字中传入;完成态/静态省略) */
20
+ typed?: string;
21
+ }
22
+
23
+ /**
24
+ * 渲染产物:转义后的富文本 HTML。内容已安全,容器可直接 `innerHTML` 上屏;
25
+ * 输出刻意只有这一个字段——渲染接缝不掺布局决策。
26
+ */
27
+ export interface DialogueLineView {
28
+ /** 富文本 HTML(已全量转义;容器用 v-html / innerHTML 上屏) */
29
+ html: string;
30
+ }
31
+
32
+ /** 统一渲染:typed 优先(打字中),否则整段 text。模板系统的替换点也在这里。 */
33
+ export function renderDialogueLine(line: DialogueLineInput): DialogueLineView {
34
+ return { html: renderInlineMarkup(line.typed ?? line.text) };
35
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * 打字机二段式(可测纯逻辑,UI 层 rAF 驱动 tick)。
3
+ * - 打字流 = 原文剥离 {p}/{w}/{fast};**样式标记零宽**:不消耗打字时长、不计入可见字符,
4
+ * 且可见前缀**永不以半截标记结尾**——否则渲染层会把 `{color=#` 之类的残缺标记原样上屏
5
+ * (回归保护:打字中裸标记不得上屏)
6
+ * - tick(dt) 按 cps 推进已显示的可见字符数;遇停顿点停下等点击
7
+ * - click():停在 {p}/{w} → 越过停顿继续打字(passed-pause);未完成 → 瞬间完成(completed);
8
+ * 已完成 → no-op(UI 转 advance)
9
+ */
10
+
11
+ /** 打字流快照:流文本、停顿点坐标与可见字符总数 */
12
+ export interface TypingStream {
13
+ /** 打字流:原文去 {p}/{w}/{fast};样式标记整段保留(零宽,渲染层据此着色) */
14
+ stream: string;
15
+ /** 停顿点(可见字符坐标:流内标记零宽不计) */
16
+ pausePoints: number[];
17
+ /** 可见字符总数(流内标记不计) */
18
+ visibleTotal: number;
19
+ }
20
+
21
+ /** 标记分词:{p}/{w} 记停顿点、{fast} 删除、其余 {…} 标记保留在流内(零宽) */
22
+ export function tokenizeStream(text: string): TypingStream {
23
+ let stream = "";
24
+ let visibleTotal = 0;
25
+ const pausePoints: number[] = [];
26
+ let i = 0;
27
+ while (i < text.length) {
28
+ const ch = text[i]!;
29
+ if (ch !== "{") {
30
+ stream += ch;
31
+ visibleTotal += 1;
32
+ i += 1;
33
+ continue;
34
+ }
35
+ const end = text.indexOf("}", i);
36
+ if (end < 0) {
37
+ stream += text.slice(i); // 未闭合容错:尾部残缺标记按字面保留
38
+ break;
39
+ }
40
+ const content = text.slice(i + 1, end);
41
+ if (content === "p" || content === "w") {
42
+ pausePoints.push(visibleTotal); // 停顿发生在标记前的可见字符处
43
+ i = end + 1;
44
+ continue;
45
+ }
46
+ if (content === "fast") {
47
+ i = end + 1;
48
+ continue;
49
+ }
50
+ stream += text.slice(i, end + 1); // 样式标记零宽随流
51
+ i = end + 1;
52
+ }
53
+ return { stream, pausePoints, visibleTotal };
54
+ }
55
+
56
+ /**
57
+ * 一句话的打字进度机:按 cps 推进可见字符,遇停顿点停下等点击。
58
+ *
59
+ * 进度用「已显示的可见字符数」表示,不用流下标——样式标记零宽,不参与计时与停顿坐标。
60
+ * 用法:宿主每帧调 `tick(dt)`,用户点击调 `click()`,用 `visible` 取当前该上屏的文本前缀。
61
+ * 本类只管进度,不碰 DOM,也不自己计时。
62
+ */
63
+ export class Typewriter {
64
+ /**
65
+ * 打字进度:已显示的可见字符数,流内样式标记不算。
66
+ * `tick` 每帧按预算累加、`click` 瞬间置满,因此它只增不减,上界是 `visibleTotal`。
67
+ * `visible` 用它决定前缀取到哪里,`done` / `pausedAtMark` 也由它推导;达到上界即打完。
68
+ */
69
+ private shown = 0; // 已显示的可见字符数(流内标记不计)
70
+ /**
71
+ * 下一个未消费的停顿点在 `pausePoints` 中的下标。只有「正停在 {p}/{w} 上点击」
72
+ * (`click` 返回 passed-pause)会把它 +1,其余路径都不动它,所以它也只增不减。
73
+ * `currentPause()` 靠它取出当前停顿坐标;`pausePoints` 升序,于是「`shown` 是否已够到
74
+ * 该坐标」就是「是否停在停顿点上」的判定(`pausedAtMark`)。
75
+ */
76
+ private nextPause = 0; // 下一个未消费的停顿点(在 pausePoints 中的下标)
77
+ /** 停顿点的可见字符坐标(升序,构造时由分词结果一次算定;只读) */
78
+ private readonly pausePoints: number[];
79
+ /** 打字流:原文去 {p}/{w}/{fast},样式标记整段保留(见 `tokenizeStream`) */
80
+ private readonly stream: string;
81
+ /** 可见字符总数;`shown` 达到它即打完 */
82
+ private readonly visibleTotal: number;
83
+
84
+ /**
85
+ * @param text 原始富文本(含 {p}/{w}/{fast} 与样式标记),构造时就地分词
86
+ * @param cps 每秒推进的可见字符数;`setSpeed` 可在播放中调整
87
+ */
88
+ constructor(
89
+ text: string,
90
+ private cps: number,
91
+ ) {
92
+ const tokenized = tokenizeStream(text);
93
+ this.stream = tokenized.stream;
94
+ this.pausePoints = tokenized.pausePoints;
95
+ this.visibleTotal = tokenized.visibleTotal;
96
+ }
97
+
98
+ /** SetTextSpeed:玩家偏好即时调整当前句打字速度(非法值忽略,下限 1) */
99
+ setSpeed(cps: number): void {
100
+ if (typeof cps === "number" && Number.isFinite(cps)) {
101
+ this.cps = Math.max(1, cps);
102
+ }
103
+ }
104
+
105
+ /** 下一个未消费停顿点的可见坐标(无则 null) */
106
+ private currentPause(): number | null {
107
+ return this.nextPause < this.pausePoints.length
108
+ ? this.pausePoints[this.nextPause]!
109
+ : null;
110
+ }
111
+
112
+ /**
113
+ * 推进一帧:按 `cps × dtSeconds` 增加已显示字符数,但**不越过下一个停顿点**。
114
+ *
115
+ * 停在停顿点后本方法变为空转,直到 `click()` 消费掉该停顿点。已打完或正停在
116
+ * 停顿点时直接返回;`cps` 使本帧预算不足 1 个字符时也返回(等下一帧累积)。
117
+ */
118
+ tick(dtSeconds: number): void {
119
+ if (this.done || this.pausedAtMark) return;
120
+ const budget = Math.ceil(this.cps * dtSeconds);
121
+ if (budget <= 0) return; // cps=0 → 永不推进(点击兜底完成)
122
+ const pause = this.currentPause();
123
+ const limit = pause === null ? this.visibleTotal : pause;
124
+ this.shown = Math.min(this.shown + budget, limit);
125
+ }
126
+
127
+ /** 点击(二段式):停在 {p}/{w} → 越过停顿继续打字;未完成 → 瞬间完成;已完成 → no-op(UI 转 advance) */
128
+ click(): "passed-pause" | "completed" | "no-op" {
129
+ if (this.done) return "no-op";
130
+ if (this.pausedAtMark) {
131
+ this.nextPause += 1;
132
+ return "passed-pause";
133
+ }
134
+ this.shown = this.visibleTotal;
135
+ return "completed";
136
+ }
137
+
138
+ /** 是否已打完(可见字符全部显示;停在停顿点上时**不算**打完) */
139
+ get done(): boolean {
140
+ return this.shown >= this.visibleTotal;
141
+ }
142
+
143
+ /**
144
+ * 可见前缀:可见坐标 → 流前缀。途经的完整标记整段吞入(渲染层据此着色),
145
+ * 停在刚显示的可见字符处——因此前缀**永不以半截标记结尾**;打完时返回整条流(含收尾标记)。
146
+ */
147
+ get visible(): string {
148
+ if (this.shown >= this.visibleTotal) return this.stream;
149
+ let out = "";
150
+ let visibleCount = 0;
151
+ let i = 0;
152
+ while (i < this.stream.length && visibleCount < this.shown) {
153
+ const ch = this.stream[i]!;
154
+ if (ch !== "{") {
155
+ out += ch;
156
+ visibleCount += 1;
157
+ i += 1;
158
+ continue;
159
+ }
160
+ const end = this.stream.indexOf("}", i);
161
+ if (end < 0) {
162
+ out += this.stream.slice(i); // 理论不达:分词已做未闭合容错
163
+ break;
164
+ }
165
+ out += this.stream.slice(i, end + 1); // 标记零宽:整段吞入
166
+ i = end + 1;
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** 是否停在 {p}/{w} 段内停顿点(点击将越过停顿而非瞬间完成) */
172
+ get pausedAtMark(): boolean {
173
+ const pause = this.currentPause();
174
+ return pause !== null && this.shown >= pause;
175
+ }
176
+
177
+ /** 本条文本的可见字符总数(`shown` 的上界;进度条与「打完了吗」的分母) */
178
+ get total(): number {
179
+ return this.visibleTotal;
180
+ }
181
+ }
@@ -0,0 +1,7 @@
1
+ /** DOM 物化域出口:`RenderSpec` → Web 节点(ui 内唯一允许碰 DOM 的目录,见本目录文件头规则)。 */
2
+ export { createDomRenderPort, materialize } from "./materialize";
3
+ export {
4
+ createDomOverlaySurface,
5
+ type DomOverlaySurface,
6
+ type NarrativeMounts,
7
+ } from "./overlay-surface";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 本目录是 `packages/ui` 内**唯一**允许出现 DOM 类型与 DOM 全局的目录。
3
+ *
4
+ * 与 `packages/ui/src/host/index.ts` 同一口气的规则:其余目录一律只产出 `RenderSpec`;
5
+ * 需要碰 DOM 的地方经 `RenderPort` 收口到这里,别的平台(小程序)在同一位置换成自己的物化器。
6
+ */
7
+ import type {
8
+ RenderEvent,
9
+ RenderEventPatch,
10
+ RenderKind,
11
+ RenderPort,
12
+ RenderPropValue,
13
+ RenderSpec,
14
+ } from "../element/spec";
15
+
16
+ /** 中立词表 → Web 标签(`text` 落在 `div` 上:文本族的落地形态一直是 `div`) */
17
+ const KIND_TAG: Readonly<Record<RenderKind, string>> = {
18
+ box: "div",
19
+ text: "div",
20
+ image: "img",
21
+ media: "video",
22
+ input: "input",
23
+ slider: "input",
24
+ checkbox: "input",
25
+ action: "button",
26
+ };
27
+
28
+ /** 写属性:顺序即 `props` 的插入顺序(`crossOrigin` 须先于 `src`,见图像族渲染器) */
29
+ function applyProps(
30
+ node: HTMLElement,
31
+ props: Readonly<Record<string, RenderPropValue>>,
32
+ ): void {
33
+ const target = node as unknown as Record<string, RenderPropValue>;
34
+ for (const [key, value] of Object.entries(props)) {
35
+ target[key] = value;
36
+ }
37
+ }
38
+
39
+ function applyPatch(node: HTMLElement, patch: RenderEventPatch): void {
40
+ if (patch.style !== undefined) Object.assign(node.style, patch.style);
41
+ if (patch.props !== undefined) applyProps(node, patch.props);
42
+ }
43
+
44
+ function bindEvents(
45
+ node: HTMLElement,
46
+ events: readonly RenderEvent[] | undefined,
47
+ ): void {
48
+ for (const event of events ?? []) {
49
+ node.addEventListener(event.on, (domEvent) => {
50
+ if (event.stopPropagation === true) domEvent.stopPropagation();
51
+ const patch = event.handler?.();
52
+ if (patch !== undefined) applyPatch(node, patch);
53
+ });
54
+ }
55
+ }
56
+
57
+ /** 把一棵描述树物化成 DOM 节点(含整棵子树与事件) */
58
+ export function materialize(spec: RenderSpec): HTMLElement {
59
+ const node = document.createElement(KIND_TAG[spec.kind]);
60
+ if (spec.className !== undefined) node.className = spec.className;
61
+ if (spec.style !== undefined) Object.assign(node.style, spec.style);
62
+ if (spec.text !== undefined) node.textContent = spec.text;
63
+ if (spec.html !== undefined) node.innerHTML = spec.html;
64
+ if (spec.props !== undefined) applyProps(node, spec.props);
65
+ // 帧驱动定位锚(动画按 id 找节点;无 dataset 的环境自动跳过)
66
+ if (spec.id !== undefined && node.dataset !== undefined) {
67
+ node.dataset.lfId = spec.id;
68
+ }
69
+ for (const child of spec.children ?? []) node.appendChild(materialize(child));
70
+ bindEvents(node, spec.events);
71
+ return node;
72
+ }
73
+
74
+ /**
75
+ * 把一个元素当作渲染根,造出 Web 版渲染端口。
76
+ *
77
+ * 每次调用产出独立的端口对象(无状态包装),因此同一元素重复取端口不会串扰。
78
+ */
79
+ export function createDomRenderPort(element: HTMLElement): RenderPort {
80
+ return {
81
+ clear(): void {
82
+ element.innerHTML = "";
83
+ },
84
+ mount(spec: RenderSpec): void {
85
+ element.appendChild(materialize(spec));
86
+ },
87
+ };
88
+ }