import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { buildCharacterAttributeProfile, } from '../../data/attributeProfileGenerator'; import { resolveAttributeSchema, resolveCharacterAttributeProfile, } from '../../data/attributeResolver'; import { buildCustomWorldPlayableCharacters, ROLE_TEMPLATE_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'; export type RpgEntryCharacterSelectViewProps = { worldType: WorldType; customWorldProfile: CustomWorldProfile | null; onBack: () => void; onConfirm: (character: Character) => void; }; const CHARACTER_DISPLAY: Record< string, { name: string; title: string; role: string; tags: string[] } > = { '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: ['守护', '稳定', '突破'], }, }; 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 buildSelectionCharacterKey(character: Character, index: number) { const normalizedId = character.id.trim(); if (normalizedId) { return normalizedId; } const fallbackSeed = character.name.trim() || character.title.trim() || character.description.trim() || 'character'; return `selection-character-${index}-${fallbackSeed}`; } 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 RpgEntryCharacterSelectView({ worldType, customWorldProfile, onBack, onConfirm, }: RpgEntryCharacterSelectViewProps) { const selectionCharacters = useMemo( () => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS), [customWorldProfile], ); const selectionEntries = useMemo( () => selectionCharacters.map((character, index) => ({ character, selectionKey: buildSelectionCharacterKey(character, index), })), [selectionCharacters], ); const [selectedCharacterKey, setSelectedCharacterKey] = useState(selectionEntries[0]?.selectionKey ?? ''); 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 selectedCharacterEntry = useMemo( () => selectionEntries.find(entry => entry.selectionKey === selectedCharacterKey) ?? selectionEntries[0] ?? null, [selectedCharacterKey, selectionEntries], ); const selectedCharacter = selectedCharacterEntry?.character ?? null; const selectedCharacterDraft = selectedCharacterEntry ? characterSelectionDrafts[selectedCharacterEntry.selectionKey] ?? null : null; const selectedCharacterPreview = useMemo( () => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft), [selectedCharacter, selectedCharacterDraft], ); const selectedCharacterMeta = selectedCharacter ? getCharacterMeta(selectedCharacter, {name: selectedCharacterDraft?.name}) : null; const attributeSchema = useMemo( () => resolveAttributeSchema(worldType, customWorldProfile), [customWorldProfile, worldType], ); const selectedAttributeProfile = useMemo( () => selectedCharacter ? resolveCharacterAttributeProfile( selectedCharacter, worldType, customWorldProfile, ) ?? buildCharacterAttributeProfile(selectedCharacter, attributeSchema) : null, [attributeSchema, customWorldProfile, selectedCharacter, worldType], ); 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 focusedEntry = selectionEntries[focusedCharacterIndex]; if (focusedEntry && focusedEntry.selectionKey !== selectedCharacterKey) { setSelectedCharacterKey(focusedEntry.selectionKey); } }, [focusedCharacterIndex, selectedCharacterKey, selectionEntries]); useEffect(() => { if (selectionEntries.length === 0) return; if (!selectionEntries.some(entry => entry.selectionKey === selectedCharacterKey)) { const firstEntry = selectionEntries[0]; if (firstEntry) { setSelectedCharacterKey(firstEntry.selectionKey); } } }, [selectedCharacterKey, selectionEntries]); const openCharacterDraftEditor = () => { if (!selectedCharacterPreview) return; setCharacterDraftName(selectedCharacterPreview.name); setCharacterDraftBackstory(selectedCharacterPreview.backstory); setCharacterDraftError(null); setShowCharacterDraftModal(true); }; const saveCharacterDraft = () => { if (!selectedCharacter || !selectedCharacterEntry) return; const nextName = characterDraftName.trim(); const nextBackstory = characterDraftBackstory.trim(); if (!nextName) { setCharacterDraftError('请输入角色名称。'); return; } if (!nextBackstory) { setCharacterDraftError('请输入角色背景故事。'); return; } setCharacterSelectionDrafts(current => ({ ...current, [selectedCharacterEntry.selectionKey]: { name: nextName, backstory: nextBackstory, }, })); setCharacterDraftError(null); setShowCharacterDraftModal(false); }; if (!selectedCharacter || !selectedCharacterMeta) { return null; } return ( <>
选择你的角色
左右滑动浏览角色
{selectionEntries.map(({ character, selectionKey }, index) => { const characterDraft = characterSelectionDrafts[selectionKey]; const meta = getCharacterMeta(character, {name: characterDraft?.name}); const selected = selectionKey === selectedCharacterKey; return ( ); })}
角色属性
{selectedCharacterMeta.title} 性别: {getGenderLabel(selectedCharacter.gender)}
{attributeSchema.slots.map((slot) => (
{slot.name}: {selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
))}
背景故事
{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} /> ); } export const CharacterSelectionFlow = RpgEntryCharacterSelectView;