Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 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

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

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ coverage/
.DS_Store
*.log
.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,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,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

@@ -53,12 +53,12 @@
### 2.3 工程审查文档也已经指出同样风险
[docs/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md](/E:/Repos/Genarrative/docs/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md) 明确沉淀过一条经验:
[docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md](/E:/Repos/Genarrative/docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md) 明确沉淀过一条经验:
- 浏览器直连会遇到 CORS
- 更稳的方案是开发服务器代理,再由前端请求 `/api/llm/...`
[docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](/E:/Repos/Genarrative/docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 也明确指出:
[docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](/E:/Repos/Genarrative/docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 也明确指出:
- 编辑器、运行时、类后端能力全部耦合在 Vite 配置里
- 未来如果做独立部署、多人协作、远程编辑、权限控制,会非常难迁移

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

@@ -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,12 +1,7 @@
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
resolveRoleCombatStats,
type RoleCombatStats,
} from '../data/attributeCombat';
import {
formatAttributeList,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
@@ -15,7 +10,6 @@ import {
formatBuildContributionPercent,
getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getBuildContributionQualityRatio,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
} from '../data/buildDamage';
@@ -36,7 +30,9 @@ import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
AnimationState,
Character,
CompanionArcState,
CompanionRenderState,
CompanionResolution,
CustomWorldProfile,
EquipmentLoadout,
GameState,
@@ -53,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';
@@ -73,6 +80,8 @@ interface CharacterPanelProps {
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
chatSummaries?: Record<string, string>;
onInspectMember?: (selection: GameCanvasEntitySelection) => void;
companionArcStates?: CompanionArcState[];
companionResolutions?: CompanionResolution[];
}
type PartyMember = {
@@ -95,212 +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 formatAttributePercentValue(value: number) {
return `${formatAttributeMetricValue(value * 100)}%`;
}
function getAttributeBonusPillClassName(bonus: number) {
if (bonus >= 0.05) {
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
}
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 getAttributeEffectText(
slotId: string,
combatStats: RoleCombatStats,
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>,
) {
switch (slotId) {
case 'axis_a':
return `攻击倍率 x${formatAttributeMetricValue(combatStats.attackPowerMultiplier)}`;
case 'axis_b':
return `${resourceLabels.maxHp} +${combatStats.maxHpBonus}`;
case 'axis_c':
return `${resourceLabels.hp}恢复 +${combatStats.storyRecovery}`;
case 'axis_d':
return `攻击速度 ${formatAttributeMetricValue(combatStats.turnSpeed)}`;
case 'axis_e':
return `暴击率 ${formatAttributePercentValue(combatStats.critChance)}`;
case 'axis_f':
return `暴击伤害 x${formatAttributeMetricValue(combatStats.critDamageMultiplier)}`;
default:
return '提升战斗表现';
}
}
function buildLeaderEquipmentRows(
playerCharacter: Character,
playerEquipment: EquipmentLoadout,
@@ -331,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,
@@ -355,6 +148,8 @@ export function CharacterPanel({
npcStates = {},
quests,
onInspectMember,
companionArcStates = [],
companionResolutions = [],
}: CharacterPanelProps) {
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
@@ -458,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)
@@ -503,96 +310,6 @@ export function CharacterPanel({
: null,
[customWorldProfile, selectedMember, worldType],
);
const selectedAttributeRows = useMemo(
() =>
selectedMemberAttributeProfile
? formatAttributeList(
selectedMemberAttributeProfile,
selectedAttributeSchema,
)
: [],
[selectedAttributeSchema, selectedMemberAttributeProfile],
);
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 selectedBoostedAttributeProfile = useMemo(() => {
if (!selectedMemberAttributeProfile) {
return null;
}
return {
...selectedMemberAttributeProfile,
values: {
...(selectedMemberAttributeProfile.values ?? {}),
...Object.fromEntries(
selectedAttributeSchema.slots.map((slot) => {
const baseValue =
selectedMemberAttributeProfile.values?.[slot.slotId] ?? 0;
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
return [
slot.slotId,
Number((baseValue * (1 + totalBonus)).toFixed(4)),
];
}),
),
},
};
}, [
selectedAttributeBonusBySlot,
selectedAttributeSchema,
selectedMemberAttributeProfile,
]);
const selectedBoostedCombatStats = useMemo(
() =>
selectedMember
? resolveRoleCombatStats(selectedBoostedAttributeProfile)
: null,
[selectedBoostedAttributeProfile, selectedMember],
);
const selectedDisplayAttributeRows = useMemo(
() =>
selectedAttributeRows.map(({ slot, value }) => {
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
effectText: selectedBoostedCombatStats
? getAttributeEffectText(
slot.slotId,
selectedBoostedCombatStats,
resourceLabels,
)
: slot.combatUseText,
};
}),
[
resourceLabels,
selectedAttributeBonusBySlot,
selectedAttributeRows,
selectedBoostedCombatStats,
],
);
const selectedContributionAttributes = selectedContributionRow
? getBuildContributionAttributeRows(
selectedContributionRow,
@@ -941,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}
@@ -959,46 +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,
effectText,
}) => (
<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 className="min-w-0 flex-1">
<div className="text-2xl font-bold text-white">
{formatAttributeMetricValue(boostedValue)}
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
>
{' '}
{formatBuildContributionPercent(totalBonus)}
</span>
<div className="text-[10px] text-zinc-500">
{formatAttributeMetricValue(baseValue)}
</div>
</div>
</div>
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
{effectText}
</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>
@@ -1037,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

@@ -3,13 +3,10 @@ import { motion } from 'motion/react';
import type {
CustomWorldGenerationProgress,
} from '../services/ai';
import { AnimationState, type Character } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
interface CustomWorldGenerationViewProps {
settingText: string;
actionPreviewCharacters: Character[];
progress: CustomWorldGenerationProgress | null;
isGenerating: boolean;
error: string | null;
@@ -19,28 +16,6 @@ interface CustomWorldGenerationViewProps {
onInterrupt: () => void;
}
const ACTION_SHOWCASE: Array<{
label: string;
description: string;
state: AnimationState;
}> = [
{
label: '冲阵测试',
description: '检查角色前探、推进与开场压迫感。',
state: AnimationState.RUN,
},
{
label: '交战演示',
description: '预热战斗站姿与交锋节奏。',
state: AnimationState.ATTACK,
},
{
label: '驻场待命',
description: '确认角色在剧情停驻时的氛围姿态。',
state: AnimationState.IDLE,
},
] as const;
function formatDuration(ms: number) {
const safeMs = Math.max(0, Math.round(ms));
const totalSeconds = Math.ceil(safeMs / 1000);
@@ -64,7 +39,6 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
export function CustomWorldGenerationView({
settingText,
actionPreviewCharacters,
progress,
isGenerating,
error,
@@ -101,275 +75,172 @@ export function CustomWorldGenerationView({
</div>
</div>
<div className="grid flex-none gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.1fr)_minmax(22rem,0.9fr)]">
<div className="flex flex-col gap-4 xl:min-h-0">
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
</div>
<div className="mt-1 text-sm text-zinc-400">
</div>
<div 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>
<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' : ''}`}
<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'
}`}
>
</button>
</div>
<div className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText}
</div>
</section>
<section
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
{progress?.phaseLabel ?? '正在启动世界生成'}
</div>
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
</div>
</div>
<div className="shrink-0 sm:text-right">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
{progressValue}%
</div>
</div>
</div>
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
<motion.div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
animate={{ width: `${progressValue}%` }}
transition={{ duration: 0.35, ease: 'easeOut' }}
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{estimatedWaitText}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{elapsedText}
</div>
</div>
</div>
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
{steps.map((step) => (
<div
key={step.id}
className={`rounded-2xl border px-4 py-3 transition-colors ${
step.status === 'completed'
? 'border-emerald-400/16 bg-emerald-500/8'
: step.status === 'active'
? 'border-sky-300/22 bg-sky-500/10'
: 'border-white/8 bg-black/18'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{step.label}
</div>
<div className="text-xs text-zinc-300">
{step.completed}/{step.total}
</div>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{step.label}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{step.detail}
<div className="text-xs text-zinc-300">
{step.completed}/{step.total}
</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 className="mt-1 text-xs leading-6 text-zinc-400">
{step.detail}
</div>
</div>
) : null}
))}
</div>
<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>
</>
) : (
{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={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"
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>
)}
</div>
</section>
</div>
<div className="flex flex-col gap-4 xl:min-h-0">
<section
className="pixel-nine-slice pixel-panel relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<motion.div
className="pointer-events-none absolute -left-8 top-0 h-36 w-36 rounded-full bg-sky-400/18 blur-3xl"
animate={{
opacity: [0.22, 0.48, 0.22],
scale: [0.92, 1.08, 0.92],
}}
transition={{ duration: 6.5, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="pointer-events-none absolute bottom-0 right-0 h-32 w-32 rounded-full bg-amber-200/12 blur-3xl"
animate={{ opacity: [0.18, 0.4, 0.18], scale: [1, 1.12, 1] }}
transition={{ duration: 7.2, repeat: Infinity, ease: 'easeInOut' }}
/>
<div className="relative z-10">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
</div>
<div className="mt-2 text-xl font-black leading-tight text-white sm:text-2xl">
</div>
<div className="mt-3 max-w-[26rem] text-sm leading-6 text-zinc-300">
</div>
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200 col-span-2 sm:col-span-1">
</div>
</div>
</div>
</section>
<section
className="pixel-nine-slice pixel-panel xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-1 text-sm leading-6 text-zinc-300">
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-3">
{ACTION_SHOWCASE.map((showcase, index) => {
const character =
actionPreviewCharacters[
index % Math.max(1, actionPreviewCharacters.length)
];
return (
<div
key={showcase.label}
className="rounded-[1.5rem] border border-white/8 bg-black/22 px-4 py-4"
>
<div className="flex h-28 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(125,211,252,0.18),rgba(10,12,18,0.1)_38%,rgba(10,12,18,0.76)_100%)] sm:h-32">
{character ? (
<CharacterAnimator
state={showcase.state}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : null}
</div>
<div className="mt-3 text-sm font-semibold text-white">
{showcase.label}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{showcase.description}
</div>
{character ? (
<div className="mt-3 rounded-full border border-sky-300/14 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
{character.name}
</div>
) : null}
<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>
);
})}
</div>
</section>
</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

@@ -3,7 +3,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import {
readSavedCustomWorldProfiles,
@@ -15,6 +14,13 @@ import {
generateCustomWorldProfile,
} from '../../services/ai';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
createEmptyCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
import {
type CustomWorldCreatorIntent,
type CustomWorldGenerationMode,
type CustomWorldProfile,
type GameState,
WorldType,
@@ -77,8 +83,6 @@ const WORLD_OPTIONS = [
},
] as const;
const GENERATION_PREVIEW_CHARACTERS = PRESET_CHARACTERS.slice(0, 3);
function generateWorldOnlineCounts(): WorldOnlineCounts {
const roll = (base: number) =>
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
@@ -88,6 +92,73 @@ function generateWorldOnlineCounts(): WorldOnlineCounts {
};
}
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 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({
selectionStage,
setSelectionStage,
@@ -107,7 +178,12 @@ 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] =
@@ -170,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());
@@ -224,6 +313,18 @@ export function PreGameSelectionFlow({
return;
}
if (generatedCustomWorldProfile) {
setCustomWorldCreatorIntent(
generatedCustomWorldProfile.creatorIntent ??
({
...createEmptyCustomWorldCreatorIntent('freeform'),
rawSettingText: generatedCustomWorldProfile.settingText,
} satisfies CustomWorldCreatorIntent),
);
setCustomWorldGenerationMode(
generatedCustomWorldProfile.generationMode ?? 'full',
);
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('world');
@@ -253,14 +354,268 @@ export function PreGameSelectionFlow({
setSelectionStage('world');
};
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);
setCustomWorldProgress(null);
setSelectionStage('custom-world-result');
};
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 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);
} 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 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 settingText = customWorldDraft.trim();
if (!settingText) {
setCustomWorldError('请先输入世界设置。');
const generationText =
buildCustomWorldCreatorIntentGenerationText(
customWorldCreatorIntent,
).trim() || customWorldCreatorIntent.rawSettingText.trim();
const settingText = customWorldSettingPreview.trim() || generationText;
if (!generationText) {
setCustomWorldError(
customWorldCreatorIntent.sourceMode === 'card'
? '请至少填写一个世界锚点。'
: '请先输入世界设置。',
);
return;
}
@@ -275,16 +630,32 @@ export function PreGameSelectionFlow({
setIsGeneratingCustomWorld(true);
try {
const profile = await generateCustomWorldProfile(settingText, {
signal: abortController.signal,
onProgress: setCustomWorldProgress,
});
const profile = await generateCustomWorldProfile(
{
settingText,
creatorIntent: customWorldCreatorIntent,
generationMode: customWorldGenerationMode,
},
{
signal: abortController.signal,
onProgress: setCustomWorldProgress,
},
);
if (abortController.signal.aborted) {
return;
}
setGeneratedCustomWorldProfile(profile);
const persistedProfile = generatedCustomWorldProfile
? {
...profile,
id: generatedCustomWorldProfile.id,
}
: profile;
const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile);
setSavedCustomWorldProfiles(savedProfiles);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setSelectionStage('custom-world-result');
setCustomWorldProgress(null);
setSelectionStage('world');
} catch (error) {
if (abortController.signal.aborted) {
setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。');
@@ -353,7 +724,10 @@ export function PreGameSelectionFlow({
onClick={() => {
handleStartNewGame();
setGeneratedCustomWorldProfile(null);
setCustomWorldDraft('');
setCustomWorldCreatorIntent(
createEmptyCustomWorldCreatorIntent('freeform'),
);
setCustomWorldGenerationMode('fast');
setCustomWorldError(null);
setCustomWorldProgress(null);
setShowCustomWorldModal(false);
@@ -500,56 +874,64 @@ 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
@@ -597,8 +979,7 @@ export function PreGameSelectionFlow({
className="flex h-full min-h-0 flex-col"
>
<CustomWorldGenerationView
settingText={customWorldDraft.trim()}
actionPreviewCharacters={GENERATION_PREVIEW_CHARACTERS}
settingText={customWorldSettingPreview}
progress={customWorldProgress}
isGenerating={isGeneratingCustomWorld}
error={customWorldError}
@@ -635,6 +1016,24 @@ export function PreGameSelectionFlow({
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>
@@ -643,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);

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

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

View File

@@ -1 +0,0 @@
export {createSceneHostileNpcsFromIds, createSceneMonstersFromIds} from './hostileNpcs';

View File

@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest';
import type { Character, Encounter, GameState, InventoryItem } from '../types';
import { AnimationState, WorldType } from '../types';
import {
buildNpcHelpReward,
buildGiftCandidateSummary,
buildInitialNpcState,
buildNpcEncounterStoryMoment,
buildNpcHelpReward,
buildNpcTradeTransactionActionText,
syncNpcTradeInventory,
} from './npcInteractions';
@@ -90,11 +90,9 @@ function createGameState(
name: 'Camp',
description: 'A temporary camp.',
imageSrc: '/camp.png',
monsterIds: [],
npcs: [],
treasureHints: [],
},
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
@@ -154,7 +152,6 @@ describe('npcInteractions', () => {
scene: {
id: 'scene-1',
name: 'Camp',
monsterIds: [],
npcs: [],
treasureHints: [],
},
@@ -179,7 +176,6 @@ describe('npcInteractions', () => {
scene: {
id: 'scene-1',
name: 'Camp',
monsterIds: [],
npcs: [],
treasureHints: [],
},

View File

@@ -13,7 +13,7 @@ import {
NpcPersistentState,
NpcWarmthStage,
QuestLogEntry,
SceneMonster,
SceneHostileNpc,
ScenePresetInfo,
StoryMoment,
StoryOption,
@@ -142,6 +142,182 @@ const RARITY_LABELS: Record<ItemRarity, string> = {
legendary: '传说',
};
function clampStanceMetric(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function normalizeRecentStanceNotes(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).slice(-3)
: [];
}
export function buildInitialStanceProfile(
affinity: number,
options: {
recruited?: boolean;
hostile?: boolean;
roleText?: string | null;
} = {},
) {
const recruitedBonus = options.recruited ? 14 : 0;
const hostilePenalty = options.hostile ? 18 : 0;
const roleText = options.roleText ?? '';
const currentConflictTag =
/||/u.test(roleText)
? '旧案'
: /||/u.test(roleText)
? '守线'
: /||/u.test(roleText)
? '交易'
: null;
return {
trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus - hostilePenalty),
warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus),
ideologicalFit: clampStanceMetric(48 + affinity * 0.25),
fearOrGuard: clampStanceMetric(62 - affinity * 0.55 + hostilePenalty),
loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)),
currentConflictTag,
recentApprovals: [],
recentDisapprovals: [],
};
}
export function applyStoryChoiceToStanceProfile(
stanceProfile: NpcPersistentState['stanceProfile'],
action: 'npc_chat' | 'npc_help' | 'npc_gift' | 'npc_recruit' | 'npc_quest_accept',
options: {
affinityGain?: number;
recruited?: boolean;
} = {},
) {
const base =
stanceProfile ??
buildInitialStanceProfile(0, {
recruited: options.recruited,
});
const affinityGain = options.affinityGain ?? 0;
const approvalNotes = [...base.recentApprovals];
const disapprovalNotes = [...base.recentDisapprovals];
const applyApproval = (note: string) => {
approvalNotes.push(note);
while (approvalNotes.length > 3) approvalNotes.shift();
};
const applyDisapproval = (note: string) => {
disapprovalNotes.push(note);
while (disapprovalNotes.length > 3) disapprovalNotes.shift();
};
const next = {
...base,
trust: base.trust,
warmth: base.warmth,
ideologicalFit: base.ideologicalFit,
fearOrGuard: base.fearOrGuard,
loyalty: base.loyalty,
};
switch (action) {
case 'npc_chat':
next.trust += 6 + affinityGain * 2;
next.warmth += 4 + affinityGain * 2;
next.fearOrGuard -= 5 + affinityGain;
if (affinityGain >= 0) {
applyApproval('你愿意先从眼前局势和试探开始说话。');
} else {
applyDisapproval('这轮交流没能真正对上节奏。');
}
break;
case 'npc_help':
next.trust += 12;
next.warmth += 6;
next.fearOrGuard -= 8;
applyApproval('你在对方需要的时候搭了手。');
break;
case 'npc_gift':
next.trust += 6 + affinityGain;
next.warmth += 10 + affinityGain * 2;
next.fearOrGuard -= 4;
applyApproval('你给出的东西回应了对方眼下的处境。');
break;
case 'npc_recruit':
next.trust += 8;
next.warmth += 6;
next.loyalty += 18;
next.fearOrGuard -= 10;
applyApproval('你正式把对方纳入了同行关系。');
break;
case 'npc_quest_accept':
next.trust += 7;
next.ideologicalFit += 5;
next.loyalty += 4;
applyApproval('你接住了对方主动交出来的事。');
break;
}
return {
...next,
trust: clampStanceMetric(next.trust),
warmth: clampStanceMetric(next.warmth),
ideologicalFit: clampStanceMetric(next.ideologicalFit),
fearOrGuard: clampStanceMetric(next.fearOrGuard),
loyalty: clampStanceMetric(next.loyalty),
recentApprovals: approvalNotes,
recentDisapprovals: disapprovalNotes,
};
}
function normalizeStanceProfile(
stanceProfile: NpcPersistentState['stanceProfile'],
npcState: NpcPersistentState,
) {
if (!stanceProfile) {
return buildInitialStanceProfile(npcState.affinity, {
recruited: npcState.recruited,
});
}
return {
trust: clampStanceMetric(stanceProfile.trust ?? 40),
warmth: clampStanceMetric(stanceProfile.warmth ?? 35),
ideologicalFit: clampStanceMetric(stanceProfile.ideologicalFit ?? 45),
fearOrGuard: clampStanceMetric(stanceProfile.fearOrGuard ?? 55),
loyalty: clampStanceMetric(stanceProfile.loyalty ?? 20),
currentConflictTag: stanceProfile.currentConflictTag ?? null,
recentApprovals: normalizeRecentStanceNotes(stanceProfile.recentApprovals),
recentDisapprovals: normalizeRecentStanceNotes(stanceProfile.recentDisapprovals),
};
}
export function describeNpcNarrativePressure(
encounter: Encounter,
npcState: NpcPersistentState,
) {
const narrativeProfile = encounter.narrativeProfile;
const guardedText =
npcState.stanceProfile?.fearOrGuard && npcState.stanceProfile.fearOrGuard > 68
? '对方明显绷着一口气,不愿先把主动权让出去。'
: '对方把分寸拿得很紧,像是随时准备把话题拨回表层。';
if (!narrativeProfile) {
return guardedText;
}
return [
narrativeProfile.immediatePressure || guardedText,
narrativeProfile.contradiction
? `话里还带着一点错位:${narrativeProfile.contradiction}`
: null,
narrativeProfile.reactionHooks[0]
? `只要提到${narrativeProfile.reactionHooks[0]},对方就可能立刻变调。`
: null,
]
.filter(Boolean)
.join(' ');
}
function makeItemId(prefix: string, category: string, name: string) {
return `${prefix}:${encodeURIComponent(`${category}-${name}`)}`;
}
@@ -726,6 +902,7 @@ export function normalizeNpcPersistentState(
seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds)
? npcState.seenBackstoryChapterIds.filter((fact): fact is string => typeof fact === 'string')
: [],
stanceProfile: normalizeStanceProfile(npcState.stanceProfile, npcState),
};
}
@@ -1374,6 +1551,11 @@ export function buildInitialNpcState(
knownAttributeRumors: attributeRumors,
firstMeaningfulContactResolved: false,
seenBackstoryChapterIds: [],
stanceProfile: buildInitialStanceProfile(initialAffinity, {
recruited: false,
hostile: Boolean(encounter.monsterPresetId) || initialAffinity < 0,
roleText: encounter.context,
}),
});
}
@@ -1644,7 +1826,7 @@ export function createNpcBattleMonster(
hostile: true,
xMeters: 3.2,
},
} satisfies SceneMonster;
} satisfies SceneHostileNpc;
}
const recruitCombatStats = recruitCharacter
@@ -1695,7 +1877,7 @@ export function createNpcBattleMonster(
...encounter,
xMeters: 3.2,
},
} satisfies SceneMonster;
} satisfies SceneHostileNpc;
}
return {
@@ -1716,7 +1898,7 @@ export function createNpcBattleMonster(
...encounter,
xMeters: 3.2,
},
} satisfies SceneMonster;
} satisfies SceneHostileNpc;
}
export function getNpcLootItems(
@@ -1753,7 +1935,7 @@ export function buildNpcEncounterStoryMoment({
activeQuests: QuestLogEntry[];
scene: Pick<
ScenePresetInfo,
'id' | 'name' | 'monsterIds' | 'npcs' | 'treasureHints'
'id' | 'name' | 'npcs' | 'treasureHints'
> | null;
worldType: WorldType | null;
partySize: number;
@@ -1944,8 +2126,8 @@ export function buildNpcEncounterStoryMoment({
overrideText ??
(
isNpcFirstMeaningfulContact(encounter, npcState)
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}${getNpcActionText(encounter)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcNarrativePressure(encounter, npcState)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}${getNpcActionText(encounter)} ${describeNpcNarrativePressure(encounter, npcState)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
),
options: sortStoryOptionsByPriority(
options,

View File

@@ -14,10 +14,20 @@ const TEST_SCENE = {
id: 'forest_path',
name: 'Forest Path',
description: 'A narrow trail with fresh claw marks.',
monsterIds: ['wolf_alpha'],
npcs: [],
npcs: [
{
id: 'hostile-wolf-alpha',
name: '狼王',
description: 'A hostile wolf alpha.',
avatar: '狼',
role: '敌对角色',
monsterPresetId: 'wolf_alpha',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: [],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'monsterIds' | 'npcs' | 'treasureHints'>;
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
function requireStep(quest: QuestLogEntry, stepId: string): QuestStep {
const step = quest.steps?.find(item => item.id === stepId);

View File

@@ -1,3 +1,4 @@
import type {QuestGenerationContext} from '../services/aiTypes';
import type {
QuestCompilationRequest,
QuestContract,
@@ -7,7 +8,8 @@ import type {
QuestProgressSignal,
QuestSceneSnapshot,
} from '../services/questTypes';
import type {QuestGenerationContext} from '../services/aiTypes';
import { buildNarrativeDocument } from '../services/storyEngine/documentCarrierCompiler';
import { buildThreadContractsFromProfile } from '../services/storyEngine/threadContract';
import {
type CustomWorldProfile,
type QuestLogEntry,
@@ -171,13 +173,24 @@ function buildQuestReward(params: {
fixedKinds: [...runtimeConfig.fixedKinds],
fixedPermanence: [...runtimeConfig.fixedPermanence],
});
const threadContract = context?.customWorldProfile?.threadContracts?.find((contract) =>
(context.activeThreadIds ?? []).includes(contract.threadId),
) ?? null;
const rewardItems = flattenDirectedRuntimeRewardItems(directedReward);
const documentItem =
rewardTheme === 'intel' && threadContract
? buildNarrativeDocument({
contract: threadContract,
titleSeed: `${issuerNpcName}留下的调查简札`,
})
: null;
const reward: QuestReward = {
affinityBonus: narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12,
currency: rewardTheme === 'intel'
? (worldType === 'XIANXIA' ? 40 : 58)
: (worldType === 'XIANXIA' ? 54 : 72),
items: flattenDirectedRuntimeRewardItems(directedReward),
items: documentItem ? [...rewardItems, documentItem] : rewardItems,
storyHint: directedReward.storyHint,
};
@@ -199,6 +212,34 @@ function buildRewardText(reward: QuestReward, worldType: WorldType | null) {
return `完成后可获得好感 +${reward.affinityBonus}${formatCurrency(reward.currency, worldType)}${itemText}${intelText}`;
}
function resolveQuestThreadContract(params: {
context?: QuestGenerationContext;
issuerNpcId: string;
scene: QuestSceneSnapshot | null;
}) {
const profile = params.context?.customWorldProfile;
if (!profile?.storyGraph) {
return null;
}
const contracts =
profile.threadContracts && profile.threadContracts.length > 0
? profile.threadContracts
: buildThreadContractsFromProfile(profile);
const activeThreadIds = params.context?.activeThreadIds ?? [];
const contract = contracts.find((candidate) =>
activeThreadIds.includes(candidate.threadId)
|| candidate.issuerActorId === params.issuerNpcId
|| candidate.steps.some((step) =>
step.completionSignalIds.some((signalId) =>
params.scene?.id ? signalId.includes(params.scene.id) : false,
),
),
) ?? contracts[0] ?? null;
return contract;
}
function buildQuestId(issuerNpcId: string, kind: QuestObjectiveKind, targetKey: string) {
return `quest:${issuerNpcId}:${kind}:${targetKey}`;
}
@@ -226,7 +267,7 @@ function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: Worl
const hostileNpc = getSceneHostileNpcs(scene)[0] ?? null;
if (hostileNpc) {
const targetHostileNpcId = hostileNpc.hostileNpcPresetId ?? hostileNpc.monsterPresetId ?? hostileNpc.id;
const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id;
const targetHostileNpcName = worldType
? getHostileNpcPresetById(worldType, targetHostileNpcId)?.name ?? hostileNpc.name ?? targetHostileNpcId
: hostileNpc.name ?? targetHostileNpcId;
@@ -240,19 +281,6 @@ function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: Worl
};
}
const fallbackHostileNpcId = scene.hostileNpcIds?.[0] ?? scene.monsterIds?.[0];
if (fallbackHostileNpcId) {
return {
kind: 'defeat_hostile_npc',
targetHostileNpcId: fallbackHostileNpcId,
targetHostileNpcName: worldType
? getHostileNpcPresetById(worldType, fallbackHostileNpcId)?.name ?? fallbackHostileNpcId
: fallbackHostileNpcId,
targetSceneId: scene.id,
suggestedThreatType: 'hostile_npc',
};
}
if ((scene.treasureHints?.length ?? 0) > 0) {
return {
kind: 'inspect_treasure',
@@ -568,6 +596,12 @@ export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
status,
steps,
activeStepId,
actId: quest.actId ?? null,
threadId: quest.threadId ?? null,
contractId: quest.contractId ?? null,
discoveredFactIds: quest.discoveredFactIds ?? [],
relatedCarrierIds: quest.relatedCarrierIds ?? [],
consequenceIds: quest.consequenceIds ?? [],
};
return {
@@ -825,12 +859,20 @@ export function compileQuestIntentToQuest(
},
failPolicy: 'never',
};
const threadContract = resolveQuestThreadContract({
context: params.context,
issuerNpcId: params.issuerNpcId,
scene: params.scene,
});
return normalizeQuestLogEntry({
id: contract.id,
issuerNpcId: contract.issuerNpcId,
issuerNpcName: contract.issuerNpcName,
sceneId: contract.sceneId,
actId: params.context?.actState?.id ?? null,
threadId: threadContract?.threadId ?? null,
contractId: threadContract?.id ?? null,
title: contract.title,
description: contract.description,
summary: contract.summary,
@@ -843,8 +885,11 @@ export function compileQuestIntentToQuest(
narrativeBinding: contract.narrativeBinding,
steps: contract.steps,
activeStepId: contract.steps[0]?.id ?? null,
visibleStage: 0,
visibleStage: threadContract?.visibleStage ?? 0,
hiddenFlags: [],
discoveredFactIds: [],
relatedCarrierIds: [],
consequenceIds: [],
});
}

View File

@@ -1,4 +1,10 @@
import type {QuestGenerationContext} from '../services/aiTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from '../services/storyEngine/actorNarrativeProfile';
import { buildThemePackFromWorldProfile } from '../services/storyEngine/themePack';
import { buildFallbackWorldStoryGraph } from '../services/storyEngine/worldStoryGraph';
import type {
EquipmentLoadout,
GameState,
@@ -78,12 +84,81 @@ function derivePlayerBuildGaps(playerBuildTags: string[]) {
.slice(0, 3);
}
function resolveRelatedNpcNarrativeProfile(params: {
customWorldProfile: GameState['customWorldProfile'];
encounter: GameState['currentEncounter'];
}) {
const { customWorldProfile, encounter } = params;
if (!customWorldProfile || !encounter || encounter.kind !== 'npc') {
return null;
}
const role =
customWorldProfile.storyNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
)
?? customWorldProfile.playableNpcs.find((npc) =>
npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return encounter.narrativeProfile ?? null;
}
const themePack =
customWorldProfile.themePack ?? buildThemePackFromWorldProfile(customWorldProfile);
const storyGraph =
customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
}
function resolveActiveThreadIds(params: {
customWorldProfile: GameState['customWorldProfile'];
relatedNpcNarrativeProfile: RuntimeItemGenerationContext['relatedNpcNarrativeProfile'];
storyEngineMemory?: GameState['storyEngineMemory'] | QuestGenerationContext['activeThreadIds'];
}) {
const threadSource = params.storyEngineMemory;
if (Array.isArray(threadSource) && threadSource.length > 0) {
return threadSource.slice(0, 4);
}
if (
threadSource &&
!Array.isArray(threadSource) &&
threadSource.activeThreadIds?.length
) {
return threadSource.activeThreadIds.slice(0, 4);
}
if (params.relatedNpcNarrativeProfile?.relatedThreadIds.length) {
return params.relatedNpcNarrativeProfile.relatedThreadIds.slice(0, 4);
}
if (!params.customWorldProfile) {
return [];
}
const themePack =
params.customWorldProfile.themePack
?? buildThemePackFromWorldProfile(params.customWorldProfile);
const storyGraph =
params.customWorldProfile.storyGraph
?? buildFallbackWorldStoryGraph(params.customWorldProfile, themePack);
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
}
function buildBaseRuntimeContext(params: {
worldType: GameState['worldType'];
customWorldProfile: GameState['customWorldProfile'];
scene: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
encounter: GameState['currentEncounter'];
relatedNpcState: GameState['npcStates'][string] | null;
storyEngineMemory?: GameState['storyEngineMemory'] | QuestGenerationContext['activeThreadIds'];
storyHistory: GameState['storyHistory'];
playerCharacterId: string;
playerBuildTags: string[];
@@ -96,6 +171,7 @@ function buildBaseRuntimeContext(params: {
scene,
encounter,
relatedNpcState,
storyEngineMemory,
storyHistory,
playerCharacterId,
playerBuildTags,
@@ -103,6 +179,15 @@ function buildBaseRuntimeContext(params: {
generationChannel,
} = params;
const recentStoryLines = buildRecentStoryLines(storyHistory);
const relatedNpcNarrativeProfile = resolveRelatedNpcNarrativeProfile({
customWorldProfile,
encounter,
});
const activeThreadIds = resolveActiveThreadIds({
customWorldProfile,
relatedNpcNarrativeProfile,
storyEngineMemory,
});
return {
worldType,
@@ -117,9 +202,11 @@ function buildBaseRuntimeContext(params: {
encounterNpcName: encounter?.npcName ?? null,
encounterContextText: encounter?.context ?? null,
relatedNpcState,
relatedNpcNarrativeProfile,
relatedScene: scene,
recentStorySummary: buildRecentStorySummary(recentStoryLines),
recentActions: recentStoryLines,
activeThreadIds,
playerCharacterId,
playerBuildTags,
playerBuildGaps: derivePlayerBuildGaps(playerBuildTags),
@@ -146,6 +233,7 @@ export function buildLooseRuntimeItemGenerationContext(params: {
scene: params.scene ?? null,
encounter: params.encounter ?? null,
relatedNpcState: params.relatedNpcState ?? null,
storyEngineMemory: params.customWorldProfile?.storyGraph?.visibleThreads.map((thread) => thread.id) ?? [],
storyHistory: params.storyHistory ?? [],
playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player',
playerBuildTags: params.playerBuildTags ?? [],
@@ -180,6 +268,7 @@ export function buildRuntimeItemGenerationContext(params: {
scene,
encounter,
relatedNpcState,
storyEngineMemory: state.storyEngineMemory,
storyHistory: state.storyHistory,
playerCharacterId: state.playerCharacter?.id ?? 'unknown-player',
playerBuildTags,
@@ -243,6 +332,7 @@ export function buildQuestRuntimeItemGenerationContext(params: {
recruited: false,
revealedFacts: [],
},
storyEngineMemory: context.activeThreadIds,
storyHistory: context.recentStoryMoments ?? [],
playerCharacterId: context.playerCharacter?.id ?? 'quest-player',
playerBuildTags,

View File

@@ -41,6 +41,9 @@ describe('runtime item director', () => {
expect(reward.primaryItem?.runtimeMetadata?.generationChannel).toBe('treasure');
expect(reward.primaryItem?.runtimeMetadata?.relationAnchor?.type).toBe('npc');
expect(reward.primaryItem?.name).not.toBe('未命名秘物');
expect(reward.primaryItem?.runtimeMetadata?.storyFingerprint?.visibleClue).toBeTruthy();
expect(reward.primaryItem?.runtimeMetadata?.storyFingerprint?.unresolvedQuestion).toBeTruthy();
expect(reward.primaryItem?.description).toContain('适合当前局势里的临场构筑调整');
});
it('keeps identity-sensitive runtime items separate when adding inventory', () => {

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