@lingfan-engine/editor 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingfan-engine/editor",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "灵泛叙事作者层:op schema 单一事实源 + 词函数子语言 + 编辑期诊断 + 故事树纯映射器——只读写故事 JSON,不碰运行时与平台",
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "sideEffects": false,
15
15
  "dependencies": {
16
- "@lingfan-engine/core": "^0.2.0",
16
+ "@lingfan-engine/core": "^0.3.0",
17
17
  "zod": "^4.6.5"
18
18
  },
19
19
  "license": "Apache-2.0",
@@ -65,15 +65,13 @@ export function chapterGroupOf(
65
65
  /**
66
66
  * 场景类型的**徽标文案**(单一事实源:列侧栏 / 章节树共用,组件不得自写映射)。
67
67
  *
68
- * - `"menu"` → 「菜单」;`"ui"` → 「界面」
68
+ * - `"ui"` → 「界面」
69
69
  * - 缺省(`undefined`)与 `"game"` → `null`(**game 是常态不标**——满屏「剧情」徽标是噪音)
70
70
  */
71
71
  export function sceneTypeBadgeOf(
72
72
  type: StoryColumn["type"],
73
- ): "菜单" | "界面" | null {
74
- if (type === "menu") return "菜单";
75
- if (type === "ui") return "界面";
76
- return null;
73
+ ): "界面" | null {
74
+ return type === "ui" ? "界面" : null;
77
75
  }
78
76
 
79
77
  /** 命令条数(flow 看 commands、scene 看 entry;缺省 0) */
@@ -25,6 +25,7 @@ export {
25
25
  sceneTypeBadgeOf,
26
26
  } from "./build.ts";
27
27
 
28
+
28
29
  export type {
29
30
  ChapterDir,
30
31
  ChapterGroup,
@@ -21,9 +21,8 @@ export interface ChapterNode {
21
21
  /** 该章节的场景所属分组(由 `type` 决定,**不按目录猜**) */
22
22
  readonly group: ChapterGroup;
23
23
  /**
24
- * 原始场景类型(列的 `type` 字段原样透传;`undefined` = 缺省 game)。
25
- * 分组判定只吃 `group`;要**精确到 菜单/界面** 的展示走 `sceneTypeBadgeOf(node.type)`
26
- * (`group=ui` 只说明「不可回溯」,不说明是哪一种)。
24
+ * 归约后的场景类型(契约值;`undefined` = 缺省 game)。
25
+ * 分组判定只吃 `group`——`group=ui` 只说明「不可回溯」,不说明作者原本写的是哪一种。
27
26
  */
28
27
  readonly type: StoryColumn["type"];
29
28
  /** 章节在文件树中的位置(逻辑路径,用于联动资源树选中) */
@@ -11,7 +11,8 @@ export type OpGroup =
11
11
  | "save"
12
12
  | "audio"
13
13
  | "video"
14
- | "minigame";
14
+ | "minigame"
15
+ | "ui";
15
16
 
16
17
  /**
17
18
  * 字段呈现语义(驱动诊断扫描面与表单控件选型):
@@ -5,7 +5,7 @@
5
5
  * ——「列名即标签」,重命名必须保持全部列的引用图一致。
6
6
  */
7
7
 
8
- import type { Story } from "@lingfan-engine/core";
8
+ import type { SceneType, Story } from "@lingfan-engine/core";
9
9
  import { isSafeFileNameSegment } from "@lingfan-engine/core";
10
10
  import {
11
11
  getAtPointer,
@@ -77,11 +77,11 @@ export function addColumn(
77
77
  id?: string;
78
78
  kind?: "scene" | "flow";
79
79
  /**
80
- * 运行语义轴(与 `kind` 正交):game 缺省 / menu 菜单 / ui 覆盖层。
80
+ * 运行语义轴(与 `kind` 正交):缺省 = game,非缺省只可能是 ui(界面层)。
81
81
  * **缺省与 game 都不写字段**——默认值不显式存储(与写回保真同一口径:
82
82
  * 新建列的内存形态必须与重开后的解析形态深等)。
83
83
  */
84
- type?: "game" | "menu" | "ui";
84
+ type?: SceneType;
85
85
  /** 语义化建议(仅 id 缺省时参与;见 suggestColumnId) */
86
86
  hint?: string | null;
87
87
  } = {},
@@ -16,10 +16,10 @@ export const TRANSLATE_SURFACES: Readonly<Record<string, readonly string[]>> = {
16
16
  // 候选只翻显示文本;`value` 是要写进变量的原样值,**不进翻译面**(否则多语言下故事逻辑随语言漂移)
17
17
  input: ["prompt", "options[].text"],
18
18
  notify: ["text"],
19
- // character.screen 仅入编辑器比对面(原文收集/键抽取),运行期**不翻**:引擎恰好 7 个
20
- // translate 调用点(runtime/i18n/translate.ts:31、ops/dialog.ts:92/107/171/175、
21
- // ops/interaction.ts:42、ops/media.ts:262),均不含 screen(说话人显示名解析产物
22
- // 走 say 的 Translate 挂接,查表见 runtime/ops/dialog.ts execSay;docs concepts/i18n.md 同口径)。
19
+ // character.screen 仅入编辑器比对面(原文收集/键抽取),运行期**不翻**:引擎的
20
+ // translate 调用点分布在 runtime/i18n/translate.ts、ops/dialog.ts、ops/interaction.ts
21
+ // 与 ops/media/notify.ts,均不含 screen(说话人显示名解析产物走 say 的 Translate
22
+ // 挂接,查表见 runtime/ops/dialog.ts execSay;docs concepts/i18n.md 同口径)。
23
23
  character: ["screen"],
24
24
  };
25
25
 
package/src/index.ts CHANGED
@@ -63,6 +63,11 @@ export type {
63
63
  } from "./schema/index.ts";
64
64
  /** 词汇层构建期轻类型校验警告(expr/cond 组装时按引擎类型规则产出;drain 取走) */
65
65
  export type { ExpressionWarning } from "./script/index.ts";
66
+ /**
67
+ * 挂载 UI 句柄的形状:故事源 `import hub from "…/ui/hub/hub.vue"` 拿到的就是这个。
68
+ * 经包根再导出一次,`stories-build/authoring` 的 ambient 声明才能只 import type 转发。
69
+ */
70
+ export type { UiHandle } from "./script/index.ts";
66
71
  export {
67
72
  BUILTIN_OP_SURFACE,
68
73
  mergeOpMeta,
@@ -242,6 +247,8 @@ export {
242
247
  type ColumnNameRegistry,
243
248
  type GuardNameRegistry,
244
249
  type InterpolationKeyRegistry,
250
+ type KnownUiName,
251
+ type UiNameRegistry,
245
252
  } from "./script/index.ts";
246
253
 
247
254
  /**
@@ -299,6 +306,7 @@ export {
299
306
  localVar,
300
307
  menu,
301
308
  minigame,
309
+ mount,
302
310
  navigate,
303
311
  newArray,
304
312
  notify,
@@ -315,6 +323,11 @@ export {
315
323
  say,
316
324
  sceneSetup,
317
325
  sceneType,
326
+ audioPlay,
327
+ audioStop,
328
+ audioVolume,
329
+ audioPause,
330
+ audioResume,
318
331
  se,
319
332
  seekVideo,
320
333
  set,
@@ -324,6 +337,7 @@ export {
324
337
  staticFor,
325
338
  stopAmbient,
326
339
  stopBgm,
340
+ stopSe,
327
341
  stopVideo,
328
342
  stopVoice,
329
343
  style,
@@ -22,6 +22,7 @@ export const OP_GROUP_ORDER: readonly OpGroup[] = [
22
22
  "audio",
23
23
  "video",
24
24
  "minigame",
25
+ "ui",
25
26
  ];
26
27
 
27
28
  /** op 分组中文标签(单一事实源) */
@@ -34,6 +35,7 @@ export const OP_GROUP_LABELS: Readonly<Record<OpGroup, string>> = {
34
35
  audio: "音频",
35
36
  video: "视频",
36
37
  minigame: "小游戏",
38
+ ui: "UI",
37
39
  };
38
40
 
39
41
  export const OP_META: readonly OpMeta[] = [
@@ -82,6 +84,12 @@ export const OP_META: readonly OpMeta[] = [
82
84
  { op: "stop_ambient", label: "停止环境音", group: "audio" },
83
85
  { op: "voice", label: "语音", group: "audio" },
84
86
  { op: "stop_voice", label: "停止语音", group: "audio" },
87
+ { op: "stop_se", label: "停止音效", group: "audio" },
88
+ { op: "audio_play", label: "起播音频实例", group: "audio" },
89
+ { op: "audio_stop", label: "停止音频实例", group: "audio" },
90
+ { op: "audio_volume", label: "调音频实例音量", group: "audio" },
91
+ { op: "audio_pause", label: "暂停音频通道", group: "audio" },
92
+ { op: "audio_resume", label: "恢复音频通道", group: "audio" },
85
93
  { op: "video", label: "视频", group: "video" },
86
94
  { op: "cutscene", label: "过场", group: "video" },
87
95
  { op: "seek_video", label: "视频跳转", group: "video" },
@@ -104,6 +112,8 @@ export const OP_META: readonly OpMeta[] = [
104
112
  { op: "transition", label: "转场", group: "presentation" },
105
113
  { op: "shake", label: "屏幕震动", group: "presentation" },
106
114
  { op: "text_typewriter", label: "打字机设置", group: "presentation" },
115
+ // 挂载 UI
116
+ { op: "mount", label: "挂载 UI 显隐", group: "ui" },
107
117
  ];
108
118
 
109
119
  interface FieldMeta {
@@ -237,6 +247,20 @@ export const FIELD_META: Readonly<Record<string, FieldMeta>> = {
237
247
  "voice.auto_stop": { label: "推进即停", kind: "boolean" },
238
248
  "voice.restart": { label: "回起点重播", kind: "boolean" },
239
249
  "stop_voice.fade": { label: "淡出(毫秒)", kind: "number" },
250
+ "stop_se.fade": { label: "淡出(毫秒)", kind: "number" },
251
+ "audio_play.inst": { label: "实例 id", kind: "text" },
252
+ "audio_play.channel": { label: "归属通道", kind: "text" },
253
+ "audio_play.resource": { label: "资源", kind: "text" },
254
+ "audio_play.volume": { label: "音量(0–1)", kind: "number" },
255
+ "audio_play.loop": { label: "循环", kind: "boolean" },
256
+ "audio_play.fade": { label: "淡入(毫秒)", kind: "number" },
257
+ "audio_play.restart": { label: "重新起播", kind: "boolean" },
258
+ "audio_stop.inst": { label: "实例 id(留空 = 全部)", kind: "text" },
259
+ "audio_stop.fade": { label: "淡出(毫秒)", kind: "number" },
260
+ "audio_volume.inst": { label: "实例 id", kind: "text" },
261
+ "audio_volume.volume": { label: "音量(0–1)", kind: "number" },
262
+ "audio_pause.channel": { label: "通道(留空 = 全部常驻)", kind: "text" },
263
+ "audio_resume.channel": { label: "通道(留空 = 全部常驻)", kind: "text" },
240
264
  "video.resource": { label: "资源", kind: "resource" },
241
265
  "video.volume": { label: "音量", kind: "number" },
242
266
  "video.loop": { label: "循环", kind: "boolean" },
@@ -295,6 +319,9 @@ export const FIELD_META: Readonly<Record<string, FieldMeta>> = {
295
319
  "shake.duration": { label: "时长(秒)", kind: "number" },
296
320
  "text_typewriter.enabled": { label: "启用", kind: "boolean" },
297
321
  "text_typewriter.speed": { label: "速度(字符/秒)", kind: "number" },
322
+ "mount.name": { label: "UI 名", kind: "text" },
323
+ "mount.visible": { label: "挂载", kind: "boolean" },
324
+ "mount.props": { label: "入参", kind: "object" },
298
325
  };
299
326
 
300
327
  /** 文本插值面(诊断扫描用):text kind 字段集合的查询入口 */
@@ -231,6 +231,42 @@ const voiceSchema = z.strictObject({
231
231
  });
232
232
  const stopSchema = z.strictObject({ fade: Fade.optional() });
233
233
 
234
+ /** 音频实例 id:与扩展 / 玩法系统同一条短 id 形态(引擎侧是唯一声明处,此处只校验形态) */
235
+ const AudioInstanceId = z
236
+ .string()
237
+ .regex(/^[a-z][a-z0-9_-]{0,31}$/, "须为小写字母开头的短 id(1..32 位)");
238
+
239
+ const audioChannelSchema = z.enum(["bgm", "se", "ambient", "voice"]);
240
+
241
+ const audioPlaySchema = z.strictObject({
242
+ inst: AudioInstanceId,
243
+ channel: audioChannelSchema,
244
+ resource: NonEmpty,
245
+ volume: Finite.optional(),
246
+ loop: z.boolean().optional(),
247
+ fade: Fade.optional(),
248
+ restart: z.boolean().optional(),
249
+ });
250
+
251
+ const audioStopSchema = z.strictObject({
252
+ inst: AudioInstanceId.optional(),
253
+ fade: Fade.optional(),
254
+ });
255
+
256
+ const audioVolumeSchema = z.strictObject({
257
+ inst: AudioInstanceId,
258
+ volume: Finite,
259
+ });
260
+
261
+ // channel 省略 = 全部常驻通道(se 除外),故是 optional 而非缺省成某个通道
262
+ const audioPauseSchema = z.strictObject({
263
+ channel: audioChannelSchema.optional(),
264
+ });
265
+
266
+ const audioResumeSchema = z.strictObject({
267
+ channel: audioChannelSchema.optional(),
268
+ });
269
+
234
270
  const videoSchema = z.strictObject({
235
271
  ...playFields,
236
272
  loop: z.boolean().optional(),
@@ -318,6 +354,21 @@ const textTypewriterSchema = z.strictObject({
318
354
  speed: z.number().finite().positive().optional(),
319
355
  });
320
356
 
357
+ /**
358
+ * `mount`:挂载 / 卸载一个 UI 组件。
359
+ *
360
+ * `name` 是 UI 目录名(短标识:小写字母开头,其后小写字母 / 数字 / `_` / `-`,1..32 位)——
361
+ * 与引擎侧 `UI_NAME_PATTERN` 同一形态,名字同时是产物键与目录名,两处必须一致。
362
+ * `props` 只收对象;键值是否 JSON 安全由运行期写入点判定(schema 看不见值语义)。
363
+ */
364
+ const mountSchema = z.strictObject({
365
+ name: z
366
+ .string()
367
+ .regex(/^[a-z][a-z0-9_-]{0,31}$/, "UI 名须小写字母开头,其后小写字母/数字/_/-,1..32 位"),
368
+ visible: z.boolean(),
369
+ props: z.record(z.string(), z.unknown()).optional(),
370
+ });
371
+
321
372
  export {
322
373
  saySchema,
323
374
  menuSchema,
@@ -357,6 +408,11 @@ export {
357
408
  seSchema,
358
409
  voiceSchema,
359
410
  stopSchema,
411
+ audioPlaySchema,
412
+ audioStopSchema,
413
+ audioVolumeSchema,
414
+ audioPauseSchema,
415
+ audioResumeSchema,
360
416
  videoSchema,
361
417
  cutsceneSchema,
362
418
  seekVideoSchema,
@@ -375,4 +431,5 @@ export {
375
431
  transitionSchema,
376
432
  shakeSchema,
377
433
  textTypewriterSchema,
434
+ mountSchema,
378
435
  };
@@ -36,6 +36,11 @@ import {
36
36
  saveDeleteSchema,
37
37
  bgmSchema,
38
38
  stopSchema,
39
+ audioPlaySchema,
40
+ audioStopSchema,
41
+ audioVolumeSchema,
42
+ audioPauseSchema,
43
+ audioResumeSchema,
39
44
  seSchema,
40
45
  voiceSchema,
41
46
  videoSchema,
@@ -56,6 +61,7 @@ import {
56
61
  transitionSchema,
57
62
  shakeSchema,
58
63
  textTypewriterSchema,
64
+ mountSchema,
59
65
  } from "./commands.ts";
60
66
 
61
67
  /** op → 负载 schema(不含 op 键本身);字面量键形态 = `ScriptCommand` 判别联合的派生源。
@@ -106,6 +112,12 @@ export const OP_SCHEMA_MAP = {
106
112
  stop_ambient: stopSchema,
107
113
  voice: voiceSchema,
108
114
  stop_voice: stopSchema,
115
+ stop_se: stopSchema,
116
+ audio_play: audioPlaySchema,
117
+ audio_stop: audioStopSchema,
118
+ audio_volume: audioVolumeSchema,
119
+ audio_pause: audioPauseSchema,
120
+ audio_resume: audioResumeSchema,
109
121
  video: videoSchema,
110
122
  cutscene: cutsceneSchema,
111
123
  seek_video: seekVideoSchema,
@@ -128,6 +140,8 @@ export const OP_SCHEMA_MAP = {
128
140
  transition: transitionSchema,
129
141
  shake: shakeSchema,
130
142
  text_typewriter: textTypewriterSchema,
143
+ // 挂载 UI
144
+ mount: mountSchema,
131
145
  };
132
146
 
133
147
  /** 运行期查找面(与 `OP_SCHEMA_MAP` 同一对象,键集恒等);新增 op 自动可用 */
@@ -51,6 +51,9 @@ export {
51
51
  type KnownInterpolationKey,
52
52
  } from "./words/index.ts";
53
53
 
54
+ // —— 挂载 UI(句柄形状 + 挂载词)——
55
+ export { mount, type KnownUiName, type UiHandle, type UiNameRegistry } from "./words/index.ts";
56
+
54
57
  // —— 叙事 ——
55
58
  export {
56
59
  say,
@@ -112,6 +115,11 @@ export {
112
115
  // —— 存档 / 音频 / 视频 / 小游戏 ——
113
116
  export { save, load, autoSave, saveDelete } from "./words/index.ts";
114
117
  export {
118
+ audioPlay,
119
+ audioStop,
120
+ audioVolume,
121
+ audioPause,
122
+ audioResume,
115
123
  bgm,
116
124
  stopBgm,
117
125
  se,
@@ -119,8 +127,12 @@ export {
119
127
  stopAmbient,
120
128
  voice,
121
129
  stopVoice,
130
+ stopSe,
122
131
  } from "./words/index.ts";
123
132
  export type {
133
+ AudioPlayOptions,
134
+ AudioStopOptions,
135
+ AudioPauseOptions,
124
136
  PlayOptions,
125
137
  BgmOptions,
126
138
  StopOptions,
@@ -18,6 +18,11 @@ export const VOCABULARY_OPS = [
18
18
  "array_pop",
19
19
  "array_push",
20
20
  "assert",
21
+ "audio_pause",
22
+ "audio_play",
23
+ "audio_resume",
24
+ "audio_stop",
25
+ "audio_volume",
21
26
  "auto_save",
22
27
  "background",
23
28
  "bg_switch",
@@ -44,6 +49,7 @@ export const VOCABULARY_OPS = [
44
49
  "local",
45
50
  "menu",
46
51
  "minigame",
52
+ "mount",
47
53
  "navigate",
48
54
  "notify",
49
55
  "nvl",
@@ -62,6 +68,7 @@ export const VOCABULARY_OPS = [
62
68
  "show",
63
69
  "stop_ambient",
64
70
  "stop_bgm",
71
+ "stop_se",
65
72
  "stop_video",
66
73
  "stop_voice",
67
74
  "style",
@@ -95,6 +102,12 @@ export const SCRIPT_COVERAGE: Readonly<Record<string, string>> = {
95
102
  stopAmbient: "stop_ambient",
96
103
  voice: "voice",
97
104
  stopVoice: "stop_voice",
105
+ stopSe: "stop_se",
106
+ audioPlay: "audio_play",
107
+ audioStop: "audio_stop",
108
+ audioVolume: "audio_volume",
109
+ audioPause: "audio_pause",
110
+ audioResume: "audio_resume",
98
111
  // —— elements.ts ——
99
112
  show: "show",
100
113
  hide: "hide",
@@ -143,6 +156,8 @@ export const SCRIPT_COVERAGE: Readonly<Record<string, string>> = {
143
156
  load: "load",
144
157
  autoSave: "auto_save",
145
158
  saveDelete: "save_delete",
159
+ // —— ui.ts ——
160
+ mount: "mount",
146
161
  // —— vars.ts ——
147
162
  set: "set",
148
163
  define: "define",
@@ -112,3 +112,122 @@ export function voice(
112
112
  export function stopVoice(opts?: StopOptions): CommandOf<"stop_voice"> {
113
113
  return { op: "stop_voice", ...opts };
114
114
  }
115
+
116
+ /**
117
+ * 停止在播音效(`fade` 渐出毫秒,缺省立即)。
118
+ *
119
+ * 音效是「一次性触发 + 可停止」的通道:`se` 每次触发独立叠放,
120
+ * 本命令停掉当前在播的全部音效(已淡出完成的不受影响)。
121
+ */
122
+ export function stopSe(opts?: StopOptions): CommandOf<"stop_se"> {
123
+ return { op: "stop_se", ...opts };
124
+ }
125
+
126
+ /** `audio_play` 的可选项:音量、循环、淡入毫秒、显式重播 */
127
+ export type AudioPlayOptions = Omit<
128
+ CommandOf<"audio_play">,
129
+ "op" | "inst" | "channel" | "resource"
130
+ >;
131
+
132
+ /** `audio_stop` 的可选项:目标实例(省略 = 停全部)、淡出毫秒 */
133
+ export type AudioStopOptions = Omit<CommandOf<"audio_stop">, "op">;
134
+
135
+ /**
136
+ * 起播(或更新)一个**音频实例**:按名字点名的独立音轨,与四通道并存。
137
+ *
138
+ * - `channel` 决定偏好归口(BGM 的音量偏好管 bgm 实例,音效的管 se 实例)。
139
+ * - 同实例重复起播:同资源 = 更新(保留位置),换资源或 `restart: true` = 重播。
140
+ * - 与 {@link bgm} / {@link se} 的区别:通道是「叙事语义上的那一路声音」
141
+ * (各一条、随存档走),实例是「作者点名的编排单元」(可多个、各自独立叠放)。
142
+ *
143
+ * @param inst 实例 id(小写字母开头的短 id,1..32 位)。
144
+ * @param channel 归属通道:`bgm` / `se` / `ambient` / `voice`。
145
+ * @param resource 音频资源路径。
146
+ * @param opts 可选项,见 {@link AudioPlayOptions}。
147
+ * @returns `audio_play` 故事命令。
148
+ * @example
149
+ * audioPlay("rain", "ambient", "audio/rain.ogg", { volume: 0.3 });
150
+ * audioVolume("rain", 0.6);
151
+ */
152
+ export function audioPlay(
153
+ inst: string,
154
+ channel: "bgm" | "se" | "ambient" | "voice",
155
+ resource: string,
156
+ opts?: AudioPlayOptions,
157
+ ): CommandOf<"audio_play"> {
158
+ return { op: "audio_play", inst, channel, resource, ...opts };
159
+ }
160
+
161
+ /**
162
+ * 停止音频实例:给了 `inst` 只停那一轨,省略则停掉当前全部实例。
163
+ * @param inst 目标实例 id(省略 = 停全部)。
164
+ * @param opts 可选项:淡出毫秒。
165
+ * @returns `audio_stop` 故事命令。
166
+ */
167
+ export function audioStop(
168
+ inst?: string,
169
+ opts?: AudioStopOptions,
170
+ ): CommandOf<"audio_stop"> {
171
+ return {
172
+ op: "audio_stop",
173
+ ...(inst === undefined ? {} : { inst }),
174
+ ...opts,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * 只改一个音频实例的音量:不动播放位置、不重播。
180
+ *
181
+ * 这是「实时调音」的正规入口——拖动滑块时不要用 {@link audioPlay} 重发,
182
+ * 那会重设位置并打断淡入。
183
+ *
184
+ * @param inst 目标实例 id。
185
+ * @param volume 音量(0–1)。
186
+ * @returns `audio_volume` 故事命令。
187
+ */
188
+ export function audioVolume(
189
+ inst: string,
190
+ volume: number,
191
+ ): CommandOf<"audio_volume"> {
192
+ return { op: "audio_volume", inst, volume };
193
+ }
194
+
195
+ /** `audio_pause` / `audio_resume` 的可选项:目标通道(省略 = 全部常驻通道) */
196
+ export type AudioPauseOptions = Omit<CommandOf<"audio_pause">, "op">;
197
+
198
+ /**
199
+ * 暂停音频通道:保留播放器与播放位置,只是不出声;恢复从原位置继续。
200
+ *
201
+ * - 省略 `channel` = 全部常驻通道(`bgm` / `ambient` / `voice`)。
202
+ * - `se` 是一次性触发,暂停它没有可恢复的东西——显式传 `se` 不动作。
203
+ * - 只对正在播放的通道生效;已停止或从未播放的通道不受影响(连按两次只算一次)。
204
+ *
205
+ * @param opts 可选项,见 {@link AudioPauseOptions}。
206
+ * @returns `audio_pause` 故事命令。
207
+ * @example
208
+ * audioPause(); // 暂停全部常驻通道
209
+ * audioPause({ channel: "bgm" });
210
+ */
211
+ export function audioPause(
212
+ opts?: AudioPauseOptions,
213
+ ): CommandOf<"audio_pause"> {
214
+ return { op: "audio_pause", ...opts };
215
+ }
216
+
217
+ /**
218
+ * 恢复被 {@link audioPause} 暂停的通道:从暂停位置续播,不从头播。
219
+ *
220
+ * 恢复用的是暂停前那个播放态(资源、音量、循环原样),因此不会重新起播、
221
+ * 也不会重设淡入。未处于暂停态的通道不动作。
222
+ *
223
+ * @param opts 可选项,见 {@link AudioPauseOptions}。
224
+ * @returns `audio_resume` 故事命令。
225
+ * @example
226
+ * audioResume(); // 恢复全部常驻通道
227
+ * audioResume({ channel: "ambient" });
228
+ */
229
+ export function audioResume(
230
+ opts?: AudioPauseOptions,
231
+ ): CommandOf<"audio_resume"> {
232
+ return { op: "audio_resume", ...opts };
233
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Script 词汇层 · **具名实现槽位**(cell —— build-time 声明词)。
3
3
  *
4
- * cell 声明的实现由构建期扫描收集进生成注册物(`Stories.src/gen/fun_register.g.ts`):
4
+ * cell 声明的实现由构建期扫描收集进生成注册物(`src/gen/fun_register.g.ts`):
5
5
  * 故事 JSON 只留名字引用(guard 的 `fn` 字段),实现住生成模块——
6
6
  * 「名字在数据、实现在代码、build 做名字闭合」。
7
7
  *
@@ -31,7 +31,7 @@ export type KnownGuardName = keyof GuardNameRegistry extends never
31
31
  : keyof GuardNameRegistry & string;
32
32
 
33
33
  /**
34
- * 列名注册表(类型扩充点):构建期生成的 `Stories.src/gen/stories.gen.d.ts`
34
+ * 列名注册表(类型扩充点):构建期生成的 `src/gen/stories.gen.d.ts`
35
35
  * 往本接口塞进工程内每个列 id 的字面量键 ⇒ `jump("…")` 与 `option("…", "…")`
36
36
  * 的目标列名获得补全、写错即红。空表退化为普通 string。
37
37
  */
@@ -2,7 +2,7 @@
2
2
  * Script 词汇层 · **复合词**(多 op/元素的高频组合,纯展开)。
3
3
  * 复合词不产出新语义:每个函数 = 既有元素的语法糖。
4
4
  */
5
- import type { ElementNode } from "@lingfan-engine/core";
5
+ import type { ElementNode, SceneType } from "@lingfan-engine/core";
6
6
 
7
7
  export interface ElementOptions {
8
8
  id?: string;
@@ -72,7 +72,7 @@ export function buttonElement(
72
72
  * @returns 背景与标题元素节点数组(可省略标题)。
73
73
  * @example
74
74
  * export default scene("square", () => ({
75
- * type: "menu",
75
+ * type: "ui",
76
76
  * elements: [...sceneSetup("images/square.png", "城镇广场")],
77
77
  * columns: { ... },
78
78
  * }));
@@ -117,9 +117,9 @@ export function sceneSetup(
117
117
 
118
118
  /**
119
119
  * 列的 `type` 字段(game 缺省不写——与序列化口径一致)。
120
- * @param type 列类型:menu(菜单列)或 ui(界面列)。
120
+ * @param type 列类型:`ui`(界面层:标题 / 设置 / 覆盖层 / 弹窗)。
121
121
  * @returns 带类型标记的对象,展开进列定义。
122
122
  */
123
- export function sceneType(type: "menu" | "ui"): { type: "menu" | "ui" } {
123
+ export function sceneType(type: SceneType): { type: SceneType } {
124
124
  return { type };
125
125
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Script 词汇层 · 词条实现(作者可用的 builder 与选项类型)。
3
3
  * 每个文件对应一族词条:叙事 / 流程 / 变量 / 存档 / 音频 / 视频 / 小游戏 /
4
- * 元素 / 复合词 / 扩展 / 具名实现槽位 / 交互。
4
+ * 元素 / 复合词 / 扩展 / 具名实现槽位 / 挂载 UI / 交互。
5
5
  * 各文件导出名互不重复,故此处整体转发;对外仍由 `script` 出口按族列出名单。
6
6
  *
7
7
  * 每个词条函数都是**纯数据构造**:只返回一条 `StoryCommand`(与 JSON 同族),
@@ -19,5 +19,6 @@ export * from "./interaction.ts";
19
19
  export * from "./minigame.ts";
20
20
  export * from "./narrative.ts";
21
21
  export * from "./save.ts";
22
+ export * from "./ui.ts";
22
23
  export * from "./vars.ts";
23
24
  export * from "./video.ts";
@@ -10,7 +10,7 @@ import type { KnownColumnName, KnownInterpolationKey } from "./cells.ts";
10
10
  // —— `{key}` 插值键的编译期校验(纯类型层,零运行期成本)——
11
11
  //
12
12
  // 只在**字符串字面量**上生效:模板串、变量、宽 string 全部放行(动态文本没法查,
13
- // 也不该吓唬作者);键表来自生成物 `Stories.src/gen/stories.gen.d.ts`。
13
+ // 也不该吓唬作者);键表来自生成物 `src/gen/stories.gen.d.ts`。
14
14
  // 富文本行内标记必须豁免——`{b}`/`{color=#FFD700}` 这类与插值共用一个花括号语法,
15
15
  // 豁免表对齐引擎 `runtime/expr/interpolate.ts` 的行内标记集。
16
16
  type TrimText<S extends string> = S extends ` ${infer R}`
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Script 词汇层 · **挂载 UI**(ui)。
3
+ *
4
+ * 故事源用一行 import 拿到一个 UI 的句柄:
5
+ *
6
+ * ```ts
7
+ * import hub from "../../ui/hub/hub.vue";
8
+ * mount(hub, true);
9
+ * ```
10
+ *
11
+ * 句柄在编译期就退化成一个名字(`"hub"`),运行期没有对象、没有文件加载——
12
+ * 这正是「静态声明」的含义:写 import 只是告诉编译器「这一列会挂哪个 UI」,
13
+ * 真正把 UI 挂上舞台是 `mount` op 在运行期做的事。
14
+ *
15
+ * 本文件是 `UiHandle` 的**唯一声明处**;`@lingfan-engine/stories-build/authoring`
16
+ * 只做类型转发,所以故事源不需要额外的 tsconfig 配置就能拿到同一份形状。
17
+ */
18
+ import type { CommandOf } from "../../schema/index.ts";
19
+
20
+ export interface UiHandle<N extends string = string> {
21
+ readonly face: "uis";
22
+ readonly name: N;
23
+ }
24
+
25
+ /**
26
+ * UI 名注册表(类型扩充点):生成物 `src/gen/ui_register.g.ts` 尾部
27
+ * `declare module "@lingfan-engine/editor"` 往本接口塞进每个 UI 名的字面量键 ⇒
28
+ * {@link mount} 的名字参数获得补全与写错即红。空表退化为普通 string。
29
+ */
30
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- 类型扩充点:只有接口可被生成物 declare module 合并(type 别名不能),空体是有意形态
31
+ export interface UiNameRegistry {}
32
+
33
+ /** 已声明 UI 名(有声明 = 字面量联合;空 = string) */
34
+ export type KnownUiName = keyof UiNameRegistry extends never
35
+ ? string
36
+ : keyof UiNameRegistry & string;
37
+
38
+ /**
39
+ * 挂载 / 卸载一个 UI 组件:`mount(hub, true)`、`mount(hub, false)`、
40
+ * `mount(map, true, { scene: "forest" })`。
41
+ *
42
+ * 状态写进 `SYS.mounts`,随快照 / 存档 / 回溯自动随行;宿主按这张表决定
43
+ * 渲染树上有哪些组件。**隐藏 = 从渲染树移除**(卸载组件),不是 `display: none`。
44
+ *
45
+ * @param handle UI 句柄(`import hub from "…/hub.vue"`)或其名字字符串。
46
+ * @param visible 是否挂载。必填:没有「切换」写法,显隐写死才能让回溯读到确定的终值。
47
+ * @param props 传给组件的入参(JSON 安全)。给了就**整份替换**,不与上一次合并——
48
+ * 组件的入参集合是它的契约,得能完整指定(含「删掉一个入参」)。
49
+ * @returns `mount` 故事命令。
50
+ * @example
51
+ * import hub from "../../ui/hub/hub.vue";
52
+ * mount(hub, true);
53
+ * mount(hub, false);
54
+ */
55
+ export function mount<const N extends string>(
56
+ handle: UiHandle<N> | KnownUiName,
57
+ visible: boolean,
58
+ props?: Readonly<Record<string, unknown>>,
59
+ ): CommandOf<"mount"> {
60
+ const name = typeof handle === "string" ? handle : handle.name;
61
+ const cmd: CommandOf<"mount"> = { op: "mount", name, visible };
62
+ if (props !== undefined) cmd.props = { ...props };
63
+ return cmd;
64
+ }
@@ -1,19 +1,12 @@
1
1
  /**
2
- * 通用取值判据:编辑器包多个域(诊断、i18n、布局)都要对「这个值是不是一条记录」下判断。
2
+ * 通用取值判据的**转发口**:编辑器包多个域(诊断、i18n、布局)都要对「这个值是不是一条记录」下判断。
3
3
  *
4
- * 为什么放在共享层:判据本身不含业务含义,任何层都可以引用;而它一旦分叉,
5
- * 分叉点就是静默的行为差异——同一棵树,两处判定不同就会出现「这边认、那边不认」。
6
- * 所以编辑器包只有这一处实现,缺人少写一个 `!Array.isArray` 就是症状的来源。
7
- */
8
-
9
- /**
10
- * 是否是**普通对象**(JSON 意义上的记录):`null` 与数组都不算。
4
+ * **实现只有一处**,在引擎包 `packages/core/src/shared/guards.ts`(经 `@lingfan-engine/core`
5
+ * 包出口公开,链路由 `core/src/index.ts` → `core/src/contracts/index.ts` → `core/src/shared/index.ts`
6
+ * 三级转发构成)。此处只做转发,不持有实现体。
11
7
  *
12
- * 关键在最后那个 `!Array.isArray`:数组也是 `typeof === "object"`,
13
- * 少了它就会让数组通过「记录」校验,下游会拿下标当字段名继续走。
8
+ * 为什么不在这里留第二份实现:判据本身不含业务含义,任何层都可以引用;而它一旦分叉,
9
+ * 分叉点就是静默的行为差异——同一棵树,两处判定不同就会出现「这边认、那边不认」。
10
+ * 缺人少写一个 `!Array.isArray` 就是症状的来源。
14
11
  */
15
- export function isPlainObject(
16
- value: unknown,
17
- ): value is Record<string, unknown> {
18
- return typeof value === "object" && value !== null && !Array.isArray(value);
19
- }
12
+ export { isPlainObject } from "@lingfan-engine/core";