@lingfan-engine/ui 0.1.5 → 0.3.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/package.json +2 -2
- package/src/audio/index.ts +12 -5
- package/src/audio/plan/actions.ts +184 -0
- package/src/audio/plan/index.ts +2 -0
- package/src/audio/renderer/create.ts +150 -0
- package/src/audio/renderer/index.ts +3 -0
- package/src/audio/renderer/types.ts +21 -0
- package/src/audio/view/empty.ts +11 -0
- package/src/audio/view/index.ts +4 -0
- package/src/audio/view/read.ts +69 -0
- package/src/audio/view/types.ts +41 -0
- package/src/dom/materialize.ts +23 -2
- package/src/dom/overlay-surface.ts +16 -0
- package/src/element/index.ts +7 -0
- package/src/element/registry.ts +10 -0
- package/src/element/render.ts +12 -2
- package/src/element/renderers/controls.ts +25 -3
- package/src/element/spec.ts +17 -3
- package/src/element/writeback.ts +125 -0
- package/src/host/entry.ts +32 -0
- package/src/host/index.ts +1 -0
- package/src/host/layer-view.ts +8 -2
- package/src/host/state-view.ts +5 -0
- package/src/index.ts +23 -0
- package/src/input/index.ts +1 -0
- package/src/input/scope.ts +11 -3
- package/src/mount/index.ts +30 -0
- package/src/mount/plan.ts +86 -0
- package/src/mount/projection.ts +57 -0
- package/src/mount/selection.ts +61 -0
- package/src/mount/store.ts +54 -0
- package/src/overlay/choices-view.ts +12 -1
- package/src/overlay/create.ts +3 -2
- package/src/overlay/projector.ts +8 -0
- package/src/overlay/surface.ts +13 -0
- package/src/save/continue.ts +55 -0
- package/src/save/index.ts +7 -0
- package/src/video/videoView.ts +22 -1
- package/src/audio/audio.ts +0 -331
package/src/element/render.ts
CHANGED
|
@@ -13,10 +13,11 @@ import type { RenderPort, RenderSpec } from "./spec";
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* 一帧元素树的渲染请求:`registry` / `port` / `elements` 必给,
|
|
16
|
-
*
|
|
16
|
+
* 其余回调不传即「没有对应的宿主能力」(表现为元素不可点、表达式不求解、
|
|
17
|
+
* 资源显示替代文本、控件值不落地)。
|
|
17
18
|
*
|
|
18
19
|
* `port` 是宿主注入的渲染端口(Web 侧 `createDomRenderPort`)——渲染器只产出描述树,
|
|
19
|
-
*
|
|
20
|
+
* 平台差异全部完成在端口实现里。
|
|
20
21
|
*/
|
|
21
22
|
export interface ElementTreeRenderOptions {
|
|
22
23
|
registry: ElementRegistry;
|
|
@@ -28,6 +29,14 @@ export interface ElementTreeRenderOptions {
|
|
|
28
29
|
evalDisable?: (expression: string) => boolean | null;
|
|
29
30
|
/** 资源解析(source/src/path → URL) */
|
|
30
31
|
resolveResource?: (path: string) => string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* 控件值变化回写(`slider` 等带值控件用):渲染器只上报「哪个元素变成了什么值」,
|
|
34
|
+
* 落点由宿主决定(偏好 / 作用域状态 / 命名命令)。不传 = 控件可操作但值不落地。
|
|
35
|
+
*/
|
|
36
|
+
onElementValueChange?: (
|
|
37
|
+
element: ElementInstance,
|
|
38
|
+
value: number | string | boolean,
|
|
39
|
+
) => void;
|
|
31
40
|
/** 未注册类型上报(fail-closed;宿主负责可见诊断) */
|
|
32
41
|
onUnknownType?: (type: string) => void;
|
|
33
42
|
}
|
|
@@ -63,6 +72,7 @@ export function renderElementTree(options: ElementTreeRenderOptions): void {
|
|
|
63
72
|
activate: options.activate,
|
|
64
73
|
evalDisable: options.evalDisable,
|
|
65
74
|
resolveResource: options.resolveResource,
|
|
75
|
+
onElementValueChange: options.onElementValueChange,
|
|
66
76
|
});
|
|
67
77
|
// 舞台内叠放序(两级叠放:元素之间在 stage 容器内独立比较)覆盖作者声明,
|
|
68
78
|
// 帧驱动定位锚挂在根节点上(动画按 id 找节点)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { clamp01 } from "@lingfan-engine/core";
|
|
6
6
|
import { bindInteraction, isDisabled } from "./bindings";
|
|
7
|
-
import { createRoot, numProp, withProps, withStyle } from "./base";
|
|
7
|
+
import { createRoot, numProp, withEvents, withProps, withStyle } from "./base";
|
|
8
8
|
import type { ElementRenderContext } from "../registry";
|
|
9
9
|
import type { RenderSpec } from "../spec";
|
|
10
10
|
|
|
@@ -54,7 +54,13 @@ export function renderProgress(ctx: ElementRenderContext): RenderSpec {
|
|
|
54
54
|
return { ...spec, style, children: [fill] };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* 滑块:`min`/`max`/`value` 归一后写进原生 input;纵向由 `orientation` 决定书写方向。
|
|
59
|
+
*
|
|
60
|
+
* 值回写挂 `change`(值已定才上报,拖动过程中的每帧不刷状态):控件给的是字符串,
|
|
61
|
+
* 这里转数字——`min`/`max` 的区间由控件自身保证,本层不再裁剪。
|
|
62
|
+
* 回写不改节点:滑块自己持有当前值,描述树只在重渲时覆盖它。
|
|
63
|
+
*/
|
|
58
64
|
export function renderSlider(ctx: ElementRenderContext): RenderSpec {
|
|
59
65
|
const spec = createRoot("slider", ctx);
|
|
60
66
|
const withPropsSpec = withProps(spec, {
|
|
@@ -67,7 +73,23 @@ export function renderSlider(ctx: ElementRenderContext): RenderSpec {
|
|
|
67
73
|
ctx.element.props.orientation === "vertical"
|
|
68
74
|
? withStyle(withPropsSpec, { writingMode: "vertical-lr" })
|
|
69
75
|
: withPropsSpec;
|
|
70
|
-
|
|
76
|
+
const bound = bindInteraction(out, ctx);
|
|
77
|
+
// 禁用即不挂回写(与点击同一条短路:禁用的控件不该改动任何状态)
|
|
78
|
+
if (isDisabled(ctx)) return bound;
|
|
79
|
+
return withEvents(bound, [
|
|
80
|
+
{
|
|
81
|
+
on: "change",
|
|
82
|
+
handler: (raw) => {
|
|
83
|
+
// 空串的 Number() 是 0 而不是 NaN,须先挡掉——「没给值」不等于「值为 0」
|
|
84
|
+
if (typeof raw === "string" && raw.trim() === "") return undefined;
|
|
85
|
+
const value = typeof raw === "string" ? Number(raw) : raw;
|
|
86
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
87
|
+
ctx.onElementValueChange?.(ctx.element, value);
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
71
93
|
}
|
|
72
94
|
|
|
73
95
|
/** 复选框:`checked === true` 才勾上(其余取值一律不勾,不猜真值) */
|
package/src/element/spec.ts
CHANGED
|
@@ -32,10 +32,24 @@ export interface RenderEventPatch {
|
|
|
32
32
|
props?: Readonly<Record<string, RenderPropValue>>;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* 事件名:前三个是交互态(进出/点击),后两个带值(表单类控件用)。
|
|
37
|
+
*
|
|
38
|
+
* `input` 是「值在变」(连续),`change` 是「值已定」(一次)——滑块拖动时前者每帧都来,
|
|
39
|
+
* 后者只在松手/提交时来,写回状态一律走 `change`,避免每帧刷引擎。
|
|
40
|
+
*/
|
|
41
|
+
export type RenderEventName =
|
|
42
|
+
"click" | "mouseenter" | "mouseleave" | "input" | "change";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 事件绑定;`handler` 的返回值即触发时要应用的补丁(无返回值 = 不改节点)。
|
|
46
|
+
*
|
|
47
|
+
* `handler` 的可选参数是事件载荷:只有 `input` / `change` 会带(控件当前值),
|
|
48
|
+
* 其余事件一律 `undefined`。既有 handler 不接参数,多一个可选参数对它们零影响。
|
|
49
|
+
*/
|
|
36
50
|
export interface RenderEvent {
|
|
37
|
-
on:
|
|
38
|
-
handler?: () => RenderEventPatch | void;
|
|
51
|
+
on: RenderEventName;
|
|
52
|
+
handler?: (value?: RenderPropValue) => RenderEventPatch | void;
|
|
39
53
|
/** 阻止冒泡(元素点击不应同时触发舞台推进) */
|
|
40
54
|
stopPropagation?: boolean;
|
|
41
55
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 元素值回写的落点路由:元素层只上报「哪个元素变成了什么值」,落到哪里由本模块决定。
|
|
3
|
+
*
|
|
4
|
+
* 元素渲染器不认识引擎,也不知道「音量」和「分数」有什么区别——它只把
|
|
5
|
+
* `(element, value)` 交出来。本模块是**全仓唯一的落点判定点**(模板骨架与
|
|
6
|
+
* playground 都调它,两处不各判一套,否则作者的同一个 `cmd="volume:bgm"`
|
|
7
|
+
* 在两个宿主里会得到不同结果):
|
|
8
|
+
*
|
|
9
|
+
* - `cmd="volume:bgm"` / `volume:se` / `volume:ambient` / `volume:voice` → 玩家偏好通道音量;
|
|
10
|
+
* - `cmd="game.<系统>.<键>"` → 外部玩法系统命名空间(经 `GameStateWriter.setScoped`);
|
|
11
|
+
* - 其余(含无 `cmd`)→ 不落,返回 false 由调用方上报。
|
|
12
|
+
*
|
|
13
|
+
* **三条硬约束**:
|
|
14
|
+
* 1. 本模块**不直写 SSOT**——`__` 保留键由 `GameStateWriter` 的保留键门卫物理挡住,
|
|
15
|
+
* 这里连碰都碰不到(`setVolume` 走偏好、`setScoped` 走 `game.` 前缀)。
|
|
16
|
+
* 2. 形态判定与落点判定分开:`parseWritebackTarget` 是纯解析(可单测),
|
|
17
|
+
* `routeElementValueChange` 只做分发,不重复解析。
|
|
18
|
+
* 3. 本模块**只做判定与分发**,端口由宿主注入(`ElementWritebackPorts`)——
|
|
19
|
+
* UI 层因此仍然不持有引擎引用,`ElementInstance` 之外不 import 任何运行时物。
|
|
20
|
+
*
|
|
21
|
+
* 为什么用 `cmd` 当落点而不是新增元素属性:`cmd` 已经是「宿主命名命令」的既有载体
|
|
22
|
+
* (`packages/core/src/contracts/element.ts` 通用属性表),滑块与按钮共用一套寻址,
|
|
23
|
+
* 不为回写另开一个属性名——那会让作者要记两套。
|
|
24
|
+
*/
|
|
25
|
+
import type { AudioChannel, ElementInstance } from "@lingfan-engine/core";
|
|
26
|
+
import { GAME_SYSTEM_ID_PATTERN } from "@lingfan-engine/core";
|
|
27
|
+
|
|
28
|
+
/** 音量落点前缀(`volume:bgm` 形态) */
|
|
29
|
+
const VOLUME_PREFIX = "volume:";
|
|
30
|
+
|
|
31
|
+
/** 玩法系统落点前缀(`game.<系统>.<键>` 形态,与 `setScoped` 的命名空间同源) */
|
|
32
|
+
const GAME_PREFIX = "game.";
|
|
33
|
+
|
|
34
|
+
/** 四通道白名单:与 `AudioChannel` 同源,写死在这里是为了运行期可判定 */
|
|
35
|
+
const AUDIO_CHANNELS: ReadonlySet<string> = new Set([
|
|
36
|
+
"bgm",
|
|
37
|
+
"se",
|
|
38
|
+
"ambient",
|
|
39
|
+
"voice",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** 回写落点:解析结果是一个判别联合,不认识的一律 `null` */
|
|
43
|
+
export type WritebackTarget =
|
|
44
|
+
| { kind: "volume"; channel: AudioChannel }
|
|
45
|
+
| { kind: "game"; systemId: string; key: string };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 解析元素的回写落点。`cmd` 为空 / 不认识的形态 ⇒ `null`(调用方按「未声明落点」处理)。
|
|
49
|
+
*
|
|
50
|
+
* `game.` 形态要求**恰好三段**(`game.<系统>.<键>`):多段会把点号当分隔符吞进键名,
|
|
51
|
+
* 少段则系统 id 缺失——两种都是作者的笔误,宁可解析失败上报,也不要猜。
|
|
52
|
+
*/
|
|
53
|
+
export function parseWritebackTarget(
|
|
54
|
+
props: Readonly<Record<string, unknown>>,
|
|
55
|
+
): WritebackTarget | null {
|
|
56
|
+
const cmd = props.cmd;
|
|
57
|
+
if (typeof cmd !== "string" || cmd === "") return null;
|
|
58
|
+
|
|
59
|
+
if (cmd.startsWith(VOLUME_PREFIX)) {
|
|
60
|
+
const channel = cmd.slice(VOLUME_PREFIX.length);
|
|
61
|
+
if (!AUDIO_CHANNELS.has(channel)) return null;
|
|
62
|
+
return { kind: "volume", channel: channel as AudioChannel };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (cmd.startsWith(GAME_PREFIX)) {
|
|
66
|
+
const parts = cmd.split(".");
|
|
67
|
+
if (parts.length !== 3) return null;
|
|
68
|
+
const [, systemId, key] = parts;
|
|
69
|
+
if (systemId === undefined || key === undefined) return null;
|
|
70
|
+
if (systemId === "" || key === "") return null;
|
|
71
|
+
// 系统 id 形态与 minigame / interaction 的 `game` 同域(单一形态来源)
|
|
72
|
+
if (!GAME_SYSTEM_ID_PATTERN.test(systemId)) return null;
|
|
73
|
+
return { kind: "game", systemId, key };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 回写落点需要的能力(宿主组合根注入;两件都是既有闸门,不新开写入面) */
|
|
80
|
+
export interface ElementWritebackPorts {
|
|
81
|
+
/** 玩家偏好通道音量(内部 clamp01,且触发渲染层重规划) */
|
|
82
|
+
setVolume(channel: AudioChannel, value: number): void;
|
|
83
|
+
/** 外部玩法系统命名空间写入(保留键与 JSON 安全由引擎门卫把关) */
|
|
84
|
+
setScoped(systemId: string, key: string, value: unknown): boolean;
|
|
85
|
+
/** 落点解析失败或写入被拒时的诊断出口 */
|
|
86
|
+
reportError(message: string): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 把一次元素值回写落到宿主既有闸门上。
|
|
91
|
+
*
|
|
92
|
+
* @returns 是否落成功(`false` = 已上报诊断,状态原样)
|
|
93
|
+
*/
|
|
94
|
+
export function routeElementValueChange(
|
|
95
|
+
element: ElementInstance,
|
|
96
|
+
value: number | string | boolean,
|
|
97
|
+
ports: ElementWritebackPorts,
|
|
98
|
+
): boolean {
|
|
99
|
+
const target = parseWritebackTarget(element.props);
|
|
100
|
+
if (target === null) {
|
|
101
|
+
ports.reportError(
|
|
102
|
+
`元素 ${String(element.id)} 的值回写没有落点:cmd 需为 volume:<通道> 或 game.<系统>.<键>`,
|
|
103
|
+
);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (target.kind === "volume") {
|
|
108
|
+
// 音量是 0..1 的实数:控件给的是 min..max 的刻度值,由元素自身的
|
|
109
|
+
// `min`/`max` 决定量纲,这里不做换算(作者把 min/max 设成 0..1 即是音量)
|
|
110
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
111
|
+
ports.reportError(`元素 ${String(element.id)} 的音量回写值不是有限数字`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
ports.setVolume(target.channel, value);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!ports.setScoped(target.systemId, target.key, value)) {
|
|
119
|
+
ports.reportError(
|
|
120
|
+
`元素 ${String(element.id)} 的状态回写被拒:${target.systemId}.${target.key}(详见引擎诊断)`,
|
|
121
|
+
);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「进入游戏」要走到哪一列。
|
|
3
|
+
*
|
|
4
|
+
* 引擎只知道自己停在哪一列、那一列是什么类型:它不认识「启动界面」这种概念。
|
|
5
|
+
* 所以「开始」这个动作在宿主侧必须翻译成一个具体的列 id,而**不能**写死列名:
|
|
6
|
+
* 工程换了、入口列改了,写死的名字就会指到不存在的列。
|
|
7
|
+
*
|
|
8
|
+
* 判据只两条:入口列本身是 `game` 就直接进它;否则取**列清单里第一个 `game` 列**。
|
|
9
|
+
* 两条都落空(整个工程没有 `game` 列)返回 `undefined`——调用方据此把按钮置灰,
|
|
10
|
+
* 而不是让玩家点进一片空白。
|
|
11
|
+
*/
|
|
12
|
+
import type { ColumnSummary } from "@lingfan-engine/core";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 挑出「开始」按钮该导航到的列。
|
|
16
|
+
*
|
|
17
|
+
* @param columns 列清单(来自 `engine.columns()`,顺序即工程里的列序)。
|
|
18
|
+
* @param entry 入口列 id(来自工程清单的 `entry`)。
|
|
19
|
+
* @returns 目标列 id;没有可进入的 `game` 列时返回 `undefined`。
|
|
20
|
+
*
|
|
21
|
+
* 入口列优先:工程可以刻意把入口做成一个 `game` 列(那就没有启动界面这一屏),
|
|
22
|
+
* 这时「开始」与「继续」都在同一列上,行为与玩家的直觉一致。
|
|
23
|
+
*/
|
|
24
|
+
export function firstGameColumn(
|
|
25
|
+
columns: readonly ColumnSummary[],
|
|
26
|
+
entry: string,
|
|
27
|
+
): string | undefined {
|
|
28
|
+
const entryColumn = columns.find((column) => column.id === entry);
|
|
29
|
+
if (entryColumn !== undefined && entryColumn.type === "game") return entry;
|
|
30
|
+
const first = columns.find((column) => column.type === "game");
|
|
31
|
+
return first === undefined ? undefined : first.id;
|
|
32
|
+
}
|
package/src/host/index.ts
CHANGED
package/src/host/layer-view.ts
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
* 一个层最终用哪个 z 由三级链决定(实例 > 工程层默认 > 内建),判定本身在引擎里
|
|
5
5
|
* (`resolveInstanceZ`);本模块只负责两件事:
|
|
6
6
|
* 1. 记住某层被命令参数覆盖成了什么(`setOverride`),值不是数字时视为「回层默认」;
|
|
7
|
-
* 2. 用注入的 `applyLayerZ`
|
|
7
|
+
* 2. 用注入的 `applyLayerZ` 把由 DOM 承载的层交给宿主去写。
|
|
8
8
|
*
|
|
9
9
|
* 视频层单独一个方法:它的 z 不在 DOM 上而在视频端口内部,且**只在视频层自己变化时**
|
|
10
10
|
* 才需要下发。合并进 `apply()` 会让「改标题栏层级」也写入一次视频样式——多出来的
|
|
11
11
|
* 那一次写没有任何语义,却足以让「谁在改 DOM」变得难查。
|
|
12
|
+
*
|
|
13
|
+
* 历史与偏好两个面板层也在这里下发:它们的槽位在宿主里是**可选节点**(不给就没有面板),
|
|
14
|
+
* 但既然 `DEFAULT_LAYER_Z` 给它们定了 1500,就必须真的写下去——否则它们的 z 停在
|
|
15
|
+
* `auto`,会被 z 更小的对话层(999)盖住,表现为「面板打开了却看不见」。
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import {
|
|
@@ -17,13 +21,15 @@ import {
|
|
|
17
21
|
type LayerZTable,
|
|
18
22
|
} from "@lingfan-engine/core";
|
|
19
23
|
|
|
20
|
-
/** 由 DOM
|
|
24
|
+
/** 由 DOM 承载的层(顺序即写入顺序;视频/小游戏不在此表) */
|
|
21
25
|
const DOM_LAYERS: readonly LayerId[] = [
|
|
22
26
|
"stage",
|
|
23
27
|
"dialogue",
|
|
24
28
|
"choices",
|
|
25
29
|
"notifications",
|
|
26
30
|
"toolbar",
|
|
31
|
+
"history",
|
|
32
|
+
"prefs",
|
|
27
33
|
];
|
|
28
34
|
|
|
29
35
|
/** 层级视图的输入:工程层默认表 + 两个下发出口(视频层可省) */
|
package/src/host/state-view.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface StateKeyTable {
|
|
|
62
62
|
menuOptions: string;
|
|
63
63
|
/** 菜单选项目标 */
|
|
64
64
|
menuTargets: string;
|
|
65
|
+
/** 输入候选(可选:不订阅则输入等待只有输入框,行为同加候选之前) */
|
|
66
|
+
inputOptions?: string;
|
|
65
67
|
/** 对话框显隐 */
|
|
66
68
|
dialogVisible: string;
|
|
67
69
|
/** 舞台元素表 */
|
|
@@ -140,6 +142,9 @@ export function createStateView(
|
|
|
140
142
|
// 选项与目标两键分别写入:任一到达都做一次整体重建
|
|
141
143
|
if (key === keys.menuOptions || key === keys.menuTargets)
|
|
142
144
|
return { kind: "choices" };
|
|
145
|
+
// 输入候选与输入提示同属选择层:候选到达也重建(否则点了候选后旧候选会留在屏上)
|
|
146
|
+
if (keys.inputOptions !== undefined && key === keys.inputOptions)
|
|
147
|
+
return { kind: "choices" };
|
|
143
148
|
if (key === keys.dialogVisible)
|
|
144
149
|
return { kind: "dialog-visible", hidden: value === "hide" };
|
|
145
150
|
if (key === keys.elements) {
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,22 @@ export {
|
|
|
61
61
|
type VideoRendererOptions,
|
|
62
62
|
} from "./video";
|
|
63
63
|
export { createMinigameRegistry, type MinigameRegistry } from "./minigame";
|
|
64
|
+
export {
|
|
65
|
+
createMountStore,
|
|
66
|
+
missingMounts,
|
|
67
|
+
mountEntryOf,
|
|
68
|
+
mountProps,
|
|
69
|
+
planMounts,
|
|
70
|
+
selectMounts,
|
|
71
|
+
visibleMounts,
|
|
72
|
+
type MountEntry,
|
|
73
|
+
type MountPlan,
|
|
74
|
+
type MountSelection,
|
|
75
|
+
type MountSource,
|
|
76
|
+
type MountStore,
|
|
77
|
+
type MountTable,
|
|
78
|
+
} from "./mount";
|
|
79
|
+
export { continueSlot, latestSave } from "./save";
|
|
64
80
|
export {
|
|
65
81
|
createElementRegistry,
|
|
66
82
|
createElementResourceResolver,
|
|
@@ -72,9 +88,11 @@ export {
|
|
|
72
88
|
hasElementInteraction,
|
|
73
89
|
interpolateAnimation,
|
|
74
90
|
isElementDisabled,
|
|
91
|
+
parseWritebackTarget,
|
|
75
92
|
registerBuiltinElementRenderers,
|
|
76
93
|
renderElementTree,
|
|
77
94
|
resolveElementAction,
|
|
95
|
+
routeElementValueChange,
|
|
78
96
|
shakeOffset,
|
|
79
97
|
transitionOpacity,
|
|
80
98
|
type ElementAction,
|
|
@@ -84,12 +102,15 @@ export {
|
|
|
84
102
|
type ElementRenderContext,
|
|
85
103
|
type ElementResourceResolver,
|
|
86
104
|
type ElementTreeRenderOptions,
|
|
105
|
+
type ElementWritebackPorts,
|
|
87
106
|
type RenderEvent,
|
|
107
|
+
type RenderEventName,
|
|
88
108
|
type RenderEventPatch,
|
|
89
109
|
type RenderKind,
|
|
90
110
|
type RenderPort,
|
|
91
111
|
type RenderPropValue,
|
|
92
112
|
type RenderSpec,
|
|
113
|
+
type WritebackTarget,
|
|
93
114
|
} from "./element";
|
|
94
115
|
export {
|
|
95
116
|
createDomOverlaySurface,
|
|
@@ -106,6 +127,7 @@ export {
|
|
|
106
127
|
export {
|
|
107
128
|
GAME_INPUT_BLOCKED_SELECTOR,
|
|
108
129
|
INPUT_SCOPES,
|
|
130
|
+
UI_ZONE_ATTRIBUTE,
|
|
109
131
|
createInputScopeState,
|
|
110
132
|
isGameInputTarget,
|
|
111
133
|
isInputScope,
|
|
@@ -139,6 +161,7 @@ export {
|
|
|
139
161
|
createLayerView,
|
|
140
162
|
createStateView,
|
|
141
163
|
createVisualEffects,
|
|
164
|
+
firstGameColumn,
|
|
142
165
|
isDialogueSuppressed,
|
|
143
166
|
isNvlActive,
|
|
144
167
|
isTypingEnabled,
|
package/src/input/index.ts
CHANGED
package/src/input/scope.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 1. **事件目标层面**([`isGameInputTarget`]):舞台铺满视口且内部挂着工具条与面板,
|
|
7
7
|
* 事件会从这些控件冒泡上来。命中原生输入控件、或位于标记为 UI 容器的元素内
|
|
8
|
-
*
|
|
8
|
+
* ([`UI_ZONE_ATTRIBUTE`])即非游戏输入——否则在历史面板里滚动会连带把故事回退,
|
|
9
9
|
* 输入框里的空格会被推进逻辑消费。
|
|
10
10
|
* 2. **模式层面**([`InputScope`]):外部玩法系统接管期间(方向键行走、战斗、QTE),
|
|
11
11
|
* 推进类按键应整体让位,而不是逐事件判断。
|
|
@@ -35,9 +35,17 @@ export function isInputScope(value: unknown): value is InputScope {
|
|
|
35
35
|
return value === "dialogue" || value === "world" || value === "ui";
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* 界面区标记:带此属性的节点内的滚轮 / 按键归 UI,不穿透到游戏输入。
|
|
40
|
+
*
|
|
41
|
+
* 语义归属在**输入域**这一侧,不归宿主:宿主只负责给面板根打上这个属性,
|
|
42
|
+
* 「带了它就等于 UI 输入」这条判据由本模块定义。所以常量声明在这里,
|
|
43
|
+
* 宿主与嵌入方 import 使用——依赖方向是收窄(宿主不再自持一份字面量),不是扩大。
|
|
44
|
+
*/
|
|
45
|
+
export const UI_ZONE_ATTRIBUTE = "data-ui-zone";
|
|
46
|
+
|
|
38
47
|
/** 原生输入控件 + UI 容器标记;命中即非游戏输入 */
|
|
39
|
-
export const GAME_INPUT_BLOCKED_SELECTOR =
|
|
40
|
-
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [data-ui-zone]';
|
|
48
|
+
export const GAME_INPUT_BLOCKED_SELECTOR = `input, textarea, select, [contenteditable]:not([contenteditable="false"]), [${UI_ZONE_ATTRIBUTE}]`;
|
|
41
49
|
|
|
42
50
|
/** 判定所需的最小事件目标接口(显式入参便于单测;真实值 = DOM Element) */
|
|
43
51
|
export interface ClosestLike {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 挂载域出口(唯一出口):把 `SYS.mounts` 翻译成「该渲染谁、带什么入参、和上一帧比变了几件事」。
|
|
3
|
+
*
|
|
4
|
+
* 这一域只做纯投影、差量与选择,不碰 DOM、不认识框架——三种宿主形态(原生 DOM / Vue / React)
|
|
5
|
+
* 各自把结果落到自己的渲染树上。判据因此可以脱离浏览器跑:同一对输入必得同一份结果。
|
|
6
|
+
*
|
|
7
|
+
* 三件事刻意分开,因为三种宿主需要的粒度不同:
|
|
8
|
+
* - `visibleMounts` / `mountProps` / `mountEntryOf` 回答「现在是什么样」,声明式宿主按它渲染即可;
|
|
9
|
+
* - `selectMounts` / `missingMounts` 再加一层「名字 → 组件」查表与取不到的 fail-closed;
|
|
10
|
+
* - `planMounts` 回答「比上次变了什么」,命令式宿主需要它才知道卸谁、挂谁;
|
|
11
|
+
* - `createMountStore` 是订阅通路:三条形态共用同一份值,退订只有一处实现。
|
|
12
|
+
*/
|
|
13
|
+
export {
|
|
14
|
+
mountEntryOf,
|
|
15
|
+
mountProps,
|
|
16
|
+
visibleMounts,
|
|
17
|
+
type MountEntry,
|
|
18
|
+
type MountTable,
|
|
19
|
+
} from "./projection.ts";
|
|
20
|
+
export { planMounts, type MountPlan } from "./plan.ts";
|
|
21
|
+
export {
|
|
22
|
+
missingMounts,
|
|
23
|
+
selectMounts,
|
|
24
|
+
type MountSelection,
|
|
25
|
+
} from "./selection.ts";
|
|
26
|
+
export {
|
|
27
|
+
createMountStore,
|
|
28
|
+
type MountSource,
|
|
29
|
+
type MountStore,
|
|
30
|
+
} from "./store.ts";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挂载差量:两次 `SYS.mounts` 之间该做哪几件事。
|
|
3
|
+
*
|
|
4
|
+
* 声明式宿主(Vue / React)靠 `v-for` 加 key 自己就能算出增删,命令式宿主(原生 DOM)
|
|
5
|
+
* 不能——它必须明确知道「谁要挂、谁要卸、谁只是换了入参」。差量策略放在这里而不是
|
|
6
|
+
* 渲染器里,是为了让它**可测**:三形态行为一致的判据,最终落成「同一对输入必得同一份差量」。
|
|
7
|
+
*
|
|
8
|
+
* 只比可见性,不猜组件内部:`props` 变了就整份重渲染,不做浅合并、不做逐字段下发。
|
|
9
|
+
* 组件拿到的永远是完整入参——否则「字段被删了」和「字段没传」在组件里无法区分。
|
|
10
|
+
*/
|
|
11
|
+
import { isMountTable, type MountTable } from "@lingfan-engine/core";
|
|
12
|
+
import { visibleMounts } from "./projection.ts";
|
|
13
|
+
|
|
14
|
+
/** 一份差量:卸载、挂载、换入参三组名字(各自码点序) */
|
|
15
|
+
export interface MountPlan {
|
|
16
|
+
/** 上一帧可见、这一帧不可见或已消失:从渲染树移除 */
|
|
17
|
+
readonly unmount: readonly string[];
|
|
18
|
+
/** 这一帧新出现的可见 UI:新建并挂上 */
|
|
19
|
+
readonly mount: readonly string[];
|
|
20
|
+
/** 两帧都可见但入参变了:卸掉旧的、挂上新的(不是改属性) */
|
|
21
|
+
readonly update: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 空差量(首帧之前、或两帧相同) */
|
|
25
|
+
const EMPTY_PLAN: MountPlan = { unmount: [], mount: [], update: [] };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 算两份挂载表之间的差量。
|
|
29
|
+
*
|
|
30
|
+
* @param previous 上一帧的 `SYS.mounts` 原始值(首帧传 `undefined`)
|
|
31
|
+
* @param next 这一帧的原始值
|
|
32
|
+
*/
|
|
33
|
+
export function planMounts(previous: unknown, next: unknown): MountPlan {
|
|
34
|
+
const before = visibleMounts(previous);
|
|
35
|
+
const after = visibleMounts(next);
|
|
36
|
+
if (before.length === 0 && after.length === 0) return EMPTY_PLAN;
|
|
37
|
+
const beforeSet = new Set(before);
|
|
38
|
+
const afterSet = new Set(after);
|
|
39
|
+
const unmount: string[] = [];
|
|
40
|
+
const update: string[] = [];
|
|
41
|
+
for (const name of before) {
|
|
42
|
+
if (!afterSet.has(name)) {
|
|
43
|
+
unmount.push(name);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!sameProps(previous, next, name)) update.push(name);
|
|
47
|
+
}
|
|
48
|
+
const mount = after.filter((name) => !beforeSet.has(name));
|
|
49
|
+
return { unmount, mount, update };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 某个名字在两帧里的入参是否等价(名字必须两帧都可见,调用方已判) */
|
|
53
|
+
function sameProps(previous: unknown, next: unknown, name: string): boolean {
|
|
54
|
+
if (!isMountTable(previous) || !isMountTable(next)) return false;
|
|
55
|
+
return propsKey(previous, name) === propsKey(next, name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 入参的稳定序列化:键排序后拼接,用于「换没换」的判定。
|
|
60
|
+
*
|
|
61
|
+
* 引擎写入侧已保证 props 是 JSON 安全值,所以这里不会遇到函数、循环引用或 `Map`;
|
|
62
|
+
* 真遇到(存档被改坏)也只退化成「判定为不同」,重渲染一次而已,不影响正确性。
|
|
63
|
+
*/
|
|
64
|
+
function propsKey(table: MountTable, name: string): string {
|
|
65
|
+
const props = table[name]?.props;
|
|
66
|
+
if (props === undefined) return "";
|
|
67
|
+
try {
|
|
68
|
+
return stableStringify(props);
|
|
69
|
+
} catch {
|
|
70
|
+
return "\u0000";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 键排序的 JSON 序列化(`JSON.stringify` 的键序随插入序变,不能直接比) */
|
|
75
|
+
function stableStringify(value: unknown): string {
|
|
76
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
79
|
+
}
|
|
80
|
+
const record = value as Record<string, unknown>;
|
|
81
|
+
const keys = Object.keys(record).sort();
|
|
82
|
+
const parts = keys.map(
|
|
83
|
+
(key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`,
|
|
84
|
+
);
|
|
85
|
+
return `{${parts.join(",")}}`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挂载表投影:把 `SYS.mounts` 的原始值变成「该渲染哪些 UI、各自带什么入参」。
|
|
3
|
+
*
|
|
4
|
+
* 三种宿主形态(原生 DOM / Vue / React)共用这一份判定。各写一份的后果不是代码重复,
|
|
5
|
+
* 而是同一份存档在三种宿主上挂出不同的组件——「三形态行为一致」正是靠这里成面的。
|
|
6
|
+
*
|
|
7
|
+
* 表可能来自存档,也可能被改坏(存档是用户可编辑的 JSON)。所以一律先判后用:
|
|
8
|
+
* 判不过按空表处理,渲染层照常工作——少挂一个 UI 比整屏白掉好,这里不抛错。
|
|
9
|
+
*
|
|
10
|
+
* 形态定义不在这里:`MountEntry` / `MountTable` 由引擎契约给出,展示层只消费。
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
isMountTable,
|
|
14
|
+
mountNames,
|
|
15
|
+
type MountEntry,
|
|
16
|
+
type MountTable,
|
|
17
|
+
} from "@lingfan-engine/core";
|
|
18
|
+
|
|
19
|
+
export type { MountEntry, MountTable };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 从 SSOT 原始值投影出可见集合(脏值按空表,不卡死渲染)。
|
|
23
|
+
*
|
|
24
|
+
* 顺序是码点序而非对象键序:同一份状态必须投影出同一串挂载动作,
|
|
25
|
+
* 否则两台机器上的同一存档会挂出不同顺序的组件。
|
|
26
|
+
*/
|
|
27
|
+
export function visibleMounts(raw: unknown): readonly string[] {
|
|
28
|
+
if (!isMountTable(raw)) return [];
|
|
29
|
+
return mountNames(raw).filter((name) => raw[name].visible === true);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 取某 UI 的完整挂载记录:不可见或没有这条记录时返回 `undefined`。
|
|
34
|
+
*
|
|
35
|
+
* 宿主一次拿全(显隐 + 入参),不必为同一个名字判两遍——判两遍就有两次不一致的机会。
|
|
36
|
+
*/
|
|
37
|
+
export function mountEntryOf(
|
|
38
|
+
raw: unknown,
|
|
39
|
+
name: string,
|
|
40
|
+
): MountEntry | undefined {
|
|
41
|
+
if (!isMountTable(raw)) return undefined;
|
|
42
|
+
const entry: MountEntry | undefined = raw[name];
|
|
43
|
+
if (entry === undefined || !entry.visible) return undefined;
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 取某 UI 的 props:不可见时返回 `undefined`。
|
|
49
|
+
*
|
|
50
|
+
* 返回的是记录里的那一份引用,不是拷贝——调用方只读。要改就整表新建(写时复制)。
|
|
51
|
+
*/
|
|
52
|
+
export function mountProps(
|
|
53
|
+
raw: unknown,
|
|
54
|
+
name: string,
|
|
55
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
56
|
+
return mountEntryOf(raw, name)?.props;
|
|
57
|
+
}
|