@lingfan-engine/ui 0.2.0 → 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 +22 -1
- package/src/element/index.ts +7 -0
- package/src/element/registry.ts +10 -0
- package/src/element/render.ts +11 -1
- 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/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/create.ts +2 -2
- 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
|
@@ -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/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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挂载选择:把「可见集合」和「名字 → 组件」表合成一份可直接渲染的清单。
|
|
3
|
+
*
|
|
4
|
+
* 三种宿主形态的模板各写一遍「按名字取组件、取不到就跳过」的后果不是代码重复,
|
|
5
|
+
* 而是同一份存档在三种宿主上挂出不同的组件集合——取不到时一种静默跳过、另一种抛错,
|
|
6
|
+
* 排查起来就是三套现象。选择逻辑收在这里,模板只负责把清单铺到自己的渲染树上。
|
|
7
|
+
*
|
|
8
|
+
* 取不到组件一律 fail-closed:**不渲染**那一条,并**逐条上报**名字。
|
|
9
|
+
* 静默跳过是这里最坏的选项——作者写了 `mount hub true` 却没看见组件,无从下手;
|
|
10
|
+
* 抛错则会让一个笔误黑掉整屏。上报走注入的回调,宿主决定写错误条还是打控制台。
|
|
11
|
+
*/
|
|
12
|
+
import { isMountTable } from "@lingfan-engine/core";
|
|
13
|
+
import { mountProps, visibleMounts } from "./projection.ts";
|
|
14
|
+
|
|
15
|
+
/** 一条可渲染的挂载:名字、组件本身、完整入参 */
|
|
16
|
+
export interface MountSelection {
|
|
17
|
+
/** UI 名(= `src/ui/` 下的目录名,也是 `SYS.mounts` 的键) */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** 组件:Vue / React 形态是组件对象,原生形态是工厂函数;本域不解释它 */
|
|
20
|
+
readonly component: unknown;
|
|
21
|
+
/** 该 UI 这一帧的完整入参(不可见时不会有这一条) */
|
|
22
|
+
readonly props: Readonly<Record<string, unknown>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 算出这一帧该渲染哪些 UI。
|
|
27
|
+
*
|
|
28
|
+
* @param raw `SYS.mounts` 的原始值(脏值按空表,不抛)
|
|
29
|
+
* @param table 「名字 → 组件」表(生成物 `uis`)
|
|
30
|
+
* @param onMissing 取不到组件时的上报口;缺省只静默跳过(不建议——见文件头)
|
|
31
|
+
*/
|
|
32
|
+
export function selectMounts(
|
|
33
|
+
raw: unknown,
|
|
34
|
+
table: Readonly<Record<string, unknown>>,
|
|
35
|
+
onMissing?: (name: string) => void,
|
|
36
|
+
): readonly MountSelection[] {
|
|
37
|
+
const out: MountSelection[] = [];
|
|
38
|
+
for (const name of visibleMounts(raw)) {
|
|
39
|
+
const component = table[name];
|
|
40
|
+
if (component === undefined) {
|
|
41
|
+
onMissing?.(name);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
out.push({ name, component, props: mountProps(raw, name) ?? {} });
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 表里没有这个名字的可见 UI 名单(宿主用它做一次性诊断,避免每帧重复上报)。
|
|
51
|
+
*
|
|
52
|
+
* `selectMounts` 的 `onMissing` 是逐帧回调,命令式宿主适合直接接;
|
|
53
|
+
* 声明式宿主按帧重渲染,重复上报会刷屏——用它算「稳定差集」再自己决定上报时机。
|
|
54
|
+
*/
|
|
55
|
+
export function missingMounts(
|
|
56
|
+
raw: unknown,
|
|
57
|
+
table: Readonly<Record<string, unknown>>,
|
|
58
|
+
): readonly string[] {
|
|
59
|
+
if (!isMountTable(raw)) return [];
|
|
60
|
+
return visibleMounts(raw).filter((name) => table[name] === undefined);
|
|
61
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挂载表订阅:把 `SYS.mounts` 的变化做成一个**宿主无关**的可订阅值。
|
|
3
|
+
*
|
|
4
|
+
* 三种宿主形态要的是同一件事——「现在该挂谁、各自带什么入参」,但拿值的方式不同:
|
|
5
|
+
* 命令式宿主(原生 DOM)要一个回调去算差量,声明式宿主(Vue / React)要一个能进各自
|
|
6
|
+
* 响应式系统的快照 + 订阅对。这里只提供最小面:`snapshot()` 读当前值、`subscribe()`
|
|
7
|
+
* 订阅变化、`set()` 由装配处把引擎状态推进来。
|
|
8
|
+
*
|
|
9
|
+
* 为什么不让各形态自己订阅引擎:订阅与退订就成了三份实现,任何一份漏掉退订都会在
|
|
10
|
+
* 页面拆掉之后继续往已销毁的渲染树里写。值在这里只有一条通路。
|
|
11
|
+
*
|
|
12
|
+
* 本文件不碰 DOM、不认识框架、没有模块级可变状态——一个 store 的全部状态都在闭包里。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 只读视图:声明式宿主拿它渲染,命令式宿主拿它算差量 */
|
|
16
|
+
export interface MountSource {
|
|
17
|
+
/** 当前 `SYS.mounts` 原始值;引擎还没写过时是 `undefined` */
|
|
18
|
+
snapshot(): unknown;
|
|
19
|
+
/** 订阅变化,返回退订函数。订阅时**不**立刻回调——调用方先自己读一次快照 */
|
|
20
|
+
subscribe(listener: (raw: unknown) => void): () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 可写视图:只有装配处拿得到,写入口不在渲染器手里 */
|
|
24
|
+
export interface MountStore extends MountSource {
|
|
25
|
+
/** 写入新值;与当前值是同一个引用时不通知(引擎写侧本就是整表新建) */
|
|
26
|
+
set(raw: unknown): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 造一个挂载表 store。
|
|
31
|
+
*
|
|
32
|
+
* @param initial 初始值;缺省 `undefined` 即「引擎还没报过」,投影按空表处理
|
|
33
|
+
*/
|
|
34
|
+
export function createMountStore(initial?: unknown): MountStore {
|
|
35
|
+
let current: unknown = initial;
|
|
36
|
+
const listeners = new Set<(raw: unknown) => void>();
|
|
37
|
+
return {
|
|
38
|
+
snapshot(): unknown {
|
|
39
|
+
return current;
|
|
40
|
+
},
|
|
41
|
+
set(raw: unknown): void {
|
|
42
|
+
if (Object.is(raw, current)) return;
|
|
43
|
+
current = raw;
|
|
44
|
+
// 复制一份再遍历:监听器里退订自己是常见写法,直接遍历 Set 会跳过后一个
|
|
45
|
+
for (const listener of [...listeners]) listener(current);
|
|
46
|
+
},
|
|
47
|
+
subscribe(listener: (raw: unknown) => void): () => void {
|
|
48
|
+
listeners.add(listener);
|
|
49
|
+
return (): void => {
|
|
50
|
+
listeners.delete(listener);
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|