2 Commits

Author SHA1 Message Date
ddcb5d5c8c Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
2026-04-06 23:19:00 +08:00
d678929064 Fix DashScope env loading for scene image generation 2026-04-06 15:01:15 +08:00
250 changed files with 24299 additions and 2167 deletions

View File

@@ -1,10 +1,11 @@
# Temporary baseline for legacy files that already contain broken text.
# Remove a path from this list as soon as the file is repaired.
docs/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md
docs/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md
docs/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md
docs/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md
docs/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md
docs/audits/text/GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md
docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md
docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md
docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md
docs/audits/text/GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md
src/components/AdventurePanel.tsx
src/data/customWorldCharacterLoadout.ts
dist_check_monster_position/**

View File

@@ -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"

View File

@@ -53,6 +53,8 @@ module.exports = {
],
ignorePatterns: [
'dist',
'dist_check',
'dist_check_monster_position',
'node_modules',
'public/Icons',
'media',

4
.gitignore vendored
View File

@@ -4,5 +4,5 @@ dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
.env.local
/public/generated-custom-world-scenes

View File

@@ -9,3 +9,4 @@
- 非必要不要整文件重写,尤其是包含中文的文件;优先做局部补丁,避免把未改动的中文内容重新编码。
- 修改包含中文的文件后,优先运行仓库里的编码检查,确保没有把文本写坏。
- UI面板中不要默认写一些规则描述文案清爽一些按照游戏UI设计规范设计即可。
- UI设计需要兼顾网页端、移动端双端的使用体验确保在不同设备上都能正常显示和操作移动端优先考虑。

View File

@@ -86,42 +86,33 @@ npm run check:content
主运行时:
- [src/App.tsx](/E:/Repos/ai-native-visual-rpg/src/App.tsx)
- [src/components/GameShell.tsx](/E:/Repos/ai-native-visual-rpg/src/components/GameShell.tsx)
- [src/hooks/useCombatFlow.ts](/E:/Repos/ai-native-visual-rpg/src/hooks/useCombatFlow.ts)
- [src/hooks/useStoryGeneration.ts](/E:/Repos/ai-native-visual-rpg/src/hooks/useStoryGeneration.ts)
- [src/App.tsx](/E:/Repos/Genarrative/src/App.tsx)
- [src/components/GameShell.tsx](/E:/Repos/Genarrative/src/components/GameShell.tsx)
- [src/hooks/useCombatFlow.ts](/E:/Repos/Genarrative/src/hooks/useCombatFlow.ts)
- [src/hooks/useStoryGeneration.ts](/E:/Repos/Genarrative/src/hooks/useStoryGeneration.ts)
编辑器:
- [src/components/PresetEditor.tsx](/E:/Repos/ai-native-visual-rpg/src/components/PresetEditor.tsx)
- [src/components/NpcVisualEditor.tsx](/E:/Repos/ai-native-visual-rpg/src/components/NpcVisualEditor.tsx)
- [src/components/StateFunctionEditor.tsx](/E:/Repos/ai-native-visual-rpg/src/components/StateFunctionEditor.tsx)
- [src/components/PresetEditor.tsx](/E:/Repos/Genarrative/src/components/PresetEditor.tsx)
- [src/components/NpcVisualEditor.tsx](/E:/Repos/Genarrative/src/components/NpcVisualEditor.tsx)
- [src/components/StateFunctionEditor.tsx](/E:/Repos/Genarrative/src/components/StateFunctionEditor.tsx)
核心数据:
- [src/data/scenePresets.ts](/E:/Repos/ai-native-visual-rpg/src/data/scenePresets.ts)
- [src/data/characterPresets.ts](/E:/Repos/ai-native-visual-rpg/src/data/characterPresets.ts)
- [src/data/monsterPresets.ts](/E:/Repos/ai-native-visual-rpg/src/data/monsterPresets.ts)
- [src/data/npcInteractions.ts](/E:/Repos/ai-native-visual-rpg/src/data/npcInteractions.ts)
- [src/data/treasureInteractions.ts](/E:/Repos/ai-native-visual-rpg/src/data/treasureInteractions.ts)
- [src/data/scenePresets.ts](/E:/Repos/Genarrative/src/data/scenePresets.ts)
- [src/data/characterPresets.ts](/E:/Repos/Genarrative/src/data/characterPresets.ts)
- [src/data/monsterPresets.ts](/E:/Repos/Genarrative/src/data/monsterPresets.ts)
- [src/data/npcInteractions.ts](/E:/Repos/Genarrative/src/data/npcInteractions.ts)
- [src/data/treasureInteractions.ts](/E:/Repos/Genarrative/src/data/treasureInteractions.ts)
## 文档入口
开发经验沉淀:
- [docs/PROJECT_DEVELOPMENT_EXPERIENCE.md](/E:/Repos/ai-native-visual-rpg/docs/PROJECT_DEVELOPMENT_EXPERIENCE.md)
- [docs/FUNCTION_SCRIPT_CATALOG_2026-04-04.md](/E:/Repos/ai-native-visual-rpg/docs/FUNCTION_SCRIPT_CATALOG_2026-04-04.md)
移动端与 UI 经验:
- [docs/MOBILE_UI_DEV_EXPERIENCE.md](/E:/Repos/ai-native-visual-rpg/docs/MOBILE_UI_DEV_EXPERIENCE.md)
- [docs/AGENT_UI_CHANGELOG.md](/E:/Repos/ai-native-visual-rpg/docs/AGENT_UI_CHANGELOG.md)
- [UI_CODING_STANDARD.md](/E:/Repos/ai-native-visual-rpg/UI_CODING_STANDARD.md)
优化路线图:
- [docs/PROJECT_OPTIMIZATION_AND_FEATURE_ROADMAP.md](/E:/Repos/ai-native-visual-rpg/docs/PROJECT_OPTIMIZATION_AND_FEATURE_ROADMAP.md)
切磋状态机说明:
- [AGENT_SPAR_STATE_MACHINE_GUIDE.md](C:/Users/windows/Downloads/AGENT_SPAR_STATE_MACHINE_GUIDE.md)
- [docs/README.md](/E:/Repos/Genarrative/docs/README.md):文档总入口,按主题分类后的导航页
- [docs/experience/README.md](/E:/Repos/Genarrative/docs/experience/README.md)项目开发经验、UI 交接、历史实现经验
- [docs/audits/README.md](/E:/Repos/Genarrative/docs/audits/README.md):工程审查、文本审计、专项审计
- [docs/planning/README.md](/E:/Repos/Genarrative/docs/planning/README.md):当前阶段优先级与推进顺序
- [docs/design/README.md](/E:/Repos/Genarrative/docs/design/README.md):玩法、关系、物品与对话设计
- [docs/technical/README.md](/E:/Repos/Genarrative/docs/technical/README.md):技术路线、服务端方案、外部产品拆解
- [docs/reference/README.md](/E:/Repos/Genarrative/docs/reference/README.md)Function 与脚本速查
- [docs/prd/](/E:/Repos/Genarrative/docs/prd/)PRD 与阶段计划,原样保留
- [UI_CODING_STANDARD.md](/E:/Repos/Genarrative/UI_CODING_STANDARD.md)UI 资产与编码规范

View File

@@ -1,6 +1,6 @@
# UI Coding Standard
> **会话交接 / 改动总览**:见 `docs/AGENT_UI_CHANGELOG.md`文件映射、9-slice 架构、已知坑、未收尾项)。
> **会话交接 / 改动总览**:见 `docs/experience/AGENT_UI_CHANGELOG.md`文件映射、9-slice 架构、已知坑、未收尾项)。
## Goal

View File

@@ -1,91 +0,0 @@
# 中文乱码位置清单
更新时间:`2026-03-24`
## 说明
- 本文档用于记录仓库内已确认或高置信度疑似存在中文乱码的位置。
- 当前这份文档是重建版本;原有的 [`docs/CHINESE_MOJIBAKE_INVENTORY.md`](/E:/Repos/ai-native-visual-rpg/docs/CHINESE_MOJIBAKE_INVENTORY.md) 本身也已经乱码,因此已整体替换。
- 本次整理依据:
- 仓库内旧清单中的完整文件/行号信息
- 本轮人工复核时再次直接看到的明显乱码位置
- 由于仓库内同时存在“文件内容已写坏”和“终端/工具显示失真”两类情况,下面清单优先保留高置信位置,便于后续逐项修复。
## 扫描范围
- 已纳入:`src/``docs/`、根目录文档与元数据文件
- 已排除:`.git/``node_modules/``dist/`、纯图片资源目录
## 高置信位置
### 文档与元数据
- [`docs/AGENT_UI_CHANGELOG.md`](/E:/Repos/ai-native-visual-rpg/docs/AGENT_UI_CHANGELOG.md)1, 3, 7, 9, 11-18, 24, 26-28, 32-33, 37, 39, 41-43, 47, 49, 51, 53-66, 68, 72, 74, 77-79, 83, 87, 89-90, 94, 96, 98-100, 104, 106-112, 116
- [`UI_CODING_STANDARD.md`](/E:/Repos/ai-native-visual-rpg/UI_CODING_STANDARD.md)3, 91, 104, 108, 112, 156, 158, 160-166
- [`metadata.json`](/E:/Repos/ai-native-visual-rpg/metadata.json)2-3
### 组件层
- [`src/components/AdventurePanel.tsx`](/E:/Repos/ai-native-visual-rpg/src/components/AdventurePanel.tsx)57, 65
- [`src/components/CharacterPanel.tsx`](/E:/Repos/ai-native-visual-rpg/src/components/CharacterPanel.tsx)37, 65-66, 91-95, 102-103
- [`src/components/GameCanvas.tsx`](/E:/Repos/ai-native-visual-rpg/src/components/GameCanvas.tsx)240, 462
- [`src/components/GameShell.tsx`](/E:/Repos/ai-native-visual-rpg/src/components/GameShell.tsx)108, 116, 124, 138, 171, 181
- [`src/components/InventoryPanel.tsx`](/E:/Repos/ai-native-visual-rpg/src/components/InventoryPanel.tsx)55, 58, 82-83, 181-184, 189, 191
- [`src/components/MapModal.tsx`](/E:/Repos/ai-native-visual-rpg/src/components/MapModal.tsx)105, 108, 136
- [`src/components/MedievalNpcAnimator.tsx`](/E:/Repos/ai-native-visual-rpg/src/components/MedievalNpcAnimator.tsx)124
- [`src/components/NpcVisualEditor.tsx`](/E:/Repos/ai-native-visual-rpg/src/components/NpcVisualEditor.tsx)65, 69-71, 403, 440, 444, 446, 464, 467, 470, 482, 569, 571, 585, 610, 628, 662, 690, 694-695, 697, 722, 751, 759, 775, 777, 781, 824
- [`src/components/PresetEditor.tsx`](/E:/Repos/ai-native-visual-rpg/src/components/PresetEditor.tsx)34-37, 43-44, 94, 96, 349, 470, 472, 480, 482, 512, 516, 519, 525, 568, 612, 618, 637, 639, 643, 645, 652, 661, 677, 740, 769, 771, 779, 781, 806, 809, 820, 831, 835, 837, 840, 848, 871, 894, 916, 918, 930, 932, 950, 953, 956, 960, 962, 990, 1004, 1006, 1012, 1018, 1024, 1030, 1036, 1064, 1120, 1122, 1130, 1132, 1150, 1153, 1156, 1172-1175, 1180, 1182, 1186, 1188, 1199, 1203-1204, 1208, 1240, 1242
### 数据层
- [`src/data/characterPresets.ts`](/E:/Repos/ai-native-visual-rpg/src/data/characterPresets.ts)97, 102, 104, 107, 129, 132-133, 142, 144, 170, 276, 302, 470, 496, 531, 540, 566, 699-700, 729, 972
- [`src/data/medievalNpcVisuals.ts`](/E:/Repos/ai-native-visual-rpg/src/data/medievalNpcVisuals.ts)103, 115, 117, 119, 136, 154, 156, 161, 167, 174, 177, 189, 226, 235-236, 241, 244-245, 249-254, 256-257, 260, 262, 274, 278, 288, 451-453, 565, 568, 577, 592
- [`src/data/monsterPresets.ts`](/E:/Repos/ai-native-visual-rpg/src/data/monsterPresets.ts)41-42, 54, 60-61, 79-80, 92, 98-99, 117-118, 136-137, 155-156, 171-173, 185, 191-192, 204, 210-211, 229-230, 242, 248-249, 261, 267-268, 280, 286-287, 304-305, 323-324, 335
- [`src/data/monsters.ts`](/E:/Repos/ai-native-visual-rpg/src/data/monsters.ts)112
- [`src/data/npcInteractions.ts`](/E:/Repos/ai-native-visual-rpg/src/data/npcInteractions.ts)68-71, 80, 82-83, 161, 165, 173, 182, 188-190, 196, 198, 205, 231, 241, 245, 255-260, 272, 296, 319-320, 372, 444-445, 449, 451, 453, 507, 569-570, 578-579, 587-588, 597, 605-606, 615, 617-618, 626-627, 634, 641-643, 652, 661, 665, 670, 672, 676
- [`src/data/scenePresets.ts`](/E:/Repos/ai-native-visual-rpg/src/data/scenePresets.ts)115, 120, 122, 128, 133, 135, 141, 146, 148, 154, 159, 161, 167, 172, 174, 180, 185, 187, 192-193, 198, 200, 205-206, 211, 213, 219, 224, 226, 232, 237, 239, 245, 250, 252, 258, 263, 265, 274, 279, 281, 287, 292, 294, 299-300, 305, 307, 313, 318, 320, 326, 331, 333, 339, 344, 346, 352, 357, 359, 364-365, 370, 372, 377-378, 383, 385, 390-391, 396, 398, 404, 409, 411, 417, 422, 424, 509, 523, 525
- [`src/data/stateFunctions.ts`](/E:/Repos/ai-native-visual-rpg/src/data/stateFunctions.ts)72-73, 80, 95-96, 103, 117-118, 125, 139-140, 147, 161-162, 169, 186-187, 194, 209-210, 217, 237-238, 255-256, 273-274, 294, 311-312, 329-330, 420, 430-431, 433-435, 437-438, 440-442, 444-445, 447, 449, 451-452, 454-456, 458, 460-461, 464, 466, 468, 484-485, 487, 489, 491, 493, 601, 618
### Hooks 与服务层
- [`src/hooks/useCombatFlow.ts`](/E:/Repos/ai-native-visual-rpg/src/hooks/useCombatFlow.ts)54, 56-58, 566
- [`src/services/ai.ts`](/E:/Repos/ai-native-visual-rpg/src/services/ai.ts)200-201, 209-210, 234-235, 269-270, 309, 311, 317, 338, 341, 358, 382
- [`src/services/prompt.ts`](/E:/Repos/ai-native-visual-rpg/src/services/prompt.ts)7-8, 10, 13-15, 19-20, 25-40, 43, 55, 61-62, 64, 66, 74-76, 78-79, 83-84, 87-90, 96, 103-104, 112, 115, 157, 159, 161-162, 164-165, 167-168, 170, 172-173
### 其他源码
- [`src/uiAssets.ts`](/E:/Repos/ai-native-visual-rpg/src/uiAssets.ts)54, 115, 122, 129, 142, 173, 180
## 本轮人工复核补充
以下位置是在本轮实现过程中直接再次看到的明显乱码文本,建议优先复查:
- [`src/hooks/useCombatFlow.ts`](/E:/Repos/ai-native-visual-rpg/src/hooks/useCombatFlow.ts)1094, 1554, 1556-1557
## 处理优先级建议
### 第一批
- `src/components/GameShell.tsx`
- `src/components/InventoryPanel.tsx`
- `src/components/CharacterPanel.tsx`
- `src/data/characterPresets.ts`
- `src/data/npcInteractions.ts`
- `src/data/scenePresets.ts`
- `src/services/prompt.ts`
### 第二批
- `src/components/PresetEditor.tsx`
- `src/components/NpcVisualEditor.tsx`
- `src/data/monsterPresets.ts`
- `src/data/stateFunctions.ts`
- `docs/AGENT_UI_CHANGELOG.md`
- `UI_CODING_STANDARD.md`
## 备注
- 当前文档的目标是“先把位置收拢清楚”,不是直接修复乱码。
- 如果你下一步要我继续,我可以基于这份清单继续做两件事之一:
- 逐文件修复中文乱码
- 先做一个“乱码修复优先级 + 替换建议”文档

30
docs/README.md Normal file
View File

@@ -0,0 +1,30 @@
# 文档总览
`docs/` 现在按主题拆成了 6 类,`docs/prd/` 保持独立,不参与本次整理改写。
## 快速入口
- [经验沉淀](./experience/README.md)项目开发经验、UI 交接、历史实现经验。
- [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。
- [系统设计](./design/README.md):玩法、关系、物品与对话设计。
- [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解。
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
- [参考目录](./reference/README.md):脚本/Function 速查入口。
- [PRD](./prd/):产品需求与阶段计划,原样保留。
## 推荐阅读顺序
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
2. 再看 [工程审查总览](./audits/engineering/README.md) 和 [文本审计总览](./audits/text/README.md),了解当前风险。
3. 需要排期时看 [规划与优先级](./planning/README.md)。
4. 需要补方案时进入 [系统设计](./design/README.md) / [技术方案](./technical/README.md)。
5. 需要对齐目标边界时再进入 [PRD](./prd/)。
## 分类规则
- `experience/`:偏方法论、交接经验、长期有效的开发结论。
- `audits/`:偏“现状扫描 / 问题定位 / 是否达标”的审查类文档。
- `design/`:偏玩法机制、叙事关系、系统结构设计。
- `technical/`:偏技术选型、实现路线、竞品/产品形态拆解。
- `planning/`:偏阶段优先级与推进顺序。
- `reference/`:偏目录、速查、检索辅助。

View File

@@ -4,10 +4,10 @@
本次审计重点阅读并对照了这些位置:
- `docs/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md`
- `docs/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md`
- `docs/PROJECT_DEVELOPMENT_EXPERIENCE.md`
- `docs/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md`
- `docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md`
- `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md`
- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md`
- `docs/planning/CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md`
- `src/data/stateFunctions.ts`
- `src/data/npcInteractions.ts`
- `src/data/treasureInteractions.ts`

View File

@@ -7,7 +7,7 @@
- `docs/prd/AI_NATIVE_RUNTIME_ITEM_GENERATION_DESIGN.md`
- `docs/prd/RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md`
- `docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md`
- `docs/EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md`
- `docs/design/EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md`
## 结论速览

19
docs/audits/README.md Normal file
View File

@@ -0,0 +1,19 @@
# 审计与复盘
这一组文档聚焦“当前状态是否健康、问题在哪里、和目标设计差多少”。
## 系列总览
- [engineering/README.md](./engineering/README.md):工程优化审查三轮记录的融合入口。
- [text/README.md](./text/README.md):文本、英文残留、乱码审计系列的融合入口。
## 专项审计
- [FUNCTION_DESIGN_AUDIT_2026-04-03.md](./FUNCTION_DESIGN_AUDIT_2026-04-03.md)Function 体系分层、职责边界和当前结构问题。
- [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。
## 推荐使用方式
1. 先读系列总览,确认“最新结论在哪一份”。
2. 再按需要进入具体日期文档,查看当时的证据和上下文。
3. 做方案设计前,优先把对应审计文档看完,避免重复踩已知问题。

View File

@@ -8,7 +8,7 @@
## 先说结论
这轮代码库相较 `docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md` 已经有明显进展,项目不再是“所有能力都糊在一个入口文件里”的状态了,但整体仍然处于“重构过渡期”。
这轮代码库相较 `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md` 已经有明显进展,项目不再是“所有能力都糊在一个入口文件里”的状态了,但整体仍然处于“重构过渡期”。
已经落地的积极变化:

View File

@@ -0,0 +1,173 @@
# 怪物-NPC 脚本统一整改审计
日期2026-04-06
## 核心结论
当前工程仍然没有真正落实“怪物就是初始好感度为负数的 NPC”这一原则。
现状不是“NPC 脚本里支持 hostile 状态”,而是同时存在两条并行链路:
1. `npc / encounter / npcStates / npcInteraction`
2. `monster / hostileNpc / sceneMonsters / sceneHostileNpcs / hostileNpcPresets`
这会直接导致:
- 同一个敌对实体同时拥有 NPC 身份和 monster 身份。
- 场景、战斗、渲染、提示词都在维护两套入口。
- 后续修 bug 时,任何位置、死亡、血条、入场、掉落问题都要同时查两条链路。
本次文档的目标不是立刻改代码,而是先把应该删除的分叉脚本、应该降级成素材层的文件、以及必须合并的字段全部列清楚,作为后续统一改造的依据。
## 当前违背原则的根因
### 1. 场景数据仍然把“怪物”和“NPC”当成两类实体
当前场景层同时维护:
- `ScenePreset.monsterIds`
- `SceneNpc[]`
- `ScenePresetInfo.hostileNpcIds`
- `SceneNpc.monsterPresetId / hostileNpcPresetId`
这意味着场景里一个敌对单位既可以来自 `monsterIds`,也可以来自 `npcs`,甚至会被脚本再生成为 hostile scene npc。
### 2. 运行时仍然存在“怪物专用实体状态”
当前运行时仍然同时维护:
- `GameState.sceneMonsters`
- `GameState.sceneHostileNpcs`
- `SceneHostileNpc / SceneMonster`
这和“怪物本质上只是 hostile NPC”是冲突的。真正统一后运行时只应该保留一套“场景 NPC / 战斗 NPC”状态。
### 3. 战斗脚本仍然把怪物当独立 actor
战斗层目前不是“NPC 战斗,只是 hostile 的那部分会出手”,而是显式写了:
- `TurnActor = 'player' | 'companion' | 'monster'`
- `getClosestMonster`
- `resetCombatPresentation(monsters, ...)`
- `sceneMonsters` 全链路结算
这会强制后面所有视觉、掉落、提示词、AI 上下文都跟着叫 monster。
### 4. 渲染层仍然有 monster 专属显示入口
画布层当前仍然依赖:
- `sceneMonsters`
- `sceneHostileNpcs`
- `monsterPresetId`
- `HostileNpcAnimator`
也就是“敌对 NPC 是否按 NPC 脚本渲染”这件事,到最终显示层仍然没有统一。
## 一级删除清单
下面这些文件属于“业务流程分叉脚本”,不是单纯资源适配层。后续统一时应优先删除或并入 NPC 主链路。
| 文件 | 当前分叉角色 | 处理建议 |
| --- | --- | --- |
| `src/data/monsters.ts` | 对 `hostileNpcs.ts` 的别名出口 | 直接删除,禁止继续保留 monster 专属入口名。 |
| `src/data/hostileNpcs.ts` | 负责 monster 创建、编队、距离、朝向、变化、落位 | 按 hostile NPC 运行时工具重写并并入 NPC 体系;原文件名不应继续保留。 |
| `src/data/sceneEncounterPreviews.ts` | 单独构造 hostile encounter group、auto battle、hostile preview | 删除 monster 专线逻辑,改为“负好感 NPC 预览/入场/转战斗”。 |
| `src/hooks/combat/battlePlan.ts` | 使用 `monster` actor、`sceneMonsters``getClosestMonster` | 改成统一的 hostile NPC combatant 规划脚本monster actor 概念应移除。 |
| `src/hooks/combat/playback.ts` | 使用 `sceneMonsters` 播放怪物战斗演出 | 改成统一 NPC 战斗回放;不再区分 monster 播放器。 |
| `src/components/game-canvas/GameCanvasRuntime.tsx` | 运行时按 `sceneMonsters / sceneHostileNpcs` 双数据源选敌方实体 | 删除双源兜底,统一成单一 hostile NPC 列表。 |
| `src/components/game-canvas/GameCanvasEntityLayer.tsx` | 敌方实体按 monsterPreset 分支渲染 | 改成同一套 NPC 实体渲染,视觉差异仅由 visual preset 决定。 |
| `src/components/game-canvas/GameCanvasShared.tsx` | 定义 `sceneMonsters / sceneHostileNpcs` props 与 monster 专属计算 | 删除这些字段与 helper改成统一 NPC 画布协议。 |
| `src/components/preset-editor/MonsterPresetPanel.tsx` | 独立怪物预设编辑面板 | 并回 NPC hostile visual preset 面板,或删除该独立编辑器入口。 |
| `src/components/preset-editor/MonsterPresetTab.tsx` | 独立怪物预设页签 | 与上面一并删除或并入 NPC preset 编辑器。 |
| `src/components/preset-editor/ScenePresetPanel.tsx` | 仍然单独编辑 `monsterIds` | 删除 `monsterIds` 编辑项,只保留场景 NPC 列表。 |
## 二级归并清单
下面这些文件不一定需要物理删除,但它们当前仍然在放大 monster / NPC 分轨,必须在统一改造时一起收口。
| 文件 | 当前问题 | 处理建议 |
| --- | --- | --- |
| `src/data/scenePresets.ts` | 通过 `monsterIds` 再生 hostile scene npc并区分 `getSceneHostileNpcs / getSceneFriendlyNpcs` | 保留场景数据文件本身,但删除 `monsterIds` 体系,让敌对角色直接存在于 `npcs` 中。 |
| `src/data/customWorldNpcMonsters.ts` | 用单独脚本推导“怪物型 NPC”预设 | 可保留为 hostile visual preset 选择器,但不能再生成第二套实体语义。 |
| `src/data/hostileNpcPresets.ts` | 目前既是视觉预设库,也是独立 hostile 流程的数据源 | 降级为 hostile visual/combat preset 库;不再拥有独立实体生命周期。 |
| `src/components/HostileNpcAnimator.tsx` | 当前名字和调用语义都在暗示“独立怪物实体” | 可以保留为贴图播放器,但应改为 hostile NPC 的视觉适配组件,而不是独立物种脚本。 |
| `src/components/AdventureEntityModal.tsx` | 详情弹窗仍会优先查 monster preset / hostileNpcPreset | 统一读取 NPC 档案;视觉差异只通过 hostile preset 补充。 |
| `src/components/SkillEffectPreview.tsx` | 预览器直接使用 `sceneMonsters``createSceneMonstersFromIds` | 改成统一 hostile NPC 预览态。 |
| `src/components/StateFunctionEditor.tsx` | 编辑器里仍然直接造 monster battle preview | 改成 hostile NPC preview。 |
| `src/components/preset-editor/SceneNpcPresetPanel.tsx` | 仍然暴露 `monsterPresetId` 字段 | 改成更明确的 hostile visual preset 字段避免“怪物类型”和“NPC 类型”双语义。 |
| `src/hooks/story/npcEncounterActions.ts` | 虽然入口叫 npc但内部仍然写 `sceneMonsters / sceneHostileNpcs` | 改成统一 hostile NPC 战斗状态字段。 |
| `src/hooks/story/choiceActions.ts` | 仍然有 `buildHostileNpcBattleReward``getResolvedSceneHostileNpcs` 这一层额外概念 | 统一到 hostile NPC 结算工具,不再把“敌对 NPC”和“monster”混称。 |
| `src/hooks/useStoryGeneration.ts` | 给 AI/剧情层传入 `sceneMonsters``sceneHostileNpcs` | 改成统一的 hostile NPC 上下文切片。 |
| `src/services/prompt.ts` | 仍然从 `monsterIds``createSceneMonstersFromIds` 组 prompt | 改成从场景 NPC 列表中筛出 hostile NPC。 |
| `src/services/questDirector.ts` | 仍然依赖 `monsterPresetId` 推导当前敌对目标 | 统一改为基于负好感或 hostile 标记的 NPC。 |
| `src/services/ai.ts` | 仍然混用 `monsterIds`、sceneNpc 的 hostile 判定 | 与场景统一后改成只读 NPC 列表。 |
| `src/services/questTypes.ts` | 仍然把 `hostileNpcIds / monsterIds` 当作 scene 快照字段 | 删除 `monsterIds`,保留 hostile NPC 语义。 |
## 可保留但必须降级为“素材/配置层”的内容
下面这些内容不一定要消失,但不能继续作为独立业务链路存在:
| 文件/内容 | 可以保留的原因 | 必须收口的边界 |
| --- | --- | --- |
| `src/components/HostileNpcAnimator.tsx` | 怪物贴图是特殊资源,需要专门 sprite sheet 播放器 | 只负责画图,不再决定实体类型、战斗身份、交互入口。 |
| `src/data/hostileNpcPresets.ts` | hostile visual/combat preset 仍然有价值 | 只能作为 hostile NPC 的 visual/combat preset 库不再驱动另一套“monster 实体”。 |
| `src/data/hostileNpcOverrides.json` | 资源级 override 仍可继续用 | 不能再配套出独立 hostile 流程。 |
| `src/data/monsterOverrides.json` | 如果只是素材映射,可迁移到 hostile visual preset override | 不应继续以 monster 专属命名长期存在。 |
| `src/data/customWorldNpcMonsters.ts` | 自定义世界里确实需要从文本匹配 hostile visual preset | 只能产出“NPC 使用哪个 hostile visual preset”不能产出独立 monster 身份。 |
## 字段级必须合并的内容
后续改代码时,至少要把下面这些字段和类型一起收口:
| 当前字段/类型 | 问题 | 合并方向 |
| --- | --- | --- |
| `ScenePreset.monsterIds` | 场景里额外保存一份怪物池 | 删除,只保留 `npcs`。 |
| `ScenePresetInfo.hostileNpcIds` | 历史遗留双字段 | 直接由 `npcs.filter(initialAffinity < 0 或 hostile)` 推导。 |
| `Encounter.monsterPresetId` | 把 hostile NPC 再次物种化 | 改成 hostile visual preset 字段,或并入统一 visualRef。 |
| `Encounter.hostileNpcPresetId` | 与 `monsterPresetId` 语义重叠 | 与上面合并为一个字段。 |
| `GameState.sceneMonsters` | 把敌对 NPC 单独塞进 monster 容器 | 改成统一 `sceneNpcCombatants` 或等价单一列表。 |
| `GameState.sceneHostileNpcs` | 历史兼容层,导致双数据源 | 删除。 |
| `SceneHostileNpc / SceneMonster` | 类型名直接固化了分轨 | 改成统一的 hostile NPC / scene combat NPC 类型。 |
| `SceneHostileNpcChange / SceneMonsterChange` | 继续复制同一套变更结构 | 合并成统一 NPC scene change。 |
| `SceneNpc.monsterPresetId / hostileNpcPresetId` | 同一实体上挂两套 preset 入口 | 收敛为一个 hostile visual/combat preset 字段。 |
## 本轮最优先的删除顺序
建议后续真正改代码时,按下面顺序删并,风险最低:
1. 先删字段入口:`monsterIds / sceneHostileNpcs / hostileNpcPresetId`
2. 再删运行时双轨:`src/data/monsters.ts``src/data/hostileNpcs.ts``src/data/sceneEncounterPreviews.ts`
3. 再删战斗双轨:`battlePlan.ts``playback.ts` 里的 `monster` actor 与 `sceneMonsters`
4. 再删画布双轨:`GameCanvasRuntime.tsx``GameCanvasEntityLayer.tsx``GameCanvasShared.tsx`
5. 最后清编辑器和提示词:`MonsterPresetPanel.tsx``MonsterPresetTab.tsx``prompt.ts``questDirector.ts`
## 改造后的目标形态
统一后应只剩下这一套语义:
- 场景中所有可见角色都放在 `npcs`
- 怪物 = `initialAffinity < 0``hostile = true` 的 NPC
- hostile 的视觉差异只来自 hostile visual preset
- 战斗中所有敌方单位都属于 hostile NPC combatant
- AI、任务、渲染、详情、掉落都只读同一套 NPC 数据
如果后面代码里还出现下面这些关键词,基本都说明分轨没有删干净:
- `sceneMonsters`
- `sceneHostileNpcs`
- `monsterIds`
- `hostileNpcPresetId`
- `createSceneMonstersFromIds`
- `getClosestMonster`
- `TurnActor = 'monster'`
## 这份文档的使用方式
后续正式开始改造时,建议把文件分成三批执行:
1. “直接删掉”的入口脚本
2. “改名并并回 NPC 主链路”的桥接脚本
3. “仅保留素材职责”的 renderer / preset 文件
不要继续接受“名字叫 NPC但内部仍然先转成 monster 再跑”的中间态。

View File

@@ -0,0 +1,19 @@
# 工程优化审查总览
这一组是同主题的连续审查记录,建议不要把它们当作三份彼此独立的文档来看。
## 当前推荐入口
1. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md)
这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。
2. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md)
适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。
3. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md)
适合看第一轮系统性工程扫描,了解最早的问题基线。
## 融合结论
- 三轮结论是一致收敛的:问题不在“有没有开始工程化”,而在“工程化是否真正覆盖了最危险的主链路”。
- 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。
- 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。
- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01` 的顺序回看演进。

View File

@@ -0,0 +1,91 @@
# 中文乱码位置清单
更新时间:`2026-03-24`
## 说明
- 本文档用于记录仓库内已确认或高置信度疑似存在中文乱码的位置。
- 当前这份文档是重建版本;原有的 [`docs/audits/text/CHINESE_MOJIBAKE_INVENTORY.md`](/E:/Repos/Genarrative/docs/audits/text/CHINESE_MOJIBAKE_INVENTORY.md) 本身也已经乱码,因此已整体替换。
- 本次整理依据:
- 仓库内旧清单中的完整文件/行号信息
- 本轮人工复核时再次直接看到的明显乱码位置
- 由于仓库内同时存在“文件内容已写坏”和“终端/工具显示失真”两类情况,下面清单优先保留高置信位置,便于后续逐项修复。
## 扫描范围
- 已纳入:`src/``docs/`、根目录文档与元数据文件
- 已排除:`.git/``node_modules/``dist/`、纯图片资源目录
## 高置信位置
### 文档与元数据
- [`docs/experience/AGENT_UI_CHANGELOG.md`](/E:/Repos/Genarrative/docs/experience/AGENT_UI_CHANGELOG.md)1, 3, 7, 9, 11-18, 24, 26-28, 32-33, 37, 39, 41-43, 47, 49, 51, 53-66, 68, 72, 74, 77-79, 83, 87, 89-90, 94, 96, 98-100, 104, 106-112, 116
- [`UI_CODING_STANDARD.md`](/E:/Repos/Genarrative/UI_CODING_STANDARD.md)3, 91, 104, 108, 112, 156, 158, 160-166
- [`metadata.json`](/E:/Repos/Genarrative/metadata.json)2-3
### 组件层
- [`src/components/AdventurePanel.tsx`](/E:/Repos/Genarrative/src/components/AdventurePanel.tsx)57, 65
- [`src/components/CharacterPanel.tsx`](/E:/Repos/Genarrative/src/components/CharacterPanel.tsx)37, 65-66, 91-95, 102-103
- [`src/components/GameCanvas.tsx`](/E:/Repos/Genarrative/src/components/GameCanvas.tsx)240, 462
- [`src/components/GameShell.tsx`](/E:/Repos/Genarrative/src/components/GameShell.tsx)108, 116, 124, 138, 171, 181
- [`src/components/InventoryPanel.tsx`](/E:/Repos/Genarrative/src/components/InventoryPanel.tsx)55, 58, 82-83, 181-184, 189, 191
- [`src/components/MapModal.tsx`](/E:/Repos/Genarrative/src/components/MapModal.tsx)105, 108, 136
- [`src/components/MedievalNpcAnimator.tsx`](/E:/Repos/Genarrative/src/components/MedievalNpcAnimator.tsx)124
- [`src/components/NpcVisualEditor.tsx`](/E:/Repos/Genarrative/src/components/NpcVisualEditor.tsx)65, 69-71, 403, 440, 444, 446, 464, 467, 470, 482, 569, 571, 585, 610, 628, 662, 690, 694-695, 697, 722, 751, 759, 775, 777, 781, 824
- [`src/components/PresetEditor.tsx`](/E:/Repos/Genarrative/src/components/PresetEditor.tsx)34-37, 43-44, 94, 96, 349, 470, 472, 480, 482, 512, 516, 519, 525, 568, 612, 618, 637, 639, 643, 645, 652, 661, 677, 740, 769, 771, 779, 781, 806, 809, 820, 831, 835, 837, 840, 848, 871, 894, 916, 918, 930, 932, 950, 953, 956, 960, 962, 990, 1004, 1006, 1012, 1018, 1024, 1030, 1036, 1064, 1120, 1122, 1130, 1132, 1150, 1153, 1156, 1172-1175, 1180, 1182, 1186, 1188, 1199, 1203-1204, 1208, 1240, 1242
### 数据层
- [`src/data/characterPresets.ts`](/E:/Repos/Genarrative/src/data/characterPresets.ts)97, 102, 104, 107, 129, 132-133, 142, 144, 170, 276, 302, 470, 496, 531, 540, 566, 699-700, 729, 972
- [`src/data/medievalNpcVisuals.ts`](/E:/Repos/Genarrative/src/data/medievalNpcVisuals.ts)103, 115, 117, 119, 136, 154, 156, 161, 167, 174, 177, 189, 226, 235-236, 241, 244-245, 249-254, 256-257, 260, 262, 274, 278, 288, 451-453, 565, 568, 577, 592
- [`src/data/monsterPresets.ts`](/E:/Repos/Genarrative/src/data/monsterPresets.ts)41-42, 54, 60-61, 79-80, 92, 98-99, 117-118, 136-137, 155-156, 171-173, 185, 191-192, 204, 210-211, 229-230, 242, 248-249, 261, 267-268, 280, 286-287, 304-305, 323-324, 335
- [`src/data/monsters.ts`](/E:/Repos/Genarrative/src/data/monsters.ts)112
- [`src/data/npcInteractions.ts`](/E:/Repos/Genarrative/src/data/npcInteractions.ts)68-71, 80, 82-83, 161, 165, 173, 182, 188-190, 196, 198, 205, 231, 241, 245, 255-260, 272, 296, 319-320, 372, 444-445, 449, 451, 453, 507, 569-570, 578-579, 587-588, 597, 605-606, 615, 617-618, 626-627, 634, 641-643, 652, 661, 665, 670, 672, 676
- [`src/data/scenePresets.ts`](/E:/Repos/Genarrative/src/data/scenePresets.ts)115, 120, 122, 128, 133, 135, 141, 146, 148, 154, 159, 161, 167, 172, 174, 180, 185, 187, 192-193, 198, 200, 205-206, 211, 213, 219, 224, 226, 232, 237, 239, 245, 250, 252, 258, 263, 265, 274, 279, 281, 287, 292, 294, 299-300, 305, 307, 313, 318, 320, 326, 331, 333, 339, 344, 346, 352, 357, 359, 364-365, 370, 372, 377-378, 383, 385, 390-391, 396, 398, 404, 409, 411, 417, 422, 424, 509, 523, 525
- [`src/data/stateFunctions.ts`](/E:/Repos/Genarrative/src/data/stateFunctions.ts)72-73, 80, 95-96, 103, 117-118, 125, 139-140, 147, 161-162, 169, 186-187, 194, 209-210, 217, 237-238, 255-256, 273-274, 294, 311-312, 329-330, 420, 430-431, 433-435, 437-438, 440-442, 444-445, 447, 449, 451-452, 454-456, 458, 460-461, 464, 466, 468, 484-485, 487, 489, 491, 493, 601, 618
### Hooks 与服务层
- [`src/hooks/useCombatFlow.ts`](/E:/Repos/Genarrative/src/hooks/useCombatFlow.ts)54, 56-58, 566
- [`src/services/ai.ts`](/E:/Repos/Genarrative/src/services/ai.ts)200-201, 209-210, 234-235, 269-270, 309, 311, 317, 338, 341, 358, 382
- [`src/services/prompt.ts`](/E:/Repos/Genarrative/src/services/prompt.ts)7-8, 10, 13-15, 19-20, 25-40, 43, 55, 61-62, 64, 66, 74-76, 78-79, 83-84, 87-90, 96, 103-104, 112, 115, 157, 159, 161-162, 164-165, 167-168, 170, 172-173
### 其他源码
- [`src/uiAssets.ts`](/E:/Repos/Genarrative/src/uiAssets.ts)54, 115, 122, 129, 142, 173, 180
## 本轮人工复核补充
以下位置是在本轮实现过程中直接再次看到的明显乱码文本,建议优先复查:
- [`src/hooks/useCombatFlow.ts`](/E:/Repos/Genarrative/src/hooks/useCombatFlow.ts)1094, 1554, 1556-1557
## 处理优先级建议
### 第一批
- `src/components/GameShell.tsx`
- `src/components/InventoryPanel.tsx`
- `src/components/CharacterPanel.tsx`
- `src/data/characterPresets.ts`
- `src/data/npcInteractions.ts`
- `src/data/scenePresets.ts`
- `src/services/prompt.ts`
### 第二批
- `src/components/PresetEditor.tsx`
- `src/components/NpcVisualEditor.tsx`
- `src/data/monsterPresets.ts`
- `src/data/stateFunctions.ts`
- `docs/experience/AGENT_UI_CHANGELOG.md`
- `UI_CODING_STANDARD.md`
## 备注
- 当前文档的目标是“先把位置收拢清楚”,不是直接修复乱码。
- 如果你下一步要我继续,我可以基于这份清单继续做两件事之一:
- 逐文件修复中文乱码
- 先做一个“乱码修复优先级 + 替换建议”文档

View File

@@ -0,0 +1,27 @@
# 文本与乱码审计总览
这一组文档记录的是同一条清理链路的不同阶段:从“发现哪里有英文/乱码”到“扩展到 prompt、npcInteraction、编辑器深层文本”。
## 当前推荐入口
1. [CHINESE_MOJIBAKE_INVENTORY.md](./CHINESE_MOJIBAKE_INVENTORY.md)
偏全局库存清单,适合先确认问题分布范围。
2. [GAME_UI_PRESET_EDITOR_NPC_PROMPT_TEXT_AUDIT_2026-04-02_DEEP_SCAN.md](./GAME_UI_PRESET_EDITOR_NPC_PROMPT_TEXT_AUDIT_2026-04-02_DEEP_SCAN.md)
这是当前最完整的深度审计版本,已经扩到 `prompt``npcInteractions`、运行时弹窗与编辑器深层文本。
3. [GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md](./GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-02.md)
适合看“扩展重查版”的 UI / 预设 / 编辑器问题面。
## 历史时间线
- [EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md](./EDITOR_GAME_PRESET_TEXT_AUDIT_2026-03-25.md):较早期的整体首轮盘点。
- [GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md](./GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-29.md):复查阶段,开始收紧范围和口径。
- [GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md](./GAME_EDITOR_PRESET_TEXT_AUDIT_2026-03-30.md):继续复核真实乱码与英文残留。
- [GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md](./GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-30_CONTINUED.md):对上一轮的续扫补充。
- [GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md](./GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-03-31.md):继续收敛 UI、预设与编辑器问题。
- [GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md](./GAME_UI_PRESET_EDITOR_TEXT_AUDIT_2026-04-01.md):进入更明确的审计范围与方法阶段。
## 融合结论
- 早期几份文档主要负责“摸清哪里有问题”。
- `2026-04-02` 两份文档开始把重点收敛到真正会影响玩家体验和 AI 生成质量的链路。
- 现在做文本修复时,不必从最早一份开始逐篇读;优先看 `CHINESE_MOJIBAKE_INVENTORY``2026-04-02` 两份即可。

View File

@@ -0,0 +1,492 @@
# 自定义世界创作者输入与 AI 分工边界设计
更新时间:`2026-04-06`
## 0. 目标
这份文档回答一个非常关键的问题:
**在“低创作门槛、高创作自由度”的前提下,自定义世界里哪些内容应该交给创作者直接定义,哪些内容应该交给 AI 和系统完成。**
这里默认我们的创作者:
- 不需要有专业作家背景
- 不需要有专业游戏设计背景
- 但希望作品有明显个人风格,而不是只是在用一个会自动补全设定的模板工具
一句话目标:
**让创作者把精力放在“决定这个世界为什么值得被创作”,把 AI 用在“把这个世界展开、编译、铺开、校验、补足”。**
## 1. 总体结论
自定义世界的分工边界应该遵守 3 条硬原则:
1. 灵魂归创作者,杂活归 AI。
- 凡是决定作品气质、主题、冲突、人物关系、审美方向的内容,都应由创作者掌握。
2. 重点对象归创作者,长尾铺量归 AI。
- 创作者应重点塑造少量关键角色、关键地点、关键冲突、关键意象,而不是被迫手填几十个 NPC、几十个场景、几百条描述。
3. 决策归创作者,编译归 AI / 系统。
- 创作者负责说“这个世界要成为什么样”AI / 系统负责把它编译成可运行的数据、规则、文本、关系钩子和运行时结构。
这意味着:
- 创作者应该主要编辑“高杠杆创作锚点”
- AI 应该主要承担“批量展开 + 结构编译 + 一致性维护 + 专业执行”
## 2. 什么内容应该交给创作者
真正应该交给创作者的,不是大量表格字段,而是下面这些会显著决定作品质量、且 AI 不擅长替代的内容。
## 2.1 世界核心命题
创作者应该直接定义:
- 这个世界的一句话设定
- 这个世界最吸引人的核心幻想
- 玩家来到这个世界,最想体验的感觉是什么
- 这个世界和常规同题材作品相比,最不同的地方是什么
原因:
- 这是作品的创作方向盘
- 一旦这一层是空的,后面所有 AI 扩写都会变成“像一个世界”,而不是“这个世界”
## 2.2 主题、气质与边界
创作者应该直接定义:
- 主题关键词
- 情绪基调
- 审美偏好
- 禁忌内容 / 不希望出现的表达
- 可以接受的黑暗度、浪漫度、残酷度、神秘度
原因:
- 这决定了 AI 后续生成时的“味道”
- 这类判断很难靠 AI 替代,因为它本质上不是信息补全,而是审美取舍
## 2.3 玩家身份与开局处境
创作者应该直接定义:
- 玩家扮演的是什么人
- 玩家一开始最缺什么、最想要什么
- 开局时玩家被卷入什么局面
- 玩家在这个世界里天然站在哪个位置上
原因:
- 这决定了整个世界的观看视角
- 同一个世界,玩家视角不同,最终体验会完全不同
## 2.4 核心冲突与关键势力
创作者应该直接定义少量高价值内容:
- 世界当前最重要的 `2~4` 条明面冲突
- 世界背后最关键的 `1~3` 条暗面问题
- `2~6` 个关键势力
- 这些势力各自想要什么、害怕什么、互相卡住了什么
原因:
- 冲突结构决定世界是否“有戏”
- 势力关系是 AI 最容易写散、写平、写成百科介绍的部分
- 这一层由创作者把握,才能真正提高作品的辨识度
## 2.5 关键角色与关系张力
创作者应该直接定义少量关键角色,而不是所有 NPC。
建议重点交给创作者的,是:
- `3~8` 个关键角色
- 玩家与这些人的潜在关系
- 这些角色彼此之间的债、仇、秘密、误解、利益绑定
- 每个关键角色“表面上像什么、实际上压着什么”
原因:
- 角色关系是最能显著提升作品质量的部分之一
- 这也是 AI 最容易写得“完整但无味”的部分
- 创作者不需要写长篇背景,但应掌握这些角色真正的关系骨架
## 2.6 关键地点与空间记忆点
创作者应该直接定义:
- `4~12` 个关键地点 / 区域 / 地标
- 这些地方为什么重要
- 这些地方承载什么冲突、危险、秘密或情绪记忆
- 玩家第一次来到这里时应该感到什么
原因:
- “地方感”是世界质量的重要来源
- 关键地点一旦成立AI 后续才能稳定地生成周边事件、物件、NPC 和线索
## 2.7 标志性意象、物件、怪物、制度与规则
创作者应该优先控制世界里最能代表它的东西:
- 标志性物件
- 标志性怪物 / 生物
- 标志性能力体系 / 修炼体系 / 技术体系
- 标志性社会制度 / 宗教 / 仪式 / 禁忌
- 世界的硬规则
原因:
- 这些内容决定世界的“手感”
- 它们不是普通细节,而是会反复影响命名、剧情、视觉、对话与玩法解释的母题
## 2.8 创作者应直接控制的“禁止事项”
创作者必须能明确锁定:
- 什么绝对不能改
- 什么不能被 AI 自动扩写到别的方向
- 哪些角色、地点、关系、设定是核心锚点
- 哪些内容允许 AI 自由发挥,哪些只能在锚点附近变体
原因:
- 高自由度不等于所有内容都开放漂移
- 如果没有“锁定机制”AI 会把创作者真正关心的内容稀释掉
## 3. 什么内容应该交给 AI 和系统
应该交给 AI 的,不是“重要内容”,而是“重要内容之外的大量展开、编译、补缝、校验与专业执行”。
## 3.1 批量生成的长尾内容
应该主要交给 AI
- 普通 NPC
- 路人、商贩、巡逻者、村民、杂兵
- 次级场景
- 场景支线事件
- 大量普通物品
- 世界的长尾命名与描述
原因:
- 这些内容数量大、重复度高
- 它们需要“贴合世界”,但不需要都由创作者逐个手写
- AI 很适合做“围绕锚点的批量铺量”
## 3.2 从创作锚点到系统结构的编译
应该交给 AI / 系统:
- 从自然语言世界设定中提取题材词汇
- 从关键冲突中编译出世界叙事图谱
- 从关键角色卡编译出角色叙事档案
- 从创作者输入里自动生成标签、钩子、隐藏线索、章节摘要
- 从地点和关系中编译出场景连接、事件触发和叙事回响
对应当前仓库,下面这些结构更适合由 AI / 系统生成,而不是让玩家直接编辑:
- `ThemePack`
- `WorldStoryGraph`
- `ActorNarrativeProfile`
- `KnowledgeFact`
- `VisibilitySlice`
- `SceneNarrativeDirective`
- `CarrierStoryFingerprint`
- `ThreadContract`
- `StorySignal`
原因:
- 这些是运行时结构,不是创作者真正想表达的作品内容
- 直接暴露给玩家,会把创作过程变成专业数据填表
## 3.3 专业化、规则化的任务
应该交给 AI / 系统:
- 数值平衡
- 标签归纳
- 稀有度预算
- 初始技能与初始物品的批量配置
- build 方向匹配
- 地图连接补全
- 触发条件与推进信号编译
- 背景章节拆分与 teaser 生成
- 运行时物件命名与叙事描述的变体生成
原因:
- 这些工作要么重复、要么专业、要么容易做脏活累活
- 让非专业创作者处理,会显著提高门槛,却不一定显著提高质量
## 3.4 一致性、纠错与查漏补缺
应该交给 AI / 系统持续处理:
- 世界设定冲突检查
- 角色关系矛盾检查
- 同名 / 重复 / 设定撞车检查
- 信息越权泄露检查
- prompt 裁剪
- 风格一致性检查
- “这个角色/地点/物件是否真的和世界主线有关”的弱关联检查
原因:
- 这是 AI 比人更适合做的“维护型工作”
- 它属于创作支持,不属于创作者必须亲手完成的创作
## 4. 最合理的边界不是二分法,而是三层分工
自定义世界最合理的结构不是“玩家写”与“AI 写”的简单二选一,而是三层。
## 4.1 第一层:创作者必控层
这一层必须给创作者高自由度,且能被锁定:
- 世界核心命题
- 主题与气质
- 玩家身份与开局
- 核心冲突
- 关键势力
- 关键角色
- 关键地点
- 标志性物件 / 怪物 / 规则
- 禁止事项
这层的原则是:
**少而重。**
## 4.2 第二层:创作者可选强化层
这一层不应强制填写,但应该允许创作者继续深挖:
- 明线 / 暗线种子
- 角色之间的旧事
- 地点背后的旧伤
- 标志性物件的来历
- 关键角色的口头习惯、禁忌、执念
- 关键地点的视觉母题与情绪目标
这层的原则是:
**愿意细写的人可以拉高作品上限,不愿细写的人也不会被门槛卡住。**
## 4.3 第三层AI 自动展开层
这一层默认交给 AI / 系统:
- 长尾 NPC
- 次级地点
- 章节拆分
- 初始技能
- 初始物品
- 标签与属性映射
- 任务 contract
- 物件叙事指纹
- 可见性裁剪
- 运行时导演指令
- 批量命名与文案变体
这层的原则是:
**AI 可以做多、做快、做杂,但不能越过第一层锁定内容。**
## 5. 具体模块的建议归属
| 模块 | 建议归属 | 创作者应控制什么 | AI / 系统应负责什么 |
| --- | --- | --- | --- |
| 世界一句话设定、核心幻想、核心卖点 | 创作者直接控制 | 直接写、直接改、可锁定 | 给出备选表述和扩展方向 |
| 主题、基调、审美、禁忌 | 创作者直接控制 | 选择 / 改写 / 锁定 | 生成风格词、避雷词、提示词约束 |
| 玩家身份、开局处境、玩家目标 | 创作者直接控制 | 直接定义 | 补足开局钩子和初始叙事包装 |
| 关键势力与核心冲突 | 创作者主控AI 辅助 | 定义核心关系和立场 | 扩展冲突支路、生成世界线程 |
| 关键角色 | 创作者主控AI 辅助 | 定义角色骨架、关系张力、秘密方向 | 生成长背景、章节拆分、技能、物品、叙事档案 |
| 关键地点 | 创作者主控AI 辅助 | 定义地点意义、气氛、秘密 | 扩展场景细节、连接关系、遭遇分布 |
| 标志性物件 / 怪物 / 制度 / 规则 | 创作者主控AI 辅助 | 定义代表性要素与硬边界 | 扩展变体、命名、说明、运行时挂钩 |
| 普通 NPC / 路人 / 杂兵 / 次级地点 | 主要交给 AI | 仅在需要时抽查或替换 | 批量生成与风格保持 |
| 角色长背景、章节 teaser、context snippet | 主要交给 AI | 创作者只改关键角色即可 | 自动拆章、压缩、解锁节奏整理 |
| 技能、初始物品、标签、构筑倾向 | 主要交给 AI / 系统 | 提供偏好或少量 override | 按角色和世界规则自动编译 |
| 世界图谱、知识事实、可见性、导演指令 | AI / 系统内部层 | 不应默认暴露给玩家 | 运行时编译与维护 |
| 一致性检查、冲突检查、越权检查 | AI / 系统内部层 | 查看报告、决定是否采纳修改 | 自动扫描并提出修正建议 |
## 6. 不应该要求玩家直接填写的字段
为了真正做到低门槛,下面这些内容不应直接以“专业字段”形式强迫玩家填写。
## 6.1 不应该要求玩家手填原始数值
例如:
- `initialAffinity`
- `dangerLevel`
- 精确数值型 build 倾向
- 复杂掉落预算
更合理的做法是让创作者填写直觉表达,例如:
- `初见就戒备`
- `容易合作`
- `这里非常危险`
- `偏爆发型`
再由系统编译成运行时数值。
## 6.2 不应该要求玩家手填技术型结构
例如:
- `tags`
- `attributeSchema`
- `ThemePack`
- `WorldStoryGraph`
- `VisibilitySlice`
- `SceneNarrativeDirective`
- `ThreadContract`
原因:
- 这些字段属于系统运行结构,不属于创作者自然的创作语言
- 直接让玩家填,会把工具变成只有懂系统的人才能用
## 6.3 不应该要求玩家逐个补完所有人物设定字段
当前 `CustomWorldRoleProfile` 里这些字段:
- `backstory`
- `personality`
- `motivation`
- `combatStyle`
- `backstoryReveal`
- `skills`
- `initialItems`
更适合的做法不是全部让玩家手写,而是先让玩家填写更自然的“角色卡”:
- 这个人表面上是什么样
- 这个人真正想要什么
- 这个人最不想被提到什么
- 这个人和玩家之间最可能形成什么关系
- 这个人和哪个地点 / 物件 / 旧事绑得最紧
再由 AI / 系统编译成当前结构。
## 7. 推荐的创作输入形态
要让非专业创作者也能高自由度创作,输入形态必须改成“自然语言创作卡”,而不是“系统字段表单”。
## 7.1 世界层卡片
建议至少有这些卡片:
1. 世界一句话
2. 主题与气质
3. 玩家是谁
4. 核心冲突
5. 关键势力
6. 关键角色
7. 关键地点
8. 标志性要素
9. 禁止事项
## 7.2 每张卡片都允许 3 种输入方式
1. 一句话自由输入
- 适合低门槛创作者
2. 标签 / 选项 / 语气滑条
- 适合不想写太多字的创作者
3. 高级补充
- 适合愿意继续深挖的人
这样才能做到:
- 不会逼着用户写长文
- 但也不会限制愿意创作的人继续把世界做深
## 7.3 必须支持“锁定”与“局部重生成”
这是高创作自由度里非常关键的一点。
创作者应当能:
- 锁定一个角色
- 锁定一个地点
- 锁定一条冲突
- 只重生成未锁定部分
- 围绕锁定内容重写其余世界
否则创作者每次调用 AI都会有“好不容易想好的东西被洗掉”的感受。
## 8. 面向当前仓库的结构映射建议
为了便于后续落实现有系统,这份边界建议可以直接映射到当前结构:
## 8.1 创作者输入层
建议主要映射到:
- `CustomWorldProfile.settingText`
- `CustomWorldProfile.name`
- `CustomWorldProfile.subtitle`
- `CustomWorldProfile.summary`
- `CustomWorldProfile.tone`
- `CustomWorldProfile.playerGoal`
- `CustomWorldProfile.majorFactions`
- `CustomWorldProfile.coreConflicts`
以及关键角色、关键地点的创作卡输入。
## 8.2 AI 编译层
由 AI / 系统从创作者输入自动补出:
- `themePack`
- `storyGraph`
- `knowledgeFacts`
- `threadContracts`
- 每个关键角色的 `narrativeProfile`
- 每个角色的 `backstoryReveal`
- 每个角色的 `skills`
- 每个角色的 `initialItems`
## 8.3 运行时支持层
运行时继续由 AI / 系统维护:
- `VisibilitySlice`
- `SceneNarrativeDirective`
- `CarrierStoryFingerprint`
- `StorySignal`
这些内容应该是“系统如何把世界跑起来”,不是“创作者必须亲手写完的创作内容”。
## 9. 产品层面的最终结论
如果我们的目标真的是“低创作门槛、高创作自由度”,那么自定义世界不应该做成一个要求用户:
- 填很多字段
- 写很多长文
- 理解很多系统结构
- 自己负责平衡、命名、拆章节、补标签、补长尾内容
的专业编辑器。
它应该做成这样:
1. 创作者决定世界的灵魂锚点。
2. 创作者重点塑造少量关键人、关键地、关键冲突、关键物。
3. AI 围绕这些锚点批量展开长尾内容。
4. 系统把这些内容编译成可运行的图谱、可见性、任务、物件和关系结构。
5. 创作者随时可以锁定核心创意,并局部重生成其余部分。
一句话收束:
**创作者应该写“这个世界为什么动人”AI 应该负责“让这个世界长出来并跑起来”。**

18
docs/design/README.md Normal file
View File

@@ -0,0 +1,18 @@
# 系统设计
这一组是玩法、关系、物品、对话等系统设计文档,偏“应该怎么设计”而不是“现在哪里出问题”。
## 文档列表
- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里创作者输入与 AI 分工边界设计。
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
- [EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md](./EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md):配装构筑与合成/锻造闭环设计。
- [COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md](./COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md):角色首遇感、关系分层解锁、私聊系统设计。
- [npc-conversation-situation-draft.md](./npc-conversation-situation-draft.md)NPC 对话阶段和情景注入草案。
## 推荐阅读
- 做物品、Build、锻造相关需求时先看前两份。
- 做自定义世界创作工作台、创作者输入边界、AI 分工设计时,先看第一份。
- 做角色关系、同伴互动、对话表现时,先看后两份。
- 如果要判断是否符合目标,再和 `docs/prd/` 中对应 PRD 对照阅读。

View File

@@ -35,7 +35,7 @@
完成内容:
- 将 [NpcVisualEditor](/E:/Repos/ai-native-visual-rpg/src/components/NpcVisualEditor.tsx) 嵌入 [PresetEditor](/E:/Repos/ai-native-visual-rpg/src/components/PresetEditor.tsx) 的 NPC 编辑页
- 将 [NpcVisualEditor](/E:/Repos/Genarrative/src/components/NpcVisualEditor.tsx) 嵌入 [PresetEditor](/E:/Repos/Genarrative/src/components/PresetEditor.tsx) 的 NPC 编辑页
- 让 NPC 文本字段与视觉字段围绕同一个当前选中 NPC 联动
- 保留视觉覆盖保存与全局布局保存能力
@@ -48,7 +48,7 @@
完成内容:
- 在 [medievalNpcVisuals.ts](/E:/Repos/ai-native-visual-rpg/src/data/medievalNpcVisuals.ts) 中重建了 Medieval NPC 的资产定义
- 在 [medievalNpcVisuals.ts](/E:/Repos/Genarrative/src/data/medievalNpcVisuals.ts) 中重建了 Medieval NPC 的资产定义
- 补齐了 cloth / leather / metal / melee / magic / ranged 六大类真实素材
- 为素材增加了:
- 语义化名称
@@ -68,7 +68,7 @@
完成内容:
- 在 [MedievalNpcAnimator.tsx](/E:/Repos/ai-native-visual-rpg/src/components/MedievalNpcAnimator.tsx) 中为 `AtlasSprite` 增加:
- 在 [MedievalNpcAnimator.tsx](/E:/Repos/Genarrative/src/components/MedievalNpcAnimator.tsx) 中为 `AtlasSprite` 增加:
- `tileWidth`
- `tileHeight`
- 对齐偏移支持
@@ -90,7 +90,7 @@
完成内容:
- 在 [characterPresets.ts](/E:/Repos/ai-native-visual-rpg/src/data/characterPresets.ts) 中重新核对 5 个玩家角色的 Hero 动画目录
- 在 [characterPresets.ts](/E:/Repos/Genarrative/src/data/characterPresets.ts) 中重新核对 5 个玩家角色的 Hero 动画目录
- 修正了错误帧数、错误前缀、遗漏动作
- 补齐了真实存在但之前未接入的动作:
- `acquire`
@@ -106,9 +106,9 @@
相关文件:
- [types.ts](/E:/Repos/ai-native-visual-rpg/src/types.ts)
- [CharacterAnimator.tsx](/E:/Repos/ai-native-visual-rpg/src/components/CharacterAnimator.tsx)
- [characterCombat.ts](/E:/Repos/ai-native-visual-rpg/src/data/characterCombat.ts)
- [types.ts](/E:/Repos/Genarrative/src/types.ts)
- [CharacterAnimator.tsx](/E:/Repos/Genarrative/src/components/CharacterAnimator.tsx)
- [characterCombat.ts](/E:/Repos/Genarrative/src/data/characterCombat.ts)
经验:
@@ -121,7 +121,7 @@
完成内容:
- 在 [monsterPresets.ts](/E:/Repos/ai-native-visual-rpg/src/data/monsterPresets.ts) 中把怪物动画从“连续帧猜测”改成“按图集行起点取帧”
- 在 [monsterPresets.ts](/E:/Repos/Genarrative/src/data/monsterPresets.ts) 中把怪物动画从“连续帧猜测”改成“按图集行起点取帧”
- 补上缺失的 `die` 配置
- 清除了所有落进空白格的动画段
@@ -151,11 +151,11 @@
完成内容:
- 在 [StateFunctionEditor.tsx](/E:/Repos/ai-native-visual-rpg/src/components/StateFunctionEditor.tsx) 中新增 `BehaviorExecutionPreview`
- 在 [StateFunctionEditor.tsx](/E:/Repos/Genarrative/src/components/StateFunctionEditor.tsx) 中新增 `BehaviorExecutionPreview`
- 预览不再是静态推测,而是:
1. 构造本地 `GameState`
2. 调用真实 `resolveFunctionOption`
3. 再调用 [useCombatFlow.ts](/E:/Repos/ai-native-visual-rpg/src/hooks/useCombatFlow.ts) 的
3. 再调用 [useCombatFlow.ts](/E:/Repos/Genarrative/src/hooks/useCombatFlow.ts) 的
- `buildResolvedChoiceState`
- `playResolvedChoice`
- 从而直接复用游戏真实逻辑

View File

@@ -260,7 +260,7 @@ node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0
如需继续细看已有沉淀,可结合以下文档一起阅读:
- `docs/PROJECT_DEVELOPMENT_EXPERIENCE.md`
- `docs/MOBILE_UI_DEV_EXPERIENCE.md`
- `docs/CODEX_IMPLEMENTATION_EXPERIENCE_2026-03-24.md`
- `docs/AGENT_UI_CHANGELOG.md`
- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md`
- `docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`
- `docs/experience/CODEX_IMPLEMENTATION_EXPERIENCE_2026-03-24.md`
- `docs/experience/AGENT_UI_CHANGELOG.md`

25
docs/experience/README.md Normal file
View File

@@ -0,0 +1,25 @@
# 经验沉淀
这一组文档主要回答两个问题:
- 这个项目开发时有哪些稳定有效的方法论。
- 遇到 UI、运行时、编辑器、AI 边界问题时,优先应该怎么判断。
## 推荐入口
1. [PROJECT_WORK_EXPERIENCE_PLAYBOOK.md](./PROJECT_WORK_EXPERIENCE_PLAYBOOK.md):最完整的项目开发手册,适合先建立全局认识。
2. [PROJECT_DEVELOPMENT_EXPERIENCE.md](./PROJECT_DEVELOPMENT_EXPERIENCE.md):项目级经验浓缩版,适合快速回顾。
3. [ADVENTURE_RUNTIME_DEV_EXPERIENCE.md](./ADVENTURE_RUNTIME_DEV_EXPERIENCE.md)专门看运行时、战斗、演出、NPC 流程时优先读。
4. [MOBILE_UI_DEV_EXPERIENCE.md](./MOBILE_UI_DEV_EXPERIENCE.md):做移动端/游戏 UI 时的布局和交互经验。
5. [AGENT_UI_CHANGELOG.md](./AGENT_UI_CHANGELOG.md):当前 UI 改动脉络、资产约束和已知坑。
## 历史实现经验
- [CODEX_IMPLEMENTATION_EXPERIENCE_2026-03-24.md](./CODEX_IMPLEMENTATION_EXPERIENCE_2026-03-24.md):偏“这类需求怎么拆链路”的实战经验。
- [CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md](./CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md):偏“之前做过什么、怎么做的”的历史记录。
## 使用建议
- 只需要读一份时,优先看 `PROJECT_WORK_EXPERIENCE_PLAYBOOK`
- 做 UI 改动时,把本目录和根目录的 `UI_CODING_STANDARD.md` 对照着看。
- 做运行时流程改动时,把本目录和 `docs/audits/engineering/README.md` 一起看,能更快发现风险边界。

View File

@@ -23,7 +23,7 @@
### 为什么必须排第一
- `docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` 已明确指出:当前最值得优先优化的不是继续加功能,而是把“半完成的工程化”补齐。
- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` 已明确指出:当前最值得优先优化的不是继续加功能,而是把“半完成的工程化”补齐。
- 文档中提到过 `lint` 失败、`build` warning、核心热区文件被 ESLint ignore、部分测试未进入默认套件这意味着当前代码库还不在真正稳定的绿色基线。
- 在这种状态下继续叠加新玩法,只会把问题扩散到更多运行时链路和编辑器链路。
@@ -51,8 +51,8 @@
### 为什么必须紧跟在 P0-1 后面
- `docs/PROJECT_DEVELOPMENT_EXPERIENCE.md``docs/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md``docs/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md` 都反复强调:这个项目是叙事、状态、演出、界面四条链路耦合的复合项目,不能靠大文件硬扛。
- `docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md``docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` 一致指出,`useStoryGeneration``useCombatFlow``GameShell` 仍然是当前最大的复杂度集中点。
- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md``docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md``docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md` 都反复强调:这个项目是叙事、状态、演出、界面四条链路耦合的复合项目,不能靠大文件硬扛。
- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md``docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` 一致指出,`useStoryGeneration``useCombatFlow``GameShell` 仍然是当前最大的复杂度集中点。
- 如果不先拆主链,后面的统一属性系统、任务系统、物品导演层都会继续堆进现有巨型流程控制器,技术债只会翻倍。
### 本阶段要做什么
@@ -133,7 +133,7 @@
### 为什么它值得排在任务系统前面
- `docs/AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md` 明确指出当前宝藏、NPC、任务、锻造等入口都有物品但缺少统一导演层奖励与场景/NPC/事件的贴合度不够高。
- `docs/design/AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md` 明确指出当前宝藏、NPC、任务、锻造等入口都有物品但缺少统一导演层奖励与场景/NPC/事件的贴合度不够高。
- 相比任务系统,运行时物品奖励能更快提升“世界贴脸感”和“当下反馈质量”,且可以先从宝藏入口低风险落地。
### 本阶段要做什么
@@ -206,7 +206,7 @@
### 为什么它放在 P2
- `docs/PROJECT_DEVELOPMENT_EXPERIENCE.md``docs/MOBILE_UI_DEV_EXPERIENCE.md` 都强调:冒险页必须优先保证上方演出、一屏选项和文本区自适应。
- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md``docs/experience/MOBILE_UI_DEV_EXPERIENCE.md` 都强调:冒险页必须优先保证上方演出、一屏选项和文本区自适应。
- 但从当前文档判断,移动端体验和包体问题更像“持续治理项”,不是当前阶段最核心的系统阻塞点。
### 本阶段要做什么
@@ -265,13 +265,13 @@
## 本清单的主要依据
- `docs/PROJECT_DEVELOPMENT_EXPERIENCE.md`
- `docs/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md`
- `docs/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md`
- `docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md`
- `docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md`
- `docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md`
- `docs/AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md`
- `docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md`
- `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md`
- `docs/experience/ADVENTURE_RUNTIME_DEV_EXPERIENCE.md`
- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md`
- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md`
- `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md`
- `docs/design/AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md`
- `docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md`
- `docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md`
- `docs/prd/AI_NATIVE_QUEST_SYSTEM_PRD_2026-04-02.md`

10
docs/planning/README.md Normal file
View File

@@ -0,0 +1,10 @@
# 规划与优先级
## 当前入口
- [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。
## 使用建议
- 需要排期、拆阶段、判断先修基线还是先加功能时,先看这份。
- 这份文档大量引用了经验文档、工程审查和 PRD适合作为跨文档导航页使用。

View File

@@ -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 质感”。

View 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 原生剧情框架之上。

View File

@@ -0,0 +1,699 @@
# AI 原生自定义世界生成流程优化 PRD
更新时间:`2026-04-06`
## 0. 文档目的
这份 PRD 用于基于 [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](../design/CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md) 的分工结论,系统优化当前自定义世界生成流程。
目标不是推翻当前已经存在的多阶段生成链,而是解决下面这个核心错位:
**当前仓库已经开始把世界生成拆成 `framework -> themePack -> storyGraph -> role outline -> dossier -> narrativeProfile` 的分阶段 AI 编译流程,但创作者入口仍然是“一段大文本”,结果页又把大量低杠杆字段重新扔回给创作者人工兜底。**
一句话定义本次优化:
**让创作者先定义世界灵魂锚点,再让 AI / 系统围绕锚点分层生成、分层展开、分层可控地完成长尾内容。**
## 1. 当前流程现状
## 1.1 当前用户流程
当前自定义世界的实际产品链路大致是:
1. 世界选择页点击“创建自定义世界”
2. 弹出创建弹窗,只输入一段 `世界设定文本`
3. 调用 `generateCustomWorldProfile(...)`
4. 进入分阶段生成页,依次跑:
- 世界框架
- 题材适配层
- 世界线程图谱
- 可扮演角色骨架
- 场景角色骨架
- 场景骨架
- 场景连接
- 角色叙事补全
- 角色档案补全
- 角色叙事档案补全
- 最终归档
5. 生成完成后进入结果页
6. 在结果页里按 `世界 / 可扮演角色 / 场景角色 / 场景` 四个页签逐项编辑
7. 保存并进入世界
对应当前仓库的主要模块:
- 输入入口:`src/components/SelectionCustomizationModals.tsx`
- 预开局流程:`src/components/game-shell/PreGameSelectionFlow.tsx`
- 分阶段生成:`src/services/ai.ts`
- prompt 与结构归一:`src/services/customWorld.ts`
- 扩展与编译:`src/services/customWorldBuilder.ts`
- 结果页:`src/components/CustomWorldResultView.tsx`
- 结果目录:`src/components/CustomWorldEntityCatalog.tsx`
- 实体编辑器:`src/components/CustomWorldEntityEditorModal.tsx`
## 1.2 当前流程已经做对了什么
当前流程并不是“完全错误”,它已经有几个很值得保留的基础:
1. 生成链已经不是单次大 JSON 直出,而是分阶段生成。
2. 已经存在 `themePack / storyGraph / narrativeProfile / knowledgeFacts / threadContracts` 这类更接近 AI 原生剧情引擎的结构。
3. 已经有阶段进度反馈,而不是黑盒等待。
4. 已经有结果页与人工编辑能力,可以作为后续工作台的基础。
5. 已经有 normalize / fallback / repair prompt 机制,说明链路具备继续工程化的基础。
本次优化应尽量复用这些已有能力,而不是回到“单次 prompt 直接生世界”的旧思路。
## 1.3 当前流程的核心问题
## 1.3.1 创作者入口过于粗糙
当前创建入口只有一块大文本输入框。
这会直接导致:
1. 不会写长描述的用户很难开局。
2. 愿意精细创作的用户没有结构化落点。
3. 系统无法明确分辨“哪些是创作者真正想锁定的锚点,哪些只是随口补充的描述”。
结果就是:
**输入端自由但信息信号不稳定AI 虽然能生成很多内容,却不一定生成的是创作者真正关心的内容。**
## 1.3.2 创作者与 AI 的职责发生倒置
当前流程实际上是:
- 创作者先写一段泛化设定
- AI 再把整个世界铺满
- 创作者最后回到结果页,人工修改大量角色、章节、技能、初始物品、场景连接等细节
这与“低创作门槛、高创作自由度”的目标相反。
因为真正应该由创作者控制的,是:
- 世界核心命题
- 主题与气质
- 玩家视角
- 核心冲突
- 关键角色
- 关键地点
- 标志性物件 / 怪物 / 禁忌
而不是让创作者在结果页里逐个补:
- `backstoryReveal.chapters`
- `skills`
- `initialItems`
- `sceneNpcIds`
- `connections`
- 各类长尾角色与场景的细枝末节
## 1.3.3 关键内容与长尾内容没有分层
当前流程会稳定生成:
- `5` 个可扮演角色
- `25+` 个场景角色
- `10+` 个场景
问题不在数量本身,而在于系统并没有明确区分:
1. 哪些是创作者应重点塑造的关键对象
2. 哪些只是 AI 应自动展开的长尾铺量
这会导致两个问题:
1. AI 在早期就花大量成本生成长尾内容,等待时间长。
2. 创作者在结果页里面对的是一整套“全部都生成了”的世界,而不是“先抓关键锚点,再决定是否继续铺开”。
## 1.3.4 当前结果页暴露了过多低杠杆字段
当前结果页和实体编辑器允许编辑的字段过多,而且很多是低创作价值、系统结构化字段:
- 背景章节的标题、阈值、teaser、content、contextSnippet
- 角色技能与初始物品
- 场景 NPC 分配
- 场景连接网络
这对“专业创作者”当然有帮助,但对目标用户来说,容易把工具变成:
**看起来自由度很高,实际上需要承担很多系统编辑工作。**
## 1.3.5 当前重新生成是“整世界覆盖式”的
当前结果页的“重新生成”会清空并重做当前世界的所有信息。
这意味着:
1. 创作者一旦修改过内容,就会担心被覆盖。
2. 没有“锁定关键内容,只重生成长尾部分”的机制。
3. AI 无法真正成为创作搭档,只像一次性大批量生成器。
## 1.3.6 当前生成阶段是“模型视角”,不是“创作者视角”
当前生成页展示的是系统批次和阶段进度,这很好,但它主要回答的是:
- 现在模型在跑哪一步
没有回答的是:
- 创作者最关心的关键角色是否已经成型
- 世界冲突是否已经稳定
- 当前这轮已经锁定了哪些核心创意
- 接下来生成的是关键锚点,还是长尾内容
也就是说:
**当前链路的进度可见了,但创作过程仍然没有真正可见。**
## 2. 本次优化的设计目标
这次优化要同时满足 6 个目标:
1. 降低输入门槛
- 不要求创作者一上来写长文,不要求理解系统字段。
2. 提高高杠杆创作自由度
- 让创作者直接控制世界灵魂锚点,而不是低价值细节。
3. 明确创作者与 AI 的职责边界
- 创作者负责“决定什么值得创作”AI 负责“把它展开并跑起来”。
4. 保留现有分阶段生成骨架
- 不推翻 `framework -> themePack -> storyGraph -> role/landmark` 的已有结构。
5. 引入锁定与局部重生成
- 让创作者能保住自己在乎的内容,只重做其余部分。
6. 把结果页从“数据总表”升级成“创作工作台”
- 让编辑界面按创作价值组织,而不是按底层对象堆字段。
## 3. 核心产品结论
优化后的自定义世界流程应该改为:
```text
创作者输入世界锚点
-> AI 编译创作者意图摘要
-> 创作者确认 / 锁定关键锚点
-> AI 先生成关键角色与关键地点
-> 创作者可局部修改 / 局部重生成
-> AI 再展开长尾 NPC、长尾场景与运行时编译结构
-> 结果页以“锚点 / 关键对象 / 扩展内容 / 运行时摘要”方式组织
-> 保存并进入世界
```
一句话:
**先做创作决策,再做内容展开;先做关键对象,再做长尾铺量;先让创作者锁定灵魂,再让 AI 扩散世界。**
## 4. 输入层优化方案
## 4.1 把“单一大文本”升级为“创作卡片”
新的输入层不应只有一块大 textarea而应拆成卡片式输入。
建议至少包含 9 张卡:
1. 世界一句话
2. 主题与气质
3. 玩家是谁
4. 核心冲突
5. 关键势力
6. 关键角色
7. 关键地点
8. 标志性要素
9. 禁止事项
每张卡都支持:
1. 一句话输入
2. 标签 / 选项辅助
3. 高级补充展开
这样做的目的不是让用户填更多,而是让:
- 不会写长文的人,也能靠卡片完成创作输入
- 愿意深挖的人,也有明确位置继续提升质量
## 4.2 保留“自由输入模式”,但不再只靠它
当前的 `settingText` 不应被废弃,而应保留为:
1. 快速模式
2. 导入模式
3. 兜底模式
系统应支持两种输入入口:
1. 快速文本模式
- 用户只写一段设定,系统自动拆分成创作卡片建议
2. 卡片模式
- 用户直接按结构化方式输入世界锚点
两种模式最终都编译成统一的创作者意图对象。
## 4.3 必填与选填要分开
不应要求用户每一张卡都填完。
建议:
- 必填:
- 世界一句话
- 主题与气质
- 玩家是谁
- 核心冲突
- 选填:
- 关键势力
- 关键角色
- 关键地点
- 标志性要素
- 禁止事项
这样既能保证世界最小成型,又不会把创作者门槛抬高。
## 4.4 明确支持“锁定”
每张卡片、每个关键角色、每个关键地点都应支持锁定。
锁定后:
1. AI 不得在重生成时覆盖该内容
2. 长尾内容只能围绕它展开
3. 结果页里应明确显示其为“创作者锚点”
## 5. 生成链路优化方案
## 5.1 新增“创作者意图编译层”
在真正开始世界生成前,先新增一个轻量阶段:
**Creator Intent Compile**
输入:
- 文本模式输入
- 或卡片模式输入
输出:
- 创作者意图摘要
- 世界锚点摘要
- 系统识别出的关键角色 / 冲突 / 地点 / 禁忌
这一步的作用不是生成世界,而是先回答:
1. 系统理解到的世界核心是什么
2. 哪些内容将被视为创作者强锚点
3. 哪些内容将交给 AI 扩展
## 5.2 把当前生成链改成“关键先行、长尾后补”
当前 `generateCustomWorldProfile(...)` 的分阶段结构可以保留,但生成顺序需要更创作者化。
建议改成 5 层:
### 第一层:世界锚点层
先生成:
- 世界框架
- ThemePack
- StoryGraph 的基础版
- 创作者锚点摘要
这一层完成后,系统应能让创作者看到:
- 世界现在到底被理解成了什么
- 哪些冲突 / 势力 / 意象被识别出来了
### 第二层:关键对象层
优先生成:
- 关键可扮演角色
- 关键场景角色
- 关键地点
这一层优先围绕创作者明确输入的角色和地点,而不是先铺满全部数量。
### 第三层:创作者校对层
在继续展开长尾内容前,应允许创作者做一次轻量校对:
- 确认关键角色是否对
- 确认关键地点是否对
- 锁定已经满意的内容
- 对不满意的关键对象做局部重生成
### 第四层:长尾展开层
在关键层稳定后,再生成:
- 普通场景角色
- 路人 / 杂兵 / 补位 NPC
- 次级地点
- 扩展连接关系
- 长尾命名和描述
### 第五层:运行时编译层
最后再编译:
- `themePack`
- `storyGraph`
- `knowledgeFacts`
- `threadContracts`
- `narrativeProfile`
- 各类运行时支持结构
## 5.3 支持“快速开局生成”和“完整展开生成”
建议提供两种生成策略:
1. 快速开局
- 先完成世界锚点 + 关键对象 + 最小可玩档案
- 用户可以更快看到世界雏形
2. 完整展开
- 一次性继续补齐长尾场景角色、地标和完整世界网络
这样做的价值很高:
1. 降低首次等待焦虑
2. 让创作者更早介入关键对象校正
3. 避免系统在创作方向还没稳定前,先铺满大量长尾内容
## 5.4 角色与场景生成要改成“锚点优先 + 长尾补位”
当前角色与场景的生成方式更像“按配额批量产出”。
优化后应改为:
1. 先生成创作者明确指定的关键角色 / 地点
2. 再根据世界冲突自动补位缺失的角色原型和场景功能位
3. 最后再铺长尾
这样生成出来的世界会更像“围绕创作者意图长出来”,而不是“先生成了一个完整世界,再让创作者去认领”
## 6. 结果页与编辑工作台优化方案
## 6.1 结果页不应再按“底层对象目录”组织为主
当前结果页主要按:
- 世界
- 可扮演角色
- 场景角色
- 场景
来组织。
优化后建议改成 4 层工作台:
1. 创作锚点
- 展示创作者输入和锁定内容
2. 关键对象
- 关键角色、关键地点、关键冲突对象
3. 扩展内容
- AI 自动展开的长尾角色、长尾地点、补位内容
4. 世界编译摘要
- 展示世界线程、题材包、运行时摘要,但默认不要求创作者编辑
## 6.2 编辑界面应遵守“高价值字段前置,低价值字段折叠”
对创作者默认暴露的应是:
- 角色一句话定位
- 角色表面面貌
- 角色真正想要什么
- 角色与谁有关
- 地点为什么重要
- 地点承载什么冲突
- 世界哪些规则不能动
不应默认把下面这些字段摆在第一屏:
- `backstoryReveal.chapters`
- `skills`
- `initialItems`
- `contextSnippet`
- `sceneNpcIds`
- `connections`
这些应被系统下沉到:
- 自动生成
- 高级模式
- 或局部展开区域
## 6.3 新增局部重生成
结果页必须支持至少 4 种局部重生成:
1. 仅重生成某个关键角色
2. 仅重生成某个关键地点
3. 仅重生成长尾场景角色
4. 仅重生成场景网络 / 长尾连接
并且所有局部重生成都必须:
- 尊重锁定内容
- 显示影响范围
- 避免全局覆盖
## 6.4 新增“AI 建议修订”而非“只允许手改”
结果页不应只提供人工编辑,还应提供:
- 让 AI 重写一句话定位
- 让 AI 补强冲突张力
- 让 AI 改得更克制 / 更黑暗 / 更传奇 / 更现实
- 让 AI 只围绕已锁定内容变体
这样才能真正体现“AI 是创作辅助,而不是一次性生成器”。
## 7. 数据结构建议
## 7.1 新增 `CustomWorldCreatorIntent`
建议新增创作者输入的统一结构:
```ts
interface CustomWorldCreatorIntent {
sourceMode: 'freeform' | 'card';
rawSettingText: string;
worldHook: string;
themeKeywords: string[];
toneDirectives: string[];
playerPremise: string;
openingSituation: string;
coreConflicts: string[];
keyFactions: CreatorFactionSeed[];
keyCharacters: CreatorCharacterSeed[];
keyLandmarks: CreatorLandmarkSeed[];
iconicElements: string[];
forbiddenDirectives: string[];
}
```
作用:
- 把“创作者真正输入了什么”从最终 `CustomWorldProfile` 中分离出来
## 7.2 新增 `CustomWorldAnchorPack`
建议新增系统编译后的锚点包:
```ts
interface CustomWorldAnchorPack {
worldSummary: string;
creatorIntentSummary: string;
lockedAnchorIds: string[];
keyConflictSummaries: string[];
keyFactionSummaries: string[];
keyCharacterAnchors: ActorAnchor[];
keyLandmarkAnchors: LandmarkAnchor[];
motifDirectives: string[];
}
```
作用:
- 让后续长尾扩展、局部重生成、结果页工作台都围绕同一套“创作锚点”工作
## 7.3 新增 `CustomWorldLockState`
```ts
interface CustomWorldLockState {
worldLockedFields: string[];
lockedCharacterIds: string[];
lockedLandmarkIds: string[];
lockedConflictIds: string[];
lockedFactionIds: string[];
}
```
作用:
- 明确哪些内容 AI 不能重写
## 7.4 新增 `CustomWorldGenerationDraft`
```ts
interface CustomWorldGenerationDraft {
creatorIntent: CustomWorldCreatorIntent;
anchorPack: CustomWorldAnchorPack | null;
profile: CustomWorldProfile | null;
lockState: CustomWorldLockState;
generationMode: 'fast' | 'full';
generationStatus: 'idle' | 'compiling' | 'generating_key' | 'generating_long_tail' | 'ready';
}
```
作用:
- 让“创作者输入、AI 编译、结果编辑”成为连续工作流,而不是只有最终成品对象
## 8. 与当前仓库的接入建议
## 8.1 输入层
优先改:
- `src/components/SelectionCustomizationModals.tsx`
- `src/components/game-shell/PreGameSelectionFlow.tsx`
目标:
- 把单 textarea 升级为“快速模式 + 卡片模式”
- 新增创作者意图状态
- 新增锁定和局部重生成入口
## 8.2 prompt 与生成服务层
优先改:
- `src/services/customWorld.ts`
- `src/services/ai.ts`
目标:
- 新增 Creator Intent Compile prompt
- 新增 Anchor Pack compile prompt
- 新增局部重生成 prompt
- 保留现有 `framework / themePack / storyGraph / role / landmark` 生成骨架
## 8.3 Builder 与结构层
优先改:
- `src/services/customWorldBuilder.ts`
- `src/types/customWorld.ts`
目标:
-`CustomWorldProfile` 增加创作者意图与锚点相关扩展字段
- 保持旧档兼容
- 让现有 builder 能同时消费 `creatorIntent + anchorPack + profile seed`
## 8.4 结果页与编辑器层
优先改:
- `src/components/CustomWorldResultView.tsx`
- `src/components/CustomWorldEntityCatalog.tsx`
- `src/components/CustomWorldEntityEditorModal.tsx`
目标:
- 结果页由“数据目录”转为“创作工作台”
- 支持局部重生成
- 高价值字段前置,低价值字段折叠
- 允许 AI 辅助改写,而不只是手工编辑
## 9. 明确不做什么
本次优化不做以下事情:
1. 不推翻当前自定义世界最终输出仍是 `CustomWorldProfile` 的兼容目标
2. 不把所有运行时结构都暴露给创作者直接编辑
3. 不要求创作者理解 `themePack / storyGraph / knowledgeFacts / threadContracts` 等系统结构
4. 不把复杂数值平衡、掉落预算、build 预算转移给创作者
5. 不把“高自由度”理解成“所有字段都手工可改”
## 10. 验收标准
做到以下几点,才算这次优化真正成立:
1. 创作者可以不用写长文,只靠卡片输入也能完成自定义世界创建。
2. 系统会明确区分“创作者锚点”和“AI 自动展开内容”。
3. 创作者不再需要默认手改大量 `skills / initialItems / backstoryReveal / scene connections` 才能得到可用世界。
4. 结果页支持锁定关键角色、关键地点、关键冲突,并支持局部重生成。
5. 重新生成不再默认覆盖整个世界。
6. 当前 `framework -> themePack -> storyGraph -> role/landmark` 生成主链可以继续复用,而不是被废弃。
7. 结果页默认展示的是高创作价值对象,而不是系统级低层字段。
8. 长尾内容生成明显后置于关键对象生成,创作者能更早看到并修正关键对象。
9. 旧的自由文本输入模式仍然可用,但不再是唯一入口。
## 11. 推荐落地顺序
## 阶段 A先加创作者意图层
先做:
- `CustomWorldCreatorIntent`
- 输入卡片 UI
- 快速文本 -> 卡片建议编译
目标:
- 先把创作者输入从“单一大文本”升级成“可识别的创作锚点”
## 阶段 B再加锚点包与锁定能力
先做:
- `CustomWorldAnchorPack`
- `CustomWorldLockState`
- 锁定 UI
目标:
- 让后续生成真正知道“哪些不能动”
## 阶段 C再改生成顺序
先做:
- 关键对象优先
- 长尾内容后置
- 快速开局模式
目标:
- 缩短“看到关键结果”的等待时间
## 阶段 D最后重做结果页工作台
先做:
- 锚点页
- 关键对象页
- 扩展内容页
- 局部重生成
目标:
- 让结果页真正成为创作工具,而不是人工补表页面
## 12. 一句话结论
当前自定义世界流程最需要优化的,不是“让 AI 再多生成一点内容”,而是:
**把创作者从低价值字段编辑里解放出来,让创作者负责世界灵魂锚点,让 AI 负责围绕这些锚点分层生成、分层展开、分层可控地把世界长出来。**

View File

@@ -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 背景写得更长,而是:
**让世界里的每个人、每件物都像被主线和暗线真正碰过,名字里有来路,描述里有旧事,对话里有保留,低好感时也能让玩家感觉到背后压着一整段没被说出来的故事。**

View File

@@ -0,0 +1,869 @@
# AI 原生剧情引擎第一阶段技术落地方案
更新时间:`2026-04-06`
## 0. 文档目的
这份方案用于把以下三份 PRD 收束成当前仓库可直接开工的第一阶段技术实现方案:
- `docs/prd/AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md`
- `docs/prd/AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md`
- `docs/prd/AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md`
目标不是一次把“经典 RPG 体验”全部做完,而是在当前项目框架内先做出一个最小但完整的剧情引擎闭环,让后续可以继续往《仙剑》《轩辕剑》《古剑》《黑神话》《博德之门》所代表的体验能力上叠。
这份方案必须满足两个约束:
1. 不推翻当前仓库已有的 `customWorld / prompt / npcInteractions / questDirector / runtimeItem` 主链。
2. 第一阶段做完后,玩家能立刻感知到内容质量提升,而不是只有内部结构更“先进”。
## 1. 第一阶段目标
第一阶段只做 4 件必须一起成立的事:
1. 给自定义世界补 `题材适配层 + 世界线程图谱 + 角色叙事档案`
2. 给运行时 prompt 补 `信息可见性裁剪 + 情境导演最小指令`
3. 给重点 NPC 补 `低好感也有戏` 的首遇表达与最小关系立场矩阵
4. 给重点运行时物件补 `叙事指纹`,让名称、描述、来源真正开始承载故事
一句话定义第一阶段:
**先把“世界会说话、角色会藏话、物件会带话、prompt 不再全知”这四个基础能力接起来。**
## 2. 第一阶段完成定义
第一阶段完成后,必须同时满足下面这些结果:
1. 新生成的自定义世界,结构里会稳定带上 `ThemePack``WorldStoryGraph``ActorNarrativeProfile`
2. 自定义世界 NPC 在首遇或低披露阶段,不再把完整 `backstory` 与所有未解锁章节直接注入 prompt。
3. 低初始好感 NPC 的首轮文本,会出现“当前压力 + 错位说辞 + 暗线钩子”,而不只是更冷淡。
4. 稀有以上或重点奖励物件,会带 `storyFingerprint`,名称与描述不再只是 build 方向模板句。
5. 旧存档和旧自定义世界数据仍能平滑读取,缺失字段有 fallback。
6. 至少一条主链已经把“世界线程 -> 角色档案 -> 可见信息 -> 文本生成 -> 记忆回写”跑通。
## 3. 范围控制
## 3.1 第一阶段纳入范围
纳入范围的模块:
- `src/services/customWorld.ts`
- `src/services/prompt.ts`
- `src/data/npcInteractions.ts`
- `src/hooks/useStoryGeneration.ts`
- `src/services/questDirector.ts`
- `src/services/runtimeItemAiPrompt.ts`
- `src/data/runtimeItemNarrative.ts`
- `src/types/customWorld.ts`
- `src/types/game.ts`
- `src/types/scene.ts`
- `src/types/runtimeItem.ts`
- `src/services/aiTypes.ts`
新增模块:
- `src/types/storyEngine.ts`
- `src/services/storyEngine/themePack.ts`
- `src/services/storyEngine/worldStoryGraph.ts`
- `src/services/storyEngine/actorNarrativeProfile.ts`
- `src/services/storyEngine/visibilityEngine.ts`
- `src/services/storyEngine/sceneNarrativeDirector.ts`
- `src/services/storyEngine/carrierNarrativeCompiler.ts`
## 3.2 第一阶段明确不做
以下内容留到第二阶段及以后,不放进本阶段:
1. 全量营地事件系统
2. 队友 approval / disapproval 的完整 UI 面板
3. 复杂多结局主线编排器
4. 全题材内容包与人工词典大规模沉淀
5. 所有叙事载体类型一次性接入
6. 全支线线程化改造
7. 复杂 scene residue 地图化编辑器
原因很简单:
**第一阶段的目标是把底层语法和最小闭环接通,而不是把所有经典 RPG 能力一次铺满。**
## 4. 第一阶段最小闭环
建议把第一阶段的最小闭环定义为:
```text
自定义世界生成
-> 题材适配层
-> 世界线程图谱
-> 角色叙事档案
-> 遭遇时构造可见性切片
-> 构造场景导演指令
-> 生成首遇 / 对话 / 任务 / 物件叙事
-> 回写最小剧情记忆
```
这个闭环里,先只强接两个高价值落点:
1. 大世界 NPC 遭遇
2. 运行时重点物件
原因:
- 这两条链最容易让玩家立刻感到“内容变得像经典 RPG 了”
- 也最符合当前仓库已经有的主链结构
## 5. 数据结构落地方案
## 5.1 新增 `src/types/storyEngine.ts`
建议把第一阶段的引擎基础类型集中到一个新文件,避免继续把新语义散进已有 `story.ts`
建议包含这些结构:
```ts
export interface ThemePack {
id: string;
displayName: string;
toneRange: string[];
institutionLexicon: string[];
tabooLexicon: string[];
artifactClasses: string[];
actorArchetypes: string[];
conflictForms: string[];
clueForms: string[];
namingPatterns: string[];
revealStyles: string[];
}
export interface StoryThread {
id: string;
title: string;
visibility: 'visible' | 'hidden';
summary: string;
conflictType: string;
stakes: string;
involvedFactionIds: string[];
involvedActorIds: string[];
relatedLocationIds: string[];
}
export interface StoryScar {
id: string;
title: string;
pastEvent: string;
publicResidue: string;
hiddenTruth: string;
relatedActorIds: string[];
relatedLocationIds: string[];
}
export interface StoryMotif {
id: string;
label: string;
semanticRole: string;
lexicalHints: string[];
}
export interface WorldStoryGraph {
visibleThreads: StoryThread[];
hiddenThreads: StoryThread[];
scars: StoryScar[];
motifs: StoryMotif[];
}
export interface ActorNarrativeProfile {
publicMask: string;
firstContactMask: string;
visibleLine: string;
hiddenLine: string;
contradiction: string;
debtOrBurden: string;
taboo: string;
immediatePressure: string;
relatedThreadIds: string[];
relatedScarIds: string[];
reactionHooks: string[];
}
export interface VisibilitySlice {
factIds: string[];
sayableFactIds: string[];
inferredFactIds: string[];
forbiddenFactIds: string[];
misdirectionHints: string[];
}
export interface SceneNarrativeDirective {
primaryPressure: string;
activeThreadIds: string[];
foregroundActorIds: string[];
foregroundCarrierIds: string[];
revealBudget: 'low' | 'medium' | 'high';
emotionalCadence: 'tense' | 'curious' | 'hostile' | 'intimate' | 'tragic' | 'mysterious';
}
export interface CarrierStoryFingerprint {
visibleClue: string;
witnessMark: string;
unresolvedQuestion: string;
currentAppearanceReason: string;
relatedThreadIds: string[];
relatedScarIds: string[];
reactionHooks: string[];
}
export interface CompanionStanceProfile {
trust: number;
warmth: number;
ideologicalFit: number;
fearOrGuard: number;
loyalty: number;
currentConflictTag?: string | null;
recentApprovals: string[];
recentDisapprovals: string[];
}
export interface StoryEngineMemoryState {
discoveredFactIds: string[];
activeThreadIds: string[];
resolvedScarIds: string[];
recentCarrierIds: string[];
}
```
同时更新:
- `src/types.ts`
`storyEngine.ts` 导出。
## 5.2 扩展 `src/types/customWorld.ts`
扩展:
```ts
interface CustomWorldRoleProfile {
narrativeProfile?: ActorNarrativeProfile;
}
interface CustomWorldProfile {
themePack?: ThemePack | null;
storyGraph?: WorldStoryGraph | null;
}
```
注意:
1. 这两个字段都要允许 `null` / 缺失
2. `normalizeCustomWorldProfile(...)` 必须能从旧档案平滑补 fallback
## 5.3 扩展 `src/types/runtimeItem.ts`
扩展:
```ts
interface RuntimeItemMetadata {
storyFingerprint?: CarrierStoryFingerprint;
}
```
同时扩展 AI 意图:
```ts
interface RuntimeItemAiIntent {
visibleClue?: string;
witnessMark?: string;
unfinishedBusiness?: string;
hiddenHook?: string;
reactionHooks?: string[];
namingPattern?: string;
}
```
## 5.4 扩展 `src/types/game.ts`
扩展:
```ts
interface GameState {
storyEngineMemory?: StoryEngineMemoryState;
}
```
用途:
1. 记录已发现事实
2. 记录当前激活线程
3. 记录已经回响过的旧伤
4. 记录最近拿到的重点叙事载体
## 5.5 扩展 `src/types/scene.ts`
扩展:
```ts
interface NpcPersistentState {
stanceProfile?: CompanionStanceProfile;
}
interface Encounter {
narrativeProfile?: ActorNarrativeProfile;
}
interface SceneNpc {
narrativeProfile?: ActorNarrativeProfile;
}
```
注意:
- 这里的 `stanceProfile` 不只给已入队同伴使用,重点 NPC 也可以先共享这套结构
## 5.6 扩展 `src/services/aiTypes.ts`
`StoryGenerationContext` 增加:
```ts
visibilitySlice?: VisibilitySlice | null;
sceneNarrativeDirective?: SceneNarrativeDirective | null;
encounterNarrativeProfile?: ActorNarrativeProfile | null;
activeThreadIds?: string[] | null;
```
`QuestGenerationContext` 增加:
```ts
activeThreadIds?: string[] | null;
issuerNarrativeProfile?: ActorNarrativeProfile | null;
```
## 6. 模块实现方案
## 6.1 `src/services/storyEngine/themePack.ts`
职责:
1. 根据 `customWorldProfile.templateWorldType``summary``tone` 生成题材适配层
2. 如果是预设世界,也能提供内置默认 `ThemePack`
3. 给后续命名、术语、禁忌词、物件类别提供统一词汇入口
建议导出:
```ts
buildThemePackFromWorldProfile(profile)
resolveFallbackThemePack(worldType)
```
本阶段要求:
- 先做 deterministic builder不强依赖额外 LLM 调用
## 6.2 `src/services/storyEngine/worldStoryGraph.ts`
职责:
1. 根据 `CustomWorldProfile` 现有字段生成或补全 `WorldStoryGraph`
2. 先从 `majorFactions / coreConflicts / landmarks / storyNpcs` 抽图谱
3. 缺失时允许走 LLM 辅助生成,但要有 deterministic fallback
建议导出:
```ts
buildFallbackWorldStoryGraph(profile, themePack)
generateWorldStoryGraphWithAi(profile, themePack)
normalizeWorldStoryGraph(value, fallback)
```
本阶段要求:
- 默认必须可离线 fallback
- 图谱生成失败不能阻塞整个自定义世界生成
## 6.3 `src/services/storyEngine/actorNarrativeProfile.ts`
职责:
1. 从角色的 `description / backstory / motivation / relationshipHooks / tags / backstoryReveal`
编译 `ActorNarrativeProfile`
2. 缺失时给出 fallback
3. 为 prompt 提供稳定的“首遇面具、当前压力、暗线钩子”
建议导出:
```ts
buildFallbackActorNarrativeProfile(role, graph)
generateActorNarrativeProfileWithAi(role, graph, themePack)
normalizeActorNarrativeProfile(value, fallback)
```
## 6.4 `src/services/storyEngine/visibilityEngine.ts`
职责:
1. 根据当前遭遇、好感、首遇状态、已解锁章节、故事记忆构造 `VisibilitySlice`
2. 明确哪些事实:
- 可以进入 prompt
- 只能作为推测
- 绝对不能进入本轮上下文
建议导出:
```ts
buildEncounterVisibilitySlice(params)
buildQuestVisibilitySlice(params)
buildCarrierVisibilitySlice(params)
```
本阶段关键约束:
1. 自定义世界 NPC 首遇时禁止注入完整 `backstory`
2. 未解锁章节禁止注入 `content`
3. 低披露阶段允许注入:
- `publicMask`
- `firstContactMask`
- `visibleLine`
- `immediatePressure`
- 已解锁章节的 `contextSnippet`
## 6.5 `src/services/storyEngine/sceneNarrativeDirector.ts`
职责:
1. 用当前场景、遭遇、最近行动、激活线程构造 `SceneNarrativeDirective`
2. 告诉 prompt 本轮更应该强调:
- 紧张
- 试探
- 情感深化
- 揭示
- 悬念
建议导出:
```ts
buildSceneNarrativeDirective(params)
```
本阶段先做 local director不新增额外 LLM 调用。
## 6.6 `src/services/storyEngine/carrierNarrativeCompiler.ts`
职责:
1. 根据 `WorldStoryGraph + relationAnchor + scene + actorNarrativeProfile`
编译 `CarrierStoryFingerprint`
2. 给运行时重点物件提供:
- 可见线索
- 见证痕
- 未完成问题
- 当前出现理由
- 后续反应钩子
建议导出:
```ts
buildRuntimeItemStoryFingerprint(params)
buildCarrierNarrativeName(params)
buildCarrierNarrativeDescription(params)
```
## 7. 现有文件改造方案
## 7.1 `src/services/customWorld.ts`
当前问题:
1. 角色生成偏设定卡
2. 没有先产题材适配层与世界线程图谱
3. 角色背景和章节没有统一挂到世界线程上
改造方案:
1. 保留现有多阶段生成结构
2. 插入两个新阶段:
- `theme_pack_and_story_graph`
- `actor_narrative_profile`
3. 最终输出时把:
- `themePack`
- `storyGraph`
- 每个 NPC 的 `narrativeProfile`
写回 `CustomWorldProfile`
建议新的顺序:
1. 世界基础框架
2. ThemePack
3. WorldStoryGraph
4. 角色基础字段
5. ActorNarrativeProfile
6. backstoryReveal / skills / initialItems
## 7.2 `src/services/prompt.ts`
这是第一阶段必须重点改的文件。
当前问题已经确认:
1. 自定义世界 NPC 遭遇会直接注入完整 `backstory`
2. 会直接注入所有章节摘要
3. `describeCustomWorldSection(...)` 会整体塞入太多 NPC 全量信息
改造方案:
1. 所有自定义世界 NPC prompt 统一先经过 `buildEncounterVisibilitySlice(...)`
2. 只允许读取 `visibilitySlice` 输出的事实
3. `describeCustomWorldSection(...)` 改成只注入:
- 世界摘要
- ThemePack 摘要
- StoryGraph 激活线程摘要
- 当前相关 NPC 的公开面与线程索引
禁止继续注入:
1. 完整 `backstory`
2. 未解锁 `backstoryReveal.content`
3. 多角色全量技能、初始物品、完整背景
## 7.3 `src/data/npcInteractions.ts`
改造目标:
1. 低好感角色从“更冷”改成“更有压力和错位”
2. 接入最小 `stanceProfile`
建议新增:
```ts
buildInitialStanceProfile(...)
applyStoryChoiceToStanceProfile(...)
describeNpcNarrativePressure(...)
```
本阶段只接这些行为的 stance 更新:
1. `npc_chat`
2. `npc_help`
3. `npc_gift`
4. `npc_recruit`
5. `npc_quest_accept`
先不做完整 approval UI只做
- 内部状态更新
- prompt 注入差异
- 文本反应差异
## 7.4 `src/hooks/useStoryGeneration.ts`
当前不要再往里塞更多内容逻辑,而是让它接新引擎组件。
改造方式:
1. 构造 `visibilitySlice`
2. 构造 `sceneNarrativeDirective`
3. 把这两者透传给 `generateInitialStory / generateNextStep`
4. 在回合结束时回写:
- `storyEngineMemory`
- `revealedFactIds`
- 最近激活线程
它在第一阶段里只做 orchestration不承担新的叙事规则实现。
## 7.5 `src/services/questDirector.ts`
当前它已经有:
- AI intent
- fallback builder
- compile to quest
第一阶段只补最小 thread awareness不把整个任务系统完全重做。
改造方式:
1. `QuestGenerationContext` 增加:
- `activeThreadIds`
- `issuerNarrativeProfile`
2. `buildQuestIntentPrompt(...)` 增加:
- 当前激活线程
- NPC 当前压力
- 可披露线索
本阶段不改:
- `QuestLogEntry` 主结构
- `questFlow.ts` 主编译方式
## 7.6 `src/services/runtimeItemAiPrompt.ts`
扩展当前物品意图 contract但不让它直接生成成品。
新增字段:
1. `visibleClue`
2. `witnessMark`
3. `unfinishedBusiness`
4. `hiddenHook`
5. `reactionHooks`
6. `namingPattern`
要求模型回答:
1. 这件物到底见证过什么
2. 它为什么现在出现
3. 谁以后会对它起反应
## 7.7 `src/data/runtimeItemNarrative.ts`
这是第一阶段另一个必须重点改的文件。
当前问题:
1. 名称偏词块拼接
2. 描述偏单模板句
改造方案:
1. 先从 `CarrierStoryFingerprint` 编译名称
2. 描述固定升级成三层式:
- 表面痕迹
- 旧事牵连
- 当前局势意义
3. `sourceReason` 继续保留,但不再是描述的全部中心
## 8. Prompt Contract 方案
## 8.1 自定义世界生成新增 contract
### 1. ThemePack Contract
输入:
- 世界 summary
- tone
- templateWorldType
- factions
- coreConflicts
输出:
- 题材词汇
- 冲突形式
- 载体种类
- 命名范式
### 2. WorldStoryGraph Contract
输入:
- 世界摘要
- major factions
- core conflicts
- landmarks
- story NPC 简要清单
输出:
- `visibleThreads`
- `hiddenThreads`
- `scars`
- `motifs`
### 3. ActorNarrativeProfile Contract
输入:
- 角色基础字段
- 角色 `backstoryReveal`
- 所属世界线程摘要
输出:
- `publicMask`
- `firstContactMask`
- `visibleLine`
- `hiddenLine`
- `contradiction`
- `debtOrBurden`
- `taboo`
- `immediatePressure`
- `reactionHooks`
## 8.2 运行时剧情生成 contract
运行时主 prompt 不再直接吃全量世界设定,而是吃:
1. `SceneNarrativeDirective`
2. `VisibilitySlice`
3. 当前遭遇公开信息
4. 已解锁章节摘要
5. 当前激活线程
## 8.3 运行时物件 contract
输入:
- 生成渠道
- 关系锚点
- 当前场景
- 当前激活线程
- 相关角色公开面与当前压力
输出:
- 物件叙事指纹字段
- 命名范式建议
- build 倾向字段
## 9. 迁移与兼容方案
## 9.1 旧自定义世界兼容
旧数据可能没有:
- `themePack`
- `storyGraph`
- `narrativeProfile`
策略:
1. 读取时用 `normalizeCustomWorldProfile(...)` 自动补 fallback
2. fallback 不阻塞运行时
3. 下次保存时按新结构写回
## 9.2 旧存档兼容
旧存档可能没有:
- `storyEngineMemory`
- `stanceProfile`
- `storyFingerprint`
策略:
1. `GameState` 初始化时补空结构
2. `NpcPersistentState` 初始化时补最小 stance
3. 旧物品没有 `storyFingerprint` 时,不报错,走旧描述逻辑 fallback
## 10. 测试方案
## 10.1 单元测试
建议新增:
1. `visibilityEngine.test.ts`
- 验证首遇和低披露阶段不会泄露完整背景
2. `worldStoryGraph.test.ts`
- 验证 fallback 图谱最少字段齐全
3. `actorNarrativeProfile.test.ts`
- 验证 fallback profile 至少包含压力、错位、钩子
4. `carrierNarrativeCompiler.test.ts`
- 验证重点物件能稳定产出指纹、名称和三层描述
## 10.2 集成测试
建议补这些集成检查:
1. 自定义世界生成后,`CustomWorldProfile.storyGraph``narrativeProfile` 存在
2. `prompt.ts` 在首遇自定义世界 NPC 时不含完整 `backstory`
3. `runtimeItemNarrative.ts` 重点物件输出包含故事指纹与升级描述
4. `questDirector.ts` 任务上下文已经能读取线程与角色叙事档案
## 10.3 手工验证场景
至少做这 4 条人工回归:
1. 初见低好感 NPC
2. 初见中高好感 NPC
3. 拿到 rare 级重点物件
4. 从某 NPC 处接到调查或关系类任务
## 11. 交付顺序
建议按下面顺序做,避免返工:
1. 类型与 fallback
- `storyEngine.ts`
- 现有类型扩展
- 旧档兼容
2. 自定义世界生成链
- `themePack`
- `worldStoryGraph`
- `actorNarrativeProfile`
3. prompt 可见性层
- `visibilityEngine`
- `prompt.ts` 裁剪
- `useStoryGeneration.ts` 接线
4. NPC 首遇和 stance 最小链
- `npcInteractions.ts`
- `StoryGenerationContext` 注入
5. 重点物件叙事链
- `runtimeItemAiPrompt.ts`
- `carrierNarrativeCompiler.ts`
- `runtimeItemNarrative.ts`
6. 任务线程感知最小接入
- `questDirector.ts`
7. 测试与内容回归
## 12. 风险与处理
## 风险 1阶段过大
处理:
- 第一阶段只强接 NPC 与重点物件两条链
- 营地事件、全支线线程化后置
## 风险 2prompt 体积继续膨胀
处理:
- 先做 `VisibilitySlice`
- 严禁继续直接注入全量背景
## 风险 3旧档与旧内容不兼容
处理:
- 所有新增字段必须有 normalize fallback
- 第一阶段不删除旧字段
## 风险 4AI 输出不稳定
处理:
- 所有新 contract 都必须配 deterministic fallback builder
- AI 只产意图,不直接产运行时成品
## 13. 第一阶段验收清单
做到以下几点,才算第一阶段可收口:
1. 新生成自定义世界可稳定带出 `ThemePack + WorldStoryGraph + ActorNarrativeProfile`
2. 首遇自定义世界 NPC 时prompt 中不再包含完整 `backstory`
3. 低好感 NPC 首轮文本能显著表现“压力、错位、钩子”
4. 稀有以上重点物件会稳定带出 `storyFingerprint`
5. 重点物件名称与描述不再只依赖固定模板句
6. 任务生成至少能读取激活线程和 NPC 当前叙事压力
7. 旧档读取正常
8. `lint / 相关测试 / check:encoding` 通过
## 14. 一句话结论
第一阶段不要追求“立刻做出所有经典 RPG 体验”,而要先把:
- 世界线程
- 角色叙事档案
- 信息可见性
- 重点物件叙事指纹
这四个底座接进当前仓库主链。
只要这一步做稳,后面无论是《仙剑》式角色关系、《轩辕剑》式历史神话、《古剑》式世界厚度、《黑神话》式空间残痕,还是《博德之门》式队友反应,都会开始有一个真正可持续生长的引擎底盘。

View File

@@ -0,0 +1,644 @@
# AI 原生剧情引擎第二阶段技术落地方案
更新时间:`2026-04-06`
## 0. 文档目的
这份方案建立在当前仓库已经基本完成的第一阶段能力之上,面向第二阶段的真实技术推进。
第一阶段已经把这些底座接进主链:
1. `ThemePack`
2. `WorldStoryGraph`
3. `ActorNarrativeProfile`
4. `VisibilitySlice`
5. `SceneNarrativeDirective`
6. `CarrierStoryFingerprint`
7. `storyEngineMemory`
同时也已经把这些链打通了:
1. 自定义世界扩展构建链
2. 自定义世界 NPC 首遇 prompt 裁剪
3. 运行时重点物件叙事编译
4. 任务 prompt 的最小线程感知
5. NPC 最小 stance 更新
第二阶段不再解决“底座有没有”,而是解决:
**这些底座如何从“静态骨架 + deterministic fallback”升级成“真正能持续驱动经典 RPG 体验的动态剧情引擎”。**
## 1. 第一阶段审计结论
先明确当前真实状态,再定义第二阶段范围。
## 1.1 已经落地的部分
按代码审计结果,以下内容已经可以判定为“已落地”:
1. 类型层已接入
- `src/types/storyEngine.ts`
- `src/types/customWorld.ts`
- `src/types/game.ts`
- `src/types/runtimeItem.ts`
- `src/types/scene.ts`
- `src/services/aiTypes.ts`
2. 自定义世界扩展构建已接入
- `src/services/customWorldBuilder.ts`
- `src/services/customWorld.ts`
3. Prompt 可见性裁剪已接入主链
- `src/services/prompt.ts`
- `src/hooks/useStoryGeneration.ts`
4. 重点物件叙事指纹已接入主链
- `src/services/storyEngine/carrierNarrativeCompiler.ts`
- `src/data/runtimeItemNarrative.ts`
5. 最小记忆回写已接入
- `src/hooks/story/progressionActions.ts`
- `src/services/storyEngine/echoMemory.ts`
6. 第一阶段关键测试已通过
- `prompt.test.ts`
- `customWorldBuilder.test.ts`
- `visibilityEngine.test.ts`
- `carrierNarrativeCompiler.test.ts`
- `runtimeItemDirector.test.ts`
- `npcInteractions.test.ts`
7. 工程门禁本轮检查通过
- `lint`
- `typecheck`
- `check:encoding`
## 1.2 仍然属于“部分落地”的部分
以下能力虽然已经有骨架,但还没达到第二阶段想要的经典 RPG 体验强度:
1. `WorldStoryGraph`
- 当前主要仍是 fallback 构建AI 版本还是 stub。
2. `ActorNarrativeProfile`
- 当前主要仍是 fallback 编译AI 版本还是 stub。
3. `CompanionStanceProfile`
- 当前只覆盖局部 NPC 行为,不覆盖全局选择反馈、队友插话、营地事件。
4. `storyEngineMemory`
- 当前已经回写,但消费端还很弱,回响还没有真正反过来改变世界表达。
5. `QuestDirector`
- 当前已经有线程感知,但任务本体还没有成为“线程合约”的实体。
6. `CarrierStoryFingerprint`
- 当前只强接运行时物件,还没扩到地点残痕、文书、证物、怪物掉落情报、场景调查结果。
## 1.3 当前进入第二阶段前,最值得注意的两个真实缺口
### 缺口 AAI 版世界线程 / 角色档案生成仍未真正接管主链
当前代码里虽然已经有:
- `generateWorldStoryGraphWithAi(...)`
- `generateActorNarrativeProfileWithAi(...)`
但它们还只是 fallback 包装,本质上没有真的调用独立 AI contract。
这意味着:
- 当前世界线程和角色叙事档案仍偏“本地启发式编译”
- 第一阶段解决了“结构没有”的问题
- 但还没有真正进入“AI 原生世界叙事图谱”阶段
### 缺口 B可见性虽然裁掉了完整背景但隐式泄露仍未完全收紧
当前 `prompt.ts` 里,对自定义世界 NPC 的:
- `relationshipHooks`
- `tags`
仍是无条件注入。
如果这些字段本身带有暗线名词、组织名、旧案对象名,那么:
- 首遇阶段仍有隐式越权泄露的风险
这说明第二阶段必须把“知识图谱”和“可见性切片”的颗粒度继续做细,不能只停留在章节级裁剪。
## 2. 第二阶段目标
第二阶段只做 5 件事:
1. 把世界线程和角色叙事档案从 fallback 编译升级成真正的 AI contract 驱动
2.`VisibilitySlice` 升级成可追踪的 `KnowledgeFact` 图谱
3.`CompanionStanceProfile` 从局部 NPC 状态升级成队友反应系统
4.`QuestDirector` 从“知道线程”升级成“线程合约推进器”
5. 把叙事载体从“重点物件”扩展成“物件 + 地点 + 文书 + 调查残痕”
一句话定义第二阶段:
**从“会组织故事骨架”升级到“会持续推进、反馈、回响和分化故事”。**
## 3. 第二阶段完成定义
第二阶段完成后,必须同时满足:
1. 自定义世界的 `ThemePack / WorldStoryGraph / ActorNarrativeProfile` 至少核心部分由独立 AI contract 生成,而不再只是 fallback。
2. 世界中的“事实”被显式建模成可发现、可说、可误判、可禁止的 `KnowledgeFact`
3. 队友会对关键选择给出稳定的认可 / 反对 / 沉默 / 紧张回避反馈。
4. 至少一类任务已经真正挂在 `StoryThread` 上推进,并能根据 `signal` 更新阶段。
5. 玩家获得重点物件、调查地点或读取文书后,后续 NPC 和剧情会产生回响。
6. “经典 RPG 感”开始明显来自系统联动,而不只是单次文本质量提升。
## 4. 第二阶段范围
## 4.1 纳入范围
- `src/services/customWorld.ts`
- `src/services/customWorldBuilder.ts`
- `src/services/prompt.ts`
- `src/services/questDirector.ts`
- `src/services/questPrompt.ts`
- `src/data/questFlow.ts`
- `src/data/npcInteractions.ts`
- `src/hooks/useStoryGeneration.ts`
- `src/hooks/story/npcEncounterActions.ts`
- `src/hooks/story/npcInteraction.ts`
- `src/services/storyEngine/*`
- `src/types/storyEngine.ts`
- `src/types/story.ts`
## 4.2 新增模块
- `src/services/storyEngine/knowledgeGraph.ts`
- `src/services/storyEngine/knowledgeContract.ts`
- `src/services/storyEngine/companionReactionDirector.ts`
- `src/services/storyEngine/threadContract.ts`
- `src/services/storyEngine/threadSignalRouter.ts`
- `src/services/storyEngine/sceneResidueCompiler.ts`
- `src/services/storyEngine/narrativeCarrierCatalog.ts`
## 4.3 明确不做
第二阶段仍不做:
1. 全量结局树编辑器
2. 全世界所有对象统一入图谱编辑器
3. 多周目专用 meta-story 系统
4. 完整 romance / betrayal / leave-party 大型队友事件网
5. 全 UI 层 approval feed 面板
原因:
第二阶段的核心仍是“让引擎开始动态驱动经典 RPG 体验”,而不是做内容工具大平台。
## 5. 第二阶段最小闭环
建议第二阶段最小闭环定义为:
```text
世界图谱 AI 生成
-> 角色叙事档案 AI 生成
-> KnowledgeFact 图谱
-> VisibilitySlice 2.0
-> 队友反应导演
-> 线程合约生成
-> signal 推进
-> 地点/文书/物件回响
-> 记忆回写
```
本阶段最关键的用户可感知落点有 3 条:
1. 队友 / 同伴反应链
2. 调查 / 任务推进链
3. 地点 / 物件 / 文书残痕链
## 6. 数据结构升级方案
## 6.1 `src/types/storyEngine.ts`
第二阶段新增:
```ts
export interface KnowledgeFact {
id: string;
title: string;
content: string;
ownerActorIds: string[];
relatedThreadIds: string[];
relatedScarIds: string[];
sourceType: 'actor' | 'item' | 'document' | 'scene' | 'monster' | 'quest';
visibility: 'public' | 'discoverable' | 'private' | 'forbidden';
sayability: 'direct' | 'indirect' | 'reactive_only';
aliases?: string[];
}
export interface ThreadContractStep {
id: string;
title: string;
revealText: string;
completionSignalIds: string[];
optionalFactIds: string[];
}
export interface ThreadContract {
id: string;
threadId: string;
issuerActorId?: string | null;
narrativeType: 'investigation' | 'escort' | 'hunt' | 'relationship' | 'trial' | 'recovery';
currentStepId: string | null;
visibleStage: number;
steps: ThreadContractStep[];
followupThreadIds: string[];
}
export interface StorySignal {
id: string;
signalType:
| 'enter_scene'
| 'leave_scene'
| 'talk_to_actor'
| 'obtain_carrier'
| 'inspect_scene'
| 'win_battle'
| 'give_item'
| 'accept_contract'
| 'resolve_contract_step';
actorId?: string | null;
sceneId?: string | null;
carrierId?: string | null;
threadIds?: string[];
}
export interface CompanionReactionRecord {
id: string;
characterId: string;
reactionType: 'approve' | 'disapprove' | 'concern' | 'silence' | 'curious';
reason: string;
relatedThreadIds: string[];
createdAt: string;
}
```
并扩展:
```ts
export interface StoryEngineMemoryState {
discoveredFactIds: string[];
inferredFactIds?: string[];
activeThreadIds: string[];
resolvedScarIds: string[];
recentCarrierIds: string[];
recentSignalIds?: string[];
recentCompanionReactions?: CompanionReactionRecord[];
}
```
## 6.2 `src/types/story.ts`
第二阶段建议扩展任务结构,而不是直接新起平行系统。
扩展:
```ts
interface QuestLogEntry {
threadId?: string | null;
contractId?: string | null;
discoveredFactIds?: string[];
relatedCarrierIds?: string[];
}
```
注意:
- 第二阶段仍继续复用 `QuestLogEntry`
- 不推翻当前任务系统,只增强其线程感知和推进能力
## 6.3 `src/types/scene.ts`
扩展:
```ts
interface ScenePresetInfo {
narrativeResidues?: SceneNarrativeResidue[];
}
interface SceneNarrativeResidue {
id: string;
title: string;
visibleClue: string;
linkedFactIds: string[];
linkedThreadIds: string[];
}
```
## 7. 模块实现方案
## 7.1 `knowledgeGraph.ts`
职责:
1.`WorldStoryGraph + ActorNarrativeProfile + backstoryReveal + carriers`
编译出全局 `KnowledgeFact[]`
2. 统一管理:
- 事实 ID
- 所属角色
- 所属线程
- 是否可直说
- 是否只能反应式表达
建议导出:
```ts
buildKnowledgeGraph(profile)
buildActorKnowledgeFacts(role, graph)
buildCarrierKnowledgeFacts(carrier, graph)
```
第二阶段关键要求:
- `VisibilitySlice` 不再只靠字符串标签推断
- 必须能落到真实事实节点
## 7.2 `knowledgeContract.ts`
职责:
1.`KnowledgeFact` 构造 `VisibilitySlice 2.0`
2. 支持:
- 可直接说的事实
- 只能暗示的事实
- 只会在反应中暴露的事实
- 玩家已经误判但系统不盖章的事实
建议导出:
```ts
buildVisibilitySliceFromFacts(params)
buildMisdirectionFacts(params)
```
## 7.3 `companionReactionDirector.ts`
职责:
1. 根据玩家选择、活跃线程、事实图谱、队友 stance 生成队友反应
2. 把当前只存在于 `stanceProfile` 内的数值,变成:
- 明确的认可 / 反对 / 担忧 / 沉默
- 可写入聊天、插话、任务后反馈的短反应
建议导出:
```ts
buildCompanionReactionBatch(params)
applyCompanionReactionToStance(params)
```
第二阶段要求:
1. 至少支持关键选择后队友短反应
2. 至少支持营地或旅途中一次补充评价
## 7.4 `threadContract.ts`
职责:
1.`StoryThread` 编译成可推进的 `ThreadContract`
2. 为调查、关系、试炼、追索等线程提供稳定步骤结构
建议导出:
```ts
buildThreadContract(params)
compileQuestFromThreadContract(params)
```
第二阶段目标:
- 不是重做任务系统
- 而是让任务成为线程推进器的一种表现
## 7.5 `threadSignalRouter.ts`
职责:
1. 接收运行时 signal
2. 判断哪些线程、哪些合约步骤应该推进
3. 触发:
- 任务步骤完成
- 新事实解锁
- 队友反应
- 新残痕 / 新物件回响
建议导出:
```ts
collectStorySignals(params)
resolveSignalsToThreadUpdates(params)
```
## 7.6 `sceneResidueCompiler.ts`
职责:
1. 给场景和调查结果补 `narrativeResidues`
2. 让场景本身开始承担《黑神话》《古剑》式残痕叙事
建议导出:
```ts
buildSceneNarrativeResidues(params)
buildResidueInspectResult(params)
```
第二阶段要求:
1. 至少在自定义世界 landmark 上生成残痕
2. 至少能在“观察痕迹 / inspect / observe_signs”时读出来
## 7.7 `narrativeCarrierCatalog.ts`
职责:
把当前只覆盖 `InventoryItem` 的载体,扩成统一的叙事载体目录:
1. 物件
2. 文书
3. 场景残痕
4. 怪物特殊掉落情报
5. 任务证据
建议导出:
```ts
buildNarrativeCarrierCatalog(state)
resolveCarrierById(id)
```
## 8. 现有文件改造方案
## 8.1 `src/services/customWorld.ts`
第二阶段要做的,不是继续堆 fallback而是把 AI contract 真正接进生成链。
改造目标:
1. `ThemePack` 允许 AI 细化词汇与命名模式
2. `WorldStoryGraph` 允许 AI 生成更像世界主线 / 暗线的线程图谱
3. `ActorNarrativeProfile` 允许 AI 生成更有作者性的角色命题与错位感
要求:
- 仍保留 fallback
- AI 失败时不阻塞生成
- AI 成功时优先写入结构化结果
## 8.2 `src/services/customWorldBuilder.ts`
第二阶段不再只是 normalize而要支持
1. 合并 AI 输出与 fallback
2. 校验线程、角色、场景之间的 ID 关联
3.`KnowledgeFact` 预编译结果一起挂回 profile
## 8.3 `src/services/prompt.ts`
这是第二阶段最重要的运行时改造点。
改造目标:
1. 彻底移除低披露阶段对 `relationshipHooks` / `tags` 的无条件直出
2. `VisibilitySlice` 改为基于 `KnowledgeFact` 组装
3. 引入 `recentCompanionReactions`
4. 引入 `recentCarrierEchoes`
第二阶段后prompt 不应再直接拼太多原始字段,而应该优先拼:
1. 可见事实
2. 可推测事实
3. 反应提示
4. 线程压力
## 8.4 `src/data/npcInteractions.ts`
第二阶段重点:
1.`stanceProfile` 从局部数值状态,升级成真正会改变文本与反应方向的关系模型
2. 增加:
- 审慎沉默
- 价值观反对
- 被触发的 taboo 反应
- 队友间互评
仍不做大 UI但要开始做
- 运行时差异反应文本
- 旅途中短插话
## 8.5 `src/hooks/useStoryGeneration.ts`
第二阶段继续保持 orchestrator 职责,不吞业务细节。
新增职责:
1. 收集 `StorySignal`
2. 路由到 `threadSignalRouter`
3. 收集队友反应批次
4. 把反应和回响写回 story history / memory
## 8.6 `src/services/questDirector.ts` + `src/data/questFlow.ts`
第二阶段目标:
1. 任务不再只是“任务意图 -> 任务”
2. 而是:
- `StoryThread -> ThreadContract -> Quest manifestation`
最低要求:
1. 调查类任务
2. 关系类任务
先完成线程化。
## 8.7 `src/data/runtimeItemNarrative.ts`
第二阶段这里不再是主攻点,但仍要扩:
1.`narrativeCarrierCatalog` 对齐
2. 支持非物件类叙事载体复用同一套描述语法
3. 支持 NPC 对重点物件产生反应
## 9. 第二阶段开发顺序
建议顺序如下:
1. `KnowledgeFact` 图谱
2. `VisibilitySlice 2.0`
3. Prompt 泄露收口
4. Companion reaction director
5. Thread contract / signal router
6. Scene residue / narrative carriers
7. 自定义世界 AI contract 真接入
8. 测试与内容回归
原因:
- 不先做知识图谱和可见性,后面所有反应系统都会继续飘
- 不先做 signal router线程化任务和回响都无法稳定推进
## 10. 测试方案
## 10.1 新增单元测试
建议新增:
1. `knowledgeGraph.test.ts`
2. `knowledgeContract.test.ts`
3. `companionReactionDirector.test.ts`
4. `threadContract.test.ts`
5. `threadSignalRouter.test.ts`
6. `sceneResidueCompiler.test.ts`
## 10.2 新增集成测试
建议补以下集成路径:
1. 关键选择 -> 队友反应 -> stance 更新
2. 获取重点物件 -> memory 回写 -> 后续 NPC 反应
3. 调查线索 -> signal -> thread contract step 推进
4. 自定义世界 AI 线程图谱 -> prompt 可见性 -> 首遇文本
## 10.3 第二阶段验收测试场景
至少人工回归这 5 条:
1. 队友对玩家价值观选择的分化反应
2. 调查类任务拿到线索后任务推进
3. 场景残痕被观察后触发新对话变化
4. 重点物件被某 NPC 识别并改变口风
5. 同一条暗线在人物、任务、地点、物件上多次回响
## 11. 第二阶段验收标准
做到以下几点,才算第二阶段收口:
1. 自定义世界线程图谱和角色叙事档案已真正支持 AI contract 生成
2. 首遇和低披露阶段不再通过 `relationshipHooks / tags` 等隐式字段泄露暗线信息
3. 队友会对关键选择给出差异化反应,且能写回关系状态
4. 至少一类任务已经通过 `ThreadContract + StorySignal` 推进
5. 地点调查、物件获取、对话推进之间开始形成回响闭环
6. `storyEngineMemory` 中的 `recentCarrierIds / resolvedScarIds / recentCompanionReactions`
已被运行时消费,而不只是存着
7. 相关测试通过,`lint` / `typecheck` / `check:encoding` 通过
## 12. 一句话结论
第一阶段解决的是“剧情引擎的骨架有没有”,第二阶段要解决的是:
**这些骨架能不能开始真正驱动选择后果、队友反应、调查推进和世界回响。**
只要第二阶段做稳,当前项目就会从“结构上像 AI 原生剧情引擎”,真正走到“体验上开始像经典单机 RPG”。

View File

@@ -0,0 +1,690 @@
# AI 原生剧情引擎第三阶段技术落地方案
更新时间:`2026-04-06`
## 0. 文档目的
这份方案建立在当前仓库第二阶段已经基本落地的前提上,面向第三阶段的技术推进。
按当前代码审计结果,第二阶段这些关键能力已经基本接入:
1. `KnowledgeFact`
2. `VisibilitySlice 2.0`
3. `CompanionReactionRecord`
4. `ThreadContract`
5. `StorySignal`
6. `SceneNarrativeResidue`
7. `narrativeCarrierCatalog`
8. `recentCompanionReactions / recentCarrierEchoes`
9. 自定义世界生成链中的 `ThemePack / StoryGraph / ActorNarrativeProfile` AI 生成阶段
同时,这些链已经形成了真实接线:
1. `customWorldBuilder` 会生成 `knowledgeFacts / threadContracts / narrativeResidues`
2. `useStoryGeneration` 会读取 `knowledgeFacts` 并构造新的可见性切片
3. `progressionActions` 会收集 `signals`、更新 `thread`、生成 `companion reactions`
4. `questFlow` 已开始挂接 `threadContract`
5. `prompt.ts` 已消费最近队友反应与载体回响
因此第三阶段不再解决:
- 引擎底层语法有没有
- 线程和事实能不能进主链
第三阶段要解决的是:
**如何让这些系统进一步长成“章节化、队友线、世界状态变化、旅程高光、长期回顾”这些真正构成经典单机 RPG 体验的中高层叙事能力。**
## 1. 第二阶段审计结论
## 1.1 可以判定为“基本落地”的部分
按代码和测试情况,第二阶段已经基本落地:
1. 第二阶段新增模块都已存在并有测试:
- `knowledgeGraph`
- `knowledgeContract`
- `companionReactionDirector`
- `threadContract`
- `threadSignalRouter`
- `sceneResidueCompiler`
- `echoMemory`
2. 关键主链已接入:
- `customWorldBuilder.ts`
- `useStoryGeneration.ts`
- `progressionActions.ts`
- `questFlow.ts`
- `prompt.ts`
3. 第二阶段相关测试通过:
- `knowledgeGraph.test.ts`
- `knowledgeContract.test.ts`
- `companionReactionDirector.test.ts`
- `threadContract.test.ts`
- `threadSignalRouter.test.ts`
- `sceneResidueCompiler.test.ts`
- `echoMemory.test.ts`
4. 工程门禁通过:
- `lint`
- `typecheck`
- `check:encoding`
所以,“第二阶段技术落地方案已经基本落地”这个判断是成立的。
## 1.2 第二阶段仍然留下的真实缺口
虽然第二阶段已经基本落地,但它目前更像“动态剧情中层骨架”,还没完全进入“经典 RPG 体验高层”。
当前最关键的 6 个缺口是:
1. `ThreadContract` 仍偏浅
- 目前主要还是两步式 contract更多是“线程感知任务”还不是多阶段主线/支线推进器。
2. `CompanionReactionDirector` 仍偏通用
- 现在队友反应能发生,但还主要是通用 approve/disapprove 级别,还没有真正进入角色个人命题、价值观冲突、营地事件、队友互评。
3. `KnowledgeFact` 虽然存在,但还缺“长期总结层”
- 现在事实能进可见性切片,但还缺章节摘要、世界回顾、角色关系归档、游玩 recap。
4. `SceneNarrativeResidue` 仍偏静态
- 场景开始能讲故事了,但场景还不会因为线程推进、事件完成、阵营变化而发生状态变异。
5. 世界状态变化还很弱
- 当前 `signal` 会推进记忆和 quest可是还不会稳定驱动
- 场景变更
- NPC 立场改口
- 商店库存风格变化
- 场景敌人/残痕刷新
6. 缺少章节 / 旅程 / 高光导演层
- 现在有线程、有事实、有反应,但还没有一个更高层的“章节推进器”来控制:
- 主线节奏
- 旅程段落
- 大事件前后
- 营地休整与情感释放
- 章节收束与下一章开启
## 1.3 结论
第一阶段解决的是“骨架有没有”,第二阶段解决的是“中层动态有没有”,第三阶段要解决的是:
**这些系统能不能组合出真正像经典单机 RPG 的章节感、队友线、旅程高光和世界变化。**
## 2. 第三阶段目标
第三阶段只做 5 件事:
1. 建立 `章节 / 旅程 / 高光导演层`
2. 建立 `队友个人线与营地事件层`
3. 建立 `世界状态变化与阵营温度层`
4. 建立 `叙事文书 / 档案 / 回顾摘要层`
5. 建立 `大事件 / 章节高潮 / Boss 前后叙事编排层`
一句话定义第三阶段:
**从“动态剧情系统”升级到“能持续制造章节记忆点和旅程余味的 RPG 叙事框架”。**
## 3. 第三阶段完成定义
第三阶段完成后,至少要同时满足:
1. 游戏运行中能形成 `章节感`
- 玩家明确感到自己经历了“第一章、第二章、转折、大事件、余波”。
2. 队友不再只是对选择做短反应
- 而是会有:
- 个人线推进
- 营地事件
- 队友互评
- 价值观冲突
- 忠诚变化
3. 世界会因为线程推进发生状态变化
- 某些场景描述、残痕、NPC 态度、敌对压力、商店风格、任务机会会改变。
4. 玩家会得到结构化回顾
- 包括:
- 当前章节摘要
- 已发现关键真相
- 队友关系变化
- 最近大事件
5. 至少一条主线或关键线程,已经能从“起线 -> 扩张 -> 转折 -> 高潮 -> 余波”完整走完。
## 4. 第三阶段范围
## 4.1 纳入范围
- `src/services/storyEngine/*`
- `src/hooks/useStoryGeneration.ts`
- `src/hooks/story/progressionActions.ts`
- `src/data/npcInteractions.ts`
- `src/data/questFlow.ts`
- `src/services/prompt.ts`
- `src/components/AdventureEntityModal.tsx`
- `src/components/CharacterPanel.tsx`
- `src/components/InventoryPanel.tsx`
- `src/components/AdventurePanel.tsx`
- `src/types/storyEngine.ts`
- `src/types/story.ts`
- `src/types/game.ts`
- `src/types/scene.ts`
## 4.2 新增模块
- `src/services/storyEngine/chapterDirector.ts`
- `src/services/storyEngine/journeyBeatPlanner.ts`
- `src/services/storyEngine/companionArcDirector.ts`
- `src/services/storyEngine/campEventDirector.ts`
- `src/services/storyEngine/worldMutationRouter.ts`
- `src/services/storyEngine/factionTensionState.ts`
- `src/services/storyEngine/documentCarrierCompiler.ts`
- `src/services/storyEngine/storyChronicle.ts`
- `src/services/storyEngine/recapDigest.ts`
- `src/services/storyEngine/setpieceDirector.ts`
## 4.3 第三阶段明确不做
第三阶段仍不做:
1. 全流程可视化剧情编辑器
2. 通用 mod 工具链
3. 全量配音/镜头脚本系统
4. 无限复杂分支树编辑器
5. 所有支线都拥有章节级演出
原因:
第三阶段目标是把当前系统推到“经典 RPG 体验层”,不是同时把制作工具也做到工业级。
## 5. 第三阶段最小闭环
建议第三阶段最小闭环为:
```text
ChapterDirector
-> JourneyBeatPlanner
-> ThreadContract / StorySignal
-> CompanionArcDirector
-> CampEventDirector
-> WorldMutationRouter
-> SetpieceDirector
-> StoryChronicle / RecapDigest
```
这条闭环对应玩家可感知的体验是:
1. 章节推进
2. 旅程段落变化
3. 队友关系与个人线变化
4. 场景 / 世界状态变化
5. 大事件 / 高潮回合
6. 章节后的回顾与余波
## 6. 数据结构升级方案
## 6.1 `src/types/storyEngine.ts`
第三阶段建议新增:
```ts
export interface ChapterState {
id: string;
title: string;
theme: string;
primaryThreadIds: string[];
stage: 'opening' | 'expansion' | 'turning_point' | 'climax' | 'aftermath';
chapterSummary: string;
}
export interface JourneyBeat {
id: string;
beatType: 'approach' | 'investigation' | 'camp' | 'conflict' | 'boss_prelude' | 'climax' | 'recovery';
title: string;
triggerThreadIds: string[];
recommendedSceneIds: string[];
emotionalGoal: string;
}
export interface CompanionArcState {
characterId: string;
arcTheme: string;
currentStage: 'closed' | 'guarded' | 'opening' | 'conflicted' | 'bonded' | 'resolved';
activeConflictTags: string[];
pendingEventIds: string[];
resolvedEventIds: string[];
}
export interface CampEvent {
id: string;
eventType: 'private_talk' | 'party_banter' | 'conflict' | 'comfort' | 'reveal' | 'decision';
title: string;
participantCharacterIds: string[];
triggerReason: string;
relatedThreadIds: string[];
}
export interface WorldMutation {
id: string;
mutationType: 'scene_text' | 'npc_attitude' | 'shop_style' | 'enemy_pressure' | 'route_lock' | 'route_unlock';
targetId: string;
reason: string;
relatedThreadIds: string[];
}
export interface FactionTensionState {
factionId: string;
temperature: number;
pressureSummary: string;
activeConflictThreadIds: string[];
}
export interface ChronicleEntry {
id: string;
category: 'chapter' | 'thread' | 'companion' | 'carrier' | 'scene' | 'world_event';
title: string;
summary: string;
relatedIds: string[];
createdAt: string;
}
```
并扩展:
```ts
export interface StoryEngineMemoryState {
currentChapter?: ChapterState | null;
currentJourneyBeatId?: string | null;
companionArcStates?: CompanionArcState[];
worldMutations?: WorldMutation[];
chronicle?: ChronicleEntry[];
}
```
## 6.2 `src/types/game.ts`
第三阶段建议新增:
```ts
interface GameState {
chapterState?: ChapterState | null;
}
```
说明:
- `chapterState` 放在 `GameState` 顶层,便于 UI 和主流程快速读取
- 详细历史仍留在 `storyEngineMemory`
## 6.3 `src/types/scene.ts`
第三阶段建议扩展:
```ts
interface ScenePresetInfo {
mutationStateText?: string | null;
currentPressureLevel?: 'low' | 'medium' | 'high' | 'extreme';
}
```
用于场景在章节推进后发生显式变化。
## 7. 模块实现方案
## 7.1 `chapterDirector.ts`
职责:
1. 根据主活跃线程、最近 signal、世界冲突温度决定当前章节处于哪个阶段
2. 生成:
- 章节标题
- 当前主题
- 当前主线线程
- 当前章节摘要
建议导出:
```ts
resolveCurrentChapterState(params)
advanceChapterState(params)
```
第三阶段目标:
- 让玩家的体验从“连续事件流”变成“章节化旅程”
## 7.2 `journeyBeatPlanner.ts`
职责:
1. 在章节内部规划短周期旅程段落
2. 给当前阶段分配:
- 接近
- 调查
- 休整
- 冲突
- 高潮前奏
- 余波
建议导出:
```ts
buildJourneyBeatQueue(params)
resolveCurrentJourneyBeat(params)
```
它是第三阶段对标《黑神话》旅程感和《仙剑》节奏起伏的关键模块。
## 7.3 `companionArcDirector.ts`
职责:
1. 把当前队友的 stance、thread、reaction、facts 组合成真正的个人线状态
2. 决定某个队友此刻更接近:
- 打开
- 回避
- 冲突
- 信任
- 和解
建议导出:
```ts
buildCompanionArcState(params)
advanceCompanionArc(params)
```
目标:
- 从“通用反应”进化到“每个队友都有正在发生的个人线”
## 7.4 `campEventDirector.ts`
职责:
1. 根据 `CompanionArcState + recentCompanionReactions + chapterState`
决定是否生成营地/旅途中事件
2. 生成:
- 私聊事件
- 队友互评
- 意见冲突
- 情绪缓和
- 个人秘密推进
建议导出:
```ts
evaluateCampEventOpportunity(params)
buildCampEvent(params)
```
这是第三阶段对标《仙剑》角色羁绊、《博德之门》营地戏的核心模块。
## 7.5 `worldMutationRouter.ts`
职责:
1.`StorySignal + ThreadContract + ChapterState` 转成世界状态变化
2. 更新:
- 场景描述
- NPC 口风
- 商店风格
- 敌人压力
- 路线开关
建议导出:
```ts
resolveWorldMutations(params)
applyWorldMutationsToGameState(params)
```
第三阶段目标:
- 世界开始“因为你做过的事而变”
## 7.6 `factionTensionState.ts`
职责:
1. 给 major factions 维护温度和压力摘要
2. 决定某些章节里哪条势力线正在升温
建议导出:
```ts
buildFactionTensionState(profile, memory)
applySignalToFactionTension(params)
```
它主要对标《轩辕剑》的大时代张力和《古剑》的世界推进感。
## 7.7 `documentCarrierCompiler.ts`
职责:
1. 把“文书、口供、记录、日志、残页、信件”等正式纳入叙事载体体系
2.`narrativeCarrierCatalog` 对齐
建议导出:
```ts
buildNarrativeDocument(params)
compileDocumentKnowledgeFacts(params)
```
目标:
- 第三阶段让叙事载体不再只限于物件和场景残痕
## 7.8 `storyChronicle.ts`
职责:
1. 把当前发生过的大事件、关键事实、角色转折写入 chronicle
2. 让玩家和系统都有一个更高层的“已发生过什么”的长期摘要
建议导出:
```ts
appendChronicleEntry(params)
buildChronicleSummary(params)
```
## 7.9 `recapDigest.ts`
职责:
1. 生成章节回顾、阶段摘要、下回合简报
2. 给 UI、存档恢复、继续游戏提供更强的 recap 文本
建议导出:
```ts
buildChapterRecap(params)
buildContinueGameDigest(params)
```
## 7.10 `setpieceDirector.ts`
职责:
1. 决定何时进入大事件、Boss 前奏、对峙、章节高潮
2. 给主链输出高光导演指令
建议导出:
```ts
evaluateSetpieceOpportunity(params)
buildSetpieceDirective(params)
```
第三阶段目标:
- 让剧情不只是“持续往前”,而是有真正高光和收束
## 8. 现有文件改造方案
## 8.1 `src/hooks/useStoryGeneration.ts`
第三阶段这里要继续保持 orchestrator 职责,但增加“高层导演”的接线:
新增接线:
1. `chapterDirector`
2. `journeyBeatPlanner`
3. `setpieceDirector`
4. `campEventDirector`
它要做的事情:
1. 读取当前章节状态
2. 读取当前旅程 beat
3. 判断本回合是否应该转入营地 / 高潮 / 余波
4. 把这些高层指令压入 prompt
## 8.2 `src/hooks/story/progressionActions.ts`
这里是第三阶段最重要的状态推进点。
新增职责:
1. 调用 `worldMutationRouter`
2. 调用 `companionArcDirector`
3. 调用 `storyChronicle`
4. 回写:
- `chapterState`
- `companionArcStates`
- `worldMutations`
- `chronicle`
## 8.3 `src/services/prompt.ts`
第三阶段改造目标:
1. 加入 `chapterState`
2. 加入 `journeyBeat`
3. 加入 `recentWorldMutations`
4. 加入 `recentChronicleSummary`
提示词不再只围绕“当前场景 + 当前遭遇 + facts”还要围绕
1. 当前章节主题
2. 当前旅程段落
3. 最近发生的世界变化
4. 队友当前情绪与线索包袱
## 8.4 `src/data/npcInteractions.ts`
第三阶段重点:
1. 支持由 `CompanionArcState` 决定对话气质
2. 支持队友互评与冲突事件
3. 支持同一选择在不同 arc stage 下触发不同态度
## 8.5 `src/data/questFlow.ts`
第三阶段目标:
1. 任务从“线程 manifest”继续升级为“章节与旅程服务”
2. 支持:
- 章节中期任务
- 高潮前置任务
- 余波任务
## 8.6 `src/components/*`
第三阶段不主打大 UI 重做,但建议最小补这些表现:
1. `AdventurePanel`
- 当前章节标题 / 当前旅程 beat 轻量显示
2. `CharacterPanel`
- 队友当前个人线阶段
3. `AdventureEntityModal`
- 最近与该角色相关的 chronicle / residue / carrier 片段
4. `InventoryPanel`
- 文书 / 证据 / 特殊载体的独立入口
## 9. 第三阶段开发顺序
建议顺序如下:
1. `chapterDirector`
2. `journeyBeatPlanner`
3. `companionArcDirector`
4. `campEventDirector`
5. `worldMutationRouter`
6. `documentCarrierCompiler`
7. `storyChronicle`
8. `recapDigest`
9. `setpieceDirector`
10. UI 最小接线
原因:
- 没有章节和旅程层,后面的营地事件和高潮都无从安放
- 没有 world mutation很多后续内容仍会像“文本在变世界没变”
## 10. 测试方案
## 10.1 新增单元测试
建议新增:
1. `chapterDirector.test.ts`
2. `journeyBeatPlanner.test.ts`
3. `companionArcDirector.test.ts`
4. `campEventDirector.test.ts`
5. `worldMutationRouter.test.ts`
6. `factionTensionState.test.ts`
7. `documentCarrierCompiler.test.ts`
8. `storyChronicle.test.ts`
9. `recapDigest.test.ts`
10. `setpieceDirector.test.ts`
## 10.2 新增集成测试
建议补这 5 类:
1. 线程推进到转折点后,章节状态发生变化
2. 队友在关键选择后推进个人线阶段
3. 获得关键文书后,营地事件或下一轮剧情出现新回响
4. 某条主线推进后,场景文本和 NPC 态度发生世界状态变化
5. 高潮前后prompt 中的章节、旅程、世界变化信息都能被正确注入
## 10.3 人工回归场景
至少做这 6 条:
1. 一条调查线程从起线推进到转折
2. 一个队友从 guarded 推到 conflicted / bonded
3. 一个营地事件被触发并影响后续关系
4. 一个重点物件 + 文书 + 场景残痕组成同一条暗线回响
5. 一个章节进入高潮前后,场景压力显著变化
6. 继续游戏时能看到结构化 recap而不是只靠最后几条 story history
## 11. 第三阶段验收标准
做到以下几点,才算第三阶段可收口:
1. 系统能稳定给出 `ChapterState`
2. 至少一条主线线程可以经历 `opening -> expansion -> turning_point -> climax -> aftermath`
3. 队友个人线能有明确阶段推进,并能触发营地或旅途事件
4. 世界状态会因为线程推进产生可见变化
5. 文书/证据类叙事载体正式进入主链
6. 玩家可以在运行时获得章节回顾和近期大事件摘要
7. 高潮节点会由 `setpieceDirector` 显式导演,而不是随机撞出来
8. 相关测试通过,`lint` / `typecheck` / `check:encoding` 通过
## 12. 一句话结论
第一阶段解决“骨架”,第二阶段解决“动态系统”,第三阶段要解决的是:
**这些动态系统能不能真正长出章节感、队友线、世界变化和章节高光。**
只有第三阶段做稳,当前项目才会从“已经很像 AI 原生剧情引擎”,真正走向“能稳定制造经典单机 RPG 旅程体验”的层级。

View File

@@ -0,0 +1,725 @@
# AI 原生剧情引擎第四阶段技术落地方案
更新时间:`2026-04-06`
## 0. 文档目的
这份方案建立在当前仓库第三阶段已经基本落地的前提上,面向第四阶段的技术推进。
按当前代码审计结果,第三阶段已经基本把这些中高层能力接进了主链:
1. `ChapterState`
2. `JourneyBeat`
3. `CompanionArcState`
4. `CampEvent`
5. `WorldMutation`
6. `FactionTensionState`
7. `ChronicleEntry`
8. `ContinueGameDigest`
9. `SetpieceDirective`
并且这些能力已经开始被:
- `useStoryGeneration`
- `progressionActions`
- `prompt.ts`
- `AdventurePanel`
- `CharacterPanel`
- `AdventureEntityModal`
- `InventoryPanel`
实际消费。
因此第四阶段不再解决:
- 底层图谱有没有
- 动态系统有没有
- 章节、旅程和高光导演有没有
第四阶段要解决的是:
**如何让当前已经具备的动态叙事系统,真正进入“可持续量产内容、可收束长线剧情、可形成多幕主线与结局体验、可进行作者性控制和质量校验”的阶段。**
一句话说:
**前三阶段把引擎“做起来”,第四阶段要把它“做完整、做可控、做可持续量产”。**
## 1. 第三阶段审计结论
## 1.1 可以判定为“基本落地”的部分
按代码和测试情况,第三阶段已经基本落地:
1. 第三阶段新增模块都已存在并有测试:
- `chapterDirector`
- `journeyBeatPlanner`
- `companionArcDirector`
- `campEventDirector`
- `worldMutationRouter`
- `factionTensionState`
- `documentCarrierCompiler`
- `storyChronicle`
- `recapDigest`
- `setpieceDirector`
2. 关键主链已接入:
- `useStoryGeneration.ts`
- `progressionActions.ts`
- `prompt.ts`
- `AdventurePanel.tsx`
- `CharacterPanel.tsx`
- `AdventureEntityModal.tsx`
- `InventoryPanel.tsx`
- `GameShell.tsx`
3. 第三阶段相关测试通过:
- `chapterDirector.test.ts`
- `journeyBeatPlanner.test.ts`
- `companionArcDirector.test.ts`
- `campEventDirector.test.ts`
- `worldMutationRouter.test.ts`
- `factionTensionState.test.ts`
- `documentCarrierCompiler.test.ts`
- `storyChronicle.test.ts`
- `recapDigest.test.ts`
- `setpieceDirector.test.ts`
4. 工程门禁通过:
- `lint`
- `typecheck`
- `check:encoding`
所以,“第三阶段技术落地方案已经基本落地”这个判断成立。
## 1.2 第三阶段留下的真实缺口
虽然第三阶段已经把“章节、旅程、队友线、世界变化、高光导演”这些高层能力接起来了,但它们还更像“运行时中高层系统”,还没有进入真正的“长线 campaign 完整度”和“内容生产可控性”阶段。
当前最关键的 7 个缺口是:
1. `ChapterState` 已有,但还缺 `Act / Campaign` 级结构
- 目前更像章节段落推进器,还不是完整的多幕主线架构。
2. 队友个人线已有,但还缺“收束态”
- 当前能推进 arc stage但还没有
- 个人线结局
- 决裂 / 离队 / 归队
- 忠诚锁定
- 决战前站队
3. 世界变化已有,但还缺“全局后果账本”
- 当前能改场景和口风,但还没有统一的:
- 关键选择后果台账
- 阵营态势累积
- 长线不可逆变化
4. 文书与 chronicle 已有,但还缺“玩家可管理的故事档案”
- 现在有内容生成和少量展示,但还缺真正的 codex / archive 层。
5. `SetpieceDirector` 已有,但还偏事件触发器
- 当前能识别高潮和余波,但还没有:
- 多阶段高潮编排
- 决战前准备段
- 决战后结算段
6. 缺少结局 / 尾声系统
- 当前可以推进 journey但没有明确的
- 线程结局
- 角色结局
- 世界结局
- 尾声摘要
7. 缺少作者性约束与质量门禁
- 当前系统很强,但如果要稳定产出“经典 RPG 级长线内容”,必须补:
- branch budget
- ending coverage
- contradiction checks
- pacing checks
- narrative QA 报告
## 1.3 结论
第一阶段解决“骨架”,第二阶段解决“动态系统”,第三阶段解决“章节和旅程感”,第四阶段要解决的是:
**这套引擎能不能稳定做出完整 campaign并且能被持续生产、校验、维护和扩张。**
## 2. 第四阶段目标
第四阶段只做 5 件事:
1. 建立 `Campaign / Act / Ending` 级主线结构
2. 建立 `Companion Resolution` 队友线收束系统
3. 建立 `Consequence Ledger` 全局后果账本
4. 建立 `Narrative Codex / Archive` 故事档案系统
5. 建立 `Authorial Constraint + Narrative QA` 内容生产与质量门禁体系
一句话定义第四阶段:
**从“能跑出经典 RPG 旅程感”升级到“能稳定完成一整部经典 RPG campaign”。**
## 3. 第四阶段完成定义
第四阶段完成后,至少要同时满足:
1. 系统能够维护多幕结构
- `Act I / II / III` 或等价 campaign 分段真正成立。
2. 至少一条完整主线能走到结局
- 并且结局由:
- 主线程结果
- 队友个人线结果
- 阵营态势
- 关键后果账本
共同决定。
3. 队友个人线能有明确收束
- 包括:
- 和解
- 坚定追随
- 决裂
- 离队
- 牺牲 / 缺席 / 旁观
4. 世界后果被系统记录并能影响结局和尾声
- 不只是局部场景文字变化。
5. 玩家可以查看已发生的主线、角色线、文书、关键真相和结局线索。
6. 生成链具备明确的作者性约束和 QA 报告,不再只靠人工抽样验证。
## 4. 第四阶段范围
## 4.1 纳入范围
- `src/services/storyEngine/*`
- `src/hooks/story/progressionActions.ts`
- `src/hooks/useStoryGeneration.ts`
- `src/services/prompt.ts`
- `src/data/questFlow.ts`
- `src/data/npcInteractions.ts`
- `src/components/AdventurePanel.tsx`
- `src/components/CharacterPanel.tsx`
- `src/components/InventoryPanel.tsx`
- `src/components/AdventureEntityModal.tsx`
- `src/components/GameShell.tsx`
- `src/types/storyEngine.ts`
- `src/types/game.ts`
- `src/types/story.ts`
## 4.2 新增模块
- `src/services/storyEngine/campaignDirector.ts`
- `src/services/storyEngine/actPlanner.ts`
- `src/services/storyEngine/endingResolver.ts`
- `src/services/storyEngine/epilogueComposer.ts`
- `src/services/storyEngine/companionResolutionDirector.ts`
- `src/services/storyEngine/consequenceLedger.ts`
- `src/services/storyEngine/narrativeCodex.ts`
- `src/services/storyEngine/authorialConstraintPack.ts`
- `src/services/storyEngine/branchBudgetPlanner.ts`
- `src/services/storyEngine/narrativeQaReport.ts`
- `src/services/storyEngine/narrativeConsistencyChecks.ts`
## 4.3 第四阶段明确不做
第四阶段仍不做:
1. 完整可视化剧情节点编辑器
2. 商业级本地化 pipeline
3. 全自动配音 / 镜头脚本工具链
4. 全量开放给 mod 作者的内容 DSL
5. 无上限多周目 meta-progression
原因:
第四阶段的任务是把 campaign 层做完整和可控,不是同时做全生产平台。
## 5. 第四阶段最小闭环
建议第四阶段最小闭环为:
```text
CampaignDirector
-> ActPlanner
-> Chapter / Journey / Thread systems
-> ConsequenceLedger
-> CompanionResolutionDirector
-> EndingResolver
-> EpilogueComposer
-> NarrativeCodex
-> NarrativeQaReport
```
这条闭环对应玩家可感知的体验是:
1. 这不是无限流局部冒险,而是一段完整 campaign
2. 你的选择有长期代价
3. 队友线会真正收束
4. 结局和尾声会回收之前埋下的线
5. 整个过程具有更强作者性和完成度
## 6. 数据结构升级方案
## 6.1 `src/types/storyEngine.ts`
第四阶段建议新增:
```ts
export interface CampaignState {
id: string;
title: string;
currentActId: string | null;
currentActIndex: number;
resolvedEndingId?: string | null;
}
export interface ActState {
id: string;
title: string;
actIndex: number;
theme: string;
primaryThreadIds: string[];
status: 'opening' | 'midgame' | 'late_game' | 'finale' | 'resolved';
}
export interface ConsequenceRecord {
id: string;
category: 'thread' | 'companion' | 'faction' | 'world' | 'ending_flag';
title: string;
summary: string;
weight: number;
relatedIds: string[];
irreversible: boolean;
}
export interface CompanionResolution {
characterId: string;
resolutionType: 'bonded' | 'reconciled' | 'estranged' | 'departed' | 'sacrificed' | 'neutral';
summary: string;
relatedThreadIds: string[];
}
export interface EndingState {
id: string;
title: string;
endingType: 'heroic' | 'tragic' | 'bitter_sweet' | 'fractured' | 'ascendant';
summary: string;
contributingThreadIds: string[];
companionResolutions: CompanionResolution[];
worldOutcomeSummary: string;
}
export interface AuthorialConstraintPack {
toneRules: string[];
noGoPatterns: string[];
branchBudget: {
maxMajorDivergences: number;
maxEndingFamilies: number;
};
mandatoryThemes: string[];
requiredPayoffs: string[];
}
export interface NarrativeQaIssue {
id: string;
severity: 'low' | 'medium' | 'high';
category: 'consistency' | 'pacing' | 'payoff' | 'branch_budget' | 'reveal_leak';
summary: string;
relatedIds: string[];
}
export interface NarrativeQaReport {
generatedAt: string;
issues: NarrativeQaIssue[];
summary: string;
}
```
并扩展:
```ts
export interface StoryEngineMemoryState {
campaignState?: CampaignState | null;
actState?: ActState | null;
consequenceLedger?: ConsequenceRecord[];
companionResolutions?: CompanionResolution[];
endingState?: EndingState | null;
authorialConstraintPack?: AuthorialConstraintPack | null;
narrativeQaReport?: NarrativeQaReport | null;
}
```
## 6.2 `src/types/game.ts`
扩展:
```ts
interface GameState {
campaignState?: CampaignState | null;
}
```
## 6.3 `src/types/story.ts`
扩展:
```ts
interface QuestLogEntry {
actId?: string | null;
consequenceIds?: string[];
}
```
目的:
- 让任务正式进入 campaign / act 结构
## 7. 模块实现方案
## 7.1 `campaignDirector.ts`
职责:
1. 建立整个 campaign 的总状态
2. 管理当前 act 与 campaign 收束进度
建议导出:
```ts
resolveCampaignState(params)
advanceCampaignState(params)
```
目标:
- 从章节推进升级到“整部故事”的推进
## 7.2 `actPlanner.ts`
职责:
1. 根据主线程、世界 tension、队友线推进情况划分 act
2. 决定何时进入:
- 第一幕铺陈
- 第二幕扩张
- 第三幕决战
建议导出:
```ts
buildActPlan(params)
resolveCurrentActState(params)
```
## 7.3 `consequenceLedger.ts`
职责:
1. 记录关键选择和线程推进的长期后果
2. 形成一个统一可回收的“后果账本”
建议导出:
```ts
appendConsequenceRecord(params)
buildConsequenceLedgerSummary(params)
```
这是第四阶段最重要的全局语义层之一。
## 7.4 `companionResolutionDirector.ts`
职责:
1. 根据 `CompanionArcState + reactions + key consequences`
决定队友线的最终收束状态
2. 输出:
- bonded
- reconciled
- estranged
- departed
- sacrificed
建议导出:
```ts
resolveCompanionResolution(params)
resolveAllCompanionResolutions(params)
```
## 7.5 `endingResolver.ts`
职责:
1. 综合:
- 主线程结局
- 队友收束
- faction tension
- consequence ledger
2. 生成最终 `EndingState`
建议导出:
```ts
resolveEndingState(params)
```
目标:
- 真正形成“经典 RPG 结局体验”
## 7.6 `epilogueComposer.ts`
职责:
1. 为结局后生成尾声摘要
2. 回收:
- 关键线程
- 队友去向
- 世界变化
- 玩家留下的长期后果
建议导出:
```ts
buildEpilogueSummary(params)
```
## 7.7 `narrativeCodex.ts`
职责:
1. 把当前已有的:
- facts
- residues
- carriers
- chronicle
- documents
组织成玩家可浏览的故事档案系统
建议导出:
```ts
buildNarrativeCodex(state)
buildCodexSections(state)
```
## 7.8 `authorialConstraintPack.ts`
职责:
1. 把当前题材和 campaign 级作者性要求结构化
2. 给 AI generation、ending、setpiece、epilogue 全部提供一致约束
建议导出:
```ts
buildAuthorialConstraintPack(params)
```
## 7.9 `branchBudgetPlanner.ts`
职责:
1. 控制 major divergence 数量
2. 控制 ending family 数量
3. 防止 campaign 后期分支爆炸
建议导出:
```ts
evaluateBranchBudget(params)
```
## 7.10 `narrativeQaReport.ts`
职责:
1. 汇总 consistency / pacing / payoff / leak / branch budget 等问题
2. 输出结构化 QA report
建议导出:
```ts
buildNarrativeQaReport(params)
```
## 7.11 `narrativeConsistencyChecks.ts`
职责:
1. 做规则化检查
2. 捕捉:
- 事实泄露
- payoff 缺失
- unresolved thread 过多
- 角色线断裂
- ending 收束不足
建议导出:
```ts
runNarrativeConsistencyChecks(params)
```
## 8. 现有文件改造方案
## 8.1 `useStoryGeneration.ts`
第四阶段这里新增 campaign 层接线:
1. `campaignDirector`
2. `actPlanner`
3. `authorialConstraintPack`
并把结果注入 prompt
1. 当前 act
2. 当前 campaign 目标
3. 当前分支预算压力
4. 当前必须回收的 payoff
## 8.2 `progressionActions.ts`
这是第四阶段最重要的状态推进点。
新增职责:
1. 维护 `CampaignState / ActState`
2. 回写 `ConsequenceLedger`
3. 解析 `CompanionResolution`
4. 在满足条件时触发 `EndingResolver`
5. 生成 `NarrativeQaReport`
## 8.3 `prompt.ts`
第四阶段改造目标:
1. prompt 不只是围绕当前章节和旅程
2. 还要围绕:
- 当前 act
- campaign 主命题
- 已积累的关键后果
- 还必须回收的 payoff
- 作者性 constraint pack
## 8.4 `questFlow.ts`
第四阶段目标:
1. 任务不只是 thread / chapter 服务
2. 还要进入 act/campaign 层的安排
至少新增:
1. act 关键任务
2. ending 前置任务
3. 队友个人线收束任务
## 8.5 `npcInteractions.ts`
第四阶段目标:
1. 队友/NPC 关系可以进入 resolution 判定
2. 支持队友去留和最终站队
3. 支持更高权重的不可逆关系变化
## 8.6 `components/*`
第四阶段不主打大规模 UI 重做,但建议最小补这些:
1. `AdventurePanel`
- 当前 act / chapter / ending pressure
2. `CharacterPanel`
- companion resolution progress
3. `InventoryPanel`
- narrative codex / document archive 入口
4. `AdventureEntityModal`
- 当前角色的关键 consequence / resolution 片段
## 9. 第四阶段开发顺序
建议顺序如下:
1. `campaignDirector`
2. `actPlanner`
3. `consequenceLedger`
4. `companionResolutionDirector`
5. `endingResolver`
6. `epilogueComposer`
7. `narrativeCodex`
8. `authorialConstraintPack`
9. `branchBudgetPlanner`
10. `narrativeConsistencyChecks`
11. `narrativeQaReport`
12. UI 最小接线
原因:
- 没有 campaign / act结局和 QA 都无从谈起
- 没有 consequence ledgerending 会继续偏表层组合
- 没有 authorial constraints 和 QA长线内容会越来越漂
## 10. 测试方案
## 10.1 新增单元测试
建议新增:
1. `campaignDirector.test.ts`
2. `actPlanner.test.ts`
3. `consequenceLedger.test.ts`
4. `companionResolutionDirector.test.ts`
5. `endingResolver.test.ts`
6. `epilogueComposer.test.ts`
7. `narrativeCodex.test.ts`
8. `authorialConstraintPack.test.ts`
9. `branchBudgetPlanner.test.ts`
10. `narrativeConsistencyChecks.test.ts`
11. `narrativeQaReport.test.ts`
## 10.2 新增集成测试
建议补这 6 类:
1. 主线程推进到 act 切换
2. 关键后果进入 ledger 并影响后续 companion resolution
3. 队友个人线收束进入 ending
4. ending state 正确聚合主线、队友线、世界状态
5. epilogue 能回收关键 threads / companions / world mutations
6. QA report 能检测未回收 payoff 或分支超预算
## 10.3 人工回归场景
至少做这 6 条:
1. 一条完整主线从开局跑到结局
2. 两名队友在同一 campaign 中走向不同 resolution
3. 一个重大选择改变结局基调
4. 一条暗线在结局中被正式回收
5. 玩家可以查看完整 codex / chronicle / documents
6. continue game / ending / epilogue 三个摘要层彼此一致
## 11. 第四阶段验收标准
做到以下几点,才算第四阶段可收口:
1. 系统能维护 `CampaignState + ActState`
2. 至少一条完整主线可稳定走到 `EndingState`
3. 队友线能稳定产出 `CompanionResolution`
4. `ConsequenceLedger` 能真实影响 ending 和 epilogue
5. `NarrativeCodex` 可供玩家查看关键故事档案
6. 系统能输出结构化 `NarrativeQaReport`
7. 分支预算和作者性约束已开始进入 generation 主链
8. 相关测试通过,`lint` / `typecheck` / `check:encoding` 通过
## 12. 一句话结论
前三阶段让当前项目拥有了“会生成、会推进、会分层、会回响、会制造章节感”的 AI 原生剧情引擎。
第四阶段要做的,是让它进一步成为:
**一套能稳定完成整部 RPG campaign、能回收长线伏笔、能产出多幕主线和结局、还能被持续生产和校验的完整叙事系统。**

View File

@@ -0,0 +1,676 @@
# AI 原生剧情引擎第五阶段技术落地方案
更新时间:`2026-04-06`
## 0. 文档目的
这份方案建立在当前仓库第四阶段已经基本落地的前提上,面向第五阶段的技术推进。
按当前代码审计结果,第四阶段已经基本把这些 campaign 级能力接进了主链:
1. `CampaignState`
2. `ActState`
3. `ConsequenceRecord`
4. `CompanionResolution`
5. `EndingState`
6. `NarrativeCodex`
7. `AuthorialConstraintPack`
8. `NarrativeQaReport`
并且这些能力已经开始被:
- `progressionActions`
- `useStoryGeneration`
- `prompt.ts`
- `AdventurePanel`
- `CharacterPanel`
- `InventoryPanel`
- `AdventureEntityModal`
实际消费。
因此第五阶段不再解决:
- campaign / act 有没有
- ending / epilogue 有没有
- codex / qa / constraint 有没有
第五阶段要解决的是:
**如何让这套已经能完成完整 campaign 的引擎,进一步成为一套可批量生产、可多 campaign 复用、可仿真评估、可玩家画像自适应、可发布运营的叙事平台。**
一句话说:
**前四阶段把引擎做成“一部完整 RPG 的叙事系统”,第五阶段要把它做成“可以持续生产很多完整 RPG 体验的叙事平台”。**
## 1. 第四阶段审计结论
## 1.1 可以判定为“基本落地”的部分
按代码和测试情况,第四阶段已经基本落地:
1. 第四阶段新增模块都已存在并有测试:
- `campaignDirector`
- `actPlanner`
- `consequenceLedger`
- `companionResolutionDirector`
- `endingResolver`
- `epilogueComposer`
- `narrativeCodex`
- `authorialConstraintPack`
- `branchBudgetPlanner`
- `narrativeConsistencyChecks`
- `narrativeQaReport`
2. 关键主链已接入:
- `useStoryGeneration.ts`
- `progressionActions.ts`
- `prompt.ts`
- `AdventurePanel.tsx`
- `CharacterPanel.tsx`
- `InventoryPanel.tsx`
- `AdventureEntityModal.tsx`
- `GameShell.tsx`
3. 第四阶段相关测试通过:
- `campaignDirector.test.ts`
- `actPlanner.test.ts`
- `consequenceLedger.test.ts`
- `companionResolutionDirector.test.ts`
- `endingResolver.test.ts`
- `epilogueComposer.test.ts`
- `narrativeCodex.test.ts`
- `authorialConstraintPack.test.ts`
- `branchBudgetPlanner.test.ts`
- `narrativeConsistencyChecks.test.ts`
- `narrativeQaReport.test.ts`
4. 工程门禁通过:
- `lint`
- `typecheck`
- `check:encoding`
所以,“第四阶段技术落地方案已经基本落地”这个判断成立。
## 1.2 第四阶段留下的真实缺口
虽然第四阶段已经把 campaign 层做出来了,但它还更像“单 campaign 可运行系统”,还没有进入真正的“生产平台”和“高重玩度生态”阶段。
当前最关键的 8 个缺口是:
1. `CampaignState / EndingState` 已有,但仍偏单线单实例
- 当前更像运行时主战役状态,还没有正式进入“多 campaign 包”和“多 ending family 策略”。
2. `AuthorialConstraintPack` 已有,但仍偏静态
- 目前更像运行时约束快照,还不是可以为不同 campaign / world pack / authorship style 配置的资产层。
3. `NarrativeQaReport` 已有,但仍偏单次运行内检查
- 还没有真正的批量仿真、回归矩阵、发布门禁。
4. `BranchBudgetPlanner` 已有,但仍偏轻量
- 当前更像局部预算检查,不是完整的 campaign 分支运营工具。
5. 缺少玩家画像与自适应导演
- 当前系统很强,但还没有回答:
- 这位玩家偏剧情、偏探索、偏队友、偏战斗、偏收集哪一种?
- 节奏和推荐内容是否应该因玩家风格而微调?
6. 缺少多 campaign / 多 scenario 资产化管理
- 现在能做出一部完整故事,但还没有正式的:
- scenario pack
- campaign registry
- world pack dependency
- reusable story modules
7. 缺少批量仿真和回放能力
- 当前以测试和局部运行验证为主,还没有:
- playthrough simulation
- branch matrix runner
- narrative regression playback
8. 缺少发布与迭代运营链路
- 当前系统适合开发,但还没有:
- telemetry
- release gate report
- save migration manifest
- content diff report
## 1.3 结论
第一阶段解决“骨架”第二阶段解决“动态系统”第三阶段解决“章节与旅程”第四阶段解决“campaign 完整度”,第五阶段要解决的是:
**这套引擎能不能从“一次做成一部作品”,升级到“可以持续生产、评估、复用、发布和运营很多部作品”。**
## 2. 第五阶段目标
第五阶段只做 5 件事:
1. 建立 `Scenario Pack / Campaign Registry` 多 campaign 资产体系
2. 建立 `Simulation Lab / Regression Matrix` 批量仿真与回放评估体系
3. 建立 `Player Style Model / Adaptive Narrative Tuner` 玩家画像与自适应导演体系
4. 建立 `Release Gate / Telemetry / Save Migration` 发布运营体系
5. 建立 `Narrative Production Platform` 内容复用、依赖管理和版本演化体系
一句话定义第五阶段:
**从“完整 campaign 引擎”升级到“可持续生产和运营 campaign 的叙事平台”。**
## 3. 第五阶段完成定义
第五阶段完成后,至少要同时满足:
1. 系统可以同时承载多个 `Scenario Pack / Campaign Pack`
2. 不同 campaign 能共享部分叙事模块、队友模板、载体模板、约束包
3. 发布前可以跑批量 narrative simulation而不只靠单点测试
4. 系统能识别不同玩家风格,并对节奏、事件权重、推荐内容做有限自适应
5. 系统有正式的 `Release Gate Report`
6. 版本更新时有 `Save Migration Manifest`
7. campaign 之间可以被复用、比较、回归和持续优化
## 4. 第五阶段范围
## 4.1 纳入范围
- `src/services/storyEngine/*`
- `src/hooks/story/progressionActions.ts`
- `src/hooks/useStoryGeneration.ts`
- `src/services/prompt.ts`
- `src/services/customWorldBuilder.ts`
- `src/services/customWorld.ts`
- `src/types/storyEngine.ts`
- `src/types/game.ts`
- `src/types/customWorld.ts`
## 4.2 新增模块
- `src/services/storyEngine/scenarioPackRegistry.ts`
- `src/services/storyEngine/campaignPackCompiler.ts`
- `src/services/storyEngine/contentDependencyGraph.ts`
- `src/services/storyEngine/storySimulationRunner.ts`
- `src/services/storyEngine/playthroughMatrixLab.ts`
- `src/services/storyEngine/narrativeRegressionReplay.ts`
- `src/services/storyEngine/playerStyleProfiler.ts`
- `src/services/storyEngine/adaptiveNarrativeTuner.ts`
- `src/services/storyEngine/narrativeTelemetry.ts`
- `src/services/storyEngine/releaseGateReport.ts`
- `src/services/storyEngine/saveMigrationManifest.ts`
- `src/services/storyEngine/contentDiffReport.ts`
## 4.3 第五阶段明确不做
第五阶段仍不做:
1. 大规模联网运营后台
2. 多人协作编辑器平台
3. 商业级可视化内容管理系统
4. 实时云端剧情编排服务
5. 全自动商业数据分析平台
原因:
第五阶段目标是把引擎推进到“可工业化生产和可持续运营”的层级,而不是直接变成完整 SaaS。
## 5. 第五阶段最小闭环
建议第五阶段最小闭环为:
```text
ScenarioPackRegistry
-> CampaignPackCompiler
-> StorySimulationRunner
-> PlaythroughMatrixLab
-> PlayerStyleProfiler
-> AdaptiveNarrativeTuner
-> NarrativeTelemetry
-> ReleaseGateReport
-> SaveMigrationManifest
```
对应到实际价值,就是:
1. campaign 可以被资产化管理
2. 发布前可以批量仿真
3. 上线后可以持续调优
4. 更新时不会轻易打坏旧存档
## 6. 数据结构升级方案
## 6.1 `src/types/storyEngine.ts`
第五阶段建议新增:
```ts
export interface ScenarioPack {
id: string;
title: string;
version: string;
worldPackIds: string[];
campaignIds: string[];
sharedConstraintPackIds: string[];
}
export interface CampaignPack {
id: string;
scenarioPackId: string;
title: string;
authoringStyle: string;
campaignStateSeed: CampaignState;
actTemplates: ActState[];
requiredCompanionIds: string[];
}
export interface PlayerStyleProfile {
id: string;
preferenceWeights: {
story: number;
exploration: number;
combat: number;
companion: number;
collection: number;
};
dominantStyle:
| 'story_first'
| 'explorer'
| 'combat_driver'
| 'companion_bond'
| 'collector';
}
export interface SimulationRunResult {
id: string;
scenarioPackId: string;
campaignPackId: string;
seed: string;
endingId?: string | null;
activeThreadCountPeak: number;
fracturedCompanionCount: number;
issueCount: number;
summary: string;
}
export interface ReleaseGateReport {
generatedAt: string;
status: 'pass' | 'warn' | 'block';
summary: string;
blockingIssues: string[];
simulationCoverage: number;
}
export interface SaveMigrationManifest {
version: string;
requiredTransforms: string[];
backwardCompatible: boolean;
}
```
并扩展:
```ts
export interface StoryEngineMemoryState {
playerStyleProfile?: PlayerStyleProfile | null;
simulationRunResults?: SimulationRunResult[];
releaseGateReport?: ReleaseGateReport | null;
saveMigrationManifest?: SaveMigrationManifest | null;
}
```
## 6.2 `src/types/customWorld.ts`
第五阶段建议扩展:
```ts
interface CustomWorldProfile {
scenarioPackId?: string | null;
campaignPackId?: string | null;
}
```
目的:
- 让世界档案正式进入 pack 体系
## 6.3 `src/types/game.ts`
第五阶段建议扩展:
```ts
interface GameState {
activeScenarioPackId?: string | null;
activeCampaignPackId?: string | null;
}
```
## 7. 模块实现方案
## 7.1 `scenarioPackRegistry.ts`
职责:
1. 维护多个 `ScenarioPack`
2. 让不同 world / campaign / constraint pack 成为正式资产
建议导出:
```ts
registerScenarioPack(pack)
resolveScenarioPack(id)
listScenarioPacks()
```
## 7.2 `campaignPackCompiler.ts`
职责:
1. 把现有 campaign 结构编译成标准 `CampaignPack`
2. 支持:
- 不同 authorial style
- 不同 required companions
- 不同 ending families
建议导出:
```ts
buildCampaignPack(params)
compileCampaignFromWorldProfile(params)
```
## 7.3 `contentDependencyGraph.ts`
职责:
1. 维护:
- campaign -> world
- campaign -> companion
- campaign -> thread
- campaign -> constraint pack
之间的依赖关系
建议导出:
```ts
buildContentDependencyGraph(params)
```
## 7.4 `storySimulationRunner.ts`
职责:
1. 跑单条 playthrough 仿真
2. 输出:
- 走到哪类 ending
- 多少线程未回收
- 队友收束情况
- QA 问题数量
建议导出:
```ts
runStorySimulation(params)
```
## 7.5 `playthroughMatrixLab.ts`
职责:
1. 用多组 seed、多种选择倾向跑批量仿真
2. 汇总:
- ending 分布
- unresolved thread 峰值
- fractured companion 分布
- branch budget 压力
建议导出:
```ts
runPlaythroughMatrix(params)
buildMatrixSummary(params)
```
## 7.6 `narrativeRegressionReplay.ts`
职责:
1. 记录关键 playthrough 样本
2. 在版本升级后回放,检查 narrative regressions
建议导出:
```ts
recordReplaySeed(params)
replayNarrativeRun(params)
```
## 7.7 `playerStyleProfiler.ts`
职责:
1. 根据玩家行为识别风格画像
2. 依据:
- 选项偏好
- 队友互动密度
- 探索行为
- 战斗投入
- 收集倾向
建议导出:
```ts
buildPlayerStyleProfile(state)
updatePlayerStyleProfileFromAction(params)
```
## 7.8 `adaptiveNarrativeTuner.ts`
职责:
1. 基于 `PlayerStyleProfile` 微调运行时导演参数
2. 允许有限自适应:
- 更偏队友戏
- 更偏探索残痕
- 更偏高压冲突
- 更偏调查展开
建议导出:
```ts
resolveAdaptiveNarrativeBias(params)
applyAdaptiveTuningToPromptContext(params)
```
注意:
- 这里必须是“有限微调”,不能破坏作者性和主命题
## 7.9 `narrativeTelemetry.ts`
职责:
1. 汇总关键 narrative runtime 指标
2. 至少包括:
- 平均活跃线程数
- 队友反应密度
- ending family 分布
- unresolved payoff 计数
建议导出:
```ts
captureNarrativeTelemetry(params)
buildTelemetrySnapshot(params)
```
## 7.10 `releaseGateReport.ts`
职责:
1. 把:
- QA report
- simulation results
- branch budget
- unresolved threads
汇总成正式发布门禁报告
建议导出:
```ts
buildReleaseGateReport(params)
```
## 7.11 `saveMigrationManifest.ts`
职责:
1. 为不同版本 story engine state 定义迁移要求
2. 确保多 campaign、多版本更新后旧存档还能继续用
建议导出:
```ts
buildSaveMigrationManifest(params)
applyStoryEngineMigration(params)
```
## 7.12 `contentDiffReport.ts`
职责:
1. 比较两个 campaign/world/constraint pack 版本的差异
2. 用于发布前评估改动面
建议导出:
```ts
buildContentDiffReport(params)
```
## 8. 现有文件改造方案
## 8.1 `useStoryGeneration.ts`
第五阶段新增:
1. 读取 `PlayerStyleProfile`
2. 应用 `AdaptiveNarrativeTuner`
3. 把 adaptive bias 注入 prompt
作用:
- 让同一套 campaign 在不破坏作者性前提下,对不同玩家风格有更好的适配
## 8.2 `progressionActions.ts`
第五阶段重点:
1. 回写 `PlayerStyleProfile`
2. 回写 `SimulationRunResult`(仅调试/实验模式)
3. 回写 `ReleaseGateReport`(构建或工具态)
4. 维护 `SaveMigrationManifest`
## 8.3 `prompt.ts`
第五阶段改造目标:
1. 增加 adaptive bias 提示
2. 增加 authoring style / scenario pack 提示
3. 继续控制不要让自适应破坏核心主题和 mandatory payoffs
## 8.4 `customWorldBuilder.ts` / `customWorld.ts`
第五阶段目标:
1. 正式支持 `ScenarioPack / CampaignPack` 资产化
2. 支持不同 pack 的约束包和版本号
3. 支持 build diff / migration 检查
## 8.5 `useGamePersistence.ts`
第五阶段重点:
1. 引入 `SaveMigrationManifest`
2. 对 story engine state 做版本迁移
3. 确保多 pack、多版本情况下 continue game 仍安全
## 9. 第五阶段开发顺序
建议顺序如下:
1. `scenarioPackRegistry`
2. `campaignPackCompiler`
3. `contentDependencyGraph`
4. `playerStyleProfiler`
5. `adaptiveNarrativeTuner`
6. `storySimulationRunner`
7. `playthroughMatrixLab`
8. `narrativeRegressionReplay`
9. `narrativeTelemetry`
10. `releaseGateReport`
11. `saveMigrationManifest`
12. `contentDiffReport`
原因:
- 先没有 pack 和依赖图,就无法进入平台化
- 先没有 player style profile就无法做自适应
- 先没有 simulation/regression就无法真正做 release gate
## 10. 测试方案
## 10.1 新增单元测试
建议新增:
1. `scenarioPackRegistry.test.ts`
2. `campaignPackCompiler.test.ts`
3. `contentDependencyGraph.test.ts`
4. `playerStyleProfiler.test.ts`
5. `adaptiveNarrativeTuner.test.ts`
6. `storySimulationRunner.test.ts`
7. `playthroughMatrixLab.test.ts`
8. `narrativeRegressionReplay.test.ts`
9. `narrativeTelemetry.test.ts`
10. `releaseGateReport.test.ts`
11. `saveMigrationManifest.test.ts`
12. `contentDiffReport.test.ts`
## 10.2 新增集成测试
建议补这 6 类:
1. 同一 scenario pack 下多 campaign 装载
2. 版本更新后旧存档迁移
3. 同一 campaign 不同 player style 下的有限自适应
4. playthrough matrix 跑出多种 ending family
5. release gate 正确拦截高风险版本
6. regression replay 能检测 narrative drift
## 10.3 人工回归场景
至少做这 6 条:
1. 同一套世界切两条不同 campaign 运行
2. 同一 campaign 用两种玩家风格跑出明显不同的节奏偏重
3. 更新版本后旧存档继续游戏
4. 运行一批 simulation 并生成 release gate report
5. narrative codex 在不同 campaign 间内容不串线
6. ending 和 epilogue 在 regression replay 中保持稳定
## 11. 第五阶段验收标准
做到以下几点,才算第五阶段可收口:
1. 系统支持 `ScenarioPack + CampaignPack`
2. 系统支持 `PlayerStyleProfile + AdaptiveNarrativeTuner`
3. 系统支持批量 `playthrough matrix simulation`
4. 系统支持 `ReleaseGateReport`
5. 系统支持 `SaveMigrationManifest`
6. 系统支持 `NarrativeTelemetry`
7. campaign 可以被复用、对比、回归和版本迁移
8. 相关测试通过,`lint` / `typecheck` / `check:encoding` 通过
## 12. 一句话结论
前四阶段让当前项目拥有了一套从世界图谱到 campaign 收束都能运转的 AI 原生剧情引擎。
第五阶段要做的,是让它进一步成为:
**一套能够持续生产多个 campaign、进行批量仿真评估、适配不同玩家风格、支持版本演化和发布门禁的叙事平台。**

View File

@@ -0,0 +1,559 @@
# AI 原生剧情引擎第六阶段技术落地方案
更新时间:`2026-04-06`
## 0. 是否需要第六阶段
结论先说:
**需要,但第六阶段不应该继续主要围绕“再加一层 runtime 叙事引擎能力”展开,而应该转向“内容生产、策划协同、仿真可视化、版本治理和持续调优”的平台层。**
原因很明确:
按当前代码审计结果,前五阶段已经基本把这些核心能力做出来了:
1. 世界图谱
2. 角色叙事档案
3. 信息可见性
4. 线程与信号推进
5. 队友反应与个人线
6. 章节 / 旅程 / 高光导演
7. campaign / act / ending / epilogue
8. codex / chronicle / QA report
9. scenario pack / campaign pack
10. simulation / regression / release gate / migration
这说明:
- “引擎能力不够”已经不是最核心的问题了
- 接下来最大的瓶颈会变成:
- 内容生产效率
- 策划可控性
- 多版本治理
- 调优闭环
- 让设计师和内容作者能真正驾驭这套系统
所以第六阶段不是 “story engine phase 6 = 再造一个新 runtime 层”,而是:
**让这套引擎从“强大的技术系统”,进化成“可被持续使用的剧情生产系统”。**
## 1. 第五阶段审计结论
## 1.1 可以判定为“基本落地”的部分
按代码和测试情况,第五阶段已经基本落地:
1. 第五阶段模块已存在并有测试:
- `scenarioPackRegistry`
- `campaignPackCompiler`
- `contentDependencyGraph`
- `storySimulationRunner`
- `playthroughMatrixLab`
- `narrativeRegressionReplay`
- `playerStyleProfiler`
- `adaptiveNarrativeTuner`
- `narrativeTelemetry`
- `releaseGateReport`
- `saveMigrationManifest`
- `contentDiffReport`
2. 关键主链已接入:
- `progressionActions.ts`
- `useStoryGeneration.ts`
- `prompt.ts`
- `customWorldBuilder.ts`
- `useGamePersistence.ts`
3. 第五阶段相关测试通过:
- `scenarioPackRegistry.test.ts`
- `campaignPackCompiler.test.ts`
- `contentDependencyGraph.test.ts`
- `storySimulationRunner.test.ts`
- `playthroughMatrixLab.test.ts`
- `narrativeRegressionReplay.test.ts`
- `playerStyleProfiler.test.ts`
- `adaptiveNarrativeTuner.test.ts`
- `narrativeTelemetry.test.ts`
- `releaseGateReport.test.ts`
- `saveMigrationManifest.test.ts`
- `contentDiffReport.test.ts`
## 1.2 当前真实缺口
虽然第五阶段已经把“平台化骨架”做出来了,但它还明显偏:
1. 后台能力
2. 内部结构
3. 代码级接口
还没有真正形成下面这些“生产工作台”能力:
1. 设计师 / 内容作者可视化地理解:
- 当前 world pack
- 当前 campaign pack
- 当前 branch budget
- 当前 unresolved payoff
2. 仿真结果可视化与比对
- 现在能跑 simulation但还缺
- matrix dashboard
- ending 分布图
- unresolved thread 热区
- companion fracture 热图
3. 人机协同编辑闭环
- 当前可以生成、回归、校验,但还缺:
- 人工批注
- 审核通过 / 驳回
- 局部重生成
- 受控覆盖
4. 内容治理体系
- 当前 pack 可以存在,但还缺:
- 版本状态
- 草稿 / 候选 / 已发布
- 回滚
- 依赖冲突提示
5. 面向策划的 narrative ops
- 当前 QA report 存在,但还缺:
- 每日 / 每版本比较
- 关键指标趋势
- 哪条线程最常断
- 哪个 ending 最少触发
## 1.3 额外需要注意的现实问题
本轮审计里还有一个工程事实需要单独记下:
- 第五阶段相关测试通过
- 但当前全仓 `lint` 仍不是完全绿色,存在 import 排序和未使用导入问题
这说明:
**从第六阶段开始,除了继续扩功能,更应该把“生产平台自己的门禁和治理”一起正式纳入。**
## 2. 第六阶段目标
第六阶段只做 5 件事:
1. 建立 `Narrative Studio Workspace` 剧情工作台
2. 建立 `Simulation Dashboard / Regression Review` 仿真与回归可视化
3. 建立 `Human-in-the-loop Authoring Flow` 人机协同创作流
4. 建立 `Pack Governance / Publish Workflow` 内容治理与发布流
5. 建立 `Narrative Ops` 指标、比较、告警与持续调优流
一句话定义第六阶段:
**从“叙事平台”升级到“叙事生产系统”。**
## 3. 第六阶段完成定义
第六阶段完成后,至少要同时满足:
1. 设计师不看代码,也能看懂当前 campaign 的结构状态。
2. 可以用可视化方式查看 simulation / regression / release gate 的结果。
3. 可以对 AI 产物做人工批注、重生成、局部覆盖和审批。
4. `ScenarioPack / CampaignPack` 有正式的版本状态与发布流。
5. narrative QA 和 telemetry 不只是生成结果,而是成为可比较、可追踪、可告警的运营资产。
## 4. 第六阶段范围
## 4.1 纳入范围
- `src/services/storyEngine/*`
- `src/components/*`
- `src/components/preset-editor/*`
- `src/components/CustomWorldGenerationView.tsx`
- `src/components/StateFunctionEditor.tsx`
- `src/components/SelectionCustomizationModals.tsx`
- `src/services/customWorld.ts`
- `src/services/customWorldBuilder.ts`
- `src/hooks/useGamePersistence.ts`
- `src/types/storyEngine.ts`
## 4.2 新增模块
- `src/services/storyEngine/narrativeStudioState.ts`
- `src/services/storyEngine/simulationDashboardModel.ts`
- `src/services/storyEngine/reviewQueue.ts`
- `src/services/storyEngine/regenerationPatchPlan.ts`
- `src/services/storyEngine/packPublishWorkflow.ts`
- `src/services/storyEngine/packRollbackPlan.ts`
- `src/services/storyEngine/narrativeOpsMetrics.ts`
- `src/services/storyEngine/narrativeAlerting.ts`
- `src/services/storyEngine/narrativeReviewNotes.ts`
- `src/services/storyEngine/qaTrendReport.ts`
## 4.3 第六阶段明确不做
第六阶段仍不做:
1. 全在线多人协同编辑器
2. 完整云端数据平台
3. 商业 BI 大盘系统
4. 全自动策划代理替代人工审核
5. 对外开放市场级内容生态
原因:
第六阶段的目标是让内部生产真正运转起来,而不是直接做成对外产品平台。
## 5. 第六阶段最小闭环
建议第六阶段最小闭环为:
```text
NarrativeStudioState
-> SimulationDashboardModel
-> ReviewQueue
-> RegenerationPatchPlan
-> PackPublishWorkflow
-> NarrativeOpsMetrics
-> QaTrendReport
-> NarrativeAlerting
```
对应到实际价值,就是:
1. 可以看到当前内容状态
2. 可以看 simulation 结果
3. 可以对问题内容提出修复
4. 可以受控发布和回滚
5. 可以持续观察质量变化
## 6. 数据结构升级方案
## 6.1 `src/types/storyEngine.ts`
第六阶段建议新增:
```ts
export interface NarrativeReviewNote {
id: string;
targetId: string;
targetType: 'thread' | 'chapter' | 'companion' | 'carrier' | 'ending' | 'pack';
note: string;
status: 'open' | 'addressed' | 'ignored';
}
export interface ReviewQueueItem {
id: string;
source: 'qa_report' | 'simulation' | 'manual';
priority: 'low' | 'medium' | 'high';
summary: string;
relatedIds: string[];
}
export interface PackPublishState {
packId: string;
version: string;
status: 'draft' | 'candidate' | 'released' | 'rolled_back';
lastPublishedAt?: string | null;
}
export interface NarrativeOpsSnapshot {
generatedAt: string;
activePackCount: number;
releaseGatePassRate: number;
unresolvedIssueCount: number;
topRiskAreas: string[];
}
export interface QaTrendPoint {
label: string;
issueCount: number;
blockingCount: number;
}
```
并扩展:
```ts
export interface StoryEngineMemoryState {
reviewNotes?: NarrativeReviewNote[];
reviewQueue?: ReviewQueueItem[];
publishStates?: PackPublishState[];
narrativeOpsSnapshot?: NarrativeOpsSnapshot | null;
qaTrend?: QaTrendPoint[];
}
```
## 7. 模块实现方案
## 7.1 `narrativeStudioState.ts`
职责:
1. 聚合当前:
- campaign 状态
- branch budget
- QA report
- ending 状态
- simulation 结果
建议导出:
```ts
buildNarrativeStudioState(params)
```
## 7.2 `simulationDashboardModel.ts`
职责:
1. 把 matrix / replay / telemetry 结果整理成可展示模型
2. 输出:
- ending 分布
- unresolved threads 排行
- fracture companion 排行
- regression failure 列表
建议导出:
```ts
buildSimulationDashboardModel(params)
```
## 7.3 `reviewQueue.ts`
职责:
1. 把 QA、simulation、手工批注统一收敛成 review queue
2. 支持优先级排序与状态流转
建议导出:
```ts
buildReviewQueue(params)
advanceReviewQueueItem(params)
```
## 7.4 `regenerationPatchPlan.ts`
职责:
1. 把 review item 转成局部重生成 / 局部 override 计划
2. 支持:
- 角色档案重生成
- 线程补偿
- ending 修复
- 文书 / 载体文本修复
建议导出:
```ts
buildRegenerationPatchPlan(params)
```
## 7.5 `packPublishWorkflow.ts`
职责:
1. 管理 pack 的发布状态
2. 要求:
- 发布前通过 release gate
- 生成 publish record
建议导出:
```ts
advancePackPublishState(params)
canPublishPack(params)
```
## 7.6 `packRollbackPlan.ts`
职责:
1. 对已发布 pack 生成回滚方案
2.`SaveMigrationManifest` 协作
建议导出:
```ts
buildPackRollbackPlan(params)
```
## 7.7 `narrativeOpsMetrics.ts`
职责:
1. 聚合 narrative ops 指标
2. 输出:
- pass rate
- issue density
- regression stability
- ending diversity
建议导出:
```ts
buildNarrativeOpsSnapshot(params)
```
## 7.8 `narrativeAlerting.ts`
职责:
1. 对:
- release gate blocking
- regression drift
- unresolved issue spike
发出告警
建议导出:
```ts
evaluateNarrativeAlerts(params)
```
## 7.9 `narrativeReviewNotes.ts`
职责:
1. 让人工可以对:
- chapter
- ending
- thread
- companion
记录审核笔记
建议导出:
```ts
appendNarrativeReviewNote(params)
resolveNarrativeReviewNote(params)
```
## 7.10 `qaTrendReport.ts`
职责:
1. 把多次 QA report 做趋势比较
2. 输出:
- issue 趋势
- block 趋势
- 最常出问题区域
建议导出:
```ts
buildQaTrendReport(params)
```
## 8. 现有文件改造方案
## 8.1 `progressionActions.ts`
第六阶段重点:
1. 回写 `reviewQueue`
2. 回写 `narrativeOpsSnapshot`
3. 回写 `qaTrend`
## 8.2 `useStoryGeneration.ts`
第六阶段重点:
1. 在 studio/debug 模式下暴露更多状态
2. 不污染正式运行时主链
## 8.3 `CustomWorldGenerationView.tsx`
建议成为第六阶段的第一个“工作台入口”,用于展示:
1. 当前 pack
2. 当前 release gate
3. 当前 QA trend
4. 当前 simulation 摘要
## 8.4 `StateFunctionEditor.tsx` / `preset-editor/*`
建议逐步增加 narrative review / patch plan 的入口,而不是只做底层数据编辑。
## 8.5 `useGamePersistence.ts`
继续强化:
1. pack version
2. migration status
3. rollback safety
## 9. 第六阶段开发顺序
建议顺序如下:
1. `reviewQueue`
2. `narrativeReviewNotes`
3. `simulationDashboardModel`
4. `narrativeOpsMetrics`
5. `qaTrendReport`
6. `packPublishWorkflow`
7. `packRollbackPlan`
8. `regenerationPatchPlan`
9. `narrativeAlerting`
10. `narrativeStudioState`
11. UI 工作台最小接线
原因:
- 第六阶段的重点是让“平台能力真正可操作”
- 所以先做 review / dashboard / publish flow比继续堆 runtime 更有价值
## 10. 测试方案
## 10.1 新增单元测试
建议新增:
1. `reviewQueue.test.ts`
2. `narrativeReviewNotes.test.ts`
3. `simulationDashboardModel.test.ts`
4. `narrativeOpsMetrics.test.ts`
5. `qaTrendReport.test.ts`
6. `packPublishWorkflow.test.ts`
7. `packRollbackPlan.test.ts`
8. `regenerationPatchPlan.test.ts`
9. `narrativeAlerting.test.ts`
10. `narrativeStudioState.test.ts`
## 10.2 新增集成测试
建议补这 5 类:
1. QA report -> review queue -> patch plan
2. simulation matrix -> dashboard -> release gate
3. pack candidate -> publish -> rollback
4. old save -> migration -> continue game
5. 多版本 QA trend 比较
## 10.3 人工回归场景
至少做这 5 条:
1. 打开 narrative 工作台能看懂当前 campaign 状态
2. 一次 regression drift 能正确进入 review queue
3. 一个 pack 通过 release gate 成功发布
4. 一个 pack 发布后可以生成 rollback plan
5. 一组 QA trend 能看出问题是在变好还是变坏
## 11. 第六阶段验收标准
做到以下几点,才算第六阶段可收口:
1. 系统具备可视化 narrative studio 状态模型
2. 系统具备 review queue 和 review note 流程
3. 系统具备 simulation dashboard 模型
4. 系统具备 publish / rollback workflow
5. 系统具备 narrative ops snapshot 和 QA trend
6. 人工审核与 AI 重生成之间形成闭环
7. 相关测试通过,`lint` / `typecheck` / `check:encoding` 通过
## 12. 一句话结论
如果前五阶段解决的是“把 AI 原生剧情引擎做成一套完整可运行、可发布、可复用的平台”,
那么第六阶段要解决的,就是:
**让这套平台真正变成团队可以长期驾驭、持续生产、持续审核、持续迭代的叙事生产系统。**

10
docs/reference/README.md Normal file
View File

@@ -0,0 +1,10 @@
# 参考目录
## 当前入口
- [FUNCTION_SCRIPT_CATALOG_2026-04-04.md](./FUNCTION_SCRIPT_CATALOG_2026-04-04.md)Function 独立脚本目录与分类速查。
## 使用建议
- 需要快速定位 Function 脚本,而不是阅读长篇方案时,优先看这里。
- 如果要评估 Function 分层是否合理,再配合 `docs/audits/FUNCTION_DESIGN_AUDIT_2026-04-03.md` 一起看。

14
docs/technical/README.md Normal file
View File

@@ -0,0 +1,14 @@
# 技术方案
这一组文档偏技术选型、实现路线和外部产品形态拆解。
## 文档列表
- [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)AI 生成角色形象与角色动画的技术路线。
- [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md)PixelMotion 产品形态与能力拆解。
- [SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md](./SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md):服务端部署、代理层与 CORS 方案。
## 使用建议
- 做实现选型时,优先看这一组。
- 做阶段排期时,把这一组和 `docs/planning/``docs/prd/` 一起看,更容易判断先后顺序。

View 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/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md](/E:/Repos/Genarrative/docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md) 明确沉淀过一条经验:
- 浏览器直连会遇到 CORS
- 更稳的方案是开发服务器代理,再由前端请求 `/api/llm/...`
[docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](/E:/Repos/Genarrative/docs/audits/engineering/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

View File

@@ -41,6 +41,8 @@ const EXCLUDED_PREFIXES = [
'.codex-logs/',
'.git/',
'dist/',
'dist_check/',
'dist_check_monster_position/',
'media/',
'node_modules/',
'public/Icons/',

View File

@@ -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),

View File

@@ -12,8 +12,8 @@ import {
createEmptyEquipmentLoadout,
getEquipmentBonuses,
} from '../src/data/equipmentEffects.ts';
import { createSceneHostileNpcsFromIds } from '../src/data/hostileNpcs.ts';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../src/data/inventoryEffects.ts';
import { createSceneMonstersFromIds } from '../src/data/monsters.ts';
import { buildInitialNpcState, buildInitialPlayerInventory, buildNpcEncounterStoryMoment, checkTradeItem, createNpcBattleMonster } from '../src/data/npcInteractions.ts';
import {
acceptQuest,
@@ -26,7 +26,7 @@ import {
import { createInitialGameRuntimeStats } from '../src/data/runtimeStats.ts';
import { createSceneCallOutEncounter, createSceneEncounterPreview, ensureSceneEncounterPreview } from '../src/data/sceneEncounterPreviews.ts';
import { buildSceneObserveSignsStoryText } from '../src/data/sceneObservation.ts';
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts';
import { AnimationState, GameState, WorldType } from '../src/types.ts';
@@ -55,7 +55,7 @@ function createBaseState(worldType: WorldType, sceneId?: string): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -92,10 +92,10 @@ function smokeScenePreviews() {
const preview = createSceneEncounterPreview(createBaseState(worldType, scene.id));
assert(preview.currentEncounter?.kind !== 'treasure', `[preview] treasure encounter should be disabled for ${worldType}`);
assert(preview.currentEncounter || preview.sceneMonsters.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`);
assert(preview.currentEncounter || preview.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`);
const ensured = ensureSceneEncounterPreview(createBaseState(worldType, scene.id));
assert(ensured.currentEncounter || ensured.sceneMonsters.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`);
assert(ensured.currentEncounter || ensured.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`);
}
}
@@ -163,20 +163,21 @@ function smokeTreasureStories() {
function smokeMonsterCreation() {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length > 0);
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length > 0);
assert(sceneWithMonster, `[monster] missing monster scene for ${worldType}`);
const monsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0);
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
const monsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
assert(monsters.length > 0, `[monster] ${sceneWithMonster.id} failed to create scene monsters`);
assert(
monsters.length === Math.min(sceneWithMonster.monsterIds.length, 3),
monsters.length === Math.min(hostileNpcPresetIds.length, 3),
`[monster] ${sceneWithMonster.id} should keep the full configured encounter group`,
);
const resolvedState = createBaseState(worldType, sceneWithMonster.id);
resolvedState.sceneMonsters = monsters;
resolvedState.sceneHostileNpcs = monsters;
resolvedState.inBattle = true;
assert(
resolvedState.sceneMonsters.length === monsters.length,
resolvedState.sceneHostileNpcs.length === monsters.length,
`[monster] ${sceneWithMonster.id} multi-enemy battle state lost monsters`,
);
}
@@ -206,7 +207,7 @@ function smokeObserveAndCallOut() {
const baseState = createBaseState(worldType, scene.id);
const callOutResult = createSceneCallOutEncounter(baseState);
assert(callOutResult.currentEncounter?.kind !== 'treasure', `[idle] treasure call_out should be disabled for ${worldType}`);
assert(callOutResult.currentEncounter || callOutResult.sceneMonsters.length > 0 || scene.monsterIds.length === 0, `[idle] call_out failed for ${scene.id}`);
assert(callOutResult.currentEncounter || callOutResult.sceneHostileNpcs.length > 0 || getSceneHostileNpcPresetIds(scene).length === 0, `[idle] call_out failed for ${scene.id}`);
const observeText = buildSceneObserveSignsStoryText(worldType, scene.id);
assert(observeText.length > 12, `[idle] observe_signs text too short for ${scene.id}`);
@@ -281,19 +282,20 @@ function smokeTradeEconomyLoop() {
function smokeEncounterTransitionLoop() {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length >= 2);
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length >= 2);
assert(sceneWithMonster, `[transition] missing multi-monster scene for ${worldType}`);
const finalMonsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0);
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
const finalMonsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
const finalState = {
...createBaseState(worldType, sceneWithMonster.id),
inBattle: true,
sceneMonsters: finalMonsters,
sceneHostileNpcs: finalMonsters,
};
const previewState = {
...finalState,
inBattle: false,
sceneMonsters: finalMonsters.map((monster, index) => ({
sceneHostileNpcs: finalMonsters.map((monster, index) => ({
...monster,
xMeters: 12 + (index * 1.8),
})),
@@ -301,15 +303,15 @@ function smokeEncounterTransitionLoop() {
const transitionState = buildEncounterTransitionState(finalState, previewState);
assert(
transitionState.sceneMonsters[1]?.xMeters === previewState.sceneMonsters[1]?.xMeters,
transitionState.sceneHostileNpcs[1]?.xMeters === previewState.sceneHostileNpcs[1]?.xMeters,
`[transition] second monster should keep its preview x during transition for ${worldType}`,
);
const halfwayState = interpolateEncounterTransitionState(transitionState, finalState, 0.5);
assert(
halfwayState.sceneMonsters.every((monster, index) => {
const startX = transitionState.sceneMonsters[index]?.xMeters ?? monster.xMeters;
const endX = finalState.sceneMonsters[index]?.xMeters ?? monster.xMeters;
halfwayState.sceneHostileNpcs.every((monster, index) => {
const startX = transitionState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters;
const endX = finalState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters;
return monster.xMeters !== startX && monster.xMeters !== endX;
}),
`[transition] all monsters should interpolate instead of only the first one for ${worldType}`,
@@ -317,7 +319,7 @@ function smokeEncounterTransitionLoop() {
const offscreenState = buildEncounterEntryState(finalState, 18);
assert(
offscreenState.sceneMonsters.every(monster => monster.xMeters >= 18),
offscreenState.sceneHostileNpcs.every(monster => monster.xMeters >= 18),
`[transition] offscreen entry should place the entire encounter group offscreen for ${worldType}`,
);
}
@@ -361,7 +363,7 @@ function smokeRosterLoop() {
function smokeQuestLoop() {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
const sceneWithNpcAndMonster = getScenePresetsByWorld(worldType).find(
scene => scene.npcs.length > 0 && scene.monsterIds.length > 0,
scene => scene.npcs.length > 0 && getSceneHostileNpcPresetIds(scene).length > 0,
);
assert(sceneWithNpcAndMonster, `[quest] missing npc+monster scene for ${worldType}`);

View File

@@ -1,6 +1,6 @@
import { getCharacterHomeSceneId, getCharacterNpcSceneIds, PRESET_CHARACTERS } from '../src/data/characterPresets.ts';
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
import { WorldType } from '../src/types.ts';
@@ -32,7 +32,7 @@ function validateScenes(errors: string[]) {
}
});
scene.monsterIds.forEach(monsterId => {
getSceneHostileNpcPresetIds(scene).forEach(monsterId => {
if (!monsterIdSet.has(monsterId)) {
addError(errors, `[scene] ${scene.id} references unknown monster "${monsterId}" in ${worldType}`);
}

View File

@@ -87,12 +87,12 @@ function validateMonsterOverrides(errors: string[]) {
const overrides = readJsonFile<Record<string, unknown>>('src/data/monsterOverrides.json');
if (!expectPlainObject(errors, 'monsterOverrides', overrides)) return;
const monsterIds = new Set(
const hostilePresetIds = new Set(
[...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA], ...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA]].map(monster => monster.id),
);
Object.entries(overrides).forEach(([monsterId, override]) => {
if (!monsterIds.has(monsterId)) {
if (!hostilePresetIds.has(monsterId)) {
errors.push(`[override] monsterOverrides contains unknown monster id "${monsterId}"`);
return;
}
@@ -109,10 +109,6 @@ function validateSceneOverrides(errors: string[]) {
const sceneIds = new Set(
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
);
const monsterIds = new Set(
[...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA], ...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA]].map(monster => monster.id),
);
Object.entries(overrides).forEach(([sceneId, override]) => {
if (!sceneIds.has(sceneId)) {
errors.push(`[override] sceneOverrides contains unknown scene id "${sceneId}"`);
@@ -134,13 +130,6 @@ function validateSceneOverrides(errors: string[]) {
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid connectedSceneIds`);
}
}
const overrideMonsterIds = override.monsterIds;
if (overrideMonsterIds !== undefined) {
if (!Array.isArray(overrideMonsterIds) || overrideMonsterIds.some(id => typeof id !== 'string' || !monsterIds.has(id))) {
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid monsterIds`);
}
}
});
}

View File

@@ -1,6 +1,6 @@
import { X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { CSSProperties, ReactNode } from 'react';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
@@ -9,16 +9,13 @@ import {
} from '../data/affinityLevels';
import {
buildRelationState,
formatAttributeList,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import {
type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getBuildContributionQualityRatio,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
resolveMonsterOutgoingDamage,
@@ -46,6 +43,7 @@ import {
createNpcBattleMonster,
normalizeNpcPersistentState,
} from '../data/npcInteractions';
import { getSceneHostileNpcPresetIds } from '../data/scenePresets';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
@@ -64,6 +62,18 @@ import {
type BackstoryUnlockedChapter,
} from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import {
getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getSkillDeliveryLabel,
getSkillStyleLabel,
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterSkillsList,
MultiplierContributionList,
StatusRow,
} from './CharacterInfoShared';
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { HostileNpcAnimator } from './HostileNpcAnimator';
@@ -110,41 +120,6 @@ function estimateNpcMaxMana(character: Character | null) {
return character ? getCharacterMaxMana(character) : 0;
}
function StatBar({
label,
current,
max,
tone,
}: {
label: string;
current: number;
max: number;
tone: 'hp' | 'mp';
}) {
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
const fillClass =
tone === 'hp'
? 'from-emerald-400 via-lime-300 to-emerald-200'
: 'from-sky-500 via-cyan-300 to-sky-100';
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">
{current} / {max}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
@@ -156,32 +131,12 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
);
}
const SKILL_STYLE_LABELS = {
burst: '爆发',
steady: '稳态',
mobility: '机动',
finisher: '终结',
projectile: '投射',
} satisfies Record<Character['skills'][number]['style'], string>;
type ContributionRow = BuildDamageBreakdown['rows'][number];
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
return skill.delivery === 'ranged' || skill.style === 'projectile'
? '远程'
: '近战';
}
function getSkillStyleLabel(skill: Character['skills'][number]) {
return SKILL_STYLE_LABELS[skill.style];
}
function resolveSkillPreviewMonsterId(gameState: GameState) {
if (!gameState.worldType) {
return null;
}
const sceneMonsterId = gameState.currentScenePreset?.monsterIds?.[0] ?? null;
const sceneMonsterId = getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
if (sceneMonsterId) {
return sceneMonsterId;
}
@@ -189,121 +144,6 @@ function resolveSkillPreviewMonsterId(gameState: GameState) {
return getMonsterPresetsByWorld(gameState.worldType)[0]?.id ?? null;
}
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}
function getContributionVisualStyle(value: number): CSSProperties {
const ratio = getContributionHeatRatio(value);
const hue = 210 - ratio * 178;
const saturation = 62 + ratio * 16;
const lightness = 56 + ratio * 6;
return {
borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`,
background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`,
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`,
color:
ratio > 0.76
? 'rgb(255 244 235)'
: ratio > 0.32
? 'rgb(236 242 248)'
: 'rgb(203 213 225)',
};
}
function MultiplierContributionList({
breakdown,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort(
(left, right) =>
right.bonusDelta - left.bonusDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
return (
<div className="space-y-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-3">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-sky-100/80">
<span></span>
<span className="text-zinc-400"></span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="min-w-[6.25rem] rounded-xl border px-3 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5"
style={getContributionVisualStyle(row.bonusDelta)}
title={`查看 ${row.label} 的标签效果`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{row.label}</span>
<span className="text-[11px] font-semibold tracking-[0.12em] text-current/80">
{getBuildContributionQualityLabel(row.bonusDelta)}
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-current/70">
{formatBuildContributionPercent(row.bonusDelta)}
</div>
</button>
))}
</div>
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
);
}
function CharacterSkills({
skills,
onSelectSkill,
}: {
skills: Character['skills'];
onSelectSkill: (skillId: string) => void;
}) {
if (skills.length === 0) {
return <div className="text-sm text-zinc-500"></div>;
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{skills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => onSelectSkill(skill.id)}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
{getSkillDeliveryLabel(skill)}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
<div>{skill.manaCost}</div>
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-3 text-[10px] tracking-[0.16em] text-sky-200/85">
{getSkillStyleLabel(skill)}
</div>
</button>
))}
</div>
);
}
function buildPreviewInventoryDescription(
characterName: string,
item: { category: string; name: string; quantity: number },
@@ -563,11 +403,10 @@ export function AdventureEntityModal({
buildInitialNpcState(npcEncounter, gameState.worldType, gameState),
)
: null;
const hostileNpcPresetId =
npcEncounter?.hostileNpcPresetId ?? npcEncounter?.hostileNpcPresetId;
const monsterPresetId = npcEncounter?.monsterPresetId ?? null;
const hostileNpcPreset =
hostileNpcPresetId && gameState.worldType
? getHostileNpcPresetById(gameState.worldType, hostileNpcPresetId)
monsterPresetId && gameState.worldType
? getHostileNpcPresetById(gameState.worldType, monsterPresetId)
: null;
const npcBattleState =
selection?.kind === 'npc' ? (selection.battleState ?? null) : null;
@@ -768,9 +607,6 @@ export function AdventureEntityModal({
customWorldProfile: gameState.customWorldProfile,
})
: null;
const attributeRows = selectedAttributeProfile
? formatAttributeList(selectedAttributeProfile, attributeSchema)
: [];
const resourceLabels = getResourceLabelsForWorld(
gameState.worldType,
gameState.customWorldProfile,
@@ -849,6 +685,31 @@ export function AdventureEntityModal({
inventory.find((item) => item.id === selectedItemId) ?? null;
const selectedSkillOwnerName =
detailCharacter?.name ?? npcEncounter?.npcName ?? title;
const recentChronicleEntries = gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
const recentCarrierEchoes = (gameState.storyEngineMemory?.recentCarrierIds ?? [])
.map((carrierId) =>
gameState.playerInventory.find((item) => item.id === carrierId)?.runtimeMetadata?.storyFingerprint?.visibleClue
?? gameState.playerInventory.find((item) => item.id === carrierId)?.name
?? '',
)
.filter(Boolean)
.slice(0, 3);
const sceneResidues = gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
const selectedCompanionResolution =
detailCharacter
? gameState.storyEngineMemory?.companionResolutions?.find(
(resolution) => resolution.characterId === detailCharacter.id,
) ?? null
: null;
const relatedConsequences = (gameState.storyEngineMemory?.consequenceLedger ?? [])
.filter((record) =>
detailCharacter
? record.relatedIds.includes(detailCharacter.id)
: npcEncounter
? record.relatedIds.includes(npcEncounter.id ?? npcEncounter.npcName)
: false,
)
.slice(-3);
useEffect(() => {
setSelectedSkillId(null);
@@ -921,6 +782,9 @@ export function AdventureEntityModal({
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
playerCharacter,
)}
/>
) : selection.kind === 'companion' &&
companionCharacter ? (
@@ -929,6 +793,9 @@ export function AdventureEntityModal({
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
companionCharacter,
)}
/>
) : npcCharacter ? (
<CharacterAnimator
@@ -936,6 +803,7 @@ export function AdventureEntityModal({
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(npcCharacter)}
/>
) : hostileNpcPreset ? (
<HostileNpcAnimator
@@ -1043,17 +911,83 @@ export function AdventureEntityModal({
</Section>
) : null}
{(recentChronicleEntries.length > 0 ||
recentCarrierEchoes.length > 0 ||
sceneResidues.length > 0 ||
relatedConsequences.length > 0 ||
Boolean(selectedCompanionResolution)) && (
<Section title="最近回响">
<div className="space-y-3">
{selectedCompanionResolution && (
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-100/85">
{selectedCompanionResolution.resolutionType} · {selectedCompanionResolution.summary}
</div>
)}
{relatedConsequences.length > 0 && (
<div className="space-y-1">
{relatedConsequences.map((record) => (
<div
key={record.id}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{record.title}</span>
{''}
{record.summary}
</div>
))}
</div>
)}
{recentChronicleEntries.length > 0 && (
<div className="space-y-1">
{recentChronicleEntries.map((entry) => (
<div
key={entry.id}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm font-medium text-white">
{entry.title}
</div>
<div className="mt-1 text-xs text-zinc-400">
{entry.summary}
</div>
</div>
))}
</div>
)}
{recentCarrierEchoes.length > 0 && (
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-amber-100/85">
{recentCarrierEchoes.join('')}
</div>
)}
{sceneResidues.length > 0 && (
<div className="space-y-1">
{sceneResidues.map((residue) => (
<div
key={residue.id}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{residue.title}</span>
{''}
{residue.visibleClue}
</div>
))}
</div>
)}
</div>
</Section>
)}
<Section title="属性">
<div className="space-y-4">
<div className="space-y-3">
<StatBar
<StatusRow
label={resourceLabels.hp}
current={hp}
max={maxHp}
tone="hp"
/>
{maxMana > 0 ? (
<StatBar
<StatusRow
label={resourceLabels.mp}
current={mana}
max={maxMana}
@@ -1069,43 +1003,27 @@ export function AdventureEntityModal({
}
/>
) : null}
{attributeRows.length > 0 ? (
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
{attributeRows.map(({ slot, value }) => (
<div
key={slot.slotId}
className="rounded-xl border border-white/8 bg-black/25 px-3 py-2"
>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 text-2xl font-bold text-white">
{value}
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{slot.definition}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-zinc-500">
</div>
)}
<CharacterAttributeGrid
attributeProfile={selectedAttributeProfile}
attributeSchema={attributeSchema}
buildBreakdown={buildBreakdown}
resourceLabels={resourceLabels}
gridClassName="grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2"
cardClassName="rounded-xl border border-white/8 bg-black/25 px-3 py-2"
/>
</div>
</Section>
{detailCharacter ? (
<Section title="技能">
<CharacterSkills
<CharacterSkillsList
skills={displayedSkills}
onSelectSkill={setSelectedSkillId}
/>
</Section>
) : displayedSkills.length > 0 ? (
<Section title="技能">
<CharacterSkills
<CharacterSkillsList
skills={displayedSkills}
onSelectSkill={setSelectedSkillId}
/>
@@ -1202,7 +1120,6 @@ export function AdventureEntityModal({
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">

View File

@@ -30,10 +30,14 @@ import { getScenePresetById } from '../data/scenePresets';
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration';
import type {
CampEvent,
ChapterState,
Character,
InventoryItem,
JourneyBeat,
NpcBattleMode,
QuestLogEntry,
SetpieceDirective,
StoryMoment,
StoryOption,
WorldType,
@@ -89,6 +93,11 @@ interface AdventurePanelProps {
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
recentChronicleSummary?: string | null;
currentCampEvent?: CampEvent | null;
setpieceDirective?: SetpieceDirective | null;
}
const AdventurePanelOverlays = lazy(async () => {
@@ -591,6 +600,11 @@ export function AdventurePanel({
musicVolume,
onMusicVolumeChange,
onSaveAndExit,
chapterState = null,
journeyBeat = null,
recentChronicleSummary = null,
currentCampEvent = null,
setpieceDirective = null,
}: AdventurePanelProps) {
const isDialogueStory = currentStory.displayMode === 'dialogue';
const dialogueTurns = currentStory.dialogue ?? [];
@@ -601,6 +615,7 @@ export function AdventurePanel({
currentStory.deferredOptions?.length,
);
const saveAndExitDisabled = isLoading || isStoryStreaming;
const [isChapterPanelOpen, setIsChapterPanelOpen] = useState(false);
const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isStatsPanelOpen, setIsStatsPanelOpen] = useState(false);
@@ -798,6 +813,7 @@ export function AdventurePanel({
[statistics],
);
const shouldMountAdventureOverlays =
isChapterPanelOpen ||
isSettingsPanelOpen ||
isStatsPanelOpen ||
isQuestPanelOpen ||
@@ -838,9 +854,23 @@ export function AdventurePanel({
</button>
<button
type="button"
onClick={() => setIsQuestPanelOpen(true)}
onClick={() => setIsChapterPanelOpen(true)}
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 7vh)' }}
>
<ScrollText className="h-4 w-4" />
<span className="leading-none"></span>
{chapterState?.title ? (
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
{chapterState.title}
</span>
) : null}
</button>
<button
type="button"
onClick={() => setIsQuestPanelOpen(true)}
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14.5vh)' }}
>
{hasCompletedQuest && (
<span
@@ -1053,12 +1083,19 @@ export function AdventurePanel({
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={onSaveAndExit}
saveAndExitDisabled={saveAndExitDisabled}
isChapterPanelOpen={isChapterPanelOpen}
setIsChapterPanelOpen={setIsChapterPanelOpen}
isQuestPanelOpen={isQuestPanelOpen}
setIsQuestPanelOpen={setIsQuestPanelOpen}
isSettingsPanelOpen={isSettingsPanelOpen}
setIsSettingsPanelOpen={setIsSettingsPanelOpen}
isStatsPanelOpen={isStatsPanelOpen}
setIsStatsPanelOpen={setIsStatsPanelOpen}
chapterState={chapterState}
journeyBeat={journeyBeat}
recentChronicleSummary={recentChronicleSummary}
currentCampEvent={currentCampEvent}
setpieceDirective={setpieceDirective}
selectedQuest={selectedQuest}
setSelectedQuestId={setSelectedQuestId}
completionNoticeQuest={completionNoticeQuest}

View File

@@ -1,7 +1,11 @@
import { motion } from 'motion/react';
import type { CSSProperties, ReactNode } from 'react';
import type { ReactNode } from 'react';
import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import { getCompanionBuildDamageBreakdown } from '../data/buildDamage';
import {
type CharacterEquipmentItem,
type CharacterInventoryItem,
@@ -11,9 +15,27 @@ import {
getInventoryItems,
} from '../data/characterPresets';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import { AnimationState, type Character, type CharacterSkillDefinition, type CustomWorldProfile, type WorldType } from '../types';
import { CHROME_ICONS, getNineSliceStyle, type NineSliceTexture, UI_CHROME } from '../uiAssets';
import {
AnimationState,
type Character,
type CustomWorldProfile,
type WorldType,
} from '../types';
import {
CHROME_ICONS,
getNineSliceStyle,
type NineSliceTexture,
UI_CHROME,
} from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
import {
getCharacterDetailSpriteStyle,
getGenderLabel,
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterSkillsList,
} from './CharacterInfoShared';
import { PixelIcon } from './PixelIcon';
interface CharacterDetailModalProps {
@@ -24,31 +46,6 @@ interface CharacterDetailModalProps {
onClose: () => void;
}
const SKILL_STYLE_LABELS: Record<CharacterSkillDefinition['style'], string> = {
burst: '爆发',
steady: '稳定',
mobility: '机动',
finisher: '终结',
projectile: '远程',
};
function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女';
if (gender === 'male') return '男';
return '未知';
}
function getCharacterDetailSpriteStyle(character: Character, scale = 1.36) {
const groundOffset = character.groundOffsetY ?? 22;
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
return {
transform: `translateY(${translateY}px) scale(${scale})`,
transformOrigin: 'center bottom',
} satisfies CSSProperties;
}
function Section({
title,
chrome = UI_CHROME.panel,
@@ -59,8 +56,13 @@ function Section({
children: ReactNode;
}) {
return (
<section className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(chrome, { paddingX: 14, paddingY: 14 })}>
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-zinc-200">{title}</div>
<section
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(chrome, { paddingX: 14, paddingY: 14 })}
>
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-zinc-200">
{title}
</div>
{children}
</section>
);
@@ -93,10 +95,17 @@ function StatPill({
function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
return (
<div className="grid gap-2 sm:grid-cols-3">
{items.map(item => (
<div key={`${item.slot}-${item.item}`} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500">{item.slot}</div>
<div className="mt-1 text-sm font-semibold text-white">{item.item}</div>
{items.map((item) => (
<div
key={`${item.slot}-${item.item}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
{item.slot}
</div>
<div className="mt-1 text-sm font-semibold text-white">
{item.item}
</div>
<div className="mt-1 text-xs text-zinc-400">{item.rarity}</div>
</div>
))}
@@ -107,39 +116,19 @@ function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
return (
<div className="grid gap-2 sm:grid-cols-2">
{items.map(item => (
<div key={`${item.category}-${item.name}-${item.quantity}`} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500">{item.category}</div>
<div className="mt-1 text-sm font-semibold text-white">{item.name}</div>
<div className="mt-1 text-xs text-zinc-400"> x{item.quantity}</div>
</div>
))}
</div>
);
}
function SkillList({
skills,
resourceLabels,
}: {
skills: CharacterSkillDefinition[];
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>;
}) {
return (
<div className="space-y-2.5">
{skills.map(skill => (
<div key={skill.id} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
{SKILL_STYLE_LABELS[skill.style]}
</span>
{items.map((item) => (
<div
key={`${item.category}-${item.name}-${item.quantity}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
{item.category}
</div>
<div className="mt-2 grid gap-1 text-xs text-zinc-400 sm:grid-cols-4">
<div>{resourceLabels.damage} {skill.damage}</div>
<div>{resourceLabels.manaCost} {skill.manaCost}</div>
<div>{resourceLabels.cooldown} {skill.cooldownTurns}</div>
<div>{resourceLabels.range} {skill.range}</div>
<div className="mt-1 text-sm font-semibold text-white">
{item.name}
</div>
<div className="mt-1 text-xs text-zinc-400">
x{item.quantity}
</div>
</div>
))}
@@ -162,8 +151,16 @@ export function CharacterDetailModal({
const equipment = getCharacterEquipment(character);
const inventory = getInventoryItems(character, worldType);
const attributeSchema = resolveAttributeSchema(worldType, customWorldProfile);
const attributeProfile = resolveCharacterAttributeProfile(character, worldType, customWorldProfile);
const attributeRows = formatAttributeList(attributeProfile, attributeSchema);
const attributeProfile = resolveCharacterAttributeProfile(
character,
worldType,
customWorldProfile,
);
const buildBreakdown = getCompanionBuildDamageBreakdown(
character,
worldType,
customWorldProfile,
);
const resourceLabels = getResourceLabelsForWorld(worldType);
return (
@@ -181,12 +178,16 @@ export function CharacterDetailModal({
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">{subtitle}</div>
<div className="text-sm font-semibold text-white">
{character.name}
</div>
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
{subtitle}
</div>
</div>
<button
type="button"
@@ -201,94 +202,105 @@ export function CharacterDetailModal({
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
<div className="space-y-4 lg:max-h-full lg:overflow-y-auto lg:pr-1">
<Section title="资料">
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
</div>
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">
</div>
<div className="mt-3 text-base font-bold text-white">{character.name}</div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
<span>{character.title}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
: {getGenderLabel(character.gender)}
</span>
</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{character.description}</p>
</div>
</Section>
<Section title="属性" chrome={UI_CHROME.statsPanel}>
<div className="grid gap-2 sm:grid-cols-2">
<StatPill
label={resourceLabels.maxHp}
value={`${getCharacterMaxHp(character, worldType, customWorldProfile)}`}
tone="hp"
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
<StatPill label={resourceLabels.maxMp} value={`${getCharacterMaxMana(character)}`} tone="mp" />
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
{attributeRows.map(({ slot, value }) => (
<div key={slot.slotId} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center">
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 text-2xl font-bold text-white">{value}</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
</div>
))}
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">
</div>
</Section>
<div className="mt-3 text-base font-bold text-white">
{character.name}
</div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
<span>{character.title}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
: {getGenderLabel(character.gender)}
</span>
</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
{character.description}
</p>
</div>
</Section>
{opening && (
<Section title="旅程">
<div className="space-y-2 text-sm leading-relaxed text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1">{opening.reason}</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1">{opening.goal}</div>
<Section title="属性" chrome={UI_CHROME.statsPanel}>
<div className="grid gap-2 sm:grid-cols-2">
<StatPill
label={resourceLabels.maxHp}
value={`${getCharacterMaxHp(character, worldType, customWorldProfile)}`}
tone="hp"
/>
<StatPill
label={resourceLabels.maxMp}
value={`${getCharacterMaxMana(character)}`}
tone="mp"
/>
</div>
<div className="mt-3">
<CharacterAttributeGrid
attributeProfile={attributeProfile}
attributeSchema={attributeSchema}
buildBreakdown={buildBreakdown}
resourceLabels={resourceLabels}
gridClassName="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 xl:grid-cols-4"
cardClassName="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
/>
</div>
</Section>
{opening && (
<Section title="旅程">
<div className="space-y-2 text-sm leading-relaxed text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1">{opening.reason}</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1">{opening.goal}</div>
</div>
</Section>
)}
</div>
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
<Section title="技能">
<SkillList skills={character.skills} resourceLabels={resourceLabels} />
</Section>
<Section title="装备">
<EquipmentGrid items={equipment} />
</Section>
<Section title="背包">
<InventoryGrid items={inventory} />
</Section>
<Section title="背景">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{character.backstory}
</div>
</Section>
<Section title="性格">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{character.personality}
</div>
</Section>
</div>
)}
</div>
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
<Section title="技能">
<CharacterSkillsList skills={character.skills} />
</Section>
<Section title="装备">
<EquipmentGrid items={equipment} />
</Section>
<Section title="背包">
<InventoryGrid items={inventory} />
</Section>
<Section title="背景">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{character.backstory}
</div>
</Section>
<Section title="性格">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{character.personality}
</div>
</Section>
</div>
</div>
</motion.div>
</motion.div>
);

View File

@@ -0,0 +1,123 @@
import type { CSSProperties } from 'react';
import { type RoleCombatStats } from '../data/attributeCombat';
import {
type BuildDamageBreakdown,
getBuildContributionQuality,
getBuildContributionQualityRatio,
} from '../data/buildDamage';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import type { Character } from '../types';
export function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女';
if (gender === 'male') return '男';
return '未明';
}
export function getCharacterDetailSpriteStyle(character: Character) {
const groundOffset = character.groundOffsetY ?? 22;
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
return {
transform: `translateY(${translateY}px) scale(1.34)`,
transformOrigin: 'center bottom',
} satisfies CSSProperties;
}
const SKILL_STYLE_LABELS = {
burst: '爆发',
steady: '稳态',
mobility: '机动',
finisher: '终结',
projectile: '投射',
} satisfies Record<Character['skills'][number]['style'], string>;
export function getSkillDeliveryLabel(skill: Character['skills'][number]) {
return skill.delivery === 'ranged' || skill.style === 'projectile'
? '远程'
: '近战';
}
export function getSkillStyleLabel(skill: Character['skills'][number]) {
return SKILL_STYLE_LABELS[skill.style];
}
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}
export function getContributionVisualStyle(value: number): CSSProperties {
const quality = getBuildContributionQuality(value);
if (quality.tier === 'epic') {
return {
borderColor: 'hsla(286, 68%, 66%, 0.46)',
background:
'linear-gradient(135deg, hsla(284, 72%, 44%, 0.34) 0%, hsla(265, 64%, 28%, 0.26) 42%, rgba(12, 16, 24, 0.94) 78%)',
boxShadow:
'inset 0 1px 0 rgba(255,255,255,0.05), 0 0 24px hsla(284, 78%, 62%, 0.22)',
color: 'rgb(247 236 255)',
};
}
const ratio = getContributionHeatRatio(value);
const hue = 210 - ratio * 178;
const saturation = 62 + ratio * 16;
const lightness = 56 + ratio * 6;
return {
borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`,
background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`,
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`,
color:
ratio > 0.76
? 'rgb(255 244 235)'
: ratio > 0.32
? 'rgb(236 242 248)'
: 'rgb(203 213 225)',
};
}
export function formatAttributeMetricValue(value: number) {
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
function formatAttributePercentValue(value: number) {
return `${formatAttributeMetricValue(value * 100)}%`;
}
export function getAttributeBonusPillClassName(bonus: number) {
if (bonus >= 0.05) {
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
}
if (bonus > 0) {
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100';
}
return 'border-white/10 bg-black/20 text-zinc-500';
}
export 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 '提升战斗表现';
}
}
export type ContributionRow = BuildDamageBreakdown['rows'][number];

View File

@@ -0,0 +1,291 @@
import { resolveRoleCombatStats } from '../data/attributeCombat';
import { getAttributeSlotValue } from '../data/attributeResolver';
import {
type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionQualityLabel,
} from '../data/buildDamage';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import type {
Character,
RoleAttributeProfile,
WorldAttributeSchema,
} from '../types';
import {
type ContributionRow,
formatAttributeMetricValue,
getAttributeBonusPillClassName,
getAttributeEffectText,
getContributionVisualStyle,
getSkillDeliveryLabel,
getSkillStyleLabel,
} from './CharacterInfoHelpers';
export function StatusRow({
label,
current,
max,
tone,
}: {
label: string;
current: number;
max: number;
tone: 'hp' | 'mp';
}) {
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
const fillClass =
tone === 'hp'
? 'from-emerald-400 via-lime-300 to-emerald-200'
: 'from-sky-500 via-cyan-300 to-sky-100';
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">
{current} / {max}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
}
export function CharacterSkillsList({
skills,
onSelectSkill,
emptyText = '暂无技能信息',
}: {
skills: Character['skills'];
onSelectSkill?: ((skillId: string) => void) | null;
emptyText?: string;
}) {
if (skills.length === 0) {
return (
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">
{emptyText}
</div>
);
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{skills.map((skill) => {
const content = (
<>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
{getSkillDeliveryLabel(skill)}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
<div>{skill.manaCost}</div>
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
{getSkillStyleLabel(skill)}
</div>
</>
);
if (onSelectSkill) {
return (
<button
key={skill.id}
type="button"
onClick={() => onSelectSkill(skill.id)}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
>
{content}
</button>
);
}
return (
<div
key={skill.id}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
>
{content}
</div>
);
})}
</div>
);
}
export function MultiplierContributionList({
breakdown,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort(
(left, right) =>
right.bonusDelta - left.bonusDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
return (
<div className="space-y-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-3">
<div className="flex flex-col items-start gap-1 text-[10px] uppercase tracking-[0.16em] text-sky-100/80 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span></span>
<span className="text-[9px] leading-4 text-zinc-400 sm:text-[10px]">
</span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="min-w-[5.2rem] rounded-xl border px-2.5 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5 sm:min-w-[6.25rem] sm:px-3"
style={getContributionVisualStyle(row.bonusDelta)}
title={`查看 ${row.label} 的标签效果`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{row.label}</span>
<span className="text-[11px] font-semibold tracking-[0.12em] text-current/80">
{getBuildContributionQualityLabel(row.bonusDelta)}
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-current/70">
{formatBuildContributionPercent(row.bonusDelta)}
</div>
</button>
))}
</div>
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
);
}
export function CharacterAttributeGrid({
attributeProfile,
attributeSchema,
buildBreakdown = null,
resourceLabels,
emptyText = '暂无属性信息',
gridClassName = 'grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2',
cardClassName = 'rounded-xl border border-white/8 bg-black/25 px-3 py-2',
}: {
attributeProfile: RoleAttributeProfile | null | undefined;
attributeSchema: WorldAttributeSchema;
buildBreakdown?: BuildDamageBreakdown | null;
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>;
emptyText?: string;
gridClassName?: string;
cardClassName?: string;
}) {
const attributeRows = attributeSchema.slots.map((slot) => ({
slot,
value: getAttributeSlotValue(attributeProfile, slot.slotId),
}));
const attributeBonusBySlot = Object.fromEntries(
attributeSchema.slots.map((slot) => [
slot.slotId,
Number(
(
buildBreakdown?.rows.reduce(
(sum, row) =>
sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0),
0,
) ?? 0
).toFixed(4),
),
]),
) as Record<string, number>;
const boostedAttributeProfile = attributeProfile
? {
...attributeProfile,
values: {
...(attributeProfile.values ?? {}),
...Object.fromEntries(
attributeSchema.slots.map((slot) => {
const baseValue = attributeProfile.values?.[slot.slotId] ?? 0;
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
return [
slot.slotId,
Number((baseValue * (1 + totalBonus)).toFixed(4)),
];
}),
),
},
}
: null;
const boostedCombatStats = boostedAttributeProfile
? resolveRoleCombatStats(boostedAttributeProfile)
: null;
const displayRows = attributeRows.map(({ slot, value }) => {
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
effectText: boostedCombatStats
? getAttributeEffectText(
slot.slotId,
boostedCombatStats,
resourceLabels,
)
: slot.combatUseText,
};
});
if (displayRows.length === 0) {
return <div className="text-sm text-zinc-500">{emptyText}</div>;
}
return (
<div className={gridClassName}>
{displayRows.map(
({ slot, baseValue, boostedValue, totalBonus, effectText }) => (
<div key={slot.slotId} className={cardClassName}>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="text-xl font-bold text-white sm:text-2xl">
{formatAttributeMetricValue(boostedValue)}
</div>
</div>
<div className="flex flex-col items-start gap-1 text-left sm:shrink-0 sm:items-end sm:text-right">
<span
className={`max-w-full rounded-full border px-2 py-0.5 text-[10px] font-medium leading-4 ${getAttributeBonusPillClassName(totalBonus)}`}
>
{formatBuildContributionPercent(totalBonus)}
</span>
<div className="text-[10px] text-zinc-500">
{formatAttributeMetricValue(baseValue)}
</div>
</div>
</div>
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
{effectText}
</div>
</div>
),
)}
</div>
);
}

View File

@@ -1,8 +1,7 @@
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
formatAttributeList,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
@@ -11,7 +10,6 @@ import {
formatBuildContributionPercent,
getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getBuildContributionQualityRatio,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
} from '../data/buildDamage';
@@ -32,7 +30,9 @@ import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
AnimationState,
Character,
CompanionArcState,
CompanionRenderState,
CompanionResolution,
CustomWorldProfile,
EquipmentLoadout,
GameState,
@@ -49,6 +49,17 @@ import {
import { AffinityStatusCard } from './AffinityStatusCard';
import { BackstoryArchive } from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import {
getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getGenderLabel,
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterSkillsList,
MultiplierContributionList,
StatusRow,
} from './CharacterInfoShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { PixelIcon } from './PixelIcon';
@@ -69,6 +80,8 @@ interface CharacterPanelProps {
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
chatSummaries?: Record<string, string>;
onInspectMember?: (selection: GameCanvasEntitySelection) => void;
companionArcStates?: CompanionArcState[];
companionResolutions?: CompanionResolution[];
}
type PartyMember = {
@@ -91,185 +104,6 @@ type EquipmentRow = {
rarityLabel: string;
};
type ContributionRow = BuildDamageBreakdown['rows'][number];
function StatusRow({
label,
current,
max,
tone,
}: {
label: string;
current: number;
max: number;
tone: 'hp' | 'mp';
}) {
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
const fillClass =
tone === 'hp'
? 'from-emerald-400 via-lime-300 to-emerald-200'
: 'from-sky-500 via-cyan-300 to-sky-100';
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">
{current} / {max}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
}
function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女';
if (gender === 'male') return '男';
return '未明';
}
const SKILL_STYLE_LABELS = {
burst: '爆发',
steady: '稳态',
mobility: '机动',
finisher: '终结',
projectile: '投射',
} satisfies Record<Character['skills'][number]['style'], string>;
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
return skill.delivery === 'ranged' || skill.style === 'projectile'
? '远程'
: '近战';
}
function CharacterSkillsList({ character }: { character: Character }) {
if (character.skills.length === 0) {
return (
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">
</div>
);
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{character.skills.map((skill) => (
<div
key={skill.id}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
{getSkillDeliveryLabel(skill)}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
<div>{skill.manaCost}</div>
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
{SKILL_STYLE_LABELS[skill.style]}
</div>
</div>
))}
</div>
);
}
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}
function getContributionVisualStyle(value: number): CSSProperties {
const ratio = getContributionHeatRatio(value);
const hue = 210 - ratio * 178;
const saturation = 62 + ratio * 16;
const lightness = 56 + ratio * 6;
return {
borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`,
background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`,
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`,
color:
ratio > 0.76
? 'rgb(255 244 235)'
: ratio > 0.32
? 'rgb(236 242 248)'
: 'rgb(203 213 225)',
};
}
function MultiplierContributionList({
breakdown,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort(
(left, right) =>
right.bonusDelta - left.bonusDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
return (
<div className="space-y-2 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-2.5">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-sky-100/80">
<span>{'\u72b6\u6001\u6807\u7b7e'}</span>
<span className="text-zinc-400">
{
'\u70b9\u51fb\u6807\u7b7e\u67e5\u770b\u5177\u4f53\u5c5e\u6027\u52a0\u6210'
}
</span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="rounded-lg border px-2.5 py-1.5 text-left text-[11px] font-medium leading-none text-white transition-transform hover:-translate-y-0.5"
style={getContributionVisualStyle(row.bonusDelta)}
title={`\u67e5\u770b ${row.label} \u7684\u6807\u7b7e\u6548\u679c`}
>
<span>{row.label}</span>
</button>
))}
</div>
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
{'\u5f53\u524d\u8fd8\u6ca1\u6709\u5f62\u6210\u6709\u6548\u6807\u7b7e'}
</span>
)}
</div>
);
}
function formatAttributeMetricValue(value: number) {
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
function getAttributeBonusPillClassName(bonus: number) {
if (bonus >= 0.05) {
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
}
if (bonus > 0) {
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100';
}
return 'border-white/10 bg-black/20 text-zinc-500';
}
function buildLeaderEquipmentRows(
playerCharacter: Character,
playerEquipment: EquipmentLoadout,
@@ -300,16 +134,6 @@ function buildCompanionEquipmentRows(
}));
}
function getCharacterDetailSpriteStyle(character: Character) {
const groundOffset = character.groundOffsetY ?? 22;
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
return {
transform: `translateY(${translateY}px) scale(1.34)`,
transformOrigin: 'center bottom',
} satisfies CSSProperties;
}
export function CharacterPanel({
worldType,
customWorldProfile = null,
@@ -324,6 +148,8 @@ export function CharacterPanel({
npcStates = {},
quests,
onInspectMember,
companionArcStates = [],
companionResolutions = [],
}: CharacterPanelProps) {
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
@@ -427,6 +253,18 @@ export function CharacterPanel({
const selectedMemberAffinity = selectedMember?.npcId
? (npcStates[selectedMember.npcId]?.affinity ?? 0)
: null;
const selectedMemberArcState =
selectedMember && !selectedMember.isLeader
? companionArcStates.find(
(arcState) => arcState.characterId === selectedMember.character.id,
) ?? null
: null;
const selectedMemberResolution =
selectedMember && !selectedMember.isLeader
? companionResolutions.find(
(resolution) => resolution.characterId === selectedMember.character.id,
) ?? null
: null;
const selectedMemberPublicBackstory =
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
? getCharacterPublicBackstorySummary(selectedMember.character, worldType)
@@ -461,52 +299,16 @@ export function CharacterPanel({
? buildLeaderEquipmentRows(playerCharacter, playerEquipment)
: buildCompanionEquipmentRows(selectedMember.character, selectedMember.id)
: [];
const selectedAttributeRows = useMemo(
const selectedMemberAttributeProfile = useMemo(
() =>
selectedMember
? formatAttributeList(
resolveCharacterAttributeProfile(
selectedMember.character,
worldType,
customWorldProfile,
),
selectedAttributeSchema,
? resolveCharacterAttributeProfile(
selectedMember.character,
worldType,
customWorldProfile,
)
: [],
[customWorldProfile, selectedAttributeSchema, selectedMember, worldType],
);
const selectedAttributeBonusBySlot = useMemo(
() =>
Object.fromEntries(
selectedAttributeSchema.slots.map((slot) => [
slot.slotId,
Number(
(
selectedBuildBreakdown?.rows.reduce(
(sum, row) =>
sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0),
0,
) ?? 0
).toFixed(4),
),
]),
) as Record<string, number>,
[selectedAttributeSchema, selectedBuildBreakdown],
);
const selectedDisplayAttributeRows = useMemo(
() =>
selectedAttributeRows.map(({ slot, value }) => {
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = value * (1 + totalBonus);
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
};
}),
[selectedAttributeBonusBySlot, selectedAttributeRows],
: null,
[customWorldProfile, selectedMember, worldType],
);
const selectedContributionAttributes = selectedContributionRow
? getBuildContributionAttributeRows(
@@ -718,7 +520,6 @@ export function CharacterPanel({
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
@@ -857,6 +658,32 @@ export function CharacterPanel({
{selectedMemberAffinity != null && (
<AffinityStatusCard affinity={selectedMemberAffinity} />
)}
{selectedMemberArcState && (
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
线
</div>
<div className="mt-1 font-semibold text-white">
{selectedMemberArcState.currentStage}
</div>
<div className="mt-1 text-[11px] text-sky-200/85">
{selectedMemberArcState.arcTheme}
</div>
</div>
)}
{selectedMemberResolution && (
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.18em] text-emerald-200/80">
</div>
<div className="mt-1 font-semibold text-white">
{selectedMemberResolution.resolutionType}
</div>
<div className="mt-1 text-[11px] text-emerald-100/85">
{selectedMemberResolution.summary}
</div>
</div>
)}
{selectedMemberAffinity != null && (
<BackstoryArchive
publicSummary={selectedMemberPublicBackstory}
@@ -875,37 +702,15 @@ 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 }) => (
<div
key={slot.slotId}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 flex items-start justify-between gap-3">
<div>
<div className="text-2xl font-bold text-white">
{formatAttributeMetricValue(boostedValue)}
</div>
<div className="mt-1 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>
</div>
),
)}
<div className="mt-4">
<CharacterAttributeGrid
attributeProfile={selectedMemberAttributeProfile}
attributeSchema={selectedAttributeSchema}
buildBreakdown={selectedBuildBreakdown}
resourceLabels={resourceLabels}
gridClassName="grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2"
cardClassName="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
/>
</div>
</div>
</div>
@@ -944,7 +749,9 @@ export function CharacterPanel({
<div className="mb-3 text-xs font-bold text-white">
{'\u6280\u80fd'}
</div>
<CharacterSkillsList character={selectedMember.character} />
<CharacterSkillsList
skills={selectedMember.character.skills}
/>
</div>
<div

View File

@@ -4,13 +4,14 @@ import {
getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
export type ResultTab = 'world' | 'anchors' | 'playable' | 'story' | 'landmarks';
interface CustomWorldEntityCatalogProps {
profile: CustomWorldProfile;
@@ -19,12 +20,18 @@ interface CustomWorldEntityCatalogProps {
onActiveTabChange: (tab: ResultTab) => void;
onEditTarget: (target: CustomWorldEditorTarget) => void;
onProfileChange: (profile: CustomWorldProfile) => void;
onRegeneratePlayableNpc?: (id: string) => void;
onRegenerateStoryNpc?: (id: string) => void;
onRegenerateLandmark?: (id: string) => void;
onRegenerateStoryExpansion?: () => void;
onRegenerateLandmarkNetwork?: () => void;
createActionLabel?: string;
onCreateAction?: () => void;
}
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
{ id: 'world', label: '世界' },
{ id: 'anchors', label: '锚点' },
{ id: 'playable', label: '可扮演角色' },
{ id: 'story', label: '场景角色' },
{ id: 'landmarks', label: '场景' },
@@ -203,6 +210,11 @@ export function CustomWorldEntityCatalog({
onActiveTabChange,
onEditTarget,
onProfileChange,
onRegeneratePlayableNpc,
onRegenerateStoryNpc,
onRegenerateLandmark,
onRegenerateStoryExpansion,
onRegenerateLandmarkNetwork,
createActionLabel,
onCreateAction,
}: CustomWorldEntityCatalogProps) {
@@ -249,8 +261,34 @@ export function CustomWorldEntityCatalog({
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
);
const creatorIntentSummary = useMemo(
() => buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
[profile.creatorIntent],
);
const lockedCharacterNames = useMemo(
() =>
new Set(
profile.creatorIntent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
),
[profile.creatorIntent],
);
const lockedLandmarkNames = useMemo(
() =>
new Set(
profile.creatorIntent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
),
[profile.creatorIntent],
);
const counts = {
world: 1,
anchors: 1,
playable: profile.playableNpcs.length,
story: profile.storyNpcs.length,
landmarks: profile.landmarks.length,
@@ -325,7 +363,7 @@ export function CustomWorldEntityCatalog({
))}
</div>
{activeTab !== 'world' ? (
{activeTab !== 'world' && activeTab !== 'anchors' ? (
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
@@ -348,6 +386,14 @@ export function CustomWorldEntityCatalog({
</div>
</Section>
{creatorIntentSummary ? (
<Section title="创作锚点" subtitle="这部分来自创作者输入AI 会围绕它继续展开世界。">
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
{creatorIntentSummary}
</div>
</Section>
) : null}
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
@@ -370,6 +416,101 @@ export function CustomWorldEntityCatalog({
</>
) : null}
{activeTab === 'anchors' ? (
<div className="space-y-3">
<Section
title="创作者输入"
subtitle="这些内容来自创作者工作台,会作为 AI 继续展开世界的锚点。"
>
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
{creatorIntentSummary || '当前还没有记录创作锚点。'}
</div>
</Section>
<Section title="关键势力">
<div className="space-y-2">
{profile.creatorIntent?.keyFactions.length ? (
profile.creatorIntent.keyFactions.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{entry.name || '未命名势力'}</div>
{entry.locked ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-1">{entry.publicGoal || '暂无目标说明'}</div>
{entry.tension ? <div className="mt-1 text-zinc-400">{entry.tension}</div> : null}
{entry.notes ? <div className="mt-1 text-zinc-500">{entry.notes}</div> : null}
</div>
))
) : (
<EmptyState title="当前没有关键势力锚点。" />
)}
</div>
</Section>
<Section title="关键角色">
<div className="space-y-2">
{profile.creatorIntent?.keyCharacters.length ? (
profile.creatorIntent.keyCharacters.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{entry.name || '未命名角色'}</div>
{entry.locked ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-1">{entry.role || '未填写身份'}</div>
{entry.publicMask ? <div className="mt-1 text-zinc-400">{entry.publicMask}</div> : null}
{entry.hiddenHook ? <div className="mt-1 text-zinc-400">线{entry.hiddenHook}</div> : null}
{entry.relationToPlayer ? <div className="mt-1 text-zinc-500">{entry.relationToPlayer}</div> : null}
</div>
))
) : (
<EmptyState title="当前没有关键角色锚点。" />
)}
</div>
</Section>
<Section title="关键地点">
<div className="space-y-2">
{profile.creatorIntent?.keyLandmarks.length ? (
profile.creatorIntent.keyLandmarks.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{entry.name || '未命名地点'}</div>
{entry.locked ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-1">{entry.purpose || '未填写作用'}</div>
{entry.mood ? <div className="mt-1 text-zinc-400">{entry.mood}</div> : null}
{entry.secret ? <div className="mt-1 text-zinc-500">{entry.secret}</div> : null}
</div>
))
) : (
<EmptyState title="当前没有关键地点锚点。" />
)}
</div>
</Section>
</div>
) : null}
{activeTab === 'playable' ? (
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
@@ -388,6 +529,14 @@ export function CustomWorldEntityCatalog({
subtitle={role.title}
actions={(
<div className="flex items-center gap-2">
{onRegeneratePlayableNpc && !lockedCharacterNames.has(role.name.trim()) ? (
<SmallButton
onClick={() => onRegeneratePlayableNpc(role.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose"></SmallButton>
</div>
@@ -400,6 +549,11 @@ export function CustomWorldEntityCatalog({
) : null}
</div>
<div className="min-w-0 flex-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<div className="mb-2 inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<div className="text-sm leading-6 text-zinc-300">{role.description}</div>
<div className="mt-2 text-xs leading-6 text-zinc-400">{role.backstory}</div>
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
@@ -463,6 +617,13 @@ export function CustomWorldEntityCatalog({
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
NPC
{onRegenerateStoryExpansion ? (
<div className="mt-3">
<SmallButton onClick={onRegenerateStoryExpansion} tone="sky">
</SmallButton>
</div>
) : null}
</div>
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
@@ -474,6 +635,14 @@ export function CustomWorldEntityCatalog({
subtitle={npc.role}
actions={(
<div className="flex items-center gap-2">
{onRegenerateStoryNpc && !lockedCharacterNames.has(npc.name.trim()) ? (
<SmallButton
onClick={() => onRegenerateStoryNpc(npc.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removeStoryNpc(npc.id, npc.name)} tone="rose"></SmallButton>
</div>
@@ -487,6 +656,11 @@ export function CustomWorldEntityCatalog({
scale={2.18}
/>
<div className="min-w-0 space-y-3">
{lockedCharacterNames.has(npc.name.trim()) ? (
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
<div className="rounded-2xl border border-sky-300/12 bg-sky-500/8 px-3 py-3 text-sm leading-6 text-sky-50/95">
{npc.backstoryReveal.publicSummary || '未填写'}
@@ -556,6 +730,13 @@ export function CustomWorldEntityCatalog({
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
NPC
{onRegenerateLandmarkNetwork ? (
<div className="mt-3">
<SmallButton onClick={onRegenerateLandmarkNetwork} tone="sky">
</SmallButton>
</div>
) : null}
</div>
{filteredLandmarks.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
@@ -566,12 +747,25 @@ export function CustomWorldEntityCatalog({
title={landmark.name}
actions={(
<div className="flex items-center gap-2">
{onRegenerateLandmark && !lockedLandmarkNames.has(landmark.name.trim()) ? (
<SmallButton
onClick={() => onRegenerateLandmark(landmark.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removeLandmark(landmark.id, landmark.name)} tone="rose"></SmallButton>
</div>
)}
>
<div className="space-y-3">
{lockedLandmarkNames.has(landmark.name.trim()) ? (
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<ImageFrame src={landmark.imageSrc} alt={landmark.name} fallbackLabel={landmark.name.slice(0, 4) || '场景'} tone="landscape" />
<div className="text-sm leading-7 text-zinc-300">{landmark.description}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">

View File

@@ -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={() => {

View File

@@ -0,0 +1,247 @@
import { motion } from 'motion/react';
import type {
CustomWorldGenerationProgress,
} from '../services/ai';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
interface CustomWorldGenerationViewProps {
settingText: string;
progress: CustomWorldGenerationProgress | null;
isGenerating: boolean;
error: string | null;
onBack: () => void;
onEditSetting: () => void;
onRetry: () => void;
onInterrupt: () => void;
}
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,
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="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
<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="whitespace-pre-line 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>
);
}

View File

@@ -15,6 +15,12 @@ interface CustomWorldResultViewProps {
onBack: () => void;
onEditSetting: () => void;
onRegenerate: () => void;
onContinueExpand?: () => void;
onRegeneratePlayableNpc?: (id: string) => void;
onRegenerateStoryNpc?: (id: string) => void;
onRegenerateLandmark?: (id: string) => void;
onRegenerateStoryExpansion?: () => void;
onRegenerateLandmarkNetwork?: () => void;
onSave: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
@@ -70,6 +76,12 @@ export function CustomWorldResultView({
onBack,
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onRegeneratePlayableNpc,
onRegenerateStoryNpc,
onRegenerateLandmark,
onRegenerateStoryExpansion,
onRegenerateLandmarkNetwork,
onSave,
onProfileChange,
}: CustomWorldResultViewProps) {
@@ -110,6 +122,11 @@ export function CustomWorldResultView({
onActiveTabChange={setActiveTab}
onEditTarget={setEditorTarget}
onProfileChange={onProfileChange}
onRegeneratePlayableNpc={onRegeneratePlayableNpc}
onRegenerateStoryNpc={onRegenerateStoryNpc}
onRegenerateLandmark={onRegenerateLandmark}
onRegenerateStoryExpansion={onRegenerateStoryExpansion}
onRegenerateLandmarkNetwork={onRegenerateLandmarkNetwork}
createActionLabel={createLabel}
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
/>
@@ -137,9 +154,19 @@ export function CustomWorldResultView({
) : null}
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
</div>
) : null}
<div className="flex items-center justify-end gap-3">
<SmallButton onClick={onEditSetting}></SmallButton>
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>
</SmallButton>
) : null}
<button
type="button"
onClick={onSave}

View File

@@ -375,7 +375,6 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
currentScenePreset={visibleGameState.currentScenePreset}
worldType={visibleGameState.worldType}
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
sceneMonsters={visibleGameState.sceneMonsters}
playerX={visibleGameState.playerX}
playerOffsetY={visibleGameState.playerOffsetY}
playerFacing={visibleGameState.playerFacing}
@@ -500,6 +499,12 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
companionRenderStates={companionRenderStates}
npcStates={visibleGameState.npcStates}
quests={visibleGameState.quests}
companionArcStates={
visibleGameState.storyEngineMemory?.companionArcStates ?? []
}
companionResolutions={
visibleGameState.storyEngineMemory?.companionResolutions ?? []
}
onOpenCamp={openCampModal}
onOpenCharacterChat={characterChatUi.openChat}
chatSummaries={characterChatSummaries}
@@ -533,6 +538,19 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
inBattle={visibleGameState.inBattle}
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
chapterState={visibleGameState.chapterState ?? null}
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
recentChronicleSummary={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
currentCampEvent={
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
}
setpieceDirective={
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
@@ -562,6 +580,15 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
continueGameDigest={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
narrativeCodex={
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
}
narrativeQaReport={
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
}
/>
</Suspense>
)}

View File

@@ -1,16 +1,7 @@
import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { formatCurrency, getInventoryItemValue } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
isInventoryItemEquippable,
} from '../data/equipmentEffects';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import type { Character, InventoryItem, WorldType } from '../types';
import {
CHROME_ICONS,
@@ -20,33 +11,57 @@ import {
} from '../uiAssets';
import { PixelIcon } from './PixelIcon';
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
return {
frameClass:
'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8',
titleClass: 'text-amber-300',
quantityClass:
'border-amber-300/30 bg-amber-500/14 text-amber-50 shadow-[0_0_18px_rgba(251,191,36,0.16)]',
auraClass: 'from-amber-500/18 via-orange-500/12 to-transparent',
glowClass: 'bg-amber-300/24',
};
case 'epic':
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
return {
frameClass:
'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-rose-500/8',
titleClass: 'text-fuchsia-300',
quantityClass:
'border-fuchsia-300/28 bg-fuchsia-500/12 text-fuchsia-50 shadow-[0_0_18px_rgba(232,121,249,0.14)]',
auraClass: 'from-fuchsia-500/18 via-rose-500/10 to-transparent',
glowClass: 'bg-fuchsia-300/22',
};
case 'rare':
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
return {
frameClass:
'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8',
titleClass: 'text-sky-300',
quantityClass:
'border-sky-300/26 bg-sky-500/12 text-sky-50 shadow-[0_0_18px_rgba(56,189,248,0.14)]',
auraClass: 'from-sky-500/18 via-cyan-500/10 to-transparent',
glowClass: 'bg-sky-300/20',
};
case 'uncommon':
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
return {
frameClass:
'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8',
titleClass: 'text-emerald-300',
quantityClass:
'border-emerald-300/24 bg-emerald-500/12 text-emerald-50 shadow-[0_0_18px_rgba(74,222,128,0.12)]',
auraClass: 'from-emerald-500/18 via-lime-500/10 to-transparent',
glowClass: 'bg-emerald-300/18',
};
default:
return 'border-white/10 bg-white/[0.04]';
}
}
function getInventoryRarityLabel(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return '传说';
case 'epic':
return '史诗';
case 'rare':
return '稀有';
case 'uncommon':
return '优秀';
default:
return '普通';
return {
frameClass: 'border-white/10 bg-white/[0.04]',
titleClass: 'text-zinc-100',
quantityClass:
'border-white/12 bg-white/[0.06] text-zinc-100 shadow-[0_0_18px_rgba(255,255,255,0.06)]',
auraClass: 'from-white/10 via-white/4 to-transparent',
glowClass: 'bg-white/10',
};
}
}
@@ -115,13 +130,14 @@ export function InventoryItemGrid({
}
const selected = selectedItemId === item.id;
const rarityTheme = getInventoryRarityTheme(item.rarity);
return (
<button
key={item.id}
type="button"
onClick={() => onSelectItem(item)}
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${rarityTheme.frameClass} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
title={`${item.name} x${item.quantity}`}
>
<div className="flex h-full items-center justify-center">
@@ -140,25 +156,30 @@ export function InventoryItemGrid({
);
}
export function InventoryItemDetailModal({
item,
playerCharacter,
worldType,
ownerLabel,
onClose,
footer,
}: {
type InventoryItemDetailModalProps = {
item: InventoryItem | null;
playerCharacter: Character;
worldType: WorldType | null;
ownerLabel?: string;
onClose: () => void;
footer?: ReactNode;
}) {
};
export function InventoryItemDetailModal({
item,
playerCharacter,
onClose,
footer,
}: InventoryItemDetailModalProps) {
const selectedItemUseEffect = item
? resolveInventoryItemUseEffect(item, playerCharacter)
: null;
const selectedItemEquipSlot = item ? getEquipmentSlotFromItem(item) : null;
const itemSummary = item
? buildInventoryItemSummary(item, selectedItemUseEffect)
: '';
const rarityTheme = item
? getInventoryRarityTheme(item.rarity)
: getInventoryRarityTheme('common');
return (
<AnimatePresence>
@@ -167,7 +188,7 @@ export function InventoryItemDetailModal({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[78] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
className="fixed inset-0 z-[78] flex items-end justify-center bg-black/78 p-3 backdrop-blur-sm sm:items-center sm:p-4"
onClick={onClose}
>
<motion.div
@@ -175,132 +196,70 @@ export function InventoryItemDetailModal({
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:max-w-lg"
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(90vh,50rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">
{item.category}
</div>
<div className="mt-1 truncate text-sm font-semibold text-white">
{item.name}
</div>
</div>
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
className="absolute right-4 top-4 z-10 rounded-full border border-white/10 bg-black/25 p-1.5 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-5"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="flex items-center gap-4">
<div
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
>
<div
className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(item.rarity)}`}
>
className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${rarityTheme.auraClass}`}
/>
<div
className={`pointer-events-none absolute -right-12 top-1/2 h-28 w-28 -translate-y-1/2 rounded-full blur-3xl sm:h-36 sm:w-36 ${rarityTheme.glowClass}`}
/>
<div className="pointer-events-none absolute right-4 top-4 opacity-[0.16] sm:right-6 sm:top-5">
<PixelIcon
src={getInventoryItemIcon(item)}
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
className="h-16 w-16 drop-shadow-[0_8px_16px_rgba(0,0,0,0.3)] sm:h-20 sm:w-20"
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-200">
{getInventoryRarityLabel(item.rarity)}
<div className="relative max-w-[80%] sm:max-w-[85%]">
<div
className={`break-words text-[clamp(1.2rem,5vw,1.95rem)] font-semibold leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.35)] ${rarityTheme.titleClass}`}
>
{item.name}
</div>
<div className="text-sm text-zinc-300">
{item.quantity}
</div>
<div className="text-sm text-zinc-300">
{ownerLabel ?? playerCharacter.name}
</div>
<div className="text-sm text-zinc-300">
使{isInventoryItemUsable(item) ? '是' : '否'}
</div>
<div className="text-sm text-zinc-300">
{selectedItemEquipSlot
? getEquipmentSlotLabel(selectedItemEquipSlot)
: '否'}
</div>
<div className="text-sm text-zinc-300">
{isInventoryItemEquippable(item)
? '可装备物品'
: '非装备物品'}
</div>
<div className="text-sm text-zinc-300">
{formatCurrency(getInventoryItemValue(item), worldType)}
<div
className={`mt-4 inline-flex items-center rounded-full border px-3 py-1.5 text-xs sm:text-sm ${rarityTheme.quantityClass}`}
>
x{item.quantity}
</div>
</div>
</div>
<div
className="pixel-nine-slice pixel-panel"
className="pixel-nine-slice pixel-panel min-h-0 flex-1"
style={getNineSliceStyle(UI_CHROME.infoPanel)}
>
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
{item.category}
<div className="relative flex h-full min-h-[clamp(18rem,48vh,30rem)] flex-col overflow-hidden">
<div
className={`pointer-events-none absolute -left-10 bottom-4 h-24 w-24 rounded-full blur-3xl sm:h-32 sm:w-32 ${rarityTheme.glowClass}`}
/>
<div className="relative h-full overflow-y-auto pr-1">
<p className="whitespace-pre-wrap text-[0.95rem] leading-7 text-zinc-100 sm:text-base sm:leading-8">
{itemSummary}
</p>
</div>
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
{item.tags.length}
</div>
</div>
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{buildInventoryItemSummary(item, selectedItemUseEffect)}
</div>
{selectedItemUseEffect?.buildBuffs.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{selectedItemUseEffect.buildBuffs.map((buff) => (
<span
key={buff.id}
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
>
{buff.name} / {buff.tags.join('、')} /{' '}
{buff.durationTurns}
</span>
))}
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
{item.tags.length > 0 ? (
item.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
</div>
{footer ?? (
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
</div>
)}
</div>
{footer != null ? (
<div className="border-t border-white/10 px-4 py-3 sm:px-5">
{footer}
</div>
) : null}
</motion.div>
</motion.div>
)}

View File

@@ -1,16 +1,15 @@
import { useMemo, useState } from 'react';
import { formatCurrency } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
isInventoryItemEquippable,
} from '../data/equipmentEffects';
import { type ForgeRecipeView, getReforgeCostView } from '../data/forgeSystem';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { type ForgeRecipeView } from '../data/forgeSystem';
import { buildInitialPlayerInventory } from '../data/npcInteractions';
import { Character, InventoryItem, WorldType } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import {
Character,
InventoryItem,
NarrativeCodexSection,
NarrativeQaReport,
WorldType,
} from '../types';
import {
InventoryItemDetailModal,
InventoryItemGrid,
@@ -32,6 +31,9 @@ interface InventoryPanelProps {
onCraftRecipe: (recipeId: string) => Promise<boolean>;
onDismantleItem: (itemId: string) => Promise<boolean>;
onReforgeItem: (itemId: string) => Promise<boolean>;
continueGameDigest?: string | null;
narrativeCodex?: NarrativeCodexSection[];
narrativeQaReport?: NarrativeQaReport | null;
}
export function InventoryPanel({
@@ -39,23 +41,14 @@ export function InventoryPanel({
worldType,
playerInventory,
playerCurrency,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
inBattle,
onUseItem,
onEquipItem,
forgeRecipes,
onCraftRecipe,
onDismantleItem,
onReforgeItem,
continueGameDigest = null,
narrativeCodex = [],
narrativeQaReport = null,
}: InventoryPanelProps) {
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
const [isUsingItem, setIsUsingItem] = useState(false);
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(
null,
);
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
const inventoryItems = useMemo(
@@ -65,57 +58,85 @@ export function InventoryPanel({
: buildInitialPlayerInventory(playerCharacter, worldType),
[playerCharacter, playerInventory, worldType],
);
const selectedItemUseEffect = selectedItem
? resolveInventoryItemUseEffect(selectedItem, playerCharacter)
: null;
const selectedItemEquipSlot = selectedItem
? getEquipmentSlotFromItem(selectedItem)
: null;
const selectedItemReforgeCost = selectedItem
? getReforgeCostView(selectedItem, worldType)
: null;
const canUseSelectedItem = Boolean(
selectedItem &&
selectedItemUseEffect &&
((selectedItemUseEffect.hpRestore > 0 && playerHp < playerMaxHp) ||
(selectedItemUseEffect.manaRestore > 0 && playerMana < playerMaxMana) ||
selectedItemUseEffect.cooldownReduction > 0 ||
selectedItemUseEffect.buildBuffs.length > 0),
);
const canEquipSelectedItem = Boolean(
selectedItem &&
selectedItemEquipSlot &&
isInventoryItemEquippable(selectedItem) &&
!inBattle,
);
const canDismantleSelectedItem = Boolean(
selectedItem &&
!inBattle &&
(isInventoryItemEquippable(selectedItem) || selectedItem.buildProfile),
);
const canReforgeSelectedItem = Boolean(
selectedItem &&
!inBattle &&
isInventoryItemEquippable(selectedItem) &&
selectedItem.buildProfile &&
selectedItemReforgeCost &&
selectedItemReforgeCost.currencyCost <= playerCurrency,
const documentItems = useMemo(
() => inventoryItems.filter((item) => item.category === '文书' || item.tags.includes('document')),
[inventoryItems],
);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto scrollbar-hide">
{continueGameDigest && (
<div className="mb-4 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs leading-relaxed text-zinc-300">
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
</div>
{continueGameDigest}
</div>
)}
<InventoryItemGrid
items={inventoryItems}
selectedItemId={selectedItem?.id ?? null}
onSelectItem={setSelectedItem}
/>
{documentItems.length > 0 && (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
</div>
<div className="space-y-2">
{documentItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setSelectedItem(item)}
className="w-full rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-left transition hover:border-white/15"
>
<div className="text-sm font-semibold text-white">{item.name}</div>
<div className="mt-1 text-xs text-zinc-400">
{item.description || '记录着当前线程的阶段性线索。'}
</div>
</button>
))}
</div>
</div>
)}
{(narrativeCodex.length > 0 || narrativeQaReport) && (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
</div>
{narrativeQaReport && (
<div className="mb-3 rounded-xl border border-amber-400/18 bg-amber-500/8 px-3 py-2 text-xs text-amber-100/85">
QA{narrativeQaReport.summary}
</div>
)}
<div className="space-y-3">
{narrativeCodex.slice(0, 3).map((section) => (
<div
key={section.id}
className="rounded-xl border border-white/8 bg-black/20 p-3"
>
<div className="text-sm font-semibold text-white">
{section.title}
</div>
<div className="mt-2 space-y-1">
{section.entries.slice(0, 3).map((entry) => (
<div key={entry.id} className="text-xs text-zinc-400">
<span className="text-zinc-200">{entry.title}</span>
{''}
{entry.summary}
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
<span></span>
@@ -198,127 +219,6 @@ export function InventoryPanel({
playerCharacter={playerCharacter}
worldType={worldType}
onClose={() => setSelectedItem(null)}
footer={
selectedItem ? (
<div className="flex justify-end gap-3">
<button
type="button"
disabled={
!canDismantleSelectedItem ||
forgeActionKey === selectedItem.id
}
onClick={async () => {
setForgeActionKey(selectedItem.id);
const dismantled = await onDismantleItem(selectedItem.id);
setForgeActionKey(null);
if (dismantled) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canDismantleSelectedItem && forgeActionKey !== selectedItem.id
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{forgeActionKey === selectedItem.id ? '拆解中...' : '拆解'}
</button>
<button
type="button"
disabled={
!canReforgeSelectedItem ||
forgeActionKey === `${selectedItem.id}:reforge`
}
onClick={async () => {
setForgeActionKey(`${selectedItem.id}:reforge`);
const reforged = await onReforgeItem(selectedItem.id);
setForgeActionKey(null);
if (reforged) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canReforgeSelectedItem &&
forgeActionKey !== `${selectedItem.id}:reforge`
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{forgeActionKey === `${selectedItem.id}:reforge`
? '重铸中...'
: '重铸'}
</button>
<button
type="button"
disabled={
!canEquipSelectedItem ||
equipmentActionKey === selectedItem.id
}
onClick={async () => {
setEquipmentActionKey(selectedItem.id);
const equipped = await onEquipItem(selectedItem.id);
setEquipmentActionKey(null);
if (equipped) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canEquipSelectedItem && equipmentActionKey !== selectedItem.id
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{equipmentActionKey === selectedItem.id
? '装备中...'
: selectedItemEquipSlot
? `装备到 ${getEquipmentSlotLabel(selectedItemEquipSlot)}`
: '不可装备'}
</button>
<button
type="button"
onClick={() => setSelectedItem(null)}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
<button
type="button"
disabled={!canUseSelectedItem || isUsingItem}
onClick={async () => {
setIsUsingItem(true);
const used = await onUseItem(selectedItem.id);
setIsUsingItem(false);
if (used) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canUseSelectedItem && !isUsingItem ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{isUsingItem ? '使用中...' : '使用'}
</button>
</div>
) : undefined
}
/>
</div>
);

View File

@@ -1,22 +1,55 @@
import { AnimatePresence, motion } from 'motion/react';
import { X } from 'lucide-react';
import type { ReactNode } from 'react';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
import type {
CustomWorldCreatorIntent,
CustomWorldGenerationMode,
} from '../types';
interface CustomWorldCreatorModalProps {
type BaseModalProps = {
isOpen: boolean;
draft: string;
onDraftChange: (value: string) => void;
title: string;
onClose: () => void;
onSubmit: () => void;
isGenerating: boolean;
progress: number;
progressLabel: string;
error: string | null;
children: ReactNode;
footer?: ReactNode;
};
function SelectionModal({
isOpen,
title,
onClose,
children,
footer = null,
}: BaseModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[#11161f] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-semibold text-white">{title}</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
{children}
</div>
{footer ? (
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
{footer}
</div>
) : null}
</div>
</div>
);
}
interface CharacterDraftModalProps {
export function CharacterDraftModal(props: {
isOpen: boolean;
characterLabel: string;
draftName: string;
@@ -25,124 +58,152 @@ interface CharacterDraftModalProps {
onBackstoryChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
error: string | null;
}
function ModalShell({
isOpen,
title,
subtitle,
onClose,
disableClose = false,
children,
}: {
isOpen: boolean;
title: string;
subtitle?: string;
onClose: () => void;
disableClose?: boolean;
children: ReactNode;
error?: string | null;
}) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={disableClose ? undefined : onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">{title}</div>
{subtitle ? (
<div className="mt-1 text-xs leading-relaxed text-zinc-400">{subtitle}</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
disabled={disableClose}
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-40' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="p-5">{children}</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
const {
isOpen,
characterLabel,
draftName,
draftBackstory,
onNameChange,
onBackstoryChange,
onClose,
onConfirm,
error = null,
} = props;
export function CustomWorldCreatorModal({
isOpen,
draft,
onDraftChange,
onClose,
onSubmit,
isGenerating,
progress,
progressLabel,
error,
}: CustomWorldCreatorModalProps) {
return (
<ModalShell
<SelectionModal
isOpen={isOpen}
title="创建自定义世界"
title="角色自定义"
onClose={onClose}
disableClose={isGenerating}
footer={(
<>
<button
type="button"
onClick={onClose}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white"
>
</button>
<button
type="button"
onClick={onConfirm}
className="rounded-2xl bg-emerald-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-emerald-300"
>
</button>
</>
)}
>
<div className="space-y-4">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{characterLabel}
</div>
<label className="block">
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-white"></div>
<textarea
value={draft}
onChange={event => onDraftChange(event.target.value)}
disabled={isGenerating}
placeholder="例如:一个被古老机关城与修真宗门共同争夺的边境世界,灵气潮汐会周期性改写地形,玩家需要在多个势力之间周旋,寻找导致世界裂缝扩大的真正原因。"
className="min-h-[22rem] w-full resize-none rounded-[1.75rem] border border-transparent bg-black/18 px-5 py-4 text-sm leading-7 text-zinc-100 outline-none transition-[background-color,box-shadow] placeholder:text-zinc-500 focus:bg-black/24 focus:shadow-[inset_0_0_0_1px_rgba(125,211,252,0.22)]"
<div className="mb-2 text-sm font-medium text-zinc-200"></div>
<input
value={draftName}
onChange={(event) => onNameChange(event.target.value)}
placeholder="输入一个更贴合这次旅程的称呼"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-emerald-400/40"
/>
</label>
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200"></div>
<textarea
value={draftBackstory}
onChange={(event) => onBackstoryChange(event.target.value)}
rows={6}
placeholder="可以补充这次开局想强调的身份、经历、执念或禁忌。"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-emerald-400/40"
/>
</label>
{(isGenerating || progress > 0) && (
<div className="rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">{progressLabel}</div>
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
</div>
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
<div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_48%,#fef08a_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
</div>
</div>
)}
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
</div>
</SelectionModal>
);
}
<div className="flex justify-end gap-3">
type CustomWorldCreatorModalProps = {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
isGenerating: boolean;
progress: number;
progressLabel: string;
error?: string | null;
} & (
| {
draft: string;
onDraftChange: (value: string) => void;
creatorIntent?: never;
onCreatorIntentChange?: never;
generationMode?: never;
onGenerationModeChange?: never;
}
| {
draft?: never;
onDraftChange?: never;
creatorIntent: CustomWorldCreatorIntent;
onCreatorIntentChange: (value: CustomWorldCreatorIntent) => void;
generationMode: CustomWorldGenerationMode;
onGenerationModeChange: (value: CustomWorldGenerationMode) => void;
}
);
function hasCreatorIntentProps(
props: CustomWorldCreatorModalProps,
): props is Extract<
CustomWorldCreatorModalProps,
{ creatorIntent: CustomWorldCreatorIntent }
> {
return 'creatorIntent' in props;
}
export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
const {
isOpen,
onClose,
onSubmit,
isGenerating,
progress,
progressLabel,
error = null,
} = props;
const draftText = hasCreatorIntentProps(props)
? props.creatorIntent.rawSettingText
: props.draft;
const updateDraftText = (value: string) => {
if (hasCreatorIntentProps(props)) {
props.onCreatorIntentChange({
...props.creatorIntent,
rawSettingText: value,
});
return;
}
props.onDraftChange(value);
};
return (
<SelectionModal
isOpen={isOpen}
title="创建自定义世界"
onClose={onClose}
footer={(
<>
<button
type="button"
onClick={onClose}
disabled={isGenerating}
className={`inline-flex min-w-24 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-5 py-2.5 text-sm text-zinc-300 transition-colors hover:bg-white/10 hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
@@ -150,90 +211,65 @@ export function CustomWorldCreatorModal({
type="button"
onClick={onSubmit}
disabled={isGenerating}
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'cursor-wait opacity-60' : ''}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-50"
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">{isGenerating ? '正在生成世界...' : '确认并开始生成'}</span>
<span className="text-white/60">{isGenerating ? '...' : '→'}</span>
</div>
{isGenerating ? '生成中...' : '开始生成'}
</button>
</div>
</div>
</ModalShell>
);
}
export function CharacterDraftModal({
isOpen,
characterLabel,
draftName,
draftBackstory,
onNameChange,
onBackstoryChange,
onClose,
onConfirm,
error,
}: CharacterDraftModalProps) {
return (
<ModalShell
isOpen={isOpen}
title="自定义角色背景"
subtitle={`你正在修改 ${characterLabel} 的角色名称与背景故事。`}
onClose={onClose}
</>
)}
>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-7 text-zinc-300">
{hasCreatorIntentProps(props) ? (
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200"></div>
<select
value={props.generationMode}
onChange={(event) =>
props.onGenerationModeChange(
event.target.value as CustomWorldGenerationMode,
)
}
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-400/40"
>
<option value="fast"></option>
<option value="full"></option>
</select>
</label>
) : null}
<div className="text-sm leading-7 text-zinc-300">
</div>
<label className="block">
<div className="mb-2 text-xs font-bold tracking-[0.14em] text-white"></div>
<input
value={draftName}
onChange={event => onNameChange(event.target.value)}
placeholder="输入新的角色名称"
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
</label>
<textarea
value={draftText}
onChange={(event) => updateDraftText(event.target.value)}
rows={8}
placeholder="例:一个雨雾笼罩的海上武侠世界,旧朝遗臣、海盗盟约与沉船秘术纠缠在一起……"
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>
<label className="block">
<div className="mb-2 text-xs font-bold tracking-[0.14em] text-white"></div>
<textarea
value={draftBackstory}
onChange={event => onBackstoryChange(event.target.value)}
placeholder="写下这名角色进入世界前后的经历、动机、执念、秘密或人与人之间的纠葛。"
className="min-h-44 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
</label>
{error ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
{isGenerating ? (
<div className="rounded-2xl border border-sky-300/15 bg-sky-500/10 px-4 py-3">
<div className="mb-2 flex items-center justify-between text-xs tracking-[0.16em] text-sky-100/80">
<span>{progressLabel}</span>
<span>{Math.max(0, Math.min(100, Math.round(progress)))}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-cyan-200 transition-[width] duration-300"
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
/>
</div>
</div>
) : null}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
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={onConfirm}
className="pixel-nine-slice pixel-pressable text-left"
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>
</div>
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
</div>
</ModalShell>
</SelectionModal>
);
}

View File

@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { getCharacterAnimationDurationMs, getSkillCasterAnimation, getSkillDelivery } from '../data/characterCombat';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import { createSceneMonstersFromIds } from '../data/monsters';
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
import { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
import { getScenePreset } from '../data/scenePresets';
import { buildSkillEffects } from '../hooks/useCombatFlow';
@@ -14,7 +14,7 @@ import {
CombatActionMode,
CombatVisualEffect,
Encounter,
SceneMonster,
SceneHostileNpc,
WorldType,
} from '../types';
import { GameCanvas } from './GameCanvas';
@@ -38,7 +38,7 @@ function getSkillReleaseDelayMs(character: Character, skill: CharacterSkillDefin
}
function buildPreviewTargetMonster(worldType: WorldType, targetMonsterId?: string | null) {
const previewMonster = createSceneMonstersFromIds(
const previewMonster = createSceneHostileNpcsFromIds(
worldType,
targetMonsterId ? [targetMonsterId] : [],
PLAYER_X,
@@ -54,7 +54,7 @@ function buildPreviewTargetMonster(worldType: WorldType, targetMonsterId?: strin
: null;
}
function resetNpcPreviewMonster(monster: SceneMonster) {
function resetNpcPreviewMonster(monster: SceneHostileNpc) {
return {
...monster,
animation: 'idle' as const,
@@ -100,7 +100,7 @@ export function SkillEffectPreview({
const [playerAnimation, setPlayerAnimation] = useState(AnimationState.IDLE);
const [playerActionMode, setPlayerActionMode] = useState<CombatActionMode>('idle');
const [sceneMonsters, setSceneMonsters] = useState<SceneMonster[]>(initialMonsters);
const [sceneHostileNpcs, setSceneMonsters] = useState<SceneHostileNpc[]>(initialMonsters);
const [activeCombatEffects, setActiveCombatEffects] = useState<CombatVisualEffect[]>([]);
const [replayTick, setReplayTick] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
@@ -224,7 +224,7 @@ export function SkillEffectPreview({
<div>
<div className="text-sm font-semibold text-white">{skill?.name ?? '未选择技能'}</div>
<div className="mt-1 text-xs text-zinc-400">
{mode === 'player' ? `受击对象:${sceneMonsters[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
{mode === 'player' ? `受击对象:${sceneHostileNpcs[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
</div>
</div>
<button
@@ -247,7 +247,7 @@ export function SkillEffectPreview({
encounter={null}
currentScenePreset={scenePreset}
worldType={worldType}
sceneMonsters={sceneMonsters}
sceneHostileNpcs={sceneHostileNpcs}
playerX={PLAYER_X}
playerOffsetY={0}
playerFacing="right"

View File

@@ -5,10 +5,16 @@ import {getSkillCasterAnimation, getSkillDelivery} from '../data/characterCombat
import {PRESET_CHARACTERS} from '../data/characterPresets';
import {createEmptyEquipmentLoadout} from '../data/equipmentEffects';
import {MONSTER_PRESETS_BY_WORLD} from '../data/hostileNpcPresets';
import {createSceneMonstersFromIds, getFacingTowardPlayer, PLAYER_BASE_X_METERS} from '../data/hostileNpcs';
import {createSceneHostileNpcsFromIds, getFacingTowardPlayer, PLAYER_BASE_X_METERS} from '../data/hostileNpcs';
import {createInitialGameRuntimeStats} from '../data/runtimeStats';
import {CALL_OUT_ENTRY_X_METERS, PREVIEW_ENTITY_X_METERS, RESOLVED_ENTITY_X_METERS} from '../data/sceneEncounterPreviews';
import {getForwardScenePreset, getScenePresetsByWorld, getTravelScenePreset, type ScenePreset} from '../data/scenePresets';
import {
getForwardScenePreset,
getSceneHostileNpcPresetIds,
getScenePresetsByWorld,
getTravelScenePreset,
type ScenePreset,
} from '../data/scenePresets';
import stateFunctionOverridesJson from '../data/stateFunctionOverrides.json';
import {
buildStateFunctionDefinitions,
@@ -37,7 +43,7 @@ import {
type GameState,
type HostileNpcRenderAnimation,
type PlayerStateMode,
type SceneMonster,
type SceneHostileNpc,
type SkillStyle,
type StoryOption,
WorldType,
@@ -66,7 +72,7 @@ type OptionBehaviorPreview = {
scenePreset: ScenePreset;
targetScene: ScenePreset;
encounter: Encounter | null;
sceneMonsters: SceneMonster[];
sceneHostileNpcs: SceneHostileNpc[];
playerAnimation: AnimationState;
playerX: number;
playerOffsetY: number;
@@ -348,7 +354,7 @@ function getTargetSceneForPreview(definition: StateFunctionDefinition, worldType
}
function createPreviewMonster(worldType: WorldType, monsterId: string, xMeters: number) {
const previewMonster = createSceneMonstersFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS)[0];
const previewMonster = createSceneHostileNpcsFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS)[0];
if (!previewMonster) return null;
return {
@@ -477,7 +483,7 @@ function createFunctionContext(
inBattle: definition.state === 'battle',
currentSceneId: scene.id,
currentSceneName: scene.name,
monsters: definition.state === 'battle' ? createSceneMonstersFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS) : [],
monsters: definition.state === 'battle' ? createSceneHostileNpcsFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS) : [],
playerHp: PREVIEW_PLAYER_HP,
playerMaxHp: PREVIEW_PLAYER_MAX_HP,
playerMana: PREVIEW_PLAYER_MANA,
@@ -532,7 +538,7 @@ function _buildBehaviorPreview(
scenePreset: scene,
targetScene,
encounter: null,
sceneMonsters: previewMonster ? [previewMonster] : [],
sceneHostileNpcs: previewMonster ? [previewMonster] : [],
playerAnimation: predictedSkill?.animation ?? AnimationState.IDLE,
playerX: 0,
playerOffsetY: 0,
@@ -563,7 +569,7 @@ function _buildBehaviorPreview(
scenePreset: scene,
targetScene,
encounter: null,
sceneMonsters: chaseMonster
sceneHostileNpcs: chaseMonster
? [{
...chaseMonster,
animation: 'move' as const,
@@ -597,8 +603,9 @@ function _buildBehaviorPreview(
: executionMode === 'idle-call-out'
? CALL_OUT_ENTRY_X_METERS
: RESOLVED_ENTITY_X_METERS;
const previewSceneHostilePresetIds = getSceneHostileNpcPresetIds(previewScene);
const idleMonster = idlePreviewKind === 'monster'
? createPreviewMonster(worldType, previewScene.monsterIds[0] ?? selectedMonsterId, entityX)
? createPreviewMonster(worldType, previewSceneHostilePresetIds[0] ?? selectedMonsterId, entityX)
: null;
const encounter = idlePreviewKind === 'npc'
? buildNpcEncounter(previewScene, entityX)
@@ -611,7 +618,7 @@ function _buildBehaviorPreview(
scenePreset: previewScene,
targetScene,
encounter,
sceneMonsters: idleMonster ? [idleMonster] : [],
sceneHostileNpcs: idleMonster ? [idleMonster] : [],
playerAnimation:
executionMode === 'idle-explore'
? AnimationState.RUN
@@ -694,7 +701,7 @@ function buildPreviewGameState(
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: scene,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -726,7 +733,7 @@ function buildPreviewGameState(
if (definition.state === 'battle') {
return {
...baseState,
sceneMonsters: createSceneMonstersFromIds(worldType, [selectedMonsterId], PLAYER_BASE_X_METERS),
sceneHostileNpcs: createSceneHostileNpcsFromIds(worldType, [selectedMonsterId], PLAYER_BASE_X_METERS),
currentEncounter: null,
inBattle: true,
};
@@ -744,7 +751,7 @@ function buildPreviewGameState(
const previewMonster = createPreviewMonster(worldType, selectedMonsterId, PREVIEW_ENTITY_X_METERS);
return {
...baseState,
sceneMonsters: previewMonster ? [previewMonster] : [],
sceneHostileNpcs: previewMonster ? [previewMonster] : [],
};
}
@@ -886,8 +893,8 @@ function BehaviorExecutionPreview({
};
}, [definition, definitions, worldType, character, scene, selectedMonsterId, idlePreviewKind, replayTick]);
const liveMonsterSummary = gameState.sceneMonsters[0]
? `${gameState.sceneMonsters[0].name} / 生命 ${gameState.sceneMonsters[0].hp}/${gameState.sceneMonsters[0].maxHp} / ${getMonsterAnimationLabel(gameState.sceneMonsters[0].animation)}`
const liveMonsterSummary = gameState.sceneHostileNpcs[0]
? `${gameState.sceneHostileNpcs[0].name} / 生命 ${gameState.sceneHostileNpcs[0].hp}/${gameState.sceneHostileNpcs[0].maxHp} / ${getMonsterAnimationLabel(gameState.sceneHostileNpcs[0].animation)}`
: gameState.currentEncounter
? `${gameState.currentEncounter.npcName} / ${gameState.currentEncounter.kind ? ENCOUNTER_KIND_LABELS[gameState.currentEncounter.kind] : '遭遇目标'}`
: '当前没有可见目标';
@@ -935,7 +942,7 @@ function BehaviorExecutionPreview({
encounter={gameState.currentEncounter}
currentScenePreset={gameState.currentScenePreset}
worldType={gameState.worldType}
sceneMonsters={gameState.sceneMonsters}
sceneHostileNpcs={gameState.sceneHostileNpcs}
playerX={gameState.playerX}
playerOffsetY={gameState.playerOffsetY}
playerFacing={gameState.playerFacing}

View File

@@ -17,7 +17,16 @@ import {type InventoryUseEffect, isInventoryItemUsable} from '../../data/invento
import {getRarityLabel} from '../../data/npcInteractions';
import {isQuestReadyToClaim} from '../../data/questFlow';
import type {BattleRewardUi, QuestFlowUi} from '../../hooks/useStoryGeneration';
import type {EquipmentSlotId, InventoryItem, QuestLogEntry, WorldType} from '../../types';
import type {
CampEvent,
ChapterState,
EquipmentSlotId,
InventoryItem,
JourneyBeat,
QuestLogEntry,
SetpieceDirective,
WorldType,
} from '../../types';
import {CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {PixelIcon} from '../PixelIcon';
@@ -57,12 +66,19 @@ interface AdventurePanelOverlaysProps {
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
saveAndExitDisabled: boolean;
isChapterPanelOpen: boolean;
setIsChapterPanelOpen: (open: boolean) => void;
isQuestPanelOpen: boolean;
setIsQuestPanelOpen: (open: boolean) => void;
isSettingsPanelOpen: boolean;
setIsSettingsPanelOpen: (open: boolean) => void;
isStatsPanelOpen: boolean;
setIsStatsPanelOpen: (open: boolean) => void;
chapterState: ChapterState | null;
journeyBeat: JourneyBeat | null;
recentChronicleSummary: string | null;
currentCampEvent: CampEvent | null;
setpieceDirective: SetpieceDirective | null;
selectedQuest: QuestLogEntry | null;
setSelectedQuestId: (questId: string | null) => void;
completionNoticeQuest: QuestLogEntry | null;
@@ -82,6 +98,78 @@ interface AdventurePanelOverlaysProps {
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
}
function getChapterStageLabel(stage: ChapterState['stage'] | null | undefined) {
switch (stage) {
case 'opening':
return '开篇';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '进行中';
}
}
function getJourneyBeatLabel(beatType: JourneyBeat['beatType'] | null | undefined) {
switch (beatType) {
case 'approach':
return '接近';
case 'investigation':
return '调查';
case 'camp':
return '休整';
case 'conflict':
return '冲突';
case 'boss_prelude':
return '决战前奏';
case 'climax':
return '高潮';
case 'recovery':
return '恢复';
default:
return '旅程';
}
}
function getCampEventLabel(eventType: CampEvent['eventType'] | null | undefined) {
switch (eventType) {
case 'private_talk':
return '私话';
case 'party_banter':
return '同行插话';
case 'conflict':
return '争执';
case 'comfort':
return '安抚';
case 'reveal':
return '透露';
case 'decision':
return '抉择';
default:
return '营地事件';
}
}
function getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType'] | null | undefined) {
switch (setpieceType) {
case 'boss_prelude':
return '决战前奏';
case 'showdown':
return '对峙';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '剧情节点';
}
}
function getQuestRewardItemIcon(item: InventoryItem) {
if (item.iconSrc) return item.iconSrc;
if (item.tags.includes('weapon')) return '/UI/Icon_Eq_Weapon.png';
@@ -393,12 +481,19 @@ export function AdventurePanelOverlays({
onMusicVolumeChange,
onSaveAndExit,
saveAndExitDisabled,
isChapterPanelOpen,
setIsChapterPanelOpen,
isQuestPanelOpen,
setIsQuestPanelOpen,
isSettingsPanelOpen,
setIsSettingsPanelOpen,
isStatsPanelOpen,
setIsStatsPanelOpen,
chapterState,
journeyBeat,
recentChronicleSummary,
currentCampEvent,
setpieceDirective,
selectedQuest,
setSelectedQuestId,
completionNoticeQuest,
@@ -421,6 +516,120 @@ export function AdventurePanelOverlays({
return (
<>
<AnimatePresence>
{isChapterPanelOpen && (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsChapterPanelOpen(false)}
>
<motion.div
initial={{opacity: 0, scale: 0.96, y: 8}}
animate={{opacity: 1, scale: 1, y: 0}}
exit={{opacity: 0, scale: 0.96, y: 8}}
transition={{duration: 0.18, ease: 'easeOut'}}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(86vh,40rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-sm font-semibold text-white">
{chapterState?.title ?? '当前章节'}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<button
type="button"
onClick={() => setIsChapterPanelOpen(false)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{chapterState?.title ?? '旅程推进中'}
</div>
{chapterState && (
<div className="mt-2 inline-flex rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] text-zinc-300">
{getChapterStageLabel(chapterState.stage)}
</div>
)}
{chapterState?.chapterSummary && (
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{chapterState.chapterSummary}
</div>
)}
</div>
{journeyBeat && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getJourneyBeatLabel(journeyBeat.beatType)} · {journeyBeat.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{journeyBeat.emotionalGoal}
</div>
</div>
)}
{currentCampEvent && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getCampEventLabel(currentCampEvent.eventType)} · {currentCampEvent.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{currentCampEvent.triggerReason}
</div>
</div>
)}
{setpieceDirective && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 text-sm font-semibold text-white">
{getSetpieceLabel(setpieceDirective.setpieceType)} · {setpieceDirective.title}
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
{setpieceDirective.dramaticQuestion}
</div>
</div>
)}
{recentChronicleSummary && (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-zinc-300">
{recentChronicleSummary}
</div>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isSettingsPanelOpen && (
<motion.div

View File

@@ -8,7 +8,7 @@ interface GameCanvasEffectLayerProps {
activeCombatEffects: CombatVisualEffect[];
getPlayerEffectLeft: (effectX: number, offsetPx?: number) => string;
getHostileNpcEffectLeft: (effectX: number, hostileNpcId?: string, offsetPx?: number) => string;
sceneHostileNpcs: SceneHostileNpc[];
sceneCombatants: SceneHostileNpc[];
playerCharacter: Character | null;
groundBottom: string;
stageLiftPx: number;
@@ -189,7 +189,7 @@ export function GameCanvasEffectLayer({
activeCombatEffects,
getPlayerEffectLeft,
getHostileNpcEffectLeft,
sceneHostileNpcs,
sceneCombatants,
playerCharacter,
groundBottom,
stageLiftPx,
@@ -210,7 +210,7 @@ export function GameCanvasEffectLayer({
const startBottom = `calc(${getEntityEffectBottom({
origin: effect.startOrigin,
hostileNpcId: effect.startHostileNpcId ?? effect.startMonsterId,
sceneHostileNpcs,
sceneCombatants,
playerCharacter,
groundBottom,
stageLiftPx,
@@ -220,7 +220,7 @@ export function GameCanvasEffectLayer({
const endBottom = `calc(${getEntityEffectBottom({
origin: effect.endOrigin ?? effect.startOrigin,
hostileNpcId: effect.endHostileNpcId ?? effect.endMonsterId ?? effect.startHostileNpcId ?? effect.startMonsterId,
sceneHostileNpcs,
sceneCombatants,
playerCharacter,
groundBottom,
stageLiftPx,

View File

@@ -26,6 +26,7 @@ import {
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneEntityZIndex,
HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX,
HpBar,
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
@@ -66,7 +67,7 @@ interface GameCanvasEntityLayerProps {
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
sceneHostileNpcs: SceneHostileNpc[];
sceneCombatants: SceneHostileNpc[];
monsters: MonsterSpriteConfig[];
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
groundBottom: string;
@@ -101,7 +102,7 @@ export function GameCanvasEntityLayer({
effectivePlayerAnimationState,
shouldShowPlayerDialogueIcon,
dialogueIndicator = null,
sceneHostileNpcs,
sceneCombatants,
monsters,
getHostileNpcOuterLeft,
groundBottom,
@@ -242,7 +243,7 @@ export function GameCanvasEntityLayer({
</div>
</motion.div>
{sceneHostileNpcs.map(hostileNpc => {
{sceneCombatants.map(hostileNpc => {
const npcEncounter = hostileNpc.encounter;
if (!npcEncounter) return null;
const config = monsters.find(item => item.id === hostileNpc.id);
@@ -256,13 +257,20 @@ export function GameCanvasEntityLayer({
? hostileNpc.facing
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
const npcCombatHpTop = getNpcCombatHpTop(npcEncounter?.characterId, npcEncounter?.monsterPresetId);
const hostileNpcBottomOffsetPx = npcMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
const opponentBottom = npcCharacter
? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter)
: `calc(${groundBottom} + ${stageLiftPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0)}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
const entityBottomOffsetPx = npcCharacter
? getCharacterBottomOffsetPx(stageLiftPx, npcCharacter, hostileNpc.yOffset ?? 0)
: stageLiftPx + (hostileNpc.yOffset ?? 0);
? getCharacterBottomOffsetPx(
stageLiftPx,
npcCharacter,
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
)
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
return (
<div
@@ -346,9 +354,12 @@ export function GameCanvasEntityLayer({
encounter.kind === 'npc' && encounter.monsterPresetId
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
: null;
const peacefulHostileBottomOffsetPx = peacefulMonsterConfig
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
: 0;
const peacefulBottomOffsetPx = peacefulResolvedCharacter
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
: stageLiftPx;
: stageLiftPx + peacefulHostileBottomOffsetPx;
const peacefulNpcSpriteFacing =
encounter.kind === 'treasure' || peacefulResolvedCharacter
? towardPeacefulPlayer
@@ -370,7 +381,7 @@ export function GameCanvasEntityLayer({
stageLiftPx,
getCharacterById(encounter.characterId),
)
: `calc(${groundBottom} + ${stageLiftPx}px)`,
: `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}px)`,
zIndex: getSceneEntityZIndex(peacefulBottomOffsetPx),
transition: isCampCompanionEncounter
? 'bottom 180ms ease'

View File

@@ -13,6 +13,7 @@ import {
getCharacterBottomOffsetPx,
getMonsterWorldLeft,
getPlayerWorldLeft,
HOSTILE_NPC_SCENE_INSET_PX,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_SPEED_PX_PER_S,
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
@@ -26,7 +27,6 @@ export function GameCanvasRuntime({
currentScenePreset,
worldType,
sceneHostileNpcs,
sceneMonsters,
playerX,
playerOffsetY,
playerFacing,
@@ -58,12 +58,8 @@ export function GameCanvasRuntime({
const stageLiftPx = 68;
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
const cameraAnchorX = scrollWorld ? playerX : PLAYER_BASE_X_METERS;
const resolvedSceneHostileNpcs =
sceneMonsters && sceneMonsters.length > 0
? sceneMonsters
: (sceneHostileNpcs ?? []);
const closestHostileNpcDistance = resolvedSceneHostileNpcs.length > 0
? Math.min(...resolvedSceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
const closestHostileNpcDistance = sceneHostileNpcs.length > 0
? Math.min(...sceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
: Infinity;
const escapeLead = scrollWorld ? Math.max(0, Math.min(1, (closestHostileNpcDistance - 1.2) / 3.4)) : 0;
const sideAnchor = '15%';
@@ -78,10 +74,13 @@ export function GameCanvasRuntime({
? playerMeleeLeft
: playerWorldLeft;
const monsterAnchorMeters = 3.2;
const getHostileNpcOuterLeft = (hostileNpc: (typeof resolvedSceneHostileNpcs)[number]) =>
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
const baseLeft =
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`;
};
const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
const base = playerActionMode === 'melee' && !scrollWorld
? playerMeleeLeft
@@ -89,7 +88,7 @@ export function GameCanvasRuntime({
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
};
const getHostileNpcEffectLeft = (effectX: number, hostileNpcId?: string, offsetPx = 0) => {
const effectHostileNpc = hostileNpcId ? resolvedSceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId) : null;
const effectHostileNpc = hostileNpcId ? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId) : null;
const base = effectHostileNpc
? getHostileNpcOuterLeft(effectHostileNpc)
: getMonsterWorldLeft(sideAnchor, effectX, cameraAnchorX, monsterAnchorMeters);
@@ -183,7 +182,7 @@ export function GameCanvasRuntime({
effectivePlayerAnimationState={effectivePlayerAnimationState}
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
dialogueIndicator={dialogueIndicator}
sceneHostileNpcs={resolvedSceneHostileNpcs}
sceneCombatants={sceneHostileNpcs}
monsters={monsters}
getHostileNpcOuterLeft={getHostileNpcOuterLeft}
groundBottom={groundBottom}
@@ -198,7 +197,7 @@ export function GameCanvasRuntime({
activeCombatEffects={activeCombatEffects}
getPlayerEffectLeft={getPlayerEffectLeft}
getHostileNpcEffectLeft={getHostileNpcEffectLeft}
sceneHostileNpcs={resolvedSceneHostileNpcs}
sceneCombatants={sceneHostileNpcs}
playerCharacter={playerCharacter}
groundBottom={groundBottom}
stageLiftPx={stageLiftPx}

View File

@@ -27,8 +27,7 @@ export interface GameCanvasProps {
encounter: Encounter | null;
currentScenePreset: ScenePresetInfo | null;
worldType: WorldType | null;
sceneHostileNpcs?: SceneHostileNpc[];
sceneMonsters?: SceneHostileNpc[];
sceneHostileNpcs: SceneHostileNpc[];
playerX: number;
playerOffsetY: number;
playerFacing: 'left' | 'right';
@@ -64,6 +63,8 @@ export const DEFAULT_COMBAT_HP_TOP_PX = -18;
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
export const HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX = -18;
export const CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
export const OPENING_CAMP_OVERLAY_SRC = '/scene_bg/hut.png';
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
@@ -162,7 +163,7 @@ export function getCharacterBottomOffsetPx(
export function getEntityEffectBottom({
origin,
hostileNpcId,
sceneHostileNpcs,
sceneCombatants,
playerCharacter,
groundBottom,
stageLiftPx,
@@ -171,7 +172,7 @@ export function getEntityEffectBottom({
}: {
origin: 'player' | 'hostile_npc' | 'monster';
hostileNpcId?: string;
sceneHostileNpcs: SceneHostileNpc[];
sceneCombatants: SceneHostileNpc[];
playerCharacter: Character | null;
groundBottom: string;
stageLiftPx: number;
@@ -184,7 +185,7 @@ export function getEntityEffectBottom({
}
const targetHostileNpc = hostileNpcId
? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId)
? sceneCombatants.find(hostileNpc => hostileNpc.id === hostileNpcId)
: null;
if (!targetHostileNpc) {

View File

@@ -50,7 +50,6 @@ export function GameShellCanvasStage({
currentScenePreset={visibleGameState.currentScenePreset}
worldType={visibleGameState.worldType}
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
sceneMonsters={visibleGameState.sceneMonsters}
playerX={visibleGameState.playerX}
playerOffsetY={visibleGameState.playerOffsetY}
playerFacing={visibleGameState.playerFacing}

View File

@@ -168,6 +168,12 @@ export function GameShellStoryPanels({
companionRenderStates={companionRenderStates}
npcStates={visibleGameState.npcStates}
quests={visibleGameState.quests}
companionArcStates={
visibleGameState.storyEngineMemory?.companionArcStates ?? []
}
companionResolutions={
visibleGameState.storyEngineMemory?.companionResolutions ?? []
}
onOpenCamp={openCampModal}
onOpenCharacterChat={characterChatUi.openChat}
chatSummaries={characterChatSummaries}
@@ -201,6 +207,19 @@ export function GameShellStoryPanels({
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
inBattle={visibleGameState.inBattle}
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
chapterState={visibleGameState.chapterState ?? null}
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
recentChronicleSummary={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
currentCampEvent={
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
}
setpieceDirective={
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
@@ -227,6 +246,15 @@ export function GameShellStoryPanels({
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
continueGameDigest={
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
}
narrativeCodex={
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
}
narrativeQaReport={
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
}
/>
</Suspense>
)}

View File

@@ -1,14 +1,26 @@
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,
} 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 {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
createEmptyCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
import {
type CustomWorldCreatorIntent,
type CustomWorldGenerationMode,
type CustomWorldProfile,
type GameState,
WorldType,
@@ -19,12 +31,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>>;
@@ -75,20 +92,71 @@ 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 buildLockedSeedNameSets(profile: CustomWorldProfile) {
const lockedCharacterNames = new Set(
profile.creatorIntent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
);
const lockedLandmarkNames = new Set(
profile.creatorIntent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
);
return {
lockedCharacterNames,
lockedLandmarkNames,
};
}
function getCustomWorldProgressLabel(progress: number) {
if (progress >= 96) return '正在完成世界归档...';
if (progress >= 78) return '正在组合场景和视觉效果...';
if (progress >= 52) return '正在生成核心角色...';
if (progress >= 28) return '正在生成可玩角色...';
return '正在解析世界设置...';
function mergeLockedProfileContent(
currentProfile: CustomWorldProfile,
nextProfile: CustomWorldProfile,
) {
const { lockedCharacterNames, lockedLandmarkNames } =
buildLockedSeedNameSets(currentProfile);
const nextPlayableNpcs = nextProfile.playableNpcs.map((npc) => {
if (!lockedCharacterNames.has(npc.name.trim())) {
return npc;
}
return (
currentProfile.playableNpcs.find(
(currentNpc) => currentNpc.name.trim() === npc.name.trim(),
) ?? npc
);
});
const nextStoryNpcs = nextProfile.storyNpcs.map((npc) => {
if (!lockedCharacterNames.has(npc.name.trim())) {
return npc;
}
return (
currentProfile.storyNpcs.find(
(currentNpc) => currentNpc.name.trim() === npc.name.trim(),
) ?? npc
);
});
const nextLandmarks = nextProfile.landmarks.map((landmark) => {
if (!lockedLandmarkNames.has(landmark.name.trim())) {
return landmark;
}
return (
currentProfile.landmarks.find(
(currentLandmark) =>
currentLandmark.name.trim() === landmark.name.trim(),
) ?? landmark
);
});
return {
...nextProfile,
playableNpcs: nextPlayableNpcs,
storyNpcs: nextStoryNpcs,
landmarks: nextLandmarks,
} satisfies CustomWorldProfile;
}
export function PreGameSelectionFlow({
@@ -110,10 +178,17 @@ export function PreGameSelectionFlow({
() => generateWorldOnlineCounts(),
);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldDraft, setCustomWorldDraft] = useState('');
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
useState<CustomWorldCreatorIntent>(() =>
createEmptyCustomWorldCreatorIntent('freeform'),
);
const [customWorldGenerationMode, setCustomWorldGenerationMode] =
useState<CustomWorldGenerationMode>('fast');
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(
() =>
@@ -171,6 +246,19 @@ export function PreGameSelectionFlow({
[savedCustomWorldProfiles],
);
const customWorldSettingPreview = useMemo(() => {
if (customWorldCreatorIntent.sourceMode === 'freeform') {
return customWorldCreatorIntent.rawSettingText.trim();
}
const intentSummary = buildCustomWorldCreatorIntentDisplayText(
customWorldCreatorIntent,
).trim();
if (intentSummary) {
return intentSummary;
}
return customWorldCreatorIntent.rawSettingText.trim();
}, [customWorldCreatorIntent]);
useEffect(() => {
if (!gameState.worldType && selectionStage === 'world') {
setWorldOnlineCounts(generateWorldOnlineCounts());
@@ -186,13 +274,63 @@ 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;
}
if (generatedCustomWorldProfile) {
setCustomWorldCreatorIntent(
generatedCustomWorldProfile.creatorIntent ??
({
...createEmptyCustomWorldCreatorIntent('freeform'),
rawSettingText: generatedCustomWorldProfile.settingText,
} satisfies CustomWorldCreatorIntent),
);
setCustomWorldGenerationMode(
generatedCustomWorldProfile.generationMode ?? 'full',
);
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('world');
setShowCustomWorldModal(true);
};
const saveGeneratedCustomWorld = () => {
if (!generatedCustomWorldProfile) {
return;
@@ -212,51 +350,343 @@ export function PreGameSelectionFlow({
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setCustomWorldProgress(null);
setSelectionStage('world');
};
const createCustomWorld = async () => {
const settingText = customWorldDraft.trim();
if (!settingText) {
setCustomWorldError('请先输入世界设置。');
const openSavedCustomWorldEditor = (profile: CustomWorldProfile) => {
if (isGeneratingCustomWorld) {
return;
}
setGeneratedCustomWorldProfile(profile);
setCustomWorldCreatorIntent(
profile.creatorIntent ??
({
...createEmptyCustomWorldCreatorIntent('freeform'),
rawSettingText: profile.settingText,
} satisfies CustomWorldCreatorIntent),
);
setCustomWorldGenerationMode(profile.generationMode ?? 'full');
setCustomWorldError(null);
setIsGeneratingCustomWorld(true);
setCustomWorldProgress(8);
setCustomWorldProgress(null);
setSelectionStage('custom-world-result');
};
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);
const regenerateFromCurrentProfile = async (
applyProfile: (
currentProfile: CustomWorldProfile,
regeneratedProfile: CustomWorldProfile,
) => CustomWorldProfile,
options: {
confirmMessage: string;
generationMode?: CustomWorldGenerationMode;
},
) => {
if (!generatedCustomWorldProfile || isGeneratingCustomWorld) {
return;
}
const confirmed = window.confirm(options.confirmMessage);
if (!confirmed) {
return;
}
const abortController = new AbortController();
customWorldAbortControllerRef.current?.abort();
customWorldAbortControllerRef.current = abortController;
setIsGeneratingCustomWorld(true);
setCustomWorldError(null);
try {
const profile = await generateCustomWorldProfile(settingText);
window.clearInterval(progressTimer);
setCustomWorldProgress(100);
await new Promise((resolve) => window.setTimeout(resolve, 180));
setGeneratedCustomWorldProfile(profile);
setShowCustomWorldModal(false);
const regeneratedProfile = await generateCustomWorldProfile(
{
settingText:
generatedCustomWorldProfile.settingText.trim() ||
customWorldSettingPreview,
creatorIntent: generatedCustomWorldProfile.creatorIntent,
generationMode:
options.generationMode ??
generatedCustomWorldProfile.generationMode ??
'full',
},
{
signal: abortController.signal,
onProgress: setCustomWorldProgress,
},
);
if (abortController.signal.aborted) {
return;
}
const mergedProfile = applyProfile(
generatedCustomWorldProfile,
mergeLockedProfileContent(generatedCustomWorldProfile, regeneratedProfile),
);
setGeneratedCustomWorldProfile(mergedProfile);
setCustomWorldProgress(null);
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 continueExpandCustomWorld = async () => {
await regenerateFromCurrentProfile(
(_currentProfile, regeneratedProfile) => ({
...regeneratedProfile,
generationMode: 'full',
generationStatus: 'complete',
}),
{
confirmMessage:
'确认继续补全当前世界吗?系统会在保留已锁定锚点的前提下,继续生成长尾角色和场景网络。',
generationMode: 'full',
},
);
};
const regeneratePlayableNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.playableNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.playableNpcs[targetIndex] ??
regeneratedProfile.playableNpcs.find(
(entry) =>
entry.name === currentProfile.playableNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
return {
...currentProfile,
playableNpcs: currentProfile.playableNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
),
};
},
{
confirmMessage: '确认重新生成这个可扮演角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.storyNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.storyNpcs[targetIndex] ??
regeneratedProfile.storyNpcs.find(
(entry) => entry.name === currentProfile.storyNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
const nextStoryNpcs = currentProfile.storyNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
);
return {
...currentProfile,
storyNpcs: nextStoryNpcs,
landmarks: currentProfile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.map((npcId) =>
npcId === id ? nextNpc.id : npcId,
),
})),
};
},
{
confirmMessage: '确认重新生成这个场景角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateLandmark = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.landmarks.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextLandmark =
regeneratedProfile.landmarks[targetIndex] ??
regeneratedProfile.landmarks.find(
(entry) => entry.name === currentProfile.landmarks[targetIndex]?.name,
);
if (!nextLandmark) {
return currentProfile;
}
return {
...currentProfile,
landmarks: currentProfile.landmarks.map((entry, index) =>
index === targetIndex ? nextLandmark : entry,
),
};
},
{
confirmMessage: '确认重新生成这个关键地点吗?当前场景的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryExpansion = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
storyNpcs: regeneratedProfile.storyNpcs,
}),
{
confirmMessage:
'确认重新生成长尾场景角色吗?已锁定锚点会保留,其余场景角色会被新的生成结果替换。',
generationMode: 'full',
},
);
};
const regenerateLandmarkNetwork = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
landmarks: currentProfile.landmarks.map((landmark, index) => ({
...landmark,
sceneNpcIds:
regeneratedProfile.landmarks[index]?.sceneNpcIds ??
landmark.sceneNpcIds,
connections:
regeneratedProfile.landmarks[index]?.connections ??
landmark.connections,
})),
}),
{
confirmMessage:
'确认重新生成场景网络吗?已锁定场景名称与描述会保留,但 NPC 分布和连接关系会按最新结果刷新。',
generationMode: 'full',
},
);
};
const createCustomWorld = async () => {
if (isGeneratingCustomWorld) {
return;
}
const generationText =
buildCustomWorldCreatorIntentGenerationText(
customWorldCreatorIntent,
).trim() || customWorldCreatorIntent.rawSettingText.trim();
const settingText = customWorldSettingPreview.trim() || generationText;
if (!generationText) {
setCustomWorldError(
customWorldCreatorIntent.sourceMode === 'card'
? '请至少填写一个世界锚点。'
: '请先输入世界设置。',
);
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);
try {
const profile = await generateCustomWorldProfile(
{
settingText,
creatorIntent: customWorldCreatorIntent,
generationMode: customWorldGenerationMode,
},
{
signal: abortController.signal,
onProgress: setCustomWorldProgress,
},
);
if (abortController.signal.aborted) {
return;
}
const persistedProfile = generatedCustomWorldProfile
? {
...profile,
id: generatedCustomWorldProfile.id,
}
: profile;
const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile);
setSavedCustomWorldProfiles(savedProfiles);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('world');
} catch (error) {
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">
@@ -294,9 +724,12 @@ export function PreGameSelectionFlow({
onClick={() => {
handleStartNewGame();
setGeneratedCustomWorldProfile(null);
setCustomWorldDraft('');
setCustomWorldCreatorIntent(
createEmptyCustomWorldCreatorIntent('freeform'),
);
setCustomWorldGenerationMode('fast');
setCustomWorldError(null);
setCustomWorldProgress(0);
setCustomWorldProgress(null);
setShowCustomWorldModal(false);
setSelectionStage('world');
}}
@@ -441,66 +874,69 @@ export function PreGameSelectionFlow({
))}
{savedCustomWorldCards.map((world) => (
<button
key={world.id}
type="button"
onClick={() =>
handleWorldSelect(WorldType.CUSTOM, world.profile)
}
className="pixel-nine-slice pixel-pressable order-1 relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.profile.name}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3">
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
<div key={world.id} className="order-1 relative">
<button
type="button"
onClick={() =>
handleWorldSelect(WorldType.CUSTOM, world.profile)
}
className="pixel-nine-slice pixel-pressable relative flex min-h-[12.5rem] w-full flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.profile.name}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3 pr-16">
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
</div>
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel === '武侠基础'
? '武侠'
: '仙侠'}
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel === '武侠基础'
? '武侠'
: '仙侠'}
<div className="mt-auto">
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
{world.profile.name}
</div>
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
{world.profile.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{world.profile.playableNpcs.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.profile.landmarks.length}
</span>
</div>
</div>
</div>
<div className="mt-auto">
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
{world.profile.name}
</div>
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
{world.profile.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{world.profile.playableNpcs.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.profile.landmarks.length}
</span>
</div>
</div>
</div>
</button>
</button>
<button
type="button"
onClick={() => openSavedCustomWorldEditor(world.profile)}
className="absolute right-3 top-3 z-20 rounded-full border border-white/10 bg-black/35 px-3 py-1.5 text-[11px] text-zinc-100 transition-colors hover:text-white"
>
</button>
</div>
))}
<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 +969,30 @@ 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={customWorldSettingPreview}
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,19 +1007,33 @@ 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();
}}
onContinueExpand={() => {
void continueExpandCustomWorld();
}}
onRegeneratePlayableNpc={(id) => {
void regeneratePlayableNpc(id);
}}
onRegenerateStoryNpc={(id) => {
void regenerateStoryNpc(id);
}}
onRegenerateLandmark={(id) => {
void regenerateLandmark(id);
}}
onRegenerateStoryExpansion={() => {
void regenerateStoryExpansion();
}}
onRegenerateLandmarkNetwork={() => {
void regenerateLandmarkNetwork();
}}
onSave={saveGeneratedCustomWorld}
/>
</motion.div>
@@ -568,11 +1042,13 @@ export function PreGameSelectionFlow({
<CustomWorldCreatorModal
isOpen={showCustomWorldModal}
draft={customWorldDraft}
onDraftChange={(value) => {
setCustomWorldDraft(value);
creatorIntent={customWorldCreatorIntent}
onCreatorIntentChange={(value) => {
setCustomWorldCreatorIntent(value);
if (customWorldError) setCustomWorldError(null);
}}
generationMode={customWorldGenerationMode}
onGenerationModeChange={setCustomWorldGenerationMode}
onClose={() => {
if (isGeneratingCustomWorld) return;
setShowCustomWorldModal(false);
@@ -581,8 +1057,8 @@ export function PreGameSelectionFlow({
void createCustomWorld();
}}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress}
progressLabel={getCustomWorldGenerationLabel(customWorldProgress)}
progress={customWorldProgress?.overallProgress ?? 0}
progressLabel={customWorldProgress?.phaseLabel ?? '正在准备生成'}
error={customWorldError}
/>

View File

@@ -30,7 +30,7 @@ function buildSceneTransitionContentKey(gameState: GameState, currentStory: Stor
const encounterKey = gameState.currentEncounter
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
: 'encounter:none';
const monsterKey = gameState.sceneMonsters
const monsterKey = gameState.sceneHostileNpcs
.map(monster => `${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`)
.join('|');
const storyKey = currentStory

View File

@@ -3,9 +3,10 @@ import { useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../../data/characterPresets';
import { validateSceneOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import { createSceneMonstersFromIds } from '../../data/hostileNpcs';
import { createSceneHostileNpcsFromIds } from '../../data/hostileNpcs';
import sceneOverridesJson from '../../data/sceneOverrides.json';
import {
getSceneHostileNpcPresetIds,
getSceneHostileNpcs,
getScenePresetsByWorld,
type ScenePresetOverride,
@@ -70,15 +71,13 @@ export function ScenePresetPanel() {
);
const hostileSceneNpcs = getSceneHostileNpcs(effectiveScene);
const hostileScenePresetIds = getSceneHostileNpcPresetIds(effectiveScene);
const previewCharacter = PRESET_CHARACTERS[0] ?? null;
const previewMonsters =
previewMode === 'monster' && hostileSceneNpcs.length > 0
? createSceneMonstersFromIds(
? createSceneHostileNpcsFromIds(
effectiveScene.worldType,
hostileSceneNpcs
.map((npc) => npc.monsterPresetId)
.filter(Boolean)
.slice(0, 1) as string[],
hostileScenePresetIds.slice(0, 1),
0,
)
: [];
@@ -191,7 +190,7 @@ export function ScenePresetPanel() {
encounter={previewEncounter}
currentScenePreset={effectiveScene}
worldType={effectiveScene.worldType}
sceneMonsters={previewMonsters}
sceneHostileNpcs={previewMonsters}
playerX={0}
playerOffsetY={0}
playerFacing="right"
@@ -279,13 +278,15 @@ export function ScenePresetPanel() {
rows={4}
/>
<TextAreaField
label="敌人 ID"
value={listInputValue(effectiveScene.monsterIds)}
onChange={(value) =>
setSceneField('monsterIds', parseListInput(value))
}
label="敌对预设 ID由场景 NPC 自动推导)"
value={listInputValue(hostileScenePresetIds)}
onChange={() => undefined}
rows={4}
disabled
/>
<div className="-mt-1 rounded-xl border border-amber-400/15 bg-amber-500/8 px-3 py-2 text-xs leading-6 text-amber-100/80">
NPC NPC hostile visual/combat preset
</div>
<TextAreaField
label="宝藏线索"
value={listInputValue(effectiveScene.treasureHints)}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
AnimationState,
@@ -115,7 +115,7 @@ function buildGameState(
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -5,7 +5,7 @@ import type {
EquipmentLoadout,
GameState,
InventoryItem,
SceneMonster,
SceneHostileNpc,
TimedBuildBuff,
WorldAttributeSchema,
} from '../types';
@@ -682,7 +682,7 @@ export function getCompanionBuildDamageBreakdown(
}
export function getMonsterBuildDamageBreakdown(
monster: SceneMonster,
monster: SceneHostileNpc,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
@@ -866,7 +866,7 @@ export function resolveCompanionOutgoingDamageResult(
}
export function resolveMonsterOutgoingDamage(
monster: SceneMonster,
monster: SceneHostileNpc,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,
@@ -887,7 +887,7 @@ export function resolveMonsterOutgoingDamage(
}
export function resolveMonsterOutgoingDamageResult(
monster: SceneMonster,
monster: SceneHostileNpc,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,

View File

@@ -3,7 +3,7 @@ import type {
BuildTagCategory,
BuildTagDefinition,
Character,
SceneMonster,
SceneHostileNpc,
TimedBuildBuff,
} from '../types';
import { getBuildTagAttributeAffinity } from './buildTagAttributeAffinity';
@@ -256,7 +256,7 @@ function inferMonsterTagsFromText(source: string) {
return tags;
}
export function getSceneMonsterCombatTags(monster: SceneMonster) {
export function getSceneMonsterCombatTags(monster: SceneHostileNpc) {
if (monster.combatTags?.length) {
return normalizeBuildTags(monster.combatTags, 3);
}

View File

@@ -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) {

View File

@@ -1,12 +1,15 @@
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
import {
normalizeCustomWorldLandmarks,
type CustomWorldLandmarkDraft,
} from './customWorldSceneGraph';
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from '../services/customWorldCreatorIntent';
import {
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
CustomWorldItem,
CustomWorldLandmark,
CustomWorldNpc,
@@ -29,6 +32,10 @@ import {
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
} from './affinityLevels';
import {coerceWorldAttributeSchema} from './attributeValidation';
import {
type CustomWorldLandmarkDraft,
normalizeCustomWorldLandmarks,
} from './customWorldSceneGraph';
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
@@ -592,6 +599,8 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
tone,
playerGoal,
templateWorldType,
majorFactions: [],
coreConflicts: [summary || playerGoal || settingText || name],
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
playableNpcs: Array.isArray(value.playableNpcs)
? value.playableNpcs
@@ -608,6 +617,29 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
landmarks: landmarkDrafts,
storyNpcs,
}),
themePack: null,
storyGraph: null,
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
anchorPack:
value.anchorPack && typeof value.anchorPack === 'object'
? (value.anchorPack as CustomWorldAnchorPack)
: buildCustomWorldAnchorPackFromIntent(
normalizeCustomWorldCreatorIntent(value.creatorIntent),
),
lockState:
value.lockState && isRecord(value.lockState)
? normalizeCustomWorldLockState(value.lockState)
: deriveCustomWorldLockStateFromIntent(
normalizeCustomWorldCreatorIntent(value.creatorIntent),
),
generationMode:
value.generationMode === 'fast' || value.generationMode === 'full'
? value.generationMode
: 'full',
generationStatus:
value.generationStatus === 'key_only' || value.generationStatus === 'complete'
? value.generationStatus
: 'complete',
};
}

View File

@@ -153,16 +153,11 @@ export function validateMonsterOverrides(
export function validateSceneOverrides(
overrideMap: Record<string, ScenePresetOverride>,
scenes: ScenePreset[],
monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
_monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
) {
const errors: string[] = [];
const sceneById = new Map(scenes.map(scene => [scene.id, scene]));
const validSceneIds = new Set(scenes.map(scene => scene.id));
const validMonsterIdsByWorld = {
[WorldType.WUXIA]: new Set((monstersByWorld[WorldType.WUXIA] ?? []).map(monster => monster.id)),
[WorldType.XIANXIA]: new Set((monstersByWorld[WorldType.XIANXIA] ?? []).map(monster => monster.id)),
[WorldType.CUSTOM]: new Set((monstersByWorld[WorldType.CUSTOM] ?? []).map(monster => monster.id)),
};
Object.entries(overrideMap).forEach(([sceneId, override]) => {
const scene = sceneById.get(sceneId);
@@ -181,11 +176,6 @@ export function validateSceneOverrides(
}
});
(override.monsterIds ?? []).forEach(monsterId => {
if (!validMonsterIdsByWorld[scene.worldType].has(monsterId)) {
pushError(errors, `${sceneId} has invalid monsterId: ${monsterId}`);
}
});
});
return errors;

View File

@@ -9,19 +9,19 @@ function lerp(start: number, end: number, progress: number) {
return roundMeters(start + ((end - start) * progress));
}
export function hasEncounterEntity(state: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>) {
return state.sceneMonsters.length > 0 || Boolean(state.currentEncounter);
export function hasEncounterEntity(state: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>) {
return state.sceneHostileNpcs.length > 0 || Boolean(state.currentEncounter);
}
export function buildEncounterEntryState(
finalState: GameState,
entryX: number,
): GameState {
if (finalState.sceneMonsters.length > 0) {
const anchorX = getMonsterGroupAnchorX(finalState.sceneMonsters);
if (finalState.sceneHostileNpcs.length > 0) {
const anchorX = getMonsterGroupAnchorX(finalState.sceneHostileNpcs);
return {
...finalState,
sceneMonsters: finalState.sceneMonsters.map(monster => {
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
const offset = monster.xMeters - anchorX;
const xMeters = roundMeters(entryX + offset);
return {
@@ -42,7 +42,7 @@ export function buildEncounterEntryState(
...finalState.currentEncounter,
xMeters: entryX,
},
sceneMonsters: [],
sceneHostileNpcs: [],
};
}
@@ -51,13 +51,13 @@ export function buildEncounterEntryState(
export function buildEncounterTransitionState(
finalState: GameState,
sourceState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
sourceState: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
): GameState {
if (finalState.sceneMonsters.length > 0) {
const sourceById = new Map(sourceState.sceneMonsters.map(monster => [monster.id, monster]));
if (finalState.sceneHostileNpcs.length > 0) {
const sourceById = new Map(sourceState.sceneHostileNpcs.map(monster => [monster.id, monster]));
return {
...finalState,
sceneMonsters: finalState.sceneMonsters.map(monster => {
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
const sourceMonster = sourceById.get(monster.id);
const xMeters = sourceMonster?.xMeters ?? monster.xMeters;
return {
@@ -78,7 +78,7 @@ export function buildEncounterTransitionState(
...finalState.currentEncounter,
xMeters: sourceState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters,
},
sceneMonsters: [],
sceneHostileNpcs: [],
};
}
@@ -86,15 +86,15 @@ export function buildEncounterTransitionState(
}
export function interpolateEncounterTransitionState(
startState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
startState: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
finalState: GameState,
progress: number,
): GameState {
if (finalState.sceneMonsters.length > 0) {
const startById = new Map(startState.sceneMonsters.map(monster => [monster.id, monster]));
if (finalState.sceneHostileNpcs.length > 0) {
const startById = new Map(startState.sceneHostileNpcs.map(monster => [monster.id, monster]));
return {
...finalState,
sceneMonsters: finalState.sceneMonsters.map(monster => {
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
const startMonster = startById.get(monster.id);
const xMeters = lerp(startMonster?.xMeters ?? monster.xMeters, monster.xMeters, progress);
return {
@@ -117,7 +117,7 @@ export function interpolateEncounterTransitionState(
...finalState.currentEncounter,
xMeters: lerp(startX, endX, progress),
},
sceneMonsters: [],
sceneHostileNpcs: [],
};
}

View File

@@ -28,11 +28,9 @@ function createGameState(): GameState {
name: '断碑古道',
description: '阴气与碎骨混在旧路之间。',
imageSrc: '/ruins.png',
monsterIds: ['monster-03'],
npcs: [],
treasureHints: [],
},
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,

View File

@@ -998,7 +998,7 @@ export function rollHostileNpcLoot(
npcDescription: `${monster.name}倒下后留下的战利痕迹。`,
npcAvatar: '',
context: state.currentScenePreset?.name ?? '战场余烬',
hostileNpcPresetId: monster.id,
monsterPresetId: monster.id,
},
});
const directedReward = buildDirectedRuntimeReward(context, {

View File

@@ -173,7 +173,6 @@ export function buildHostileNpcEncounter(
return {
id: `monster:${worldType}:${preset.id}`,
kind: 'npc',
hostileNpcPresetId: preset.id,
monsterPresetId: preset.id,
npcName: preset.name,
npcDescription: preset.description,
@@ -231,8 +230,6 @@ export function createSceneHostileNpc(
};
}
export const createSceneMonster = createSceneHostileNpc;
export function createSceneHostileNpcsFromIds(
worldType: WorldType,
hostileNpcIds: string[],
@@ -260,21 +257,19 @@ export function createSceneHostileNpcsFromIds(
.filter(Boolean) as SceneHostileNpc[];
}
export const createSceneMonstersFromIds = createSceneHostileNpcsFromIds;
export function createSceneHostileNpcsFromEncounters(
worldType: WorldType,
encounters: Encounter[],
playerX = PLAYER_BASE_X_METERS,
): SceneHostileNpc[] {
const hostileEncounters = encounters.filter(
(encounter): encounter is Encounter & { hostileNpcPresetId: string } => Boolean(encounter.hostileNpcPresetId),
(encounter): encounter is Encounter & { monsterPresetId: string } => Boolean(encounter.monsterPresetId),
);
if (hostileEncounters.length === 0) return [];
const baseMonsters = createSceneHostileNpcsFromIds(
worldType,
hostileEncounters.map(encounter => encounter.hostileNpcPresetId),
hostileEncounters.map(encounter => encounter.monsterPresetId),
playerX,
);
@@ -295,8 +290,6 @@ export function createSceneHostileNpcsFromEncounters(
});
}
export const createSceneNpcMonstersFromEncounters = createSceneHostileNpcsFromEncounters;
export function getBaseSceneHostileNpcs(worldType: WorldType, playerX = PLAYER_BASE_X_METERS): SceneHostileNpc[] {
const fallbackId = getHostileNpcPresetsByWorld(worldType)[0]?.id;
return fallbackId ? createSceneHostileNpcsFromIds(worldType, [fallbackId], playerX) : [];
@@ -312,8 +305,6 @@ export function getClosestHostileNpc(playerX: number, monsters: SceneHostileNpc[
return [...monsters].sort((a, b) => Math.abs(a.xMeters - playerX) - Math.abs(b.xMeters - playerX))[0];
}
export const getClosestMonster = getClosestHostileNpc;
export function getHostileNpcDistance(playerX: number, monster: SceneHostileNpc) {
return Math.abs(monster.xMeters - playerX);
}
@@ -371,8 +362,6 @@ export function settleHostileNpcAnimations(monsters: SceneHostileNpc[]) {
}));
}
export const settleMonsterAnimations = settleHostileNpcAnimations;
export function createFallbackOption(
functionId: string,
text: string,

Some files were not shown because too many files have changed in this diff Show More