1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-18 19:40:33 +08:00
parent 54b3d3c490
commit 8c3fbd9bcf
15 changed files with 904 additions and 65 deletions

View File

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

View File

@@ -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. 退出聊天后保留“最近一次聊天摘要”。

View File

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

View File

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

View File

@@ -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('换一换');
});

View File

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

View File

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

View File

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

View File

@@ -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: [],
}),
);
});

View File

@@ -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}
/> />

View File

@@ -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,9 +1154,15 @@ export function createStoryNpcEncounterActions({
return true; return true;
} }
case 'chat': { case 'chat': {
if (
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
) {
void handleNpcChatTurn(encounter, option.actionText); void handleNpcChatTurn(encounter, option.actionText);
return true; return true;
} }
return enterNpcChat(encounter, option);
}
case 'quest_accept': { case 'quest_accept': {
void resolveServerNpcStoryAction({ void resolveServerNpcStoryAction({
option, option,

View File

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

View File

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

View File

@@ -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: '每格边界清晰。',
}); });

View File

@@ -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(' ');
} }