292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
import { motion } from 'motion/react';
|
|
import type { CSSProperties, ReactNode } from 'react';
|
|
|
|
import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver';
|
|
import {
|
|
type CharacterEquipmentItem,
|
|
type CharacterInventoryItem,
|
|
getCharacterEquipment,
|
|
getCharacterMaxHp,
|
|
getCharacterMaxMana,
|
|
getInventoryItems,
|
|
} from '../data/characterPresets';
|
|
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
|
import { AnimationState, type Character, type CharacterSkillDefinition, type CustomWorldProfile, type WorldType } from '../types';
|
|
import { CHROME_ICONS, getNineSliceStyle, type NineSliceTexture, UI_CHROME } from '../uiAssets';
|
|
import { CharacterAnimator } from './CharacterAnimator';
|
|
import { PixelIcon } from './PixelIcon';
|
|
|
|
interface CharacterDetailModalProps {
|
|
character: Character | null;
|
|
worldType: WorldType | null;
|
|
customWorldProfile?: CustomWorldProfile | null;
|
|
subtitle?: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
|
|
const SKILL_STYLE_LABELS: Record<CharacterSkillDefinition['style'], string> = {
|
|
burst: '爆发',
|
|
steady: '稳定',
|
|
mobility: '机动',
|
|
finisher: '终结',
|
|
projectile: '远程',
|
|
};
|
|
|
|
function getGenderLabel(gender: Character['gender']) {
|
|
if (gender === 'female') return '女';
|
|
if (gender === 'male') return '男';
|
|
return '未知';
|
|
}
|
|
|
|
function getCharacterDetailSpriteStyle(character: Character, scale = 1.36) {
|
|
const groundOffset = character.groundOffsetY ?? 22;
|
|
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
|
|
|
|
return {
|
|
transform: `translateY(${translateY}px) scale(${scale})`,
|
|
transformOrigin: 'center bottom',
|
|
} satisfies CSSProperties;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
function SkillList({
|
|
skills,
|
|
resourceLabels,
|
|
}: {
|
|
skills: CharacterSkillDefinition[];
|
|
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>;
|
|
}) {
|
|
return (
|
|
<div className="space-y-2.5">
|
|
{skills.map(skill => (
|
|
<div key={skill.id} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="text-sm font-semibold text-white">{skill.name}</div>
|
|
<span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
|
|
{SKILL_STYLE_LABELS[skill.style]}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 grid gap-1 text-xs text-zinc-400 sm:grid-cols-4">
|
|
<div>{resourceLabels.damage} {skill.damage}</div>
|
|
<div>{resourceLabels.manaCost} {skill.manaCost}</div>
|
|
<div>{resourceLabels.cooldown} {skill.cooldownTurns}</div>
|
|
<div>{resourceLabels.range} {skill.range}</div>
|
|
</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 attributeRows = formatAttributeList(attributeProfile, attributeSchema);
|
|
const resourceLabels = getResourceLabelsForWorld(worldType);
|
|
|
|
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">
|
|
<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)}`} tone="hp" />
|
|
<StatPill label={resourceLabels.maxMp} value={`${getCharacterMaxMana(character)}`} tone="mp" />
|
|
</div>
|
|
<div className="mt-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
|
{attributeRows.map(({ slot, value }) => (
|
|
<div key={slot.slotId} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center">
|
|
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
|
{slot.name}
|
|
</div>
|
|
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
|
|
<div className="mt-1 font-semibold text-white">{value}</div>
|
|
</div>
|
|
))}
|
|
</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="技能">
|
|
<SkillList skills={character.skills} resourceLabels={resourceLabels} />
|
|
</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>
|
|
);
|
|
}
|