@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.
- package/LICENSE +201 -0
- package/package.json +38 -4
- package/src/audio/audio.ts +331 -0
- package/src/audio/index.ts +11 -0
- package/src/choices/index.ts +10 -0
- package/src/choices/templates.ts +74 -0
- package/src/commands/index.ts +6 -0
- package/src/commands/registry.ts +54 -0
- package/src/dialogue/index.ts +24 -0
- package/src/dialogue/inline.ts +136 -0
- package/src/dialogue/templates.ts +70 -0
- package/src/dialogue/textView.ts +35 -0
- package/src/dialogue/typewriter.ts +181 -0
- package/src/dom/index.ts +7 -0
- package/src/dom/materialize.ts +88 -0
- package/src/dom/overlay-surface.ts +278 -0
- package/src/element/animation.ts +141 -0
- package/src/element/index.ts +38 -0
- package/src/element/interaction.ts +123 -0
- package/src/element/registry.ts +69 -0
- package/src/element/render.ts +77 -0
- package/src/element/renderers/base.ts +56 -0
- package/src/element/renderers/bindings.ts +178 -0
- package/src/element/renderers/container.ts +141 -0
- package/src/element/renderers/controls.ts +80 -0
- package/src/element/renderers/decoration.ts +28 -0
- package/src/element/renderers/index.ts +5 -0
- package/src/element/renderers/media.ts +71 -0
- package/src/element/renderers/register.ts +77 -0
- package/src/element/renderers/text.ts +20 -0
- package/src/element/resource.ts +55 -0
- package/src/element/spec.ts +77 -0
- package/src/element/style.ts +150 -0
- package/src/host/duration.ts +25 -0
- package/src/host/effects.ts +174 -0
- package/src/host/frame-loop.ts +84 -0
- package/src/host/index.ts +47 -0
- package/src/host/layer-view.ts +74 -0
- package/src/host/state-view.ts +190 -0
- package/src/index.ts +161 -0
- package/src/input/index.ts +11 -0
- package/src/input/scope.ts +100 -0
- package/src/minigame/index.ts +2 -0
- package/src/minigame/registry.ts +35 -0
- package/src/notify/index.ts +11 -0
- package/src/notify/templates.ts +60 -0
- package/src/overlay/choices-view.ts +76 -0
- package/src/overlay/create.ts +254 -0
- package/src/overlay/dialogue-view.ts +72 -0
- package/src/overlay/element-tree.ts +109 -0
- package/src/overlay/index.ts +16 -0
- package/src/overlay/layer-z.ts +73 -0
- package/src/overlay/notify-view.ts +32 -0
- package/src/overlay/projector.ts +286 -0
- package/src/overlay/surface.ts +125 -0
- package/src/overlay/types.ts +124 -0
- package/src/resources/index.ts +10 -0
- package/src/resources/urlCache.ts +79 -0
- package/src/templates/index.ts +7 -0
- package/src/templates/registry.ts +79 -0
- package/src/video/index.ts +6 -0
- package/src/video/videoView.ts +122 -0
- package/README.md +0 -3
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 覆盖层端口的 DOM 实现:把每层视图模型落成真实的 `HTMLElement` 树。
|
|
3
|
+
*
|
|
4
|
+
* **本目录是 `packages/ui` 内唯一允许出现 DOM 类型与 DOM 全局的地方**(见 `dom/index.ts`
|
|
5
|
+
* 的文件头规则)。层骨架、类名、标签形状(`<p>` / `<span>` / `<form>`)、`pointer-events`
|
|
6
|
+
* 策略与输入焦点全都归这里——中立核心只给模型。
|
|
7
|
+
*
|
|
8
|
+
* 事件策略(嵌入场景的关键):**层自身恒不吃事件**(`pointer-events: none`),层里的
|
|
9
|
+
* 实际控件再逐个恢复点击(`clickable`)。于是层内空白区透传给宿主游戏——对话框铺满
|
|
10
|
+
* 视口时,玩家仍能点击对话框之外的地方。
|
|
11
|
+
*
|
|
12
|
+
* 接管层(`lf-takeover`)与转场遮罩(`lf-transition`)是**宿主自己的层**:中立核心从不
|
|
13
|
+
* 驱动它们,这里只按 `LayerZTable` 建出来并交给 `nodes`,宿主往里面挂自己的内容。
|
|
14
|
+
*/
|
|
15
|
+
import { DEFAULT_LAYER_Z, resolveInstanceZ } from "@lingfan-engine/core";
|
|
16
|
+
import type { LayerZTable } from "@lingfan-engine/core";
|
|
17
|
+
import type { FrameSource } from "../host/frame-loop";
|
|
18
|
+
import type {
|
|
19
|
+
ChoicesLayerView,
|
|
20
|
+
DialogueLayerView,
|
|
21
|
+
NotifyLayerItem,
|
|
22
|
+
OverlayLayerId,
|
|
23
|
+
OverlaySurface,
|
|
24
|
+
} from "../overlay/surface";
|
|
25
|
+
import { createDomRenderPort } from "./materialize";
|
|
26
|
+
|
|
27
|
+
/** 覆盖层挂载点(Web 侧宿主落地形态;中立契约见 `OverlaySurface`) */
|
|
28
|
+
export interface NarrativeMounts {
|
|
29
|
+
/** 覆盖层根 */
|
|
30
|
+
root: HTMLElement;
|
|
31
|
+
/** 舞台层:背景 / 立绘 */
|
|
32
|
+
stage: HTMLElement;
|
|
33
|
+
/** 元素层:舞台之上的空间层(元素树渲染目标) */
|
|
34
|
+
elementLayer: HTMLElement;
|
|
35
|
+
/** 对话层(含打字机) */
|
|
36
|
+
dialogue: HTMLElement;
|
|
37
|
+
/** 选择层(菜单 / 输入) */
|
|
38
|
+
choices: HTMLElement;
|
|
39
|
+
/** 通知层(toast) */
|
|
40
|
+
overlay: HTMLElement;
|
|
41
|
+
/** 外部系统接管层(小游戏 / 玩法系统) */
|
|
42
|
+
takeover: HTMLElement;
|
|
43
|
+
/** 全屏转场遮罩 */
|
|
44
|
+
transition: HTMLElement;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** DOM 实现:中立端口 + Web 宿主可直取的层节点 */
|
|
48
|
+
export interface DomOverlaySurface extends OverlaySurface {
|
|
49
|
+
/** 层节点表(宿主可进一步定制,如往 takeover 里挂自己的 iframe/canvas) */
|
|
50
|
+
readonly nodes: NarrativeMounts;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 建一个铺满父容器的层(层自身不吃事件) */
|
|
54
|
+
function layer(parent: HTMLElement, className: string, z: number): HTMLElement {
|
|
55
|
+
const node = parent.ownerDocument.createElement("div");
|
|
56
|
+
node.className = className;
|
|
57
|
+
node.style.position = "absolute";
|
|
58
|
+
node.style.inset = "0";
|
|
59
|
+
node.style.zIndex = String(z);
|
|
60
|
+
node.style.pointerEvents = "none";
|
|
61
|
+
parent.appendChild(node);
|
|
62
|
+
return node;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 让指定控件恢复点击(层自身透明,故必须逐控件开启) */
|
|
66
|
+
function clickable<T extends HTMLElement>(node: T): T {
|
|
67
|
+
node.style.pointerEvents = "auto";
|
|
68
|
+
return node;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 造一个以窗口 `requestAnimationFrame` 为底的帧源。
|
|
73
|
+
*
|
|
74
|
+
* 无 rAF 的环境(测试 / 降级)下 `request` 返回 0 且**永不回调**:帧循环因此静默不启,
|
|
75
|
+
* 功能仍可用(整句直出、按钮照常工作),只是打字机不走帧。
|
|
76
|
+
*
|
|
77
|
+
* 首次 `request` 用建端口时读到的窗口,其后每次重读——沿用原覆盖层「启动读一次、
|
|
78
|
+
* 之后每帧重读」的语义(宿主换文档时仍取当帧的窗口)。
|
|
79
|
+
*/
|
|
80
|
+
function createWindowFrameSource(container: HTMLElement): FrameSource {
|
|
81
|
+
const first = container.ownerDocument.defaultView;
|
|
82
|
+
let started = false;
|
|
83
|
+
return {
|
|
84
|
+
request(callback: (now: number) => void): number {
|
|
85
|
+
const view = started ? container.ownerDocument.defaultView : first;
|
|
86
|
+
started = true;
|
|
87
|
+
if (view === null || view === undefined) return 0;
|
|
88
|
+
return view.requestAnimationFrame(callback);
|
|
89
|
+
},
|
|
90
|
+
cancel(handle: number): void {
|
|
91
|
+
if (handle === 0) return;
|
|
92
|
+
container.ownerDocument.defaultView?.cancelAnimationFrame(handle);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 装配一个 DOM 覆盖层表面:按 `LayerZTable` 在父容器内建出各层,并交出端口。
|
|
99
|
+
*
|
|
100
|
+
* 创建顺序即 DOM 叠放顺序(后建的默认盖在先建的之上),z 值显式写在层样式里,因此
|
|
101
|
+
* 这里不依赖创建顺序表达层级。`takeover` 与 `choices` 初始不占位(外部系统挂载、
|
|
102
|
+
* 或进入选择等待时才显示)。`container` 只用于建层与取窗口,随后不再被引用。
|
|
103
|
+
*/
|
|
104
|
+
export function createDomOverlaySurface(
|
|
105
|
+
container: HTMLElement,
|
|
106
|
+
layerZ: Partial<LayerZTable> = {},
|
|
107
|
+
): DomOverlaySurface {
|
|
108
|
+
// 入参是「工程覆盖」而非完整表:与内建默认合并后再逐层解析(与旧 mounts.ts 同口径)。
|
|
109
|
+
const table: LayerZTable = { ...DEFAULT_LAYER_Z, ...layerZ };
|
|
110
|
+
const root = layer(container, "lf-overlay", 0);
|
|
111
|
+
const stage = layer(
|
|
112
|
+
root,
|
|
113
|
+
"lf-stage",
|
|
114
|
+
resolveInstanceZ("stage", undefined, table),
|
|
115
|
+
);
|
|
116
|
+
const elementLayer = layer(stage, "lf-element-layer", 0);
|
|
117
|
+
const transition = layer(
|
|
118
|
+
root,
|
|
119
|
+
"lf-transition",
|
|
120
|
+
resolveInstanceZ("video", undefined, table),
|
|
121
|
+
);
|
|
122
|
+
const dialogue = layer(
|
|
123
|
+
root,
|
|
124
|
+
"lf-dialogue",
|
|
125
|
+
resolveInstanceZ("dialogue", undefined, table),
|
|
126
|
+
);
|
|
127
|
+
const choices = layer(
|
|
128
|
+
root,
|
|
129
|
+
"lf-choices",
|
|
130
|
+
resolveInstanceZ("choices", undefined, table),
|
|
131
|
+
);
|
|
132
|
+
const takeover = layer(
|
|
133
|
+
root,
|
|
134
|
+
"lf-takeover",
|
|
135
|
+
resolveInstanceZ("minigame", undefined, table),
|
|
136
|
+
);
|
|
137
|
+
const overlay = layer(
|
|
138
|
+
root,
|
|
139
|
+
"lf-notifications",
|
|
140
|
+
resolveInstanceZ("notifications", undefined, table),
|
|
141
|
+
);
|
|
142
|
+
// 接管层空闲时不占位(外部系统挂载时才显示)
|
|
143
|
+
takeover.style.display = "none";
|
|
144
|
+
choices.style.display = "none";
|
|
145
|
+
|
|
146
|
+
const nodes: NarrativeMounts = {
|
|
147
|
+
root,
|
|
148
|
+
stage,
|
|
149
|
+
elementLayer,
|
|
150
|
+
dialogue,
|
|
151
|
+
choices,
|
|
152
|
+
overlay,
|
|
153
|
+
takeover,
|
|
154
|
+
transition,
|
|
155
|
+
};
|
|
156
|
+
const timers = new Set<ReturnType<typeof setTimeout>>();
|
|
157
|
+
|
|
158
|
+
/** 层位 → 节点(z 下发与「哪一层」的唯一映射处) */
|
|
159
|
+
const byLayer: Record<OverlayLayerId, HTMLElement> = {
|
|
160
|
+
stage,
|
|
161
|
+
element: elementLayer,
|
|
162
|
+
transition,
|
|
163
|
+
dialogue,
|
|
164
|
+
choices,
|
|
165
|
+
takeover,
|
|
166
|
+
notifications: overlay,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const doc = (): Document => container.ownerDocument;
|
|
170
|
+
|
|
171
|
+
const surface: DomOverlaySurface = {
|
|
172
|
+
frame: createWindowFrameSource(container),
|
|
173
|
+
stage: createDomRenderPort(stage),
|
|
174
|
+
elements: createDomRenderPort(elementLayer),
|
|
175
|
+
nodes,
|
|
176
|
+
|
|
177
|
+
setLayerZ(layerId: OverlayLayerId, z: number): void {
|
|
178
|
+
byLayer[layerId].style.zIndex = String(z);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
setDialogue(view: DialogueLayerView): void {
|
|
182
|
+
dialogue.className = `lf-dialogue ${view.rootClass}`;
|
|
183
|
+
dialogue.innerHTML = "";
|
|
184
|
+
if (view.speakerHtml !== "") {
|
|
185
|
+
const speaker = doc().createElement("p");
|
|
186
|
+
speaker.className = "lf-speaker";
|
|
187
|
+
speaker.innerHTML = view.speakerHtml;
|
|
188
|
+
if (view.speakerColor !== "") speaker.style.color = view.speakerColor;
|
|
189
|
+
dialogue.appendChild(speaker);
|
|
190
|
+
}
|
|
191
|
+
const body = doc().createElement("p");
|
|
192
|
+
body.className = "lf-text";
|
|
193
|
+
body.innerHTML = view.bodyHtml;
|
|
194
|
+
if (view.bodyFont !== "") body.style.fontFamily = `'${view.bodyFont}'`;
|
|
195
|
+
dialogue.appendChild(body);
|
|
196
|
+
if (view.hintHtml !== "") {
|
|
197
|
+
const hint = doc().createElement("span");
|
|
198
|
+
hint.className = "lf-hint";
|
|
199
|
+
hint.innerHTML = view.hintHtml;
|
|
200
|
+
dialogue.appendChild(hint);
|
|
201
|
+
}
|
|
202
|
+
// 隐藏而非清空:恢复时不会闪一下
|
|
203
|
+
dialogue.style.display = view.visible ? "" : "none";
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
setChoices(view: ChoicesLayerView | null): void {
|
|
207
|
+
choices.style.display = view === null ? "none" : "";
|
|
208
|
+
choices.innerHTML = "";
|
|
209
|
+
if (view === null) return;
|
|
210
|
+
|
|
211
|
+
if (view.input !== null) {
|
|
212
|
+
const prompt = view.input.prompt;
|
|
213
|
+
choices.className = "lf-choices";
|
|
214
|
+
const label = doc().createElement("p");
|
|
215
|
+
label.className = "lf-prompt";
|
|
216
|
+
label.textContent = prompt;
|
|
217
|
+
const form = doc().createElement("form");
|
|
218
|
+
form.className = "lf-input-row";
|
|
219
|
+
const field = clickable(doc().createElement("input"));
|
|
220
|
+
field.type = "text";
|
|
221
|
+
field.setAttribute("aria-label", prompt === "" ? "输入" : prompt);
|
|
222
|
+
const submit = clickable(doc().createElement("button"));
|
|
223
|
+
submit.type = "submit";
|
|
224
|
+
submit.textContent = view.input.submitText;
|
|
225
|
+
form.addEventListener("submit", (event) => {
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
// 出口由装配器挂上;未挂时不消费(不是本层的错,故不报错)
|
|
228
|
+
surface.onSubmitInput?.(field.value.trim());
|
|
229
|
+
});
|
|
230
|
+
form.append(field, submit);
|
|
231
|
+
choices.append(label, form);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
choices.className = `lf-choices ${view.rootClass}`;
|
|
236
|
+
if (view.promptHtml !== "") {
|
|
237
|
+
const prompt = doc().createElement("p");
|
|
238
|
+
prompt.className = "lf-prompt";
|
|
239
|
+
prompt.innerHTML = view.promptHtml;
|
|
240
|
+
choices.appendChild(prompt);
|
|
241
|
+
}
|
|
242
|
+
const row = doc().createElement("div");
|
|
243
|
+
row.className = "lf-choice-row";
|
|
244
|
+
for (const option of view.options) {
|
|
245
|
+
const button = clickable(doc().createElement("button"));
|
|
246
|
+
button.type = "button";
|
|
247
|
+
button.innerHTML = option.html;
|
|
248
|
+
// innerHTML 内容不进无障碍名计算(等价于 v-html):显式给可访问名
|
|
249
|
+
button.setAttribute("aria-label", option.label);
|
|
250
|
+
// 点击交核心 choose(目标是列 id,不是显示文本)
|
|
251
|
+
button.addEventListener("click", () =>
|
|
252
|
+
surface.onChoose?.(option.target),
|
|
253
|
+
);
|
|
254
|
+
row.appendChild(button);
|
|
255
|
+
}
|
|
256
|
+
choices.appendChild(row);
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
pushNotification(item: NotifyLayerItem, durationMs: number): void {
|
|
260
|
+
const node = doc().createElement("div");
|
|
261
|
+
node.className = item.rootClass;
|
|
262
|
+
node.innerHTML = item.bodyHtml;
|
|
263
|
+
overlay.appendChild(node);
|
|
264
|
+
const timer = setTimeout(() => {
|
|
265
|
+
timers.delete(timer);
|
|
266
|
+
node.remove();
|
|
267
|
+
}, durationMs);
|
|
268
|
+
timers.add(timer);
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
clear(): void {
|
|
272
|
+
for (const timer of timers) clearTimeout(timer);
|
|
273
|
+
timers.clear();
|
|
274
|
+
root.remove();
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
return surface;
|
|
278
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 帧驱动表现的纯逻辑(可测):缓动映射、动画插值、震动偏移。
|
|
3
|
+
*
|
|
4
|
+
* 分工:核心层写「描述」,本模块提供**每帧计算**,宿主(App.vue / 其他)把结果写到 DOM。
|
|
5
|
+
* 不碰引擎、不碰 DOM —— 便于单测覆盖(帧驱动)。
|
|
6
|
+
*/
|
|
7
|
+
import type { AnimationSpec } from "@lingfan-engine/core";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 缓动名 → 归一化函数(`t ∈ [0,1] → [0,1]`)。
|
|
11
|
+
* 默认 `EaseOutQuad`;未知名字**回退 EaseOutQuad**(不静默变成线性)。
|
|
12
|
+
*
|
|
13
|
+
* 缓动全集 **16 个**(Linear + Quad×3 + Cubic×3 + Back×3 +
|
|
14
|
+
* Elastic×3 + Bounce×3)——只列一部分的话,作者写 `EaseOutBounce` 会被静默回退成默认缓动。
|
|
15
|
+
* **回退语义**(解析失败 → `EaseOutQuad`)故不改为抛错(避免对既有故事
|
|
16
|
+
* 制造新错误);「未知缓动名」的编辑期提示列为可选后续。
|
|
17
|
+
*/
|
|
18
|
+
const EASINGS: Record<string, (t: number) => number> = {
|
|
19
|
+
// 命名**首字母大写**(`Linear`),因此这里大小写不敏感查找
|
|
20
|
+
Linear: (t) => t,
|
|
21
|
+
EaseInQuad: (t) => t * t,
|
|
22
|
+
EaseOutQuad: (t) => t * (2 - t),
|
|
23
|
+
EaseInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
|
24
|
+
EaseInCubic: (t) => t * t * t,
|
|
25
|
+
EaseOutCubic: (t) => 1 - (1 - t) ** 3,
|
|
26
|
+
EaseInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2),
|
|
27
|
+
// —— Back / Elastic / Bounce族(补齐缓动全集,逐条给定公式)——
|
|
28
|
+
// 命名与公式:Linear + Quad×3 + Cubic×3 + Back×3 + Elastic×3 + Bounce×3
|
|
29
|
+
EaseInBack: (t) => t * t * (2.70158 * t - 1.70158),
|
|
30
|
+
EaseOutBack: (t) => (t - 1) * (t - 1) * (2.70158 * (t - 1) + 1.70158) + 1,
|
|
31
|
+
EaseInOutBack: (t) =>
|
|
32
|
+
t < 0.5
|
|
33
|
+
? 0.5 * (t * 2) * (t * 2) * (2.70158 * (t * 2) - 1.70158)
|
|
34
|
+
: 0.5 *
|
|
35
|
+
((t * 2 - 2) * (t * 2 - 2) * (2.70158 * (t * 2 - 2) + 1.70158) + 2),
|
|
36
|
+
EaseInElastic: (t) =>
|
|
37
|
+
t === 0 || t === 1
|
|
38
|
+
? t
|
|
39
|
+
: -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * 2.094395102),
|
|
40
|
+
EaseOutElastic: (t) =>
|
|
41
|
+
t === 0 || t === 1
|
|
42
|
+
? t
|
|
43
|
+
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * 2.094395102) + 1,
|
|
44
|
+
EaseInOutElastic: (t) =>
|
|
45
|
+
t === 0 || t === 1
|
|
46
|
+
? t
|
|
47
|
+
: t < 0.5
|
|
48
|
+
? -(
|
|
49
|
+
Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * 1.396263402)
|
|
50
|
+
) / 2
|
|
51
|
+
: (Math.pow(2, -20 * t + 10) *
|
|
52
|
+
Math.sin((20 * t - 11.125) * 1.396263402)) /
|
|
53
|
+
2 +
|
|
54
|
+
1,
|
|
55
|
+
EaseOutBounce: (t) => easeOutBounce(t),
|
|
56
|
+
EaseInBounce: (t) => 1 - easeOutBounce(1 - t),
|
|
57
|
+
EaseInOutBounce: (t) =>
|
|
58
|
+
t < 0.5
|
|
59
|
+
? (1 - easeOutBounce(1 - 2 * t)) / 2
|
|
60
|
+
: (1 + easeOutBounce(2 * t - 1)) / 2,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Bounce 基函数(分段式) */
|
|
64
|
+
function easeOutBounce(t: number): number {
|
|
65
|
+
const n1 = 7.5625;
|
|
66
|
+
const d1 = 2.75;
|
|
67
|
+
if (t < 1 / d1) return n1 * t * t;
|
|
68
|
+
if (t < 2 / d1) {
|
|
69
|
+
const t2 = t - 1.5 / d1;
|
|
70
|
+
return n1 * t2 * t2 + 0.75;
|
|
71
|
+
}
|
|
72
|
+
if (t < 2.5 / d1) {
|
|
73
|
+
const t3 = t - 2.25 / d1;
|
|
74
|
+
return n1 * t3 * t3 + 0.9375;
|
|
75
|
+
}
|
|
76
|
+
const t4 = t - 2.625 / d1;
|
|
77
|
+
return n1 * t4 * t4 + 0.984375;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 默认缓动名(缓动查表的兜底) */
|
|
81
|
+
const DEFAULT_EASING = "EaseOutQuad";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 大小写不敏感查找表:登记表用 `Linear`(首字母大写),
|
|
85
|
+
* 而既有语料/测试可能写 `linear` —— 两者都解析到同一函数(**更宽松 = 不破坏既有故事**)。
|
|
86
|
+
*/
|
|
87
|
+
const EASING_LOOKUP: ReadonlyMap<string, (t: number) => number> = new Map(
|
|
88
|
+
Object.entries(EASINGS).map(([name, fn]) => [name.toLowerCase(), fn]),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 按名取缓动函数(大小写不敏感)。名字不认识时不报错——回退到默认缓动,
|
|
93
|
+
* 让动画照常跑完;名字写错属于作者侧问题,不该把一帧动画变成一次崩溃。
|
|
94
|
+
*/
|
|
95
|
+
export function easingFn(name: string): (t: number) => number {
|
|
96
|
+
return EASING_LOOKUP.get(name.toLowerCase()) ?? EASINGS[DEFAULT_EASING]!;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 已知缓动名(编辑器表单/诊断可枚举;不含回退项)——恒为全集 16 个 */
|
|
100
|
+
export function easingNames(): string[] {
|
|
101
|
+
return Object.keys(EASINGS);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 单条动画在 `elapsed`(秒)时刻的插值。
|
|
106
|
+
* `done=true` 表示已到终值(宿主据此把该 seq 交回 `engine.animationFinished`)。
|
|
107
|
+
*/
|
|
108
|
+
export function interpolateAnimation(
|
|
109
|
+
spec: AnimationSpec,
|
|
110
|
+
elapsed: number,
|
|
111
|
+
): { value: number; done: boolean } {
|
|
112
|
+
if (spec.duration <= 0) return { value: spec.to, done: true };
|
|
113
|
+
const t = Math.min(1, Math.max(0, elapsed / spec.duration));
|
|
114
|
+
const value = spec.from + (spec.to - spec.from) * easingFn(spec.easing)(t);
|
|
115
|
+
return { value, done: t >= 1 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 屏幕震动偏移(宿主每帧计算并应用)。
|
|
120
|
+
* 用**衰减正弦**近似:幅度随进度线性衰减到 0,x/y 取不同相位避免直线往复。
|
|
121
|
+
* `phase` 为宿主传入的帧时间(秒)或随机源,保证同一输入可复现(测试友好)。
|
|
122
|
+
*/
|
|
123
|
+
export function shakeOffset(
|
|
124
|
+
intensity: number,
|
|
125
|
+
progress: number,
|
|
126
|
+
phase: number,
|
|
127
|
+
): { x: number; y: number } {
|
|
128
|
+
const decay = Math.max(0, 1 - progress);
|
|
129
|
+
const amplitude = intensity * decay;
|
|
130
|
+
// `|| 0` 归一化 -0(Math.round 可产出 -0,会让相等断言与 style 序列化不一致)
|
|
131
|
+
return {
|
|
132
|
+
x: Math.round(Math.sin(phase * 47) * amplitude) || 0,
|
|
133
|
+
y: Math.round(Math.cos(phase * 61) * amplitude) || 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 转场遮罩不透明度(`fade` 系):0 → 1 → 0 的三段式(中点为全遮) */
|
|
138
|
+
export function transitionOpacity(progress: number): number {
|
|
139
|
+
const t = Math.min(1, Math.max(0, progress));
|
|
140
|
+
return t <= 0.5 ? t * 2 : (1 - t) * 2;
|
|
141
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** 元素渲染域出口(UI 侧单一入口)。 */
|
|
2
|
+
export {
|
|
3
|
+
createElementRegistry,
|
|
4
|
+
type ElementRegistry,
|
|
5
|
+
type ElementRenderer,
|
|
6
|
+
type ElementRenderContext,
|
|
7
|
+
} from "./registry";
|
|
8
|
+
export {
|
|
9
|
+
createElementResourceResolver,
|
|
10
|
+
type ElementResourceResolver,
|
|
11
|
+
type ElementResourceResolverOptions,
|
|
12
|
+
} from "./resource";
|
|
13
|
+
export { elementClassName, elementSource, elementStyle } from "./style";
|
|
14
|
+
export { registerBuiltinElementRenderers } from "./renderers";
|
|
15
|
+
export { renderElementTree, type ElementTreeRenderOptions } from "./render";
|
|
16
|
+
export {
|
|
17
|
+
type RenderEvent,
|
|
18
|
+
type RenderEventPatch,
|
|
19
|
+
type RenderKind,
|
|
20
|
+
type RenderPort,
|
|
21
|
+
type RenderPropValue,
|
|
22
|
+
type RenderSpec,
|
|
23
|
+
} from "./spec";
|
|
24
|
+
export {
|
|
25
|
+
hasElementInteraction,
|
|
26
|
+
isElementDisabled,
|
|
27
|
+
resolveElementAction,
|
|
28
|
+
type ElementAction,
|
|
29
|
+
type ElementActionOptions,
|
|
30
|
+
type ElementActionSource,
|
|
31
|
+
} from "./interaction";
|
|
32
|
+
export {
|
|
33
|
+
easingFn,
|
|
34
|
+
easingNames,
|
|
35
|
+
interpolateAnimation,
|
|
36
|
+
shakeOffset,
|
|
37
|
+
transitionOpacity,
|
|
38
|
+
} from "./animation";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 点击动作解析(输入语义归核心层,UI 只做翻译)。
|
|
3
|
+
*
|
|
4
|
+
* **`disabled` > `nav` > `ops` > `cmd`** —— 前四级决定**点击行为**:
|
|
5
|
+
* - `disabled` 短路(可写布尔,也可写表达式原文如 `{player.gold < 10}`,点击时刻求值)
|
|
6
|
+
* - `nav` 优先于 `ops`/`cmd`(跳列是最直接的编排语义)
|
|
7
|
+
* - `ops` = **数据侧动作序列**(点一下按序执行一串 op,复用引擎既有 op 分发表)
|
|
8
|
+
* - `cmd` = 宿主命名命令(宿主自有业务:打开面板、外部跳转…)
|
|
9
|
+
*
|
|
10
|
+
* `hover_*` / `selected_*` / `disabled_*` 是与点击**正交**的视觉态(由渲染器绑定)。
|
|
11
|
+
*
|
|
12
|
+
* 纯函数:宿主据此选路(`nav` → 核心 `navigate`;`ops` → 引擎 `runElementOps`;
|
|
13
|
+
* `cmd` → 宿主命名命令注册表)。
|
|
14
|
+
*/
|
|
15
|
+
import type { ElementInstance } from "@lingfan-engine/core";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 点击动作的解析结果:调用方据 `kind` 选路,不自行读属性。
|
|
19
|
+
*
|
|
20
|
+
* `none` / `disabled` 都不产生动作,区别在语义——前者是「元素没声明交互」,
|
|
21
|
+
* 后者是「声明了但当下被禁用」。调用方只看 `hasElementInteraction` 时两者等价,
|
|
22
|
+
* 但要区分「未声明」与「被禁用」(例如做提示文案)时用得上。
|
|
23
|
+
*/
|
|
24
|
+
export type ElementAction =
|
|
25
|
+
| { kind: "none" }
|
|
26
|
+
/** 禁用:不产生任何动作(调用方应短路,不挂点击) */
|
|
27
|
+
| { kind: "disabled" }
|
|
28
|
+
| { kind: "nav"; target: string }
|
|
29
|
+
/** 数据侧动作序列:原样交给引擎(表达式在点击时刻由引擎求值) */
|
|
30
|
+
| { kind: "ops"; ops: readonly Record<string, unknown>[] }
|
|
31
|
+
/** `value` 为原文(可能含 `{expr}`)——宿主按点击时刻插值后再交处理器 */
|
|
32
|
+
| { kind: "cmd"; name: string; value?: string };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 动作解析的可选注入点:宿主提供「表达式怎么求值」。
|
|
36
|
+
* 不传 = 不求解——字符串形态的 `disabled` 一律按「未禁用」处理(见 `isElementDisabled`)。
|
|
37
|
+
*/
|
|
38
|
+
export interface ElementActionOptions {
|
|
39
|
+
/**
|
|
40
|
+
* `disabled` 表达式求值(返回 null = 求值失败)。
|
|
41
|
+
* 由宿主注入(引擎侧 `interpolate`+解析);缺省 = 不求解,字符串形态按「未禁用」处理。
|
|
42
|
+
*/
|
|
43
|
+
evalDisable?: (expression: string) => boolean | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 禁用判定(`enabled === false` 与 `disabled` 同义,任一命中即禁用)。
|
|
48
|
+
*
|
|
49
|
+
* 表达式求值失败(null)= **不禁用**:宁可点得动、由 `ops` 执行期 fail-closed 报错,
|
|
50
|
+
* 也不因求值问题让玩家彻底无法交互(禁用态误判是「锁死」,比报错更难恢复)。
|
|
51
|
+
*/
|
|
52
|
+
export function isElementDisabled(
|
|
53
|
+
props: Record<string, unknown>,
|
|
54
|
+
options: ElementActionOptions = {},
|
|
55
|
+
): boolean {
|
|
56
|
+
if (props.enabled === false) return true;
|
|
57
|
+
const value = props.disabled;
|
|
58
|
+
if (typeof value === "boolean") return value;
|
|
59
|
+
if (typeof value === "string" && value !== "") {
|
|
60
|
+
if (options.evalDisable === undefined) return false;
|
|
61
|
+
return options.evalDisable(value) === true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `ops` 负载形态校验:非空数组,每项为对象且 `op` 为非空字符串。
|
|
68
|
+
* 畸形 = 无声明的形态 ⇒ 返回 null(调用方按「未声明 ops」继续,由引擎解析期 fail-closed 报错)。
|
|
69
|
+
*/
|
|
70
|
+
function readOps(value: unknown): readonly Record<string, unknown>[] | null {
|
|
71
|
+
if (!Array.isArray(value) || value.length === 0) return null;
|
|
72
|
+
for (const item of value) {
|
|
73
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const op = (item as { op?: unknown }).op;
|
|
77
|
+
if (typeof op !== "string" || op === "") return null;
|
|
78
|
+
}
|
|
79
|
+
return value as readonly Record<string, unknown>[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 按优先级解析出该元素在点击时应产生的动作。
|
|
84
|
+
*
|
|
85
|
+
* 优先级 `disabled` > `nav` > `ops` > `cmd`,全不命中 = `{ kind: "none" }`。
|
|
86
|
+
* 只读属性、不写状态:同一组属性任何时候调都得到同一个结果(表达式求值那步除外,
|
|
87
|
+
* 它按调用时刻求值)。交给调用方选路的,是这个判别联合——不是原始属性。
|
|
88
|
+
*/
|
|
89
|
+
export function resolveElementAction(
|
|
90
|
+
props: Record<string, unknown>,
|
|
91
|
+
options: ElementActionOptions = {},
|
|
92
|
+
): ElementAction {
|
|
93
|
+
// 最高优先级:禁用 → 不产生任何动作
|
|
94
|
+
if (isElementDisabled(props, options)) return { kind: "disabled" };
|
|
95
|
+
|
|
96
|
+
const nav = props.nav;
|
|
97
|
+
if (typeof nav === "string" && nav !== "")
|
|
98
|
+
return { kind: "nav", target: nav };
|
|
99
|
+
|
|
100
|
+
const ops = readOps(props.ops);
|
|
101
|
+
if (ops !== null) return { kind: "ops", ops };
|
|
102
|
+
|
|
103
|
+
const cmd = props.cmd;
|
|
104
|
+
if (typeof cmd === "string" && cmd !== "") {
|
|
105
|
+
const value = props.value;
|
|
106
|
+
return typeof value === "string"
|
|
107
|
+
? { kind: "cmd", name: cmd, value }
|
|
108
|
+
: { kind: "cmd", name: cmd };
|
|
109
|
+
}
|
|
110
|
+
return { kind: "none" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 元素是否拥有交互(点击类)——渲染器据此决定是否挂点击与手型光标 */
|
|
114
|
+
export function hasElementInteraction(
|
|
115
|
+
props: Record<string, unknown>,
|
|
116
|
+
options: ElementActionOptions = {},
|
|
117
|
+
): boolean {
|
|
118
|
+
const action = resolveElementAction(props, options);
|
|
119
|
+
return action.kind !== "none" && action.kind !== "disabled";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 供宿主把动作翻译成引擎/注册表调用(`source` 回传给命令处理器) */
|
|
123
|
+
export type ElementActionSource = ElementInstance;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 元素渲染器注册表(UI 层职责):核心层只声明元素 `type` 与属性,
|
|
3
|
+
* 宿主经注册表解析渲染器后把结果交给渲染端口物化。
|
|
4
|
+
*
|
|
5
|
+
* 与 minigame 注册表同一口径:**未知类型无默认回退** = fail-closed
|
|
6
|
+
* ——静默占位会掩盖内容错误(渲染"看得见",但语义是错的)。
|
|
7
|
+
*
|
|
8
|
+
* 框架与平台无关:渲染器只产出 `RenderSpec`,不碰 DOM,也不依赖任何 UI 框架
|
|
9
|
+
* (核心只写状态,UI 只渲染;渲染器是宿主可替换的产物)。
|
|
10
|
+
*/
|
|
11
|
+
import type { ElementInstance } from "@lingfan-engine/core";
|
|
12
|
+
import type { RenderSpec } from "./spec";
|
|
13
|
+
|
|
14
|
+
/** 元素渲染上下文:渲染器只消费宿主提供的能力,不直接接触引擎 */
|
|
15
|
+
export interface ElementRenderContext {
|
|
16
|
+
/** 元素实例(id/type/name/props/z/children) */
|
|
17
|
+
element: ElementInstance;
|
|
18
|
+
/**
|
|
19
|
+
* 递归渲染子元素,返回子树的描述树(容器渲染器把结果放进自己的 `children`)。
|
|
20
|
+
* 未注册的类型不产出节点(fail-closed),因此返回的项数与传入的子元素数可能不等。
|
|
21
|
+
*/
|
|
22
|
+
renderChildren: (children: readonly ElementInstance[]) => RenderSpec[];
|
|
23
|
+
/** 交互激活(判定通过后由渲染器调用;具体 nav/ops/cmd 语义归宿主) */
|
|
24
|
+
activate?: (element: ElementInstance) => void;
|
|
25
|
+
/**
|
|
26
|
+
* `disabled` 表达式求值(返回 null = 求值失败)。
|
|
27
|
+
* 表达式语法归核心层:UI 只做形态判定,求值经此回调交给宿主/引擎。
|
|
28
|
+
*/
|
|
29
|
+
evalDisable?: (expression: string) => boolean | null;
|
|
30
|
+
/** 资源解析(source/src/path → 可加载 URL);缺省或失败返回 undefined(不静默伪造) */
|
|
31
|
+
resolveResource?: (path: string) => string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 渲染器:产出元素的描述树(根节点承载类名/样式,子元素在 `children` 里) */
|
|
35
|
+
export type ElementRenderer = (ctx: ElementRenderContext) => RenderSpec;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 元素类型 → 渲染器的查表。`get` 未命中即 `undefined`,本表不做兜底
|
|
39
|
+
* (画一个空占位会让内容错误看起来「渲染成功」)。
|
|
40
|
+
* 表本身不持有渲染状态;渲染器是无状态函数,同一个可跨元素层复用。
|
|
41
|
+
*/
|
|
42
|
+
export interface ElementRegistry {
|
|
43
|
+
/** 注册/覆盖(同类型再注册 = 替换,宿主扩展与开发热替换语义) */
|
|
44
|
+
register(type: string, renderer: ElementRenderer): void;
|
|
45
|
+
/** 解析渲染器;未注册 = undefined(fail-closed,宿主必须显式上报) */
|
|
46
|
+
get(type: string): ElementRenderer | undefined;
|
|
47
|
+
has(type: string): boolean;
|
|
48
|
+
/** 已注册类型(诊断/清单用) */
|
|
49
|
+
types(): string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 造一张空的元素渲染器表——内建 36 类型由元素域的注册入口另行登记 */
|
|
53
|
+
export function createElementRegistry(): ElementRegistry {
|
|
54
|
+
const renderers = new Map<string, ElementRenderer>();
|
|
55
|
+
return {
|
|
56
|
+
register(type: string, renderer: ElementRenderer): void {
|
|
57
|
+
renderers.set(type, renderer);
|
|
58
|
+
},
|
|
59
|
+
get(type: string): ElementRenderer | undefined {
|
|
60
|
+
return renderers.get(type);
|
|
61
|
+
},
|
|
62
|
+
has(type: string): boolean {
|
|
63
|
+
return renderers.has(type);
|
|
64
|
+
},
|
|
65
|
+
types(): string[] {
|
|
66
|
+
return [...renderers.keys()];
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|