Fix DashScope env loading for scene image generation
This commit is contained in:
@@ -18,7 +18,7 @@ VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
||||
|
||||
# Server-side DashScope endpoint and API key used by the local scene-image proxy.
|
||||
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
|
||||
DASHSCOPE_API_KEY=""
|
||||
DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990"
|
||||
|
||||
# Optional model name for custom-world scene image generation.
|
||||
DASHSCOPE_IMAGE_MODEL="wan2.2-t2i-flash"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,5 +4,4 @@ dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
.env.local
|
||||
|
||||
957
docs/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md
Normal file
957
docs/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md
Normal file
@@ -0,0 +1,957 @@
|
||||
# 服务端部署与 CORS 完整技术方案
|
||||
|
||||
日期:`2026-04-05`
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文要解决的不只是“当前部署到服务器后浏览器报 CORS”,而是同时解决下面几类问题:
|
||||
|
||||
1. 浏览器访问大模型、图片生成等第三方服务时的跨域问题
|
||||
2. 前端不能安全暴露 API Key 的问题
|
||||
3. 当前项目开发期依赖 Vite middleware,生产环境缺少正式 API 服务的问题
|
||||
4. 后续服务端能力扩展时,避免再次推倒重来
|
||||
|
||||
目标结论很明确:
|
||||
|
||||
- **浏览器以后不再直连第三方大模型 / 图片服务**
|
||||
- **浏览器只访问我们自己的站点域名下的 `/api/*`**
|
||||
- **生产环境新增独立 Node API 服务,Vite 代理只保留给开发环境**
|
||||
- **把运行时接口、编辑器接口、异步任务、存储能力分层设计**
|
||||
|
||||
---
|
||||
|
||||
## 2. 结合当前仓库的现状判断
|
||||
|
||||
从当前仓库可以确认几件事:
|
||||
|
||||
### 2.1 前端已经按“走本地代理”在写
|
||||
|
||||
当前前端代码并不是直接请求第三方接口,而是请求:
|
||||
|
||||
- `/api/llm/chat/completions`
|
||||
- `/api/custom-world/scene-image`
|
||||
- `/api/item-overrides`
|
||||
- `/api/npc-visual-overrides`
|
||||
- `/api/character-overrides`
|
||||
- `/api/scene-overrides`
|
||||
- `/api/state-function-overrides`
|
||||
- `/api/character-visual/publish`
|
||||
- `/api/animation/publish`
|
||||
|
||||
这说明项目方向本身就是对的:**前端应该访问自己的 API 层,而不是浏览器直连外部服务。**
|
||||
|
||||
### 2.2 当前 API 层还只是开发期能力
|
||||
|
||||
这些接口现在主要由 [scripts/dev-server/localApiPlugins.ts](/E:/Repos/Genarrative/scripts/dev-server/localApiPlugins.ts) 挂在 Vite dev/preview 服务器里。
|
||||
|
||||
这在本地开发阶段很方便,但生产环境存在几个明显问题:
|
||||
|
||||
1. `Vite dev server / preview server` 不适合长期承担正式后端职责
|
||||
2. 编辑器写接口、文件读写、代理转发都混在构建配置链路里
|
||||
3. 未来加入鉴权、限流、审计、任务队列、数据库时会非常难扩展
|
||||
4. 浏览器部署后如果没有这层正式 API 服务,就会重新退回“直连第三方接口”,于是重新触发 CORS
|
||||
|
||||
### 2.3 工程审查文档也已经指出同样风险
|
||||
|
||||
[docs/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md](/E:/Repos/Genarrative/docs/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md) 明确沉淀过一条经验:
|
||||
|
||||
- 浏览器直连会遇到 CORS
|
||||
- 更稳的方案是开发服务器代理,再由前端请求 `/api/llm/...`
|
||||
|
||||
[docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](/E:/Repos/Genarrative/docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 也明确指出:
|
||||
|
||||
- 编辑器、运行时、类后端能力全部耦合在 Vite 配置里
|
||||
- 未来如果做独立部署、多人协作、远程编辑、权限控制,会非常难迁移
|
||||
|
||||
所以这次不是简单“补个 CORS header”就完了,而是应该顺势把正式服务端边界立起来。
|
||||
|
||||
---
|
||||
|
||||
## 3. CORS 问题的根因
|
||||
|
||||
## 3.1 浏览器的跨域限制不是第三方接口“能调用”就能绕过
|
||||
|
||||
只要浏览器页面域名和目标接口域名不一致,就可能触发跨域限制。
|
||||
|
||||
典型场景:
|
||||
|
||||
- 页面在 `https://game.example.com`
|
||||
- 浏览器直接请求 `https://ark.cn-beijing.volces.com/...`
|
||||
- 或直接请求 `https://dashscope.aliyuncs.com/...`
|
||||
|
||||
此时就算第三方服务本身可用,只要对方没有返回允许你站点的 CORS 头,浏览器依然会拦截。
|
||||
|
||||
## 3.2 这类请求几乎一定会触发预检
|
||||
|
||||
因为现在请求通常具备以下特征:
|
||||
|
||||
- `Content-Type: application/json`
|
||||
- `Authorization: Bearer ...`
|
||||
- `POST`
|
||||
- 流式返回 / SSE
|
||||
|
||||
这类请求很容易先发一个 `OPTIONS` 预检请求。只要预检没过,真正请求根本发不出去。
|
||||
|
||||
## 3.3 即使第三方临时支持 CORS,也不应该让浏览器直连
|
||||
|
||||
因为浏览器直连还有更大的问题:
|
||||
|
||||
1. API Key 会暴露到前端
|
||||
2. 无法统一限流和熔断
|
||||
3. 无法统一记录调用日志
|
||||
4. 无法对不同上游做协议适配
|
||||
5. 无法在后续接入鉴权、配额、用户级审计
|
||||
|
||||
所以**正确方向不是“让浏览器跨域成功访问第三方”,而是“让浏览器根本不需要跨域访问第三方”。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐的总体方案
|
||||
|
||||
## 4.1 核心原则
|
||||
|
||||
1. **同源优先**:浏览器尽量只访问当前站点同域下的 `/api/*`
|
||||
2. **密钥只在服务端存在**:前端不再持有真实第三方 Key
|
||||
3. **运行时接口与编辑器接口分层**
|
||||
4. **长耗时任务异步化**:图片、视频、资产发布不要长期卡在同步 HTTP 请求里
|
||||
5. **存储外置化**:生产环境不要继续把生成文件直接写回源码目录
|
||||
6. **可观测性内建**:日志、追踪、限流、告警一开始就留口子
|
||||
|
||||
## 4.2 推荐目标架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["玩家浏览器 / 编辑器浏览器"] --> B["CDN / Nginx"]
|
||||
B --> C["静态前端 dist"]
|
||||
B --> D["Node API Gateway / BFF"]
|
||||
|
||||
D --> E["LLM Provider Adapter"]
|
||||
D --> F["Image / Media Adapter"]
|
||||
D --> G["业务服务层"]
|
||||
D --> H["Redis / Queue"]
|
||||
D --> I["PostgreSQL"]
|
||||
D --> J["对象存储 OSS / S3"]
|
||||
|
||||
H --> K["Worker 异步任务进程"]
|
||||
K --> F
|
||||
K --> J
|
||||
K --> I
|
||||
```
|
||||
|
||||
这套架构的意思是:
|
||||
|
||||
- `Nginx` 负责统一入口、静态资源、反向代理
|
||||
- `Node API Gateway / BFF` 负责真正的 API 接入
|
||||
- 第三方大模型、图片服务不再暴露给浏览器
|
||||
- `Redis / Queue + Worker` 负责后续长任务
|
||||
- `PostgreSQL` 负责持久化业务数据
|
||||
- `OSS / S3` 负责图片、动画、导出资产等对象存储
|
||||
|
||||
---
|
||||
|
||||
## 5. 服务分层设计
|
||||
|
||||
## 5.1 Web 层
|
||||
|
||||
职责:
|
||||
|
||||
- 托管前端 `dist`
|
||||
- 提供 SPA 回退到 `index.html`
|
||||
- 将 `/api/*` 代理给 Node API 服务
|
||||
- 统一做 TLS、压缩、缓存、静态资源头
|
||||
|
||||
推荐:
|
||||
|
||||
- `Nginx` 或 `OpenResty`
|
||||
- 线上前面可再加 CDN
|
||||
|
||||
## 5.2 API Gateway / BFF 层
|
||||
|
||||
职责:
|
||||
|
||||
- 接收浏览器请求
|
||||
- 做 CORS、鉴权、限流、审计
|
||||
- 统一代理 LLM、图片生成、编辑器接口
|
||||
- 把前端协议转换成上游协议
|
||||
- 屏蔽第三方差异
|
||||
|
||||
推荐技术:
|
||||
|
||||
- `Node.js 22 + TypeScript`
|
||||
- Web 框架可选 `Fastify` 或 `Express`
|
||||
|
||||
建议取舍:
|
||||
|
||||
- 如果要**最快迁移当前项目**,可以先上 `Express`
|
||||
- 如果要**从一开始就更重视插件化、schema、性能**,可以直接上 `Fastify`
|
||||
|
||||
本方案不强绑定框架,重点是边界,而不是框架名。
|
||||
|
||||
## 5.3 Adapter 层
|
||||
|
||||
不要让业务代码直接到处写第三方接口细节,建议抽成单独适配层:
|
||||
|
||||
- `llmAdapter`
|
||||
- `imageAdapter`
|
||||
- `storageAdapter`
|
||||
- `queueAdapter`
|
||||
|
||||
职责:
|
||||
|
||||
- 统一请求签名
|
||||
- 统一错误结构
|
||||
- 统一超时、重试、熔断
|
||||
- 统一日志字段
|
||||
|
||||
## 5.4 业务服务层
|
||||
|
||||
建议把业务服务按域拆开,而不是继续长在 Vite 插件里:
|
||||
|
||||
- `runtimeService`
|
||||
- 聊天/剧情推进
|
||||
- 自定义世界生成
|
||||
- 场景图片生成
|
||||
- `editorService`
|
||||
- 预设读写
|
||||
- override 管理
|
||||
- 资源发布
|
||||
- `assetService`
|
||||
- 生成图片入库
|
||||
- 角色/动画资源清单
|
||||
- `authService`
|
||||
- 登录
|
||||
- 用户角色
|
||||
- 会话/Token
|
||||
|
||||
## 5.5 Worker 层
|
||||
|
||||
以下能力建议逐步迁移到异步任务:
|
||||
|
||||
- 自定义世界场景图生成
|
||||
- 角色动画素材生成
|
||||
- 视频/大图后处理
|
||||
- 导出包构建
|
||||
|
||||
理由:
|
||||
|
||||
1. 任务时间长,浏览器同步等待容易超时
|
||||
2. 失败重试不方便
|
||||
3. 需要排队与限流
|
||||
4. 后续多用户并发时,不能把 Node API 线程长期占住
|
||||
|
||||
---
|
||||
|
||||
## 6. 推荐的域名与流量策略
|
||||
|
||||
## 6.1 最推荐:前端与 API 同域
|
||||
|
||||
推荐站点入口:
|
||||
|
||||
- `https://game.example.com/` 提供前端静态资源
|
||||
- `https://game.example.com/api/*` 提供 API
|
||||
|
||||
这种方式下:
|
||||
|
||||
- 浏览器看见的是**同源**
|
||||
- 主站绝大多数请求**根本不需要 CORS**
|
||||
- 部署最稳
|
||||
- Cookie、会话、CSRF、防缓存等策略也更容易统一
|
||||
|
||||
这是**解决 CORS 的首选方案**。
|
||||
|
||||
## 6.2 备选:前后端分子域
|
||||
|
||||
如果以后要把 API 单独托管,也可以用:
|
||||
|
||||
- 前端:`https://app.example.com`
|
||||
- API:`https://api.example.com`
|
||||
|
||||
此时才需要真正开启 CORS 白名单:
|
||||
|
||||
- 只允许 `https://app.example.com`
|
||||
- 管理后台再额外允许 `https://admin.example.com`
|
||||
- 不允许 `*`
|
||||
|
||||
## 6.3 不推荐:浏览器直接访问第三方服务
|
||||
|
||||
以下方式不再推荐:
|
||||
|
||||
- 浏览器直接请求火山、DashScope、OpenAI 等服务
|
||||
- 浏览器直接持有厂商 Key
|
||||
- 浏览器直接上传生成资产到第三方后再回写本地
|
||||
|
||||
这种方式即使短期能跑,也会反复被 CORS、安全和审计问题反噬。
|
||||
|
||||
---
|
||||
|
||||
## 7. CORS 策略设计
|
||||
|
||||
## 7.1 总体策略
|
||||
|
||||
### 运行时主站
|
||||
|
||||
如果使用同域反代:
|
||||
|
||||
- 玩家访问 `game.example.com`
|
||||
- API 也在 `game.example.com/api/*`
|
||||
|
||||
此时**运行时主站可以不依赖 CORS**。
|
||||
|
||||
### 管理后台 / 编辑器
|
||||
|
||||
如果未来编辑器单独部署到:
|
||||
|
||||
- `https://editor.example.com`
|
||||
|
||||
而 API 在:
|
||||
|
||||
- `https://api.example.com`
|
||||
|
||||
才对编辑器开启白名单 CORS。
|
||||
|
||||
## 7.2 允许策略
|
||||
|
||||
建议只允许这些 Origin:
|
||||
|
||||
- `https://app.example.com`
|
||||
- `https://editor.example.com`
|
||||
- 开发环境 `http://localhost:3000`
|
||||
|
||||
并通过环境变量配置:
|
||||
|
||||
```env
|
||||
CORS_ALLOW_ORIGINS=https://app.example.com,https://editor.example.com,http://localhost:3000
|
||||
ADMIN_CORS_ALLOW_ORIGINS=https://editor.example.com,http://localhost:3000
|
||||
```
|
||||
|
||||
## 7.3 响应头建议
|
||||
|
||||
如果命中白名单,返回:
|
||||
|
||||
```http
|
||||
Access-Control-Allow-Origin: https://app.example.com
|
||||
Vary: Origin
|
||||
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||||
Access-Control-Allow-Headers: Authorization,Content-Type,X-Request-Id,X-CSRF-Token
|
||||
Access-Control-Max-Age: 600
|
||||
```
|
||||
|
||||
只有在你明确使用 Cookie 会话时,才加:
|
||||
|
||||
```http
|
||||
Access-Control-Allow-Credentials: true
|
||||
```
|
||||
|
||||
## 7.4 不建议的配置
|
||||
|
||||
不要这样配:
|
||||
|
||||
```http
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Credentials: true
|
||||
```
|
||||
|
||||
这既不安全,也不符合规范。
|
||||
|
||||
## 7.5 预检请求处理
|
||||
|
||||
`OPTIONS` 请求必须在 API 层快速返回 `204`,不要把它继续转发到业务逻辑。
|
||||
|
||||
建议:
|
||||
|
||||
- 统一中间件处理
|
||||
- 只要 Origin 不在白名单,直接拒绝
|
||||
- 预检通过后才进入业务处理
|
||||
|
||||
## 7.6 流式接口注意项
|
||||
|
||||
像 `/api/llm/chat/completions` 这种流式输出接口,还要补这些处理:
|
||||
|
||||
- `Cache-Control: no-cache`
|
||||
- `Connection: keep-alive`
|
||||
- `X-Accel-Buffering: no`
|
||||
|
||||
否则 `Nginx` 可能把流式内容缓存/聚合后再一次性吐给浏览器,导致前端看起来“流式失效”。
|
||||
|
||||
---
|
||||
|
||||
## 8. API 边界重构建议
|
||||
|
||||
建议不要继续把所有接口都平铺在根 `/api` 下,最好按域分层。
|
||||
|
||||
## 8.1 运行时 API
|
||||
|
||||
建议:
|
||||
|
||||
- `/api/runtime/llm/chat/completions`
|
||||
- `/api/runtime/custom-world/scene-image`
|
||||
- `/api/runtime/story/*`
|
||||
- `/api/runtime/save/*`
|
||||
|
||||
职责:
|
||||
|
||||
- 面向玩家运行时
|
||||
- 高并发、可限流
|
||||
- 不允许直接写源文件
|
||||
|
||||
## 8.2 编辑器 API
|
||||
|
||||
建议:
|
||||
|
||||
- `/api/editor/item-overrides`
|
||||
- `/api/editor/npc-visual-overrides`
|
||||
- `/api/editor/character-overrides`
|
||||
- `/api/editor/scene-overrides`
|
||||
- `/api/editor/state-function-overrides`
|
||||
- `/api/editor/assets/character-visual/publish`
|
||||
- `/api/editor/assets/animation/publish`
|
||||
|
||||
职责:
|
||||
|
||||
- 面向创作者、运营、内部编辑器
|
||||
- 必须鉴权
|
||||
- 必须审计
|
||||
- 不建议对公网完全开放
|
||||
|
||||
## 8.3 内部任务 API
|
||||
|
||||
建议:
|
||||
|
||||
- `/api/internal/jobs/*`
|
||||
- `/api/internal/hooks/*`
|
||||
|
||||
职责:
|
||||
|
||||
- Worker 回调
|
||||
- 内部系统同步
|
||||
- 不给浏览器直接调用
|
||||
|
||||
---
|
||||
|
||||
## 9. 生产环境最小可行架构
|
||||
|
||||
如果你希望先尽快上线,而不是一上来就做重微服务,推荐先落这个版本。
|
||||
|
||||
## 9.1 单机版拓扑
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Browser"] --> B["Nginx"]
|
||||
B --> C["dist 静态站点"]
|
||||
B --> D["Node API Service"]
|
||||
D --> E["LLM / DashScope"]
|
||||
D --> F["本机持久化目录或对象存储"]
|
||||
D --> G["PostgreSQL(可后补)"]
|
||||
```
|
||||
|
||||
## 9.2 单机版适合解决的事
|
||||
|
||||
- 当前 CORS
|
||||
- API Key 服务端托管
|
||||
- 基础流式代理
|
||||
- 简单编辑器接口
|
||||
- 初步日志与限流
|
||||
|
||||
## 9.3 单机版暂时接受的妥协
|
||||
|
||||
- 图片生成先继续同步请求
|
||||
- 小规模文件先存本机挂载目录
|
||||
- 先不拆 Worker
|
||||
- 编辑器暂时只给内网/白名单用户
|
||||
|
||||
这能保证你**先把项目稳稳部署起来**,而不是为了“最终形态”迟迟不落地。
|
||||
|
||||
---
|
||||
|
||||
## 10. 中期演进架构
|
||||
|
||||
当出现这些需求时,再进入下一阶段:
|
||||
|
||||
- 多人同时在线
|
||||
- 多创作者协作
|
||||
- 图片/视频生成任务变多
|
||||
- 需要账号体系、存档、云同步
|
||||
- 需要审计和版本回滚
|
||||
|
||||
推荐演进为:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["CDN / Nginx"] --> B["Web"]
|
||||
A --> C["API Cluster"]
|
||||
C --> D["Redis"]
|
||||
C --> E["PostgreSQL"]
|
||||
C --> F["OSS / S3"]
|
||||
C --> G["Worker Cluster"]
|
||||
G --> H["LLM / Image Vendor"]
|
||||
```
|
||||
|
||||
演进重点:
|
||||
|
||||
1. API 服务可多实例部署
|
||||
2. 任务通过队列解耦
|
||||
3. 文件写对象存储
|
||||
4. 业务状态入数据库
|
||||
5. 后台操作有审计日志
|
||||
|
||||
---
|
||||
|
||||
## 11. 对当前仓库最关键的改造建议
|
||||
|
||||
## 11.1 第一优先级:把 Vite 里的 API 能力抽出来
|
||||
|
||||
当前 [scripts/dev-server/localApiPlugins.ts](/E:/Repos/Genarrative/scripts/dev-server/localApiPlugins.ts) 里的能力,建议分三类迁移:
|
||||
|
||||
### A. 运行时代理接口
|
||||
|
||||
- `LLM_PROXY_PATH`
|
||||
- `CUSTOM_WORLD_SCENE_IMAGE_PATH`
|
||||
|
||||
这两类要迁移到正式 `server/` 服务里。
|
||||
|
||||
### B. 编辑器读写接口
|
||||
|
||||
- `item-overrides`
|
||||
- `npc-visual-overrides`
|
||||
- `character-overrides`
|
||||
- `monster-overrides`
|
||||
- `scene-overrides`
|
||||
- `scene-npc-overrides`
|
||||
- `state-function-overrides`
|
||||
|
||||
这类接口要保留,但必须加:
|
||||
|
||||
- 登录鉴权
|
||||
- 角色权限
|
||||
- 操作日志
|
||||
- 环境隔离
|
||||
|
||||
### C. 资源发布接口
|
||||
|
||||
- `character-visual/publish`
|
||||
- `animation/publish`
|
||||
|
||||
这类接口后续最适合迁移到:
|
||||
|
||||
- 对象存储
|
||||
- 任务队列
|
||||
- 元数据表
|
||||
|
||||
生产环境不建议继续“直接写 `public/generated-*` + 回写源码 JSON”。
|
||||
|
||||
## 11.2 第二优先级:把写源码文件改成写业务存储
|
||||
|
||||
开发期把 JSON 直接写回 `src/data/*.json` 可以接受,但生产环境不建议继续这样做。
|
||||
|
||||
建议演进路径:
|
||||
|
||||
### 短期
|
||||
|
||||
- 写到 `data/overrides/*.json`
|
||||
- 作为运行数据目录挂载到服务器磁盘
|
||||
- 和源码目录分离
|
||||
|
||||
### 中期
|
||||
|
||||
- `override` 元数据写 PostgreSQL
|
||||
- 原始 JSON 内容也可写数据库 JSONB
|
||||
- 大文件、图片、动画写对象存储
|
||||
|
||||
## 11.3 第三优先级:前端环境变量收敛
|
||||
|
||||
前端建议只保留少量公开变量,例如:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_LLM_PROXY_BASE_URL=/api/runtime/llm
|
||||
VITE_SCENE_IMAGE_PROXY_BASE_URL=/api/runtime/custom-world/scene-image
|
||||
```
|
||||
|
||||
而这些变量必须只存在服务端:
|
||||
|
||||
```env
|
||||
LLM_BASE_URL=...
|
||||
LLM_API_KEY=...
|
||||
DASHSCOPE_BASE_URL=...
|
||||
DASHSCOPE_API_KEY=...
|
||||
DATABASE_URL=...
|
||||
REDIS_URL=...
|
||||
OBJECT_STORAGE_BUCKET=...
|
||||
JWT_SECRET=...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 推荐的目录结构
|
||||
|
||||
在当前仓库基础上,建议逐步演化成:
|
||||
|
||||
```text
|
||||
src/ # 前端
|
||||
server/
|
||||
app.ts # API 入口
|
||||
routes/
|
||||
runtime/
|
||||
llm.ts
|
||||
customWorld.ts
|
||||
editor/
|
||||
overrides.ts
|
||||
assets.ts
|
||||
internal/
|
||||
jobs.ts
|
||||
services/
|
||||
llmService.ts
|
||||
imageService.ts
|
||||
overrideService.ts
|
||||
publishService.ts
|
||||
adapters/
|
||||
llmAdapter.ts
|
||||
dashscopeAdapter.ts
|
||||
storageAdapter.ts
|
||||
queueAdapter.ts
|
||||
middleware/
|
||||
cors.ts
|
||||
auth.ts
|
||||
rateLimit.ts
|
||||
requestId.ts
|
||||
config/
|
||||
env.ts
|
||||
workers/
|
||||
mediaWorker.ts
|
||||
storage/
|
||||
uploads/
|
||||
generated/
|
||||
docs/
|
||||
```
|
||||
|
||||
这样做的好处:
|
||||
|
||||
1. 前后端职责清晰
|
||||
2. Vite 回到构建职责
|
||||
3. 服务端逻辑可以独立部署
|
||||
4. 以后做测试、监控、扩容都更自然
|
||||
|
||||
---
|
||||
|
||||
## 13. Nginx 参考配置
|
||||
|
||||
下面是一份适合当前项目思路的参考配置。
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name game.example.com;
|
||||
|
||||
root /srv/genarrative/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
# 对流式接口很关键
|
||||
proxy_buffering off;
|
||||
add_header X-Accel-Buffering no;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果未来拆成 `app.example.com` + `api.example.com`,再在 API 服务层开启精确 CORS 白名单。
|
||||
|
||||
---
|
||||
|
||||
## 14. API 服务中的 CORS 中间件建议
|
||||
|
||||
伪代码如下:
|
||||
|
||||
```ts
|
||||
const allowOrigins = new Set([
|
||||
'https://app.example.com',
|
||||
'https://editor.example.com',
|
||||
'http://localhost:3000',
|
||||
]);
|
||||
|
||||
function applyCors(req, res) {
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowOrigins.has(origin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Methods',
|
||||
'GET,POST,PUT,PATCH,DELETE,OPTIONS',
|
||||
);
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Authorization,Content-Type,X-Request-Id,X-CSRF-Token',
|
||||
);
|
||||
res.setHeader('Access-Control-Max-Age', '600');
|
||||
}
|
||||
|
||||
app.use((req, res, next) => {
|
||||
applyCors(req, res);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
1. 返回的是**请求方 origin 原值**,不是 `*`
|
||||
2. 一定带 `Vary: Origin`
|
||||
3. `OPTIONS` 在中间件层直接结束
|
||||
4. 未命中白名单就不放行
|
||||
|
||||
---
|
||||
|
||||
## 15. 鉴权与安全建议
|
||||
|
||||
## 15.1 运行时接口
|
||||
|
||||
如果当前只是单人原型或内测,可以先允许匿名访问部分运行时接口,但仍建议至少加:
|
||||
|
||||
- IP 级限流
|
||||
- 请求大小限制
|
||||
- 上游超时
|
||||
- 请求日志
|
||||
|
||||
## 15.2 编辑器接口
|
||||
|
||||
编辑器接口不能继续裸奔。
|
||||
|
||||
至少加:
|
||||
|
||||
1. 登录态
|
||||
2. 角色权限
|
||||
3. 操作人标识
|
||||
4. 审计日志
|
||||
5. 管理后台 Origin 白名单
|
||||
|
||||
建议角色最少拆成:
|
||||
|
||||
- `player`
|
||||
- `editor`
|
||||
- `admin`
|
||||
|
||||
## 15.3 密钥管理
|
||||
|
||||
所有第三方 Key 都只放在服务端:
|
||||
|
||||
- `.env.production`
|
||||
- 服务器 secret manager
|
||||
- 容器 secret
|
||||
|
||||
不要放到:
|
||||
|
||||
- `VITE_*`
|
||||
- 浏览器 localStorage
|
||||
- 前端 bundle
|
||||
|
||||
## 15.4 限流与熔断
|
||||
|
||||
建议至少做两层:
|
||||
|
||||
### Nginx 层
|
||||
|
||||
- IP 限流
|
||||
- 突发连接限制
|
||||
|
||||
### API 层
|
||||
|
||||
- 用户 / Token 限流
|
||||
- 按接口限流
|
||||
- 上游失败熔断
|
||||
|
||||
这对图片生成和大模型请求尤其关键。
|
||||
|
||||
---
|
||||
|
||||
## 16. 存储设计建议
|
||||
|
||||
## 16.1 当前最容易出问题的点
|
||||
|
||||
当前开发期接口里存在:
|
||||
|
||||
- 直接读写 JSON 文件
|
||||
- 直接写入 `public/generated-*`
|
||||
|
||||
这在单机开发是方便的,但上线后会遇到:
|
||||
|
||||
1. 多实例之间文件不同步
|
||||
2. 发布新版本时生成文件可能丢失
|
||||
3. 容器重启后本地文件丢失
|
||||
4. 无法做版本与审计
|
||||
|
||||
## 16.2 推荐存储分层
|
||||
|
||||
### 结构化数据
|
||||
|
||||
用 PostgreSQL:
|
||||
|
||||
- 用户
|
||||
- 存档
|
||||
- override 元数据
|
||||
- 任务记录
|
||||
- 审计日志
|
||||
|
||||
### 大文件 / 资源文件
|
||||
|
||||
用对象存储:
|
||||
|
||||
- 场景图
|
||||
- 角色图
|
||||
- 动画帧
|
||||
- 导出资源
|
||||
|
||||
### 短生命周期状态
|
||||
|
||||
用 Redis:
|
||||
|
||||
- 任务队列
|
||||
- 限流计数
|
||||
- 短期缓存
|
||||
- 会话
|
||||
|
||||
---
|
||||
|
||||
## 17. 日志与可观测性
|
||||
|
||||
至少记录这些字段:
|
||||
|
||||
- `requestId`
|
||||
- `userId` / `editorId`
|
||||
- `route`
|
||||
- `origin`
|
||||
- `upstreamVendor`
|
||||
- `model`
|
||||
- `statusCode`
|
||||
- `latencyMs`
|
||||
- `timeoutMs`
|
||||
- `errorCode`
|
||||
- `errorMessage`
|
||||
|
||||
对大模型/图片接口尤其要记录:
|
||||
|
||||
1. 请求耗时
|
||||
2. 上游状态码
|
||||
3. 失败正文摘要
|
||||
4. 重试次数
|
||||
5. 任务 ID
|
||||
|
||||
这样以后排查时,才不会再次回到“只看到浏览器报 CORS,不知道真因”的状态。
|
||||
|
||||
---
|
||||
|
||||
## 18. 推荐实施顺序
|
||||
|
||||
## 阶段 1:先解决正式上线
|
||||
|
||||
目标:
|
||||
|
||||
- 前端可部署
|
||||
- API Key 不暴露
|
||||
- 不再浏览器直连第三方
|
||||
- 当前 CORS 问题彻底解决
|
||||
|
||||
动作:
|
||||
|
||||
1. 新建独立 `server/` 服务
|
||||
2. 把 `/api/llm/chat/completions` 与 `/api/custom-world/scene-image` 迁过去
|
||||
3. Nginx 统一反代 `/api/*`
|
||||
4. 前端保持原有 `/api/*` 调用方式
|
||||
5. 先在同域部署,尽量不碰 CORS
|
||||
|
||||
## 阶段 2:把编辑器接口接入正式权限
|
||||
|
||||
动作:
|
||||
|
||||
1. editor API 独立命名空间
|
||||
2. 增加登录、角色、审计
|
||||
3. 把“写源码文件”改成“写业务数据目录或数据库”
|
||||
|
||||
## 阶段 3:接入异步任务与对象存储
|
||||
|
||||
动作:
|
||||
|
||||
1. 图片生成改为 job queue
|
||||
2. 结果回写数据库与对象存储
|
||||
3. API 只返回任务状态与资源地址
|
||||
|
||||
## 阶段 4:补齐平台化能力
|
||||
|
||||
动作:
|
||||
|
||||
1. 用户系统
|
||||
2. 云存档
|
||||
3. 限流配额
|
||||
4. 后台审计
|
||||
5. 监控告警
|
||||
|
||||
---
|
||||
|
||||
## 19. 最终推荐结论
|
||||
|
||||
针对这个项目,最稳、最适合当前现状、也兼顾未来的方案是:
|
||||
|
||||
1. **不要尝试让浏览器直接跨域访问第三方模型服务**
|
||||
2. **新增独立 Node API 服务,替代当前生产环境对 Vite middleware 的依赖**
|
||||
3. **通过 Nginx 把前端和 `/api/*` 统一到同一域名下,优先从架构层消灭大部分 CORS**
|
||||
4. **把 API 分成 runtime / editor / internal 三层**
|
||||
5. **把图片生成、资源发布逐步迁移到对象存储 + 异步任务**
|
||||
6. **把未来的鉴权、限流、审计、日志、数据库能力预留在 BFF 层**
|
||||
|
||||
一句话总结:
|
||||
|
||||
**真正解决 CORS 的最佳方式,不是给浏览器更多跨域权限,而是让浏览器只访问你自己的服务;同时把当前开发期代理能力升级成正式的生产 API 架构。**
|
||||
|
||||
---
|
||||
|
||||
## 20. 适合立即执行的落地清单
|
||||
|
||||
如果现在就要开始做,建议按下面顺序推进:
|
||||
|
||||
1. 新建 `server/` 目录,先迁出 LLM 代理与场景图片代理
|
||||
2. 把线上部署改成 `Nginx -> dist + Node API`
|
||||
3. 保持前端仍然请求 `/api/llm` 与 `/api/custom-world/scene-image`
|
||||
4. 先使用同域部署,不主动引入跨子域 CORS
|
||||
5. editor 写接口上线前先加鉴权,不要裸开放
|
||||
6. 生成文件先写挂载目录,下一步再迁对象存储
|
||||
7. 为流式接口补 `proxy_buffering off`
|
||||
8. 为 API 层补 `requestId + latency + upstream error` 日志
|
||||
|
||||
如果后续需要,我可以继续把这份文档下一步直接细化成:
|
||||
|
||||
- `server/` 目录结构草案
|
||||
- `Express/Fastify` 的接口骨架
|
||||
- `Nginx` 正式可用配置
|
||||
- `Dockerfile + docker-compose` 部署方案
|
||||
- 当前仓库对应的迁移 checklist
|
||||
@@ -0,0 +1,498 @@
|
||||
# AI 原生经典 RPG 体验对标引擎 PRD
|
||||
|
||||
更新时间:`2026-04-06`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
这份 PRD 建立在已有的:
|
||||
|
||||
- `AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md`
|
||||
- `AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md`
|
||||
|
||||
之上,进一步回答一个更明确的问题:
|
||||
|
||||
**如何让当前仓库里的 AI 原生剧情引擎,不只是“会生成剧情”,而是能在现有游戏框架中,驱动出对标《仙剑》《轩辕剑》《古剑》《黑神话:悟空》《博德之门》这类经典作品的角色扮演体验。**
|
||||
|
||||
这里的“对标”不是复制题材、桥段或美术风格,而是抽取这些作品背后的体验能力:
|
||||
|
||||
1. 让玩家记住角色,而不只是记住设定。
|
||||
2. 让世界里的地点、旧物、传闻、队友、任务彼此互相讲同一件事。
|
||||
3. 让玩家的选择、立场、关系、误判、探索真正改变后续体验。
|
||||
4. 让剧情推进同时拥有“作者性”和“系统性”。
|
||||
|
||||
一句话目标:
|
||||
|
||||
**把当前项目的 AI 原生叙事,升级成一种能稳定产出“经典单机 RPG 体验质感”的剧情引擎。**
|
||||
|
||||
## 1. 研究结论先说
|
||||
|
||||
综合参考对象后,可以把这几类经典体验抽象成 5 个核心方向:
|
||||
|
||||
1. `仙剑` 型体验
|
||||
- 强角色记忆点
|
||||
- “人”和“情”优先
|
||||
- 主线、支线、传记、碎片化叙事共同塑造角色
|
||||
|
||||
2. `轩辕剑` 型体验
|
||||
- 历史 / 神话 /文化意象共同构成宏大冲突
|
||||
- 大时代与个人成长同时成立
|
||||
- 系统玩法本身带文化与世界观意味
|
||||
|
||||
3. `古剑` 型体验
|
||||
- 世界观先行
|
||||
- 主题先行
|
||||
- 角色命题、支线补完、衍生叙事共同构成厚世界
|
||||
|
||||
4. `黑神话` 型体验
|
||||
- 空间本身讲故事
|
||||
- 物件、残痕、命名、演出都带文化锚点
|
||||
- 旅程像一次带试炼感的穿行,而不是纯任务导航
|
||||
|
||||
5. `博德之门` 型体验
|
||||
- 队友强反应
|
||||
- 选择有代价
|
||||
- 关系状态、立场差异、任务分支和系统规则一起驱动叙事
|
||||
|
||||
对当前项目来说,这意味着剧情引擎必须补的,不是更多文案,而是下面 10 项能力:
|
||||
|
||||
1. 世界线程图谱
|
||||
2. 题材适配层
|
||||
3. 角色命题与秘密档案
|
||||
4. 队友反应与关系矩阵
|
||||
5. 信息可见性裁剪
|
||||
6. 地点 / 物件 / 文书 / 残痕的叙事编译
|
||||
7. 情境导演与节奏控制
|
||||
8. 线程 -> 合约 -> 信号推进
|
||||
9. 回响与长期记忆
|
||||
10. 当前框架可落地的 orchestration 主链
|
||||
|
||||
## 2. 经典作品拆解
|
||||
|
||||
## 2.1 从《仙剑》系列提炼什么
|
||||
|
||||
《仙剑》最值得提炼的,不是仙侠题材,而是:
|
||||
|
||||
1. 角色与情感先于设定说明
|
||||
2. 玩家会因为“人”和“情”记住剧情
|
||||
3. 主线之外的角色剧情、人物传记、碎片信息都在持续加深理解
|
||||
4. 角色魅力是分阶段投放,不是一口气灌输
|
||||
|
||||
对引擎的要求就是:
|
||||
|
||||
1. 每个重点角色都必须有“高光入口”
|
||||
2. 角色认知要通过多载体逐步加深
|
||||
3. 情感关系不能只挂在恋爱线,要覆盖友情、亲情、师承、旧债、牺牲、错过
|
||||
4. 碎片化叙事必须和主线、支线互文,而不是散落设定
|
||||
|
||||
## 2.2 从《轩辕剑》系列提炼什么
|
||||
|
||||
《轩辕剑》最值得提炼的,不是具体朝代,而是:
|
||||
|
||||
1. 历史与神话共存
|
||||
2. 宏观时代与个人命运互相牵引
|
||||
3. 每一代往往有明确中心主题
|
||||
4. 世界里的神器、法宝、炼妖壶、符鬼等系统并不是纯玩法道具,而是世界观的一部分
|
||||
|
||||
对引擎的要求就是:
|
||||
|
||||
1. 每个世界必须有明确的大时代冲突
|
||||
2. 每段主线必须能同时看到“苍生线”和“个人线”
|
||||
3. 每一轮内容生成都要知道自己服务的是哪条主题轴
|
||||
4. 系统物件、战斗能力、资源名称、任务机制都要与世界观绑定
|
||||
|
||||
## 2.3 从《古剑》系列提炼什么
|
||||
|
||||
《古剑》最值得提炼的,不是台词风格,而是:
|
||||
|
||||
1. 世界观不是背景板,而是叙事发动机
|
||||
2. 主题是预先确定的,比如“重生”“问道”
|
||||
3. 游戏中只展示世界的一角,但能感到背后有更大的历史层
|
||||
4. 支线、小说、传记、远古设定共同构成世界厚度
|
||||
|
||||
对引擎的要求就是:
|
||||
|
||||
1. 先生成“世界故事图谱”,再生成角色和剧情
|
||||
2. 每个世界必须有自己的主题母题
|
||||
3. 重点角色都要有“命题”
|
||||
4. 支线不能只是奖励入口,而要承担世界观展开和人物补完
|
||||
|
||||
## 2.4 从《黑神话:悟空》提炼什么
|
||||
|
||||
《黑神话:悟空》最值得提炼的,不是神话题材本身,而是:
|
||||
|
||||
1. 空间叙事强
|
||||
2. 旅程感强
|
||||
3. 文化意象直接进入地点、敌人、物件、建筑和命名
|
||||
4. 玩家不是靠大段讲解理解世界,而是靠穿行、观察、搏斗和残痕去感受
|
||||
|
||||
对引擎的要求就是:
|
||||
|
||||
1. 场景必须有“故事残痕层”
|
||||
2. 路线推进要像试炼 / 朝圣 / 追索 / 深入禁地,而不是只弹任务文本
|
||||
3. 重点物件必须是文化锚点和旧事证人
|
||||
4. 敌人与地标也应属于叙事载体,而不只是战斗内容
|
||||
|
||||
## 2.5 从《博德之门》提炼什么
|
||||
|
||||
《博德之门》最值得提炼的,不是西式奇幻题材,而是:
|
||||
|
||||
1. 队友是强叙事引擎
|
||||
2. 不同角色对同一行为会有不同立场反应
|
||||
3. 关系推进有信任门槛
|
||||
4. 玩家选择会改变关系、任务理解和队伍结构
|
||||
5. 系统层会用可控的“认可 / 不认可”“信任 / 不信任”来表达复杂反应,而不是无限写分支
|
||||
|
||||
对引擎的要求就是:
|
||||
|
||||
1. 每个重点队友都要有可追踪的立场轴和信任轴
|
||||
2. 对同一选择,队友必须出现差异化反应
|
||||
3. 关系状态要进入后续任务、聊天、协战、赠礼、剧情揭示
|
||||
4. 系统必须允许“有限分支 + 强反馈”,而不是追求不可控的全分支写作
|
||||
|
||||
## 3. 对标能力矩阵
|
||||
|
||||
## 3.1 引擎必须支持的体验支柱
|
||||
|
||||
建议把“经典 RPG 体验”拆成下面 8 根支柱:
|
||||
|
||||
1. 角色羁绊支柱
|
||||
2. 主题表达支柱
|
||||
3. 世界厚度支柱
|
||||
4. 路线试炼支柱
|
||||
5. 选择后果支柱
|
||||
6. 队友反应支柱
|
||||
7. 叙事载体支柱
|
||||
8. 回响记忆支柱
|
||||
|
||||
## 3.2 支柱与经典作品的对照
|
||||
|
||||
| 支柱 | 仙剑 | 轩辕剑 | 古剑 | 黑神话 | 博德之门 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 角色羁绊 | 强 | 中 | 强 | 中 | 强 |
|
||||
| 主题表达 | 强 | 强 | 强 | 中到强 | 强 |
|
||||
| 世界厚度 | 中到强 | 强 | 强 | 强 | 强 |
|
||||
| 路线试炼 | 中 | 中 | 中 | 强 | 中 |
|
||||
| 选择后果 | 中 | 中 | 中 | 轻 | 强 |
|
||||
| 队友反应 | 中 | 中 | 中 | 轻 | 强 |
|
||||
| 叙事载体 | 中 | 强 | 强 | 强 | 强 |
|
||||
| 回响记忆 | 中 | 强 | 强 | 中 | 强 |
|
||||
|
||||
结论不是“每个游戏都做一样多”,而是:
|
||||
|
||||
**要想对标这些经典作品的总和体验,引擎不能只强其中一项。**
|
||||
|
||||
## 4. 面向当前项目的引擎能力设计
|
||||
|
||||
## 4.1 世界线程图谱
|
||||
|
||||
用于承载:
|
||||
|
||||
- 《轩辕剑》式的大时代冲突
|
||||
- 《古剑》式的远古 / 旧史 / 深层设定
|
||||
- 《仙剑》式的主线与碎片化叙事互文
|
||||
|
||||
最低要求:
|
||||
|
||||
1. 每个世界有 `明线线程`
|
||||
2. 每个世界有 `暗线线程`
|
||||
3. 每个世界有 `旧事伤痕`
|
||||
4. 每个世界有 `主题母题`
|
||||
|
||||
## 4.2 角色命题与秘密档案
|
||||
|
||||
用于承载:
|
||||
|
||||
- 《仙剑》式角色高光与感情曲线
|
||||
- 《古剑》式角色命题与主题性成长
|
||||
- 《博德之门》式角色秘密与立场差异
|
||||
|
||||
每个重点角色必须有:
|
||||
|
||||
1. 外显身份
|
||||
2. 当前压力
|
||||
3. 表面目标
|
||||
4. 真实目标
|
||||
5. 已付代价
|
||||
6. 关系负债
|
||||
7. 禁区
|
||||
8. 可触发反应的关键词
|
||||
|
||||
## 4.3 队友反应与关系矩阵
|
||||
|
||||
这是当前项目对标《博德之门》最需要补的一块。
|
||||
|
||||
建议每个重点队友都至少维护:
|
||||
|
||||
```ts
|
||||
interface CompanionStanceProfile {
|
||||
trust: number;
|
||||
warmth: number;
|
||||
ideologicalFit: number;
|
||||
fearOrGuard: number;
|
||||
loyalty: number;
|
||||
currentConflictTag?: string | null;
|
||||
recentApprovals: string[];
|
||||
recentDisapprovals: string[];
|
||||
}
|
||||
```
|
||||
|
||||
作用:
|
||||
|
||||
1. `trust`
|
||||
- 决定是否愿意跟随你深入风险。
|
||||
|
||||
2. `warmth`
|
||||
- 决定情感表达密度。
|
||||
|
||||
3. `ideologicalFit`
|
||||
- 决定在价值观选择上的反应强度。
|
||||
|
||||
4. `fearOrGuard`
|
||||
- 决定低关系阶段是不是更容易误会、回避、抗拒。
|
||||
|
||||
5. `loyalty`
|
||||
- 决定关键节点是否站队。
|
||||
|
||||
## 4.4 信息可见性层
|
||||
|
||||
这是对标《古剑》的世界层深度、对标《仙剑》的分段投放、对标《博德之门》的可控分支,以及修复当前自定义世界 prompt 泄露问题的共同底座。
|
||||
|
||||
核心要求:
|
||||
|
||||
1. 角色知道什么
|
||||
2. 玩家知道什么
|
||||
3. 角色此刻愿意说什么
|
||||
4. 当前场景允许模型看到什么
|
||||
|
||||
必须分开建模。
|
||||
|
||||
## 4.5 叙事载体编译层
|
||||
|
||||
这是对标《黑神话》《轩辕剑》《古剑》最关键的一层。
|
||||
|
||||
载体不能只限于装备物品,应统一抽象为:
|
||||
|
||||
1. 遗物
|
||||
2. 证物
|
||||
3. 文书
|
||||
4. 材料
|
||||
5. 禁物
|
||||
6. 装置
|
||||
7. 记忆碎片
|
||||
8. 残痕场景
|
||||
|
||||
每个载体都要有:
|
||||
|
||||
1. 可见线索
|
||||
2. 见证痕
|
||||
3. 未完成问题
|
||||
4. 当前出现理由
|
||||
5. 后续反应钩子
|
||||
|
||||
## 4.6 情境导演与节奏层
|
||||
|
||||
这是对标《仙剑》的情感推进、《黑神话》的旅程压迫、《博德之门》的分支张力时最重要的“节奏控制器”。
|
||||
|
||||
导演层要先判断:
|
||||
|
||||
1. 当前是情感深化回合,还是冲突升级回合
|
||||
2. 当前应推进明线、暗线、关系线、还是主题线
|
||||
3. 当前需要推前台的是角色、地点、还是物件
|
||||
4. 当前披露预算应该是低、中还是高
|
||||
|
||||
没有导演层,AI 文本会持续飘散。
|
||||
|
||||
## 4.7 线程 -> 合约 -> 信号推进
|
||||
|
||||
这是把经典单机 RPG 的作者性故事,转成 AI 原生可持续运行结构的关键。
|
||||
|
||||
统一抽象如下:
|
||||
|
||||
1. 线程
|
||||
- 这段故事在讲哪条线。
|
||||
|
||||
2. 合约
|
||||
- 当前线的阶段目标、参与者、条件、失败态与回报。
|
||||
|
||||
3. 信号
|
||||
- 玩家做了什么,导致哪个关系、地点、物件、真相片段发生变化。
|
||||
|
||||
## 4.8 回响与长期记忆
|
||||
|
||||
这是决定剧情是否像“经典作品”的最后一层。
|
||||
|
||||
玩家之所以会觉得这些作品有余味,不是因为文本多,而是因为:
|
||||
|
||||
1. 旧事会回来
|
||||
2. 旧物会再被提起
|
||||
3. 旧误解会被翻案
|
||||
4. 旧角色会因为你做过的事改变口风
|
||||
|
||||
因此系统必须维护:
|
||||
|
||||
1. 事件记忆
|
||||
2. 关系记忆
|
||||
3. 线索记忆
|
||||
4. 误解记忆
|
||||
5. 已揭示真相记忆
|
||||
|
||||
## 5. 当前框架接入方案
|
||||
|
||||
## 5.1 `src/services/customWorld.ts`
|
||||
|
||||
从“批量生成 NPC 和地标”升级为:
|
||||
|
||||
1. 先生成 `ThemePack`
|
||||
2. 再生成 `WorldStoryGraph`
|
||||
3. 再生成 `ActorNarrativeProfile`
|
||||
4. 最后补 `backstoryReveal / skills / initialItems`
|
||||
|
||||
它将成为当前项目里最接近“古剑式世界观先行 + 轩辕剑式宏大主题”的入口。
|
||||
|
||||
## 5.2 `src/services/prompt.ts`
|
||||
|
||||
从“组装上下文”升级为:
|
||||
|
||||
1. 读取 `VisibilitySlice`
|
||||
2. 读取 `SceneNarrativeDirective`
|
||||
3. 只注入当前阶段可见、可说、可推的最小剧情上下文
|
||||
|
||||
它将成为:
|
||||
|
||||
- 对标《博德之门》强反应又可控
|
||||
- 对标《仙剑》分阶段角色理解
|
||||
- 修复当前全知视角泄露
|
||||
|
||||
的核心模块。
|
||||
|
||||
## 5.3 `src/data/npcInteractions.ts`
|
||||
|
||||
从“关系规则函数集合”升级为:
|
||||
|
||||
1. 首遇状态机
|
||||
2. 队友立场矩阵
|
||||
3. 认可 / 不认可反馈
|
||||
4. 关系冲突 tag
|
||||
5. 私聊 / 队伍 / 营地事件触发
|
||||
|
||||
它将承担当前项目里最接近《博德之门》队友系统和《仙剑》角色关系成长的部分。
|
||||
|
||||
## 5.4 `src/services/questDirector.ts`
|
||||
|
||||
从“任务导演”升级为:
|
||||
|
||||
1. 剧情线程导演
|
||||
2. 合约生成器
|
||||
3. 信号推进器
|
||||
4. 阶段揭示器
|
||||
|
||||
它将承担对标《轩辕剑》宏观主线推进和《博德之门》分支任务反馈的主链。
|
||||
|
||||
## 5.5 `src/data/runtimeItemDirector.ts` / `src/data/runtimeItemNarrative.ts`
|
||||
|
||||
从“运行时奖励导演”升级为:
|
||||
|
||||
1. 通用叙事载体编译器
|
||||
2. 重点物件故事指纹生成器
|
||||
3. 角色 / 场景 / 势力 / 旧事的回响载体
|
||||
|
||||
它将承担对标《黑神话》物件文化锚点、对标《轩辕剑》器物世界观、对标《古剑》旧史残痕的能力。
|
||||
|
||||
## 5.6 `src/hooks/useStoryGeneration.ts`
|
||||
|
||||
从“剧情总 orchestrator”收束为:
|
||||
|
||||
1. 读取导演结果
|
||||
2. 发起对应 contract
|
||||
3. 驱动生成
|
||||
4. 回写记忆与信号
|
||||
|
||||
不要继续把越来越多叙事细节直接塞进这个 hook,而是让它只做总线协调。
|
||||
|
||||
## 6. 体验验收标准
|
||||
|
||||
如果要说“能对标经典单机 RPG 体验”,至少要达到下面这些结果。
|
||||
|
||||
## 6.1 角色体验
|
||||
|
||||
1. 玩家在一次完整体验后,能明确记住至少 `3~5` 个队友 / 核心角色的个性、矛盾和关键旧事。
|
||||
2. 低好感角色不会只是“冷淡”,而是会带明确压力、错位说辞和暗线钩子。
|
||||
3. 高关系角色会在聊天、任务、协战、赠礼、事件节点中显著改变表达与立场。
|
||||
|
||||
## 6.2 世界体验
|
||||
|
||||
1. 玩家能感到世界背后有更深层的旧史与暗线,而不是所有信息都在主线上直接交代。
|
||||
2. 场景、地点、支线、人物传记、物件描述之间会互相印证。
|
||||
3. 世界主题在命名、系统、任务、人物冲突上持续一致。
|
||||
|
||||
## 6.3 选择体验
|
||||
|
||||
1. 玩家选择能被至少一名队友明确认可或反对。
|
||||
2. 至少一部分选择会影响后续任务理解、关系推进、额外剧情或可见信息。
|
||||
3. 系统既要让选择有重量,又不能因为分支爆炸而失控。
|
||||
|
||||
## 6.4 旅程体验
|
||||
|
||||
1. 场景推进要有“试炼 / 追索 / 深入 / 回望”的旅程感。
|
||||
2. 不是所有叙事都靠对话框完成,空间和载体也必须承担讲故事的职责。
|
||||
3. 玩家会因为一个地标、一个旧物、一次反应,主动去拼出暗线。
|
||||
|
||||
## 7. 推荐落地顺序
|
||||
|
||||
## 阶段 A:先补对标经典作品的共同底座
|
||||
|
||||
优先做:
|
||||
|
||||
1. `ThemePack`
|
||||
2. `WorldStoryGraph`
|
||||
3. `ActorNarrativeProfile`
|
||||
4. `VisibilitySlice`
|
||||
|
||||
## 阶段 B:优先把当前项目最能出效果的两条链做强
|
||||
|
||||
1. 队友 / NPC 关系反应链
|
||||
2. 运行时物件 / 场景残痕叙事链
|
||||
|
||||
这是当前最容易直接提升“像经典 RPG”的地方。
|
||||
|
||||
## 阶段 C:把任务和主线推进改成线程化
|
||||
|
||||
重点补:
|
||||
|
||||
1. 线程
|
||||
2. 合约
|
||||
3. 信号
|
||||
4. 阶段揭示
|
||||
|
||||
## 阶段 D:补营地 / 旅途中队友事件
|
||||
|
||||
这是当前项目对标《仙剑》与《博德之门》体验非常关键的一步。
|
||||
|
||||
建议新增:
|
||||
|
||||
1. 营地对话
|
||||
2. 旅途中短反应
|
||||
3. 关键选择后的队友插话
|
||||
4. 队友之间的互相评价
|
||||
|
||||
## 阶段 E:做经典体验压力测试
|
||||
|
||||
至少要用 5 类体验场景去压测引擎:
|
||||
|
||||
1. 情感型主线
|
||||
2. 历史 / 神话型大事件
|
||||
3. 世界观层层揭示型流程
|
||||
4. 旅程试炼型场景链
|
||||
5. 队友强反应分支型流程
|
||||
|
||||
## 8. 一句话结论
|
||||
|
||||
要让当前项目的 AI 原生剧情引擎真正对标《仙剑》《轩辕剑》《古剑》《黑神话》《博德之门》这些经典作品,关键不是去模仿哪一种题材,而是让引擎同时具备:
|
||||
|
||||
- 让人记住角色的能力
|
||||
- 让世界互相说话的能力
|
||||
- 让选择产生后果的能力
|
||||
- 让地点与物件承担叙事的能力
|
||||
- 让长线回响沉淀下来的能力
|
||||
|
||||
只有这些能力一起成立,当前框架里跑出来的体验,才会从“AI 会写剧情”,真正跨到“AI 能驱动经典 RPG 质感”。
|
||||
554
docs/prd/AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md
Normal file
554
docs/prd/AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md
Normal file
@@ -0,0 +1,554 @@
|
||||
# AI 原生跨题材剧情引擎 PRD
|
||||
|
||||
更新时间:`2026-04-06`
|
||||
|
||||
## 0. 定位
|
||||
|
||||
这份 PRD 的目标不是为某一种题材写一套“更会编故事的文案系统”,而是设计一套:
|
||||
|
||||
**可适配奇幻、武侠、仙侠、科幻、悬疑、恐怖、末世、都市、校园、神话等多种题材的 AI 原生游戏剧情引擎。**
|
||||
|
||||
它应该解决的不是单一题材里的句子风格,而是更底层的问题:
|
||||
|
||||
1. 世界的明线、暗线如何被系统化组织。
|
||||
2. 角色、地点、物件、文书、怪物、装置、尸体、遗迹这些叙事载体如何共同讲故事。
|
||||
3. 玩家当前能知道什么、误以为知道什么、还不能知道什么,如何被稳定控制。
|
||||
4. AI 如何负责叙事生成,本地规则如何负责边界、状态、可见性、推进信号与玩法编译。
|
||||
|
||||
一句话定位:
|
||||
|
||||
**它是一个“剧情引擎层”,不是一个“某题材内容包”。**
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
这套引擎要同时满足 6 个目标:
|
||||
|
||||
1. 跨题材
|
||||
- 核心语义不写死在武侠、奇幻、修仙、蒸汽朋克等具体题材上。
|
||||
|
||||
2. AI 原生
|
||||
- 剧情文本、现场张力、角色表述、线索回响由 AI 参与生成,而不是只做静态模板替换。
|
||||
|
||||
3. 规则可控
|
||||
- 世界状态、信息泄露边界、关系推进、任务推进、奖励发放仍由本地规则约束。
|
||||
|
||||
4. 叙事网状化
|
||||
- 故事不只存在于主线任务里,而是分布在角色、物件、地点、事件余波与传闻中。
|
||||
|
||||
5. 可扩展
|
||||
- 新增题材时,优先新增“题材适配层”,而不是推翻剧情引擎本体。
|
||||
|
||||
6. 可验证
|
||||
- 能明确验收“是否有故事感”“是否埋得住暗线”“是否越权泄露”“是否跨题材仍成立”。
|
||||
|
||||
## 2. 参考方法抽象
|
||||
|
||||
本次设计参考的是成熟叙事游戏的方法,不是照搬具体剧情。
|
||||
|
||||
## 2.1 可借鉴的方法来源
|
||||
|
||||
1. CRPG 方法
|
||||
- 代表思路:角色秘密、阵营立场、任务分支、物件与角色的反应联动。
|
||||
- 可借鉴点:角色不是独立设定卡,而是世界冲突中的活节点。
|
||||
|
||||
2. 沉浸式侦查 / 推理方法
|
||||
- 代表思路:线索不是一次性交代,而是靠地点、证物、口供、误导与缺失共同成立。
|
||||
- 可借鉴点:信息差、误判、再解释,是故事感的重要来源。
|
||||
|
||||
3. 系统叙事方法
|
||||
- 代表思路:事件、状态、关系、资源变化会自然生成“像故事”的结果。
|
||||
- 可借鉴点:引擎应先保证状态与因果,再让 AI 把它叙事化。
|
||||
|
||||
4. Roguelike / 重复游玩叙事方法
|
||||
- 代表思路:角色关系、旧伤、遗物、失败记录、阶段揭示会在多轮体验中叠加意义。
|
||||
- 可借鉴点:故事不是一次性讲完,而是通过回响与累积形成。
|
||||
|
||||
5. 强氛围题材方法
|
||||
- 代表思路:名字、物件、俗称、禁忌称呼、残损意象本身就携带故事。
|
||||
- 可借鉴点:叙事载体不只靠大段说明,也能靠命名与残痕表达。
|
||||
|
||||
## 2.2 抽象结论
|
||||
|
||||
综合这些方法后,这套引擎应固定采用下面这 5 个叙事原则:
|
||||
|
||||
1. 故事必须网状分布,不能只挂在主线任务上。
|
||||
2. 信息披露必须分层,不能让模型默认全知。
|
||||
3. 低关系、低信任、低理解阶段,不能减少故事密度,只能减少披露深度。
|
||||
4. 物件与地点必须是故事证人,而不只是功能容器。
|
||||
5. 题材差异应该主要落在“词汇、意象、制度、冲突形式”上,而不是改变剧情引擎基本语法。
|
||||
|
||||
## 3. 引擎核心原则
|
||||
|
||||
1. AI 负责叙事表达,本地负责规则裁决。
|
||||
2. 世界先于角色,角色先于对白,状态先于文案。
|
||||
3. 所有剧情都必须能回到“谁知道什么、谁想隐藏什么、谁正在承受什么”。
|
||||
4. 明线、暗线、代价线、回响线是所有题材共通的最小叙事单元。
|
||||
5. 信息可见性必须被数据化,而不能只靠 prompt 口头提醒。
|
||||
6. 引擎关注“剧情语法”,题材包只负责“表现词汇”。
|
||||
|
||||
## 4. 引擎总架构
|
||||
|
||||
建议把 AI 原生剧情引擎拆成 8 个层:
|
||||
|
||||
1. 世界语义层
|
||||
2. 题材适配层
|
||||
3. 角色与阵营层
|
||||
4. 信息可见性层
|
||||
5. 情境导演层
|
||||
6. 合约与信号推进层
|
||||
7. 叙事载体编译层
|
||||
8. 记忆与回响层
|
||||
|
||||
关系如下:
|
||||
|
||||
```text
|
||||
世界语义层
|
||||
-> 题材适配层
|
||||
-> 角色与阵营层
|
||||
-> 信息可见性层
|
||||
-> 情境导演层
|
||||
-> 合约与信号推进层
|
||||
-> 叙事载体编译层
|
||||
-> 记忆与回响层
|
||||
```
|
||||
|
||||
## 5. 世界语义层
|
||||
|
||||
## 5.1 目标
|
||||
|
||||
让所有题材都先被翻译成一套统一的世界叙事骨架,而不是直接开始生成角色和对白。
|
||||
|
||||
## 5.2 建议的数据结构
|
||||
|
||||
```ts
|
||||
interface StoryThread {
|
||||
id: string;
|
||||
title: string;
|
||||
visibility: 'visible' | 'hidden';
|
||||
summary: string;
|
||||
conflictType: string;
|
||||
stakes: string;
|
||||
involvedFactionIds: string[];
|
||||
involvedActorIds: string[];
|
||||
relatedLocationIds: string[];
|
||||
}
|
||||
|
||||
interface StoryScar {
|
||||
id: string;
|
||||
title: string;
|
||||
pastEvent: string;
|
||||
publicResidue: string;
|
||||
hiddenTruth: string;
|
||||
relatedActorIds: string[];
|
||||
relatedLocationIds: string[];
|
||||
}
|
||||
|
||||
interface StoryMotif {
|
||||
id: string;
|
||||
label: string;
|
||||
semanticRole: 'institution' | 'ritual' | 'technology' | 'taboo' | 'ruin' | 'memory' | 'resource' | 'creature';
|
||||
lexicalHints: string[];
|
||||
}
|
||||
|
||||
interface WorldStoryGraph {
|
||||
visibleThreads: StoryThread[];
|
||||
hiddenThreads: StoryThread[];
|
||||
scars: StoryScar[];
|
||||
motifs: StoryMotif[];
|
||||
}
|
||||
```
|
||||
|
||||
## 5.3 为什么它必须在最前面
|
||||
|
||||
没有这层图谱,就会出现这类问题:
|
||||
|
||||
1. 角色各自有设定,但彼此没有共享暗线。
|
||||
2. 物件命名很酷,但和世界冲突没有关系。
|
||||
3. 场景有氛围,但和主要矛盾不互相印证。
|
||||
4. 每次 AI 输出都像重新发明一个宇宙。
|
||||
|
||||
## 6. 题材适配层
|
||||
|
||||
## 6.1 目标
|
||||
|
||||
让题材差异变成一个可替换的“表现层”,而不是把剧情引擎本体写死成某一类世界观。
|
||||
|
||||
## 6.2 建议的数据结构
|
||||
|
||||
```ts
|
||||
interface ThemePack {
|
||||
id: string;
|
||||
displayName: string;
|
||||
toneRange: string[];
|
||||
institutionLexicon: string[];
|
||||
tabooLexicon: string[];
|
||||
artifactClasses: string[];
|
||||
actorArchetypes: string[];
|
||||
conflictForms: string[];
|
||||
clueForms: string[];
|
||||
namingPatterns: string[];
|
||||
revealStyles: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## 6.3 题材适配层负责什么
|
||||
|
||||
它只负责:
|
||||
|
||||
1. 把“机构”翻译成宗门、财团、学会、调查局、帮派、舰队、公司、教团等。
|
||||
2. 把“禁忌”翻译成邪术、封印、机密协议、校园旧规、污染区准则等。
|
||||
3. 把“叙事载体”翻译成遗物、枪械、芯片、病例、证物、祭器、录像带、样本、航图等。
|
||||
4. 把“冲突形式”翻译成宫廷斗争、公司内斗、调查失踪、阵营战争、神话追索、生存竞争等。
|
||||
|
||||
它不负责:
|
||||
|
||||
1. 决定角色到底知道什么。
|
||||
2. 决定剧情推进是否合法。
|
||||
3. 决定哪些信息此刻允许披露。
|
||||
|
||||
## 7. 角色与阵营层
|
||||
|
||||
## 7.1 角色不是“背景文本”,而是“叙事立场体”
|
||||
|
||||
每个角色都必须被拆成下面几个面向:
|
||||
|
||||
1. 外显身份
|
||||
2. 当前处境
|
||||
3. 表面目标
|
||||
4. 真实目标
|
||||
5. 隐藏关系
|
||||
6. 已付代价
|
||||
7. 不愿被碰的禁区
|
||||
8. 会触发反应的关键词
|
||||
|
||||
## 7.2 建议的数据结构
|
||||
|
||||
```ts
|
||||
interface ActorNarrativeProfile {
|
||||
publicMask: string;
|
||||
firstContactMask: string;
|
||||
visibleLine: string;
|
||||
hiddenLine: string;
|
||||
contradiction: string;
|
||||
debtOrBurden: string;
|
||||
taboo: string;
|
||||
immediatePressure: string;
|
||||
relatedThreadIds: string[];
|
||||
relatedScarIds: string[];
|
||||
reactionHooks: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## 7.3 低关系角色的引擎规则
|
||||
|
||||
低关系、低好感、低信任角色必须满足:
|
||||
|
||||
1. 有压力
|
||||
2. 有保留
|
||||
3. 有错位
|
||||
4. 有反应钩子
|
||||
|
||||
不能只是:
|
||||
|
||||
1. 说得少
|
||||
2. 更冷淡
|
||||
3. 更短句
|
||||
|
||||
正确做法应该是:
|
||||
|
||||
- 披露深度更低
|
||||
- 戏剧张力更高
|
||||
- 错误说辞更多
|
||||
- 观察与试探更明显
|
||||
|
||||
## 8. 信息可见性层
|
||||
|
||||
## 8.1 这是 AI 原生剧情引擎的核心
|
||||
|
||||
如果不把“可见性”数据化,AI 叙事会天然滑向全知视角。
|
||||
|
||||
因此必须明确区分:
|
||||
|
||||
1. 事实是否存在
|
||||
2. 玩家是否知道
|
||||
3. 当前角色是否愿意说
|
||||
4. 当前 prompt 是否允许注入
|
||||
5. 玩家是否只是误以为知道
|
||||
|
||||
## 8.2 建议的数据结构
|
||||
|
||||
```ts
|
||||
interface KnowledgeFact {
|
||||
id: string;
|
||||
content: string;
|
||||
ownerActorIds: string[];
|
||||
relatedThreadIds: string[];
|
||||
visibility: 'public' | 'discoverable' | 'private' | 'forbidden';
|
||||
}
|
||||
|
||||
interface VisibilitySlice {
|
||||
factIds: string[];
|
||||
sayableFactIds: string[];
|
||||
inferredFactIds: string[];
|
||||
forbiddenFactIds: string[];
|
||||
misdirectionHints: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## 8.3 运行时规则
|
||||
|
||||
1. prompt 只吃 `VisibilitySlice`
|
||||
2. 未解锁章节不等于不存在,但不能进当前 prompt
|
||||
3. 角色知道某事,不等于此刻愿意承认
|
||||
4. 玩家接触到线索,不等于系统要直接盖章真相
|
||||
|
||||
## 9. 情境导演层
|
||||
|
||||
## 9.1 目标
|
||||
|
||||
每一轮剧情生成都不是“让 AI 自由写”,而是先由导演层判断:
|
||||
|
||||
1. 此刻最重要的压力是什么
|
||||
2. 谁在主导场面
|
||||
3. 当前最适合推进的是明线、暗线还是关系线
|
||||
4. 哪些叙事载体应该被推到前台
|
||||
|
||||
## 9.2 导演层输入
|
||||
|
||||
- 当前场景
|
||||
- 当前实体
|
||||
- 当前关系状态
|
||||
- 当前可见信息
|
||||
- 最近信号变化
|
||||
- 玩家上一步行动
|
||||
- 尚未回响的故事线程
|
||||
|
||||
## 9.3 导演层输出
|
||||
|
||||
```ts
|
||||
interface SceneNarrativeDirective {
|
||||
primaryPressure: string;
|
||||
activeThreadIds: string[];
|
||||
foregroundActorIds: string[];
|
||||
foregroundCarrierIds: string[];
|
||||
revealBudget: 'low' | 'medium' | 'high';
|
||||
emotionalCadence: 'tense' | 'curious' | 'hostile' | 'intimate' | 'tragic' | 'mysterious';
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 合约与信号推进层
|
||||
|
||||
## 10.1 目标
|
||||
|
||||
让剧情推进不依赖纯脚本,而是依赖“意图 -> 合约 -> 信号”。
|
||||
|
||||
## 10.2 统一抽象
|
||||
|
||||
1. 意图
|
||||
- 当前想推动什么关系、冲突、调查或获取。
|
||||
|
||||
2. 合约
|
||||
- 把意图翻译成可追踪的步骤、条件、参与者与回报。
|
||||
|
||||
3. 信号
|
||||
- 玩家行动、地点变化、物件获取、关系变化、战斗结果、情报拼接后触发推进。
|
||||
|
||||
## 10.3 为什么这层跨题材都成立
|
||||
|
||||
因为不管是:
|
||||
|
||||
- 武侠寻仇
|
||||
- 科幻调查
|
||||
- 校园秘密
|
||||
- 末世生存
|
||||
- 神话追索
|
||||
|
||||
它们最终都能抽象成:
|
||||
|
||||
- 某种线索被拿到
|
||||
- 某个误会被确认或打破
|
||||
- 某种关系被推进或撕裂
|
||||
- 某个真相片段被解锁
|
||||
|
||||
## 11. 叙事载体编译层
|
||||
|
||||
## 11.1 不要只把“物品”当成装备
|
||||
|
||||
跨题材剧情引擎里,叙事载体不应仅仅是“装备 / 道具”,而应统一抽象成:
|
||||
|
||||
```ts
|
||||
type NarrativeCarrierType =
|
||||
| 'artifact'
|
||||
| 'document'
|
||||
| 'evidence'
|
||||
| 'device'
|
||||
| 'resource'
|
||||
| 'corpse'
|
||||
| 'sample'
|
||||
| 'relic'
|
||||
| 'ritual_object'
|
||||
| 'memory_fragment';
|
||||
```
|
||||
|
||||
## 11.2 每个载体必须包含的叙事指纹
|
||||
|
||||
```ts
|
||||
interface CarrierStoryFingerprint {
|
||||
visibleClue: string;
|
||||
witnessMark: string;
|
||||
unresolvedQuestion: string;
|
||||
currentAppearanceReason: string;
|
||||
relatedThreadIds: string[];
|
||||
relatedScarIds: string[];
|
||||
reactionHooks: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## 11.3 编译规则
|
||||
|
||||
每个叙事载体都至少要能回答:
|
||||
|
||||
1. 它是谁、哪处、哪次事留下的痕迹?
|
||||
2. 它为什么现在出现?
|
||||
3. 它之后能让谁产生反应?
|
||||
4. 它和哪条线程有关系?
|
||||
|
||||
这层的作用,是让:
|
||||
|
||||
- 奇幻里的遗物
|
||||
- 武侠里的旧兵器
|
||||
- 科幻里的芯片
|
||||
- 悬疑里的口供
|
||||
- 恐怖里的录像带
|
||||
- 校园题材里的匿名纸条
|
||||
|
||||
都能进入同一套剧情引擎。
|
||||
|
||||
## 12. 记忆与回响层
|
||||
|
||||
## 12.1 目标
|
||||
|
||||
让世界对玩家行动和已获取的叙事载体产生长期回响,而不是每轮都像第一次发生。
|
||||
|
||||
## 12.2 记忆分层
|
||||
|
||||
建议至少拆成:
|
||||
|
||||
1. 事件记忆
|
||||
2. 关系记忆
|
||||
3. 线索记忆
|
||||
4. 误解记忆
|
||||
5. 已揭示真相记忆
|
||||
|
||||
## 12.3 回响规则
|
||||
|
||||
一个故事线程真正成立,不是因为它被写过一次,而是因为它能在后续这些地方重新出现:
|
||||
|
||||
1. 别的角色说法里
|
||||
2. 新地点残痕里
|
||||
3. 新载体命名里
|
||||
4. 新任务前提里
|
||||
5. 关系变化反应里
|
||||
|
||||
## 13. Prompt Contract 设计
|
||||
|
||||
## 13.1 建议拆成 6 类 contract
|
||||
|
||||
1. 世界图谱 contract
|
||||
2. 角色叙事档案 contract
|
||||
3. 章节解锁 contract
|
||||
4. 场景导演 contract
|
||||
5. 叙事载体意图 contract
|
||||
6. 回响总结 contract
|
||||
|
||||
## 13.2 contract 总原则
|
||||
|
||||
1. AI 只拿当前阶段需要的最小上下文
|
||||
2. AI 不直接决定数值、库存、状态迁移、任务合法性
|
||||
3. AI 输出优先是“意图 / 钩子 / 视角 / 叙事指纹”,不是庞大成品对象
|
||||
4. 所有未解锁信息都不能被默认注入
|
||||
|
||||
## 14. 与当前仓库的接入方式
|
||||
|
||||
这套引擎并不是脱离当前项目另起炉灶,而是可以沿着已有骨架往前升级。
|
||||
|
||||
## 14.1 可直接复用的现有基础
|
||||
|
||||
1. `customWorld.ts`
|
||||
- 已有世界生成骨架,可升级成“世界图谱 + 角色叙事档案”生成入口。
|
||||
|
||||
2. `prompt.ts`
|
||||
- 已有上下文组织能力,可升级成“基于可见性切片”的 prompt 裁剪器。
|
||||
|
||||
3. `questDirector.ts`
|
||||
- 已有任务导演方向,可升级成“线程 -> 合约 -> 信号”的推进器。
|
||||
|
||||
4. `runtimeItemDirector.ts` / `runtimeItemNarrative.ts`
|
||||
- 已有运行时奖励与叙事包装能力,可升级成“叙事载体编译器”。
|
||||
|
||||
5. `npcInteractions.ts`
|
||||
- 已有关系状态和首遇逻辑,可升级成“关系与披露双轴控制器”。
|
||||
|
||||
## 14.2 建议新增的模块
|
||||
|
||||
- `src/services/storyEngine/themePack.ts`
|
||||
- `src/services/storyEngine/worldStoryGraph.ts`
|
||||
- `src/services/storyEngine/visibilityEngine.ts`
|
||||
- `src/services/storyEngine/actorNarrativeDossier.ts`
|
||||
- `src/services/storyEngine/sceneNarrativeDirector.ts`
|
||||
- `src/services/storyEngine/carrierNarrativeCompiler.ts`
|
||||
- `src/services/storyEngine/echoMemory.ts`
|
||||
|
||||
## 15. 验收标准
|
||||
|
||||
这套引擎至少要满足下面这些标准,才能算“跨题材 AI 原生剧情引擎”而不是“某一类题材文案增强器”。
|
||||
|
||||
1. 同一套引擎在至少 3 种明显不同的题材包里都能产出结构稳定的世界线程、角色秘密与叙事载体。
|
||||
2. 低关系角色在不同题材下都能做到“有压力、有错位、有暗线钩子”,而不是只会变冷淡。
|
||||
3. 未解锁信息不会在首遇、低披露或无关场景中提前进入 prompt。
|
||||
4. 至少 `80%` 的重点叙事载体都能被玩家看出与某条故事线程、某个旧伤或某个角色关系有关。
|
||||
5. 玩家在不看后台数据的情况下,仍能通过角色、物件、地点、任务描述拼出世界里的明线与暗线。
|
||||
6. 新增一个题材时,主要工作量集中在 `ThemePack` 与词汇适配,而不是重写剧情主逻辑。
|
||||
|
||||
## 16. 推荐落地顺序
|
||||
|
||||
## 阶段 A:先做引擎底层,不先卷文案
|
||||
|
||||
先补:
|
||||
|
||||
- 世界图谱
|
||||
- 可见性切片
|
||||
- 角色叙事档案
|
||||
|
||||
## 阶段 B:再接当前项目最需要的两个落点
|
||||
|
||||
1. 大世界 NPC 背景与首遇表达
|
||||
2. 运行时物件名称、描述与回响
|
||||
|
||||
## 阶段 C:再把任务、地点、关系、载体接成一张网
|
||||
|
||||
重点做:
|
||||
|
||||
- 线程推进
|
||||
- 合约生成
|
||||
- 信号触发
|
||||
- 回响回写
|
||||
|
||||
## 阶段 D:最后做多题材验证
|
||||
|
||||
至少选 3 种差异足够大的题材做压力测试:
|
||||
|
||||
1. 高奇幻 / 武侠神话类
|
||||
2. 科幻 / 调查类
|
||||
3. 悬疑 / 恐怖 / 校园 / 现代类
|
||||
|
||||
## 17. 最后结论
|
||||
|
||||
真正可复用的 AI 原生剧情引擎,不应该先问“这句像不像某个题材”,而应该先问:
|
||||
|
||||
1. 世界有哪些正在运行的明线和暗线?
|
||||
2. 谁知道什么?谁不肯说什么?谁在承受什么?
|
||||
3. 哪些角色、地点、物件、证物正在共同讲同一件事?
|
||||
4. 当前这一轮,玩家应该感到的是压力、怀疑、诱惑、误导,还是揭示?
|
||||
|
||||
只有当这些问题被引擎层回答清楚之后,不同题材的外观、词汇和风格,才会真正长在同一套 AI 原生剧情框架之上。
|
||||
@@ -0,0 +1,734 @@
|
||||
# AI 原生叙事线索驱动的物品命名与大世界 NPC 背景系统 PRD
|
||||
|
||||
更新时间:`2026-04-06`
|
||||
|
||||
## 0. 目标
|
||||
|
||||
这份 PRD 面向当前仓库,解决两个直接影响“故事感”的核心问题:
|
||||
|
||||
1. 实时生成的物品名称、描述虽然已经能贴合 build 和来源,但整体仍偏模板拼接,缺少“它背后发生过什么”的故事重量。
|
||||
2. 大世界生成的 NPC 背景目前更像设定摘要,尤其是初始好感度低的角色,容易只剩“冷淡 / 戒备 / 不说”,却没有真正的戏剧张力和暗线钩子。
|
||||
|
||||
本次设计参考《博德之门 3》《黑神话:悟空》这类 RPG 的叙事组织方式,但不照搬具体内容,而是提炼出适合当前项目的 4 个目标:
|
||||
|
||||
1. 每个 NPC 都不仅有“公开设定”,还要有能被逐步识别的明线、暗线、代价线与回响线。
|
||||
2. 每个运行时物品都不仅是“当前 build 的奖励”,还要像某件旧事留下的证物、遗物、债物或禁物。
|
||||
3. 初始好感度低不等于故事薄;低好感角色要更像“有东西不肯说”,而不是“没有东西可说”。
|
||||
4. AI 继续负责叙事表达与意图,本地规则继续负责数值、合法性、状态迁移与编译。
|
||||
|
||||
一句话目标:
|
||||
|
||||
**让角色背景、物品名称、物品描述都成为世界主线与暗线的载体,而不是玩法奖励外面包的一层随机文案。**
|
||||
|
||||
## 1. 当前问题定位
|
||||
|
||||
## 1.1 运行时物品叙事目前仍偏模板拼接
|
||||
|
||||
当前实现已经有 `runtimeItemContext -> runtimeItemDirector -> runtimeItemCompiler -> runtimeItemNarrative` 这条主链,玩法层是成立的,但叙事密度仍然不够。
|
||||
|
||||
主要问题有 3 个:
|
||||
|
||||
1. `src/data/runtimeItemNarrative.ts`
|
||||
- 名称仍然主要依赖 `来源词 + 关系词 + 功能词` 的固定拼接。
|
||||
- 描述仍然主要是“谁留下的什么 + 为什么出现 + 偏向什么 build”的单模板句式。
|
||||
|
||||
2. `src/data/runtimeItemNarrative.ts`
|
||||
- fallback 的 `reasonToAppear` 仍是“与最近局势把它推到了你面前”这一类通用解释。
|
||||
- 这能解释“为什么它不是纯随机”,但解释不了“它到底见证过什么”。
|
||||
|
||||
3. `src/services/runtimeItemAiPrompt.ts`
|
||||
- 当前 AI contract 仍聚焦 `shortNameSeed / sourcePhrase / reasonToAppear / relationHooks / desiredBuildTags`。
|
||||
- 它能产出“贴合当前局势”的轻量意图,但还不够支撑“证物感、传闻感、宿命感、旧债感”。
|
||||
|
||||
结果就是:
|
||||
|
||||
- 物品已经不再是纯随机装备,但还没有成为能埋故事线索的叙事节点。
|
||||
- 玩家会知道“它适合当前 build”,但不一定会对“它从哪段旧事里来”产生兴趣。
|
||||
|
||||
## 1.2 自定义世界 NPC 生成仍偏“字段补齐”,不够像故事角色
|
||||
|
||||
当前 `src/services/customWorld.ts` 的自定义世界生成已经能产出完整的:
|
||||
|
||||
- `backstory`
|
||||
- `personality`
|
||||
- `motivation`
|
||||
- `combatStyle`
|
||||
- `backstoryReveal`
|
||||
- `skills`
|
||||
- `initialItems`
|
||||
|
||||
但问题在于,它更像一份“角色设定卡 JSON”,而不是“能在游玩里逐步显影的故事人物”。
|
||||
|
||||
当前的主要限制有:
|
||||
|
||||
1. 单次生成字段很多,但每个字段长度被压得很短。
|
||||
2. `backstoryReveal` 固定 4 章,但更像摘要切片,而不是围绕世界冲突组织的叙事章节。
|
||||
3. 生成要求强调“不要改定位、不要超字段、字符串尽量简洁”,这对结构稳定有帮助,但会明显牺牲戏剧性。
|
||||
|
||||
结果就是:
|
||||
|
||||
- NPC 有设定,但缺少真正的事件痕迹。
|
||||
- NPC 有动机,但缺少真正的秘密、债务、误认、旧案、禁忌、未完成关系。
|
||||
- NPC 有章节,但章节之间没有足够强的剧情递进与回响。
|
||||
|
||||
## 1.3 自定义世界 NPC 的信息边界仍在越权泄露
|
||||
|
||||
这部分是当前“低好感角色仍然不神秘”的一个关键根因。
|
||||
|
||||
目前在 `src/services/prompt.ts` 中,自定义世界 NPC 遭遇描述仍会注入:
|
||||
|
||||
1. 完整 `backstory`
|
||||
2. 所有 `backstoryReveal.chapters`
|
||||
3. 技能与初始物品细节
|
||||
|
||||
同时 `describeCustomWorldSection(...)` 还会把多名 NPC 的:
|
||||
|
||||
- 公开背景
|
||||
- 完整背景
|
||||
- 动机
|
||||
- 技能
|
||||
- 初始物品
|
||||
- 章节 teaser
|
||||
|
||||
打成“自定义世界补充档案”整体注入。
|
||||
|
||||
这会直接导致两个问题:
|
||||
|
||||
1. 模型在第一次见面、低好感、甚至仅仅“面前遭遇”阶段,就已经站在“全知视角”上写角色。
|
||||
2. 角色表面上虽然还在说得很少,但模型其实已经知道太多,所以写出来的话会天然带着“设定卡背书感”,而不是“此刻只肯露一角”。
|
||||
|
||||
## 1.4 初始低好感角色目前更多是“收口”,不是“施压”
|
||||
|
||||
低好感角色无聊,核心不在于他们说得少,而在于他们缺少下面这些东西:
|
||||
|
||||
1. 面前局势里的压力
|
||||
2. 说辞与真实动机之间的错位
|
||||
3. 对某个旧事件、旧物、旧人、旧地的条件反射
|
||||
4. 让玩家感觉“这人知道点什么,但现在不肯给”的钩子
|
||||
|
||||
也就是说:
|
||||
|
||||
**当前低好感角色更多是信息减少了,但戏剧密度没有同步提高。**
|
||||
|
||||
## 2. 设计参考转译
|
||||
|
||||
本次设计参考的不是具体桥段,而是两类成熟 RPG 的叙事组织方法。
|
||||
|
||||
## 2.1 来自《博德之门 3》的可借鉴点
|
||||
|
||||
1. 角色秘密不是独立文本,而是会影响初见印象、后续关系、任务走向与物品反应。
|
||||
2. 物品、书信、遗物、口供、尸体、地标都在一起构成线索网,而不是各说各话。
|
||||
3. 真正有意思的角色,往往不是“设定很多”,而是“表面、欲望、隐瞒、代价”彼此错位。
|
||||
|
||||
## 2.2 来自《黑神话:悟空》的可借鉴点
|
||||
|
||||
1. 名称本身就带旧事与异感,不只是功能标签。
|
||||
2. 很多信息不是直接说明,而是通过残缺线索、旧痕、俗称、误传、传闻去显影。
|
||||
3. 角色和物件都会带一种“事已经发生过,但后劲还在”的叙事余震。
|
||||
|
||||
## 2.3 对当前项目的转译原则
|
||||
|
||||
转成当前仓库可落地的做法后,所有重点 NPC 与重点物品都必须携带四条线:
|
||||
|
||||
1. 明线
|
||||
- 玩家当前就能感知到的表层目标、来意、用途、冲突位置。
|
||||
|
||||
2. 暗线
|
||||
- 与世界冲突、旧事件、秘密关系、禁忌知识有关,但此刻不完全说透的线。
|
||||
|
||||
3. 代价线
|
||||
- 角色或物品背后已经失去过什么、欠着什么、被谁盯上、为什么不能轻易松口。
|
||||
|
||||
4. 回响线
|
||||
- 能与其他 NPC、场景、任务、物品互相印证的共享线索、意象、事件伤痕或势力痕迹。
|
||||
|
||||
## 3. 设计原则
|
||||
|
||||
1. AI 负责叙事层,本地负责规则层。
|
||||
- 不能让模型直接决定数值、掉落预算、好感变化和状态迁移。
|
||||
|
||||
2. 首次接触只给“可见的一角”,不给“全量设定”。
|
||||
- 低好感阶段更要严格限制 prompt 注入范围。
|
||||
|
||||
3. 低好感降低的是披露深度,不是故事密度。
|
||||
- 对方可以不坦白,但必须有压力、矛盾、误导、观察和反应。
|
||||
|
||||
4. 物品必须先是叙事证物,再是功能容器。
|
||||
- build 倾向依然重要,但需要嵌在“它是谁留下的、为什么出现在这里”之中。
|
||||
|
||||
5. 世界里的 NPC、物品、场景要共享同一套叙事词根与事件节点。
|
||||
- 不允许每次生成都像一个独立小宇宙。
|
||||
|
||||
6. UI 展示保持克制。
|
||||
- 不在面板里默认堆规则说明,只展示结果与线索感,符合项目已有的清爽游戏 UI 要求。
|
||||
|
||||
## 4. 核心系统结论
|
||||
|
||||
建议把当前“物品叙事 + 大世界 NPC 背景”升级为:
|
||||
|
||||
**世界叙事图谱 -> NPC 叙事档案 -> 运行时物品叙事指纹 -> prompt 可见性裁剪 -> 反应与回响回写**
|
||||
|
||||
它不是新起一套独立玩法,而是补在当前这些模块之上:
|
||||
|
||||
- `src/services/customWorld.ts`
|
||||
- `src/services/prompt.ts`
|
||||
- `src/data/runtimeItemContext.ts`
|
||||
- `src/data/runtimeItemDirector.ts`
|
||||
- `src/data/runtimeItemNarrative.ts`
|
||||
- `src/services/runtimeItemAiPrompt.ts`
|
||||
|
||||
## 5. 世界级叙事图谱设计
|
||||
|
||||
## 5.1 目标
|
||||
|
||||
给自定义世界补一层比 `summary / tone / factions / landmarks` 更强的叙事骨架,让 NPC 和物品都不是凭空生出来,而是从同一套世界秘密与旧事件里长出来。
|
||||
|
||||
## 5.2 建议新增的数据结构
|
||||
|
||||
```ts
|
||||
interface WorldNarrativeThread {
|
||||
id: string;
|
||||
title: string;
|
||||
lineType: 'visible' | 'shadow';
|
||||
summary: string;
|
||||
involvedFactions: string[];
|
||||
involvedNpcIds: string[];
|
||||
relatedLandmarkIds: string[];
|
||||
motifIds: string[];
|
||||
}
|
||||
|
||||
interface WorldNarrativeScar {
|
||||
id: string;
|
||||
title: string;
|
||||
pastEvent: string;
|
||||
visibleResidue: string;
|
||||
hiddenTruth: string;
|
||||
relatedNpcIds: string[];
|
||||
relatedLandmarkIds: string[];
|
||||
}
|
||||
|
||||
interface WorldNarrativeMotif {
|
||||
id: string;
|
||||
label: string;
|
||||
usage: 'name' | 'item' | 'dialogue' | 'landmark';
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
interface CustomWorldNarrativeGraph {
|
||||
visibleThreads: WorldNarrativeThread[];
|
||||
shadowThreads: WorldNarrativeThread[];
|
||||
scars: WorldNarrativeScar[];
|
||||
motifs: WorldNarrativeMotif[];
|
||||
}
|
||||
```
|
||||
|
||||
并挂到:
|
||||
|
||||
```ts
|
||||
interface CustomWorldProfile {
|
||||
narrativeGraph?: CustomWorldNarrativeGraph;
|
||||
}
|
||||
```
|
||||
|
||||
## 5.3 叙事图谱最少产出要求
|
||||
|
||||
每个自定义世界最少应生成:
|
||||
|
||||
1. `3` 条明线线程
|
||||
2. `4~6` 条暗线线程
|
||||
3. `6~10` 个旧事件伤痕
|
||||
4. `12~20` 个世界词根 / 意象母题
|
||||
|
||||
这些词根不是为了写百科,而是为了让:
|
||||
|
||||
- NPC 名字外的说话习惯
|
||||
- 物品名称中的来源词
|
||||
- 物品描述中的事件痕迹
|
||||
- 地标描述中的遗留痕迹
|
||||
|
||||
能够反复互相印证。
|
||||
|
||||
## 6. 大世界 NPC 叙事档案设计
|
||||
|
||||
## 6.1 当前 `backstoryReveal` 的问题
|
||||
|
||||
当前 `backstoryReveal` 已经有“按好感解锁”的结构,但更接近“把背景拆成 4 段”,还不够像“有明线暗线的角色档案”。
|
||||
|
||||
建议保留现有外层结构,但新增一层更强的叙事骨架。
|
||||
|
||||
## 6.2 建议新增的数据结构
|
||||
|
||||
```ts
|
||||
interface NpcNarrativeDossier {
|
||||
publicMask: string;
|
||||
firstContactMask: string;
|
||||
visibleLine: string;
|
||||
shadowLine: string;
|
||||
contradiction: string;
|
||||
debtOrOath: string;
|
||||
hiddenFearOrTaboo: string;
|
||||
scenePressure: string;
|
||||
relatedThreadIds: string[];
|
||||
relatedScarIds: string[];
|
||||
motifIds: string[];
|
||||
reactionHooks: string[];
|
||||
linkedItemSeeds: string[];
|
||||
}
|
||||
```
|
||||
|
||||
建议挂到:
|
||||
|
||||
```ts
|
||||
interface CustomWorldRoleProfile {
|
||||
narrativeDossier?: NpcNarrativeDossier;
|
||||
}
|
||||
```
|
||||
|
||||
字段含义如下:
|
||||
|
||||
- `publicMask`
|
||||
- 外人最容易听到或看到的版本。
|
||||
|
||||
- `firstContactMask`
|
||||
- 第一次接触时这人会先拿出来挡在前面的那层说辞。
|
||||
|
||||
- `visibleLine`
|
||||
- 当前出现在此地、此刻最能被玩家感知到的表层线。
|
||||
|
||||
- `shadowLine`
|
||||
- 背后真正连着哪条暗线,但还不会直接说透。
|
||||
|
||||
- `contradiction`
|
||||
- 这个角色最值得被玩家察觉的“说辞与事实错位”。
|
||||
|
||||
- `debtOrOath`
|
||||
- 让角色更像活在故事中的关键债务、誓言、旧命令或未结关系。
|
||||
|
||||
- `hiddenFearOrTaboo`
|
||||
- 这个角色不愿被提起的人、事、地、物或称谓。
|
||||
|
||||
- `scenePressure`
|
||||
- 这个角色此刻为什么紧绷、拖延、转移、误导。
|
||||
|
||||
- `reactionHooks`
|
||||
- 未来哪些人名、物名、伤痕、势力称呼会触发反应。
|
||||
|
||||
## 6.3 低初始好感角色的写法规则
|
||||
|
||||
低好感不是“少写”,而是换成“斜着写、卡着写、顶着写”。
|
||||
|
||||
建议按初始好感分 4 档处理。
|
||||
|
||||
| 初始好感区间 | 角色手感 | 必须出现的内容 | 禁止出现的内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| `<= -10` | 敌意、误认、警惕、试图抢占叙事主导权 | 现场威胁、对玩家的判断、一个错误说辞、一个破绽 | 上来完整交代来历 |
|
||||
| `-9 ~ 14` | 戒备、带压力的克制、把话题往表层拖 | 当前局势、表面理由、一个不愿明说的对象、一个观察细节 | 平铺直叙式自我介绍 |
|
||||
| `15 ~ 39` | 正常交流但不交底 | 表层目标、与场景关联、一个旧事余波 | 直接说最终动机 |
|
||||
| `>= 40` | 有合作空间但仍保留底牌 | 合作可能、旧债或旧誓、阶段性真相 | 一次性摊牌全部秘密 |
|
||||
|
||||
关键规则:
|
||||
|
||||
1. 低好感角色的首轮输出必须带“错位感”。
|
||||
2. 至少要有一个让玩家觉得“这句话不全对”的缝隙。
|
||||
3. 至少要有一个和此地事件、旧痕、物件、人物有关的具体钩子。
|
||||
|
||||
## 6.4 背景章节的重组方式
|
||||
|
||||
当前仍可保留 4 章,但不建议继续把它们仅仅当成“摘要 1、摘要 2、摘要 3、摘要 4”。
|
||||
|
||||
建议把 4 章改成稳定的功能分层:
|
||||
|
||||
1. `surface`
|
||||
- 表层来意 / 当下伪装 / 公开身份切口
|
||||
|
||||
2. `scar`
|
||||
- 旧事裂痕 / 已经失去的东西 / 不愿再提的一次失败
|
||||
|
||||
3. `bind`
|
||||
- 关系债务 / 誓言 / 阵营绑缚 / 必须维护的错误
|
||||
|
||||
4. `truth`
|
||||
- 真正目标 / 最终底牌 / 真相代价
|
||||
|
||||
保留 `id` 稳定,标题允许按世界主题动态生成,而不是固定文案。
|
||||
|
||||
## 6.5 章节文本的写法要求
|
||||
|
||||
每一章都必须同时回答两件事:
|
||||
|
||||
1. 这段旧事和世界哪条明线 / 暗线有关?
|
||||
2. 它为什么会影响这个 NPC 今天的说话方式、站位、物品、关系或敌意?
|
||||
|
||||
也就是说,章节正文不该只是“以前发生了什么”,而要能连回“现在为什么会这样”。
|
||||
|
||||
## 7. 运行时物品叙事指纹设计
|
||||
|
||||
## 7.1 当前问题
|
||||
|
||||
当前运行时物品已经能贴合:
|
||||
|
||||
- 场景
|
||||
- 遭遇
|
||||
- build 缺口
|
||||
- 关系锚点
|
||||
|
||||
但它更像“上下文化奖励”,还不够像“故事痕迹”。
|
||||
|
||||
## 7.2 建议新增的数据结构
|
||||
|
||||
```ts
|
||||
interface RuntimeItemStoryFingerprint {
|
||||
visibleClue: string;
|
||||
witnessMark: string;
|
||||
unfinishedBusiness: string;
|
||||
hiddenHook: string;
|
||||
relatedThreadIds: string[];
|
||||
relatedScarIds: string[];
|
||||
motifIds: string[];
|
||||
reactionHooks: string[];
|
||||
namingPattern:
|
||||
| 'npc_relic'
|
||||
| 'scene_relic'
|
||||
| 'faction_issue'
|
||||
| 'monster_trophy'
|
||||
| 'quest_evidence'
|
||||
| 'forbidden_object';
|
||||
}
|
||||
```
|
||||
|
||||
并挂到:
|
||||
|
||||
```ts
|
||||
interface RuntimeItemMetadata {
|
||||
storyFingerprint?: RuntimeItemStoryFingerprint;
|
||||
}
|
||||
```
|
||||
|
||||
## 7.3 每件重点物品必须携带的叙事要素
|
||||
|
||||
对 `rare` 及以上,或者 `narrativeWeight = medium / heavy` 的物品,至少要同时有:
|
||||
|
||||
1. 一个可见线索
|
||||
- 玩家光看名字、描述或出处就能捕到的痕迹。
|
||||
|
||||
2. 一个见证痕
|
||||
- 它像谁留下的、从哪次旧事里滚出来的、带着什么使用痕迹。
|
||||
|
||||
3. 一个未完成问题
|
||||
- 这件物品背后还有什么没结掉。
|
||||
|
||||
4. 一个当前出现理由
|
||||
- 为什么偏偏是现在、是这里、是你拿到它。
|
||||
|
||||
5. 一个可回响对象
|
||||
- 哪个 NPC / 场景 / 势力 / 任务之后可能对它起反应。
|
||||
|
||||
## 7.4 命名系统升级
|
||||
|
||||
当前的三段式命名方向是对的,但需要从“词块拼装”升级为“叙事指纹编译”。
|
||||
|
||||
建议按来源分 6 种命名范式:
|
||||
|
||||
| 命名范式 | 适用来源 | 推荐结构 | 示例风格 |
|
||||
| --- | --- | --- | --- |
|
||||
| 人物遗物 | NPC 奖励、遗失物 | 旧称 / 誓约 / 功能物 | `断旗旧誓护心佩` |
|
||||
| 场景遗物 | 宝藏、废墟、秘境 | 地标 / 灾痕 / 品类 | `沉炉灰纹短刃` |
|
||||
| 势力制式 | 商店、军需、黑市 | 势力 / 制式 / 用途 | `巡河司缉印符` |
|
||||
| 怪物战利 | 怪物掉落、生态素材 | 生态 / 异化 / 精粹 | `雾骨猎印精粹` |
|
||||
| 任务证物 | 委托、追查、交付 | 事件 / 口供 / 信物 | `沉港失契信物` |
|
||||
| 禁忌物 | 关键宝藏、暗线物 | 禁名 / 封痕 / 器类 | `烬名封缄骨匣` |
|
||||
|
||||
要求不是字数更长,而是:
|
||||
|
||||
- 名字里至少有一个世界词根
|
||||
- 至少有一个事件痕或关系痕
|
||||
- 最后才落到功能词或器类词
|
||||
|
||||
## 7.5 描述文案升级
|
||||
|
||||
当前描述模板需要升级为三层表达:
|
||||
|
||||
1. 第一层:可见痕迹
|
||||
- 这件东西看起来像经历过什么。
|
||||
|
||||
2. 第二层:旧事牵连
|
||||
- 它和谁、哪处、哪次旧事有关。
|
||||
|
||||
3. 第三层:当前局势意义
|
||||
- 为什么此刻来到玩家手里,以及它偏向什么战斗或 build 方向。
|
||||
|
||||
示例结构:
|
||||
|
||||
```text
|
||||
表面痕迹句。旧事牵连句。当前局势与玩法意义句。
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. 第二句必须有“谁 / 哪处 / 哪次事”的具体指向之一。
|
||||
2. 不能只有“适合当前 build”这种系统性总结。
|
||||
3. 允许保留一点空白,不要把暗线直接讲穿。
|
||||
|
||||
## 8. Prompt Contract 升级
|
||||
|
||||
## 8.1 自定义世界 NPC 生成 prompt 的问题
|
||||
|
||||
当前 prompt 的主要问题,不是字段不够,而是:
|
||||
|
||||
1. 生成阶段没有先产“世界叙事图谱”,导致 NPC 和物品共享词根不稳定。
|
||||
2. 角色阶段过早要求完整字段,导致模型更像在补设定表。
|
||||
3. 文本长度限制过严,压缩掉了故事张力。
|
||||
|
||||
## 8.2 建议改成三阶段生成
|
||||
|
||||
### 阶段 A:世界叙事图谱
|
||||
|
||||
先产出:
|
||||
|
||||
- `visibleThreads`
|
||||
- `shadowThreads`
|
||||
- `scars`
|
||||
- `motifs`
|
||||
|
||||
### 阶段 B:角色骨架
|
||||
|
||||
只产出:
|
||||
|
||||
- `name`
|
||||
- `title`
|
||||
- `role`
|
||||
- `description`
|
||||
- `initialAffinity`
|
||||
- `relationshipHooks`
|
||||
- `tags`
|
||||
- `narrativeDossier`
|
||||
|
||||
### 阶段 C:背景章节、技能、初始物品
|
||||
|
||||
基于前两阶段结果,再补:
|
||||
|
||||
- `backstory`
|
||||
- `backstoryReveal`
|
||||
- `skills`
|
||||
- `initialItems`
|
||||
|
||||
这样做的目的不是把流程变复杂,而是防止:
|
||||
|
||||
- 世界叙事图谱还没稳定,角色就先各自长成独立设定卡。
|
||||
|
||||
## 8.3 自定义世界 NPC 生成时的必填叙事约束
|
||||
|
||||
每个 NPC 必须明确写出:
|
||||
|
||||
1. 当前站在哪条明线上
|
||||
2. 真正卷入哪条暗线
|
||||
3. 一件已经发生过、仍在影响他的旧事
|
||||
4. 一个不愿被直问的对象、地点、称呼或物件
|
||||
5. 一个与玩家可能建立连接的切入口
|
||||
|
||||
## 8.4 运行时物品 AI contract 升级
|
||||
|
||||
建议在当前 `RuntimeItemAiIntent` 外增加叙事指纹字段:
|
||||
|
||||
```ts
|
||||
interface RuntimeItemAiIntent {
|
||||
shortNameSeed: string;
|
||||
sourcePhrase: string;
|
||||
reasonToAppear: string;
|
||||
relationHooks: string[];
|
||||
desiredBuildTags: string[];
|
||||
desiredFunctionalBias: Array<'heal' | 'mana' | 'cooldown' | 'guard' | 'damage'>;
|
||||
tone: 'grim' | 'mysterious' | 'martial' | 'ritual' | 'survival';
|
||||
visibleClue?: string;
|
||||
witnessMark?: string;
|
||||
unfinishedBusiness?: string;
|
||||
hiddenHook?: string;
|
||||
reactionHooks?: string[];
|
||||
namingPattern?: string;
|
||||
}
|
||||
```
|
||||
|
||||
AI 仍然不直接产出成品,只负责:
|
||||
|
||||
- 提供线索
|
||||
- 提供见证感
|
||||
- 提供未完成之事
|
||||
- 提供命名范式建议
|
||||
|
||||
本地编译层再决定:
|
||||
|
||||
- 最终名称
|
||||
- 最终描述
|
||||
- metadata 回写
|
||||
- 稀有度与 build 预算
|
||||
|
||||
## 9. Prompt 可见性裁剪规则
|
||||
|
||||
## 9.1 自定义世界 NPC 遭遇 prompt
|
||||
|
||||
必须修正为:
|
||||
|
||||
1. 首遇或低披露阶段
|
||||
- 只注入 `publicSummary`
|
||||
- 只注入 `firstContactMask / visibleLine / scenePressure`
|
||||
- 只注入已解锁章节的 `contextSnippet`
|
||||
|
||||
2. 禁止直接注入
|
||||
- 完整 `backstory`
|
||||
- 未解锁 `backstoryReveal.content`
|
||||
- 全量章节摘要
|
||||
|
||||
## 9.2 自定义世界总档案注入
|
||||
|
||||
`buildCustomWorldReferenceText(...)` 不能再把多名 NPC 的完整背景和章节提示整体塞进主 prompt。
|
||||
|
||||
建议改成:
|
||||
|
||||
1. 世界摘要
|
||||
2. 叙事图谱摘要
|
||||
3. 与当前场景 / 当前遭遇最相关的少量 NPC 索引
|
||||
|
||||
每名 NPC 只保留:
|
||||
|
||||
- 名称
|
||||
- 身份
|
||||
- 公开背景
|
||||
- 所在线程
|
||||
- 关键反应钩子
|
||||
|
||||
## 9.3 低好感阶段的 prompt 目标
|
||||
|
||||
模型应该知道的是:
|
||||
|
||||
- 这个角色此刻为什么不松口
|
||||
- 他在盯什么
|
||||
- 哪个问题会让他产生反应
|
||||
|
||||
模型不应该知道的是:
|
||||
|
||||
- 他完整的人生说明书
|
||||
|
||||
## 10. UI 表达建议
|
||||
|
||||
UI 侧保持项目当前的清爽方向,不默认堆规则说明文案。
|
||||
|
||||
建议只做这几种表达:
|
||||
|
||||
1. NPC 档案页
|
||||
- 公开背景
|
||||
- 已解锁章节
|
||||
- 未解锁章节 teaser
|
||||
- 不额外展示系统术语
|
||||
|
||||
2. 重点物品 tooltip / 描述区
|
||||
- 名称
|
||||
- 三层式描述
|
||||
- 如需增加额外信息,只显示“来历”或“传闻”一行,不做规则说明板
|
||||
|
||||
3. 与物品 / 人物的回响
|
||||
- 优先通过剧情文本、反应文本、任务追加线索体现
|
||||
- 不优先通过 UI 标签硬显示“这是一条暗线”
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
做到以下几点,才算这次需求真正成立:
|
||||
|
||||
1. 随机抽样 `20` 个运行时重点物品时,至少 `80%` 的名称能看出明确来源词与事件痕,不再像纯功能拼接词。
|
||||
2. 随机抽样 `20` 个运行时重点物品时,`100%` 的描述都能同时回答“它经历过什么”和“为什么此刻出现”。
|
||||
3. 随机抽样 `20` 个自定义世界 NPC 时,`100%` 都能指出自己挂在哪条明线、暗线和哪道旧伤上。
|
||||
4. 初始好感度低于 `15` 的 NPC,首轮文本中至少有一个“说辞与真实意图错位”的点。
|
||||
5. 首遇与低披露阶段的自定义世界 NPC prompt 中,不再直接注入完整 `backstory` 与未解锁章节。
|
||||
6. 同一世界内,物品名、场景名、NPC 章节和剧情文本之间,能够复用同一组词根与事件痕,而不是每次随机飘散。
|
||||
7. 玩家在不看后台数据的情况下,也能从名字、描述、对话和档案 teaser 中隐约拼出世界里的明线与暗线。
|
||||
|
||||
## 12. 推荐落地顺序
|
||||
|
||||
## 阶段 A:先修正信息边界与现状问题
|
||||
|
||||
优先改:
|
||||
|
||||
- `src/services/prompt.ts`
|
||||
- `src/services/customWorld.ts`
|
||||
- `src/services/customWorldReferenceText` 相关逻辑
|
||||
|
||||
目标:
|
||||
|
||||
- 堵住完整背景与未解锁章节的越权注入
|
||||
- 让低好感阶段先重新成立
|
||||
|
||||
## 阶段 B:补世界叙事图谱
|
||||
|
||||
新增:
|
||||
|
||||
- `CustomWorldNarrativeGraph`
|
||||
- 世界线程、旧伤、意象词根
|
||||
|
||||
目标:
|
||||
|
||||
- 让 NPC 与物品共享同一套故事母题
|
||||
|
||||
## 阶段 C:补 NPC 叙事档案
|
||||
|
||||
新增:
|
||||
|
||||
- `NpcNarrativeDossier`
|
||||
|
||||
调整:
|
||||
|
||||
- `backstoryReveal` 的生成逻辑
|
||||
- 低初始好感 NPC 的首遇表达规则
|
||||
|
||||
目标:
|
||||
|
||||
- 让“低好感但有戏”成为稳定产物
|
||||
|
||||
## 阶段 D:补运行时物品叙事指纹
|
||||
|
||||
新增:
|
||||
|
||||
- `RuntimeItemStoryFingerprint`
|
||||
|
||||
调整:
|
||||
|
||||
- `runtimeItemAiPrompt`
|
||||
- `runtimeItemNarrative`
|
||||
|
||||
目标:
|
||||
|
||||
- 让物品名称与描述能承载旧事、见证与未完成问题
|
||||
|
||||
## 阶段 E:做回响与反应
|
||||
|
||||
最后接:
|
||||
|
||||
- NPC 对特定物品的反应
|
||||
- 任务对物品线索的承接
|
||||
- 场景与旧伤的互相印证
|
||||
|
||||
目标:
|
||||
|
||||
- 让“名字和背景有故事”真正进入游玩闭环,而不是只停在文案层
|
||||
|
||||
## 13. 涉及文件建议
|
||||
|
||||
建议优先改动这些区域:
|
||||
|
||||
- `src/types/customWorld.ts`
|
||||
- `src/types/runtimeItem.ts`
|
||||
- `src/services/customWorld.ts`
|
||||
- `src/services/prompt.ts`
|
||||
- `src/services/runtimeItemAiPrompt.ts`
|
||||
- `src/data/runtimeItemNarrative.ts`
|
||||
- `src/data/runtimeItemDirector.ts`
|
||||
|
||||
建议新增这些模块:
|
||||
|
||||
- `src/services/customWorldNarrative.ts`
|
||||
- `src/services/customWorldNarrativePrompt.ts`
|
||||
- `src/data/runtimeItemStoryCompiler.ts`
|
||||
|
||||
## 14. 一句话结论
|
||||
|
||||
这次要做的,不是把物品文案写得更华丽,也不是把 NPC 背景写得更长,而是:
|
||||
|
||||
**让世界里的每个人、每件物都像被主线和暗线真正碰过,名字里有来路,描述里有旧事,对话里有保留,低好感时也能让玩家感觉到背后压着一整段没被说出来的故事。**
|
||||
@@ -8,7 +8,7 @@ import http, {
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { Plugin } from 'vite';
|
||||
import { loadEnv, type Plugin } from 'vite';
|
||||
|
||||
const LLM_PROXY_PATH = '/api/llm/chat/completions';
|
||||
const ITEM_CATALOG_PATH = '/api/item-catalog';
|
||||
@@ -92,6 +92,17 @@ function normalizeDashScopeBaseUrl(value: string) {
|
||||
return value.replace(/\/$/u, '');
|
||||
}
|
||||
|
||||
function resolveRuntimeEnv(
|
||||
rootDir: string,
|
||||
mode: string,
|
||||
env: Record<string, string>,
|
||||
) {
|
||||
return {
|
||||
...env,
|
||||
...loadEnv(mode, rootDir, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function extractApiErrorMessage(responseText: string, fallbackMessage: string) {
|
||||
if (!responseText.trim()) {
|
||||
return fallbackMessage;
|
||||
@@ -327,16 +338,24 @@ function proxyStreamingRequest(
|
||||
});
|
||||
}
|
||||
|
||||
function createLlmProxyPlugin(env: Record<string, string>): Plugin {
|
||||
const upstreamBaseUrl = normalizeUpstreamBaseUrl(
|
||||
env.VITE_LLM_BASE_URL ||
|
||||
env.LLM_BASE_URL ||
|
||||
'https://ark.cn-beijing.volces.com/api/v3',
|
||||
);
|
||||
const apiKey =
|
||||
env.LLM_API_KEY || env.ARK_API_KEY || env.VITE_LLM_API_KEY || '';
|
||||
|
||||
function createLlmProxyPlugin(
|
||||
rootDir: string,
|
||||
mode: string,
|
||||
env: Record<string, string>,
|
||||
): Plugin {
|
||||
const handler = async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env);
|
||||
const upstreamBaseUrl = normalizeUpstreamBaseUrl(
|
||||
runtimeEnv.VITE_LLM_BASE_URL ||
|
||||
runtimeEnv.LLM_BASE_URL ||
|
||||
'https://ark.cn-beijing.volces.com/api/v3',
|
||||
);
|
||||
const apiKey =
|
||||
runtimeEnv.LLM_API_KEY ||
|
||||
runtimeEnv.ARK_API_KEY ||
|
||||
runtimeEnv.VITE_LLM_API_KEY ||
|
||||
'';
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
@@ -783,21 +802,23 @@ function createStateFunctionOverridesPlugin(rootDir: string): Plugin {
|
||||
|
||||
function createCustomWorldSceneImagePlugin(
|
||||
rootDir: string,
|
||||
mode: string,
|
||||
env: Record<string, string>,
|
||||
): Plugin {
|
||||
const baseUrl = normalizeDashScopeBaseUrl(
|
||||
env.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
|
||||
);
|
||||
const apiKey = env.DASHSCOPE_API_KEY || '';
|
||||
const defaultModel =
|
||||
env.DASHSCOPE_IMAGE_MODEL || DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL;
|
||||
const taskTimeoutMs = Number(
|
||||
env.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS ||
|
||||
env.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS ||
|
||||
DASHSCOPE_TASK_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const handler = async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env);
|
||||
const baseUrl = normalizeDashScopeBaseUrl(
|
||||
runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
|
||||
);
|
||||
const apiKey = runtimeEnv.DASHSCOPE_API_KEY || '';
|
||||
const defaultModel =
|
||||
runtimeEnv.DASHSCOPE_IMAGE_MODEL || DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL;
|
||||
const taskTimeoutMs = Number(
|
||||
runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS ||
|
||||
runtimeEnv.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS ||
|
||||
DASHSCOPE_TASK_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
@@ -1451,11 +1472,12 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
|
||||
|
||||
export function createLocalApiPlugins(
|
||||
rootDir: string,
|
||||
mode: string,
|
||||
env: Record<string, string>,
|
||||
): Plugin[] {
|
||||
return [
|
||||
createLlmProxyPlugin(env),
|
||||
createCustomWorldSceneImagePlugin(rootDir, env),
|
||||
createLlmProxyPlugin(rootDir, mode, env),
|
||||
createCustomWorldSceneImagePlugin(rootDir, mode, env),
|
||||
createItemCatalogPlugin(rootDir),
|
||||
createItemOverridesPlugin(rootDir),
|
||||
createNpcVisualOverridePlugin(rootDir),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
resolveRoleCombatStats,
|
||||
type RoleCombatStats,
|
||||
} from '../data/attributeCombat';
|
||||
import {
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
@@ -260,6 +264,10 @@ function formatAttributeMetricValue(value: number) {
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||
}
|
||||
|
||||
function formatAttributePercentValue(value: number) {
|
||||
return `${formatAttributeMetricValue(value * 100)}%`;
|
||||
}
|
||||
|
||||
function getAttributeBonusPillClassName(bonus: number) {
|
||||
if (bonus >= 0.05) {
|
||||
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
|
||||
@@ -270,6 +278,29 @@ function getAttributeBonusPillClassName(bonus: number) {
|
||||
return 'border-white/10 bg-black/20 text-zinc-500';
|
||||
}
|
||||
|
||||
function getAttributeEffectText(
|
||||
slotId: string,
|
||||
combatStats: RoleCombatStats,
|
||||
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>,
|
||||
) {
|
||||
switch (slotId) {
|
||||
case 'axis_a':
|
||||
return `攻击倍率 x${formatAttributeMetricValue(combatStats.attackPowerMultiplier)}`;
|
||||
case 'axis_b':
|
||||
return `${resourceLabels.maxHp} +${combatStats.maxHpBonus}`;
|
||||
case 'axis_c':
|
||||
return `${resourceLabels.hp}恢复 +${combatStats.storyRecovery}`;
|
||||
case 'axis_d':
|
||||
return `攻击速度 ${formatAttributeMetricValue(combatStats.turnSpeed)}`;
|
||||
case 'axis_e':
|
||||
return `暴击率 ${formatAttributePercentValue(combatStats.critChance)}`;
|
||||
case 'axis_f':
|
||||
return `暴击伤害 x${formatAttributeMetricValue(combatStats.critDamageMultiplier)}`;
|
||||
default:
|
||||
return '提升战斗表现';
|
||||
}
|
||||
}
|
||||
|
||||
function buildLeaderEquipmentRows(
|
||||
playerCharacter: Character,
|
||||
playerEquipment: EquipmentLoadout,
|
||||
@@ -461,19 +492,26 @@ export function CharacterPanel({
|
||||
? buildLeaderEquipmentRows(playerCharacter, playerEquipment)
|
||||
: buildCompanionEquipmentRows(selectedMember.character, selectedMember.id)
|
||||
: [];
|
||||
const selectedAttributeRows = useMemo(
|
||||
const selectedMemberAttributeProfile = useMemo(
|
||||
() =>
|
||||
selectedMember
|
||||
? resolveCharacterAttributeProfile(
|
||||
selectedMember.character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
)
|
||||
: null,
|
||||
[customWorldProfile, selectedMember, worldType],
|
||||
);
|
||||
const selectedAttributeRows = useMemo(
|
||||
() =>
|
||||
selectedMemberAttributeProfile
|
||||
? formatAttributeList(
|
||||
resolveCharacterAttributeProfile(
|
||||
selectedMember.character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
),
|
||||
selectedMemberAttributeProfile,
|
||||
selectedAttributeSchema,
|
||||
)
|
||||
: [],
|
||||
[customWorldProfile, selectedAttributeSchema, selectedMember, worldType],
|
||||
[selectedAttributeSchema, selectedMemberAttributeProfile],
|
||||
);
|
||||
const selectedAttributeBonusBySlot = useMemo(
|
||||
() =>
|
||||
@@ -493,20 +531,67 @@ export function CharacterPanel({
|
||||
) as Record<string, number>,
|
||||
[selectedAttributeSchema, selectedBuildBreakdown],
|
||||
);
|
||||
const selectedBoostedAttributeProfile = useMemo(() => {
|
||||
if (!selectedMemberAttributeProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...selectedMemberAttributeProfile,
|
||||
values: {
|
||||
...(selectedMemberAttributeProfile.values ?? {}),
|
||||
...Object.fromEntries(
|
||||
selectedAttributeSchema.slots.map((slot) => {
|
||||
const baseValue =
|
||||
selectedMemberAttributeProfile.values?.[slot.slotId] ?? 0;
|
||||
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
|
||||
|
||||
return [
|
||||
slot.slotId,
|
||||
Number((baseValue * (1 + totalBonus)).toFixed(4)),
|
||||
];
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
selectedAttributeBonusBySlot,
|
||||
selectedAttributeSchema,
|
||||
selectedMemberAttributeProfile,
|
||||
]);
|
||||
const selectedBoostedCombatStats = useMemo(
|
||||
() =>
|
||||
selectedMember
|
||||
? resolveRoleCombatStats(selectedBoostedAttributeProfile)
|
||||
: null,
|
||||
[selectedBoostedAttributeProfile, selectedMember],
|
||||
);
|
||||
const selectedDisplayAttributeRows = useMemo(
|
||||
() =>
|
||||
selectedAttributeRows.map(({ slot, value }) => {
|
||||
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
|
||||
const boostedValue = value * (1 + totalBonus);
|
||||
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
|
||||
|
||||
return {
|
||||
slot,
|
||||
baseValue: value,
|
||||
boostedValue,
|
||||
totalBonus,
|
||||
effectText: selectedBoostedCombatStats
|
||||
? getAttributeEffectText(
|
||||
slot.slotId,
|
||||
selectedBoostedCombatStats,
|
||||
resourceLabels,
|
||||
)
|
||||
: slot.combatUseText,
|
||||
};
|
||||
}),
|
||||
[selectedAttributeBonusBySlot, selectedAttributeRows],
|
||||
[
|
||||
resourceLabels,
|
||||
selectedAttributeBonusBySlot,
|
||||
selectedAttributeRows,
|
||||
selectedBoostedCombatStats,
|
||||
],
|
||||
);
|
||||
const selectedContributionAttributes = selectedContributionRow
|
||||
? getBuildContributionAttributeRows(
|
||||
@@ -718,7 +803,6 @@ export function CharacterPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
@@ -877,7 +961,13 @@ export function CharacterPanel({
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
{selectedDisplayAttributeRows.map(
|
||||
({ slot, baseValue, boostedValue, totalBonus }) => (
|
||||
({
|
||||
slot,
|
||||
baseValue,
|
||||
boostedValue,
|
||||
totalBonus,
|
||||
effectText,
|
||||
}) => (
|
||||
<div
|
||||
key={slot.slotId}
|
||||
className="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
|
||||
@@ -886,22 +976,25 @@ export function CharacterPanel({
|
||||
{slot.name}
|
||||
</div>
|
||||
<div className="mt-1 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{formatAttributeMetricValue(boostedValue)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
|
||||
>
|
||||
标签加成{' '}
|
||||
{formatBuildContributionPercent(totalBonus)}
|
||||
</span>
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
原始 {formatAttributeMetricValue(baseValue)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
|
||||
>
|
||||
{formatBuildContributionPercent(totalBonus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] leading-relaxed text-zinc-500">
|
||||
{slot.definition}
|
||||
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
|
||||
{effectText}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldSceneConnection,
|
||||
type ItemRarity,
|
||||
} from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
@@ -64,6 +65,14 @@ const [
|
||||
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||||
|
||||
const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [
|
||||
{ value: 'common', label: 'common' },
|
||||
{ value: 'uncommon', label: 'uncommon' },
|
||||
{ value: 'rare', label: 'rare' },
|
||||
{ value: 'epic', label: 'epic' },
|
||||
{ value: 'legendary', label: 'legendary' },
|
||||
];
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
@@ -101,6 +110,48 @@ function clampInitialAffinity(value: string, fallback: number) {
|
||||
return Math.max(-40, Math.min(90, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function createRoleSkillDraft(seedLabel: string, index: number) {
|
||||
return {
|
||||
id: createEntryId('skill', seedLabel, Date.now() + index),
|
||||
name: `新技能${index + 1}`,
|
||||
summary: '',
|
||||
style: '起手压制',
|
||||
};
|
||||
}
|
||||
|
||||
function createRoleInitialItemDraft(seedLabel: string, index: number) {
|
||||
return {
|
||||
id: createEntryId('item', seedLabel, Date.now() + index),
|
||||
name: `新物品${index + 1}`,
|
||||
category: '材料',
|
||||
quantity: 1,
|
||||
rarity: 'rare' as ItemRarity,
|
||||
description: '',
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createBackstoryChapterDraft(seedLabel: string, index: number) {
|
||||
return {
|
||||
id: createEntryId('backstory-chapter', seedLabel, Date.now() + index),
|
||||
title: `背景片段${index + 1}`,
|
||||
affinityRequired:
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS[
|
||||
Math.min(index, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.length - 1)
|
||||
] ?? BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||||
teaser: '',
|
||||
content: '',
|
||||
contextSnippet: '',
|
||||
};
|
||||
}
|
||||
|
||||
function syncLandmarksWithStoryNpcs(
|
||||
landmarks: CustomWorldLandmark[],
|
||||
storyNpcs: CustomWorldProfile['storyNpcs'],
|
||||
@@ -700,7 +751,8 @@ function SaveBar({
|
||||
onSave: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
|
||||
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -716,16 +768,423 @@ function SaveBar({
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">保存修改</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">保存修改</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionPanel({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-400">
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackstoryRevealEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: CustomWorldPlayableNpc['backstoryReveal'];
|
||||
onChange: (value: CustomWorldPlayableNpc['backstoryReveal']) => void;
|
||||
}) {
|
||||
const updateChapter = (
|
||||
index: number,
|
||||
updater: (
|
||||
chapter: CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
|
||||
) => CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
|
||||
) => {
|
||||
onChange({
|
||||
...value,
|
||||
chapters: value.chapters.map((chapter, chapterIndex) =>
|
||||
chapterIndex === index ? updater(chapter) : chapter,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const addChapter = () => {
|
||||
onChange({
|
||||
...value,
|
||||
chapters: [
|
||||
...value.chapters,
|
||||
createBackstoryChapterDraft('custom-role', value.chapters.length),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeChapter = (index: number) => {
|
||||
if (value.chapters.length <= 1) {
|
||||
window.alert('至少保留一个背景章节。');
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({
|
||||
...value,
|
||||
chapters: value.chapters.filter(
|
||||
(_chapter, chapterIndex) => chapterIndex !== index,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionPanel
|
||||
title="背景公开与章节"
|
||||
subtitle="这里直接决定结果页、关系推进和后续剧情提示词看到的背景摘要与章节线索。"
|
||||
actions={
|
||||
<ActionButton label="新增章节" onClick={addChapter} tone="sky" />
|
||||
}
|
||||
>
|
||||
<Field label="公开背景摘要">
|
||||
<TextArea
|
||||
value={value.publicSummary}
|
||||
onChange={(nextValue) =>
|
||||
onChange({
|
||||
...value,
|
||||
publicSummary: nextValue,
|
||||
})
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
{value.chapters.map((chapter, index) => (
|
||||
<div
|
||||
key={`${chapter.id}-${index}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
背景章节 #{index + 1}
|
||||
</div>
|
||||
<ActionButton
|
||||
label="删除章节"
|
||||
onClick={() => removeChapter(index)}
|
||||
/>
|
||||
</div>
|
||||
<Field label="章节标题">
|
||||
<TextInput
|
||||
value={chapter.title}
|
||||
onChange={(nextValue) =>
|
||||
updateChapter(index, (current) => ({
|
||||
...current,
|
||||
title: nextValue,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="解锁好感">
|
||||
<TextInput
|
||||
type="number"
|
||||
value={chapter.affinityRequired}
|
||||
onChange={(nextValue) =>
|
||||
updateChapter(index, (current) => ({
|
||||
...current,
|
||||
affinityRequired: clampInitialAffinity(
|
||||
nextValue,
|
||||
current.affinityRequired,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="章节提示">
|
||||
<TextArea
|
||||
value={chapter.teaser}
|
||||
onChange={(nextValue) =>
|
||||
updateChapter(index, (current) => ({
|
||||
...current,
|
||||
teaser: nextValue,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="章节内容">
|
||||
<TextArea
|
||||
value={chapter.content}
|
||||
onChange={(nextValue) =>
|
||||
updateChapter(index, (current) => ({
|
||||
...current,
|
||||
content: nextValue,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="剧情引用摘要">
|
||||
<TextArea
|
||||
value={chapter.contextSnippet}
|
||||
onChange={(nextValue) =>
|
||||
updateChapter(index, (current) => ({
|
||||
...current,
|
||||
contextSnippet: nextValue,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillListEditor({
|
||||
value,
|
||||
onChange,
|
||||
labelSeed,
|
||||
}: {
|
||||
value: CustomWorldPlayableNpc['skills'];
|
||||
onChange: (value: CustomWorldPlayableNpc['skills']) => void;
|
||||
labelSeed: string;
|
||||
}) {
|
||||
const updateSkill = (
|
||||
index: number,
|
||||
updater: (
|
||||
skill: CustomWorldPlayableNpc['skills'][number],
|
||||
) => CustomWorldPlayableNpc['skills'][number],
|
||||
) => {
|
||||
onChange(
|
||||
value.map((skill, skillIndex) =>
|
||||
skillIndex === index ? updater(skill) : skill,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionPanel
|
||||
title="技能"
|
||||
subtitle="技能名、摘要和风格都会进入结果页与运行时 NPC 档案。"
|
||||
actions={
|
||||
<ActionButton
|
||||
label="新增技能"
|
||||
onClick={() =>
|
||||
onChange([...value, createRoleSkillDraft(labelSeed, value.length)])
|
||||
}
|
||||
tone="sky"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{value.map((skill, index) => (
|
||||
<div
|
||||
key={`${skill.id}-${index}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
技能 #{index + 1}
|
||||
</div>
|
||||
<ActionButton
|
||||
label="删除技能"
|
||||
onClick={() =>
|
||||
onChange(value.filter((_skill, skillIndex) => skillIndex !== index))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Field label="技能名称">
|
||||
<TextInput
|
||||
value={skill.name}
|
||||
onChange={(nextValue) =>
|
||||
updateSkill(index, (current) => ({
|
||||
...current,
|
||||
name: nextValue,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="技能风格">
|
||||
<TextInput
|
||||
value={skill.style}
|
||||
onChange={(nextValue) =>
|
||||
updateSkill(index, (current) => ({
|
||||
...current,
|
||||
style: nextValue,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="技能摘要">
|
||||
<TextArea
|
||||
value={skill.summary}
|
||||
onChange={(nextValue) =>
|
||||
updateSkill(index, (current) => ({
|
||||
...current,
|
||||
summary: nextValue,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function InitialItemsEditor({
|
||||
value,
|
||||
onChange,
|
||||
labelSeed,
|
||||
}: {
|
||||
value: CustomWorldPlayableNpc['initialItems'];
|
||||
onChange: (value: CustomWorldPlayableNpc['initialItems']) => void;
|
||||
labelSeed: string;
|
||||
}) {
|
||||
const updateItem = (
|
||||
index: number,
|
||||
updater: (
|
||||
item: CustomWorldPlayableNpc['initialItems'][number],
|
||||
) => CustomWorldPlayableNpc['initialItems'][number],
|
||||
) => {
|
||||
onChange(
|
||||
value.map((item, itemIndex) =>
|
||||
itemIndex === index ? updater(item) : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionPanel
|
||||
title="初始物品"
|
||||
subtitle="这里的内容会影响结果页展示,也会作为后续运行时参考档案。"
|
||||
actions={
|
||||
<ActionButton
|
||||
label="新增物品"
|
||||
onClick={() =>
|
||||
onChange([
|
||||
...value,
|
||||
createRoleInitialItemDraft(labelSeed, value.length),
|
||||
])
|
||||
}
|
||||
tone="sky"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{value.map((item, index) => (
|
||||
<div
|
||||
key={`${item.id}-${index}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
物品 #{index + 1}
|
||||
</div>
|
||||
<ActionButton
|
||||
label="删除物品"
|
||||
onClick={() =>
|
||||
onChange(value.filter((_item, itemIndex) => itemIndex !== index))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Field label="名称">
|
||||
<TextInput
|
||||
value={item.name}
|
||||
onChange={(nextValue) =>
|
||||
updateItem(index, (current) => ({
|
||||
...current,
|
||||
name: nextValue,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="分类">
|
||||
<TextInput
|
||||
value={item.category}
|
||||
onChange={(nextValue) =>
|
||||
updateItem(index, (current) => ({
|
||||
...current,
|
||||
category: nextValue,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="稀有度">
|
||||
<SelectField
|
||||
value={item.rarity}
|
||||
onChange={(nextValue) =>
|
||||
updateItem(index, (current) => ({
|
||||
...current,
|
||||
rarity: nextValue as ItemRarity,
|
||||
}))
|
||||
}
|
||||
options={ITEM_RARITY_OPTIONS}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="数量">
|
||||
<TextInput
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(nextValue) =>
|
||||
updateItem(index, (current) => ({
|
||||
...current,
|
||||
quantity: Math.max(
|
||||
1,
|
||||
parseOptionalNumber(nextValue) ?? current.quantity,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="描述">
|
||||
<TextArea
|
||||
value={item.description}
|
||||
onChange={(nextValue) =>
|
||||
updateItem(index, (current) => ({
|
||||
...current,
|
||||
description: nextValue,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标签">
|
||||
<TextArea
|
||||
value={commaText(item.tags)}
|
||||
onChange={(nextValue) =>
|
||||
updateItem(index, (current) => ({
|
||||
...current,
|
||||
tags: parseCommaText(nextValue),
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function StoryNpcVisualEditorModal({
|
||||
npc,
|
||||
visual,
|
||||
@@ -873,7 +1332,7 @@ function PlayableNpcEditor({
|
||||
return (
|
||||
<ModalShell
|
||||
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
|
||||
subtitle="可为角色指定外观模板,结果页和正式选角都会同步使用。"
|
||||
subtitle="这里可以直接修改可扮演角色的完整档案字段,结果页和正式选角都会同步使用。"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -1026,6 +1485,35 @@ function PlayableNpcEditor({
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<BackstoryRevealEditor
|
||||
value={draft.backstoryReveal}
|
||||
onChange={(backstoryReveal) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
backstoryReveal,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<SkillListEditor
|
||||
value={draft.skills}
|
||||
onChange={(skills) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
skills,
|
||||
}))
|
||||
}
|
||||
labelSeed={draft.name || draft.id}
|
||||
/>
|
||||
<InitialItemsEditor
|
||||
value={draft.initialItems}
|
||||
onChange={(initialItems) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
initialItems,
|
||||
}))
|
||||
}
|
||||
labelSeed={draft.name || draft.id}
|
||||
/>
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
@@ -1059,7 +1547,7 @@ function StoryNpcEditor({
|
||||
return (
|
||||
<ModalShell
|
||||
title={mode === 'create' ? '新增场景角色' : `编辑场景角色:${npc.name}`}
|
||||
subtitle="场景角色形象编辑已拆分到独立面板,当前页面只保留档案信息与预览。"
|
||||
subtitle="这里可以直接修改场景角色的完整档案字段,形象编辑仍保留在独立面板。"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -1200,6 +1688,35 @@ function StoryNpcEditor({
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<BackstoryRevealEditor
|
||||
value={draft.backstoryReveal}
|
||||
onChange={(backstoryReveal) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
backstoryReveal,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<SkillListEditor
|
||||
value={draft.skills}
|
||||
onChange={(skills) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
skills,
|
||||
}))
|
||||
}
|
||||
labelSeed={draft.name || draft.id}
|
||||
/>
|
||||
<InitialItemsEditor
|
||||
value={draft.initialItems}
|
||||
onChange={(initialItems) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
initialItems,
|
||||
}))
|
||||
}
|
||||
labelSeed={draft.name || draft.id}
|
||||
/>
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
|
||||
376
src/components/CustomWorldGenerationView.tsx
Normal file
376
src/components/CustomWorldGenerationView.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
} from '../services/ai';
|
||||
import { AnimationState, type Character } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
|
||||
interface CustomWorldGenerationViewProps {
|
||||
settingText: string;
|
||||
actionPreviewCharacters: Character[];
|
||||
progress: CustomWorldGenerationProgress | null;
|
||||
isGenerating: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onEditSetting: () => void;
|
||||
onRetry: () => void;
|
||||
onInterrupt: () => void;
|
||||
}
|
||||
|
||||
const ACTION_SHOWCASE: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
state: AnimationState;
|
||||
}> = [
|
||||
{
|
||||
label: '冲阵测试',
|
||||
description: '检查角色前探、推进与开场压迫感。',
|
||||
state: AnimationState.RUN,
|
||||
},
|
||||
{
|
||||
label: '交战演示',
|
||||
description: '预热战斗站姿与交锋节奏。',
|
||||
state: AnimationState.ATTACK,
|
||||
},
|
||||
{
|
||||
label: '驻场待命',
|
||||
description: '确认角色在剧情停驻时的氛围姿态。',
|
||||
state: AnimationState.IDLE,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const safeMs = Math.max(0, Math.round(ms));
|
||||
const totalSeconds = Math.ceil(safeMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes <= 0) {
|
||||
return `${Math.max(1, seconds)} 秒`;
|
||||
}
|
||||
|
||||
if (seconds === 0) {
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
return `${minutes} 分 ${seconds} 秒`;
|
||||
}
|
||||
|
||||
function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
|
||||
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
|
||||
}
|
||||
|
||||
export function CustomWorldGenerationView({
|
||||
settingText,
|
||||
actionPreviewCharacters,
|
||||
progress,
|
||||
isGenerating,
|
||||
error,
|
||||
onBack,
|
||||
onEditSetting,
|
||||
onRetry,
|
||||
onInterrupt,
|
||||
}: CustomWorldGenerationViewProps) {
|
||||
const progressValue = getProgressPercentage(progress);
|
||||
const steps = progress?.steps ?? [];
|
||||
const estimatedWaitText =
|
||||
progress?.estimatedRemainingMs != null
|
||||
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
|
||||
: '正在校准预计等待时间';
|
||||
const elapsedText =
|
||||
progress != null ? `已耗时 ${formatDuration(progress.elapsedMs)}` : '正在启动世界生成';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.86),rgba(10,12,18,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isGenerating}
|
||||
className={`rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="rounded-full border border-sky-300/16 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
{isGenerating ? '世界建设中' : error ? '生成已暂停' : '等待操作'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-none gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.1fr)_minmax(22rem,0.9fr)]">
|
||||
<div className="flex flex-col gap-4 xl:min-h-0">
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
|
||||
玩家设定
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
这段文本会直接驱动本轮世界框架、角色与场景生成。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
disabled={isGenerating}
|
||||
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
修改设定
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
|
||||
{settingText}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
生成进度
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
|
||||
{progress?.phaseLabel ?? '正在启动世界生成'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
|
||||
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 sm:text-right">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
总进度
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
|
||||
{progressValue}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
<motion.div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
|
||||
animate={{ width: `${progressValue}%` }}
|
||||
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
当前批次
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{progress?.batchLabel ?? '准备中'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
预计等待
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{estimatedWaitText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
计时
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{elapsedText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
: step.status === 'active'
|
||||
? 'border-sky-300/22 bg-sky-500/10'
|
||||
: 'border-white/8 bg-black/18'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{step.label}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-300">
|
||||
{step.completed}/{step.total}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{step.detail}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
返回修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
重新开始生成
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
|
||||
>
|
||||
中断世界生成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 xl:min-h-0">
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel relative overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute -left-8 top-0 h-36 w-36 rounded-full bg-sky-400/18 blur-3xl"
|
||||
animate={{
|
||||
opacity: [0.22, 0.48, 0.22],
|
||||
scale: [0.92, 1.08, 0.92],
|
||||
}}
|
||||
transition={{ duration: 6.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute bottom-0 right-0 h-32 w-32 rounded-full bg-amber-200/12 blur-3xl"
|
||||
animate={{ opacity: [0.18, 0.4, 0.18], scale: [1, 1.12, 1] }}
|
||||
transition={{ duration: 7.2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
|
||||
世界建造氛围
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-black leading-tight text-white sm:text-2xl">
|
||||
世界正在搭建地标、势力与角色关系
|
||||
</div>
|
||||
<div className="mt-3 max-w-[26rem] text-sm leading-6 text-zinc-300">
|
||||
生成页不再只是一根等待条。这里会持续展示本轮设定的建造状态,让等待过程也像在看一场世界开局演出。
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
|
||||
世界气候
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
|
||||
势力碰撞
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200 col-span-2 sm:col-span-1">
|
||||
场景拓扑
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel xl:min-h-0 xl:flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
可扮演角色动作素材
|
||||
</div>
|
||||
<div className="mt-1 text-sm leading-6 text-zinc-300">
|
||||
先加载一组动作素材,让世界创建阶段也保持角色演出感。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-3">
|
||||
{ACTION_SHOWCASE.map((showcase, index) => {
|
||||
const character =
|
||||
actionPreviewCharacters[
|
||||
index % Math.max(1, actionPreviewCharacters.length)
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={showcase.label}
|
||||
className="rounded-[1.5rem] border border-white/8 bg-black/22 px-4 py-4"
|
||||
>
|
||||
<div className="flex h-28 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(125,211,252,0.18),rgba(10,12,18,0.1)_38%,rgba(10,12,18,0.76)_100%)] sm:h-32">
|
||||
{character ? (
|
||||
<CharacterAnimator
|
||||
state={showcase.state}
|
||||
character={character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold text-white">
|
||||
{showcase.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{showcase.description}
|
||||
</div>
|
||||
{character ? (
|
||||
<div className="mt-3 rounded-full border border-sky-300/14 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
|
||||
{character.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
PRESET_CHARACTERS,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
readSavedCustomWorldProfiles,
|
||||
upsertSavedCustomWorldProfile,
|
||||
} from '../../data/customWorldLibrary';
|
||||
import { getScenePreset } from '../../data/scenePresets';
|
||||
import { generateCustomWorldProfile } from '../../services/ai';
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfile,
|
||||
} from '../../services/ai';
|
||||
import {
|
||||
type CustomWorldProfile,
|
||||
type GameState,
|
||||
@@ -19,12 +25,17 @@ import {
|
||||
UI_CHROME,
|
||||
WORLD_SELECT_ICONS,
|
||||
} from '../../uiAssets';
|
||||
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
|
||||
import { CustomWorldResultView } from '../CustomWorldResultView';
|
||||
import { DeveloperTeamModal } from '../DeveloperTeamModal';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { CustomWorldCreatorModal } from '../SelectionCustomizationModals';
|
||||
|
||||
export type SelectionStage = 'start' | 'world' | 'custom-world-result';
|
||||
export type SelectionStage =
|
||||
| 'start'
|
||||
| 'world'
|
||||
| 'custom-world-generating'
|
||||
| 'custom-world-result';
|
||||
|
||||
type WorldOnlineCounts = Partial<Record<WorldType, number>>;
|
||||
|
||||
@@ -66,6 +77,8 @@ const WORLD_OPTIONS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const GENERATION_PREVIEW_CHARACTERS = PRESET_CHARACTERS.slice(0, 3);
|
||||
|
||||
function generateWorldOnlineCounts(): WorldOnlineCounts {
|
||||
const roll = (base: number) =>
|
||||
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
|
||||
@@ -75,22 +88,6 @@ function generateWorldOnlineCounts(): WorldOnlineCounts {
|
||||
};
|
||||
}
|
||||
|
||||
function getCustomWorldGenerationLabel(progress: number) {
|
||||
if (progress >= 96) return '正在完成世界归档...';
|
||||
if (progress >= 78) return '正在关联地标和关键物品...';
|
||||
if (progress >= 52) return '正在生成核心角色...';
|
||||
if (progress >= 28) return '正在生成可玩角色...';
|
||||
return '正在解析世界设置...';
|
||||
}
|
||||
|
||||
function getCustomWorldProgressLabel(progress: number) {
|
||||
if (progress >= 96) return '正在完成世界归档...';
|
||||
if (progress >= 78) return '正在组合场景和视觉效果...';
|
||||
if (progress >= 52) return '正在生成核心角色...';
|
||||
if (progress >= 28) return '正在生成可玩角色...';
|
||||
return '正在解析世界设置...';
|
||||
}
|
||||
|
||||
export function PreGameSelectionFlow({
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
@@ -113,7 +110,9 @@ export function PreGameSelectionFlow({
|
||||
const [customWorldDraft, setCustomWorldDraft] = useState('');
|
||||
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
|
||||
const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false);
|
||||
const [customWorldProgress, setCustomWorldProgress] = useState(0);
|
||||
const [customWorldProgress, setCustomWorldProgress] =
|
||||
useState<CustomWorldGenerationProgress | null>(null);
|
||||
const customWorldAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const previewCustomWorldCharacters = useMemo(
|
||||
() =>
|
||||
@@ -186,13 +185,51 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
}, [generatedCustomWorldProfile, selectionStage, setSelectionStage]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
customWorldAbortControllerRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const leaveCustomWorldResult = () => {
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(0);
|
||||
setCustomWorldProgress(null);
|
||||
setSelectionStage('world');
|
||||
};
|
||||
|
||||
const leaveCustomWorldGeneration = () => {
|
||||
if (isGeneratingCustomWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setSelectionStage('world');
|
||||
};
|
||||
|
||||
const openCustomWorldCreator = () => {
|
||||
if (isGeneratingCustomWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setShowCustomWorldModal(true);
|
||||
};
|
||||
|
||||
const editCustomWorldSetting = () => {
|
||||
if (isGeneratingCustomWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setSelectionStage('world');
|
||||
setShowCustomWorldModal(true);
|
||||
};
|
||||
|
||||
const saveGeneratedCustomWorld = () => {
|
||||
if (!generatedCustomWorldProfile) {
|
||||
return;
|
||||
@@ -212,51 +249,73 @@ export function PreGameSelectionFlow({
|
||||
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(0);
|
||||
setCustomWorldProgress(null);
|
||||
setSelectionStage('world');
|
||||
};
|
||||
|
||||
const createCustomWorld = async () => {
|
||||
if (isGeneratingCustomWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingText = customWorldDraft.trim();
|
||||
if (!settingText) {
|
||||
setCustomWorldError('请先输入世界设置。');
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
customWorldAbortControllerRef.current?.abort();
|
||||
customWorldAbortControllerRef.current = abortController;
|
||||
setCustomWorldError(null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldProgress(null);
|
||||
setShowCustomWorldModal(false);
|
||||
setSelectionStage('custom-world-generating');
|
||||
setIsGeneratingCustomWorld(true);
|
||||
setCustomWorldProgress(8);
|
||||
|
||||
const progressTimer = window.setInterval(() => {
|
||||
setCustomWorldProgress((current) => {
|
||||
if (current >= 92) return current;
|
||||
return Math.min(
|
||||
92,
|
||||
current + Math.max(3, Math.round((96 - current) / 5)),
|
||||
);
|
||||
});
|
||||
}, 260);
|
||||
|
||||
try {
|
||||
const profile = await generateCustomWorldProfile(settingText);
|
||||
window.clearInterval(progressTimer);
|
||||
setCustomWorldProgress(100);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 180));
|
||||
const profile = await generateCustomWorldProfile(settingText, {
|
||||
signal: abortController.signal,
|
||||
onProgress: setCustomWorldProgress,
|
||||
});
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setGeneratedCustomWorldProfile(profile);
|
||||
setShowCustomWorldModal(false);
|
||||
setCustomWorldError(null);
|
||||
setSelectionStage('custom-world-result');
|
||||
} catch (error) {
|
||||
window.clearInterval(progressTimer);
|
||||
setCustomWorldProgress(0);
|
||||
if (abortController.signal.aborted) {
|
||||
setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。');
|
||||
return;
|
||||
}
|
||||
setCustomWorldError(
|
||||
error instanceof Error ? error.message : '生成自定义世界失败。',
|
||||
);
|
||||
} finally {
|
||||
if (customWorldAbortControllerRef.current === abortController) {
|
||||
customWorldAbortControllerRef.current = null;
|
||||
}
|
||||
setIsGeneratingCustomWorld(false);
|
||||
}
|
||||
};
|
||||
|
||||
const interruptCustomWorldGeneration = () => {
|
||||
if (!isGeneratingCustomWorld || !customWorldAbortControllerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'确认中断当前世界生成吗?本轮未完成的内容不会保留。',
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
customWorldAbortControllerRef.current.abort(new Error('世界生成已中断。'));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -296,7 +355,7 @@ export function PreGameSelectionFlow({
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldDraft('');
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(0);
|
||||
setCustomWorldProgress(null);
|
||||
setShowCustomWorldModal(false);
|
||||
setSelectionStage('world');
|
||||
}}
|
||||
@@ -495,12 +554,7 @@ export function PreGameSelectionFlow({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(0);
|
||||
setShowCustomWorldModal(true);
|
||||
}}
|
||||
onClick={openCustomWorldCreator}
|
||||
className="pixel-nine-slice pixel-pressable order-first relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 18,
|
||||
@@ -533,6 +587,31 @@ export function PreGameSelectionFlow({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!gameState.worldType &&
|
||||
selectionStage === 'custom-world-generating' && (
|
||||
<motion.div
|
||||
key="custom-world-generating"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={customWorldDraft.trim()}
|
||||
actionPreviewCharacters={GENERATION_PREVIEW_CHARACTERS}
|
||||
progress={customWorldProgress}
|
||||
isGenerating={isGeneratingCustomWorld}
|
||||
error={customWorldError}
|
||||
onBack={leaveCustomWorldGeneration}
|
||||
onEditSetting={editCustomWorldSetting}
|
||||
onRetry={() => {
|
||||
void createCustomWorld();
|
||||
}}
|
||||
onInterrupt={interruptCustomWorldGeneration}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!gameState.worldType &&
|
||||
selectionStage === 'custom-world-result' &&
|
||||
generatedCustomWorldProfile && (
|
||||
@@ -547,16 +626,12 @@ export function PreGameSelectionFlow({
|
||||
profile={generatedCustomWorldProfile}
|
||||
previewCharacters={previewCustomWorldCharacters}
|
||||
isGenerating={isGeneratingCustomWorld}
|
||||
progress={customWorldProgress}
|
||||
progressLabel={getCustomWorldProgressLabel(customWorldProgress)}
|
||||
progress={customWorldProgress?.overallProgress ?? 0}
|
||||
progressLabel={customWorldProgress?.phaseLabel ?? ''}
|
||||
error={customWorldError}
|
||||
onProfileChange={setGeneratedCustomWorldProfile}
|
||||
onBack={leaveCustomWorldResult}
|
||||
onEditSetting={() => {
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(0);
|
||||
setShowCustomWorldModal(true);
|
||||
}}
|
||||
onEditSetting={editCustomWorldSetting}
|
||||
onRegenerate={() => {
|
||||
void createCustomWorld();
|
||||
}}
|
||||
@@ -581,8 +656,8 @@ export function PreGameSelectionFlow({
|
||||
void createCustomWorld();
|
||||
}}
|
||||
isGenerating={isGeneratingCustomWorld}
|
||||
progress={customWorldProgress}
|
||||
progressLabel={getCustomWorldGenerationLabel(customWorldProgress)}
|
||||
progress={customWorldProgress?.overallProgress ?? 0}
|
||||
progressLabel={customWorldProgress?.phaseLabel ?? '正在准备生成'}
|
||||
error={customWorldError}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1882,7 +1882,7 @@ export function buildCharacterBackstoryPromptContext(
|
||||
...getUnlockedCharacterBackstoryChapters(character, affinity, worldType)
|
||||
.map(chapter => chapter.contextSnippet.trim())
|
||||
.filter(Boolean),
|
||||
].filter(Boolean);
|
||||
].filter((snippet): snippet is string => Boolean(snippet));
|
||||
}
|
||||
|
||||
export function getCharacterHomeSceneId(worldType: WorldType, characterId: string) {
|
||||
|
||||
196
src/data/scenePresets.test.ts
Normal file
196
src/data/scenePresets.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
|
||||
import { WorldType } from '../types';
|
||||
import { setRuntimeCustomWorldProfile } from './customWorldRuntime';
|
||||
import { buildEncounterFromSceneNpc, getScenePresetsByWorld } from './scenePresets';
|
||||
|
||||
function createPlayableNpc(index: number) {
|
||||
return {
|
||||
name: `可扮演角色${index + 1}`,
|
||||
title: `可扮演头衔${index + 1}`,
|
||||
role: `可扮演身份${index + 1}`,
|
||||
description: `可扮演角色描述${index + 1}`,
|
||||
backstory: `可扮演角色背景${index + 1}`,
|
||||
personality: `可扮演角色性格${index + 1}`,
|
||||
motivation: `可扮演角色动机${index + 1}`,
|
||||
combatStyle: `可扮演角色战斗风格${index + 1}`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: [`切入口${index + 1}`],
|
||||
tags: [`标签${index + 1}`],
|
||||
backstoryReveal: {
|
||||
publicSummary: `公开背景${index + 1}`,
|
||||
chapters: [
|
||||
{
|
||||
id: `surface-${index + 1}`,
|
||||
title: '表层来意',
|
||||
affinityRequired: 10,
|
||||
teaser: `提示${index + 1}-1`,
|
||||
content: `内容${index + 1}-1`,
|
||||
contextSnippet: `摘要${index + 1}-1`,
|
||||
},
|
||||
{
|
||||
id: `scar-${index + 1}`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 30,
|
||||
teaser: `提示${index + 1}-2`,
|
||||
content: `内容${index + 1}-2`,
|
||||
contextSnippet: `摘要${index + 1}-2`,
|
||||
},
|
||||
{
|
||||
id: `hidden-${index + 1}`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 55,
|
||||
teaser: `提示${index + 1}-3`,
|
||||
content: `内容${index + 1}-3`,
|
||||
contextSnippet: `摘要${index + 1}-3`,
|
||||
},
|
||||
{
|
||||
id: `final-${index + 1}`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: 80,
|
||||
teaser: `提示${index + 1}-4`,
|
||||
content: `内容${index + 1}-4`,
|
||||
contextSnippet: `摘要${index + 1}-4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{ name: `技能${index + 1}-1`, summary: '技能摘要1', style: '起手压制' },
|
||||
{ name: `技能${index + 1}-2`, summary: '技能摘要2', style: '机动周旋' },
|
||||
{ name: `技能${index + 1}-3`, summary: '技能摘要3', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
name: `物品${index + 1}-1`,
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品描述1',
|
||||
tags: ['物品标签1'],
|
||||
},
|
||||
{
|
||||
name: `物品${index + 1}-2`,
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '物品描述2',
|
||||
tags: ['物品标签2'],
|
||||
},
|
||||
{
|
||||
name: `物品${index + 1}-3`,
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '物品描述3',
|
||||
tags: ['物品标签3'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('scenePresets custom world npc mapping', () => {
|
||||
afterEach(() => {
|
||||
setRuntimeCustomWorldProfile(null);
|
||||
});
|
||||
|
||||
it('preserves custom world npc dossier fields into scene npcs and encounters', () => {
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
name: '雾潮世界',
|
||||
subtitle: '潮雾未散',
|
||||
summary: '一座围绕码头、断桥和旧潮路展开的自定义世界。',
|
||||
tone: '克制、潮湿、危险',
|
||||
playerGoal: '查清雾潮里失踪的人和桥下的旧案。',
|
||||
templateWorldType: 'WUXIA',
|
||||
playableNpcs: Array.from({ length: 5 }, (_, index) =>
|
||||
createPlayableNpc(index),
|
||||
),
|
||||
storyNpcs: [
|
||||
{
|
||||
...createPlayableNpc(10),
|
||||
name: '沈雾',
|
||||
title: '潮路领航人',
|
||||
role: '码头向导',
|
||||
description: '熟悉潮路和暗栈的旧向导。',
|
||||
backstory: '少年时曾在断桥坠潮夜里失去整队同伴。',
|
||||
personality: '谨慎冷静,先观察再表态。',
|
||||
motivation: '想把雾潮深处那条失踪航线重新找出来。',
|
||||
combatStyle: '短刀试探后再借地形逼近。',
|
||||
relationshipHooks: ['潮路', '断桥旧案'],
|
||||
tags: ['码头', '旧潮路'],
|
||||
imageSrc: '/custom/npcs/shenwu.png',
|
||||
},
|
||||
{
|
||||
...createPlayableNpc(11),
|
||||
name: '陆沉',
|
||||
title: '断桥守更',
|
||||
role: '守桥人',
|
||||
description: '夜里守着断桥口的旧灯火。',
|
||||
},
|
||||
{
|
||||
...createPlayableNpc(12),
|
||||
name: '顾潮',
|
||||
title: '潮册记录员',
|
||||
role: '记录员',
|
||||
description: '在潮账房里整理各路失踪名单。',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
name: '雾潮码头',
|
||||
description: '旧船桩和潮雾把视线切成断续的几段。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: '断桥旧道',
|
||||
relativePosition: 'north',
|
||||
summary: '顺着潮路向北可抵断桥。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '断桥旧道',
|
||||
description: '半塌的桥面上还挂着旧索和残旗。',
|
||||
dangerLevel: 'high',
|
||||
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: '雾潮码头',
|
||||
relativePosition: 'south',
|
||||
summary: '沿旧潮路南返能回码头。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'玩家想要一个围绕雾潮码头与断桥旧案展开的世界。',
|
||||
);
|
||||
|
||||
setRuntimeCustomWorldProfile(profile);
|
||||
|
||||
const scene = getScenePresetsByWorld(WorldType.CUSTOM).find(
|
||||
(entry) => entry.name === '雾潮码头',
|
||||
);
|
||||
const npc = scene?.npcs?.find((entry) => entry.name === '沈雾');
|
||||
|
||||
expect(scene).toBeTruthy();
|
||||
expect(npc).toBeTruthy();
|
||||
expect(npc?.title).toBe('潮路领航人');
|
||||
expect(npc?.backstory).toContain('断桥坠潮夜');
|
||||
expect(npc?.personality).toContain('谨慎冷静');
|
||||
expect(npc?.motivation).toContain('失踪航线');
|
||||
expect(npc?.skills).toHaveLength(3);
|
||||
expect(npc?.initialItems).toHaveLength(3);
|
||||
expect(npc?.avatar).toBe('/custom/npcs/shenwu.png');
|
||||
|
||||
const encounter = buildEncounterFromSceneNpc(npc!);
|
||||
|
||||
expect(encounter.title).toBe('潮路领航人');
|
||||
expect(encounter.backstoryReveal?.publicSummary).toBe('公开背景11');
|
||||
expect(encounter.skills?.[0]?.name).toBe('技能11-1');
|
||||
expect(encounter.initialItems?.[0]?.name).toBe('物品11-1');
|
||||
expect(encounter.imageSrc).toBe('/custom/npcs/shenwu.png');
|
||||
});
|
||||
});
|
||||
@@ -273,6 +273,18 @@ export function buildEncounterFromSceneNpc(
|
||||
initialAffinity: npc.initialAffinity,
|
||||
hostile: isHostileSceneNpc(npc),
|
||||
attributeProfile: npc.attributeProfile,
|
||||
title: npc.title,
|
||||
backstory: npc.backstory,
|
||||
personality: npc.personality,
|
||||
motivation: npc.motivation,
|
||||
combatStyle: npc.combatStyle,
|
||||
relationshipHooks: npc.relationshipHooks,
|
||||
tags: npc.tags,
|
||||
backstoryReveal: npc.backstoryReveal,
|
||||
skills: npc.skills,
|
||||
initialItems: npc.initialItems,
|
||||
imageSrc: npc.imageSrc,
|
||||
visual: npc.visual,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,8 +319,9 @@ function buildCustomSceneNpc(
|
||||
return {
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
title: npc.title,
|
||||
role: npc.role,
|
||||
avatar: npc.name.slice(0, 1) || '?',
|
||||
avatar: (npc.imageSrc ?? npc.name.slice(0, 1)) || '?',
|
||||
description: [
|
||||
npc.description,
|
||||
npc.backstoryReveal.publicSummary
|
||||
@@ -336,6 +349,20 @@ function buildCustomSceneNpc(
|
||||
? ['fight']
|
||||
: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
|
||||
attributeProfile,
|
||||
backstory: npc.backstory,
|
||||
personality: npc.personality,
|
||||
motivation: npc.motivation,
|
||||
combatStyle: npc.combatStyle,
|
||||
relationshipHooks: [...npc.relationshipHooks],
|
||||
tags: [...npc.tags],
|
||||
backstoryReveal: npc.backstoryReveal,
|
||||
skills: npc.skills.map((skill) => ({ ...skill })),
|
||||
initialItems: npc.initialItems.map((item) => ({
|
||||
...item,
|
||||
tags: [...item.tags],
|
||||
})),
|
||||
imageSrc: npc.imageSrc,
|
||||
visual: npc.visual,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +401,23 @@ function buildStoryContextFromState(
|
||||
encounterContext: state.currentEncounter?.context ?? null,
|
||||
encounterCharacterId: state.currentEncounter?.characterId ?? null,
|
||||
encounterGender: state.currentEncounter?.gender ?? null,
|
||||
encounterCustomProfile: state.currentEncounter
|
||||
? {
|
||||
title: state.currentEncounter.title ?? '',
|
||||
description: state.currentEncounter.npcDescription ?? '',
|
||||
backstory: state.currentEncounter.backstory ?? '',
|
||||
personality: state.currentEncounter.personality ?? '',
|
||||
motivation: state.currentEncounter.motivation ?? '',
|
||||
combatStyle: state.currentEncounter.combatStyle ?? '',
|
||||
relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])],
|
||||
tags: [...(state.currentEncounter.tags ?? [])],
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal,
|
||||
skills: [...(state.currentEncounter.skills ?? [])],
|
||||
initialItems: [...(state.currentEncounter.initialItems ?? [])],
|
||||
imageSrc: state.currentEncounter.imageSrc,
|
||||
visual: state.currentEncounter.visual,
|
||||
}
|
||||
: null,
|
||||
encounterAffinity: encounterDirective?.affinity ?? null,
|
||||
encounterAffinityText,
|
||||
encounterConversationStyle: encounterDirective?.style ?? null,
|
||||
|
||||
@@ -539,6 +539,70 @@ describe('ai orchestration fallbacks', () => {
|
||||
expect(debugLabels).toContain('custom-world-story-dossier-batch-1');
|
||||
});
|
||||
|
||||
it('reports staged progress while generating a custom world', async () => {
|
||||
requestPlainTextCompletionMock.mockResolvedValue(
|
||||
JSON.stringify(createCustomWorldResponse()),
|
||||
);
|
||||
const onProgress = vi.fn();
|
||||
|
||||
await generateCustomWorldProfile('一个需要展示真实进度的世界', {
|
||||
onProgress,
|
||||
});
|
||||
|
||||
const phaseIds = onProgress.mock.calls.map(
|
||||
(call) =>
|
||||
(call[0] as { phaseId?: string; overallProgress?: number }).phaseId,
|
||||
);
|
||||
const lastProgress = onProgress.mock.calls.at(-1)?.[0] as
|
||||
| { overallProgress?: number; estimatedRemainingMs?: number | null }
|
||||
| undefined;
|
||||
|
||||
expect(phaseIds).toContain('framework');
|
||||
expect(phaseIds).toContain('playable-outline');
|
||||
expect(phaseIds).toContain('story-outline');
|
||||
expect(phaseIds).toContain('landmark-seed');
|
||||
expect(phaseIds).toContain('landmark-network');
|
||||
expect(phaseIds).toContain('playable-narrative');
|
||||
expect(phaseIds).toContain('playable-dossier');
|
||||
expect(phaseIds).toContain('story-narrative');
|
||||
expect(phaseIds).toContain('story-dossier');
|
||||
expect(phaseIds).toContain('finalize');
|
||||
expect(lastProgress?.overallProgress).toBe(100);
|
||||
expect(lastProgress?.estimatedRemainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('passes abort signals through custom world generation and rejects when interrupted', async () => {
|
||||
requestPlainTextCompletionMock.mockImplementation(
|
||||
(
|
||||
_system: string,
|
||||
_user: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(options.signal?.reason ?? new Error('世界生成已中断。')),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
const generation = generateCustomWorldProfile('一个会被中断的世界', {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
abortController.abort(new Error('手动中断生成'));
|
||||
|
||||
await expect(generation).rejects.toThrow('手动中断生成');
|
||||
expect(requestPlainTextCompletionMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries custom world generation with a longer timeout after the first timeout attempt', async () => {
|
||||
requestPlainTextCompletionMock
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
|
||||
@@ -58,8 +58,10 @@ import {
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationLandmarkOutline,
|
||||
type CustomWorldGenerationRoleBatchStage,
|
||||
type CustomWorldGenerationRoleBatchType,
|
||||
type CustomWorldGenerationRoleOutline,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
@@ -106,7 +108,7 @@ type RawOptionItem = {
|
||||
|
||||
type MergeableCustomWorldRoleEntry = {
|
||||
name: string;
|
||||
} & Record<string, unknown>;
|
||||
};
|
||||
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
|
||||
import.meta.env.VITE_SCENE_IMAGE_PROXY_BASE_URL ||
|
||||
@@ -159,6 +161,157 @@ export interface CustomWorldSceneImageResult {
|
||||
actualPrompt?: string;
|
||||
}
|
||||
|
||||
const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
{
|
||||
id: 'framework',
|
||||
label: '世界框架',
|
||||
detail: '解析设定文本,确定世界主题、主目标与基础模板。',
|
||||
total: 1,
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
id: 'playable-outline',
|
||||
label: '可扮演角色骨架',
|
||||
detail: '先生成可扮演角色名单与核心定位。',
|
||||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT /
|
||||
CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'story-outline',
|
||||
label: '场景角色骨架',
|
||||
detail: '补齐世界里的关键角色与势力关系。',
|
||||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT /
|
||||
CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'landmark-seed',
|
||||
label: '场景骨架',
|
||||
detail: '生成地标、区域描述与危险等级。',
|
||||
total: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT /
|
||||
CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'landmark-network',
|
||||
label: '场景连接',
|
||||
detail: '建立场景连接关系与场景内角色分布。',
|
||||
total: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT /
|
||||
CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'playable-narrative',
|
||||
label: '可扮演角色叙事',
|
||||
detail: '为可扮演角色补充公开背景、动机与风格。',
|
||||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'playable-dossier',
|
||||
label: '可扮演角色档案',
|
||||
detail: '补齐技能、好感章节与初始携带信息。',
|
||||
total: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT / CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'story-narrative',
|
||||
label: '场景角色叙事',
|
||||
detail: '扩写场景角色的关系钩子与叙事位置。',
|
||||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'story-dossier',
|
||||
label: '场景角色档案',
|
||||
detail: '补齐场景角色档案与互动素材。',
|
||||
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
weight: Math.max(
|
||||
1,
|
||||
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'finalize',
|
||||
label: '归档世界',
|
||||
detail: '整理最终世界档案并做完整性校验。',
|
||||
total: 1,
|
||||
weight: 1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type CustomWorldGenerationStageId =
|
||||
(typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id'];
|
||||
|
||||
export interface CustomWorldGenerationStep {
|
||||
id: CustomWorldGenerationStageId;
|
||||
label: string;
|
||||
detail: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
status: 'pending' | 'active' | 'completed';
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationProgress {
|
||||
phaseId: CustomWorldGenerationStageId;
|
||||
phaseLabel: string;
|
||||
phaseDetail: string;
|
||||
batchLabel?: string;
|
||||
overallProgress: number;
|
||||
completedWeight: number;
|
||||
totalWeight: number;
|
||||
elapsedMs: number;
|
||||
estimatedRemainingMs: number | null;
|
||||
activeStepIndex: number;
|
||||
steps: CustomWorldGenerationStep[];
|
||||
}
|
||||
|
||||
export interface GenerateCustomWorldProfileOptions {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
class CustomWorldGenerationAbortedError extends Error {
|
||||
constructor(message = '世界生成已中断。') {
|
||||
super(message);
|
||||
this.name = 'CustomWorldGenerationAbortedError';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeApiErrorMessage(
|
||||
responseText: string,
|
||||
fallbackMessage: string,
|
||||
@@ -312,14 +465,212 @@ function appendUniqueNamedEntries<T extends MergeableCustomWorldRoleEntry>(
|
||||
return merged;
|
||||
}
|
||||
|
||||
const CUSTOM_WORLD_GENERATION_STAGE_MAP = new Map(
|
||||
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, stage]),
|
||||
);
|
||||
const CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT =
|
||||
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
|
||||
(sum, stage) => sum + stage.weight,
|
||||
0,
|
||||
);
|
||||
|
||||
function getCustomWorldGenerationStageIdForRoleOutline(
|
||||
roleType: CustomWorldGenerationRoleBatchType,
|
||||
): CustomWorldGenerationStageId {
|
||||
return roleType === 'playable' ? 'playable-outline' : 'story-outline';
|
||||
}
|
||||
|
||||
function getCustomWorldGenerationStageIdForRoleExpansion(
|
||||
roleType: CustomWorldGenerationRoleBatchType,
|
||||
stage: CustomWorldGenerationRoleBatchStage,
|
||||
): CustomWorldGenerationStageId {
|
||||
if (roleType === 'playable') {
|
||||
return stage === 'narrative'
|
||||
? 'playable-narrative'
|
||||
: 'playable-dossier';
|
||||
}
|
||||
|
||||
return stage === 'narrative' ? 'story-narrative' : 'story-dossier';
|
||||
}
|
||||
|
||||
function throwIfCustomWorldGenerationAborted(signal?: AbortSignal) {
|
||||
if (!signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new CustomWorldGenerationAbortedError();
|
||||
}
|
||||
|
||||
function isCustomWorldGenerationAbortLikeError(error: unknown) {
|
||||
return (
|
||||
error instanceof CustomWorldGenerationAbortedError ||
|
||||
(typeof DOMException !== 'undefined' &&
|
||||
error instanceof DOMException &&
|
||||
error.name === 'AbortError')
|
||||
);
|
||||
}
|
||||
|
||||
function createCustomWorldGenerationReporter(
|
||||
onProgress?: GenerateCustomWorldProfileOptions['onProgress'],
|
||||
) {
|
||||
const startedAt = performance.now();
|
||||
const completedByStage = Object.fromEntries(
|
||||
CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((stage) => [stage.id, 0]),
|
||||
) as Record<CustomWorldGenerationStageId, number>;
|
||||
|
||||
const emit = (
|
||||
stageId: CustomWorldGenerationStageId,
|
||||
options: Partial<{
|
||||
completed: number;
|
||||
phaseDetail: string;
|
||||
batchLabel: string;
|
||||
}> = {},
|
||||
) => {
|
||||
const stage = CUSTOM_WORLD_GENERATION_STAGE_MAP.get(stageId);
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof options.completed === 'number') {
|
||||
completedByStage[stageId] = Math.max(
|
||||
0,
|
||||
Math.min(stage.total, options.completed),
|
||||
);
|
||||
}
|
||||
|
||||
const steps = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.map((item) => {
|
||||
const completed = Math.max(
|
||||
0,
|
||||
Math.min(item.total, completedByStage[item.id]),
|
||||
);
|
||||
return {
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
detail: item.detail,
|
||||
completed,
|
||||
total: item.total,
|
||||
status:
|
||||
completed >= item.total
|
||||
? 'completed'
|
||||
: item.id === stageId
|
||||
? 'active'
|
||||
: 'pending',
|
||||
} satisfies CustomWorldGenerationStep;
|
||||
});
|
||||
|
||||
const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
|
||||
(sum, item) =>
|
||||
sum +
|
||||
(completedByStage[item.id] / item.total || 0) * item.weight,
|
||||
0,
|
||||
);
|
||||
const progressFraction =
|
||||
CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT > 0
|
||||
? completedWeight / CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT
|
||||
: 0;
|
||||
const elapsedMs = Math.max(0, performance.now() - startedAt);
|
||||
const estimatedRemainingMs =
|
||||
progressFraction > 0 && progressFraction < 1
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(elapsedMs / progressFraction - elapsedMs),
|
||||
)
|
||||
: progressFraction >= 1
|
||||
? 0
|
||||
: null;
|
||||
|
||||
onProgress?.({
|
||||
phaseId: stage.id,
|
||||
phaseLabel: stage.label,
|
||||
phaseDetail: options.phaseDetail ?? stage.detail,
|
||||
batchLabel: options.batchLabel,
|
||||
overallProgress: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(progressFraction * 100)),
|
||||
),
|
||||
completedWeight,
|
||||
totalWeight: CUSTOM_WORLD_GENERATION_TOTAL_WEIGHT,
|
||||
elapsedMs: Math.round(elapsedMs),
|
||||
estimatedRemainingMs,
|
||||
activeStepIndex: CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.findIndex(
|
||||
(item) => item.id === stage.id,
|
||||
),
|
||||
steps,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
begin(
|
||||
stageId: CustomWorldGenerationStageId,
|
||||
options: Partial<{
|
||||
phaseDetail: string;
|
||||
batchLabel: string;
|
||||
}> = {},
|
||||
) {
|
||||
emit(stageId, {
|
||||
completed: completedByStage[stageId],
|
||||
...options,
|
||||
});
|
||||
},
|
||||
update(
|
||||
stageId: CustomWorldGenerationStageId,
|
||||
completed: number,
|
||||
options: Partial<{
|
||||
phaseDetail: string;
|
||||
batchLabel: string;
|
||||
}> = {},
|
||||
) {
|
||||
emit(stageId, {
|
||||
completed,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
complete(
|
||||
stageId: CustomWorldGenerationStageId,
|
||||
options: Partial<{
|
||||
phaseDetail: string;
|
||||
batchLabel: string;
|
||||
}> = {},
|
||||
) {
|
||||
const stage = CUSTOM_WORLD_GENERATION_STAGE_MAP.get(stageId);
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(stageId, {
|
||||
completed: stage.total,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CustomWorldGenerationReporter = ReturnType<
|
||||
typeof createCustomWorldGenerationReporter
|
||||
>;
|
||||
|
||||
async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
totalCount: number;
|
||||
batchSize: number;
|
||||
reporter?: CustomWorldGenerationReporter;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, roleType, totalCount, batchSize } = params;
|
||||
let mergedEntries: MergeableCustomWorldRoleEntry[] = [];
|
||||
const {
|
||||
framework,
|
||||
roleType,
|
||||
totalCount,
|
||||
batchSize,
|
||||
reporter = createCustomWorldGenerationReporter(),
|
||||
signal,
|
||||
} = params;
|
||||
const stageId = getCustomWorldGenerationStageIdForRoleOutline(roleType);
|
||||
const plannedBatchCount = Math.max(1, Math.ceil(totalCount / batchSize));
|
||||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
let mergedEntries: CustomWorldGenerationRoleOutline[] = [];
|
||||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||||
|
||||
for (
|
||||
@@ -327,7 +678,12 @@ async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||||
batchIndex += 1
|
||||
) {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||||
reporter.update(stageId, mergedEntries.length, {
|
||||
phaseDetail: `正在生成${roleLabel},已完成 ${mergedEntries.length}/${totalCount}。`,
|
||||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldRoleOutlineBatchPrompt({
|
||||
framework,
|
||||
@@ -345,6 +701,7 @@ async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
}),
|
||||
repairDebugLabel: `custom-world-${roleType}-outline-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界${roleType === 'playable' ? '可扮演角色' : '场景角色'}名单批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
signal,
|
||||
});
|
||||
|
||||
mergedEntries = appendUniqueNamedEntries(
|
||||
@@ -352,6 +709,10 @@ async function generateCustomWorldRoleOutlineEntries(params: {
|
||||
normalizeCustomWorldGenerationRoleOutlineBatch(batchRaw, roleType),
|
||||
totalCount,
|
||||
);
|
||||
reporter.update(stageId, mergedEntries.length, {
|
||||
phaseDetail: `正在生成${roleLabel},已完成 ${mergedEntries.length}/${totalCount}。`,
|
||||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
|
||||
if (batchCount <= 0) {
|
||||
break;
|
||||
@@ -365,9 +726,18 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
totalCount: number;
|
||||
batchSize: number;
|
||||
reporter?: CustomWorldGenerationReporter;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, totalCount, batchSize } = params;
|
||||
let mergedEntries: MergeableCustomWorldRoleEntry[] = [];
|
||||
const {
|
||||
framework,
|
||||
totalCount,
|
||||
batchSize,
|
||||
reporter = createCustomWorldGenerationReporter(),
|
||||
signal,
|
||||
} = params;
|
||||
const plannedBatchCount = Math.max(1, Math.ceil(totalCount / batchSize));
|
||||
let mergedEntries: CustomWorldGenerationLandmarkOutline[] = [];
|
||||
const maxBatchAttempts = Math.max(2, Math.ceil(totalCount / batchSize) + 2);
|
||||
|
||||
for (
|
||||
@@ -375,7 +745,12 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
batchIndex < maxBatchAttempts && mergedEntries.length < totalCount;
|
||||
batchIndex += 1
|
||||
) {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const batchCount = Math.min(batchSize, totalCount - mergedEntries.length);
|
||||
reporter.update('landmark-seed', mergedEntries.length, {
|
||||
phaseDetail: `正在生成场景骨架,已完成 ${mergedEntries.length}/${totalCount}。`,
|
||||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldLandmarkSeedBatchPrompt({
|
||||
framework,
|
||||
@@ -391,6 +766,7 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
}),
|
||||
repairDebugLabel: `custom-world-landmark-seed-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界场景骨架批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
signal,
|
||||
});
|
||||
|
||||
mergedEntries = appendUniqueNamedEntries(
|
||||
@@ -398,6 +774,10 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw),
|
||||
totalCount,
|
||||
);
|
||||
reporter.update('landmark-seed', mergedEntries.length, {
|
||||
phaseDetail: `正在生成场景骨架,已完成 ${mergedEntries.length}/${totalCount}。`,
|
||||
batchLabel: `第 ${Math.min(batchIndex + 1, plannedBatchCount)} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
|
||||
if (batchCount <= 0) {
|
||||
break;
|
||||
@@ -410,16 +790,35 @@ async function generateCustomWorldLandmarkSeedEntries(params: {
|
||||
async function expandCustomWorldLandmarkNetworkEntries(params: {
|
||||
framework: CustomWorldGenerationFramework;
|
||||
storyNpcs: CustomWorldGenerationFramework['storyNpcs'];
|
||||
baseEntries: MergeableCustomWorldRoleEntry[];
|
||||
baseEntries: CustomWorldGenerationLandmarkOutline[];
|
||||
batchSize: number;
|
||||
reporter?: CustomWorldGenerationReporter;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, storyNpcs, baseEntries, batchSize } = params;
|
||||
const {
|
||||
framework,
|
||||
storyNpcs,
|
||||
baseEntries,
|
||||
batchSize,
|
||||
reporter = createCustomWorldGenerationReporter(),
|
||||
signal,
|
||||
} = params;
|
||||
const plannedBatchCount = Math.max(
|
||||
1,
|
||||
Math.ceil(framework.landmarks.length / batchSize),
|
||||
);
|
||||
let mergedEntries = baseEntries.map((entry) => ({ ...entry }));
|
||||
let processedCount = 0;
|
||||
|
||||
for (const [batchIndex, landmarkBatch] of chunkArray(
|
||||
framework.landmarks,
|
||||
batchSize,
|
||||
).entries()) {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
reporter.update('landmark-network', processedCount, {
|
||||
phaseDetail: `正在建立场景连接,已完成 ${processedCount}/${framework.landmarks.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
const batchRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldLandmarkNetworkBatchPrompt({
|
||||
framework,
|
||||
@@ -434,6 +833,7 @@ async function expandCustomWorldLandmarkNetworkEntries(params: {
|
||||
}),
|
||||
repairDebugLabel: `custom-world-landmark-network-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界场景连接批次 ${batchIndex + 1} 生成失败:模型没有返回有效内容。`,
|
||||
signal,
|
||||
});
|
||||
|
||||
mergedEntries = mergeRoleBatchDetails(
|
||||
@@ -442,6 +842,14 @@ async function expandCustomWorldLandmarkNetworkEntries(params: {
|
||||
(entry) => ({ ...entry }),
|
||||
),
|
||||
);
|
||||
processedCount = Math.min(
|
||||
framework.landmarks.length,
|
||||
processedCount + landmarkBatch.length,
|
||||
);
|
||||
reporter.update('landmark-network', processedCount, {
|
||||
phaseDetail: `正在建立场景连接,已完成 ${processedCount}/${framework.landmarks.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
}
|
||||
|
||||
return mergedEntries;
|
||||
@@ -454,19 +862,45 @@ async function expandCustomWorldRoleEntries<
|
||||
roleType: CustomWorldGenerationRoleBatchType;
|
||||
baseEntries: T[];
|
||||
batchSize: number;
|
||||
reporter?: CustomWorldGenerationReporter;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { framework, roleType, baseEntries, batchSize } = params;
|
||||
const {
|
||||
framework,
|
||||
roleType,
|
||||
baseEntries,
|
||||
batchSize,
|
||||
reporter = createCustomWorldGenerationReporter(),
|
||||
signal,
|
||||
} = params;
|
||||
const roleBatchSource =
|
||||
roleType === 'playable' ? framework.playableNpcs : framework.storyNpcs;
|
||||
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
|
||||
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
|
||||
const plannedBatchCount = Math.max(
|
||||
1,
|
||||
Math.ceil(roleBatchSource.length / batchSize),
|
||||
);
|
||||
const processedByStage: Record<CustomWorldGenerationRoleBatchStage, number> = {
|
||||
narrative: 0,
|
||||
dossier: 0,
|
||||
};
|
||||
|
||||
const requestBatchStage = async (
|
||||
roleBatch: typeof roleBatchSource,
|
||||
batchIndex: number,
|
||||
stage: CustomWorldGenerationRoleBatchStage,
|
||||
) => {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const stageLabel = stage === 'narrative' ? '叙事设定' : '档案补全';
|
||||
const stageId = getCustomWorldGenerationStageIdForRoleExpansion(
|
||||
roleType,
|
||||
stage,
|
||||
);
|
||||
reporter.update(stageId, processedByStage[stage], {
|
||||
phaseDetail: `正在补充${roleLabel}${stageLabel},已完成 ${processedByStage[stage]}/${roleBatchSource.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
const stageRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldRoleBatchPrompt({
|
||||
framework,
|
||||
@@ -484,6 +918,7 @@ async function expandCustomWorldRoleEntries<
|
||||
}),
|
||||
repairDebugLabel: `custom-world-${roleType}-${stage}-batch-${batchIndex + 1}-json-repair`,
|
||||
emptyResponseMessage: `自定义世界${roleLabel}批次 ${batchIndex + 1} 的${stageLabel}生成失败:模型没有返回有效内容。`,
|
||||
signal,
|
||||
});
|
||||
|
||||
mergedEntries = mergeRoleBatchDetails(
|
||||
@@ -493,9 +928,17 @@ async function expandCustomWorldRoleEntries<
|
||||
? (stageRaw as Record<string, unknown>)[
|
||||
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
|
||||
]
|
||||
: [],
|
||||
: []
|
||||
),
|
||||
);
|
||||
processedByStage[stage] = Math.min(
|
||||
roleBatchSource.length,
|
||||
processedByStage[stage] + roleBatch.length,
|
||||
);
|
||||
reporter.update(stageId, processedByStage[stage], {
|
||||
phaseDetail: `正在补充${roleLabel}${stageLabel},已完成 ${processedByStage[stage]}/${roleBatchSource.length}。`,
|
||||
batchLabel: `第 ${batchIndex + 1} / ${plannedBatchCount} 批`,
|
||||
});
|
||||
};
|
||||
|
||||
for (const [batchIndex, roleBatch] of chunkArray(
|
||||
@@ -513,8 +956,10 @@ async function parseCustomWorldStageResponseJson(params: {
|
||||
responseText: string;
|
||||
repairPrompt: string;
|
||||
repairDebugLabel: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const { responseText, repairPrompt, repairDebugLabel } = params;
|
||||
const { responseText, repairPrompt, repairDebugLabel, signal } = params;
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
try {
|
||||
return parseJsonResponseTextFromParser(responseText);
|
||||
} catch {
|
||||
@@ -536,9 +981,11 @@ async function parseCustomWorldStageResponseJson(params: {
|
||||
Math.min(90000, Math.round(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS / 2)),
|
||||
),
|
||||
debugLabel: repairDebugLabel,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
return parseJsonResponseTextFromParser(
|
||||
sanitizeJsonLikeText(repairedText) || repairedText,
|
||||
);
|
||||
@@ -551,6 +998,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
repairPromptBuilder: (responseText: string) => string;
|
||||
repairDebugLabel: string;
|
||||
emptyResponseMessage: string;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
const {
|
||||
userPrompt,
|
||||
@@ -558,6 +1006,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
repairPromptBuilder,
|
||||
repairDebugLabel,
|
||||
emptyResponseMessage,
|
||||
signal,
|
||||
} = params;
|
||||
const timeoutPlan = [
|
||||
CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS,
|
||||
@@ -569,6 +1018,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
|
||||
for (const [attemptIndex, timeoutMs] of timeoutPlan.entries()) {
|
||||
try {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const responseText = await requestPlainTextCompletionFromClient(
|
||||
CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
@@ -578,6 +1028,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
attemptIndex === 0
|
||||
? debugLabel
|
||||
: `${debugLabel}-retry-${attemptIndex + 1}`,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
text = typeof responseText === 'string' ? responseText : '';
|
||||
@@ -602,6 +1053,7 @@ async function requestCustomWorldJsonStage(params: {
|
||||
responseText: text,
|
||||
repairPrompt: repairPromptBuilder(text),
|
||||
repairDebugLabel,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1133,16 +1585,24 @@ export async function generateCustomWorldSceneImage({
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
settingText: string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedSettingText = settingText.trim();
|
||||
const reporter = createCustomWorldGenerationReporter(options.onProgress);
|
||||
const signal = options.signal;
|
||||
|
||||
try {
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
reporter.begin('framework', {
|
||||
phaseDetail: '正在解析你的设定文本,准备搭建世界框架。',
|
||||
});
|
||||
const frameworkRaw = await requestCustomWorldJsonStage({
|
||||
userPrompt: buildCustomWorldFrameworkPrompt(normalizedSettingText),
|
||||
debugLabel: 'custom-world-framework',
|
||||
repairPromptBuilder: buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
repairDebugLabel: 'custom-world-framework-json-repair',
|
||||
emptyResponseMessage: '自定义世界框架生成失败:模型没有返回有效内容。',
|
||||
signal,
|
||||
});
|
||||
const frameworkBase = {
|
||||
...normalizeCustomWorldGenerationFramework(
|
||||
@@ -1153,49 +1613,84 @@ export async function generateCustomWorldProfile(
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
reporter.complete('framework', {
|
||||
phaseDetail: `世界框架已确定,基础模板锚定为${frameworkBase.templateWorldType === WorldType.WUXIA ? '武侠' : '仙侠'}。`,
|
||||
});
|
||||
|
||||
reporter.begin('playable-outline', {
|
||||
phaseDetail: '正在生成可扮演角色骨架。',
|
||||
});
|
||||
const playableNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkBase,
|
||||
roleType: 'playable',
|
||||
totalCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['playableNpcs'];
|
||||
reporter.complete('playable-outline', {
|
||||
phaseDetail: `可扮演角色骨架已完成,共 ${playableNpcs.length} 名。`,
|
||||
});
|
||||
const frameworkWithPlayable = {
|
||||
...frameworkBase,
|
||||
playableNpcs,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
reporter.begin('story-outline', {
|
||||
phaseDetail: '正在生成场景角色骨架。',
|
||||
});
|
||||
const storyNpcs =
|
||||
(await generateCustomWorldRoleOutlineEntries({
|
||||
framework: frameworkWithPlayable,
|
||||
roleType: 'story',
|
||||
totalCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['storyNpcs'];
|
||||
reporter.complete('story-outline', {
|
||||
phaseDetail: `场景角色骨架已完成,共 ${storyNpcs.length} 名。`,
|
||||
});
|
||||
const frameworkWithStory = {
|
||||
...frameworkWithPlayable,
|
||||
storyNpcs,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
reporter.begin('landmark-seed', {
|
||||
phaseDetail: '正在生成场景骨架。',
|
||||
});
|
||||
const landmarkSeeds =
|
||||
(await generateCustomWorldLandmarkSeedEntries({
|
||||
framework: frameworkWithStory,
|
||||
totalCount: MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
reporter.complete('landmark-seed', {
|
||||
phaseDetail: `场景骨架已完成,共 ${landmarkSeeds.length} 个地标。`,
|
||||
});
|
||||
const frameworkWithLandmarkSeeds = {
|
||||
...frameworkWithStory,
|
||||
landmarks: landmarkSeeds,
|
||||
} satisfies CustomWorldGenerationFramework;
|
||||
|
||||
reporter.begin('landmark-network', {
|
||||
phaseDetail: '正在建立场景连接与场景角色分布。',
|
||||
});
|
||||
const landmarks =
|
||||
(await expandCustomWorldLandmarkNetworkEntries({
|
||||
framework: frameworkWithLandmarkSeeds,
|
||||
storyNpcs,
|
||||
baseEntries: landmarkSeeds,
|
||||
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
})) as CustomWorldGenerationFramework['landmarks'];
|
||||
reporter.complete('landmark-network', {
|
||||
phaseDetail: `场景连接已完成,共整理 ${landmarks.length} 个地标网络。`,
|
||||
});
|
||||
|
||||
const framework = {
|
||||
...frameworkWithStory,
|
||||
@@ -1204,19 +1699,34 @@ export async function generateCustomWorldProfile(
|
||||
validateCustomWorldGenerationFramework(framework);
|
||||
|
||||
const baseRawProfile = buildCustomWorldRawProfileFromFramework(framework);
|
||||
reporter.begin('playable-narrative', {
|
||||
phaseDetail: '正在补充可扮演角色的叙事设定。',
|
||||
});
|
||||
const mergedPlayableNpcs = await expandCustomWorldRoleEntries({
|
||||
framework,
|
||||
roleType: 'playable',
|
||||
baseEntries: baseRawProfile.playableNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
});
|
||||
|
||||
reporter.begin('story-narrative', {
|
||||
phaseDetail: '正在补充场景角色的叙事设定。',
|
||||
});
|
||||
const mergedStoryNpcs = await expandCustomWorldRoleEntries({
|
||||
framework,
|
||||
roleType: 'story',
|
||||
baseEntries: baseRawProfile.storyNpcs.map((npc) => ({ ...npc })),
|
||||
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
|
||||
reporter,
|
||||
signal,
|
||||
});
|
||||
|
||||
reporter.begin('finalize', {
|
||||
phaseDetail: '正在归档世界并做完整性校验。',
|
||||
});
|
||||
throwIfCustomWorldGenerationAborted(signal);
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
...baseRawProfile,
|
||||
@@ -1226,11 +1736,19 @@ export async function generateCustomWorldProfile(
|
||||
normalizedSettingText,
|
||||
);
|
||||
validateGeneratedCustomWorldProfile(profile);
|
||||
reporter.complete('finalize', {
|
||||
phaseDetail: `世界“${profile.name}”已完成归档。`,
|
||||
});
|
||||
return {
|
||||
...profile,
|
||||
items: [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (isCustomWorldGenerationAbortLikeError(error) || signal?.aborted) {
|
||||
throw error instanceof Error
|
||||
? error
|
||||
: new CustomWorldGenerationAbortedError();
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(
|
||||
'自定义世界生成失败:模型返回了非严格 JSON,且自动修复仍未成功,请稍后重试。',
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CharacterConversationStyle,
|
||||
CharacterGender,
|
||||
CompanionState,
|
||||
CustomWorldNpc,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
FacingDirection,
|
||||
@@ -65,6 +66,24 @@ export interface StoryGenerationContext {
|
||||
recentSharedEvent?: string | null;
|
||||
talkPriority?: string | null;
|
||||
encounterRelationshipSummary?: string | null;
|
||||
encounterCustomProfile?: Partial<
|
||||
Pick<
|
||||
CustomWorldNpc,
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
| 'backstoryReveal'
|
||||
| 'skills'
|
||||
| 'initialItems'
|
||||
| 'imageSrc'
|
||||
| 'visual'
|
||||
>
|
||||
> | null;
|
||||
partyRelationshipNotes?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
openingCampBackground?: string | null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CharacterBackstoryRevealConfig,
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleInitialItem,
|
||||
@@ -714,11 +715,18 @@ function normalizePlayableNpcList(value: unknown) {
|
||||
function normalizeStoryNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) =>
|
||||
normalizeRoleProfile(item, index, {
|
||||
idPrefix: 'story-npc',
|
||||
titleFallback: '未定称号',
|
||||
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
}),
|
||||
({
|
||||
...normalizeRoleProfile(item, index, {
|
||||
idPrefix: 'story-npc',
|
||||
titleFallback: '未定称号',
|
||||
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
}),
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
visual:
|
||||
item.visual && typeof item.visual === 'object'
|
||||
? (item.visual as CustomWorldNpc['visual'])
|
||||
: undefined,
|
||||
}) satisfies CustomWorldNpc,
|
||||
)
|
||||
.filter((entry) => entry.name);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const ENABLE_LLM_DEBUG_LOG = Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'tru
|
||||
export interface PlainTextCompletionOptions {
|
||||
timeoutMs?: number;
|
||||
debugLabel?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class LlmConnectivityError extends Error {
|
||||
@@ -71,7 +72,9 @@ async function requestMessageContent(
|
||||
) {
|
||||
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
||||
const debugLabel = options.debugLabel ?? 'chat';
|
||||
const externalSignal = options.signal;
|
||||
const controller = new AbortController();
|
||||
const handleExternalAbort = () => controller.abort();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = performance.now();
|
||||
const requestBody = {
|
||||
@@ -83,6 +86,16 @@ async function requestMessageContent(
|
||||
};
|
||||
const rawPromptText = `[System]\n${systemPrompt}\n\n[User]\n${userPrompt}`;
|
||||
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
handleExternalAbort();
|
||||
} else {
|
||||
externalSignal.addEventListener('abort', handleExternalAbort, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
logLlmDebug(`[LLM:${debugLabel}] prompt text`, rawPromptText);
|
||||
|
||||
@@ -119,6 +132,11 @@ async function requestMessageContent(
|
||||
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
if (externalSignal?.aborted) {
|
||||
throw externalSignal.reason instanceof Error
|
||||
? externalSignal.reason
|
||||
: new DOMException('The LLM request was aborted.', 'AbortError');
|
||||
}
|
||||
console.error(`[LLM:${debugLabel}] completion failed`, {
|
||||
model: MODEL,
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
@@ -128,6 +146,7 @@ async function requestMessageContent(
|
||||
return normalizeLlmError(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
externalSignal?.removeEventListener('abort', handleExternalAbort);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -412,6 +412,7 @@ function describeFrontEntity(
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, context.customWorldProfile);
|
||||
if (context.encounterName) {
|
||||
const encounterCustomProfile = context.encounterCustomProfile;
|
||||
const encounterCharacter = context.encounterCharacterId
|
||||
? getCharacterById(context.encounterCharacterId) ?? resolveEncounterRecruitCharacter({
|
||||
characterId: context.encounterCharacterId,
|
||||
@@ -427,11 +428,53 @@ function describeFrontEntity(
|
||||
const attributeProfile = encounterCharacter
|
||||
? resolveCharacterAttributeProfile(encounterCharacter, world, context.customWorldProfile)
|
||||
: inferEncounterAttributeProfile(world, context, `encounter:${context.encounterName}`, [
|
||||
inferEncounterPersonality(context.encounterContext, context.encounterDescription),
|
||||
encounterCustomProfile?.personality ||
|
||||
inferEncounterPersonality(
|
||||
context.encounterContext,
|
||||
context.encounterDescription,
|
||||
),
|
||||
encounterCustomProfile?.backstory ?? '',
|
||||
encounterCustomProfile?.motivation ?? '',
|
||||
encounterCustomProfile?.combatStyle ?? '',
|
||||
...(encounterCustomProfile?.relationshipHooks ?? []),
|
||||
...(encounterCustomProfile?.tags ?? []),
|
||||
...(encounterCustomProfile?.backstoryReveal?.chapters ?? []).flatMap(
|
||||
(chapter) => [
|
||||
chapter.title,
|
||||
chapter.teaser,
|
||||
chapter.content,
|
||||
chapter.contextSnippet,
|
||||
],
|
||||
),
|
||||
...(encounterCustomProfile?.skills ?? []).flatMap((skill) => [
|
||||
skill.name,
|
||||
skill.summary,
|
||||
skill.style,
|
||||
]),
|
||||
...(encounterCustomProfile?.initialItems ?? []).flatMap((item) => [
|
||||
item.name,
|
||||
item.category,
|
||||
item.description,
|
||||
...item.tags,
|
||||
]),
|
||||
]);
|
||||
const title = encounterCharacter?.title ?? context.encounterContext ?? '此地生灵';
|
||||
const description = encounterCharacter?.description ?? context.encounterDescription ?? '对方站在你面前,等待你进一步表态。';
|
||||
const personality = encounterCharacter?.personality ?? inferEncounterPersonality(context.encounterContext, context.encounterDescription);
|
||||
const title =
|
||||
encounterCharacter?.title ??
|
||||
encounterCustomProfile?.title ??
|
||||
context.encounterContext ??
|
||||
'此地生灵';
|
||||
const description =
|
||||
encounterCharacter?.description ??
|
||||
encounterCustomProfile?.description ??
|
||||
context.encounterDescription ??
|
||||
'对方站在你面前,等待你进一步表态。';
|
||||
const personality =
|
||||
encounterCharacter?.personality ??
|
||||
encounterCustomProfile?.personality ??
|
||||
inferEncounterPersonality(
|
||||
context.encounterContext,
|
||||
context.encounterDescription,
|
||||
);
|
||||
const backstoryLines = encounterCharacter
|
||||
? context.isFirstMeaningfulContact
|
||||
? [getCharacterPublicBackstorySummary(encounterCharacter, world)]
|
||||
@@ -440,7 +483,19 @@ function describeFrontEntity(
|
||||
context.encounterAffinity ?? 0,
|
||||
world,
|
||||
)
|
||||
: ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
|
||||
: encounterCustomProfile
|
||||
? [
|
||||
encounterCustomProfile.backstoryReveal?.publicSummary ??
|
||||
'对方有自己的来路与立场。',
|
||||
encounterCustomProfile.backstory,
|
||||
...(
|
||||
encounterCustomProfile.backstoryReveal?.chapters.map(
|
||||
(chapter) =>
|
||||
chapter.contextSnippet || chapter.content || chapter.teaser,
|
||||
) ?? []
|
||||
),
|
||||
].filter((line): line is string => Boolean(line))
|
||||
: ['对方有自己的来路与立场,只是暂时没有完全表现出来。'];
|
||||
const status = context.encounterKind === 'npc'
|
||||
? context.isFirstMeaningfulContact
|
||||
? '你们正在进行第一次真正接触,对方会先观察你的态度与来意。'
|
||||
@@ -456,6 +511,31 @@ function describeFrontEntity(
|
||||
`- 描述:${description}`,
|
||||
...describeBackstoryContext('背景', backstoryLines).map(line => `- ${line}`),
|
||||
`- 性格:${personality}`,
|
||||
encounterCustomProfile?.motivation
|
||||
? `- 当前动机:${encounterCustomProfile.motivation}`
|
||||
: null,
|
||||
encounterCustomProfile?.combatStyle
|
||||
? `- 战斗风格:${encounterCustomProfile.combatStyle}`
|
||||
: null,
|
||||
encounterCustomProfile?.relationshipHooks?.length
|
||||
? `- 关系切入口:${encounterCustomProfile.relationshipHooks.join('、')}`
|
||||
: null,
|
||||
encounterCustomProfile?.tags?.length
|
||||
? `- 标签:${encounterCustomProfile.tags.join('、')}`
|
||||
: null,
|
||||
encounterCustomProfile?.skills?.length
|
||||
? `- 自定义技能:${encounterCustomProfile.skills
|
||||
.map((skill) => `${skill.name}(${skill.style}):${skill.summary}`)
|
||||
.join(';')}`
|
||||
: null,
|
||||
encounterCustomProfile?.initialItems?.length
|
||||
? `- 随身物:${encounterCustomProfile.initialItems
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.name}x${item.quantity}(${item.category}/${item.rarity})`,
|
||||
)
|
||||
.join(';')}`
|
||||
: null,
|
||||
`- 世界属性框架:${buildSchemaSummary(schema).map(slot => `${slot.name}:${slot.definition}`).join('、')}`,
|
||||
|
||||
...(encounterCharacter ? describeEncounterOpeningByStage(encounterCharacter, world, context).map(line => `- ${line}`) : []),
|
||||
|
||||
@@ -3,8 +3,10 @@ import type {
|
||||
RoleAttributeProfile,
|
||||
RoleRelationState,
|
||||
} from './attributes';
|
||||
import type {Character} from './characters';
|
||||
import type {CustomWorldSceneRelativePosition} from './customWorld';
|
||||
import type {
|
||||
Character,
|
||||
CharacterBackstoryRevealConfig,
|
||||
} from './characters';
|
||||
import {
|
||||
AnimationState,
|
||||
type CharacterGender,
|
||||
@@ -14,6 +16,12 @@ import {
|
||||
type HostileNpcRenderAnimation,
|
||||
type NpcFunctionType,
|
||||
} from './core';
|
||||
import type {
|
||||
CustomWorldNpcVisual,
|
||||
CustomWorldRoleInitialItem,
|
||||
CustomWorldRoleSkill,
|
||||
CustomWorldSceneRelativePosition,
|
||||
} from './customWorld';
|
||||
import type {InventoryItem} from './items';
|
||||
|
||||
export interface NpcPersistentState {
|
||||
@@ -81,6 +89,18 @@ export interface Encounter {
|
||||
initialAffinity?: number;
|
||||
hostile?: boolean;
|
||||
attributeProfile?: RoleAttributeProfile;
|
||||
title?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
relationshipHooks?: string[];
|
||||
tags?: string[];
|
||||
backstoryReveal?: CharacterBackstoryRevealConfig;
|
||||
skills?: CustomWorldRoleSkill[];
|
||||
initialItems?: CustomWorldRoleInitialItem[];
|
||||
imageSrc?: string;
|
||||
visual?: CustomWorldNpcVisual;
|
||||
}
|
||||
|
||||
export interface SceneHostileNpc {
|
||||
@@ -113,6 +133,7 @@ export interface SceneNpc {
|
||||
description: string;
|
||||
avatar: string;
|
||||
role: string;
|
||||
title?: string;
|
||||
gender?: CharacterGender;
|
||||
characterId?: string;
|
||||
hostileNpcPresetId?: string;
|
||||
@@ -122,6 +143,17 @@ export interface SceneNpc {
|
||||
functions?: NpcFunctionType[];
|
||||
recruitable?: boolean;
|
||||
attributeProfile?: RoleAttributeProfile;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
relationshipHooks?: string[];
|
||||
tags?: string[];
|
||||
backstoryReveal?: CharacterBackstoryRevealConfig;
|
||||
skills?: CustomWorldRoleSkill[];
|
||||
initialItems?: CustomWorldRoleInitialItem[];
|
||||
imageSrc?: string;
|
||||
visual?: CustomWorldNpcVisual;
|
||||
}
|
||||
|
||||
export type SceneEncounterKind = 'npc' | 'treasure' | 'none';
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineConfig(({mode}) => {
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
...createLocalApiPlugins(__dirname, env),
|
||||
...createLocalApiPlugins(__dirname, mode, env),
|
||||
],
|
||||
define: {
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
|
||||
Reference in New Issue
Block a user