import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import { buildCustomWorldPlayableCharacters, PRESET_CHARACTERS, } from '../../data/characterPresets'; import {AnimationState, type Character, type CustomWorldProfile, WorldType} from '../../types'; import {getNineSliceStyle, UI_CHROME} from '../../uiAssets'; import {CharacterAnimator} from '../CharacterAnimator'; import {CharacterDetailModal} from '../CharacterDetailModal'; import {CharacterDraftModal} from '../SelectionCustomizationModals'; type CharacterSelectionDraft = { name: string; backstory: string; }; type CarouselOrientation = 'horizontal' | 'vertical'; type CharacterSelectionFlowProps = { worldType: WorldType; customWorldProfile: CustomWorldProfile | null; onBack: () => void; onConfirm: (character: Character) => void; }; const CHARACTER_DISPLAY: Record = { 'sword-princess': {name: '剑姬', title: '皇家之刃', role: '先锋', tags: ['剑术', '压制', '突进']}, 'archer-hero': {name: '弓手英雄', title: '风之射手', role: '远程', tags: ['射程', '齐射', '风筝']}, 'girl-hero': {name: '双刃刺客', title: '暗影之牙', role: '刺客', tags: ['连击', '冲锋', '机动']}, 'punch-hero': {name: '战拳', title: '近战大师', role: '战士', tags: ['爆发', '格斗', '仇恨']}, 'fighter-4': {name: '装甲长矛手', title: '重装先锋', role: '前线', tags: ['守护', '稳定', '突破']}, }; const ATTRIBUTE_LABELS: Record = { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神', }; function getGenderLabel(gender: Character['gender']) { if (gender === 'female') return '女性'; if (gender === 'male') return '男性'; return '未知'; } function clampIndex(value: number, length: number) { if (length <= 0) return 0; return Math.max(0, Math.min(length - 1, value)); } function getCharacterMeta( character: Character, overrides: Partial> = {}, ) { const preset = CHARACTER_DISPLAY[character.id]; return { name: overrides.name ?? character.name ?? preset?.name, title: overrides.title ?? character.title ?? preset?.title, role: preset?.role ?? '角色', tags: preset?.tags ?? [], }; } function applyCharacterSelectionDraft( character: Character | null, draft?: CharacterSelectionDraft | null, ) { if (!character || !draft) return character; return { ...character, name: draft.name, backstory: draft.backstory, } satisfies Character; } function getPersonalityTags(personality: string) { const tags = personality .split(/[,.!?/\\\s]+/u) .map(tag => tag.trim()) .filter(Boolean); return tags.length > 0 ? [...new Set(tags)] : [personality.trim()].filter(Boolean); } function readCarouselProgress(container: HTMLDivElement, orientation: CarouselOrientation) { const firstCard = container.querySelector('[data-carousel-card="true"]'); if (!firstCard) return 0; const styles = window.getComputedStyle(container); const gap = parseFloat( orientation === 'vertical' ? styles.rowGap || styles.gap || '0' : styles.columnGap || styles.gap || '0', ); const stride = orientation === 'vertical' ? firstCard.getBoundingClientRect().height + gap : firstCard.getBoundingClientRect().width + gap; if (stride <= 0) return 0; return orientation === 'vertical' ? container.scrollTop / stride : container.scrollLeft / stride; } function scrollCarouselToIndex(container: HTMLDivElement | null, index: number, orientation: CarouselOrientation) { if (!container) return; const firstCard = container.querySelector('[data-carousel-card="true"]'); if (!firstCard) return; const styles = window.getComputedStyle(container); const gap = parseFloat( orientation === 'vertical' ? styles.rowGap || styles.gap || '0' : styles.columnGap || styles.gap || '0', ); const stride = orientation === 'vertical' ? firstCard.getBoundingClientRect().height + gap : firstCard.getBoundingClientRect().width + gap; const behavior: ScrollBehavior = 'smooth'; if (orientation === 'vertical') { container.scrollTo({top: stride * index, behavior}); } else { container.scrollTo({left: stride * index, behavior}); } } function getCharacterCardStyle(index: number, progress: number) { const delta = index - progress; const distance = Math.min(Math.abs(delta), 2.4); if (distance < 0.08) { return { opacity: 1, zIndex: 30, transform: 'none', filter: 'none', willChange: 'auto' as const, }; } const scale = 1 - distance * 0.12; const opacity = 1 - distance * 0.28; const rotate = delta * 8; const translateY = distance * 12; const translateX = delta * -12; return { opacity, zIndex: 30 - Math.round(distance * 10), transform: `translate3d(${translateX}px, ${translateY}px, 0) scale(${scale}) rotate(${rotate}deg)`, filter: distance < 0.08 ? 'none' : `saturate(${1 - distance * 0.08})`, }; } export function CharacterSelectionFlow({ worldType, customWorldProfile, onBack, onConfirm, }: CharacterSelectionFlowProps) { const selectionCharacters = useMemo( () => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : PRESET_CHARACTERS), [customWorldProfile], ); const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? ''); const [detailCharacter, setDetailCharacter] = useState(null); const characterCarouselRef = useRef(null); const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0); const [showCharacterDraftModal, setShowCharacterDraftModal] = useState(false); const [characterDraftName, setCharacterDraftName] = useState(''); const [characterDraftBackstory, setCharacterDraftBackstory] = useState(''); const [characterDraftError, setCharacterDraftError] = useState(null); const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState>({}); const selectedCharacter = useMemo( () => selectionCharacters.find(character => character.id === selectedCharacterId) ?? selectionCharacters[0] ?? null, [selectedCharacterId, selectionCharacters], ); const selectedCharacterDraft = selectedCharacter ? characterSelectionDrafts[selectedCharacter.id] ?? null : null; const selectedCharacterPreview = useMemo( () => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft), [selectedCharacter, selectedCharacterDraft], ); const selectedCharacterMeta = selectedCharacter ? getCharacterMeta(selectedCharacter, {name: selectedCharacterDraft?.name}) : null; const selectedCharacterPersonalityTags = useMemo( () => (selectedCharacterPreview ? getPersonalityTags(selectedCharacterPreview.personality) : []), [selectedCharacterPreview], ); const focusedCharacterIndex = clampIndex(Math.round(characterCarouselProgress), selectionCharacters.length); const syncCharacterCarousel = useCallback(() => { if (!characterCarouselRef.current) return; setCharacterCarouselProgress(readCarouselProgress(characterCarouselRef.current, 'horizontal')); }, []); useEffect(() => { syncCharacterCarousel(); window.addEventListener('resize', syncCharacterCarousel); return () => window.removeEventListener('resize', syncCharacterCarousel); }, [syncCharacterCarousel]); useEffect(() => { const focusedCharacter = selectionCharacters[focusedCharacterIndex]; if (focusedCharacter && focusedCharacter.id !== selectedCharacterId) { setSelectedCharacterId(focusedCharacter.id); } }, [focusedCharacterIndex, selectedCharacterId, selectionCharacters]); useEffect(() => { if (selectionCharacters.length === 0) return; if (!selectionCharacters.some(character => character.id === selectedCharacterId)) { const firstCharacter = selectionCharacters[0]; if (firstCharacter) { setSelectedCharacterId(firstCharacter.id); } } }, [selectedCharacterId, selectionCharacters]); const openCharacterDraftEditor = () => { if (!selectedCharacterPreview) return; setCharacterDraftName(selectedCharacterPreview.name); setCharacterDraftBackstory(selectedCharacterPreview.backstory); setCharacterDraftError(null); setShowCharacterDraftModal(true); }; const saveCharacterDraft = () => { if (!selectedCharacter) return; const nextName = characterDraftName.trim(); const nextBackstory = characterDraftBackstory.trim(); if (!nextName) { setCharacterDraftError('请输入角色名称。'); return; } if (!nextBackstory) { setCharacterDraftError('请输入角色背景故事。'); return; } setCharacterSelectionDrafts(current => ({ ...current, [selectedCharacter.id]: { name: nextName, backstory: nextBackstory, }, })); setCharacterDraftError(null); setShowCharacterDraftModal(false); }; if (!selectedCharacter || !selectedCharacterMeta) { return null; } return ( <>
选择你的角色
左右滑动浏览角色
{selectionCharacters.map((character, index) => { const characterDraft = characterSelectionDrafts[character.id]; const meta = getCharacterMeta(character, {name: characterDraft?.name}); const selected = character.id === selectedCharacter.id; return ( ); })}
角色属性
{selectedCharacterMeta.title} 性别: {getGenderLabel(selectedCharacter.gender)}
{Object.entries(selectedCharacter.attributes).map(([key, value]) => (
{ATTRIBUTE_LABELS[key as keyof Character['attributes']]}: {value}
))}
背景故事
{selectedCharacterPreview?.backstory ?? selectedCharacter.backstory}
{selectedCharacterPersonalityTags.map(tag => ( {tag} ))}
setDetailCharacter(null)} /> { setCharacterDraftName(value); if (characterDraftError) setCharacterDraftError(null); }} onBackstoryChange={value => { setCharacterDraftBackstory(value); if (characterDraftError) setCharacterDraftError(null); }} onClose={() => setShowCharacterDraftModal(false)} onConfirm={saveCharacterDraft} error={characterDraftError} /> ); }