初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

View File

@@ -0,0 +1,596 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import {
readSavedCustomWorldProfiles,
upsertSavedCustomWorldProfile,
} from '../../data/customWorldLibrary';
import { getScenePreset } from '../../data/scenePresets';
import { generateCustomWorldProfile } from '../../services/ai';
import {
type CustomWorldProfile,
type GameState,
WorldType,
} from '../../types';
import {
CHROME_ICONS,
getNineSliceStyle,
UI_CHROME,
WORLD_SELECT_ICONS,
} from '../../uiAssets';
import { CustomWorldResultView } from '../CustomWorldResultView';
import { DeveloperTeamModal } from '../DeveloperTeamModal';
import { PixelIcon } from '../PixelIcon';
import { CustomWorldCreatorModal } from '../SelectionCustomizationModals';
export type SelectionStage = 'start' | 'world' | 'custom-world-result';
type WorldOnlineCounts = Partial<Record<WorldType, number>>;
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
gameState: GameState;
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleWorldSelect: (
type: WorldType,
customWorldProfile?: GameState['customWorldProfile'],
) => void;
};
const DEVELOPER_TEAM_MESSAGE =
'\u7a0b\u7b56\u7f8e\uff1a\u53d9\u4e16AI \u5305\u4ef2\u822a\n\u5408\u4f5c\u8bf7\u8054\u7cfb\u5fae\u4fe1\uff1abzh253518756';
const START_SCREEN_CONTACTS = [
{ label: 'QQ群', value: '1094580241' },
{ label: '微信', value: 'bzh253518756' },
] as const;
const WORLD_OPTIONS = [
{
id: WorldType.WUXIA,
name: '武侠',
subtitle: '刀剑江湖',
icon: WORLD_SELECT_ICONS.wuxia,
texture: UI_CHROME.worldButtonWuxia,
},
{
id: WorldType.XIANXIA,
name: '仙侠',
subtitle: '云灵仙境',
icon: WORLD_SELECT_ICONS.xianxia,
texture: UI_CHROME.worldButtonXianxia,
},
] as const;
function generateWorldOnlineCounts(): WorldOnlineCounts {
const roll = (base: number) =>
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
return {
[WorldType.WUXIA]: roll(146),
[WorldType.XIANXIA]: roll(173),
};
}
function getCustomWorldGenerationLabel(progress: number) {
if (progress >= 96) return '正在完成世界归档...';
if (progress >= 78) return '正在关联地标和关键物品...';
if (progress >= 52) return '正在生成核心角色...';
if (progress >= 28) return '正在生成可玩角色...';
return '正在解析世界设置...';
}
function getCustomWorldProgressLabel(progress: number) {
if (progress >= 96) return '正在完成世界归档...';
if (progress >= 78) return '正在组合场景和视觉效果...';
if (progress >= 52) return '正在生成核心角色...';
if (progress >= 28) return '正在生成可玩角色...';
return '正在解析世界设置...';
}
export function PreGameSelectionFlow({
selectionStage,
setSelectionStage,
gameState,
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleWorldSelect,
}: PreGameSelectionFlowProps) {
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<GameState['customWorldProfile']>(null);
const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState<
CustomWorldProfile[]
>(() => readSavedCustomWorldProfiles());
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
const [worldOnlineCounts, setWorldOnlineCounts] = useState<WorldOnlineCounts>(
() => generateWorldOnlineCounts(),
);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldDraft, setCustomWorldDraft] = useState('');
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false);
const [customWorldProgress, setCustomWorldProgress] = useState(0);
const previewCustomWorldCharacters = useMemo(
() =>
generatedCustomWorldProfile
? buildCustomWorldPlayableCharacters(generatedCustomWorldProfile)
: [],
[generatedCustomWorldProfile],
);
const worldCards = useMemo(
() =>
WORLD_OPTIONS.map((world, index) => ({
...world,
sceneImage:
getScenePreset(world.id, index + 1)?.imageSrc ??
getScenePreset(world.id, 0)?.imageSrc ??
'',
featureIcon:
world.id === WorldType.WUXIA
? '/Icons/03_Torch.png'
: '/Icons/19_Mana_potion.png',
onlineCount: worldOnlineCounts[world.id] ?? 0,
})),
[worldOnlineCounts],
);
const savedCustomWorldCards = useMemo(
() =>
savedCustomWorldProfiles.map((profile, index) => {
const anchorWorldType = profile.templateWorldType;
const leadCharacter =
buildCustomWorldPlayableCharacters(profile)[0] ?? null;
return {
id: profile.id,
profile,
texture:
anchorWorldType === WorldType.WUXIA
? UI_CHROME.worldButtonWuxia
: UI_CHROME.worldButtonXianxia,
sceneImage:
profile.landmarks[0]?.imageSrc ??
getScenePreset(anchorWorldType, (index % 3) + 1)?.imageSrc ??
getScenePreset(anchorWorldType, 0)?.imageSrc ??
'',
featurePortrait: leadCharacter?.portrait ?? '',
featureIcon:
anchorWorldType === WorldType.WUXIA
? WORLD_SELECT_ICONS.wuxia
: WORLD_SELECT_ICONS.xianxia,
accentLabel:
anchorWorldType === WorldType.WUXIA ? '武侠基础' : '仙侠基础',
};
}),
[savedCustomWorldProfiles],
);
useEffect(() => {
if (!gameState.worldType && selectionStage === 'world') {
setWorldOnlineCounts(generateWorldOnlineCounts());
}
}, [gameState.worldType, selectionStage]);
useEffect(() => {
if (
selectionStage === 'custom-world-result' &&
!generatedCustomWorldProfile
) {
setSelectionStage('world');
}
}, [generatedCustomWorldProfile, selectionStage, setSelectionStage]);
const leaveCustomWorldResult = () => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setSelectionStage('world');
};
const saveGeneratedCustomWorld = () => {
if (!generatedCustomWorldProfile) {
return;
}
try {
setSavedCustomWorldProfiles(
upsertSavedCustomWorldProfile(generatedCustomWorldProfile),
);
} catch (error) {
setCustomWorldError(
error instanceof Error ? error.message : '本地保存自定义世界失败。',
);
return;
}
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setSelectionStage('world');
};
const createCustomWorld = async () => {
const settingText = customWorldDraft.trim();
if (!settingText) {
setCustomWorldError('请先输入世界设置。');
return;
}
setCustomWorldError(null);
setIsGeneratingCustomWorld(true);
setCustomWorldProgress(8);
const progressTimer = window.setInterval(() => {
setCustomWorldProgress((current) => {
if (current >= 92) return current;
return Math.min(
92,
current + Math.max(3, Math.round((96 - current) / 5)),
);
});
}, 260);
try {
const profile = await generateCustomWorldProfile(settingText);
window.clearInterval(progressTimer);
setCustomWorldProgress(100);
await new Promise((resolve) => window.setTimeout(resolve, 180));
setGeneratedCustomWorldProfile(profile);
setShowCustomWorldModal(false);
setCustomWorldError(null);
setSelectionStage('custom-world-result');
} catch (error) {
window.clearInterval(progressTimer);
setCustomWorldProgress(0);
setCustomWorldError(
error instanceof Error ? error.message : '生成自定义世界失败。',
);
} finally {
setIsGeneratingCustomWorld(false);
}
};
return (
<>
<AnimatePresence mode="wait">
{!gameState.worldType && selectionStage === 'start' && (
<motion.div
key="start-screen"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full w-full items-center justify-center"
>
<div className="flex h-full w-full max-w-sm flex-col gap-5 py-4 sm:py-6">
<div className="flex min-h-0 flex-1 items-center">
<div className="flex w-full flex-col gap-3">
{hasSavedGame && (
<button
type="button"
onClick={handleContinueGame}
className="pixel-nine-slice pixel-pressable w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 13,
})}
>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
</button>
)}
<button
type="button"
onClick={() => {
handleStartNewGame();
setGeneratedCustomWorldProfile(null);
setCustomWorldDraft('');
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(false);
setSelectionStage('world');
}}
className="pixel-nine-slice pixel-pressable w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 13,
})}
>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">
{hasSavedGame ? '新游戏' : '开始游戏'}
</span>
<span className="text-white/60"></span>
</div>
</button>
<button
type="button"
onClick={() => setShowDeveloperTeamModal(true)}
className="pixel-nine-slice pixel-pressable w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 13,
})}
>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
<div
className="pixel-nine-slice pixel-panel w-full"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 12,
paddingY: 10,
})}
>
<div className="text-[10px] font-bold tracking-[0.2em] text-emerald-200/75">
</div>
<div className="mt-3 space-y-2">
{START_SCREEN_CONTACTS.map((contact) => (
<div
key={contact.label}
className="flex items-center justify-between gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-sm text-zinc-200"
>
<span className="text-zinc-400">
{contact.label}
</span>
<span className="font-semibold text-white">
{contact.value}
</span>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
{!gameState.worldType && selectionStage === 'world' && (
<motion.div
key="world-select"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-bold tracking-[0.2em] text-zinc-400">
</div>
<button
type="button"
onClick={() => {
setGeneratedCustomWorldProfile(null);
setSelectionStage('start');
}}
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"
>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 pb-1 md:grid-cols-2 xl:grid-cols-3">
{worldCards.map((world) => (
<button
key={world.id}
type="button"
onClick={() => handleWorldSelect(world.id)}
className="pixel-nine-slice pixel-pressable order-2 relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.subtitle}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
<div className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25">
<PixelIcon
src={world.featureIcon}
className="h-5 w-5 opacity-95"
/>
</div>
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3">
<div className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
{world.name}
</div>
<PixelIcon
src={world.icon}
className="h-10 w-10 opacity-95"
/>
</div>
<div className="mt-auto">
<div className="text-3xl font-black text-white">
{world.subtitle}
</div>
<div className="mt-2 flex items-center gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
线 {world.onlineCount}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
</span>
</div>
</div>
</div>
</button>
))}
{savedCustomWorldCards.map((world) => (
<button
key={world.id}
type="button"
onClick={() =>
handleWorldSelect(WorldType.CUSTOM, world.profile)
}
className="pixel-nine-slice pixel-pressable order-1 relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(world.texture, {
paddingX: 18,
paddingY: 16,
})}
>
{world.sceneImage && (
<img
src={world.sceneImage}
alt={world.profile.name}
className="absolute inset-0 h-full w-full object-cover opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3">
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
</div>
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.accentLabel === '武侠基础'
? '武侠'
: '仙侠'}
</div>
</div>
<div className="mt-auto">
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
{world.profile.name}
</div>
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
{world.profile.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{world.profile.playableNpcs.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
{world.profile.landmarks.length}
</span>
</div>
</div>
</div>
</button>
))}
<button
type="button"
onClick={() => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(true);
}}
className="pixel-nine-slice pixel-pressable order-first relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_42%),linear-gradient(180deg,rgba(8,10,14,0.18),rgba(8,10,14,0.82))]" />
<div className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-sky-300/20 bg-sky-500/10">
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-5 w-5 opacity-95"
/>
</div>
<div className="relative z-10 flex h-full w-full flex-col">
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
</div>
<div className="mt-auto">
<div className="text-3xl font-black text-white">
</div>
<div className="mt-2 max-w-[16rem] text-sm leading-6 text-zinc-300">
</div>
</div>
</div>
</button>
</div>
</div>
</motion.div>
)}
{!gameState.worldType &&
selectionStage === 'custom-world-result' &&
generatedCustomWorldProfile && (
<motion.div
key="custom-world-result"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<CustomWorldResultView
profile={generatedCustomWorldProfile}
previewCharacters={previewCustomWorldCharacters}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress}
progressLabel={getCustomWorldProgressLabel(customWorldProgress)}
error={customWorldError}
onProfileChange={setGeneratedCustomWorldProfile}
onBack={leaveCustomWorldResult}
onEditSetting={() => {
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(true);
}}
onRegenerate={() => {
void createCustomWorld();
}}
onSave={saveGeneratedCustomWorld}
/>
</motion.div>
)}
</AnimatePresence>
<CustomWorldCreatorModal
isOpen={showCustomWorldModal}
draft={customWorldDraft}
onDraftChange={(value) => {
setCustomWorldDraft(value);
if (customWorldError) setCustomWorldError(null);
}}
onClose={() => {
if (isGeneratingCustomWorld) return;
setShowCustomWorldModal(false);
}}
onSubmit={() => {
void createCustomWorld();
}}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress}
progressLabel={getCustomWorldGenerationLabel(customWorldProgress)}
error={customWorldError}
/>
<DeveloperTeamModal
isOpen={showDeveloperTeamModal}
message={DEVELOPER_TEAM_MESSAGE}
onClose={() => setShowDeveloperTeamModal(false)}
/>
</>
);
}