This commit is contained in:
848
src/components/CharacterPanel.tsx
Normal file
848
src/components/CharacterPanel.tsx
Normal file
@@ -0,0 +1,848 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePlayerProgressionState } from '../data/playerProgression';
|
||||
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 type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CompanionArcState,
|
||||
CompanionRenderState,
|
||||
CompanionResolution,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
QuestLogEntry,
|
||||
TimedBuildBuff,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
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 { 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'];
|
||||
quests: QuestLogEntry[];
|
||||
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 = {},
|
||||
quests,
|
||||
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 activeQuests = useMemo(
|
||||
() => quests.filter((quest) => quest.status !== 'turned_in'),
|
||||
[quests],
|
||||
);
|
||||
|
||||
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,
|
||||
})}
|
||||
>
|
||||
{activeQuests.length > 0 && (
|
||||
<div className="mb-3 rounded-xl border border-sky-400/15 bg-sky-500/8 px-3 py-3">
|
||||
<div className="mb-2 text-xs font-bold text-sky-100">
|
||||
褰撳墠濮旀墭
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{activeQuests.map((quest) => (
|
||||
<div
|
||||
key={quest.id}
|
||||
className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="font-semibold text-white">
|
||||
{quest.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{quest.summary}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedContributionLabel(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</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 className="mt-2 text-[11px] leading-5 text-zinc-500">
|
||||
{attribute.definition}
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedMemberId(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<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-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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user