@@ -57,6 +57,11 @@ test('adventure panel treats negative affinity updates as relationship change sy
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: async () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
|
||||
@@ -75,6 +75,11 @@ function renderPanel(
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: async () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
@@ -174,3 +179,67 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
|
||||
expect(html).toContain('发送');
|
||||
expect(html).not.toContain('换一换');
|
||||
});
|
||||
|
||||
test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => {
|
||||
const viewOption = createOption('npc_chat_quest_offer_view', '查看任务');
|
||||
viewOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'view',
|
||||
};
|
||||
const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务');
|
||||
replaceOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'replace',
|
||||
};
|
||||
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
|
||||
abandonOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'abandon',
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '柳无声把真正的委托说了出来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你像是还有别的话想说。' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '确实有一件事想正式托付给你。' },
|
||||
],
|
||||
options: [viewOption, replaceOption, abandonOption],
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: {
|
||||
quest: {
|
||||
id: 'quest-liu-1',
|
||||
issuerNpcId: 'npc-liu',
|
||||
issuerNpcName: '柳无声',
|
||||
sceneId: 'scene-bamboo',
|
||||
title: '竹林密信',
|
||||
description: '替柳无声查清竹林中的密信来源。',
|
||||
summary: '去竹林查清密信来源。',
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
reward: {
|
||||
affinityBonus: 5,
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '完成后可获得报酬。',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [viewOption, replaceOption, abandonOption], {
|
||||
onSubmitNpcChatInput: () => true,
|
||||
onExitNpcChat: () => true,
|
||||
});
|
||||
|
||||
expect(html).toContain('查看任务');
|
||||
expect(html).toContain('更换任务');
|
||||
expect(html).toContain('放弃任务');
|
||||
expect(html).not.toContain('发送');
|
||||
expect(html).not.toContain('输入你想对 TA 说的话');
|
||||
});
|
||||
|
||||
@@ -28,7 +28,11 @@ import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import { isQuestReadyToClaim } from '../data/questFlow';
|
||||
import { getScenePresetById } from '../data/scenePresets';
|
||||
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
|
||||
import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration';
|
||||
import type {
|
||||
BattleRewardUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../hooks/useStoryGeneration';
|
||||
import type {
|
||||
ChapterState,
|
||||
Character,
|
||||
@@ -70,6 +74,7 @@ interface AdventurePanelProps {
|
||||
worldType: WorldType | null;
|
||||
quests: QuestLogEntry[];
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalStack: GoalStackState;
|
||||
goalPulse: GoalPulseEvent | null;
|
||||
onDismissGoalPulse: () => void;
|
||||
@@ -625,6 +630,7 @@ export function AdventurePanel({
|
||||
worldType,
|
||||
quests,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalStack,
|
||||
goalPulse,
|
||||
onDismissGoalPulse,
|
||||
@@ -646,6 +652,8 @@ export function AdventurePanel({
|
||||
const dialogueTurns = currentStory.dialogue ?? [];
|
||||
const npcChatState = currentStory.npcChatState ?? null;
|
||||
const isNpcChatMode = Boolean(npcChatState);
|
||||
const pendingNpcQuestOffer = npcChatState?.pendingQuestOffer?.quest ?? null;
|
||||
const isNpcQuestOfferMode = Boolean(pendingNpcQuestOffer);
|
||||
const isStoryStreaming = Boolean(currentStory.streaming);
|
||||
const shouldHideChoiceUi = hideOptions;
|
||||
const storyScrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -689,8 +697,10 @@ export function AdventurePanel({
|
||||
[quests],
|
||||
);
|
||||
const selectedQuest = useMemo(
|
||||
() => quests.find((quest) => quest.id === selectedQuestId) ?? null,
|
||||
[quests, selectedQuestId],
|
||||
() =>
|
||||
quests.find((quest) => quest.id === selectedQuestId) ??
|
||||
(pendingNpcQuestOffer?.id === selectedQuestId ? pendingNpcQuestOffer : null),
|
||||
[pendingNpcQuestOffer, quests, selectedQuestId],
|
||||
);
|
||||
const rewardQuest = useMemo(
|
||||
() => quests.find((quest) => quest.id === rewardQuestId) ?? null,
|
||||
@@ -901,6 +911,27 @@ export function AdventurePanel({
|
||||
Boolean(selectedRewardItem);
|
||||
|
||||
const handleOptionChoice = (option: StoryOption) => {
|
||||
const pendingQuestAction =
|
||||
typeof option.runtimePayload?.npcChatQuestOfferAction === 'string'
|
||||
? option.runtimePayload.npcChatQuestOfferAction
|
||||
: null;
|
||||
if (pendingQuestAction && pendingNpcQuestOffer) {
|
||||
if (pendingQuestAction === 'view') {
|
||||
setSelectedQuestId(pendingNpcQuestOffer.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingQuestAction === 'replace') {
|
||||
void npcChatQuestOfferUi.replacePendingOffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingQuestAction === 'abandon') {
|
||||
npcChatQuestOfferUi.abandonPendingOffer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
option.interaction?.kind === 'npc' &&
|
||||
option.interaction.action === 'quest_accept'
|
||||
@@ -1179,7 +1210,7 @@ export function AdventurePanel({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{isNpcChatMode ? (
|
||||
{isNpcChatMode && !isNpcQuestOfferMode ? (
|
||||
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
@@ -1263,6 +1294,13 @@ export function AdventurePanel({
|
||||
selectedRewardEquipSlot={selectedRewardEquipSlot}
|
||||
selectedQuestSceneName={selectedQuestSceneName}
|
||||
getQuestStatusLabel={getQuestStatusLabel}
|
||||
pendingNpcQuestOffer={pendingNpcQuestOffer}
|
||||
onAcceptPendingNpcQuestOffer={() => {
|
||||
const acceptedQuestId = npcChatQuestOfferUi.acceptPendingOffer();
|
||||
if (!acceptedQuestId) return null;
|
||||
setSelectedQuestId(null);
|
||||
return acceptedQuestId;
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
77
src/components/CustomWorldCoverArtwork.tsx
Normal file
77
src/components/CustomWorldCoverArtwork.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
|
||||
|
||||
const COVER_PORTRAIT_CLASS_NAMES = [
|
||||
'h-[54%] w-[24%] translate-y-[8%]',
|
||||
'h-[68%] w-[30%]',
|
||||
'h-[56%] w-[24%] translate-y-[10%]',
|
||||
] as const;
|
||||
|
||||
type CustomWorldCoverArtworkProps = {
|
||||
imageSrc?: string | null;
|
||||
title: string;
|
||||
fallbackLabel: string;
|
||||
renderMode?: CustomWorldCoverRenderMode;
|
||||
characterImageSrcs?: string[];
|
||||
className?: string;
|
||||
overlay?: ReactNode;
|
||||
};
|
||||
|
||||
export function CustomWorldCoverArtwork({
|
||||
imageSrc,
|
||||
title,
|
||||
fallbackLabel,
|
||||
renderMode = 'image',
|
||||
characterImageSrcs = [],
|
||||
className = '',
|
||||
overlay,
|
||||
}: CustomWorldCoverArtworkProps) {
|
||||
const coverCharacterImageSrcs = characterImageSrcs.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden bg-[radial-gradient(circle_at_top,rgba(255,244,214,0.3),transparent_38%),linear-gradient(180deg,rgba(34,40,55,0.92),rgba(10,12,18,0.96))] ${className}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.04),rgba(8,10,14,0.26)_46%,rgba(8,10,14,0.82)_100%)]" />
|
||||
{!imageSrc ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-300">
|
||||
{fallbackLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{renderMode === 'scene_with_roles' && coverCharacterImageSrcs.length > 0 ? (
|
||||
<>
|
||||
<div className="absolute inset-x-0 bottom-0 h-[42%] bg-[linear-gradient(180deg,rgba(8,10,14,0)_0%,rgba(8,10,14,0.88)_100%)]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-center gap-2 px-3 pb-2 sm:pb-3">
|
||||
{coverCharacterImageSrcs.map((characterImageSrc, index) => (
|
||||
<div
|
||||
key={`${title}-cover-character-${index}-${characterImageSrc}`}
|
||||
className={`overflow-hidden rounded-[1rem] border border-white/16 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.04))] shadow-[0_12px_28px_rgba(0,0,0,0.4)] ${COVER_PORTRAIT_CLASS_NAMES[index] ?? COVER_PORTRAIT_CLASS_NAMES[1]}`}
|
||||
>
|
||||
<img
|
||||
src={characterImageSrc}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{overlay ? (
|
||||
<div className="pointer-events-none absolute inset-0">{overlay}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomWorldCoverArtwork;
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
resolveCustomWorldLandmarkImageMap,
|
||||
} from '../data/customWorldVisuals';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
|
||||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
|
||||
@@ -941,6 +943,10 @@ export function CustomWorldEntityCatalog({
|
||||
1 +
|
||||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||||
} satisfies Record<ResultTab, number>;
|
||||
const coverPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(profile),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||||
@@ -1124,6 +1130,40 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="作品封面"
|
||||
badge={
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{coverPresentation.sourceType === 'uploaded'
|
||||
? '上传封面'
|
||||
: coverPresentation.sourceType === 'generated'
|
||||
? 'AI封面'
|
||||
: '默认封面'}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
!readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'cover' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={coverPresentation.imageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={coverPresentation.renderMode}
|
||||
characterImageSrcs={coverPresentation.characterImageSrcs}
|
||||
className="aspect-[16/9] rounded-[1.4rem] border border-[var(--platform-subpanel-border)]"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
|
||||
@@ -210,7 +210,7 @@ function LandmarkEditorFlowHarness() {
|
||||
}
|
||||
|
||||
function CampEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState({
|
||||
const [profile, setProfile] = useState<CustomWorldProfile>({
|
||||
...createProfileWithLandmark(),
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
|
||||
@@ -24,7 +24,17 @@ import {
|
||||
generateCustomWorldSceneImage,
|
||||
generateCustomWorldSceneNpc,
|
||||
} from '../services/aiService';
|
||||
import {
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
type CustomWorldCoverAssetResult,
|
||||
} from '../services/customWorldCoverAssetService';
|
||||
import { buildSkillActionPrompt } from '../prompts/customWorldEntityActionPrompts';
|
||||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import {
|
||||
buildDefaultCustomWorldCoverProfile,
|
||||
resolveCustomWorldCoverPresentation,
|
||||
} from '../services/customWorldCover';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
@@ -33,6 +43,7 @@ import {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
type CustomWorldCoverProfile,
|
||||
type CustomWorldRoleInitialItem,
|
||||
type CustomWorldRoleRelation,
|
||||
type CustomWorldRoleSkill,
|
||||
@@ -47,6 +58,7 @@ import {
|
||||
} from './asset-studio/characterAssetWorkflowPersistence';
|
||||
import { useAuthUi } from './auth/AuthUiContext';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import {
|
||||
CustomWorldNpcPortrait,
|
||||
@@ -57,6 +69,7 @@ import { PixelIcon } from './PixelIcon';
|
||||
|
||||
export type CustomWorldEditorTarget =
|
||||
| { kind: 'world' }
|
||||
| { kind: 'cover' }
|
||||
| { kind: 'camp' }
|
||||
| { kind: 'playable'; mode: 'create' }
|
||||
| { kind: 'playable'; mode: 'edit'; id: string }
|
||||
@@ -273,24 +286,6 @@ function inferSkillActionTemplateId(skill: Pick<CustomWorldRoleSkill, 'name' | '
|
||||
return 'attack_slash';
|
||||
}
|
||||
|
||||
function buildSkillActionPrompt(params: {
|
||||
role: Pick<CustomWorldPlayableNpc | CustomWorldNpc, 'name' | 'title' | 'role' | 'description' | 'backstory' | 'personality' | 'motivation'>;
|
||||
skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>;
|
||||
}) {
|
||||
const { role, skill } = params;
|
||||
return [
|
||||
`${role.name},${role.title || role.role}。`,
|
||||
`技能名称:${skill.name}。`,
|
||||
skill.summary ? `技能表现:${skill.summary}。` : '',
|
||||
role.description ? `角色气质:${role.description}。` : '',
|
||||
role.personality ? `性格补充:${role.personality}。` : '',
|
||||
role.motivation ? `动作目标:${role.motivation}。` : '',
|
||||
'横版 RPG 角色技能动作,角色轮廓稳定,动作起手明确,过程连贯,收招干净,镜头稳定。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function createRoleRelationDraft(seedLabel: string, index: number): CustomWorldRoleRelation {
|
||||
return {
|
||||
id: createEntryId('relation', seedLabel, Date.now() + index),
|
||||
@@ -1687,6 +1682,408 @@ function SceneImageGenerationModal({
|
||||
);
|
||||
}
|
||||
|
||||
const FIXED_COVER_IMAGE_SIZE = '1600*900';
|
||||
|
||||
function buildGeneratedCoverProfile(
|
||||
result: CustomWorldCoverAssetResult,
|
||||
): CustomWorldCoverProfile {
|
||||
return {
|
||||
sourceType: result.sourceType,
|
||||
imageSrc: result.imageSrc,
|
||||
characterRoleIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function CoverImageGenerationModal({
|
||||
profile,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
onApply: (result: CustomWorldCoverAssetResult) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const initialPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(profile),
|
||||
[profile],
|
||||
);
|
||||
const [userPrompt, setUserPrompt] = useDraft(profile.summary || profile.name);
|
||||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latestResult, setLatestResult] =
|
||||
useState<CustomWorldCoverAssetResult | null>(null);
|
||||
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
|
||||
|
||||
const previewImageSrc = latestResult?.imageSrc || initialPresentation.imageSrc;
|
||||
|
||||
const handleReferenceImageChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await readImageFileAsDataUrl(file);
|
||||
setReferenceImageSrc(dataUrl);
|
||||
setError(null);
|
||||
} catch (uploadError) {
|
||||
setError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestClose = () => {
|
||||
if (isGenerating) {
|
||||
return;
|
||||
}
|
||||
if (latestResult) {
|
||||
setIsExitConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!userPrompt.trim()) {
|
||||
setError('请先补一句你想要的封面氛围。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await generateCustomWorldCoverImage({
|
||||
profile,
|
||||
userPrompt,
|
||||
referenceImageSrc,
|
||||
characterRoleIds:
|
||||
profile.cover?.sourceType === 'default'
|
||||
? profile.cover.characterRoleIds
|
||||
: buildDefaultCustomWorldCoverProfile(profile).characterRoleIds,
|
||||
size: FIXED_COVER_IMAGE_SIZE,
|
||||
});
|
||||
setLatestResult(result);
|
||||
} catch (generationError) {
|
||||
setError(
|
||||
generationError instanceof Error
|
||||
? generationError.message
|
||||
: '作品封面生成失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!latestResult || isGenerating) {
|
||||
return;
|
||||
}
|
||||
onApply(latestResult);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
title="AI 生成作品封面"
|
||||
onClose={handleRequestClose}
|
||||
panelClassName="sm:max-w-5xl"
|
||||
overlayClassName="z-[99]"
|
||||
disableClose={isGenerating}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
|
||||
<div className="space-y-4">
|
||||
<Field label="封面氛围">
|
||||
<TextArea
|
||||
value={userPrompt}
|
||||
onChange={(value) => setUserPrompt(value)}
|
||||
rows={7}
|
||||
placeholder="例如:风雪中的山门前景,三位主角立在残灯与旗帜之间,整体像一张正式作品封面。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="参考图(可选)">
|
||||
<div className="space-y-3">
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
{referenceImageSrc ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-white/8 bg-black/20 p-3">
|
||||
<div className="h-16 w-24 overflow-hidden rounded-xl border border-white/10 bg-black/30">
|
||||
<img
|
||||
src={referenceImageSrc}
|
||||
alt="封面参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-xs leading-5 text-zinc-400">
|
||||
已载入封面参考图
|
||||
</div>
|
||||
<ActionButton
|
||||
label="移除"
|
||||
onClick={() => setReferenceImageSrc('')}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={previewImageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={
|
||||
latestResult ? 'image' : initialPresentation.renderMode
|
||||
}
|
||||
characterImageSrcs={
|
||||
latestResult ? [] : initialPresentation.characterImageSrcs
|
||||
}
|
||||
className="aspect-[16/9] rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{latestResult ? (
|
||||
<div className="rounded-2xl border border-emerald-300/18 bg-emerald-500/10 px-4 py-3 text-sm leading-6 text-emerald-50">
|
||||
已生成完毕,保存后将替换当前作品封面。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
label="保存"
|
||||
onClick={handleSave}
|
||||
disabled={!latestResult || isGenerating}
|
||||
/>
|
||||
<ActionButton
|
||||
label={
|
||||
isGenerating
|
||||
? '正在生成...'
|
||||
: latestResult
|
||||
? '重新生成'
|
||||
: '开始生成'
|
||||
}
|
||||
onClick={() => {
|
||||
void handleGenerate();
|
||||
}}
|
||||
tone="sky"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
{isExitConfirmOpen ? (
|
||||
<PortalCompactDialogShell
|
||||
title="确认退出"
|
||||
onClose={() => setIsExitConfirmOpen(false)}
|
||||
overlayClassName="z-[140]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-4 text-sm leading-6 text-amber-50">
|
||||
当前生成结果还没有保存,确认退出吗?
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
label="继续编辑"
|
||||
onClick={() => setIsExitConfirmOpen(false)}
|
||||
/>
|
||||
<ActionButton
|
||||
label="确认退出"
|
||||
onClick={() => {
|
||||
setIsExitConfirmOpen(false);
|
||||
onClose();
|
||||
}}
|
||||
tone="sky"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PortalCompactDialogShell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldCoverEditor({
|
||||
profile,
|
||||
onSaveProfile,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
onSaveProfile: (profile: CustomWorldProfile) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draftCover, setDraftCover] = useDraft(
|
||||
profile.cover ?? buildDefaultCustomWorldCoverProfile(profile),
|
||||
);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const previewProfile = useMemo(
|
||||
() => ({
|
||||
...profile,
|
||||
cover: draftCover,
|
||||
}),
|
||||
[draftCover, profile],
|
||||
);
|
||||
const previewPresentation = useMemo(
|
||||
() => resolveCustomWorldCoverPresentation(previewProfile),
|
||||
[previewProfile],
|
||||
);
|
||||
|
||||
const handleUploadCover = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
try {
|
||||
const imageDataUrl = await readImageFileAsDataUrl(file);
|
||||
const result = await uploadCustomWorldCoverImage({
|
||||
profileId: profile.id,
|
||||
worldName: profile.name,
|
||||
imageDataUrl,
|
||||
});
|
||||
setDraftCover(buildGeneratedCoverProfile(result));
|
||||
} catch (uploadErrorValue) {
|
||||
setUploadError(
|
||||
uploadErrorValue instanceof Error
|
||||
? uploadErrorValue.message
|
||||
: '上传作品封面失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell title="编辑作品封面" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={previewPresentation.imageSrc}
|
||||
title={profile.name}
|
||||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||||
renderMode={previewPresentation.renderMode}
|
||||
characterImageSrcs={previewPresentation.characterImageSrcs}
|
||||
className="aspect-[16/9] rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
|
||||
{draftCover.sourceType === 'uploaded'
|
||||
? '当前为上传封面'
|
||||
: draftCover.sourceType === 'generated'
|
||||
? '当前为 AI 封面'
|
||||
: '当前为默认封面'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
|
||||
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
|
||||
上传封面
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
void handleUploadCover(event);
|
||||
}}
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="AI 生成"
|
||||
onClick={() => setIsGenerating(true)}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="重置为默认"
|
||||
onClick={() =>
|
||||
setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
|
||||
}
|
||||
disabled={draftCover.sourceType === 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploadError ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{uploadError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
onSaveProfile({
|
||||
...profile,
|
||||
cover: draftCover,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
{isGenerating ? (
|
||||
<CoverImageGenerationModal
|
||||
profile={previewProfile}
|
||||
onApply={(result) => {
|
||||
setDraftCover(buildGeneratedCoverProfile(result));
|
||||
setUploadError(null);
|
||||
setIsGenerating(false);
|
||||
}}
|
||||
onClose={() => setIsGenerating(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isUploading ? (
|
||||
<PortalCompactDialogShell
|
||||
title="上传封面中"
|
||||
onClose={() => {}}
|
||||
disableClose
|
||||
>
|
||||
<div className="rounded-2xl border border-sky-300/18 bg-sky-500/10 px-4 py-4 text-sm leading-6 text-sky-50">
|
||||
正在保存封面资源,请稍候。
|
||||
</div>
|
||||
</PortalCompactDialogShell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({
|
||||
onClose,
|
||||
onSave,
|
||||
@@ -4378,6 +4775,16 @@ function CustomWorldEntityEditorModal({
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'cover') {
|
||||
return (
|
||||
<WorldCoverEditor
|
||||
profile={profile}
|
||||
onSaveProfile={onProfileChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'camp') {
|
||||
return (
|
||||
<CampSceneEditor
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from './asset-studio/characterAssetWorkflowPersistence';
|
||||
import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRolePromptDefaults';
|
||||
import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference';
|
||||
import { useAuthUi } from './auth/AuthUiContext';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
|
||||
type EditableCustomWorldRole = {
|
||||
@@ -136,9 +137,15 @@ function ModalShell({
|
||||
disableClose?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4"
|
||||
className={`platform-overlay platform-theme ${platformThemeClass} fixed inset-0 z-[100] flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={
|
||||
disableClose
|
||||
? undefined
|
||||
@@ -150,7 +157,7 @@ function ModalShell({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.98),rgba(8,10,17,0.96))] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,58rem)] sm:rounded-[1.75rem]"
|
||||
className={`platform-modal-shell platform-role-studio platform-ui-shell platform-theme ${platformThemeClass} flex h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,58rem)] sm:rounded-[1.75rem]`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-4 py-4 sm:px-5">
|
||||
@@ -1146,7 +1153,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
<div className="space-y-5">
|
||||
<Section title="角色形象">
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))]">
|
||||
<div className="platform-role-studio__preview overflow-hidden rounded-3xl">
|
||||
<div className="flex min-h-[18rem] items-center justify-center p-4 sm:min-h-[22rem]">
|
||||
{previewImageSrc ? (
|
||||
<img
|
||||
@@ -1249,8 +1256,8 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
|
||||
<Section title="动作">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
|
||||
<div className="flex min-h-[28rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="platform-role-studio__preview rounded-3xl p-4">
|
||||
<div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4">
|
||||
{previewCharacter &&
|
||||
hasGeneratedAnimation(workingRole, selectedAnimation) ? (
|
||||
<div
|
||||
@@ -1402,7 +1409,7 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:mx-0 sm:rounded-3xl sm:border sm:px-4">
|
||||
<div className="platform-role-studio__footer sticky bottom-0 z-10 -mx-4 px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 sm:mx-0 sm:rounded-3xl sm:border sm:border-[var(--platform-subpanel-border)] sm:px-4">
|
||||
<div className="space-y-3">
|
||||
{saveStatus ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type CharacterChatUi,
|
||||
type GoalFlowUi,
|
||||
type InventoryFlowUi,
|
||||
type NpcChatQuestOfferUi,
|
||||
type QuestFlowUi,
|
||||
type StoryGenerationNpcUi,
|
||||
} from '../hooks/useStoryGeneration';
|
||||
@@ -53,6 +54,7 @@ interface GameShellStoryProps {
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
@@ -210,6 +212,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
} = story;
|
||||
const {
|
||||
@@ -545,6 +548,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
|
||||
@@ -24,8 +24,8 @@ function SelectionModal({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[#11161f] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-semibold text-white">{title}</div>
|
||||
<button
|
||||
|
||||
@@ -110,6 +110,8 @@ interface AdventurePanelOverlaysProps {
|
||||
selectedRewardEquipSlot: EquipmentSlotId | null;
|
||||
selectedQuestSceneName: string;
|
||||
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
|
||||
pendingNpcQuestOffer: QuestLogEntry | null;
|
||||
onAcceptPendingNpcQuestOffer: () => string | null;
|
||||
}
|
||||
|
||||
function compactSceneTaskLabel(sceneName: string | null | undefined, fallback: string) {
|
||||
@@ -828,6 +830,8 @@ export function AdventurePanelOverlays({
|
||||
selectedRewardEquipSlot,
|
||||
selectedQuestSceneName,
|
||||
getQuestStatusLabel,
|
||||
pendingNpcQuestOffer,
|
||||
onAcceptPendingNpcQuestOffer,
|
||||
}: AdventurePanelOverlaysProps) {
|
||||
const battleReward = battleRewardUi.reward;
|
||||
const sortedQuests = sortQuestsForGoalPanel(quests, goalStack);
|
||||
@@ -841,6 +845,9 @@ export function AdventurePanelOverlays({
|
||||
setSelectedRewardItemQuestId(quest.id);
|
||||
setSelectedRewardItemId(itemId);
|
||||
};
|
||||
const isPendingSelectedQuest = Boolean(
|
||||
selectedQuest && pendingNpcQuestOffer?.id === selectedQuest.id,
|
||||
);
|
||||
const closeGoalPanel = () => {
|
||||
setIsGoalPanelOpen(false);
|
||||
onDismissGoalPulse();
|
||||
@@ -1244,7 +1251,11 @@ export function AdventurePanelOverlays({
|
||||
quest={selectedQuest}
|
||||
worldType={worldType}
|
||||
sceneName={selectedQuestSceneName}
|
||||
statusLabel={getQuestStatusLabel(selectedQuest.status)}
|
||||
statusLabel={
|
||||
isPendingSelectedQuest
|
||||
? '待领取'
|
||||
: getQuestStatusLabel(selectedQuest.status)
|
||||
}
|
||||
/>
|
||||
<QuestRewardGrid
|
||||
quest={selectedQuest}
|
||||
@@ -1258,7 +1269,26 @@ export function AdventurePanelOverlays({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isQuestReadyToClaim(selectedQuest) && (
|
||||
{isPendingSelectedQuest && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const acceptedQuestId = onAcceptPendingNpcQuestOffer();
|
||||
if (!acceptedQuestId) return;
|
||||
setSelectedRewardItemQuestId(null);
|
||||
setSelectedRewardItemId(null);
|
||||
setSelectedBattleRewardItemId(null);
|
||||
}}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-white"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {paddingX: 14, paddingY: 8})}
|
||||
>
|
||||
领取任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isQuestReadyToClaim(selectedQuest) && !isPendingSelectedQuest && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,57 +1 @@
|
||||
export type PromptDefaultRole = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type CustomWorldRolePromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function pickFirstDescription(
|
||||
values: Array<string | undefined>,
|
||||
maxLength: number,
|
||||
) {
|
||||
for (const value of values) {
|
||||
const normalized = cleanSeedText(value, maxLength);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
return {
|
||||
visualPromptText: pickFirstDescription(
|
||||
[role.visualDescription, role.description],
|
||||
220,
|
||||
),
|
||||
animationPromptText: pickFirstDescription(
|
||||
[role.actionDescription, role.combatStyle],
|
||||
180,
|
||||
),
|
||||
scenePromptText: pickFirstDescription(
|
||||
[role.sceneVisualDescription, role.backstory],
|
||||
220,
|
||||
),
|
||||
};
|
||||
}
|
||||
export * from '../../prompts/customWorldRolePromptDefaults';
|
||||
|
||||
92
src/components/auth/AccountModal.test.tsx
Normal file
92
src/components/auth/AccountModal.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
|
||||
const baseUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
};
|
||||
|
||||
function renderAccountModal(overrides?: {
|
||||
user?: AuthUser;
|
||||
riskBlocks?: AuthRiskBlockSummary[];
|
||||
sessions?: AuthSessionSummary[];
|
||||
auditLogs?: AuthAuditLogEntry[];
|
||||
initialSection?: 'appearance' | 'account' | 'security' | 'devices' | 'logs' | null;
|
||||
}) {
|
||||
return render(
|
||||
<AccountModal
|
||||
user={overrides?.user ?? baseUser}
|
||||
isOpen
|
||||
initialSection={overrides?.initialSection ?? null}
|
||||
platformTheme="light"
|
||||
riskBlocks={overrides?.riskBlocks ?? []}
|
||||
sessions={overrides?.sessions ?? []}
|
||||
auditLogs={overrides?.auditLogs ?? []}
|
||||
loadingRiskBlocks={false}
|
||||
loadingSessions={false}
|
||||
loadingAuditLogs={false}
|
||||
isHydratingSettings={false}
|
||||
isPersistingSettings={false}
|
||||
settingsError={null}
|
||||
onClose={vi.fn()}
|
||||
onPlatformThemeChange={vi.fn()}
|
||||
onLogout={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshRiskBlocks={vi.fn().mockResolvedValue(undefined)}
|
||||
onLiftRiskBlock={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
||||
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
||||
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
|
||||
changePhoneCaptchaChallenge={null}
|
||||
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
})}
|
||||
onChangePhone={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
test('settings header uses a generic title instead of the phone number', () => {
|
||||
renderAccountModal();
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
|
||||
expect(screen.getByText('设置与账号安全')).toBeTruthy();
|
||||
expect(screen.queryByText('138****8000')).toBeNull();
|
||||
});
|
||||
|
||||
test('account actions open in independent panels instead of inline expansion', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(accountDialog).toBeTruthy();
|
||||
expect(within(accountDialog).getByRole('button', { name: '返回' })).toBeTruthy();
|
||||
expect(within(accountDialog).getByRole('button', { name: '更换手机号' })).toBeTruthy();
|
||||
expect(screen.queryByLabelText('新手机号')).toBeNull();
|
||||
|
||||
await user.click(within(accountDialog).getByRole('button', { name: '更换手机号' }));
|
||||
|
||||
const changePhoneDialog = screen.getByRole('dialog', { name: '绑定新手机号' });
|
||||
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
|
||||
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [initialSettingsSection, setInitialSettingsSection] =
|
||||
useState<PlatformSettingsSection>('appearance');
|
||||
useState<PlatformSettingsSection | null>(null);
|
||||
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
@@ -123,9 +123,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
);
|
||||
|
||||
const openSettingsModal = useCallback(
|
||||
(section: PlatformSettingsSection = 'appearance') => {
|
||||
(section?: PlatformSettingsSection) => {
|
||||
if (readyUser) {
|
||||
setInitialSettingsSection(section);
|
||||
setInitialSettingsSection(section ?? null);
|
||||
setShowSettingsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function CustomWorldAgentClarificationPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -44,7 +44,7 @@ export function CustomWorldAgentComposer({
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div className="relative">
|
||||
<div className="platform-remap-surface relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function CustomWorldAgentDraftDetailPanel({
|
||||
onOpenRoleAssetStudio,
|
||||
}: CustomWorldAgentDraftDetailPanelProps) {
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -38,7 +38,7 @@ export function CustomWorldAgentDraftDrawer({
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
草稿抽屉
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ type CustomWorldAgentHeaderProps = {
|
||||
|
||||
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<div className="platform-remap-surface flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function CustomWorldAgentIntentSummaryPanel({
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -24,8 +24,8 @@ export function CustomWorldAgentLauncherModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
|
||||
@@ -28,7 +28,7 @@ export function CustomWorldAgentLockBar({
|
||||
const lockedItems = readLockedItems(lockState);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
锁定内容
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function CustomWorldAgentOperationBanner({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-[1.4rem] border px-4 py-4 ${
|
||||
className={`platform-remap-surface rounded-[1.4rem] border px-4 py-4 ${
|
||||
isFailed
|
||||
? 'border-rose-400/20 bg-[#111318]/95'
|
||||
: isRunning
|
||||
|
||||
@@ -67,7 +67,7 @@ export function CustomWorldAgentQuickActions({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
快捷动作
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function CustomWorldAgentSummaryPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -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 px-1 py-2 sm:px-2">
|
||||
<div className="platform-remap-surface 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">
|
||||
暂无消息
|
||||
|
||||
@@ -42,7 +42,7 @@ export function CustomWorldAgentWorkspace({
|
||||
}: CustomWorldAgentWorkspaceProps) {
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
|
||||
<div className="platform-remap-surface mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
|
||||
正在恢复
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CustomWorldDraftCardDetailModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[95] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm xl:hidden">
|
||||
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭卡片详情"
|
||||
|
||||
@@ -40,14 +40,14 @@ export function CustomWorldGenerateEntityModal({
|
||||
const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[96] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center">
|
||||
<div className="platform-overlay fixed inset-0 z-[96] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭新增弹窗"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-xl rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(8,10,14,0.96))] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
|
||||
<div className="platform-modal-shell platform-remap-surface relative z-10 w-full max-w-xl rounded-[1.8rem] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
|
||||
@@ -48,7 +48,7 @@ export function EightAnchorProgressBar({
|
||||
const canQuickFill = currentTurn >= 2;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -21,7 +21,7 @@ type CustomWorldCreationHubProps = {
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
|
||||
<div className="platform-remap-surface flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-white">{title}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -56,7 +56,13 @@ export function CustomWorldCreationHub({
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div className="sticky top-0 z-20 -mx-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.88),rgba(10,12,18,0))] px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0">
|
||||
<div
|
||||
className="platform-remap-surface sticky top-0 z-20 -mx-3 px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, var(--platform-modal-fill), transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<button
|
||||
|
||||
@@ -42,8 +42,8 @@ export function CustomWorldCreationLauncherModal({
|
||||
const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
@@ -37,15 +38,14 @@ export function CustomWorldWorkCard({
|
||||
paddingY: 15,
|
||||
})}
|
||||
>
|
||||
{item.coverImageSrc ? (
|
||||
<img
|
||||
src={item.coverImageSrc}
|
||||
alt={item.title}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-20"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={item.coverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel={item.title.slice(0, 4) || '封面'}
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CustomWorldWorkTabs({
|
||||
onChange,
|
||||
}: CustomWorldWorkTabsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const count =
|
||||
option.id === 'draft'
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
@@ -44,7 +45,7 @@ const GameShellStoryPanels = lazy(async () => {
|
||||
function MainContentLoadingFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,6 +81,7 @@ export function GameShellMainContent({
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
companionRenderStates,
|
||||
characterChatSummaries,
|
||||
@@ -120,6 +122,7 @@ export function GameShellMainContent({
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
characterChatSummaries: Record<string, string>;
|
||||
@@ -214,6 +217,7 @@ export function GameShellMainContent({
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalUi={goalUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
} = story;
|
||||
const {
|
||||
@@ -169,6 +170,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
inventoryUi={inventoryUi}
|
||||
battleRewardUi={battleRewardUi}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalUi={goalUi}
|
||||
companionRenderStates={companionRenderStates}
|
||||
characterChatSummaries={characterChatSummaries}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
import type { CompanionRenderState, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
@@ -57,6 +58,7 @@ export function GameShellStoryPanels({
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
companionRenderStates,
|
||||
characterChatSummaries,
|
||||
@@ -85,6 +87,7 @@ export function GameShellStoryPanels({
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
characterChatSummaries: Record<string, string>;
|
||||
@@ -193,6 +196,7 @@ export function GameShellStoryPanels({
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
|
||||
19
src/components/game-shell/PlatformBrandLogo.tsx
Normal file
19
src/components/game-shell/PlatformBrandLogo.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function PlatformBrandLogo({
|
||||
className = '',
|
||||
decorative = false,
|
||||
}: {
|
||||
className?: string;
|
||||
decorative?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`platform-brand-logo ${className}`.trim()}
|
||||
role={decorative ? undefined : 'img'}
|
||||
aria-hidden={decorative || undefined}
|
||||
aria-label={decorative ? undefined : '叙世 GENARRATIVE'}
|
||||
>
|
||||
<span className="platform-brand-logo__title">叙世</span>
|
||||
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import type { AuthUser } from '../../services/authService';
|
||||
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformBrandLogo } from './PlatformBrandLogo';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
@@ -47,6 +48,8 @@ export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
|
||||
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
|
||||
const HERO_SURFACE_CLASS =
|
||||
'platform-surface platform-surface--hero platform-interactive-card';
|
||||
const MOBILE_PAGE_STAGE_CLASS = 'platform-page-stage space-y-4 pb-2';
|
||||
const DESKTOP_PAGE_STAGE_CLASS = 'platform-page-stage space-y-5 pb-4';
|
||||
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
@@ -67,6 +70,39 @@ function EmptyShelf({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SaveArchivePreview({
|
||||
entry,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
entry: ProfileSaveArchiveSummary;
|
||||
label: string;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[0_16px_36px_rgba(15,23,42,0.18)] ${className}`}
|
||||
>
|
||||
{entry.coverImageSrc ? (
|
||||
<img
|
||||
src={entry.coverImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.06),rgba(8,10,14,0.74))]" />
|
||||
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
|
||||
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldCard({
|
||||
entry,
|
||||
badge,
|
||||
@@ -155,6 +191,92 @@ function WorldCard({
|
||||
);
|
||||
}
|
||||
|
||||
function CreationLibraryCard({
|
||||
entry,
|
||||
onClick,
|
||||
}: {
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const statusLabel = entry.visibility === 'published' ? '已发布' : '草稿';
|
||||
const metaLabel =
|
||||
entry.visibility === 'published'
|
||||
? formatPlatformWorldTime(entry.publishedAt)
|
||||
: '仅自己可见';
|
||||
const primaryTag =
|
||||
buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean)[0] ?? describePlatformThemeLabel(entry.themeMode);
|
||||
const summaryText =
|
||||
entry.summaryText || entry.subtitle || '继续补完这个世界的设定与游玩入口。';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="platform-surface platform-interactive-card relative flex min-h-[13rem] w-full min-w-0 flex-col overflow-hidden px-3 py-3 text-left sm:min-h-[14rem] sm:px-3.5 sm:py-3.5"
|
||||
>
|
||||
{coverImage ? (
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<img
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-1.5 right-1.5 h-16 w-16 object-contain opacity-24 sm:h-20 sm:w-20"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.92))]" />
|
||||
<div className="relative z-10 flex h-full min-w-0 flex-col">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-[10px] font-semibold tracking-[0.12em] ${
|
||||
entry.visibility === 'published'
|
||||
? 'border-emerald-300/25 bg-emerald-500/12 text-emerald-50'
|
||||
: 'border-amber-300/25 bg-amber-500/12 text-amber-50'
|
||||
}`}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-zinc-300">
|
||||
<span className="truncate">{metaLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto min-w-0">
|
||||
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-white sm:text-[1.12rem]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-zinc-300/84">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-zinc-200/88 sm:text-xs">
|
||||
{summaryText}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-zinc-100/90">
|
||||
<span className="truncate">{primaryTag}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
|
||||
<span>{entry.visibility === 'published' ? '进入世界' : '继续创作'}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveArchiveCard({
|
||||
entry,
|
||||
onClick,
|
||||
@@ -164,40 +286,46 @@ function SaveArchiveCard({
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const summaryText = entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[12.5rem] w-full overflow-hidden p-4 text-left ${loading ? 'opacity-80' : ''}`}
|
||||
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
|
||||
>
|
||||
{entry.coverImageSrc ? (
|
||||
<img
|
||||
src={entry.coverImageSrc}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-24"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.08),rgba(8,10,14,0.92))]" />
|
||||
<div className="relative z-10 flex h-full w-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.14),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.14),transparent_28%),linear-gradient(180deg,rgba(8,10,14,0.22),rgba(8,10,14,0.9))]" />
|
||||
<div className="relative z-10 flex h-full w-full flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="line-clamp-1 text-xl font-black text-white">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-sm text-zinc-300">
|
||||
{entry.subtitle}
|
||||
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-white sm:text-xl">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 break-words text-sm text-zinc-300">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-zinc-400 sm:text-sm">
|
||||
{summaryText}
|
||||
</div>
|
||||
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
|
||||
<span>{loading ? '正在恢复' : '继续游玩'}</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
|
||||
{entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。'}
|
||||
</div>
|
||||
<SaveArchivePreview
|
||||
entry={entry}
|
||||
label={loading ? '恢复中' : '最近存档'}
|
||||
className="h-[7.4rem] w-[5.6rem] sm:h-[8rem] sm:w-[6.4rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -222,24 +350,10 @@ function PlatformTabButton({
|
||||
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||||
>
|
||||
<span className="flex flex-col items-center justify-center gap-1">
|
||||
<span
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
||||
active
|
||||
? 'bg-[rgba(255,255,255,0.78)] text-[var(--platform-text-strong)] shadow-[0_12px_24px_rgba(255,91,132,0.16)]'
|
||||
: 'bg-[rgba(255,255,255,0.46)] text-[var(--platform-text-soft)]'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-[1.05rem] w-[1.05rem] ${
|
||||
active ? 'text-violet-100' : 'text-zinc-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="platform-bottom-nav__icon-shell">
|
||||
<Icon className="platform-bottom-nav__icon h-[1.05rem] w-[1.05rem]" />
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold tracking-[0.18em] ${
|
||||
active ? 'text-white' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
<span className="platform-bottom-nav__label text-[11px] font-semibold tracking-[0.18em]">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
@@ -264,18 +378,10 @@ function DesktopTabButton({
|
||||
onClick={onClick}
|
||||
className={`platform-desktop-rail__button ${active ? 'platform-desktop-rail__button--active' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-full ${
|
||||
active ? 'bg-white/18 text-white' : 'bg-white/58 text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-[1.1rem] w-[1.1rem]" />
|
||||
<span className="platform-desktop-rail__icon-shell">
|
||||
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold tracking-[0.2em] ${
|
||||
active ? 'text-white' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
<span className="platform-desktop-rail__label text-[11px] font-semibold tracking-[0.2em]">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -469,7 +575,7 @@ function ProfileStatCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-white/92"
|
||||
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<Icon className="h-4 w-4" />
|
||||
@@ -504,9 +610,9 @@ function ProfileShortcutButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ?? undefined}
|
||||
className="platform-subpanel flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-white/92"
|
||||
className="platform-subpanel flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
|
||||
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">{label}</div>
|
||||
@@ -549,17 +655,14 @@ export function PlatformHomeView({
|
||||
latestEntries: CustomWorldGalleryCard[];
|
||||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
historyEntries: PlatformBrowseHistoryEntry[];
|
||||
historyError: string | null;
|
||||
profileDashboard: ProfileDashboardSummary | null;
|
||||
isLoadingPlatform: boolean;
|
||||
isLoadingDashboard: boolean;
|
||||
isClearingHistory: boolean;
|
||||
isResumingSaveWorldKey: string | null;
|
||||
platformError: string | null;
|
||||
dashboardError: string | null;
|
||||
onContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
|
||||
onClearHistory: () => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
@@ -622,10 +725,17 @@ export function PlatformHomeView({
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
|
||||
let content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
onClick={() => {
|
||||
if (hasSavedGame) {
|
||||
onContinueGame();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,47,112,0.92),rgba(255,136,104,0.9))]" />
|
||||
@@ -707,7 +817,7 @@ export function PlatformHomeView({
|
||||
|
||||
if (activeTab === 'create') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
@@ -736,18 +846,12 @@ export function PlatformHomeView({
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<WorldCard
|
||||
<CreationLibraryCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
metaLabel={
|
||||
entry.visibility === 'published'
|
||||
? formatPlatformWorldTime(entry.publishedAt)
|
||||
: '仅自己可见'
|
||||
}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
@@ -769,22 +873,15 @@ export function PlatformHomeView({
|
||||
|
||||
if (activeTab === 'saves') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
<section
|
||||
className={`${HERO_SURFACE_CLASS} relative overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
{latestSaveEntry?.coverImageSrc ? (
|
||||
<img
|
||||
src={latestSaveEntry.coverImageSrc}
|
||||
alt={latestSaveEntry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-28"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,92,120,0.92),rgba(255,139,98,0.9))]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="relative z-10 flex min-h-[10.5rem] flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--cool">
|
||||
SAVE ARCHIVE
|
||||
</span>
|
||||
@@ -792,15 +889,24 @@ export function PlatformHomeView({
|
||||
{saveEntries.length > 0 ? `${saveEntries.length} 个存档` : '暂无存档'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
{latestSaveEntry ? latestSaveEntry.worldName : '存档'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[30rem] text-sm leading-6 text-zinc-200/88">
|
||||
{latestSaveEntry
|
||||
? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。`
|
||||
: '你在平台里留下的最近可恢复存档会显示在这里。'}
|
||||
<div className="flex min-w-0 items-stretch gap-3 sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 break-words text-[1.95rem] font-black leading-[1.02] text-white sm:text-3xl">
|
||||
{latestSaveEntry ? latestSaveEntry.worldName : '存档'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
|
||||
{latestSaveEntry
|
||||
? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。`
|
||||
: '你在平台里留下的最近可恢复存档会显示在这里。'}
|
||||
</div>
|
||||
</div>
|
||||
{latestSaveEntry ? (
|
||||
<SaveArchivePreview
|
||||
entry={latestSaveEntry}
|
||||
label="最近更新"
|
||||
className="h-[8.8rem] w-[6.1rem] sm:h-[9.4rem] sm:w-[7rem]"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -851,54 +957,54 @@ export function PlatformHomeView({
|
||||
|
||||
if (activeTab === 'profile') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
<section className="overflow-hidden rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(248,244,236,0.96),rgba(232,225,214,0.92))] p-4 text-slate-900 shadow-[0_18px_50px_rgba(0,0,0,0.18)]">
|
||||
<section className="platform-profile-hero rounded-[1.8rem] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="relative h-16 w-16 shrink-0 rounded-[1.4rem] bg-[linear-gradient(135deg,#2a3141,#66718a)] text-white shadow-[0_12px_24px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="platform-profile-avatar relative h-16 w-16 shrink-0 rounded-[1.4rem]"
|
||||
>
|
||||
<span className="flex h-full w-full items-center justify-center text-2xl font-black">
|
||||
{avatarLabel}
|
||||
</span>
|
||||
<span className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full border border-white/30 bg-white/85 text-slate-700">
|
||||
<span className="platform-profile-camera absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full">
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xl font-black text-slate-900">
|
||||
<div className="truncate text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{authUi.user.displayName}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-900/6 text-slate-700 transition hover:bg-slate-900/10"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="platform-profile-icon-button flex h-7 w-7 items-center justify-center rounded-full"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--platform-text-soft)]">
|
||||
<span>叙世号 {publicUserCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(publicUserCode)}
|
||||
className="flex items-center gap-1 rounded-full bg-slate-900/6 px-2 py-1 text-slate-700 transition hover:bg-slate-900/10"
|
||||
className="platform-profile-chip flex items-center gap-1 rounded-full px-2 py-1"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
|
||||
<span className="platform-profile-chip rounded-full px-2.5 py-1">
|
||||
{describeLoginMethod(authUi.user.loginMethod)}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
|
||||
<span className="platform-profile-chip rounded-full px-2.5 py-1">
|
||||
{describeBindingStatus(authUi.user.bindingStatus)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -907,12 +1013,12 @@ export function PlatformHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-2 rounded-[1.1rem] bg-[linear-gradient(135deg,#ff4f8b,#ff8a73)] px-3 py-2 text-left text-white shadow-[0_12px_24px_rgba(255,79,139,0.24)]"
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">会员充值</div>
|
||||
<div className="text-[10px] text-white/80">普通用户</div>
|
||||
<div className="text-[10px] opacity-80">普通用户</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -997,18 +1103,16 @@ export function PlatformHomeView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openSettingsModal()}
|
||||
className="platform-subpanel platform-interactive-card flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-white/92"
|
||||
className="platform-subpanel platform-interactive-card flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
|
||||
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<Settings className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
设置
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">主题与账号</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">设置</div>
|
||||
<div className="text-xs text-zinc-400">主题与账号</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||
</button>
|
||||
@@ -1034,7 +1138,7 @@ export function PlatformHomeView({
|
||||
|
||||
const desktopContent =
|
||||
activeTab === 'home' ? (
|
||||
<div className="space-y-5 pb-4">
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||||
{platformError ? (
|
||||
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{platformError}
|
||||
@@ -1044,7 +1148,14 @@ export function PlatformHomeView({
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
onClick={() => {
|
||||
if (hasSavedGame) {
|
||||
onContinueGame();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
|
||||
>
|
||||
{desktopHeroCover ? (
|
||||
@@ -1172,7 +1283,14 @@ export function PlatformHomeView({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
onClick={() => {
|
||||
if (hasSavedGame) {
|
||||
onContinueGame();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
className="platform-surface platform-surface--soft platform-interactive-card relative block w-full overflow-hidden px-5 py-5 text-left"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,164,198,0.18),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(255,202,176,0.18),transparent_32%)]" />
|
||||
@@ -1315,10 +1433,7 @@ export function PlatformHomeView({
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col lg:hidden">
|
||||
<div className="mb-4">
|
||||
<div className="platform-brand-logo" aria-label="叙世 GENARRATIVE">
|
||||
<div className="platform-brand-logo__title">叙世</div>
|
||||
<div className="platform-brand-logo__subtitle">GENARRATIVE</div>
|
||||
</div>
|
||||
<PlatformBrandLogo />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||
@@ -1362,10 +1477,10 @@ export function PlatformHomeView({
|
||||
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
|
||||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||||
<div className="platform-brand-logo shrink-0" aria-hidden="true">
|
||||
<div className="platform-brand-logo__title">叙世</div>
|
||||
<div className="platform-brand-logo__subtitle">GENARRATIVE</div>
|
||||
</div>
|
||||
<PlatformBrandLogo
|
||||
className="shrink-0"
|
||||
decorative
|
||||
/>
|
||||
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getCustomWorldAgentSession,
|
||||
streamCustomWorldAgentMessage,
|
||||
} from '../../services/aiService';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
@@ -29,14 +30,30 @@ import {
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
type PlatformSettingsSection,
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from '../auth/AuthUiContext';
|
||||
import {
|
||||
PreGameSelectionFlow,
|
||||
type SelectionStage,
|
||||
} from './PreGameSelectionFlow';
|
||||
|
||||
async function clickFirstButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
) {
|
||||
const buttons = screen.getAllByRole('button', { name });
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
async function clickFirstAsyncButtonByName(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
name: string | RegExp,
|
||||
) {
|
||||
const buttons = await screen.findAllByRole('button', { name });
|
||||
await user.click(buttons[0]!);
|
||||
}
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
createCustomWorldAgentSession: vi.fn(),
|
||||
executeCustomWorldAgentAction: vi.fn(),
|
||||
@@ -204,6 +221,26 @@ type TestAuthValue = {
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue {
|
||||
return {
|
||||
user: mockAuthUser,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function TestWrapper({
|
||||
withAuth = false,
|
||||
authValue,
|
||||
@@ -211,7 +248,7 @@ function TestWrapper({
|
||||
}: {
|
||||
withAuth?: boolean;
|
||||
authValue?: TestAuthValue;
|
||||
onContinueGame?: (snapshot?: unknown) => void;
|
||||
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
} = {}) {
|
||||
const [selectionStage, setSelectionStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
@@ -235,24 +272,7 @@ function TestWrapper({
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={
|
||||
authValue ?? {
|
||||
user: mockAuthUser,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: () => {},
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}
|
||||
}
|
||||
value={authValue ?? createAuthValue()}
|
||||
>
|
||||
{content}
|
||||
</AuthUiContext.Provider>
|
||||
@@ -292,7 +312,7 @@ beforeEach(() => {
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {} as GameState,
|
||||
},
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
@@ -351,8 +371,8 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
|
||||
expect(screen.getByText('选择创作类型')).toBeTruthy();
|
||||
|
||||
@@ -399,14 +419,11 @@ test('clicking a public work while logged out routes through requireAuth', async
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={{
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -425,19 +442,16 @@ test('selecting RPG creation while logged out routes through requireAuth', async
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={{
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
openLoginModal: () => {},
|
||||
requireAuth,
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
}}
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
@@ -449,8 +463,8 @@ test('starting draft generation leaves the agent workspace and shows the generat
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
expect(
|
||||
@@ -582,8 +596,8 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(screen.getByRole('button', { name: /开启新的创作/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstButtonByName(user, /开启新的创作/u);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
|
||||
await waitFor(
|
||||
@@ -608,8 +622,6 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
});
|
||||
|
||||
test('authenticated users with save archives default into the saves tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'custom:world-1',
|
||||
@@ -626,9 +638,9 @@ test('authenticated users with save archives default into the saves tab', async
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(await screen.findByText('全部存档')).toBeTruthy();
|
||||
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
|
||||
expect(screen.getByText('最近更新时间排序')).toBeTruthy();
|
||||
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('save tab can resume a selected archive directly into the game', async () => {
|
||||
@@ -668,12 +680,12 @@ test('save tab can resume a selected archive directly into the game', async () =
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
} as GameState,
|
||||
},
|
||||
} as HydratedSavedGameSnapshot,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /潮雾列岛/u }));
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
|
||||
@@ -719,8 +731,8 @@ test('owned world detail can delete a work and return to the create tab list', a
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '创作' }));
|
||||
await user.click(await screen.findByRole('button', { name: /潮雾列岛/u }));
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
|
||||
await user.click(await screen.findByRole('button', { name: '删除作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -731,6 +743,7 @@ test('owned world detail can delete a work and return to the create tab list', a
|
||||
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
|
||||
});
|
||||
expect(
|
||||
screen.getByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。'),
|
||||
screen.getAllByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。')
|
||||
.length,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
clearPlatformBrowseHistory,
|
||||
hasPendingPlatformBrowseHistoryMigration,
|
||||
markPlatformBrowseHistoryMigrated,
|
||||
type PlatformBrowseHistoryEntry,
|
||||
@@ -56,7 +55,6 @@ import {
|
||||
writePlatformBrowseHistory,
|
||||
} from '../../services/platformBrowseHistory';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
deleteCustomWorldProfile,
|
||||
getCustomWorldGalleryDetail,
|
||||
getProfileDashboard,
|
||||
@@ -171,7 +169,7 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
|
||||
function LazyPanelFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,12 +228,11 @@ export function PreGameSelectionFlow({
|
||||
const [profileDashboard, setProfileDashboard] =
|
||||
useState<ProfileDashboardSummary | null>(null);
|
||||
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [_historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
|
||||
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
|
||||
const [isClearingHistory, setIsClearingHistory] = useState(false);
|
||||
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -1049,31 +1046,6 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearBrowseHistory = async () => {
|
||||
if (isClearingHistory || historyEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm('确认清空全部浏览历史吗?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsClearingHistory(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
clearPlatformBrowseHistory(authUi?.user);
|
||||
if (authUi?.user) {
|
||||
await clearProfileBrowseHistory();
|
||||
}
|
||||
setHistoryEntries([]);
|
||||
} catch (error) {
|
||||
setHistoryError(resolveErrorMessage(error, '清空浏览历史失败。'));
|
||||
} finally {
|
||||
setIsClearingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeSaveEntry = useCallback(
|
||||
async (entry: ProfileSaveArchiveSummary) => {
|
||||
if (!authUi?.user || isResumingSaveWorldKey) {
|
||||
@@ -1318,11 +1290,9 @@ export function PreGameSelectionFlow({
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
historyEntries={historyEntries}
|
||||
historyError={historyError}
|
||||
profileDashboard={profileDashboard}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isClearingHistory={isClearingHistory}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
@@ -1332,9 +1302,6 @@ export function PreGameSelectionFlow({
|
||||
onResumeSave={(entry) => {
|
||||
void handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onClearHistory={() => {
|
||||
void handleClearBrowseHistory();
|
||||
}}
|
||||
onOpenCreateWorld={openCustomWorldCreator}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
@@ -1366,7 +1333,7 @@ export function PreGameSelectionFlow({
|
||||
>
|
||||
{isDetailLoading || !selectedDetailEntry ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{detailError || '正在读取作品详情...'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1452,7 +1419,7 @@ export function PreGameSelectionFlow({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
{isLoadingAgentSession
|
||||
? '正在准备 Agent 共创工作区...'
|
||||
: creationTypeError || '正在恢复创作工作区...'}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
StoryGenerationNpcUi,
|
||||
} from '../../hooks/useStoryGeneration';
|
||||
@@ -41,6 +42,7 @@ export interface GameShellStoryProps {
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
const {
|
||||
resolveServerRuntimeChoiceMock,
|
||||
streamNpcChatTurnMock,
|
||||
generateQuestForNpcEncounterMock,
|
||||
} = vi.hoisted(() => ({
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
streamNpcChatTurnMock: vi.fn(),
|
||||
generateQuestForNpcEncounterMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
streamNpcChatTurn: streamNpcChatTurnMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/questDirector', () => ({
|
||||
generateQuestForNpcEncounter: generateQuestForNpcEncounterMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type QuestLogEntry,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { createStoryNpcEncounterActions } from './npcEncounterActions';
|
||||
|
||||
function createCharacter(): Character {
|
||||
@@ -30,6 +61,7 @@ function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-rival',
|
||||
kind: 'npc',
|
||||
characterId: 'char-rival',
|
||||
npcName: '断桥客',
|
||||
npcDescription: '拦路的旧敌',
|
||||
npcAvatar: '/npc.png',
|
||||
@@ -163,11 +195,144 @@ function createCurrentChatStory(): StoryMoment {
|
||||
};
|
||||
}
|
||||
|
||||
function createQuest(id: string, title: string): QuestLogEntry {
|
||||
return {
|
||||
id,
|
||||
issuerNpcId: 'npc-rival',
|
||||
issuerNpcName: '断桥客',
|
||||
sceneId: 'scene-bridge',
|
||||
title,
|
||||
description: `${title}的详细说明。`,
|
||||
summary: `${title}的简要目标。`,
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
reward: {
|
||||
affinityBonus: 6,
|
||||
currency: 30,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '完成后可以领取报酬。',
|
||||
steps: [
|
||||
{
|
||||
id: `${id}-step-1`,
|
||||
title: '查清线索',
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
progress: 0,
|
||||
revealText: '先去断桥口附近看看留下了什么痕迹。',
|
||||
completeText: '线索已经查清。',
|
||||
},
|
||||
],
|
||||
activeStepId: `${id}-step-1`,
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingQuestOfferStory(quest = createQuest('quest-bridge', '断桥旧案')): StoryMoment {
|
||||
return {
|
||||
text: '断桥客终于把真正的委托说了出来。',
|
||||
options: [
|
||||
createOption('npc_chat_quest_offer_view', '查看任务'),
|
||||
createOption('npc_chat_quest_offer_replace', '更换任务'),
|
||||
createOption('npc_chat_quest_offer_abandon', '放弃任务'),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '这件事我只想托给你。',
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: {
|
||||
quest,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAcceptedPendingQuestStory(
|
||||
quest = createQuest('quest-bridge', '断桥旧案'),
|
||||
): StoryMoment {
|
||||
return {
|
||||
text: [
|
||||
'这件事我只想托给你。',
|
||||
'这件事我愿意接下,你把关键要点交给我。',
|
||||
'那就拜托你了。先去断桥口附近看看留下了什么痕迹。',
|
||||
].join('\n'),
|
||||
options: [
|
||||
createOption('npc_chat', '这件事里你最担心哪一步', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_chat', '我回来时你最想先知道什么', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_chat', '除了这份委托,你还想提醒我什么', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '这件事我只想托给你。',
|
||||
},
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '这件事我愿意接下,你把关键要点交给我。',
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text:
|
||||
quest.steps?.[0]?.revealText?.trim() &&
|
||||
quest.steps[0].revealText.trim().length > 0
|
||||
? `那就拜托你了。${quest.steps[0].revealText}`
|
||||
: `那就拜托你了。${quest.summary}`,
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type GenerateStoryForStateTestDouble = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
function createNpcEncounterActions(overrides: {
|
||||
gameState?: GameState;
|
||||
currentStory?: StoryMoment | null;
|
||||
generateStoryForState?: ReturnType<typeof vi.fn>;
|
||||
getAvailableOptionsForState?: ReturnType<typeof vi.fn>;
|
||||
generateStoryForState?: GenerateStoryForStateTestDouble;
|
||||
getAvailableOptionsForState?: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
}) {
|
||||
const gameState = overrides.gameState ?? createState();
|
||||
const currentStory = overrides.currentStory ?? createCurrentChatStory();
|
||||
@@ -231,15 +396,15 @@ function createNpcEncounterActions(overrides: {
|
||||
})),
|
||||
generateStoryForState:
|
||||
overrides.generateStoryForState ??
|
||||
vi.fn().mockResolvedValue({
|
||||
((vi.fn().mockResolvedValue({
|
||||
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
|
||||
options: [createOption('idle_observe_signs', '观察周围动静')],
|
||||
}),
|
||||
}) as unknown) as GenerateStoryForStateTestDouble),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getTypewriterDelay: vi.fn(() => 0),
|
||||
getAvailableOptionsForState:
|
||||
overrides.getAvailableOptionsForState ??
|
||||
vi.fn(() => [
|
||||
(((vi.fn(() => [
|
||||
createOption('npc_chat', '先问问你为什么堵在这里', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
@@ -250,15 +415,39 @@ function createNpcEncounterActions(overrides: {
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
]),
|
||||
]) as unknown) as (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[])),
|
||||
sanitizeOptions: vi.fn((options: StoryOption[]) => options),
|
||||
sortOptions: vi.fn((options: StoryOption[]) => options),
|
||||
buildContinueAdventureOption: vi.fn(() =>
|
||||
createOption('story_continue_adventure', '继续'),
|
||||
),
|
||||
getNpcEncounterKey: vi.fn((encounter: Encounter) => encounter.id ?? encounter.npcName),
|
||||
getResolvedNpcState: vi.fn((state: GameState, encounter: Encounter) => state.npcStates[encounter.id ?? encounter.npcName]),
|
||||
updateNpcState: vi.fn((state: GameState) => state),
|
||||
getResolvedNpcState: vi.fn(
|
||||
(state: GameState, encounter: Encounter) =>
|
||||
state.npcStates[encounter.id ?? encounter.npcName]!,
|
||||
),
|
||||
updateNpcState: vi.fn(
|
||||
(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (
|
||||
npcState: GameState['npcStates'][string],
|
||||
) => GameState['npcStates'][string],
|
||||
) => {
|
||||
const encounterKey = encounter.id ?? encounter.npcName;
|
||||
const currentNpcState = state.npcStates[encounterKey]!;
|
||||
return {
|
||||
...state,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[encounterKey]: updater(currentNpcState),
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
cloneInventoryItemForOwner: vi.fn(),
|
||||
resolveNpcInteractionDecision: vi.fn(() => ({ kind: 'default' })),
|
||||
npcInteractionFlow: {
|
||||
@@ -280,7 +469,124 @@ function createNpcEncounterActions(overrides: {
|
||||
};
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe('npcEncounterActions', () => {
|
||||
beforeEach(() => {
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
streamNpcChatTurnMock.mockReset();
|
||||
generateQuestForNpcEncounterMock.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['npc_help', '请求援手', 'help'],
|
||||
['npc_leave', '先离开这里', 'leave'],
|
||||
['npc_fight', '直接动手', 'fight'],
|
||||
['npc_spar', '先切磋一回', 'spar'],
|
||||
])(
|
||||
'delegates %s to the server runtime resolver instead of resolving locally',
|
||||
async (functionId, actionText, action) => {
|
||||
const nextGameState = createState({
|
||||
playerHp: 88,
|
||||
npcInteractionActive: action === 'leave' ? false : true,
|
||||
});
|
||||
const nextStory = {
|
||||
text: `server:${functionId}`,
|
||||
options: [createOption('idle_observe_signs', '观察周围动静')],
|
||||
} satisfies StoryMoment;
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: nextGameState,
|
||||
},
|
||||
nextStory,
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({});
|
||||
|
||||
expect(
|
||||
actions.handleNpcInteraction(
|
||||
createOption(functionId, actionText, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: action as 'help' | 'leave' | 'fight' | 'spar',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: actions.gameState,
|
||||
currentStory: actions.currentStory,
|
||||
option: expect.objectContaining({
|
||||
functionId,
|
||||
actionText,
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(actions.setGameState).toHaveBeenCalledWith(nextGameState);
|
||||
expect(actions.setCurrentStory).toHaveBeenCalledWith(nextStory);
|
||||
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
|
||||
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: createState({
|
||||
quests: [],
|
||||
}),
|
||||
},
|
||||
nextStory: {
|
||||
text: '后端已完成任务交付结算。',
|
||||
options: [createOption('npc_leave', '离开当前角色')],
|
||||
} satisfies StoryMoment,
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({});
|
||||
|
||||
expect(
|
||||
actions.handleNpcInteraction(
|
||||
createOption('npc_quest_turn_in', '交付委托', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_turn_in',
|
||||
questId: 'quest-bridge',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_quest_turn_in',
|
||||
interaction: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_turn_in',
|
||||
questId: 'quest-bridge',
|
||||
}),
|
||||
}),
|
||||
payload: {
|
||||
questId: 'quest-bridge',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-runs story reasoning after exiting npc chat and appends the new story to history', async () => {
|
||||
const gameState = createState({
|
||||
storyHistory: [
|
||||
@@ -323,9 +629,7 @@ describe('npcEncounterActions', () => {
|
||||
});
|
||||
|
||||
expect(actions.exitNpcChat()).toBe(true);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(generateStoryForState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -424,4 +728,179 @@ describe('npcEncounterActions', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
streamNpcChatTurnMock.mockResolvedValueOnce({
|
||||
affinityDelta: 2,
|
||||
affinityText: '断桥客的语气明显缓和下来。',
|
||||
npcReply: '你既然愿意听,我就把这件事说开。',
|
||||
suggestions: ['这件事最早是从什么时候开始的'],
|
||||
pendingQuestOffer: {
|
||||
quest: pendingQuest,
|
||||
introText:
|
||||
'断桥客沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:断桥口的密信',
|
||||
},
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({});
|
||||
|
||||
await expect(
|
||||
actions.handleNpcChatTurn(createEncounter(), '那你先把来龙去脉讲清楚。'),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
id: 'npc-rival',
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'那你先把来龙去脉讲清楚。',
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
questOfferContext: expect.objectContaining({
|
||||
turnCount: 2,
|
||||
state: expect.objectContaining({
|
||||
currentEncounter: expect.objectContaining({
|
||||
id: 'npc-rival',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(generateQuestForNpcEncounterMock).not.toHaveBeenCalled();
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'查看任务',
|
||||
'更换任务',
|
||||
'放弃任务',
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'npc',
|
||||
text: expect.stringContaining('正式交给你'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces a pending quest offer by reusing the existing quest generator', async () => {
|
||||
const currentQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
const nextQuest = createQuest('quest-bridge-replaced', '断桥夜巡');
|
||||
generateQuestForNpcEncounterMock.mockResolvedValueOnce(nextQuest);
|
||||
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(currentQuest),
|
||||
});
|
||||
|
||||
await expect(actions.replacePendingNpcQuestOffer()).resolves.toBe(true);
|
||||
|
||||
expect(generateQuestForNpcEncounterMock).toHaveBeenCalledTimes(1);
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(nextQuest);
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'查看任务',
|
||||
'更换任务',
|
||||
'放弃任务',
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-2)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'player',
|
||||
text: '能不能换一份更适合眼下局势的委托?',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards pending quest offer acceptance to the server runtime resolver', async () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
const nextState = createState({
|
||||
quests: [pendingQuest],
|
||||
runtimeStats: {
|
||||
...createState().runtimeStats,
|
||||
questsAccepted: 1,
|
||||
},
|
||||
});
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: nextState,
|
||||
},
|
||||
nextStory: createAcceptedPendingQuestStory(pendingQuest),
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(pendingQuest),
|
||||
});
|
||||
|
||||
expect(actions.acceptPendingNpcQuestOffer()).toBe(pendingQuest.id);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: actions.gameState,
|
||||
currentStory: actions.currentStory,
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_quest_accept',
|
||||
actionText: '你答应接下断桥客的委托。',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_accept',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
quests: [
|
||||
expect.objectContaining({
|
||||
id: pendingQuest.id,
|
||||
}),
|
||||
],
|
||||
runtimeStats: expect.objectContaining({
|
||||
questsAccepted: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'这件事里你最担心哪一步',
|
||||
'我回来时你最想先知道什么',
|
||||
'除了这份委托,你还想提醒我什么',
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-2)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'player',
|
||||
text: '这件事我愿意接下,你把关键要点交给我。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('abandons a pending quest offer and returns to free npc chat', () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(pendingQuest),
|
||||
});
|
||||
|
||||
expect(actions.abandonPendingNpcQuestOffer()).toBe(true);
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
'那先继续聊聊你刚才没说完的部分',
|
||||
'除了委托,你对眼前局势还有什么判断',
|
||||
'先把这附近真正危险的地方说清楚',
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-2)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'player',
|
||||
text: '这件事我先不接,咱们还是先聊别的。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,12 @@ export interface QuestFlowUi {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface NpcChatQuestOfferUi {
|
||||
replacePendingOffer: () => Promise<boolean>;
|
||||
abandonPendingOffer: () => boolean;
|
||||
acceptPendingOffer: () => string | null;
|
||||
}
|
||||
|
||||
export interface GoalFlowUi {
|
||||
goalStack: GoalStackState;
|
||||
pulse: GoalPulseEvent | null;
|
||||
|
||||
@@ -135,6 +135,7 @@ export function useStoryFlowCoordinator({
|
||||
clearStoryInteractionUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
} = useStoryInteractionCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
@@ -184,5 +185,6 @@ export function useStoryFlowCoordinator({
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +99,9 @@ export function useStoryInteractionCoordinator({
|
||||
finalizeNpcBattleResult,
|
||||
handleNpcChatTurn,
|
||||
exitNpcChat,
|
||||
replacePendingNpcQuestOffer,
|
||||
abandonPendingNpcQuestOffer,
|
||||
acceptPendingNpcQuestOffer,
|
||||
} = createStoryNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
npcInteractionFlow,
|
||||
@@ -225,5 +228,10 @@ export function useStoryInteractionCoordinator({
|
||||
return true;
|
||||
},
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi: {
|
||||
replacePendingOffer: replacePendingNpcQuestOffer,
|
||||
abandonPendingOffer: abandonPendingNpcQuestOffer,
|
||||
acceptPendingOffer: acceptPendingNpcQuestOffer,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ export function useGameShellRuntime(): GameShellProps {
|
||||
inventoryUi: storyFlow.inventoryUi,
|
||||
battleRewardUi: storyFlow.battleRewardUi,
|
||||
questUi: storyFlow.questUi,
|
||||
npcChatQuestOfferUi: storyFlow.npcChatQuestOfferUi,
|
||||
goalUi: storyFlow.goalUi,
|
||||
},
|
||||
entry: {
|
||||
|
||||
@@ -45,6 +45,7 @@ export type {
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
QuestFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
RecruitModalState,
|
||||
StoryGenerationNpcUi,
|
||||
TradeModalState,
|
||||
@@ -98,6 +99,7 @@ export function useStoryGeneration({
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
} = useStoryFlowCoordinator({
|
||||
gameState,
|
||||
setGameState,
|
||||
@@ -139,5 +141,6 @@ export function useStoryGeneration({
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
};
|
||||
}
|
||||
|
||||
493
src/index.css
493
src/index.css
@@ -8,6 +8,8 @@
|
||||
@font-face {
|
||||
font-family: "Fusion Pixel";
|
||||
src: url("/fusion-pixel.ttf") format("truetype");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@@ -129,6 +131,9 @@ body {
|
||||
--platform-text-strong: #28151d;
|
||||
--platform-text-base: #5c4650;
|
||||
--platform-text-soft: #886f79;
|
||||
--platform-brand-logo-title: #3b1a24;
|
||||
--platform-brand-logo-subtitle: #d93570;
|
||||
--platform-brand-logo-shadow: #8f5870;
|
||||
--platform-line-soft: rgba(233, 183, 202, 0.42);
|
||||
--platform-subpanel-fill:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(255, 244, 248, 0.72));
|
||||
@@ -163,6 +168,7 @@ body {
|
||||
--platform-nav-active-fill:
|
||||
linear-gradient(180deg, rgba(255, 91, 132, 0.18), rgba(255, 151, 116, 0.18));
|
||||
--platform-nav-active-border: rgba(255, 126, 154, 0.3);
|
||||
--platform-nav-active-shadow: 0 12px 28px rgba(255, 91, 132, 0.12);
|
||||
--platform-modal-fill:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 245, 248, 0.95));
|
||||
--platform-modal-border: rgba(255, 255, 255, 0.52);
|
||||
@@ -180,6 +186,34 @@ body {
|
||||
linear-gradient(180deg, rgba(239, 78, 122, 0.22), rgba(255, 255, 255, 0.42));
|
||||
--platform-track-border: rgba(234, 193, 208, 0.52);
|
||||
--platform-track-fill: rgba(255, 255, 255, 0.7);
|
||||
--platform-page-fill:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.44), rgba(255, 245, 248, 0.24));
|
||||
--platform-page-border: rgba(255, 255, 255, 0.24);
|
||||
--platform-input-fill: rgba(255, 255, 255, 0.82);
|
||||
--platform-input-fill-focus: rgba(255, 255, 255, 0.96);
|
||||
--platform-input-highlight: rgba(255, 255, 255, 0.72);
|
||||
--platform-input-focus-ring: rgba(255, 91, 132, 0.14);
|
||||
--platform-nav-item-text: #7b606c;
|
||||
--platform-nav-item-text-active: #2d1820;
|
||||
--platform-nav-item-hover-fill: rgba(255, 255, 255, 0.52);
|
||||
--platform-nav-item-icon-fill: rgba(255, 255, 255, 0.66);
|
||||
--platform-nav-item-icon-text: #7a5d67;
|
||||
--platform-nav-item-icon-active-fill: rgba(255, 255, 255, 0.92);
|
||||
--platform-nav-item-icon-active-text: #d93570;
|
||||
--platform-nav-icon-active-shadow: 0 12px 24px rgba(255, 91, 132, 0.16);
|
||||
--platform-profile-hero-fill:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 245, 248, 0.9));
|
||||
--platform-profile-hero-border: rgba(255, 255, 255, 0.52);
|
||||
--platform-profile-hero-shadow: 0 20px 56px rgba(216, 74, 124, 0.18);
|
||||
--platform-profile-avatar-fill:
|
||||
linear-gradient(135deg, rgba(255, 79, 139, 0.96), rgba(255, 140, 110, 0.9));
|
||||
--platform-profile-avatar-shadow: 0 14px 30px rgba(255, 79, 139, 0.24);
|
||||
--platform-profile-chip-fill: rgba(255, 255, 255, 0.74);
|
||||
--platform-profile-chip-hover-fill: rgba(255, 255, 255, 0.92);
|
||||
--platform-profile-chip-text: #6a505b;
|
||||
--platform-profile-action-fill: linear-gradient(135deg, #ff4f8b, #ff8a73);
|
||||
--platform-profile-action-text: #fff7fb;
|
||||
--platform-profile-action-shadow: 0 14px 30px rgba(255, 79, 139, 0.24);
|
||||
}
|
||||
|
||||
.platform-theme--dark {
|
||||
@@ -210,6 +244,9 @@ body {
|
||||
--platform-text-strong: #ffffff;
|
||||
--platform-text-base: rgb(228 228 231);
|
||||
--platform-text-soft: rgb(161 161 170);
|
||||
--platform-brand-logo-title: #fff7dc;
|
||||
--platform-brand-logo-subtitle: #9fe7ff;
|
||||
--platform-brand-logo-shadow: #040814;
|
||||
--platform-line-soft: rgba(255, 255, 255, 0.1);
|
||||
--platform-subpanel-fill: rgba(255, 255, 255, 0.05);
|
||||
--platform-subpanel-border: rgba(255, 255, 255, 0.1);
|
||||
@@ -241,8 +278,9 @@ body {
|
||||
--platform-nav-fill:
|
||||
linear-gradient(180deg, rgba(109, 40, 217, 0.12), rgba(255, 255, 255, 0.03));
|
||||
--platform-nav-active-fill:
|
||||
linear-gradient(180deg, rgba(91, 108, 255, 0.28), rgba(61, 217, 255, 0.1));
|
||||
--platform-nav-active-border: rgba(160, 169, 255, 0.18);
|
||||
linear-gradient(180deg, rgba(91, 108, 255, 0.2), rgba(61, 217, 255, 0.08));
|
||||
--platform-nav-active-border: rgba(160, 169, 255, 0.24);
|
||||
--platform-nav-active-shadow: 0 12px 28px rgba(8, 14, 42, 0.4);
|
||||
--platform-modal-fill:
|
||||
linear-gradient(180deg, rgba(16, 18, 46, 0.98), rgba(7, 8, 19, 0.98));
|
||||
--platform-modal-border: rgba(160, 169, 255, 0.12);
|
||||
@@ -259,45 +297,70 @@ body {
|
||||
--platform-overlay-fill: rgba(5, 8, 28, 0.72);
|
||||
--platform-track-border: rgba(255, 255, 255, 0.12);
|
||||
--platform-track-fill: rgba(255, 255, 255, 0.08);
|
||||
--platform-page-fill:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
|
||||
--platform-page-border: rgba(255, 255, 255, 0.06);
|
||||
--platform-input-fill: rgba(255, 255, 255, 0.05);
|
||||
--platform-input-fill-focus: rgba(255, 255, 255, 0.08);
|
||||
--platform-input-highlight: rgba(255, 255, 255, 0.06);
|
||||
--platform-input-focus-ring: rgba(91, 108, 255, 0.22);
|
||||
--platform-nav-item-text: rgb(161 161 170);
|
||||
--platform-nav-item-text-active: rgb(238 248 255);
|
||||
--platform-nav-item-hover-fill: rgba(91, 108, 255, 0.08);
|
||||
--platform-nav-item-icon-fill: rgba(255, 255, 255, 0.06);
|
||||
--platform-nav-item-icon-text: rgb(161 161 170);
|
||||
--platform-nav-item-icon-active-fill:
|
||||
linear-gradient(180deg, rgba(91, 108, 255, 0.24), rgba(61, 217, 255, 0.12));
|
||||
--platform-nav-item-icon-active-text: rgb(238 248 255);
|
||||
--platform-nav-icon-active-shadow: 0 12px 24px rgba(8, 14, 42, 0.42);
|
||||
--platform-profile-hero-fill:
|
||||
linear-gradient(180deg, rgba(20, 24, 58, 0.96), rgba(8, 10, 24, 0.98));
|
||||
--platform-profile-hero-border: rgba(160, 169, 255, 0.14);
|
||||
--platform-profile-hero-shadow: 0 24px 70px rgba(5, 8, 28, 0.42);
|
||||
--platform-profile-avatar-fill:
|
||||
linear-gradient(135deg, rgba(91, 108, 255, 0.94), rgba(61, 217, 255, 0.78));
|
||||
--platform-profile-avatar-shadow: 0 14px 32px rgba(61, 217, 255, 0.16);
|
||||
--platform-profile-chip-fill: rgba(255, 255, 255, 0.08);
|
||||
--platform-profile-chip-hover-fill: rgba(255, 255, 255, 0.14);
|
||||
--platform-profile-chip-text: rgb(228 228 231);
|
||||
--platform-profile-action-fill: linear-gradient(135deg, #5b6cff, #3dd9ff);
|
||||
--platform-profile-action-text: rgb(238 248 255);
|
||||
--platform-profile-action-shadow: 0 14px 32px rgba(91, 108, 255, 0.22);
|
||||
}
|
||||
|
||||
.platform-brand-logo {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
gap: 0.18rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.platform-brand-logo__title,
|
||||
.platform-brand-logo__subtitle {
|
||||
font-family: "Noto Serif SC", "Inter", ui-sans-serif, system-ui, sans-serif !important;
|
||||
font-synthesis: none;
|
||||
font-kerning: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-brand-logo__title {
|
||||
font-size: clamp(1.95rem, 5vw, 2.7rem);
|
||||
font-weight: 700;
|
||||
line-height: 0.96;
|
||||
letter-spacing: 0.18em;
|
||||
color: #fffdf7;
|
||||
text-shadow:
|
||||
0 2px 0 rgba(3, 7, 18, 0.88),
|
||||
0 10px 28px rgba(0, 0, 0, 0.4),
|
||||
0 0 18px rgba(129, 140, 248, 0.16);
|
||||
font-family: "Fusion Pixel", "Inter", ui-sans-serif, system-ui, sans-serif !important;
|
||||
font-size: clamp(1.9rem, 5.2vw, 2.65rem);
|
||||
font-weight: 400;
|
||||
line-height: 0.92;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--platform-brand-logo-title);
|
||||
}
|
||||
|
||||
.platform-brand-logo__subtitle {
|
||||
padding-left: 0.14rem;
|
||||
font-size: clamp(0.58rem, 1.8vw, 0.72rem);
|
||||
font-family: "Inter", ui-sans-serif, system-ui, sans-serif !important;
|
||||
font-weight: 600;
|
||||
padding-left: 0.08rem;
|
||||
font-family: "Fusion Pixel", "Inter", ui-sans-serif, system-ui, sans-serif !important;
|
||||
font-size: clamp(0.56rem, 1.7vw, 0.7rem);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.34em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(228, 228, 231, 0.82);
|
||||
text-shadow:
|
||||
0 1px 0 rgba(3, 7, 18, 0.88),
|
||||
0 8px 20px rgba(0, 0, 0, 0.34),
|
||||
0 0 12px rgba(34, 211, 238, 0.14);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--platform-brand-logo-subtitle);
|
||||
}
|
||||
|
||||
.platform-main-shell {
|
||||
@@ -316,6 +379,16 @@ body {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.platform-page-stage {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
border: 1px solid var(--platform-page-border);
|
||||
border-radius: 2rem;
|
||||
background: var(--platform-page-fill);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.platform-surface {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -451,7 +524,7 @@ body {
|
||||
color: var(--platform-text-strong);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 12px 28px rgba(255, 91, 132, 0.12);
|
||||
var(--platform-nav-active-shadow);
|
||||
}
|
||||
|
||||
.platform-button {
|
||||
@@ -551,7 +624,7 @@ body {
|
||||
justify-content: center;
|
||||
border-radius: 1rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
color: rgb(161 161 170);
|
||||
color: var(--platform-nav-item-text);
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
color 180ms ease,
|
||||
@@ -560,8 +633,8 @@ body {
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button:hover {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--platform-text-strong);
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active {
|
||||
@@ -570,7 +643,72 @@ body {
|
||||
color: var(--platform-text-strong);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 12px 28px rgba(255, 91, 132, 0.12);
|
||||
var(--platform-nav-active-shadow);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__icon-shell,
|
||||
.platform-desktop-rail__icon-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: var(--platform-nav-item-icon-fill);
|
||||
color: var(--platform-nav-item-icon-text);
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
color 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__icon-shell {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.platform-desktop-rail__icon-shell {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__icon,
|
||||
.platform-desktop-rail__icon {
|
||||
color: var(--platform-nav-item-icon-text);
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__label,
|
||||
.platform-desktop-rail__label {
|
||||
color: var(--platform-nav-item-text);
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button:hover .platform-bottom-nav__icon-shell,
|
||||
.platform-desktop-rail__button:hover .platform-desktop-rail__icon-shell {
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button:hover .platform-bottom-nav__icon,
|
||||
.platform-bottom-nav__button:hover .platform-bottom-nav__label,
|
||||
.platform-desktop-rail__button:hover .platform-desktop-rail__icon,
|
||||
.platform-desktop-rail__button:hover .platform-desktop-rail__label {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active .platform-bottom-nav__icon-shell,
|
||||
.platform-desktop-rail__button--active .platform-desktop-rail__icon-shell {
|
||||
background: var(--platform-nav-item-icon-active-fill);
|
||||
box-shadow: var(--platform-nav-icon-active-shadow);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active .platform-bottom-nav__icon,
|
||||
.platform-desktop-rail__button--active .platform-desktop-rail__icon {
|
||||
color: var(--platform-nav-item-icon-active-text);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active .platform-bottom-nav__label,
|
||||
.platform-desktop-rail__button--active .platform-desktop-rail__label {
|
||||
color: var(--platform-nav-item-text-active);
|
||||
}
|
||||
|
||||
.platform-modal-shell {
|
||||
@@ -664,7 +802,7 @@ body {
|
||||
gap: 0.6rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 1.5rem;
|
||||
color: rgb(161 161 170);
|
||||
color: var(--platform-nav-item-text);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease,
|
||||
@@ -686,7 +824,7 @@ body {
|
||||
color: var(--platform-text-strong);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 14px 28px rgba(255, 91, 132, 0.12);
|
||||
var(--platform-nav-active-shadow);
|
||||
}
|
||||
|
||||
.platform-desktop-panel {
|
||||
@@ -760,7 +898,7 @@ body {
|
||||
width: 100%;
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
background: var(--platform-input-fill);
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--platform-text-strong);
|
||||
outline: none;
|
||||
@@ -768,7 +906,7 @@ body {
|
||||
border-color 180ms ease,
|
||||
background-color 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 1px 0 var(--platform-input-highlight);
|
||||
}
|
||||
|
||||
.platform-input::placeholder {
|
||||
@@ -777,8 +915,124 @@ body {
|
||||
|
||||
.platform-input:focus {
|
||||
border-color: var(--platform-nav-active-border);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 0 0 3px rgba(255, 91, 132, 0.12);
|
||||
background: var(--platform-input-fill-focus);
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
.platform-profile-hero {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-profile-hero-border);
|
||||
background: var(--platform-profile-hero-fill);
|
||||
color: var(--platform-text-strong);
|
||||
box-shadow: var(--platform-profile-hero-shadow);
|
||||
}
|
||||
|
||||
.platform-profile-avatar {
|
||||
background: var(--platform-profile-avatar-fill);
|
||||
color: white;
|
||||
box-shadow: var(--platform-profile-avatar-shadow);
|
||||
}
|
||||
|
||||
.platform-profile-camera,
|
||||
.platform-profile-chip,
|
||||
.platform-profile-icon-button {
|
||||
background: var(--platform-profile-chip-fill);
|
||||
color: var(--platform-profile-chip-text);
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
color 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.platform-profile-camera {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
}
|
||||
|
||||
.platform-profile-chip:hover,
|
||||
.platform-profile-icon-button:hover {
|
||||
background: var(--platform-profile-chip-hover-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-profile-action {
|
||||
background: var(--platform-profile-action-fill);
|
||||
color: var(--platform-profile-action-text);
|
||||
box-shadow: var(--platform-profile-action-shadow);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
filter 180ms ease;
|
||||
}
|
||||
|
||||
.platform-profile-action:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
.platform-role-studio__preview {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background:
|
||||
radial-gradient(circle at top, var(--platform-surface-glow-a), transparent 48%),
|
||||
var(--platform-subpanel-fill);
|
||||
}
|
||||
|
||||
.platform-role-studio__stage {
|
||||
border: 1px solid var(--platform-subpanel-border);
|
||||
background: var(--platform-track-fill);
|
||||
}
|
||||
|
||||
.platform-role-studio__footer {
|
||||
border-top: 1px solid var(--platform-subpanel-border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 16%),
|
||||
var(--platform-desktop-panel-fill);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.platform-theme :where(
|
||||
.platform-modal-shell,
|
||||
.platform-auth-card,
|
||||
.platform-subpanel,
|
||||
.platform-remap-surface,
|
||||
.platform-role-studio
|
||||
) :where(
|
||||
input:not([type='file']):not([type='range']):not([type='checkbox']):not([type='radio']),
|
||||
textarea,
|
||||
select
|
||||
) {
|
||||
border-color: var(--platform-subpanel-border) !important;
|
||||
background: var(--platform-input-fill) !important;
|
||||
color: var(--platform-text-strong) !important;
|
||||
box-shadow: inset 0 1px 0 var(--platform-input-highlight);
|
||||
}
|
||||
|
||||
.platform-theme :where(
|
||||
.platform-modal-shell,
|
||||
.platform-auth-card,
|
||||
.platform-subpanel,
|
||||
.platform-remap-surface,
|
||||
.platform-role-studio
|
||||
) :where(
|
||||
input:not([type='file']):not([type='range']):not([type='checkbox']):not([type='radio']),
|
||||
textarea,
|
||||
select
|
||||
)::placeholder {
|
||||
color: var(--platform-text-soft) !important;
|
||||
}
|
||||
|
||||
.platform-theme :where(
|
||||
.platform-modal-shell,
|
||||
.platform-auth-card,
|
||||
.platform-subpanel,
|
||||
.platform-remap-surface,
|
||||
.platform-role-studio
|
||||
) :where(
|
||||
input:not([type='file']):not([type='range']):not([type='checkbox']):not([type='radio']),
|
||||
textarea,
|
||||
select
|
||||
):focus {
|
||||
border-color: var(--platform-nav-active-border) !important;
|
||||
background: var(--platform-input-fill-focus) !important;
|
||||
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
|
||||
}
|
||||
|
||||
.platform-banner {
|
||||
@@ -1045,6 +1299,165 @@ body {
|
||||
) :where(
|
||||
[class*='border-emerald-300/'],
|
||||
[class*='border-emerald-400/']
|
||||
) {
|
||||
border-color: var(--platform-success-border) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='text-white'],
|
||||
[class*='text-zinc-50'],
|
||||
[class*='text-zinc-100']
|
||||
) {
|
||||
color: var(--platform-text-strong) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='text-zinc-200'],
|
||||
[class*='text-zinc-300']
|
||||
) {
|
||||
color: var(--platform-text-base) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='text-zinc-400'],
|
||||
[class*='text-zinc-500']
|
||||
) {
|
||||
color: var(--platform-text-soft) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light :where(
|
||||
.platform-modal-shell,
|
||||
.platform-desktop-panel,
|
||||
.platform-desktop-trending-item,
|
||||
.platform-auth-card,
|
||||
.platform-subpanel,
|
||||
.platform-remap-surface,
|
||||
.platform-role-studio
|
||||
) :where(
|
||||
[class~='bg-black/24'],
|
||||
[class~='bg-black/26'],
|
||||
[class~='bg-black/30'],
|
||||
[class~='bg-[#111318]/92'],
|
||||
[class~='bg-[#111318]/95'],
|
||||
[class~='bg-[#11161f]']
|
||||
) {
|
||||
background: var(--platform-subpanel-fill) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light :where(
|
||||
.platform-modal-shell,
|
||||
.platform-desktop-panel,
|
||||
.platform-desktop-trending-item,
|
||||
.platform-auth-card,
|
||||
.platform-subpanel,
|
||||
.platform-role-studio
|
||||
) :where(
|
||||
[class~='bg-white/10'],
|
||||
[class~='bg-white/12']
|
||||
) {
|
||||
background-color: var(--platform-track-fill) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class~='bg-black/18'],
|
||||
[class~='bg-black/20'],
|
||||
[class~='bg-black/22'],
|
||||
[class~='bg-black/24'],
|
||||
[class~='bg-black/26'],
|
||||
[class~='bg-black/30'],
|
||||
[class~='bg-[#111318]/92'],
|
||||
[class~='bg-[#111318]/95'],
|
||||
[class~='bg-white/5'],
|
||||
[class~='bg-white/6'],
|
||||
[class~='bg-white/8']
|
||||
) {
|
||||
background: var(--platform-subpanel-fill) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='border-white/8'],
|
||||
[class*='border-white/10'],
|
||||
[class*='border-white/12'],
|
||||
[class*='border-white/14'],
|
||||
[class*='border-white/16'],
|
||||
[class*='border-white/18'],
|
||||
[class*='border-white/20'],
|
||||
[class*='border-white/22'],
|
||||
[class*='border-white/25']
|
||||
) {
|
||||
border-color: var(--platform-subpanel-border) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='text-sky-50'],
|
||||
[class*='text-sky-100'],
|
||||
[class*='text-sky-200'],
|
||||
[class*='text-sky-300']
|
||||
) {
|
||||
color: var(--platform-cool-text) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='bg-sky-500/8'],
|
||||
[class*='bg-sky-500/10'],
|
||||
[class*='bg-sky-500/12'],
|
||||
[class*='bg-sky-500/15']
|
||||
) {
|
||||
background-color: var(--platform-cool-bg) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='border-sky-200/'],
|
||||
[class*='border-sky-300/'],
|
||||
[class*='border-sky-400/']
|
||||
) {
|
||||
border-color: var(--platform-cool-border) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='text-amber-50'],
|
||||
[class*='text-amber-100'],
|
||||
[class*='text-amber-200']
|
||||
) {
|
||||
color: var(--platform-warm-text) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='bg-amber-500/8'],
|
||||
[class*='bg-amber-500/10'],
|
||||
[class*='bg-amber-500/12'],
|
||||
[class*='bg-amber-500/15']
|
||||
) {
|
||||
background-color: var(--platform-warm-bg) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='border-amber-300/'],
|
||||
[class*='border-amber-400/']
|
||||
) {
|
||||
border-color: var(--platform-warm-border) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='text-emerald-50'],
|
||||
[class*='text-emerald-100'],
|
||||
[class*='text-emerald-600']
|
||||
) {
|
||||
color: var(--platform-success-text) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='bg-emerald-500/8'],
|
||||
[class*='bg-emerald-500/10'],
|
||||
[class*='bg-emerald-500/12'],
|
||||
[class*='bg-emerald-500/15']
|
||||
) {
|
||||
background-color: var(--platform-success-bg) !important;
|
||||
}
|
||||
|
||||
.platform-theme--light .platform-remap-surface :where(
|
||||
[class*='border-emerald-300/'],
|
||||
[class*='border-emerald-400/']
|
||||
) {
|
||||
border-color: var(--platform-success-border) !important;
|
||||
}
|
||||
@@ -1522,17 +1935,17 @@ button {
|
||||
}
|
||||
|
||||
.platform-brand-logo {
|
||||
gap: 0.28rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.platform-brand-logo__title {
|
||||
font-size: 1.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.platform-brand-logo__subtitle {
|
||||
font-size: 0.54rem;
|
||||
letter-spacing: 0.22em;
|
||||
font-size: 0.5rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.world-carousel {
|
||||
|
||||
333
src/prompts/characterChatPrompts.ts
Normal file
333
src/prompts/characterChatPrompts.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
buildSchemaSummary,
|
||||
describeTopAttributes,
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
buildCharacterBackstoryPromptContext,
|
||||
getCharacterPublicBackstorySummary,
|
||||
getLockedCharacterBackstoryChapters,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
FacingDirection,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildCustomWorldReferenceText } from '../services/customWorld';
|
||||
import { buildStoryPromptHistory } from '../services/storyHistory';
|
||||
|
||||
export interface CharacterChatTargetStatus {
|
||||
roleLabel?: string | null;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
}
|
||||
|
||||
export interface CharacterChatPromptContext {
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
inBattle: boolean;
|
||||
playerFacing: FacingDirection;
|
||||
playerAnimation: AnimationState;
|
||||
sceneName?: string | null;
|
||||
sceneDescription?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
|
||||
只回复这名角色此刻会对玩家说的话。
|
||||
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
|
||||
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
|
||||
只输出纯文本,共 3 行,每行一条。
|
||||
不要加编号、项目符号、Markdown 或额外说明。
|
||||
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
|
||||
只输出一段简洁文字。
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
function describeWorld(world: WorldType) {
|
||||
if (world === WorldType.WUXIA) return '边城模板';
|
||||
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||||
return '自定义世界';
|
||||
}
|
||||
|
||||
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
|
||||
return customWorldProfile
|
||||
? `自定义世界参考:\n${buildCustomWorldReferenceText(customWorldProfile)}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function describeGender(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function describeFacing(facing: FacingDirection) {
|
||||
return facing === 'left' ? '左' : '右';
|
||||
}
|
||||
|
||||
function describeHpBand(ratio: number) {
|
||||
if (ratio >= 0.95) return '几乎无伤';
|
||||
if (ratio >= 0.75) return '状态稳健';
|
||||
if (ratio >= 0.55) return '略有消耗';
|
||||
if (ratio >= 0.35) return '伤势明显';
|
||||
if (ratio >= 0.15) return '伤势沉重';
|
||||
return '濒临极限';
|
||||
}
|
||||
|
||||
function describeManaBand(ratio: number) {
|
||||
if (ratio >= 0.9) return '充盈';
|
||||
if (ratio >= 0.7) return '稳定';
|
||||
if (ratio >= 0.45) return '尚可';
|
||||
if (ratio >= 0.2) return '偏低';
|
||||
if (ratio > 0) return '接近枯竭';
|
||||
return '耗尽';
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: StoryMoment[]) {
|
||||
const promptHistory = buildStoryPromptHistory(history);
|
||||
|
||||
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
promptHistory.previousSummary
|
||||
? `更早剧情摘要:\n${promptHistory.previousSummary}`
|
||||
: '更早剧情摘要:暂无。',
|
||||
promptHistory.recentOriginalRounds.length > 0
|
||||
? `最近 3 轮剧情:\n${promptHistory.recentOriginalRounds
|
||||
.map((item, index) => `- 第 ${index + 1} 轮:\n${item}`)
|
||||
.join('\n')}`
|
||||
: '最近 3 轮剧情:暂无。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeBackstoryContext(label: string, snippets: string[]) {
|
||||
const normalized = snippets
|
||||
.map(snippet => snippet.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [`${label}:暂无公开信息。`];
|
||||
}
|
||||
|
||||
return normalized.map((snippet, index) =>
|
||||
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`,
|
||||
);
|
||||
}
|
||||
|
||||
function describeCharacterInfo(
|
||||
label: string,
|
||||
character: Character,
|
||||
world: WorldType,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
options: {
|
||||
affinity?: number | null;
|
||||
includeUnlockProgress?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(character, world, customWorldProfile);
|
||||
const skills = character.skills.length > 0
|
||||
? character.skills
|
||||
.map(
|
||||
skill => `${skill.name}(伤害 ${skill.damage}/灵力 ${skill.manaCost}/冷却 ${skill.cooldownTurns})`,
|
||||
)
|
||||
.join(' | ')
|
||||
: '无';
|
||||
const backgroundLines = options.affinity == null
|
||||
? [getCharacterPublicBackstorySummary(character, world)]
|
||||
: buildCharacterBackstoryPromptContext(character, options.affinity, world);
|
||||
const nextLockedChapter = options.includeUnlockProgress && options.affinity != null
|
||||
? getLockedCharacterBackstoryChapters(character, options.affinity, world)[0] ?? null
|
||||
: null;
|
||||
const schemaSummary = buildSchemaSummary(schema)
|
||||
.map(slot => `${slot.name}(${slot.definition})`)
|
||||
.join(' | ');
|
||||
const topAttributes = describeTopAttributes(attributeProfile, schema).join('、') || '无';
|
||||
const attributeDetails = formatAttributeList(attributeProfile, schema)
|
||||
.map(entry => `${entry.slot.name} ${entry.value}`)
|
||||
.join(' | ');
|
||||
|
||||
return [
|
||||
`${label}姓名:${character.name}`,
|
||||
`${label}称号:${character.title}`,
|
||||
`${label}性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
`${label}描述:${character.description}`,
|
||||
...describeBackstoryContext(`${label}背景`, backgroundLines),
|
||||
nextLockedChapter
|
||||
? `${label}未解锁背景:${nextLockedChapter.title}(需好感 ${nextLockedChapter.affinityRequired},当前只知道:${nextLockedChapter.teaser})`
|
||||
: null,
|
||||
`${label}性格:${character.personality}`,
|
||||
`${label}世界属性框架:${schemaSummary}`,
|
||||
`${label}主要属性:${topAttributes}`,
|
||||
`${label}属性详情:${attributeDetails}`,
|
||||
`${label}技能:${skills}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeChatContext(world: WorldType, context: CharacterChatPromptContext) {
|
||||
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
|
||||
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
`玩家战斗状态:${context.inBattle ? '战斗中' : '非战斗'}`,
|
||||
`场景:${context.sceneName ?? '当前区域'}`,
|
||||
`场景描述:${context.sceneDescription ?? '周围气氛仍未安定。'}`,
|
||||
`玩家状态:生命 ${context.playerHp}/${context.playerMaxHp}(${describeHpBand(hpRatio)}),灵力 ${context.playerMana}/${context.playerMaxMana}(${describeManaBand(manaRatio)}),朝向 ${describeFacing(context.playerFacing)},动作 ${context.playerAnimation}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeTargetStatus(status: CharacterChatTargetStatus) {
|
||||
const hpRatio = status.hp / Math.max(status.maxHp, 1);
|
||||
const manaRatio = status.mana / Math.max(status.maxMana, 1);
|
||||
|
||||
return [
|
||||
`对方身份:${status.roleLabel ?? '同行角色'}`,
|
||||
`对方状态:生命 ${status.hp}/${status.maxHp}(${describeHpBand(hpRatio)}),灵力 ${status.mana}/${status.maxMana}(${describeManaBand(manaRatio)})`,
|
||||
status.affinity != null ? `当前好感:${status.affinity}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
if (history.length === 0) {
|
||||
return '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'聊天记录:',
|
||||
...history.slice(-12).map(turn => `- ${turn.speaker === 'player' ? '玩家' : '角色'}:${turn.text}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
playerMessage: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
`玩家刚刚对 ${targetCharacter.name} 说:${playerMessage}`,
|
||||
`现在请以 ${targetCharacter.name} 的身份,直接回复玩家。`,
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
const latestCharacterReply = [...conversationHistory]
|
||||
.reverse()
|
||||
.find(turn => turn.speaker === 'character')?.text ?? null;
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
latestCharacterReply
|
||||
? `角色刚刚的回复:${latestCharacterReply}`
|
||||
: `玩家正准备与 ${targetCharacter.name} 开始一段新的私聊。`,
|
||||
'生成 3 条可以直接发送的简短玩家回复候选。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
previousSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
previousSummary ? `旧摘要:${previousSummary}` : '旧摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
'请把旧摘要与最新聊天合并成一段更新后的关系摘要,供后续剧情推理使用。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
32
src/prompts/customWorldEntityActionPrompts.ts
Normal file
32
src/prompts/customWorldEntityActionPrompts.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldRoleSkill,
|
||||
} from '../types';
|
||||
|
||||
export function buildSkillActionPrompt(params: {
|
||||
role: Pick<
|
||||
CustomWorldPlayableNpc | CustomWorldNpc,
|
||||
| 'name'
|
||||
| 'title'
|
||||
| 'role'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
>;
|
||||
skill: Pick<CustomWorldRoleSkill, 'name' | 'summary'>;
|
||||
}) {
|
||||
const { role, skill } = params;
|
||||
return [
|
||||
`${role.name},${role.title || role.role}。`,
|
||||
`技能名称:${skill.name}。`,
|
||||
skill.summary ? `技能表现:${skill.summary}。` : '',
|
||||
role.description ? `角色气质:${role.description}。` : '',
|
||||
role.personality ? `性格补充:${role.personality}。` : '',
|
||||
role.motivation ? `动作目标:${role.motivation}。` : '',
|
||||
'横版 RPG 角色技能动作,角色轮廓稳定,动作起手明确,过程连贯,收招干净,镜头稳定。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
8
src/prompts/customWorldOrchestratorPrompts.ts
Normal file
8
src/prompts/customWorldOrchestratorPrompts.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。
|
||||
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
|
||||
|
||||
export const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
1076
src/prompts/customWorldPrompts.ts
Normal file
1076
src/prompts/customWorldPrompts.ts
Normal file
File diff suppressed because it is too large
Load Diff
57
src/prompts/customWorldRolePromptDefaults.ts
Normal file
57
src/prompts/customWorldRolePromptDefaults.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type PromptDefaultRole = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type CustomWorldRolePromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function pickFirstDescription(
|
||||
values: Array<string | undefined>,
|
||||
maxLength: number,
|
||||
) {
|
||||
for (const value of values) {
|
||||
const normalized = cleanSeedText(value, maxLength);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
return {
|
||||
visualPromptText: pickFirstDescription(
|
||||
[role.visualDescription, role.description],
|
||||
220,
|
||||
),
|
||||
animationPromptText: pickFirstDescription(
|
||||
[role.actionDescription, role.combatStyle],
|
||||
180,
|
||||
),
|
||||
scenePromptText: pickFirstDescription(
|
||||
[role.sceneVisualDescription, role.backstory],
|
||||
220,
|
||||
),
|
||||
};
|
||||
}
|
||||
175
src/prompts/questPrompts.ts
Normal file
175
src/prompts/questPrompts.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type {QuestGenerationContext} from '../services/aiTypes';
|
||||
import type {QuestOpportunity, QuestSceneSnapshot} from '../services/questTypes';
|
||||
import { buildQuestVisibilitySlice } from '../services/storyEngine/visibilityEngine';
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
|
||||
const moments = context.recentStoryMoments
|
||||
.slice(-4)
|
||||
.map(moment => `- ${moment.text}`)
|
||||
.join('\n');
|
||||
|
||||
return moments || '- 暂无近期剧情记录';
|
||||
}
|
||||
|
||||
function summarizeCurrentQuests(context: QuestGenerationContext) {
|
||||
const summary = context.currentQuestSummary?.map(quest =>
|
||||
`- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`,
|
||||
).join('\n');
|
||||
|
||||
return summary || '- 当前没有进行中的任务';
|
||||
}
|
||||
|
||||
function summarizeCompanions(context: QuestGenerationContext) {
|
||||
const active = context.activeCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
const roster = context.rosterCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
return `当前同行角色:${active}\n队伍名册:${roster}`;
|
||||
}
|
||||
|
||||
function summarizePlayerState(context: QuestGenerationContext) {
|
||||
const playerName = context.playerCharacter?.name ?? '未知角色';
|
||||
const playerTitle = context.playerCharacter?.title ?? '未知称号';
|
||||
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
|
||||
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
|
||||
const inventory = context.playerInventory?.slice(0, 8).map(item => item.name).join('、') || '无';
|
||||
|
||||
return [
|
||||
`玩家:${playerName}(${playerTitle})`,
|
||||
`生命:${hp}`,
|
||||
`灵力:${mana}`,
|
||||
`背包快照:${inventory}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeScene(scene: QuestSceneSnapshot | null, context: QuestGenerationContext) {
|
||||
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
|
||||
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
|
||||
|
||||
return [
|
||||
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
|
||||
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
|
||||
`敌对角色 ID:${hostileNpcIds}`,
|
||||
`宝藏线索数量:${treasureHintCount}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeActiveThreads(context: QuestGenerationContext) {
|
||||
if (!context.activeThreadIds?.length) {
|
||||
return '暂无明确激活线程';
|
||||
}
|
||||
|
||||
const storyGraph = context.customWorldProfile?.storyGraph;
|
||||
const labels = context.activeThreadIds.map((threadId) =>
|
||||
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
|
||||
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
||||
);
|
||||
|
||||
return labels.join('、');
|
||||
}
|
||||
|
||||
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
const profile = context.issuerNarrativeProfile;
|
||||
if (!profile) {
|
||||
return '暂无额外叙事档案';
|
||||
}
|
||||
|
||||
return [
|
||||
`公开面:${profile.publicMask}`,
|
||||
`表层线:${profile.visibleLine}`,
|
||||
`当前压力:${profile.immediatePressure}`,
|
||||
profile.reactionHooks.length > 0
|
||||
? `反应钩子:${profile.reactionHooks.join('、')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function summarizeQuestVisibility(context: QuestGenerationContext) {
|
||||
const slice = buildQuestVisibilitySlice({
|
||||
issuerNarrativeProfile: context.issuerNarrativeProfile,
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
});
|
||||
|
||||
return [
|
||||
`可直接披露:${slice.sayableFactIds.join('、') || '无'}`,
|
||||
`只宜写成推测:${slice.inferredFactIds.join('、') || '无'}`,
|
||||
`禁止直接说破:${slice.forbiddenFactIds.join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
|
||||
只返回 JSON,不要输出 Markdown。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intent": {
|
||||
"title": "中文任务标题",
|
||||
"description": "中文任务描述",
|
||||
"summary": "中文短摘要",
|
||||
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
|
||||
"dramaticNeed": "string",
|
||||
"issuerGoal": "string",
|
||||
"playerHook": "string",
|
||||
"worldReason": "string",
|
||||
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
|
||||
"urgency": "low|medium|high",
|
||||
"intimacy": "transactional|cooperative|trust_based",
|
||||
"rewardTheme": "currency|resource|relationship|intel|rare_item",
|
||||
"followupHooks": ["string"]
|
||||
}
|
||||
}
|
||||
|
||||
规则:
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 任务必须扎根于当前场景、发布者和近期剧情。
|
||||
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
|
||||
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
|
||||
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
|
||||
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
|
||||
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
|
||||
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
|
||||
|
||||
export function buildQuestIntentPrompt(params: {
|
||||
context: QuestGenerationContext;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
opportunity: QuestOpportunity;
|
||||
}) {
|
||||
const {context, scene, opportunity} = params;
|
||||
const customWorldSummary = context.customWorldProfile
|
||||
? `${context.customWorldProfile.name}: ${context.customWorldProfile.summary}`
|
||||
: '无';
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(context.worldType)}`,
|
||||
`自定义世界摘要:${customWorldSummary}`,
|
||||
`发布角色:${context.issuerNpcName ?? '未知'}(${context.issuerNpcId ?? '未知'})`,
|
||||
`发布者身份:${context.issuerNpcContext ?? '暂无'}`,
|
||||
`发布者好感:${context.issuerAffinity ?? 0}`,
|
||||
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
|
||||
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
|
||||
`当前激活线程:${summarizeActiveThreads(context)}`,
|
||||
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
|
||||
`任务可见性切片:\n${summarizeQuestVisibility(context)}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
summarizeCompanions(context),
|
||||
`当前任务机会:${opportunity.reason}`,
|
||||
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
|
||||
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
|
||||
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
|
||||
].join('\n\n');
|
||||
}
|
||||
629
src/prompts/qwenSpriteSheetToolPrompts.ts
Normal file
629
src/prompts/qwenSpriteSheetToolPrompts.ts
Normal file
@@ -0,0 +1,629 @@
|
||||
export type QwenSpriteActionTemplateId =
|
||||
| 'idle'
|
||||
| 'run'
|
||||
| 'attack_slash'
|
||||
| 'hurt'
|
||||
| 'die';
|
||||
|
||||
export type QwenSpriteActionTemplate = {
|
||||
id: QwenSpriteActionTemplateId;
|
||||
label: string;
|
||||
loop: boolean;
|
||||
defaultFps: number;
|
||||
bodyTravel: string;
|
||||
weaponRule: string;
|
||||
stagingDirection?: string;
|
||||
defaultDetailText?: string;
|
||||
sequenceLines: [string, string, string, string];
|
||||
ending: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_MASTER_NEGATIVE_PROMPT =
|
||||
'正面视角,左朝向,完全 90 度纯右视图,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,建筑场景,道具堆叠,漂浮物,烟雾环境,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
|
||||
export const DEFAULT_SHEET_NEGATIVE_PROMPT =
|
||||
'多角色,左右朝向混乱,前视图,背视图,镜头切换,景别变化,特写,脚底裁切,头顶裁切,缺手,缺脚,额外肢体,武器消失,武器换手,服装变化,脸部变化,发型变化,动作不连续,重复帧过多,构图混乱,背景复杂,强透视,运动模糊,残影,文字,水印,UI,边框覆盖角色';
|
||||
|
||||
export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
|
||||
'多角色,错误朝向,缺手,缺脚,额外肢体,武器消失,武器换手,脸部变化,发型变化,服装变化,模糊,运动模糊,复杂背景,文字,水印';
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
const SIDE_FACING_RIGHT_TEXT =
|
||||
'角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。';
|
||||
const SUBJECT_ONLY_TEXT =
|
||||
'画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。';
|
||||
const CLEAN_BACKGROUND_TEXT =
|
||||
'背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。';
|
||||
const STYLE_REFERENCE_SCOPE_TEXT =
|
||||
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
|
||||
const CONCEPT_INTERPRETATION_TEXT =
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
|
||||
const HUMANLIKE_PRIORITY_TEXT =
|
||||
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。';
|
||||
const CONCEPT_HIERARCHY_TEXT =
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
|
||||
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',
|
||||
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Fighter 4/original/Hero/idle/idle01.png',
|
||||
];
|
||||
|
||||
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
|
||||
{
|
||||
id: 'idle',
|
||||
label: '待机循环',
|
||||
loop: true,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '原地',
|
||||
weaponRule: '武器始终在主手,位置稳定',
|
||||
sequenceLines: [
|
||||
'1-4 帧:稳定站姿,轻微呼吸起伏',
|
||||
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
|
||||
'9-12 帧:呼气回落,重心恢复',
|
||||
'13-16 帧:逐渐回到与首帧接近的站姿',
|
||||
],
|
||||
ending: '第 16 帧自然衔接第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'run',
|
||||
label: '奔跑循环',
|
||||
loop: true,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '小幅前移但角色中心基本固定',
|
||||
weaponRule: '武器始终在主手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
|
||||
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
|
||||
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
|
||||
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
|
||||
],
|
||||
ending: '第 16 帧能无缝接回第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'attack_slash',
|
||||
label: '横斩攻击',
|
||||
loop: false,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '中幅前探',
|
||||
weaponRule: '右手持武器,始终右手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:轻微收身蓄力,武器向后收',
|
||||
'5-8 帧:重心前压,挥击开始',
|
||||
'9-12 帧:斩击达到最大幅度,动作力量最强',
|
||||
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
|
||||
],
|
||||
ending: '第 16 帧停在收招后稳定姿态',
|
||||
},
|
||||
{
|
||||
id: 'hurt',
|
||||
label: '受击后仰',
|
||||
loop: false,
|
||||
defaultFps: 10,
|
||||
bodyTravel: '原地或极小后仰',
|
||||
weaponRule: '武器不要脱手,不要换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:突然受击,头肩后仰',
|
||||
'5-8 帧:身体失衡最明显',
|
||||
'9-12 帧:手臂和武器随惯性摆动',
|
||||
'13-16 帧:逐渐恢复到勉强站稳的姿态',
|
||||
],
|
||||
ending: '第 16 帧能接回 idle 或下一个动作',
|
||||
},
|
||||
{
|
||||
id: 'die',
|
||||
label: '倒地死亡',
|
||||
loop: false,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '明显倒地位移',
|
||||
weaponRule: '武器不可瞬间消失',
|
||||
sequenceLines: [
|
||||
'1-4 帧:受创失衡,重心被打断',
|
||||
'5-8 帧:身体明显下坠或后仰',
|
||||
'9-12 帧:倒地过程完成,动作幅度最大',
|
||||
'13-16 帧:停在清晰的终止姿态',
|
||||
],
|
||||
ending: '第 16 帧停在死亡结束姿态,不需要循环',
|
||||
},
|
||||
];
|
||||
|
||||
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) {
|
||||
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) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function loadImageFromSource(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function drawContainedImage(
|
||||
context: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) {
|
||||
const fitScale = Math.min(
|
||||
options.width / image.width,
|
||||
options.height / image.height,
|
||||
);
|
||||
const drawWidth = image.width * fitScale;
|
||||
const drawHeight = image.height * fitScale;
|
||||
const drawX = options.x + (options.width - drawWidth) / 2;
|
||||
const drawY = options.y + (options.height - drawHeight) / 2;
|
||||
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
export async function sliceSpriteSheetFrames(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
},
|
||||
) {
|
||||
const image = await loadImageFromSource(spriteSource);
|
||||
const frameWidth = Math.floor(image.width / options.cols);
|
||||
const frameHeight = Math.floor(image.height / options.rows);
|
||||
const frames: string[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < options.rows; rowIndex += 1) {
|
||||
for (let colIndex = 0; colIndex < options.cols; colIndex += 1) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth;
|
||||
canvas.height = frameHeight;
|
||||
context.drawImage(
|
||||
image,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
0,
|
||||
0,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
frames.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frames,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
outputSize?: number;
|
||||
},
|
||||
) {
|
||||
const sliced = await sliceSpriteSheetFrames(spriteSource, {
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
});
|
||||
const frameSource = sliced.frames[options.frameIndex];
|
||||
|
||||
if (!frameSource) {
|
||||
throw new Error('帧索引超出范围。');
|
||||
}
|
||||
|
||||
if (!options.outputSize) {
|
||||
return frameSource;
|
||||
}
|
||||
|
||||
const image = await loadImageFromSource(frameSource);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = options.outputSize;
|
||||
canvas.height = options.outputSize;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function replaceSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
replacementSource: string;
|
||||
},
|
||||
) {
|
||||
const spriteImage = await loadImageFromSource(spriteSource);
|
||||
const replacementImage = await loadImageFromSource(options.replacementSource);
|
||||
const frameWidth = Math.floor(spriteImage.width / options.cols);
|
||||
const frameHeight = Math.floor(spriteImage.height / options.rows);
|
||||
const rowIndex = Math.floor(options.frameIndex / options.cols);
|
||||
const colIndex = options.frameIndex % options.cols;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = spriteImage.width;
|
||||
canvas.height = spriteImage.height;
|
||||
context.drawImage(spriteImage, 0, 0);
|
||||
context.clearRect(
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
context.drawImage(
|
||||
replacementImage,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameIndices(
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return frameOrder.filter((frameIndex) => activeFrames.includes(frameIndex));
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameSources(
|
||||
frameDataUrls: string[],
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return buildOrderedActiveFrameIndices(frameOrder, activeFrames)
|
||||
.map((frameIndex) => frameDataUrls[frameIndex] ?? '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function composeSpriteSheetFromFrames(
|
||||
frameSources: string[],
|
||||
options: {
|
||||
cols: number;
|
||||
rows?: number;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
padToGrid?: boolean;
|
||||
},
|
||||
) {
|
||||
if (frameSources.length === 0) {
|
||||
throw new Error('没有可用于拼接精灵表的帧。');
|
||||
}
|
||||
|
||||
const images = await Promise.all(
|
||||
frameSources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const frameWidth =
|
||||
options.frameWidth ??
|
||||
Math.max(...images.map((image) => image.width), 1);
|
||||
const frameHeight =
|
||||
options.frameHeight ??
|
||||
Math.max(...images.map((image) => image.height), 1);
|
||||
const rows =
|
||||
options.rows ?? Math.max(1, Math.ceil(images.length / options.cols));
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth * options.cols;
|
||||
canvas.height = frameHeight * rows;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const totalCells = options.padToGrid ? rows * options.cols : images.length;
|
||||
for (let index = 0; index < totalCells; index += 1) {
|
||||
const image = images[index];
|
||||
if (!image) {
|
||||
continue;
|
||||
}
|
||||
const rowIndex = Math.floor(index / options.cols);
|
||||
const colIndex = index % options.cols;
|
||||
drawContainedImage(context, image, {
|
||||
x: colIndex * frameWidth,
|
||||
y: rowIndex * frameHeight,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
rows,
|
||||
cols: options.cols,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frameCount: frameSources.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPlayableCharacterStyleReferenceBoard(
|
||||
sources = PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
|
||||
) {
|
||||
const images = await Promise.all(
|
||||
sources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const cols = 3;
|
||||
const rows = 2;
|
||||
const cellSize = 320;
|
||||
const padding = 24;
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = cols * cellSize + padding * 2;
|
||||
canvas.height = rows * cellSize + padding * 2;
|
||||
context.fillStyle = '#f6f0dd';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const colIndex = index % cols;
|
||||
const rowIndex = Math.floor(index / cols);
|
||||
drawContainedImage(context, image, {
|
||||
x: padding + colIndex * cellSize,
|
||||
y: padding + rowIndex * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
});
|
||||
});
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
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} ?????????????????????????????????????/??/???????????????????????`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CHARACTER_DETAIL_COVERAGE_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildSheetPrompt(options: {
|
||||
characterBrief: string;
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
extraDirection: string;
|
||||
}) {
|
||||
return [
|
||||
`???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.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.characterBrief.trim(),
|
||||
`???????${options.extraDirection.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildRepairPrompt(options: {
|
||||
issueText: string;
|
||||
useNeighborLabel: '???' | '???';
|
||||
}) {
|
||||
return [
|
||||
`???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');
|
||||
}
|
||||
|
||||
export function buildVideoActionPrompt(options: {
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
actionDetailText: string;
|
||||
useChromaKey: boolean;
|
||||
characterBrief: string;
|
||||
}) {
|
||||
return [
|
||||
`???????????????? ${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.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||
`?????${options.actionTemplate.sequenceLines.join('?')}??????${options.actionTemplate.ending}?`,
|
||||
options.useChromaKey
|
||||
? '??????????????????????????????'
|
||||
: '?????????????',
|
||||
`???????${options.actionDetailText.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
`?????${options.characterBrief.trim()}`,
|
||||
'?????????????????????????????????????????',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export async function triggerDataUrlDownload(
|
||||
filename: string,
|
||||
dataUrl: string,
|
||||
) {
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function triggerJsonDownload(filename: string, value: unknown) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function buildDefaultFrameOrder(frameCount: number) {
|
||||
return Array.from({ length: frameCount }, (_, index) => index);
|
||||
}
|
||||
|
||||
export function restoreAllFrames(frameCount: number) {
|
||||
return buildDefaultFrameOrder(frameCount);
|
||||
}
|
||||
|
||||
export function buildMasterNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,也不要把主题词自动扩写成角色以外的场景元素,除非文字设定明确要求`;
|
||||
}
|
||||
|
||||
export function buildSheetNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_SHEET_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function buildRepairNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_REPAIR_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function moveFrameOrderItem(
|
||||
frameOrder: number[],
|
||||
frameIndex: number,
|
||||
direction: -1 | 1,
|
||||
) {
|
||||
const currentOrderIndex = frameOrder.indexOf(frameIndex);
|
||||
if (currentOrderIndex < 0) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const targetIndex = currentOrderIndex + direction;
|
||||
if (targetIndex < 0 || targetIndex >= frameOrder.length) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const nextOrder = [...frameOrder];
|
||||
const [item] = nextOrder.splice(currentOrderIndex, 1);
|
||||
nextOrder.splice(targetIndex, 0, item);
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function toggleActiveFrame(activeFrames: number[], frameIndex: number) {
|
||||
if (activeFrames.includes(frameIndex)) {
|
||||
return activeFrames.filter((item) => item !== frameIndex);
|
||||
}
|
||||
|
||||
return [...activeFrames, frameIndex].sort((left, right) => left - right);
|
||||
}
|
||||
119
src/prompts/runtimeItemPrompts.ts
Normal file
119
src/prompts/runtimeItemPrompts.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
buildRuntimeItemAiIntent,
|
||||
buildRuntimeItemAiPromptInput,
|
||||
} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import { buildRuntimeItemStoryFingerprint } from '../services/storyEngine/carrierNarrativeCompiler';
|
||||
import { buildCarrierVisibilitySlice } from '../services/storyEngine/visibilityEngine';
|
||||
|
||||
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return `NPC:${anchor.npcName}`;
|
||||
case 'scene':
|
||||
return `场景:${anchor.sceneName}`;
|
||||
case 'monster':
|
||||
return `怪物:${anchor.monsterName}`;
|
||||
case 'quest':
|
||||
return `任务:${anchor.questName}`;
|
||||
case 'faction':
|
||||
return `势力:${anchor.factionName}`;
|
||||
default:
|
||||
return `地标:${anchor.landmarkName}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describeCarrierFactId(factId: string) {
|
||||
if (factId === 'visibleClue') return '可见线索';
|
||||
if (factId === 'currentAppearanceReason') return '当前出现理由';
|
||||
if (factId === 'witnessMark') return '见证痕';
|
||||
if (factId === 'unresolvedQuestion') return '未完成问题';
|
||||
if (factId.startsWith('thread:')) return `线程索引(${factId.slice('thread:'.length)})`;
|
||||
return factId;
|
||||
}
|
||||
|
||||
function describePlan(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
index: number,
|
||||
) {
|
||||
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
|
||||
const fallbackIntent = buildRuntimeItemAiIntent(context, plan);
|
||||
const fallbackFingerprint = buildRuntimeItemStoryFingerprint({
|
||||
context,
|
||||
plan,
|
||||
intent: fallbackIntent,
|
||||
});
|
||||
const visibilitySlice = buildCarrierVisibilitySlice({
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
storyFingerprint: fallbackFingerprint,
|
||||
});
|
||||
|
||||
return [
|
||||
`物品 ${index + 1}`,
|
||||
`- slot: ${plan.slot}`,
|
||||
`- 物品类型: ${promptInput.desiredItemKind}`,
|
||||
`- 持续性: ${promptInput.permanence}`,
|
||||
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
|
||||
`- 世界摘要: ${promptInput.worldSummary}`,
|
||||
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
|
||||
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
|
||||
`- 相关人物: ${promptInput.relatedNpcSummary}`,
|
||||
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
|
||||
`- 近期剧情: ${promptInput.recentStorySummary}`,
|
||||
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
|
||||
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
|
||||
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
|
||||
`- 物件可直写线索: ${visibilitySlice.sayableFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
`- 物件只宜暗写: ${visibilitySlice.inferredFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival",
|
||||
"visibleClue": "玩家第一眼能抓到的痕迹",
|
||||
"witnessMark": "它见证过什么的使用痕",
|
||||
"unfinishedBusiness": "背后仍未结清的问题",
|
||||
"hiddenHook": "更深一层但别直接讲穿的钩子",
|
||||
"reactionHooks": ["以后谁会对它起反应"],
|
||||
"namingPattern": "命名范式建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- visibleClue / witnessMark / unfinishedBusiness / hiddenHook 要优先围绕当前激活线程、相关角色压力和旧痕来写。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPrompt(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.context.generationChannel}`,
|
||||
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
|
||||
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
}
|
||||
5
src/prompts/storyOrchestratorPrompts.ts
Normal file
5
src/prompts/storyOrchestratorPrompts.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
|
||||
你会收到一个已经解析过的剧情 JSON 对象。
|
||||
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
|
||||
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
1881
src/prompts/storyPromptBuilders.ts
Normal file
1881
src/prompts/storyPromptBuilders.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,26 @@ import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
resolveFunctionOption,
|
||||
} from '../data/stateFunctions';
|
||||
import {
|
||||
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt,
|
||||
buildCustomWorldActorNarrativeProfileBatchPrompt,
|
||||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
buildCustomWorldFrameworkPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleBatchPrompt,
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
buildCustomWorldStoryGraphJsonRepairPrompt,
|
||||
buildCustomWorldStoryGraphPrompt,
|
||||
buildCustomWorldThemePackJsonRepairPrompt,
|
||||
buildCustomWorldThemePackPrompt,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
} from '../prompts/customWorldPrompts';
|
||||
import {
|
||||
AIResponse,
|
||||
Character,
|
||||
@@ -40,6 +60,8 @@ import {
|
||||
buildOfflineNpcRecruitDialogue as buildOfflineNpcRecruitDialogueFromFallback,
|
||||
} from './aiFallbacks';
|
||||
import type {
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
@@ -56,30 +78,12 @@ import {
|
||||
CharacterChatTargetStatus,
|
||||
} from './characterChatPrompt';
|
||||
import {
|
||||
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt,
|
||||
buildCustomWorldActorNarrativeProfileBatchPrompt,
|
||||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
buildCustomWorldFrameworkPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleBatchPrompt,
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
buildCustomWorldStoryGraphJsonRepairPrompt,
|
||||
buildCustomWorldStoryGraphPrompt,
|
||||
buildCustomWorldThemePackJsonRepairPrompt,
|
||||
buildCustomWorldThemePackPrompt,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationLandmarkOutline,
|
||||
type CustomWorldGenerationRoleBatchStage,
|
||||
type CustomWorldGenerationRoleBatchType,
|
||||
type CustomWorldGenerationRoleOutline,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
@@ -137,6 +141,8 @@ export type {
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
export type {
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
@@ -154,8 +160,7 @@ type MergeableCustomWorldRoleEntry = {
|
||||
};
|
||||
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
|
||||
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL ||
|
||||
'/api/custom-world/scene-image';
|
||||
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL || '/api/custom-world/scene-image';
|
||||
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
@@ -179,38 +184,6 @@ const CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS = (() => {
|
||||
return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 150000;
|
||||
})();
|
||||
|
||||
export interface CustomWorldSceneImageRequest {
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'subtitle'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'settingText'
|
||||
>;
|
||||
landmark: Pick<
|
||||
CustomWorldProfile['landmarks'][number],
|
||||
'id' | 'name' | 'description' | 'dangerLevel'
|
||||
>;
|
||||
userPrompt?: string;
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
size?: string;
|
||||
referenceImageSrc?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldSceneImageResult {
|
||||
imageSrc: string;
|
||||
assetId: string;
|
||||
model: string;
|
||||
size: string;
|
||||
taskId: string;
|
||||
prompt: string;
|
||||
actualPrompt?: string;
|
||||
}
|
||||
|
||||
const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
{
|
||||
id: 'framework',
|
||||
@@ -1992,23 +1965,26 @@ export async function generateCustomWorldSceneImage({
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetchWithApiAuth(CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profileId: profile.id,
|
||||
worldName: profile.name,
|
||||
landmarkId: landmark.id,
|
||||
landmarkName: landmark.name,
|
||||
prompt: resolvedPrompt,
|
||||
negativePrompt: resolvedNegativePrompt,
|
||||
size,
|
||||
...(referenceImageSrc?.trim()
|
||||
? { referenceImageSrc: referenceImageSrc.trim() }
|
||||
: {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profileId: profile.id,
|
||||
worldName: profile.name,
|
||||
landmarkId: landmark.id,
|
||||
landmarkName: landmark.name,
|
||||
prompt: resolvedPrompt,
|
||||
negativePrompt: resolvedNegativePrompt,
|
||||
size,
|
||||
...(referenceImageSrc?.trim()
|
||||
? { referenceImageSrc: referenceImageSrc.trim() }
|
||||
: {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -32,12 +32,13 @@ import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
@@ -48,7 +49,7 @@ import type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
} from './ai';
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
@@ -365,13 +366,18 @@ export async function generateCustomWorldProfile(
|
||||
: {
|
||||
settingText: input.settingText,
|
||||
creatorIntent: input.creatorIntent ?? null,
|
||||
generationMode: input.generationMode === 'fast' ? 'fast' as const : 'full' as const,
|
||||
generationMode:
|
||||
input.generationMode === 'fast'
|
||||
? ('fast' as const)
|
||||
: ('full' as const),
|
||||
};
|
||||
|
||||
const session = await createCustomWorldSession({
|
||||
settingText: normalizedInput.settingText,
|
||||
creatorIntent:
|
||||
normalizedInput.creatorIntent as Record<string, unknown> | null,
|
||||
creatorIntent: normalizedInput.creatorIntent as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
generationMode: normalizedInput.generationMode,
|
||||
});
|
||||
|
||||
@@ -380,7 +386,8 @@ export async function generateCustomWorldProfile(
|
||||
typeof normalizedInput.creatorIntent?.worldHook === 'string' &&
|
||||
normalizedInput.creatorIntent.worldHook.trim()
|
||||
? normalizedInput.creatorIntent.worldHook.trim()
|
||||
: normalizedInput.settingText.trim().slice(0, 120) || '这是一个围绕失衡秩序展开的世界。',
|
||||
: normalizedInput.settingText.trim().slice(0, 120) ||
|
||||
'这是一个围绕失衡秩序展开的世界。',
|
||||
player_premise:
|
||||
typeof normalizedInput.creatorIntent?.playerPremise === 'string' &&
|
||||
normalizedInput.creatorIntent.playerPremise.trim()
|
||||
@@ -395,9 +402,9 @@ export async function generateCustomWorldProfile(
|
||||
Array.isArray(normalizedInput.creatorIntent?.coreConflicts) &&
|
||||
normalizedInput.creatorIntent.coreConflicts.length > 0
|
||||
? normalizedInput.creatorIntent.coreConflicts
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
: '旧秩序与新威胁正在同时逼近,各方都在争夺主动权。',
|
||||
};
|
||||
|
||||
@@ -406,7 +413,8 @@ export async function generateCustomWorldProfile(
|
||||
continue;
|
||||
}
|
||||
|
||||
const answer = fallbackAnswerMap[question.id] || normalizedInput.settingText.trim();
|
||||
const answer =
|
||||
fallbackAnswerMap[question.id] || normalizedInput.settingText.trim();
|
||||
await answerCustomWorldSessionQuestion(session.sessionId, {
|
||||
questionId: question.id,
|
||||
answer,
|
||||
@@ -475,20 +483,30 @@ export async function streamCustomWorldSessionGeneration(
|
||||
const payload = JSON.parse(payloadText) as Record<string, unknown>;
|
||||
if (eventName === 'progress') {
|
||||
if (
|
||||
typeof payload.phaseId === 'string'
|
||||
&& typeof payload.phaseLabel === 'string'
|
||||
&& typeof payload.phaseDetail === 'string'
|
||||
&& typeof payload.overallProgress === 'number'
|
||||
&& Array.isArray(payload.steps)
|
||||
typeof payload.phaseId === 'string' &&
|
||||
typeof payload.phaseLabel === 'string' &&
|
||||
typeof payload.phaseDetail === 'string' &&
|
||||
typeof payload.overallProgress === 'number' &&
|
||||
Array.isArray(payload.steps)
|
||||
) {
|
||||
options.onProgress?.(payload as unknown as CustomWorldGenerationProgress);
|
||||
options.onProgress?.(
|
||||
payload as unknown as CustomWorldGenerationProgress,
|
||||
);
|
||||
} else {
|
||||
options.onProgress?.({
|
||||
phaseId: 'finalize',
|
||||
phaseLabel: typeof payload.phase === 'string' ? payload.phase : 'generating',
|
||||
phaseDetail: typeof payload.phase === 'string' ? payload.phase : 'generating',
|
||||
phaseLabel:
|
||||
typeof payload.phase === 'string'
|
||||
? payload.phase
|
||||
: 'generating',
|
||||
phaseDetail:
|
||||
typeof payload.phase === 'string'
|
||||
? payload.phase
|
||||
: 'generating',
|
||||
overallProgress:
|
||||
typeof payload.progress === 'number' ? payload.progress / 100 : 0,
|
||||
typeof payload.progress === 'number'
|
||||
? payload.progress / 100
|
||||
: 0,
|
||||
completedWeight:
|
||||
typeof payload.progress === 'number' ? payload.progress : 0,
|
||||
totalWeight: 100,
|
||||
@@ -499,7 +517,11 @@ export async function streamCustomWorldSessionGeneration(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (eventName === 'result' && payload.profile && typeof payload.profile === 'object') {
|
||||
if (
|
||||
eventName === 'result' &&
|
||||
payload.profile &&
|
||||
typeof payload.profile === 'object'
|
||||
) {
|
||||
latestProfile = payload.profile as Record<string, unknown>;
|
||||
}
|
||||
if (eventName === 'error') {
|
||||
@@ -521,10 +543,17 @@ export async function streamCustomWorldSessionGeneration(
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage(
|
||||
...args: [CustomWorldSceneImageRequest]
|
||||
payload: CustomWorldSceneImageRequest,
|
||||
) {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldSceneImage(...args);
|
||||
return requestJson<CustomWorldSceneImageResult>(
|
||||
`${CUSTOM_WORLD_API_BASE}/custom-world/scene-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成自定义世界场景图失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneNpc(payload: {
|
||||
@@ -779,10 +808,12 @@ export async function getCustomWorldAgentOperation(
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
): Promise<CustomWorldAgentOperationRecord> {
|
||||
const response = await requestJson<{
|
||||
operation?: CustomWorldAgentOperationRecord;
|
||||
data?: CustomWorldAgentOperationRecord;
|
||||
} & Partial<CustomWorldAgentOperationRecord>>(
|
||||
const response = await requestJson<
|
||||
{
|
||||
operation?: CustomWorldAgentOperationRecord;
|
||||
data?: CustomWorldAgentOperationRecord;
|
||||
} & Partial<CustomWorldAgentOperationRecord>
|
||||
>(
|
||||
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/operations/${encodeURIComponent(operationId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -790,7 +821,9 @@ export async function getCustomWorldAgentOperation(
|
||||
'读取共创操作状态失败',
|
||||
);
|
||||
|
||||
return (response.operation ?? response.data ?? response) as CustomWorldAgentOperationRecord;
|
||||
return (response.operation ??
|
||||
response.data ??
|
||||
response) as CustomWorldAgentOperationRecord;
|
||||
}
|
||||
|
||||
export async function getCustomWorldAgentCardDetail(
|
||||
@@ -827,7 +860,9 @@ export async function answerCustomWorldSessionQuestion(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload satisfies AnswerCustomWorldSessionQuestionRequest),
|
||||
body: JSON.stringify(
|
||||
payload satisfies AnswerCustomWorldSessionQuestionRequest,
|
||||
),
|
||||
},
|
||||
'提交自定义世界补充设定失败',
|
||||
);
|
||||
@@ -938,6 +973,10 @@ export async function streamNpcChatTurn(
|
||||
npcState: Record<string, unknown>,
|
||||
options: {
|
||||
onReplyUpdate?: (text: string) => void;
|
||||
questOfferContext?: {
|
||||
state: GameState;
|
||||
turnCount: number;
|
||||
} | null;
|
||||
} = {},
|
||||
) {
|
||||
const payload = {
|
||||
@@ -952,13 +991,23 @@ export async function streamNpcChatTurn(
|
||||
dialogue: conversationHistory ?? [],
|
||||
playerMessage,
|
||||
npcState,
|
||||
questOfferContext: options.questOfferContext
|
||||
? {
|
||||
state: options.questOfferContext.state,
|
||||
encounter,
|
||||
turnCount: options.questOfferContext.turnCount,
|
||||
}
|
||||
: null,
|
||||
} satisfies NpcChatTurnRequest;
|
||||
|
||||
const response = await fetchWithApiAuth(`${RUNTIME_API_BASE}/chat/npc/turn/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
`${RUNTIME_API_BASE}/chat/npc/turn/stream`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
@@ -998,7 +1047,10 @@ export async function streamNpcChatTurn(
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'reply_delta') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const nextText =
|
||||
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
||||
accumulatedReply = nextText;
|
||||
@@ -1014,7 +1066,10 @@ export async function streamNpcChatTurn(
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'error') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
throw new Error(
|
||||
typeof payloadRecord.message === 'string'
|
||||
? payloadRecord.message
|
||||
|
||||
@@ -42,7 +42,7 @@ import type {
|
||||
WorldMutation,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {ConversationPressure, ConversationSituation} from '../types';
|
||||
import type { ConversationPressure, ConversationSituation } from '../types';
|
||||
|
||||
export interface StoryRequestOptions {
|
||||
availableOptions?: StoryOption[];
|
||||
@@ -53,6 +53,39 @@ export interface TextStreamOptions {
|
||||
onUpdate?: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface CustomWorldSceneImageRequest {
|
||||
profile: {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
settingText: string;
|
||||
};
|
||||
landmark: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
};
|
||||
userPrompt?: string;
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
size?: string;
|
||||
referenceImageSrc?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldSceneImageResult {
|
||||
imageSrc: string;
|
||||
assetId: string;
|
||||
model: string;
|
||||
size: string;
|
||||
taskId: string;
|
||||
prompt: string;
|
||||
actualPrompt?: string;
|
||||
}
|
||||
|
||||
export interface StoryGenerationContext {
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
@@ -88,7 +121,12 @@ export interface StoryGenerationContext {
|
||||
encounterAllowedTopics?: string[] | null;
|
||||
encounterBlockedTopics?: string[] | null;
|
||||
isFirstMeaningfulContact?: boolean;
|
||||
firstContactRelationStance?: 'guarded' | 'neutral' | 'cooperative' | 'bonded' | null;
|
||||
firstContactRelationStance?:
|
||||
| 'guarded'
|
||||
| 'neutral'
|
||||
| 'cooperative'
|
||||
| 'bonded'
|
||||
| null;
|
||||
conversationSituation?: ConversationSituation | null;
|
||||
conversationPressure?: ConversationPressure | null;
|
||||
recentSharedEvent?: string | null;
|
||||
|
||||
@@ -1,333 +1 @@
|
||||
import {
|
||||
buildSchemaSummary,
|
||||
describeTopAttributes,
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
buildCharacterBackstoryPromptContext,
|
||||
getCharacterPublicBackstorySummary,
|
||||
getLockedCharacterBackstoryChapters,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
FacingDirection,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildCustomWorldReferenceText } from './customWorld';
|
||||
import { buildStoryPromptHistory } from './storyHistory';
|
||||
|
||||
export interface CharacterChatTargetStatus {
|
||||
roleLabel?: string | null;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
}
|
||||
|
||||
export interface CharacterChatPromptContext {
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
inBattle: boolean;
|
||||
playerFacing: FacingDirection;
|
||||
playerAnimation: AnimationState;
|
||||
sceneName?: string | null;
|
||||
sceneDescription?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
|
||||
只回复这名角色此刻会对玩家说的话。
|
||||
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
|
||||
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
|
||||
只输出纯文本,共 3 行,每行一条。
|
||||
不要加编号、项目符号、Markdown 或额外说明。
|
||||
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
|
||||
只输出一段简洁文字。
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
function describeWorld(world: WorldType) {
|
||||
if (world === WorldType.WUXIA) return '边城模板';
|
||||
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||||
return '自定义世界';
|
||||
}
|
||||
|
||||
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
|
||||
return customWorldProfile
|
||||
? `自定义世界参考:\n${buildCustomWorldReferenceText(customWorldProfile)}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function describeGender(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function describeFacing(facing: FacingDirection) {
|
||||
return facing === 'left' ? '左' : '右';
|
||||
}
|
||||
|
||||
function describeHpBand(ratio: number) {
|
||||
if (ratio >= 0.95) return '几乎无伤';
|
||||
if (ratio >= 0.75) return '状态稳健';
|
||||
if (ratio >= 0.55) return '略有消耗';
|
||||
if (ratio >= 0.35) return '伤势明显';
|
||||
if (ratio >= 0.15) return '伤势沉重';
|
||||
return '濒临极限';
|
||||
}
|
||||
|
||||
function describeManaBand(ratio: number) {
|
||||
if (ratio >= 0.9) return '充盈';
|
||||
if (ratio >= 0.7) return '稳定';
|
||||
if (ratio >= 0.45) return '尚可';
|
||||
if (ratio >= 0.2) return '偏低';
|
||||
if (ratio > 0) return '接近枯竭';
|
||||
return '耗尽';
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: StoryMoment[]) {
|
||||
const promptHistory = buildStoryPromptHistory(history);
|
||||
|
||||
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
promptHistory.previousSummary
|
||||
? `更早剧情摘要:\n${promptHistory.previousSummary}`
|
||||
: '更早剧情摘要:暂无。',
|
||||
promptHistory.recentOriginalRounds.length > 0
|
||||
? `最近 3 轮剧情:\n${promptHistory.recentOriginalRounds
|
||||
.map((item, index) => `- 第 ${index + 1} 轮:\n${item}`)
|
||||
.join('\n')}`
|
||||
: '最近 3 轮剧情:暂无。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeBackstoryContext(label: string, snippets: string[]) {
|
||||
const normalized = snippets
|
||||
.map(snippet => snippet.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [`${label}:暂无公开信息。`];
|
||||
}
|
||||
|
||||
return normalized.map((snippet, index) =>
|
||||
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`,
|
||||
);
|
||||
}
|
||||
|
||||
function describeCharacterInfo(
|
||||
label: string,
|
||||
character: Character,
|
||||
world: WorldType,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
options: {
|
||||
affinity?: number | null;
|
||||
includeUnlockProgress?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(character, world, customWorldProfile);
|
||||
const skills = character.skills.length > 0
|
||||
? character.skills
|
||||
.map(
|
||||
skill => `${skill.name}(伤害 ${skill.damage}/灵力 ${skill.manaCost}/冷却 ${skill.cooldownTurns})`,
|
||||
)
|
||||
.join(' | ')
|
||||
: '无';
|
||||
const backgroundLines = options.affinity == null
|
||||
? [getCharacterPublicBackstorySummary(character, world)]
|
||||
: buildCharacterBackstoryPromptContext(character, options.affinity, world);
|
||||
const nextLockedChapter = options.includeUnlockProgress && options.affinity != null
|
||||
? getLockedCharacterBackstoryChapters(character, options.affinity, world)[0] ?? null
|
||||
: null;
|
||||
const schemaSummary = buildSchemaSummary(schema)
|
||||
.map(slot => `${slot.name}(${slot.definition})`)
|
||||
.join(' | ');
|
||||
const topAttributes = describeTopAttributes(attributeProfile, schema).join('、') || '无';
|
||||
const attributeDetails = formatAttributeList(attributeProfile, schema)
|
||||
.map(entry => `${entry.slot.name} ${entry.value}`)
|
||||
.join(' | ');
|
||||
|
||||
return [
|
||||
`${label}姓名:${character.name}`,
|
||||
`${label}称号:${character.title}`,
|
||||
`${label}性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
`${label}描述:${character.description}`,
|
||||
...describeBackstoryContext(`${label}背景`, backgroundLines),
|
||||
nextLockedChapter
|
||||
? `${label}未解锁背景:${nextLockedChapter.title}(需好感 ${nextLockedChapter.affinityRequired},当前只知道:${nextLockedChapter.teaser})`
|
||||
: null,
|
||||
`${label}性格:${character.personality}`,
|
||||
`${label}世界属性框架:${schemaSummary}`,
|
||||
`${label}主要属性:${topAttributes}`,
|
||||
`${label}属性详情:${attributeDetails}`,
|
||||
`${label}技能:${skills}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeChatContext(world: WorldType, context: CharacterChatPromptContext) {
|
||||
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
|
||||
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
`玩家战斗状态:${context.inBattle ? '战斗中' : '非战斗'}`,
|
||||
`场景:${context.sceneName ?? '当前区域'}`,
|
||||
`场景描述:${context.sceneDescription ?? '周围气氛仍未安定。'}`,
|
||||
`玩家状态:生命 ${context.playerHp}/${context.playerMaxHp}(${describeHpBand(hpRatio)}),灵力 ${context.playerMana}/${context.playerMaxMana}(${describeManaBand(manaRatio)}),朝向 ${describeFacing(context.playerFacing)},动作 ${context.playerAnimation}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeTargetStatus(status: CharacterChatTargetStatus) {
|
||||
const hpRatio = status.hp / Math.max(status.maxHp, 1);
|
||||
const manaRatio = status.mana / Math.max(status.maxMana, 1);
|
||||
|
||||
return [
|
||||
`对方身份:${status.roleLabel ?? '同行角色'}`,
|
||||
`对方状态:生命 ${status.hp}/${status.maxHp}(${describeHpBand(hpRatio)}),灵力 ${status.mana}/${status.maxMana}(${describeManaBand(manaRatio)})`,
|
||||
status.affinity != null ? `当前好感:${status.affinity}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
if (history.length === 0) {
|
||||
return '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'聊天记录:',
|
||||
...history.slice(-12).map(turn => `- ${turn.speaker === 'player' ? '玩家' : '角色'}:${turn.text}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
playerMessage: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
`玩家刚刚对 ${targetCharacter.name} 说:${playerMessage}`,
|
||||
`现在请以 ${targetCharacter.name} 的身份,直接回复玩家。`,
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
const latestCharacterReply = [...conversationHistory]
|
||||
.reverse()
|
||||
.find(turn => turn.speaker === 'character')?.text ?? null;
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
latestCharacterReply
|
||||
? `角色刚刚的回复:${latestCharacterReply}`
|
||||
: `玩家正准备与 ${targetCharacter.name} 开始一段新的私聊。`,
|
||||
'生成 3 条可以直接发送的简短玩家回复候选。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
previousSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
previousSummary ? `旧摘要:${previousSummary}` : '旧摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
'请把旧摘要与最新聊天合并成一段更新后的关系摘要,供后续剧情推理使用。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
export * from '../prompts/characterChatPrompts';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
131
src/services/customWorldCover.ts
Normal file
131
src/services/customWorldCover.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import type {
|
||||
CustomWorldCoverProfile,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
|
||||
export type CustomWorldCoverRenderMode = 'image' | 'scene_with_roles';
|
||||
|
||||
export type CustomWorldCoverPresentation = {
|
||||
imageSrc: string | null;
|
||||
renderMode: CustomWorldCoverRenderMode;
|
||||
characterImageSrcs: string[];
|
||||
sourceType: CustomWorldCoverProfile['sourceType'];
|
||||
};
|
||||
|
||||
function resolveOpeningSceneImageSrc(profile: CustomWorldProfile) {
|
||||
const campImageSrc = profile.camp?.imageSrc?.trim() || '';
|
||||
if (campImageSrc) {
|
||||
return campImageSrc;
|
||||
}
|
||||
|
||||
return (
|
||||
profile.landmarks
|
||||
.map((landmark) => landmark.imageSrc?.trim() || '')
|
||||
.find(Boolean) || null
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePlayableCoverImageSrc(role: CustomWorldPlayableNpc) {
|
||||
const explicitImageSrc = role.imageSrc?.trim() || '';
|
||||
if (explicitImageSrc) {
|
||||
return explicitImageSrc;
|
||||
}
|
||||
|
||||
if (!role.templateCharacterId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === role.templateCharacterId,
|
||||
)?.portrait ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCoverCharacterRoleIds(
|
||||
profile: Pick<CustomWorldProfile, 'playableNpcs'>,
|
||||
roleIds?: string[] | null,
|
||||
) {
|
||||
const availableIds = new Set(
|
||||
profile.playableNpcs.map((role) => role.id.trim()).filter(Boolean),
|
||||
);
|
||||
const selectedIds = Array.isArray(roleIds)
|
||||
? [
|
||||
...new Set(
|
||||
roleIds
|
||||
.map((roleId) => roleId.trim())
|
||||
.filter((roleId) => roleId && availableIds.has(roleId)),
|
||||
),
|
||||
].slice(0, 3)
|
||||
: [];
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
return profile.playableNpcs
|
||||
.map((role) => role.id.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
export function buildDefaultCustomWorldCoverProfile(
|
||||
profile: Pick<CustomWorldProfile, 'playableNpcs'>,
|
||||
): CustomWorldCoverProfile {
|
||||
return {
|
||||
sourceType: 'default',
|
||||
imageSrc: null,
|
||||
characterRoleIds: normalizeCoverCharacterRoleIds(profile),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCustomWorldCoverPresentation(
|
||||
profile: CustomWorldProfile,
|
||||
): CustomWorldCoverPresentation {
|
||||
const cover = profile.cover;
|
||||
const sourceType =
|
||||
cover?.sourceType === 'uploaded' || cover?.sourceType === 'generated'
|
||||
? cover.sourceType
|
||||
: 'default';
|
||||
const explicitImageSrc = cover?.imageSrc?.trim() || '';
|
||||
|
||||
if (sourceType !== 'default' && explicitImageSrc) {
|
||||
return {
|
||||
imageSrc: explicitImageSrc,
|
||||
renderMode: 'image',
|
||||
characterImageSrcs: [],
|
||||
sourceType,
|
||||
};
|
||||
}
|
||||
|
||||
const openingSceneImageSrc = resolveOpeningSceneImageSrc(profile);
|
||||
const roleById = new Map(
|
||||
profile.playableNpcs.map((role) => [role.id.trim(), role] as const),
|
||||
);
|
||||
const characterImageSrcs = normalizeCoverCharacterRoleIds(
|
||||
profile,
|
||||
cover?.characterRoleIds,
|
||||
)
|
||||
.map((roleId) => roleById.get(roleId))
|
||||
.map((role) => (role ? resolvePlayableCoverImageSrc(role) : null))
|
||||
.filter((imageSrc): imageSrc is string => Boolean(imageSrc));
|
||||
const leadPlayableImageSrc =
|
||||
profile.playableNpcs
|
||||
.map((role) => resolvePlayableCoverImageSrc(role))
|
||||
.find(Boolean) || null;
|
||||
|
||||
return {
|
||||
imageSrc: openingSceneImageSrc || leadPlayableImageSrc,
|
||||
renderMode:
|
||||
openingSceneImageSrc && characterImageSrcs.length > 0
|
||||
? 'scene_with_roles'
|
||||
: 'image',
|
||||
characterImageSrcs:
|
||||
openingSceneImageSrc && characterImageSrcs.length > 0
|
||||
? characterImageSrcs
|
||||
: [],
|
||||
sourceType: 'default',
|
||||
};
|
||||
}
|
||||
57
src/services/customWorldCoverAssetService.ts
Normal file
57
src/services/customWorldCoverAssetService.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { requestJson } from './apiClient';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
|
||||
const CUSTOM_WORLD_COVER_API_BASE = '/api/runtime/custom-world';
|
||||
|
||||
export interface CustomWorldCoverAssetResult {
|
||||
imageSrc: string;
|
||||
assetId: string;
|
||||
sourceType: 'uploaded' | 'generated';
|
||||
model?: string;
|
||||
size?: string;
|
||||
taskId?: string;
|
||||
prompt?: string;
|
||||
actualPrompt?: string;
|
||||
}
|
||||
|
||||
export interface GenerateCustomWorldCoverImageRequest {
|
||||
profile: CustomWorldProfile;
|
||||
userPrompt?: string;
|
||||
referenceImageSrc?: string;
|
||||
characterRoleIds?: string[];
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface UploadCustomWorldCoverImageRequest {
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
imageDataUrl: string;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldCoverImage(
|
||||
payload: GenerateCustomWorldCoverImageRequest,
|
||||
) {
|
||||
return requestJson<CustomWorldCoverAssetResult>(
|
||||
`${CUSTOM_WORLD_COVER_API_BASE}/cover-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成作品封面失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadCustomWorldCoverImage(
|
||||
payload: UploadCustomWorldCoverImageRequest,
|
||||
) {
|
||||
return requestJson<CustomWorldCoverAssetResult>(
|
||||
`${CUSTOM_WORLD_COVER_API_BASE}/cover-upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'上传作品封面失败',
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -230,7 +230,17 @@ export async function generateQuestForNpcEncounter(params: {
|
||||
'任务生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[QuestDirector] backend quest generation failed, falling back', error);
|
||||
console.warn(
|
||||
'[QuestDirector] backend quest generation failed, using deterministic fallback',
|
||||
error,
|
||||
);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'fallback_builder',
|
||||
},
|
||||
fallbackIntent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,175 +1 @@
|
||||
import type {QuestGenerationContext} from './aiTypes';
|
||||
import type {QuestOpportunity, QuestSceneSnapshot} from './questTypes';
|
||||
import { buildQuestVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
|
||||
const moments = context.recentStoryMoments
|
||||
.slice(-4)
|
||||
.map(moment => `- ${moment.text}`)
|
||||
.join('\n');
|
||||
|
||||
return moments || '- 暂无近期剧情记录';
|
||||
}
|
||||
|
||||
function summarizeCurrentQuests(context: QuestGenerationContext) {
|
||||
const summary = context.currentQuestSummary?.map(quest =>
|
||||
`- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`,
|
||||
).join('\n');
|
||||
|
||||
return summary || '- 当前没有进行中的任务';
|
||||
}
|
||||
|
||||
function summarizeCompanions(context: QuestGenerationContext) {
|
||||
const active = context.activeCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
const roster = context.rosterCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
return `当前同行角色:${active}\n队伍名册:${roster}`;
|
||||
}
|
||||
|
||||
function summarizePlayerState(context: QuestGenerationContext) {
|
||||
const playerName = context.playerCharacter?.name ?? '未知角色';
|
||||
const playerTitle = context.playerCharacter?.title ?? '未知称号';
|
||||
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
|
||||
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
|
||||
const inventory = context.playerInventory?.slice(0, 8).map(item => item.name).join('、') || '无';
|
||||
|
||||
return [
|
||||
`玩家:${playerName}(${playerTitle})`,
|
||||
`生命:${hp}`,
|
||||
`灵力:${mana}`,
|
||||
`背包快照:${inventory}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeScene(scene: QuestSceneSnapshot | null, context: QuestGenerationContext) {
|
||||
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
|
||||
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
|
||||
|
||||
return [
|
||||
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
|
||||
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
|
||||
`敌对角色 ID:${hostileNpcIds}`,
|
||||
`宝藏线索数量:${treasureHintCount}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeActiveThreads(context: QuestGenerationContext) {
|
||||
if (!context.activeThreadIds?.length) {
|
||||
return '暂无明确激活线程';
|
||||
}
|
||||
|
||||
const storyGraph = context.customWorldProfile?.storyGraph;
|
||||
const labels = context.activeThreadIds.map((threadId) =>
|
||||
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
|
||||
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
||||
);
|
||||
|
||||
return labels.join('、');
|
||||
}
|
||||
|
||||
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
const profile = context.issuerNarrativeProfile;
|
||||
if (!profile) {
|
||||
return '暂无额外叙事档案';
|
||||
}
|
||||
|
||||
return [
|
||||
`公开面:${profile.publicMask}`,
|
||||
`表层线:${profile.visibleLine}`,
|
||||
`当前压力:${profile.immediatePressure}`,
|
||||
profile.reactionHooks.length > 0
|
||||
? `反应钩子:${profile.reactionHooks.join('、')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function summarizeQuestVisibility(context: QuestGenerationContext) {
|
||||
const slice = buildQuestVisibilitySlice({
|
||||
issuerNarrativeProfile: context.issuerNarrativeProfile,
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
});
|
||||
|
||||
return [
|
||||
`可直接披露:${slice.sayableFactIds.join('、') || '无'}`,
|
||||
`只宜写成推测:${slice.inferredFactIds.join('、') || '无'}`,
|
||||
`禁止直接说破:${slice.forbiddenFactIds.join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
|
||||
只返回 JSON,不要输出 Markdown。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intent": {
|
||||
"title": "中文任务标题",
|
||||
"description": "中文任务描述",
|
||||
"summary": "中文短摘要",
|
||||
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
|
||||
"dramaticNeed": "string",
|
||||
"issuerGoal": "string",
|
||||
"playerHook": "string",
|
||||
"worldReason": "string",
|
||||
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
|
||||
"urgency": "low|medium|high",
|
||||
"intimacy": "transactional|cooperative|trust_based",
|
||||
"rewardTheme": "currency|resource|relationship|intel|rare_item",
|
||||
"followupHooks": ["string"]
|
||||
}
|
||||
}
|
||||
|
||||
规则:
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 任务必须扎根于当前场景、发布者和近期剧情。
|
||||
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
|
||||
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
|
||||
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
|
||||
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
|
||||
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
|
||||
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
|
||||
|
||||
export function buildQuestIntentPrompt(params: {
|
||||
context: QuestGenerationContext;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
opportunity: QuestOpportunity;
|
||||
}) {
|
||||
const {context, scene, opportunity} = params;
|
||||
const customWorldSummary = context.customWorldProfile
|
||||
? `${context.customWorldProfile.name}: ${context.customWorldProfile.summary}`
|
||||
: '无';
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(context.worldType)}`,
|
||||
`自定义世界摘要:${customWorldSummary}`,
|
||||
`发布角色:${context.issuerNpcName ?? '未知'}(${context.issuerNpcId ?? '未知'})`,
|
||||
`发布者身份:${context.issuerNpcContext ?? '暂无'}`,
|
||||
`发布者好感:${context.issuerAffinity ?? 0}`,
|
||||
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
|
||||
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
|
||||
`当前激活线程:${summarizeActiveThreads(context)}`,
|
||||
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
|
||||
`任务可见性切片:\n${summarizeQuestVisibility(context)}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
summarizeCompanions(context),
|
||||
`当前任务机会:${opportunity.reason}`,
|
||||
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
|
||||
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
|
||||
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
|
||||
].join('\n\n');
|
||||
}
|
||||
export * from '../prompts/questPrompts';
|
||||
|
||||
@@ -107,7 +107,11 @@ export async function generateRuntimeItemAiIntents(params: {
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[runtimeItemAiDirector] backend intent generation failed, falling back', error);
|
||||
console.warn(
|
||||
'[runtimeItemAiDirector] backend intent generation failed, using deterministic fallback',
|
||||
error,
|
||||
);
|
||||
return fallbackIntents;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,119 +1 @@
|
||||
import {
|
||||
buildRuntimeItemAiIntent,
|
||||
buildRuntimeItemAiPromptInput,
|
||||
} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import { buildRuntimeItemStoryFingerprint } from './storyEngine/carrierNarrativeCompiler';
|
||||
import { buildCarrierVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
|
||||
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return `NPC:${anchor.npcName}`;
|
||||
case 'scene':
|
||||
return `场景:${anchor.sceneName}`;
|
||||
case 'monster':
|
||||
return `怪物:${anchor.monsterName}`;
|
||||
case 'quest':
|
||||
return `任务:${anchor.questName}`;
|
||||
case 'faction':
|
||||
return `势力:${anchor.factionName}`;
|
||||
default:
|
||||
return `地标:${anchor.landmarkName}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describeCarrierFactId(factId: string) {
|
||||
if (factId === 'visibleClue') return '可见线索';
|
||||
if (factId === 'currentAppearanceReason') return '当前出现理由';
|
||||
if (factId === 'witnessMark') return '见证痕';
|
||||
if (factId === 'unresolvedQuestion') return '未完成问题';
|
||||
if (factId.startsWith('thread:')) return `线程索引(${factId.slice('thread:'.length)})`;
|
||||
return factId;
|
||||
}
|
||||
|
||||
function describePlan(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
index: number,
|
||||
) {
|
||||
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
|
||||
const fallbackIntent = buildRuntimeItemAiIntent(context, plan);
|
||||
const fallbackFingerprint = buildRuntimeItemStoryFingerprint({
|
||||
context,
|
||||
plan,
|
||||
intent: fallbackIntent,
|
||||
});
|
||||
const visibilitySlice = buildCarrierVisibilitySlice({
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
storyFingerprint: fallbackFingerprint,
|
||||
});
|
||||
|
||||
return [
|
||||
`物品 ${index + 1}`,
|
||||
`- slot: ${plan.slot}`,
|
||||
`- 物品类型: ${promptInput.desiredItemKind}`,
|
||||
`- 持续性: ${promptInput.permanence}`,
|
||||
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
|
||||
`- 世界摘要: ${promptInput.worldSummary}`,
|
||||
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
|
||||
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
|
||||
`- 相关人物: ${promptInput.relatedNpcSummary}`,
|
||||
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
|
||||
`- 近期剧情: ${promptInput.recentStorySummary}`,
|
||||
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
|
||||
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
|
||||
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
|
||||
`- 物件可直写线索: ${visibilitySlice.sayableFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
`- 物件只宜暗写: ${visibilitySlice.inferredFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival",
|
||||
"visibleClue": "玩家第一眼能抓到的痕迹",
|
||||
"witnessMark": "它见证过什么的使用痕",
|
||||
"unfinishedBusiness": "背后仍未结清的问题",
|
||||
"hiddenHook": "更深一层但别直接讲穿的钩子",
|
||||
"reactionHooks": ["以后谁会对它起反应"],
|
||||
"namingPattern": "命名范式建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- visibleClue / witnessMark / unfinishedBusiness / hiddenHook 要优先围绕当前激活线程、相关角色压力和旧痕来写。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPrompt(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.context.generationChannel}`,
|
||||
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
|
||||
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
}
|
||||
export * from '../prompts/runtimeItemPrompts';
|
||||
|
||||
@@ -5,7 +5,8 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
@@ -223,24 +224,19 @@ describe('runtimeStoryService', () => {
|
||||
expect(getRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('hydrates runtime option interaction metadata from the current encounter', () => {
|
||||
it('preserves runtime option interaction metadata from the server response', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
gameState: {
|
||||
currentEncounter: {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc',
|
||||
npcName: '梁伯',
|
||||
npcDescription: '沿街商贩',
|
||||
npcAvatar: '',
|
||||
context: '沿街商贩',
|
||||
},
|
||||
} as never,
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
scope: 'npc',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
} from '../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../types';
|
||||
import { AnimationState } from '../types';
|
||||
import { type ApiRetryOptions,requestJson } from './apiClient';
|
||||
import { type ApiRetryOptions, requestJson } from './apiClient';
|
||||
|
||||
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
|
||||
const DEFAULT_SESSION_ID = 'runtime-main';
|
||||
@@ -62,46 +62,9 @@ function requestRuntimeStoryJson<T>(
|
||||
);
|
||||
}
|
||||
|
||||
function buildRuntimeOptionInteraction(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption['interaction'] {
|
||||
const encounter = gameState?.currentEncounter;
|
||||
|
||||
if (encounter?.kind === 'npc') {
|
||||
const npcId = encounter.id ?? encounter.npcName;
|
||||
const npcActionMap: Record<string, StoryOption['interaction']> = {
|
||||
npc_chat: { kind: 'npc', npcId, action: 'chat' },
|
||||
npc_help: { kind: 'npc', npcId, action: 'help' },
|
||||
npc_fight: { kind: 'npc', npcId, action: 'fight' },
|
||||
npc_leave: { kind: 'npc', npcId, action: 'leave' },
|
||||
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
|
||||
npc_spar: { kind: 'npc', npcId, action: 'spar' },
|
||||
npc_trade: { kind: 'npc', npcId, action: 'trade' },
|
||||
npc_gift: { kind: 'npc', npcId, action: 'gift' },
|
||||
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
|
||||
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
|
||||
};
|
||||
|
||||
return npcActionMap[option.functionId];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'treasure') {
|
||||
const treasureActionMap: Record<string, StoryOption['interaction']> = {
|
||||
treasure_secure: { kind: 'treasure', action: 'secure' },
|
||||
treasure_inspect: { kind: 'treasure', action: 'inspect' },
|
||||
treasure_leave: { kind: 'treasure', action: 'leave' },
|
||||
};
|
||||
|
||||
return treasureActionMap[option.functionId];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createRuntimeStoryOption(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
_gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
@@ -116,14 +79,16 @@ function createRuntimeStoryOption(
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: buildRuntimeOptionInteraction(option, gameState),
|
||||
interaction: option.interaction as StoryOption['interaction'] | undefined,
|
||||
runtimePayload: option.payload,
|
||||
disabled: option.disabled,
|
||||
disabledReason: option.reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeSessionId(gameState: Pick<GameState, 'runtimeSessionId'>) {
|
||||
export function getRuntimeSessionId(
|
||||
gameState: Pick<GameState, 'runtimeSessionId'>,
|
||||
) {
|
||||
return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID;
|
||||
}
|
||||
|
||||
@@ -158,7 +123,7 @@ export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
storyText: string;
|
||||
options: RuntimeStoryOptionView[];
|
||||
gameState?: Pick<GameState, 'currentEncounter'>;
|
||||
}) {
|
||||
}): StoryMoment {
|
||||
return {
|
||||
text: params.storyText,
|
||||
options: params.options.map((option) =>
|
||||
@@ -170,11 +135,9 @@ export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
function shouldPreferSnapshotStory(story: StoryMoment | null) {
|
||||
return Boolean(
|
||||
story &&
|
||||
(
|
||||
story.displayMode === 'dialogue' ||
|
||||
(story.displayMode === 'dialogue' ||
|
||||
story.deferredOptions?.length ||
|
||||
story.dialogue?.length
|
||||
),
|
||||
story.dialogue?.length),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +182,9 @@ export async function getRuntimeStoryState(
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
@@ -258,12 +223,12 @@ export async function resolveRuntimeStoryAction(
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
);
|
||||
return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
getCustomWorldGalleryDetail,
|
||||
listCustomWorldGallery,
|
||||
listProfileBrowseHistory,
|
||||
listProfileSaveArchives,
|
||||
resumeProfileSaveArchive,
|
||||
@@ -106,6 +108,50 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageService public custom world gallery routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads the public gallery without attaching auth or refresh coupling', async () => {
|
||||
await listCustomWorldGallery();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品广场失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads public gallery detail without attaching auth or refresh coupling', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
});
|
||||
|
||||
await getCustomWorldGalleryDetail('user-1', 'profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery/user-1/profile-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品详情失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageService save archive routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
|
||||
@@ -40,6 +40,8 @@ const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
export type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
function requestRuntimeJson<T>(
|
||||
@@ -60,10 +62,27 @@ function requestRuntimeJson<T>(
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry },
|
||||
{
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function requestPublicRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
@@ -300,7 +319,7 @@ export async function unpublishCustomWorldProfile(
|
||||
export async function listCustomWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldGalleryResponse>(
|
||||
const response = await requestPublicRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
@@ -315,7 +334,7 @@ export async function getCustomWorldGalleryDetail(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
const response = await requestPublicRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
|
||||
@@ -1,629 +1 @@
|
||||
export type QwenSpriteActionTemplateId =
|
||||
| 'idle'
|
||||
| 'run'
|
||||
| 'attack_slash'
|
||||
| 'hurt'
|
||||
| 'die';
|
||||
|
||||
export type QwenSpriteActionTemplate = {
|
||||
id: QwenSpriteActionTemplateId;
|
||||
label: string;
|
||||
loop: boolean;
|
||||
defaultFps: number;
|
||||
bodyTravel: string;
|
||||
weaponRule: string;
|
||||
stagingDirection?: string;
|
||||
defaultDetailText?: string;
|
||||
sequenceLines: [string, string, string, string];
|
||||
ending: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_MASTER_NEGATIVE_PROMPT =
|
||||
'正面视角,左朝向,完全 90 度纯右视图,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,建筑场景,道具堆叠,漂浮物,烟雾环境,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
|
||||
export const DEFAULT_SHEET_NEGATIVE_PROMPT =
|
||||
'多角色,左右朝向混乱,前视图,背视图,镜头切换,景别变化,特写,脚底裁切,头顶裁切,缺手,缺脚,额外肢体,武器消失,武器换手,服装变化,脸部变化,发型变化,动作不连续,重复帧过多,构图混乱,背景复杂,强透视,运动模糊,残影,文字,水印,UI,边框覆盖角色';
|
||||
|
||||
export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
|
||||
'多角色,错误朝向,缺手,缺脚,额外肢体,武器消失,武器换手,脸部变化,发型变化,服装变化,模糊,运动模糊,复杂背景,文字,水印';
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
const SIDE_FACING_RIGHT_TEXT =
|
||||
'角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。';
|
||||
const SUBJECT_ONLY_TEXT =
|
||||
'画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。';
|
||||
const CLEAN_BACKGROUND_TEXT =
|
||||
'背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。';
|
||||
const STYLE_REFERENCE_SCOPE_TEXT =
|
||||
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
|
||||
const CONCEPT_INTERPRETATION_TEXT =
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
|
||||
const HUMANLIKE_PRIORITY_TEXT =
|
||||
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。';
|
||||
const CONCEPT_HIERARCHY_TEXT =
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
|
||||
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',
|
||||
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Fighter 4/original/Hero/idle/idle01.png',
|
||||
];
|
||||
|
||||
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
|
||||
{
|
||||
id: 'idle',
|
||||
label: '待机循环',
|
||||
loop: true,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '原地',
|
||||
weaponRule: '武器始终在主手,位置稳定',
|
||||
sequenceLines: [
|
||||
'1-4 帧:稳定站姿,轻微呼吸起伏',
|
||||
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
|
||||
'9-12 帧:呼气回落,重心恢复',
|
||||
'13-16 帧:逐渐回到与首帧接近的站姿',
|
||||
],
|
||||
ending: '第 16 帧自然衔接第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'run',
|
||||
label: '奔跑循环',
|
||||
loop: true,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '小幅前移但角色中心基本固定',
|
||||
weaponRule: '武器始终在主手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
|
||||
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
|
||||
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
|
||||
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
|
||||
],
|
||||
ending: '第 16 帧能无缝接回第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'attack_slash',
|
||||
label: '横斩攻击',
|
||||
loop: false,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '中幅前探',
|
||||
weaponRule: '右手持武器,始终右手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:轻微收身蓄力,武器向后收',
|
||||
'5-8 帧:重心前压,挥击开始',
|
||||
'9-12 帧:斩击达到最大幅度,动作力量最强',
|
||||
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
|
||||
],
|
||||
ending: '第 16 帧停在收招后稳定姿态',
|
||||
},
|
||||
{
|
||||
id: 'hurt',
|
||||
label: '受击后仰',
|
||||
loop: false,
|
||||
defaultFps: 10,
|
||||
bodyTravel: '原地或极小后仰',
|
||||
weaponRule: '武器不要脱手,不要换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:突然受击,头肩后仰',
|
||||
'5-8 帧:身体失衡最明显',
|
||||
'9-12 帧:手臂和武器随惯性摆动',
|
||||
'13-16 帧:逐渐恢复到勉强站稳的姿态',
|
||||
],
|
||||
ending: '第 16 帧能接回 idle 或下一个动作',
|
||||
},
|
||||
{
|
||||
id: 'die',
|
||||
label: '倒地死亡',
|
||||
loop: false,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '明显倒地位移',
|
||||
weaponRule: '武器不可瞬间消失',
|
||||
sequenceLines: [
|
||||
'1-4 帧:受创失衡,重心被打断',
|
||||
'5-8 帧:身体明显下坠或后仰',
|
||||
'9-12 帧:倒地过程完成,动作幅度最大',
|
||||
'13-16 帧:停在清晰的终止姿态',
|
||||
],
|
||||
ending: '第 16 帧停在死亡结束姿态,不需要循环',
|
||||
},
|
||||
];
|
||||
|
||||
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) {
|
||||
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) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function loadImageFromSource(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function drawContainedImage(
|
||||
context: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) {
|
||||
const fitScale = Math.min(
|
||||
options.width / image.width,
|
||||
options.height / image.height,
|
||||
);
|
||||
const drawWidth = image.width * fitScale;
|
||||
const drawHeight = image.height * fitScale;
|
||||
const drawX = options.x + (options.width - drawWidth) / 2;
|
||||
const drawY = options.y + (options.height - drawHeight) / 2;
|
||||
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
export async function sliceSpriteSheetFrames(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
},
|
||||
) {
|
||||
const image = await loadImageFromSource(spriteSource);
|
||||
const frameWidth = Math.floor(image.width / options.cols);
|
||||
const frameHeight = Math.floor(image.height / options.rows);
|
||||
const frames: string[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < options.rows; rowIndex += 1) {
|
||||
for (let colIndex = 0; colIndex < options.cols; colIndex += 1) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth;
|
||||
canvas.height = frameHeight;
|
||||
context.drawImage(
|
||||
image,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
0,
|
||||
0,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
frames.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frames,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
outputSize?: number;
|
||||
},
|
||||
) {
|
||||
const sliced = await sliceSpriteSheetFrames(spriteSource, {
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
});
|
||||
const frameSource = sliced.frames[options.frameIndex];
|
||||
|
||||
if (!frameSource) {
|
||||
throw new Error('帧索引超出范围。');
|
||||
}
|
||||
|
||||
if (!options.outputSize) {
|
||||
return frameSource;
|
||||
}
|
||||
|
||||
const image = await loadImageFromSource(frameSource);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = options.outputSize;
|
||||
canvas.height = options.outputSize;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function replaceSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
replacementSource: string;
|
||||
},
|
||||
) {
|
||||
const spriteImage = await loadImageFromSource(spriteSource);
|
||||
const replacementImage = await loadImageFromSource(options.replacementSource);
|
||||
const frameWidth = Math.floor(spriteImage.width / options.cols);
|
||||
const frameHeight = Math.floor(spriteImage.height / options.rows);
|
||||
const rowIndex = Math.floor(options.frameIndex / options.cols);
|
||||
const colIndex = options.frameIndex % options.cols;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = spriteImage.width;
|
||||
canvas.height = spriteImage.height;
|
||||
context.drawImage(spriteImage, 0, 0);
|
||||
context.clearRect(
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
context.drawImage(
|
||||
replacementImage,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameIndices(
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return frameOrder.filter((frameIndex) => activeFrames.includes(frameIndex));
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameSources(
|
||||
frameDataUrls: string[],
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return buildOrderedActiveFrameIndices(frameOrder, activeFrames)
|
||||
.map((frameIndex) => frameDataUrls[frameIndex] ?? '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function composeSpriteSheetFromFrames(
|
||||
frameSources: string[],
|
||||
options: {
|
||||
cols: number;
|
||||
rows?: number;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
padToGrid?: boolean;
|
||||
},
|
||||
) {
|
||||
if (frameSources.length === 0) {
|
||||
throw new Error('没有可用于拼接精灵表的帧。');
|
||||
}
|
||||
|
||||
const images = await Promise.all(
|
||||
frameSources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const frameWidth =
|
||||
options.frameWidth ??
|
||||
Math.max(...images.map((image) => image.width), 1);
|
||||
const frameHeight =
|
||||
options.frameHeight ??
|
||||
Math.max(...images.map((image) => image.height), 1);
|
||||
const rows =
|
||||
options.rows ?? Math.max(1, Math.ceil(images.length / options.cols));
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth * options.cols;
|
||||
canvas.height = frameHeight * rows;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const totalCells = options.padToGrid ? rows * options.cols : images.length;
|
||||
for (let index = 0; index < totalCells; index += 1) {
|
||||
const image = images[index];
|
||||
if (!image) {
|
||||
continue;
|
||||
}
|
||||
const rowIndex = Math.floor(index / options.cols);
|
||||
const colIndex = index % options.cols;
|
||||
drawContainedImage(context, image, {
|
||||
x: colIndex * frameWidth,
|
||||
y: rowIndex * frameHeight,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
rows,
|
||||
cols: options.cols,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frameCount: frameSources.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPlayableCharacterStyleReferenceBoard(
|
||||
sources = PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
|
||||
) {
|
||||
const images = await Promise.all(
|
||||
sources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const cols = 3;
|
||||
const rows = 2;
|
||||
const cellSize = 320;
|
||||
const padding = 24;
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = cols * cellSize + padding * 2;
|
||||
canvas.height = rows * cellSize + padding * 2;
|
||||
context.fillStyle = '#f6f0dd';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const colIndex = index % cols;
|
||||
const rowIndex = Math.floor(index / cols);
|
||||
drawContainedImage(context, image, {
|
||||
x: padding + colIndex * cellSize,
|
||||
y: padding + rowIndex * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
});
|
||||
});
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
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} ?????????????????????????????????????/??/???????????????????????`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CHARACTER_DETAIL_COVERAGE_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildSheetPrompt(options: {
|
||||
characterBrief: string;
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
extraDirection: string;
|
||||
}) {
|
||||
return [
|
||||
`???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.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.characterBrief.trim(),
|
||||
`???????${options.extraDirection.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildRepairPrompt(options: {
|
||||
issueText: string;
|
||||
useNeighborLabel: '???' | '???';
|
||||
}) {
|
||||
return [
|
||||
`???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');
|
||||
}
|
||||
|
||||
export function buildVideoActionPrompt(options: {
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
actionDetailText: string;
|
||||
useChromaKey: boolean;
|
||||
characterBrief: string;
|
||||
}) {
|
||||
return [
|
||||
`???????????????? ${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.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||
`?????${options.actionTemplate.sequenceLines.join('?')}??????${options.actionTemplate.ending}?`,
|
||||
options.useChromaKey
|
||||
? '??????????????????????????????'
|
||||
: '?????????????',
|
||||
`???????${options.actionDetailText.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
`?????${options.characterBrief.trim()}`,
|
||||
'?????????????????????????????????????????',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export async function triggerDataUrlDownload(
|
||||
filename: string,
|
||||
dataUrl: string,
|
||||
) {
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function triggerJsonDownload(filename: string, value: unknown) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function buildDefaultFrameOrder(frameCount: number) {
|
||||
return Array.from({ length: frameCount }, (_, index) => index);
|
||||
}
|
||||
|
||||
export function restoreAllFrames(frameCount: number) {
|
||||
return buildDefaultFrameOrder(frameCount);
|
||||
}
|
||||
|
||||
export function buildMasterNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,也不要把主题词自动扩写成角色以外的场景元素,除非文字设定明确要求`;
|
||||
}
|
||||
|
||||
export function buildSheetNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_SHEET_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function buildRepairNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_REPAIR_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function moveFrameOrderItem(
|
||||
frameOrder: number[],
|
||||
frameIndex: number,
|
||||
direction: -1 | 1,
|
||||
) {
|
||||
const currentOrderIndex = frameOrder.indexOf(frameIndex);
|
||||
if (currentOrderIndex < 0) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const targetIndex = currentOrderIndex + direction;
|
||||
if (targetIndex < 0 || targetIndex >= frameOrder.length) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const nextOrder = [...frameOrder];
|
||||
const [item] = nextOrder.splice(currentOrderIndex, 1);
|
||||
nextOrder.splice(targetIndex, 0, item);
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function toggleActiveFrame(activeFrames: number[], frameIndex: number) {
|
||||
if (activeFrames.includes(frameIndex)) {
|
||||
return activeFrames.filter((item) => item !== frameIndex);
|
||||
}
|
||||
|
||||
return [...activeFrames, frameIndex].sort((left, right) => left - right);
|
||||
}
|
||||
export * from '../prompts/qwenSpriteSheetToolPrompts';
|
||||
|
||||
@@ -25,11 +25,18 @@ import type {
|
||||
export type CustomWorldCreatorInputMode = 'freeform' | 'card';
|
||||
export type CustomWorldGenerationMode = 'fast' | 'full';
|
||||
export type CustomWorldGenerationStatus = 'key_only' | 'complete';
|
||||
export type CustomWorldCoverSourceType = 'default' | 'uploaded' | 'generated';
|
||||
export type CustomWorldAgentUiState = {
|
||||
activeSessionId?: string | null;
|
||||
activeOperationId?: string | null;
|
||||
};
|
||||
|
||||
export interface CustomWorldCoverProfile {
|
||||
sourceType: CustomWorldCoverSourceType;
|
||||
imageSrc?: string | null;
|
||||
characterRoleIds?: string[];
|
||||
}
|
||||
|
||||
export interface CreatorFactionSeed {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -338,6 +345,7 @@ export interface CustomWorldProfile {
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
cover?: CustomWorldCoverProfile | null;
|
||||
templateWorldType: WorldTemplateType;
|
||||
compatibilityTemplateWorldType?: WorldTemplateType | null;
|
||||
majorFactions: string[];
|
||||
|
||||
@@ -115,6 +115,9 @@ export interface StoryNpcChatState {
|
||||
npcName: string;
|
||||
turnCount: number;
|
||||
customInputPlaceholder?: string;
|
||||
pendingQuestOffer?: {
|
||||
quest: QuestLogEntry;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CharacterChatTurn {
|
||||
|
||||
Reference in New Issue
Block a user