@@ -1,8 +1,8 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { AdventurePanel } from './AdventurePanel';
|
||||
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../types';
|
||||
import { AdventurePanel } from './AdventurePanel';
|
||||
|
||||
function createCharacter(): Character {
|
||||
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(
|
||||
<AdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
isLoading={overrides.isLoading ?? false}
|
||||
displayedOptions={displayedOptions}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
hideOptions={overrides.hideOptions ?? false}
|
||||
canRefreshOptions={overrides.canRefreshOptions ?? false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onSubmitNpcChatInput={overrides.onSubmitNpcChatInput}
|
||||
onExitNpcChat={overrides.onExitNpcChat}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
@@ -129,3 +141,36 @@ test('adventure panel does not show deferred hint for non-continue options with
|
||||
|
||||
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>
|
||||
</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
|
||||
type="button"
|
||||
onClick={onRefreshOptions}
|
||||
@@ -1064,7 +1073,7 @@ export function AdventurePanel({
|
||||
/>
|
||||
<span className="text-xs leading-none">换一换</span>
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -1082,7 +1091,8 @@ export function AdventurePanel({
|
||||
) : shouldHideChoiceUi ? (
|
||||
<div className="p-4" aria-hidden="true" />
|
||||
) : (
|
||||
displayedOptions.map((option, index) => {
|
||||
<>
|
||||
{displayedOptions.map((option, index) => {
|
||||
const optionImpactSummary = getOptionImpactSummary(
|
||||
option,
|
||||
playerCharacter,
|
||||
@@ -1146,24 +1156,59 @@ export function AdventurePanel({
|
||||
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
{getCompactOptionDetailText(option) && (
|
||||
{!isNpcChatMode && getCompactOptionDetailText(option) && (
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||
{getCompactOptionDetailText(option)}
|
||||
</div>
|
||||
)}
|
||||
{option.goalAffordance?.label && (
|
||||
{!isNpcChatMode && option.goalAffordance?.label && (
|
||||
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
|
||||
{option.goalAffordance.label}
|
||||
</div>
|
||||
)}
|
||||
{optionImpactSummary && (
|
||||
{!isNpcChatMode && optionImpactSummary && (
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{optionImpactSummary}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function CustomWorldAgentComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="shrink-0">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -55,16 +55,16 @@ export function CustomWorldAgentComposer({
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
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
|
||||
type="button"
|
||||
onClick={submit}
|
||||
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>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function CustomWorldAgentThread({
|
||||
}, [messages, streamingReplyText, isStreamingReply]);
|
||||
|
||||
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 ? (
|
||||
<div className="m-auto text-sm text-zinc-400">
|
||||
暂无消息
|
||||
@@ -68,13 +68,13 @@ export function CustomWorldAgentThread({
|
||||
{!isUser &&
|
||||
index === lastAssistantMessageIndex &&
|
||||
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) => (
|
||||
<button
|
||||
key={`recommended-reply-${replyIndex}-${reply}`}
|
||||
type="button"
|
||||
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}
|
||||
</button>
|
||||
|
||||
@@ -159,3 +159,34 @@ test('workspace exposes draft action when progress reaches 100', async () => {
|
||||
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">
|
||||
<CustomWorldAgentThread
|
||||
messages={session.messages}
|
||||
recommendedReplies={session.recommendedReplies}
|
||||
onRecommendedReply={(text) => {
|
||||
submitMessage(text);
|
||||
}}
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
applyStoryChoiceToStanceProfile,
|
||||
buildNpcChatResultText,
|
||||
buildNpcHelpCommitActionText,
|
||||
buildNpcHelpResultText,
|
||||
buildNpcHelpReward,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
createNpcBattleMonster,
|
||||
describeNpcAffinityInWords,
|
||||
generateNpcHelpReward,
|
||||
getChatAffinityOutcome,
|
||||
getNpcLootItems,
|
||||
getNpcSparMaxHp,
|
||||
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: {
|
||||
encounter: Encounter;
|
||||
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 (
|
||||
encounter: Encounter,
|
||||
playerMessage: string,
|
||||
@@ -1071,8 +1154,14 @@ export function createStoryNpcEncounterActions({
|
||||
return true;
|
||||
}
|
||||
case 'chat': {
|
||||
void handleNpcChatTurn(encounter, option.actionText);
|
||||
return true;
|
||||
if (
|
||||
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
|
||||
) {
|
||||
void handleNpcChatTurn(encounter, option.actionText);
|
||||
return true;
|
||||
}
|
||||
|
||||
return enterNpcChat(encounter, option);
|
||||
}
|
||||
case 'quest_accept': {
|
||||
void resolveServerNpcStoryAction({
|
||||
|
||||
@@ -90,8 +90,6 @@ export function useStoryInteractionCoordinator({
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: StoryInteractionCoordinatorParams) {
|
||||
const { buildNpcStory } = runtimeSupport;
|
||||
|
||||
const { handleTreasureInteraction } = useTreasureFlow(
|
||||
interactionConfig.treasureFlow,
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { SectionCard } from '../editor/shared/SectionCard';
|
||||
import { AnimationState } from '../types';
|
||||
import {
|
||||
DEFAULT_CHARACTER_BRIEF,
|
||||
buildDefaultFrameOrder,
|
||||
buildMasterNegativePrompt,
|
||||
buildMasterPrompt,
|
||||
@@ -145,10 +146,9 @@ function DraftStrip({
|
||||
}
|
||||
|
||||
export default function QwenSpriteSheetTool() {
|
||||
const initialActionTemplateId: QwenSpriteActionTemplateId = 'idle';
|
||||
const [assetKey, setAssetKey] = useState('qwen-sprite-demo');
|
||||
const [characterBrief, setCharacterBrief] = useState(
|
||||
'Q版大头身少女冒险者,头部占比更大,约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。',
|
||||
);
|
||||
const [characterBrief, setCharacterBrief] = useState(DEFAULT_CHARACTER_BRIEF);
|
||||
const [styleReferenceBoardSource, setStyleReferenceBoardSource] = useState('');
|
||||
const masterCandidateCount = 2;
|
||||
const masterSeed = 1101;
|
||||
@@ -160,13 +160,13 @@ export default function QwenSpriteSheetTool() {
|
||||
const [isGeneratingMaster, setIsGeneratingMaster] = useState(false);
|
||||
|
||||
const [actionTemplateId, setActionTemplateId] =
|
||||
useState<QwenSpriteActionTemplateId>('idle');
|
||||
const [actionKey, setActionKey] = useState('idle');
|
||||
useState<QwenSpriteActionTemplateId>(initialActionTemplateId);
|
||||
const [actionKey, setActionKey] = useState(initialActionTemplateId);
|
||||
const [actionGenerationMode, setActionGenerationMode] = useState<
|
||||
'direct-sheet' | 'image-to-video'
|
||||
>('image-to-video');
|
||||
const [actionDetailText, setActionDetailText] = useState(
|
||||
'动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。',
|
||||
getActionTemplateById(initialActionTemplateId).defaultDetailText ?? '',
|
||||
);
|
||||
const [sheetCandidateCount, setSheetCandidateCount] = useState(2);
|
||||
const [sheetSeed, setSheetSeed] = useState(2101);
|
||||
@@ -204,6 +204,8 @@ export default function QwenSpriteSheetTool() {
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const previousAutoActionDetailRef =
|
||||
useRef<QwenSpriteActionTemplateId>(initialActionTemplateId);
|
||||
const preserveEditorStateRef = useRef(false);
|
||||
const editorStateShapeRef = useRef({
|
||||
activeLength: 0,
|
||||
@@ -274,6 +276,19 @@ export default function QwenSpriteSheetTool() {
|
||||
useEffect(() => {
|
||||
setActionKey(actionTemplate.id);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildDefaultFrameOrder,
|
||||
DEFAULT_CHARACTER_BRIEF,
|
||||
buildMasterNegativePrompt,
|
||||
buildMasterPrompt,
|
||||
buildOrderedActiveFrameIndices,
|
||||
@@ -49,10 +50,21 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
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', () => {
|
||||
const actionTemplate = getActionTemplateById('attack_slash');
|
||||
const prompt = buildSheetPrompt({
|
||||
characterBrief: '黑发青年剑士,右手持长剑。',
|
||||
actionTemplate: getActionTemplateById('attack_slash'),
|
||||
actionTemplate,
|
||||
extraDirection: '每格边界清晰。',
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ export type QwenSpriteActionTemplate = {
|
||||
defaultFps: number;
|
||||
bodyTravel: string;
|
||||
weaponRule: string;
|
||||
stagingDirection?: string;
|
||||
defaultDetailText?: string;
|
||||
sequenceLines: [string, string, string, string];
|
||||
ending: string;
|
||||
};
|
||||
@@ -48,6 +50,14 @@ const THEME_APPLICATION_BOUNDARY_TEXT =
|
||||
const CHIBI_CHARACTER_TEXT =
|
||||
'即使角色带有怪物或海洋主题,也优先做成 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 = [
|
||||
'/character/Sword Princess/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) {
|
||||
return (
|
||||
QWEN_SPRITE_ACTION_TEMPLATES.find((template) => template.id === id) ??
|
||||
QWEN_SPRITE_ACTION_TEMPLATES[0]
|
||||
);
|
||||
const template =
|
||||
QWEN_SPRITE_ACTION_TEMPLATES.find((candidate) => candidate.id === id) ??
|
||||
QWEN_SPRITE_ACTION_TEMPLATES[0];
|
||||
return {
|
||||
...template,
|
||||
...ACTION_TEMPLATE_DETAILS[template.id],
|
||||
};
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(file: File) {
|
||||
@@ -423,11 +472,13 @@ export async function buildPlayableCharacterStyleReferenceBoard(
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
|
||||
`视角要求:${SIDE_FACING_RIGHT_TEXT}`,
|
||||
`主体要求:${SUBJECT_ONLY_TEXT}`,
|
||||
`画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。${CLEAN_BACKGROUND_TEXT}`,
|
||||
`风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
|
||||
'???2D ???????????????????????????????????????????? sprite sheet ???',
|
||||
`?????${SIDE_FACING_RIGHT_TEXT}`,
|
||||
`?????${SUBJECT_ONLY_TEXT}`,
|
||||
`?????1:1 ??????????????????????????????????????????????????${CLEAN_BACKGROUND_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,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
@@ -444,20 +495,22 @@ export function buildSheetPrompt(options: {
|
||||
extraDirection: string;
|
||||
}) {
|
||||
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,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`动作名:${options.actionTemplate.label}`,
|
||||
`是否循环:${options.actionTemplate.loop ? '是' : '否'}`,
|
||||
`身体位移:${options.actionTemplate.bodyTravel}`,
|
||||
`武器规则:${options.actionTemplate.weaponRule}`,
|
||||
`????${options.actionTemplate.label}`,
|
||||
`???????${options.actionTemplate.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||
`?????${options.actionTemplate.loop ? '?' : '?'}`,
|
||||
`?????${options.actionTemplate.bodyTravel}`,
|
||||
`?????${options.actionTemplate.weaponRule}`,
|
||||
...options.actionTemplate.sequenceLines,
|
||||
`结尾要求:${options.actionTemplate.ending}`,
|
||||
'输出要求:每一格都要清晰分开,网格顺序从左到右、从上到下,动作连续,首尾关系明确,轮廓稳定,发型稳定,服装结构稳定,武器始终在正确的手中,背景为纯浅色,适合后续切成 sprite frames。',
|
||||
`?????${options.actionTemplate.ending}`,
|
||||
'?????????????????????????????????????????????????????????????????????????????????? sprite frames?',
|
||||
options.characterBrief.trim(),
|
||||
options.extraDirection.trim(),
|
||||
`???????${options.extraDirection.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
@@ -465,13 +518,13 @@ export function buildSheetPrompt(options: {
|
||||
|
||||
export function buildRepairPrompt(options: {
|
||||
issueText: string;
|
||||
useNeighborLabel: '上一帧' | '下一帧';
|
||||
useNeighborLabel: '???' | '???';
|
||||
}) {
|
||||
return [
|
||||
`使用图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 中。`,
|
||||
'保持不变:发型、服装结构、主配色、武器类型、朝向。',
|
||||
`重点修复:${options.issueText.trim() || '修复手脚畸形、武器错误或朝向不一致问题。'}`,
|
||||
`???1??????????2??????????3???????2??${options.useNeighborLabel}?`,
|
||||
`?????????????????????????????????????????????????????????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() || '????????????????????'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -482,19 +535,21 @@ export function buildVideoActionPrompt(options: {
|
||||
characterBrief: string;
|
||||
}) {
|
||||
return [
|
||||
`单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`,
|
||||
`角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
`???????????????? ${options.actionTemplate.label}?`,
|
||||
`??????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,
|
||||
`动作结构:${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.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
|
||||
`角色设定:${options.characterBrief.trim()}`,
|
||||
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
|
||||
? '??????????????????????????????'
|
||||
: '?????????????',
|
||||
`???????${options.actionDetailText.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
`?????${options.characterBrief.trim()}`,
|
||||
'?????????????????????????????????????????',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user