@lingfan-engine/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +201 -0
  2. package/package.json +37 -3
  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,77 @@
1
+ /**
2
+ * 内建元素渲染器注册 —— **36 类型全覆盖**(唯一登记处)。
3
+ *
4
+ * 登记表是「类型名 → 渲染器」的完整映射,多个类型共用同一渲染器时并列书写,
5
+ * 便于一眼核对覆盖面;分组注释与渲染器文件的分族保持一致。
6
+ */
7
+ import type { ElementRegistry, ElementRenderer } from "../registry";
8
+ import { renderImage, renderImageButton, renderVideo } from "./media";
9
+ import {
10
+ renderButton,
11
+ renderCheckbox,
12
+ renderProgress,
13
+ renderSlider,
14
+ } from "./controls";
15
+ import {
16
+ renderBorder,
17
+ renderCanvas,
18
+ renderContainer,
19
+ renderGrid,
20
+ renderScroll,
21
+ } from "./container";
22
+ import { renderSeparator, renderSpacer } from "./decoration";
23
+ import { renderText } from "./text";
24
+
25
+ /** 内建渲染器注册(宿主可在其后覆盖任意类型;未注册类型由宿主 fail-closed 上报) */
26
+ export function registerBuiltinElementRenderers(
27
+ registry: ElementRegistry,
28
+ ): void {
29
+ const reg: Record<string, ElementRenderer> = {
30
+ // 文本族
31
+ text: renderText,
32
+ dialog: renderText,
33
+ narrator: renderText,
34
+ speaker: renderText,
35
+ // 交互族
36
+ button: renderButton,
37
+ choice: renderButton,
38
+ imagebutton: renderImageButton,
39
+ // 图像族
40
+ image: renderImage,
41
+ background: renderImage,
42
+ portrait: renderImage,
43
+ video: renderVideo,
44
+ // 容器族
45
+ panel: renderContainer,
46
+ frame: renderContainer,
47
+ window: renderContainer,
48
+ dialogbox: renderContainer,
49
+ choicebox: renderContainer,
50
+ infobox: renderContainer,
51
+ overlay: renderContainer,
52
+ popup: renderContainer,
53
+ vbox: renderContainer,
54
+ hbox: renderContainer,
55
+ grid: renderGrid,
56
+ stack: renderContainer,
57
+ stackpanel: renderContainer,
58
+ canvas: renderCanvas,
59
+ border: renderBorder,
60
+ // 滚动族
61
+ scroll: renderScroll,
62
+ scrollviewer: renderScroll,
63
+ viewport: renderScroll,
64
+ // 进度族
65
+ bar: renderProgress,
66
+ vbar: renderProgress,
67
+ progressbar: renderProgress,
68
+ slider: renderSlider,
69
+ checkbox: renderCheckbox,
70
+ // 间隔族
71
+ separator: renderSeparator,
72
+ spacer: renderSpacer,
73
+ };
74
+ for (const [type, renderer] of Object.entries(reg)) {
75
+ registry.register(type, renderer);
76
+ }
77
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 文本族渲染(text / dialog / narrator / speaker)。
3
+ *
4
+ * 四种类型共用同一渲染器:差异由属性(font/size/color/halign…)与 CSS 类名表达,
5
+ * 结构上都是「一个承载纯文本的块元素」。文本内容**不做转义/净化**——
6
+ * 文本族的富文本渲染由对话域负责;元素文本是纯文本,交给宿主原样落地。
7
+ */
8
+ import type { ElementRenderContext } from "../registry";
9
+ import type { RenderSpec } from "../spec";
10
+ import { bindInteraction } from "./bindings";
11
+ import { createRoot } from "./base";
12
+
13
+ export function renderText(ctx: ElementRenderContext): RenderSpec {
14
+ const spec = createRoot("text", ctx);
15
+ const text = ctx.element.props.text;
16
+ return bindInteraction(
17
+ typeof text === "string" ? { ...spec, text } : spec,
18
+ ctx,
19
+ );
20
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 元素资源解析缓存:`ResourcePort.resolve` 的**同步查表皮**(三宿主共用单一事实源)。
3
+ *
4
+ * 元素渲染是同步的(`renderElementTree` 直接产 DOM),而资源解析是异步的——
5
+ * 这个接缝把两者对上:同步查表 → 在途去重 → 命中即返回 URL;
6
+ * 未命中**只启动一次**异步解析,落地后回调宿主重渲染(整体重建语义,与 renderElementTree 一致);
7
+ * 失败保持替代文本(不伪造 URL,诊断归端口/宿主)。
8
+ *
9
+ * 缓存机制本身在资源域(`../resources`)只有一份;此处只把它对到元素渲染的同步契约上。
10
+ */
11
+ import { createResourceUrlCache } from "../resources";
12
+
13
+ /**
14
+ * 装配输入:一个异步解析函数 + 一个「落地了」的回调。
15
+ * 缓存不关心 URL 怎么来的——换掉 `resolve` 就换了取源方式(本地 / 远端 / 测试替身)。
16
+ */
17
+ export interface ElementResourceResolverOptions {
18
+ /** 资源解析(通常即 `resourcePort.resolve`) */
19
+ resolve: (path: string) => Promise<string>;
20
+ /** 解析落地后的回调(宿主据此重渲染元素层) */
21
+ onResolved: () => void;
22
+ }
23
+
24
+ /**
25
+ * 元素渲染侧看到的解析器:**同步**的一问一答。
26
+ * 只暴露这一个方法——在途去重、缓存、失败重试的细节都封在工厂里,
27
+ * 调用点(`renderElementTree`)不需要知道资源是异步拿到的。
28
+ */
29
+ export interface ElementResourceResolver {
30
+ /** 供 `renderElementTree` 的 `resolveResource` 使用;未就绪返回 `undefined`(显示替代文本) */
31
+ resolveForElement: (path: string) => string | undefined;
32
+ }
33
+
34
+ /**
35
+ * 把异步资源端口接到元素渲染的同步契约上。
36
+ *
37
+ * 未命中时启动一次解析并立即返回 `undefined`(这一帧显示替代文本),
38
+ * 落地后经 `onResolved` 让宿主重渲染;解析失败不缓存失败态,下次问还会再试。
39
+ */
40
+ export function createElementResourceResolver(
41
+ options: ElementResourceResolverOptions,
42
+ ): ElementResourceResolver {
43
+ const cache = createResourceUrlCache(options.resolve, {
44
+ onResolved: options.onResolved,
45
+ });
46
+ return {
47
+ resolveForElement(path: string): string | undefined {
48
+ const cached = cache.get(path);
49
+ if (cached !== undefined) return cached;
50
+ // 失败不粘滞:资源补上后仍可再试(错误归端口/宿主诊断,此处不重渲染)
51
+ void cache.resolve(path).catch(() => undefined);
52
+ return undefined;
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 渲染描述树(平台中立):渲染器不建节点,只产出这棵树;由宿主注入的渲染端口
3
+ * 物化成自己的节点模型(Web 侧实现见 `packages/ui/src/dom`)。
4
+ *
5
+ * 中立判据:本文件不出现 DOM 类型、DOM 全局与 DOM 标签名——节点种类用 `RenderKind`
6
+ * 词表。`slider` / `checkbox` 不与 `input` 合并成「`input` + type 字段」:
7
+ * `range` / `checkbox` 是 DOM 的 type 词表,留在描述树里等于把 DOM 词汇漏进中立面。
8
+ */
9
+
10
+ /** 节点种类:物化器负责映射到自己平台的标签/组件 */
11
+ export type RenderKind =
12
+ | "box"
13
+ | "text"
14
+ | "image"
15
+ | "media"
16
+ | "input"
17
+ | "slider"
18
+ | "checkbox"
19
+ | "action";
20
+
21
+ /** 写入节点的原生属性值(属性名沿用各民族平台的通用拼写:`src` / `alt` / `disabled` …) */
22
+ export type RenderPropValue = string | number | boolean;
23
+
24
+ /**
25
+ * 事件触发时对目标节点应用的改动。
26
+ *
27
+ * 视觉态交换(hover 进入/离开、点击切换选中)都走这条通道:
28
+ * 渲染器不需要拿到节点,就能表达「触发时改哪些样式/属性」。
29
+ */
30
+ export interface RenderEventPatch {
31
+ style?: Readonly<Record<string, string>>;
32
+ props?: Readonly<Record<string, RenderPropValue>>;
33
+ }
34
+
35
+ /** 事件绑定;`handler` 的返回值即触发时要应用的补丁(无返回值 = 不改节点) */
36
+ export interface RenderEvent {
37
+ on: "click" | "mouseenter" | "mouseleave";
38
+ handler?: () => RenderEventPatch | void;
39
+ /** 阻止冒泡(元素点击不应同时触发舞台推进) */
40
+ stopPropagation?: boolean;
41
+ }
42
+
43
+ /**
44
+ * 一棵节点的描述。字段与物化顺序一致:类名 → 样式 → 文本/富文本 → 属性 → 定位锚 →
45
+ * 子节点 → 事件。
46
+ *
47
+ * `html` 沿用「调用方负责转义」的既有约定(见对话文本视图),本树不做二次净化。
48
+ */
49
+ export interface RenderSpec {
50
+ kind: RenderKind;
51
+ /** 类名(物化器原样写入;元素渲染器在此放基类 + 类型类 + 作者 class) */
52
+ className?: string;
53
+ /** CSS 声明(camelCase 键,与元素的属性 → CSS 映射同源) */
54
+ style?: Readonly<Record<string, string>>;
55
+ /** 纯文本内容 */
56
+ text?: string;
57
+ /** 富文本内容(调用方负责转义) */
58
+ html?: string;
59
+ props?: Readonly<Record<string, RenderPropValue>>;
60
+ children?: readonly RenderSpec[];
61
+ events?: readonly RenderEvent[];
62
+ /** 定位锚(动画按元素 id 找节点;无锚点概念的宿主可忽略) */
63
+ id?: string;
64
+ }
65
+
66
+ /**
67
+ * 渲染端口(宿主注入):一对动作——清空渲染根、物化一棵描述树挂上去。
68
+ *
69
+ * 「整体重建」是元素渲染的既有语义(每帧全量给元素,不做增量对比),
70
+ * 因此端口只需这两条原语即可覆盖现状;增量更新由宿主内部决定。
71
+ */
72
+ export interface RenderPort {
73
+ /** 清空渲染根下的全部节点 */
74
+ clear(): void;
75
+ /** 物化一棵描述树并挂到渲染根下 */
76
+ mount(spec: RenderSpec): void;
77
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 元素属性 → CSS 映射(纯函数,可测)。
3
+ * 只做「属性名/值 → 样式声明」的机械映射:属性合法性在解析期已 fail-closed,
4
+ * 此处不重复校验;**未覆盖的属性一律不出现在结果中**。
5
+ *
6
+ * 「未覆盖」分两类:
7
+ * ① **由渲染器消费**而非 CSS 的属性(`text` / `source` / `orientation` / `checked` / `nav` / `cmd` / Grid 轨道…)——正常分工;
8
+ * ② **已声明但当前无 CSS 语义的属性**(`valign` / `xalign` / `yalign` / `xoffset` / `yoffset` / `xanchor` / `yanchor`)——
9
+ * 对本映射写了不生效;清单单一事实源 = `@lingfan-engine/editor` 的 `UNIMPLEMENTED_ELEMENT_ATTRS`
10
+ * (编辑器不再下发这些控件,并对已存在的写出 warning;三面对齐互锁测试守住差集)。
11
+ * 例外:`order` 不是死属性——引擎装载期在用(z 兜底:packages/core/src/data/element.ts:157、
12
+ * runtime/internal/frame-stack.ts:62 消费),只是不经本 CSS 映射,故不在本清单。
13
+ *
14
+ * 定位语义:`x`/`y` 支持百分比(若换算为像素 Margin 就锁死了尺寸,
15
+ * DOM 侧直接交给 CSS,`left: 50%` 天然按父容器尺寸解析)。
16
+ */
17
+
18
+ /** 数值 → css 长度(px);字符串原样(支持 `50%` / `2em` / `center` 等) */
19
+ function len(value: unknown): string | undefined {
20
+ if (typeof value === "number" && Number.isFinite(value)) return `${value}px`;
21
+ if (typeof value === "string" && value !== "") return value;
22
+ return undefined;
23
+ }
24
+
25
+ /** 数值原样(opacity / zIndex / scale 等纯数字) */
26
+ function num(value: unknown): string | undefined {
27
+ return typeof value === "number" && Number.isFinite(value)
28
+ ? String(value)
29
+ : undefined;
30
+ }
31
+
32
+ /** 布尔 → 显隐(`visible=false` 等价 `display:none`) */
33
+ function bool(value: unknown): boolean | undefined {
34
+ return typeof value === "boolean" ? value : undefined;
35
+ }
36
+
37
+ /** 首个子属性值(多键别名:source/src/path、color/fontColor/textColor) */
38
+ function pick(props: Record<string, unknown>, ...keys: string[]): unknown {
39
+ for (const key of keys) {
40
+ const value = props[key];
41
+ if (value !== undefined) return value;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ /**
47
+ * 属性 → CSS 声明表。返回对象可直接 `Object.assign(el.style, table)`。
48
+ * 未实现/不适用的属性不出现在结果中。
49
+ */
50
+ export function elementStyle(
51
+ props: Record<string, unknown>,
52
+ ): Record<string, string> {
53
+ const out: Record<string, string> = {};
54
+ const set = (key: string, value: string | undefined): void => {
55
+ if (value !== undefined) out[key] = value;
56
+ };
57
+
58
+ // —— 定位(x/y/right/bottom 任一存在即绝对定位;百分比值原样交给 CSS)——
59
+ const x = len(props.x);
60
+ const y = len(props.y);
61
+ const right = len(props.right);
62
+ const bottom = len(props.bottom);
63
+ if (
64
+ x !== undefined ||
65
+ y !== undefined ||
66
+ right !== undefined ||
67
+ bottom !== undefined
68
+ ) {
69
+ set("position", "absolute");
70
+ if (x !== undefined) set("left", x);
71
+ if (y !== undefined) set("top", y);
72
+ if (right !== undefined) set("right", right);
73
+ if (bottom !== undefined) set("bottom", bottom);
74
+ }
75
+ // align 是 halign 的安全网:显式 textAlign/halign 优先
76
+ set(
77
+ "textAlign",
78
+ len(props.textAlign) ?? len(props.halign) ?? len(props.align),
79
+ );
80
+
81
+ // —— 尺寸 ——
82
+ set("width", len(props.width));
83
+ set("height", len(props.height));
84
+ set("minWidth", len(props.minWidth));
85
+ set("minHeight", len(props.minHeight));
86
+ set("maxWidth", len(props.maxWidth));
87
+ set("maxHeight", len(props.maxHeight));
88
+
89
+ // —— 盒模型 ——
90
+ set("margin", len(props.margin));
91
+ set("padding", len(props.padding));
92
+
93
+ // —— 外观 ——
94
+ set("opacity", num(props.opacity));
95
+ set("zIndex", num(props.zindex));
96
+ set("cursor", len(props.cursor));
97
+ const visible = bool(props.visible);
98
+ if (visible === false) set("display", "none");
99
+ if (bool(props.clipToBounds) === true) set("overflow", "hidden");
100
+
101
+ // —— 变换 ——
102
+ const transforms: string[] = [];
103
+ const rotation = num(props.rotation);
104
+ if (rotation !== undefined) transforms.push(`rotate(${rotation}deg)`);
105
+ const scale = num(props.scale);
106
+ if (scale !== undefined) transforms.push(`scale(${scale})`);
107
+ else {
108
+ const scaleX = num(props.scaleX);
109
+ const scaleY = num(props.scaleY);
110
+ if (scaleX !== undefined || scaleY !== undefined) {
111
+ transforms.push(`scale(${scaleX ?? "1"}, ${scaleY ?? "1"})`);
112
+ }
113
+ }
114
+ if (transforms.length > 0) set("transform", transforms.join(" "));
115
+
116
+ // —— 边框 ——
117
+ set("borderRadius", len(props.cornerRadius));
118
+ const borderThickness = len(props.borderThickness);
119
+ const borderColor = len(props.borderColor) ?? len(props.borderBrush);
120
+ if (borderThickness !== undefined || borderColor !== undefined) {
121
+ set(
122
+ "border",
123
+ `${borderThickness ?? "1px"} solid ${borderColor ?? "currentColor"}`,
124
+ );
125
+ }
126
+
127
+ // —— 文本 ——
128
+ // size = 文本类 fontSize 别名
129
+ set("fontSize", len(props.fontSize) ?? len(props.size));
130
+ set("fontFamily", len(props.font));
131
+ const color = pick(props, "color", "fontColor", "textColor");
132
+ set("color", typeof color === "string" ? color : undefined);
133
+
134
+ return out;
135
+ }
136
+
137
+ /** 内容/样式类属性(不进 CSS,由渲染器消费) */
138
+ export function elementClassName(props: Record<string, unknown>): string {
139
+ // `style` 是 `class` 的别名
140
+ const raw = pick(props, "class", "style");
141
+ return typeof raw === "string" ? raw : "";
142
+ }
143
+
144
+ /** 图片类元素的资源路径(source 优先,src/path 兜底) */
145
+ export function elementSource(
146
+ props: Record<string, unknown>,
147
+ ): string | undefined {
148
+ const raw = pick(props, "source", "src", "path");
149
+ return typeof raw === "string" && raw !== "" ? raw : undefined;
150
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 宿主表现的行业默认节律(具名导出,**不承担「统一默认值」职责**)。
3
+ *
4
+ * 两个参考宿主的实际取值并不相同:Vue 参考宿主用 1500/2500/3000 ms 与各自的偏好速度,
5
+ * 原生 DOM 模板宿主用 2600 ms 与 30 cps。所以这里只给「同一件事的默认叫什么名字」,
6
+ * 由各自宿主把自己原本的字面量作为参数传进来——同源的是**声明**,不是取值。
7
+ */
8
+
9
+ /** 文字推进的默认速度(可见字符/秒):故事未声明 `text_typewriter` 速度时的兜底 */
10
+ export const DEFAULT_TEXT_CPS = 30;
11
+
12
+ /** 提示条默认驻留时长(毫秒) */
13
+ export const DEFAULT_TOAST_MS = 2600;
14
+
15
+ /** 通知默认驻留时长(毫秒):`notify` 未自带 duration 时的兜底,最长一档 */
16
+ export const DEFAULT_NOTIFY_MS = 3000;
17
+
18
+ /** 操作反馈轻提示默认驻留时长(毫秒):存/读按钮等即时反馈 */
19
+ export const DEFAULT_ACTION_TOAST_MS = 1500;
20
+
21
+ /** 读档完成提示默认驻留时长(毫秒):文案含状态说明,比即时反馈多留一档 */
22
+ export const DEFAULT_LOAD_TOAST_MS = 2500;
23
+
24
+ /** 回溯轻提示默认驻留时长(毫秒):与即时反馈同档 */
25
+ export const DEFAULT_ROLLBACK_NOTICE_MS = 1500;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 帧驱动表现:把引擎里的**动画描述**逐帧插值成 DOM 写入。
3
+ *
4
+ * 引擎只写描述(离散、进快照),逐帧的插值与 DOM 写入发生在宿主侧——本模块就是那一步的
5
+ * 唯一实现。插值数学一律取自展示域既有纯函数(`interpolateAnimation` / `transitionOpacity` /
6
+ * `shakeOffset`),此处不新增第二份算式。
7
+ *
8
+ * 三段共用一个 `dt`,顺序固定:元素动画 → 全屏转场 → 屏幕震动。
9
+ *
10
+ * 本模块**不认识引擎实例**:它用到的那几件事(当前动画队列、转场与震动描述、三个播毕交回)
11
+ * 全部以 `EffectSource` 端口注入。于是每处引擎调用都变成宿主那侧的一行接线,而时机判定、
12
+ * 累计量与收尾顺序只留在这一处。
13
+ *
14
+ * DOM 写入同样经注入:
15
+ * - `onAnimatedProperty(target, property, value)` 按元素 id 把数值属性写进该节点
16
+ * (节点解析与写入都在宿主侧,本模块既不持有也不查询节点);
17
+ * - `onTransitionFrame(opacity)` / `onTransitionDone()` 与 `onShakeFrame(x, y)` / `onShakeDone()`
18
+ * 成对出现:宿主在 `*Frame` 里写遮罩与位移,在 `*Done` 里收尾(隐藏遮罩、位移归位)。
19
+ *
20
+ * 元素动画**必须交回引擎**(`source.animationFinished`):否则动画队列只增不减(长会话泄漏),
21
+ * 且元素永远到不了终值。
22
+ *
23
+ * 「节点在不在」由宿主在 `on*` 里自行兜底(如遮罩元素缺失时直接丢弃该帧):本模块不做
24
+ * 存在性判断,否则同一份时机判定会被各宿主的 DOM 结构差异撕成多份。
25
+ *
26
+ * 逐帧读 `animations()` 而非构造期取一次:动画由故事命令在任意时刻建立。
27
+ */
28
+
29
+ import {
30
+ interpolateAnimation,
31
+ shakeOffset,
32
+ transitionOpacity,
33
+ } from "../element";
34
+ import type { AnimationSpec } from "@lingfan-engine/core";
35
+
36
+ /**
37
+ * 元素动画的可写样式声明:只有故事语言里出现的那五个数值属性。
38
+ * 用对象而不是直接写 `node.style`,是为了让「属性 → CSS」这层映射留在本模块,
39
+ * 而把唯一的写动作留在宿主(`Object.assign(node.style, …)`)。
40
+ */
41
+ export interface AnimatedStyle {
42
+ left?: string;
43
+ top?: string;
44
+ opacity?: string;
45
+ transform?: string;
46
+ }
47
+
48
+ /**
49
+ * 数值属性 → CSS 声明。
50
+ *
51
+ * `x`/`y` 走 `left`/`top`(相对定位位移),`rotation`/`scale` 都落在 `transform` 上——
52
+ * 同一时刻只有一个 transform 生效,这是元素样式既有的口径(作者若要叠加请自己写 `style`)。
53
+ * 未识别的属性返回 null:宿主据此跳过写入,而不是写一个空声明把已有样式抹掉。
54
+ */
55
+ export function resolveAnimatedStyle(
56
+ property: string,
57
+ value: number,
58
+ ): AnimatedStyle | null {
59
+ if (property === "x") return { left: `${value}px` };
60
+ if (property === "y") return { top: `${value}px` };
61
+ if (property === "opacity") return { opacity: String(value) };
62
+ if (property === "rotation") return { transform: `rotate(${value}deg)` };
63
+ if (property === "scale") return { transform: `scale(${value})` };
64
+ return null;
65
+ }
66
+
67
+ /** 引擎侧事实与交回动作:三个播毕交回是动画队列得以继续推进的唯一出口 */
68
+ export interface EffectSource {
69
+ /** 当前待播的元素动画队列 */
70
+ animations(): readonly AnimationSpec[];
71
+ /** 全屏转场的启动键(未在转场时为 undefined) */
72
+ transition(): { duration: number } | undefined;
73
+ /** 屏幕震动的启动键(未在震动时为 undefined) */
74
+ shake(): { intensity: number; duration: number } | undefined;
75
+ /** 元素动画播毕:交回引擎,元素才会落到终值 */
76
+ animationFinished(seq: number): void;
77
+ /** 全屏转场播毕:交回引擎,转场命令才会继续 */
78
+ transitionFinished(): void;
79
+ /** 屏幕震动播毕:交回引擎,震动命令才会继续 */
80
+ shakeFinished(): void;
81
+ }
82
+
83
+ /**
84
+ * 宿主触点(注入端口):本模块只做插值与时机判定,节点解析与 DOM 读写全由宿主实现。
85
+ * 本模块只交出 `target`(元素 id)——不持有节点、不查节点、不读样式、不写样式。
86
+ */
87
+ export interface AnimationHost {
88
+ /**
89
+ * 数值属性 → CSS 写入:宿主按 `target` 自行解析动画节点(元素渲染器写在
90
+ * `data-lf-id` 上的定位锚),映射口径用 `resolveAnimatedStyle`;节点不存在时宿主忽略该帧。
91
+ */
92
+ onAnimatedProperty(target: string, property: string, value: number): void;
93
+ }
94
+
95
+ /** 全屏转场与屏幕震动的宿主触点(每帧回调先于播毕回调) */
96
+ export interface EffectSurfaceHost {
97
+ /** 转场遮罩的一帧:写入不透明度(显示时机由宿主决定) */
98
+ onTransitionFrame(opacity: number): void;
99
+ /** 转场播毕:宿主收尾(隐藏遮罩) */
100
+ onTransitionDone(): void;
101
+ /** 屏幕震动的一帧:对舞台根施加偏移 */
102
+ onShakeFrame(x: number, y: number): void;
103
+ /** 震动播毕:宿主归位 */
104
+ onShakeDone(): void;
105
+ }
106
+
107
+ /** 逐帧驱动函数的输入:三段各自的事实来源与 DOM 触点 */
108
+ export interface VisualEffectsOptions {
109
+ /** 引擎侧事实与播毕交回 */
110
+ source: EffectSource;
111
+ animation: AnimationHost;
112
+ surface: EffectSurfaceHost;
113
+ }
114
+
115
+ /**
116
+ * 创建一个逐帧驱动函数:宿主每帧调用一次,传入距上一帧的秒数。
117
+ *
118
+ * 累计量(各动画已播秒数、转场已播秒数、震动时钟)都在闭包里,每实例一份——
119
+ * 放在模块级会让同一页面的两个舞台互相串扰。
120
+ */
121
+ export function createVisualEffects(
122
+ options: VisualEffectsOptions,
123
+ ): (dt: number) => void {
124
+ const { source, animation, surface } = options;
125
+ /** seq → 已播秒数 */
126
+ const animatedElapsed = new Map<number, number>();
127
+ let transitionElapsed = 0;
128
+ let shakeClock = 0;
129
+
130
+ return (dt: number): void => {
131
+ // ① 元素动画:累计 elapsed → 插值写 DOM → 播毕交回引擎(终值写回元素属性)
132
+ const animations = source.animations();
133
+ if (animations.length > 0) {
134
+ for (const spec of animations) {
135
+ const elapsed = (animatedElapsed.get(spec.seq) ?? 0) + dt;
136
+ animatedElapsed.set(spec.seq, elapsed);
137
+ const { value, done } = interpolateAnimation(spec, elapsed);
138
+ animation.onAnimatedProperty(spec.target, spec.property, value);
139
+ if (done) {
140
+ animatedElapsed.delete(spec.seq);
141
+ source.animationFinished(spec.seq);
142
+ }
143
+ }
144
+ }
145
+
146
+ // ② 全屏转场:读启动键按进度改遮罩,播毕清除
147
+ const transition = source.transition();
148
+ if (transition != null) {
149
+ transitionElapsed += dt;
150
+ const progress =
151
+ transition.duration > 0 ? transitionElapsed / transition.duration : 1;
152
+ surface.onTransitionFrame(transitionOpacity(progress));
153
+ if (progress >= 1) {
154
+ transitionElapsed = 0;
155
+ surface.onTransitionDone();
156
+ source.transitionFinished();
157
+ }
158
+ }
159
+
160
+ // ③ 屏幕震动:对舞台根施加衰减偏移,播毕归位
161
+ const shake = source.shake();
162
+ if (shake != null) {
163
+ shakeClock += dt;
164
+ const progress = shake.duration > 0 ? shakeClock / shake.duration : 1;
165
+ const { x, y } = shakeOffset(shake.intensity, progress, shakeClock);
166
+ surface.onShakeFrame(x, y);
167
+ if (progress >= 1) {
168
+ shakeClock = 0;
169
+ surface.onShakeDone();
170
+ source.shakeFinished();
171
+ }
172
+ }
173
+ };
174
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 帧循环:把「每帧要推进的事情」编排成一次连续续帧的循环。
3
+ *
4
+ * 推进什么由注入的 `onTick` 决定,本模块只管排帧、算 `dt` 与三种收尾:
5
+ * - `start()` 幂等;`stop()` 不可逆(卸载收尾);`setRunning()` 是运行中的暂停/恢复,
6
+ * 恢复时重置时间基准,暂停的时长不会被算进下一帧的 `dt`。
7
+ * - 帧源(`requestAnimationFrame` 或宿主的等价物)由外部注入——本模块不认平台 API,
8
+ * 也不自己计时:`dt` 由帧源给的时间戳算出。
9
+ * - 首帧没有上一帧可比,`dt` 记 0:否则开场会凭空多推进一段时间。
10
+ */
11
+
12
+ /** 帧源:宿主把平台的「下一帧」能力包成这两个方法 */
13
+ export interface FrameSource {
14
+ /** 申请下一帧;回调收到帧源自己的时间原点(毫秒) */
15
+ request(callback: (now: number) => void): number;
16
+ /** 取消已申请的帧(句柄来自 `request` 的返回值) */
17
+ cancel(handle: number): void;
18
+ }
19
+
20
+ /** 帧循环的输入:帧源 + 每帧要推进的一件事 */
21
+ export interface FrameLoopOptions {
22
+ frame: FrameSource;
23
+ /** 推进一帧;`dtSeconds` 是距上一帧的秒数(首帧为 0) */
24
+ onTick(dtSeconds: number): void;
25
+ }
26
+
27
+ /**
28
+ * 帧循环句柄:`start()` 后续帧自动申请,直到 `stop()`(不可逆)或 `setRunning(false)`。
29
+ *
30
+ * 句柄只暴露这三个开关,推进顺序由装配方的 `onTick` 固定,循环本身不猜任何业务。
31
+ */
32
+ export interface FrameLoop {
33
+ /** 启动循环:已在运行或已卸载时为空操作 */
34
+ start(): void;
35
+ /** 不可逆地停帧并取消已排定的帧(卸载时调用) */
36
+ stop(): void;
37
+ /** 运行中的暂停/恢复;恢复时会重置时间基准 */
38
+ setRunning(running: boolean): void;
39
+ }
40
+
41
+ /**
42
+ * 创建帧循环:帧源与每帧动作全部由外部注入,故本模块可被任何渲染宿主复用
43
+ * (覆盖层与各宿主的 `startHostFrameLoop` 都建在它之上)。
44
+ */
45
+ export function createFrameLoop(options: FrameLoopOptions): FrameLoop {
46
+ let handle = 0;
47
+ let last = 0;
48
+ let running = false;
49
+ let disposed = false;
50
+
51
+ const frame = (now: number): void => {
52
+ if (!running) return;
53
+ const dt = last > 0 ? (now - last) / 1000 : 0;
54
+ last = now;
55
+
56
+ options.onTick(dt);
57
+
58
+ // `stop()` 可能发生在帧内(如加载失败):那时不再续帧
59
+ if (running) handle = options.frame.request(frame);
60
+ };
61
+
62
+ return {
63
+ start(): void {
64
+ if (running || disposed) return;
65
+ running = true;
66
+ handle = options.frame.request(frame);
67
+ },
68
+ stop(): void {
69
+ if (disposed) return;
70
+ disposed = true;
71
+ running = false;
72
+ if (handle !== 0) options.frame.cancel(handle);
73
+ handle = 0;
74
+ last = 0;
75
+ },
76
+ setRunning(next: boolean): void {
77
+ if (disposed || next === running) return;
78
+ running = next;
79
+ // 暂停的时长不算进下一帧:恢复时丢掉上一次的基准
80
+ last = 0;
81
+ if (running) handle = options.frame.request(frame);
82
+ },
83
+ };
84
+ }