创作数据流程收束
This commit is contained in:
@@ -1,807 +0,0 @@
|
||||
import {AnimatePresence, motion} from 'motion/react';
|
||||
import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import {getWorldCampScenePreset} from '../data/scenePresets';
|
||||
import {BottomTab} from '../hooks/useGameFlow';
|
||||
import {resolveActiveSceneActBlueprint} from '../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
type BattleRewardUi,
|
||||
type CharacterChatUi,
|
||||
type GoalFlowUi,
|
||||
type InventoryFlowUi,
|
||||
type NpcChatQuestOfferUi,
|
||||
type QuestFlowUi,
|
||||
type StoryGenerationNpcUi,
|
||||
} from '../hooks/useStoryGeneration';
|
||||
import {
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type CompanionRenderState,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../types';
|
||||
import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets';
|
||||
import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow';
|
||||
import {PreGameSelectionFlow} from './game-shell/PreGameSelectionFlow';
|
||||
import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './game-shell/useSceneTransitionModel';
|
||||
import {useGameShellViewModel} from './game-shell/useGameShellViewModel';
|
||||
import {GameCanvas} from './GameCanvas';
|
||||
import {PixelIcon} from './PixelIcon';
|
||||
|
||||
interface GameShellSessionProps {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
aiError: string | null;
|
||||
bottomTab: BottomTab;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
isMapOpen: boolean;
|
||||
setIsMapOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface GameShellStoryProps {
|
||||
displayedOptions: StoryOption[];
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
handleChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
handleMapTravelToScene: (sceneId: string) => boolean;
|
||||
npcUi: StoryGenerationNpcUi;
|
||||
characterChatUi: CharacterChatUi;
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
}
|
||||
|
||||
interface GameShellEntryProps {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleSaveAndExit: () => void;
|
||||
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
|
||||
handleBackToWorldSelect: () => void;
|
||||
handleCharacterSelect: (character: Character) => void;
|
||||
}
|
||||
|
||||
interface GameShellCompanionProps {
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
buildCompanionRenderStates: (state: GameState) => CompanionRenderState[];
|
||||
onBenchCompanion: (npcId: string) => void;
|
||||
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
|
||||
}
|
||||
|
||||
interface GameShellAudioProps {
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
}
|
||||
|
||||
interface GameShellProps {
|
||||
session: GameShellSessionProps;
|
||||
story: GameShellStoryProps;
|
||||
entry: GameShellEntryProps;
|
||||
companions: GameShellCompanionProps;
|
||||
audio: GameShellAudioProps;
|
||||
}
|
||||
|
||||
const AdventureEntityModal = lazy(async () => {
|
||||
const module = await import('./AdventureEntityModal');
|
||||
|
||||
return {
|
||||
default: module.AdventureEntityModal,
|
||||
};
|
||||
});
|
||||
|
||||
const CharacterChatModal = lazy(async () => {
|
||||
const module = await import('./CharacterChatModal');
|
||||
|
||||
return {
|
||||
default: module.CharacterChatModal,
|
||||
};
|
||||
});
|
||||
|
||||
const CompanionCampModal = lazy(async () => {
|
||||
const module = await import('./CompanionCampModal');
|
||||
|
||||
return {
|
||||
default: module.CompanionCampModal,
|
||||
};
|
||||
});
|
||||
|
||||
const MapModal = lazy(async () => {
|
||||
const module = await import('./MapModal');
|
||||
|
||||
return {
|
||||
default: module.MapModal,
|
||||
};
|
||||
});
|
||||
|
||||
const NpcModals = lazy(async () => {
|
||||
const module = await import('./NpcModals');
|
||||
|
||||
return {
|
||||
default: module.NpcModals,
|
||||
};
|
||||
});
|
||||
|
||||
const AdventurePanel = lazy(async () => {
|
||||
const module = await import('./AdventurePanel');
|
||||
|
||||
return {
|
||||
default: module.AdventurePanel,
|
||||
};
|
||||
});
|
||||
|
||||
const CharacterPanel = lazy(async () => {
|
||||
const module = await import('./CharacterPanel');
|
||||
|
||||
return {
|
||||
default: module.CharacterPanel,
|
||||
};
|
||||
});
|
||||
|
||||
const InventoryPanel = lazy(async () => {
|
||||
const module = await import('./InventoryPanel');
|
||||
|
||||
return {
|
||||
default: module.InventoryPanel,
|
||||
};
|
||||
});
|
||||
|
||||
function ModalLoadingFallback({
|
||||
label,
|
||||
onClose,
|
||||
}: {
|
||||
label: string;
|
||||
onClose?: (() => void) | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onClick={onClose ?? undefined}
|
||||
>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-modal-shell flex min-h-40 w-full max-w-md items-center justify-center px-6 py-8 text-center text-sm text-zinc-300 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelLoadingFallback({
|
||||
label,
|
||||
}: {
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="pixel-nine-slice flex min-h-0 flex-1 items-center justify-center px-4 py-6 text-center text-xs uppercase tracking-[0.24em] text-zinc-500" style={getNineSliceStyle(UI_CHROME.modalPanel)}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameShell({session, story, entry, companions, audio}: GameShellProps) {
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
} = session;
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
handleMapTravelToScene,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
} = story;
|
||||
const {
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
} = entry;
|
||||
const {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion,
|
||||
onActivateRosterCompanion,
|
||||
} = companions;
|
||||
const {musicVolume, onMusicVolumeChange} = audio;
|
||||
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const openingCampSceneId = useMemo(
|
||||
() => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null),
|
||||
[gameState.worldType],
|
||||
);
|
||||
const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal);
|
||||
const {
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
overlayPanel,
|
||||
openOverlayPanel,
|
||||
closeOverlayPanel,
|
||||
selectedSceneEntity,
|
||||
setSelectedSceneEntity,
|
||||
openPartyMemberDetails,
|
||||
closeAdventureEntityModal,
|
||||
showTeamModal,
|
||||
openCampModal,
|
||||
closeCampModal,
|
||||
resetForSaveAndExit,
|
||||
shouldMountAdventureEntityModal,
|
||||
shouldMountCampModal,
|
||||
shouldMountMapModal,
|
||||
shouldMountCharacterChatModal,
|
||||
shouldMountNpcModals,
|
||||
} = useGameShellViewModel({
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen: Boolean(characterChatUi.modal),
|
||||
hasNpcModalOpen,
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
sceneTransitionToken,
|
||||
setSceneTransitionDurations,
|
||||
beginSceneTransition,
|
||||
} = useSceneTransitionModel({
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
});
|
||||
const isCharacterSelectionStage =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const collapseTopStage = gameState.currentScene === 'Selection';
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const visibleStoryForRender = visibleCurrentStory;
|
||||
|
||||
const dialogueIndicator = useMemo(() => {
|
||||
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null;
|
||||
return {
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
|
||||
} as const;
|
||||
}, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]);
|
||||
|
||||
const characterChatSummaries = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]),
|
||||
),
|
||||
[gameState.characterChats],
|
||||
);
|
||||
|
||||
const visibleCompanionRenderStates = useMemo(
|
||||
() => buildCompanionRenderStates(visibleGameState),
|
||||
[buildCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const canvasCompanionRenderStates = useMemo(() => {
|
||||
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
|
||||
? visibleGameState.currentEncounter.id ?? null
|
||||
: null;
|
||||
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
|
||||
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
|
||||
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
|
||||
|
||||
const livePlayTimeMs = useMemo(
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
const activeSceneAct = useMemo(
|
||||
() => resolveActiveSceneActBlueprint({
|
||||
profile: visibleGameState.customWorldProfile,
|
||||
sceneId: visibleGameState.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: visibleGameState.storyEngineMemory,
|
||||
}),
|
||||
[
|
||||
visibleGameState.currentScenePreset?.id,
|
||||
visibleGameState.customWorldProfile,
|
||||
visibleGameState.storyEngineMemory,
|
||||
],
|
||||
);
|
||||
const activeSceneChapter = useMemo(() => {
|
||||
if (!visibleGameState.customWorldProfile || !visibleGameState.currentScenePreset?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
visibleGameState.customWorldProfile.sceneChapterBlueprints?.find(
|
||||
entry => entry.sceneId === visibleGameState.currentScenePreset?.id
|
||||
|| entry.linkedLandmarkIds.includes(visibleGameState.currentScenePreset?.id ?? ''),
|
||||
) ?? null
|
||||
);
|
||||
}, [
|
||||
visibleGameState.currentScenePreset?.id,
|
||||
visibleGameState.customWorldProfile,
|
||||
]);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() => ({
|
||||
playTimeMs: livePlayTimeMs,
|
||||
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
|
||||
questsAccepted: gameState.runtimeStats.questsAccepted,
|
||||
questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length,
|
||||
questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length,
|
||||
itemsUsed: gameState.runtimeStats.itemsUsed,
|
||||
scenesTraveled: gameState.runtimeStats.scenesTraveled,
|
||||
currentSceneName: visibleGameState.currentScenePreset?.name ?? 'Current Area',
|
||||
playerCurrency: visibleGameState.playerCurrency,
|
||||
inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0),
|
||||
inventoryStackCount: visibleGameState.playerInventory.length,
|
||||
activeCompanionCount: visibleGameState.companions.length,
|
||||
rosterCompanionCount: visibleGameState.roster.length,
|
||||
}),
|
||||
[
|
||||
gameState.runtimeStats.itemsUsed,
|
||||
gameState.runtimeStats.hostileNpcsDefeated,
|
||||
gameState.runtimeStats.questsAccepted,
|
||||
gameState.runtimeStats.scenesTraveled,
|
||||
livePlayTimeMs,
|
||||
visibleGameState.companions.length,
|
||||
visibleGameState.currentScenePreset?.name,
|
||||
visibleGameState.playerCurrency,
|
||||
visibleGameState.playerInventory,
|
||||
visibleGameState.quests,
|
||||
visibleGameState.roster.length,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
setClockNow(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter]);
|
||||
|
||||
const handleSceneTransitionChoice = useCallback((option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
}, [beginSceneTransition, handleChoice]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
<div className={`relative ${collapseTopStage ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
||||
{collapseTopStage ? null : (
|
||||
<GameCanvas
|
||||
scrollWorld={visibleGameState.scrollWorld}
|
||||
animationState={visibleGameState.animationState}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
encounter={visibleGameState.currentEncounter}
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
||||
playerX={visibleGameState.playerX}
|
||||
playerOffsetY={visibleGameState.playerOffsetY}
|
||||
playerFacing={visibleGameState.playerFacing}
|
||||
playerActionMode={visibleGameState.playerActionMode}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
activeCombatEffects={visibleGameState.activeCombatEffects}
|
||||
companions={canvasCompanionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
npcAffinityEffect={visibleStoryForRender?.npcAffinityEffect ?? null}
|
||||
onEntitySelect={setSelectedSceneEntity}
|
||||
onSceneNameClick={() => setIsMapOpen(true)}
|
||||
sceneTransitionPhase={sceneTransitionPhase}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
onSceneTransitionDurationsChange={setSceneTransitionDurations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
|
||||
style={{
|
||||
backgroundColor: isCharacterSelectionStage ? '#0d1016' : undefined,
|
||||
backgroundImage: isCharacterSelectionStage
|
||||
? undefined
|
||||
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
|
||||
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
|
||||
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{!gameState.worldType && (
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
gameState={gameState}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={handleStartNewGame}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gameState.worldType && !gameState.playerCharacter && (
|
||||
<motion.div
|
||||
key="character-select-shell"
|
||||
initial={{opacity: 0, y: 12}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -12}}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<CharacterSelectionFlow
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
onBack={() => {
|
||||
handleBackToWorldSelect();
|
||||
setSelectionStage('platform');
|
||||
}}
|
||||
onConfirm={handleCharacterSelect}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{visibleGameState.playerCharacter && visibleStoryForRender && (
|
||||
<motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col">
|
||||
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={() => setBottomTab('character')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'character' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'character' ? TAB_ICONS.character.active : TAB_ICONS.character.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">队伍</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('adventure')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'adventure' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'adventure' ? TAB_ICONS.adventure.active : TAB_ICONS.adventure.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">冒险</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('inventory')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(bottomTab === 'inventory' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">背包</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bottomTab === 'character' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
|
||||
<CharacterPanel
|
||||
worldType={visibleGameState.worldType}
|
||||
customWorldProfile={visibleGameState.customWorldProfile}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
playerEquipment={visibleGameState.playerEquipment}
|
||||
activeBuildBuffs={visibleGameState.activeBuildBuffs}
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
quests={visibleGameState.quests}
|
||||
companionArcStates={
|
||||
visibleGameState.storyEngineMemory?.companionArcStates ?? []
|
||||
}
|
||||
companionResolutions={
|
||||
visibleGameState.storyEngineMemory?.companionResolutions ?? []
|
||||
}
|
||||
onOpenCamp={openCampModal}
|
||||
onOpenCharacterChat={characterChatUi.openChat}
|
||||
chatSummaries={characterChatSummaries}
|
||||
onInspectMember={openPartyMemberDetails}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{bottomTab === 'adventure' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
|
||||
<AdventurePanel
|
||||
aiError={aiError}
|
||||
currentStory={visibleStoryForRender}
|
||||
isLoading={isLoading}
|
||||
displayedOptions={displayedOptions}
|
||||
hideOptions={shouldHideStoryOptions}
|
||||
canRefreshOptions={canRefreshOptions}
|
||||
onRefreshOptions={handleRefreshOptions}
|
||||
onChoice={handleSceneTransitionChoice}
|
||||
onSubmitNpcChatInput={handleNpcChatInput}
|
||||
onExitNpcChat={exitNpcChat}
|
||||
onOpenCharacter={() => openOverlayPanel('character')}
|
||||
onOpenInventory={() => openOverlayPanel('inventory')}
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
battleRewardUi={battleRewardUi}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
|
||||
chapterState={visibleGameState.chapterState ?? null}
|
||||
journeyBeat={
|
||||
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
|
||||
}
|
||||
currentSceneActTitle={activeSceneAct?.title ?? null}
|
||||
currentSceneActIndex={
|
||||
activeSceneChapter && activeSceneAct
|
||||
? (() => {
|
||||
const actIndex = activeSceneChapter.acts.findIndex(
|
||||
act => act.id === activeSceneAct.id,
|
||||
);
|
||||
return actIndex >= 0 ? actIndex + 1 : null;
|
||||
})()
|
||||
: null
|
||||
}
|
||||
currentSceneActCount={activeSceneChapter?.acts.length ?? null}
|
||||
statistics={adventureStatistics}
|
||||
musicVolume={musicVolume}
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
onSaveAndExit={() => {
|
||||
resetForSaveAndExit();
|
||||
handleSaveAndExit();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{bottomTab === 'inventory' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
||||
<InventoryPanel
|
||||
playerCharacter={visibleGameState.playerCharacter}
|
||||
worldType={visibleGameState.worldType}
|
||||
playerInventory={visibleGameState.playerInventory}
|
||||
playerCurrency={visibleGameState.playerCurrency}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
onUseItem={inventoryUi.useInventoryItem}
|
||||
onEquipItem={inventoryUi.equipInventoryItem}
|
||||
forgeRecipes={inventoryUi.forgeRecipes}
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
continueGameDigest={
|
||||
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
narrativeCodex={
|
||||
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
|
||||
}
|
||||
narrativeQaReport={
|
||||
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{shouldMountAdventureEntityModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载冒险详情..." onClose={closeAdventureEntityModal} />}>
|
||||
<AdventureEntityModal
|
||||
selection={selectedSceneEntity}
|
||||
gameState={gameState}
|
||||
onClose={closeAdventureEntityModal}
|
||||
onOpenCharacterChat={target => {
|
||||
closeAdventureEntityModal();
|
||||
characterChatUi.openChat(target);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{overlayPanel && gameState.playerCharacter && (
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onClick={closeOverlayPanel}
|
||||
>
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.96, y: 8}}
|
||||
animate={{opacity: 1, scale: 1, y: 0}}
|
||||
exit={{opacity: 0, scale: 0.96, y: 8}}
|
||||
transition={{duration: 0.18, ease: 'easeOut'}}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10 text-sm font-semibold text-white">{overlayPanel === 'character' ? '队伍' : '背包'}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeOverlayPanel}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 p-5">
|
||||
{overlayPanel === 'character' ? (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
|
||||
<CharacterPanel
|
||||
worldType={gameState.worldType}
|
||||
customWorldProfile={gameState.customWorldProfile}
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
playerHp={gameState.playerHp}
|
||||
playerMaxHp={gameState.playerMaxHp}
|
||||
playerMana={gameState.playerMana}
|
||||
playerMaxMana={gameState.playerMaxMana}
|
||||
playerEquipment={gameState.playerEquipment}
|
||||
activeBuildBuffs={gameState.activeBuildBuffs}
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={gameState.npcStates}
|
||||
quests={gameState.quests}
|
||||
onOpenCamp={() => {
|
||||
closeOverlayPanel();
|
||||
openCampModal();
|
||||
}}
|
||||
onOpenCharacterChat={target => {
|
||||
closeOverlayPanel();
|
||||
characterChatUi.openChat(target);
|
||||
}}
|
||||
chatSummaries={characterChatSummaries}
|
||||
onInspectMember={openPartyMemberDetails}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
||||
<InventoryPanel
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
worldType={gameState.worldType}
|
||||
playerInventory={gameState.playerInventory}
|
||||
playerCurrency={gameState.playerCurrency}
|
||||
playerHp={gameState.playerHp}
|
||||
playerMaxHp={gameState.playerMaxHp}
|
||||
playerMana={gameState.playerMana}
|
||||
playerMaxMana={gameState.playerMaxMana}
|
||||
inBattle={gameState.inBattle}
|
||||
onUseItem={inventoryUi.useInventoryItem}
|
||||
onEquipItem={inventoryUi.equipInventoryItem}
|
||||
forgeRecipes={inventoryUi.forgeRecipes}
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{shouldMountCampModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载队伍营地..." onClose={closeCampModal} />}>
|
||||
<CompanionCampModal
|
||||
isOpen={showTeamModal}
|
||||
playerCharacter={gameState.playerCharacter}
|
||||
companions={gameState.companions}
|
||||
roster={gameState.roster}
|
||||
inBattle={gameState.inBattle}
|
||||
onClose={closeCampModal}
|
||||
onBenchCompanion={onBenchCompanion}
|
||||
onActivateCompanion={onActivateRosterCompanion}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldMountMapModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载地图..." onClose={() => setIsMapOpen(false)} />}>
|
||||
<MapModal
|
||||
isOpen={isMapOpen}
|
||||
currentScenePreset={gameState.currentScenePreset}
|
||||
worldType={gameState.worldType}
|
||||
canTravel={!gameState.inBattle && !isLoading}
|
||||
onTravelToScene={scene => {
|
||||
const triggered = handleMapTravelToScene(scene.id);
|
||||
if (triggered) {
|
||||
setIsMapOpen(false);
|
||||
}
|
||||
}}
|
||||
isTraveling={isLoading}
|
||||
onClose={() => setIsMapOpen(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldMountCharacterChatModal && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载角色聊天..." onClose={characterChatUi.closeChat} />}>
|
||||
<CharacterChatModal
|
||||
modal={characterChatUi.modal}
|
||||
onClose={characterChatUi.closeChat}
|
||||
onDraftChange={characterChatUi.setDraft}
|
||||
onUseSuggestion={characterChatUi.useSuggestion}
|
||||
onRefreshSuggestions={characterChatUi.refreshSuggestions}
|
||||
onSendDraft={characterChatUi.sendDraft}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldMountNpcModals && (
|
||||
<Suspense fallback={<ModalLoadingFallback label="正在加载场景角色交互..." />}>
|
||||
<NpcModals gameState={gameState} npcUi={npcUi} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
getStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
@@ -20,7 +20,6 @@ const authMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
@@ -31,9 +30,9 @@ vi.mock('../../services/authService', () => ({
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: vi.fn(),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
@@ -76,8 +75,11 @@ const mockUser: AuthUser = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.getStoredAccessToken.mockReturnValue(null);
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
|
||||
authMocks.sendPhoneLoginCode.mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
getStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
@@ -228,12 +227,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setShowLoginModal(true);
|
||||
}
|
||||
|
||||
const token = getStoredAccessToken();
|
||||
if (!token) {
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isActive) {
|
||||
@@ -241,9 +234,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
if (!nextSession.user) {
|
||||
setUser(null);
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
setStatus('unauthenticated');
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('clarification panel shows pending questions and ready state', () => {
|
||||
const pendingHtml = renderToStaticMarkup(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: ['world_hook'],
|
||||
missingKeys: ['player_premise', 'core_conflict'],
|
||||
}}
|
||||
pendingClarifications={[
|
||||
{
|
||||
id: 'player_premise',
|
||||
label: '玩家身份与开局',
|
||||
question: '玩家是谁,故事开场时卡在什么处境里?',
|
||||
targetKey: 'player_premise',
|
||||
priority: 2,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const readyHtml = renderToStaticMarkup(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
}}
|
||||
pendingClarifications={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(pendingHtml).toContain('待补充问题');
|
||||
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
|
||||
expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
|
||||
});
|
||||
|
||||
test('falls back to stable keys when clarification ids are empty', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: [],
|
||||
missingKeys: ['player_premise', 'core_conflict'],
|
||||
}}
|
||||
pendingClarifications={[
|
||||
{
|
||||
id: '',
|
||||
label: '玩家身份与开局',
|
||||
question: '玩家是谁,故事开场时卡在什么处境里?',
|
||||
targetKey: 'player_premise',
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
label: '核心冲突',
|
||||
question: '第一阶段最直接撞上的冲突是什么?',
|
||||
targetKey: 'core_conflict',
|
||||
priority: 1,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/玩家身份与开局/u)).toBeTruthy();
|
||||
expect(screen.getByText(/核心冲突/u)).toBeTruthy();
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldPendingClarification,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentClarificationPanelProps = {
|
||||
pendingClarifications: CustomWorldPendingClarification[];
|
||||
readiness: CreatorIntentReadiness;
|
||||
};
|
||||
|
||||
export function CustomWorldAgentClarificationPanel({
|
||||
pendingClarifications,
|
||||
readiness,
|
||||
}: CustomWorldAgentClarificationPanelProps) {
|
||||
if (readiness.isReady) {
|
||||
return (
|
||||
<section className="rounded-[1.5rem] border border-emerald-300/18 bg-emerald-500/8 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-emerald-100/80">
|
||||
下一阶段
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
当前设定已齐备,可以进入下一阶段
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
待补充问题
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
先补最关键的 1 到 3 项
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
|
||||
{pendingClarifications.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{pendingClarifications.slice(0, 3).map((item, index) => (
|
||||
<div
|
||||
key={item.id.trim() || `clarification-${item.targetKey}-${index}`}
|
||||
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{index + 1}. {item.label}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500">P{item.priority}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
{item.question}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
|
||||
|
||||
const CHARACTER_DETAIL: CustomWorldDraftCardDetail = {
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
title: '沈砺',
|
||||
sections: [
|
||||
{
|
||||
id: 'name',
|
||||
label: '角色名',
|
||||
value: '沈砺',
|
||||
},
|
||||
{
|
||||
id: 'publicMask',
|
||||
label: '外显身份',
|
||||
value: '守灯会里最熟悉旧航道的人。',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: '角色摘要',
|
||||
value: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['name', 'publicMask', 'summary'],
|
||||
warningMessages: [],
|
||||
assetStatus: 'missing',
|
||||
assetStatusLabel: '待生成主图',
|
||||
};
|
||||
|
||||
function DetailInteractionHarness() {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [generateMode, setGenerateMode] = useState<'character' | 'landmark' | null>(
|
||||
null,
|
||||
);
|
||||
const [savedPayload, setSavedPayload] = useState<string>('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={CHARACTER_DETAIL}
|
||||
loading={false}
|
||||
editMode={editMode}
|
||||
onClose={() => {}}
|
||||
onStartEdit={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
onCancelEdit={() => {
|
||||
setEditMode(false);
|
||||
}}
|
||||
onSave={(sections) => {
|
||||
setSavedPayload(JSON.stringify(sections));
|
||||
setEditMode(false);
|
||||
}}
|
||||
onGenerateCharacter={() => {
|
||||
setGenerateMode('character');
|
||||
}}
|
||||
onGenerateLandmark={() => {
|
||||
setGenerateMode('landmark');
|
||||
}}
|
||||
onOpenRoleAssetStudio={() => {}}
|
||||
/>
|
||||
<CustomWorldGenerateEntityModal
|
||||
open={generateMode !== null}
|
||||
mode={generateMode ?? 'character'}
|
||||
anchorCardTitle={CHARACTER_DETAIL.title}
|
||||
onClose={() => {
|
||||
setGenerateMode(null);
|
||||
}}
|
||||
onSubmit={() => {
|
||||
setGenerateMode(null);
|
||||
}}
|
||||
/>
|
||||
<div data-testid="saved-payload">{savedPayload}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
test('draft detail panel supports edit save and opening generate modals', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DetailInteractionHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '编辑设定' }));
|
||||
const summaryInput = screen.getByLabelText('角色摘要');
|
||||
await user.clear(summaryInput);
|
||||
await user.type(summaryInput, '他像旧友,也像最早知道航道秘密的人。');
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
expect(screen.getByTestId('saved-payload').textContent).toContain(
|
||||
'他像旧友,也像最早知道航道秘密的人。',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '新增角色' }));
|
||||
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
|
||||
expect(screen.getByText('当前参考卡')).toBeTruthy();
|
||||
const closeButtons = screen.getAllByRole('button', { name: '关闭' });
|
||||
await user.click(closeButtons[closeButtons.length - 1]!);
|
||||
|
||||
expect(screen.getByRole('button', { name: '角色资产' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '新增场景' }));
|
||||
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
|
||||
test('draft detail panel renders sections and warnings', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'thread-1',
|
||||
kind: 'thread',
|
||||
title: '谁掌握航道解释权',
|
||||
sections: [
|
||||
{
|
||||
id: 'thread-type',
|
||||
label: '线程类型',
|
||||
value: '明线',
|
||||
},
|
||||
{
|
||||
id: 'thread-conflict',
|
||||
label: '冲突内容',
|
||||
value: '守灯会与沉船商盟正在争夺航道解释权。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['character-1', 'landmark-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['title', 'summary', 'conflictType', 'stakes'],
|
||||
warningMessages: ['这条线还缺少更明确的地点挂点。'],
|
||||
}}
|
||||
loading={false}
|
||||
onClose={() => {}}
|
||||
onStartEdit={() => {}}
|
||||
onGenerateCharacter={() => {}}
|
||||
onGenerateLandmark={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('谁掌握航道解释权');
|
||||
expect(html).toContain('线程类型');
|
||||
expect(html).toContain('守灯会与沉船商盟');
|
||||
expect(html).toContain('继续精修');
|
||||
expect(html).toContain('编辑设定');
|
||||
expect(html).toContain('新增角色');
|
||||
});
|
||||
|
||||
test('draft detail panel renders scene chapter label and background preview', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'scene-chapter-docks',
|
||||
kind: 'scene_chapter',
|
||||
title: '潮汐码头章节',
|
||||
sections: [
|
||||
{
|
||||
id: 'sceneName',
|
||||
label: '所属场景',
|
||||
value: '潮汐码头',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:backgroundImageSrc',
|
||||
label: '第 1 幕背景图',
|
||||
value: '/images/scene/docks-act-1.webp',
|
||||
},
|
||||
],
|
||||
linkedIds: ['landmark-docks', 'thread-smuggling'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['title', 'summary', 'act:act-docks-1:title'],
|
||||
warningMessages: [],
|
||||
}}
|
||||
loading={false}
|
||||
onClose={() => {}}
|
||||
onStartEdit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('场景章节');
|
||||
expect(html).toContain('第 1 幕背景图');
|
||||
expect(html).toContain('img');
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel';
|
||||
|
||||
type CustomWorldAgentDraftDetailPanelProps = {
|
||||
detail: CustomWorldDraftCardDetail | null;
|
||||
loading: boolean;
|
||||
busy?: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onStartEdit?: () => void;
|
||||
onCancelEdit?: () => void;
|
||||
onSave?: (
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
value: string;
|
||||
}>,
|
||||
) => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
onGenerateLandmark?: () => void;
|
||||
onOpenRoleAssetStudio?: () => void;
|
||||
};
|
||||
|
||||
function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
|
||||
if (kind === 'world') return '世界总卡';
|
||||
if (kind === 'camp') return '营地';
|
||||
if (kind === 'faction') return '势力';
|
||||
if (kind === 'character') return '角色';
|
||||
if (kind === 'landmark') return '地点';
|
||||
if (kind === 'thread') return '线程';
|
||||
if (kind === 'chapter') return '第一幕';
|
||||
if (kind === 'scene_chapter') return '场景章节';
|
||||
return '草稿卡';
|
||||
}
|
||||
|
||||
function ActionButton(props: {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'sky';
|
||||
}) {
|
||||
const { label, onClick, disabled = false, tone = 'default' } = props;
|
||||
|
||||
if (!onClick) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-3 py-1.5 text-[11px] transition ${
|
||||
tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
|
||||
} disabled:cursor-not-allowed disabled:opacity-45`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentDraftDetailPanel({
|
||||
detail,
|
||||
loading,
|
||||
busy = false,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSave,
|
||||
onGenerateCharacter,
|
||||
onGenerateLandmark,
|
||||
onOpenRoleAssetStudio,
|
||||
}: CustomWorldAgentDraftDetailPanelProps) {
|
||||
const shouldRenderImagePreview = (
|
||||
detailKind: CustomWorldDraftCardDetail['kind'],
|
||||
sectionId: string,
|
||||
value: string,
|
||||
) =>
|
||||
detailKind === 'scene_chapter' &&
|
||||
sectionId.endsWith(':backgroundImageSrc') &&
|
||||
value !== '待继续精修';
|
||||
|
||||
return (
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
卡片详情
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{loading ? '正在读取' : detail?.title || '选择一张草稿卡'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-white/8 bg-white/5 px-4 py-5 text-sm leading-7 text-zinc-300">
|
||||
正在整理这张卡的内容。
|
||||
</div>
|
||||
) : detail ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
|
||||
{resolveKindLabel(detail.kind)}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
|
||||
关联 {detail.linkedIds.length}
|
||||
</span>
|
||||
{detail.editable ? (
|
||||
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
可编辑
|
||||
</span>
|
||||
) : null}
|
||||
{detail.kind === 'character' && detail.assetStatusLabel ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
{detail.assetStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!editMode && detail.editable ? (
|
||||
<ActionButton
|
||||
label="编辑设定"
|
||||
onClick={onStartEdit}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
{!editMode && detail.kind === 'character' ? (
|
||||
<ActionButton
|
||||
label="角色资产"
|
||||
onClick={onOpenRoleAssetStudio}
|
||||
disabled={busy}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
{!editMode ? (
|
||||
<>
|
||||
<ActionButton
|
||||
label="新增角色"
|
||||
onClick={onGenerateCharacter}
|
||||
disabled={busy}
|
||||
tone="sky"
|
||||
/>
|
||||
<ActionButton
|
||||
label="新增场景"
|
||||
onClick={onGenerateLandmark}
|
||||
disabled={busy}
|
||||
tone="sky"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editMode && onSave && onCancelEdit ? (
|
||||
<CustomWorldDraftEditPanel
|
||||
detail={detail}
|
||||
disabled={busy}
|
||||
onSave={onSave}
|
||||
onCancel={onCancelEdit}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{detail.sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
{section.label}
|
||||
</div>
|
||||
{shouldRenderImagePreview(detail.kind, section.id, section.value) ? (
|
||||
<img
|
||||
src={section.value}
|
||||
alt={section.label}
|
||||
className="mt-3 h-40 w-full rounded-[1rem] border border-white/10 object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100">
|
||||
{section.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.warningMessages.length > 0 ? (
|
||||
<div className="rounded-[1.15rem] border border-amber-300/20 bg-amber-500/10 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-amber-100">
|
||||
继续精修
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.warningMessages.map((message, index) => (
|
||||
<div
|
||||
key={`${detail.id}-warning-${index}`}
|
||||
className="text-sm leading-7 text-amber-50"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm leading-7 text-zinc-400">
|
||||
从草稿抽屉里点开一张卡,就能在这里看世界底稿的具体内容。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { CustomWorldDraftCardSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentDraftDrawerProps = {
|
||||
draftCards: CustomWorldDraftCardSummary[];
|
||||
activeCardId?: string | null;
|
||||
onSelectCard: (cardId: string) => void;
|
||||
};
|
||||
|
||||
const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
|
||||
'world',
|
||||
'chapter',
|
||||
'scene_chapter',
|
||||
'thread',
|
||||
'faction',
|
||||
'character',
|
||||
'landmark',
|
||||
'camp',
|
||||
];
|
||||
|
||||
function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
|
||||
if (kind === 'world') return '世界总卡';
|
||||
if (kind === 'chapter') return '第一幕';
|
||||
if (kind === 'scene_chapter') return '场景章节';
|
||||
if (kind === 'thread') return '世界线程';
|
||||
if (kind === 'faction') return '势力';
|
||||
if (kind === 'character') return '关键角色';
|
||||
if (kind === 'landmark') return '关键地点';
|
||||
if (kind === 'camp') return '营地';
|
||||
return '草稿卡';
|
||||
}
|
||||
|
||||
export function CustomWorldAgentDraftDrawer({
|
||||
draftCards,
|
||||
activeCardId,
|
||||
onSelectCard,
|
||||
}: CustomWorldAgentDraftDrawerProps) {
|
||||
const groupedCards = DRAWER_KIND_ORDER.map((kind) => ({
|
||||
kind,
|
||||
items: draftCards.filter((card) => card.kind === kind),
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
草稿抽屉
|
||||
</div>
|
||||
{groupedCards.length > 0 ? (
|
||||
<div className="mt-3 space-y-4">
|
||||
{groupedCards.map((group) => (
|
||||
<section key={group.kind}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] tracking-[0.18em] text-zinc-400">
|
||||
{resolveGroupLabel(group.kind)}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{group.items.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{group.items.map((card, index) => {
|
||||
const isActive = activeCardId === card.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={card.id || `${group.kind}-card-${index}`}
|
||||
type="button"
|
||||
onClick={() => onSelectCard(card.id)}
|
||||
className={`w-full rounded-[1.2rem] border px-3 py-3 text-left transition ${
|
||||
isActive
|
||||
? 'border-sky-300/30 bg-sky-500/10'
|
||||
: 'border-white/8 bg-white/5 hover:border-white/14'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
{card.warningCount > 0 ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
|
||||
{card.warningCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-400">
|
||||
{card.subtitle}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
{card.summary}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
|
||||
关联 {card.linkedIds.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
|
||||
{card.status === 'warning' ? '待精修' : '建议稿'}
|
||||
</span>
|
||||
{card.kind === 'character' && card.assetStatusLabel ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
{card.assetStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-400">
|
||||
当前设定收束后,世界底稿会先从这里长出来。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentIntentSummaryPanel } from './CustomWorldAgentIntentSummaryPanel';
|
||||
|
||||
test('intent summary panel shows collected custom world anchors', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentIntentSummaryPanel
|
||||
creatorIntent={{
|
||||
sourceMode: 'freeform',
|
||||
rawSettingText: '',
|
||||
worldHook: '一个被潮雾切开的列岛世界。',
|
||||
themeKeywords: ['海岛'],
|
||||
toneDirectives: ['冷峻'],
|
||||
playerPremise: '玩家是失职返乡的守灯人。',
|
||||
openingSituation: '开局站在即将熄灭的旧灯塔上。',
|
||||
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
|
||||
keyCharacters: [],
|
||||
iconicElements: ['潮雾钟声'],
|
||||
}}
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: ['relationship_seed'],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('已收集设定');
|
||||
expect(html).toContain('世界一句话');
|
||||
expect(html).toContain('一个被潮雾切开的列岛世界');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('5/6');
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { CreatorIntentReadiness } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
evaluateCustomWorldCreatorIntentReadiness,
|
||||
hasMeaningfulCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
|
||||
type CustomWorldAgentIntentSummaryPanelProps = {
|
||||
creatorIntent: Record<string, unknown> | null;
|
||||
readiness: CreatorIntentReadiness;
|
||||
};
|
||||
|
||||
export function CustomWorldAgentIntentSummaryPanel({
|
||||
creatorIntent,
|
||||
readiness,
|
||||
}: CustomWorldAgentIntentSummaryPanelProps) {
|
||||
const intent = normalizeCustomWorldCreatorIntent(creatorIntent);
|
||||
const resolvedReadiness =
|
||||
readiness ?? evaluateCustomWorldCreatorIntentReadiness(intent);
|
||||
const items = [
|
||||
{
|
||||
label: '世界一句话',
|
||||
value: intent?.worldHook || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('world_hook'),
|
||||
},
|
||||
{
|
||||
label: '玩家身份',
|
||||
value: intent?.playerPremise || '',
|
||||
ready: Boolean(intent?.playerPremise),
|
||||
},
|
||||
{
|
||||
label: '开局处境',
|
||||
value: intent?.openingSituation || '',
|
||||
ready: Boolean(intent?.openingSituation),
|
||||
},
|
||||
{
|
||||
label: '核心冲突',
|
||||
value: intent?.coreConflicts.join('、') || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('core_conflict'),
|
||||
},
|
||||
{
|
||||
label: '主题气质',
|
||||
value:
|
||||
[...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])]
|
||||
.filter(Boolean)
|
||||
.join('、') || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('theme_and_tone'),
|
||||
},
|
||||
{
|
||||
label: '标志性要素',
|
||||
value: intent?.iconicElements.join('、') || '',
|
||||
ready: resolvedReadiness.completedKeys.includes('iconic_element'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
已收集设定
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
|
||||
{resolvedReadiness.completedKeys.length}/6
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasMeaningfulCustomWorldCreatorIntent(intent) ? (
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`rounded-[1.15rem] border px-3 py-3 ${
|
||||
item.ready
|
||||
? 'border-emerald-300/18 bg-emerald-500/8'
|
||||
: 'border-white/8 bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-100">
|
||||
{item.value || '待补充'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400">
|
||||
还在收集你的世界设定
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
type CustomWorldAgentLauncherModalProps = {
|
||||
isOpen: boolean;
|
||||
seedText: string;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSeedTextChange: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function CustomWorldAgentLauncherModal({
|
||||
isOpen,
|
||||
seedText,
|
||||
isBusy,
|
||||
error,
|
||||
onClose,
|
||||
onSeedTextChange,
|
||||
onConfirm,
|
||||
}: CustomWorldAgentLauncherModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
开始和 Agent 共创
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
输入一段种子灵感,先进入新的工作区。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
Seed Text
|
||||
</div>
|
||||
<textarea
|
||||
value={seedText}
|
||||
onChange={(event) => onSeedTextChange(event.target.value)}
|
||||
rows={7}
|
||||
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
|
||||
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? '处理中...' : '开始共创'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
type CustomWorldAgentLockBarProps = {
|
||||
lockState: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
function readLockedItems(lockState: Record<string, unknown> | null) {
|
||||
if (!lockState) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(
|
||||
Object.entries(lockState)
|
||||
.flatMap(([key, value]) =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => `${key}:${item}`)
|
||||
: typeof value === 'string' && value.trim()
|
||||
? [`${key}:${value.trim()}`]
|
||||
: [],
|
||||
)
|
||||
)].slice(0, 8);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentLockBar({
|
||||
lockState,
|
||||
}: CustomWorldAgentLockBarProps) {
|
||||
const lockedItems = readLockedItems(lockState);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
锁定内容
|
||||
</div>
|
||||
{lockedItems.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{lockedItems.map((item, index) => (
|
||||
<span
|
||||
key={`locked-item-${index}-${item}`}
|
||||
className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-400">
|
||||
暂未锁定内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { CustomWorldSuggestedAction } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentQuickActionsProps = {
|
||||
suggestedActions: CustomWorldSuggestedAction[];
|
||||
disabled: boolean;
|
||||
canDraftFoundation: boolean;
|
||||
showEntityActions?: boolean;
|
||||
showSummaryAction?: boolean;
|
||||
onRequestSummary: () => void;
|
||||
onDraftFoundation: () => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
onGenerateLandmark?: () => void;
|
||||
onGenerateRoleAssets?: () => void;
|
||||
showRoleAssetAction?: boolean;
|
||||
onFocusSuggestedAction: (action?: CustomWorldSuggestedAction) => void;
|
||||
};
|
||||
|
||||
function QuickActionButton(props: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
tone?: 'default' | 'sky' | 'amber';
|
||||
}) {
|
||||
const { label, onClick, disabled, tone = 'default' } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-[1.1rem] border px-4 py-3 text-left text-sm transition disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
tone === 'amber'
|
||||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100 hover:text-white'
|
||||
: tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||||
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentQuickActions({
|
||||
suggestedActions,
|
||||
disabled,
|
||||
canDraftFoundation,
|
||||
showEntityActions = false,
|
||||
showSummaryAction = true,
|
||||
onRequestSummary,
|
||||
onDraftFoundation,
|
||||
onGenerateCharacter,
|
||||
onGenerateLandmark,
|
||||
onGenerateRoleAssets,
|
||||
showRoleAssetAction = false,
|
||||
onFocusSuggestedAction,
|
||||
}: CustomWorldAgentQuickActionsProps) {
|
||||
const summaryAction = suggestedActions.find(
|
||||
(action) => action.type === 'request_summary',
|
||||
);
|
||||
const draftAction = suggestedActions.find(
|
||||
(action) => action.type === 'draft_foundation',
|
||||
);
|
||||
const refinementActions = suggestedActions.filter(
|
||||
(action) =>
|
||||
action.type !== 'request_summary' && action.type !== 'draft_foundation',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
快捷动作
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{showSummaryAction ? (
|
||||
<QuickActionButton
|
||||
label={summaryAction?.label ?? '总结当前设定'}
|
||||
onClick={onRequestSummary}
|
||||
disabled={disabled}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
{draftAction && canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={draftAction.label}
|
||||
onClick={onDraftFoundation}
|
||||
disabled={disabled}
|
||||
tone="amber"
|
||||
/>
|
||||
) : null}
|
||||
{showEntityActions && onGenerateCharacter ? (
|
||||
<QuickActionButton
|
||||
label="新增角色"
|
||||
onClick={onGenerateCharacter}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
{showEntityActions && onGenerateLandmark ? (
|
||||
<QuickActionButton
|
||||
label="新增场景"
|
||||
onClick={onGenerateLandmark}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
{showRoleAssetAction && onGenerateRoleAssets ? (
|
||||
<QuickActionButton
|
||||
label="生成角色主图与动作"
|
||||
onClick={onGenerateRoleAssets}
|
||||
disabled={disabled}
|
||||
tone="amber"
|
||||
/>
|
||||
) : null}
|
||||
{refinementActions.length > 0 ? (
|
||||
refinementActions.slice(0, 2).map((action) => (
|
||||
<QuickActionButton
|
||||
key={action.id}
|
||||
label={action.label}
|
||||
onClick={() => onFocusSuggestedAction(action)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))
|
||||
) : !draftAction || !canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={showEntityActions ? '继续精修当前草稿' : '继续补充设定'}
|
||||
onClick={() => onFocusSuggestedAction()}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldAgentSummaryPanelProps = {
|
||||
session: CustomWorldAgentSessionSnapshot;
|
||||
};
|
||||
|
||||
function readSummaryText(
|
||||
draftProfile: Record<string, unknown> | null,
|
||||
fallback: string,
|
||||
) {
|
||||
const title =
|
||||
typeof draftProfile?.title === 'string' ? draftProfile.title.trim() : '';
|
||||
const summary =
|
||||
typeof draftProfile?.summary === 'string'
|
||||
? draftProfile.summary.trim()
|
||||
: '';
|
||||
|
||||
return {
|
||||
title: title || '世界摘要待整理',
|
||||
summary: summary || fallback,
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomWorldAgentSummaryPanel({
|
||||
session,
|
||||
}: CustomWorldAgentSummaryPanelProps) {
|
||||
const pendingCount = session.pendingClarifications.length;
|
||||
const { title, summary } = readSummaryText(
|
||||
session.draftProfile,
|
||||
'第一阶段先收住世界设定,后续阶段再把这里整理成更完整的世界底稿摘要。',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
顶部摘要
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
|
||||
消息 {session.messages.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
|
||||
待澄清 {pendingCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-300">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
|
||||
type CustomWorldDraftCardDetailModalProps = {
|
||||
open: boolean;
|
||||
detail: CustomWorldDraftCardDetail | null;
|
||||
loading: boolean;
|
||||
busy?: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onStartEdit?: () => void;
|
||||
onCancelEdit?: () => void;
|
||||
onSave?: (
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
value: string;
|
||||
}>,
|
||||
) => void;
|
||||
onGenerateCharacter?: () => void;
|
||||
onGenerateLandmark?: () => void;
|
||||
onOpenRoleAssetStudio?: () => void;
|
||||
};
|
||||
|
||||
export function CustomWorldDraftCardDetailModal({
|
||||
open,
|
||||
detail,
|
||||
loading,
|
||||
busy = false,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSave,
|
||||
onGenerateCharacter,
|
||||
onGenerateLandmark,
|
||||
onOpenRoleAssetStudio,
|
||||
}: CustomWorldDraftCardDetailModalProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭卡片详情"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<div className="relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto">
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={detail}
|
||||
loading={loading}
|
||||
busy={busy}
|
||||
editMode={editMode}
|
||||
onClose={onClose}
|
||||
onStartEdit={onStartEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSave={onSave}
|
||||
onGenerateCharacter={onGenerateCharacter}
|
||||
onGenerateLandmark={onGenerateLandmark}
|
||||
onOpenRoleAssetStudio={onOpenRoleAssetStudio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
|
||||
test('draft detail panel renders editable form in edit mode', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
title: '沈砺',
|
||||
sections: [
|
||||
{
|
||||
id: 'name',
|
||||
label: '角色名',
|
||||
value: '沈砺',
|
||||
},
|
||||
{
|
||||
id: 'publicMask',
|
||||
label: '外显身份',
|
||||
value: '守灯会里最熟悉旧航道的人。',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: '角色摘要',
|
||||
value: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['name', 'publicMask', 'summary'],
|
||||
warningMessages: [],
|
||||
}}
|
||||
loading={false}
|
||||
editMode
|
||||
onClose={() => {}}
|
||||
onCancelEdit={() => {}}
|
||||
onSave={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('保存');
|
||||
expect(html).toContain('取消');
|
||||
expect(html).toContain('角色名');
|
||||
expect(html).toContain('textarea');
|
||||
});
|
||||
|
||||
test('draft detail panel uses textarea for scene chapter act narrative fields', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={{
|
||||
id: 'scene-chapter-docks',
|
||||
kind: 'scene_chapter',
|
||||
title: '潮汐码头章节',
|
||||
sections: [
|
||||
{
|
||||
id: 'title',
|
||||
label: '场景章节标题',
|
||||
value: '潮汐码头章节',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:summary',
|
||||
label: '第 1 幕摘要',
|
||||
value: '玩家刚抵达时,林潮先决定要不要放行。',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:encounterNpcIds',
|
||||
label: '第 1 幕相遇 NPC',
|
||||
value: '林潮\n晏九',
|
||||
},
|
||||
{
|
||||
id: 'act:act-docks-1:transitionHook',
|
||||
label: '第 1 幕过渡钩子',
|
||||
value: '确认站位后,真正的封锁者会压上来。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-smuggling'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: [
|
||||
'title',
|
||||
'act:act-docks-1:summary',
|
||||
'act:act-docks-1:encounterNpcIds',
|
||||
'act:act-docks-1:transitionHook',
|
||||
],
|
||||
warningMessages: [],
|
||||
}}
|
||||
loading={false}
|
||||
editMode
|
||||
onClose={() => {}}
|
||||
onCancelEdit={() => {}}
|
||||
onSave={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('第 1 幕摘要');
|
||||
expect(html).toContain('第 1 幕相遇 NPC');
|
||||
expect(html).toContain('第 1 幕过渡钩子');
|
||||
expect(html).toContain('textarea');
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
|
||||
type CustomWorldDraftEditPanelProps = {
|
||||
detail: CustomWorldDraftCardDetail;
|
||||
disabled?: boolean;
|
||||
onSave: (
|
||||
sections: Array<{
|
||||
sectionId: string;
|
||||
value: string;
|
||||
}>,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function shouldUseTextarea(sectionId: string, value: string) {
|
||||
const sceneActField = sectionId.match(/^act:[^:]+:(.+)$/u)?.[1] ?? null;
|
||||
return (
|
||||
value.length > 28 ||
|
||||
value.includes('\n') ||
|
||||
sectionId === 'summary' ||
|
||||
sectionId === 'tone' ||
|
||||
sectionId === 'coreConflicts' ||
|
||||
sectionId === 'hiddenHook' ||
|
||||
sectionId === 'secret' ||
|
||||
sectionId === 'stakes' ||
|
||||
sectionId === 'openingEvent' ||
|
||||
sectionId === 'understandingShift' ||
|
||||
sectionId === 'description' ||
|
||||
sceneActField === 'summary' ||
|
||||
sceneActField === 'encounterNpcIds' ||
|
||||
sceneActField === 'actGoal' ||
|
||||
sceneActField === 'transitionHook'
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldDraftEditPanel({
|
||||
detail,
|
||||
disabled = false,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: CustomWorldDraftEditPanelProps) {
|
||||
const editableSections = useMemo(
|
||||
() =>
|
||||
detail.sections.filter((section) =>
|
||||
detail.editableSectionIds.includes(section.id),
|
||||
),
|
||||
[detail],
|
||||
);
|
||||
const [draftValues, setDraftValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
editableSections.map((section) => [section.id, section.value]),
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValues(
|
||||
Object.fromEntries(editableSections.map((section) => [section.id, section.value])),
|
||||
);
|
||||
}, [editableSections]);
|
||||
|
||||
if (editableSections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{editableSections.map((section) => {
|
||||
const value = draftValues[section.id] ?? '';
|
||||
const multiline = shouldUseTextarea(section.id, value);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={section.id}
|
||||
className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
{section.label}
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setDraftValues((current) => ({
|
||||
...current,
|
||||
[section.id]: nextValue,
|
||||
}));
|
||||
}}
|
||||
rows={4}
|
||||
disabled={disabled}
|
||||
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setDraftValues((current) => ({
|
||||
...current,
|
||||
[section.id]: nextValue,
|
||||
}));
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="mt-2 h-11 w-full rounded-[0.9rem] border border-white/10 bg-black/26 px-3 text-sm text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(
|
||||
editableSections.map((section) => ({
|
||||
sectionId: section.id,
|
||||
value: draftValues[section.id] ?? '',
|
||||
})),
|
||||
);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type CustomWorldGenerateEntityModalProps = {
|
||||
open: boolean;
|
||||
mode: 'character' | 'landmark';
|
||||
anchorCardTitle?: string | null;
|
||||
disabled?: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: {
|
||||
count: number;
|
||||
promptText: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export function CustomWorldGenerateEntityModal({
|
||||
open,
|
||||
mode,
|
||||
anchorCardTitle,
|
||||
disabled = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: CustomWorldGenerateEntityModalProps) {
|
||||
const [count, setCount] = useState(2);
|
||||
const [promptText, setPromptText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(2);
|
||||
setPromptText('');
|
||||
}, [open, mode]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = mode === 'character' ? '新增角色' : '新增场景';
|
||||
const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[96] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭新增弹窗"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 cursor-default"
|
||||
/>
|
||||
<div className="platform-modal-shell platform-remap-surface relative z-10 w-full max-w-xl rounded-[1.8rem] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
AI 扩写
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{title}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{anchorCardTitle ? (
|
||||
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
当前参考卡
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-zinc-100">{anchorCardTitle}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">数量</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{[1, 2, 3].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setCount(value)}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border px-4 py-2 text-sm transition ${
|
||||
count === value
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
|
||||
} disabled:cursor-not-allowed disabled:opacity-45`}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
|
||||
补充要求
|
||||
</div>
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={(event) => setPromptText(event.target.value)}
|
||||
rows={5}
|
||||
disabled={disabled}
|
||||
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSubmit({
|
||||
count,
|
||||
promptText: promptText.trim(),
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,10 @@ type CustomWorldCreationHubProps = {
|
||||
|
||||
function EmptyState({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-white">{title}</div>
|
||||
<div className="platform-subpanel flex min-h-[14rem] flex-col items-center justify-center rounded-[1.6rem] px-6 py-8 text-center">
|
||||
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,35 +54,26 @@ export function CustomWorldCreationHub({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div
|
||||
className="platform-remap-surface sticky top-0 z-20 -mx-3 px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(180deg, var(--platform-modal-fill), transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="pb-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="mt-4 text-[1.8rem] font-black leading-tight text-white sm:text-[2.3rem]">
|
||||
<div className="mt-4 text-[1.8rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2.3rem]">
|
||||
创作中心
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden shrink-0 gap-2 sm:flex">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
|
||||
<span className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
草稿 {draftCount}
|
||||
</span>
|
||||
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已发布 {publishedCount}
|
||||
</span>
|
||||
</div>
|
||||
@@ -98,12 +91,12 @@ export function CustomWorldCreationHub({
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-3xl border border-rose-400/18 bg-rose-500/10 px-4 py-4 text-sm leading-7 text-rose-100">
|
||||
<div className="platform-banner platform-banner--danger rounded-[1.4rem] px-4 py-4 text-sm leading-7">
|
||||
<div>{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-3 rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
|
||||
className="platform-button platform-button--ghost mt-3 min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
@@ -115,15 +108,15 @@ export function CustomWorldCreationHub({
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}`}
|
||||
className="min-h-[12rem] rounded-[1.8rem] border border-white/8 bg-white/5 p-5"
|
||||
className="platform-subpanel min-h-[12rem] rounded-[1.6rem] p-5"
|
||||
>
|
||||
<div className="h-4 w-20 rounded-full bg-white/10" />
|
||||
<div className="mt-6 h-8 w-36 rounded-full bg-white/10" />
|
||||
<div className="mt-4 h-4 w-full rounded-full bg-white/10" />
|
||||
<div className="mt-2 h-4 w-4/5 rounded-full bg-white/10" />
|
||||
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-6 h-8 w-36 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-4 h-4 w-full rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="mt-8 flex gap-2">
|
||||
<div className="h-7 w-20 rounded-full bg-white/10" />
|
||||
<div className="h-7 w-20 rounded-full bg-white/10" />
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import type { CustomWorldQuestion } from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
type CustomWorldCreationLauncherModalProps = {
|
||||
isOpen: boolean;
|
||||
mode: 'create' | 'resume';
|
||||
seedText: string;
|
||||
seedTextLocked: boolean;
|
||||
questions: CustomWorldQuestion[];
|
||||
answers: Record<string, string>;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
lastError?: string | null;
|
||||
primaryLabel: string;
|
||||
onClose: () => void;
|
||||
onSeedTextChange: (value: string) => void;
|
||||
onAnswerChange: (questionId: string, value: string) => void;
|
||||
onPrimaryAction: () => void;
|
||||
};
|
||||
|
||||
export function CustomWorldCreationLauncherModal({
|
||||
isOpen,
|
||||
mode,
|
||||
seedText,
|
||||
seedTextLocked,
|
||||
questions,
|
||||
answers,
|
||||
isBusy,
|
||||
error,
|
||||
lastError = null,
|
||||
primaryLabel,
|
||||
onClose,
|
||||
onSeedTextChange,
|
||||
onAnswerChange,
|
||||
onPrimaryAction,
|
||||
}: CustomWorldCreationLauncherModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="platform-modal-shell platform-remap-surface flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
{mode === 'create' ? '新建作品' : '继续创作'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
输入一点灵感,开始共创一个新世界。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
世界灵感
|
||||
</div>
|
||||
<textarea
|
||||
value={seedText}
|
||||
onChange={(event) => onSeedTextChange(event.target.value)}
|
||||
rows={seedTextLocked ? 4 : 6}
|
||||
readOnly={seedTextLocked}
|
||||
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
|
||||
className={`w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 ${
|
||||
seedTextLocked ? 'cursor-not-allowed opacity-75' : ''
|
||||
}`}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{unansweredQuestions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-3xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-zinc-300">
|
||||
先补齐几条关键锚点,再开始生成。
|
||||
</div>
|
||||
{unansweredQuestions.map((question) => (
|
||||
<label key={question.id} className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">
|
||||
{question.label}
|
||||
</div>
|
||||
<div className="mb-2 text-xs leading-6 text-zinc-400">
|
||||
{question.question}
|
||||
</div>
|
||||
<textarea
|
||||
value={answers[question.id] ?? question.answer ?? ''}
|
||||
onChange={(event) =>
|
||||
onAnswerChange(question.id, event.target.value)
|
||||
}
|
||||
rows={3}
|
||||
placeholder="补充一句就可以。"
|
||||
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lastError ? (
|
||||
<div className="rounded-3xl border border-amber-400/25 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
|
||||
上次生成未完成:{lastError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrimaryAction}
|
||||
disabled={isBusy}
|
||||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? '处理中...' : primaryLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
onCreateNew: () => void;
|
||||
};
|
||||
@@ -8,35 +6,22 @@ export function CustomWorldCreationStartCard({
|
||||
onCreateNew,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[1.75rem] border border-white/8 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.28),rgba(8,10,14,0.82))] px-5 py-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
新建作品
|
||||
</div>
|
||||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-5 py-5">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
新建作品
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateNew}
|
||||
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 18,
|
||||
paddingY: 11,
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateNew}
|
||||
className="platform-button platform-button--primary w-full justify-between rounded-[1.1rem] text-left sm:w-auto"
|
||||
>
|
||||
<span className="text-sm font-semibold">新建作品</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
@@ -31,76 +30,71 @@ export function CustomWorldWorkCard({
|
||||
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel relative overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 15,
|
||||
})}
|
||||
>
|
||||
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem]">
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={item.coverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel={item.title.slice(0, 4) || '封面'}
|
||||
fallbackLabel="封面"
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="absolute inset-0"
|
||||
className="platform-cover-artwork absolute inset-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span
|
||||
className={`rounded-full border px-3 py-1 text-[10px] tracking-[0.18em] ${
|
||||
className={`platform-pill px-3 py-1 text-[10px] ${
|
||||
isDraft
|
||||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
|
||||
: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
|
||||
? 'platform-pill--warm'
|
||||
: 'platform-pill--success'
|
||||
}`}
|
||||
>
|
||||
{isDraft ? '草稿' : '已发布'}
|
||||
</span>
|
||||
{item.stageLabel ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{item.stageLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400">
|
||||
<div className="shrink-0 text-[11px] text-[var(--platform-text-soft)]">
|
||||
{formatUpdatedAt(item.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-black text-white">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs tracking-[0.12em] text-zinc-400">
|
||||
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-7 text-zinc-200/90">
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)]">
|
||||
{item.summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-3 pt-4">
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{roleCountLabel} {item.playableNpcCount}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
地点 {item.landmarkCount}
|
||||
</span>
|
||||
{item.roleVisualReadyCount ? (
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] text-amber-100">
|
||||
<span className="platform-pill platform-pill--warm px-3 py-1 text-[10px]">
|
||||
主图 {item.roleVisualReadyCount}
|
||||
</span>
|
||||
) : null}
|
||||
{item.roleAnimationReadyCount ? (
|
||||
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
|
||||
动作 {item.roleAnimationReadyCount}
|
||||
</span>
|
||||
) : null}
|
||||
{item.roleAssetSummaryLabel ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{item.roleAssetSummaryLabel}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -108,7 +102,7 @@ export function CustomWorldWorkCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white"
|
||||
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
{isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'}
|
||||
</button>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CustomWorldWorkTabs({
|
||||
onChange,
|
||||
}: CustomWorldWorkTabsProps) {
|
||||
return (
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{FILTER_OPTIONS.map((option) => {
|
||||
const count =
|
||||
option.id === 'draft'
|
||||
@@ -37,10 +37,8 @@ export function CustomWorldWorkTabs({
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`shrink-0 rounded-full border px-4 py-2 text-sm transition ${
|
||||
activeFilter === option.id
|
||||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/18 text-zinc-300 hover:text-white'
|
||||
className={`platform-tab shrink-0 px-4 py-2 text-sm ${
|
||||
activeFilter === option.id ? 'platform-tab--active' : ''
|
||||
}`}
|
||||
>
|
||||
{option.label} {count}
|
||||
|
||||
@@ -19,7 +19,13 @@ import {
|
||||
UserPlus,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { type ComponentType, useMemo } from 'react';
|
||||
import {
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
@@ -52,6 +58,47 @@ const MOBILE_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-4 pb-2';
|
||||
const DESKTOP_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface space-y-5 pb-4';
|
||||
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
|
||||
|
||||
function usePlatformDesktopLayout() {
|
||||
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(DESKTOP_LAYOUT_QUERY).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(DESKTOP_LAYOUT_QUERY);
|
||||
const updateLayout = (event?: MediaQueryListEvent) => {
|
||||
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateLayout();
|
||||
|
||||
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', updateLayout);
|
||||
return () => mediaQuery.removeEventListener('change', updateLayout);
|
||||
}
|
||||
|
||||
mediaQuery.addListener(updateLayout);
|
||||
return () => mediaQuery.removeListener(updateLayout);
|
||||
}, []);
|
||||
|
||||
return isDesktopLayout;
|
||||
}
|
||||
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
@@ -653,6 +700,7 @@ export function PlatformHomeView({
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
onOpenProfileDashboardCard,
|
||||
createTabContent,
|
||||
}: {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
@@ -679,9 +727,11 @@ export function PlatformHomeView({
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||||
createTabContent?: ReactNode;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const featuredShelf = useMemo(
|
||||
() => featuredEntries.slice(0, 6),
|
||||
[featuredEntries],
|
||||
@@ -732,7 +782,7 @@ export function PlatformHomeView({
|
||||
const desktopReleaseGrid = latestEntries.slice(0, 6);
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
|
||||
let content = (
|
||||
let content: ReactNode = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -824,59 +874,62 @@ export function PlatformHomeView({
|
||||
);
|
||||
|
||||
if (activeTab === 'create') {
|
||||
content = (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="platform-pill platform-pill--cool w-fit">
|
||||
CREATE
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">开启新的创作</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span>选择类型并继续</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
content =
|
||||
createTabContent ?? (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="platform-pill platform-pill--cool w-fit">
|
||||
CREATE
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
开启新的创作
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span>选择类型并继续</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<CreationLibraryCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf
|
||||
text={
|
||||
isAuthenticated
|
||||
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
|
||||
: '登录后查看你的作品。'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<CreationLibraryCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf
|
||||
text={
|
||||
isAuthenticated
|
||||
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
|
||||
: '登录后查看你的作品。'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'saves') {
|
||||
@@ -1117,7 +1170,7 @@ export function PlatformHomeView({
|
||||
);
|
||||
}
|
||||
|
||||
const desktopContent =
|
||||
const desktopContent: ReactNode =
|
||||
activeTab === 'home' ? (
|
||||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||||
{platformError ? (
|
||||
@@ -1415,9 +1468,9 @@ export function PlatformHomeView({
|
||||
content
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col lg:hidden">
|
||||
if (!isDesktopLayout) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4">
|
||||
<PlatformBrandLogo />
|
||||
</div>
|
||||
@@ -1461,8 +1514,12 @@ export function PlatformHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="hidden h-full min-h-0 lg:flex lg:flex-col">
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
|
||||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||||
|
||||
@@ -1540,6 +1540,64 @@ export function PreGameSelectionFlow({
|
||||
customWorldWorkEntries.length > 0
|
||||
? customWorldWorkEntries
|
||||
: buildCreationHubFallbackItems(savedCustomWorldEntries);
|
||||
const creationHubContent = (
|
||||
<CustomWorldCreationHub
|
||||
items={creationHubItems}
|
||||
loading={isLoadingPlatform}
|
||||
error={isLoadingPlatform ? null : (platformError ?? creationTypeError)}
|
||||
onBack={() => {
|
||||
setPlatformTab('home');
|
||||
}}
|
||||
onRetry={() => {
|
||||
setPlatformError(null);
|
||||
void refreshCustomWorldWorks().catch((error) => {
|
||||
setPlatformError(
|
||||
resolveErrorMessage(error, '读取创作作品列表失败。'),
|
||||
);
|
||||
});
|
||||
}}
|
||||
onCreateNew={openCreationTypePicker}
|
||||
onResumeDraft={(sessionId) => {
|
||||
runProtectedAction(() => {
|
||||
void handleOpenCreationWork({
|
||||
workId: `draft:${sessionId}`,
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
publishedAt: null,
|
||||
stage: null,
|
||||
stageLabel: '',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId,
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
});
|
||||
});
|
||||
}}
|
||||
onEnterPublished={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
const matchedWork = creationHubItems.find(
|
||||
(entry) => entry.profileId === profileId,
|
||||
);
|
||||
if (!matchedWork) {
|
||||
return;
|
||||
}
|
||||
void handleOpenCreationWork(matchedWork);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1552,106 +1610,48 @@ export function PreGameSelectionFlow({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
{platformTab === 'create' ? (
|
||||
<CustomWorldCreationHub
|
||||
items={creationHubItems}
|
||||
loading={isLoadingPlatform}
|
||||
error={isLoadingPlatform ? null : (platformError ?? creationTypeError)}
|
||||
onBack={() => {
|
||||
setPlatformTab('home');
|
||||
}}
|
||||
onRetry={() => {
|
||||
setPlatformError(null);
|
||||
void refreshCustomWorldWorks().catch((error) => {
|
||||
setPlatformError(
|
||||
resolveErrorMessage(error, '读取创作作品列表失败。'),
|
||||
);
|
||||
});
|
||||
}}
|
||||
onCreateNew={openCreationTypePicker}
|
||||
onResumeDraft={(sessionId) => {
|
||||
runProtectedAction(() => {
|
||||
void handleOpenCreationWork({
|
||||
workId: `draft:${sessionId}`,
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
publishedAt: null,
|
||||
stage: null,
|
||||
stageLabel: '',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId,
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
});
|
||||
});
|
||||
}}
|
||||
onEnterPublished={(profileId) => {
|
||||
runProtectedAction(() => {
|
||||
const matchedWork = creationHubItems.find(
|
||||
(entry) => entry.profileId === profileId,
|
||||
);
|
||||
if (!matchedWork) {
|
||||
return;
|
||||
}
|
||||
void handleOpenCreationWork(matchedWork);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PlatformHomeView
|
||||
activeTab={platformTab}
|
||||
onTabChange={setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
historyEntries={historyEntries}
|
||||
profileDashboard={profileDashboard}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
<PlatformHomeView
|
||||
activeTab={platformTab}
|
||||
onTabChange={setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={saveEntries}
|
||||
saveError={saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={publishedGalleryEntries}
|
||||
myEntries={savedCustomWorldEntries}
|
||||
historyEntries={historyEntries}
|
||||
profileDashboard={profileDashboard}
|
||||
isLoadingPlatform={isLoadingPlatform}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||||
platformError={
|
||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||
}
|
||||
dashboardError={isLoadingDashboard ? null : dashboardError}
|
||||
createTabContent={creationHubContent}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
void handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCustomWorldCreator}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void openGalleryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenProfileDashboardCard={() => {
|
||||
if (dashboardError) {
|
||||
void refreshProfileDashboard();
|
||||
}
|
||||
dashboardError={isLoadingDashboard ? null : dashboardError}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
void handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCustomWorldCreator}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void openGalleryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onOpenProfileDashboardCard={() => {
|
||||
if (dashboardError) {
|
||||
void refreshProfileDashboard();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,822 +0,0 @@
|
||||
export const BUILD_TAG_SIMILARITY_PAIRS: Array<readonly [string, string, number]> = [
|
||||
['快剑', '连段', 0.5652],
|
||||
['快剑', '突进', 0.639],
|
||||
['快剑', '追击', 0.5853],
|
||||
['快剑', '快袭', 0.7138],
|
||||
['快剑', '远射', 0.5732],
|
||||
['快剑', '游击', 0.5988],
|
||||
['快剑', '机动', 0.6052],
|
||||
['快剑', '风行', 0.6161],
|
||||
['快剑', '重击', 0.6202],
|
||||
['快剑', '爆发', 0.5647],
|
||||
['快剑', '破甲', 0.614],
|
||||
['快剑', '压制', 0.4927],
|
||||
['快剑', '压血', 0.4749],
|
||||
['快剑', '守御', 0.5533],
|
||||
['快剑', '护体', 0.4756],
|
||||
['快剑', '重甲', 0.5429],
|
||||
['快剑', '反击', 0.5565],
|
||||
['快剑', '镇邪', 0.4676],
|
||||
['快剑', '法修', 0.4834],
|
||||
['快剑', '法力', 0.4449],
|
||||
['快剑', '雷法', 0.5596],
|
||||
['快剑', '符阵', 0.502],
|
||||
['快剑', '控场', 0.4504],
|
||||
['快剑', '过载', 0.488],
|
||||
['快剑', '回复', 0.4804],
|
||||
['快剑', '护持', 0.4336],
|
||||
['快剑', '续战', 0.5132],
|
||||
['快剑', '命纹', 0.4916],
|
||||
['快剑', '机缘', 0.3826],
|
||||
['快剑', '冷却', 0.5143],
|
||||
['快剑', '统御', 0.4761],
|
||||
['快剑', '均衡', 0.4664],
|
||||
['快剑', '工巧', 0.4952],
|
||||
['快剑', '炼药', 0.496],
|
||||
['快剑', '先锋', 0.4771],
|
||||
['快剑', '狂战', 0.647],
|
||||
['快剑', '法剑', 0.7139],
|
||||
['快剑', '圣佑', 0.4072],
|
||||
['快剑', '堡垒', 0.5619],
|
||||
['快剑', '起手', 0.4805],
|
||||
['连段', '突进', 0.6269],
|
||||
['连段', '追击', 0.689],
|
||||
['连段', '快袭', 0.6255],
|
||||
['连段', '远射', 0.5834],
|
||||
['连段', '游击', 0.6451],
|
||||
['连段', '机动', 0.608],
|
||||
['连段', '风行', 0.5415],
|
||||
['连段', '重击', 0.6386],
|
||||
['连段', '爆发', 0.645],
|
||||
['连段', '破甲', 0.6281],
|
||||
['连段', '压制', 0.6701],
|
||||
['连段', '压血', 0.5097],
|
||||
['连段', '守御', 0.5739],
|
||||
['连段', '护体', 0.5288],
|
||||
['连段', '重甲', 0.5223],
|
||||
['连段', '反击', 0.6474],
|
||||
['连段', '镇邪', 0.4724],
|
||||
['连段', '法修', 0.5649],
|
||||
['连段', '法力', 0.5241],
|
||||
['连段', '雷法', 0.5451],
|
||||
['连段', '符阵', 0.5724],
|
||||
['连段', '控场', 0.6077],
|
||||
['连段', '过载', 0.5676],
|
||||
['连段', '回复', 0.5579],
|
||||
['连段', '护持', 0.5517],
|
||||
['连段', '续战', 0.6264],
|
||||
['连段', '命纹', 0.5979],
|
||||
['连段', '机缘', 0.4965],
|
||||
['连段', '冷却', 0.5609],
|
||||
['连段', '统御', 0.5969],
|
||||
['连段', '均衡', 0.5373],
|
||||
['连段', '工巧', 0.5193],
|
||||
['连段', '炼药', 0.5167],
|
||||
['连段', '先锋', 0.5145],
|
||||
['连段', '狂战', 0.5901],
|
||||
['连段', '法剑', 0.5712],
|
||||
['连段', '圣佑', 0.4439],
|
||||
['连段', '堡垒', 0.5764],
|
||||
['连段', '起手', 0.5323],
|
||||
['突进', '追击', 0.7523],
|
||||
['突进', '快袭', 0.7773],
|
||||
['突进', '远射', 0.681],
|
||||
['突进', '游击', 0.7741],
|
||||
['突进', '机动', 0.7414],
|
||||
['突进', '风行', 0.6838],
|
||||
['突进', '重击', 0.6753],
|
||||
['突进', '爆发', 0.679],
|
||||
['突进', '破甲', 0.7068],
|
||||
['突进', '压制', 0.6313],
|
||||
['突进', '压血', 0.5655],
|
||||
['突进', '守御', 0.6259],
|
||||
['突进', '护体', 0.5488],
|
||||
['突进', '重甲', 0.564],
|
||||
['突进', '反击', 0.6671],
|
||||
['突进', '镇邪', 0.5221],
|
||||
['突进', '法修', 0.5476],
|
||||
['突进', '法力', 0.4857],
|
||||
['突进', '雷法', 0.5302],
|
||||
['突进', '符阵', 0.6334],
|
||||
['突进', '控场', 0.5836],
|
||||
['突进', '过载', 0.6108],
|
||||
['突进', '回复', 0.589],
|
||||
['突进', '护持', 0.5765],
|
||||
['突进', '续战', 0.622],
|
||||
['突进', '命纹', 0.5123],
|
||||
['突进', '机缘', 0.546],
|
||||
['突进', '冷却', 0.5704],
|
||||
['突进', '统御', 0.5823],
|
||||
['突进', '均衡', 0.5443],
|
||||
['突进', '工巧', 0.5262],
|
||||
['突进', '炼药', 0.5517],
|
||||
['突进', '先锋', 0.6528],
|
||||
['突进', '狂战', 0.7068],
|
||||
['突进', '法剑', 0.5439],
|
||||
['突进', '圣佑', 0.4391],
|
||||
['突进', '堡垒', 0.6254],
|
||||
['突进', '起手', 0.6149],
|
||||
['追击', '快袭', 0.7158],
|
||||
['追击', '远射', 0.667],
|
||||
['追击', '游击', 0.7451],
|
||||
['追击', '机动', 0.6764],
|
||||
['追击', '风行', 0.6563],
|
||||
['追击', '重击', 0.65],
|
||||
['追击', '爆发', 0.6452],
|
||||
['追击', '破甲', 0.672],
|
||||
['追击', '压制', 0.6919],
|
||||
['追击', '压血', 0.5459],
|
||||
['追击', '守御', 0.6761],
|
||||
['追击', '护体', 0.6079],
|
||||
['追击', '重甲', 0.521],
|
||||
['追击', '反击', 0.7187],
|
||||
['追击', '镇邪', 0.5213],
|
||||
['追击', '法修', 0.5634],
|
||||
['追击', '法力', 0.5432],
|
||||
['追击', '雷法', 0.5044],
|
||||
['追击', '符阵', 0.6157],
|
||||
['追击', '控场', 0.5958],
|
||||
['追击', '过载', 0.6295],
|
||||
['追击', '回复', 0.6354],
|
||||
['追击', '护持', 0.6463],
|
||||
['追击', '续战', 0.7218],
|
||||
['追击', '命纹', 0.5615],
|
||||
['追击', '机缘', 0.5268],
|
||||
['追击', '冷却', 0.6234],
|
||||
['追击', '统御', 0.6099],
|
||||
['追击', '均衡', 0.5722],
|
||||
['追击', '工巧', 0.4781],
|
||||
['追击', '炼药', 0.5361],
|
||||
['追击', '先锋', 0.5768],
|
||||
['追击', '狂战', 0.6197],
|
||||
['追击', '法剑', 0.5672],
|
||||
['追击', '圣佑', 0.5151],
|
||||
['追击', '堡垒', 0.5949],
|
||||
['追击', '起手', 0.5904],
|
||||
['快袭', '远射', 0.6641],
|
||||
['快袭', '游击', 0.7421],
|
||||
['快袭', '机动', 0.6528],
|
||||
['快袭', '风行', 0.6794],
|
||||
['快袭', '重击', 0.7028],
|
||||
['快袭', '爆发', 0.6717],
|
||||
['快袭', '破甲', 0.6879],
|
||||
['快袭', '压制', 0.6159],
|
||||
['快袭', '压血', 0.5831],
|
||||
['快袭', '守御', 0.6133],
|
||||
['快袭', '护体', 0.5434],
|
||||
['快袭', '重甲', 0.5328],
|
||||
['快袭', '反击', 0.6898],
|
||||
['快袭', '镇邪', 0.5335],
|
||||
['快袭', '法修', 0.5486],
|
||||
['快袭', '法力', 0.5028],
|
||||
['快袭', '雷法', 0.593],
|
||||
['快袭', '符阵', 0.5869],
|
||||
['快袭', '控场', 0.5595],
|
||||
['快袭', '过载', 0.5888],
|
||||
['快袭', '回复', 0.5748],
|
||||
['快袭', '护持', 0.5483],
|
||||
['快袭', '续战', 0.5473],
|
||||
['快袭', '命纹', 0.5426],
|
||||
['快袭', '机缘', 0.4854],
|
||||
['快袭', '冷却', 0.5745],
|
||||
['快袭', '统御', 0.5406],
|
||||
['快袭', '均衡', 0.5206],
|
||||
['快袭', '工巧', 0.5192],
|
||||
['快袭', '炼药', 0.5656],
|
||||
['快袭', '先锋', 0.5619],
|
||||
['快袭', '狂战', 0.6826],
|
||||
['快袭', '法剑', 0.5964],
|
||||
['快袭', '圣佑', 0.4988],
|
||||
['快袭', '堡垒', 0.5941],
|
||||
['快袭', '起手', 0.5629],
|
||||
['远射', '游击', 0.6795],
|
||||
['远射', '机动', 0.6245],
|
||||
['远射', '风行', 0.5979],
|
||||
['远射', '重击', 0.6841],
|
||||
['远射', '爆发', 0.5983],
|
||||
['远射', '破甲', 0.5703],
|
||||
['远射', '压制', 0.5474],
|
||||
['远射', '压血', 0.4747],
|
||||
['远射', '守御', 0.5923],
|
||||
['远射', '护体', 0.5016],
|
||||
['远射', '重甲', 0.5697],
|
||||
['远射', '反击', 0.5669],
|
||||
['远射', '镇邪', 0.4811],
|
||||
['远射', '法修', 0.5174],
|
||||
['远射', '法力', 0.4697],
|
||||
['远射', '雷法', 0.5274],
|
||||
['远射', '符阵', 0.5635],
|
||||
['远射', '控场', 0.562],
|
||||
['远射', '过载', 0.526],
|
||||
['远射', '回复', 0.5325],
|
||||
['远射', '护持', 0.5249],
|
||||
['远射', '续战', 0.643],
|
||||
['远射', '命纹', 0.4921],
|
||||
['远射', '机缘', 0.4417],
|
||||
['远射', '冷却', 0.4675],
|
||||
['远射', '统御', 0.5189],
|
||||
['远射', '均衡', 0.5349],
|
||||
['远射', '工巧', 0.5065],
|
||||
['远射', '炼药', 0.5366],
|
||||
['远射', '先锋', 0.5259],
|
||||
['远射', '狂战', 0.5781],
|
||||
['远射', '法剑', 0.5516],
|
||||
['远射', '圣佑', 0.4589],
|
||||
['远射', '堡垒', 0.5505],
|
||||
['远射', '起手', 0.5292],
|
||||
['游击', '机动', 0.7143],
|
||||
['游击', '风行', 0.6702],
|
||||
['游击', '重击', 0.6773],
|
||||
['游击', '爆发', 0.6322],
|
||||
['游击', '破甲', 0.6881],
|
||||
['游击', '压制', 0.624],
|
||||
['游击', '压血', 0.5784],
|
||||
['游击', '守御', 0.6592],
|
||||
['游击', '护体', 0.5893],
|
||||
['游击', '重甲', 0.5299],
|
||||
['游击', '反击', 0.7005],
|
||||
['游击', '镇邪', 0.5426],
|
||||
['游击', '法修', 0.5741],
|
||||
['游击', '法力', 0.5228],
|
||||
['游击', '雷法', 0.523],
|
||||
['游击', '符阵', 0.6419],
|
||||
['游击', '控场', 0.6207],
|
||||
['游击', '过载', 0.5284],
|
||||
['游击', '回复', 0.556],
|
||||
['游击', '护持', 0.5766],
|
||||
['游击', '续战', 0.6038],
|
||||
['游击', '命纹', 0.5388],
|
||||
['游击', '机缘', 0.5112],
|
||||
['游击', '冷却', 0.554],
|
||||
['游击', '统御', 0.5779],
|
||||
['游击', '均衡', 0.5303],
|
||||
['游击', '工巧', 0.568],
|
||||
['游击', '炼药', 0.5643],
|
||||
['游击', '先锋', 0.6093],
|
||||
['游击', '狂战', 0.7051],
|
||||
['游击', '法剑', 0.6137],
|
||||
['游击', '圣佑', 0.4791],
|
||||
['游击', '堡垒', 0.6628],
|
||||
['游击', '起手', 0.5949],
|
||||
['机动', '风行', 0.779],
|
||||
['机动', '重击', 0.6363],
|
||||
['机动', '爆发', 0.627],
|
||||
['机动', '破甲', 0.6149],
|
||||
['机动', '压制', 0.6331],
|
||||
['机动', '压血', 0.5046],
|
||||
['机动', '守御', 0.6324],
|
||||
['机动', '护体', 0.5913],
|
||||
['机动', '重甲', 0.5902],
|
||||
['机动', '反击', 0.5831],
|
||||
['机动', '镇邪', 0.4794],
|
||||
['机动', '法修', 0.5937],
|
||||
['机动', '法力', 0.5797],
|
||||
['机动', '雷法', 0.5596],
|
||||
['机动', '符阵', 0.5786],
|
||||
['机动', '控场', 0.5914],
|
||||
['机动', '过载', 0.5971],
|
||||
['机动', '回复', 0.5985],
|
||||
['机动', '护持', 0.5888],
|
||||
['机动', '续战', 0.6009],
|
||||
['机动', '命纹', 0.565],
|
||||
['机动', '机缘', 0.5935],
|
||||
['机动', '冷却', 0.5674],
|
||||
['机动', '统御', 0.5976],
|
||||
['机动', '均衡', 0.5708],
|
||||
['机动', '工巧', 0.6219],
|
||||
['机动', '炼药', 0.5386],
|
||||
['机动', '先锋', 0.5903],
|
||||
['机动', '狂战', 0.6296],
|
||||
['机动', '法剑', 0.534],
|
||||
['机动', '圣佑', 0.4903],
|
||||
['机动', '堡垒', 0.5817],
|
||||
['机动', '起手', 0.5714],
|
||||
['风行', '重击', 0.5814],
|
||||
['风行', '爆发', 0.5888],
|
||||
['风行', '破甲', 0.5965],
|
||||
['风行', '压制', 0.5891],
|
||||
['风行', '压血', 0.4822],
|
||||
['风行', '守御', 0.5779],
|
||||
['风行', '护体', 0.563],
|
||||
['风行', '重甲', 0.5303],
|
||||
['风行', '反击', 0.5606],
|
||||
['风行', '镇邪', 0.4994],
|
||||
['风行', '法修', 0.5666],
|
||||
['风行', '法力', 0.5465],
|
||||
['风行', '雷法', 0.6185],
|
||||
['风行', '符阵', 0.6019],
|
||||
['风行', '控场', 0.5591],
|
||||
['风行', '过载', 0.5711],
|
||||
['风行', '回复', 0.5502],
|
||||
['风行', '护持', 0.5305],
|
||||
['风行', '续战', 0.5345],
|
||||
['风行', '命纹', 0.5222],
|
||||
['风行', '机缘', 0.4927],
|
||||
['风行', '冷却', 0.5579],
|
||||
['风行', '统御', 0.5759],
|
||||
['风行', '均衡', 0.5518],
|
||||
['风行', '工巧', 0.5349],
|
||||
['风行', '炼药', 0.4949],
|
||||
['风行', '先锋', 0.5716],
|
||||
['风行', '狂战', 0.637],
|
||||
['风行', '法剑', 0.5606],
|
||||
['风行', '圣佑', 0.4625],
|
||||
['风行', '堡垒', 0.5604],
|
||||
['风行', '起手', 0.5593],
|
||||
['重击', '爆发', 0.6945],
|
||||
['重击', '破甲', 0.6774],
|
||||
['重击', '压制', 0.6399],
|
||||
['重击', '压血', 0.5782],
|
||||
['重击', '守御', 0.6601],
|
||||
['重击', '护体', 0.5804],
|
||||
['重击', '重甲', 0.7689],
|
||||
['重击', '反击', 0.6758],
|
||||
['重击', '镇邪', 0.5743],
|
||||
['重击', '法修', 0.5315],
|
||||
['重击', '法力', 0.5534],
|
||||
['重击', '雷法', 0.6306],
|
||||
['重击', '符阵', 0.5642],
|
||||
['重击', '控场', 0.555],
|
||||
['重击', '过载', 0.603],
|
||||
['重击', '回复', 0.6222],
|
||||
['重击', '护持', 0.5424],
|
||||
['重击', '续战', 0.6046],
|
||||
['重击', '命纹', 0.5489],
|
||||
['重击', '机缘', 0.4778],
|
||||
['重击', '冷却', 0.5272],
|
||||
['重击', '统御', 0.5262],
|
||||
['重击', '均衡', 0.5299],
|
||||
['重击', '工巧', 0.5468],
|
||||
['重击', '炼药', 0.5696],
|
||||
['重击', '先锋', 0.5126],
|
||||
['重击', '狂战', 0.6859],
|
||||
['重击', '法剑', 0.593],
|
||||
['重击', '圣佑', 0.5253],
|
||||
['重击', '堡垒', 0.6473],
|
||||
['重击', '起手', 0.5027],
|
||||
['爆发', '破甲', 0.6471],
|
||||
['爆发', '压制', 0.6149],
|
||||
['爆发', '压血', 0.6011],
|
||||
['爆发', '守御', 0.6566],
|
||||
['爆发', '护体', 0.6024],
|
||||
['爆发', '重甲', 0.5939],
|
||||
['爆发', '反击', 0.6182],
|
||||
['爆发', '镇邪', 0.5866],
|
||||
['爆发', '法修', 0.5946],
|
||||
['爆发', '法力', 0.5942],
|
||||
['爆发', '雷法', 0.6125],
|
||||
['爆发', '符阵', 0.6034],
|
||||
['爆发', '控场', 0.553],
|
||||
['爆发', '过载', 0.6815],
|
||||
['爆发', '回复', 0.6327],
|
||||
['爆发', '护持', 0.5936],
|
||||
['爆发', '续战', 0.5908],
|
||||
['爆发', '命纹', 0.6207],
|
||||
['爆发', '机缘', 0.5688],
|
||||
['爆发', '冷却', 0.655],
|
||||
['爆发', '统御', 0.5539],
|
||||
['爆发', '均衡', 0.5714],
|
||||
['爆发', '工巧', 0.5236],
|
||||
['爆发', '炼药', 0.5637],
|
||||
['爆发', '先锋', 0.5253],
|
||||
['爆发', '狂战', 0.6509],
|
||||
['爆发', '法剑', 0.5871],
|
||||
['爆发', '圣佑', 0.5475],
|
||||
['爆发', '堡垒', 0.6038],
|
||||
['爆发', '起手', 0.578],
|
||||
['破甲', '压制', 0.6264],
|
||||
['破甲', '压血', 0.5547],
|
||||
['破甲', '守御', 0.7071],
|
||||
['破甲', '护体', 0.658],
|
||||
['破甲', '重甲', 0.7112],
|
||||
['破甲', '反击', 0.6969],
|
||||
['破甲', '镇邪', 0.6075],
|
||||
['破甲', '法修', 0.5574],
|
||||
['破甲', '法力', 0.5077],
|
||||
['破甲', '雷法', 0.5545],
|
||||
['破甲', '符阵', 0.6422],
|
||||
['破甲', '控场', 0.5732],
|
||||
['破甲', '过载', 0.5188],
|
||||
['破甲', '回复', 0.5907],
|
||||
['破甲', '护持', 0.5939],
|
||||
['破甲', '续战', 0.587],
|
||||
['破甲', '命纹', 0.5605],
|
||||
['破甲', '机缘', 0.4785],
|
||||
['破甲', '冷却', 0.5631],
|
||||
['破甲', '统御', 0.5967],
|
||||
['破甲', '均衡', 0.5164],
|
||||
['破甲', '工巧', 0.5391],
|
||||
['破甲', '炼药', 0.543],
|
||||
['破甲', '先锋', 0.6307],
|
||||
['破甲', '狂战', 0.6562],
|
||||
['破甲', '法剑', 0.6461],
|
||||
['破甲', '圣佑', 0.542],
|
||||
['破甲', '堡垒', 0.6839],
|
||||
['破甲', '起手', 0.562],
|
||||
['压制', '压血', 0.6276],
|
||||
['压制', '守御', 0.6984],
|
||||
['压制', '护体', 0.6072],
|
||||
['压制', '重甲', 0.545],
|
||||
['压制', '反击', 0.7119],
|
||||
['压制', '镇邪', 0.5967],
|
||||
['压制', '法修', 0.553],
|
||||
['压制', '法力', 0.5656],
|
||||
['压制', '雷法', 0.5769],
|
||||
['压制', '符阵', 0.5715],
|
||||
['压制', '控场', 0.749],
|
||||
['压制', '过载', 0.6006],
|
||||
['压制', '回复', 0.5653],
|
||||
['压制', '护持', 0.6715],
|
||||
['压制', '续战', 0.5801],
|
||||
['压制', '命纹', 0.551],
|
||||
['压制', '机缘', 0.5549],
|
||||
['压制', '冷却', 0.5747],
|
||||
['压制', '统御', 0.6469],
|
||||
['压制', '均衡', 0.5886],
|
||||
['压制', '工巧', 0.5383],
|
||||
['压制', '炼药', 0.535],
|
||||
['压制', '先锋', 0.5772],
|
||||
['压制', '狂战', 0.5509],
|
||||
['压制', '法剑', 0.5397],
|
||||
['压制', '圣佑', 0.5325],
|
||||
['压制', '堡垒', 0.6023],
|
||||
['压制', '起手', 0.5394],
|
||||
['压血', '守御', 0.5914],
|
||||
['压血', '护体', 0.5175],
|
||||
['压血', '重甲', 0.5008],
|
||||
['压血', '反击', 0.6153],
|
||||
['压血', '镇邪', 0.5773],
|
||||
['压血', '法修', 0.4587],
|
||||
['压血', '法力', 0.4814],
|
||||
['压血', '雷法', 0.5198],
|
||||
['压血', '符阵', 0.5016],
|
||||
['压血', '控场', 0.5041],
|
||||
['压血', '过载', 0.5253],
|
||||
['压血', '回复', 0.5365],
|
||||
['压血', '护持', 0.515],
|
||||
['压血', '续战', 0.497],
|
||||
['压血', '命纹', 0.4709],
|
||||
['压血', '机缘', 0.4604],
|
||||
['压血', '冷却', 0.5177],
|
||||
['压血', '统御', 0.4647],
|
||||
['压血', '均衡', 0.4405],
|
||||
['压血', '工巧', 0.405],
|
||||
['压血', '炼药', 0.5014],
|
||||
['压血', '先锋', 0.5009],
|
||||
['压血', '狂战', 0.6444],
|
||||
['压血', '法剑', 0.5183],
|
||||
['压血', '圣佑', 0.4573],
|
||||
['压血', '堡垒', 0.5345],
|
||||
['压血', '起手', 0.4848],
|
||||
['守御', '护体', 0.7607],
|
||||
['守御', '重甲', 0.7127],
|
||||
['守御', '反击', 0.7066],
|
||||
['守御', '镇邪', 0.6594],
|
||||
['守御', '法修', 0.5957],
|
||||
['守御', '法力', 0.614],
|
||||
['守御', '雷法', 0.5409],
|
||||
['守御', '符阵', 0.6034],
|
||||
['守御', '控场', 0.6585],
|
||||
['守御', '过载', 0.5236],
|
||||
['守御', '回复', 0.5995],
|
||||
['守御', '护持', 0.7566],
|
||||
['守御', '续战', 0.6493],
|
||||
['守御', '命纹', 0.5943],
|
||||
['守御', '机缘', 0.494],
|
||||
['守御', '冷却', 0.5445],
|
||||
['守御', '统御', 0.6908],
|
||||
['守御', '均衡', 0.6017],
|
||||
['守御', '工巧', 0.5528],
|
||||
['守御', '炼药', 0.5365],
|
||||
['守御', '先锋', 0.665],
|
||||
['守御', '狂战', 0.62],
|
||||
['守御', '法剑', 0.6191],
|
||||
['守御', '圣佑', 0.665],
|
||||
['守御', '堡垒', 0.7582],
|
||||
['守御', '起手', 0.5109],
|
||||
['护体', '重甲', 0.6496],
|
||||
['护体', '反击', 0.6368],
|
||||
['护体', '镇邪', 0.6807],
|
||||
['护体', '法修', 0.6148],
|
||||
['护体', '法力', 0.6455],
|
||||
['护体', '雷法', 0.5794],
|
||||
['护体', '符阵', 0.6043],
|
||||
['护体', '控场', 0.5781],
|
||||
['护体', '过载', 0.5113],
|
||||
['护体', '回复', 0.5927],
|
||||
['护体', '护持', 0.7176],
|
||||
['护体', '续战', 0.5704],
|
||||
['护体', '命纹', 0.6048],
|
||||
['护体', '机缘', 0.4744],
|
||||
['护体', '冷却', 0.4956],
|
||||
['护体', '统御', 0.6238],
|
||||
['护体', '均衡', 0.5194],
|
||||
['护体', '工巧', 0.4965],
|
||||
['护体', '炼药', 0.5417],
|
||||
['护体', '先锋', 0.5728],
|
||||
['护体', '狂战', 0.5373],
|
||||
['护体', '法剑', 0.6043],
|
||||
['护体', '圣佑', 0.725],
|
||||
['护体', '堡垒', 0.6437],
|
||||
['护体', '起手', 0.4898],
|
||||
['重甲', '反击', 0.5685],
|
||||
['重甲', '镇邪', 0.556],
|
||||
['重甲', '法修', 0.4907],
|
||||
['重甲', '法力', 0.5033],
|
||||
['重甲', '雷法', 0.513],
|
||||
['重甲', '符阵', 0.5623],
|
||||
['重甲', '控场', 0.5215],
|
||||
['重甲', '过载', 0.5094],
|
||||
['重甲', '回复', 0.573],
|
||||
['重甲', '护持', 0.579],
|
||||
['重甲', '续战', 0.6005],
|
||||
['重甲', '命纹', 0.5357],
|
||||
['重甲', '机缘', 0.4124],
|
||||
['重甲', '冷却', 0.4427],
|
||||
['重甲', '统御', 0.5444],
|
||||
['重甲', '均衡', 0.5009],
|
||||
['重甲', '工巧', 0.5093],
|
||||
['重甲', '炼药', 0.5303],
|
||||
['重甲', '先锋', 0.5833],
|
||||
['重甲', '狂战', 0.6125],
|
||||
['重甲', '法剑', 0.5387],
|
||||
['重甲', '圣佑', 0.5324],
|
||||
['重甲', '堡垒', 0.7125],
|
||||
['重甲', '起手', 0.473],
|
||||
['反击', '镇邪', 0.6254],
|
||||
['反击', '法修', 0.5644],
|
||||
['反击', '法力', 0.543],
|
||||
['反击', '雷法', 0.5453],
|
||||
['反击', '符阵', 0.6064],
|
||||
['反击', '控场', 0.615],
|
||||
['反击', '过载', 0.5353],
|
||||
['反击', '回复', 0.5944],
|
||||
['反击', '护持', 0.6343],
|
||||
['反击', '续战', 0.576],
|
||||
['反击', '命纹', 0.5603],
|
||||
['反击', '机缘', 0.5539],
|
||||
['反击', '冷却', 0.5888],
|
||||
['反击', '统御', 0.5744],
|
||||
['反击', '均衡', 0.5154],
|
||||
['反击', '工巧', 0.4982],
|
||||
['反击', '炼药', 0.5217],
|
||||
['反击', '先锋', 0.5842],
|
||||
['反击', '狂战', 0.6543],
|
||||
['反击', '法剑', 0.581],
|
||||
['反击', '圣佑', 0.5798],
|
||||
['反击', '堡垒', 0.6901],
|
||||
['反击', '起手', 0.5609],
|
||||
['镇邪', '法修', 0.5702],
|
||||
['镇邪', '法力', 0.5942],
|
||||
['镇邪', '雷法', 0.6029],
|
||||
['镇邪', '符阵', 0.6614],
|
||||
['镇邪', '控场', 0.568],
|
||||
['镇邪', '过载', 0.5081],
|
||||
['镇邪', '回复', 0.5846],
|
||||
['镇邪', '护持', 0.5381],
|
||||
['镇邪', '续战', 0.4889],
|
||||
['镇邪', '命纹', 0.5353],
|
||||
['镇邪', '机缘', 0.457],
|
||||
['镇邪', '冷却', 0.5112],
|
||||
['镇邪', '统御', 0.5196],
|
||||
['镇邪', '均衡', 0.4713],
|
||||
['镇邪', '工巧', 0.4463],
|
||||
['镇邪', '炼药', 0.4968],
|
||||
['镇邪', '先锋', 0.4949],
|
||||
['镇邪', '狂战', 0.591],
|
||||
['镇邪', '法剑', 0.6089],
|
||||
['镇邪', '圣佑', 0.6872],
|
||||
['镇邪', '堡垒', 0.6238],
|
||||
['镇邪', '起手', 0.4864],
|
||||
['法修', '法力', 0.7208],
|
||||
['法修', '雷法', 0.6333],
|
||||
['法修', '符阵', 0.6298],
|
||||
['法修', '控场', 0.5764],
|
||||
['法修', '过载', 0.578],
|
||||
['法修', '回复', 0.5988],
|
||||
['法修', '护持', 0.5658],
|
||||
['法修', '续战', 0.541],
|
||||
['法修', '命纹', 0.6459],
|
||||
['法修', '机缘', 0.5116],
|
||||
['法修', '冷却', 0.5266],
|
||||
['法修', '统御', 0.6418],
|
||||
['法修', '均衡', 0.4631],
|
||||
['法修', '工巧', 0.5871],
|
||||
['法修', '炼药', 0.6282],
|
||||
['法修', '先锋', 0.5125],
|
||||
['法修', '狂战', 0.5753],
|
||||
['法修', '法剑', 0.7104],
|
||||
['法修', '圣佑', 0.5691],
|
||||
['法修', '堡垒', 0.5534],
|
||||
['法修', '起手', 0.5379],
|
||||
['法力', '雷法', 0.61],
|
||||
['法力', '符阵', 0.5773],
|
||||
['法力', '控场', 0.4987],
|
||||
['法力', '过载', 0.605],
|
||||
['法力', '回复', 0.598],
|
||||
['法力', '护持', 0.5795],
|
||||
['法力', '续战', 0.5531],
|
||||
['法力', '命纹', 0.6539],
|
||||
['法力', '机缘', 0.5452],
|
||||
['法力', '冷却', 0.5194],
|
||||
['法力', '统御', 0.6022],
|
||||
['法力', '均衡', 0.5077],
|
||||
['法力', '工巧', 0.5687],
|
||||
['法力', '炼药', 0.5806],
|
||||
['法力', '先锋', 0.4548],
|
||||
['法力', '狂战', 0.5491],
|
||||
['法力', '法剑', 0.6307],
|
||||
['法力', '圣佑', 0.5769],
|
||||
['法力', '堡垒', 0.5276],
|
||||
['法力', '起手', 0.4906],
|
||||
['雷法', '符阵', 0.597],
|
||||
['雷法', '控场', 0.5121],
|
||||
['雷法', '过载', 0.5585],
|
||||
['雷法', '回复', 0.5585],
|
||||
['雷法', '护持', 0.4631],
|
||||
['雷法', '续战', 0.4514],
|
||||
['雷法', '命纹', 0.5399],
|
||||
['雷法', '机缘', 0.4319],
|
||||
['雷法', '冷却', 0.4935],
|
||||
['雷法', '统御', 0.5187],
|
||||
['雷法', '均衡', 0.4183],
|
||||
['雷法', '工巧', 0.4696],
|
||||
['雷法', '炼药', 0.5434],
|
||||
['雷法', '先锋', 0.4505],
|
||||
['雷法', '狂战', 0.5692],
|
||||
['雷法', '法剑', 0.6448],
|
||||
['雷法', '圣佑', 0.5153],
|
||||
['雷法', '堡垒', 0.4846],
|
||||
['雷法', '起手', 0.4587],
|
||||
['符阵', '控场', 0.6108],
|
||||
['符阵', '过载', 0.5434],
|
||||
['符阵', '回复', 0.5793],
|
||||
['符阵', '护持', 0.5548],
|
||||
['符阵', '续战', 0.5653],
|
||||
['符阵', '命纹', 0.5807],
|
||||
['符阵', '机缘', 0.5305],
|
||||
['符阵', '冷却', 0.5247],
|
||||
['符阵', '统御', 0.596],
|
||||
['符阵', '均衡', 0.4758],
|
||||
['符阵', '工巧', 0.5118],
|
||||
['符阵', '炼药', 0.5587],
|
||||
['符阵', '先锋', 0.5208],
|
||||
['符阵', '狂战', 0.6399],
|
||||
['符阵', '法剑', 0.6213],
|
||||
['符阵', '圣佑', 0.5678],
|
||||
['符阵', '堡垒', 0.6326],
|
||||
['符阵', '起手', 0.5444],
|
||||
['控场', '过载', 0.5345],
|
||||
['控场', '回复', 0.5483],
|
||||
['控场', '护持', 0.5773],
|
||||
['控场', '续战', 0.5117],
|
||||
['控场', '命纹', 0.5334],
|
||||
['控场', '机缘', 0.5049],
|
||||
['控场', '冷却', 0.5248],
|
||||
['控场', '统御', 0.6024],
|
||||
['控场', '均衡', 0.5389],
|
||||
['控场', '工巧', 0.5029],
|
||||
['控场', '炼药', 0.4973],
|
||||
['控场', '先锋', 0.5409],
|
||||
['控场', '狂战', 0.5177],
|
||||
['控场', '法剑', 0.4913],
|
||||
['控场', '圣佑', 0.4957],
|
||||
['控场', '堡垒', 0.5744],
|
||||
['控场', '起手', 0.5304],
|
||||
['过载', '回复', 0.621],
|
||||
['过载', '护持', 0.523],
|
||||
['过载', '续战', 0.5862],
|
||||
['过载', '命纹', 0.4679],
|
||||
['过载', '机缘', 0.4845],
|
||||
['过载', '冷却', 0.6125],
|
||||
['过载', '统御', 0.5142],
|
||||
['过载', '均衡', 0.512],
|
||||
['过载', '工巧', 0.4477],
|
||||
['过载', '炼药', 0.5165],
|
||||
['过载', '先锋', 0.472],
|
||||
['过载', '狂战', 0.5719],
|
||||
['过载', '法剑', 0.5009],
|
||||
['过载', '圣佑', 0.4471],
|
||||
['过载', '堡垒', 0.4894],
|
||||
['过载', '起手', 0.5507],
|
||||
['回复', '护持', 0.6385],
|
||||
['回复', '续战', 0.7208],
|
||||
['回复', '命纹', 0.5697],
|
||||
['回复', '机缘', 0.5503],
|
||||
['回复', '冷却', 0.6266],
|
||||
['回复', '统御', 0.6014],
|
||||
['回复', '均衡', 0.5601],
|
||||
['回复', '工巧', 0.551],
|
||||
['回复', '炼药', 0.6223],
|
||||
['回复', '先锋', 0.49],
|
||||
['回复', '狂战', 0.5537],
|
||||
['回复', '法剑', 0.5084],
|
||||
['回复', '圣佑', 0.5796],
|
||||
['回复', '堡垒', 0.5532],
|
||||
['回复', '起手', 0.5605],
|
||||
['护持', '续战', 0.6602],
|
||||
['护持', '命纹', 0.5718],
|
||||
['护持', '机缘', 0.5891],
|
||||
['护持', '冷却', 0.5337],
|
||||
['护持', '统御', 0.6472],
|
||||
['护持', '均衡', 0.5856],
|
||||
['护持', '工巧', 0.5238],
|
||||
['护持', '炼药', 0.5062],
|
||||
['护持', '先锋', 0.6196],
|
||||
['护持', '狂战', 0.5233],
|
||||
['护持', '法剑', 0.4996],
|
||||
['护持', '圣佑', 0.6853],
|
||||
['护持', '堡垒', 0.6118],
|
||||
['护持', '起手', 0.522],
|
||||
['续战', '命纹', 0.5695],
|
||||
['续战', '机缘', 0.5065],
|
||||
['续战', '冷却', 0.5479],
|
||||
['续战', '统御', 0.6108],
|
||||
['续战', '均衡', 0.6118],
|
||||
['续战', '工巧', 0.4802],
|
||||
['续战', '炼药', 0.5326],
|
||||
['续战', '先锋', 0.5283],
|
||||
['续战', '狂战', 0.589],
|
||||
['续战', '法剑', 0.5245],
|
||||
['续战', '圣佑', 0.4996],
|
||||
['续战', '堡垒', 0.5803],
|
||||
['续战', '起手', 0.5138],
|
||||
['命纹', '机缘', 0.6391],
|
||||
['命纹', '冷却', 0.4859],
|
||||
['命纹', '统御', 0.5763],
|
||||
['命纹', '均衡', 0.4904],
|
||||
['命纹', '工巧', 0.5419],
|
||||
['命纹', '炼药', 0.5336],
|
||||
['命纹', '先锋', 0.4693],
|
||||
['命纹', '狂战', 0.5297],
|
||||
['命纹', '法剑', 0.5789],
|
||||
['命纹', '圣佑', 0.5901],
|
||||
['命纹', '堡垒', 0.5077],
|
||||
['命纹', '起手', 0.5058],
|
||||
['机缘', '冷却', 0.4758],
|
||||
['机缘', '统御', 0.5276],
|
||||
['机缘', '均衡', 0.5299],
|
||||
['机缘', '工巧', 0.5537],
|
||||
['机缘', '炼药', 0.4607],
|
||||
['机缘', '先锋', 0.4412],
|
||||
['机缘', '狂战', 0.4916],
|
||||
['机缘', '法剑', 0.4279],
|
||||
['机缘', '圣佑', 0.5334],
|
||||
['机缘', '堡垒', 0.4821],
|
||||
['机缘', '起手', 0.5271],
|
||||
['冷却', '统御', 0.4681],
|
||||
['冷却', '均衡', 0.5192],
|
||||
['冷却', '工巧', 0.4528],
|
||||
['冷却', '炼药', 0.5105],
|
||||
['冷却', '先锋', 0.4123],
|
||||
['冷却', '狂战', 0.5277],
|
||||
['冷却', '法剑', 0.4966],
|
||||
['冷却', '圣佑', 0.427],
|
||||
['冷却', '堡垒', 0.5101],
|
||||
['冷却', '起手', 0.5162],
|
||||
['统御', '均衡', 0.5641],
|
||||
['统御', '工巧', 0.5768],
|
||||
['统御', '炼药', 0.5346],
|
||||
['统御', '先锋', 0.633],
|
||||
['统御', '狂战', 0.5482],
|
||||
['统御', '法剑', 0.5855],
|
||||
['统御', '圣佑', 0.5481],
|
||||
['统御', '堡垒', 0.5774],
|
||||
['统御', '起手', 0.5428],
|
||||
['均衡', '工巧', 0.5957],
|
||||
['均衡', '炼药', 0.4911],
|
||||
['均衡', '先锋', 0.4931],
|
||||
['均衡', '狂战', 0.488],
|
||||
['均衡', '法剑', 0.5058],
|
||||
['均衡', '圣佑', 0.4665],
|
||||
['均衡', '堡垒', 0.5519],
|
||||
['均衡', '起手', 0.5537],
|
||||
['工巧', '炼药', 0.6352],
|
||||
['工巧', '先锋', 0.4415],
|
||||
['工巧', '狂战', 0.4979],
|
||||
['工巧', '法剑', 0.5548],
|
||||
['工巧', '圣佑', 0.4727],
|
||||
['工巧', '堡垒', 0.5285],
|
||||
['工巧', '起手', 0.5812],
|
||||
['炼药', '先锋', 0.4232],
|
||||
['炼药', '狂战', 0.5542],
|
||||
['炼药', '法剑', 0.5878],
|
||||
['炼药', '圣佑', 0.4755],
|
||||
['炼药', '堡垒', 0.5155],
|
||||
['炼药', '起手', 0.5478],
|
||||
['先锋', '狂战', 0.5637],
|
||||
['先锋', '法剑', 0.516],
|
||||
['先锋', '圣佑', 0.4738],
|
||||
['先锋', '堡垒', 0.6068],
|
||||
['先锋', '起手', 0.5946],
|
||||
['狂战', '法剑', 0.6253],
|
||||
['狂战', '圣佑', 0.4844],
|
||||
['狂战', '堡垒', 0.6703],
|
||||
['狂战', '起手', 0.5352],
|
||||
['法剑', '圣佑', 0.5668],
|
||||
['法剑', '堡垒', 0.5968],
|
||||
['法剑', '起手', 0.4903],
|
||||
['圣佑', '堡垒', 0.5733],
|
||||
['圣佑', '起手', 0.4104],
|
||||
['堡垒', '起手', 0.5186],
|
||||
] as const;
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { InventoryItem } from '../types';
|
||||
|
||||
export function buildCustomWorldStarterInventoryItems(): InventoryItem[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildCustomWorldStarterEquipmentItems(): InventoryItem[] {
|
||||
return [];
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function EditorEmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { SaveBar, SelectField, type SelectFieldOption } from './FormFields';
|
||||
import { SectionCard } from './SectionCard';
|
||||
|
||||
export function EditorSelectionCard({
|
||||
title,
|
||||
description,
|
||||
selectLabel,
|
||||
selectValue,
|
||||
onSelectChange,
|
||||
selectOptions,
|
||||
saveLabel,
|
||||
onSave,
|
||||
isSaving,
|
||||
saveMessage,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
selectLabel: string;
|
||||
selectValue: string | number;
|
||||
onSelectChange: (value: string) => void;
|
||||
selectOptions: SelectFieldOption[];
|
||||
saveLabel: string;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
saveMessage: string | null;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SectionCard title={title} description={description}>
|
||||
<SelectField
|
||||
label={selectLabel}
|
||||
value={selectValue}
|
||||
onChange={onSelectChange}
|
||||
options={selectOptions}
|
||||
/>
|
||||
{children}
|
||||
<SaveBar
|
||||
saveLabel={saveLabel}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
saveMessage={saveMessage}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function cloneValue<T>(value: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
@@ -27,10 +27,6 @@ export const ASSET_API_PATHS = {
|
||||
characterAnimationJobs: `${ASSETS_API_BASE_PATH}/character-animation/jobs`,
|
||||
characterAnimationImportVideo: `${ASSETS_API_BASE_PATH}/character-animation/import-video`,
|
||||
characterAnimationTemplates: `${ASSETS_API_BASE_PATH}/character-animation/templates`,
|
||||
qwenSpriteMaster: `${ASSETS_API_BASE_PATH}/qwen-sprite/master`,
|
||||
qwenSpriteSheet: `${ASSETS_API_BASE_PATH}/qwen-sprite/sheet`,
|
||||
qwenSpriteFrameRepair: `${ASSETS_API_BASE_PATH}/qwen-sprite/frame-repair`,
|
||||
qwenSpriteSave: `${ASSETS_API_BASE_PATH}/qwen-sprite/save`,
|
||||
} as const;
|
||||
|
||||
export const EDITOR_ITEM_CATALOG_API_PATH =
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
saveEditorJsonResource,
|
||||
type EditorJsonResourceId,
|
||||
} from './editorApiClient';
|
||||
|
||||
type UseJsonSaveOptions = {
|
||||
resourceId: EditorJsonResourceId;
|
||||
payload: Record<string, unknown>;
|
||||
validate?: () => string[];
|
||||
successMessage: string;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export function useJsonSave({
|
||||
resourceId,
|
||||
payload,
|
||||
validate,
|
||||
successMessage,
|
||||
errorMessage,
|
||||
}: UseJsonSaveOptions) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
|
||||
const save = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveMessage(null);
|
||||
|
||||
const validationErrors = validate?.() ?? [];
|
||||
if (validationErrors.length > 0) {
|
||||
setSaveMessage(validationErrors.slice(0, 3).join(' | '));
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveEditorJsonResource(resourceId, payload, errorMessage);
|
||||
setSaveMessage(successMessage);
|
||||
} catch (error) {
|
||||
setSaveMessage(error instanceof Error ? error.message : errorMessage);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { isSaving, saveMessage, save };
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import type { Character, Encounter, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
playOpeningAdventureSequence,
|
||||
type PreparedOpeningAdventure,
|
||||
} from './openingAdventure';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type BuildDialogueStoryMoment = (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
|
||||
export function useStoryBootstrap(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
prepareOpeningAdventure: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => PreparedOpeningAdventure | null;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
buildDialogueStoryMoment: BuildDialogueStoryMoment;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
|
||||
inferOpeningCampFollowupOptions: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
baseOptions: StoryOption[],
|
||||
openingBackground: string,
|
||||
openingDialogue: string,
|
||||
) => Promise<StoryOption[]>;
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
}) {
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
prepareOpeningAdventure,
|
||||
getNpcEncounterKey,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
isNpcEncounter,
|
||||
isInitialCompanionEncounter,
|
||||
} = params;
|
||||
|
||||
const [preparedOpeningAdventure, setPreparedOpeningAdventure] =
|
||||
useState<PreparedOpeningAdventure | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
gameState.currentScene !== 'Story' ||
|
||||
!gameState.playerCharacter ||
|
||||
gameState.storyHistory.length > 0 ||
|
||||
currentStory ||
|
||||
!isNpcEncounter(gameState.currentEncounter) ||
|
||||
gameState.currentEncounter.specialBehavior !== 'initial_companion'
|
||||
) {
|
||||
setPreparedOpeningAdventure(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreparedOpeningAdventure(
|
||||
prepareOpeningAdventure(gameState, gameState.playerCharacter),
|
||||
);
|
||||
}, [
|
||||
currentStory,
|
||||
gameState,
|
||||
isNpcEncounter,
|
||||
prepareOpeningAdventure,
|
||||
]);
|
||||
|
||||
const startOpeningAdventure = useCallback(async () => {
|
||||
if (
|
||||
!gameState.playerCharacter ||
|
||||
!isNpcEncounter(gameState.currentEncounter)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encounter = gameState.currentEncounter;
|
||||
if (encounter.specialBehavior !== 'initial_companion') {
|
||||
return;
|
||||
}
|
||||
|
||||
const preparedStory =
|
||||
preparedOpeningAdventure?.encounterKey === getNpcEncounterKey(encounter)
|
||||
? preparedOpeningAdventure
|
||||
: prepareOpeningAdventure(gameState, gameState.playerCharacter);
|
||||
|
||||
if (!preparedStory) {
|
||||
return;
|
||||
}
|
||||
|
||||
await playOpeningAdventureSequence({
|
||||
gameState,
|
||||
character: gameState.playerCharacter,
|
||||
encounter,
|
||||
preparedStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
});
|
||||
}, [
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
gameState,
|
||||
getNpcEncounterKey,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
isNpcEncounter,
|
||||
prepareOpeningAdventure,
|
||||
preparedOpeningAdventure,
|
||||
setAiError,
|
||||
setCurrentStory,
|
||||
setGameState,
|
||||
setIsLoading,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const startStory = async () => {
|
||||
if (
|
||||
gameState.currentScene !== 'Story' ||
|
||||
!gameState.worldType ||
|
||||
!gameState.playerCharacter ||
|
||||
currentStory ||
|
||||
isLoading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
gameState.storyHistory.length === 0 &&
|
||||
isInitialCompanionEncounter(gameState.currentEncounter) &&
|
||||
!gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
void startOpeningAdventure();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setAiError(null);
|
||||
const nextStory = await generateStoryForState({
|
||||
state: gameState,
|
||||
character: gameState.playerCharacter,
|
||||
history: [],
|
||||
});
|
||||
setGameState(applyStoryReasoningRecovery(gameState));
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to start story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(gameState, gameState.playerCharacter),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void startStory();
|
||||
}, [
|
||||
buildFallbackStoryForState,
|
||||
currentStory,
|
||||
gameState,
|
||||
generateStoryForState,
|
||||
isInitialCompanionEncounter,
|
||||
isLoading,
|
||||
setAiError,
|
||||
setCurrentStory,
|
||||
setGameState,
|
||||
setIsLoading,
|
||||
startOpeningAdventure,
|
||||
]);
|
||||
|
||||
const resetPreparedOpeningAdventure = useCallback(() => {
|
||||
setPreparedOpeningAdventure(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
preparedOpeningAdventure,
|
||||
startOpeningAdventure,
|
||||
resetPreparedOpeningAdventure,
|
||||
};
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
} from '../data/equipmentEffects';
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
removeInventoryItem,
|
||||
} from '../data/npcInteractions';
|
||||
import { EquipmentSlotId, GameState, InventoryItem } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
function normalizeEquippedItem(item: InventoryItem) {
|
||||
return {
|
||||
...item,
|
||||
quantity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEquipResultText(
|
||||
item: InventoryItem,
|
||||
slot: EquipmentSlotId,
|
||||
replacedItem?: InventoryItem | null,
|
||||
) {
|
||||
return replacedItem
|
||||
? `你将${replacedItem.name}从${getEquipmentSlotLabel(slot)}位上换下,改为装备${item.name}。`
|
||||
: `你将${item.name}装备在${getEquipmentSlotLabel(slot)}位上。`;
|
||||
}
|
||||
|
||||
function buildUnequipResultText(item: InventoryItem) {
|
||||
return `你卸下了${item.name},暂时收回背包。`;
|
||||
}
|
||||
|
||||
export function useEquipmentFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
}) {
|
||||
const handleEquipInventoryItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item || item.quantity <= 0) return false;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
if (!slot) return false;
|
||||
|
||||
const replacedItem = gameState.playerEquipment[slot];
|
||||
const nextEquipment = {
|
||||
...gameState.playerEquipment,
|
||||
[slot]: normalizeEquippedItem(item),
|
||||
};
|
||||
|
||||
let nextInventory = removeInventoryItem(
|
||||
gameState.playerInventory,
|
||||
item.id,
|
||||
1,
|
||||
);
|
||||
if (replacedItem) {
|
||||
nextInventory = addInventoryItems(nextInventory, [replacedItem]);
|
||||
}
|
||||
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...gameState,
|
||||
playerInventory: nextInventory,
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`装备${item.name}`,
|
||||
buildEquipResultText(item, slot, replacedItem),
|
||||
EQUIPMENT_EQUIP_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
const handleUnequipItem = useCallback(
|
||||
async (slot: EquipmentSlotId) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const equippedItem = gameState.playerEquipment[slot];
|
||||
if (!equippedItem) return false;
|
||||
|
||||
const nextEquipment = {
|
||||
...gameState.playerEquipment,
|
||||
[slot]: null,
|
||||
};
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...gameState,
|
||||
playerInventory: addInventoryItems(gameState.playerInventory, [
|
||||
equippedItem,
|
||||
]),
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`卸下${equippedItem.name}`,
|
||||
buildUnequipResultText(equippedItem),
|
||||
EQUIPMENT_UNEQUIP_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
return {
|
||||
handleEquipInventoryItem,
|
||||
handleUnequipItem,
|
||||
};
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
buildForgeSuccessText,
|
||||
executeDismantleItem,
|
||||
executeForgeRecipe,
|
||||
executeReforgeItem,
|
||||
getForgeRecipeViews,
|
||||
getReforgeCostView,
|
||||
} from '../data/forgeSystem';
|
||||
import {
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import type { GameState } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
export function useForgeFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
}) {
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
|
||||
const handleCraftRecipe = useCallback(
|
||||
async (recipeId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const result = executeForgeRecipe(
|
||||
gameState.playerInventory,
|
||||
recipeId,
|
||||
gameState.worldType,
|
||||
gameState.playerCurrency,
|
||||
);
|
||||
if (!result) return false;
|
||||
|
||||
const recipe = forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerCurrency: result.currency,
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`制作${result.createdItem.name}`,
|
||||
buildForgeSuccessText('craft', {
|
||||
recipeName: recipe?.name ?? recipeId,
|
||||
createdItemName: result.createdItem.name,
|
||||
currencyText: recipe?.currencyText,
|
||||
}),
|
||||
FORGE_CRAFT_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, forgeRecipes, gameState],
|
||||
);
|
||||
|
||||
const handleDismantleItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const sourceItem = gameState.playerInventory.find(
|
||||
(item) => item.id === itemId,
|
||||
);
|
||||
if (!sourceItem) return false;
|
||||
|
||||
const result = executeDismantleItem(gameState.playerInventory, itemId);
|
||||
if (!result) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`拆解${sourceItem.name}`,
|
||||
buildForgeSuccessText('dismantle', {
|
||||
sourceItemName: sourceItem.name,
|
||||
outputNames: result.outputs.map((item) => item.name),
|
||||
}),
|
||||
FORGE_DISMANTLE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
const handleReforgeItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const sourceItem = gameState.playerInventory.find(
|
||||
(item) => item.id === itemId,
|
||||
);
|
||||
if (!sourceItem) return false;
|
||||
|
||||
const result = executeReforgeItem(
|
||||
gameState.playerInventory,
|
||||
itemId,
|
||||
gameState.playerCurrency,
|
||||
);
|
||||
if (!result) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerCurrency: Math.max(
|
||||
0,
|
||||
gameState.playerCurrency - result.currencyCost,
|
||||
),
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
const reforgeCost = getReforgeCostView(sourceItem, gameState.worldType);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`重铸${sourceItem.name}`,
|
||||
buildForgeSuccessText('reforge', {
|
||||
sourceItemName: sourceItem.name,
|
||||
createdItemName: result.reforgedItem.name,
|
||||
currencyText: reforgeCost.currencyText,
|
||||
}),
|
||||
FORGE_REFORGE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
return {
|
||||
forgeRecipes,
|
||||
handleCraftRecipe,
|
||||
handleDismantleItem,
|
||||
handleReforgeItem,
|
||||
getReforgeCostView,
|
||||
};
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { appendBuildBuffs } from '../data/buildDamage';
|
||||
import { INVENTORY_USE_FUNCTION } from '../data/functionCatalog';
|
||||
import {
|
||||
buildInventoryUseResultText,
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import { removeInventoryItem } from '../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../data/runtimeStats';
|
||||
import { GameState } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
type TickCooldowns = (
|
||||
cooldowns: Record<string, number>,
|
||||
) => Record<string, number>;
|
||||
|
||||
export function useInventoryFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
tickCooldowns: TickCooldowns;
|
||||
}) {
|
||||
const handleUseInventoryItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter) return false;
|
||||
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item || !isInventoryItemUsable(item) || item.quantity <= 0)
|
||||
return false;
|
||||
|
||||
const effect = resolveInventoryItemUseEffect(
|
||||
item,
|
||||
gameState.playerCharacter,
|
||||
);
|
||||
if (!effect) return false;
|
||||
|
||||
if (
|
||||
effect.hpRestore <= 0 &&
|
||||
effect.manaRestore <= 0 &&
|
||||
effect.cooldownReduction <= 0 &&
|
||||
effect.buildBuffs.length <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cooldowns = gameState.playerSkillCooldowns;
|
||||
for (let index = 0; index < effect.cooldownReduction; index += 1) {
|
||||
cooldowns = tickCooldowns(cooldowns);
|
||||
}
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerHp: Math.min(
|
||||
gameState.playerMaxHp,
|
||||
gameState.playerHp + effect.hpRestore,
|
||||
),
|
||||
playerMana: Math.min(
|
||||
gameState.playerMaxMana,
|
||||
gameState.playerMana + effect.manaRestore,
|
||||
),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
activeBuildBuffs: appendBuildBuffs(
|
||||
gameState.activeBuildBuffs,
|
||||
effect.buildBuffs,
|
||||
),
|
||||
playerInventory: removeInventoryItem(
|
||||
gameState.playerInventory,
|
||||
item.id,
|
||||
1,
|
||||
),
|
||||
runtimeStats: incrementGameRuntimeStats(gameState.runtimeStats, {
|
||||
itemsUsed: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`使用${item.name}`,
|
||||
buildInventoryUseResultText(item, effect),
|
||||
INVENTORY_USE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState, tickCooldowns],
|
||||
);
|
||||
|
||||
return {
|
||||
handleUseInventoryItem,
|
||||
};
|
||||
}
|
||||
@@ -1365,6 +1365,15 @@ body {
|
||||
background: var(--platform-track-fill);
|
||||
}
|
||||
|
||||
.platform-cover-artwork {
|
||||
background: radial-gradient(
|
||||
circle at top,
|
||||
var(--platform-surface-glow-a),
|
||||
transparent 42%
|
||||
),
|
||||
var(--platform-subpanel-fill);
|
||||
}
|
||||
|
||||
.platform-theme--light
|
||||
:where(
|
||||
.platform-surface:not(.platform-surface--hero),
|
||||
@@ -2398,6 +2407,26 @@ button {
|
||||
@media (max-width: 640px) {
|
||||
:root {
|
||||
--ui-scale: 0.8;
|
||||
--platform-bottom-nav-height: 3.35rem;
|
||||
--platform-bottom-nav-label-size: 10px;
|
||||
}
|
||||
|
||||
.platform-main-shell {
|
||||
padding-inline: max(0.75rem, env(safe-area-inset-left)) max(
|
||||
0.75rem,
|
||||
env(safe-area-inset-right)
|
||||
);
|
||||
padding-top: max(0.75rem, env(safe-area-inset-top));
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.platform-page-stage {
|
||||
border-radius: 1.45rem;
|
||||
}
|
||||
|
||||
.platform-bottom-nav {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.selection-hero-brand {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
不要输出 Markdown、代码块、解释、注释或额外文字。
|
||||
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
|
||||
|
||||
export const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
|
||||
@@ -1,629 +0,0 @@
|
||||
export type QwenSpriteActionTemplateId =
|
||||
| 'idle'
|
||||
| 'run'
|
||||
| 'attack_slash'
|
||||
| 'hurt'
|
||||
| 'die';
|
||||
|
||||
export type QwenSpriteActionTemplate = {
|
||||
id: QwenSpriteActionTemplateId;
|
||||
label: string;
|
||||
loop: boolean;
|
||||
defaultFps: number;
|
||||
bodyTravel: string;
|
||||
weaponRule: string;
|
||||
stagingDirection?: string;
|
||||
defaultDetailText?: string;
|
||||
sequenceLines: [string, string, string, string];
|
||||
ending: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_MASTER_NEGATIVE_PROMPT =
|
||||
'正面视角,左朝向,完全 90 度纯右视图,镜头透视,半身像,脚被裁切,头顶被裁切,多角色,复杂背景,建筑场景,道具堆叠,漂浮物,烟雾环境,武器消失,武器换手,额外手臂,额外腿,服装变化,脸部变化,模糊,运动模糊,文字,水印,UI 元素';
|
||||
|
||||
export const DEFAULT_SHEET_NEGATIVE_PROMPT =
|
||||
'多角色,左右朝向混乱,前视图,背视图,镜头切换,景别变化,特写,脚底裁切,头顶裁切,缺手,缺脚,额外肢体,武器消失,武器换手,服装变化,脸部变化,发型变化,动作不连续,重复帧过多,构图混乱,背景复杂,强透视,运动模糊,残影,文字,水印,UI,边框覆盖角色';
|
||||
|
||||
export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
|
||||
'多角色,错误朝向,缺手,缺脚,额外肢体,武器消失,武器换手,脸部变化,发型变化,服装变化,模糊,运动模糊,复杂背景,文字,水印';
|
||||
|
||||
const CHIBI_STYLE_TEXT =
|
||||
'Q版大头身动作角色,头身比固定控制在 2 到 3 头身,头部占比明显更大,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
|
||||
const PIXEL_STYLE_TEXT =
|
||||
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
|
||||
const SIDE_FACING_RIGHT_TEXT =
|
||||
'角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。';
|
||||
const SUBJECT_ONLY_TEXT =
|
||||
'画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。';
|
||||
const CLEAN_BACKGROUND_TEXT =
|
||||
'背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。';
|
||||
const STYLE_REFERENCE_SCOPE_TEXT =
|
||||
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
|
||||
const CONCEPT_INTERPRETATION_TEXT =
|
||||
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
|
||||
const HUMANLIKE_PRIORITY_TEXT =
|
||||
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时,才改为对应非人身体。';
|
||||
const CONCEPT_HIERARCHY_TEXT =
|
||||
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
|
||||
const THEME_APPLICATION_BOUNDARY_TEXT =
|
||||
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、武器和发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。';
|
||||
const CHIBI_CHARACTER_TEXT =
|
||||
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色,方便读图和后续动画化。';
|
||||
|
||||
const SETTING_AND_ROLE_ALIGNMENT_TEXT =
|
||||
'先从角色设定中提炼世界设定、时代氛围、阵营身份、职业职责、战斗习惯、装备结构、材质配色和情绪气质,再把这些信息落实到发型、服装层次、护具、武器、饰品、轮廓和动作节奏里,不要混入与设定无关的现代服饰、写实摄影镜头、枪械体系或其他世界观元素。';
|
||||
const CHARACTER_DETAIL_COVERAGE_TEXT =
|
||||
'角色描述需要尽量落到可视化细节:发型与脸部识别点、年龄层与气质、服装层次、护具与配饰、武器/法器类型、主色与材质、职业姿态与世界观痕迹,避免只有抽象身份词。';
|
||||
|
||||
export const DEFAULT_CHARACTER_BRIEF =
|
||||
'魔潮复苏边境城邦中的少女遗迹冒险者,Q版大头身,约 2 到 3 头身,金棕色微卷短发,琥珀色眼睛,额前碎发与侧边发束清晰,表情明亮但带警觉;穿分层轻甲、短披风和旅行短裙,皮革护腕、金属护膝、系带短靴完整可见;腰间挂药剂包、地图筒与小型护符,右手持单手短剑,主色为蜂蜜金、墨绿和旧银,轮廓利落,像长期在遗迹与荒野间行动的年轻探索者。';
|
||||
|
||||
export const PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES = [
|
||||
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
|
||||
'/character/Archer Hero/Original/Hero/idle/idle01.png',
|
||||
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Fighter 4/original/Hero/idle/idle01.png',
|
||||
];
|
||||
|
||||
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
|
||||
{
|
||||
id: 'idle',
|
||||
label: '待机循环',
|
||||
loop: true,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '原地',
|
||||
weaponRule: '武器始终在主手,位置稳定',
|
||||
sequenceLines: [
|
||||
'1-4 帧:稳定站姿,轻微呼吸起伏',
|
||||
'5-8 帧:胸腔与肩膀轻微抬起,衣摆极轻微变化',
|
||||
'9-12 帧:呼气回落,重心恢复',
|
||||
'13-16 帧:逐渐回到与首帧接近的站姿',
|
||||
],
|
||||
ending: '第 16 帧自然衔接第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'run',
|
||||
label: '奔跑循环',
|
||||
loop: true,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '小幅前移但角色中心基本固定',
|
||||
weaponRule: '武器始终在主手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:右腿前摆,左腿后蹬,身体略前倾',
|
||||
'5-8 帧:双腿交叉经过身体下方,手臂反向摆动',
|
||||
'9-12 帧:左腿前摆,右腿后蹬,继续前倾',
|
||||
'13-16 帧:完成另一半跑步循环并回到可接第 1 帧的状态',
|
||||
],
|
||||
ending: '第 16 帧能无缝接回第 1 帧',
|
||||
},
|
||||
{
|
||||
id: 'attack_slash',
|
||||
label: '横斩攻击',
|
||||
loop: false,
|
||||
defaultFps: 12,
|
||||
bodyTravel: '中幅前探',
|
||||
weaponRule: '右手持武器,始终右手,不换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:轻微收身蓄力,武器向后收',
|
||||
'5-8 帧:重心前压,挥击开始',
|
||||
'9-12 帧:斩击达到最大幅度,动作力量最强',
|
||||
'13-16 帧:顺势收招,回到可接下一动作的稳定姿态',
|
||||
],
|
||||
ending: '第 16 帧停在收招后稳定姿态',
|
||||
},
|
||||
{
|
||||
id: 'hurt',
|
||||
label: '受击后仰',
|
||||
loop: false,
|
||||
defaultFps: 10,
|
||||
bodyTravel: '原地或极小后仰',
|
||||
weaponRule: '武器不要脱手,不要换手',
|
||||
sequenceLines: [
|
||||
'1-4 帧:突然受击,头肩后仰',
|
||||
'5-8 帧:身体失衡最明显',
|
||||
'9-12 帧:手臂和武器随惯性摆动',
|
||||
'13-16 帧:逐渐恢复到勉强站稳的姿态',
|
||||
],
|
||||
ending: '第 16 帧能接回 idle 或下一个动作',
|
||||
},
|
||||
{
|
||||
id: 'die',
|
||||
label: '倒地死亡',
|
||||
loop: false,
|
||||
defaultFps: 8,
|
||||
bodyTravel: '明显倒地位移',
|
||||
weaponRule: '武器不可瞬间消失',
|
||||
sequenceLines: [
|
||||
'1-4 帧:受创失衡,重心被打断',
|
||||
'5-8 帧:身体明显下坠或后仰',
|
||||
'9-12 帧:倒地过程完成,动作幅度最大',
|
||||
'13-16 帧:停在清晰的终止姿态',
|
||||
],
|
||||
ending: '第 16 帧停在死亡结束姿态,不需要循环',
|
||||
},
|
||||
];
|
||||
|
||||
const ACTION_TEMPLATE_DETAILS: Record<
|
||||
QwenSpriteActionTemplateId,
|
||||
{ stagingDirection: string; defaultDetailText: string }
|
||||
> = {
|
||||
idle: {
|
||||
stagingDirection:
|
||||
'演出重点是轻呼吸、微幅重心起伏、戒备感和可循环衔接。',
|
||||
defaultDetailText:
|
||||
'保持与角色设定一致的戒备气质和职业站姿,重心稳在脚下,呼吸起伏轻微,披风、衣摆、发梢和小型饰品只有细小摆动,武器安静跟随身体微动,像角色在所属世界里随时准备探索、交涉或开战的待机瞬间。',
|
||||
},
|
||||
run: {
|
||||
stagingDirection:
|
||||
'演出重点是持续推进、步点清楚、上身稳定和装备惯性。',
|
||||
defaultDetailText:
|
||||
'跑动时要体现角色职业和世界设定中的装备重量感,身体略微前压,步幅清晰,手臂与武器顺势摆动,披风、衣摆和挂件跟着惯性后甩,像角色正穿过战场、街巷或遗迹通道迅速移动,节奏连续稳定。',
|
||||
},
|
||||
attack_slash: {
|
||||
stagingDirection:
|
||||
'演出重点是收身蓄力、爆发斩击、武器轨迹和收招稳定。',
|
||||
defaultDetailText:
|
||||
'攻击前先有短暂收身蓄力,再顺着主手武器做干净利落的前踏斩击,爆发点明确,武器轨迹贴合角色职业习惯与世界观武器设定,衣摆和挂件随发力甩动,收招后还能稳住重心,像久经战斗训练的真实角色动作。',
|
||||
},
|
||||
hurt: {
|
||||
stagingDirection:
|
||||
'演出重点是冲击反馈、短暂失衡、惯性摆动和重新稳住。',
|
||||
defaultDetailText:
|
||||
'受击瞬间要有明确冲击反馈,肩背后仰或身体侧偏,表情短促吃痛但不夸张,武器和手臂因惯性发生合理摆动,服装层次和挂件跟着抖动一下,随后努力稳住姿态,像这个角色在其世界观里真实挨到一击后的反应。',
|
||||
},
|
||||
die: {
|
||||
stagingDirection:
|
||||
'演出重点是失衡下坠、动作逐渐停尽和终止姿态清晰。',
|
||||
defaultDetailText:
|
||||
'死亡动作要先表现失衡与力量被抽离,再完成明显倒下或瘫落,武器和四肢随惯性下坠,披风、衣摆和饰品有最后一次明显摆动,最终停在清晰的终止姿态,符合角色身份、装备重量和世界氛围,不要轻飘或喜剧化。',
|
||||
},
|
||||
};
|
||||
|
||||
export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
|
||||
const template =
|
||||
QWEN_SPRITE_ACTION_TEMPLATES.find((candidate) => candidate.id === id) ??
|
||||
QWEN_SPRITE_ACTION_TEMPLATES[0];
|
||||
return {
|
||||
...template,
|
||||
...ACTION_TEMPLATE_DETAILS[template.id],
|
||||
};
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function loadImageFromSource(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function drawContainedImage(
|
||||
context: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) {
|
||||
const fitScale = Math.min(
|
||||
options.width / image.width,
|
||||
options.height / image.height,
|
||||
);
|
||||
const drawWidth = image.width * fitScale;
|
||||
const drawHeight = image.height * fitScale;
|
||||
const drawX = options.x + (options.width - drawWidth) / 2;
|
||||
const drawY = options.y + (options.height - drawHeight) / 2;
|
||||
|
||||
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
}
|
||||
|
||||
export async function sliceSpriteSheetFrames(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
},
|
||||
) {
|
||||
const image = await loadImageFromSource(spriteSource);
|
||||
const frameWidth = Math.floor(image.width / options.cols);
|
||||
const frameHeight = Math.floor(image.height / options.rows);
|
||||
const frames: string[] = [];
|
||||
|
||||
for (let rowIndex = 0; rowIndex < options.rows; rowIndex += 1) {
|
||||
for (let colIndex = 0; colIndex < options.cols; colIndex += 1) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth;
|
||||
canvas.height = frameHeight;
|
||||
context.drawImage(
|
||||
image,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
0,
|
||||
0,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
frames.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frames,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
outputSize?: number;
|
||||
},
|
||||
) {
|
||||
const sliced = await sliceSpriteSheetFrames(spriteSource, {
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
});
|
||||
const frameSource = sliced.frames[options.frameIndex];
|
||||
|
||||
if (!frameSource) {
|
||||
throw new Error('帧索引超出范围。');
|
||||
}
|
||||
|
||||
if (!options.outputSize) {
|
||||
return frameSource;
|
||||
}
|
||||
|
||||
const image = await loadImageFromSource(frameSource);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = options.outputSize;
|
||||
canvas.height = options.outputSize;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function replaceSpriteFrame(
|
||||
spriteSource: string,
|
||||
options: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
frameIndex: number;
|
||||
replacementSource: string;
|
||||
},
|
||||
) {
|
||||
const spriteImage = await loadImageFromSource(spriteSource);
|
||||
const replacementImage = await loadImageFromSource(options.replacementSource);
|
||||
const frameWidth = Math.floor(spriteImage.width / options.cols);
|
||||
const frameHeight = Math.floor(spriteImage.height / options.rows);
|
||||
const rowIndex = Math.floor(options.frameIndex / options.cols);
|
||||
const colIndex = options.frameIndex % options.cols;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = spriteImage.width;
|
||||
canvas.height = spriteImage.height;
|
||||
context.drawImage(spriteImage, 0, 0);
|
||||
context.clearRect(
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
context.drawImage(
|
||||
replacementImage,
|
||||
colIndex * frameWidth,
|
||||
rowIndex * frameHeight,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameIndices(
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return frameOrder.filter((frameIndex) => activeFrames.includes(frameIndex));
|
||||
}
|
||||
|
||||
export function buildOrderedActiveFrameSources(
|
||||
frameDataUrls: string[],
|
||||
frameOrder: number[],
|
||||
activeFrames: number[],
|
||||
) {
|
||||
return buildOrderedActiveFrameIndices(frameOrder, activeFrames)
|
||||
.map((frameIndex) => frameDataUrls[frameIndex] ?? '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function composeSpriteSheetFromFrames(
|
||||
frameSources: string[],
|
||||
options: {
|
||||
cols: number;
|
||||
rows?: number;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
padToGrid?: boolean;
|
||||
},
|
||||
) {
|
||||
if (frameSources.length === 0) {
|
||||
throw new Error('没有可用于拼接精灵表的帧。');
|
||||
}
|
||||
|
||||
const images = await Promise.all(
|
||||
frameSources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const frameWidth =
|
||||
options.frameWidth ??
|
||||
Math.max(...images.map((image) => image.width), 1);
|
||||
const frameHeight =
|
||||
options.frameHeight ??
|
||||
Math.max(...images.map((image) => image.height), 1);
|
||||
const rows =
|
||||
options.rows ?? Math.max(1, Math.ceil(images.length / options.cols));
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = frameWidth * options.cols;
|
||||
canvas.height = frameHeight * rows;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const totalCells = options.padToGrid ? rows * options.cols : images.length;
|
||||
for (let index = 0; index < totalCells; index += 1) {
|
||||
const image = images[index];
|
||||
if (!image) {
|
||||
continue;
|
||||
}
|
||||
const rowIndex = Math.floor(index / options.cols);
|
||||
const colIndex = index % options.cols;
|
||||
drawContainedImage(context, image, {
|
||||
x: colIndex * frameWidth,
|
||||
y: rowIndex * frameHeight,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
rows,
|
||||
cols: options.cols,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
frameCount: frameSources.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildPlayableCharacterStyleReferenceBoard(
|
||||
sources = PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
|
||||
) {
|
||||
const images = await Promise.all(
|
||||
sources.map((source) => loadImageFromSource(source)),
|
||||
);
|
||||
const cols = 3;
|
||||
const rows = 2;
|
||||
const cellSize = 320;
|
||||
const padding = 24;
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('无法创建画布上下文');
|
||||
}
|
||||
|
||||
canvas.width = cols * cellSize + padding * 2;
|
||||
canvas.height = rows * cellSize + padding * 2;
|
||||
context.fillStyle = '#f6f0dd';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const colIndex = index % cols;
|
||||
const rowIndex = Math.floor(index / cols);
|
||||
drawContainedImage(context, image, {
|
||||
x: padding + colIndex * cellSize,
|
||||
y: padding + rowIndex * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
});
|
||||
});
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function buildMasterPrompt(characterBrief: string) {
|
||||
return [
|
||||
'???2D ???????????????????????????????????????????? sprite sheet ???',
|
||||
`?????${SIDE_FACING_RIGHT_TEXT}`,
|
||||
`?????${SUBJECT_ONLY_TEXT}`,
|
||||
`?????1:1 ??????????????????????????????????????????????????${CLEAN_BACKGROUND_TEXT}`,
|
||||
`?????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ?????????????????????????????????????/??/???????????????????????`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CHARACTER_DETAIL_COVERAGE_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
characterBrief.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildSheetPrompt(options: {
|
||||
characterBrief: string;
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
extraDirection: string;
|
||||
}) {
|
||||
return [
|
||||
`???1??????????? 4x4 ? sprite sheet?? 16 ????????????????????????????????????????????????????????????????????????????????????????????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`????${options.actionTemplate.label}`,
|
||||
`???????${options.actionTemplate.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||
`?????${options.actionTemplate.loop ? '?' : '?'}`,
|
||||
`?????${options.actionTemplate.bodyTravel}`,
|
||||
`?????${options.actionTemplate.weaponRule}`,
|
||||
...options.actionTemplate.sequenceLines,
|
||||
`?????${options.actionTemplate.ending}`,
|
||||
'?????????????????????????????????????????????????????????????????????????????????? sprite frames?',
|
||||
options.characterBrief.trim(),
|
||||
`???????${options.extraDirection.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildRepairPrompt(options: {
|
||||
issueText: string;
|
||||
useNeighborLabel: '???' | '???';
|
||||
}) {
|
||||
return [
|
||||
`???1??????????2??????????3???????2??${options.useNeighborLabel}?`,
|
||||
`?????????????????????????????????????????????????????????2???????1?????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${SETTING_AND_ROLE_ALIGNMENT_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${THEME_APPLICATION_BOUNDARY_TEXT} ???3???????????????? sprite sheet ??`,
|
||||
'?????????????????????????',
|
||||
`?????${options.issueText.trim() || '????????????????????'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildVideoActionPrompt(options: {
|
||||
actionTemplate: QwenSpriteActionTemplate;
|
||||
actionDetailText: string;
|
||||
useChromaKey: boolean;
|
||||
characterBrief: string;
|
||||
}) {
|
||||
return [
|
||||
`???????????????? ${options.actionTemplate.label}?`,
|
||||
`??????1??????????????????????????????????? 90 ??????${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
|
||||
SETTING_AND_ROLE_ALIGNMENT_TEXT,
|
||||
CONCEPT_INTERPRETATION_TEXT,
|
||||
HUMANLIKE_PRIORITY_TEXT,
|
||||
CONCEPT_HIERARCHY_TEXT,
|
||||
THEME_APPLICATION_BOUNDARY_TEXT,
|
||||
`???????${options.actionTemplate.stagingDirection ?? ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].stagingDirection}`,
|
||||
`?????${options.actionTemplate.sequenceLines.join('?')}??????${options.actionTemplate.ending}?`,
|
||||
options.useChromaKey
|
||||
? '??????????????????????????????'
|
||||
: '?????????????',
|
||||
`???????${options.actionDetailText.trim() || options.actionTemplate.defaultDetailText || ACTION_TEMPLATE_DETAILS[options.actionTemplate.id].defaultDetailText}`,
|
||||
`?????${options.characterBrief.trim()}`,
|
||||
'?????????????????????????????????????????',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export async function triggerDataUrlDownload(
|
||||
filename: string,
|
||||
dataUrl: string,
|
||||
) {
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function triggerJsonDownload(filename: string, value: unknown) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
export function buildDefaultFrameOrder(frameCount: number) {
|
||||
return Array.from({ length: frameCount }, (_, index) => index);
|
||||
}
|
||||
|
||||
export function restoreAllFrames(frameCount: number) {
|
||||
return buildDefaultFrameOrder(frameCount);
|
||||
}
|
||||
|
||||
export function buildMasterNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,也不要把主题词自动扩写成角色以外的场景元素,除非文字设定明确要求`;
|
||||
}
|
||||
|
||||
export function buildSheetNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_SHEET_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function buildRepairNegativePrompt(_characterBrief: string) {
|
||||
return `${DEFAULT_REPAIR_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
|
||||
}
|
||||
|
||||
export function moveFrameOrderItem(
|
||||
frameOrder: number[],
|
||||
frameIndex: number,
|
||||
direction: -1 | 1,
|
||||
) {
|
||||
const currentOrderIndex = frameOrder.indexOf(frameIndex);
|
||||
if (currentOrderIndex < 0) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const targetIndex = currentOrderIndex + direction;
|
||||
if (targetIndex < 0 || targetIndex >= frameOrder.length) {
|
||||
return frameOrder;
|
||||
}
|
||||
|
||||
const nextOrder = [...frameOrder];
|
||||
const [item] = nextOrder.splice(currentOrderIndex, 1);
|
||||
nextOrder.splice(targetIndex, 0, item);
|
||||
return nextOrder;
|
||||
}
|
||||
|
||||
export function toggleActiveFrame(activeFrames: number[], frameIndex: number) {
|
||||
if (activeFrames.includes(frameIndex)) {
|
||||
return activeFrames.filter((item) => item !== frameIndex);
|
||||
}
|
||||
|
||||
return [...activeFrames, frameIndex].sort((left, right) => left - right);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
|
||||
你会收到一个已经解析过的剧情 JSON 对象。
|
||||
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
|
||||
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
|
||||
@@ -9,7 +9,7 @@ describe('matchAppRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('routes deprecated editor paths back to the main game', () => {
|
||||
it('routes former standalone editor paths back to the main game', () => {
|
||||
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
||||
kind: 'game',
|
||||
});
|
||||
@@ -26,10 +26,4 @@ describe('matchAppRoute', () => {
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the sprite tool route', () => {
|
||||
expect(matchAppRoute('/sprite-tool')).toEqual({
|
||||
kind: 'qwen-sprite-tool',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,6 @@ type AppRouteComponent = LazyExoticComponent<
|
||||
export type AppRouteMatch =
|
||||
| {
|
||||
kind: 'game';
|
||||
}
|
||||
| {
|
||||
kind: 'qwen-sprite-tool';
|
||||
};
|
||||
|
||||
export type ResolvedAppRoute = {
|
||||
@@ -23,15 +20,6 @@ export type ResolvedAppRoute = {
|
||||
};
|
||||
|
||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||
const QwenSpriteToolApp = lazy(
|
||||
() => import('../tools/QwenSpriteSheetTool'),
|
||||
) as AppRouteComponent;
|
||||
|
||||
const QWEN_SPRITE_TOOL_PREFIXES = [
|
||||
'/qwen-sprite-tool',
|
||||
'/sprite-tool',
|
||||
'/pixelmotion-qwen',
|
||||
];
|
||||
|
||||
function normalizeRoutePath(pathname: string) {
|
||||
const trimmedPathname = pathname.trim().toLowerCase();
|
||||
@@ -43,25 +31,8 @@ function normalizeRoutePath(pathname: string) {
|
||||
return trimmedPathname.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function matchesRoutePrefix(pathname: string, prefix: string) {
|
||||
const normalizedPrefix = normalizeRoutePath(prefix);
|
||||
|
||||
return (
|
||||
pathname === normalizedPrefix || pathname.startsWith(`${normalizedPrefix}/`)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchAppRoute(pathname: string): AppRouteMatch {
|
||||
const normalizedPathname = normalizeRoutePath(pathname);
|
||||
const isQwenSpriteToolRoute = QWEN_SPRITE_TOOL_PREFIXES.some((prefix) =>
|
||||
matchesRoutePrefix(normalizedPathname, prefix),
|
||||
);
|
||||
|
||||
if (isQwenSpriteToolRoute) {
|
||||
return {
|
||||
kind: 'qwen-sprite-tool',
|
||||
};
|
||||
}
|
||||
void normalizeRoutePath(pathname);
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
@@ -71,15 +42,6 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
|
||||
export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
||||
const matchedRoute = matchAppRoute(pathname);
|
||||
|
||||
if (matchedRoute.kind === 'qwen-sprite-tool') {
|
||||
return {
|
||||
kind: matchedRoute.kind,
|
||||
loadingEyebrow: '正在载入精灵表工坊',
|
||||
loadingText: '正在载入 Qwen 精灵表工具...',
|
||||
Component: QwenSpriteToolApp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
loadingEyebrow: '正在载入游戏',
|
||||
|
||||
@@ -10,11 +10,7 @@ import type {
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
AnswerCustomWorldSessionQuestionRequest,
|
||||
CreateCustomWorldSessionRequest,
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSessionRecord,
|
||||
CustomWorldSessionSummary,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
@@ -357,190 +353,8 @@ export async function generateCustomWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedInput =
|
||||
typeof input === 'string'
|
||||
? {
|
||||
settingText: input,
|
||||
creatorIntent: null,
|
||||
generationMode: 'full' as const,
|
||||
}
|
||||
: {
|
||||
settingText: input.settingText,
|
||||
creatorIntent: input.creatorIntent ?? null,
|
||||
generationMode:
|
||||
input.generationMode === 'fast'
|
||||
? ('fast' as const)
|
||||
: ('full' as const),
|
||||
};
|
||||
|
||||
const session = await createCustomWorldSession({
|
||||
settingText: normalizedInput.settingText,
|
||||
creatorIntent: normalizedInput.creatorIntent as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
generationMode: normalizedInput.generationMode,
|
||||
});
|
||||
|
||||
const fallbackAnswerMap: Record<string, string> = {
|
||||
world_hook:
|
||||
typeof normalizedInput.creatorIntent?.worldHook === 'string' &&
|
||||
normalizedInput.creatorIntent.worldHook.trim()
|
||||
? normalizedInput.creatorIntent.worldHook.trim()
|
||||
: normalizedInput.settingText.trim().slice(0, 120) ||
|
||||
'这是一个围绕失衡秩序展开的世界。',
|
||||
player_premise:
|
||||
typeof normalizedInput.creatorIntent?.playerPremise === 'string' &&
|
||||
normalizedInput.creatorIntent.playerPremise.trim()
|
||||
? normalizedInput.creatorIntent.playerPremise.trim()
|
||||
: '玩家是一名被卷入局势中心的行动者。',
|
||||
opening_situation:
|
||||
typeof normalizedInput.creatorIntent?.openingSituation === 'string' &&
|
||||
normalizedInput.creatorIntent.openingSituation.trim()
|
||||
? normalizedInput.creatorIntent.openingSituation.trim()
|
||||
: '故事开局时,玩家正身处风暴边缘,必须立刻判断立场与风险。',
|
||||
core_conflict:
|
||||
Array.isArray(normalizedInput.creatorIntent?.coreConflicts) &&
|
||||
normalizedInput.creatorIntent.coreConflicts.length > 0
|
||||
? normalizedInput.creatorIntent.coreConflicts
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
: '旧秩序与新威胁正在同时逼近,各方都在争夺主动权。',
|
||||
};
|
||||
|
||||
for (const question of session.questions ?? []) {
|
||||
if (question.answer?.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const answer =
|
||||
fallbackAnswerMap[question.id] || normalizedInput.settingText.trim();
|
||||
await answerCustomWorldSessionQuestion(session.sessionId, {
|
||||
questionId: question.id,
|
||||
answer,
|
||||
});
|
||||
}
|
||||
|
||||
return streamCustomWorldSessionGeneration(session.sessionId, options);
|
||||
}
|
||||
|
||||
export async function streamCustomWorldSessionGeneration(
|
||||
sessionId: string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const response = await fetchWithApiAuth(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/generate/stream`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, '生成自定义世界失败'));
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('自定义世界生成流不可用');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let latestProfile: Record<string, unknown> | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
let eventName = '';
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payloadText = line.slice(5).trim();
|
||||
if (!payloadText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadText) as Record<string, unknown>;
|
||||
if (eventName === 'progress') {
|
||||
if (
|
||||
typeof payload.phaseId === 'string' &&
|
||||
typeof payload.phaseLabel === 'string' &&
|
||||
typeof payload.phaseDetail === 'string' &&
|
||||
typeof payload.overallProgress === 'number' &&
|
||||
Array.isArray(payload.steps)
|
||||
) {
|
||||
options.onProgress?.(
|
||||
payload as unknown as CustomWorldGenerationProgress,
|
||||
);
|
||||
} else {
|
||||
options.onProgress?.({
|
||||
phaseId: 'finalize',
|
||||
phaseLabel:
|
||||
typeof payload.phase === 'string'
|
||||
? payload.phase
|
||||
: 'generating',
|
||||
phaseDetail:
|
||||
typeof payload.phase === 'string'
|
||||
? payload.phase
|
||||
: 'generating',
|
||||
overallProgress:
|
||||
typeof payload.progress === 'number'
|
||||
? payload.progress / 100
|
||||
: 0,
|
||||
completedWeight:
|
||||
typeof payload.progress === 'number' ? payload.progress : 0,
|
||||
totalWeight: 100,
|
||||
elapsedMs: 0,
|
||||
estimatedRemainingMs: null,
|
||||
activeStepIndex: 0,
|
||||
steps: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
eventName === 'result' &&
|
||||
payload.profile &&
|
||||
typeof payload.profile === 'object'
|
||||
) {
|
||||
latestProfile = payload.profile as Record<string, unknown>;
|
||||
}
|
||||
if (eventName === 'error') {
|
||||
throw new Error(
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: '生成自定义世界失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestProfile) {
|
||||
throw new Error('自定义世界生成未返回结果');
|
||||
}
|
||||
|
||||
return latestProfile as unknown as CustomWorldProfile;
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldProfile(input, options);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage(
|
||||
@@ -625,22 +439,6 @@ export async function generateCustomWorldLandmark(payload: {
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function createCustomWorldSession(payload: {
|
||||
settingText: string;
|
||||
creatorIntent?: Record<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
}) {
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
|
||||
},
|
||||
'创建自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCustomWorldWorks() {
|
||||
const response = await requestJson<ListCustomWorldWorksResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world/works`,
|
||||
@@ -842,33 +640,6 @@ export async function getCustomWorldAgentCardDetail(
|
||||
return response.card as CustomWorldDraftCardDetail;
|
||||
}
|
||||
|
||||
export async function getCustomWorldSession(sessionId: string) {
|
||||
return requestJson<CustomWorldSessionRecord>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function answerCustomWorldSessionQuestion(
|
||||
sessionId: string,
|
||||
payload: { questionId: string; answer: string },
|
||||
) {
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(
|
||||
payload satisfies AnswerCustomWorldSessionQuestionRequest,
|
||||
),
|
||||
},
|
||||
'提交自定义世界补充设定失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamCharacterPanelChatReply(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
} from '../../packages/shared/src/http';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
|
||||
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
|
||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const API_VERSION_HEADER = 'x-api-version';
|
||||
@@ -376,46 +374,6 @@ export function clearStoredAccessToken(
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAutoAuthCredentials() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = window.localStorage.getItem(AUTO_AUTH_USERNAME_KEY)?.trim() || '';
|
||||
const password = window.localStorage.getItem(AUTO_AUTH_PASSWORD_KEY)?.trim() || '';
|
||||
|
||||
if (!username || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
export function setStoredAutoAuthCredentials(credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
}) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(AUTO_AUTH_USERNAME_KEY, credentials.username.trim());
|
||||
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
|
||||
}
|
||||
|
||||
export function clearStoredAutoAuthCredentials() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
|
||||
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(
|
||||
headers?: HeadersInit,
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||
|
||||
@@ -7,9 +7,7 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
getStoredAutoAuthCredentials,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
@@ -67,7 +65,6 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
});
|
||||
|
||||
it('creates credentials that match current username/password constraints', () => {
|
||||
@@ -78,7 +75,7 @@ describe('authService auto auth', () => {
|
||||
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('stores jwt and auto credentials after auth entry', async () => {
|
||||
it('stores jwt after auth entry without persisting guest credentials locally', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-token-value',
|
||||
user: {
|
||||
@@ -99,10 +96,6 @@ describe('authService auto auth', () => {
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
expect(getStoredAccessToken()).toBe('jwt-token-value');
|
||||
expect(getStoredAutoAuthCredentials()).toEqual({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
@@ -115,9 +108,7 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses stored auto credentials before generating a new account', async () => {
|
||||
window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01');
|
||||
window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password');
|
||||
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-restored',
|
||||
user: {
|
||||
@@ -132,16 +123,24 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
|
||||
const result = await ensureAutoAuthUser();
|
||||
const authEntryBody = JSON.parse(
|
||||
requestJsonMock.mock.calls[0]?.[1]?.body as string,
|
||||
) as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
expect(result.user.username).toBe('guest_saveduser01');
|
||||
expect(result.credentials).toEqual({
|
||||
username: 'guest_saveduser01',
|
||||
password: 'auto_saved_password',
|
||||
});
|
||||
expect(result.credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
|
||||
expect(result.credentials.password).toMatch(
|
||||
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
|
||||
);
|
||||
expect(authEntryBody).toEqual(result.credentials);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(String),
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
@@ -168,7 +167,13 @@ describe('authService auto auth', () => {
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult).toEqual(secondResult);
|
||||
expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
|
||||
const authEntryBody = JSON.parse(
|
||||
requestJsonMock.mock.calls[0]?.[1]?.body as string,
|
||||
) as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
expect(authEntryBody).toEqual(firstResult.credentials);
|
||||
});
|
||||
|
||||
it('sends phone login code through the new auth endpoint', async () => {
|
||||
|
||||
@@ -24,11 +24,8 @@ import type {
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAutoAuthCredentials,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
setStoredAutoAuthCredentials,
|
||||
} from './apiClient';
|
||||
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
@@ -121,7 +118,6 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
|
||||
export function clearAuthSession() {
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
}
|
||||
|
||||
export async function sendPhoneLoginCode(
|
||||
@@ -249,14 +245,12 @@ export async function authEntryWithStoredCredentials(
|
||||
normalizedCredentials.username,
|
||||
normalizedCredentials.password,
|
||||
);
|
||||
setStoredAutoAuthCredentials(normalizedCredentials);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function ensureAutoAuthUser() {
|
||||
pendingAutoAuthUser ??= (async () => {
|
||||
const credentials =
|
||||
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
|
||||
const credentials = createAutoAuthCredentials();
|
||||
const user = await authEntryWithStoredCredentials(credentials);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { WorldType } from '../types';
|
||||
|
||||
const ATTRIBUTE_LABELS = {
|
||||
strength: 'Strength',
|
||||
agility: 'Agility',
|
||||
intelligence: 'Intelligence',
|
||||
spirit: 'Spirit',
|
||||
} as const;
|
||||
|
||||
const RESOURCE_LABELS = {
|
||||
hp: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '灵力上限',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: '灵力消耗',
|
||||
} as const;
|
||||
|
||||
export function buildThemedSkillName(_profile: unknown, style: string, index = 0) {
|
||||
return `${style || 'skill'}-${index + 1}`;
|
||||
}
|
||||
|
||||
export function buildCustomCampSceneName(profile: { name?: string; camp?: { name?: string | null } | null } | null | undefined) {
|
||||
return profile?.camp?.name?.trim() || (profile?.name ? `${profile.name}归舍` : '归舍');
|
||||
}
|
||||
|
||||
export function getAttributeLabelsForWorld(_worldType: WorldType | null) {
|
||||
return ATTRIBUTE_LABELS;
|
||||
}
|
||||
|
||||
export function getResourceLabelsForWorld(_worldType: WorldType | null) {
|
||||
return RESOURCE_LABELS;
|
||||
}
|
||||
|
||||
export function buildThemedItemName(_profile: unknown, category: string, rarity: string, seedKey: string) {
|
||||
return `${category}-${rarity}-${seedKey}`;
|
||||
}
|
||||
|
||||
export function buildThemedItemDescription(_profile: unknown, category: string, rarity: string, seedKey: string) {
|
||||
return `${category}-${rarity}-${seedKey} description`;
|
||||
}
|
||||
|
||||
export function inferCustomItemMechanics() {
|
||||
return {
|
||||
tags: [],
|
||||
equipmentSlotId: null,
|
||||
statProfile: null,
|
||||
useProfile: null,
|
||||
value: 0,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function getTypewriterDelay(char: string) {
|
||||
if (/[。!??!]/u.test(char)) return 240;
|
||||
if (/[,、;:,;:]/u.test(char)) return 150;
|
||||
if (/\s/u.test(char)) return 45;
|
||||
return 90;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
||||
import {
|
||||
buildDefaultFrameOrder,
|
||||
DEFAULT_CHARACTER_BRIEF,
|
||||
buildMasterNegativePrompt,
|
||||
buildMasterPrompt,
|
||||
buildOrderedActiveFrameIndices,
|
||||
buildOrderedActiveFrameSources,
|
||||
buildRepairNegativePrompt,
|
||||
buildRepairPrompt,
|
||||
buildSheetNegativePrompt,
|
||||
buildSheetPrompt,
|
||||
buildVideoActionPrompt,
|
||||
getActionTemplateById,
|
||||
moveFrameOrderItem,
|
||||
PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
|
||||
restoreAllFrames,
|
||||
toggleActiveFrame,
|
||||
} from './qwenSpriteSheetToolModel';
|
||||
|
||||
describe('qwenSpriteSheetToolModel', () => {
|
||||
it('builds ordered active frame indices from current order and active set', () => {
|
||||
expect(buildOrderedActiveFrameIndices([3, 1, 0, 2], [0, 2, 3])).toEqual([
|
||||
3, 0, 2,
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds ordered active frame sources', () => {
|
||||
expect(
|
||||
buildOrderedActiveFrameSources(
|
||||
['f0', 'f1', 'f2', 'f3'],
|
||||
[3, 1, 0, 2],
|
||||
[0, 2, 3],
|
||||
),
|
||||
).toEqual(['f3', 'f0', 'f2']);
|
||||
});
|
||||
|
||||
it('moves a frame forward or backward in order', () => {
|
||||
expect(moveFrameOrderItem([0, 1, 2, 3], 2, -1)).toEqual([0, 2, 1, 3]);
|
||||
expect(moveFrameOrderItem([0, 1, 2, 3], 1, 1)).toEqual([0, 2, 1, 3]);
|
||||
expect(moveFrameOrderItem([0, 1, 2, 3], 0, -1)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('toggles active frames without duplicating indices', () => {
|
||||
expect(toggleActiveFrame([0, 2, 3], 2)).toEqual([0, 3]);
|
||||
expect(toggleActiveFrame([0, 3], 2)).toEqual([0, 2, 3]);
|
||||
});
|
||||
|
||||
it('restores all frames to the default order', () => {
|
||||
expect(buildDefaultFrameOrder(4)).toEqual([0, 1, 2, 3]);
|
||||
expect(restoreAllFrames(4)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('provides action-specific default detail text for all five action templates', () => {
|
||||
const actionTemplateIds = ['idle', 'run', 'attack_slash', 'hurt', 'die'] as const;
|
||||
|
||||
actionTemplateIds.forEach((actionTemplateId) => {
|
||||
const actionTemplate = getActionTemplateById(actionTemplateId);
|
||||
expect(actionTemplate.defaultDetailText?.length ?? 0).toBeGreaterThan(20);
|
||||
expect(actionTemplate.stagingDirection?.length ?? 0).toBeGreaterThan(8);
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a sheet prompt that contains the template structure', () => {
|
||||
const actionTemplate = getActionTemplateById('attack_slash');
|
||||
const prompt = buildSheetPrompt({
|
||||
characterBrief: '黑发青年剑士,右手持长剑。',
|
||||
actionTemplate,
|
||||
extraDirection: '每格边界清晰。',
|
||||
});
|
||||
|
||||
expect(prompt).toContain('4x4');
|
||||
expect(prompt).toContain('横斩攻击');
|
||||
expect(prompt).toContain('1-4 帧');
|
||||
expect(prompt).toContain('黑发青年剑士');
|
||||
expect(prompt).toContain('每格边界清晰');
|
||||
expect(prompt).toContain('大头身');
|
||||
});
|
||||
|
||||
it('builds a master prompt with square canvas and richer world-character detail coverage', () => {
|
||||
const prompt = buildMasterPrompt(DEFAULT_CHARACTER_BRIEF);
|
||||
|
||||
expect(prompt).toContain('1:1');
|
||||
expect(prompt).toContain('sprite sheet');
|
||||
expect(prompt).toContain('90');
|
||||
expect(prompt).toContain(DEFAULT_CHARACTER_BRIEF);
|
||||
expect(prompt).toContain('????????????');
|
||||
expect(prompt).toContain('????????????????????????');
|
||||
});
|
||||
|
||||
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(prompt).toContain('主题词默认只作用在角色自身');
|
||||
expect(negativePrompt).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
});
|
||||
|
||||
it('keeps theme words on the character instead of leaking into the background', () => {
|
||||
const prompt = buildMasterPrompt('机械祭司,冷白金属外套,环形圣徽。');
|
||||
|
||||
expect(prompt).toContain('主题词默认只作用在角色自身');
|
||||
expect(prompt).toContain('不要把主题词自动扩写成背景建筑');
|
||||
expect(prompt).not.toContain('水母国王');
|
||||
});
|
||||
|
||||
it('builds a repair prompt that keeps chibi ratio', () => {
|
||||
const prompt = buildRepairPrompt({
|
||||
issueText: '修复头部和手部比例。',
|
||||
useNeighborLabel: '上一帧',
|
||||
});
|
||||
|
||||
expect(prompt).toContain('上一帧');
|
||||
expect(prompt).toContain('大头身');
|
||||
});
|
||||
|
||||
it('builds a video action prompt with pixel style constraints', () => {
|
||||
const actionTemplate = getActionTemplateById('run');
|
||||
const prompt = buildVideoActionPrompt({
|
||||
actionTemplate,
|
||||
actionDetailText: '?????????????????????????????????????????????',
|
||||
characterBrief: '?????????????????????????????????????????????',
|
||||
useChromaKey: true,
|
||||
});
|
||||
|
||||
expect(prompt).toContain(actionTemplate.label);
|
||||
expect(prompt).toContain(actionTemplate.stagingDirection ?? '');
|
||||
expect(prompt).toContain('90');
|
||||
expect(prompt).toContain('Q');
|
||||
expect(prompt).toContain('sprite');
|
||||
});
|
||||
|
||||
it('builds generic theme over-literalization negatives', () => {
|
||||
expect(buildSheetNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
expect(buildRepairNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
|
||||
expect(buildMasterNegativePrompt('机械祭司')).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);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/qwenSpriteSheetToolPrompts';
|
||||
@@ -1,113 +0,0 @@
|
||||
import {
|
||||
ASSET_API_PATHS,
|
||||
postApiJson,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
|
||||
const QWEN_SPRITE_MASTER_API_PATH = ASSET_API_PATHS.qwenSpriteMaster;
|
||||
const QWEN_SPRITE_SHEET_API_PATH = ASSET_API_PATHS.qwenSpriteSheet;
|
||||
const QWEN_SPRITE_FRAME_REPAIR_API_PATH =
|
||||
ASSET_API_PATHS.qwenSpriteFrameRepair;
|
||||
const QWEN_SPRITE_SAVE_API_PATH = ASSET_API_PATHS.qwenSpriteSave;
|
||||
|
||||
export type QwenSpriteImageDraft = {
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
remoteUrl?: string;
|
||||
};
|
||||
|
||||
export type GenerateQwenSpritePayload = {
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
promptExtend: boolean;
|
||||
candidateCount: number;
|
||||
seed?: number;
|
||||
referenceImages: string[];
|
||||
};
|
||||
|
||||
export type RepairQwenSpriteFramePayload = {
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
model: string;
|
||||
size: string;
|
||||
promptExtend: boolean;
|
||||
seed?: number;
|
||||
referenceImages: string[];
|
||||
};
|
||||
|
||||
export type SaveQwenSpriteAssetPayload = {
|
||||
assetKey: string;
|
||||
actionKey: string;
|
||||
masterSource: string;
|
||||
sheetSource: string;
|
||||
framesDataUrls: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
prompts: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function postJson<T>(
|
||||
url: string,
|
||||
payload: Record<string, unknown>,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return postApiJson<T>(url, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function generateQwenSpriteMaster(
|
||||
payload: GenerateQwenSpritePayload,
|
||||
) {
|
||||
return postJson<{
|
||||
ok: true;
|
||||
draftId: string;
|
||||
drafts: QwenSpriteImageDraft[];
|
||||
model: string;
|
||||
size: string;
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
}>(QWEN_SPRITE_MASTER_API_PATH, payload, '生成主图失败');
|
||||
}
|
||||
|
||||
export async function generateQwenSpriteSheet(
|
||||
payload: GenerateQwenSpritePayload,
|
||||
) {
|
||||
return postJson<{
|
||||
ok: true;
|
||||
draftId: string;
|
||||
drafts: QwenSpriteImageDraft[];
|
||||
model: string;
|
||||
size: string;
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
}>(QWEN_SPRITE_SHEET_API_PATH, payload, '生成精灵表失败');
|
||||
}
|
||||
|
||||
export async function repairQwenSpriteFrame(
|
||||
payload: RepairQwenSpriteFramePayload,
|
||||
) {
|
||||
return postJson<{
|
||||
ok: true;
|
||||
draftId: string;
|
||||
drafts: QwenSpriteImageDraft[];
|
||||
repairedFrame: QwenSpriteImageDraft | null;
|
||||
model: string;
|
||||
size: string;
|
||||
promptText: string;
|
||||
negativePrompt: string;
|
||||
}>(QWEN_SPRITE_FRAME_REPAIR_API_PATH, payload, '修复帧失败');
|
||||
}
|
||||
|
||||
export async function saveQwenSpriteAsset(
|
||||
payload: SaveQwenSpriteAssetPayload,
|
||||
) {
|
||||
return postJson<{
|
||||
ok: true;
|
||||
assetId: string;
|
||||
assetDir: string;
|
||||
masterImagePath: string | null;
|
||||
sheetImagePath: string;
|
||||
framePaths: string[];
|
||||
saveMessage: string;
|
||||
}>(QWEN_SPRITE_SAVE_API_PATH, payload, '保存精灵表失败');
|
||||
}
|
||||
Reference in New Issue
Block a user