@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,47 @@
1
+ /**
2
+ * 宿主公共面出口(唯一出口):两个参考宿主(Vue 参考宿主、原生 DOM 模板宿主)共用的
3
+ * 「表现层骨架」——帧循环、状态分发、层级下发、帧驱动表现、节律默认值。
4
+ *
5
+ * 这里只放**与平台无关的纯逻辑**:DOM 写入、框架状态、样式表统统留在各宿主自己的目录里。
6
+ * 判据很简单——本目录不得出现 `document` / `window` / 框架 import / 模块级可变状态;
7
+ * 需要碰 DOM 的地方一律以接口形式把动作注入进来。
8
+ */
9
+ export {
10
+ DEFAULT_ACTION_TOAST_MS,
11
+ DEFAULT_LOAD_TOAST_MS,
12
+ DEFAULT_NOTIFY_MS,
13
+ DEFAULT_ROLLBACK_NOTICE_MS,
14
+ DEFAULT_TEXT_CPS,
15
+ DEFAULT_TOAST_MS,
16
+ } from "./duration";
17
+ export {
18
+ createVisualEffects,
19
+ resolveAnimatedStyle,
20
+ type AnimatedStyle,
21
+ type AnimationHost,
22
+ type EffectSource,
23
+ type EffectSurfaceHost,
24
+ type VisualEffectsOptions,
25
+ } from "./effects";
26
+ export {
27
+ createFrameLoop,
28
+ type FrameLoop,
29
+ type FrameLoopOptions,
30
+ type FrameSource,
31
+ } from "./frame-loop";
32
+ export {
33
+ createLayerView,
34
+ type LayerView,
35
+ type LayerViewOptions,
36
+ } from "./layer-view";
37
+ export {
38
+ createStateView,
39
+ isDialogueSuppressed,
40
+ isNvlActive,
41
+ isTypingEnabled,
42
+ resolveTypingCps,
43
+ type StateIntent,
44
+ type StateKeyTable,
45
+ type StateViewOptions,
46
+ type TypingSetting,
47
+ } from "./state-view";
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 层级 z:把「实例级 z 覆盖」这张表按层落到渲染面上。
3
+ *
4
+ * 一个层最终用哪个 z 由三级链决定(实例 > 工程层默认 > 内建),判定本身在引擎里
5
+ * (`resolveInstanceZ`);本模块只负责两件事:
6
+ * 1. 记住某层被命令参数覆盖成了什么(`setOverride`),值不是数字时视为「回层默认」;
7
+ * 2. 用注入的 `applyLayerZ` 把六个 DOM 层交给宿主去写。
8
+ *
9
+ * 视频层单独一个方法:它的 z 不在 DOM 上而在视频端口内部,且**只在视频层自己变化时**
10
+ * 才需要下发。合并进 `apply()` 会让「改标题栏层级」也写入一次视频样式——多出来的
11
+ * 那一次写没有任何语义,却足以让「谁在改 DOM」变得难查。
12
+ */
13
+
14
+ import {
15
+ resolveInstanceZ,
16
+ type LayerId,
17
+ type LayerZTable,
18
+ } from "@lingfan-engine/core";
19
+
20
+ /** 由 DOM 承载的层(顺序即写入顺序;视频/小游戏/历史/偏好不在此表) */
21
+ const DOM_LAYERS: readonly LayerId[] = [
22
+ "stage",
23
+ "dialogue",
24
+ "choices",
25
+ "notifications",
26
+ "toolbar",
27
+ ];
28
+
29
+ /** 层级视图的输入:工程层默认表 + 两个下发出口(视频层可省) */
30
+ export interface LayerViewOptions {
31
+ /** 工程层默认(`project.json` 的 `shell.layers`) */
32
+ layerZ: LayerZTable;
33
+ /** 写某个 DOM 层的 z 序 */
34
+ applyLayerZ(layer: LayerId, z: number): void;
35
+ /** 视频层 z 下发(端口内部;不提供时 `applyVideo()` 为空操作) */
36
+ onVideoZ?(z: number): void;
37
+ }
38
+
39
+ /** 层级视图句柄:实例级覆盖的收纳与下发 */
40
+ export interface LayerView {
41
+ /** 命令参数写入的实例级覆盖:`undefined` = 回层默认 */
42
+ setOverride(layer: string, value: number | undefined): void;
43
+ /** 某个层的最终 z */
44
+ zOf(layer: LayerId): number;
45
+ /** 把所有 DOM 层的最终 z 下发一次(装配期与每次覆盖变化后调用) */
46
+ apply(): void;
47
+ /** 单独下发视频层 z(仅在该层变化后调用) */
48
+ applyVideo(): void;
49
+ }
50
+
51
+ /**
52
+ * 创建层级视图:覆盖表在闭包里,每实例一份。
53
+ * `apply()` 只遍历由 DOM 承载的层;视频层走 `applyVideo()` 单独下发。
54
+ */
55
+ export function createLayerView(options: LayerViewOptions): LayerView {
56
+ const overrides: Partial<Record<string, number>> = {};
57
+
58
+ const zOf = (layer: LayerId): number =>
59
+ resolveInstanceZ(layer, overrides[layer], options.layerZ);
60
+
61
+ return {
62
+ setOverride(layer: string, value: number | undefined): void {
63
+ if (value === undefined) delete overrides[layer];
64
+ else overrides[layer] = value;
65
+ },
66
+ zOf,
67
+ apply(): void {
68
+ for (const layer of DOM_LAYERS) options.applyLayerZ(layer, zOf(layer));
69
+ },
70
+ applyVideo(): void {
71
+ options.onVideoZ?.(zOf("video"));
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * 状态分发:把引擎状态变更(`{ key, value }`)翻译成宿主认得的一条**视图意图**。
3
+ *
4
+ * 引擎的状态键是弱类型的(`value: unknown`),每个键怎么解读、缺省怎么回退,是一套容易
5
+ * 在多个宿主里各写一遍、然后慢慢走样的规则:数值键要不要落回层默认、数组键要不要兜空、
6
+ * `"hide"` 算不算隐藏、打字机开关缺省是开还是关……这里把这套规则收成一处。
7
+ *
8
+ * 分工是「意图带数据、导出函数带规则」:意图只把状态里的事实搬出来(文本、设置、数组),
9
+ * 需要宿主提供外部事实才能算出的值(默认字速、角色配色)走具名注入或导出的纯函数,
10
+ * 于是宿主的 switch 里只剩「落到自己的 DOM / 框架状态上」这一件事。
11
+ *
12
+ * **键名与键分类都由宿主注入**:订阅哪几个状态键是宿主与引擎之间的接线契约,本模块不内置
13
+ * 任何键名;「键名 → 层名」的判定也用宿主给的函数(通常就是引擎的 `instanceZLayer`)。
14
+ *
15
+ * 本模块是纯函数式的:不持有视图状态、不写 DOM。
16
+ */
17
+
18
+ import type { ElementInstance, LayerId } from "@lingfan-engine/core";
19
+
20
+ /** 故事级打字机设置(`text_typewriter` 命令写入的状态形态) */
21
+ export interface TypingSetting {
22
+ enabled?: boolean;
23
+ speed?: number;
24
+ }
25
+
26
+ /** 一条视图意图:`kind` 决定宿主去看哪个字段 */
27
+ export type StateIntent =
28
+ /** 某层的实例级 z 被改写(`z` 为 undefined = 回层默认) */
29
+ | { kind: "layer"; layer: LayerId; z: number | undefined }
30
+ /** 说话人一行:空串 = 无人说话 */
31
+ | { kind: "speaker"; text: string; color: string | null }
32
+ /** 新的一句正文;`setting` 为 undefined = 用宿主默认(打字机开、默认字速) */
33
+ | { kind: "dialog-text"; text: string; setting: TypingSetting | undefined }
34
+ /** 等待态机的当前态(决定对话层让位与选项区形态) */
35
+ | { kind: "waiting"; waiting: string }
36
+ /** NVL 模式(`"none"` = 关闭) */
37
+ | { kind: "nvl-mode"; mode: string }
38
+ /** NVL 缓冲行(整体替换) */
39
+ | { kind: "nvl-buffer"; lines: readonly string[] }
40
+ /** 菜单选项或输入提示变了:选项区整体重建 */
41
+ | { kind: "choices" }
42
+ /** 对话框显隐(`window show|hide`) */
43
+ | { kind: "dialog-visible"; hidden: boolean }
44
+ /** 舞台元素整体替换 */
45
+ | { kind: "elements"; elements: readonly ElementInstance[] }
46
+ /** 与本宿主无关的键 */
47
+ | { kind: "none" };
48
+
49
+ /** 宿主订阅的状态键表:每项是引擎状态键的名字,只是换个可读的别名 */
50
+ export interface StateKeyTable {
51
+ /** 当前说话人 */
52
+ speaker: string;
53
+ /** 当前正文 */
54
+ dialogText: string;
55
+ /** 等待态 */
56
+ waiting: string;
57
+ /** NVL 模式 */
58
+ nvlMode: string;
59
+ /** NVL 缓冲 */
60
+ nvlBuffer: string;
61
+ /** 菜单选项文本 */
62
+ menuOptions: string;
63
+ /** 菜单选项目标 */
64
+ menuTargets: string;
65
+ /** 对话框显隐 */
66
+ dialogVisible: string;
67
+ /** 舞台元素表 */
68
+ elements: string;
69
+ }
70
+
71
+ /** 状态分发器的输入:键表 + 三个查询出口(层级反查、打字机设置、角色配色) */
72
+ export interface StateViewOptions {
73
+ /** 宿主订阅的键表 */
74
+ keys: StateKeyTable;
75
+ /** 键名 → 该键是否承载某一层的实例级 z;不是层级键时返回 undefined */
76
+ resolveLayer(key: string): LayerId | undefined;
77
+ /** 读当前故事级打字机设置 */
78
+ readTypingSetting(): TypingSetting | undefined;
79
+ /** 角色配色查询;无名或未定义配色时返回 null */
80
+ readCharacterColor(name: string): string | null;
81
+ }
82
+
83
+ /** 读说话人一行:状态键里存的是字符串,非字符串一律当空 */
84
+ function readSpeakerIntent(
85
+ value: unknown,
86
+ readCharacterColor: (name: string) => string | null,
87
+ ): StateIntent {
88
+ const text = typeof value === "string" ? value : "";
89
+ // 空串也要查一次:名字为空时配色查询的返回值同样是配色口径的一部分,跳过会引入分叉
90
+ return { kind: "speaker", text, color: readCharacterColor(text) };
91
+ }
92
+
93
+ /**
94
+ * 创建状态分发器:返回的函数对每个状态键给出一条意图。
95
+ * 各键互不重叠,故分支顺序只影响可读性。
96
+ */
97
+ export function createStateView(
98
+ options: StateViewOptions,
99
+ ): (key: string, value: unknown) => StateIntent {
100
+ const { keys } = options;
101
+ return (key: string, value: unknown): StateIntent => {
102
+ // 实例级 z:先问宿主这个键是不是层级键,值非数字时视为「回层默认」
103
+ const layer = options.resolveLayer(key);
104
+ if (layer !== undefined) {
105
+ return {
106
+ kind: "layer",
107
+ layer,
108
+ z: typeof value === "number" ? value : undefined,
109
+ };
110
+ }
111
+ if (key === keys.speaker) {
112
+ return readSpeakerIntent(value, options.readCharacterColor);
113
+ }
114
+ if (key === keys.dialogText) {
115
+ const text = typeof value === "string" ? value : "";
116
+ return {
117
+ kind: "dialog-text",
118
+ text,
119
+ setting: options.readTypingSetting(),
120
+ };
121
+ }
122
+ if (key === keys.waiting) {
123
+ return {
124
+ kind: "waiting",
125
+ waiting: typeof value === "string" ? value : "none",
126
+ };
127
+ }
128
+ if (key === keys.nvlMode) {
129
+ return {
130
+ kind: "nvl-mode",
131
+ mode: typeof value === "string" ? value : "none",
132
+ };
133
+ }
134
+ if (key === keys.nvlBuffer) {
135
+ return {
136
+ kind: "nvl-buffer",
137
+ lines: Array.isArray(value) ? (value as string[]) : [],
138
+ };
139
+ }
140
+ // 选项与目标两键分别写入:任一到达都做一次整体重建
141
+ if (key === keys.menuOptions || key === keys.menuTargets)
142
+ return { kind: "choices" };
143
+ if (key === keys.dialogVisible)
144
+ return { kind: "dialog-visible", hidden: value === "hide" };
145
+ if (key === keys.elements) {
146
+ return {
147
+ kind: "elements",
148
+ elements: (value as ElementInstance[] | undefined) ?? [],
149
+ };
150
+ }
151
+ return { kind: "none" };
152
+ };
153
+ }
154
+
155
+ /**
156
+ * 本句字速:故事给了正数就用它,否则用宿主默认。
157
+ * `0` / 负数 / 非数字都当没给——`0` 会让打字机永远不前进(表现为卡死在空台词)。
158
+ */
159
+ export function resolveTypingCps(
160
+ setting: TypingSetting | undefined,
161
+ fallback: number,
162
+ ): number {
163
+ const speed = setting?.speed;
164
+ return typeof speed === "number" && speed > 0 ? speed : fallback;
165
+ }
166
+
167
+ /** 本句是否走打字机:只有显式 `enabled === false` 才整句直出,缺省是打字机开 */
168
+ export function isTypingEnabled(setting: TypingSetting | undefined): boolean {
169
+ return setting?.enabled !== false;
170
+ }
171
+
172
+ /**
173
+ * NVL 层是否生效:模式开着**且**有内容。
174
+ * 只有模式而无内容时仍走普通对话框——否则会闪出一个空白整屏层。
175
+ */
176
+ export function isNvlActive(mode: string, lines: readonly string[]): boolean {
177
+ return mode !== "none" && lines.length > 0;
178
+ }
179
+
180
+ /**
181
+ * 对话层是否让位。三个让位条件互相独立:
182
+ * NVL 接管、故事显式隐藏、以及视频等待期——视频要占满舞台,压不住底部对话框会很难看。
183
+ */
184
+ export function isDialogueSuppressed(state: {
185
+ nvlActive: boolean;
186
+ hidden: boolean;
187
+ waiting: string;
188
+ }): boolean {
189
+ return state.nvlActive || state.hidden || state.waiting === "video";
190
+ }
package/src/index.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 参考展示层出口:按渲染域归类(dialogue 对话渲染 / audio 音频差量 / video 视频命令流),
3
+ * 框架无关的可测纯逻辑。具体宿主(Vue/React/原生 DOM)只消费这些入口,换框架不影响核心。
4
+ */
5
+ export {
6
+ configureStoryFonts,
7
+ renderInlineMarkup,
8
+ sanitizeFont,
9
+ type StoryFontGate,
10
+ Typewriter,
11
+ renderDialogueLine,
12
+ type DialogueLineInput,
13
+ type DialogueLineView,
14
+ } from "./dialogue";
15
+ export {
16
+ builtinBubbleTemplate,
17
+ createDialogueTemplateRegistry,
18
+ type DialogueTemplateFn,
19
+ type DialogueTemplateInput,
20
+ type DialogueTemplateRegistry,
21
+ type DialogueTemplateView,
22
+ } from "./dialogue";
23
+ export {
24
+ TemplateRegistry,
25
+ createTemplateRegistry,
26
+ type TemplateFn,
27
+ type TemplateViewBase,
28
+ } from "./templates";
29
+ export {
30
+ builtinChoiceTemplate,
31
+ createChoiceTemplateRegistry,
32
+ type ChoiceTemplateFn,
33
+ type ChoiceTemplateInput,
34
+ type ChoiceTemplateOption,
35
+ type ChoiceTemplateRegistry,
36
+ type ChoiceTemplateView,
37
+ } from "./choices";
38
+ export {
39
+ builtinNotifyTemplate,
40
+ createNotifyTemplateRegistry,
41
+ toNotifyTone,
42
+ type NotifyTemplateFn,
43
+ type NotifyTemplateInput,
44
+ type NotifyTemplateRegistry,
45
+ type NotifyTemplateView,
46
+ type NotifyTone,
47
+ } from "./notify";
48
+ export {
49
+ EMPTY_AUDIO_VIEW,
50
+ createAudioRenderer,
51
+ planAudioActions,
52
+ readAudioView,
53
+ type AudioAction,
54
+ type AudioRenderer,
55
+ type AudioRendererOptions,
56
+ type AudioView,
57
+ } from "./audio";
58
+ export {
59
+ createVideoRenderer,
60
+ type VideoRenderer,
61
+ type VideoRendererOptions,
62
+ } from "./video";
63
+ export { createMinigameRegistry, type MinigameRegistry } from "./minigame";
64
+ export {
65
+ createElementRegistry,
66
+ createElementResourceResolver,
67
+ easingFn,
68
+ easingNames,
69
+ elementClassName,
70
+ elementSource,
71
+ elementStyle,
72
+ hasElementInteraction,
73
+ interpolateAnimation,
74
+ isElementDisabled,
75
+ registerBuiltinElementRenderers,
76
+ renderElementTree,
77
+ resolveElementAction,
78
+ shakeOffset,
79
+ transitionOpacity,
80
+ type ElementAction,
81
+ type ElementActionOptions,
82
+ type ElementRegistry,
83
+ type ElementRenderer,
84
+ type ElementRenderContext,
85
+ type ElementResourceResolver,
86
+ type ElementTreeRenderOptions,
87
+ type RenderEvent,
88
+ type RenderEventPatch,
89
+ type RenderKind,
90
+ type RenderPort,
91
+ type RenderPropValue,
92
+ type RenderSpec,
93
+ } from "./element";
94
+ export {
95
+ createDomOverlaySurface,
96
+ createDomRenderPort,
97
+ materialize,
98
+ type DomOverlaySurface,
99
+ type NarrativeMounts,
100
+ } from "./dom";
101
+ export {
102
+ createCommandRegistry,
103
+ type CommandRegistry,
104
+ type NamedCommandHandler,
105
+ } from "./commands";
106
+ export {
107
+ GAME_INPUT_BLOCKED_SELECTOR,
108
+ INPUT_SCOPES,
109
+ createInputScopeState,
110
+ isGameInputTarget,
111
+ isInputScope,
112
+ routesToNarrative,
113
+ type ClosestLike,
114
+ type InputScope,
115
+ type InputScopeState,
116
+ } from "./input";
117
+ export {
118
+ createNarrativeOverlay,
119
+ type ChoicesLayerInput,
120
+ type ChoicesLayerOption,
121
+ type ChoicesLayerView,
122
+ type ChoicesTexts,
123
+ type DialogueLayerView,
124
+ type NarrativeOverlay,
125
+ type NarrativeOverlayOptions,
126
+ type NarrativeOverlayView,
127
+ type NotifyLayerItem,
128
+ type OverlayLayerId,
129
+ type OverlaySurface,
130
+ } from "./overlay";
131
+ export {
132
+ DEFAULT_ACTION_TOAST_MS,
133
+ DEFAULT_LOAD_TOAST_MS,
134
+ DEFAULT_NOTIFY_MS,
135
+ DEFAULT_ROLLBACK_NOTICE_MS,
136
+ DEFAULT_TEXT_CPS,
137
+ DEFAULT_TOAST_MS,
138
+ createFrameLoop,
139
+ createLayerView,
140
+ createStateView,
141
+ createVisualEffects,
142
+ isDialogueSuppressed,
143
+ isNvlActive,
144
+ isTypingEnabled,
145
+ resolveAnimatedStyle,
146
+ resolveTypingCps,
147
+ type AnimatedStyle,
148
+ type AnimationHost,
149
+ type EffectSource,
150
+ type EffectSurfaceHost,
151
+ type FrameLoop,
152
+ type FrameLoopOptions,
153
+ type FrameSource,
154
+ type LayerView,
155
+ type LayerViewOptions,
156
+ type StateIntent,
157
+ type StateKeyTable,
158
+ type StateViewOptions,
159
+ type TypingSetting,
160
+ type VisualEffectsOptions,
161
+ } from "./host";
@@ -0,0 +1,11 @@
1
+ export {
2
+ GAME_INPUT_BLOCKED_SELECTOR,
3
+ INPUT_SCOPES,
4
+ createInputScopeState,
5
+ isGameInputTarget,
6
+ isInputScope,
7
+ routesToNarrative,
8
+ type ClosestLike,
9
+ type InputScope,
10
+ type InputScopeState,
11
+ } from "./scope";
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 输入域声明与游戏输入判据(框架无关纯逻辑,宿主与嵌入方共用)。
3
+ *
4
+ * 回答两个不同层面的「谁消费输入」:
5
+ *
6
+ * 1. **事件目标层面**([`isGameInputTarget`]):舞台铺满视口且内部挂着工具条与面板,
7
+ * 事件会从这些控件冒泡上来。命中原生输入控件、或位于标记为 UI 容器的元素内
8
+ * (`[data-ui-zone]`)即非游戏输入——否则在历史面板里滚动会连带把故事回退,
9
+ * 输入框里的空格会被推进逻辑消费。
10
+ * 2. **模式层面**([`InputScope`]):外部玩法系统接管期间(方向键行走、战斗、QTE),
11
+ * 推进类按键应整体让位,而不是逐事件判断。
12
+ *
13
+ * 两者正交,[`routesToNarrative`] 同时要求「域为对话」与「目标不是控件」。
14
+ * 域是**宿主状态**而非引擎状态:输入路由属展示层职责,引擎只暴露等待态
15
+ * (`__waiting`),由宿主决定把按键交给谁。
16
+ *
17
+ * 有意不看「容器当前是否真能滚动」:同一面板的滚轮行为不应随内容多少变脸
18
+ * (内容不满一屏时也一律不回溯,避免滚轮在面板里时而回溯时而滚动)。
19
+ */
20
+
21
+ /** 输入域:本帧的按键 / 滚轮 / 点击归谁消费 */
22
+ export type InputScope =
23
+ /** 对话域(默认):推进、回溯、前进由叙事层消费 */
24
+ | "dialogue"
25
+ /** 外部玩法域:叙事层整体让位,输入归外部系统(行走、战斗…) */
26
+ | "world"
27
+ /** 面板域:输入归界面控件,叙事层不消费 */
28
+ | "ui";
29
+
30
+ /** 域全集(诊断 / 遍历用;顺序 = 默认在前) */
31
+ export const INPUT_SCOPES: readonly InputScope[] = ["dialogue", "world", "ui"];
32
+
33
+ /** 域值收窄:非声明值一律不成立(外部传入的字符串不做猜测) */
34
+ export function isInputScope(value: unknown): value is InputScope {
35
+ return value === "dialogue" || value === "world" || value === "ui";
36
+ }
37
+
38
+ /** 原生输入控件 + UI 容器标记;命中即非游戏输入 */
39
+ export const GAME_INPUT_BLOCKED_SELECTOR =
40
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [data-ui-zone]';
41
+
42
+ /** 判定所需的最小事件目标接口(显式入参便于单测;真实值 = DOM Element) */
43
+ export interface ClosestLike {
44
+ closest(selector: string): unknown | null;
45
+ }
46
+
47
+ /**
48
+ * 事件目标是否属于游戏域(`true` = 应触发游戏输入)。
49
+ * 不具备 `closest` 能力的目标(视口/文档/非元素)保守视为游戏域——它们不可能是控件内部。
50
+ */
51
+ export function isGameInputTarget(target: unknown): boolean {
52
+ if (target === null || typeof target !== "object") return true;
53
+ const el = target as Partial<ClosestLike>;
54
+ if (typeof el.closest !== "function") return true;
55
+ return el.closest(GAME_INPUT_BLOCKED_SELECTOR) === null;
56
+ }
57
+
58
+ /**
59
+ * 本次输入是否归叙事层消费:**域为对话** 且 **目标不在控件/UI 面板内**。
60
+ *
61
+ * 任一不成立即不消费,宿主据此短路——玩法接管期间连 `advance` 都不发,
62
+ * 避免「让位后仍在后台推进故事」这类竞态。
63
+ */
64
+ export function routesToNarrative(scope: InputScope, target: unknown): boolean {
65
+ return scope === "dialogue" && isGameInputTarget(target);
66
+ }
67
+
68
+ /** 可变输入域(宿主持有;订阅用于界面提示与路由刷新) */
69
+ export interface InputScopeState {
70
+ current(): InputScope;
71
+ /** 切换域;非法值忽略(保持原域)——域来自宿主,畸形输入不猜 */
72
+ set(scope: InputScope): void;
73
+ /** 订阅域变化;返回退订函数 */
74
+ subscribe(listener: (scope: InputScope) => void): () => void;
75
+ }
76
+
77
+ /**
78
+ * 建输入域状态。默认 `dialogue`(叙事激活),玩法系统接管时由宿主切 `world`。
79
+ * 同值重复设置不通知订阅者(宿主可能在每帧断言域)。
80
+ */
81
+ export function createInputScopeState(
82
+ initial: InputScope = "dialogue",
83
+ ): InputScopeState {
84
+ let scope: InputScope = initial;
85
+ const listeners = new Set<(scope: InputScope) => void>();
86
+ return {
87
+ current: () => scope,
88
+ set(next: InputScope): void {
89
+ if (!isInputScope(next) || next === scope) return;
90
+ scope = next;
91
+ for (const listener of listeners) listener(scope);
92
+ },
93
+ subscribe(listener: (scope: InputScope) => void): () => void {
94
+ listeners.add(listener);
95
+ return () => {
96
+ listeners.delete(listener);
97
+ };
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,2 @@
1
+ /** 小游戏渲染域出口:小游戏注册表(宿主按名字取用实现,渲染层不内建任何小游戏)。 */
2
+ export { createMinigameRegistry, type MinigameRegistry } from "./registry";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 小游戏注册表(UI 层职责):minigame 命令只声明 gameId,
3
+ * 宿主经注册表解析工厂后挂载。与对话框模板注册表的关键差异:**未知 gameId 无默认回退**
4
+ * ——get 返回 undefined = fail-closed(宿主必须显式上报,不得伪造完成)。
5
+ */
6
+
7
+ import type { MinigameFactory } from "@lingfan-engine/core";
8
+
9
+ /**
10
+ * 小游戏 id → 工厂的查表。`get` 未命中即 `undefined`,调用方必须显式上报
11
+ * ——玩法系统未注册时不得上报完成结果,否则叙事会带着假的完成结果往下走。
12
+ */
13
+ export interface MinigameRegistry {
14
+ /** 注册/覆盖更新(同 gameId 再注册 = 替换,开发热替换语义) */
15
+ register(gameId: string, factory: MinigameFactory): void;
16
+ /** 解析工厂;未注册 = undefined(fail-closed:不伪造默认实现) */
17
+ get(gameId: string): MinigameFactory | undefined;
18
+ has(gameId: string): boolean;
19
+ }
20
+
21
+ /** 造一张空的小游戏表;玩法系统由宿主在装配期逐个注册 */
22
+ export function createMinigameRegistry(): MinigameRegistry {
23
+ const factories = new Map<string, MinigameFactory>();
24
+ return {
25
+ register(gameId: string, factory: MinigameFactory): void {
26
+ factories.set(gameId, factory);
27
+ },
28
+ get(gameId: string): MinigameFactory | undefined {
29
+ return factories.get(gameId);
30
+ },
31
+ has(gameId: string): boolean {
32
+ return factories.has(gameId);
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,11 @@
1
+ /** 通知层挂载点出口(模板注册制)。 */
2
+ export {
3
+ builtinNotifyTemplate,
4
+ createNotifyTemplateRegistry,
5
+ toNotifyTone,
6
+ type NotifyTemplateFn,
7
+ type NotifyTemplateInput,
8
+ type NotifyTemplateRegistry,
9
+ type NotifyTemplateView,
10
+ type NotifyTone,
11
+ } from "./templates";