Files
Genarrative/src/components/game-shell/CharacterSelectionFlow.tsx
高物 c49c64896a
Some checks failed
CI / verify (push) Has been cancelled
初始仓库迁移
2026-04-04 23:57:06 +08:00

428 lines
17 KiB
TypeScript

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<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: ['守护', '稳定', '突破']},
};
const ATTRIBUTE_LABELS: Record<keyof Character['attributes'], string> = {
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<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 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 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<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 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 (
<>
<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]"
>
{selectionCharacters.map((character, index) => {
const characterDraft = characterSelectionDrafts[character.id];
const meta = getCharacterMeta(character, {name: characterDraft?.name});
const selected = character.id === selectedCharacter.id;
return (
<button
key={character.id}
type="button"
onClick={() => {
setSelectedCharacterId(character.id);
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-4 gap-1 text-[11px] text-zinc-300 sm:gap-1.5 sm:text-[13px]">
{Object.entries(selectedCharacter.attributes).map(([key, value]) => (
<div key={key} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
{ATTRIBUTE_LABELS[key as keyof Character['attributes']]}: {value}
</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}
/>
</>
);
}