1
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user