Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -14,7 +14,8 @@ import {
} from '../data/customWorldSceneGraph';
import {
getAllCustomWorldSceneImages,
getDefaultCustomWorldSceneImage,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImage,
} from '../data/customWorldVisuals';
import {
type CustomWorldSceneImageResult,
@@ -24,6 +25,7 @@ import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
} from '../services/customWorld';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
AnimationState,
CustomWorldLandmark,
@@ -612,7 +614,25 @@ function SceneImageGenerationModal({
const [latestResult, setLatestResult] =
useState<CustomWorldSceneImageResult | null>(null);
const previewImageSrc = latestResult?.imageSrc ?? landmark.imageSrc;
const previewImageSrc = useMemo(() => {
if (latestResult?.imageSrc) {
return latestResult.imageSrc;
}
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === landmark.id,
);
return resolveCustomWorldLandmarkImage(
profile,
landmark,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== landmark.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [landmark, latestResult, profile]);
const handleGenerate = async () => {
if (!prompt.trim()) {
@@ -1238,6 +1258,29 @@ function WorldEditor({
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(profile);
const [isCampPresetPickerOpen, setIsCampPresetPickerOpen] = useState(false);
const [isCampAiGenerateOpen, setIsCampAiGenerateOpen] = useState(false);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedCampScene = useMemo(
() => resolveCustomWorldCampScene(draft),
[draft],
);
const resolvedCampImageSrc = useMemo(
() => resolveCustomWorldCampSceneImage(draft),
[draft],
);
const campSceneDraft = useMemo<CustomWorldLandmark>(
() => ({
id: 'custom-scene-camp',
name: resolvedCampScene.name,
description: resolvedCampScene.description,
dangerLevel: resolvedCampScene.dangerLevel,
imageSrc: resolvedCampScene.imageSrc,
sceneNpcIds: [],
connections: [],
}),
[resolvedCampScene],
);
return (
<ModalShell
@@ -1289,6 +1332,84 @@ function WorldEditor({
rows={3}
/>
</Field>
<Field label="开局归处名称">
<TextInput
value={resolvedCampScene.name}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
name: value,
},
}))
}
/>
</Field>
<Field label="开局归处描述">
<TextArea
value={resolvedCampScene.description}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
description: value,
},
}))
}
rows={4}
/>
</Field>
<Field label="开局归处危险度">
<TextInput
value={resolvedCampScene.dangerLevel}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
dangerLevel: value,
},
}))
}
/>
</Field>
<ImageField
label="开局归处背景"
value={resolvedCampImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: value || undefined,
},
}))
}
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
tone="landscape"
showInput={false}
previewOverlay={<SceneSparringPreview profile={draft} />}
footer={(
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
<ActionButton
label="预设选择"
onClick={() => setIsCampPresetPickerOpen(true)}
tone="sky"
/>
<ActionButton
label="智能生成"
onClick={() => setIsCampAiGenerateOpen(true)}
/>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
</div>
</div>
)}
/>
<Field label="玩家原始设定">
<TextArea
value={draft.settingText}
@@ -1298,6 +1419,38 @@ function WorldEditor({
rows={4}
/>
</Field>
{isCampPresetPickerOpen ? (
<ScenePresetPickerModal
selectedSrc={resolvedCampScene.imageSrc}
presetImages={presetImages}
onSelect={(value) =>
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: value,
},
}))
}
onClose={() => setIsCampPresetPickerOpen(false)}
/>
) : null}
{isCampAiGenerateOpen ? (
<SceneImageGenerationModal
profile={draft}
landmark={campSceneDraft}
onApply={(result) => {
setDraft((current) => ({
...current,
camp: {
...resolveCustomWorldCampScene(current),
imageSrc: result.imageSrc,
},
}));
}}
onClose={() => setIsCampAiGenerateOpen(false)}
/>
) : null}
<SaveBar
onClose={onClose}
onSave={() => {
@@ -1769,6 +1922,21 @@ function LandmarkEditor({
npc: CustomWorldNpc;
} | null>(null);
const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []);
const resolvedDraftImageSrc = useMemo(() => {
const landmarkIndex = profile.landmarks.findIndex(
(entry) => entry.id === draft.id,
);
return resolveCustomWorldLandmarkImage(
profile,
draft,
landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length,
profile.landmarks
.filter((entry) => entry.id !== draft.id)
.map((entry) => entry.imageSrc)
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
}, [draft, profile]);
const storyNpcById = useMemo(
() => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])),
[draftStoryNpcs],
@@ -1847,7 +2015,7 @@ function LandmarkEditor({
<div className="space-y-4">
<ImageField
label="场景图片"
value={draft.imageSrc}
value={resolvedDraftImageSrc}
onChange={(value) =>
setDraft((current) => ({
...current,
@@ -2367,11 +2535,7 @@ function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
name: `自定义场景${profile.landmarks.length + 1}`,
description: '',
dangerLevel: '中',
imageSrc: getDefaultCustomWorldSceneImage(
profile.id || profile.name,
profile.landmarks.length,
profile.templateWorldType,
),
imageSrc: undefined,
sceneNpcIds: profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
connections: previousLandmark
? [