This commit is contained in:
319
src/components/CharacterDetailModal.tsx
Normal file
319
src/components/CharacterDetailModal.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { motion } from 'motion/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import { getCompanionBuildDamageBreakdown } from '../data/buildDamage';
|
||||
import {
|
||||
type CharacterEquipmentItem,
|
||||
type CharacterInventoryItem,
|
||||
getCharacterEquipment,
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
getInventoryItems,
|
||||
} from '../data/characterPresets';
|
||||
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getNineSliceStyle,
|
||||
type NineSliceTexture,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
getCharacterDetailSpriteStyle,
|
||||
getGenderLabel,
|
||||
} from './CharacterInfoHelpers';
|
||||
import {
|
||||
CharacterAttributeGrid,
|
||||
CharacterSkillsList,
|
||||
} from './CharacterInfoShared';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
interface CharacterDetailModalProps {
|
||||
character: Character | null;
|
||||
worldType: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
chrome = UI_CHROME.panel,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
chrome?: NineSliceTexture;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(chrome, { paddingX: 14, paddingY: 14 })}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-zinc-200">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: 'neutral' | 'hp' | 'mp';
|
||||
}) {
|
||||
const toneClassName =
|
||||
tone === 'hp'
|
||||
? 'border-rose-400/20 bg-rose-500/10 text-rose-100'
|
||||
: tone === 'mp'
|
||||
? 'border-sky-400/20 bg-sky-500/10 text-sky-100'
|
||||
: 'border-white/10 bg-black/20 text-zinc-200';
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border px-3 py-2 ${toneClassName}`}>
|
||||
<div className="text-[10px] tracking-[0.18em] text-white/60">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={`${item.slot}-${item.item}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{item.slot}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{item.item}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{item.rarity}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={`${item.category}-${item.name}-${item.quantity}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{item.category}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
数量 x{item.quantity}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterDetailModal({
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile = null,
|
||||
subtitle = '初始伙伴',
|
||||
onClose,
|
||||
}: CharacterDetailModalProps) {
|
||||
if (!character) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opening = worldType ? character.adventureOpenings[worldType] : null;
|
||||
const equipment = getCharacterEquipment(character);
|
||||
const inventory = getInventoryItems(character, worldType);
|
||||
const attributeSchema = resolveAttributeSchema(worldType, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const buildBreakdown = getCompanionBuildDamageBreakdown(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const resourceLabels = getResourceLabelsForWorld(
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[72] flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
aria-label="关闭角色详情"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
|
||||
<div className="space-y-4 lg:max-h-full lg:overflow-y-auto lg:pr-1">
|
||||
<Section title="资料">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||
{character.visual ? (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(character.visual)}
|
||||
scale={2.08}
|
||||
/>
|
||||
) : (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.IDLE}
|
||||
character={character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(character)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">
|
||||
候选人
|
||||
</div>
|
||||
<div className="mt-3 text-base font-bold text-white">
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<span>{character.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
性别: {getGenderLabel(character.gender)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.description}
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="属性" chrome={UI_CHROME.statsPanel}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<StatPill
|
||||
label={resourceLabels.maxHp}
|
||||
value={`${getCharacterMaxHp(character, worldType, customWorldProfile)}`}
|
||||
tone="hp"
|
||||
/>
|
||||
<StatPill
|
||||
label={resourceLabels.maxMp}
|
||||
value={`${getCharacterMaxMana(character)}`}
|
||||
tone="mp"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<CharacterAttributeGrid
|
||||
attributeProfile={attributeProfile}
|
||||
attributeSchema={attributeSchema}
|
||||
buildBreakdown={buildBreakdown}
|
||||
resourceLabels={resourceLabels}
|
||||
gridClassName="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 xl:grid-cols-4"
|
||||
cardClassName="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{opening && (
|
||||
<Section title="旅程">
|
||||
<div className="space-y-2 text-sm leading-relaxed text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
原因
|
||||
</div>
|
||||
<div className="mt-1">{opening.reason}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
目标
|
||||
</div>
|
||||
<div className="mt-1">{opening.goal}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<Section title="技能">
|
||||
<CharacterSkillsList skills={character.skills} />
|
||||
</Section>
|
||||
|
||||
<Section title="装备">
|
||||
<EquipmentGrid items={equipment} />
|
||||
</Section>
|
||||
|
||||
<Section title="背包">
|
||||
<InventoryGrid items={inventory} />
|
||||
</Section>
|
||||
|
||||
<Section title="背景">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.backstory}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="性格">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.personality}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user