@@ -1,7 +1,7 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
## 项目约束
|
## 项目约束
|
||||||
|
- 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中
|
||||||
- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。
|
- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。
|
||||||
- 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。
|
- 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。
|
||||||
- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。
|
- 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
- 禁止将功能说明描述类的文本默认写入UI界面中。
|
- 禁止将功能说明描述类的文本默认写入UI界面中。
|
||||||
- prd文档中每个模块的描述要落地设计到可以精准编码到位,不能出现需求落地漂移。
|
- prd文档中每个模块的描述要落地设计到可以精准编码到位,不能出现需求落地漂移。
|
||||||
|
|
||||||
|
|
||||||
## 文档图谱
|
## 文档图谱
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
# AI 原生 NPC 单轮聊天会话迭代 PRD
|
||||||
|
|
||||||
|
更新时间:`2026-04-18`
|
||||||
|
|
||||||
|
## 0. 文档目的
|
||||||
|
|
||||||
|
本 PRD 只定义 `npc_chat` 在冒险主面板中的这一轮迭代落地方式。
|
||||||
|
|
||||||
|
目标不是新建一套聊天系统,而是在保持现有游戏 UI 外壳、剧情面板结构、消息流位置不变的前提下,把 `npc_chat` 从“触发一次后回到普通剧情流”升级为“进入可持续续写的单轮聊天会话”。
|
||||||
|
|
||||||
|
本次文档必须直接指导编码,避免需求落地漂移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 一句话定义
|
||||||
|
|
||||||
|
玩家点击 `npc_chat` 后,进入 NPC 聊天模式;每次只完成一轮“玩家输入 -> NPC 回复 -> 关系变化消息 -> 下一轮 3 个建议选项 + 1 个自定义输入”,直到玩家主动退出聊天。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 背景与问题
|
||||||
|
|
||||||
|
当前 `npc_chat` 更接近“触发一段剧情性对话”,而不是“持续聊天”:
|
||||||
|
|
||||||
|
1. 玩家点击聊天后,常常是一次性生成较长结果,再回到普通冒险选项。
|
||||||
|
2. 缺少稳定的续聊状态,无法把多轮聊天作为一个连续会话维持。
|
||||||
|
3. 好感度变化更多停留在逻辑层,玩家在消息流中感知不明显。
|
||||||
|
4. 没有单独的退出聊天控制,用户只能被动等系统切回普通状态。
|
||||||
|
5. 选项形态仍偏剧情动作,不够像聊天接话。
|
||||||
|
|
||||||
|
这会导致 `npc_chat` 的体验不像“和角色对话”,而像“触发一个剧情功能”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 本次目标
|
||||||
|
|
||||||
|
本次迭代必须同时满足以下目标:
|
||||||
|
|
||||||
|
1. `npc_chat` 触发后进入聊天交互态。
|
||||||
|
2. 聊天态沿用当前主冒险面板,不新增页面、不弹新系统。
|
||||||
|
3. 每次用户只推进一轮对话。
|
||||||
|
4. 每轮结束后稳定出现 `3` 个建议续聊选项。
|
||||||
|
5. 每轮结束后稳定出现 `1` 个自定义输入框。
|
||||||
|
6. 玩家选择建议项或提交自定义输入后,继续在同一消息队列中续写。
|
||||||
|
7. 好感度增减必须作为“系统消息”插入到对话消息队列中。
|
||||||
|
8. NPC 回复必须支持流式传输,并在前端边接收边解析显示。
|
||||||
|
9. 背包按钮所在行的最右侧必须新增“退出聊天”按钮。
|
||||||
|
10. 退出聊天后恢复普通冒险态,不保留当前聊天输入框与聊天建议项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 明确不做
|
||||||
|
|
||||||
|
本次不做:
|
||||||
|
|
||||||
|
1. 不新建独立聊天页面。
|
||||||
|
2. 不重做现有主面板 UI 结构。
|
||||||
|
3. 不引入多 NPC 并行聊天。
|
||||||
|
4. 不做聊天记录存档面板。
|
||||||
|
5. 不做复杂关系公式配置后台。
|
||||||
|
6. 不做语音输入、表情、附件等扩展输入能力。
|
||||||
|
7. 不把前端改成承担关系计算或剧情判定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 核心体验
|
||||||
|
|
||||||
|
## 5.1 进入聊天
|
||||||
|
|
||||||
|
当玩家点击 `npc_chat` 选项时:
|
||||||
|
|
||||||
|
1. 保持当前冒险面板布局不变。
|
||||||
|
2. 中部内容区切换为聊天消息流展示模式。
|
||||||
|
3. 底部选项区切换为聊天建议区。
|
||||||
|
4. 底部附加一个自定义输入框与发送按钮。
|
||||||
|
5. 顶层不跳转、不弹窗、不覆盖成新页面。
|
||||||
|
|
||||||
|
## 5.2 单轮推进
|
||||||
|
|
||||||
|
单轮定义固定为:
|
||||||
|
|
||||||
|
1. 玩家通过“建议选项”或“自定义输入”提交一句话。
|
||||||
|
2. 玩家消息立即进入消息队列。
|
||||||
|
3. NPC 回复开始流式显示。
|
||||||
|
4. 流式结束后,如果有关系变化,插入一条系统消息。
|
||||||
|
5. 系统刷新下一轮 `3` 个建议选项。
|
||||||
|
6. 系统保留自定义输入入口,等待下一轮。
|
||||||
|
|
||||||
|
## 5.3 退出聊天
|
||||||
|
|
||||||
|
玩家点击“退出聊天”后:
|
||||||
|
|
||||||
|
1. 当前聊天态结束。
|
||||||
|
2. 面板底部恢复普通冒险选项区域。
|
||||||
|
3. 本轮聊天输入框、聊天建议项消失。
|
||||||
|
4. 当前故事内容回到普通故事展示逻辑。
|
||||||
|
|
||||||
|
退出聊天不触发额外确认弹窗。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI 设计要求
|
||||||
|
|
||||||
|
## 6.1 保持不变的部分
|
||||||
|
|
||||||
|
以下 UI 外壳保持当前实现:
|
||||||
|
|
||||||
|
1. 主冒险面板整体框架。
|
||||||
|
2. 对话消息区所在位置。
|
||||||
|
3. 底部操作区的整体层级。
|
||||||
|
4. 队伍按钮、背包按钮的视觉风格。
|
||||||
|
|
||||||
|
## 6.2 必须变化的部分
|
||||||
|
|
||||||
|
### 消息区
|
||||||
|
|
||||||
|
1. 聊天态下,消息区按时间顺序展示:
|
||||||
|
- 玩家消息
|
||||||
|
- NPC 消息
|
||||||
|
- 系统关系变化消息
|
||||||
|
2. 系统关系变化消息必须和普通消息共用同一消息流容器。
|
||||||
|
3. 系统关系变化消息视觉上应与玩家/NPC 气泡有明确区分。
|
||||||
|
|
||||||
|
### 底部按钮区
|
||||||
|
|
||||||
|
1. 左侧仍保留队伍、背包按钮。
|
||||||
|
2. 右侧在聊天态下展示“退出聊天”按钮。
|
||||||
|
3. “退出聊天”按钮必须位于该行最右侧。
|
||||||
|
4. 非聊天态下,该位置仍保持原有刷新选项按钮逻辑。
|
||||||
|
|
||||||
|
### 选项区
|
||||||
|
|
||||||
|
1. 聊天态下只展示 `3` 个续聊选项。
|
||||||
|
2. 聊天态下不展示普通剧情选项附带的说明文案、目标提示、影响摘要。
|
||||||
|
3. 聊天态下选项文案本身就是“下一句怎么接”。
|
||||||
|
|
||||||
|
### 输入区
|
||||||
|
|
||||||
|
1. 聊天态下在 3 个建议项下方展示输入框。
|
||||||
|
2. 输入框右侧固定展示发送按钮。
|
||||||
|
3. 输入框在请求进行中禁用。
|
||||||
|
4. 点击发送或回车提交时,进入下一轮。
|
||||||
|
|
||||||
|
## 6.3 移动端要求
|
||||||
|
|
||||||
|
1. 移动端优先保证输入框与发送按钮可点击。
|
||||||
|
2. 三个建议选项必须保持纵向堆叠,不做横向卡片排布。
|
||||||
|
3. “退出聊天”按钮在小屏下仍需完整可见,不允许被背包按钮挤出。
|
||||||
|
4. 输入框与发送按钮在窄屏下优先保证输入框宽度,其次压缩按钮内边距。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 前后端职责边界
|
||||||
|
|
||||||
|
遵循“前端只负责表现,逻辑和数据放后端”原则。
|
||||||
|
|
||||||
|
## 7.1 前端职责
|
||||||
|
|
||||||
|
前端只负责:
|
||||||
|
|
||||||
|
1. 进入/退出聊天态的 UI 切换。
|
||||||
|
2. 渲染当前消息队列。
|
||||||
|
3. 发送玩家本轮输入。
|
||||||
|
4. 接收流式事件并实时更新 NPC 当前回复文本。
|
||||||
|
5. 渲染系统关系变化消息。
|
||||||
|
6. 渲染下一轮 `3` 个建议项与自定义输入框。
|
||||||
|
|
||||||
|
前端不负责:
|
||||||
|
|
||||||
|
1. 生成 NPC 回复文本。
|
||||||
|
2. 生成建议续聊选项。
|
||||||
|
3. 计算关系增减。
|
||||||
|
4. 解析剧情逻辑分支。
|
||||||
|
|
||||||
|
## 7.2 后端职责
|
||||||
|
|
||||||
|
后端负责:
|
||||||
|
|
||||||
|
1. 接收 NPC 单轮聊天请求。
|
||||||
|
2. 结合当前世界、角色、NPC 状态、历史消息构建 prompt。
|
||||||
|
3. 流式生成 NPC 回复。
|
||||||
|
4. 解析回复内容。
|
||||||
|
5. 生成下一轮 3 个建议续聊选项。
|
||||||
|
6. 计算并返回本轮关系增减。
|
||||||
|
7. 通过 SSE 向前端发送流式事件与最终结果。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 数据结构要求
|
||||||
|
|
||||||
|
## 8.1 前端故事态扩展
|
||||||
|
|
||||||
|
`StoryMoment` 需要具备聊天态附加状态:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type StoryNpcChatState = {
|
||||||
|
npcId: string;
|
||||||
|
npcName: string;
|
||||||
|
turnCount: number;
|
||||||
|
customInputPlaceholder?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. 仅当当前故事处于 NPC 聊天模式时写入。
|
||||||
|
2. `turnCount` 表示已完成的轮数。
|
||||||
|
3. `npcId` 用于保证只续写当前聊天对象。
|
||||||
|
|
||||||
|
## 8.2 聊天消息结构
|
||||||
|
|
||||||
|
消息队列需要支持系统消息:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type StoryDialogueTurn = {
|
||||||
|
speaker: 'player' | 'npc' | 'companion' | 'system';
|
||||||
|
speakerName?: string;
|
||||||
|
text: string;
|
||||||
|
affinityDelta?: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. `system` 只用于关系变化、系统反馈类消息。
|
||||||
|
2. `affinityDelta` 仅在关系变化消息中写入。
|
||||||
|
|
||||||
|
## 8.3 单轮接口契约
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type NpcChatTurnRequest = {
|
||||||
|
worldType: WorldType;
|
||||||
|
player: Character;
|
||||||
|
encounter: Encounter;
|
||||||
|
history: StoryMoment[];
|
||||||
|
dialogue: StoryDialogueTurn[];
|
||||||
|
playerMessage: string;
|
||||||
|
npcState: {
|
||||||
|
affinity: number;
|
||||||
|
chattedCount: number;
|
||||||
|
recruited: boolean;
|
||||||
|
};
|
||||||
|
context: StoryGenerationContext;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
返回完成事件载荷:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type NpcChatTurnResult = {
|
||||||
|
npcReply: string;
|
||||||
|
affinityDelta: number;
|
||||||
|
affinityText: string;
|
||||||
|
suggestions: string[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 流式协议
|
||||||
|
|
||||||
|
本次统一使用 SSE。
|
||||||
|
|
||||||
|
## 9.1 事件类型
|
||||||
|
|
||||||
|
后端至少输出以下事件:
|
||||||
|
|
||||||
|
1. `reply_delta`
|
||||||
|
2. `complete`
|
||||||
|
3. `error`
|
||||||
|
|
||||||
|
## 9.2 事件含义
|
||||||
|
|
||||||
|
### `reply_delta`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
1. 逐步推送 NPC 当前回复文本增量。
|
||||||
|
2. 前端收到后立即更新消息流中“当前 NPC 气泡”的文本。
|
||||||
|
|
||||||
|
### `complete`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
1. 标记本轮流式输出结束。
|
||||||
|
2. 一次性返回最终结果对象:
|
||||||
|
- `npcReply`
|
||||||
|
- `affinityDelta`
|
||||||
|
- `affinityText`
|
||||||
|
- `suggestions`
|
||||||
|
|
||||||
|
### `error`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
1. 标记本轮失败。
|
||||||
|
2. 前端停止流式态并回退到可继续输入的状态。
|
||||||
|
|
||||||
|
## 9.3 前端解析规则
|
||||||
|
|
||||||
|
1. 当收到第一个 `reply_delta` 时,若消息流末尾还没有 NPC 临时消息,前端先插入一条空的 NPC 消息。
|
||||||
|
2. 每次收到 `reply_delta`,替换该临时 NPC 消息文本。
|
||||||
|
3. 收到 `complete` 后,把临时 NPC 消息固化为最终文本。
|
||||||
|
4. 如果 `affinityDelta !== 0`,在 NPC 消息后追加一条系统关系消息。
|
||||||
|
5. 之后再刷新下一轮建议选项。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 关系变化显示规则
|
||||||
|
|
||||||
|
## 10.1 显示位置
|
||||||
|
|
||||||
|
关系变化必须作为一条独立消息插入消息队列,位置在本轮 NPC 回复之后、下一轮建议项之前。
|
||||||
|
|
||||||
|
## 10.2 文案规则
|
||||||
|
|
||||||
|
1. 有增长时显示正向文案,例如:`关系升温 好感 +3`
|
||||||
|
2. 有下降时显示负向文案,例如:`关系转冷 好感 -2`
|
||||||
|
3. 无变化时不强制插入系统消息。
|
||||||
|
|
||||||
|
## 10.3 视觉规则
|
||||||
|
|
||||||
|
1. 关系变化消息居中显示。
|
||||||
|
2. 关系变化消息使用不同于普通对话气泡的视觉样式。
|
||||||
|
3. 正向变化可使用更暖色的边框/底色。
|
||||||
|
4. 负向变化与中性反馈使用系统消息样式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 交互流程
|
||||||
|
|
||||||
|
## 11.1 进入流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
玩家点击 npc_chat
|
||||||
|
-> 系统进入聊天态
|
||||||
|
-> 当前故事切换为聊天消息模式
|
||||||
|
-> 展示本轮可选接话
|
||||||
|
-> 展示自定义输入框
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11.2 单轮流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
玩家点击建议项或提交输入
|
||||||
|
-> 玩家消息立即入队
|
||||||
|
-> 前端发起 /runtime/chat/npc/turn/stream
|
||||||
|
-> 后端流式返回 NPC 回复
|
||||||
|
-> 前端边接收边渲染 NPC 当前回复
|
||||||
|
-> complete 返回最终结果
|
||||||
|
-> 前端插入关系变化系统消息
|
||||||
|
-> 前端刷新下一轮 3 个建议项
|
||||||
|
-> 等待下一轮输入
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11.3 退出流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
玩家点击退出聊天
|
||||||
|
-> 清理当前聊天态标记
|
||||||
|
-> 当前故事回到普通冒险展示
|
||||||
|
-> 恢复普通选项区
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 状态更新规则
|
||||||
|
|
||||||
|
每完成一轮聊天,系统必须更新:
|
||||||
|
|
||||||
|
1. `npcState.affinity`
|
||||||
|
2. `npcState.chattedCount`
|
||||||
|
3. `npcState.relationState`
|
||||||
|
4. `npcState.stanceProfile`
|
||||||
|
5. `storyHistory`
|
||||||
|
6. `currentStory.dialogue`
|
||||||
|
7. `currentStory.npcChatState.turnCount`
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. 当前轮玩家输入和 NPC 回复都要进入故事历史。
|
||||||
|
2. 关系变化消息属于展示型系统消息,可进入当前对话队列,但不要求作为独立剧情行动历史参与模型推理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 异常与兜底
|
||||||
|
|
||||||
|
## 13.1 流式失败
|
||||||
|
|
||||||
|
如果流式失败:
|
||||||
|
|
||||||
|
1. 当前轮玩家消息保留。
|
||||||
|
2. 不保留半截乱码式 NPC 文本。
|
||||||
|
3. 前端恢复可继续输入状态。
|
||||||
|
4. 使用后端或前端兜底建议项,保证仍有 3 个建议续聊选项可选。
|
||||||
|
|
||||||
|
## 13.2 建议项不足
|
||||||
|
|
||||||
|
如果后端返回建议项不足 `3` 条:
|
||||||
|
|
||||||
|
1. 由后端优先补齐兜底话术。
|
||||||
|
2. 前端只展示最多 `3` 条。
|
||||||
|
|
||||||
|
## 13.3 空输入
|
||||||
|
|
||||||
|
空输入、纯空格输入不发请求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 代码落点
|
||||||
|
|
||||||
|
本次迭代应优先落在现有链路:
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
1. `src/hooks/story/npcEncounterActions.ts`
|
||||||
|
2. `src/hooks/story/useStoryInteractionCoordinator.ts`
|
||||||
|
3. `src/hooks/story/useStoryFlowCoordinator.ts`
|
||||||
|
4. `src/hooks/useStoryGeneration.ts`
|
||||||
|
5. `src/services/aiService.ts`
|
||||||
|
6. `src/components/AdventurePanel.tsx`
|
||||||
|
7. `src/components/GameShell.tsx`
|
||||||
|
|
||||||
|
### 共享契约
|
||||||
|
|
||||||
|
1. `packages/shared/src/contracts/story.ts`
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
1. `server-node/src/routes/runtimeRoutes.ts`
|
||||||
|
2. `server-node/src/services/chatService.ts`
|
||||||
|
3. `server-node/src/modules/ai/chatPromptBuilders.ts`
|
||||||
|
4. `server-node/src/modules/ai/chatOrchestrator.ts`
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. 复用现有故事流、运行时接口和主面板。
|
||||||
|
2. 不另起新页面、新 store、新聊天系统。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 验收标准
|
||||||
|
|
||||||
|
以下全部满足才算通过:
|
||||||
|
|
||||||
|
1. 点击 `npc_chat` 后,面板进入聊天态而不是跳去新页面。
|
||||||
|
2. 当前消息区能连续展示玩家消息、NPC 消息、系统关系消息。
|
||||||
|
3. 每轮结束后稳定出现 `3` 个建议项。
|
||||||
|
4. 每轮结束后稳定出现 `1` 个自定义输入框。
|
||||||
|
5. 点击建议项可继续下一轮。
|
||||||
|
6. 输入自定义文本后点击发送或回车可继续下一轮。
|
||||||
|
7. NPC 回复支持流式逐字/逐段显示。
|
||||||
|
8. 好感度变化会以消息形式插入聊天队列。
|
||||||
|
9. 背包按钮所在行最右侧可见“退出聊天”按钮。
|
||||||
|
10. 点击“退出聊天”后恢复普通冒险态。
|
||||||
|
11. 聊天态下不显示普通剧情选项的说明、目标提示、影响摘要。
|
||||||
|
12. 移动端下输入框、发送按钮、退出按钮均可正常操作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 后续可扩展方向
|
||||||
|
|
||||||
|
本次上线后,再考虑后续迭代:
|
||||||
|
|
||||||
|
1. 更精细的关系变化公式。
|
||||||
|
2. 基于 NPC 性格的建议续聊风格差异。
|
||||||
|
3. 聊天摘要沉淀到长期记忆。
|
||||||
|
4. 聊天中触发支线、任务、物品、邀约等事件。
|
||||||
|
5. 退出聊天后保留“最近一次聊天摘要”。
|
||||||
@@ -47,6 +47,75 @@ function readNumber(value: unknown, fallback = 0) {
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countKeywordMatches(text: string, keywords: string[]) {
|
||||||
|
return keywords.reduce(
|
||||||
|
(count, keyword) => (text.includes(keyword) ? count + 1 : count),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampAffinityDelta(value: number) {
|
||||||
|
return Math.max(-3, Math.min(3, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeNpcChatAffinityDelta(params: {
|
||||||
|
playerMessage: string;
|
||||||
|
npcReply: string;
|
||||||
|
chattedCount: number;
|
||||||
|
}) {
|
||||||
|
const playerMessage = params.playerMessage.trim();
|
||||||
|
const npcReply = params.npcReply.trim();
|
||||||
|
const positiveKeywords = [
|
||||||
|
'谢谢',
|
||||||
|
'辛苦',
|
||||||
|
'抱歉',
|
||||||
|
'理解',
|
||||||
|
'相信',
|
||||||
|
'放心',
|
||||||
|
'一起',
|
||||||
|
'帮你',
|
||||||
|
'在意',
|
||||||
|
'关心',
|
||||||
|
];
|
||||||
|
const negativeKeywords = [
|
||||||
|
'闭嘴',
|
||||||
|
'滚',
|
||||||
|
'少废话',
|
||||||
|
'威胁',
|
||||||
|
'骗',
|
||||||
|
'不信',
|
||||||
|
'别装',
|
||||||
|
'快说',
|
||||||
|
'审问',
|
||||||
|
'怀疑',
|
||||||
|
];
|
||||||
|
const warmReplyKeywords = ['可以', '愿意', '放心', '谢谢', '明白', '好'];
|
||||||
|
const coldReplyKeywords = ['没必要', '不想', '别问', '与你无关', '算了', '住口'];
|
||||||
|
|
||||||
|
const positiveScore =
|
||||||
|
countKeywordMatches(playerMessage, positiveKeywords) +
|
||||||
|
countKeywordMatches(npcReply, warmReplyKeywords);
|
||||||
|
const negativeScore =
|
||||||
|
countKeywordMatches(playerMessage, negativeKeywords) +
|
||||||
|
countKeywordMatches(npcReply, coldReplyKeywords);
|
||||||
|
|
||||||
|
if (positiveScore === 0 && negativeScore === 0) {
|
||||||
|
return params.chattedCount === 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positiveScore > negativeScore) {
|
||||||
|
const baseDelta =
|
||||||
|
positiveScore - negativeScore + (params.chattedCount <= 1 ? 1 : 0);
|
||||||
|
return clampAffinityDelta(baseDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (negativeScore > positiveScore) {
|
||||||
|
return clampAffinityDelta(positiveScore - negativeScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
function describeAffinityShift(affinityDelta: number) {
|
function describeAffinityShift(affinityDelta: number) {
|
||||||
if (affinityDelta >= 8) return '态度明显软化了下来。';
|
if (affinityDelta >= 8) return '态度明显软化了下来。';
|
||||||
if (affinityDelta >= 5) return '态度比刚才亲近了一些。';
|
if (affinityDelta >= 5) return '态度比刚才亲近了一些。';
|
||||||
@@ -153,7 +222,11 @@ export async function streamNpcChatTurnFromOrchestrator(
|
|||||||
const suggestions = parseLineListContent(suggestionText, 3);
|
const suggestions = parseLineListContent(suggestionText, 3);
|
||||||
const npcState = readRecord(params.payload.npcState);
|
const npcState = readRecord(params.payload.npcState);
|
||||||
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||||||
const affinityDelta = Math.max(2, 6 - chattedCount);
|
const affinityDelta = computeNpcChatAffinityDelta({
|
||||||
|
playerMessage: params.payload.playerMessage,
|
||||||
|
npcReply: npcReply || streamedReply,
|
||||||
|
chattedCount,
|
||||||
|
});
|
||||||
|
|
||||||
writeSseEvent(params.response, 'complete', {
|
writeSseEvent(params.response, 'complete', {
|
||||||
npcReply: npcReply || streamedReply,
|
npcReply: npcReply || streamedReply,
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export function buildCharacterPanelChatSummaryPrompt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildNpcDialoguePromptBase(
|
function buildNpcDialoguePromptBase(
|
||||||
payload: NpcChatDialogueRequest | NpcRecruitDialogueRequest,
|
payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest,
|
||||||
) {
|
) {
|
||||||
const encounter = describeEncounter(payload.encounter);
|
const encounter = describeEncounter(payload.encounter);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { expect, test } from 'vitest';
|
import { expect, test } from 'vitest';
|
||||||
|
|
||||||
import { AdventurePanel } from './AdventurePanel';
|
|
||||||
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../types';
|
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../types';
|
||||||
|
import { AdventurePanel } from './AdventurePanel';
|
||||||
|
|
||||||
function createCharacter(): Character {
|
function createCharacter(): Character {
|
||||||
return {
|
return {
|
||||||
@@ -43,17 +43,29 @@ function createOption(functionId: string, actionText: string): StoryOption {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPanel(currentStory: StoryMoment, displayedOptions: StoryOption[]) {
|
function renderPanel(
|
||||||
|
currentStory: StoryMoment,
|
||||||
|
displayedOptions: StoryOption[],
|
||||||
|
overrides: {
|
||||||
|
canRefreshOptions?: boolean;
|
||||||
|
hideOptions?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onSubmitNpcChatInput?: (input: string) => boolean;
|
||||||
|
onExitNpcChat?: () => boolean;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
return renderToStaticMarkup(
|
return renderToStaticMarkup(
|
||||||
<AdventurePanel
|
<AdventurePanel
|
||||||
aiError={null}
|
aiError={null}
|
||||||
currentStory={currentStory}
|
currentStory={currentStory}
|
||||||
isLoading={false}
|
isLoading={overrides.isLoading ?? false}
|
||||||
displayedOptions={displayedOptions}
|
displayedOptions={displayedOptions}
|
||||||
hideOptions={false}
|
hideOptions={overrides.hideOptions ?? false}
|
||||||
canRefreshOptions={false}
|
canRefreshOptions={overrides.canRefreshOptions ?? false}
|
||||||
onRefreshOptions={() => undefined}
|
onRefreshOptions={() => undefined}
|
||||||
onChoice={() => undefined}
|
onChoice={() => undefined}
|
||||||
|
onSubmitNpcChatInput={overrides.onSubmitNpcChatInput}
|
||||||
|
onExitNpcChat={overrides.onExitNpcChat}
|
||||||
onOpenCharacter={() => undefined}
|
onOpenCharacter={() => undefined}
|
||||||
onOpenInventory={() => undefined}
|
onOpenInventory={() => undefined}
|
||||||
playerCharacter={createCharacter()}
|
playerCharacter={createCharacter()}
|
||||||
@@ -129,3 +141,36 @@ test('adventure panel does not show deferred hint for non-continue options with
|
|||||||
|
|
||||||
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
|
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('adventure panel shows npc chat custom input and exit button in chat mode', () => {
|
||||||
|
const optionA = createOption('npc_chat', '先听对方把话说完');
|
||||||
|
const optionB = createOption('npc_chat', '顺着这个问题继续追问');
|
||||||
|
const optionC = createOption('npc_chat', '换个更轻松的语气回应');
|
||||||
|
const currentStory: StoryMoment = {
|
||||||
|
text: '你们的对话正在继续。',
|
||||||
|
displayMode: 'dialogue',
|
||||||
|
dialogue: [
|
||||||
|
{ speaker: 'player', text: '你刚才那句话是什么意思?' },
|
||||||
|
{ speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' },
|
||||||
|
{ speaker: 'system', text: '关系升温 好感 +3', affinityDelta: 3 },
|
||||||
|
],
|
||||||
|
options: [optionA, optionB, optionC],
|
||||||
|
npcChatState: {
|
||||||
|
npcId: 'npc-liu',
|
||||||
|
npcName: '柳无声',
|
||||||
|
turnCount: 2,
|
||||||
|
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = renderPanel(currentStory, [optionA, optionB, optionC], {
|
||||||
|
canRefreshOptions: true,
|
||||||
|
onSubmitNpcChatInput: () => true,
|
||||||
|
onExitNpcChat: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain('退出聊天');
|
||||||
|
expect(html).toContain('输入你想对 TA 说的话');
|
||||||
|
expect(html).toContain('发送');
|
||||||
|
expect(html).not.toContain('换一换');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1051,7 +1051,16 @@ export function AdventurePanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canRefreshOptions && !shouldHideChoiceUi && (
|
{isNpcChatMode ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onExitNpcChat?.()}
|
||||||
|
aria-label="退出聊天"
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
|
||||||
|
>
|
||||||
|
<span className="text-xs leading-none">退出聊天</span>
|
||||||
|
</button>
|
||||||
|
) : canRefreshOptions && !shouldHideChoiceUi ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRefreshOptions}
|
onClick={onRefreshOptions}
|
||||||
@@ -1064,7 +1073,7 @@ export function AdventurePanel({
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs leading-none">换一换</span>
|
<span className="text-xs leading-none">换一换</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1082,7 +1091,8 @@ export function AdventurePanel({
|
|||||||
) : shouldHideChoiceUi ? (
|
) : shouldHideChoiceUi ? (
|
||||||
<div className="p-4" aria-hidden="true" />
|
<div className="p-4" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
displayedOptions.map((option, index) => {
|
<>
|
||||||
|
{displayedOptions.map((option, index) => {
|
||||||
const optionImpactSummary = getOptionImpactSummary(
|
const optionImpactSummary = getOptionImpactSummary(
|
||||||
option,
|
option,
|
||||||
playerCharacter,
|
playerCharacter,
|
||||||
@@ -1146,24 +1156,59 @@ export function AdventurePanel({
|
|||||||
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{getCompactOptionDetailText(option) && (
|
{!isNpcChatMode && getCompactOptionDetailText(option) && (
|
||||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||||
{getCompactOptionDetailText(option)}
|
{getCompactOptionDetailText(option)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{option.goalAffordance?.label && (
|
{!isNpcChatMode && option.goalAffordance?.label && (
|
||||||
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
|
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
|
||||||
{option.goalAffordance.label}
|
{option.goalAffordance.label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{optionImpactSummary && (
|
{!isNpcChatMode && optionImpactSummary && (
|
||||||
<div className="mt-1 text-[10px] text-zinc-500">
|
<div className="mt-1 text-[10px] text-zinc-500">
|
||||||
{optionImpactSummary}
|
{optionImpactSummary}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
{isNpcChatMode ? (
|
||||||
|
<div className="pixel-nine-slice pixel-panel mt-1 border border-white/10 bg-black/25 p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={npcChatDraft}
|
||||||
|
onChange={(event) => setNpcChatDraft(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
!event.nativeEvent.isComposing
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
submitNpcChatDraft();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
npcChatState?.customInputPlaceholder ??
|
||||||
|
'输入你想说的话'
|
||||||
|
}
|
||||||
|
className="h-9 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
|
||||||
|
maxLength={80}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submitNpcChatDraft}
|
||||||
|
disabled={isLoading || !npcChatDraft.trim()}
|
||||||
|
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-3 text-xs text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function CustomWorldAgentComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
<div className="shrink-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -55,16 +55,16 @@ export function CustomWorldAgentComposer({
|
|||||||
submit();
|
submit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
rows={3}
|
rows={2}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder="输入消息"
|
placeholder="输入消息"
|
||||||
className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 pb-12 pr-20 pt-3 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
className="min-h-[5.5rem] w-full resize-none rounded-[1.35rem] border border-white/10 bg-[#111318]/92 px-4 pb-11 pr-18 pt-2.5 text-sm leading-5.5 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={disabled || !text.trim()}
|
disabled={disabled || !text.trim()}
|
||||||
className="absolute bottom-3 right-3 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
className="absolute bottom-2.5 right-2.5 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
>
|
>
|
||||||
发送
|
发送
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function CustomWorldAgentThread({
|
|||||||
}, [messages, streamingReplyText, isStreamingReply]);
|
}, [messages, streamingReplyText, isStreamingReply]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="m-auto text-sm text-zinc-400">
|
<div className="m-auto text-sm text-zinc-400">
|
||||||
暂无消息
|
暂无消息
|
||||||
@@ -68,13 +68,13 @@ export function CustomWorldAgentThread({
|
|||||||
{!isUser &&
|
{!isUser &&
|
||||||
index === lastAssistantMessageIndex &&
|
index === lastAssistantMessageIndex &&
|
||||||
visibleRecommendedReplies.length > 0 ? (
|
visibleRecommendedReplies.length > 0 ? (
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
<div className="mt-2.5 flex flex-col gap-1.5">
|
||||||
{visibleRecommendedReplies.map((reply, replyIndex) => (
|
{visibleRecommendedReplies.map((reply, replyIndex) => (
|
||||||
<button
|
<button
|
||||||
key={`recommended-reply-${replyIndex}-${reply}`}
|
key={`recommended-reply-${replyIndex}-${reply}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRecommendedReply?.(reply)}
|
onClick={() => onRecommendedReply?.(reply)}
|
||||||
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs leading-5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
|
className="rounded-[0.95rem] border border-white/10 bg-white/5 px-2.5 py-1.5 text-left text-[11px] leading-4.5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
|
||||||
>
|
>
|
||||||
{reply}
|
{reply}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -159,3 +159,34 @@ test('workspace exposes draft action when progress reaches 100', async () => {
|
|||||||
action: 'draft_foundation',
|
action: 'draft_foundation',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('workspace submits recommended reply from thread', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSubmitMessage = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CustomWorldAgentWorkspace
|
||||||
|
session={{
|
||||||
|
...baseSession,
|
||||||
|
recommendedReplies: ['继续补充这个世界的核心冲突'],
|
||||||
|
}}
|
||||||
|
activeOperation={null}
|
||||||
|
onBack={() => {}}
|
||||||
|
onSubmitMessage={onSubmitMessage}
|
||||||
|
onExecuteAction={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: '继续补充这个世界的核心冲突' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
text: '继续补充这个世界的核心冲突',
|
||||||
|
quickFillRequested: false,
|
||||||
|
focusCardId: null,
|
||||||
|
selectedCardIds: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ export function CustomWorldAgentWorkspace({
|
|||||||
<div className="h-full min-h-[18rem] lg:min-h-0">
|
<div className="h-full min-h-[18rem] lg:min-h-0">
|
||||||
<CustomWorldAgentThread
|
<CustomWorldAgentThread
|
||||||
messages={session.messages}
|
messages={session.messages}
|
||||||
|
recommendedReplies={session.recommendedReplies}
|
||||||
|
onRecommendedReply={(text) => {
|
||||||
|
submitMessage(text);
|
||||||
|
}}
|
||||||
streamingReplyText={streamingReplyText}
|
streamingReplyText={streamingReplyText}
|
||||||
isStreamingReply={isStreamingReply}
|
isStreamingReply={isStreamingReply}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
|
|||||||
import {
|
import {
|
||||||
addInventoryItems,
|
addInventoryItems,
|
||||||
applyStoryChoiceToStanceProfile,
|
applyStoryChoiceToStanceProfile,
|
||||||
buildNpcChatResultText,
|
|
||||||
buildNpcHelpCommitActionText,
|
buildNpcHelpCommitActionText,
|
||||||
buildNpcHelpResultText,
|
buildNpcHelpResultText,
|
||||||
buildNpcHelpReward,
|
buildNpcHelpReward,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
createNpcBattleMonster,
|
createNpcBattleMonster,
|
||||||
describeNpcAffinityInWords,
|
describeNpcAffinityInWords,
|
||||||
generateNpcHelpReward,
|
generateNpcHelpReward,
|
||||||
getChatAffinityOutcome,
|
|
||||||
getNpcLootItems,
|
getNpcLootItems,
|
||||||
getNpcSparMaxHp,
|
getNpcSparMaxHp,
|
||||||
markNpcFirstMeaningfulContactResolved,
|
markNpcFirstMeaningfulContactResolved,
|
||||||
@@ -609,6 +607,60 @@ export function createStoryNpcEncounterActions({
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isNpcChatOptionForEncounter = (
|
||||||
|
option: StoryOption,
|
||||||
|
encounter: Encounter,
|
||||||
|
) => {
|
||||||
|
if (option.functionId !== 'npc_chat') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.interaction?.kind !== 'npc') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
option.interaction.action === 'chat' &&
|
||||||
|
option.interaction.npcId === (encounter.id ?? encounter.npcName)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildNpcChatEntryOptions = (
|
||||||
|
encounter: Encounter,
|
||||||
|
selectedOption: StoryOption,
|
||||||
|
) => {
|
||||||
|
const candidateOptions = [
|
||||||
|
selectedOption,
|
||||||
|
...(currentStory?.options ?? []).filter((option) =>
|
||||||
|
isNpcChatOptionForEncounter(option, encounter),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const dedupedOptions: StoryOption[] = [];
|
||||||
|
const seenActionTexts = new Set<string>();
|
||||||
|
|
||||||
|
for (const option of candidateOptions) {
|
||||||
|
const actionText = option.actionText?.trim();
|
||||||
|
if (!actionText || seenActionTexts.has(actionText)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenActionTexts.add(actionText);
|
||||||
|
dedupedOptions.push(option);
|
||||||
|
if (dedupedOptions.length === 3) {
|
||||||
|
return dedupedOptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackSuggestions = buildFallbackNpcChatSuggestions(
|
||||||
|
currentStory?.text?.trim() || selectedOption.actionText,
|
||||||
|
);
|
||||||
|
const mergedSuggestions = [
|
||||||
|
...dedupedOptions.map((option) => option.actionText),
|
||||||
|
...fallbackSuggestions.filter((suggestion) => !seenActionTexts.has(suggestion)),
|
||||||
|
].slice(0, 3);
|
||||||
|
|
||||||
|
return buildNpcChatTurnOptions(encounter, mergedSuggestions);
|
||||||
|
};
|
||||||
|
|
||||||
const buildNpcChatStoryMoment = (params: {
|
const buildNpcChatStoryMoment = (params: {
|
||||||
encounter: Encounter;
|
encounter: Encounter;
|
||||||
dialogue: NonNullable<StoryMoment['dialogue']>;
|
dialogue: NonNullable<StoryMoment['dialogue']>;
|
||||||
@@ -629,6 +681,37 @@ export function createStoryNpcEncounterActions({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const enterNpcChat = (
|
||||||
|
encounter: Encounter,
|
||||||
|
selectedOption: StoryOption,
|
||||||
|
) => {
|
||||||
|
const openingDialogue =
|
||||||
|
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
|
||||||
|
currentStory.dialogue
|
||||||
|
? [...currentStory.dialogue]
|
||||||
|
: currentStory?.dialogue && currentStory.dialogue.length > 0
|
||||||
|
? [...currentStory.dialogue]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
speaker: 'npc' as const,
|
||||||
|
speakerName: encounter.npcName,
|
||||||
|
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
setAiError(null);
|
||||||
|
setCurrentStory(
|
||||||
|
buildNpcChatStoryMoment({
|
||||||
|
encounter,
|
||||||
|
dialogue: openingDialogue,
|
||||||
|
options: buildNpcChatEntryOptions(encounter, selectedOption),
|
||||||
|
streaming: false,
|
||||||
|
turnCount: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const handleNpcChatTurn = async (
|
const handleNpcChatTurn = async (
|
||||||
encounter: Encounter,
|
encounter: Encounter,
|
||||||
playerMessage: string,
|
playerMessage: string,
|
||||||
@@ -1071,8 +1154,14 @@ export function createStoryNpcEncounterActions({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case 'chat': {
|
case 'chat': {
|
||||||
void handleNpcChatTurn(encounter, option.actionText);
|
if (
|
||||||
return true;
|
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
|
||||||
|
) {
|
||||||
|
void handleNpcChatTurn(encounter, option.actionText);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return enterNpcChat(encounter, option);
|
||||||
}
|
}
|
||||||
case 'quest_accept': {
|
case 'quest_accept': {
|
||||||
void resolveServerNpcStoryAction({
|
void resolveServerNpcStoryAction({
|
||||||
|
|||||||
@@ -90,8 +90,6 @@ export function useStoryInteractionCoordinator({
|
|||||||
fallbackCompanionName,
|
fallbackCompanionName,
|
||||||
turnVisualMs,
|
turnVisualMs,
|
||||||
}: StoryInteractionCoordinatorParams) {
|
}: StoryInteractionCoordinatorParams) {
|
||||||
const { buildNpcStory } = runtimeSupport;
|
|
||||||
|
|
||||||
const { handleTreasureInteraction } = useTreasureFlow(
|
const { handleTreasureInteraction } = useTreasureFlow(
|
||||||
interactionConfig.treasureFlow,
|
interactionConfig.treasureFlow,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { SectionCard } from '../editor/shared/SectionCard';
|
import { SectionCard } from '../editor/shared/SectionCard';
|
||||||
import { AnimationState } from '../types';
|
import { AnimationState } from '../types';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_CHARACTER_BRIEF,
|
||||||
buildDefaultFrameOrder,
|
buildDefaultFrameOrder,
|
||||||
buildMasterNegativePrompt,
|
buildMasterNegativePrompt,
|
||||||
buildMasterPrompt,
|
buildMasterPrompt,
|
||||||
@@ -145,10 +146,9 @@ function DraftStrip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function QwenSpriteSheetTool() {
|
export default function QwenSpriteSheetTool() {
|
||||||
|
const initialActionTemplateId: QwenSpriteActionTemplateId = 'idle';
|
||||||
const [assetKey, setAssetKey] = useState('qwen-sprite-demo');
|
const [assetKey, setAssetKey] = useState('qwen-sprite-demo');
|
||||||
const [characterBrief, setCharacterBrief] = useState(
|
const [characterBrief, setCharacterBrief] = useState(DEFAULT_CHARACTER_BRIEF);
|
||||||
'Q版大头身少女冒险者,头部占比更大,约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。',
|
|
||||||
);
|
|
||||||
const [styleReferenceBoardSource, setStyleReferenceBoardSource] = useState('');
|
const [styleReferenceBoardSource, setStyleReferenceBoardSource] = useState('');
|
||||||
const masterCandidateCount = 2;
|
const masterCandidateCount = 2;
|
||||||
const masterSeed = 1101;
|
const masterSeed = 1101;
|
||||||
@@ -160,13 +160,13 @@ export default function QwenSpriteSheetTool() {
|
|||||||
const [isGeneratingMaster, setIsGeneratingMaster] = useState(false);
|
const [isGeneratingMaster, setIsGeneratingMaster] = useState(false);
|
||||||
|
|
||||||
const [actionTemplateId, setActionTemplateId] =
|
const [actionTemplateId, setActionTemplateId] =
|
||||||
useState<QwenSpriteActionTemplateId>('idle');
|
useState<QwenSpriteActionTemplateId>(initialActionTemplateId);
|
||||||
const [actionKey, setActionKey] = useState('idle');
|
const [actionKey, setActionKey] = useState(initialActionTemplateId);
|
||||||
const [actionGenerationMode, setActionGenerationMode] = useState<
|
const [actionGenerationMode, setActionGenerationMode] = useState<
|
||||||
'direct-sheet' | 'image-to-video'
|
'direct-sheet' | 'image-to-video'
|
||||||
>('image-to-video');
|
>('image-to-video');
|
||||||
const [actionDetailText, setActionDetailText] = useState(
|
const [actionDetailText, setActionDetailText] = useState(
|
||||||
'动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。',
|
getActionTemplateById(initialActionTemplateId).defaultDetailText ?? '',
|
||||||
);
|
);
|
||||||
const [sheetCandidateCount, setSheetCandidateCount] = useState(2);
|
const [sheetCandidateCount, setSheetCandidateCount] = useState(2);
|
||||||
const [sheetSeed, setSheetSeed] = useState(2101);
|
const [sheetSeed, setSheetSeed] = useState(2101);
|
||||||
@@ -204,6 +204,8 @@ export default function QwenSpriteSheetTool() {
|
|||||||
|
|
||||||
const [saveStatus, setSaveStatus] = useState<string | null>(null);
|
const [saveStatus, setSaveStatus] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const previousAutoActionDetailRef =
|
||||||
|
useRef<QwenSpriteActionTemplateId>(initialActionTemplateId);
|
||||||
const preserveEditorStateRef = useRef(false);
|
const preserveEditorStateRef = useRef(false);
|
||||||
const editorStateShapeRef = useRef({
|
const editorStateShapeRef = useRef({
|
||||||
activeLength: 0,
|
activeLength: 0,
|
||||||
@@ -274,6 +276,19 @@ export default function QwenSpriteSheetTool() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActionKey(actionTemplate.id);
|
setActionKey(actionTemplate.id);
|
||||||
setFps(actionTemplate.defaultFps);
|
setFps(actionTemplate.defaultFps);
|
||||||
|
setActionDetailText((currentValue) => {
|
||||||
|
const previousTemplate = getActionTemplateById(
|
||||||
|
previousAutoActionDetailRef.current,
|
||||||
|
);
|
||||||
|
previousAutoActionDetailRef.current = actionTemplate.id;
|
||||||
|
if (
|
||||||
|
!currentValue.trim() ||
|
||||||
|
currentValue.trim() === (previousTemplate.defaultDetailText ?? '').trim()
|
||||||
|
) {
|
||||||
|
return actionTemplate.defaultDetailText ?? currentValue;
|
||||||
|
}
|
||||||
|
return currentValue;
|
||||||
|
});
|
||||||
}, [actionTemplate]);
|
}, [actionTemplate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
buildDefaultFrameOrder,
|
buildDefaultFrameOrder,
|
||||||
|
DEFAULT_CHARACTER_BRIEF,
|
||||||
buildMasterNegativePrompt,
|
buildMasterNegativePrompt,
|
||||||
buildMasterPrompt,
|
buildMasterPrompt,
|
||||||
buildOrderedActiveFrameIndices,
|
buildOrderedActiveFrameIndices,
|
||||||
@@ -49,10 +50,21 @@ describe('qwenSpriteSheetToolModel', () => {
|
|||||||
expect(restoreAllFrames(4)).toEqual([0, 1, 2, 3]);
|
expect(restoreAllFrames(4)).toEqual([0, 1, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('provides action-specific default detail text for all five action templates', () => {
|
||||||
|
const actionTemplateIds = ['idle', 'run', 'attack_slash', 'hurt', 'die'] as const;
|
||||||
|
|
||||||
|
actionTemplateIds.forEach((actionTemplateId) => {
|
||||||
|
const actionTemplate = getActionTemplateById(actionTemplateId);
|
||||||
|
expect(actionTemplate.defaultDetailText?.length ?? 0).toBeGreaterThan(20);
|
||||||
|
expect(actionTemplate.stagingDirection?.length ?? 0).toBeGreaterThan(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('builds a sheet prompt that contains the template structure', () => {
|
it('builds a sheet prompt that contains the template structure', () => {
|
||||||
|
const actionTemplate = getActionTemplateById('attack_slash');
|
||||||
const prompt = buildSheetPrompt({
|
const prompt = buildSheetPrompt({
|
||||||
characterBrief: '黑发青年剑士,右手持长剑。',
|
characterBrief: '黑发青年剑士,右手持长剑。',
|
||||||
actionTemplate: getActionTemplateById('attack_slash'),
|
actionTemplate,
|
||||||
extraDirection: '每格边界清晰。',
|
extraDirection: '每格边界清晰。',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type QwenSpriteActionTemplate = {
|
|||||||
defaultFps: number;
|
defaultFps: number;
|
||||||
bodyTravel: string;
|
bodyTravel: string;
|
||||||
weaponRule: string;
|
weaponRule: string;
|
||||||
|
stagingDirection?: string;
|
||||||
|
defaultDetailText?: string;
|
||||||
sequenceLines: [string, string, string, string];
|
sequenceLines: [string, string, string, string];
|
||||||
ending: string;
|
ending: string;
|
||||||
};
|
};
|
||||||
@@ -48,6 +50,14 @@ const THEME_APPLICATION_BOUNDARY_TEXT =
|
|||||||
const CHIBI_CHARACTER_TEXT =
|
const CHIBI_CHARACTER_TEXT =
|
||||||
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。';
|
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。';
|
||||||
|
|
||||||
|
const SETTING_AND_ROLE_ALIGNMENT_TEXT =
|
||||||
|
'先从角色设定中提炼世界设定、时代氛围、阵营身份、职业职责、战斗习惯、装备结构、材质配色和情绪气质,再把这些信息落实到发型、服装层次、护具、武器、饰品、轮廓和动作节奏里,不要混入与设定无关的现代服饰、写实摄影镜头、枪械体系或其他世界观元素。';
|
||||||
|
const CHARACTER_DETAIL_COVERAGE_TEXT =
|
||||||
|
'角色描述需要尽量落到可视化细节:发型与脸部识别点、年龄层与气质、服装层次、护具与配饰、武器/法器类型、主色与材质、职业姿态与世界观痕迹,避免只有抽象身份词。';
|
||||||
|
|
||||||
|
export const DEFAULT_CHARACTER_BRIEF =
|
||||||
|
'魔潮复苏边境城邦中的少女遗迹冒险者,Q版大头身,约 2 到 3 头身,金棕色微卷短发,琥珀色眼睛,额前碎发与侧边发束清晰,表情明亮但带警觉;穿分层轻甲、短披风和旅行短裙,皮革护腕、金属护膝、系带短靴完整可见;腰间挂药剂包、地图筒与小型护符,右手持单手短剑,主色为蜂蜜金、墨绿和旧银,轮廓利落,像长期在遗迹与荒野间行动的年轻探索者。';
|
||||||
|
|
||||||
export const PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES = [
|
export const PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES = [
|
||||||
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
|
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
|
||||||
'/character/Archer Hero/Original/Hero/idle/idle01.png',
|
'/character/Archer Hero/Original/Hero/idle/idle01.png',
|
||||||
@@ -134,11 +144,50 @@ export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ACTION_TEMPLATE_DETAILS: Record<
|
||||||
|
QwenSpriteActionTemplateId,
|
||||||
|
{ stagingDirection: string; defaultDetailText: string }
|
||||||
|
> = {
|
||||||
|
idle: {
|
||||||
|
stagingDirection:
|
||||||
|
'演出重点是轻呼吸、微幅重心起伏、戒备感和可循环衔接。',
|
||||||
|
defaultDetailText:
|
||||||
|
'保持与角色设定一致的戒备气质和职业站姿,重心稳在脚下,呼吸起伏轻微,披风、衣摆、发梢和小型饰品只有细小摆动,武器安静跟随身体微动,像角色在所属世界里随时准备探索、交涉或开战的待机瞬间。',
|
||||||
|
},
|
||||||
|
run: {
|
||||||
|
stagingDirection:
|
||||||
|
'演出重点是持续推进、步点清楚、上身稳定和装备惯性。',
|
||||||
|
defaultDetailText:
|
||||||
|
'跑动时要体现角色职业和世界设定中的装备重量感,身体略微前压,步幅清晰,手臂与武器顺势摆动,披风、衣摆和挂件跟着惯性后甩,像角色正穿过战场、街巷或遗迹通道迅速移动,节奏连续稳定。',
|
||||||
|
},
|
||||||
|
attack_slash: {
|
||||||
|
stagingDirection:
|
||||||
|
'演出重点是收身蓄力、爆发斩击、武器轨迹和收招稳定。',
|
||||||
|
defaultDetailText:
|
||||||
|
'攻击前先有短暂收身蓄力,再顺着主手武器做干净利落的前踏斩击,爆发点明确,武器轨迹贴合角色职业习惯与世界观武器设定,衣摆和挂件随发力甩动,收招后还能稳住重心,像久经战斗训练的真实角色动作。',
|
||||||
|
},
|
||||||
|
hurt: {
|
||||||
|
stagingDirection:
|
||||||
|
'演出重点是冲击反馈、短暂失衡、惯性摆动和重新稳住。',
|
||||||
|
defaultDetailText:
|
||||||
|
'受击瞬间要有明确冲击反馈,肩背后仰或身体侧偏,表情短促吃痛但不夸张,武器和手臂因惯性发生合理摆动,服装层次和挂件跟着抖动一下,随后努力稳住姿态,像这个角色在其世界观里真实挨到一击后的反应。',
|
||||||
|
},
|
||||||
|
die: {
|
||||||
|
stagingDirection:
|
||||||
|
'演出重点是失衡下坠、动作逐渐停尽和终止姿态清晰。',
|
||||||
|
defaultDetailText:
|
||||||
|
'死亡动作要先表现失衡与力量被抽离,再完成明显倒下或瘫落,武器和四肢随惯性下坠,披风、衣摆和饰品有最后一次明显摆动,最终停在清晰的终止姿态,符合角色身份、装备重量和世界氛围,不要轻飘或喜剧化。',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
||||||
return (
|
const template =
|
||||||
QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ??
|
QWEN_SPRITE_ACTION_TEMPLATES.find((candidate) => candidate.id === id) ??
|
||||||
QWEN_SPRITE_ACTION_TEMPLATES[0]
|
QWEN_SPRITE_ACTION_TEMPLATES[0];
|
||||||
);
|
return {
|
||||||
|
...template,
|
||||||
|
...ACTION_TEMPLATE_DETAILS[template.id],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readFileAsDataUrl(file: File) {
|
export function readFileAsDataUrl(file: File) {
|
||||||
@@ -423,11 +472,13 @@ export async function buildPlayableCharacterStyleReferenceBoard(
|
|||||||
|
|
||||||
export function buildMasterPrompt(characterBrief: string) {
|
export function buildMasterPrompt(characterBrief: string) {
|
||||||
return [
|
return [
|
||||||
'单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
'???2D ???????????????????????????????????????????? sprite sheet ???',
|
||||||
`视角要求:${SIDE_FACING_RIGHT_TEXT}`,
|
`?????${SIDE_FACING_RIGHT_TEXT}`,
|
||||||
`主体要求:${SUBJECT_ONLY_TEXT}`,
|
`?????${SUBJECT_ONLY_TEXT}`,
|
||||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。${CLEAN_BACKGROUND_TEXT}`,
|
`?????1:1 ??????????????????????????????????????????????????${CLEAN_BACKGROUND_TEXT}`,
|
||||||
`风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
|
`?????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ?????????????????????????????????????/??/???????????????????????`,
|
||||||
|
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||||
|
CHARACTER_DETAIL_COVERAGE_TEXT,
|
||||||
CONCEPT_INTERPRETATION_TEXT,
|
CONCEPT_INTERPRETATION_TEXT,
|
||||||
HUMANLIKE_PRIORITY_TEXT,
|
HUMANLIKE_PRIORITY_TEXT,
|
||||||
CONCEPT_HIERARCHY_TEXT,
|
CONCEPT_HIERARCHY_TEXT,
|
||||||
@@ -444,20 +495,22 @@ export function buildSheetPrompt(options: {
|
|||||||
extraDirection: string;
|
extraDirection: string;
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
`使用图1作为风格参考。生成一张 4x4 的 sprite sheet,共 16 帧,展示同一个角色的连续动作。角色保持右向斜侧身动作视角,主体完整出现在每一个格子里,底部轮廓稳定,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转,也不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
`???1??????????? 4x4 ? sprite sheet?? 16 ????????????????????????????????????????????????????????????????????????????????????????????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||||
|
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||||
CONCEPT_INTERPRETATION_TEXT,
|
CONCEPT_INTERPRETATION_TEXT,
|
||||||
HUMANLIKE_PRIORITY_TEXT,
|
HUMANLIKE_PRIORITY_TEXT,
|
||||||
CONCEPT_HIERARCHY_TEXT,
|
CONCEPT_HIERARCHY_TEXT,
|
||||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||||
`动作名:${options.actionTemplate.label}`,
|
`????${options.actionTemplate.label}`,
|
||||||
`是否循环:${options.actionTemplate.loop ? '是' : '否'}`,
|
`???????${options.actionTemplate.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||||
`身体位移:${options.actionTemplate.bodyTravel}`,
|
`?????${options.actionTemplate.loop ? '?' : '?'}`,
|
||||||
`武器规则:${options.actionTemplate.weaponRule}`,
|
`?????${options.actionTemplate.bodyTravel}`,
|
||||||
|
`?????${options.actionTemplate.weaponRule}`,
|
||||||
...options.actionTemplate.sequenceLines,
|
...options.actionTemplate.sequenceLines,
|
||||||
`结尾要求:${options.actionTemplate.ending}`,
|
`?????${options.actionTemplate.ending}`,
|
||||||
'输出要求:每一格都要清晰分开,网格顺序从左到右、从上到下,动作连续,首尾关系明确,轮廓稳定,发型稳定,服装结构稳定,武器始终在正确的手中,背景为纯浅色,适合后续切成 sprite frames。',
|
'?????????????????????????????????????????????????????????????????????????????????? sprite frames?',
|
||||||
options.characterBrief.trim(),
|
options.characterBrief.trim(),
|
||||||
options.extraDirection.trim(),
|
`???????${options.extraDirection.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
@@ -465,13 +518,13 @@ export function buildSheetPrompt(options: {
|
|||||||
|
|
||||||
export function buildRepairPrompt(options: {
|
export function buildRepairPrompt(options: {
|
||||||
issueText: string;
|
issueText: string;
|
||||||
useNeighborLabel: '上一帧' | '下一帧';
|
useNeighborLabel: '???' | '???';
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
`使用图1作为风格参考,参考图2的动作连续性,修复图3这一个单帧。图2代表${options.useNeighborLabel}。`,
|
`???1??????????2??????????3???????2??${options.useNeighborLabel}?`,
|
||||||
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色保持右向斜侧身动作视角,主体完整,底部结构稳定,保持与图2连续,并且与图1是同一个角色,不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${THEME_APPLICATION_BOUNDARY_TEXT} 修复图3中的错误,使这一帧适合插回原来的 sprite sheet 中。`,
|
`?????????????????????????????????????????????????????????2???????1?????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${SETTING_AND_ROLE_ALIGNMENT_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${THEME_APPLICATION_BOUNDARY_TEXT} ???3???????????????? sprite sheet ??`,
|
||||||
'保持不变:发型、服装结构、主配色、武器类型、朝向。',
|
'?????????????????????????',
|
||||||
`重点修复:${options.issueText.trim() || '修复手脚畸形、武器错误或朝向不一致问题。'}`,
|
`?????${options.issueText.trim() || '????????????????????'}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,19 +535,21 @@ export function buildVideoActionPrompt(options: {
|
|||||||
characterBrief: string;
|
characterBrief: string;
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
`单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`,
|
`???????????????? ${options.actionTemplate.label}?`,
|
||||||
`角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
`??????1??????????????????????????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||||
|
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||||
CONCEPT_INTERPRETATION_TEXT,
|
CONCEPT_INTERPRETATION_TEXT,
|
||||||
HUMANLIKE_PRIORITY_TEXT,
|
HUMANLIKE_PRIORITY_TEXT,
|
||||||
CONCEPT_HIERARCHY_TEXT,
|
CONCEPT_HIERARCHY_TEXT,
|
||||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||||
`动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`,
|
`???????${options.actionTemplate.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||||
|
`?????${options.actionTemplate.sequenceLines.join('?')}??????${options.actionTemplate.ending}?`,
|
||||||
options.useChromaKey
|
options.useChromaKey
|
||||||
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
|
? '??????????????????????????????'
|
||||||
: '背景简洁纯净,无复杂场景。',
|
: '?????????????',
|
||||||
`动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
|
`???????${options.actionDetailText.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||||
`角色设定:${options.characterBrief.trim()}`,
|
`?????${options.characterBrief.trim()}`,
|
||||||
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
|
'?????????????????????????????????????????',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user