809 lines
32 KiB
TypeScript
809 lines
32 KiB
TypeScript
import { AnimatePresence, motion } from 'motion/react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import {
|
|
resolveAttributeSchema,
|
|
resolveCharacterAttributeProfile,
|
|
} from '../data/attributeResolver';
|
|
import {
|
|
type BuildDamageBreakdown,
|
|
formatBuildContributionPercent,
|
|
getBuildContributionAttributeRows,
|
|
getBuildContributionQualityLabel,
|
|
getCompanionBuildDamageBreakdown,
|
|
getPlayerBuildDamageBreakdown,
|
|
} from '../data/buildDamage';
|
|
import {
|
|
getCharacterEquipment,
|
|
getCharacterPublicBackstorySummary,
|
|
getLockedCharacterBackstoryChapters,
|
|
getUnlockedCharacterBackstoryChapters,
|
|
} from '../data/characterPresets';
|
|
import {
|
|
buildInitialEquipmentLoadout,
|
|
EQUIPMENT_SLOTS,
|
|
getEquipmentRarityLabel,
|
|
getEquipmentSlotLabel,
|
|
} from '../data/equipmentEffects';
|
|
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
|
|
import { normalizePlayerProgressionState } from '../data/playerProgression';
|
|
import type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
|
|
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
|
import {
|
|
AnimationState,
|
|
Character,
|
|
CompanionArcState,
|
|
CompanionRenderState,
|
|
CompanionResolution,
|
|
CustomWorldProfile,
|
|
EquipmentLoadout,
|
|
GameState,
|
|
TimedBuildBuff,
|
|
WorldType,
|
|
} from '../types';
|
|
import {
|
|
getEquipmentSlotIcon,
|
|
getNineSliceStyle,
|
|
UI_CHROME,
|
|
} from '../uiAssets';
|
|
import { AffinityStatusCard } from './AffinityStatusCard';
|
|
import { BackstoryArchive } from './BackstoryArchive';
|
|
import { CharacterAnimator } from './CharacterAnimator';
|
|
import {
|
|
getCharacterDetailSpriteStyle,
|
|
getContributionVisualStyle,
|
|
getGenderLabel,
|
|
} from './CharacterInfoHelpers';
|
|
import {
|
|
CharacterAttributeGrid,
|
|
CharacterIdentityBadges,
|
|
CharacterSkillsList,
|
|
MultiplierContributionList,
|
|
PlayerLevelProgress,
|
|
StatusRow,
|
|
} from './CharacterInfoShared';
|
|
import type { GameCanvasEntitySelection } from './GameCanvas';
|
|
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
|
import { PixelCloseButton } from './PixelCloseButton';
|
|
import { PixelIcon } from './PixelIcon';
|
|
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
|
|
|
interface CharacterPanelProps {
|
|
worldType: WorldType | null;
|
|
customWorldProfile?: CustomWorldProfile | null;
|
|
playerCharacter: Character;
|
|
playerProgression?: GameState['playerProgression'] | null;
|
|
playerHp: number;
|
|
playerMaxHp: number;
|
|
playerMana: number;
|
|
playerMaxMana: number;
|
|
playerEquipment: EquipmentLoadout;
|
|
activeBuildBuffs?: TimedBuildBuff[];
|
|
companionRenderStates: CompanionRenderState[];
|
|
npcStates?: GameState['npcStates'];
|
|
onOpenCamp?: () => void;
|
|
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
|
|
chatSummaries?: Record<string, string>;
|
|
onInspectMember?: (selection: GameCanvasEntitySelection) => void;
|
|
companionArcStates?: CompanionArcState[];
|
|
companionResolutions?: CompanionResolution[];
|
|
}
|
|
|
|
type PartyMember = {
|
|
id: string;
|
|
npcId: string | null;
|
|
renderState: CompanionRenderState | null;
|
|
character: Character;
|
|
roleLabel: string;
|
|
hp: number;
|
|
maxHp: number;
|
|
mana: number;
|
|
maxMana: number;
|
|
isLeader: boolean;
|
|
levelText: string | null;
|
|
};
|
|
|
|
type EquipmentRow = {
|
|
key: string;
|
|
slotLabel: string;
|
|
itemLabel: string;
|
|
rarityLabel: string;
|
|
};
|
|
|
|
function buildLeaderEquipmentRows(
|
|
playerCharacter: Character,
|
|
playerEquipment: EquipmentLoadout,
|
|
): EquipmentRow[] {
|
|
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
|
|
return EQUIPMENT_SLOTS.map((slot) => {
|
|
const equippedItem = playerEquipment[slot] ?? starterLoadout[slot];
|
|
return {
|
|
key: `leader-${slot}`,
|
|
slotLabel: getEquipmentSlotLabel(slot),
|
|
itemLabel: equippedItem?.name ?? '绌轰綅',
|
|
rarityLabel: equippedItem
|
|
? getEquipmentRarityLabel(equippedItem.rarity)
|
|
: '绌轰綅',
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildCompanionEquipmentRows(
|
|
character: Character,
|
|
keyPrefix: string,
|
|
): EquipmentRow[] {
|
|
return getCharacterEquipment(character).map((item) => ({
|
|
key: `${keyPrefix}-${item.slot}-${item.item}`,
|
|
slotLabel: item.slot,
|
|
itemLabel: item.item,
|
|
rarityLabel: item.rarity,
|
|
}));
|
|
}
|
|
|
|
export function CharacterPanel({
|
|
worldType,
|
|
customWorldProfile = null,
|
|
playerCharacter,
|
|
playerProgression = null,
|
|
playerHp,
|
|
playerMaxHp,
|
|
playerMana,
|
|
playerMaxMana,
|
|
playerEquipment,
|
|
activeBuildBuffs = [],
|
|
companionRenderStates,
|
|
npcStates = {},
|
|
onInspectMember,
|
|
companionArcStates = [],
|
|
companionResolutions = [],
|
|
}: CharacterPanelProps) {
|
|
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
|
|
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
|
|
string | null
|
|
>(null);
|
|
const normalizedPlayerProgression =
|
|
normalizePlayerProgressionState(playerProgression);
|
|
const leaderLevelText = `Lv.${normalizedPlayerProgression.level}`;
|
|
const companionReferenceLevelText = `参考 Lv.${normalizedPlayerProgression.level}`;
|
|
|
|
const partyMembers = useMemo<PartyMember[]>(
|
|
() => [
|
|
{
|
|
id: `leader-${playerCharacter.id}`,
|
|
npcId: null,
|
|
renderState: null,
|
|
character: playerCharacter,
|
|
roleLabel: '\u961f\u957f',
|
|
hp: playerHp,
|
|
maxHp: playerMaxHp,
|
|
mana: playerMana,
|
|
maxMana: playerMaxMana,
|
|
isLeader: true,
|
|
levelText: leaderLevelText,
|
|
},
|
|
...companionRenderStates.map((companion) => ({
|
|
id: companion.npcId,
|
|
npcId: companion.npcId,
|
|
renderState: companion,
|
|
character: companion.character,
|
|
roleLabel: '\u540c\u884c',
|
|
hp: companion.hp,
|
|
maxHp: companion.maxHp,
|
|
mana: companion.mana,
|
|
maxMana: companion.maxMana,
|
|
isLeader: false,
|
|
levelText: companionReferenceLevelText,
|
|
})),
|
|
],
|
|
[
|
|
companionReferenceLevelText,
|
|
companionRenderStates,
|
|
leaderLevelText,
|
|
playerCharacter,
|
|
playerHp,
|
|
playerMaxHp,
|
|
playerMana,
|
|
playerMaxMana,
|
|
],
|
|
);
|
|
|
|
const selectedMember = useMemo(
|
|
() => partyMembers.find((member) => member.id === selectedMemberId) ?? null,
|
|
[partyMembers, selectedMemberId],
|
|
);
|
|
|
|
const buildBreakdownByMemberId = useMemo(
|
|
() =>
|
|
Object.fromEntries(
|
|
partyMembers.map((member) => [
|
|
member.id,
|
|
member.isLeader
|
|
? getPlayerBuildDamageBreakdown(
|
|
{
|
|
worldType,
|
|
customWorldProfile,
|
|
playerEquipment,
|
|
activeBuildBuffs,
|
|
} as GameState,
|
|
playerCharacter,
|
|
)
|
|
: getCompanionBuildDamageBreakdown(
|
|
member.character,
|
|
worldType,
|
|
customWorldProfile,
|
|
),
|
|
]),
|
|
) as Record<string, BuildDamageBreakdown>,
|
|
[
|
|
activeBuildBuffs,
|
|
customWorldProfile,
|
|
partyMembers,
|
|
playerCharacter,
|
|
playerEquipment,
|
|
worldType,
|
|
],
|
|
);
|
|
|
|
const selectedBuildBreakdown = selectedMember
|
|
? (buildBreakdownByMemberId[selectedMember.id] ?? null)
|
|
: null;
|
|
const selectedContributionRow =
|
|
selectedBuildBreakdown?.rows.find(
|
|
(row) => row.label === selectedContributionLabel,
|
|
) ?? null;
|
|
const selectedAttributeSchema = resolveAttributeSchema(
|
|
worldType,
|
|
customWorldProfile,
|
|
);
|
|
const resourceLabels = getResourceLabelsForWorld(
|
|
worldType,
|
|
customWorldProfile,
|
|
);
|
|
const selectedMemberAffinity = selectedMember?.npcId
|
|
? (npcStates[selectedMember.npcId]?.affinity ?? 0)
|
|
: null;
|
|
const selectedMemberArcState =
|
|
selectedMember && !selectedMember.isLeader
|
|
? (companionArcStates.find(
|
|
(arcState) => arcState.characterId === selectedMember.character.id,
|
|
) ?? null)
|
|
: null;
|
|
const selectedMemberResolution =
|
|
selectedMember && !selectedMember.isLeader
|
|
? (companionResolutions.find(
|
|
(resolution) =>
|
|
resolution.characterId === selectedMember.character.id,
|
|
) ?? null)
|
|
: null;
|
|
const selectedMemberPublicBackstory =
|
|
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
|
? getCharacterPublicBackstorySummary(selectedMember.character, worldType)
|
|
: null;
|
|
const selectedMemberUnlockedBackstoryChapters =
|
|
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
|
? getUnlockedCharacterBackstoryChapters(
|
|
selectedMember.character,
|
|
selectedMemberAffinity,
|
|
worldType,
|
|
).map((chapter) => ({
|
|
id: chapter.id,
|
|
title: chapter.title,
|
|
content: chapter.content,
|
|
}))
|
|
: [];
|
|
const selectedMemberLockedBackstoryChapters =
|
|
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
|
? getLockedCharacterBackstoryChapters(
|
|
selectedMember.character,
|
|
selectedMemberAffinity,
|
|
worldType,
|
|
).map((chapter) => ({
|
|
id: chapter.id,
|
|
title: chapter.title,
|
|
teaser: chapter.teaser,
|
|
affinityRequired: chapter.affinityRequired,
|
|
}))
|
|
: [];
|
|
const selectedEquipmentRows = selectedMember
|
|
? selectedMember.isLeader
|
|
? buildLeaderEquipmentRows(playerCharacter, playerEquipment)
|
|
: buildCompanionEquipmentRows(selectedMember.character, selectedMember.id)
|
|
: [];
|
|
const selectedMemberAttributeProfile = useMemo(
|
|
() =>
|
|
selectedMember
|
|
? resolveCharacterAttributeProfile(
|
|
selectedMember.character,
|
|
worldType,
|
|
customWorldProfile,
|
|
)
|
|
: null,
|
|
[customWorldProfile, selectedMember, worldType],
|
|
);
|
|
const selectedContributionAttributes = selectedContributionRow
|
|
? getBuildContributionAttributeRows(
|
|
selectedContributionRow,
|
|
selectedAttributeSchema,
|
|
{ resourceLabels },
|
|
)
|
|
: [];
|
|
|
|
useEffect(() => {
|
|
if (!selectedContributionLabel) return;
|
|
if (!selectedContributionRow) {
|
|
setSelectedContributionLabel(null);
|
|
}
|
|
}, [selectedContributionLabel, selectedContributionRow]);
|
|
|
|
useEffect(() => {
|
|
if (!onInspectMember || !selectedMemberId) return;
|
|
setSelectedMemberId(null);
|
|
}, [onInspectMember, selectedMemberId]);
|
|
|
|
const handleMemberInspect = (member: PartyMember) => {
|
|
if (onInspectMember) {
|
|
if (member.isLeader) {
|
|
onInspectMember({ kind: 'player' });
|
|
return;
|
|
}
|
|
|
|
if (member.renderState) {
|
|
onInspectMember({ kind: 'companion', companion: member.renderState });
|
|
return;
|
|
}
|
|
}
|
|
|
|
setSelectedMemberId(member.id);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div
|
|
className="pixel-nine-slice pixel-panel min-h-0 flex-1"
|
|
style={getNineSliceStyle(UI_CHROME.panel, {
|
|
paddingX: 14,
|
|
paddingY: 12,
|
|
})}
|
|
>
|
|
<div className="mb-3 text-xs font-bold text-white">队伍成员</div>
|
|
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
|
|
{partyMembers.map((member) => (
|
|
<button
|
|
key={member.id}
|
|
type="button"
|
|
onClick={() => handleMemberInspect(member)}
|
|
className="w-full px-0 py-1 text-left transition-opacity hover:opacity-90"
|
|
>
|
|
<div className="flex items-start gap-3 rounded-xl border border-white/6 bg-black/18 px-3 py-3">
|
|
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent sm:h-16 sm:w-16">
|
|
<ResolvedAssetImage
|
|
src={member.character.portrait}
|
|
alt={member.character.name}
|
|
className="h-full w-full scale-125 object-contain object-bottom"
|
|
style={{ imageRendering: 'pixelated' }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold text-white">
|
|
{member.character.name}
|
|
</div>
|
|
<div className="truncate text-[10px] tracking-[0.16em] text-zinc-500">
|
|
{member.character.title}
|
|
</div>
|
|
</div>
|
|
<CharacterIdentityBadges
|
|
roleLabel={member.roleLabel}
|
|
levelText={member.levelText}
|
|
roleTone={member.isLeader ? 'amber' : 'sky'}
|
|
className="shrink-0 justify-end"
|
|
/>
|
|
</div>
|
|
<div className="mt-2.5 space-y-2.5">
|
|
<StatusRow
|
|
label={resourceLabels.hp}
|
|
current={member.hp}
|
|
max={member.maxHp}
|
|
tone="hp"
|
|
/>
|
|
<StatusRow
|
|
label={resourceLabels.mp}
|
|
current={member.mana}
|
|
max={member.maxMana}
|
|
tone="mp"
|
|
/>
|
|
</div>
|
|
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-zinc-400">
|
|
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-zinc-200">
|
|
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0}{' '}
|
|
标签
|
|
</span>
|
|
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-emerald-100">
|
|
{'\u9002\u914d'} x
|
|
{buildBreakdownByMemberId[
|
|
member.id
|
|
]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{selectedContributionRow && selectedMember && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
|
onClick={() => setSelectedContributionLabel(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
|
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,40rem)] w-full max-w-xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
|
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-[10px] tracking-[0.22em] text-sky-300/80">
|
|
{'\u6807\u7b7e\u6548\u679c'}
|
|
</div>
|
|
<div className="mt-1 truncate text-sm font-semibold text-white">
|
|
{selectedContributionRow.label}
|
|
</div>
|
|
<div className="mt-1 text-[10px] tracking-[0.18em] text-zinc-500">
|
|
{selectedMember.character.name}
|
|
</div>
|
|
</div>
|
|
<PixelCloseButton
|
|
onClick={() => setSelectedContributionLabel(null)}
|
|
label="关闭标签效果"
|
|
/>
|
|
</div>
|
|
|
|
<div className="overflow-y-auto p-4 sm:p-5">
|
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
|
|
<div className="space-y-4">
|
|
<div
|
|
className="rounded-2xl border px-4 py-4"
|
|
style={getContributionVisualStyle(
|
|
selectedContributionRow.bonusDelta,
|
|
)}
|
|
>
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[10px] uppercase tracking-[0.16em] text-current/70">
|
|
标签概览
|
|
</div>
|
|
<div className="mt-2 text-sm font-semibold">
|
|
{selectedContributionRow.label}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-xl border border-current/15 bg-black/25 px-3 py-2 text-right">
|
|
<div className="text-[11px] tracking-[0.14em] text-current/70">
|
|
{getBuildContributionQualityLabel(
|
|
selectedContributionRow.bonusDelta,
|
|
)}
|
|
</div>
|
|
<div className="mt-1 text-sm font-semibold">
|
|
{'\u603b\u52a0\u6210'}{' '}
|
|
{formatBuildContributionPercent(
|
|
selectedContributionRow.bonusDelta,
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
|
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
|
{'\u5c5e\u6027\u52a0\u6210'}
|
|
</div>
|
|
|
|
{selectedContributionAttributes.length > 0 ? (
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
{selectedContributionAttributes.map((attribute) => (
|
|
<div
|
|
key={`${selectedContributionRow.label}-${attribute.slotId}`}
|
|
className="rounded-xl border border-white/8 bg-black/25 px-4 py-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
|
|
<span>{attribute.label}</span>
|
|
<span className="font-semibold text-white">
|
|
{formatBuildContributionPercent(
|
|
attribute.modifierDelta,
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-6 text-zinc-400">
|
|
{
|
|
'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<AnimatePresence>
|
|
{selectedMember && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
|
onClick={() => setSelectedMemberId(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
|
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,56rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
|
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-[10px] tracking-[0.22em] text-sky-300/80">
|
|
角色详情
|
|
</div>
|
|
<div className="mt-1 truncate text-sm font-semibold text-white">
|
|
{selectedMember.character.name}
|
|
</div>
|
|
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">
|
|
{selectedMember.character.title}
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
<CharacterIdentityBadges
|
|
roleLabel={selectedMember.roleLabel}
|
|
levelText={selectedMember.levelText}
|
|
roleTone={selectedMember.isLeader ? 'amber' : 'sky'}
|
|
/>
|
|
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
|
{getGenderLabel(selectedMember.character.gender)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<PixelCloseButton
|
|
onClick={() => setSelectedMemberId(null)}
|
|
label="关闭角色详情"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm: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">
|
|
<div
|
|
className="pixel-nine-slice pixel-panel"
|
|
style={getNineSliceStyle(UI_CHROME.panel)}
|
|
>
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="flex h-36 w-full max-w-[15rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20 sm:h-40">
|
|
{selectedMember.character.visual ? (
|
|
<MedievalNpcAnimator
|
|
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
|
selectedMember.character.visual,
|
|
)}
|
|
scale={2.08}
|
|
/>
|
|
) : (
|
|
<CharacterAnimator
|
|
state={AnimationState.IDLE}
|
|
character={selectedMember.character}
|
|
className="h-full w-full"
|
|
imageClassName="object-bottom"
|
|
style={getCharacterDetailSpriteStyle(
|
|
selectedMember.character,
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="mt-3 text-base font-bold text-white">
|
|
{selectedMember.character.name}
|
|
</div>
|
|
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">
|
|
{selectedMember.character.title}
|
|
</div>
|
|
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
|
{selectedMember.character.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="pixel-nine-slice pixel-panel"
|
|
style={getNineSliceStyle(UI_CHROME.statsPanel)}
|
|
>
|
|
<div className="mb-3 text-xs font-bold text-white">
|
|
状态
|
|
</div>
|
|
<div className="space-y-3">
|
|
{selectedMember.isLeader && (
|
|
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3">
|
|
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
|
|
等级
|
|
</div>
|
|
<PlayerLevelProgress
|
|
level={normalizedPlayerProgression.level}
|
|
currentLevelXp={
|
|
normalizedPlayerProgression.currentLevelXp
|
|
}
|
|
xpToNextLevel={
|
|
normalizedPlayerProgression.xpToNextLevel
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
<StatusRow
|
|
label={resourceLabels.hp}
|
|
current={selectedMember.hp}
|
|
max={selectedMember.maxHp}
|
|
tone="hp"
|
|
/>
|
|
<StatusRow
|
|
label={resourceLabels.mp}
|
|
current={selectedMember.mana}
|
|
max={selectedMember.maxMana}
|
|
tone="mp"
|
|
/>
|
|
{selectedMemberAffinity != null && (
|
|
<AffinityStatusCard affinity={selectedMemberAffinity} />
|
|
)}
|
|
{selectedMemberArcState && (
|
|
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-300">
|
|
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
|
个人线阶段
|
|
</div>
|
|
<div className="mt-1 font-semibold text-white">
|
|
{selectedMemberArcState.currentStage}
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-sky-200/85">
|
|
{selectedMemberArcState.arcTheme}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{selectedMemberResolution && (
|
|
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-zinc-300">
|
|
<div className="text-[10px] uppercase tracking-[0.18em] text-emerald-200/80">
|
|
收束状态
|
|
</div>
|
|
<div className="mt-1 font-semibold text-white">
|
|
{selectedMemberResolution.resolutionType}
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-emerald-100/85">
|
|
{selectedMemberResolution.summary}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{selectedMemberAffinity != null && (
|
|
<BackstoryArchive
|
|
publicSummary={selectedMemberPublicBackstory}
|
|
unlockedChapters={
|
|
selectedMemberUnlockedBackstoryChapters
|
|
}
|
|
lockedChapters={selectedMemberLockedBackstoryChapters}
|
|
/>
|
|
)}
|
|
{selectedBuildBreakdown && (
|
|
<MultiplierContributionList
|
|
breakdown={selectedBuildBreakdown}
|
|
onSelectContribution={(row) =>
|
|
setSelectedContributionLabel(row.label)
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="mt-4">
|
|
<CharacterAttributeGrid
|
|
attributeProfile={selectedMemberAttributeProfile}
|
|
attributeSchema={selectedAttributeSchema}
|
|
buildBreakdown={selectedBuildBreakdown}
|
|
resourceLabels={resourceLabels}
|
|
gridClassName="grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2"
|
|
cardClassName="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
|
{selectedMemberAffinity == null && (
|
|
<div
|
|
className="pixel-nine-slice pixel-panel"
|
|
style={getNineSliceStyle(UI_CHROME.panel)}
|
|
>
|
|
<div className="mb-3 text-xs font-bold text-white">
|
|
背景故事
|
|
</div>
|
|
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
|
{selectedMember.character.backstory}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className="pixel-nine-slice pixel-panel"
|
|
style={getNineSliceStyle(UI_CHROME.panel)}
|
|
>
|
|
<div className="mb-3 text-xs font-bold text-white">
|
|
性格
|
|
</div>
|
|
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
|
{selectedMember.character.personality}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="pixel-nine-slice pixel-panel"
|
|
style={getNineSliceStyle(UI_CHROME.panel)}
|
|
>
|
|
<div className="mb-3 text-xs font-bold text-white">
|
|
{'\u6280\u80fd'}
|
|
</div>
|
|
<CharacterSkillsList
|
|
skills={selectedMember.character.skills}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className="pixel-nine-slice pixel-panel"
|
|
style={getNineSliceStyle(UI_CHROME.panel)}
|
|
>
|
|
<div className="mb-3 text-xs font-bold text-white">
|
|
装备
|
|
</div>
|
|
<div className="space-y-2 text-sm text-zinc-300">
|
|
{selectedEquipmentRows.map((item) => (
|
|
<div
|
|
key={item.key}
|
|
className="flex items-center justify-between rounded-lg border border-white/5 bg-black/20 px-3 py-2"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<PixelIcon
|
|
src={getEquipmentSlotIcon(item.slotLabel)}
|
|
className="h-8 w-8"
|
|
/>
|
|
<div>
|
|
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
|
|
{item.slotLabel}
|
|
</div>
|
|
<div>{item.itemLabel}</div>
|
|
</div>
|
|
</div>
|
|
<span className="rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
|
|
{item.rarityLabel}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|