Files
Genarrative/src/components/CharacterPanel.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

798 lines
30 KiB
TypeScript

import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import {
type BuildDamageBreakdown,
getBuildContributionAttributeRows,
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,
getGenderLabel,
} from './CharacterInfoHelpers';
import {
BuildContributionDetailPanel,
CharacterAttributeGrid,
CharacterIdentityBadges,
CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress,
StatusRow,
} from './CharacterInfoShared';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
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">
<PlatformPillBadge
tone="darkNeutral"
size="xs"
className="px-2 py-0.5 font-normal text-zinc-200"
>
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0}{' '}
</PlatformPillBadge>
<PlatformPillBadge
tone="darkEmerald"
size="xs"
className="px-2 py-0.5 font-normal"
>
{'\u9002\u914d'} x
{buildBreakdownByMemberId[
member.id
]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
</PlatformPillBadge>
</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">
<BuildContributionDetailPanel
row={selectedContributionRow}
attributes={selectedContributionAttributes}
/>
</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'}
/>
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="px-2 py-0.5 text-[9px] font-normal text-zinc-200"
>
{getGenderLabel(selectedMember.character.gender)}
</PlatformPillBadge>
</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 && (
<PlatformSubpanel
as="div"
surface="darkAmber"
radius="xs"
padding="sm"
data-testid="character-panel-level-progress"
>
<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
}
/>
</PlatformSubpanel>
)}
<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 && (
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="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>
</PlatformSubpanel>
)}
{selectedMemberResolution && (
<PlatformSubpanel
as="div"
surface="darkEmerald"
radius="xs"
padding="row"
className="text-xs"
data-testid="character-panel-resolution"
>
<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>
</PlatformSubpanel>
)}
{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>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
>
{selectedMember.character.backstory}
</PlatformSubpanel>
</div>
)}
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.panel)}
>
<div className="mb-3 text-xs font-bold text-white">
</div>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
>
{selectedMember.character.personality}
</PlatformSubpanel>
</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) => (
<PlatformSubpanel
as="div"
key={item.key}
surface="dark"
radius="xs"
padding="row"
className="flex items-center justify-between"
>
<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>
<PlatformPillBadge
tone="darkAmber"
size="xxs"
className="px-2 py-0.5 font-normal"
>
{item.rarityLabel}
</PlatformPillBadge>
</PlatformSubpanel>
))}
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}