312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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 {
|
|
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 { PixelCloseButton } from './PixelCloseButton';
|
|
|
|
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>
|
|
<PixelCloseButton onClick={onClose} label="关闭角色详情" />
|
|
</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>
|
|
);
|
|
}
|