@lingfan-engine/create 0.0.0-stage → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/bin/create.mjs +18 -0
- package/package.json +44 -3
- package/src/cli/cli.ts +302 -0
- package/src/cli/prompts.ts +115 -0
- package/src/generate/dotfiles.ts +27 -0
- package/src/generate/index.ts +20 -0
- package/src/generate/project.ts +279 -0
- package/src/generate/replace.ts +91 -0
- package/src/index.ts +46 -0
- package/src/overlay/index.ts +10 -0
- package/src/overlay/merge.ts +143 -0
- package/src/registry/index.ts +15 -0
- package/src/registry/template-meta.ts +107 -0
- package/src/registry/versions.ts +109 -0
- package/src/shared/guards.ts +85 -0
- package/src/shared/index.ts +7 -0
- package/src/shared/texts.ts +55 -0
- package/template/README.md +138 -0
- package/template/__PROJECT__/Resources/Audio/.gitkeep +0 -0
- package/template/__PROJECT__/Resources/Fonts/README.md +10 -0
- package/template/__PROJECT__/Resources/Images/.gitkeep +0 -0
- package/template/__PROJECT__/Resources/Lang/.gitkeep +0 -0
- package/template/__PROJECT__/Resources/Stories/title/title_main.story +24 -0
- package/template/__PROJECT__/Resources/Video/.gitkeep +0 -0
- package/template/__PROJECT__/Resources/project.json +7 -0
- package/template/__PROJECT__/gitignore +8 -0
- package/template/__PROJECT__/index.html +34 -0
- package/template/__PROJECT__/package.json +22 -0
- package/template/__PROJECT__/pnpm-workspace.yaml +8 -0
- package/template/__PROJECT__/src/host/boot.ts +181 -0
- package/template/__PROJECT__/src/host/dialogue-view.ts +235 -0
- package/template/__PROJECT__/src/host/effects.ts +68 -0
- package/template/__PROJECT__/src/host/engine-wiring.ts +206 -0
- package/template/__PROJECT__/src/host/frame-loop.ts +42 -0
- package/template/__PROJECT__/src/host/index.ts +27 -0
- package/template/__PROJECT__/src/host/input-map.ts +51 -0
- package/template/__PROJECT__/src/host/layer-z.ts +50 -0
- package/template/__PROJECT__/src/host/save-slots.ts +38 -0
- package/template/__PROJECT__/src/host/stage-dom.ts +74 -0
- package/template/__PROJECT__/src/host/theme-fonts.ts +60 -0
- package/template/__PROJECT__/src/host/toast.ts +29 -0
- package/template/__PROJECT__/src/main.ts +61 -0
- package/template/__PROJECT__/src/styles/stage.css +183 -0
- package/template/__PROJECT__/src/vite-env.d.ts +14 -0
- package/template/__PROJECT__/tsconfig.json +19 -0
- package/template/__PROJECT__/tsconfig.node.json +14 -0
- package/template/__PROJECT__/vite.config.ts +64 -0
- package/template/overlays/README.md +70 -0
- package/template/overlays/miniprogram/README.md +104 -0
- package/template/overlays/miniprogram/index.html +15 -0
- package/template/overlays/miniprogram/package.patch.json +40 -0
- package/template/overlays/miniprogram/scripts/serve-project.mjs +273 -0
- package/template/overlays/miniprogram/src/App.vue +23 -0
- package/template/overlays/miniprogram/src/components/ChoiceLayer.vue +208 -0
- package/template/overlays/miniprogram/src/components/DialogueLayer.vue +183 -0
- package/template/overlays/miniprogram/src/components/NodeTree.vue +44 -0
- package/template/overlays/miniprogram/src/components/NoticeLayer.vue +87 -0
- package/template/overlays/miniprogram/src/components/SaveLoadBar.vue +168 -0
- package/template/overlays/miniprogram/src/components/StageView.vue +50 -0
- package/template/overlays/miniprogram/src/env.d.ts +1 -0
- package/template/overlays/miniprogram/src/main.ts +12 -0
- package/template/overlays/miniprogram/src/manifest.json +21 -0
- package/template/overlays/miniprogram/src/mp/frame.ts +51 -0
- package/template/overlays/miniprogram/src/mp/guards.ts +28 -0
- package/template/overlays/miniprogram/src/mp/host.ts +252 -0
- package/template/overlays/miniprogram/src/mp/index.ts +9 -0
- package/template/overlays/miniprogram/src/mp/platform.ts +210 -0
- package/template/overlays/miniprogram/src/mp/ports.ts +84 -0
- package/template/overlays/miniprogram/src/mp/project-source.ts +77 -0
- package/template/overlays/miniprogram/src/mp/slots.ts +64 -0
- package/template/overlays/miniprogram/src/mp/surface.ts +239 -0
- package/template/overlays/miniprogram/src/mp/video-surface.ts +104 -0
- package/template/overlays/miniprogram/src/mp/view.ts +91 -0
- package/template/overlays/miniprogram/src/pages/index/index.vue +299 -0
- package/template/overlays/miniprogram/src/pages.json +18 -0
- package/template/overlays/miniprogram/tsconfig.json +20 -0
- package/template/overlays/miniprogram/vite.config.ts +87 -0
- package/template/overlays/react/index.html +16 -0
- package/template/overlays/react/package.patch.json +18 -0
- package/template/overlays/react/src/App.tsx +32 -0
- package/template/overlays/react/src/main.tsx +74 -0
- package/template/overlays/react/src/vite-env.d.ts +14 -0
- package/template/overlays/react/tsconfig.json +20 -0
- package/template/overlays/react/vite.config.ts +67 -0
- package/template/overlays/tauri/README.md +59 -0
- package/template/overlays/tauri/package.patch.json +14 -0
- package/template/overlays/tauri/src/main.ts +58 -0
- package/template/overlays/tauri/src-tauri/Cargo.toml +16 -0
- package/template/overlays/tauri/src-tauri/build.rs +6 -0
- package/template/overlays/tauri/src-tauri/capabilities/default.json +7 -0
- package/template/overlays/tauri/src-tauri/icons/128x128.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/128x128@2x.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/32x32.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/Square107x107Logo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/Square142x142Logo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/Square150x150Logo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/Square284x284Logo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/Square30x30Logo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/Square310x310Logo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/Square44x44Logo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/Square71x71Logo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/Square89x89Logo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/StoreLogo.png +0 -0
- package/template/overlays/tauri/src-tauri/icons/icon.icns +0 -0
- package/template/overlays/tauri/src-tauri/icons/icon.ico +0 -0
- package/template/overlays/tauri/src-tauri/icons/icon.png +0 -0
- package/template/overlays/tauri/src-tauri/src/lib.rs +26 -0
- package/template/overlays/tauri/src-tauri/src/main.rs +7 -0
- package/template/overlays/tauri/src-tauri/src/preferences.rs +50 -0
- package/template/overlays/tauri/src-tauri/src/save.rs +178 -0
- package/template/overlays/tauri/src-tauri/tauri.conf.json +35 -0
- package/template/overlays/vue/index.html +16 -0
- package/template/overlays/vue/package.patch.json +16 -0
- package/template/overlays/vue/src/App.vue +30 -0
- package/template/overlays/vue/src/main.ts +68 -0
- package/template/overlays/vue/src/vite-env.d.ts +24 -0
- package/template/overlays/vue/tsconfig.json +19 -0
- package/template/overlays/vue/vite.config.ts +67 -0
- package/template/pack-template.ps1 +65 -0
- package/template/template-meta.json +40 -0
- package/README.md +0 -3
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 启动装配:端口 → 工程 → 引擎 → 视图 → 订阅 → 帧循环 → 开跑。
|
|
3
|
+
*
|
|
4
|
+
* 顺序是有约束的,不是随手排的:
|
|
5
|
+
* 1. 端口先于引擎(引擎构造要吃存档端口);
|
|
6
|
+
* 2. 层级表先落一次 DOM(此时视频端口还没建,视频层不参与);
|
|
7
|
+
* 3. 视图先于订阅(订阅一建立就会回调,视图必须已经能接住);
|
|
8
|
+
* 4. `engine.start()` 最后(此前任何一次状态回调都还没有订阅者,也不该有)。
|
|
9
|
+
*
|
|
10
|
+
* 可配置项都走具名选项,缺省值即本宿主的现状取值:宿主换一个工程或换一套节律时
|
|
11
|
+
* 只改组合根传进来的值,不动本文件。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
SYS,
|
|
16
|
+
StoryEngine,
|
|
17
|
+
resolveLayerZ,
|
|
18
|
+
type AudioPort,
|
|
19
|
+
type ProjectFilesPort,
|
|
20
|
+
type ResourcePort,
|
|
21
|
+
type SavePort,
|
|
22
|
+
type Story,
|
|
23
|
+
type VideoPort,
|
|
24
|
+
} from "@lingfan-engine/core";
|
|
25
|
+
import {
|
|
26
|
+
createFetchProjectFilesPort,
|
|
27
|
+
createStaticResourcePort,
|
|
28
|
+
createWebAudioPort,
|
|
29
|
+
createWebStorageSavePort,
|
|
30
|
+
createWebVideoPort,
|
|
31
|
+
loadProject,
|
|
32
|
+
} from "@lingfan-engine/adapters";
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_TEXT_CPS,
|
|
35
|
+
DEFAULT_TOAST_MS,
|
|
36
|
+
createAudioRenderer,
|
|
37
|
+
createVideoRenderer,
|
|
38
|
+
type TypingSetting,
|
|
39
|
+
} from "@lingfan-engine/ui";
|
|
40
|
+
import { createErrorReporter, type StageDom } from "./stage-dom";
|
|
41
|
+
import { createHostLayerZ } from "./layer-z";
|
|
42
|
+
import { fillSaveSlots, wireSaveSlots } from "./save-slots";
|
|
43
|
+
import { createToast } from "./toast";
|
|
44
|
+
import { createDialogueView } from "./dialogue-view";
|
|
45
|
+
import { createHostEffects } from "./effects";
|
|
46
|
+
import { wireEngine } from "./engine-wiring";
|
|
47
|
+
import { startHostFrameLoop } from "./frame-loop";
|
|
48
|
+
import { wireInput } from "./input-map";
|
|
49
|
+
import { installStoryFonts } from "./theme-fonts";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 装配输入:节点句柄表、资源根内的清单与故事表,以及两项可配置节律。
|
|
53
|
+
* 取值全部由组合根给定——本文件不含宿主特有的字面量。
|
|
54
|
+
*/
|
|
55
|
+
export interface HostBootOptions {
|
|
56
|
+
/** 常驻节点句柄表(由组合根解析 `#id` 得到) */
|
|
57
|
+
dom: StageDom;
|
|
58
|
+
/** 工程清单的逻辑路径(相对资源根) */
|
|
59
|
+
manifestFile: string;
|
|
60
|
+
/** 故事文件的逻辑路径表(相对资源根;新增故事必须同步此表) */
|
|
61
|
+
stories: readonly string[];
|
|
62
|
+
/** 故事未声明字速时的兜底(可见字符 / 秒);缺省 = 展示层给出的默认字速 */
|
|
63
|
+
defaultCps?: number;
|
|
64
|
+
/** 提示条驻留毫秒数;缺省 = 展示层给出的默认时长 */
|
|
65
|
+
toastDurationMs?: number;
|
|
66
|
+
/**
|
|
67
|
+
* 端口覆盖:换平台形态时由组合根传入原生实现,装配顺序与接线一行不改。
|
|
68
|
+
* 缺省走 Web 形态(静态资源根 + localStorage 存档 + fetch 取工程文件)。
|
|
69
|
+
*/
|
|
70
|
+
ports?: BootPortOverrides;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 端口覆盖表:换平台形态时由组合根传入原生实现,装配顺序与接线一行不改。
|
|
75
|
+
* 三个端口都可单独覆盖——缺哪一个就用该端口的 Web 形态。
|
|
76
|
+
*/
|
|
77
|
+
export interface BootPortOverrides {
|
|
78
|
+
/** 工程文件供给(缺省走 fetch 取资源根下的静态文件) */
|
|
79
|
+
files?: ProjectFilesPort;
|
|
80
|
+
/** 资源 URL 解析(缺省走 WebView 源根静态资源) */
|
|
81
|
+
resources?: ResourcePort;
|
|
82
|
+
/** 存档(桌面壳换成 Rust 命令:WebView 的 localStorage 会被清缓存带走) */
|
|
83
|
+
saves?: SavePort;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 装配并启动本宿主。失败时抛出(组合根负责把失败写进页面),
|
|
88
|
+
* 中途失败不会留下半启动的宿主:所有接线都在本函数内完成,抛出即放弃。
|
|
89
|
+
*/
|
|
90
|
+
export async function bootHost(options: HostBootOptions): Promise<void> {
|
|
91
|
+
const { dom } = options;
|
|
92
|
+
|
|
93
|
+
// 字体装配(纯展示,boot 一次性):@font-face 注入 + 项目默认字体主题面,
|
|
94
|
+
// 先于一切视图建立——首个对话帧渲染时 CSS 变量已就位
|
|
95
|
+
installStoryFonts();
|
|
96
|
+
|
|
97
|
+
// —— 工程文件:清单与故事同走 ProjectFilesPort(组装归引擎纯函数,宿主不预解析)——
|
|
98
|
+
const filesPort: ProjectFilesPort =
|
|
99
|
+
options.ports?.files ??
|
|
100
|
+
createFetchProjectFilesPort({
|
|
101
|
+
manifest: options.manifestFile,
|
|
102
|
+
stories: options.stories,
|
|
103
|
+
});
|
|
104
|
+
const manifest: unknown = await filesPort.manifest();
|
|
105
|
+
const story: Story = await loadProject(filesPort);
|
|
106
|
+
|
|
107
|
+
// —— 适配器装配(Web 形态为缺省;原生形态经 ports 换掉资源与存档,契约不变)——
|
|
108
|
+
const reportError = createErrorReporter(dom);
|
|
109
|
+
const resourcePort: ResourcePort =
|
|
110
|
+
options.ports?.resources ?? createStaticResourcePort();
|
|
111
|
+
const audioPort: AudioPort = createWebAudioPort({ onError: reportError });
|
|
112
|
+
const savePort: SavePort = options.ports?.saves ?? createWebStorageSavePort();
|
|
113
|
+
|
|
114
|
+
// —— 层级(z 序):内建默认 × 工程覆盖(project.json 的 shell.layers)——
|
|
115
|
+
const layerZ = resolveLayerZ(manifest);
|
|
116
|
+
// 视频端口的 z 在端口内部(与 DOM 层不同),故端口晚于首次落 DOM 建立,
|
|
117
|
+
// 由 getter 交给层级视图按需下发。
|
|
118
|
+
let videoPort: VideoPort | null = null;
|
|
119
|
+
const layers = createHostLayerZ({
|
|
120
|
+
dom,
|
|
121
|
+
layerZ,
|
|
122
|
+
readVideoPort: () => videoPort,
|
|
123
|
+
});
|
|
124
|
+
layers.apply();
|
|
125
|
+
|
|
126
|
+
videoPort = createWebVideoPort({
|
|
127
|
+
onError: reportError,
|
|
128
|
+
zIndex: layerZ.video,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// —— 存档壳配置:槽位(project.json 的 shell.saves.slots 可覆盖)——
|
|
132
|
+
fillSaveSlots(dom, manifest);
|
|
133
|
+
|
|
134
|
+
const engine = new StoryEngine(story, { historyLimit: 200, savePort });
|
|
135
|
+
const audio = createAudioRenderer(engine, audioPort, resourcePort, {
|
|
136
|
+
onError: reportError,
|
|
137
|
+
});
|
|
138
|
+
const video = createVideoRenderer(engine, videoPort, resourcePort, {
|
|
139
|
+
onError: reportError,
|
|
140
|
+
onVideoFinished: () => engine.videoFinished(), // 播放结束 → 引擎解除 video 等待
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const toast = createToast({
|
|
144
|
+
dom,
|
|
145
|
+
durationMs: options.toastDurationMs ?? DEFAULT_TOAST_MS,
|
|
146
|
+
});
|
|
147
|
+
const dialogue = createDialogueView({
|
|
148
|
+
dom,
|
|
149
|
+
engine,
|
|
150
|
+
defaultCps: options.defaultCps ?? DEFAULT_TEXT_CPS,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
wireEngine({
|
|
154
|
+
dom,
|
|
155
|
+
engine,
|
|
156
|
+
layers,
|
|
157
|
+
dialogue,
|
|
158
|
+
resources: resourcePort,
|
|
159
|
+
toast,
|
|
160
|
+
reportError,
|
|
161
|
+
// 故事级打字机设置(`text_typewriter` 命令);未设置时按默认字速走
|
|
162
|
+
readTypingSetting: () =>
|
|
163
|
+
engine.get(SYS.typewriter) as TypingSetting | undefined,
|
|
164
|
+
// 读档后媒体位置需显式对齐:引擎只回状态,播放位置由渲染器自己同步
|
|
165
|
+
syncMedia: () => {
|
|
166
|
+
audio.sync();
|
|
167
|
+
video.sync();
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const driveEffects = createHostEffects({ dom, engine });
|
|
172
|
+
startHostFrameLoop({
|
|
173
|
+
dialogue,
|
|
174
|
+
onMediaTick: () => audio.pollPosition(), // 媒体位置帧级回写
|
|
175
|
+
onFrame: driveEffects,
|
|
176
|
+
});
|
|
177
|
+
wireInput({ dom, engine, dialogue });
|
|
178
|
+
wireSaveSlots(dom, engine);
|
|
179
|
+
|
|
180
|
+
engine.start();
|
|
181
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对话与等待态渲染:说话人 / 正文 / NVL / 菜单 / 输入 / 等待六个等待态落到 DOM 上。
|
|
3
|
+
*
|
|
4
|
+
* 「每个等待态都必须有可推进出口」是本模块的硬要求——引擎能进入的等待态一个都不能少,
|
|
5
|
+
* 少了就是故事永久停滞。菜单与输入在渲染的同时挂好推进回调(`choose` / `input`),
|
|
6
|
+
* 等待态与视频态给可跳过的可见提示,小游戏态给 fail-closed 提示(本模板不带注册表,
|
|
7
|
+
* 不伪造完成)。
|
|
8
|
+
*
|
|
9
|
+
* 打字机实例与「当前已上屏的文本」都由本模块持有:帧循环每帧把可见文本交上来,
|
|
10
|
+
* 是否重写 DOM 由这里判定——文本没变就不碰 DOM,否则每帧一次 innerHTML 会把
|
|
11
|
+
* 行内标记重建成新节点。
|
|
12
|
+
*
|
|
13
|
+
* 显隐判定不在这里自算:NVL 是否接管、对话层是否让位都是展示层的纯函数
|
|
14
|
+
* (`isNvlActive` / `isDialogueSuppressed`),本模块只把结果写成样式。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { SYS } from "@lingfan-engine/core";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_TEXT_CPS,
|
|
20
|
+
Typewriter,
|
|
21
|
+
isDialogueSuppressed,
|
|
22
|
+
isNvlActive,
|
|
23
|
+
isTypingEnabled,
|
|
24
|
+
renderInlineMarkup,
|
|
25
|
+
resolveTypingCps,
|
|
26
|
+
sanitizeFont,
|
|
27
|
+
type TypingSetting,
|
|
28
|
+
} from "@lingfan-engine/ui";
|
|
29
|
+
import type { StageDom } from "./stage-dom";
|
|
30
|
+
|
|
31
|
+
/** 对话视图要用到的引擎能力:读状态、推进菜单与输入 */
|
|
32
|
+
export interface DialogueEngine {
|
|
33
|
+
/** 读一个状态键的当前值 */
|
|
34
|
+
get(key: string): unknown;
|
|
35
|
+
/** 选中一个菜单项(推进 menu 等待态) */
|
|
36
|
+
choose(optionId: string): void;
|
|
37
|
+
/** 提交输入(推进 input 等待态) */
|
|
38
|
+
input(value: string): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 对话视图的输入:节点句柄表、引擎能力面与字速兜底。
|
|
43
|
+
* 故事自己声明了打字机速度时以故事为准,`defaultCps` 只在故事未声明时生效。
|
|
44
|
+
*/
|
|
45
|
+
export interface DialogueViewOptions {
|
|
46
|
+
/** 常驻节点句柄表 */
|
|
47
|
+
dom: StageDom;
|
|
48
|
+
/** 引擎能力面 */
|
|
49
|
+
engine: DialogueEngine;
|
|
50
|
+
/** 故事未声明字速时的兜底(可见字符 / 秒);缺省 = 展示层给出的默认字速 */
|
|
51
|
+
defaultCps?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 对话视图句柄:状态键的每条意图各有一个落点,另有两个帧循环用的钩子 */
|
|
55
|
+
export interface DialogueView {
|
|
56
|
+
/** 等待态切换:提示符、对话层让位与选项区形态一起更新 */
|
|
57
|
+
setWaiting(waiting: string): void;
|
|
58
|
+
/** 对话框显隐(`window show|hide`) */
|
|
59
|
+
setDialogVisible(hidden: boolean): void;
|
|
60
|
+
/** NVL 模式切换 */
|
|
61
|
+
setNvlMode(mode: string): void;
|
|
62
|
+
/** NVL 缓冲行整体替换 */
|
|
63
|
+
setNvlBuffer(lines: readonly string[]): void;
|
|
64
|
+
/** 菜单选项或输入提示变了:选项区整体重建 */
|
|
65
|
+
setMenuChanged(): void;
|
|
66
|
+
/** 新的一句正文:按故事级打字机设置决定整句直出还是逐字上屏 */
|
|
67
|
+
setDialogText(text: string, setting: TypingSetting | undefined): void;
|
|
68
|
+
/** 小游戏接管:提示里带上 game 名,便于作者定位 */
|
|
69
|
+
setMinigame(game: string): void;
|
|
70
|
+
/** 读当前打字机(整句直出时为 null) */
|
|
71
|
+
readTypewriter(): Typewriter | null;
|
|
72
|
+
/** 上交本帧的可见文本(未变化时不重写 DOM) */
|
|
73
|
+
onText(visible: string): void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 造一个对话视图。六个等待态与两个显隐开关的状态都收在闭包里,每实例一份——
|
|
78
|
+
* 放在模块级会让同一页面的两个宿主互相串扰。
|
|
79
|
+
*/
|
|
80
|
+
export function createDialogueView(options: DialogueViewOptions): DialogueView {
|
|
81
|
+
const { dom, engine } = options;
|
|
82
|
+
const defaultCps = options.defaultCps ?? DEFAULT_TEXT_CPS;
|
|
83
|
+
|
|
84
|
+
/** 当前打字机;整句直出时为 null */
|
|
85
|
+
let typewriter: Typewriter | null = null;
|
|
86
|
+
/** 已上屏的文本:帧循环据此判断要不要重写 */
|
|
87
|
+
let shown = "";
|
|
88
|
+
/** 故事显式隐藏对话框(`window hide`) */
|
|
89
|
+
let dialogHidden = false;
|
|
90
|
+
/** NVL 模式(`"none"` = 关闭) */
|
|
91
|
+
let nvlMode = "none";
|
|
92
|
+
/** NVL 缓冲行(整体替换) */
|
|
93
|
+
let nvlLines: readonly string[] = [];
|
|
94
|
+
/** 当前等待态(决定对话层让位与选项区形态) */
|
|
95
|
+
let waiting = "none";
|
|
96
|
+
/** 小游戏接管名(提示文案用) */
|
|
97
|
+
let minigameId = "";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 对话层与 NVL 层的显隐。
|
|
101
|
+
* 两个让位理由独立:NVL 接管、故事显式隐藏、以及视频等待期——视频要占满舞台,
|
|
102
|
+
* 压不住底部对话框会很难看。NVL 内容每次重算,因为模式与缓冲行是分开到达的两条状态。
|
|
103
|
+
*/
|
|
104
|
+
function applyDialogueVisibility(): void {
|
|
105
|
+
const nvl = isNvlActive(nvlMode, nvlLines);
|
|
106
|
+
const suppressed = isDialogueSuppressed({
|
|
107
|
+
nvlActive: nvl,
|
|
108
|
+
hidden: dialogHidden,
|
|
109
|
+
waiting,
|
|
110
|
+
});
|
|
111
|
+
dom.dialogue.style.display = suppressed ? "none" : "";
|
|
112
|
+
dom.nvl.style.display = nvl ? "block" : "none";
|
|
113
|
+
if (nvl) {
|
|
114
|
+
dom.nvl.innerHTML = nvlLines
|
|
115
|
+
.map((line) => renderInlineMarkup(line))
|
|
116
|
+
.join("<br>");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 本句字体落 DOM(随每句正文调用):引擎分辨链结果(SYS.dialogFont)过字符集
|
|
122
|
+
* 白名单后上正文元素;空或非法名回落 CSS 变量(--lf-story-font,项目默认字体档)。
|
|
123
|
+
*/
|
|
124
|
+
function applySentenceFont(): void {
|
|
125
|
+
const raw = engine.get(SYS.dialogFont);
|
|
126
|
+
const font = typeof raw === "string" ? sanitizeFont(raw) : null;
|
|
127
|
+
if (font !== null) dom.text.style.fontFamily = `'${font}'`;
|
|
128
|
+
else dom.text.style.removeProperty("font-family");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 等待态 UI:**每个等待态都必须有可推进出口**(否则故事停滞) */
|
|
132
|
+
function renderChoices(): void {
|
|
133
|
+
dom.choices.replaceChildren();
|
|
134
|
+
if (waiting === "menu") {
|
|
135
|
+
const prompt = document.createElement("p");
|
|
136
|
+
prompt.className = "prompt";
|
|
137
|
+
prompt.textContent = String(engine.get(SYS.menuPrompt) ?? "");
|
|
138
|
+
dom.choices.append(prompt);
|
|
139
|
+
const texts = (engine.get(SYS.menuOptions) as string[] | undefined) ?? [];
|
|
140
|
+
const targets =
|
|
141
|
+
(engine.get(SYS.menuTargets) as string[] | undefined) ?? [];
|
|
142
|
+
texts.forEach((label, i) => {
|
|
143
|
+
const button = document.createElement("button");
|
|
144
|
+
button.className = "choice";
|
|
145
|
+
button.type = "button";
|
|
146
|
+
button.textContent = label;
|
|
147
|
+
button.addEventListener("click", () => engine.choose(targets[i] ?? ""));
|
|
148
|
+
dom.choices.append(button);
|
|
149
|
+
});
|
|
150
|
+
} else if (waiting === "input") {
|
|
151
|
+
const prompt = document.createElement("p");
|
|
152
|
+
prompt.className = "prompt";
|
|
153
|
+
prompt.textContent = String(engine.get(SYS.inputPrompt) ?? "");
|
|
154
|
+
const row = document.createElement("form");
|
|
155
|
+
row.className = "input-row";
|
|
156
|
+
const input = document.createElement("input");
|
|
157
|
+
input.type = "text";
|
|
158
|
+
input.maxLength = 20;
|
|
159
|
+
const ok = document.createElement("button");
|
|
160
|
+
ok.type = "submit";
|
|
161
|
+
ok.textContent = "确定";
|
|
162
|
+
row.append(input, ok);
|
|
163
|
+
row.addEventListener("submit", (event) => {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
const value = input.value.trim();
|
|
166
|
+
if (value === "") return;
|
|
167
|
+
engine.input(value);
|
|
168
|
+
});
|
|
169
|
+
dom.choices.append(prompt, row);
|
|
170
|
+
input.focus();
|
|
171
|
+
} else if (waiting === "wait" || waiting === "video") {
|
|
172
|
+
const note = document.createElement("p");
|
|
173
|
+
note.className = "note";
|
|
174
|
+
note.textContent =
|
|
175
|
+
waiting === "video"
|
|
176
|
+
? "视频播放中……(点击或空格跳过)"
|
|
177
|
+
: "等待中……(点击或空格跳过)";
|
|
178
|
+
dom.choices.append(note);
|
|
179
|
+
} else if (waiting === "minigame") {
|
|
180
|
+
const banner = document.createElement("p");
|
|
181
|
+
banner.className = "banner";
|
|
182
|
+
banner.textContent =
|
|
183
|
+
`故事进入了小游戏等待${minigameId === "" ? "" : `:${minigameId}`}。` +
|
|
184
|
+
"本模板不带小游戏注册表(fail-closed:不伪造完成)——要在宿主里支持," +
|
|
185
|
+
"请用 `createMinigameRegistry` 注册,并在 mount 事件后调用 engine.resolveMinigame(result)。";
|
|
186
|
+
dom.choices.append(banner);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
setWaiting(next: string): void {
|
|
192
|
+
waiting = next;
|
|
193
|
+
dom.hint.textContent = waiting === "dialog" ? "▼" : "";
|
|
194
|
+
applyDialogueVisibility();
|
|
195
|
+
renderChoices();
|
|
196
|
+
},
|
|
197
|
+
setDialogVisible(hidden: boolean): void {
|
|
198
|
+
dialogHidden = hidden;
|
|
199
|
+
applyDialogueVisibility();
|
|
200
|
+
},
|
|
201
|
+
setNvlMode(mode: string): void {
|
|
202
|
+
nvlMode = mode;
|
|
203
|
+
applyDialogueVisibility();
|
|
204
|
+
},
|
|
205
|
+
setNvlBuffer(lines: readonly string[]): void {
|
|
206
|
+
nvlLines = lines;
|
|
207
|
+
applyDialogueVisibility();
|
|
208
|
+
},
|
|
209
|
+
setMenuChanged(): void {
|
|
210
|
+
renderChoices();
|
|
211
|
+
},
|
|
212
|
+
setDialogText(text: string, setting: TypingSetting | undefined): void {
|
|
213
|
+
// 故事级打字机设置(`text_typewriter` 命令);enabled=false = 整句即时
|
|
214
|
+
const cps = resolveTypingCps(setting, defaultCps);
|
|
215
|
+
typewriter = isTypingEnabled(setting) ? new Typewriter(text, cps) : null;
|
|
216
|
+
applySentenceFont();
|
|
217
|
+
if (typewriter === null) {
|
|
218
|
+
shown = text;
|
|
219
|
+
dom.text.innerHTML = renderInlineMarkup(text);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
setMinigame(game: string): void {
|
|
223
|
+
minigameId = game;
|
|
224
|
+
renderChoices();
|
|
225
|
+
},
|
|
226
|
+
readTypewriter(): Typewriter | null {
|
|
227
|
+
return typewriter;
|
|
228
|
+
},
|
|
229
|
+
onText(visible: string): void {
|
|
230
|
+
if (visible === shown) return;
|
|
231
|
+
shown = visible;
|
|
232
|
+
dom.text.innerHTML = renderInlineMarkup(visible);
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 帧驱动表现:把引擎里的动画描述逐帧写成 DOM。
|
|
3
|
+
*
|
|
4
|
+
* 机制(累计、插值、播毕时机)在展示层(`createVisualEffects`),本文件只做两件本宿主
|
|
5
|
+
* 独有的事:把引擎与本宿主的 DOM 接上,以及把数值属性落成 CSS 声明。
|
|
6
|
+
*
|
|
7
|
+
* 元素动画**必须交回引擎**(`animationFinished`):否则动画队列只增不减(长会话泄漏),
|
|
8
|
+
* 元素也永远到不了终值——终值是引擎在播毕后写回元素属性的。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { SYS, type StoryEngine } from "@lingfan-engine/core";
|
|
12
|
+
import { createVisualEffects, resolveAnimatedStyle } from "@lingfan-engine/ui";
|
|
13
|
+
import type { StageDom } from "./stage-dom";
|
|
14
|
+
|
|
15
|
+
/** 逐帧驱动函数的输入:节点句柄表与引擎(三个播毕交回都直连引擎) */
|
|
16
|
+
export interface HostEffectsOptions {
|
|
17
|
+
/** 常驻节点句柄表 */
|
|
18
|
+
dom: StageDom;
|
|
19
|
+
/** 引擎:动画队列、转场与震动的启动键、三个播毕交回 */
|
|
20
|
+
engine: StoryEngine;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 造本宿主的逐帧驱动函数:宿主每帧调用一次,传入距上一帧的秒数 */
|
|
24
|
+
export function createHostEffects(
|
|
25
|
+
options: HostEffectsOptions,
|
|
26
|
+
): (dt: number) => void {
|
|
27
|
+
const { dom, engine } = options;
|
|
28
|
+
return createVisualEffects({
|
|
29
|
+
source: {
|
|
30
|
+
animations: () => engine.animations(),
|
|
31
|
+
transition: () =>
|
|
32
|
+
engine.get(SYS.transition) as { duration: number } | undefined,
|
|
33
|
+
shake: () =>
|
|
34
|
+
engine.get(SYS.shake) as
|
|
35
|
+
{ intensity: number; duration: number } | undefined,
|
|
36
|
+
animationFinished: (seq) => engine.animationFinished(seq),
|
|
37
|
+
transitionFinished: () => engine.transitionFinished(),
|
|
38
|
+
shakeFinished: () => engine.shakeFinished(),
|
|
39
|
+
},
|
|
40
|
+
animation: {
|
|
41
|
+
// 动画节点在舞台层里按 data-lf-id 找(元素渲染器写入的定位锚)
|
|
42
|
+
onAnimatedProperty: (target, property, value) => {
|
|
43
|
+
const node = dom.stage.querySelector<HTMLElement>(
|
|
44
|
+
`[data-lf-id="${target}"]`,
|
|
45
|
+
);
|
|
46
|
+
if (node === null) return;
|
|
47
|
+
const style = resolveAnimatedStyle(property, value);
|
|
48
|
+
// 未识别的属性跳过:写一个空声明会把元素已有样式抹掉
|
|
49
|
+
if (style !== null) Object.assign(node.style, style);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
surface: {
|
|
53
|
+
onTransitionFrame: (opacity) => {
|
|
54
|
+
dom.transition.style.display = "block";
|
|
55
|
+
dom.transition.style.opacity = String(opacity);
|
|
56
|
+
},
|
|
57
|
+
onTransitionDone: () => {
|
|
58
|
+
dom.transition.style.display = "none";
|
|
59
|
+
},
|
|
60
|
+
onShakeFrame: (x, y) => {
|
|
61
|
+
dom.root.style.transform = `translate(${x}px, ${y}px)`;
|
|
62
|
+
},
|
|
63
|
+
onShakeDone: () => {
|
|
64
|
+
dom.root.style.transform = "";
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 引擎订阅:把状态变更与出站事件翻译成本宿主的视图调用,并管好舞台元素层。
|
|
3
|
+
*
|
|
4
|
+
* 状态键怎么解读、缺省怎么回退,收在展示层的状态分发器里(`createStateView`)——
|
|
5
|
+
* 本文件只做两件事:给出本宿主订阅的键表,以及把每条**视图意图**落到自己的视图上。
|
|
6
|
+
*
|
|
7
|
+
* 元素层(`SYS.elements`)一并在此:它是同一路状态变更的落点,且两处都要 fail-closed
|
|
8
|
+
* 上报——**元素类型未注册**与**元素命令未注册**都不静默跳过,作者写了却看不见效果,
|
|
9
|
+
* 比报一行错难查得多。
|
|
10
|
+
*
|
|
11
|
+
* 出站事件按 `payload.kind` 分派:致命错误写错误条,提示类走 toast,存档类给回执,
|
|
12
|
+
* 小游戏挂载换一条可见的 fail-closed 提示(模板不带注册表,不伪造完成)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
SYS,
|
|
17
|
+
instanceZLayer,
|
|
18
|
+
type ElementInstance,
|
|
19
|
+
type OutboundPayload,
|
|
20
|
+
type ResourcePort,
|
|
21
|
+
type StoryEngine,
|
|
22
|
+
} from "@lingfan-engine/core";
|
|
23
|
+
import {
|
|
24
|
+
createCommandRegistry,
|
|
25
|
+
createElementRegistry,
|
|
26
|
+
createElementResourceResolver,
|
|
27
|
+
createDomRenderPort,
|
|
28
|
+
createStateView,
|
|
29
|
+
registerBuiltinElementRenderers,
|
|
30
|
+
renderElementTree,
|
|
31
|
+
resolveElementAction,
|
|
32
|
+
type LayerView,
|
|
33
|
+
type StateIntent,
|
|
34
|
+
type TypingSetting,
|
|
35
|
+
} from "@lingfan-engine/ui";
|
|
36
|
+
import type { StageDom } from "./stage-dom";
|
|
37
|
+
import type { DialogueView } from "./dialogue-view";
|
|
38
|
+
|
|
39
|
+
/** 宿主订阅的状态键表:与引擎的键名一一对应,换键名只改这里 */
|
|
40
|
+
const STATE_KEYS = {
|
|
41
|
+
speaker: SYS.currentDialogSpeaker,
|
|
42
|
+
dialogText: SYS.currentDialogText,
|
|
43
|
+
waiting: SYS.waiting,
|
|
44
|
+
nvlMode: SYS.nvlMode,
|
|
45
|
+
nvlBuffer: SYS.nvlBuffer,
|
|
46
|
+
menuOptions: SYS.menuOptions,
|
|
47
|
+
menuTargets: SYS.menuTargets,
|
|
48
|
+
dialogVisible: SYS.dialogVisible,
|
|
49
|
+
elements: SYS.elements,
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 订阅接线的输入:节点句柄表、引擎、以及本宿主已有的几个视图与出口。
|
|
54
|
+
* 本文件只做「引擎说什么 → 谁去落 DOM」的分发,不自己实现任何渲染。
|
|
55
|
+
*/
|
|
56
|
+
export interface HostWiringOptions {
|
|
57
|
+
/** 常驻节点句柄表 */
|
|
58
|
+
dom: StageDom;
|
|
59
|
+
/** 引擎:订阅、状态读取与元素命令面的落地 */
|
|
60
|
+
engine: StoryEngine;
|
|
61
|
+
/** 层级视图:实例级 z 的收纳 */
|
|
62
|
+
layers: LayerView;
|
|
63
|
+
/** 对话视图:对话 / 等待 / 选项 / NVL 的落点 */
|
|
64
|
+
dialogue: DialogueView;
|
|
65
|
+
/** 资源端口:元素资源的逻辑寻址 */
|
|
66
|
+
resources: ResourcePort;
|
|
67
|
+
/** 提示条 */
|
|
68
|
+
toast(text: string): void;
|
|
69
|
+
/** 诊断出口(页面错误条 + 控制台) */
|
|
70
|
+
reportError(message: string): void;
|
|
71
|
+
/** 读当前故事级打字机设置 */
|
|
72
|
+
readTypingSetting(): TypingSetting | undefined;
|
|
73
|
+
/** 存档 / 读档完成后的显式对齐(音频与视频渲染器) */
|
|
74
|
+
syncMedia(): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 接线状态订阅、元素层与出站事件 */
|
|
78
|
+
export function wireEngine(options: HostWiringOptions): void {
|
|
79
|
+
const { dom, engine, layers, dialogue, resources, reportError } = options;
|
|
80
|
+
|
|
81
|
+
// —— 舞台元素层:注册内建渲染器,装配命令表与资源解析 ——
|
|
82
|
+
const elementRegistry = createElementRegistry();
|
|
83
|
+
registerBuiltinElementRenderers(elementRegistry);
|
|
84
|
+
// 元素 `cmd` 的业务命令注册表:未注册 fail-closed(不静默吞掉)
|
|
85
|
+
const commands = createCommandRegistry();
|
|
86
|
+
let elements: readonly ElementInstance[] = [];
|
|
87
|
+
|
|
88
|
+
// 资源解析缓存 = @lingfan-engine/ui 共用实现(宿主只提供端口与重渲染回调)
|
|
89
|
+
const elementResources = createElementResourceResolver({
|
|
90
|
+
resolve: (path) => resources.resolve(path),
|
|
91
|
+
onResolved: () => renderElements(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** 意图 → 命令:`nav` → 核心 navigate;`cmd` → 宿主命令注册表(`value` 点击时插值) */
|
|
95
|
+
function activateElement(element: ElementInstance): void {
|
|
96
|
+
const action = resolveElementAction(element.props);
|
|
97
|
+
if (action.kind === "nav") {
|
|
98
|
+
engine.navigate(action.target);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (action.kind !== "cmd") return;
|
|
102
|
+
const handler = commands.get(action.name);
|
|
103
|
+
if (handler === undefined) {
|
|
104
|
+
reportError(`元素命令未注册:${action.name}(宿主需经命令注册表提供)`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
handler(
|
|
108
|
+
action.value === undefined ? undefined : engine.interpolate(action.value),
|
|
109
|
+
element,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderElements(): void {
|
|
114
|
+
renderElementTree({
|
|
115
|
+
registry: elementRegistry,
|
|
116
|
+
port: createDomRenderPort(dom.stage),
|
|
117
|
+
elements,
|
|
118
|
+
activate: activateElement,
|
|
119
|
+
resolveResource: elementResources.resolveForElement,
|
|
120
|
+
onUnknownType: (type) => reportError(`元素类型未注册:${type}`),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// —— 状态变更 → 视图意图 → 本宿主的落点 ——
|
|
125
|
+
const toIntent = createStateView({
|
|
126
|
+
keys: STATE_KEYS,
|
|
127
|
+
resolveLayer: (key) => instanceZLayer(key),
|
|
128
|
+
readTypingSetting: () => options.readTypingSetting(),
|
|
129
|
+
readCharacterColor: (name) => {
|
|
130
|
+
const color = engine.getCharacter(name)?.color;
|
|
131
|
+
return typeof color === "string" ? color : null;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** 一条视图意图落到本宿主(分支与状态键一一对应) */
|
|
136
|
+
function applyIntent(intent: StateIntent): void {
|
|
137
|
+
switch (intent.kind) {
|
|
138
|
+
case "layer":
|
|
139
|
+
layers.setOverride(intent.layer, intent.z);
|
|
140
|
+
layers.apply();
|
|
141
|
+
if (intent.layer === "video") layers.applyVideo(); // 视频层在端口内部
|
|
142
|
+
return;
|
|
143
|
+
case "speaker":
|
|
144
|
+
dom.speaker.textContent = intent.text;
|
|
145
|
+
dom.speaker.style.color = intent.color ?? "";
|
|
146
|
+
return;
|
|
147
|
+
case "dialog-text":
|
|
148
|
+
dialogue.setDialogText(intent.text, intent.setting);
|
|
149
|
+
return;
|
|
150
|
+
case "waiting":
|
|
151
|
+
dialogue.setWaiting(intent.waiting);
|
|
152
|
+
return;
|
|
153
|
+
case "nvl-mode":
|
|
154
|
+
dialogue.setNvlMode(intent.mode);
|
|
155
|
+
return;
|
|
156
|
+
case "nvl-buffer":
|
|
157
|
+
dialogue.setNvlBuffer(intent.lines);
|
|
158
|
+
return;
|
|
159
|
+
case "choices":
|
|
160
|
+
dialogue.setMenuChanged();
|
|
161
|
+
return;
|
|
162
|
+
case "dialog-visible":
|
|
163
|
+
dialogue.setDialogVisible(intent.hidden);
|
|
164
|
+
return;
|
|
165
|
+
case "elements":
|
|
166
|
+
elements = intent.elements;
|
|
167
|
+
renderElements();
|
|
168
|
+
return;
|
|
169
|
+
case "none":
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
engine.onStateChanged(({ key, value }) => {
|
|
175
|
+
applyIntent(toIntent(key, value));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** 出站事件:致命错误 / 提示 / 小游戏挂载 / 存档回执 */
|
|
179
|
+
function applyEvent(payload: OutboundPayload): void {
|
|
180
|
+
switch (payload.kind) {
|
|
181
|
+
case "engine.error":
|
|
182
|
+
dom.error.textContent = `[${payload.code}] ${payload.message}`;
|
|
183
|
+
console.error(`[engine] ${payload.code}: ${payload.message}`);
|
|
184
|
+
return;
|
|
185
|
+
case "notify":
|
|
186
|
+
options.toast(payload.text);
|
|
187
|
+
return;
|
|
188
|
+
case "minigame.mount":
|
|
189
|
+
dialogue.setMinigame(payload.game);
|
|
190
|
+
return;
|
|
191
|
+
case "save.done":
|
|
192
|
+
options.toast(`已保存到 ${payload.slot}`);
|
|
193
|
+
return;
|
|
194
|
+
case "load.done":
|
|
195
|
+
options.toast(`已读取 ${payload.slot}`);
|
|
196
|
+
options.syncMedia();
|
|
197
|
+
return;
|
|
198
|
+
default:
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
engine.onEvent(({ payload }) => {
|
|
204
|
+
applyEvent(payload);
|
|
205
|
+
});
|
|
206
|
+
}
|