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

This commit is contained in:
2026-04-20 09:54:17 +08:00
parent 67c584b4df
commit 50759f3c1e
159 changed files with 16938 additions and 16925 deletions

View File

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

View File

@@ -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 说的话');
});

View File

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

View 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;

View File

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

View File

@@ -210,7 +210,7 @@ function LandmarkEditorFlowHarness() {
}
function CampEditorFlowHarness() {
const [profile, setProfile] = useState({
const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(),
camp: {
name: '潮灯居',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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="关闭卡片详情"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

@@ -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 || '正在恢复创作工作区...'}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -158,6 +158,7 @@ export function useGameShellRuntime(): GameShellProps {
inventoryUi: storyFlow.inventoryUi,
battleRewardUi: storyFlow.battleRewardUi,
questUi: storyFlow.questUi,
npcChatQuestOfferUi: storyFlow.npcChatQuestOfferUi,
goalUi: storyFlow.goalUi,
},
entry: {

View File

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

View File

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

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

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

View 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、代码块、解释或额外文字。`;

File diff suppressed because it is too large Load Diff

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

View 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);
}

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

View File

@@ -0,0 +1,5 @@
export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
你会收到一个已经解析过的剧情 JSON 对象。
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View 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',
};
}

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)}`,

View File

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

View File

@@ -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[];

View File

@@ -115,6 +115,9 @@ export interface StoryNpcChatState {
npcName: string;
turnCount: number;
customInputPlaceholder?: string;
pendingQuestOffer?: {
quest: QuestLogEntry;
} | null;
}
export interface CharacterChatTurn {