Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View File

@@ -20,7 +20,7 @@ export default function App() {
isMapOpen,
setIsMapOpen,
resetGame,
handleWorldSelect: selectWorld,
handleCustomWorldSelect: selectCustomWorld,
handleBackToWorldSelect: backToWorldSelect,
handleCharacterSelect: selectCharacter,
} = useGameFlow();
@@ -73,12 +73,11 @@ export default function App() {
return () => window.clearInterval(intervalId);
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
const handleWorldSelect = (
worldType: Parameters<typeof selectWorld>[0],
customWorldProfile?: Parameters<typeof selectWorld>[1],
const handleCustomWorldSelect = (
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
) => {
storyFlow.resetStoryState();
selectWorld(worldType, customWorldProfile);
selectCustomWorld(customWorldProfile);
};
const handleCharacterSelect = (
@@ -152,7 +151,7 @@ export default function App() {
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
};

View File

@@ -1,8 +1,7 @@
import { type ReactNode, useDeferredValue, useMemo, useState } from 'react';
import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
import {
getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
@@ -25,11 +24,8 @@ interface CustomWorldEntityCatalogProps {
onActiveTabChange: (tab: ResultTab) => void;
onEditTarget: (target: CustomWorldEditorTarget) => void;
onProfileChange: (profile: CustomWorldProfile) => void;
onRegeneratePlayableNpc?: (id: string) => void;
onRegenerateStoryNpc?: (id: string) => void;
onRegenerateLandmark?: (id: string) => void;
onRegenerateStoryExpansion?: () => void;
onRegenerateLandmarkNetwork?: () => void;
onDeleteStoryNpcs?: (ids: string[]) => void;
onDeleteLandmarks?: (ids: string[]) => void;
createActionLabel?: string;
onCreateAction?: () => void;
}
@@ -146,6 +142,57 @@ function EmptyState({ title }: { title: string }) {
);
}
function CatalogCard({
title,
description,
media,
isSelectionMode,
isSelected,
onClick,
}: {
title: string;
description: string;
media: ReactNode;
isSelectionMode: boolean;
isSelected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
isSelected
? 'border-rose-300/35 bg-rose-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
}`}
>
<div className="space-y-3">
<div className="overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25">
{media}
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-base font-semibold text-white">{title}</div>
{isSelectionMode ? (
<div
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
isSelected
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{isSelected ? '已选' : '选择'}
</div>
) : null}
</div>
<div className="text-sm leading-6 text-zinc-300">
{description || '暂无描述'}
</div>
</div>
</button>
);
}
function matchText(text: string, query: string) {
return text.toLowerCase().includes(query.toLowerCase());
}
@@ -161,6 +208,8 @@ type CatalogRole =
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number];
type BulkDeleteTab = 'story' | 'landmarks';
function buildRoleSearchText(role: CatalogRole) {
return [
role.name,
@@ -215,15 +264,14 @@ export function CustomWorldEntityCatalog({
onActiveTabChange,
onEditTarget,
onProfileChange,
onRegeneratePlayableNpc,
onRegenerateStoryNpc,
onRegenerateLandmark,
onRegenerateStoryExpansion,
onRegenerateLandmarkNetwork,
onDeleteStoryNpcs,
onDeleteLandmarks,
createActionLabel,
onCreateAction,
}: CustomWorldEntityCatalogProps) {
const [searchDraft, setSearchDraft] = useState('');
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(null);
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
const deferredSearch = useDeferredValue(searchDraft.trim());
const storyNpcById = useMemo(
@@ -289,16 +337,6 @@ export function CustomWorldEntityCatalog({
),
[profile.creatorIntent],
);
const lockedLandmarkNames = useMemo(
() =>
new Set(
profile.creatorIntent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
),
[profile.creatorIntent],
);
const counts = {
world: 1,
@@ -308,6 +346,17 @@ export function CustomWorldEntityCatalog({
landmarks: profile.landmarks.length,
} satisfies Record<ResultTab, number>;
const bulkDeleteTab: BulkDeleteTab | null =
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
const isBulkDeleteMode = bulkDeleteMode === bulkDeleteTab;
useEffect(() => {
if (bulkDeleteMode && bulkDeleteMode !== activeTab) {
setBulkDeleteMode(null);
setSelectedBulkIds([]);
}
}, [activeTab, bulkDeleteMode]);
const removePlayable = (id: string, name: string) => {
if (profile.playableNpcs.length <= 1) {
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。');
@@ -320,37 +369,43 @@ export function CustomWorldEntityCatalog({
});
};
const removeStoryNpc = (id: string, name: string) => {
if (!window.confirm(`确认删除场景角色「${name}」吗?`)) return;
const nextStoryNpcs = profile.storyNpcs.filter(npc => npc.id !== id);
onProfileChange({
...profile,
storyNpcs: nextStoryNpcs,
landmarks: normalizeCustomWorldLandmarks({
landmarks: profile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => npcId !== id),
})),
storyNpcs: nextStoryNpcs,
}),
});
const startBulkDelete = (tab: BulkDeleteTab) => {
setBulkDeleteMode(tab);
setSelectedBulkIds([]);
};
const removeLandmark = (id: string, name: string) => {
if (!window.confirm(`确认删除场景「${name}」吗?`)) return;
const nextLandmarks = profile.landmarks.filter(landmark => landmark.id !== id);
onProfileChange({
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: nextLandmarks.map((landmark) => ({
...landmark,
connections: landmark.connections.filter(
(connection) => connection.targetLandmarkId !== id,
),
})),
storyNpcs: profile.storyNpcs,
}),
});
const cancelBulkDelete = () => {
setBulkDeleteMode(null);
setSelectedBulkIds([]);
};
const toggleBulkSelected = (id: string) => {
setSelectedBulkIds((current) =>
current.includes(id)
? current.filter((entry) => entry !== id)
: [...current, id],
);
};
const confirmBulkDelete = () => {
if (!bulkDeleteTab || selectedBulkIds.length === 0) {
return;
}
const label = bulkDeleteTab === 'story' ? '场景角色' : '场景';
const confirmed = window.confirm(
`确认批量删除 ${selectedBulkIds.length}${label}吗?`,
);
if (!confirmed) {
return;
}
if (bulkDeleteTab === 'story') {
onDeleteStoryNpcs?.(selectedBulkIds);
} else {
onDeleteLandmarks?.(selectedBulkIds);
}
cancelBulkDelete();
};
return (
@@ -378,13 +433,37 @@ export function CustomWorldEntityCatalog({
</div>
{activeTab !== 'world' && activeTab !== 'anchors' ? (
<div className="flex items-center gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
</div>
{createActionLabel && onCreateAction ? (
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
{isBulkDeleteMode ? (
<>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300">
{selectedBulkIds.length}
</div>
<SmallButton onClick={cancelBulkDelete}></SmallButton>
<SmallButton
onClick={confirmBulkDelete}
tone="rose"
>
</SmallButton>
</>
) : (
<>
{createActionLabel && onCreateAction ? (
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
) : null}
{bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
<SmallButton onClick={() => startBulkDelete(bulkDeleteTab)} tone="rose">
</SmallButton>
) : null}
</>
)}
</div>
</div>
) : null}
</div>
@@ -560,14 +639,6 @@ export function CustomWorldEntityCatalog({
subtitle={role.title}
actions={(
<div className="flex items-center gap-2">
{onRegeneratePlayableNpc && !lockedCharacterNames.has(role.name.trim()) ? (
<SmallButton
onClick={() => onRegeneratePlayableNpc(role.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose"></SmallButton>
</div>
@@ -646,111 +717,32 @@ export function CustomWorldEntityCatalog({
{activeTab === 'story' ? (
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
NPC
{onRegenerateStoryExpansion ? (
<div className="mt-3">
<SmallButton onClick={onRegenerateStoryExpansion} tone="sky">
</SmallButton>
</div>
) : null}
</div>
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
) : (
filteredStory.map(npc => (
<div key={npc.id}>
<Section
<CatalogCard
title={npc.name}
subtitle={npc.role}
actions={(
<div className="flex items-center gap-2">
{onRegenerateStoryNpc && !lockedCharacterNames.has(npc.name.trim()) ? (
<SmallButton
onClick={() => onRegenerateStoryNpc(npc.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removeStoryNpc(npc.id, npc.name)} tone="rose"></SmallButton>
</div>
)}
>
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
description={npc.description}
isSelectionMode={isBulkDeleteMode}
isSelected={selectedBulkIds.includes(npc.id)}
onClick={() =>
isBulkDeleteMode
? toggleBulkSelected(npc.id)
: onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })
}
media={(
<CustomWorldNpcPortrait
npc={npc}
profile={profile}
visual={npc.visual}
className="aspect-square"
scale={2.18}
preferImageSrc
/>
<div className="min-w-0 space-y-3">
{lockedCharacterNames.has(npc.name.trim()) ? (
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
<div className="rounded-2xl border border-sky-300/12 bg-sky-500/8 px-3 py-3 text-sm leading-6 text-sky-50/95">
{npc.backstoryReveal.publicSummary || '未填写'}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.title}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.initialAffinity}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.personality || '未填写'}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.combatStyle || '未填写'}</div>
</div>
{npc.backstory ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.backstory}</div>
) : null}
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.motivation}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="mt-2 space-y-2">
{npc.backstoryReveal.chapters.map(chapter => (
<div key={`${npc.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{chapter.affinityRequired} · {chapter.title}{chapter.teaser}
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="mt-2 space-y-2">
{npc.skills.map(skill => (
<div key={`${npc.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{skill.name} · {skill.style}{skill.summary}
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="mt-2 space-y-2">
{npc.initialItems.map(item => (
<div key={`${npc.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{item.name} x{item.quantity} · {item.category} · {item.rarity}{item.description}
</div>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{npc.relationshipHooks.map(hook => (
<span key={`${npc.id}-${hook}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
{hook}
</span>
))}
{npc.tags.map(tag => (
<span key={`${npc.id}-tag-${tag}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
{tag}
</span>
))}
</div>
</div>
</div>
</Section>
)}
/>
</div>
))
)}
@@ -759,85 +751,30 @@ export function CustomWorldEntityCatalog({
{activeTab === 'landmarks' ? (
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
NPC
{onRegenerateLandmarkNetwork ? (
<div className="mt-3">
<SmallButton onClick={onRegenerateLandmarkNetwork} tone="sky">
</SmallButton>
</div>
) : null}
</div>
{filteredLandmarks.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
filteredLandmarks.map(landmark => (
<div key={landmark.id}>
<Section
<CatalogCard
title={landmark.name}
actions={(
<div className="flex items-center gap-2">
{onRegenerateLandmark && !lockedLandmarkNames.has(landmark.name.trim()) ? (
<SmallButton
onClick={() => onRegenerateLandmark(landmark.id)}
tone="sky"
>
AI重生成
</SmallButton>
) : null}
<SmallButton onClick={() => onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removeLandmark(landmark.id, landmark.name)} tone="rose"></SmallButton>
</div>
)}
>
<div className="space-y-3">
{lockedLandmarkNames.has(landmark.name.trim()) ? (
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
description={landmark.description}
isSelectionMode={isBulkDeleteMode}
isSelected={selectedBulkIds.includes(landmark.id)}
onClick={() =>
isBulkDeleteMode
? toggleBulkSelected(landmark.id)
: onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })
}
media={(
<ImageFrame
src={landmarkImageById.get(landmark.id) ?? landmark.imageSrc}
alt={landmark.name}
fallbackLabel={landmark.name.slice(0, 4) || '场景'}
tone="landscape"
/>
<div className="text-sm leading-7 text-zinc-300">{landmark.description}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">
{landmark.dangerLevel || '未填写'}
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"> NPC</div>
<div className="mt-2 flex flex-wrap gap-2">
{landmark.sceneNpcIds.length > 0 ? (
landmark.sceneNpcIds.map((npcId) => (
<span key={`${landmark.id}-npc-${npcId}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
{storyNpcById.get(npcId)?.name ?? '未匹配角色'}
</span>
))
) : (
<span className="text-xs text-zinc-500"></span>
)}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="mt-2 space-y-2">
{landmark.connections.length > 0 ? (
landmark.connections.map((connection) => (
<div key={`${landmark.id}-connection-${connection.targetLandmarkId}-${connection.relativePosition}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{getCustomWorldSceneRelativePositionLabel(connection.relativePosition)} · {landmarkById.get(connection.targetLandmarkId)?.name ?? '未匹配场景'}
{connection.summary ? `${connection.summary}` : ''}
</div>
))
) : (
<div className="text-xs text-zinc-500"></div>
)}
</div>
</div>
</div>
</Section>
)}
/>
</div>
))
)}

View File

@@ -1,8 +1,8 @@
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import type { ChangeEvent } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
} from '../data/affinityLevels';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
@@ -21,10 +21,6 @@ import {
type CustomWorldSceneImageResult,
generateCustomWorldSceneImage,
} from '../services/ai';
import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
} from '../services/customWorld';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
AnimationState,
@@ -42,6 +38,7 @@ import {
CustomWorldNpcPortrait,
CustomWorldNpcVisualEditor,
} from './CustomWorldNpcVisualEditor';
import { CustomWorldRoleAssetStudioModal } from './CustomWorldRoleAssetStudioModal';
import { PixelIcon } from './PixelIcon';
export type CustomWorldEditorTarget =
@@ -170,6 +167,15 @@ function useDraft<T>(value: T) {
return [draft, setDraft] as const;
}
function readImageFileAsDataUrl(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 ModalShell({
title,
subtitle,
@@ -179,6 +185,7 @@ function ModalShell({
overlayClassName = 'z-[98]',
bodyClassName = '',
disableClose = false,
usePixelFont = false,
}: {
title: string;
subtitle?: string;
@@ -188,6 +195,7 @@ function ModalShell({
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
return (
<div
@@ -195,7 +203,7 @@ function ModalShell({
onClick={disableClose ? undefined : onClose}
>
<div
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full 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,56rem)] ${panelClassName} sm:rounded-[1.75rem]`}
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full 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,56rem)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
@@ -229,6 +237,83 @@ function ModalShell({
);
}
function _PortalModalShell(props: {
title: string;
subtitle?: string;
onClose: () => void;
children: ReactNode;
panelClassName?: string;
overlayClassName?: string;
bodyClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<ModalShell {...props} />, document.body);
}
function CompactDialogShell({
title,
onClose,
children,
overlayClassName = 'z-[140]',
disableClose = false,
usePixelFont = false,
}: {
title: string;
onClose: () => void;
children: ReactNode;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
return (
<div
className={`fixed inset-0 ${overlayClassName} flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm`}
onClick={disableClose ? undefined : onClose}
>
<div
className={`pixel-nine-slice pixel-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : ''}`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4">
<div className="min-w-0 text-sm font-semibold text-white">
{title}
</div>
<button
type="button"
onClick={onClose}
disabled={disableClose}
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}
function PortalCompactDialogShell(props: {
title: string;
onClose: () => void;
children: ReactNode;
overlayClassName?: string;
disableClose?: boolean;
usePixelFont?: boolean;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(<CompactDialogShell {...props} />, document.body);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
const hasVisibleChildren = Children.toArray(children).some(
(child) => !(typeof child === 'string' && child.trim().length === 0),
@@ -401,12 +486,14 @@ function ActionButton({
}: {
label: string;
onClick: () => void;
tone?: 'default' | 'sky';
tone?: 'default' | 'sky' | 'rose';
disabled?: boolean;
}) {
const toneClassName =
tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
: tone === 'rose'
? 'border-rose-300/22 bg-rose-500/12 text-rose-50 hover:border-rose-200/40 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
return (
@@ -556,36 +643,7 @@ function ScenePresetPickerModal({
);
}
function AiComingSoonModal({
title,
subtitle,
onClose,
}: {
title: string;
subtitle: string;
onClose: () => void;
}) {
return (
<ModalShell title={title} subtitle={subtitle} onClose={onClose}>
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.18),transparent_55%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] px-6 py-10 text-center">
<div className="whitespace-pre-line text-2xl font-black tracking-[0.2em] text-white">
{'\n'}
</div>
</div>
<div className="flex justify-end">
<ActionButton label="知道了" onClick={onClose} tone="sky" />
</div>
</div>
</ModalShell>
);
}
const SCENE_IMAGE_SIZE_OPTIONS = [
{ value: '1280*720', label: '横版 16:9推荐' },
{ value: '1280*1280', label: '方图 1:1' },
{ value: '960*1280', label: '竖版 3:4' },
] as const;
const FIXED_SCENE_IMAGE_SIZE = '1280*720';
function SceneImageGenerationModal({
profile,
@@ -598,27 +656,17 @@ function SceneImageGenerationModal({
onApply: (result: CustomWorldSceneImageResult) => void;
onClose: () => void;
}) {
const defaultPrompt = useMemo(
() => buildCustomWorldSceneImagePrompt(profile, landmark),
[profile, landmark],
);
const [prompt, setPrompt] = useDraft(defaultPrompt);
const [negativePrompt, setNegativePrompt] = useDraft(
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
);
const [size, setSize] = useDraft<string>(
SCENE_IMAGE_SIZE_OPTIONS[0]?.value ?? '1280*720',
const [userPrompt, setUserPrompt] = useDraft(
landmark.name.trim() || landmark.description.trim(),
);
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [latestResult, setLatestResult] =
useState<CustomWorldSceneImageResult | null>(null);
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
const previewImageSrc = useMemo(() => {
if (latestResult?.imageSrc) {
return latestResult.imageSrc;
}
const originalImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === landmark.id,
);
@@ -632,11 +680,46 @@ function SceneImageGenerationModal({
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [landmark, latestResult, profile]);
}, [landmark, profile]);
const previewImageSrc = latestResult?.imageSrc || originalImageSrc;
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 (!prompt.trim()) {
setError('请先填写场景提示词。');
if (!userPrompt.trim()) {
setError('请先描述想要生成的画面内容。');
return;
}
@@ -647,12 +730,11 @@ function SceneImageGenerationModal({
const result = await generateCustomWorldSceneImage({
profile,
landmark,
prompt,
negativePrompt,
size,
userPrompt,
size: FIXED_SCENE_IMAGE_SIZE,
...(referenceImageSrc ? { referenceImageSrc } : {}),
});
setLatestResult(result);
onApply(result);
} catch (generationError) {
setError(
generationError instanceof Error
@@ -664,136 +746,195 @@ function SceneImageGenerationModal({
}
};
const handleSave = () => {
if (!latestResult || isGenerating) {
return;
}
onApply(latestResult);
onClose();
};
return (
<ModalShell
title={`智能生成:${landmark.name || '当前场景'}`}
subtitle="会调用阿里云文生图模型生成新的场景背景,并立即回写到当前编辑草稿。"
onClose={onClose}
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">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
<>
<ModalShell
title={`智能生成:${landmark.name || '当前场景'}`}
onClose={handleRequestClose}
panelClassName="sm:max-w-5xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
usePixelFont
>
<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={8}
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>
<Field label="场景提示词">
<TextArea
value={prompt}
onChange={(value) => setPrompt(value)}
rows={10}
placeholder="描述这个场景的地貌、建筑、天气、光线与氛围。"
/>
</Field>
<Field label="反向提示词">
<TextArea
value={negativePrompt}
onChange={(value) => setNegativePrompt(value)}
rows={4}
placeholder="例如文字、水印、logo、UI界面、人物近景。"
/>
</Field>
<Field label="图片比例">
<SelectField
value={size}
onChange={setSize}
options={SCENE_IMAGE_SIZE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
/>
</Field>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={previewImageSrc}
alt={landmark.name || '场景预览'}
fallbackLabel={landmark.name ? landmark.name.slice(0, 4) : '场景'}
tone="landscape"
/>
</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">
稿{latestResult.model}
{latestResult.size}
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ImagePreview
src={previewImageSrc}
alt={landmark.name || '场景预览'}
fallbackLabel={
landmark.name ? landmark.name.slice(0, 4) : '场景'
}
tone="landscape"
/>
</div>
) : (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
</div>
)}
{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}
{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}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<ActionButton
label="关闭"
onClick={onClose}
disabled={isGenerating}
/>
<ActionButton
label={
isGenerating
? '正在生成...'
: latestResult
? '重新生成'
: '开始生成'
}
onClick={() => {
void handleGenerate();
}}
tone="sky"
disabled={isGenerating}
/>
{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>
</div>
</ModalShell>
</ModalShell>
{isExitConfirmOpen ? (
<PortalCompactDialogShell
title="确认退出"
onClose={() => setIsExitConfirmOpen(false)}
overlayClassName="z-[140]"
usePixelFont
>
<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 SaveBar({
onClose,
onSave,
extraAction,
}: {
onClose: () => void;
onSave: () => void;
extraAction?: ReactNode;
}) {
return (
<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:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
<div
className={`flex flex-col gap-3 ${
extraAction
? 'sm:flex-row sm:items-center sm:justify-between'
: 'sm:flex-row sm:justify-end'
}`}
>
</button>
<button
type="button"
onClick={onSave}
className="pixel-nine-slice pixel-pressable text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
{extraAction ? (
<div className="flex flex-col gap-3 sm:flex-row">{extraAction}</div>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
</button>
<button
type="button"
onClick={onSave}
className="pixel-nine-slice pixel-pressable text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
</div>
);
@@ -1025,7 +1166,9 @@ function SkillListEditor({
<ActionButton
label="删除技能"
onClick={() =>
onChange(value.filter((_skill, skillIndex) => skillIndex !== index))
onChange(
value.filter((_skill, skillIndex) => skillIndex !== index),
)
}
/>
</div>
@@ -1120,7 +1263,9 @@ function InitialItemsEditor({
<ActionButton
label="删除物品"
onClick={() =>
onChange(value.filter((_item, itemIndex) => itemIndex !== index))
onChange(
value.filter((_item, itemIndex) => itemIndex !== index),
)
}
/>
</div>
@@ -1209,15 +1354,15 @@ function StoryNpcVisualEditorModal({
npc,
visual,
onChange,
onOpenAiStudio,
onClose,
}: {
npc: CustomWorldNpc;
visual: NonNullable<CustomWorldNpc['visual']>;
onChange: (visual: NonNullable<CustomWorldNpc['visual']>) => void;
onOpenAiStudio?: () => void;
onClose: () => void;
}) {
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
return (
<ModalShell
title={`修改形象:${npc.name}`}
@@ -1235,15 +1380,11 @@ function StoryNpcVisualEditorModal({
}}
value={visual}
onChange={onChange}
onAiGenerate={() => setIsAiGenerateOpen(true)}
onAiGenerate={() => {
onClose();
onOpenAiStudio?.();
}}
/>
{isAiGenerateOpen ? (
<AiComingSoonModal
title="智能生成场景角色形象"
subtitle="场景角色形象智能生成功能仍在开发中。"
onClose={() => setIsAiGenerateOpen(false)}
/>
) : null}
</ModalShell>
);
}
@@ -1391,7 +1532,7 @@ function WorldEditor({
tone="landscape"
showInput={false}
previewOverlay={<SceneSparringPreview profile={draft} />}
footer={(
footer={
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
@@ -1408,7 +1549,7 @@ function WorldEditor({
</div>
</div>
)}
}
/>
<Field label="玩家原始设定">
<TextArea
@@ -1475,6 +1616,7 @@ function PlayableNpcEditor({
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(npc);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const selectedTemplate =
PRESET_CHARACTERS.find(
(character) => character.id === draft.templateCharacterId,
@@ -1493,7 +1635,7 @@ function PlayableNpcEditor({
<div className="grid gap-4 rounded-2xl border border-white/8 bg-black/20 p-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img
src={selectedTemplate.portrait}
src={draft.imageSrc || selectedTemplate.portrait}
alt={selectedTemplate.name}
className="h-28 w-full object-cover object-top"
/>
@@ -1511,6 +1653,25 @@ function PlayableNpcEditor({
<div className="mt-3 text-sm leading-6 text-zinc-300">
{selectedTemplate.description}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-3">
<ActionButton
label="AI生成形象与动作"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
</div>
</div>
) : null}
@@ -1678,6 +1839,19 @@ function PlayableNpcEditor({
onClose();
}}
/>
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
role={draft}
roleKind="playable"
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
);
@@ -1696,6 +1870,7 @@ function StoryNpcEditor({
}) {
const [draft, setDraft] = useDraft(npc);
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
return (
<ModalShell
@@ -1712,6 +1887,7 @@ function StoryNpcEditor({
visual={draft.visual}
className="aspect-square w-full max-w-[9.5rem]"
scale={2.05}
preferImageSrc
/>
</div>
<div className="min-w-0 space-y-3">
@@ -1729,6 +1905,23 @@ function StoryNpcEditor({
onClick={() => setIsVisualEditorOpen(true)}
tone="sky"
/>
<ActionButton
label="AI生成形象与动作"
onClick={() => setIsAiAssetStudioOpen(true)}
tone="sky"
/>
</div>
<div className="flex flex-wrap gap-2">
{draft.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{draft.generatedAnimationSetId ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
</div>
</div>
@@ -1892,9 +2085,23 @@ function StoryNpcEditor({
onChange={(visual) =>
setDraft((current) => ({ ...current, visual }))
}
onOpenAiStudio={() => setIsAiAssetStudioOpen(true)}
onClose={() => setIsVisualEditorOpen(false)}
/>
) : null}
{isAiAssetStudioOpen ? (
<CustomWorldRoleAssetStudioModal
role={draft}
roleKind="story"
onApply={(nextRole) =>
setDraft((current) => ({
...current,
...nextRole,
}))
}
onClose={() => setIsAiAssetStudioOpen(false)}
/>
) : null}
</div>
</ModalShell>
);
@@ -1957,7 +2164,9 @@ function LandmarkEditor({
const updateConnection = (
index: number,
updater: (connection: CustomWorldSceneConnection) => CustomWorldSceneConnection,
updater: (
connection: CustomWorldSceneConnection,
) => CustomWorldSceneConnection,
) => {
setDraft((current) => ({
...current,
@@ -1996,7 +2205,9 @@ function LandmarkEditor({
const nextLandmarks =
mode === 'create'
? [...profile.landmarks, draft]
: profile.landmarks.map((entry) => (entry.id === draft.id ? draft : entry));
: profile.landmarks.map((entry) =>
entry.id === draft.id ? draft : entry,
);
onSaveProfile({
...profile,
@@ -2077,7 +2288,8 @@ function LandmarkEditor({
NPC
</div>
<div className="mt-2 text-sm leading-6 text-zinc-400">
3 NPC NPC
3 NPC
NPC
</div>
</div>
<ActionButton
@@ -2179,11 +2391,7 @@ function LandmarkEditor({
线
</div>
</div>
<ActionButton
label="新增连接"
onClick={addConnection}
tone="sky"
/>
<ActionButton label="新增连接" onClick={addConnection} tone="sky" />
</div>
<div className="mt-3 space-y-3">
{draft.connections.length > 0 ? (
@@ -2214,7 +2422,8 @@ function LandmarkEditor({
onChange={(value) =>
updateConnection(index, (current) => ({
...current,
relativePosition: value as CustomWorldSceneConnection['relativePosition'],
relativePosition:
value as CustomWorldSceneConnection['relativePosition'],
}))
}
options={CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
@@ -2246,7 +2455,8 @@ function LandmarkEditor({
setDraft((current) => ({
...current,
connections: current.connections.filter(
(_item, connectionIndex) => connectionIndex !== index,
(_item, connectionIndex) =>
connectionIndex !== index,
),
}))
}
@@ -2309,7 +2519,9 @@ function LandmarkEditor({
setDraftStoryNpcs((current) =>
npcEditorState.mode === 'create'
? [...current, nextNpc]
: current.map((item) => (item.id === nextNpc.id ? nextNpc : item)),
: current.map((item) =>
item.id === nextNpc.id ? nextNpc : item,
),
);
setDraft((current) => ({
...current,
@@ -2428,7 +2640,9 @@ function createPlayableNpc(
};
}
function createStoryNpc(profile: Pick<CustomWorldProfile, 'storyNpcs'>): CustomWorldNpc {
function createStoryNpc(
profile: Pick<CustomWorldProfile, 'storyNpcs'>,
): CustomWorldNpc {
const seed = Date.now() + profile.storyNpcs.length;
const npc = {
id: createEntryId(
@@ -2638,15 +2852,15 @@ export function CustomWorldEntityEditorModal({
}
if (target.mode === 'create') {
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
return (
<LandmarkEditor
profile={profile}
landmark={createLandmark(profile)}
mode="create"
onSaveProfile={onProfileChange}
onClose={onClose}
/>
);
}
const landmark = profile.landmarks.find((entry) => entry.id === target.id);

View File

@@ -23,12 +23,19 @@ import {
type MedievalRace,
sanitizeCustomWorldNpcVisual,
} from '../data/medievalNpcVisuals';
import { type CustomWorldNpc, type CustomWorldNpcVisual } from '../types';
import {
type CustomWorldNpc,
type CustomWorldNpcVisual,
type CustomWorldProfile,
} from '../types';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import { HostileNpcAnimator } from './HostileNpcAnimator';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>
type EditableNpcSource = Pick<
CustomWorldNpc,
'id' | 'name' | 'role' | 'description' | 'imageSrc'
>
& Partial<
Pick<
CustomWorldNpc,
@@ -291,25 +298,37 @@ function ActionButton({
export function CustomWorldNpcPortrait({
npc,
profile,
visual,
className = '',
scale = 2.05,
preferImageSrc = false,
}: {
npc: EditableNpcSource;
profile?: CustomWorldProfile | null;
visual?: CustomWorldNpcVisual;
className?: string;
scale?: number;
preferImageSrc?: boolean;
}) {
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
const monsterPreset = visual
? null
: resolveCustomWorldNpcMonsterPreset(npc);
: resolveCustomWorldNpcMonsterPreset(npc, undefined, profile ?? null);
const preferredImageSrc =
preferImageSrc && npc.imageSrc?.trim() ? npc.imageSrc.trim() : '';
return (
<div className={`relative overflow-hidden rounded-2xl 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))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
{monsterPreset ? (
{preferredImageSrc ? (
<img
src={preferredImageSrc}
alt={npc.name}
className="h-full w-full object-contain drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
/>
) : monsterPreset ? (
<div
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
style={{
@@ -333,11 +352,13 @@ export function CustomWorldNpcPortrait({
export function CustomWorldNpcVisualEditor({
npc,
profile,
value,
onChange,
onAiGenerate,
}: {
npc: EditableNpcSource;
profile?: CustomWorldProfile | null;
value?: CustomWorldNpcVisual;
onChange: (value: CustomWorldNpcVisual) => void;
onAiGenerate: () => void;
@@ -411,6 +432,7 @@ export function CustomWorldNpcVisualEditor({
<div className="mx-auto w-full max-w-[9.5rem] space-y-3">
<CustomWorldNpcPortrait
npc={npc}
profile={profile}
visual={effectiveVisual}
className="aspect-square"
scale={2.05}

View File

@@ -1,5 +1,6 @@
import { type ReactNode,useMemo, useState } from 'react';
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import { Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CustomWorldEntityCatalog, type ResultTab } from './CustomWorldEntityCatalog';
@@ -16,11 +17,6 @@ interface CustomWorldResultViewProps {
onEditSetting: () => void;
onRegenerate: () => void;
onContinueExpand?: () => void;
onRegeneratePlayableNpc?: (id: string) => void;
onRegenerateStoryNpc?: (id: string) => void;
onRegenerateLandmark?: (id: string) => void;
onRegenerateStoryExpansion?: () => void;
onRegenerateLandmarkNetwork?: () => void;
onSave: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
@@ -66,6 +62,46 @@ function getCreateLabelByTab(activeTab: ResultTab) {
return '';
}
function removeStoryNpcsFromProfile(
profile: CustomWorldProfile,
ids: string[],
) {
const idSet = new Set(ids);
const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id));
return {
...profile,
storyNpcs: nextStoryNpcs,
landmarks: normalizeCustomWorldLandmarks({
landmarks: profile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)),
})),
storyNpcs: nextStoryNpcs,
}),
} satisfies CustomWorldProfile;
}
function removeLandmarksFromProfile(profile: CustomWorldProfile, ids: string[]) {
const idSet = new Set(ids);
const nextLandmarks = profile.landmarks.filter(
(landmark) => !idSet.has(landmark.id),
);
return {
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: nextLandmarks.map((landmark) => ({
...landmark,
connections: landmark.connections.filter(
(connection) => !idSet.has(connection.targetLandmarkId),
),
})),
storyNpcs: profile.storyNpcs,
}),
} satisfies CustomWorldProfile;
}
export function CustomWorldResultView({
profile,
previewCharacters,
@@ -77,11 +113,6 @@ export function CustomWorldResultView({
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onRegeneratePlayableNpc,
onRegenerateStoryNpc,
onRegenerateLandmark,
onRegenerateStoryExpansion,
onRegenerateLandmarkNetwork,
onSave,
onProfileChange,
}: CustomWorldResultViewProps) {
@@ -101,6 +132,16 @@ export function CustomWorldResultView({
triggerRegenerate();
};
const handleDeleteStoryNpcs = (ids: string[]) => {
if (ids.length === 0) return;
onProfileChange(removeStoryNpcsFromProfile(profile, ids));
};
const handleDeleteLandmarks = (ids: string[]) => {
if (ids.length === 0) return;
onProfileChange(removeLandmarksFromProfile(profile, ids));
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4 flex justify-start">
@@ -122,11 +163,8 @@ export function CustomWorldResultView({
onActiveTabChange={setActiveTab}
onEditTarget={setEditorTarget}
onProfileChange={onProfileChange}
onRegeneratePlayableNpc={onRegeneratePlayableNpc}
onRegenerateStoryNpc={onRegenerateStoryNpc}
onRegenerateLandmark={onRegenerateLandmark}
onRegenerateStoryExpansion={onRegenerateStoryExpansion}
onRegenerateLandmarkNetwork={onRegenerateLandmarkNetwork}
onDeleteStoryNpcs={handleDeleteStoryNpcs}
onDeleteLandmarks={handleDeleteLandmarks}
createActionLabel={createLabel}
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
/>

View File

@@ -0,0 +1,987 @@
import {
CheckCircle2,
Film,
ImagePlus,
RefreshCcw,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CustomWorldNpc,
type CustomWorldPlayableNpc,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import {
buildAnimationClipFromVideoSource,
type DraftAnimationClip,
readFileAsDataUrl,
} from './preset-editor/characterAssetStudioModel';
import {
type CharacterAnimationDraftPayload,
type CharacterAnimationGenerationPayload,
type CharacterVisualDraft,
type CharacterVisualSourceMode,
generateCharacterAnimationDraft,
generateCharacterVisualCandidates,
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
} from './preset-editor/characterAssetStudioPersistence';
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
type CustomWorldAiActionConfig = {
animation: AnimationState;
label: string;
templateId: string;
fps: number;
frameCount: number;
durationSeconds: number;
loop: boolean;
};
const VISUAL_SOURCE_OPTIONS: Array<{
label: string;
value: Exclude<CharacterVisualSourceMode, 'upload'>;
}> = [
{ label: '纯文生主图', value: 'text-to-image' },
{ label: '参考图生主图', value: 'image-to-image' },
];
const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
},
{
animation: AnimationState.RUN,
label: '奔跑',
templateId: 'run',
fps: 12,
frameCount: 8,
durationSeconds: 4,
loop: true,
},
{
animation: AnimationState.ATTACK,
label: '攻击',
templateId: 'attack_slash',
fps: 12,
frameCount: 8,
durationSeconds: 3,
loop: false,
},
{
animation: AnimationState.HURT,
label: '受击',
templateId: 'hurt',
fps: 10,
frameCount: 6,
durationSeconds: 3,
loop: false,
},
{
animation: AnimationState.DIE,
label: '死亡',
templateId: 'die',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: false,
},
];
function ModalShell({
title,
subtitle,
onClose,
disableClose = false,
children,
}: {
title: string;
subtitle?: string;
onClose: () => void;
disableClose?: boolean;
children: React.ReactNode;
}) {
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"
onClick={disableClose ? undefined : onClose}
>
<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]"
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">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">
{title}
</div>
{subtitle ? (
<div className="mt-1 text-xs leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
disabled={disableClose}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-2 text-xs text-zinc-300 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
{children}
</div>
</div>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-4 rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-200">
{title}
</div>
{children}
</section>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="block">
<div className="mb-2 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
{label}
</div>
{children}
</label>
);
}
function SelectInput({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors focus:border-sky-300/35"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
function TextArea({
value,
onChange,
rows = 4,
placeholder,
readOnly = false,
}: {
value: string;
onChange: (value: string) => void;
rows?: number;
placeholder?: string;
readOnly?: boolean;
}) {
return (
<textarea
rows={rows}
value={value}
placeholder={placeholder}
readOnly={readOnly}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
/>
);
}
function StatusBadge({
tone,
children,
}: {
tone: 'green' | 'amber' | 'zinc';
children: ReactNode;
}) {
const toneClassName = {
green: 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100',
amber: 'border-amber-400/30 bg-amber-500/10 text-amber-100',
zinc: 'border-white/10 bg-black/20 text-zinc-300',
}[tone];
return (
<span
className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] ${toneClassName}`}
>
{children}
</span>
);
}
function ActionButton({
icon,
label,
onClick,
disabled = false,
tone = 'default',
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
tone?: 'default' | 'sky' | 'green';
}) {
const toneClassName =
tone === 'green'
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
: tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{icon}
<span>{label}</span>
</button>
);
}
function buildRoleCharacterBrief(
role: EditableCustomWorldRole,
templateLabel?: string,
) {
return [
`角色名称:${role.name}`,
`角色头衔:${role.title}`,
`世界身份:${role.role}`,
role.description ? `角色描述:${role.description}` : '',
role.backstory ? `角色背景:${role.backstory}` : '',
role.personality ? `角色性格:${role.personality}` : '',
role.motivation ? `角色动机:${role.motivation}` : '',
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
templateLabel ? `参考模板:${templateLabel}` : '',
]
.filter(Boolean)
.join('\n');
}
function mergeRole<T extends EditableCustomWorldRole>(
role: T,
patch: Partial<T>,
) {
return {
...role,
...patch,
};
}
export function CustomWorldRoleAssetStudioModal({
role,
roleKind,
onApply,
onClose,
}: {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
onApply: (nextRole: EditableCustomWorldRole) => void;
onClose: () => void;
}) {
const [sourceMode, setSourceMode] =
useState<Exclude<CharacterVisualSourceMode, 'upload'>>(
role.imageSrc ? 'image-to-image' : 'text-to-image',
);
const [visualPromptText, setVisualPromptText] = useState('');
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<
string[]
>([]);
const [visualDrafts, setVisualDrafts] = useState<CharacterVisualDraft[]>([]);
const [selectedVisualDraftId, setSelectedVisualDraftId] = useState('');
const [visualStatus, setVisualStatus] = useState<string | null>(null);
const [isGeneratingVisuals, setIsGeneratingVisuals] = useState(false);
const [isApplyingVisual, setIsApplyingVisual] = useState(false);
const [selectedAnimation, setSelectedAnimation] = useState<AnimationState>(
CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE,
);
const [animationPromptText, setAnimationPromptText] = useState('');
const [draftAnimations, setDraftAnimations] = useState<
Partial<Record<AnimationState, DraftAnimationClip>>
>({});
const [animationStatus, setAnimationStatus] = useState<string | null>(null);
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
const [isApplyingAnimations, setIsApplyingAnimations] = useState(false);
const selectedTemplate =
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
? PRESET_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
) ?? null
: null;
const characterBriefText = useMemo(
() =>
buildRoleCharacterBrief(
role,
selectedTemplate
? `${selectedTemplate.name} / ${selectedTemplate.title}`
: undefined,
),
[role, selectedTemplate],
);
const effectiveReferenceImages =
referenceImageDataUrls.length > 0
? referenceImageDataUrls
: role.imageSrc
? [role.imageSrc]
: [];
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc =
selectedVisualDraft?.imageSrc ??
role.imageSrc ??
selectedTemplate?.portrait ??
'';
const selectedActionConfig =
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
CORE_ACTIONS[0];
const appliedActionCount = CORE_ACTIONS.filter(
(item) => role.animationMap?.[item.animation]?.basePath,
).length;
const handleReferenceImageUpload = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) {
return;
}
const uploadedDataUrls = await Promise.all(
Array.from(fileList)
.slice(0, 4)
.map((file) => readFileAsDataUrl(file)),
);
setReferenceImageDataUrls(uploadedDataUrls);
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。`);
event.target.value = '';
};
const handleGenerateVisuals = async () => {
setIsGeneratingVisuals(true);
setVisualStatus(null);
try {
if (
sourceMode === 'image-to-image' &&
effectiveReferenceImages.length === 0
) {
throw new Error('参考图生主图至少需要一张参考图。');
}
const result = await generateCharacterVisualCandidates({
characterId: role.id,
sourceMode,
promptText: visualPromptText,
characterBriefText,
referenceImageDataUrls: effectiveReferenceImages,
candidateCount: 3,
imageModel: 'wan2.7-image-pro',
size: '1024*1536',
});
setVisualDrafts(result.drafts);
setSelectedVisualDraftId(result.drafts[0]?.id ?? '');
setVisualStatus(`已生成 ${result.drafts.length} 个主图候选。`);
} catch (error) {
setVisualStatus(
error instanceof Error ? error.message : '生成角色主图失败。',
);
} finally {
setIsGeneratingVisuals(false);
}
};
const handleApplyVisual = async () => {
if (!selectedVisualDraft) {
setVisualStatus('请先选择一个主图候选。');
return;
}
setIsApplyingVisual(true);
setVisualStatus(null);
try {
const result = await publishCharacterVisualAsset({
characterId: role.id,
sourceMode,
promptText: visualPromptText,
selectedPreviewSource: selectedVisualDraft.imageSrc,
previewSources: visualDrafts.map((draft) => draft.imageSrc),
width: selectedVisualDraft.width,
height: selectedVisualDraft.height,
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
}),
);
setDraftAnimations({});
setAnimationStatus(null);
setVisualStatus('主图已应用到当前角色,可继续生成核心动作。');
} catch (error) {
setVisualStatus(
error instanceof Error ? error.message : '应用角色主图失败。',
);
} finally {
setIsApplyingVisual(false);
}
};
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
if (!role.imageSrc || !role.generatedVisualAssetId) {
throw new Error('请先应用主图,再生成动作。');
}
const result = await generateCharacterAnimationDraft({
characterId: role.id,
strategy: 'image-to-video',
animation: config.animation,
promptText: animationPromptText,
characterBriefText,
actionTemplateId: config.templateId,
visualSource: role.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: config.frameCount,
fps: config.fps,
durationSeconds: config.durationSeconds,
loop: config.loop,
useChromaKey: true,
resolution: '720P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
if (result.strategy !== 'image-to-video') {
throw new Error('当前自定义世界动作工坊只支持图生视频方案。');
}
return buildAnimationClipFromVideoSource(result.previewVideoPath, {
animation: config.animation,
fps: config.fps,
loop: config.loop,
frameCount: config.frameCount,
applyChromaKey: true,
});
};
const handleGenerateSingleAnimation = async () => {
if (!selectedActionConfig) {
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
try {
const clip = await generateActionClip(selectedActionConfig);
setDraftAnimations((current) => ({
...current,
[selectedActionConfig.animation]: clip,
}));
setAnimationStatus(`${selectedActionConfig.label} 动作草稿已生成。`);
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '生成角色动作失败。',
);
} finally {
setIsGeneratingAnimations(false);
}
};
const handleGenerateAllAnimations = async () => {
setIsGeneratingAnimations(true);
setAnimationStatus(null);
try {
const nextDrafts: Partial<Record<AnimationState, DraftAnimationClip>> = {
...draftAnimations,
};
for (const config of CORE_ACTIONS) {
setAnimationStatus(`正在生成 ${config.label} 动作...`);
nextDrafts[config.animation] = await generateActionClip(config);
}
setDraftAnimations(nextDrafts);
setAnimationStatus('核心动作草稿已生成,可直接应用到当前角色。');
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '批量生成核心动作失败。',
);
} finally {
setIsGeneratingAnimations(false);
}
};
const handleApplyAnimations = async () => {
if (!role.generatedVisualAssetId) {
setAnimationStatus('请先应用主图,再应用动作。');
return;
}
const animationEntries = Object.entries(draftAnimations).filter(
(entry): entry is [AnimationState, DraftAnimationClip] => Boolean(entry[1]),
);
if (animationEntries.length === 0) {
setAnimationStatus('请先生成至少一个核心动作草稿。');
return;
}
setIsApplyingAnimations(true);
setAnimationStatus(null);
try {
const payload = Object.fromEntries(
animationEntries.map(([animation, clip]) => [
animation,
{
framesDataUrls: clip.frames,
fps: clip.fps,
loop: clip.loop,
frameWidth: clip.frameWidth,
frameHeight: clip.frameHeight,
} satisfies CharacterAnimationDraftPayload,
]),
);
const result = await publishCharacterAnimationAssets({
characterId: role.id,
visualAssetId: role.generatedVisualAssetId,
animations: payload,
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
generatedAnimationSetId: result.animationSetId,
animationMap: {
...(role.animationMap ?? {}),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
}),
);
setAnimationStatus('核心动作已应用到当前角色。');
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '应用角色动作失败。',
);
} finally {
setIsApplyingAnimations(false);
}
};
return (
<ModalShell
title="AI 角色资产"
subtitle="先应用主图,再走图生视频抽帧生成核心动作。"
onClose={onClose}
disableClose={
isGeneratingVisuals ||
isApplyingVisual ||
isGeneratingAnimations ||
isApplyingAnimations
}
>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.05fr)_minmax(22rem,0.95fr)]">
<div className="space-y-5">
<Section title="阶段 A · 主图">
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="space-y-4">
<Field label="主图方式">
<SelectInput
value={sourceMode}
onChange={(value) =>
setSourceMode(
value as Exclude<CharacterVisualSourceMode, 'upload'>,
)
}
options={VISUAL_SOURCE_OPTIONS}
/>
</Field>
<Field label="形象补充要求">
<TextArea
value={visualPromptText}
onChange={setVisualPromptText}
rows={6}
placeholder="例如:衣摆更利落、剑柄更明显、整体更像江湖少女剑客。"
/>
</Field>
<Field label="参考图">
<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"
multiple
onChange={handleReferenceImageUpload}
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="mt-3 flex flex-wrap gap-2">
{effectiveReferenceImages.map((imageSrc, index) => (
<div
key={`${imageSrc}-${index}`}
className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/25"
>
<img
src={imageSrc}
alt={`reference-${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
{effectiveReferenceImages.length === 0 ? (
<div className="rounded-xl border border-dashed border-white/10 px-4 py-3 text-xs text-zinc-500">
使
</div>
) : null}
</div>
</Field>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={<ImagePlus className="h-4 w-4" />}
label={isGeneratingVisuals ? '生成中...' : '生成主图候选'}
onClick={() => void handleGenerateVisuals()}
disabled={isGeneratingVisuals}
tone="sky"
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingVisual ? '应用中...' : '应用主图'}
onClick={() => void handleApplyVisual()}
disabled={isApplyingVisual || !selectedVisualDraft}
tone="green"
/>
</div>
{visualStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{visualStatus}
</div>
) : null}
</div>
<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="flex min-h-[20rem] items-center justify-center p-4">
{previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
className="max-h-[28rem] w-full object-contain"
/>
) : selectedTemplate ? (
<img
src={selectedTemplate.portrait}
alt={selectedTemplate.name}
className="max-h-[20rem] w-full object-contain"
/>
) : (
<div className="px-6 text-center text-sm leading-7 text-zinc-500">
</div>
)}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{visualDrafts.map((draft) => {
const isSelected = draft.id === selectedVisualDraftId;
return (
<button
key={draft.id}
type="button"
onClick={() => setSelectedVisualDraftId(draft.id)}
className={`overflow-hidden rounded-2xl border text-left transition-colors ${
isSelected
? 'border-emerald-400/40 bg-emerald-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20'
}`}
>
<div className="aspect-[3/4] overflow-hidden bg-black/30 p-2">
<img
src={draft.imageSrc}
alt={draft.label}
className="h-full w-full object-contain"
/>
</div>
<div className="flex items-center justify-between gap-3 px-3 py-3">
<div className="text-sm text-white">{draft.label}</div>
{isSelected ? (
<StatusBadge tone="green"></StatusBadge>
) : null}
</div>
</button>
);
})}
{visualDrafts.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-black/20 px-4 py-8 text-sm text-zinc-500 sm:col-span-2">
</div>
) : null}
</div>
</div>
</div>
</Section>
<Section title="阶段 B · 核心动作">
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="space-y-4">
<Field label="动作槽位">
<SelectInput
value={selectedAnimation}
onChange={(value) =>
setSelectedAnimation(value as AnimationState)
}
options={CORE_ACTIONS.map((item) => ({
value: item.animation,
label: item.label,
}))}
/>
</Field>
<Field label="动作补充要求">
<TextArea
value={animationPromptText}
onChange={setAnimationPromptText}
rows={5}
placeholder="例如:剑客攻击更干脆,收招更稳;奔跑时衣摆不要飘得过大。"
/>
</Field>
<div className="flex flex-wrap gap-3">
<ActionButton
icon={<RefreshCcw className="h-4 w-4" />}
label={
isGeneratingAnimations
? '生成中...'
: `生成${selectedActionConfig?.label ?? '当前'}动作`
}
onClick={() => void handleGenerateSingleAnimation()}
disabled={isGeneratingAnimations || !role.imageSrc}
tone="sky"
/>
<ActionButton
icon={<Film className="h-4 w-4" />}
label={
isGeneratingAnimations ? '生成中...' : '生成核心动作'
}
onClick={() => void handleGenerateAllAnimations()}
disabled={isGeneratingAnimations || !role.imageSrc}
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingAnimations ? '应用中...' : '应用动作'}
onClick={() => void handleApplyAnimations()}
disabled={isApplyingAnimations}
tone="green"
/>
</div>
{animationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{animationStatus}
</div>
) : null}
</div>
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge tone="amber">
{appliedActionCount}/{CORE_ACTIONS.length}
</StatusBadge>
{role.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
)}
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{CORE_ACTIONS.map((item) => {
const hasDraft = Boolean(draftAnimations[item.animation]);
const isApplied = Boolean(
role.animationMap?.[item.animation]?.basePath,
);
return (
<div
key={item.animation}
className={`rounded-2xl border px-4 py-4 ${
item.animation === selectedAnimation
? 'border-sky-300/30 bg-sky-500/10'
: 'border-white/10 bg-black/20'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{item.label}
</div>
{hasDraft ? (
<StatusBadge tone="green">稿</StatusBadge>
) : isApplied ? (
<StatusBadge tone="amber"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
)}
</div>
<div className="mt-2 text-xs leading-6 text-zinc-500">
</div>
</div>
);
})}
</div>
</div>
<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="mb-3 text-[11px] font-bold tracking-[0.18em] text-zinc-300">
</div>
<div className="flex items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
{roleKind === 'playable' && selectedTemplate ? (
<div className="h-[220px] w-[220px]">
<CharacterAnimator
state={selectedAnimation}
character={{
...selectedTemplate,
id: role.id,
name: role.name,
title: role.title,
portrait: role.imageSrc || selectedTemplate.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId:
role.generatedAnimationSetId,
animationMap: role.animationMap
? {
...(selectedTemplate.animationMap ?? {}),
...role.animationMap,
}
: selectedTemplate.animationMap,
}}
className="h-full w-full"
/>
</div>
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
className="max-h-[16rem] w-full object-contain"
/>
) : (
<div className="px-4 text-sm text-zinc-500">
</div>
)}
</div>
</div>
</div>
</div>
</Section>
</div>
<div className="space-y-5">
<Section title="当前角色档案">
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="text-lg font-semibold text-white">{role.name}</div>
<div className="mt-1 text-sm text-zinc-400">
{role.title} / {role.role}
</div>
<div className="mt-4 space-y-3 text-sm leading-7 text-zinc-300">
{role.description ? <div>{role.description}</div> : null}
{role.combatStyle ? <div>{role.combatStyle}</div> : null}
{role.tags.length > 0 ? <div>{role.tags.join('、')}</div> : null}
</div>
</div>
<Field label="自动提示词依据">
<TextArea
value={characterBriefText}
onChange={() => {}}
rows={14}
placeholder=""
readOnly
/>
</Field>
</Section>
<Section title="当前进度">
<div className="grid gap-3">
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm text-zinc-200"></div>
{role.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
)}
</div>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm text-zinc-200"></div>
{appliedActionCount > 0 ? (
<StatusBadge tone="amber">
{appliedActionCount}/{CORE_ACTIONS.length}
</StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
)}
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-500">
</div>
</div>
</Section>
</div>
</div>
</ModalShell>
);
}

View File

@@ -14,11 +14,11 @@ import {
} from '../hooks/useStoryGeneration';
import {
type Character,
type CustomWorldProfile,
type CompanionRenderState,
type GameState,
type StoryMoment,
type StoryOption,
type WorldType,
} from '../types';
import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets';
import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow';
@@ -58,7 +58,7 @@ interface GameShellEntryProps {
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: Character) => void;
}
@@ -211,7 +211,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
@@ -430,7 +430,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleWorldSelect={handleWorldSelect}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
)}

View File

@@ -150,7 +150,10 @@ function TradeQuantityStepper({
export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
const [tradeDetail, setTradeDetail] = useState<TradeDetailState>(null);
const currencyName = getCurrencyName(gameState.worldType);
const currencyName = getCurrencyName(
gameState.worldType,
gameState.customWorldProfile,
);
const tradeModal = npcUi.tradeModal;
const tradeNpcState = tradeModal
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]

View File

@@ -1135,84 +1135,53 @@ export function AdventurePanelOverlays({
</div>
<div className="flex-1 overflow-y-auto p-3 scrollbar-hide">
{(() => {
if (!activeGoalQuest) {
return null;
}
const currentTaskCard = buildCurrentTaskCardCopy({
goalStack,
goalPulse,
journeyBeat,
sceneName: statistics.currentSceneName,
});
if (!currentTaskCard) {
return null;
}
return (
<TaskTemplateCard
eyebrow={currentTaskCard.eyebrow}
title={currentTaskCard.title}
description={currentTaskCard.description}
condition={currentTaskCard.condition}
progress={currentTaskCard.progress}
reward={activeGoalQuest?.reward ?? null}
onRewardItemSelect={
activeGoalQuest
? itemId => selectQuestRewardItem(activeGoalQuest, itemId)
: null
}
tone="main"
/>
);
})()}
{quests.length > 0 ? (
<div className={`${activeGoalQuest ? 'mt-3' : ''} space-y-2`}>
<div className="space-y-2">
{sortedQuests.map(quest => (
<button
<div
key={quest.id}
type="button"
onClick={() => setSelectedQuestId(quest.id)}
className="w-full rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-left transition hover:border-white/15"
className="rounded-2xl border border-white/8 bg-black/20 transition hover:border-white/15 focus-within:border-white/15"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
{goalStack.activeGoal?.sourceKind === 'quest' && goalStack.activeGoal.sourceId === quest.id
? '当前主任务'
: '任务'}
<button
type="button"
onClick={() => setSelectedQuestId(quest.id)}
className="w-full px-3 pt-3 text-left focus:outline-none"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[10px] tracking-[0.2em] text-zinc-500"></div>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
isQuestReadyToClaim(quest)
? 'border border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
: quest.status === 'turned_in'
? 'border border-white/10 bg-white/8 text-zinc-200'
: 'border border-sky-400/20 bg-sky-500/10 text-sky-100'
}`}>
{getQuestStatusLabel(quest.status)}
</span>
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${
isQuestReadyToClaim(quest)
? 'border border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
: quest.status === 'turned_in'
? 'border border-white/10 bg-white/8 text-zinc-200'
: 'border border-sky-400/20 bg-sky-500/10 text-sky-100'
}`}>
{getQuestStatusLabel(quest.status)}
</span>
<div className="mt-2 text-sm font-semibold text-white">
{formatTaskTitle(quest.title)}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
<span className="text-zinc-500"></span>
{quest.description || quest.narrativeBinding?.playerHook || quest.summary}
</div>
<div className="mt-1 text-xs leading-relaxed text-zinc-300">
<span className="text-zinc-500"></span>
{buildQuestConditionText(quest, worldType)}
</div>
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
{quest.issuerNpcName}
{` · 任务进度:${getQuestProgressText(quest)}`}
</div>
</button>
<div className="px-3 pb-3">
<QuestRewardIconStrip
reward={quest.reward}
onSelectItem={itemId => selectQuestRewardItem(quest, itemId)}
/>
</div>
<div className="mt-2 text-sm font-semibold text-white">
{formatTaskTitle(quest.title)}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
<span className="text-zinc-500"></span>
{quest.description || quest.narrativeBinding?.playerHook || quest.summary}
</div>
<div className="mt-1 text-xs leading-relaxed text-zinc-300">
<span className="text-zinc-500"></span>
{buildQuestConditionText(quest, worldType)}
</div>
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
{quest.issuerNpcName}
{` · 任务进度:${getQuestProgressText(quest)}`}
</div>
<QuestRewardIconStrip
reward={quest.reward}
onSelectItem={itemId => selectQuestRewardItem(quest, itemId)}
/>
</button>
</div>
))}
</div>
) : (

View File

@@ -10,10 +10,10 @@ import type {
} from '../../hooks/useStoryGeneration';
import type {
CompanionRenderState,
CustomWorldProfile,
GameState,
StoryMoment,
StoryOption,
WorldType,
} from '../../types';
import { UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
@@ -51,7 +51,7 @@ export function GameShellMainContent({
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
displayedOptions,
@@ -88,7 +88,7 @@ export function GameShellMainContent({
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
displayedOptions: StoryOption[];
@@ -132,7 +132,7 @@ export function GameShellMainContent({
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleWorldSelect={handleWorldSelect}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
)}

View File

@@ -40,7 +40,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
@@ -228,7 +228,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleWorldSelect={handleWorldSelect}
handleCustomWorldSelect={handleCustomWorldSelect}
handleBackToWorldSelect={handleBackToWorldSelect}
handleCharacterSelect={handleCharacterSelect}
displayedOptions={displayedOptions}

View File

@@ -9,7 +9,6 @@ import {
upsertSavedCustomWorldProfile,
} from '../../data/customWorldLibrary';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { getScenePreset } from '../../data/scenePresets';
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile,
@@ -25,7 +24,6 @@ import {
type CustomWorldGenerationMode,
type CustomWorldProfile,
type GameState,
WorldType,
} from '../../types';
import {
CHROME_ICONS,
@@ -45,8 +43,6 @@ export type SelectionStage =
| 'custom-world-generating'
| 'custom-world-result';
type WorldOnlineCounts = Partial<Record<WorldType, number>>;
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
@@ -54,10 +50,7 @@ type PreGameSelectionFlowProps = {
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleWorldSelect: (
type: WorldType,
customWorldProfile?: GameState['customWorldProfile'],
) => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
};
const DEVELOPER_TEAM_MESSAGE =
@@ -68,32 +61,6 @@ const START_SCREEN_CONTACTS = [
{ label: '微信', value: 'bzh253518756' },
] as const;
const WORLD_OPTIONS = [
{
id: WorldType.WUXIA,
name: '武侠',
subtitle: '刀剑江湖',
icon: WORLD_SELECT_ICONS.wuxia,
texture: UI_CHROME.worldButtonWuxia,
},
{
id: WorldType.XIANXIA,
name: '仙侠',
subtitle: '云灵仙境',
icon: WORLD_SELECT_ICONS.xianxia,
texture: UI_CHROME.worldButtonXianxia,
},
] as const;
function generateWorldOnlineCounts(): WorldOnlineCounts {
const roll = (base: number) =>
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
return {
[WorldType.WUXIA]: roll(146),
[WorldType.XIANXIA]: roll(173),
};
}
function buildLockedSeedNameSets(profile: CustomWorldProfile) {
const lockedCharacterNames = new Set(
profile.creatorIntent?.keyCharacters
@@ -168,7 +135,7 @@ export function PreGameSelectionFlow({
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleWorldSelect,
handleCustomWorldSelect,
}: PreGameSelectionFlowProps) {
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<GameState['customWorldProfile']>(null);
@@ -176,9 +143,6 @@ export function PreGameSelectionFlow({
CustomWorldProfile[]
>(() => readSavedCustomWorldProfiles());
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
const [worldOnlineCounts, setWorldOnlineCounts] = useState<WorldOnlineCounts>(
() => generateWorldOnlineCounts(),
);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
useState<CustomWorldCreatorIntent>(() =>
@@ -200,23 +164,6 @@ export function PreGameSelectionFlow({
[generatedCustomWorldProfile],
);
const worldCards = useMemo(
() =>
WORLD_OPTIONS.map((world, index) => ({
...world,
sceneImage:
getScenePreset(world.id, index + 1)?.imageSrc ??
getScenePreset(world.id, 0)?.imageSrc ??
'',
featureIcon:
world.id === WorldType.WUXIA
? '/Icons/03_Torch.png'
: '/Icons/19_Mana_potion.png',
onlineCount: worldOnlineCounts[world.id] ?? 0,
})),
[worldOnlineCounts],
);
const savedCustomWorldCards = useMemo(
() =>
savedCustomWorldProfiles.map((profile) => {
@@ -255,12 +202,6 @@ export function PreGameSelectionFlow({
return customWorldCreatorIntent.rawSettingText.trim();
}, [customWorldCreatorIntent]);
useEffect(() => {
if (!gameState.worldType && selectionStage === 'world') {
setWorldOnlineCounts(generateWorldOnlineCounts());
}
}, [gameState.worldType, selectionStage]);
useEffect(() => {
if (
selectionStage === 'custom-world-result' &&
@@ -343,7 +284,7 @@ export function PreGameSelectionFlow({
return;
}
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
handleCustomWorldSelect(generatedCustomWorldProfile);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(null);
@@ -454,147 +395,6 @@ export function PreGameSelectionFlow({
);
};
const regeneratePlayableNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.playableNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.playableNpcs[targetIndex] ??
regeneratedProfile.playableNpcs.find(
(entry) =>
entry.name === currentProfile.playableNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
return {
...currentProfile,
playableNpcs: currentProfile.playableNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
),
};
},
{
confirmMessage: '确认重新生成这个可扮演角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryNpc = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.storyNpcs.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextNpc =
regeneratedProfile.storyNpcs[targetIndex] ??
regeneratedProfile.storyNpcs.find(
(entry) => entry.name === currentProfile.storyNpcs[targetIndex]?.name,
);
if (!nextNpc) {
return currentProfile;
}
const nextStoryNpcs = currentProfile.storyNpcs.map((entry, index) =>
index === targetIndex ? nextNpc : entry,
);
return {
...currentProfile,
storyNpcs: nextStoryNpcs,
landmarks: currentProfile.landmarks.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.map((npcId) =>
npcId === id ? nextNpc.id : npcId,
),
})),
};
},
{
confirmMessage: '确认重新生成这个场景角色吗?当前角色的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateLandmark = async (id: string) => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => {
const targetIndex = currentProfile.landmarks.findIndex(
(entry) => entry.id === id,
);
if (targetIndex < 0) {
return currentProfile;
}
const nextLandmark =
regeneratedProfile.landmarks[targetIndex] ??
regeneratedProfile.landmarks.find(
(entry) => entry.name === currentProfile.landmarks[targetIndex]?.name,
);
if (!nextLandmark) {
return currentProfile;
}
return {
...currentProfile,
landmarks: currentProfile.landmarks.map((entry, index) =>
index === targetIndex ? nextLandmark : entry,
),
};
},
{
confirmMessage: '确认重新生成这个关键地点吗?当前场景的 AI 生成内容会被替换。',
generationMode: 'full',
},
);
};
const regenerateStoryExpansion = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
storyNpcs: regeneratedProfile.storyNpcs,
}),
{
confirmMessage:
'确认重新生成长尾场景角色吗?已锁定锚点会保留,其余场景角色会被新的生成结果替换。',
generationMode: 'full',
},
);
};
const regenerateLandmarkNetwork = async () => {
await regenerateFromCurrentProfile(
(currentProfile, regeneratedProfile) => ({
...currentProfile,
landmarks: currentProfile.landmarks.map((landmark, index) => ({
...landmark,
sceneNpcIds:
regeneratedProfile.landmarks[index]?.sceneNpcIds ??
landmark.sceneNpcIds,
connections:
regeneratedProfile.landmarks[index]?.connections ??
landmark.connections,
})),
}),
{
confirmMessage:
'确认重新生成场景网络吗?已锁定场景名称与描述会保留,但 NPC 分布和连接关系会按最新结果刷新。',
generationMode: 'full',
},
);
};
const createCustomWorld = async () => {
if (isGeneratingCustomWorld) {
return;
@@ -800,7 +600,7 @@ export function PreGameSelectionFlow({
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-bold tracking-[0.2em] text-zinc-400">
</div>
<button
type="button"
@@ -814,67 +614,14 @@ export function PreGameSelectionFlow({
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 pb-1 md:grid-cols-2 xl:grid-cols-3">
{worldCards.map((world) => (
<button
key={world.id}
type="button"
onClick={() => handleWorldSelect(world.id)}
className="pixel-nine-slice pixel-pressable order-2 relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.subtitle}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
<div className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25">
<PixelIcon
src={world.featureIcon}
className="h-5 w-5 opacity-95"
/>
</div>
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3">
<div className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
{world.name}
</div>
<PixelIcon
src={world.icon}
className="h-10 w-10 opacity-95"
/>
</div>
<div className="mt-auto">
<div className="text-3xl font-black text-white">
{world.subtitle}
</div>
<div className="mt-2 flex items-center gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
线 {world.onlineCount}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
</span>
</div>
</div>
</div>
</button>
))}
{savedCustomWorldCards.map((world) => (
<div key={world.id} className="order-1 relative">
<button
type="button"
onClick={() =>
handleWorldSelect(WorldType.CUSTOM, world.profile)
handleCustomWorldSelect(world.profile)
}
className="pixel-nine-slice pixel-pressable relative flex min-h-[12.5rem] w-full flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
@@ -1013,21 +760,6 @@ export function PreGameSelectionFlow({
onContinueExpand={() => {
void continueExpandCustomWorld();
}}
onRegeneratePlayableNpc={(id) => {
void regeneratePlayableNpc(id);
}}
onRegenerateStoryNpc={(id) => {
void regenerateStoryNpc(id);
}}
onRegenerateLandmark={(id) => {
void regenerateLandmark(id);
}}
onRegenerateStoryExpansion={() => {
void regenerateStoryExpansion();
}}
onRegenerateLandmarkNetwork={() => {
void regenerateLandmarkNetwork();
}}
onSave={saveGeneratedCustomWorld}
/>
</motion.div>

View File

@@ -9,11 +9,11 @@ import type {
} from '../../hooks/useStoryGeneration';
import type {
Character,
CustomWorldProfile,
CompanionRenderState,
GameState,
StoryMoment,
StoryOption,
WorldType,
} from '../../types';
export interface GameShellSessionProps {
@@ -46,7 +46,7 @@ export interface GameShellEntryProps {
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: Character) => void;
}

View File

@@ -43,6 +43,7 @@ export type CharacterVisualGenerationPayload = {
characterId: string;
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
promptText: string;
characterBriefText?: string;
referenceImageDataUrls: string[];
candidateCount: number;
imageModel: string;
@@ -57,6 +58,7 @@ export type CharacterVisualPublishPayload = {
previewSources: string[];
width: number;
height: number;
updateCharacterOverride?: boolean;
};
export type CharacterAnimationGenerationPayload = {
@@ -64,6 +66,8 @@ export type CharacterAnimationGenerationPayload = {
strategy: CharacterAnimationStrategy;
animation: string;
promptText: string;
characterBriefText?: string;
actionTemplateId?: string;
visualSource: string;
referenceImageDataUrls: string[];
referenceVideoDataUrls: string[];
@@ -243,6 +247,7 @@ export async function publishCharacterAnimationAssets(payload: {
characterId: string;
visualAssetId: string;
animations: Record<string, CharacterAnimationDraftPayload>;
updateCharacterOverride?: boolean;
}) {
const response = await fetch(CHARACTER_ANIMATION_PUBLISH_API_PATH, {
method: 'POST',
@@ -259,6 +264,7 @@ export async function publishCharacterAnimationAssets(payload: {
ok: true;
animationSetId: string;
overrideMap: Record<string, unknown>;
animationMap: Record<string, unknown>;
saveMessage: string;
};
}

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
import { AnimationState } from '../types';
import {
buildCustomWorldRuntimeCharacters,
getCharacterById,
@@ -136,6 +137,28 @@ describe('characterPresets custom world runtime characters', () => {
relationshipHooks: ['断桥旧案', '夜港潮路'],
tags: ['码头', '潮路', '短刀'],
imageSrc: '/custom/npcs/shenwu.png',
generatedVisualAssetId: 'visual-custom-shenwu',
generatedAnimationSetId: 'animation-set-custom-shenwu',
animationMap: {
[AnimationState.IDLE]: {
folder: 'idle',
prefix: 'frame',
frames: 8,
startFrame: 1,
extension: 'png',
basePath:
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle',
},
[AnimationState.ATTACK]: {
folder: 'attack',
prefix: 'frame',
frames: 8,
startFrame: 1,
extension: 'png',
basePath:
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/attack',
},
},
visual: {
race: 'human',
bodyColor: 'blue',
@@ -217,6 +240,15 @@ describe('characterPresets custom world runtime characters', () => {
expect(storyCharacter?.backstory).toContain('断桥坠潮夜');
expect(storyCharacter?.skills[0]?.name).toBe('技能11-1');
expect(storyCharacter?.portrait).toBe('/custom/npcs/shenwu.png');
expect(storyCharacter?.generatedVisualAssetId).toBe(
'visual-custom-shenwu',
);
expect(storyCharacter?.generatedAnimationSetId).toBe(
'animation-set-custom-shenwu',
);
expect(storyCharacter?.animationMap?.[AnimationState.IDLE]?.basePath).toBe(
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle',
);
expect(storyCharacter?.visual).toEqual(storyRole?.visual);
expect(storyCharacter?.groundOffsetY).toBe(22);

View File

@@ -1,4 +1,5 @@
import { buildThemedSkillName } from '../services/customWorldPresentation';
import { resolveRoleTemplateCharacterIdFromReferenceProfile } from '../services/customWorldReferenceSignals';
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
import {
AnimationState,
@@ -1625,7 +1626,15 @@ function buildCustomWorldRoleCharacter(
description: role.description,
backstory: role.backstory,
backstoryReveal: role.backstoryReveal,
portrait: ('imageSrc' in role && role.imageSrc?.trim()) || baseCharacter.portrait,
portrait: role.imageSrc?.trim() || baseCharacter.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
generatedAnimationSetId: role.generatedAnimationSetId,
animationMap: role.animationMap
? {
...(baseCharacter.animationMap ?? {}),
...role.animationMap,
}
: baseCharacter.animationMap,
visual: 'visual' in role ? role.visual : undefined,
groundOffsetY: 'visual' in role && role.visual ? 22 : baseCharacter.groundOffsetY,
personality: role.personality,
@@ -1657,6 +1666,7 @@ function buildCustomWorldRoleCharacter(
function pickCustomWorldRoleTemplateCharacter(
role: CustomWorldRuntimeRole,
fallbackIndex: number,
profile?: CustomWorldProfile | null,
) {
const fallbackTemplateCharacter = PRESET_CHARACTERS[
fallbackIndex % Math.max(1, PRESET_CHARACTERS.length)
@@ -1672,6 +1682,28 @@ function pickCustomWorldRoleTemplateCharacter(
return explicitTemplateCharacter;
}
const referenceTemplateCharacterId = resolveRoleTemplateCharacterIdFromReferenceProfile(
profile ?? null,
{
id: role.id,
name: role.name,
title: role.title,
role: role.role,
description: role.description,
personality: role.personality,
combatStyle: role.combatStyle,
tags: role.tags,
},
);
const referenceTemplateCharacter = referenceTemplateCharacterId
? PRESET_CHARACTERS.find(
(character) => character.id === referenceTemplateCharacterId,
) ?? null
: null;
if (referenceTemplateCharacter) {
return referenceTemplateCharacter;
}
const heuristicTemplateCharacter = PRESET_CHARACTERS.find(
character =>
character.id === resolveFallbackRecruitTemplateCharacterId([
@@ -1698,7 +1730,11 @@ export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile |
}
return profile.playableNpcs.map((role, index) => {
const templateCharacter = pickCustomWorldRoleTemplateCharacter(role, index);
const templateCharacter = pickCustomWorldRoleTemplateCharacter(
role,
index,
profile,
);
return buildCustomWorldRoleCharacter(
templateCharacter,
@@ -1721,6 +1757,7 @@ export function buildCustomWorldRuntimeCharacters(profile: CustomWorldProfile |
const templateCharacter = pickCustomWorldRoleTemplateCharacter(
role,
profile.playableNpcs.length + index,
profile,
);
return buildCustomWorldRoleCharacter(

View File

@@ -7,7 +7,10 @@ import {
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from '../services/customWorldCreatorIntent';
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
import {
AnimationState,
CharacterAnimationConfig,
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
@@ -47,6 +50,7 @@ const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
const ANIMATION_STATES = new Set<AnimationState>(Object.values(AnimationState));
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set<CustomWorldNpcVisualGearType>(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']);
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
@@ -361,6 +365,54 @@ function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | u
};
}
function normalizeCharacterAnimationConfig(
value: unknown,
): CharacterAnimationConfig | null {
if (!isRecord(value)) return null;
const folder = toText(value.folder);
const prefix = toText(value.prefix);
const frames = Math.max(1, toOptionalInteger(value.frames) ?? 0);
if (!folder || !prefix || frames <= 0) {
return null;
}
const startFrame = toOptionalInteger(value.startFrame);
const extension = toText(value.extension);
const file = toText(value.file);
const basePath = toText(value.basePath);
return {
folder,
prefix,
frames,
...(startFrame ? { startFrame: Math.max(1, startFrame) } : {}),
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
};
}
function normalizeGeneratedAnimationMap(value: unknown) {
if (!isRecord(value)) return undefined;
const entries = Object.entries(value).flatMap(([key, rawConfig]) => {
if (!ANIMATION_STATES.has(key as AnimationState)) {
return [];
}
const config = normalizeCharacterAnimationConfig(rawConfig);
return config ? [[key as AnimationState, config] as const] : [];
});
return entries.length > 0
? Object.fromEntries(entries) as Partial<
Record<AnimationState, CharacterAnimationConfig>
>
: undefined;
}
function normalizeItemStatProfile(value: unknown): ItemStatProfile | null {
if (!isRecord(value)) return null;
@@ -408,9 +460,9 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-playable-${index + 1}`),
name,
return {
id: toText(value.id, `saved-playable-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
@@ -419,14 +471,18 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null {
if (!isRecord(value)) return null;
@@ -450,9 +506,9 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-story-${index + 1}`),
name,
return {
id: toText(value.id, `saved-story-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
@@ -461,15 +517,18 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
visual: normalizeCustomWorldNpcVisual(value.visual),
};
}
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
visual: normalizeCustomWorldNpcVisual(value.visual),
};
}
function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
if (!isRecord(value)) return null;
@@ -619,7 +678,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
: [];
return {
const normalizedProfile = {
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
settingText,
name,
@@ -670,6 +729,14 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
value.generationStatus === 'key_only' || value.generationStatus === 'complete'
? value.generationStatus
: 'complete',
} satisfies CustomWorldProfile;
return {
...normalizedProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
value.ownedSettingLayers,
normalizedProfile,
),
};
}

View File

@@ -1,4 +1,13 @@
import { type CustomWorldNpc, type CustomWorldPlayableNpc, WorldType } from '../types';
import {
collectCreatureArchetypeSignals,
resolveCreatureArchetypeForSource,
} from '../services/customWorldReferenceSignals';
import {
type CustomWorldNpc,
type CustomWorldPlayableNpc,
type CustomWorldProfile,
WorldType,
} from '../types';
import {
getMonsterPresetsByWorld,
type HostileNpcPreset,
@@ -64,6 +73,10 @@ function getMonsterPresetPool(worldType?: WorldType | null) {
});
}
function getAllMonsterPresets() {
return getMonsterPresetPool(null);
}
function uniqueText(values: Array<string | null | undefined>) {
return [
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
@@ -118,9 +131,99 @@ function scoreMonsterPreset(preset: HostileNpcPreset, sourceText: string) {
return score;
}
function scoreMonsterPresetWithArchetype(
preset: HostileNpcPreset,
sourceText: string,
options: {
archetypeSignals?: ReturnType<typeof collectCreatureArchetypeSignals> | null;
preferredWorldType?: WorldType | null;
} = {},
) {
let score = scoreMonsterPreset(preset, sourceText);
const { archetypeSignals, preferredWorldType } = options;
if (archetypeSignals) {
archetypeSignals.keywords.forEach((keyword) => {
if (!keyword) {
return;
}
if (
preset.name.includes(keyword)
|| preset.habitatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
|| preset.combatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
) {
score += keyword.length >= 3 ? 6 : 4;
}
});
archetypeSignals.combatTags.forEach((tag) => {
if (preset.combatTags.includes(tag)) {
score += 8;
}
});
archetypeSignals.habitatTags.forEach((tag) => {
if (preset.habitatTags.includes(tag)) {
score += 6;
}
});
}
if (
preferredWorldType
&& preferredWorldType !== WorldType.CUSTOM
&& preset.worldType === preferredWorldType
) {
score += 3;
}
return score;
}
export function getCustomWorldMonsterPresetPool(
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
) {
const presets = getAllMonsterPresets();
const creatureArchetypes =
profile?.ownedSettingLayers?.referenceProfile.creatureArchetypes ?? [];
if (creatureArchetypes.length === 0) {
return presets;
}
const preferredWorldType = profile?.templateWorldType ?? null;
const scoredPresets = presets
.map((preset) => {
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
const nextScore = scoreMonsterPresetWithArchetype(
preset,
preset.name,
{
archetypeSignals: collectCreatureArchetypeSignals(archetype),
preferredWorldType,
},
);
return Math.max(bestScore, nextScore);
}, 0);
return {
preset,
score: archetypeScore,
};
})
.sort((left, right) => right.score - left.score);
const filtered = scoredPresets
.filter((entry) => entry.score > 0)
.map((entry) => entry.preset);
return filtered.length > 0 ? filtered : presets;
}
export function resolveCustomWorldNpcMonsterPreset(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
) {
const sourceText = buildMonsterSourceText(npc);
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
@@ -132,7 +235,18 @@ export function resolveCustomWorldNpcMonsterPreset(
return null;
}
const candidates = getMonsterPresetPool(worldType);
const preferredWorldType = profile?.templateWorldType ?? worldType ?? null;
const referenceArchetype = resolveCreatureArchetypeForSource(
profile as CustomWorldProfile | null | undefined,
npc,
);
const archetypeSignals = referenceArchetype
? collectCreatureArchetypeSignals(referenceArchetype)
: null;
const candidates =
profile && profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length
? getCustomWorldMonsterPresetPool(profile)
: getMonsterPresetPool(worldType);
if (candidates.length === 0) {
return null;
}
@@ -140,7 +254,10 @@ export function resolveCustomWorldNpcMonsterPreset(
const scoredCandidates = candidates
.map((candidate) => ({
candidate,
score: scoreMonsterPreset(candidate, sourceText),
score: scoreMonsterPresetWithArchetype(candidate, sourceText, {
archetypeSignals,
preferredWorldType,
}),
}))
.sort((left, right) => right.score - left.score);
@@ -154,6 +271,7 @@ export function resolveCustomWorldNpcMonsterPreset(
export function resolveCustomWorldNpcMonsterPresetId(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
) {
return resolveCustomWorldNpcMonsterPreset(npc, worldType)?.id ?? null;
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
}

View File

@@ -1,4 +1,8 @@
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
collectSceneBucketSignalKeywords,
resolveSceneBucketForLandmark,
} from '../services/customWorldReferenceSignals';
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
import {
type CustomWorldLandmark,
@@ -197,6 +201,7 @@ type CustomWorldSceneImageMatchOptions = {
| 'settingText'
| 'templateWorldType'
| 'camp'
| 'ownedSettingLayers'
> | null;
landmark?: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel'> | null;
usedImageSrcs?: Iterable<string>;
@@ -262,6 +267,36 @@ function buildSceneReferencePool(worldType: WorldTemplateType) {
}));
}
function buildOwnedSceneReferencePool(
profile: Pick<
CustomWorldProfile,
'id' | 'name' | 'ownedSettingLayers'
>,
) {
const sceneBuckets =
profile.ownedSettingLayers?.referenceProfile.sceneBuckets ?? [];
if (sceneBuckets.length === 0) {
return [];
}
const pool = getAllCustomWorldSceneImages();
if (pool.length === 0) {
return [];
}
return sceneBuckets.map((bucket, index) => {
const offset =
hashText(`${profile.id || profile.name}:${bucket.id}:${bucket.label}`)
% pool.length;
return {
name: bucket.label,
keywords: collectSceneBucketSignalKeywords(bucket),
imageSrc: pool[(offset + index) % pool.length] ?? '',
};
});
}
function buildSourceText(
seedKey: string,
index: number,
@@ -369,7 +404,13 @@ export function getDefaultCustomWorldSceneImage(
worldType: WorldTemplateType,
options: CustomWorldSceneImageMatchOptions = {},
) {
const pool = collectWorldSceneImagePool(worldType);
const ownedReferencePool = options.profile
? buildOwnedSceneReferencePool(options.profile)
: [];
const pool =
ownedReferencePool.length > 0
? getAllCustomWorldSceneImages()
: collectWorldSceneImagePool(worldType);
if (pool.length === 0) {
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
}
@@ -379,12 +420,34 @@ export function getDefaultCustomWorldSceneImage(
.map((value) => normalizeOptionalImageSrc(value))
.filter((value): value is string => Boolean(value)),
);
const sourceText = buildSourceText(seedKey, index, worldType, options);
const referencePool = buildSceneReferencePool(worldType);
const preferredSceneBucket =
options.profile && options.landmark
? resolveSceneBucketForLandmark(
options.profile as CustomWorldProfile,
options.landmark,
)
: null;
const sourceText = [
buildSourceText(seedKey, index, worldType, options),
preferredSceneBucket?.label ?? '',
...(preferredSceneBucket
? collectSceneBucketSignalKeywords(preferredSceneBucket)
: []),
].join(' ');
const referencePool =
ownedReferencePool.length > 0
? ownedReferencePool
: buildSceneReferencePool(worldType);
const scoredReferences = referencePool
.map((reference, referenceIndex) => ({
imageSrc: reference.imageSrc,
score: scoreSceneReference(reference, sourceText),
score:
scoreSceneReference(reference, sourceText)
+ (
preferredSceneBucket && reference.name === preferredSceneBucket.label
? 28
: 0
),
tieBreaker: hashText(`${seedKey}:${reference.name}:${referenceIndex}`),
}))
.sort((left, right) => {
@@ -418,7 +481,14 @@ export function getDefaultCustomWorldSceneImage(
export function resolveCustomWorldLandmarkImage(
profile: Pick<
CustomWorldProfile,
'id' | 'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'ownedSettingLayers'
>,
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
index: number,
@@ -453,6 +523,7 @@ export function resolveCustomWorldLandmarkImageMap(
| 'templateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
>,
) {
const usedImageSrcs = new Set(
@@ -490,6 +561,7 @@ export function resolveCustomWorldCampSceneImage(
| 'templateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
>,
) {
const campScene = resolveCustomWorldCampScene(profile);

View File

@@ -1,4 +1,6 @@
import { InventoryItem, WorldType } from '../types';
import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSettingLayers';
import { CustomWorldProfile, InventoryItem, WorldType } from '../types';
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
const RARITY_BASE_VALUES: Record<InventoryItem['rarity'], number> = {
common: 12,
@@ -8,13 +10,40 @@ const RARITY_BASE_VALUES: Record<InventoryItem['rarity'], number> = {
legendary: 168,
};
export function getCurrencyName(worldType: WorldType | null) {
function resolveEconomyProfile(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const profile =
customWorldProfile ??
(worldType === WorldType.CUSTOM ? getRuntimeCustomWorldProfile() : null);
return resolveCustomWorldRuleProfile(profile);
}
export function getCurrencyName(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const ruleProfile = resolveEconomyProfile(worldType, customWorldProfile);
if (ruleProfile) {
return ruleProfile.resourceLabels.currency;
}
if (worldType === WorldType.XIANXIA) return '灵石';
if (worldType === WorldType.WUXIA) return '铜钱';
return '钱币';
}
export function getInitialPlayerCurrency(worldType: WorldType | null) {
export function getInitialPlayerCurrency(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const ruleProfile = resolveEconomyProfile(worldType, customWorldProfile);
if (ruleProfile) {
return ruleProfile.economyProfile.initialCurrency;
}
return worldType === WorldType.XIANXIA ? 140 : 160;
}
@@ -55,6 +84,10 @@ export function getNpcBuybackPrice(item: InventoryItem, affinity: number) {
return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier));
}
export function formatCurrency(value: number, worldType: WorldType | null) {
return `${value} ${getCurrencyName(worldType)}`;
export function formatCurrency(
value: number,
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
return `${value} ${getCurrencyName(worldType, customWorldProfile)}`;
}

View File

@@ -186,6 +186,30 @@ describe('npcInteractions', () => {
expect(story.options.some((option) => option.functionId === 'npc_gift')).toBe(false);
});
it('uses ai-first copy for quest offers instead of prebuilding a fallback quest preview', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-ruins',
name: '遗迹外缘',
npcs: [],
treasureHints: ['半截封泥'],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
const questOption = story.options.find((option) => option.functionId === 'npc_quest_accept');
expect(questOption).toBeTruthy();
expect(questOption?.detailText).toContain('AI 剧情引擎');
expect(questOption?.detailText).not.toContain('完成后可获得');
});
it('builds concrete trade action text for story continuation', () => {
const encounter = createEncounter();

View File

@@ -68,9 +68,8 @@ import {
type GiftAffinityInsight,
} from './npcAttributeInsights';
import {
buildQuestAcceptDetail,
buildQuestForEncounter,
buildQuestTurnInDetail,
evaluateQuestOpportunity,
getQuestForIssuer,
} from './questFlow';
import {
@@ -1379,6 +1378,30 @@ function buildNpcOption(
} as StoryOption;
}
function buildQuestAcceptOpportunityDetail(params: {
issuerNpcId: string;
issuerNpcName: string;
roleText: string;
scene: Pick<ScenePresetInfo, 'id' | 'name' | 'npcs' | 'treasureHints'> | null;
worldType: WorldType | null;
currentQuests: QuestLogEntry[];
}) {
const opportunity = evaluateQuestOpportunity({
issuerNpcId: params.issuerNpcId,
issuerNpcName: params.issuerNpcName,
roleText: params.roleText,
scene: params.scene,
worldType: params.worldType,
currentQuests: params.currentQuests,
});
if (!opportunity.shouldOffer) {
return null;
}
return `${opportunity.reason} 接取后将由 AI 剧情引擎根据当前局势生成具体目标、步骤与奖励。`;
}
function getPlayerBenefitScore(item: InventoryItem, character: Character) {
let score = getInventoryItemValue(item);
const customWorldProfile = getRuntimeCustomWorldProfile();
@@ -1967,13 +1990,14 @@ export function buildNpcEncounterStoryMoment({
})
: null;
const activeQuest = getQuestForIssuer(activeQuests, npcId);
const generatedQuest = buildQuestForEncounter({
const questAcceptDetail = !activeQuest ? buildQuestAcceptOpportunityDetail({
issuerNpcId: npcId,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene,
worldType,
});
currentQuests: activeQuests,
}) : null;
const options: StoryOption[] = [];
const isHostileEncounter =
npcState.affinity < 0 ||
@@ -2098,15 +2122,14 @@ export function buildNpcEncounterStoryMoment({
activeQuest.id,
),
);
} else if (!activeQuest && generatedQuest) {
} else if (!activeQuest && questAcceptDetail) {
options.push(
buildNpcOption(
NPC_QUEST_ACCEPT_FUNCTION.id,
`接下${encounter.npcName}的委托`,
buildQuestAcceptDetail(generatedQuest),
questAcceptDetail,
npcId,
'quest_accept',
generatedQuest.id,
),
);
}

View File

@@ -5,6 +5,7 @@ import {WorldType} from '../types';
import {
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk,
buildChapterQuestForScene,
buildQuestForEncounter,
isQuestReadyToClaim,
normalizeQuestLogEntries,
@@ -29,6 +30,62 @@ const TEST_SCENE = {
treasureHints: [],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
const CHAPTER_SCENE = {
id: 'palace_court',
name: '宫苑内庭',
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
npcs: [
{
id: 'npc-maid',
name: '旧宫侍女',
description: '她总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
initialAffinity: 8,
hostile: false,
},
{
id: 'hostile-guard',
name: '旧宫戍影',
description: '巡行在回廊深处的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
const OVERRIDDEN_SCENE = {
id: 'wuxia-palace-court',
name: '宫苑内庭',
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
npcs: [
{
id: 'wuxia-npc-maid',
name: '旧宫侍女',
description: '嘴上说得少,却总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
initialAffinity: 8,
hostile: false,
},
{
id: 'hostile-guard',
name: '旧宫戍影',
description: '巡行在回廊深处的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
function requireStep(quest: QuestLogEntry, stepId: string): QuestStep {
const step = quest.steps?.find(item => item.id === stepId);
expect(step).toBeTruthy();
@@ -111,5 +168,55 @@ describe('questFlow', () => {
expect(normalized?.status).toBe('completed');
expect(normalized?.progress).toBe(1);
});
});
it('builds a scene chapter quest that reuses staged quest steps', () => {
const quest = buildChapterQuestForScene({
scene: CHAPTER_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
expect(quest?.chapterId).toBe('chapter:scene:palace_court');
expect(quest?.sceneId).toBe('palace_court');
expect(quest?.steps?.map((step) => step.kind)).toEqual([
'talk_to_npc',
'defeat_hostile_npc',
'talk_to_npc',
]);
});
it('lets scene chapter quests advance through npc talk and scene pressure steps', () => {
const quest = buildChapterQuestForScene({
scene: CHAPTER_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
const afterOpeningTalk = applyQuestProgressFromNpcTalk([quest!], 'npc-maid')[0];
expect(afterOpeningTalk?.objective.kind).toBe('defeat_hostile_npc');
const afterPressure = applyQuestProgressFromHostileNpcDefeat(
[afterOpeningTalk!],
CHAPTER_SCENE.id,
['monster-11'],
)[0];
expect(afterPressure?.objective.kind).toBe('talk_to_npc');
const afterTurningTalk = applyQuestProgressFromNpcTalk([afterPressure!], 'npc-maid')[0];
expect(afterTurningTalk?.status).toBe('ready_to_turn_in');
expect(isQuestReadyToClaim(afterTurningTalk!)).toBe(true);
});
it('uses scene chapter overrides to prefer investigation beats on key scenes', () => {
const quest = buildChapterQuestForScene({
scene: OVERRIDDEN_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
expect(quest?.title).toBe('查清内庭旧痕');
expect(requireStep(quest!, 'step_scene_pressure').kind).toBe('inspect_treasure');
expect(requireStep(quest!, 'step_scene_pressure').title).toBe('调查回廊暗格');
expect(requireStep(quest!, 'step_scene_turning').title).toBe('拿旧金牌去对问侍女');
});
});

View File

@@ -28,7 +28,7 @@ import {
} from './runtimeItemContext';
import {buildDirectedRuntimeReward} from './runtimeItemDirector';
import {flattenDirectedRuntimeRewardItems} from './runtimeItemNarrative';
import {getSceneHostileNpcs} from './scenePresets';
import {getSceneFriendlyNpcs, getSceneHostileNpcs} from './scenePresets';
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired'];
@@ -52,6 +52,112 @@ type SceneQuestThreat =
suggestedThreatType: 'relationship';
};
type SceneChapterOverride = {
title?: string;
description?: string;
summary?: string;
preferredObjectiveKind?: QuestObjectiveKind;
openingTalk?: Partial<Pick<QuestStep, 'title' | 'revealText' | 'completeText'>>;
pressureStep?: Partial<Pick<QuestStep, 'title' | 'revealText' | 'completeText'>>;
turningTalk?: Partial<Pick<QuestStep, 'title' | 'revealText' | 'completeText'>>;
};
const SCENE_CHAPTER_OVERRIDES: Record<string, SceneChapterOverride> = {
'wuxia-palace-court': {
title: '查清内庭旧痕',
description: '旧宫侍女显然知道宫苑内庭近来的异动不只是一条禁行回廊那么简单,你需要顺着残痕把这一章真正翻开。',
summary: '在宫苑内庭查清旧案残痕,并逼出侍女压着没说的那一层旧事',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '追问禁行回廊',
revealText: '先问清旧宫侍女为什么总拦着那条回廊,这一章的开口多半就藏在她的口风里。',
completeText: '旧宫侍女已经把最表层的理由说出来了,但真正的旧事还压在更深处。',
},
pressureStep: {
title: '调查回廊暗格',
revealText: '先把回廊暗格里的香囊翻出来,确认内庭异动究竟是在遮人,还是在遮旧案。',
completeText: '回廊暗格已经给出了回应,内庭这章也开始逼近改判前的节点。',
},
turningTalk: {
title: '拿旧金牌去对问侍女',
revealText: '把你查到的旧金牌和暗格痕迹带回去,和旧宫侍女把这层旧事对清楚。',
completeText: '旧宫侍女已经接住你的追问,这章也被你真正推到了收束前夜。',
},
},
'wuxia-rain-street': {
title: '追索雨街账册',
description: '夜灯摊主见过太多不该见的人,雨夜长街的异常更像一条被水汽和灯影压着的旧账,你得先把线索翻出来。',
summary: '在雨夜长街查出湿布包和账册残页背后到底是谁在追索谁',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '向摊主问清夜街异样',
revealText: '先和夜灯摊主把这条街最近不对劲的地方问清,别让这章一开始就被人带偏。',
},
pressureStep: {
title: '翻出灯下残页',
revealText: '顺着浸湿的布包和账册残页查下去,这条长街真正压着的旧账就会冒头。',
},
turningTalk: {
title: '拿账册回去对灯摊主',
revealText: '把你翻到的账册残页拿回去,逼夜灯摊主把没说透的那半句话补完。',
},
},
'wuxia-forge-works': {
title: '追索失落兵谱',
description: '老铸匠一眼就认出你身上的杀气来源,铸坊工场里压着的旧兵谱和铁匣显然不只是废料,你得先把这章的火候逼出来。',
summary: '在铸坊工场追出失落兵谱的去向,并问清是谁把旧匣压在风箱后面',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '追问兵器缺口来路',
revealText: '先让老铸匠把兵器缺口看清楚,他多半已经从上面认出了这章的来路。',
},
pressureStep: {
title: '翻出风箱后兵谱',
revealText: '先把风箱后压着的旧兵谱和铁匣找出来,铸坊这章的真正火候就在那附近。',
},
turningTalk: {
title: '拿兵谱回去问铸匠',
revealText: '把你翻到的兵谱拿回去,对着老铸匠把来历和去向一并问透。',
},
},
'xianxia-cloud-gate': {
title: '查明仙门符匣异动',
description: '守门灵官一直像在等一份迟迟未到的回报,云海仙门的符匣和玉牌显然牵着更深的禁制线,你得先把这章的入口找准。',
summary: '在云海仙门查清符匣和门阙阴影背后的异常,确认谁在借仙门遮掩旧事',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '向灵官问清门阙异象',
revealText: '先让守门灵官把云海仙门最近的异象说清楚,这章的入口多半就藏在他守着不放的话头里。',
},
pressureStep: {
title: '调查云阶符匣',
revealText: '顺着云阶尽头的灵符匣查下去,把仙门这一章真正的异常先钉住。',
},
turningTalk: {
title: '带着符匣回去问灵官',
revealText: '把你查到的符匣线索带回去,逼守门灵官把没说完的禁制旧事补全。',
},
},
'xianxia-star-vessel': {
title: '追索星图旧航线',
description: '星舟舵手守着旧航线图不肯松手,甲板上压着的星图匣和灵罗盘像在等人把残缺航线拼起来,这一章更适合从调查切进去。',
summary: '在星舟甲板拼出失落航线的缺口,并问清是谁把旧坐标压在高空风压里',
preferredObjectiveKind: 'inspect_treasure',
openingTalk: {
title: '追问失落航线',
revealText: '先和星舟舵手把旧航线的缺口问清楚,别让甲板上的风声把真正的方向吹散。',
},
pressureStep: {
title: '调查舵台后星图匣',
revealText: '把舵台后的星图匣和灵罗盘先翻出来,这章的方向才会真正落到你手里。',
},
turningTalk: {
title: '带着航线回去问舵手',
revealText: '把你拼出来的航线缺口带回去,逼星舟舵手把这段旧路说到底。',
},
},
};
function resolveQuestRewardRuntimeConfig(params: {
roleText: string;
rewardTheme: QuestIntent['rewardTheme'];
@@ -244,6 +350,10 @@ function buildQuestId(issuerNpcId: string, kind: QuestObjectiveKind, targetKey:
return `quest:${issuerNpcId}:${kind}:${targetKey}`;
}
export function buildSceneChapterId(sceneId: string) {
return `chapter:scene:${sceneId}`;
}
function isRewardReadyStatus(status: QuestStatus) {
return REWARD_READY_STATUSES.includes(status);
}
@@ -505,6 +615,214 @@ function buildTalkBackStep(issuerNpcId: string, issuerNpcName: string): QuestSte
};
}
function buildSceneOpeningTalkStep(params: {
issuerNpcId: string;
issuerNpcName: string;
sceneName: string;
override?: SceneChapterOverride | null;
}) {
const {issuerNpcId, issuerNpcName, sceneName, override} = params;
const title = override?.openingTalk?.title ?? `${issuerNpcName} 打听异动`;
return {
id: 'step_scene_opening',
kind: 'talk_to_npc',
targetNpcId: issuerNpcId,
requiredCount: 1,
progress: 0,
title,
revealText: override?.openingTalk?.revealText
?? `${issuerNpcName} 明显知道 ${sceneName} 最近不对劲,先和她把眼前局势问清楚。`,
completeText: override?.openingTalk?.completeText
?? `${issuerNpcName} 的回应已经把 ${sceneName} 这一章真正带进了正题。`,
} satisfies QuestStep;
}
function buildSceneTurningTalkStep(params: {
issuerNpcId: string;
issuerNpcName: string;
sceneName: string;
override?: SceneChapterOverride | null;
}) {
const {issuerNpcId, issuerNpcName, sceneName, override} = params;
const title = override?.turningTalk?.title ?? `回去与 ${issuerNpcName} 对证`;
return {
id: 'step_scene_turning',
kind: 'talk_to_npc',
targetNpcId: issuerNpcId,
requiredCount: 1,
progress: 0,
title,
revealText: override?.turningTalk?.revealText
?? `把你在 ${sceneName} 查到的情况带回去,和 ${issuerNpcName} 把这一层旧事对清楚。`,
completeText: override?.turningTalk?.completeText
?? `${issuerNpcName} 已经接住你的回报,这一章也逼近最后的收束。`,
} satisfies QuestStep;
}
function resolveSceneChapterIssuer(scene: QuestSceneSnapshot | null) {
const friendlyNpc = getSceneFriendlyNpcs(scene)[0] ?? null;
if (!friendlyNpc) {
return {
issuerNpcId: scene?.id ? `scene-chapter:${scene.id}` : 'scene-chapter:unknown',
issuerNpcName: scene?.name ?? '当前区域',
roleText: scene?.description ?? scene?.name ?? '场景章节',
hasGuideNpc: false,
};
}
return {
issuerNpcId: friendlyNpc.id,
issuerNpcName: friendlyNpc.name,
roleText: friendlyNpc.role || friendlyNpc.description || scene?.description || friendlyNpc.name,
hasGuideNpc: true,
};
}
function buildSceneChapterPrimaryStep(params: {
scene: QuestSceneSnapshot;
worldType: WorldType | null;
issuerNpcId: string;
issuerNpcName: string;
hasGuideNpc: boolean;
override?: SceneChapterOverride | null;
}) {
const {scene, worldType, issuerNpcId, issuerNpcName, hasGuideNpc, override} = params;
const threat = getScenePrimaryThreat(scene, worldType);
const preferredObjectiveKind = override?.preferredObjectiveKind ?? null;
if (preferredObjectiveKind === 'inspect_treasure' && (scene.treasureHints?.length ?? 0) > 0) {
const clueLabel = scene.treasureHints?.[0] ?? scene.name;
return {
id: 'step_scene_pressure',
kind: 'inspect_treasure',
targetSceneId: scene.id,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `调查 ${clueLabel}`,
revealText: override?.pressureStep?.revealText ?? (
hasGuideNpc
? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。`
: `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`
),
completeText: override?.pressureStep?.completeText
?? `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`,
} satisfies QuestStep;
}
if ((preferredObjectiveKind === 'defeat_hostile_npc' || !preferredObjectiveKind) && threat?.kind === 'defeat_hostile_npc') {
const hostileNpcName = threat.targetHostileNpcName;
return {
id: 'step_scene_pressure',
kind: 'defeat_hostile_npc',
targetHostileNpcId: threat.targetHostileNpcId,
targetSceneId: threat.targetSceneId,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `压制 ${hostileNpcName}`,
revealText: override?.pressureStep?.revealText ?? (
hasGuideNpc
? `${issuerNpcName} 要你先压制 ${hostileNpcName},再回来确认 ${scene.name} 里的异动究竟是谁在推动。`
: `先压下 ${hostileNpcName} 带来的压力,才能把 ${scene.name} 这一章继续往下推。`
),
completeText: override?.pressureStep?.completeText
?? `${hostileNpcName} 已被压制,${scene.name} 这一章的核心压力开始松动。`,
} satisfies QuestStep;
}
if ((scene.treasureHints?.length ?? 0) > 0) {
const clueLabel = scene.treasureHints?.[0] ?? scene.name;
return {
id: 'step_scene_pressure',
kind: 'inspect_treasure',
targetSceneId: scene.id,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `调查 ${clueLabel}`,
revealText: override?.pressureStep?.revealText ?? (
hasGuideNpc
? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。`
: `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`
),
completeText: override?.pressureStep?.completeText
?? `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`,
} satisfies QuestStep;
}
return {
id: 'step_scene_pressure',
kind: 'talk_to_npc',
targetNpcId: issuerNpcId,
requiredCount: 1,
progress: 0,
title: override?.pressureStep?.title ?? `继续逼问 ${issuerNpcName}`,
revealText: override?.pressureStep?.revealText ?? `${issuerNpcName} 还压着一层没说透的话,把这章的中段压力继续顶上去。`,
completeText: override?.pressureStep?.completeText ?? `${issuerNpcName} 的口风终于松了一层,这章也开始逼近转折。`,
} satisfies QuestStep;
}
function buildSceneChapterSteps(params: {
scene: QuestSceneSnapshot;
worldType: WorldType | null;
issuerNpcId: string;
issuerNpcName: string;
hasGuideNpc: boolean;
override?: SceneChapterOverride | null;
}) {
const {scene, worldType, issuerNpcId, issuerNpcName, hasGuideNpc, override} = params;
const steps: QuestStep[] = [];
if (hasGuideNpc) {
steps.push(buildSceneOpeningTalkStep({
issuerNpcId,
issuerNpcName,
sceneName: scene.name,
override,
}));
}
steps.push(buildSceneChapterPrimaryStep({
scene,
worldType,
issuerNpcId,
issuerNpcName,
hasGuideNpc,
override,
}));
if (hasGuideNpc) {
steps.push(buildSceneTurningTalkStep({
issuerNpcId,
issuerNpcName,
sceneName: scene.name,
override,
}));
}
return steps;
}
function resolveSceneChapterNarrativeType(scene: QuestSceneSnapshot, worldType: WorldType | null) {
const threat = getScenePrimaryThreat(scene, worldType);
if (threat?.kind === 'defeat_hostile_npc') {
return 'bounty' as const;
}
if ((scene.treasureHints?.length ?? 0) > 0) {
return 'investigation' as const;
}
return 'relationship' as const;
}
function resolveSceneChapterRewardTheme(scene: QuestSceneSnapshot, worldType: WorldType | null) {
const threat = getScenePrimaryThreat(scene, worldType);
if ((scene.treasureHints?.length ?? 0) > 0) {
return 'intel' as const;
}
if (threat?.kind === 'defeat_hostile_npc') {
return 'resource' as const;
}
return 'relationship' as const;
}
function deriveObjectiveFromStep(step: QuestStep | null, issuerNpcId: string): QuestObjective {
if (!step) {
return {
@@ -609,6 +927,7 @@ export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
const normalizedQuest: QuestLogEntry = {
...quest,
chapterId: quest.chapterId ?? null,
objective,
progress,
status,
@@ -713,6 +1032,18 @@ export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string)
return quests.find(quest => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in') ?? null;
}
export function getChapterQuestForScene(quests: QuestLogEntry[], sceneId: string | null | undefined) {
if (!sceneId) {
return null;
}
const chapterId = buildSceneChapterId(sceneId);
return quests.find((quest) =>
quest.chapterId === chapterId
&& !isTerminalStatus(quest.status),
) ?? null;
}
export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity {
const {issuerNpcId, scene, currentQuests = []} = params;
if (!scene) {
@@ -927,6 +1258,109 @@ export function buildQuestForEncounter(params: QuestPreviewRequest): QuestLogEnt
);
}
export function buildChapterQuestForScene(params: {
scene: QuestSceneSnapshot | null;
worldType: WorldType | null;
context?: QuestGenerationContext;
}) {
const {scene, worldType, context} = params;
if (!scene) {
return null;
}
const {
issuerNpcId,
issuerNpcName,
roleText,
hasGuideNpc,
} = resolveSceneChapterIssuer(scene);
const override = SCENE_CHAPTER_OVERRIDES[scene.id] ?? null;
const steps = buildSceneChapterSteps({
scene,
worldType,
issuerNpcId,
issuerNpcName,
hasGuideNpc,
override,
});
if (steps.length <= 0) {
return null;
}
const narrativeType = resolveSceneChapterNarrativeType(scene, worldType);
const rewardTheme = resolveSceneChapterRewardTheme(scene, worldType);
const reward = buildQuestReward({
issuerNpcId,
issuerNpcName,
worldType,
roleText,
rewardTheme,
narrativeType,
scene,
context,
});
const rewardText = buildRewardText(reward, worldType);
const threadContract = resolveQuestThreadContract({
context,
issuerNpcId,
scene,
});
const chapterId = buildSceneChapterId(scene.id);
const threat = getScenePrimaryThreat(scene, worldType);
const title = normalizeQuestTitle(
override?.title ?? `${compactQuestLabel(scene.name, 6)}异动`,
`查明${compactQuestLabel(scene.name, 6)}`,
);
return normalizeQuestLogEntry({
id: `quest:chapter:${scene.id}`,
issuerNpcId,
issuerNpcName,
sceneId: scene.id,
chapterId,
actId: context?.actState?.id ?? null,
threadId: threadContract?.threadId ?? null,
contractId: threadContract?.id ?? null,
title,
description: override?.description ?? (
hasGuideNpc
? `${issuerNpcName} 认为 ${scene.name} 这一带的异动并不简单,希望你把眼前的线索与压力真正查清。`
: `${scene.name} 当前的局势还没有收束,你需要把这一章的线索和压力真正接住。`
),
summary: override?.summary ?? `${scene.name} 接住这一章的线索并完成收束`,
objective: deriveObjectiveFromStep(steps[0] ?? null, issuerNpcId),
progress: 0,
status: 'active',
completionNotified: false,
reward,
rewardText,
narrativeBinding: {
origin: 'fallback_builder',
narrativeType,
dramaticNeed: hasGuideNpc
? `${issuerNpcName} 明显知道 ${scene.name} 的局势正在失衡,但还没把真正的问题说透。`
: `${scene.name} 的异常正在把这段局势往前推,你需要先把现场的主压力接住。`,
issuerGoal: hasGuideNpc
? `查清 ${scene.name} 的异动到底是谁、哪件旧事或哪层残痕在推动。`
: `${scene.name} 当前未收束的压力和线索梳理清楚。`,
playerHook: `你已经进入 ${scene.name},这一章现在就落在你面前。`,
worldReason: threat?.kind === 'defeat_hostile_npc'
? `${scene.name} 的敌对压力已经摆到了台前,不先处理就很难继续推进。`
: `${scene.name} 的线索和残痕已经堆到足以独立成章的程度。`,
followupHooks: [
`${scene.name} 的这一章收束后,下一段 lead 会开始变得更明确。`,
],
},
steps,
activeStepId: steps[0]?.id ?? null,
visibleStage: 0,
hiddenFlags: [],
discoveredFactIds: [],
relatedCarrierIds: [],
consequenceIds: [],
});
}
export function buildQuestAcceptDetail(quest: QuestLogEntry) {
const normalizedQuest = withNormalizedQuest(quest);
const activeStep = getQuestActiveStep(normalizedQuest);

View File

@@ -25,12 +25,13 @@ import {
PRESET_CHARACTERS,
} from './characterPresets';
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
import { getCustomWorldMonsterPresetPool } from './customWorldNpcMonsters';
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from './customWorldVisuals';
import { getMonsterPresetById, getMonsterPresetsByWorld } from './hostileNpcPresets';
import { getMonsterPresetById } from './hostileNpcPresets';
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
import sceneOverridesJson from './sceneOverrides.json';
@@ -307,7 +308,7 @@ function buildCustomSceneNpc(
);
const monsterPreset =
npc.initialAffinity < 0
? resolveCustomWorldNpcMonsterPreset(npc)
? resolveCustomWorldNpcMonsterPreset(npc, WorldType.CUSTOM, profile)
: null;
const hostile = npc.initialAffinity < 0 || Boolean(monsterPreset);
const attributeProfile = monsterPreset?.attributeProfile
@@ -378,7 +379,7 @@ function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const campSceneProfile = resolveCustomWorldCampScene(profile);
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const baseMonsterPool: string[] = getMonsterPresetsByWorld(WorldType.CUSTOM)
const baseMonsterPool: string[] = getCustomWorldMonsterPresetPool(profile)
.map((monster) => monster.id)
.filter((monsterId: string, index: number, array: string[]) => array.indexOf(monsterId) === index);
const fallbackMonsterIds: string[] = baseMonsterPool.length > 0 ? baseMonsterPool : [];

View File

@@ -1,3 +1,4 @@
import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSettingLayers';
import type {CustomWorldProfile, WorldAttributeSchema} from '../types';
import {WorldType} from '../types';
@@ -162,8 +163,11 @@ export function getWorldAttributeSchema(
worldType: WorldType | null | undefined,
customWorldProfile?: CustomWorldProfile | null,
) {
if (worldType === WorldType.CUSTOM && customWorldProfile?.attributeSchema) {
return customWorldProfile.attributeSchema;
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return (
resolveCustomWorldRuleProfile(customWorldProfile)?.attributeSchema
?? customWorldProfile.attributeSchema
);
}
if (worldType === WorldType.XIANXIA) {

View File

@@ -25,6 +25,7 @@ import {
import {
acceptQuest,
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk,
applyQuestProgressFromSpar,
buildQuestAcceptResultText,
buildQuestForEncounter,
@@ -456,8 +457,13 @@ export function createStoryNpcEncounterActions({
]
: provisionalHistory
: appendHistory(gameState, actionText, finalDialogueText);
const progressedQuests = applyQuestProgressFromNpcTalk(
nextState.quests,
encounter.id ?? encounter.npcName,
);
const finalState = {
...nextState,
quests: progressedQuests,
storyHistory: finalHistory,
};
const finalOpeningCampContext = buildOpeningCampChatContext(

View File

@@ -3,6 +3,11 @@ import type {
SetStateAction,
} from 'react';
import {
acceptQuest,
buildChapterQuestForScene,
getChapterQuestForScene,
} from '../../data/questFlow';
import {
hasEncounterEntity,
interpolateEncounterTransitionState,
@@ -176,6 +181,67 @@ function findNewInventoryItems(previousState: GameState, nextState: GameState) {
return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
}
function ensureSceneChapterQuestState(params: {
previousState: GameState;
nextState: GameState;
}) {
const storyEngineMemory =
params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const scene = params.nextState.currentScenePreset;
if (
params.nextState.currentScene !== 'Story'
|| !params.nextState.worldType
|| !scene?.id
) {
return {
...params.nextState,
storyEngineMemory,
};
}
const openedSceneChapterIds = dedupeStrings([
...(storyEngineMemory.openedSceneChapterIds ?? []),
], 64);
if (openedSceneChapterIds.includes(scene.id)) {
return {
...params.nextState,
storyEngineMemory: {
...storyEngineMemory,
openedSceneChapterIds,
},
};
}
const nextMemory = {
...storyEngineMemory,
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
};
const existingChapterQuest = getChapterQuestForScene(params.nextState.quests, scene.id);
if (existingChapterQuest) {
return {
...params.nextState,
storyEngineMemory: nextMemory,
};
}
const chapterQuest = buildChapterQuestForScene({
scene,
worldType: params.nextState.worldType,
});
if (!chapterQuest) {
return {
...params.nextState,
storyEngineMemory: nextMemory,
};
}
return {
...params.nextState,
storyEngineMemory: nextMemory,
quests: acceptQuest(params.nextState.quests, chapterQuest),
};
}
function applyStoryEngineEchoes(params: {
previousState: GameState;
nextState: GameState;
@@ -200,13 +266,17 @@ function applyStoryEngineEchoes(params: {
signals,
contracts,
});
const stateWithSceneChapter = ensureSceneChapterQuestState({
previousState: params.previousState,
nextState: stateWithSignals,
});
const reactions = buildCompanionReactionBatch({
state: stateWithSignals,
state: stateWithSceneChapter,
signals,
actionText: params.actionText,
});
const stateWithReactions = applyCompanionReactionToStance({
state: stateWithSignals,
state: stateWithSceneChapter,
reactions,
});
const storyEngineMemory = stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();

View File

@@ -56,6 +56,7 @@ function createQuest(status: QuestLogEntry['status']): QuestLogEntry {
issuerNpcId: 'npc-trader',
issuerNpcName: 'Trader Lin',
sceneId: 'scene-1',
chapterId: 'chapter:scene:scene-1',
title: 'Deliver the cache',
description: 'Deliver the cache safely.',
summary: 'Help Trader Lin recover the cache.',
@@ -174,4 +175,75 @@ describe('sessionActions', () => {
expect(rewardClaim.nextState.npcStates['npc-trader']?.affinity).toBe(7);
expect(rewardClaim).toHaveProperty('handoff');
});
it('refreshes chapter state after a chapter quest is turned in', () => {
const baseState = {
...createBaseState(),
currentScenePreset: {
id: 'scene-1',
name: '断桥旧哨',
description: '断桥边风声未散。',
imageSrc: '/scene-1.png',
treasureHints: [],
npcs: [],
},
chapterState: {
id: 'chapter:scene:scene-1',
title: '断桥旧哨·高潮',
theme: '回报遗迹调查',
primaryThreadIds: [],
stage: 'climax' as const,
chapterSummary: '当前章节已逼近最后收束。',
sceneId: 'scene-1',
chapterQuestId: 'quest-1',
},
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: ['scene-1'],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: {
id: 'chapter:scene:scene-1',
title: '断桥旧哨·高潮',
theme: '回报遗迹调查',
primaryThreadIds: [],
stage: 'climax' as const,
chapterSummary: '当前章节已逼近最后收束。',
sceneId: 'scene-1',
chapterQuestId: 'quest-1',
},
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
campaignState: null,
actState: null,
consequenceLedger: [],
companionResolutions: [],
endingState: null,
authorialConstraintPack: null,
branchBudgetStatus: null,
narrativeQaReport: null,
narrativeCodex: [],
},
} satisfies GameState;
const rewardClaim = applyQuestRewardClaim(baseState, 'quest-1');
expect(rewardClaim).not.toBeNull();
if (!rewardClaim) {
throw new Error('Expected reward claim result');
}
expect(rewardClaim.nextState.chapterState?.stage).toBe('aftermath');
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('aftermath');
});
});

View File

@@ -9,8 +9,13 @@ import {
markQuestCompletionNotified,
markQuestTurnedIn,
} from '../../data/questFlow';
import {
advanceChapterState,
resolveCurrentChapterState,
} from '../../services/storyEngine/chapterDirector';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
GameState,
StoryMoment,
@@ -63,10 +68,29 @@ export function applyQuestRewardClaim(
}
: state.npcStates,
}, quest.reward.items);
const chapterState = advanceChapterState({
previousChapter:
nextState.chapterState
?? nextState.storyEngineMemory?.currentChapter
?? null,
nextChapter: resolveCurrentChapterState({
state: nextState,
}),
});
const storyEngineMemory =
nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const synchronizedNextState: GameState = {
...nextState,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
},
};
return {
nextState,
handoff: buildGoalHandoffFromState(nextState),
nextState: synchronizedNextState,
handoff: buildGoalHandoffFromState(synchronizedNextState),
};
}

View File

@@ -7,6 +7,9 @@ import {
NPC_TRADE_FUNCTION,
shouldNpcRecruitOpenModal,
} from '../../data/functionCatalog';
import {
applyQuestProgressFromSceneReached,
} from '../../data/questFlow';
import {
buildInitialNpcState,
getPreferredGiftItemId,
@@ -139,6 +142,7 @@ export function buildMapTravelResolution(
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
scenesTraveled: 1,
}),
quests: applyQuestProgressFromSceneReached(state.quests, targetScene.id),
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,

View File

@@ -120,11 +120,11 @@ export function useGameFlow() {
setGameState(createInitialGameState());
};
const handleWorldSelect = (type: WorldType, customWorldProfile: CustomWorldProfile | null = null) => {
const resolvedWorldType = customWorldProfile ? WorldType.CUSTOM : type;
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
const resolvedWorldType = WorldType.CUSTOM;
setRuntimeCustomWorldProfile(customWorldProfile);
setRuntimeCharacterOverrides(
customWorldProfile ? buildCustomWorldRuntimeCharacters(customWorldProfile) : null,
buildCustomWorldRuntimeCharacters(customWorldProfile),
);
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
setIsMapOpen(false);
@@ -217,7 +217,10 @@ export function useGameFlow() {
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(gameState.worldType),
playerCurrency: getInitialPlayerCurrency(
gameState.worldType,
gameState.customWorldProfile,
),
playerInventory: buildInitialPlayerInventory(character, gameState.worldType),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates: initialEncounter && initialNpcState
@@ -248,7 +251,7 @@ export function useGameFlow() {
isMapOpen,
setIsMapOpen,
resetGame,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
};

View File

@@ -18,7 +18,7 @@ import {
buildSaveMigrationManifest,
} from '../services/storyEngine/saveMigrationManifest';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { GameState, StoryMoment } from '../types';
import { GameState, StoryMoment, WorldType } from '../types';
import { BottomTab } from './useGameFlow';
const AUTO_SAVE_DELAY_MS = 400;
@@ -31,6 +31,14 @@ function normalizeSavedStory(story: StoryMoment | null) {
} satisfies StoryMoment;
}
function isPlayableSavedGameState(gameState: GameState | null | undefined) {
return Boolean(
gameState
&& gameState.worldType === WorldType.CUSTOM
&& gameState.customWorldProfile,
);
}
function normalizeCharacterChats(gameState: GameState) {
const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [
characterId,
@@ -100,7 +108,10 @@ function normalizeSavedGameState(gameState: GameState) {
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
playerCurrency: typeof gameState.playerCurrency === 'number'
? gameState.playerCurrency
: getInitialPlayerCurrency(gameState.worldType),
: getInitialPlayerCurrency(
gameState.worldType,
normalizedEncounterState.customWorldProfile,
),
quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []),
roster: normalizedRoster,
npcStates: Object.fromEntries(
@@ -171,7 +182,8 @@ export function useGamePersistence({
const [hasSavedGame, setHasSavedGame] = useState(false);
useEffect(() => {
setHasSavedGame(Boolean(readSavedSnapshot()));
const snapshot = readSavedSnapshot();
setHasSavedGame(isPlayableSavedGameState(snapshot?.gameState ?? null));
}, []);
useEffect(() => {
@@ -187,7 +199,7 @@ export function useGamePersistence({
});
if (didSave) {
setHasSavedGame(true);
setHasSavedGame(isPlayableSavedGameState(gameState));
}
}, AUTO_SAVE_DELAY_MS);
@@ -214,7 +226,7 @@ export function useGamePersistence({
});
if (didSave) {
setHasSavedGame(true);
setHasSavedGame(isPlayableSavedGameState(nextGameState));
}
return didSave;
@@ -232,6 +244,11 @@ export function useGamePersistence({
return false;
}
if (!isPlayableSavedGameState(snapshot.gameState)) {
clearSavedGame();
return false;
}
resetStoryState();
setGameState(normalizeSavedGameState(snapshot.gameState));
setBottomTab(snapshot.bottomTab ?? 'adventure');

View File

@@ -639,6 +639,7 @@ function buildStoryContextFromState(
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
currentSceneId: state.currentScenePreset?.id ?? null,
chapterState,
journeyBeat,
setpieceDirective,
@@ -1865,6 +1866,7 @@ export function useStoryGeneration({
buildGoalStackState({
quests: gameState.quests,
worldType: gameState.worldType,
currentSceneId: gameState.currentScenePreset?.id ?? null,
chapterState:
gameState.chapterState
?? gameState.storyEngineMemory?.currentChapter
@@ -1878,6 +1880,7 @@ export function useStoryGeneration({
}),
[
gameState.chapterState,
gameState.currentScenePreset?.id,
gameState.currentScenePreset?.name,
gameState.quests,
gameState.storyEngineMemory?.currentCampEvent,

View File

@@ -201,9 +201,30 @@ function createPlayableNpc(index: number) {
{ name: `技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
],
initialItems: [
{ name: `物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
{ name: `物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
{ name: `物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
{
name: `物品${index + 1}-1`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: '物品说明1',
tags: ['物品标签1'],
},
{
name: `物品${index + 1}-2`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '物品说明2',
tags: ['物品标签2'],
},
{
name: `物品${index + 1}-3`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '物品说明3',
tags: ['物品标签3'],
},
],
};
}
@@ -259,14 +280,47 @@ function createStoryNpc(index: number) {
],
},
skills: [
{ name: `世界技能${index + 1}-1`, summary: '技能说明1', style: '起手压制' },
{ name: `世界技能${index + 1}-2`, summary: '技能说明2', style: '机动周旋' },
{ name: `世界技能${index + 1}-3`, summary: '技能说明3', style: '爆发终结' },
{
name: `世界技能${index + 1}-1`,
summary: '技能说明1',
style: '起手压制',
},
{
name: `世界技能${index + 1}-2`,
summary: '技能说明2',
style: '机动周旋',
},
{
name: `世界技能${index + 1}-3`,
summary: '技能说明3',
style: '爆发终结',
},
],
initialItems: [
{ name: `世界物品${index + 1}-1`, category: '武器', quantity: 1, rarity: 'rare', description: '物品说明1', tags: ['物品标签1'] },
{ name: `世界物品${index + 1}-2`, category: '消耗品', quantity: 2, rarity: 'uncommon', description: '物品说明2', tags: ['物品标签2'] },
{ name: `世界物品${index + 1}-3`, category: '专属物品', quantity: 1, rarity: 'rare', description: '物品说明3', tags: ['物品标签3'] },
{
name: `世界物品${index + 1}-1`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: '物品说明1',
tags: ['物品标签1'],
},
{
name: `世界物品${index + 1}-2`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '物品说明2',
tags: ['物品标签2'],
},
{
name: `世界物品${index + 1}-3`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '物品说明3',
tags: ['物品标签3'],
},
],
};
}
@@ -692,15 +746,12 @@ describe('ai orchestration fallbacks', () => {
it('passes abort signals through custom world generation and rejects when interrupted', async () => {
requestPlainTextCompletionMock.mockImplementation(
(
_system: string,
_user: string,
options?: { signal?: AbortSignal },
) =>
(_system: string, _user: string, options?: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
options?.signal?.addEventListener(
'abort',
() => reject(options.signal?.reason ?? new Error('世界生成已中断。')),
() =>
reject(options.signal?.reason ?? new Error('世界生成已中断。')),
{ once: true },
);
}),
@@ -786,7 +837,9 @@ describe('ai orchestration fallbacks', () => {
expect(requestPlainTextCompletionMock).toHaveBeenNthCalledWith(
2,
expect.stringContaining('你是 JSON 修复器'),
expect.stringContaining('不要输出 playableNpcs、storyNpcs、landmarks、items'),
expect.stringContaining(
'不要输出 playableNpcs、storyNpcs、landmarks、items',
),
expect.objectContaining({
debugLabel: 'custom-world-framework-json-repair',
}),
@@ -836,7 +889,9 @@ describe('ai orchestration fallbacks', () => {
expect(profile.creatorIntent?.sourceMode).toBe('card');
expect(profile.creatorIntent?.keyCharacters[0]?.name).toBe('沈砺');
expect(profile.anchorPack?.keyCharacterAnchors[0]?.name).toBe('沈砺');
expect(profile.anchorPack?.lockedAnchorIds).toContain('creator-character-1');
expect(profile.anchorPack?.lockedAnchorIds).toContain(
'creator-character-1',
);
});
it('generates a custom world scene image through the local proxy and returns the saved asset path', async () => {
@@ -846,10 +901,10 @@ describe('ai orchestration fallbacks', () => {
JSON.stringify({
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
assetId: 'custom-scene-1',
model: 'wan2.2-t2i-flash',
model: 'wan2.7-image',
size: '1280*720',
taskId: 'task-123',
prompt: '用于测试的提示词',
prompt: '系统整理后的提示词',
actualPrompt: '扩写后的提示词',
}),
} as Response);
@@ -870,9 +925,9 @@ describe('ai orchestration fallbacks', () => {
description: '被潮雾与旧升降机包围的码头。',
dangerLevel: 'high',
},
prompt: '用于测试的提示词',
negativePrompt: '文字,水印',
userPrompt: '雨夜的栈桥横跨黑色海沟,塔楼灯火被潮雾吞没。',
size: '1280*720',
referenceImageSrc: '/scene_bg/reference-layout.png',
});
expect(fetchMock).toHaveBeenCalledOnce();
@@ -883,13 +938,30 @@ describe('ai orchestration fallbacks', () => {
headers: { 'Content-Type': 'application/json' },
}),
);
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const requestBody = JSON.parse(String(request.body)) as {
prompt: string;
referenceImageSrc?: string;
};
expect(requestBody.referenceImageSrc).toBe(
'/scene_bg/reference-layout.png',
);
expect(requestBody.prompt).toContain('像素风场景背景');
expect(requestBody.prompt).toContain('画面构图必须严格按上下 1:1 分区');
expect(requestBody.prompt).toContain('下半部分严格占据整张图的 1/2 高度');
expect(requestBody.prompt).toContain('模拟 3D 游戏视角的地面近景');
expect(requestBody.prompt).toContain(
'下半部分的内容必须是明确可站立的地面本体',
);
expect(requestBody.prompt).toContain('已提供一张自定义参考图');
expect(requestBody.prompt).toContain('雨夜的栈桥横跨黑色海沟');
expect(result).toEqual({
imageSrc: '/generated-custom-world-scenes/world/landmark/scene.png',
assetId: 'custom-scene-1',
model: 'wan2.2-t2i-flash',
model: 'wan2.7-image',
size: '1280*720',
taskId: 'task-123',
prompt: '用于测试的提示词',
prompt: '系统整理后的提示词',
actualPrompt: '扩写后的提示词',
});
});

View File

@@ -180,9 +180,11 @@ export interface CustomWorldSceneImageRequest {
CustomWorldProfile['landmarks'][number],
'id' | 'name' | 'description' | 'dangerLevel'
>;
userPrompt?: string;
prompt?: string;
negativePrompt?: string;
size?: string;
referenceImageSrc?: string;
}
export interface CustomWorldSceneImageResult {
@@ -312,7 +314,9 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
Math.ceil(
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
),
),
},
{
@@ -334,7 +338,9 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
total: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
weight: Math.max(
1,
Math.ceil(MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE),
Math.ceil(
MIN_CUSTOM_WORLD_STORY_NPC_COUNT / CUSTOM_WORLD_STORY_BATCH_SIZE,
),
),
},
{
@@ -451,9 +457,8 @@ function resolveCustomWorldGenerationInput(
settingText: normalizedSettingText,
generationSeedText: generationSeedText.trim(),
creatorIntent,
generationMode: input.generationMode === 'fast'
? ('fast' as const)
: ('full' as const),
generationMode:
input.generationMode === 'fast' ? ('fast' as const) : ('full' as const),
};
}
@@ -621,9 +626,7 @@ function getCustomWorldGenerationStageIdForRoleExpansion(
stage: CustomWorldGenerationRoleBatchStage,
): CustomWorldGenerationStageId {
if (roleType === 'playable') {
return stage === 'narrative'
? 'playable-narrative'
: 'playable-dossier';
return stage === 'narrative' ? 'playable-narrative' : 'playable-dossier';
}
return stage === 'narrative' ? 'story-narrative' : 'story-dossier';
@@ -704,8 +707,7 @@ function createCustomWorldGenerationReporter(
const completedWeight = CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS.reduce(
(sum, item) =>
sum +
(completedByStage[item.id] / item.total || 0) * item.weight,
sum + (completedByStage[item.id] / item.total || 0) * item.weight,
0,
);
const progressFraction =
@@ -715,10 +717,7 @@ function createCustomWorldGenerationReporter(
const elapsedMs = Math.max(0, performance.now() - startedAt);
const estimatedRemainingMs =
progressFraction > 0 && progressFraction < 1
? Math.max(
0,
Math.round(elapsedMs / progressFraction - elapsedMs),
)
? Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs))
: progressFraction >= 1
? 0
: null;
@@ -1023,10 +1022,11 @@ async function expandCustomWorldRoleEntries<
1,
Math.ceil(roleBatchSource.length / batchSize),
);
const processedByStage: Record<CustomWorldGenerationRoleBatchStage, number> = {
narrative: 0,
dossier: 0,
};
const processedByStage: Record<CustomWorldGenerationRoleBatchStage, number> =
{
narrative: 0,
dossier: 0,
};
const requestBatchStage = async (
roleBatch: typeof roleBatchSource,
@@ -1070,7 +1070,7 @@ async function expandCustomWorldRoleEntries<
? (stageRaw as Record<string, unknown>)[
roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'
]
: []
: [],
),
);
processedByStage[stage] = Math.min(
@@ -1112,7 +1112,8 @@ async function generateCustomWorldThemePackWithAi(params: {
repairPromptBuilder: (responseText) =>
buildCustomWorldThemePackJsonRepairPrompt({ responseText }),
repairDebugLabel: 'custom-world-theme-pack-json-repair',
emptyResponseMessage: '自定义世界 ThemePack 生成失败:模型没有返回有效内容。',
emptyResponseMessage:
'自定义世界 ThemePack 生成失败:模型没有返回有效内容。',
signal,
});
@@ -1145,7 +1146,8 @@ async function generateCustomWorldStoryGraphWithAi(params: {
repairPromptBuilder: (responseText) =>
buildCustomWorldStoryGraphJsonRepairPrompt({ responseText }),
repairDebugLabel: 'custom-world-story-graph-json-repair',
emptyResponseMessage: '自定义世界 StoryGraph 生成失败:模型没有返回有效内容。',
emptyResponseMessage:
'自定义世界 StoryGraph 生成失败:模型没有返回有效内容。',
signal,
});
@@ -1177,11 +1179,17 @@ async function expandCustomWorldActorNarrativeProfiles<
const roleBatchSource = baseEntries;
const roleLabel = roleType === 'playable' ? '可扮演角色' : '场景角色';
const stageId = getCustomWorldGenerationStageIdForActorProfile(roleType);
const plannedBatchCount = Math.max(1, Math.ceil(roleBatchSource.length / batchSize));
const plannedBatchCount = Math.max(
1,
Math.ceil(roleBatchSource.length / batchSize),
);
let mergedEntries = baseEntries.map((entry) => ({ ...entry })) as T[];
let processedCount = 0;
for (const [batchIndex, roleBatch] of chunkArray(roleBatchSource, batchSize).entries()) {
for (const [batchIndex, roleBatch] of chunkArray(
roleBatchSource,
batchSize,
).entries()) {
throwIfCustomWorldGenerationAborted(signal);
reporter.update(stageId, processedCount, {
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}`,
@@ -1217,7 +1225,10 @@ async function expandCustomWorldActorNarrativeProfiles<
: [],
),
);
processedCount = Math.min(roleBatchSource.length, processedCount + roleBatch.length);
processedCount = Math.min(
roleBatchSource.length,
processedCount + roleBatch.length,
);
reporter.update(stageId, processedCount, {
phaseDetail: `正在补充${roleLabel}叙事档案,已完成 ${processedCount}/${roleBatchSource.length}`,
batchLabel: `${batchIndex + 1} / ${plannedBatchCount}`,
@@ -1268,7 +1279,10 @@ async function parseCustomWorldStageResponseJson(params: {
{
timeoutMs: Math.max(
30000,
Math.min(90000, Math.round(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS / 2)),
Math.min(
90000,
Math.round(CLIENT_CUSTOM_WORLD_REQUEST_TIMEOUT_MS / 2),
),
),
debugLabel: repairDebugLabel,
signal,
@@ -1379,8 +1393,9 @@ function normalizeEncounterResult(
const kind = typeof item.kind === 'string' ? item.kind.trim() : '';
if (kind === 'monster') {
const fallbackHostileNpc =
scene?.npcs.find((npc: SceneNpc) => isHostileSceneNpc(npc));
const fallbackHostileNpc = scene?.npcs.find((npc: SceneNpc) =>
isHostileSceneNpc(npc),
);
return fallbackHostileNpc
? { kind: 'npc', npcId: fallbackHostileNpc.id }
@@ -1429,7 +1444,7 @@ function buildEncounterDrivenResolution(
);
if (sceneNpc?.monsterPresetId && isHostileSceneNpc(sceneNpc)) {
return {
monsters: createSceneHostileNpcsFromEncounters(
monsters: createSceneHostileNpcsFromEncounters(
worldType,
[buildEncounterFromSceneNpc(sceneNpc, context.playerX)],
context.playerX,
@@ -1751,7 +1766,11 @@ async function repairStoryNarrativeLanguage(
).inBattle;
if (!needsStoryLanguageRepair(response)) {
return finalizeStoryNarrativeLanguage(response, context, responseBattleState);
return finalizeStoryNarrativeLanguage(
response,
context,
responseBattleState,
);
}
try {
@@ -1783,7 +1802,11 @@ async function repairStoryNarrativeLanguage(
);
} catch (error) {
console.warn('Failed to repair mixed-language story response:', error);
return finalizeStoryNarrativeLanguage(response, context, responseBattleState);
return finalizeStoryNarrativeLanguage(
response,
context,
responseBattleState,
);
}
}
@@ -1913,12 +1936,17 @@ async function requestCompletion(
export async function generateCustomWorldSceneImage({
profile,
landmark,
userPrompt,
prompt,
negativePrompt,
size = '1280*720',
referenceImageSrc,
}: CustomWorldSceneImageRequest): Promise<CustomWorldSceneImageResult> {
const resolvedPrompt =
prompt?.trim() || buildCustomWorldSceneImagePrompt(profile, landmark);
prompt?.trim() ||
buildCustomWorldSceneImagePrompt(profile, landmark, userPrompt, {
hasReferenceImage: Boolean(referenceImageSrc?.trim()),
});
const resolvedNegativePrompt =
negativePrompt?.trim() || DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT;
const controller = new AbortController();
@@ -1939,6 +1967,9 @@ export async function generateCustomWorldSceneImage({
prompt: resolvedPrompt,
negativePrompt: resolvedNegativePrompt,
size,
...(referenceImageSrc?.trim()
? { referenceImageSrc: referenceImageSrc.trim() }
: {}),
}),
signal: controller.signal,
});
@@ -2045,15 +2076,14 @@ export async function generateCustomWorldProfile(
reporter.begin('playable-outline', {
phaseDetail: '正在生成可扮演角色骨架。',
});
const playableNpcs =
(await generateCustomWorldRoleOutlineEntries({
framework: frameworkBase,
roleType: 'playable',
totalCount: generationTargets.playableCount,
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
reporter,
signal,
})) as CustomWorldGenerationFramework['playableNpcs'];
const playableNpcs = (await generateCustomWorldRoleOutlineEntries({
framework: frameworkBase,
roleType: 'playable',
totalCount: generationTargets.playableCount,
batchSize: CUSTOM_WORLD_FRAMEWORK_PLAYABLE_OUTLINE_BATCH_SIZE,
reporter,
signal,
})) as CustomWorldGenerationFramework['playableNpcs'];
reporter.complete('playable-outline', {
phaseDetail: `可扮演角色骨架已完成,共 ${playableNpcs.length} 名。`,
});
@@ -2065,15 +2095,14 @@ export async function generateCustomWorldProfile(
reporter.begin('story-outline', {
phaseDetail: '正在生成场景角色骨架。',
});
const storyNpcs =
(await generateCustomWorldRoleOutlineEntries({
framework: frameworkWithPlayable,
roleType: 'story',
totalCount: generationTargets.storyCount,
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
reporter,
signal,
})) as CustomWorldGenerationFramework['storyNpcs'];
const storyNpcs = (await generateCustomWorldRoleOutlineEntries({
framework: frameworkWithPlayable,
roleType: 'story',
totalCount: generationTargets.storyCount,
batchSize: CUSTOM_WORLD_FRAMEWORK_STORY_OUTLINE_BATCH_SIZE,
reporter,
signal,
})) as CustomWorldGenerationFramework['storyNpcs'];
reporter.complete('story-outline', {
phaseDetail: `场景角色骨架已完成,共 ${storyNpcs.length} 名。`,
});
@@ -2085,14 +2114,13 @@ export async function generateCustomWorldProfile(
reporter.begin('landmark-seed', {
phaseDetail: '正在生成场景骨架。',
});
const landmarkSeeds =
(await generateCustomWorldLandmarkSeedEntries({
framework: frameworkWithStory,
totalCount: generationTargets.landmarkCount,
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
reporter,
signal,
})) as CustomWorldGenerationFramework['landmarks'];
const landmarkSeeds = (await generateCustomWorldLandmarkSeedEntries({
framework: frameworkWithStory,
totalCount: generationTargets.landmarkCount,
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_SEED_BATCH_SIZE,
reporter,
signal,
})) as CustomWorldGenerationFramework['landmarks'];
reporter.complete('landmark-seed', {
phaseDetail: `场景骨架已完成,共 ${landmarkSeeds.length} 个地标。`,
});
@@ -2104,15 +2132,14 @@ export async function generateCustomWorldProfile(
reporter.begin('landmark-network', {
phaseDetail: '正在建立场景连接与场景角色分布。',
});
const landmarks =
(await expandCustomWorldLandmarkNetworkEntries({
framework: frameworkWithLandmarkSeeds,
storyNpcs,
baseEntries: landmarkSeeds,
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
reporter,
signal,
})) as CustomWorldGenerationFramework['landmarks'];
const landmarks = (await expandCustomWorldLandmarkNetworkEntries({
framework: frameworkWithLandmarkSeeds,
storyNpcs,
baseEntries: landmarkSeeds,
batchSize: CUSTOM_WORLD_FRAMEWORK_LANDMARK_NETWORK_BATCH_SIZE,
reporter,
signal,
})) as CustomWorldGenerationFramework['landmarks'];
reporter.complete('landmark-network', {
phaseDetail: `场景连接已完成,共整理 ${landmarks.length} 个地标网络。`,
});
@@ -2177,32 +2204,34 @@ export async function generateCustomWorldProfile(
reporter.begin('playable-profile', {
phaseDetail: '正在补充可扮演角色的叙事档案。',
});
const playableNpcsWithNarrativeProfile = await expandCustomWorldActorNarrativeProfiles({
framework,
roleType: 'playable',
baseEntries: profileSeed.playableNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
themePack,
storyGraph,
reporter,
signal,
});
const playableNpcsWithNarrativeProfile =
await expandCustomWorldActorNarrativeProfiles({
framework,
roleType: 'playable',
baseEntries: profileSeed.playableNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_PLAYABLE_BATCH_SIZE,
themePack,
storyGraph,
reporter,
signal,
});
reporter.complete('playable-profile', {
phaseDetail: `可扮演角色叙事档案已完成,共 ${playableNpcsWithNarrativeProfile.length} 名。`,
});
reporter.begin('story-profile', {
phaseDetail: '正在补充场景角色的叙事档案。',
});
const storyNpcsWithNarrativeProfile = await expandCustomWorldActorNarrativeProfiles({
framework,
roleType: 'story',
baseEntries: profileSeed.storyNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
themePack,
storyGraph,
reporter,
signal,
});
const storyNpcsWithNarrativeProfile =
await expandCustomWorldActorNarrativeProfiles({
framework,
roleType: 'story',
baseEntries: profileSeed.storyNpcs.map((npc) => ({ ...npc })),
batchSize: CUSTOM_WORLD_STORY_BATCH_SIZE,
themePack,
storyGraph,
reporter,
signal,
});
reporter.complete('story-profile', {
phaseDetail: `场景角色叙事档案已完成,共 ${storyNpcsWithNarrativeProfile.length} 名。`,
});
@@ -2236,9 +2265,11 @@ export async function generateCustomWorldProfile(
settingText: normalizedSettingText || profile.settingText,
creatorIntent,
anchorPack:
profile.anchorPack ?? buildCustomWorldAnchorPackFromIntent(creatorIntent),
profile.anchorPack ??
buildCustomWorldAnchorPackFromIntent(creatorIntent),
lockState:
profile.lockState ?? deriveCustomWorldLockStateFromIntent(creatorIntent),
profile.lockState ??
deriveCustomWorldLockStateFromIntent(creatorIntent),
generationMode,
generationStatus: generationTargets.generationStatus,
items: [],

View File

@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import { getCurrencyName } from '../data/economy';
import { WorldType } from '../types';
import { normalizeCustomWorldProfile } from './customWorld';
describe('normalizeCustomWorldProfile', () => {
@@ -224,4 +226,59 @@ describe('normalizeCustomWorldProfile', () => {
(connection) => connection.targetLandmarkId === profile.landmarks[0]?.id,
)).toBe(true);
});
it('compiles and preserves owned setting layers for runtime consumption', () => {
const profile = normalizeCustomWorldProfile(
{
name: '雾潮港',
summary: '被潮灾旧闻反复撕开的边港。',
tone: '潮湿、迷雾、压抑',
playerGoal: '查清港区失踪名单为何重复出现',
templateWorldType: WorldType.WUXIA,
ownedSettingLayers: {
ruleProfile: {
resourceLabels: {
hp: '潮命',
mp: '潮息',
maxHp: '潮命上限',
maxMp: '潮息上限',
damage: '潮势',
guard: '潮护',
range: '潮距',
cooldown: '回潮',
manaCost: '潮息消耗',
currency: '雾银',
},
economyProfile: {
initialCurrency: 188,
},
},
semanticAnchor: {
genreSignals: ['海岸悬疑'],
conflictForms: ['追查失踪'],
institutionTypes: ['港务'],
tabooTypes: ['回潮夜'],
carrierTypes: ['航图'],
forceSystemTypes: ['潮汐'],
atmosphereTags: ['迷雾'],
},
},
},
'玩家想要一个围绕迷雾港区与潮灾旧闻展开的世界。',
);
expect(profile.ownedSettingLayers?.ruleProfile.resourceLabels.currency).toBe(
'雾银',
);
expect(profile.ownedSettingLayers?.ruleProfile.economyProfile.initialCurrency).toBe(
188,
);
expect(getCurrencyName(WorldType.CUSTOM, profile)).toBe('雾银');
expect(
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType,
).toBe(WorldType.WUXIA);
expect(
profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length,
).toBeGreaterThan(0);
});
});

View File

@@ -11,6 +11,8 @@ import {
} from '../data/customWorldSceneGraph';
import {
ActorNarrativeProfile,
AnimationState,
CharacterAnimationConfig,
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
@@ -35,6 +37,7 @@ import {
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from './customWorldCreatorIntent';
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -73,6 +76,9 @@ const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3;
const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES =
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const CUSTOM_WORLD_ANIMATION_STATES = new Set<AnimationState>(
Object.values(AnimationState),
);
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
@@ -135,6 +141,12 @@ function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toFiniteInteger(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: undefined;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? (value.filter((item) => item && typeof item === 'object') as Array<
@@ -165,6 +177,59 @@ function normalizeInitialAffinity(value: unknown, fallback: number) {
: fallback;
}
function normalizeGeneratedAnimationConfig(
value: unknown,
): CharacterAnimationConfig | null {
if (!value || typeof value !== 'object') {
return null;
}
const item = value as Record<string, unknown>;
const folder = toText(item.folder);
const prefix = toText(item.prefix);
const frames = Math.max(1, toFiniteInteger(item.frames) ?? 0);
if (!folder || !prefix || frames <= 0) {
return null;
}
const startFrame = toFiniteInteger(item.startFrame);
const extension = toText(item.extension);
const file = toText(item.file);
const basePath = toText(item.basePath);
return {
folder,
prefix,
frames,
...(startFrame ? { startFrame: Math.max(1, startFrame) } : {}),
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
};
}
function normalizeGeneratedAnimationMap(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const entries = Object.entries(value).flatMap(([key, rawConfig]) => {
if (!CUSTOM_WORLD_ANIMATION_STATES.has(key as AnimationState)) {
return [];
}
const config = normalizeGeneratedAnimationConfig(rawConfig);
return config ? [[key as AnimationState, config] as const] : [];
});
return entries.length > 0
? (Object.fromEntries(entries) as Partial<
Record<AnimationState, CharacterAnimationConfig>
>)
: undefined;
}
function normalizeWorldType(value: unknown, sourceText: string) {
const worldType = toText(value).toUpperCase();
if (worldType === WorldType.WUXIA || worldType === WorldType.XIANXIA) {
@@ -184,9 +249,7 @@ function normalizeRarity(
function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
const category = toText(value);
if (
(
CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]
).includes(category)
(CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category)
) {
return category === '专属物' ? '专属物品' : category;
}
@@ -289,7 +352,10 @@ function buildFallbackBackstoryReveal(
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ??
`背景片段${index + 1}`,
affinityRequired,
teaser: truncateText(fallbackContents[index] ?? normalizedBackstory, 22),
teaser: truncateText(
fallbackContents[index] ?? normalizedBackstory,
22,
),
content: truncateText(
fallbackContents[index] ?? normalizedBackstory,
72,
@@ -335,7 +401,8 @@ function normalizeBackstoryReveal(
(rawChapter && toText(rawChapter.title)) ||
fallbackChapter?.title ||
`背景片段${index + 1}`,
affinityRequired: fallbackChapter?.affinityRequired ?? defaultAffinity,
affinityRequired:
fallbackChapter?.affinityRequired ?? defaultAffinity,
teaser:
(rawChapter && toText(rawChapter.teaser)) ||
fallbackChapter?.teaser ||
@@ -358,7 +425,8 @@ function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
const skillNameSeed = source.title || source.role || source.name || '角色';
const skillSummarySeed =
source.combatStyle || source.description || `${source.name}善于把握局势。`;
const motivationSeed = source.motivation || source.personality || source.backstory;
const motivationSeed =
source.motivation || source.personality || source.backstory;
return [
{
@@ -447,7 +515,9 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
quantity: 1,
rarity: 'rare',
description: truncateText(
source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`,
source.backstory ||
source.motivation ||
`${source.name}不愿随意交出的信物。`,
36,
),
tags: normalizeTags(
@@ -540,7 +610,7 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
templateWorldType,
});
return {
const baseProfile = {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
settingText: settingText.trim(),
name,
@@ -573,6 +643,14 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
lockState: normalizeCustomWorldLockState(null),
generationMode: 'full',
generationStatus: 'complete',
} satisfies CustomWorldProfile;
return {
...baseProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
null,
baseProfile,
),
};
}
@@ -715,7 +793,8 @@ function normalizeRoleProfile(
},
) {
const name = toText(item.name);
const title = toText(item.title) || toText(item.role) || options.titleFallback;
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
@@ -741,9 +820,19 @@ function normalizeRoleProfile(
return {
...normalizedRole,
backstoryReveal: normalizeBackstoryReveal(item.backstoryReveal, normalizedRole),
backstoryReveal: normalizeBackstoryReveal(
item.backstoryReveal,
normalizedRole,
),
skills: normalizeRoleSkillList(item.skills, normalizedRole),
initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole),
initialItems: normalizeRoleInitialItemList(
item.initialItems,
normalizedRole,
),
imageSrc: toText(item.imageSrc) || undefined,
generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(item.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(item.animationMap),
narrativeProfile:
item.narrativeProfile && typeof item.narrativeProfile === 'object'
? (item.narrativeProfile as ActorNarrativeProfile)
@@ -767,19 +856,20 @@ function normalizePlayableNpcList(value: unknown) {
function normalizeStoryNpcList(value: unknown) {
return toRecordArray(value)
.map((item, index) =>
({
...normalizeRoleProfile(item, index, {
idPrefix: 'story-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
}),
imageSrc: toText(item.imageSrc) || undefined,
visual:
item.visual && typeof item.visual === 'object'
? (item.visual as CustomWorldNpc['visual'])
: undefined,
}) satisfies CustomWorldNpc,
.map(
(item, index) =>
({
...normalizeRoleProfile(item, index, {
idPrefix: 'story-npc',
titleFallback: '未定称号',
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
}),
imageSrc: toText(item.imageSrc) || undefined,
visual:
item.visual && typeof item.visual === 'object'
? (item.visual as CustomWorldNpc['visual'])
: undefined,
}) satisfies CustomWorldNpc,
)
.filter((entry) => entry.name);
}
@@ -812,7 +902,8 @@ function normalizeRoleOutlineList(
const normalized = toRecordArray(value)
.map((item) => {
const name = toText(item.name);
const title = toText(item.title) || toText(item.role) || options.titleFallback;
const title =
toText(item.title) || toText(item.role) || options.titleFallback;
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
@@ -846,12 +937,19 @@ function normalizeCampOutline(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
>,
): CustomWorldGenerationCampOutline {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
name: toText(item.name) || fallback.name,
@@ -867,7 +965,8 @@ function normalizeLandmarkOutlineList(value: unknown) {
return {
name,
description:
toText(item.description) || truncateText(`${name}暗藏新的局势变化。`, 40),
toText(item.description) ||
truncateText(`${name}暗藏新的局势变化。`, 40),
dangerLevel: toText(item.dangerLevel) || 'medium',
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
@@ -956,12 +1055,19 @@ function normalizeCampScene(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
>,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {};
return {
name: toText(item.name) || fallback.name,
@@ -1019,7 +1125,7 @@ export function normalizeCustomWorldProfile(
templateWorldType,
});
return {
const normalizedProfile = {
id:
toText(item.id) ||
`custom-world-${Date.now().toString(36)}-${slugify(name)}`,
@@ -1070,9 +1176,18 @@ export function normalizeCustomWorldProfile(
? item.generationMode
: fallback.generationMode,
generationStatus:
item.generationStatus === 'key_only' || item.generationStatus === 'complete'
item.generationStatus === 'key_only' ||
item.generationStatus === 'complete'
? item.generationStatus
: fallback.generationStatus,
} satisfies CustomWorldProfile;
return {
...normalizedProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
item.ownedSettingLayers,
normalizedProfile,
),
};
}
@@ -1148,7 +1263,7 @@ function buildRoleOutlinePromptLines(
.map((role) => {
const appearanceText =
options.roleType === 'story'
? appearanceLookup.get(role.name)?.join('、') ?? '未指定'
? (appearanceLookup.get(role.name)?.join('、') ?? '未指定')
: '';
return [
`- ${role.name} / ${role.title}`,
@@ -1636,9 +1751,10 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: {
storyNpcs: CustomWorldGenerationRoleOutline[];
}) {
const { framework, landmarkBatch, storyNpcs } = params;
const relativePositionValues = CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => option.value,
).join('|');
const relativePositionValues =
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => option.value,
).join('|');
const allLandmarkNames = framework.landmarks.map((landmark) => landmark.name);
const storyNpcNames = storyNpcs.map((npc) => npc.name);
@@ -1779,8 +1895,8 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'{',
` "${key}": [`,
' {',
' "name": "角色名称",',
' "backstoryReveal": {',
' "name": "角色名称",',
' "backstoryReveal": {',
' "publicSummary": "公开可见的背景摘要",',
' "chapters": [',
` { "id": "surface", "title": "表层来意", "affinityRequired": ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}, "teaser": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感可见的提示", "content": "${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES[0]}好感时解锁的背景内容", "contextSnippet": "可供后续剧情引用的摘要" },`,
@@ -1862,9 +1978,10 @@ export function buildCustomWorldRoleBatchJsonRepairPrompt(params: {
}
export function buildCustomWorldGenerationPrompt(settingText: string) {
const relativePositionValues = CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => option.value,
).join('|');
const relativePositionValues =
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map(
(option) => option.value,
).join('|');
return [
'请根据下面的玩家设定创建一份自定义世界档案。',
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
@@ -2005,21 +2122,28 @@ export function buildCustomWorldReferenceText(
const landmarkById = new Map(
profile.landmarks.map((landmark) => [landmark.id, landmark]),
);
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
const themePack =
profile.themePack ?? buildThemePackFromWorldProfile(profile);
const storyGraph =
profile.storyGraph ?? buildFallbackWorldStoryGraph(profile, themePack);
const activeThreadIds =
options.activeThreadIds?.filter(Boolean)?.length
? options.activeThreadIds.filter(Boolean)
: storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
const activeThreads = [...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
const activeThreadIds = options.activeThreadIds?.filter(Boolean)?.length
? options.activeThreadIds.filter(Boolean)
: storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
const activeThreads = [
...storyGraph.visibleThreads,
...storyGraph.hiddenThreads,
]
.filter((thread) => activeThreadIds.includes(thread.id))
.slice(0, 3);
const highlightNpcNames = new Set(
(options.highlightNpcNames ?? []).map((name) => name.trim()).filter(Boolean),
(options.highlightNpcNames ?? [])
.map((name) => name.trim())
.filter(Boolean),
);
const describeNpcReference = (
npc: CustomWorldProfile['storyNpcs'][number] | CustomWorldProfile['playableNpcs'][number],
npc:
| CustomWorldProfile['storyNpcs'][number]
| CustomWorldProfile['playableNpcs'][number],
) => {
const narrativeProfile = normalizeActorNarrativeProfile(
npc.narrativeProfile,
@@ -2028,9 +2152,11 @@ export function buildCustomWorldReferenceText(
return `- ${npc.name} / ${npc.title}:身份 ${npc.role};公开面:${narrativeProfile.publicMask};表层线:${narrativeProfile.visibleLine};当前压力:${narrativeProfile.immediatePressure};相关线程:${
narrativeProfile.relatedThreadIds
.map((threadId) =>
[...storyGraph.visibleThreads, ...storyGraph.hiddenThreads]
.find((thread) => thread.id === threadId)?.title ?? threadId,
.map(
(threadId) =>
[...storyGraph.visibleThreads, ...storyGraph.hiddenThreads].find(
(thread) => thread.id === threadId,
)?.title ?? threadId,
)
.join('、') || '暂无'
};反应钩子:${narrativeProfile.reactionHooks.join('、') || '暂无'}`;
@@ -2058,7 +2184,9 @@ export function buildCustomWorldReferenceText(
};连接:${
landmark.connections
.map((connection) => {
const targetLandmark = landmarkById.get(connection.targetLandmarkId);
const targetLandmark = landmarkById.get(
connection.targetLandmarkId,
);
if (!targetLandmark) {
return '';
}
@@ -2110,7 +2238,9 @@ export function validateGeneratedCustomWorldProfile(
}
const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id));
const validLandmarkIds = new Set(profile.landmarks.map((landmark) => landmark.id));
const validLandmarkIds = new Set(
profile.landmarks.map((landmark) => landmark.id),
);
profile.landmarks.forEach((landmark) => {
const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)];
@@ -2185,6 +2315,10 @@ export function buildCustomWorldSceneImagePrompt(
'name' | 'subtitle' | 'summary' | 'tone' | 'playerGoal' | 'settingText'
>,
landmark: Pick<CustomWorldLandmark, 'name' | 'description' | 'dangerLevel'>,
userPrompt = '',
options: {
hasReferenceImage?: boolean;
} = {},
) {
const worldName = clampSceneImageText(profile.name, 18) || '未命名世界';
const worldSubtitle = clampSceneImageText(profile.subtitle, 18);
@@ -2194,10 +2328,18 @@ export function buildCustomWorldSceneImagePrompt(
const worldSetting = clampSceneImageText(profile.settingText, 72);
const landmarkName = clampSceneImageText(landmark.name, 18) || '未命名场景';
const landmarkDescription = clampSceneImageText(landmark.description, 96);
const requestedVisual = clampSceneImageText(userPrompt, 120);
const dangerMood = describeDangerLevel(landmark.dangerLevel);
return [
'横版幻想 RPG 场景背景概念图,适合作为 2D 游戏战斗与探索背景,环境主体清晰,空间层次明确,电影感光影,细节丰富。',
'横版 16:9 2D RPG 生成高完成度像素风场景背景,适合作为剧情探索与战斗底图。',
'画面构图必须严格按上下 1:1 分区:上半部分严格控制在整张图的 1/2 高度内,只描绘场景远景与中远景轮廓,不要让背景内容向下侵占超过半屏。',
'下半部分严格占据整张图的 1/2 高度,用于玩家角色站位与展示,必须是模拟 3D 游戏视角的地面近景,有明确的透视延伸和近大远小关系,不是平铺的 2D 侧视地面。',
'下半部分的内容必须是明确可站立的地面本体,例如道路、石板、平台、广场、甲板、沙地或草地,要有连续、稳定、可落脚的站位逻辑,不能只是装饰性前景、坑洞、障碍堆、栏杆带或不可通行的景物。',
'下半部分地面近景要保持相对简洁、低细节、轮廓清楚、便于角色站立,不要堆满道具、植被、碎石、栏杆或复杂装饰。',
options.hasReferenceImage
? '已提供一张自定义参考图,可适度参考其构图、镜头或氛围,但仍以本次场景需求为准,不要生硬照搬。'
: '',
`世界:${worldName}${worldSubtitle ? `${worldSubtitle}` : ''}`,
worldSetting ? `玩家设定:${worldSetting}` : '',
worldSummary ? `世界概述:${worldSummary}` : '',
@@ -2205,8 +2347,9 @@ export function buildCustomWorldSceneImagePrompt(
worldGoal ? `玩家目标关联:${worldGoal}` : '',
`场景名称:${landmarkName}`,
landmarkDescription ? `场景描述:${landmarkDescription}` : '',
requestedVisual ? `本次想要生成的画面内容:${requestedVisual}` : '',
`${dangerMood}`,
'不要出现 UI、字幕、文字、水印logo人物仅可作为很小的远景剪影画面重点放在建筑、地貌、光线与氛围。',
'不要出现 UI、字幕、文字、水印logo 或装饰边框,人物仅可作为很小的远景剪影,画面重点放在场景本身,不要遮挡下半部分的角色展示区域。',
]
.filter(Boolean)
.join('');

View File

@@ -89,5 +89,12 @@ describe('buildExpandedCustomWorldProfile', () => {
expect(profile.storyGraph?.hiddenThreads.length).toBeGreaterThan(0);
expect(profile.storyNpcs[0]?.narrativeProfile?.immediatePressure).toBeTruthy();
expect(profile.playableNpcs[0]?.narrativeProfile?.relatedThreadIds.length).toBeGreaterThan(0);
expect(profile.ownedSettingLayers?.expressionProfile.themePack.displayName).toBe(
profile.themePack?.displayName,
);
expect(profile.ownedSettingLayers?.referenceProfile.roleArchetypes.length).toBeGreaterThan(0);
expect(profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType).toBe(
'WUXIA',
);
});
});

View File

@@ -7,6 +7,7 @@ import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags';
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import { CustomWorldProfile, WorldType } from '../types';
import { normalizeCustomWorldProfile } from './customWorld';
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -272,11 +273,19 @@ export function buildExpandedCustomWorldProfile(
});
registerScenarioPack(compiledPacks.scenarioPack);
return {
const finalizedProfile = {
...profileWithNarrative,
knowledgeFacts,
threadContracts,
scenarioPackId: profile.scenarioPackId ?? compiledPacks.scenarioPack.id,
campaignPackId: profile.campaignPackId ?? compiledPacks.campaignPack.id,
} satisfies CustomWorldProfile;
return {
...finalizedProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
finalizedProfile.ownedSettingLayers,
finalizedProfile,
),
};
}

View File

@@ -0,0 +1,969 @@
import { coerceWorldAttributeSchema } from '../data/attributeValidation';
import {
type CreatureArchetypeProfile,
type CustomWorldCompatibilityProfile,
type CustomWorldExpressionProfile,
type CustomWorldOwnedSettingLayers,
type CustomWorldProfile,
type CustomWorldReferenceProfile,
type CustomWorldRuleProfile,
type CustomWorldSemanticAnchor,
type RoleArchetypeProfile,
type SceneArchetypeBucket,
WorldType,
} from '../types';
import { type CustomWorldThemeMode, detectCustomWorldThemeMode } from './customWorldTheme';
import {
buildThemePackFromWorldProfile,
normalizeThemePack,
} from './storyEngine/themePack';
const OWNED_SETTING_LAYER_MIGRATION_VERSION =
'2026-04-08-owned-setting-layers-v1';
const RESOURCE_LABEL_PRESETS: Record<
CustomWorldThemeMode,
CustomWorldRuleProfile['resourceLabels']
> = {
mythic: {
hp: '生命',
mp: '心流',
maxHp: '生命上限',
maxMp: '心流上限',
damage: '势能',
guard: '防护',
range: '距离',
cooldown: '回整',
manaCost: '心流消耗',
currency: '旅券',
},
martial: {
hp: '气血',
mp: '内力',
maxHp: '气血上限',
maxMp: '内力上限',
damage: '招式',
guard: '防御',
range: '招距',
cooldown: '调息',
manaCost: '内力消耗',
currency: '铜钱',
},
arcane: {
hp: '元命',
mp: '灵韵',
maxHp: '元命上限',
maxMp: '灵韵上限',
damage: '术法',
guard: '护盾',
range: '术距',
cooldown: '回息',
manaCost: '灵韵消耗',
currency: '灵石',
},
machina: {
hp: '耐久',
mp: '能量',
maxHp: '耐久上限',
maxMp: '能量上限',
damage: '火力',
guard: '护盾',
range: '射程',
cooldown: '充能',
manaCost: '能量消耗',
currency: '配给券',
},
tide: {
hp: '潮命',
mp: '潮息',
maxHp: '潮命上限',
maxMp: '潮息上限',
damage: '潮势',
guard: '潮护',
range: '潮距',
cooldown: '回潮',
manaCost: '潮息消耗',
currency: '潮银',
},
rift: {
hp: '界命',
mp: '裂能',
maxHp: '界命上限',
maxMp: '裂能上限',
damage: '界势',
guard: '稳界',
range: '界距',
cooldown: '复界',
manaCost: '裂能消耗',
currency: '边贸券',
},
};
const INITIAL_CURRENCY_PRESETS: Record<CustomWorldThemeMode, number> = {
mythic: 160,
martial: 160,
arcane: 140,
machina: 160,
tide: 160,
rift: 160,
};
const SEMANTIC_ANCHOR_PRESETS: Record<
CustomWorldThemeMode,
Omit<CustomWorldSemanticAnchor, 'atmosphereTags'>
> = {
mythic: {
genreSignals: ['跨题材冒险', '未知旅境'],
conflictForms: ['追查', '护送', '回收', '失踪追索'],
institutionTypes: ['据点', '旅团', '档案室', '归舍'],
tabooTypes: ['越界', '封存', '失约', '旧痕'],
carrierTypes: ['信物', '残页', '样本', '旧钥'],
forceSystemTypes: ['回响', '誓约', '遗物', '余波'],
},
martial: {
genreSignals: ['江湖纷争', '旧案追索'],
conflictForms: ['寻仇', '围剿', '护送', '失踪追查'],
institutionTypes: ['门派', '镖局', '巡司', '商号'],
tabooTypes: ['旧案', '断誓', '禁脉', '失契'],
carrierTypes: ['遗兵', '令牌', '残卷', '旧佩'],
forceSystemTypes: ['心法', '招式', '经脉', '誓约'],
},
arcane: {
genreSignals: ['灵异修行', '秘境因果'],
conflictForms: ['夺脉', '封印失衡', '宗门旧案', '秘境争夺'],
institutionTypes: ['宗门', '法坛', '巡守司', '灵舟会'],
tabooTypes: ['封印', '禁术', '残魂', '逆脉'],
carrierTypes: ['法器', '灵符', '玉简', '阵核'],
forceSystemTypes: ['灵脉', '术式', '契约', '神识'],
},
machina: {
genreSignals: ['工业前线', '失控科技'],
conflictForms: ['封锁', '回收', '追查事故', '前线失守'],
institutionTypes: ['财团', '工坊', '舰队', '调查局'],
tabooTypes: ['过载', '失控协议', '封存日志', '污染区'],
carrierTypes: ['芯片', '驱动核', '记录模组', '封存匣'],
forceSystemTypes: ['科技', '协议', '驱动', '能量网'],
},
tide: {
genreSignals: ['海岸悬疑', '潮灾余波'],
conflictForms: ['封港', '海路争夺', '追查失踪', '护送穿渡'],
institutionTypes: ['港务', '巡海司', '渡船会', '潮站'],
tabooTypes: ['沉船', '禁海区', '回潮夜', '失契'],
carrierTypes: ['航图', '潮印', '信标', '封潮匣'],
forceSystemTypes: ['潮汐', '雾潮', '海誓', '异流'],
},
rift: {
genreSignals: ['裂界边境', '战线余烬'],
conflictForms: ['守线', '撤离', '回收异常', '追查失线'],
institutionTypes: ['前哨', '巡边队', '断层站', '回收组'],
tabooTypes: ['断层失守', '界外污染', '封桥令', '旧撤离线'],
carrierTypes: ['界核', '锚印', '样本', '回响记录'],
forceSystemTypes: ['裂界', '界压', '污染', '锚定'],
},
};
const CREATURE_ARCHETYPE_PRESETS: Record<
CustomWorldThemeMode,
Array<Omit<CreatureArchetypeProfile, 'id'>>
> = {
mythic: [
{
label: '潜伏袭击者',
threatStyle: '借地形潜伏后突然贴身施压。',
keywords: ['潜伏', '伏击', '前探阻断'],
},
{
label: '群居骚扰者',
threatStyle: '依靠数量与机动性反复撕扯阵线。',
keywords: ['群居', '扰动', '消耗'],
},
{
label: '回响追猎者',
threatStyle: '会追着异常痕迹与关键目标持续压迫。',
keywords: ['回响', '追索', '持续压迫'],
},
],
martial: [
{
label: '潜伏袭击者',
threatStyle: '先藏身再借速度打出首轮杀招。',
keywords: ['潜袭', '伏击', '贴身爆发'],
},
{
label: '重甲承压者',
threatStyle: '站住正面、顶着伤害强行换血。',
keywords: ['承压', '守线', '正面对撞'],
},
{
label: '远程威胁者',
threatStyle: '依靠暗器、弓弩或投掷不断压制走位。',
keywords: ['远程', '压制', '封走位'],
},
],
arcane: [
{
label: '灵体回响体',
threatStyle: '借余波与残识干扰节奏并持续追逼。',
keywords: ['灵体', '回响', '术式残留'],
},
{
label: '异化污染体',
threatStyle: '被灵潮扭曲后具备高压近身威胁。',
keywords: ['异化', '污染', '近身撕咬'],
},
{
label: '机关守卫体',
threatStyle: '围绕阵核或封印节点进行固守打击。',
keywords: ['机关', '守卫', '节点压制'],
},
],
machina: [
{
label: '远程威胁者',
threatStyle: '依靠火力、脉冲或投射装置封锁空间。',
keywords: ['火力', '远程', '封锁'],
},
{
label: '重装阻断者',
threatStyle: '借重甲和装置正面堵截推进线路。',
keywords: ['重装', '阻断', '压线'],
},
{
label: '失控追击者',
threatStyle: '高频位移并持续追杀被标记目标。',
keywords: ['失控', '追击', '高机动'],
},
],
tide: [
{
label: '群居骚扰者',
threatStyle: '借潮湿地形和数量优势消耗行进队伍。',
keywords: ['群居', '潮湿', '消耗'],
},
{
label: '潜伏袭击者',
threatStyle: '利用雾潮与死角打出突袭。',
keywords: ['迷雾', '潜伏', '突袭'],
},
{
label: '异化污染体',
threatStyle: '会沿潮灾痕迹持续扩散压迫。',
keywords: ['潮灾', '异化', '扩散'],
},
],
rift: [
{
label: '异化污染体',
threatStyle: '长期暴露在裂界环境后具备高压侵蚀性。',
keywords: ['污染', '侵蚀', '裂界'],
},
{
label: '远程威胁者',
threatStyle: '依靠界压残波或碎片投射逼迫走位。',
keywords: ['界压', '残波', '远程'],
},
{
label: '机关守卫体',
threatStyle: '围绕前哨节点和封桥设施持续守线。',
keywords: ['前哨', '守线', '节点'],
},
],
};
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toStringArray(value: unknown, max = 8) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => toText(item))
.filter(Boolean)
.slice(0, max);
}
function dedupeStrings(
values: Array<string | null | undefined>,
max = 8,
) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, max);
}
function splitToneTags(tone: string) {
return dedupeStrings(tone.split(/[,/\s]+/u), 6);
}
function inferInstitutionType(labels: string[]) {
return dedupeStrings(
labels.map((label) => {
if (/[]/u.test(label)) return '';
if (/[]/u.test(label)) return '';
if (/[]/u.test(label)) return '';
if (/[]/u.test(label)) return '';
if (/[]/u.test(label)) return '';
if (/[]/u.test(label)) return '';
if (/[]/u.test(label)) return '';
if (/[]/u.test(label)) return '';
return label.length <= 8 ? label : '';
}),
4,
);
}
function inferForceSystemTypes(profile: CustomWorldProfile) {
const source = `${profile.settingText} ${profile.summary} ${profile.tone} ${profile.playerGoal}`;
const detected = [
/|||||/u.test(source) ? '' : null,
/|||/u.test(source) ? '' : null,
/||||/u.test(source) ? '' : null,
/||||||/u.test(source) ? '' : null,
/||/u.test(source) ? '' : null,
/||/u.test(source) ? '' : null,
];
return dedupeStrings(detected, 4);
}
function inferRoleArchetypeLabel(
role: Pick<CustomWorldProfile['playableNpcs'][number], 'combatStyle' | 'role' | 'tags'>,
) {
const source = `${role.role} ${role.combatStyle} ${role.tags.join(' ')}`;
if (/[|||||]/u.test(source)) {
return '远程压制型';
}
if (/[|||线||]/u.test(source)) {
return '续航承压型';
}
if (/[|||||]/u.test(source)) {
return '潜行爆发型';
}
if (/[|||||||]/u.test(source)) {
return '控场解构型';
}
return '正面推进型';
}
function inferSceneBucketLabel(
landmark: Pick<CustomWorldProfile['landmarks'][number], 'name' | 'description' | 'dangerLevel'>,
) {
const source = `${landmark.name} ${landmark.description}`;
if (/[||||]/u.test(source)) return '';
if (/[||||]/u.test(source)) return '';
if (/[殿|||]/u.test(source)) return '殿';
if (/[||||]/u.test(source)) return '';
if (/[||||]/u.test(source)) return '';
if (/[||||]/u.test(source)) return '';
if (/[||||]/u.test(source)) return '';
return landmark.dangerLevel === 'high' || landmark.dangerLevel === 'extreme'
? '高压交汇区'
: '叙事缓冲区';
}
function buildRoleArchetypes(profile: CustomWorldProfile) {
return profile.playableNpcs.slice(0, 6).map((role, index) => ({
id: `role-archetype-${index + 1}`,
label: inferRoleArchetypeLabel(role),
combatFocus: role.combatStyle.trim() || role.role.trim() || '围绕核心职责推进战局。',
narrativeFunction:
role.role.trim() || role.description.trim() || '在主线推进中提供关键响应。',
sourceRoleIds: [role.id],
sourceTemplateCharacterIds: role.templateCharacterId
? [role.templateCharacterId]
: [],
tags: dedupeStrings(role.tags, 5),
})) satisfies RoleArchetypeProfile[];
}
function buildSceneBuckets(profile: CustomWorldProfile) {
return profile.landmarks.slice(0, 8).map((landmark, index) => ({
id: `scene-bucket-${index + 1}`,
label: inferSceneBucketLabel(landmark),
moodTags: dedupeStrings(
[landmark.dangerLevel, ...splitToneTags(profile.tone)],
4,
),
keywords: dedupeStrings([landmark.name, landmark.description], 4),
referenceLandmarkIds: [landmark.id],
})) satisfies SceneArchetypeBucket[];
}
function buildCreatureArchetypes(mode: CustomWorldThemeMode) {
return CREATURE_ARCHETYPE_PRESETS[mode].map((creature, index) => ({
id: `creature-archetype-${index + 1}`,
...creature,
})) satisfies CreatureArchetypeProfile[];
}
function buildThemePackSeed(profile: CustomWorldProfile) {
return buildThemePackFromWorldProfile({
settingText: profile.settingText,
summary: profile.summary,
tone: profile.tone,
playerGoal: profile.playerGoal,
templateWorldType: profile.templateWorldType,
majorFactions: profile.majorFactions,
coreConflicts: profile.coreConflicts,
ownedSettingLayers: null,
});
}
function compileSemanticAnchor(
profile: CustomWorldProfile,
mode: CustomWorldThemeMode,
) {
const preset = SEMANTIC_ANCHOR_PRESETS[mode];
const creatorIntent = profile.creatorIntent;
const institutionHints = inferInstitutionType([
...profile.majorFactions,
...(creatorIntent?.keyFactions.map((seed) => seed.name) ?? []),
]);
const forceSystemTypes = inferForceSystemTypes(profile);
return {
genreSignals: dedupeStrings(
[...(creatorIntent?.themeKeywords ?? []), ...preset.genreSignals],
6,
),
conflictForms: dedupeStrings(
[...profile.coreConflicts, ...preset.conflictForms],
6,
),
institutionTypes: dedupeStrings(
[...institutionHints, ...preset.institutionTypes],
6,
),
tabooTypes: dedupeStrings(
[...profile.coreConflicts, ...preset.tabooTypes],
6,
),
carrierTypes: dedupeStrings(
[...(creatorIntent?.iconicElements ?? []), ...preset.carrierTypes],
6,
),
forceSystemTypes: dedupeStrings(
[...forceSystemTypes, ...preset.forceSystemTypes],
6,
),
atmosphereTags: dedupeStrings(
[...splitToneTags(profile.tone), ...preset.genreSignals],
6,
),
} satisfies CustomWorldSemanticAnchor;
}
function compileRuleProfile(
profile: CustomWorldProfile,
mode: CustomWorldThemeMode,
) {
return {
attributeSchema: profile.attributeSchema,
resourceLabels: RESOURCE_LABEL_PRESETS[mode],
economyProfile: {
initialCurrency: INITIAL_CURRENCY_PRESETS[mode],
},
} satisfies CustomWorldRuleProfile;
}
function compileExpressionProfile(
profile: CustomWorldProfile,
semanticAnchor: CustomWorldSemanticAnchor,
) {
const fallbackThemePack = buildThemePackSeed(profile);
const themePack = normalizeThemePack(profile.themePack, fallbackThemePack);
return {
themePack,
presentationTone: dedupeStrings(
[profile.tone, ...semanticAnchor.atmosphereTags, ...themePack.toneRange],
8,
),
namingDirectives: dedupeStrings(themePack.namingPatterns, 6),
clueDirectives: dedupeStrings(themePack.clueForms, 6),
revealDirectives: dedupeStrings(themePack.revealStyles, 6),
} satisfies CustomWorldExpressionProfile;
}
function compileReferenceProfile(
profile: CustomWorldProfile,
mode: CustomWorldThemeMode,
) {
return {
roleArchetypes: buildRoleArchetypes(profile),
sceneBuckets: buildSceneBuckets(profile),
creatureArchetypes: buildCreatureArchetypes(mode),
} satisfies CustomWorldReferenceProfile;
}
function compileCompatibilityProfile(profile: CustomWorldProfile) {
return {
legacyTemplateWorldType: profile.templateWorldType ?? WorldType.WUXIA,
migrationVersion: OWNED_SETTING_LAYER_MIGRATION_VERSION,
} satisfies CustomWorldCompatibilityProfile;
}
function normalizeRoleArchetypes(
value: unknown,
fallback: RoleArchetypeProfile[],
) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const item = entry as Record<string, unknown>;
const label = toText(item.label);
if (!label) {
return null;
}
return {
id: toText(item.id) || `role-archetype-${index + 1}`,
label,
combatFocus:
toText(item.combatFocus) ||
fallback[index]?.combatFocus ||
'围绕核心职责推进战局。',
narrativeFunction:
toText(item.narrativeFunction) ||
fallback[index]?.narrativeFunction ||
'在主线推进中提供关键响应。',
sourceRoleIds: toStringArray(item.sourceRoleIds, 4),
sourceTemplateCharacterIds: toStringArray(
item.sourceTemplateCharacterIds,
4,
),
tags: dedupeStrings(toStringArray(item.tags, 5), 5),
} satisfies RoleArchetypeProfile;
})
.filter((entry): entry is RoleArchetypeProfile => Boolean(entry));
return normalized.length > 0 ? normalized : fallback;
}
function normalizeSceneBuckets(
value: unknown,
fallback: SceneArchetypeBucket[],
) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const item = entry as Record<string, unknown>;
const label = toText(item.label);
if (!label) {
return null;
}
return {
id: toText(item.id) || `scene-bucket-${index + 1}`,
label,
moodTags: dedupeStrings(toStringArray(item.moodTags, 4), 4),
keywords: dedupeStrings(toStringArray(item.keywords, 4), 4),
referenceLandmarkIds: toStringArray(item.referenceLandmarkIds, 4),
} satisfies SceneArchetypeBucket;
})
.filter((entry): entry is SceneArchetypeBucket => Boolean(entry));
return normalized.length > 0 ? normalized : fallback;
}
function normalizeCreatureArchetypes(
value: unknown,
fallback: CreatureArchetypeProfile[],
) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const item = entry as Record<string, unknown>;
const label = toText(item.label);
if (!label) {
return null;
}
return {
id: toText(item.id) || `creature-archetype-${index + 1}`,
label,
threatStyle:
toText(item.threatStyle) ||
fallback[index]?.threatStyle ||
'围绕核心威胁方式持续施压。',
keywords: dedupeStrings(toStringArray(item.keywords, 4), 4),
} satisfies CreatureArchetypeProfile;
})
.filter((entry): entry is CreatureArchetypeProfile => Boolean(entry));
return normalized.length > 0 ? normalized : fallback;
}
export function compileOwnedSettingLayersFromLegacyTemplate(
profile: CustomWorldProfile,
) {
const mode = detectCustomWorldThemeMode({
settingText: profile.settingText,
summary: profile.summary,
tone: profile.tone,
playerGoal: profile.playerGoal,
templateWorldType: profile.templateWorldType,
ownedSettingLayers: null,
});
const semanticAnchor = compileSemanticAnchor(profile, mode);
return {
semanticAnchor,
ruleProfile: compileRuleProfile(profile, mode),
expressionProfile: compileExpressionProfile(profile, semanticAnchor),
referenceProfile: compileReferenceProfile(profile, mode),
compatibilityProfile: compileCompatibilityProfile(profile),
} satisfies CustomWorldOwnedSettingLayers;
}
export function normalizeCustomWorldOwnedSettingLayers(
value: unknown,
profile: CustomWorldProfile,
) {
const fallback = compileOwnedSettingLayersFromLegacyTemplate(profile);
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Record<string, unknown>;
const semanticAnchorItem =
item.semanticAnchor && typeof item.semanticAnchor === 'object'
? (item.semanticAnchor as Record<string, unknown>)
: {};
const ruleProfileItem =
item.ruleProfile && typeof item.ruleProfile === 'object'
? (item.ruleProfile as Record<string, unknown>)
: {};
const resourceLabelsItem =
ruleProfileItem.resourceLabels &&
typeof ruleProfileItem.resourceLabels === 'object'
? (ruleProfileItem.resourceLabels as Record<string, unknown>)
: {};
const expressionProfileItem =
item.expressionProfile && typeof item.expressionProfile === 'object'
? (item.expressionProfile as Record<string, unknown>)
: {};
const referenceProfileItem =
item.referenceProfile && typeof item.referenceProfile === 'object'
? (item.referenceProfile as Record<string, unknown>)
: {};
const compatibilityProfileItem =
item.compatibilityProfile && typeof item.compatibilityProfile === 'object'
? (item.compatibilityProfile as Record<string, unknown>)
: {};
return {
semanticAnchor: {
genreSignals: dedupeStrings(
toStringArray(
semanticAnchorItem.genreSignals,
fallback.semanticAnchor.genreSignals.length,
),
fallback.semanticAnchor.genreSignals.length,
).length
? dedupeStrings(
toStringArray(
semanticAnchorItem.genreSignals,
fallback.semanticAnchor.genreSignals.length,
),
fallback.semanticAnchor.genreSignals.length,
)
: fallback.semanticAnchor.genreSignals,
conflictForms: dedupeStrings(
toStringArray(
semanticAnchorItem.conflictForms,
fallback.semanticAnchor.conflictForms.length,
),
fallback.semanticAnchor.conflictForms.length,
).length
? dedupeStrings(
toStringArray(
semanticAnchorItem.conflictForms,
fallback.semanticAnchor.conflictForms.length,
),
fallback.semanticAnchor.conflictForms.length,
)
: fallback.semanticAnchor.conflictForms,
institutionTypes: dedupeStrings(
toStringArray(
semanticAnchorItem.institutionTypes,
fallback.semanticAnchor.institutionTypes.length,
),
fallback.semanticAnchor.institutionTypes.length,
).length
? dedupeStrings(
toStringArray(
semanticAnchorItem.institutionTypes,
fallback.semanticAnchor.institutionTypes.length,
),
fallback.semanticAnchor.institutionTypes.length,
)
: fallback.semanticAnchor.institutionTypes,
tabooTypes: dedupeStrings(
toStringArray(
semanticAnchorItem.tabooTypes,
fallback.semanticAnchor.tabooTypes.length,
),
fallback.semanticAnchor.tabooTypes.length,
).length
? dedupeStrings(
toStringArray(
semanticAnchorItem.tabooTypes,
fallback.semanticAnchor.tabooTypes.length,
),
fallback.semanticAnchor.tabooTypes.length,
)
: fallback.semanticAnchor.tabooTypes,
carrierTypes: dedupeStrings(
toStringArray(
semanticAnchorItem.carrierTypes,
fallback.semanticAnchor.carrierTypes.length,
),
fallback.semanticAnchor.carrierTypes.length,
).length
? dedupeStrings(
toStringArray(
semanticAnchorItem.carrierTypes,
fallback.semanticAnchor.carrierTypes.length,
),
fallback.semanticAnchor.carrierTypes.length,
)
: fallback.semanticAnchor.carrierTypes,
forceSystemTypes: dedupeStrings(
toStringArray(
semanticAnchorItem.forceSystemTypes,
fallback.semanticAnchor.forceSystemTypes.length,
),
fallback.semanticAnchor.forceSystemTypes.length,
).length
? dedupeStrings(
toStringArray(
semanticAnchorItem.forceSystemTypes,
fallback.semanticAnchor.forceSystemTypes.length,
),
fallback.semanticAnchor.forceSystemTypes.length,
)
: fallback.semanticAnchor.forceSystemTypes,
atmosphereTags: dedupeStrings(
toStringArray(
semanticAnchorItem.atmosphereTags,
fallback.semanticAnchor.atmosphereTags.length,
),
fallback.semanticAnchor.atmosphereTags.length,
).length
? dedupeStrings(
toStringArray(
semanticAnchorItem.atmosphereTags,
fallback.semanticAnchor.atmosphereTags.length,
),
fallback.semanticAnchor.atmosphereTags.length,
)
: fallback.semanticAnchor.atmosphereTags,
},
ruleProfile: {
attributeSchema: coerceWorldAttributeSchema(
ruleProfileItem.attributeSchema,
fallback.ruleProfile.attributeSchema,
),
resourceLabels: {
hp: toText(resourceLabelsItem.hp) || fallback.ruleProfile.resourceLabels.hp,
mp: toText(resourceLabelsItem.mp) || fallback.ruleProfile.resourceLabels.mp,
maxHp:
toText(resourceLabelsItem.maxHp) ||
fallback.ruleProfile.resourceLabels.maxHp,
maxMp:
toText(resourceLabelsItem.maxMp) ||
fallback.ruleProfile.resourceLabels.maxMp,
damage:
toText(resourceLabelsItem.damage) ||
fallback.ruleProfile.resourceLabels.damage,
guard:
toText(resourceLabelsItem.guard) ||
fallback.ruleProfile.resourceLabels.guard,
range:
toText(resourceLabelsItem.range) ||
fallback.ruleProfile.resourceLabels.range,
cooldown:
toText(resourceLabelsItem.cooldown) ||
fallback.ruleProfile.resourceLabels.cooldown,
manaCost:
toText(resourceLabelsItem.manaCost) ||
fallback.ruleProfile.resourceLabels.manaCost,
currency:
toText(resourceLabelsItem.currency) ||
fallback.ruleProfile.resourceLabels.currency,
},
economyProfile: {
initialCurrency:
typeof ruleProfileItem.economyProfile === 'object' &&
ruleProfileItem.economyProfile &&
typeof (ruleProfileItem.economyProfile as Record<string, unknown>)
.initialCurrency === 'number' &&
Number.isFinite(
(ruleProfileItem.economyProfile as Record<string, unknown>)
.initialCurrency,
)
? Math.max(
0,
Math.round(
(ruleProfileItem.economyProfile as Record<string, unknown>)
.initialCurrency as number,
),
)
: fallback.ruleProfile.economyProfile.initialCurrency,
},
},
expressionProfile: {
themePack: normalizeThemePack(
expressionProfileItem.themePack,
fallback.expressionProfile.themePack,
),
presentationTone: dedupeStrings(
toStringArray(
expressionProfileItem.presentationTone,
fallback.expressionProfile.presentationTone.length,
),
fallback.expressionProfile.presentationTone.length,
).length
? dedupeStrings(
toStringArray(
expressionProfileItem.presentationTone,
fallback.expressionProfile.presentationTone.length,
),
fallback.expressionProfile.presentationTone.length,
)
: fallback.expressionProfile.presentationTone,
namingDirectives: dedupeStrings(
toStringArray(
expressionProfileItem.namingDirectives,
fallback.expressionProfile.namingDirectives.length,
),
fallback.expressionProfile.namingDirectives.length,
).length
? dedupeStrings(
toStringArray(
expressionProfileItem.namingDirectives,
fallback.expressionProfile.namingDirectives.length,
),
fallback.expressionProfile.namingDirectives.length,
)
: fallback.expressionProfile.namingDirectives,
clueDirectives: dedupeStrings(
toStringArray(
expressionProfileItem.clueDirectives,
fallback.expressionProfile.clueDirectives.length,
),
fallback.expressionProfile.clueDirectives.length,
).length
? dedupeStrings(
toStringArray(
expressionProfileItem.clueDirectives,
fallback.expressionProfile.clueDirectives.length,
),
fallback.expressionProfile.clueDirectives.length,
)
: fallback.expressionProfile.clueDirectives,
revealDirectives: dedupeStrings(
toStringArray(
expressionProfileItem.revealDirectives,
fallback.expressionProfile.revealDirectives.length,
),
fallback.expressionProfile.revealDirectives.length,
).length
? dedupeStrings(
toStringArray(
expressionProfileItem.revealDirectives,
fallback.expressionProfile.revealDirectives.length,
),
fallback.expressionProfile.revealDirectives.length,
)
: fallback.expressionProfile.revealDirectives,
},
referenceProfile: {
roleArchetypes: normalizeRoleArchetypes(
referenceProfileItem.roleArchetypes,
fallback.referenceProfile.roleArchetypes,
),
sceneBuckets: normalizeSceneBuckets(
referenceProfileItem.sceneBuckets,
fallback.referenceProfile.sceneBuckets,
),
creatureArchetypes: normalizeCreatureArchetypes(
referenceProfileItem.creatureArchetypes,
fallback.referenceProfile.creatureArchetypes,
),
},
compatibilityProfile: {
legacyTemplateWorldType:
compatibilityProfileItem.legacyTemplateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: compatibilityProfileItem.legacyTemplateWorldType === WorldType.WUXIA
? WorldType.WUXIA
: fallback.compatibilityProfile?.legacyTemplateWorldType ?? null,
migrationVersion:
toText(compatibilityProfileItem.migrationVersion) ||
fallback.compatibilityProfile?.migrationVersion ||
OWNED_SETTING_LAYER_MIGRATION_VERSION,
},
} satisfies CustomWorldOwnedSettingLayers;
}
export function resolveCustomWorldOwnedSettingLayers(
profile: CustomWorldProfile | null | undefined,
) {
if (!profile) {
return null;
}
return profile.ownedSettingLayers ?? compileOwnedSettingLayersFromLegacyTemplate(profile);
}
export function resolveCustomWorldRuleProfile(
profile: CustomWorldProfile | null | undefined,
) {
return resolveCustomWorldOwnedSettingLayers(profile)?.ruleProfile ?? null;
}
export function resolveCustomWorldExpressionProfile(
profile: CustomWorldProfile | null | undefined,
) {
return resolveCustomWorldOwnedSettingLayers(profile)?.expressionProfile ?? null;
}
export function resolveCustomWorldSemanticAnchor(
profile: CustomWorldProfile | null | undefined,
) {
return resolveCustomWorldOwnedSettingLayers(profile)?.semanticAnchor ?? null;
}
export function resolveCustomWorldCompatibilityProfile(
profile: CustomWorldProfile | null | undefined,
) {
return resolveCustomWorldOwnedSettingLayers(profile)?.compatibilityProfile ?? null;
}

View File

@@ -12,6 +12,7 @@ import {
WorldType,
} from '../types';
import { resolveCustomWorldCampScene } from './customWorldCamp';
import { resolveCustomWorldRuleProfile } from './customWorldOwnedSettingLayers';
import {
type CustomWorldThemeMode,
detectCustomWorldThemeMode,
@@ -373,6 +374,21 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
};
}
const ruleProfile = resolveCustomWorldRuleProfile(profile);
if (ruleProfile) {
return {
hp: ruleProfile.resourceLabels.hp,
mp: ruleProfile.resourceLabels.mp,
maxHp: ruleProfile.resourceLabels.maxHp,
maxMp: ruleProfile.resourceLabels.maxMp,
damage: ruleProfile.resourceLabels.damage,
guard: ruleProfile.resourceLabels.guard,
range: ruleProfile.resourceLabels.range,
cooldown: ruleProfile.resourceLabels.cooldown,
manaCost: ruleProfile.resourceLabels.manaCost,
};
}
const presentation = getWorldPresentation(profile);
return {
hp: presentation.hpLabel,

View File

@@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest';
import { type CustomWorldProfile, WorldType } from '../types';
import {
collectCreatureArchetypeSignals,
collectSceneBucketSignalKeywords,
resolveCreatureArchetypeForSource,
resolveRoleTemplateCharacterIdFromReferenceProfile,
resolveSceneBucketForLandmark,
} from './customWorldReferenceSignals';
function buildReferenceProfileHarness() {
return {
id: 'reference-harness',
settingText: '围绕裂界港区、断桥前线与工业旧站展开的世界。',
name: '裂桥港区',
subtitle: '前线潮压',
summary: '断桥、港区和旧站之间的战线不断回响。',
tone: '高压、潮湿、迟滞',
playerGoal: '查清断桥封锁与旧站事故背后的真相',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema:test',
worldId: 'custom:test',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '裂桥港区',
settingSummary: '断桥前线',
tone: '高压',
conflictCore: '旧站事故',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
ownedSettingLayers: {
semanticAnchor: {
genreSignals: ['裂界边境'],
conflictForms: ['追查失线'],
institutionTypes: ['前哨'],
tabooTypes: ['封桥令'],
carrierTypes: ['界核'],
forceSystemTypes: ['裂界'],
atmosphereTags: ['高压'],
},
ruleProfile: {
attributeSchema: {
id: 'schema:test',
worldId: 'custom:test',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '裂桥港区',
settingSummary: '断桥前线',
tone: '高压',
conflictCore: '旧站事故',
},
slots: [],
},
resourceLabels: {
hp: '界命',
mp: '裂能',
maxHp: '界命上限',
maxMp: '裂能上限',
damage: '界势',
guard: '稳界',
range: '界距',
cooldown: '复界',
manaCost: '裂能消耗',
currency: '边贸券',
},
economyProfile: {
initialCurrency: 160,
},
},
expressionProfile: {
themePack: {
id: 'theme:test',
displayName: '裂桥前线',
toneRange: ['高压'],
institutionLexicon: ['前哨'],
tabooLexicon: ['封桥令'],
artifactClasses: ['界核'],
actorArchetypes: ['边巡者'],
conflictForms: ['追查失线'],
clueForms: ['裂痕'],
namingPatterns: ['前哨+旧痕+器类'],
revealStyles: ['证词错位'],
},
presentationTone: ['高压'],
namingDirectives: ['前哨+旧痕+器类'],
clueDirectives: ['裂痕'],
revealDirectives: ['证词错位'],
},
referenceProfile: {
roleArchetypes: [
{
id: 'role-1',
label: '远程压制型',
combatFocus: '依靠弓与远程火力持续压制。',
narrativeFunction: '为队伍提供远程压制与侦查。',
sourceRoleIds: [],
sourceTemplateCharacterIds: [],
tags: ['远程', '射击'],
},
],
sceneBuckets: [
{
id: 'scene-1',
label: '工业热区',
moodTags: ['高压'],
keywords: ['旧站', '工坊'],
referenceLandmarkIds: ['landmark-industrial'],
},
{
id: 'scene-2',
label: '临水渡口区',
moodTags: ['潮湿'],
keywords: ['港区', '渡桥'],
referenceLandmarkIds: ['landmark-harbor'],
},
],
creatureArchetypes: [
{
id: 'creature-1',
label: '机关守卫体',
threatStyle: '围绕节点和装置进行守线压制。',
keywords: ['机关', '守卫', '旧站'],
},
{
id: 'creature-2',
label: '远程威胁者',
threatStyle: '依靠远程投射和凝视压制走位。',
keywords: ['远程', '压制', '索敌'],
},
],
},
compatibilityProfile: {
legacyTemplateWorldType: WorldType.WUXIA,
migrationVersion: 'test',
},
},
themePack: null,
storyGraph: null,
creatorIntent: null,
anchorPack: null,
lockState: null,
generationMode: 'full',
generationStatus: 'complete',
} satisfies CustomWorldProfile;
}
describe('customWorldReferenceSignals', () => {
it('resolves scene buckets by explicit landmark ownership', () => {
const profile = buildReferenceProfileHarness();
const bucket = resolveSceneBucketForLandmark(profile, {
id: 'landmark-industrial',
name: '旧站锅炉层',
description: '轨道和锅炉残响仍卡在热区深处。',
});
expect(bucket?.label).toBe('工业热区');
expect(collectSceneBucketSignalKeywords(bucket!).includes('工坊')).toBe(true);
});
it('resolves creature archetypes and exposes combat/habitat signal tags', () => {
const profile = buildReferenceProfileHarness();
const archetype = resolveCreatureArchetypeForSource(profile, {
name: '旧站守卫傀',
role: '节点守卫',
description: '围绕工坊旧站守线,遇敌后会启动压制炮座。',
combatStyle: '守住节点后用远程火力封锁通路。',
tags: ['机关', '旧站', '守卫'],
});
const signals = collectCreatureArchetypeSignals(archetype!);
expect(archetype?.label).toBe('机关守卫体');
expect(signals.combatTags).toContain('守御');
expect(signals.habitatTags).toContain('工场');
});
it('maps role archetypes back to suitable preset character templates', () => {
const profile = buildReferenceProfileHarness();
const templateCharacterId = resolveRoleTemplateCharacterIdFromReferenceProfile(
profile,
{
id: 'story-role-1',
name: '雾港狙巡手',
title: '岸线压制者',
role: '远程巡手',
description: '负责在港区高点做远程掩护与索敌压制。',
personality: '冷静,开火前总先确认潮向。',
combatStyle: '高点远程压制,必要时转为游击拉扯。',
tags: ['远程', '射击', '港区'],
},
);
expect(templateCharacterId).toBe('archer-hero');
});
});

View File

@@ -0,0 +1,369 @@
import type {
CreatureArchetypeProfile,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
RoleArchetypeProfile,
SceneArchetypeBucket,
} from '../types';
type SceneBucketSignalPreset = {
keywords: string[];
};
type CreatureArchetypeSignalPreset = {
keywords: string[];
combatTags: string[];
habitatTags: string[];
};
type RoleArchetypeSignalPreset = {
keywords: string[];
templateCharacterIds: string[];
};
const SCENE_BUCKET_SIGNAL_PRESETS: Record<string, SceneBucketSignalPreset> = {
: {
keywords: ['入口', '关口', '哨站', '桥口', '门廊', '边关'],
},
: {
keywords: ['渡口', '码头', '港口', '岸线', '船坞', '水路'],
},
殿: {
keywords: ['祭坛', '神殿', '仪式', '法坛', '庙宇', '圣所'],
},
: {
keywords: ['高空', '悬桥', '云阶', '塔顶', '崖道', '飞桥'],
},
: {
keywords: ['工坊', '轨道', '机库', '熔炉', '工场', '锅炉'],
},
: {
keywords: ['地宫', '矿道', '遗迹', '洞窟', '墓道', '地底'],
},
: {
keywords: ['街巷', '聚落', '城镇', '营地', '居所', '市集'],
},
: {
keywords: ['险地', '封锁', '交汇', '前线', '险关', '断层'],
},
: {
keywords: ['归处', '栖居', '缓冲', '休整', '据点', '落脚'],
},
};
const CREATURE_ARCHETYPE_SIGNAL_PRESETS: Record<
string,
CreatureArchetypeSignalPreset
> = {
: {
keywords: ['潜伏', '伏击', '突袭', '暗影', '贴身'],
combatTags: ['快袭', '突进', '机动'],
habitatTags: ['雾林', '断垣', '妖雾', '崖壁'],
},
: {
keywords: ['重甲', '承压', '守线', '堵截', '厚重'],
combatTags: ['重甲', '守御', '护体', '堡垒'],
habitatTags: ['矿道', '废城', '边关', '地宫'],
},
: {
keywords: ['群居', '骚扰', '游窜', '围猎', '消耗'],
combatTags: ['机动', '追击', '控场'],
habitatTags: ['竹林', '雾林', '荒野', '月湖'],
},
: {
keywords: ['远程', '投射', '压制', '炮击', '凝视'],
combatTags: ['远射', '法修', '雷法'],
habitatTags: ['长街', '仙门', '星舟', '祭坛'],
},
: {
keywords: ['异化', '污染', '腐化', '潮灾', '侵蚀'],
combatTags: ['法力', '回复', '重甲'],
habitatTags: ['洞天', '谷地', '秘境', '灵泉'],
},
: {
keywords: ['灵体', '回响', '残魂', '旧痕', '幽灵'],
combatTags: ['镇邪', '控场', '法修'],
habitatTags: ['遗迹', '祭坛', '古迹', '废寺'],
},
: {
keywords: ['机关', '守卫', '节点', '封印', '装置'],
combatTags: ['守御', '压制', '符阵'],
habitatTags: ['铸坊', '工场', '前哨', '长廊'],
},
: {
keywords: ['追猎', '回响', '索敌', '追索', '名单'],
combatTags: ['追击', '压制', '机动'],
habitatTags: ['前线', '断层', '渡口', '雾港'],
},
};
const ROLE_ARCHETYPE_SIGNAL_PRESETS: Record<string, RoleArchetypeSignalPreset> = {
: {
keywords: ['推进', '压前', '正面', '先锋', '破阵'],
templateCharacterIds: ['sword-princess', 'punch-hero'],
},
: {
keywords: ['远程', '弓', '射击', '投掷', '炮击'],
templateCharacterIds: ['archer-hero'],
},
: {
keywords: ['控场', '阵', '法', '机关', '解构', '牵制'],
templateCharacterIds: ['fighter-4', 'girl-hero'],
},
: {
keywords: ['承压', '护体', '守御', '续航', '稳阵'],
templateCharacterIds: ['fighter-4', 'punch-hero'],
},
: {
keywords: ['潜行', '爆发', '影袭', '突进', '追击'],
templateCharacterIds: ['girl-hero', 'sword-princess'],
},
};
type ReferenceRoleSource = Pick<
CustomWorldPlayableNpc | CustomWorldNpc,
'id' | 'name' | 'title' | 'role' | 'description' | 'personality' | 'combatStyle' | 'tags'
>;
type ReferenceCreatureSource = Partial<
Pick<
CustomWorldPlayableNpc & CustomWorldNpc,
| 'id'
| 'name'
| 'title'
| 'role'
| 'description'
| 'backstory'
| 'personality'
| 'motivation'
| 'combatStyle'
| 'relationshipHooks'
| 'tags'
>
>;
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function dedupeStrings(
values: Array<string | null | undefined>,
max = 12,
) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, max);
}
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash >>> 0;
}
function buildRoleSourceText(role: ReferenceRoleSource) {
return dedupeStrings([
role.name,
role.title,
role.role,
role.description,
role.personality,
role.combatStyle,
...(role.tags ?? []),
]).join(' ');
}
function buildCreatureSourceText(source: ReferenceCreatureSource) {
return dedupeStrings([
source.name,
source.title,
source.role,
source.description,
source.backstory,
source.personality,
source.motivation,
source.combatStyle,
...(source.relationshipHooks ?? []),
...(source.tags ?? []),
]).join(' ');
}
function scoreTextMatches(sourceText: string, keywords: string[]) {
return keywords.reduce((score, keyword) => {
if (!keyword || !sourceText.includes(keyword)) {
return score;
}
if (keyword.length >= 4) {
return score + 8;
}
if (keyword.length === 3) {
return score + 6;
}
return score + 4;
}, 0);
}
function getReferenceProfile(profile: CustomWorldProfile | null | undefined) {
return profile?.ownedSettingLayers?.referenceProfile ?? null;
}
export function collectSceneBucketSignalKeywords(
bucket: Pick<SceneArchetypeBucket, 'label' | 'keywords' | 'moodTags'>,
) {
const preset = SCENE_BUCKET_SIGNAL_PRESETS[bucket.label];
return dedupeStrings([
bucket.label,
...bucket.keywords,
...bucket.moodTags,
...(preset?.keywords ?? []),
]);
}
export function resolveSceneBucketForLandmark(
profile: CustomWorldProfile | null | undefined,
landmark: Pick<CustomWorldProfile['landmarks'][number], 'id' | 'name' | 'description'>,
) {
const sceneBuckets = getReferenceProfile(profile)?.sceneBuckets ?? [];
if (sceneBuckets.length === 0) {
return null;
}
const explicitBucket = sceneBuckets.find((bucket) =>
bucket.referenceLandmarkIds.includes(landmark.id),
);
if (explicitBucket) {
return explicitBucket;
}
const sourceText = dedupeStrings([landmark.name, landmark.description]).join(' ');
const scoredBuckets = sceneBuckets
.map((bucket) => ({
bucket,
score: scoreTextMatches(sourceText, collectSceneBucketSignalKeywords(bucket)),
}))
.sort((left, right) => right.score - left.score);
return (scoredBuckets[0]?.score ?? 0) > 0 ? scoredBuckets[0]?.bucket ?? null : null;
}
export function collectCreatureArchetypeSignals(
archetype: Pick<CreatureArchetypeProfile, 'label' | 'threatStyle' | 'keywords'>,
) {
const preset = CREATURE_ARCHETYPE_SIGNAL_PRESETS[archetype.label];
return {
keywords: dedupeStrings([
archetype.label,
archetype.threatStyle,
...archetype.keywords,
...(preset?.keywords ?? []),
]),
combatTags: dedupeStrings(preset?.combatTags ?? [], 6),
habitatTags: dedupeStrings(preset?.habitatTags ?? [], 6),
};
}
export function resolveCreatureArchetypeForSource(
profile: CustomWorldProfile | null | undefined,
source: ReferenceCreatureSource,
) {
const creatureArchetypes = getReferenceProfile(profile)?.creatureArchetypes ?? [];
if (creatureArchetypes.length === 0) {
return null;
}
const sourceText = buildCreatureSourceText(source);
const scoredArchetypes = creatureArchetypes
.map((archetype) => ({
archetype,
score: scoreTextMatches(
sourceText,
collectCreatureArchetypeSignals(archetype).keywords,
),
}))
.sort((left, right) => right.score - left.score);
return (scoredArchetypes[0]?.score ?? 0) > 0
? scoredArchetypes[0]?.archetype ?? null
: creatureArchetypes[0] ?? null;
}
function collectRoleArchetypeSignals(
archetype: Pick<
RoleArchetypeProfile,
| 'label'
| 'combatFocus'
| 'narrativeFunction'
| 'tags'
| 'sourceTemplateCharacterIds'
>,
) {
const preset = ROLE_ARCHETYPE_SIGNAL_PRESETS[archetype.label];
return {
keywords: dedupeStrings([
archetype.label,
archetype.combatFocus,
archetype.narrativeFunction,
...archetype.tags,
...(preset?.keywords ?? []),
]),
templateCharacterIds:
archetype.sourceTemplateCharacterIds.length > 0
? archetype.sourceTemplateCharacterIds
: preset?.templateCharacterIds ?? [],
};
}
export function resolveRoleArchetypeForRole(
profile: CustomWorldProfile | null | undefined,
role: ReferenceRoleSource,
) {
const roleArchetypes = getReferenceProfile(profile)?.roleArchetypes ?? [];
if (roleArchetypes.length === 0) {
return null;
}
const explicitArchetype = roleArchetypes.find((archetype) =>
archetype.sourceRoleIds.includes(role.id),
);
if (explicitArchetype) {
return explicitArchetype;
}
const sourceText = buildRoleSourceText(role);
const scoredArchetypes = roleArchetypes
.map((archetype) => ({
archetype,
score: scoreTextMatches(sourceText, collectRoleArchetypeSignals(archetype).keywords),
}))
.sort((left, right) => right.score - left.score);
return (scoredArchetypes[0]?.score ?? 0) > 0
? scoredArchetypes[0]?.archetype ?? null
: roleArchetypes[0] ?? null;
}
export function resolveRoleTemplateCharacterIdFromReferenceProfile(
profile: CustomWorldProfile | null | undefined,
role: ReferenceRoleSource,
) {
const archetype = resolveRoleArchetypeForRole(profile, role);
if (!archetype) {
return null;
}
const templateCharacterIds = collectRoleArchetypeSignals(archetype).templateCharacterIds;
if (templateCharacterIds.length === 0) {
return null;
}
const seedSource = toText(role.id) || buildRoleSourceText(role);
return templateCharacterIds[hashText(seedSource) % templateCharacterIds.length] ?? null;
}

View File

@@ -9,9 +9,32 @@ export type CustomWorldThemeMode =
| 'mythic';
export function detectCustomWorldThemeMode(
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
profile: Pick<
CustomWorldProfile,
| 'settingText'
| 'summary'
| 'tone'
| 'playerGoal'
| 'templateWorldType'
| 'ownedSettingLayers'
>,
): CustomWorldThemeMode {
const source = `${profile.settingText} ${profile.summary} ${profile.tone} ${profile.playerGoal}`;
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
const source = [
profile.settingText,
profile.summary,
profile.tone,
profile.playerGoal,
...(semanticAnchor?.genreSignals ?? []),
...(semanticAnchor?.conflictForms ?? []),
...(semanticAnchor?.institutionTypes ?? []),
...(semanticAnchor?.tabooTypes ?? []),
...(semanticAnchor?.carrierTypes ?? []),
...(semanticAnchor?.forceSystemTypes ?? []),
...(semanticAnchor?.atmosphereTags ?? []),
...(expressionProfile?.presentationTone ?? []),
].join(' ');
if (/[齿]/u.test(source)) return 'machina';
if (/[]/u.test(source)) return 'tide';
@@ -23,8 +46,26 @@ export function detectCustomWorldThemeMode(
}
export function resolveCustomWorldAnchorWorldType(
profile: Pick<CustomWorldProfile, 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'>,
profile: Pick<
CustomWorldProfile,
| 'settingText'
| 'summary'
| 'tone'
| 'playerGoal'
| 'templateWorldType'
| 'ownedSettingLayers'
>,
): WorldTemplateType {
const legacyTemplateWorldType =
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType;
if (
legacyTemplateWorldType === WorldType.WUXIA ||
legacyTemplateWorldType === WorldType.XIANXIA
) {
return legacyTemplateWorldType;
}
const themeMode = detectCustomWorldThemeMode(profile);
return themeMode === 'arcane' ? WorldType.XIANXIA : WorldType.WUXIA;
}

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState } from '../../types';
import { buildChapterQuestForScene } from '../../data/questFlow';
import { AnimationState, type GameState, WorldType } from '../../types';
import { advanceChapterState, resolveCurrentChapterState } from './chapterDirector';
function createState(signalCount: number): GameState {
@@ -73,6 +74,54 @@ function createState(signalCount: number): GameState {
};
}
function createSceneChapterState() {
const quest = buildChapterQuestForScene({
scene: {
id: 'scene-court',
name: '宫苑内庭',
description: '回廊深处静得过分。',
npcs: [
{
id: 'npc-maid',
name: '旧宫侍女',
description: '她总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
hostile: false,
},
{
id: 'hostile-shadow',
name: '旧宫戍影',
description: '巡行在回廊里的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
hostile: true,
},
],
treasureHints: ['回廊暗格里的香囊'],
},
worldType: WorldType.WUXIA,
});
if (!quest) {
throw new Error('Expected chapter quest');
}
return {
...createState(0),
currentScenePreset: {
id: 'scene-court',
name: '宫苑内庭',
description: '回廊深处静得过分。',
imageSrc: '/scene.png',
treasureHints: ['回廊暗格里的香囊'],
npcs: [],
},
quests: [quest],
} satisfies GameState;
}
describe('chapterDirector', () => {
it('resolves chapter stages from signal intensity', () => {
expect(resolveCurrentChapterState({ state: createState(1) }).stage).toBe('opening');
@@ -89,4 +138,58 @@ describe('chapterDirector', () => {
expect(next.id).toBe(previous.id);
});
it('binds the current chapter to the current scene chapter quest', () => {
const openingState = createSceneChapterState();
const openingChapter = resolveCurrentChapterState({ state: openingState });
expect(openingChapter.id).toBe('chapter:scene:scene-court');
expect(openingChapter.sceneId).toBe('scene-court');
expect(openingChapter.chapterQuestId).toBe('quest:chapter:scene-court');
expect(openingChapter.stage).toBe('opening');
const turningState: GameState = {
...openingState,
quests: [
{
...openingState.quests[0]!,
steps: openingState.quests[0]!.steps?.map((step) =>
step.id === 'step_scene_opening'
? { ...step, progress: step.requiredCount }
: step.id === 'step_scene_pressure'
? { ...step, progress: step.requiredCount }
: step,
),
activeStepId: 'step_scene_turning',
},
],
};
expect(resolveCurrentChapterState({ state: turningState }).stage).toBe('turning_point');
const climaxState: GameState = {
...turningState,
quests: [
{
...turningState.quests[0]!,
steps: turningState.quests[0]!.steps?.map((step) => ({
...step,
progress: step.requiredCount,
})),
activeStepId: null,
status: 'ready_to_turn_in',
},
],
};
expect(resolveCurrentChapterState({ state: climaxState }).stage).toBe('climax');
const aftermathState: GameState = {
...climaxState,
quests: [
{
...climaxState.quests[0]!,
status: 'turned_in',
},
],
};
expect(resolveCurrentChapterState({ state: aftermathState }).stage).toBe('aftermath');
});
});

View File

@@ -1,4 +1,5 @@
import type { ChapterState, CustomWorldProfile, GameState } from '../../types';
import { buildSceneChapterId, getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import type { ChapterState, CustomWorldProfile, GameState, QuestLogEntry } from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 4) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
@@ -26,6 +27,86 @@ function resolveChapterTheme(profile: CustomWorldProfile | null | undefined, pri
return profile?.themePack?.displayName ?? profile?.summary ?? '旅程推进';
}
function getStageLabel(stage: ChapterState['stage']) {
switch (stage) {
case 'opening':
return '序章';
case 'expansion':
return '展开';
case 'turning_point':
return '转折';
case 'climax':
return '高潮';
case 'aftermath':
return '余波';
default:
return '推进';
}
}
function resolveSceneChapterQuest(state: GameState) {
const sceneId = state.currentScenePreset?.id;
if (!sceneId) {
return null;
}
const chapterId = buildSceneChapterId(sceneId);
return state.quests.find((quest) =>
quest.chapterId === chapterId
&& quest.status !== 'failed'
&& quest.status !== 'expired',
) ?? null;
}
function deriveChapterStageFromQuest(quest: QuestLogEntry): ChapterState['stage'] {
if (quest.status === 'turned_in') {
return 'aftermath';
}
if (isQuestReadyToClaim(quest)) {
return 'climax';
}
const activeStep = getQuestActiveStep(quest);
const activeStepIndex = activeStep
? Math.max(0, quest.steps?.findIndex((step) => step.id === activeStep.id) ?? 0)
: -1;
if (activeStepIndex <= 0) {
return 'opening';
}
if (activeStepIndex === 1) {
return 'expansion';
}
return 'turning_point';
}
function buildSceneChapterSummary(params: {
sceneName: string;
quest: QuestLogEntry;
stage: ChapterState['stage'];
}) {
const {sceneName, quest, stage} = params;
const activeStep = getQuestActiveStep(quest);
switch (stage) {
case 'opening':
return `${sceneName} 的这一章刚刚开启。${activeStep?.revealText ?? quest.description}`;
case 'expansion':
return `${sceneName} 的压力正在展开。${activeStep?.revealText ?? quest.summary}`;
case 'turning_point':
return `${sceneName} 的线索正在改写当前判断。${activeStep?.revealText ?? quest.summary}`;
case 'climax':
return `${sceneName} 的核心矛盾已经被推到最后一步,只差把这一章正式收束。`;
case 'aftermath':
return `${sceneName} 这一章已经完成收束,余波和下一段去向正在显形。`;
default:
return `${sceneName} 的这一章仍在推进中。`;
}
}
export function resolveCurrentChapterState(params: {
state: GameState;
}) {
@@ -39,6 +120,32 @@ export function resolveCurrentChapterState(params: {
);
const signalCount = storyEngineMemory?.recentSignalIds?.length ?? 0;
const chronicleCount = storyEngineMemory?.chronicle?.length ?? 0;
const sceneChapterQuest = resolveSceneChapterQuest(state);
const currentSceneId = state.currentScenePreset?.id ?? null;
const currentSceneName = state.currentScenePreset?.name ?? '当前区域';
if (sceneChapterQuest && currentSceneId) {
const stage = deriveChapterStageFromQuest(sceneChapterQuest);
const theme = sceneChapterQuest.title || resolveChapterTheme(profile, threadTitles);
return {
id: buildSceneChapterId(currentSceneId),
title: `${currentSceneName}·${getStageLabel(stage)}`,
theme,
primaryThreadIds: dedupeStrings([
sceneChapterQuest.threadId,
...activeThreadIds,
], 3),
stage,
chapterSummary: buildSceneChapterSummary({
sceneName: currentSceneName,
quest: sceneChapterQuest,
stage,
}),
sceneId: currentSceneId,
chapterQuestId: sceneChapterQuest.id,
} satisfies ChapterState;
}
const stage = resolveChapterStage({
signalCount,
chronicleCount,
@@ -46,15 +153,7 @@ export function resolveCurrentChapterState(params: {
currentStage: state.chapterState?.stage ?? storyEngineMemory?.currentChapter?.stage ?? null,
});
const theme = resolveChapterTheme(profile, threadTitles);
const title = `${theme || '旅程'}·${stage === 'opening'
? '序章'
: stage === 'expansion'
? '展开'
: stage === 'turning_point'
? '转折'
: stage === 'climax'
? '高潮'
: '余波'}`;
const title = `${theme || '旅程'}·${getStageLabel(stage)}`;
return {
id: `chapter:${dedupeStrings(activeThreadIds, 2).join('+') || 'default'}:${stage}`,
@@ -63,6 +162,8 @@ export function resolveCurrentChapterState(params: {
primaryThreadIds: dedupeStrings(activeThreadIds, 3),
stage,
chapterSummary: `${title} 当前围绕 ${theme || '旅程主线'} 推进。`,
sceneId: null,
chapterQuestId: null,
} satisfies ChapterState;
}

View File

@@ -26,6 +26,7 @@ function createQuest(overrides: Partial<QuestLogEntry> & Pick<QuestLogEntry, 'id
issuerNpcId: overrides.issuerNpcId ?? `${overrides.id}-issuer`,
issuerNpcName: overrides.issuerNpcName ?? '林朔',
sceneId: overrides.sceneId ?? 'scene-ruins',
chapterId: overrides.chapterId ?? null,
title: overrides.title,
description: overrides.description ?? `${overrides.title} 的说明`,
summary: overrides.summary ?? `${overrides.title} 的摘要`,
@@ -181,6 +182,31 @@ describe('goalDirector', () => {
expect(describeGoalStackForPrompt(goalStack)).toContain('当前玩家任务推进');
});
it('prefers the current scene chapter quest over unrelated ready quests', () => {
const currentSceneQuest = createQuest({
id: 'quest-chapter-scene-court',
title: '查明宫苑内庭',
sceneId: 'scene-court',
chapterId: 'chapter:scene:scene-court',
status: 'active',
});
const unrelatedReadyQuest = createQuest({
id: 'quest-ready-other',
title: '回报断桥调查',
sceneId: 'scene-bridge',
status: 'ready_to_turn_in',
});
const goalStack = buildGoalStackState({
quests: [unrelatedReadyQuest, currentSceneQuest],
worldType: null,
currentSceneId: 'scene-court',
currentSceneName: '宫苑内庭',
});
expect(goalStack.activeGoal?.sourceId).toBe('quest-chapter-scene-court');
});
it('annotates options with advance/support affordances and builds quest reward handoff', () => {
const readyQuest = createQuest({
id: 'quest-ready',

View File

@@ -1,5 +1,5 @@
import { isContinueAdventureOption } from '../../data/functionCatalog';
import { getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import { buildSceneChapterId, getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
import { getScenePresetById } from '../../data/scenePresets';
import type {
CampEvent,
@@ -466,12 +466,20 @@ function buildCampEventSupportGoal(currentCampEvent: CampEvent) {
} satisfies GoalStackEntry;
}
function resolvePrimaryQuest(quests: QuestLogEntry[]) {
function resolvePrimaryQuest(quests: QuestLogEntry[], currentSceneId?: string | null) {
const liveQuests = quests.filter(isLiveQuest);
if (liveQuests.length <= 0) {
return null;
}
const currentSceneChapterId = currentSceneId ? buildSceneChapterId(currentSceneId) : null;
const currentSceneChapterQuest = currentSceneChapterId
? liveQuests.find((quest) => quest.chapterId === currentSceneChapterId) ?? null
: null;
if (currentSceneChapterQuest) {
return currentSceneChapterQuest;
}
return liveQuests.find((quest) => isQuestReadyToClaim(quest))
?? liveQuests.find((quest) => quest.status === 'active')
?? liveQuests.find((quest) => quest.status === 'discovered')
@@ -486,6 +494,7 @@ export function buildGoalStackState(params: {
journeyBeat?: JourneyBeat | null;
setpieceDirective?: SetpieceDirective | null;
currentCampEvent?: CampEvent | null;
currentSceneId?: string | null;
currentSceneName?: string | null;
}) {
const {
@@ -495,9 +504,10 @@ export function buildGoalStackState(params: {
journeyBeat = null,
setpieceDirective = null,
currentCampEvent = null,
currentSceneId = null,
currentSceneName = null,
} = params;
const primaryQuest = resolvePrimaryQuest(quests);
const primaryQuest = resolvePrimaryQuest(quests, currentSceneId);
const northStarGoal = setpieceDirective
? buildSetpieceNorthStarGoal(setpieceDirective)
: chapterState
@@ -766,6 +776,7 @@ export function buildGoalHandoffFromState(state: GameState): GoalHandoff | null
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
currentSceneId: state.currentScenePreset?.id ?? null,
chapterState: state.chapterState ?? state.storyEngineMemory?.currentChapter ?? null,
journeyBeat: state.storyEngineMemory?.currentJourneyBeat ?? null,
setpieceDirective: state.storyEngineMemory?.currentSetpieceDirective ?? null,

View File

@@ -95,4 +95,34 @@ describe('storyChronicle', () => {
expect(chronicle.length).toBeGreaterThan(0);
expect(summary).toContain('封桥旧案·展开');
});
it('dedupes unchanged chapter chronicle entries', () => {
const chapterState = {
id: 'chapter:scene:scene-court',
title: '宫苑内庭·展开',
theme: '宫苑旧案',
primaryThreadIds: ['thread-court'],
stage: 'expansion' as const,
chapterSummary: '宫苑内庭的这一章正在展开。',
sceneId: 'scene-court',
chapterQuestId: 'quest:chapter:scene-court',
};
const firstChronicle = appendChronicleEntries({
state,
chapterState,
});
const secondChronicle = appendChronicleEntries({
state: {
...state,
storyEngineMemory: {
...state.storyEngineMemory!,
chronicle: firstChronicle,
},
},
chapterState,
});
expect(secondChronicle.filter((entry) => entry.id === 'chronicle:chapter:chapter:scene:scene-court')).toHaveLength(1);
});
});

View File

@@ -13,6 +13,18 @@ function createChronicleId(category: ChronicleEntry['category'], key: string) {
return `chronicle:${category}:${key}`;
}
function dedupeChronicleEntries(entries: ChronicleEntry[]) {
const seen = new Set<string>();
return entries.filter((entry) => {
const signature = `${entry.id}::${entry.summary}`;
if (seen.has(signature)) {
return false;
}
seen.add(signature);
return true;
});
}
export function appendChronicleEntries(params: {
state: GameState;
chapterState?: ChapterState | null;
@@ -80,7 +92,7 @@ export function appendChronicleEntries(params: {
});
}
return [...existing, ...additions].slice(-18);
return dedupeChronicleEntries([...existing, ...additions]).slice(-18);
}
export function buildChronicleSummary(state: GameState) {

View File

@@ -119,6 +119,31 @@ function cloneThemePack(mode: string, preset: ThemePackPreset): ThemePack {
};
}
function collectSemanticAnchorLexicon(
profile: Pick<CustomWorldProfile, 'ownedSettingLayers'>,
) {
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
if (!semanticAnchor && !expressionProfile) {
return [];
}
return dedupeStrings([
...(semanticAnchor?.genreSignals ?? []),
...(semanticAnchor?.conflictForms ?? []),
...(semanticAnchor?.institutionTypes ?? []),
...(semanticAnchor?.tabooTypes ?? []),
...(semanticAnchor?.carrierTypes ?? []),
...(semanticAnchor?.forceSystemTypes ?? []),
...(semanticAnchor?.atmosphereTags ?? []),
...(expressionProfile?.presentationTone ?? []),
...(expressionProfile?.namingDirectives ?? []),
...(expressionProfile?.clueDirectives ?? []),
...(expressionProfile?.revealDirectives ?? []),
]);
}
function resolveThemeModeFromWorldType(
worldType: WorldTemplateType | WorldType | null | undefined,
) {
@@ -180,30 +205,67 @@ export function buildThemePackFromWorldProfile(
| 'templateWorldType'
| 'majorFactions'
| 'coreConflicts'
| 'ownedSettingLayers'
> & {
templateWorldType: WorldTemplateType | WorldType;
},
) {
const mode = detectCustomWorldThemeMode(profile);
const base = cloneThemePack(mode, THEME_PACK_PRESETS[mode]!);
const ownedThemePack = profile.ownedSettingLayers?.expressionProfile?.themePack;
if (ownedThemePack) {
return normalizeThemePack(ownedThemePack, base);
}
const lexicon = collectProfileLexicon(profile);
const semanticLexicon = collectSemanticAnchorLexicon(profile);
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
return normalizeThemePack(
{
...base,
institutionLexicon: dedupeStrings([
...base.institutionLexicon,
...(semanticAnchor?.institutionTypes ?? []),
...lexicon.filter((item) => item.length >= 2),
]),
tabooLexicon: dedupeStrings([
...base.tabooLexicon,
...(semanticAnchor?.tabooTypes ?? []),
...(profile.coreConflicts ?? []).slice(0, 4),
]),
artifactClasses: dedupeStrings([
...base.artifactClasses,
...(semanticAnchor?.carrierTypes ?? []),
]),
conflictForms: dedupeStrings([
...base.conflictForms,
...(semanticAnchor?.conflictForms ?? []),
...(profile.coreConflicts ?? []).slice(0, 3),
]),
clueForms: dedupeStrings([
...base.clueForms,
...(expressionProfile?.clueDirectives ?? []),
...(profile.majorFactions ?? []).slice(0, 3),
]),
toneRange: dedupeStrings([profile.tone, ...base.toneRange]),
namingPatterns: dedupeStrings([
...base.namingPatterns,
...(expressionProfile?.namingDirectives ?? []),
]),
revealStyles: dedupeStrings([
...base.revealStyles,
...(expressionProfile?.revealDirectives ?? []),
]),
toneRange: dedupeStrings([
profile.tone,
...(expressionProfile?.presentationTone ?? []),
...base.toneRange,
]),
actorArchetypes: dedupeStrings([
...base.actorArchetypes,
...semanticLexicon.filter((item) => item.length >= 2),
]),
},
base,
);

View File

@@ -45,6 +45,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: [],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,

View File

@@ -10,8 +10,14 @@ import {
Upload,
Wrench,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildAnimationClipFromVideoSource,
GENERATED_FRAME_HEIGHT,
GENERATED_FRAME_WIDTH,
} from '../components/preset-editor/characterAssetStudioModel';
import { generateCharacterAnimationDraft } from '../components/preset-editor/characterAssetStudioPersistence';
import {
NumberField,
SelectField,
@@ -19,22 +25,24 @@ import {
TextField,
} from '../editor/shared/FormFields';
import { SectionCard } from '../editor/shared/SectionCard';
import { AnimationState } from '../types';
import {
buildDefaultFrameOrder,
buildMasterNegativePrompt,
buildMasterPrompt,
buildOrderedActiveFrameSources,
buildPlayableCharacterStyleReferenceBoard,
buildRepairNegativePrompt,
buildRepairPrompt,
buildSheetNegativePrompt,
buildSheetPrompt,
buildVideoActionPrompt,
composeSpriteSheetFromFrames,
DEFAULT_MASTER_NEGATIVE_PROMPT,
DEFAULT_REPAIR_NEGATIVE_PROMPT,
DEFAULT_SHEET_NEGATIVE_PROMPT,
extractSpriteFrame,
getActionTemplateById,
moveFrameOrderItem,
QWEN_SPRITE_ACTION_TEMPLATES,
type QwenSpriteActionTemplateId,
readFileAsDataUrl,
replaceSpriteFrame,
restoreAllFrames,
sliceSpriteSheetFrames,
@@ -53,6 +61,31 @@ import {
const MODEL_OPTIONS = [
{ label: 'Qwen-Image-2.0', value: 'qwen-image-2.0' },
];
const ACTION_GENERATION_MODE_OPTIONS = [
{ label: '方案一:直接生成精灵表', value: 'direct-sheet' },
{ label: '方案二:图生视频后抽帧', value: 'image-to-video' },
];
const FIXED_IMAGE_TO_VIDEO_MODEL = 'wan2.2-kf2v-flash';
const FIXED_IMAGE_TO_VIDEO_RESOLUTION = '480P';
const FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS = 5;
function mapActionTemplateIdToAnimationState(
actionTemplateId: QwenSpriteActionTemplateId,
) {
switch (actionTemplateId) {
case 'run':
return AnimationState.RUN;
case 'attack_slash':
return AnimationState.ATTACK;
case 'hurt':
return AnimationState.HURT;
case 'die':
return AnimationState.DIE;
case 'idle':
default:
return AnimationState.IDLE;
}
}
function StatusNote({ title, message }: { title: string; message: string }) {
return (
@@ -116,21 +149,10 @@ export default function QwenSpriteSheetTool() {
const [characterBrief, setCharacterBrief] = useState(
'Q版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。',
);
const [masterPromptText, setMasterPromptText] = useState(() =>
buildMasterPrompt(
'Q版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。',
),
);
const [masterNegativePrompt, setMasterNegativePrompt] = useState(
DEFAULT_MASTER_NEGATIVE_PROMPT,
);
const [masterModel, setMasterModel] = useState('qwen-image-2.0');
const [masterSize, setMasterSize] = useState('1024*1024');
const [masterCandidateCount, setMasterCandidateCount] = useState(2);
const [masterSeed, setMasterSeed] = useState(1101);
const [masterPromptExtend, setMasterPromptExtend] = useState(true);
const [masterReferenceImages, setMasterReferenceImages] = useState<string[]>([]);
const [styleReferenceBoardSource, setStyleReferenceBoardSource] = useState('');
const masterCandidateCount = 2;
const masterSeed = 1101;
const masterPromptExtend = true;
const [masterDrafts, setMasterDrafts] = useState<QwenSpriteImageDraft[]>([]);
const [selectedMasterDraftId, setSelectedMasterDraftId] = useState('');
const [selectedMasterSource, setSelectedMasterSource] = useState('');
@@ -140,24 +162,22 @@ export default function QwenSpriteSheetTool() {
const [actionTemplateId, setActionTemplateId] =
useState<QwenSpriteActionTemplateId>('idle');
const [actionKey, setActionKey] = useState('idle');
const [sheetPromptText, setSheetPromptText] = useState('');
const [sheetNegativePrompt, setSheetNegativePrompt] = useState(
DEFAULT_SHEET_NEGATIVE_PROMPT,
const [actionGenerationMode, setActionGenerationMode] = useState<
'direct-sheet' | 'image-to-video'
>('image-to-video');
const [actionDetailText, setActionDetailText] = useState(
'动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。',
);
const [sheetModel, setSheetModel] = useState('qwen-image-2.0');
const [sheetSize, setSheetSize] = useState('1024*1024');
const [sheetCandidateCount, setSheetCandidateCount] = useState(2);
const [sheetSeed, setSheetSeed] = useState(2101);
const [sheetPromptExtend, setSheetPromptExtend] = useState(false);
const [sheetExtraDirection, setSheetExtraDirection] = useState(
'每格边界清晰,背景纯浅色,适合后续切帧。',
);
const [poseBoardSource, setPoseBoardSource] = useState('');
const [sheetDrafts, setSheetDrafts] = useState<QwenSpriteImageDraft[]>([]);
const [selectedSheetDraftId, setSelectedSheetDraftId] = useState('');
const [editedSheetSource, setEditedSheetSource] = useState('');
const [sheetStatus, setSheetStatus] = useState<string | null>(null);
const [isGeneratingSheet, setIsGeneratingSheet] = useState(false);
const [actionVideoPreviewPath, setActionVideoPreviewPath] = useState('');
const [useChromaKey, setUseChromaKey] = useState(true);
const [frameDataUrls, setFrameDataUrls] = useState<string[]>([]);
const [frameWidth, setFrameWidth] = useState(0);
@@ -172,10 +192,6 @@ export default function QwenSpriteSheetTool() {
const [repairIssueText, setRepairIssueText] = useState(
'修复手脚、武器和朝向,使这一帧与相邻帧连续。',
);
const [repairPromptText, setRepairPromptText] = useState('');
const [repairNegativePrompt, setRepairNegativePrompt] = useState(
DEFAULT_REPAIR_NEGATIVE_PROMPT,
);
const [repairModel, setRepairModel] = useState('qwen-image-2.0');
const [repairSeed, setRepairSeed] = useState(3101);
const [repairPromptExtend, setRepairPromptExtend] = useState(false);
@@ -198,6 +214,58 @@ export default function QwenSpriteSheetTool() {
() => getActionTemplateById(actionTemplateId),
[actionTemplateId],
);
const masterPromptText = useMemo(
() => buildMasterPrompt(characterBrief),
[characterBrief],
);
const masterNegativePrompt = useMemo(
() => buildMasterNegativePrompt(characterBrief),
[characterBrief],
);
const masterModel = 'qwen-image-2.0';
const masterSize = '1024*1024';
const sheetNegativePrompt = useMemo(
() => buildSheetNegativePrompt(characterBrief),
[characterBrief],
);
const repairNegativePrompt = useMemo(
() => buildRepairNegativePrompt(characterBrief),
[characterBrief],
);
const sheetModel = 'qwen-image-2.0';
const sheetSize = '1024*1024';
const resolvedActionAnimationState = useMemo(
() => mapActionTemplateIdToAnimationState(actionTemplateId),
[actionTemplateId],
);
const sheetPromptText = useMemo(
() =>
buildSheetPrompt({
characterBrief,
actionTemplate,
extraDirection: `动作细节描述:${actionDetailText.trim()}`,
}),
[actionDetailText, actionTemplate, characterBrief],
);
const repairPromptText = useMemo(
() =>
buildRepairPrompt({
issueText: repairIssueText,
useNeighborLabel:
repairNeighborDirection === 'previous' ? '上一帧' : '下一帧',
}),
[repairIssueText, repairNeighborDirection],
);
const videoActionPrompt = useMemo(
() =>
buildVideoActionPrompt({
actionTemplate,
actionDetailText,
characterBrief,
useChromaKey,
}),
[actionDetailText, actionTemplate, characterBrief, useChromaKey],
);
const previewFrames = useMemo(
() => buildOrderedActiveFrameSources(frameDataUrls, frameOrder, activeFrames),
[activeFrames, frameDataUrls, frameOrder],
@@ -208,6 +276,32 @@ export default function QwenSpriteSheetTool() {
setFps(actionTemplate.defaultFps);
}, [actionTemplate]);
useEffect(() => {
let isMounted = true;
void buildPlayableCharacterStyleReferenceBoard()
.then((nextBoard) => {
if (!isMounted) {
return;
}
setStyleReferenceBoardSource(nextBoard);
})
.catch((error) => {
if (!isMounted) {
return;
}
setMasterStatus(
error instanceof Error
? error.message
: '构建默认风格参考板失败。',
);
});
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
editorStateShapeRef.current = {
activeLength: activeFrames.length,
@@ -215,34 +309,6 @@ export default function QwenSpriteSheetTool() {
};
}, [activeFrames.length, frameOrder.length]);
useEffect(() => {
if (!sheetPromptText.trim()) {
setSheetPromptText(
buildSheetPrompt({
characterBrief,
actionTemplate,
extraDirection: sheetExtraDirection,
}),
);
}
}, [actionTemplate, characterBrief, sheetExtraDirection, sheetPromptText]);
useEffect(() => {
if (!repairPromptText.trim()) {
setRepairPromptText(
buildRepairPrompt({
issueText: repairIssueText,
useNeighborLabel:
repairNeighborDirection === 'previous' ? '上一帧' : '下一帧',
}),
);
}
}, [
repairIssueText,
repairNeighborDirection,
repairPromptText,
]);
useEffect(() => {
if (!editedSheetSource) {
setFrameDataUrls([]);
@@ -303,40 +369,6 @@ export default function QwenSpriteSheetTool() {
return () => window.clearInterval(intervalId);
}, [fps, isPreviewPlaying, previewFrames]);
const handleMasterReferenceUpload = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) {
return;
}
const uploaded = await Promise.all(
Array.from(fileList)
.slice(0, 3)
.map((file) => readFileAsDataUrl(file)),
);
setMasterReferenceImages(uploaded);
if (!selectedMasterSource && uploaded[0]) {
setSelectedMasterSource(uploaded[0]);
}
setMasterStatus(`已载入 ${uploaded.length} 张参考图。`);
event.target.value = '';
};
const handlePoseBoardUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) {
return;
}
const firstFile = fileList[0];
if (!firstFile) {
return;
}
setPoseBoardSource(await readFileAsDataUrl(firstFile));
setSheetStatus('已载入动作参考板。');
event.target.value = '';
};
const handleGenerateMaster = async () => {
setIsGeneratingMaster(true);
setMasterStatus(null);
@@ -349,7 +381,9 @@ export default function QwenSpriteSheetTool() {
promptExtend: masterPromptExtend,
candidateCount: masterCandidateCount,
seed: masterSeed > 0 ? masterSeed : undefined,
referenceImages: masterReferenceImages,
referenceImages: styleReferenceBoardSource
? [styleReferenceBoardSource]
: [],
});
setMasterDrafts(result.drafts);
setSelectedMasterDraftId(result.drafts[0]?.id ?? '');
@@ -370,25 +404,95 @@ export default function QwenSpriteSheetTool() {
setIsGeneratingSheet(true);
setSheetStatus(null);
try {
const result = await generateQwenSpriteSheet({
promptText: sheetPromptText,
negativePrompt: sheetNegativePrompt,
model: sheetModel,
size: sheetSize,
promptExtend: sheetPromptExtend,
candidateCount: sheetCandidateCount,
seed: sheetSeed > 0 ? sheetSeed : undefined,
referenceImages: [
selectedMasterSource,
...(poseBoardSource ? [poseBoardSource] : []),
],
});
setSheetDrafts(result.drafts);
setSelectedSheetDraftId(result.drafts[0]?.id ?? '');
setEditedSheetSource(result.drafts[0]?.imageSrc ?? '');
setSheetStatus(`已生成 ${result.drafts.length} 张精灵表候选。`);
if (actionGenerationMode === 'image-to-video') {
const result = await generateCharacterAnimationDraft({
characterId: assetKey || 'qwen-sprite-tool',
strategy: 'image-to-video',
animation: actionKey,
promptText: videoActionPrompt,
visualSource: selectedMasterSource,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: 16,
fps: actionTemplate.defaultFps,
durationSeconds: FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS,
loop: actionTemplate.loop,
useChromaKey,
resolution: FIXED_IMAGE_TO_VIDEO_RESOLUTION,
imageSequenceModel: 'wan2.7-image-pro',
videoModel: FIXED_IMAGE_TO_VIDEO_MODEL,
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
});
if (result.strategy !== 'image-to-video') {
throw new Error('图生视频接口返回了非预期结果。');
}
const clip = await buildAnimationClipFromVideoSource(
result.previewVideoPath,
{
animation: resolvedActionAnimationState,
fps: actionTemplate.defaultFps,
loop: actionTemplate.loop,
frameCount: 16,
frameWidth: GENERATED_FRAME_WIDTH,
frameHeight: GENERATED_FRAME_HEIGHT,
applyChromaKey: useChromaKey,
},
);
const composedSheet = await composeSpriteSheetFromFrames(clip.frames, {
cols: 4,
rows: 4,
frameWidth: clip.frameWidth,
frameHeight: clip.frameHeight,
padToGrid: true,
});
const videoDraftId = `${assetKey || 'qwen-sprite'}-video-${Date.now()}`;
setActionVideoPreviewPath(result.previewVideoPath);
setSheetDrafts([
{
id: videoDraftId,
label: '图生视频抽帧结果',
imageSrc: composedSheet.dataUrl,
},
]);
setSelectedSheetDraftId(videoDraftId);
setEditedSheetSource(composedSheet.dataUrl);
setFps(actionTemplate.defaultFps);
setSheetStatus(
'图生视频已生成,并已自动抽帧为 16 帧精灵表草稿。',
);
} else {
const result = await generateQwenSpriteSheet({
promptText: sheetPromptText,
negativePrompt: sheetNegativePrompt,
model: sheetModel,
size: sheetSize,
promptExtend: sheetPromptExtend,
candidateCount: sheetCandidateCount,
seed: sheetSeed > 0 ? sheetSeed : undefined,
referenceImages: [selectedMasterSource],
});
setActionVideoPreviewPath('');
setSheetDrafts(result.drafts);
setSelectedSheetDraftId(result.drafts[0]?.id ?? '');
setEditedSheetSource(result.drafts[0]?.imageSrc ?? '');
setSheetStatus(`已生成 ${result.drafts.length} 张精灵表候选。`);
}
} catch (error) {
setSheetStatus(error instanceof Error ? error.message : '生成精灵表失败。');
const rawMessage =
error instanceof Error ? error.message : '生成精灵表失败。';
if (
actionGenerationMode === 'image-to-video' &&
/DataInspectionFailed/u.test(rawMessage)
) {
setSheetStatus(
'图生视频请求已到达模型侧,但触发了供应商内容审查。建议把动作细节描述改得更中性一些,减少暴力/未成年感/敏感词,再试一次;也可以临时切回“直接生成精灵表”方案。',
);
} else {
setSheetStatus(rawMessage);
}
} finally {
setIsGeneratingSheet(false);
}
@@ -488,6 +592,7 @@ export default function QwenSpriteSheetTool() {
sheetSource: composedSheet.dataUrl,
framesDataUrls: orderedActiveFrameSources,
metadata: {
generationMode: actionGenerationMode,
rows: composedSheet.rows,
cols: composedSheet.cols,
sourceFrameCount: frameDataUrls.length,
@@ -499,6 +604,8 @@ export default function QwenSpriteSheetTool() {
activeFrames,
frameOrder,
actionTemplateId,
actionDetailText,
previewVideoPath: actionVideoPreviewPath || undefined,
},
prompts: {
characterBrief,
@@ -506,6 +613,7 @@ export default function QwenSpriteSheetTool() {
masterNegativePrompt,
sheetPromptText,
sheetNegativePrompt,
videoActionPrompt,
repairPromptText,
repairNegativePrompt,
},
@@ -539,67 +647,39 @@ export default function QwenSpriteSheetTool() {
<SectionCard title="基础信息" description="保存时会落到 public/generated-qwen-sprites。">
<div className="space-y-4">
<TextField label="资产标识" value={assetKey} onChange={setAssetKey} />
<TextField label="动作标识" value={actionKey} onChange={setActionKey} />
<TextAreaField
label="角色描述"
value={characterBrief}
onChange={setCharacterBrief}
rows={4}
/>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{actionKey}
</div>
</div>
</SectionCard>
<SectionCard title="阶段 A主图" description="先产出一张适合做动作的标准角色图。">
<SectionCard title="阶段 A主图" description="这里只输入角色描述。工具会自动绑定项目内可扮演角色的像素风样式参考,并强制使用身体朝右的动作角色视角。">
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setMasterPromptText(buildMasterPrompt(characterBrief))}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10"
>
<Sparkles className="h-4 w-4" />
<span></span>
</button>
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10">
<Upload className="h-4 w-4" />
<span></span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={handleMasterReferenceUpload}
className="hidden"
/>
</label>
</div>
<TextAreaField label="主图提示词" value={masterPromptText} onChange={setMasterPromptText} rows={8} />
<TextAreaField
label="主图负向提示词"
value={masterNegativePrompt}
onChange={setMasterNegativePrompt}
rows={4}
label="角色描述与设定"
value={characterBrief}
onChange={setCharacterBrief}
rows={5}
placeholder="例如Q版大头身少女剑士头部占比更大明亮表情双刃或短剑轻装冒险服。"
/>
<SelectField label="模型" value={masterModel} onChange={setMasterModel} options={MODEL_OPTIONS} />
<SelectField
label="尺寸"
value={masterSize}
onChange={setMasterSize}
options={[
{ label: '1024 × 1024', value: '1024*1024' },
{ label: '1536 × 1536', value: '1536*1536' },
]}
/>
<NumberField label="候选数量" value={masterCandidateCount} onChange={setMasterCandidateCount} min={1} />
<NumberField label="种子" value={masterSeed} onChange={setMasterSeed} min={0} />
<label className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200">
<input
type="checkbox"
checked={masterPromptExtend}
onChange={(event) => setMasterPromptExtend(event.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/40"
/>
<span> prompt_extend</span>
</label>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
A Q版大头身
</div>
{styleReferenceBoardSource && (
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-400">
</div>
<div className="flex h-[220px] items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[linear-gradient(180deg,#171b23,#0d1117)] p-2">
<img
src={styleReferenceBoardSource}
alt="自动风格参考板"
className="h-full w-full object-contain pixelated"
style={{ imageRendering: 'pixelated' }}
/>
</div>
</div>
)}
<button
type="button"
onClick={() => void handleGenerateMaster()}
@@ -627,8 +707,18 @@ export default function QwenSpriteSheetTool() {
</div>
</SectionCard>
<SectionCard title="阶段 B精灵表" description="把动作翻成模板卡,再生成整张 4x4 / 16 帧。">
<SectionCard title="阶段 B动作生成" description="这里只选择动作类型并填写动作细节描述。工具会自动在底层拼接视频/精灵表提示词。">
<div className="space-y-4">
<SelectField
label="生成方案"
value={actionGenerationMode}
onChange={(value) =>
setActionGenerationMode(
value as 'direct-sheet' | 'image-to-video',
)
}
options={ACTION_GENERATION_MODE_OPTIONS}
/>
<SelectField
label="动作模板"
value={actionTemplateId}
@@ -641,63 +731,81 @@ export default function QwenSpriteSheetTool() {
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
{actionTemplate.label} / {actionTemplate.defaultFps} FPS / {actionTemplate.loop ? '循环动作' : '一次性动作'}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() =>
setSheetPromptText(
buildSheetPrompt({
characterBrief,
actionTemplate,
extraDirection: sheetExtraDirection,
}),
)
}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10"
>
<Sparkles className="h-4 w-4" />
<span></span>
</button>
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10">
<Upload className="h-4 w-4" />
<span></span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handlePoseBoardUpload}
className="hidden"
/>
</label>
</div>
<TextAreaField label="精灵表提示词" value={sheetPromptText} onChange={setSheetPromptText} rows={8} />
<TextAreaField label="附加动作要求" value={sheetExtraDirection} onChange={setSheetExtraDirection} rows={3} />
<TextAreaField
label="精灵表负向提示词"
value={sheetNegativePrompt}
onChange={setSheetNegativePrompt}
label="动作细节描述"
value={actionDetailText}
onChange={setActionDetailText}
rows={4}
placeholder="例如:先轻微下压蓄力,再迅速前踏横斩,收招干净,动作利落。"
/>
<SelectField label="模型" value={sheetModel} onChange={setSheetModel} options={MODEL_OPTIONS} />
<SelectField
label="尺寸"
value={sheetSize}
onChange={setSheetSize}
options={[
{ label: '1024 × 1024', value: '1024*1024' },
{ label: '1536 × 1536', value: '1536*1536' },
]}
<TextAreaField
label={
actionGenerationMode === 'image-to-video'
? '底层视频提示词预览(自动生成)'
: '底层精灵表提示词预览(自动生成)'
}
value={
actionGenerationMode === 'image-to-video'
? videoActionPrompt
: sheetPromptText
}
onChange={() => {}}
rows={6}
disabled
/>
<NumberField label="候选数量" value={sheetCandidateCount} onChange={setSheetCandidateCount} min={1} />
<NumberField label="种子" value={sheetSeed} onChange={setSheetSeed} min={0} />
<label className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200">
<input
type="checkbox"
checked={sheetPromptExtend}
onChange={(event) => setSheetPromptExtend(event.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/40"
/>
<span> prompt_extend</span>
</label>
{actionGenerationMode === 'image-to-video' ? (
<>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
`wan2.2-kf2v-flash` `480P` 16
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-1">{FIXED_IMAGE_TO_VIDEO_MODEL}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-1">{FIXED_IMAGE_TO_VIDEO_RESOLUTION}</div>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-1">{FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS} </div>
</div>
<label className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200">
<input
type="checkbox"
checked={useChromaKey}
onChange={(event) => setUseChromaKey(event.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/40"
/>
<span>绿便</span>
</label>
</>
) : (
<>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
</div>
<NumberField label="候选数量" value={sheetCandidateCount} onChange={setSheetCandidateCount} min={1} />
<NumberField label="种子" value={sheetSeed} onChange={setSheetSeed} min={0} />
<label className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200">
<input
type="checkbox"
checked={sheetPromptExtend}
onChange={(event) => setSheetPromptExtend(event.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/40"
/>
<span> prompt_extend</span>
</label>
</>
)}
<button
type="button"
onClick={() => void handleGenerateSheet()}
@@ -705,22 +813,14 @@ export default function QwenSpriteSheetTool() {
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 px-4 py-3 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
>
<Sparkles className="h-4 w-4" />
<span>{isGeneratingSheet ? '生成中...' : '生成精灵表候选'}</span>
<span>
{isGeneratingSheet
? '生成中...'
: actionGenerationMode === 'image-to-video'
? '生成动作视频并抽帧'
: '生成精灵表候选'}
</span>
</button>
{poseBoardSource && (
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-400">
</div>
<div className="flex h-[180px] items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[linear-gradient(180deg,#171b23,#0d1117)] p-2">
<img
src={poseBoardSource}
alt="动作参考板"
className="h-full w-full object-contain"
/>
</div>
</div>
)}
{sheetStatus && <StatusNote title="精灵表状态" message={sheetStatus} />}
</div>
</SectionCard>
@@ -753,9 +853,21 @@ export default function QwenSpriteSheetTool() {
</SectionCard>
)}
<SectionCard title="阶段 C编辑与修帧">
<SectionCard title="阶段 C视频抽帧结果 / 精灵表编辑">
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
<div className="space-y-4">
{actionVideoPreviewPath && (
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 text-sm font-medium text-white"></div>
<video
src={actionVideoPreviewPath}
controls
loop
muted
className="w-full rounded-xl border border-white/10 bg-black/30"
/>
</div>
)}
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-medium text-white"></div>
@@ -827,6 +939,7 @@ export default function QwenSpriteSheetTool() {
triggerJsonDownload(`${assetKey}-${actionKey}-metadata.json`, {
assetKey,
actionKey,
generationMode: actionGenerationMode,
rows: 4,
cols: 4,
sourceFrameCount: frameDataUrls.length,
@@ -838,6 +951,8 @@ export default function QwenSpriteSheetTool() {
activeFrames,
frameOrder,
actionTemplateId,
actionDetailText,
previewVideoPath: actionVideoPreviewPath || undefined,
})
}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10"
@@ -928,29 +1043,14 @@ export default function QwenSpriteSheetTool() {
<span></span>
</div>
<div className="space-y-4">
<button
type="button"
onClick={() =>
setRepairPromptText(
buildRepairPrompt({
issueText: repairIssueText,
useNeighborLabel:
repairNeighborDirection === 'previous' ? '上一帧' : '下一帧',
}),
)
}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10"
>
<Sparkles className="h-4 w-4" />
<span></span>
</button>
<TextAreaField label="问题描述" value={repairIssueText} onChange={setRepairIssueText} rows={3} />
<TextAreaField label="修帧提示词" value={repairPromptText} onChange={setRepairPromptText} rows={6} />
<TextAreaField label="修帧提示词(自动生成)" value={repairPromptText} onChange={() => {}} rows={6} disabled />
<TextAreaField
label="修帧负向提示词"
label="修帧负向提示词(固定)"
value={repairNegativePrompt}
onChange={setRepairNegativePrompt}
onChange={() => {}}
rows={4}
disabled
/>
<SelectField
label="连续性参考"

View File

@@ -1,12 +1,17 @@
import {
buildDefaultFrameOrder,
buildMasterNegativePrompt,
buildMasterPrompt,
buildOrderedActiveFrameIndices,
buildOrderedActiveFrameSources,
buildRepairNegativePrompt,
buildRepairPrompt,
buildSheetNegativePrompt,
buildSheetPrompt,
buildVideoActionPrompt,
getActionTemplateById,
moveFrameOrderItem,
PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
restoreAllFrames,
toggleActiveFrame,
} from './qwenSpriteSheetToolModel';
@@ -67,6 +72,27 @@ describe('qwenSpriteSheetToolModel', () => {
expect(prompt).toContain('2 到 3 头身');
});
it('strengthens non-human species traits for siren-like characters', () => {
const prompt = buildMasterPrompt('海妖,海洋歌者,蓝绿色鳞片,海妖耳鳍。');
const negativePrompt = buildMasterNegativePrompt(
'海妖,海洋歌者,蓝绿色鳞片,海妖耳鳍。',
);
expect(prompt).toContain('如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色');
expect(prompt).toContain('严格约束身体结构骨架');
expect(prompt).toContain('沿用参考图的人形动作角色身体结构');
expect(negativePrompt).toContain('不要机械地把主题词直接画成完整怪物本体');
});
it('teaches the model how to interpret jellyfish king concepts', () => {
const prompt = buildMasterPrompt('水母国王,半透明伞盖,荧光斑点,权杖。');
expect(prompt).toContain('示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色');
expect(prompt).toContain('水母主题的服装和配饰');
expect(prompt).toContain('水母权杖');
expect(prompt).toContain('而不是完整水母怪物本体');
});
it('builds a repair prompt that keeps chibi ratio', () => {
const prompt = buildRepairPrompt({
issueText: '修复头部和手部比例。',
@@ -76,4 +102,30 @@ describe('qwenSpriteSheetToolModel', () => {
expect(prompt).toContain('上一帧');
expect(prompt).toContain('大头身');
});
it('builds a video action prompt with pixel style constraints', () => {
const prompt = buildVideoActionPrompt({
actionTemplate: getActionTemplateById('run'),
actionDetailText: '跑步时上身前倾,手臂摆动明显。',
characterBrief: '海妖刺客,蓝绿色鳞片,鱼鳍耳。',
useChromaKey: true,
});
expect(prompt).toContain('动作视频');
expect(prompt).toContain('侧身朝右');
expect(prompt).toContain('像素风');
expect(prompt).toContain('绿幕');
expect(prompt).toContain('默认优先生成人形拟人化角色');
expect(prompt).toContain('Q版可爱的人形动作角色');
});
it('builds generic theme over-literalization negatives', () => {
expect(buildSheetNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
expect(buildRepairNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
});
it('contains built-in playable character style reference sources', () => {
expect(PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES.length).toBeGreaterThan(0);
expect(PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES.some((source) => source.includes('Girl Hero 1'))).toBe(true);
});
});

View File

@@ -27,6 +27,28 @@ export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
const CHIBI_STYLE_TEXT =
'Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
const PIXEL_STYLE_TEXT =
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
const STYLE_REFERENCE_SCOPE_TEXT =
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
const CONCEPT_INTERPRETATION_TEXT =
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
const HUMANLIKE_PRIORITY_TEXT =
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。';
const JELLYFISH_THEME_EXAMPLE_TEXT =
'示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。';
const CONCEPT_HIERARCHY_TEXT =
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
const CHIBI_CHARACTER_TEXT =
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。';
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[] = [
{
@@ -357,15 +379,55 @@ export async function composeSpriteSheetFromFrames(
};
}
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 动画。',
'画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
`风格要求:${CHIBI_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。`,
'单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
'画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
`风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
CONCEPT_INTERPRETATION_TEXT,
HUMANLIKE_PRIORITY_TEXT,
CONCEPT_HIERARCHY_TEXT,
JELLYFISH_THEME_EXAMPLE_TEXT,
characterBrief.trim(),
]
.filter(Boolean)
.join('\n\n');
.join('\n');
}
export function buildSheetPrompt(options: {
@@ -374,7 +436,11 @@ export function buildSheetPrompt(options: {
extraDirection: string;
}) {
return [
`使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet共 16 帧,展示同一个角色的连续动作。角色始终朝右,全身完整出现在每一个格子里,脚底始终可见,地面线高度基本一致,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。${CHIBI_STYLE_TEXT}`,
`使用图1作为风格参考。生成一张 4x4 的 sprite sheet共 16 帧,展示同一个角色的连续动作。角色始终朝右,主体完整出现在每一个格子里,底部轮廓稳定,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
CONCEPT_INTERPRETATION_TEXT,
HUMANLIKE_PRIORITY_TEXT,
CONCEPT_HIERARCHY_TEXT,
JELLYFISH_THEME_EXAMPLE_TEXT,
`动作名:${options.actionTemplate.label}`,
`是否循环:${options.actionTemplate.loop ? '是' : '否'}`,
`身体位移:${options.actionTemplate.bodyTravel}`,
@@ -394,13 +460,36 @@ export function buildRepairPrompt(options: {
useNeighborLabel: '上一帧' | '下一帧';
}) {
return [
`使用图1作为角色身份与服装武器的唯一标准参考图2的动作连续性修复图3这一个单帧。图2代表${options.useNeighborLabel}`,
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色始终朝右,全身完整,脚底位置稳定保持与图2连续并且与图1是同一个角色。${CHIBI_STYLE_TEXT} 修复图3中的错误使这一帧适合插回原来的 sprite sheet 中。`,
`使用图1作为风格参考参考图2的动作连续性修复图3这一个单帧。图2代表${options.useNeighborLabel}`,
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色始终朝右,主体完整,底部结构稳定保持与图2连续并且与图1是同一个角色。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${JELLYFISH_THEME_EXAMPLE_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同一角色始终侧身朝右镜头稳定轮廓清晰。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
CONCEPT_INTERPRETATION_TEXT,
HUMANLIKE_PRIORITY_TEXT,
CONCEPT_HIERARCHY_TEXT,
JELLYFISH_THEME_EXAMPLE_TEXT,
`动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`,
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
: '背景简洁纯净,无复杂场景。',
`动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
`角色设定:${options.characterBrief.trim()}`,
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
].join(' ');
}
export async function triggerDataUrlDownload(
filename: string,
dataUrl: string,
@@ -435,6 +524,18 @@ 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,

View File

@@ -5,6 +5,8 @@ import type {
} from './attributes';
import type { CharacterBackstoryRevealConfig } from './characters';
import {
AnimationState,
type CharacterAnimationConfig,
type EquipmentSlotId,
type ItemRarity,
type WorldTemplateType,
@@ -99,6 +101,87 @@ export interface CustomWorldLockState {
lockedFactionIds: string[];
}
export interface CustomWorldSemanticAnchor {
genreSignals: string[];
conflictForms: string[];
institutionTypes: string[];
tabooTypes: string[];
carrierTypes: string[];
forceSystemTypes: string[];
atmosphereTags: string[];
}
export interface CustomWorldRuleProfile {
attributeSchema: WorldAttributeSchema;
resourceLabels: {
hp: string;
mp: string;
maxHp: string;
maxMp: string;
damage: string;
guard: string;
range: string;
cooldown: string;
manaCost: string;
currency: string;
};
economyProfile: {
initialCurrency: number;
};
}
export interface RoleArchetypeProfile {
id: string;
label: string;
combatFocus: string;
narrativeFunction: string;
sourceRoleIds: string[];
sourceTemplateCharacterIds: string[];
tags: string[];
}
export interface SceneArchetypeBucket {
id: string;
label: string;
moodTags: string[];
keywords: string[];
referenceLandmarkIds: string[];
}
export interface CreatureArchetypeProfile {
id: string;
label: string;
threatStyle: string;
keywords: string[];
}
export interface CustomWorldReferenceProfile {
roleArchetypes: RoleArchetypeProfile[];
sceneBuckets: SceneArchetypeBucket[];
creatureArchetypes: CreatureArchetypeProfile[];
}
export interface CustomWorldExpressionProfile {
themePack: ThemePack;
presentationTone: string[];
namingDirectives: string[];
clueDirectives: string[];
revealDirectives: string[];
}
export interface CustomWorldCompatibilityProfile {
legacyTemplateWorldType?: WorldTemplateType | null;
migrationVersion: string;
}
export interface CustomWorldOwnedSettingLayers {
semanticAnchor: CustomWorldSemanticAnchor;
ruleProfile: CustomWorldRuleProfile;
expressionProfile: CustomWorldExpressionProfile;
referenceProfile: CustomWorldReferenceProfile;
compatibilityProfile?: CustomWorldCompatibilityProfile | null;
}
export interface CustomWorldRoleSkill {
id: string;
name: string;
@@ -132,6 +215,10 @@ export interface CustomWorldRoleProfile {
backstoryReveal: CharacterBackstoryRevealConfig;
skills: CustomWorldRoleSkill[];
initialItems: CustomWorldRoleInitialItem[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Partial<Record<AnimationState, CharacterAnimationConfig>>;
attributeProfile?: RoleAttributeProfile;
narrativeProfile?: ActorNarrativeProfile | null;
}
@@ -164,7 +251,6 @@ export interface CustomWorldPlayableNpc extends CustomWorldRoleProfile {
}
export interface CustomWorldNpc extends CustomWorldRoleProfile {
imageSrc?: string;
visual?: CustomWorldNpcVisual;
}
@@ -248,6 +334,7 @@ export interface CustomWorldProfile {
creatorIntent?: CustomWorldCreatorIntent | null;
anchorPack?: CustomWorldAnchorPack | null;
lockState?: CustomWorldLockState | null;
ownedSettingLayers?: CustomWorldOwnedSettingLayers | null;
generationMode?: CustomWorldGenerationMode | null;
generationStatus?: CustomWorldGenerationStatus | null;
scenarioPackId?: string | null;

View File

@@ -77,6 +77,7 @@ export interface QuestLogEntry {
issuerNpcId: string;
issuerNpcName: string;
sceneId: string | null;
chapterId?: string | null;
actId?: string | null;
threadId?: string | null;
contractId?: string | null;

View File

@@ -262,6 +262,8 @@ export interface ChapterState {
primaryThreadIds: string[];
stage: 'opening' | 'expansion' | 'turning_point' | 'climax' | 'aftermath';
chapterSummary: string;
sceneId?: string | null;
chapterQuestId?: string | null;
}
export interface JourneyBeat {
@@ -519,6 +521,7 @@ export interface StoryEngineMemoryState {
activeThreadIds: string[];
resolvedScarIds: string[];
recentCarrierIds: string[];
openedSceneChapterIds?: string[];
recentSignalIds?: string[];
recentCompanionReactions?: CompanionReactionRecord[];
currentChapter?: ChapterState | null;