创作数据流程收束

This commit is contained in:
2026-04-21 09:44:17 +08:00
parent effe0355bd
commit 3614e1f5a2
93 changed files with 1794 additions and 8651 deletions

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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);
});

View File

@@ -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>
);
}

View File

@@ -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();
});

View File

@@ -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');
});

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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');
});

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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');
});

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
))}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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}

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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;

View File

@@ -1,9 +0,0 @@
import type { InventoryItem } from '../types';
export function buildCustomWorldStarterInventoryItems(): InventoryItem[] {
return [];
}
export function buildCustomWorldStarterEquipmentItems(): InventoryItem[] {
return [];
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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 =

View File

@@ -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 };
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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 {

View File

@@ -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、代码块、解释或额外文字。`;

View File

@@ -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);
}

View File

@@ -1,5 +0,0 @@
export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
你会收到一个已经解析过的剧情 JSON 对象。
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;

View File

@@ -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',
});
});
});

View File

@@ -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: '正在载入游戏',

View File

@@ -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,

View File

@@ -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'> = {},

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -1 +0,0 @@
export * from '../prompts/qwenSpriteSheetToolPrompts';

View File

@@ -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, '保存精灵表失败');
}