This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

@@ -0,0 +1,318 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph';
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
import type {
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../../types';
import type { ResultTab } from '../CustomWorldEntityCatalog';
import type { RpgCreationEditorTarget } from '../rpg-creation-editor/RpgCreationEntityEditorModal';
export type EntityGenerationKind = 'playable' | 'story' | 'landmark';
export type PendingGeneratedEntity = {
id: string;
kind: EntityGenerationKind;
title: string;
progress: number;
phaseLabel: string;
};
export type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
function getCreateTargetByTab(
activeTab: ResultTab,
): RpgCreationEditorTarget | null {
if (activeTab === 'playable') return { kind: 'playable', mode: 'create' };
if (activeTab === 'story') return { kind: 'story', mode: 'create' };
if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' };
return null;
}
function getCreateLabelByTab(activeTab: ResultTab) {
if (activeTab === 'playable') return '新增可扮演角色';
if (activeTab === 'story') return '新增场景角色';
if (activeTab === 'landmarks') return '新增场景';
return '';
}
function createPendingGeneratedEntity(
kind: EntityGenerationKind,
): PendingGeneratedEntity {
return {
id: `pending-${kind}-${Date.now()}`,
kind,
title:
kind === 'playable'
? '新可扮演角色'
: kind === 'story'
? '新场景角色'
: '新场景',
progress: 8,
phaseLabel: '正在整理世界上下文',
};
}
function resolvePendingPhaseLabel(
kind: EntityGenerationKind,
progress: number,
) {
if (progress < 28) {
return '正在整理世界上下文';
}
if (progress < 72) {
return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构';
}
return '正在回写结果';
}
function prependPlayableNpc(
profile: CustomWorldProfile,
npc: CustomWorldPlayableNpc,
) {
return {
...profile,
playableNpcs: [npc, ...profile.playableNpcs],
} satisfies CustomWorldProfile;
}
function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) {
return {
...profile,
storyNpcs: [npc, ...profile.storyNpcs],
} satisfies CustomWorldProfile;
}
function prependLandmark(
profile: CustomWorldProfile,
landmark: CustomWorldLandmark,
) {
return {
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: [landmark, ...profile.landmarks],
storyNpcs: profile.storyNpcs,
}),
} satisfies CustomWorldProfile;
}
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 useRpgCreationResultActions(params: {
activeTab: ResultTab;
isGenerating: boolean;
onProfileChange: (profile: CustomWorldProfile) => void;
profile: CustomWorldProfile;
readOnly: boolean;
triggerRegenerate?: () => void;
}) {
const {
activeTab,
isGenerating,
onProfileChange,
profile,
readOnly,
triggerRegenerate,
} = params;
const [editorTarget, setEditorTarget] =
useState<RpgCreationEditorTarget | null>(null);
const [pendingGeneratedEntity, setPendingGeneratedEntity] =
useState<PendingGeneratedEntity | null>(null);
const [recentGeneratedIds, setRecentGeneratedIds] = useState<RecentGeneratedIds>(
{
playable: [],
story: [],
landmark: [],
},
);
const [localGenerationError, setLocalGenerationError] = useState<string | null>(
null,
);
const pendingProgressTimerRef = useRef<number | null>(null);
const createTarget = useMemo(
() => getCreateTargetByTab(activeTab),
[activeTab],
);
const createLabel = useMemo(
() => getCreateLabelByTab(activeTab),
[activeTab],
);
const stopPendingProgressTimer = () => {
if (pendingProgressTimerRef.current !== null) {
window.clearInterval(pendingProgressTimerRef.current);
pendingProgressTimerRef.current = null;
}
};
useEffect(() => () => stopPendingProgressTimer(), []);
const startPendingProgress = (kind: EntityGenerationKind) => {
stopPendingProgressTimer();
setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
pendingProgressTimerRef.current = window.setInterval(() => {
setPendingGeneratedEntity((current) => {
if (!current || current.kind !== kind) {
return current;
}
const nextProgress = Math.min(
current.progress + (current.progress < 56 ? 11 : 5),
88,
);
return {
...current,
progress: nextProgress,
phaseLabel: resolvePendingPhaseLabel(kind, nextProgress),
};
});
}, 520);
};
const finishPendingProgress = () => {
stopPendingProgressTimer();
setPendingGeneratedEntity(null);
};
const markGeneratedAsRecent = (
kind: EntityGenerationKind,
generatedId: string,
) => {
setRecentGeneratedIds((current) => ({
...current,
[kind]: [
generatedId,
...current[kind].filter((id) => id !== generatedId),
].slice(0, 6),
}));
};
const handleGenerateEntity = async (kind: EntityGenerationKind) => {
if (readOnly || isGenerating || pendingGeneratedEntity) {
return;
}
setLocalGenerationError(null);
startPendingProgress(kind);
try {
if (kind === 'playable') {
const nextNpc = await rpgCreationAssetClient.generatePlayableNpc({
profile,
});
onProfileChange(prependPlayableNpc(profile, nextNpc));
markGeneratedAsRecent('playable', nextNpc.id);
} else if (kind === 'story') {
const nextNpc = await rpgCreationAssetClient.generateStoryNpc({
profile,
});
onProfileChange(prependStoryNpc(profile, nextNpc));
markGeneratedAsRecent('story', nextNpc.id);
} else {
const nextLandmark = await rpgCreationAssetClient.generateLandmark({
profile,
});
onProfileChange(prependLandmark(profile, nextLandmark));
markGeneratedAsRecent('landmark', nextLandmark.id);
}
} catch (generationError) {
setLocalGenerationError(
generationError instanceof Error
? generationError.message
: '生成失败,请稍后重试。',
);
} finally {
finishPendingProgress();
}
};
const handleRegenerate = () => {
if (isGenerating || !triggerRegenerate) {
return;
}
const confirmed = window.confirm(
`确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息包括你修改和新增的所有内容。`,
);
if (!confirmed) {
return;
}
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 {
createLabel,
createTarget,
editorTarget,
handleDeleteLandmarks,
handleDeleteStoryNpcs,
handleGenerateEntity,
handleRegenerate,
localGenerationError,
pendingGeneratedEntity,
recentGeneratedIds,
setEditorTarget,
closeEditorTarget: () => setEditorTarget(null),
};
}