1
This commit is contained in:
504
src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
Normal file
504
src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
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<Pick<Character, 'name' | 'title'>> = {},
|
||||
) {
|
||||
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<HTMLElement>('[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<HTMLElement>('[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<Character | null>(null);
|
||||
const characterCarouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0);
|
||||
const [showCharacterDraftModal, setShowCharacterDraftModal] = useState(false);
|
||||
const [characterDraftName, setCharacterDraftName] = useState('');
|
||||
const [characterDraftBackstory, setCharacterDraftBackstory] = useState('');
|
||||
const [characterDraftError, setCharacterDraftError] = useState<string | null>(null);
|
||||
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({});
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-3 flex justify-start">
|
||||
<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"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4 text-center">
|
||||
<div className="text-2xl font-black text-white sm:text-[2rem]">选择你的角色</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.14em] text-zinc-500">左右滑动浏览角色</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={characterCarouselRef}
|
||||
onScroll={syncCharacterCarousel}
|
||||
className="character-carousel scrollbar-hide flex-[1_1_auto]"
|
||||
>
|
||||
{selectionEntries.map(({ character, selectionKey }, index) => {
|
||||
const characterDraft = characterSelectionDrafts[selectionKey];
|
||||
const meta = getCharacterMeta(character, {name: characterDraft?.name});
|
||||
const selected = selectionKey === selectedCharacterKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={selectionKey}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCharacterKey(selectionKey);
|
||||
scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal');
|
||||
}}
|
||||
data-carousel-card="true"
|
||||
className={`character-carousel__card ${selected ? 'character-carousel__card--active' : ''}`}
|
||||
style={getCharacterCardStyle(index, characterCarouselProgress)}
|
||||
>
|
||||
<span className={`character-carousel__surface ${selected ? 'character-carousel__surface--active' : ''}`}>
|
||||
<span className="character-carousel__cover">
|
||||
{selected ? (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={character}
|
||||
className="character-carousel__portrait character-carousel__portrait--animated"
|
||||
/>
|
||||
) : (
|
||||
<img src={character.portrait} alt={meta.name} className="character-carousel__portrait" style={{imageRendering: 'pixelated'}} />
|
||||
)}
|
||||
</span>
|
||||
{selected ? (
|
||||
<>
|
||||
<span className="character-carousel__selected-name">{meta.name}</span>
|
||||
<span className="character-carousel__meta character-carousel__meta--selected">
|
||||
<span className="character-carousel__title character-carousel__title--selected">{meta.title}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="character-carousel__meta">
|
||||
<span className="character-carousel__name">{meta.name}</span>
|
||||
<span className="character-carousel__title">{meta.title}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[0.85fr_1.15fr]">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel, {paddingX: 12, paddingY: 10})}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold text-white">角色属性</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-zinc-500">
|
||||
<span>{selectedCharacterMeta.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
性别: {getGenderLabel(selectedCharacter.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-[11px] text-zinc-300 sm:grid-cols-3 sm:gap-1.5 sm:text-[13px]">
|
||||
{attributeSchema.slots.map((slot) => (
|
||||
<div key={slot.slotId} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
|
||||
{slot.name}: {selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel character-backstory-panel flex flex-col"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {paddingX: 12, paddingY: 10})}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div className="character-backstory-title text-xs font-bold text-white">背景故事</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCharacterDraftEditor}
|
||||
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] text-sky-100 transition-colors hover:text-white"
|
||||
>
|
||||
自定义
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col text-[13px] leading-6 text-zinc-300">
|
||||
<div>{selectedCharacterPreview?.backstory ?? selectedCharacter.backstory}</div>
|
||||
<div className="mt-auto flex items-end justify-between gap-3 pt-3">
|
||||
<div className="min-w-0 flex flex-wrap gap-1.5">
|
||||
{selectedCharacterPersonalityTags.map(tag => (
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] text-zinc-300">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailCharacter(selectedCharacterPreview)}
|
||||
aria-label={`查看${selectedCharacterPreview?.name ?? selectedCharacter.name}的详情`}
|
||||
className="shrink-0 text-[11px] font-medium text-sky-200 transition-colors hover:text-white"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(selectedCharacterPreview ?? selectedCharacter)}
|
||||
className="pixel-nine-slice pixel-pressable mx-auto block w-full max-w-[16rem] text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {paddingX: 14, paddingY: 9})}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<CharacterDetailModal
|
||||
character={detailCharacter}
|
||||
worldType={worldType}
|
||||
customWorldProfile={customWorldProfile}
|
||||
subtitle="角色详情"
|
||||
onClose={() => setDetailCharacter(null)}
|
||||
/>
|
||||
<CharacterDraftModal
|
||||
isOpen={showCharacterDraftModal}
|
||||
characterLabel={selectedCharacterMeta ? `${selectedCharacterMeta.name} / ${selectedCharacterMeta.title}` : '当前角色'}
|
||||
draftName={characterDraftName}
|
||||
draftBackstory={characterDraftBackstory}
|
||||
onNameChange={value => {
|
||||
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;
|
||||
Reference in New Issue
Block a user