@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,59 @@
|
|
|
1
|
+
# 桌面壳层(Tauri 2)
|
|
2
|
+
|
|
3
|
+
这一层是**壳**,不是框架层:页面结构沿用骨架那一份,`index.html` 与 `vite.config.ts` 原样保留。
|
|
4
|
+
加进来的只有工程根下的 `src-tauri/`、一份清单补丁,以及本层自己的组合根 `src/main.ts`——
|
|
5
|
+
它覆盖骨架的同名文件,只把存档端口换成 Rust 实现。
|
|
6
|
+
|
|
7
|
+
## 内容
|
|
8
|
+
|
|
9
|
+
- `src-tauri/Cargo.toml`、`build.rs`、`tauri.conf.json`、`capabilities/default.json`
|
|
10
|
+
- `src-tauri/src/`:`main.rs`(转发到库入口)、`lib.rs`(装配与命令注册)、`save.rs`、`preferences.rs`
|
|
11
|
+
- `src/main.ts`:覆盖骨架的组合根,`ports.saves` 换成 `createTauriSavePort()`
|
|
12
|
+
- `package.patch.json`:补 `@tauri-apps/api`(运行时)与 `@tauri-apps/cli`(命令行),加 `tauri` 脚本
|
|
13
|
+
|
|
14
|
+
窗口 1280×720,标题取工程名;`frontendDist` 指向 `../dist`,开发期 `devUrl` 指向
|
|
15
|
+
`http://localhost:1420`(与骨架 `vite.config.ts` 的 `server.port` 一致),所以 `beforeDevCommand`
|
|
16
|
+
直接跑骨架的 `pnpm dev`。
|
|
17
|
+
|
|
18
|
+
## 最小子集:只做两件事
|
|
19
|
+
|
|
20
|
+
### 存档换成文件
|
|
21
|
+
|
|
22
|
+
`save_write` / `save_read` / `save_delete` / `save_list`,一槽一文件落在应用数据目录的 `saves/` 下,
|
|
23
|
+
写临时文件再改名,避免半截 JSON 覆盖好档;槽位名只放行字母数字与 `-` `_`,挡住 `..` 这类逃逸路径。
|
|
24
|
+
|
|
25
|
+
为什么换:WebView 的 `localStorage` 落在系统给这个应用分配的 WebView 数据目录里,清缓存、换系统账户、
|
|
26
|
+
重装都会带走存档;桌面工程的存档应当是可指路径上的文件。
|
|
27
|
+
|
|
28
|
+
### 偏好换成文件
|
|
29
|
+
|
|
30
|
+
`preferences_read` / `preferences_write`,明文落在 `app_data/preferences.json`——偏好只有音量、
|
|
31
|
+
语言这类非敏感项,解密负担留在存档域;字段校验交给前端 `PlayerPreferences.hydrate`,
|
|
32
|
+
新增偏好项不必同步改 Rust。
|
|
33
|
+
|
|
34
|
+
命令名与参数形状是**跨语言契约**:`packages/adapters/src/save/tauri.ts` 与
|
|
35
|
+
`src/preferences/tauriPreferences.ts` 按这些名字 invoke,改名要两边同时改。
|
|
36
|
+
|
|
37
|
+
## 工程文件与资源:不换端口,也不造命令
|
|
38
|
+
|
|
39
|
+
骨架的 `vite.config.ts` 把 `Resources/` 当静态根,打包时这些文件被复制进 `dist/`,
|
|
40
|
+
所以打包后 `/project.json`、`/Stories/**`、`/Audio/x.mp3` 仍按同一套路径取得到。
|
|
41
|
+
桌面壳不需要为此多造一组 Rust 命令,也不需要 `fs` 能力或 `asset` 协议——
|
|
42
|
+
除非工程要读**运行时可变的**外部目录(那时该加的是一对「端口 + 适配器」,不是往本层塞命令)。
|
|
43
|
+
|
|
44
|
+
## 故意不提供的
|
|
45
|
+
|
|
46
|
+
资源加密、媒体 HTTP 服务、zip 索引、工程写回、开发期 WS——这些在参考宿主里有实现,
|
|
47
|
+
本层不带。要加密或媒体服务的工程自己加:能力以端口 + 适配器成对出现,
|
|
48
|
+
壳层只负责把端口接到原生侧(插口是骨架的 `src/ports.ts` 与 `bootHost` 的 `ports` 选项)。
|
|
49
|
+
|
|
50
|
+
## 怎么用
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm install
|
|
54
|
+
pnpm tauri dev
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`pnpm tauri dev` 会先跑骨架的 `vite`,再编译 Rust 侧并开窗。首次编译要下依赖,耗时在 Rust 侧。
|
|
58
|
+
|
|
59
|
+
打包命令(`pnpm tauri build`)本层不在自动化流程里跑,需要时由人手工执行。
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组合根(桌面壳形态):与骨架只差一处——把存档端口换成 Rust 实现。
|
|
3
|
+
*
|
|
4
|
+
* 为什么换存档:WebView 的 `localStorage` 落在系统给这个应用分配的 WebView 数据目录里,
|
|
5
|
+
* 清缓存、换系统账户、重装都会带走存档;桌面工程的存档应当是可指路径上的文件。
|
|
6
|
+
* 换法是把端口交给 `bootHost` 的 `ports.saves`,装配顺序与其余接线一行不改。
|
|
7
|
+
*
|
|
8
|
+
* 工程文件与资源**不换**:骨架的 `vite.config.ts` 把 `Resources/` 当静态根,
|
|
9
|
+
* 打包时这些文件被复制进 `dist/`,所以打包后 `/project.json`、`/Stories/**`、
|
|
10
|
+
* `/Audio/x.mp3` 仍按同一套路径取得到——桌面壳不需要为此多造一组 Rust 命令。
|
|
11
|
+
*
|
|
12
|
+
* 页面结构沿用骨架的 `index.html`(本层不带自己的),故节点解析与骨架同源。
|
|
13
|
+
*/
|
|
14
|
+
import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
|
|
15
|
+
import { createTauriSavePort } from "@lingfan-engine/adapters";
|
|
16
|
+
import { bootHost, must, reportBootFailure, type StageDom } from "./host";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
|
|
20
|
+
* 打包后它们位于 `dist/` 顶层,路径与开发期一致。
|
|
21
|
+
* **新增故事文件必须同步此表**(Web 形态按显式清单取文件)。
|
|
22
|
+
*/
|
|
23
|
+
const MANIFEST = "project.json";
|
|
24
|
+
/** 本工程的故事文件表(相对资源根;新增故事必须同步此表) */
|
|
25
|
+
const STORIES = ["Stories/title/title_main.story"];
|
|
26
|
+
|
|
27
|
+
/** 页面节点句柄表:每个 id 都必须存在于 index.html(缺一个 `must` 当场抛) */
|
|
28
|
+
const dom: StageDom = {
|
|
29
|
+
root: must("#stage-root"),
|
|
30
|
+
stage: must("#stage"),
|
|
31
|
+
dialogue: must("#dialogue"),
|
|
32
|
+
speaker: must("#speaker"),
|
|
33
|
+
text: must("#text"),
|
|
34
|
+
hint: must("#hint"),
|
|
35
|
+
nvl: must("#nvl"),
|
|
36
|
+
choices: must("#choices"),
|
|
37
|
+
notifications: must("#notifications"),
|
|
38
|
+
toolbar: must("#toolbar"),
|
|
39
|
+
slot: must<HTMLSelectElement>("#slot"),
|
|
40
|
+
save: must<HTMLButtonElement>("#save"),
|
|
41
|
+
load: must<HTMLButtonElement>("#load"),
|
|
42
|
+
transition: must("#transition"),
|
|
43
|
+
error: must("#error"),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
void bootHost({
|
|
47
|
+
dom,
|
|
48
|
+
manifestFile: MANIFEST,
|
|
49
|
+
stories: STORIES,
|
|
50
|
+
defaultCps: DEFAULT_TEXT_CPS,
|
|
51
|
+
toastDurationMs: DEFAULT_TOAST_MS,
|
|
52
|
+
ports: {
|
|
53
|
+
// 存档落到应用数据目录的 saves/ 下,一槽一文件(加密与回档保护在 Rust 侧扩展)
|
|
54
|
+
saves: createTauriSavePort(),
|
|
55
|
+
},
|
|
56
|
+
}).catch((error: unknown) => {
|
|
57
|
+
reportBootFailure(dom, error);
|
|
58
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "__project__"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
|
|
6
|
+
[lib]
|
|
7
|
+
name = "app_lib"
|
|
8
|
+
crate-type = ["staticlib", "cdylib", "rlib"]
|
|
9
|
+
|
|
10
|
+
[build-dependencies]
|
|
11
|
+
tauri-build = { version = "2", features = [] }
|
|
12
|
+
|
|
13
|
+
[dependencies]
|
|
14
|
+
tauri = { version = "2", features = [] }
|
|
15
|
+
serde = { version = "1", features = ["derive"] }
|
|
16
|
+
serde_json = "1"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//! 库入口:装配 Tauri 应用并注册命令表。
|
|
2
|
+
//!
|
|
3
|
+
//! 拆出 `lib.rs` 是为了让移动端也能复用同一份 `run()`(移动端由外部运行器链接
|
|
4
|
+
//! `app_lib`,没有自己的 `main`)。桌面端 `main.rs` 只做一次转发。
|
|
5
|
+
//!
|
|
6
|
+
//! 命令分两个域注册:存档(`save/`)与偏好(`preferences/`)。新增命令时在对应
|
|
7
|
+
//! 域的模块里实现,再到 `invoke_handler` 里点名——Tauri 不做按模块自动收集。
|
|
8
|
+
|
|
9
|
+
mod preferences;
|
|
10
|
+
mod save;
|
|
11
|
+
|
|
12
|
+
/// 启动应用。命令名必须与前端 `packages/adapters/src/save/tauri.ts`、
|
|
13
|
+
/// `src/preferences/tauriPreferences.ts` 里的 `invoke(...)` 逐字一致。
|
|
14
|
+
pub fn run() {
|
|
15
|
+
tauri::Builder::default()
|
|
16
|
+
.invoke_handler(tauri::generate_handler![
|
|
17
|
+
save::save_write,
|
|
18
|
+
save::save_read,
|
|
19
|
+
save::save_delete,
|
|
20
|
+
save::save_list,
|
|
21
|
+
preferences::preferences_read,
|
|
22
|
+
preferences::preferences_write,
|
|
23
|
+
])
|
|
24
|
+
.run(tauri::generate_context!())
|
|
25
|
+
.expect("Tauri 应用启动失败");
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//! 偏好域的 Rust 侧实现:`preferences_read` / `preferences_write` 两条命令。
|
|
2
|
+
//!
|
|
3
|
+
//! 与存档的差别在于**不加密**:偏好只有音量、语言、窗口尺寸这类非敏感项,
|
|
4
|
+
//! 密钥管理留在存档域。文件落在应用数据目录的 `preferences.json`,明文 JSON。
|
|
5
|
+
//!
|
|
6
|
+
//! 字段校验不在这里做:读回来的值原样交给前端,由引擎的 `PlayerPreferences.hydrate`
|
|
7
|
+
//! 兜非法字段并退回缺省值。这样新增偏好项时不需要同步改 Rust。
|
|
8
|
+
|
|
9
|
+
use std::fs;
|
|
10
|
+
use std::path::PathBuf;
|
|
11
|
+
|
|
12
|
+
use serde_json::Value;
|
|
13
|
+
use tauri::{AppHandle, Manager};
|
|
14
|
+
|
|
15
|
+
/// 偏好文件路径。
|
|
16
|
+
fn prefs_path(app: &AppHandle) -> Result<PathBuf, String> {
|
|
17
|
+
let dir = app
|
|
18
|
+
.path()
|
|
19
|
+
.app_data_dir()
|
|
20
|
+
.map_err(|e| format!("取应用数据目录失败: {e}"))?;
|
|
21
|
+
fs::create_dir_all(&dir).map_err(|e| format!("建应用数据目录失败: {e}"))?;
|
|
22
|
+
Ok(dir.join("preferences.json"))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[tauri::command]
|
|
26
|
+
pub fn preferences_read(app: AppHandle) -> Result<Option<Value>, String> {
|
|
27
|
+
let path = prefs_path(&app)?;
|
|
28
|
+
// 第一次启动没有这个文件,返回 None 让前端取缺省值,不当成错误。
|
|
29
|
+
let text = match fs::read_to_string(&path) {
|
|
30
|
+
Ok(text) => text,
|
|
31
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
32
|
+
Err(e) => return Err(format!("读偏好失败: {e}")),
|
|
33
|
+
};
|
|
34
|
+
match serde_json::from_str::<Value>(&text) {
|
|
35
|
+
Ok(value) => Ok(Some(value)),
|
|
36
|
+
// 文件被手工改坏时按「没有偏好」处理:偏好不值得为它挡住启动。
|
|
37
|
+
Err(_) => Ok(None),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#[tauri::command]
|
|
42
|
+
pub fn preferences_write(app: AppHandle, prefs: Value) -> Result<(), String> {
|
|
43
|
+
let path = prefs_path(&app)?;
|
|
44
|
+
let text = serde_json::to_string_pretty(&prefs).map_err(|e| format!("序列化偏好失败: {e}"))?;
|
|
45
|
+
// 与存档同法:临时文件 + 改名,避免半截 JSON 覆盖掉还能读的旧偏好。
|
|
46
|
+
let tmp = path.with_extension("json.tmp");
|
|
47
|
+
fs::write(&tmp, text).map_err(|e| format!("写偏好失败: {e}"))?;
|
|
48
|
+
fs::rename(&tmp, &path).map_err(|e| format!("替换偏好失败: {e}"))?;
|
|
49
|
+
Ok(())
|
|
50
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
//! 存档域的 Rust 侧实现:`save_write` / `save_read` / `save_delete` / `save_list` 四条命令。
|
|
2
|
+
//!
|
|
3
|
+
//! 命令名与参数形状是**跨语言契约**:前端 `packages/adapters/src/save/tauri.ts` 按这些名字
|
|
4
|
+
//! invoke,改名或改参数键就要两边同时改。槽位摘要是 snake_case,前端自己做 camelCase 映射。
|
|
5
|
+
//!
|
|
6
|
+
//! 存档落在应用数据目录的 `saves/` 下,一槽一文件(`<slot>.json`),先写临时文件再改名,
|
|
7
|
+
//! 避免进程在写一半时被杀留下半个存档。
|
|
8
|
+
|
|
9
|
+
use std::fs;
|
|
10
|
+
use std::path::PathBuf;
|
|
11
|
+
|
|
12
|
+
use serde::{Deserialize, Serialize};
|
|
13
|
+
use tauri::{AppHandle, Manager};
|
|
14
|
+
|
|
15
|
+
/// `save_list` 的返回行。字段名保持 snake_case,前端按这个形状映射成引擎的槽位摘要。
|
|
16
|
+
#[derive(Serialize, Deserialize)]
|
|
17
|
+
pub struct SlotSummary {
|
|
18
|
+
pub slot: String,
|
|
19
|
+
pub save_count: u32,
|
|
20
|
+
pub timestamp: u64,
|
|
21
|
+
pub mode: String,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// 存档目录:应用数据目录下的 `saves/`,不存在时创建。
|
|
25
|
+
fn saves_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
|
26
|
+
let dir = app
|
|
27
|
+
.path()
|
|
28
|
+
.app_data_dir()
|
|
29
|
+
.map_err(|e| format!("取应用数据目录失败: {e}"))?
|
|
30
|
+
.join("saves");
|
|
31
|
+
fs::create_dir_all(&dir).map_err(|e| format!("建存档目录失败: {e}"))?;
|
|
32
|
+
Ok(dir)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// 校验槽位名。槽位名来自调用方,`..` 或路径分隔符会逃出存档目录,
|
|
36
|
+
/// 因此只放行 ASCII 字母数字与 `-` `_`。
|
|
37
|
+
///
|
|
38
|
+
/// 单独拆出来是为了能直接对校验规则做单元测试:`slot_path` 需要 `AppHandle`,
|
|
39
|
+
/// 在测试里造不出来。
|
|
40
|
+
fn validate_slot(slot: &str) -> Result<(), String> {
|
|
41
|
+
if slot.is_empty()
|
|
42
|
+
|| !slot
|
|
43
|
+
.chars()
|
|
44
|
+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
45
|
+
{
|
|
46
|
+
return Err(format!("非法槽位名: {slot}"));
|
|
47
|
+
}
|
|
48
|
+
Ok(())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// 单个槽位的文件路径。
|
|
52
|
+
fn slot_path(app: &AppHandle, slot: &str) -> Result<PathBuf, String> {
|
|
53
|
+
validate_slot(slot)?;
|
|
54
|
+
Ok(saves_dir(app)?.join(format!("{slot}.json")))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[tauri::command]
|
|
58
|
+
pub fn save_write(
|
|
59
|
+
app: AppHandle,
|
|
60
|
+
slot: String,
|
|
61
|
+
payload: String,
|
|
62
|
+
mode: String,
|
|
63
|
+
) -> Result<(), String> {
|
|
64
|
+
let target = slot_path(&app, &slot)?;
|
|
65
|
+
let body = serde_json::json!({
|
|
66
|
+
"slot": slot,
|
|
67
|
+
"mode": mode,
|
|
68
|
+
"timestamp": now_secs(),
|
|
69
|
+
"payload": payload,
|
|
70
|
+
});
|
|
71
|
+
let text = serde_json::to_string(&body).map_err(|e| format!("序列化存档失败: {e}"))?;
|
|
72
|
+
|
|
73
|
+
// 先写临时文件再改名:改名在同一文件系统内是原子的,
|
|
74
|
+
// 中途崩溃只会留下临时文件,不会让槽位文件变成半截 JSON。
|
|
75
|
+
let tmp = target.with_extension("json.tmp");
|
|
76
|
+
fs::write(&tmp, text).map_err(|e| format!("写存档失败: {e}"))?;
|
|
77
|
+
fs::rename(&tmp, &target).map_err(|e| format!("替换存档失败: {e}"))?;
|
|
78
|
+
Ok(())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[tauri::command]
|
|
82
|
+
pub fn save_read(app: AppHandle, slot: String) -> Result<String, String> {
|
|
83
|
+
let target = slot_path(&app, &slot)?;
|
|
84
|
+
let text = fs::read_to_string(&target).map_err(|e| format!("读存档失败: {e}"))?;
|
|
85
|
+
let body: serde_json::Value =
|
|
86
|
+
serde_json::from_str(&text).map_err(|e| format!("存档不是合法 JSON: {e}"))?;
|
|
87
|
+
// 只把 payload 还给前端:外面那层是本实现的封套,不属于存档内容。
|
|
88
|
+
body.get("payload")
|
|
89
|
+
.and_then(|v| v.as_str())
|
|
90
|
+
.map(|s| s.to_string())
|
|
91
|
+
.ok_or_else(|| format!("存档 {slot} 缺少 payload 字段"))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[tauri::command]
|
|
95
|
+
pub fn save_delete(app: AppHandle, slot: String) -> Result<(), String> {
|
|
96
|
+
let target = slot_path(&app, &slot)?;
|
|
97
|
+
// 槽位本就不存在时视为成功:删档是幂等操作,重试不该报错。
|
|
98
|
+
match fs::remove_file(&target) {
|
|
99
|
+
Ok(()) => Ok(()),
|
|
100
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
101
|
+
Err(e) => Err(format!("删档失败: {e}")),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[tauri::command]
|
|
106
|
+
pub fn save_list(app: AppHandle) -> Result<Vec<SlotSummary>, String> {
|
|
107
|
+
let dir = saves_dir(&app)?;
|
|
108
|
+
let mut out = Vec::new();
|
|
109
|
+
let entries = fs::read_dir(&dir).map_err(|e| format!("列存档目录失败: {e}"))?;
|
|
110
|
+
for entry in entries.flatten() {
|
|
111
|
+
let path = entry.path();
|
|
112
|
+
// 只认 `.json`:临时文件与别的东西不参与列表。
|
|
113
|
+
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
let Ok(text) = fs::read_to_string(&path) else {
|
|
117
|
+
continue;
|
|
118
|
+
};
|
|
119
|
+
let Ok(body) = serde_json::from_str::<serde_json::Value>(&text) else {
|
|
120
|
+
continue;
|
|
121
|
+
};
|
|
122
|
+
let slot = body
|
|
123
|
+
.get("slot")
|
|
124
|
+
.and_then(|v| v.as_str())
|
|
125
|
+
.map(|s| s.to_string())
|
|
126
|
+
.unwrap_or_else(|| {
|
|
127
|
+
path.file_stem()
|
|
128
|
+
.and_then(|s| s.to_str())
|
|
129
|
+
.unwrap_or_default()
|
|
130
|
+
.to_string()
|
|
131
|
+
});
|
|
132
|
+
out.push(SlotSummary {
|
|
133
|
+
slot,
|
|
134
|
+
save_count: body.get("save_count").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
|
|
135
|
+
timestamp: body.get("timestamp").and_then(|v| v.as_u64()).unwrap_or(0),
|
|
136
|
+
mode: body
|
|
137
|
+
.get("mode")
|
|
138
|
+
.and_then(|v| v.as_str())
|
|
139
|
+
.unwrap_or("normal")
|
|
140
|
+
.to_string(),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// 新的排前面:前端列表直接取这个顺序当「最近存档」。
|
|
144
|
+
out.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
|
145
|
+
Ok(out)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// 当前时间(秒)。只用于列表排序与展示,不参与任何校验。
|
|
149
|
+
fn now_secs() -> u64 {
|
|
150
|
+
std::time::SystemTime::now()
|
|
151
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
152
|
+
.map(|d| d.as_secs())
|
|
153
|
+
.unwrap_or(0)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[cfg(test)]
|
|
157
|
+
mod tests {
|
|
158
|
+
use super::validate_slot;
|
|
159
|
+
|
|
160
|
+
#[test]
|
|
161
|
+
fn accepts_regular_slot_names() {
|
|
162
|
+
for slot in ["slot1", "Slot_2", "a-b_c", "9"] {
|
|
163
|
+
assert!(validate_slot(slot).is_ok(), "{slot} 应当被接受");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn rejects_empty_slot_name() {
|
|
169
|
+
assert!(validate_slot("").is_err());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#[test]
|
|
173
|
+
fn rejects_names_escaping_save_dir() {
|
|
174
|
+
for slot in ["..", "../etc", "a/b", "a\\b", "a.b", "slot 1"] {
|
|
175
|
+
assert!(validate_slot(slot).is_err(), "{slot} 应当被拒绝");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://schema.tauri.app/config/2",
|
|
3
|
+
"productName": "__PROJECT__",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"identifier": "com.example.__project__",
|
|
6
|
+
"build": {
|
|
7
|
+
"frontendDist": "../dist",
|
|
8
|
+
"devUrl": "http://localhost:1420",
|
|
9
|
+
"beforeDevCommand": "pnpm dev",
|
|
10
|
+
"beforeBuildCommand": "pnpm build"
|
|
11
|
+
},
|
|
12
|
+
"app": {
|
|
13
|
+
"windows": [
|
|
14
|
+
{
|
|
15
|
+
"title": "__PROJECT__",
|
|
16
|
+
"width": 1280,
|
|
17
|
+
"height": 720
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"security": {
|
|
21
|
+
"csp": null
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"bundle": {
|
|
25
|
+
"active": true,
|
|
26
|
+
"targets": "all",
|
|
27
|
+
"icon": [
|
|
28
|
+
"icons/32x32.png",
|
|
29
|
+
"icons/128x128.png",
|
|
30
|
+
"icons/128x128@2x.png",
|
|
31
|
+
"icons/icon.icns",
|
|
32
|
+
"icons/icon.ico"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>__PROJECT__</title>
|
|
7
|
+
<!-- 页面外观与层样式:与「页面结构」分开,框架叠加层可复用同一份样式表 -->
|
|
8
|
+
<link rel="stylesheet" href="/src/styles/stage.css" />
|
|
9
|
+
</head>
|
|
10
|
+
|
|
11
|
+
<body>
|
|
12
|
+
<!-- Vue 的挂载点:层结构在 src/App.vue 里声明,样式仍在 src/styles/stage.css -->
|
|
13
|
+
<div id="app"></div>
|
|
14
|
+
<script type="module" src="/src/main.ts"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"vue": "^3.5.44"
|
|
4
|
+
},
|
|
5
|
+
"devDependencies": {
|
|
6
|
+
"@types/node": "^26.6.3",
|
|
7
|
+
"@vitejs/plugin-vue": "^6.0.10",
|
|
8
|
+
"vue-tsc": "^3.3.5"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "vue-tsc --noEmit && tsc --noEmit -p tsconfig.node.json"
|
|
12
|
+
},
|
|
13
|
+
"create": {
|
|
14
|
+
"pinned": ["typescript"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
页面结构(Vue 形态):与骨架的 index.html 逐层对齐——引擎与适配器不认识它,
|
|
3
|
+
换框架只改这份模板与组合根。所有 id 都是与引擎侧共用的契约,改名必须同步
|
|
4
|
+
`src/main.ts` 里的句柄表与 `src/styles/stage.css`。
|
|
5
|
+
|
|
6
|
+
本组件**不碰引擎**:装配在 `src/main.ts`(唯一组合根),交互监听由骨架的
|
|
7
|
+
`src/host/` 用原生事件绑定——Vue 在这里只负责把节点渲染出来。
|
|
8
|
+
-->
|
|
9
|
+
<template>
|
|
10
|
+
<!-- 舞台根:屏幕震动(shake)施于此;各层 z 由 main.ts 按 shell.layers 写入 -->
|
|
11
|
+
<div id="stage-root">
|
|
12
|
+
<div id="stage"></div>
|
|
13
|
+
<div id="nvl"></div>
|
|
14
|
+
<div id="dialogue">
|
|
15
|
+
<div id="speaker"></div>
|
|
16
|
+
<div id="text">加载中……</div>
|
|
17
|
+
<div id="hint"></div>
|
|
18
|
+
</div>
|
|
19
|
+
<div id="choices"></div>
|
|
20
|
+
<div id="notifications"></div>
|
|
21
|
+
<div id="toolbar">
|
|
22
|
+
<span>存档</span>
|
|
23
|
+
<select id="slot"></select>
|
|
24
|
+
<button id="save">存</button>
|
|
25
|
+
<button id="load">读</button>
|
|
26
|
+
</div>
|
|
27
|
+
<div id="transition"></div>
|
|
28
|
+
<div id="error"></div>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组合根(Vue 形态):装配适配器 → 加载组装工程 → 建引擎 → 挂载展示层。
|
|
3
|
+
*
|
|
4
|
+
* 与骨架的差别只有「谁渲染页面结构」:这里先 `createApp(App).mount("#app")`,
|
|
5
|
+
* 再读节点句柄——引擎要的 15 个节点由 `App.vue` 渲染出来,挂载前 `must` 只会抛。
|
|
6
|
+
* 骨架的 `src/host/` 一行未改:它操作的是 `StageDom` 句柄表,不认识 Vue。
|
|
7
|
+
*
|
|
8
|
+
* 页面的层结构在 `src/App.vue`,样式在 `src/styles/stage.css`(两者都来自模板骨架,
|
|
9
|
+
* 本层只替换入口与组合根)。
|
|
10
|
+
*/
|
|
11
|
+
import { createApp } from "vue";
|
|
12
|
+
import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
|
|
13
|
+
import { bootHost, must, reportBootFailure, type StageDom } from "./host";
|
|
14
|
+
import App from "./App.vue";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
|
|
18
|
+
* 宿主把 `Resources` 作为静态根,故逻辑路径就是 `project.json` / `Stories/**`。
|
|
19
|
+
* **新增故事文件必须同步此表**(Web 形态按显式清单取文件)。
|
|
20
|
+
*/
|
|
21
|
+
const MANIFEST = "project.json";
|
|
22
|
+
/** 本工程的故事文件表(相对资源根;新增故事必须同步此表) */
|
|
23
|
+
const STORIES = ["Stories/title/title_main.story"];
|
|
24
|
+
|
|
25
|
+
/** 页面节点句柄表:每个 id 都必须由 `App.vue` 渲染(缺一个 `must` 当场抛) */
|
|
26
|
+
function readDom(): StageDom {
|
|
27
|
+
return {
|
|
28
|
+
root: must("#stage-root"),
|
|
29
|
+
stage: must("#stage"),
|
|
30
|
+
dialogue: must("#dialogue"),
|
|
31
|
+
speaker: must("#speaker"),
|
|
32
|
+
text: must("#text"),
|
|
33
|
+
hint: must("#hint"),
|
|
34
|
+
nvl: must("#nvl"),
|
|
35
|
+
choices: must("#choices"),
|
|
36
|
+
notifications: must("#notifications"),
|
|
37
|
+
toolbar: must("#toolbar"),
|
|
38
|
+
slot: must<HTMLSelectElement>("#slot"),
|
|
39
|
+
save: must<HTMLButtonElement>("#save"),
|
|
40
|
+
load: must<HTMLButtonElement>("#load"),
|
|
41
|
+
transition: must("#transition"),
|
|
42
|
+
error: must("#error"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 挂载页面结构;失败(含节点缺失)写进页面错误条,不留白屏 */
|
|
47
|
+
function mountApp(): StageDom | undefined {
|
|
48
|
+
try {
|
|
49
|
+
createApp(App).mount("#app");
|
|
50
|
+
return readDom();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(error);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const dom = mountApp();
|
|
58
|
+
if (dom !== undefined) {
|
|
59
|
+
void bootHost({
|
|
60
|
+
dom,
|
|
61
|
+
manifestFile: MANIFEST,
|
|
62
|
+
stories: STORIES,
|
|
63
|
+
defaultCps: DEFAULT_TEXT_CPS,
|
|
64
|
+
toastDurationMs: DEFAULT_TOAST_MS,
|
|
65
|
+
}).catch((error: unknown) => {
|
|
66
|
+
reportBootFailure(dom, error);
|
|
67
|
+
});
|
|
68
|
+
}
|