import { AnimatePresence, motion } from 'motion/react'; import { type CSSProperties, useEffect, useMemo, useState } from 'react'; import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver'; import { type BuildDamageBreakdown, describeBuildContribution, getBuildContributionAttributeRows, getBuildSourceLabel, getCompanionBuildDamageBreakdown, getPlayerBuildDamageBreakdown, } from '../data/buildDamage'; import { getCharacterEquipment } from '../data/characterPresets'; import { buildInitialEquipmentLoadout, EQUIPMENT_SLOTS, getEquipmentRarityLabel, getEquipmentSlotLabel, } from '../data/equipmentEffects'; import type { CharacterChatTarget } from '../hooks/useStoryGeneration'; import { getResourceLabelsForWorld } from '../services/customWorldPresentation'; import { AnimationState, Character, CompanionRenderState, CustomWorldProfile, EquipmentLoadout, GameState, QuestLogEntry, TimedBuildBuff, WorldAttributeSchema, WorldType, } from '../types'; import { CHROME_ICONS, getEquipmentSlotIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { AffinityStatusCard } from './AffinityStatusCard'; import { CharacterAnimator } from './CharacterAnimator'; import type { GameCanvasEntitySelection } from './GameCanvas'; import { PixelIcon } from './PixelIcon'; interface CharacterPanelProps { worldType: WorldType | null; customWorldProfile?: CustomWorldProfile | null; playerCharacter: Character; 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; onInspectMember?: (selection: GameCanvasEntitySelection) => void; } type PartyMember = { id: string; npcId: string | null; renderState: CompanionRenderState | null; character: Character; roleLabel: string; hp: number; maxHp: number; mana: number; maxMana: number; isLeader: boolean; }; type EquipmentRow = { key: string; slotLabel: string; itemLabel: string; rarityLabel: string; }; type ContributionRow = BuildDamageBreakdown['rows'][number]; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function StatusRow({ label, current, max, tone, }: { label: string; current: number; max: number; tone: 'hp' | 'mp'; }) { const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0)); const fillClass = tone === 'hp' ? 'from-emerald-400 via-lime-300 to-emerald-200' : 'from-sky-500 via-cyan-300 to-sky-100'; return (
{label} {current} / {max}
); } function getGenderLabel(gender: Character['gender']) { if (gender === 'female') return '女'; if (gender === 'male') return '男'; return '未明'; } const SKILL_STYLE_LABELS = { burst: '爆发', steady: '稳态', mobility: '机动', finisher: '终结', projectile: '投射', } satisfies Record; function getSkillDeliveryLabel(skill: Character['skills'][number]) { return skill.delivery === 'ranged' || skill.style === 'projectile' ? '远程' : '近战'; } function CharacterSkillsList({character}: {character: Character}) { if (character.skills.length === 0) { return
暂无技能信息
; } return (
{character.skills.map(skill => (
{skill.name}
{getSkillDeliveryLabel(skill)}
伤害:{skill.damage}
法力:{skill.manaCost}
冷却:{skill.cooldownTurns}
距离:{skill.range}
{SKILL_STYLE_LABELS[skill.style]}
))}
); } function getContributionHeatRatio(value: number, minValue = 0, maxValue = 1) { const normalizedMin = Number.isFinite(minValue) ? minValue : 0; const normalizedMax = Number.isFinite(maxValue) ? maxValue : 1; const range = normalizedMax - normalizedMin; if (range <= 0.0001) { return normalizedMax > 0 ? 1 : 0; } return clamp((value - normalizedMin) / range, 0, 1); } function getContributionVisualStyle(value: number, minValue = 0, maxValue = 1): CSSProperties { const ratio = getContributionHeatRatio(value, minValue, maxValue); const hue = 210 - ratio * 178; const saturation = 62 + ratio * 16; const lightness = 56 + ratio * 6; return { borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`, background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`, boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`, color: ratio > 0.76 ? 'rgb(255 244 235)' : ratio > 0.32 ? 'rgb(236 242 248)' : 'rgb(203 213 225)', }; } function getContributionTrackStyle(value: number, minValue = 0, maxValue = 1): CSSProperties { const ratio = getContributionHeatRatio(value, minValue, maxValue); const widthRatio = 0.18 + ratio * 0.82; const hue = 210 - ratio * 178; return { width: `${widthRatio * 100}%`, background: `linear-gradient(90deg, hsla(${hue}, ${70 + ratio * 14}%, ${56 + ratio * 10}%, 0.94) 0%, rgba(255, 229, 214, 0.98) 100%)`, }; } function MultiplierContributionList({ breakdown, schema, onSelectContribution, }: { breakdown: BuildDamageBreakdown; schema: WorldAttributeSchema; onSelectContribution: (row: ContributionRow) => void; }) { const sortedRows = [...breakdown.rows].sort((left, right) => right.bonusDelta - left.bonusDelta || left.label.localeCompare(right.label, 'zh-CN')); const contributionProducts = sortedRows.map(row => row.bonusDelta); const weakestProduct = contributionProducts.length > 0 ? Math.min(...contributionProducts) : 0; const strongestProduct = contributionProducts.length > 0 ? Math.max(...contributionProducts) : 1; return (
{'\u5c5e\u6027\u9002\u914d\u5ea6'} {'\u70b9\u51fb\u6807\u7b7e\u67e5\u770b\u6536\u76ca\u6765\u81ea\u54ea\u4e9b\u5c5e\u6027'}
{'\u5c5e\u6027\u9002\u914d\u500d\u7387'}
x{breakdown.buildDamageMultiplier.toFixed(2)}
{'\u603b\u52a0\u6210'} +{breakdown.buildDamageBonus.toFixed(2)}
{sortedRows.length > 0 ? (
{sortedRows.map(row => ( ))}
) : ( {'\u5f53\u524d\u8fd8\u6ca1\u6709\u5f62\u6210\u6709\u6548\u6807\u7b7e'} )}
); } 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, })); } function getCharacterDetailSpriteStyle(character: Character) { const groundOffset = character.groundOffsetY ?? 22; const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34)); return { transform: `translateY(${translateY}px) scale(1.34)`, transformOrigin: 'center bottom', } satisfies CSSProperties; } export function CharacterPanel({ worldType, customWorldProfile = null, playerCharacter, playerHp, playerMaxHp, playerMana, playerMaxMana, playerEquipment, activeBuildBuffs = [], companionRenderStates, npcStates = {}, quests, onInspectMember, }: CharacterPanelProps) { const [selectedMemberId, setSelectedMemberId] = useState(null); const [selectedContributionLabel, setSelectedContributionLabel] = useState(null); const partyMembers = useMemo( () => [ { id: `leader-${playerCharacter.id}`, npcId: null, renderState: null, character: playerCharacter, roleLabel: '闃熼暱', hp: playerHp, maxHp: playerMaxHp, mana: playerMana, maxMana: playerMaxMana, isLeader: true, }, ...companionRenderStates.map(companion => ({ id: companion.npcId, npcId: companion.npcId, renderState: companion, character: companion.character, roleLabel: '鍚岃', hp: companion.hp, maxHp: companion.maxHp, mana: companion.mana, maxMana: companion.maxMana, isLeader: false, })), ], [companionRenderStates, 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, [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 selectedContributionProducts = selectedBuildBreakdown?.rows.map(row => row.bonusDelta) ?? []; const selectedContributionMinProduct = selectedContributionProducts.length > 0 ? Math.min(...selectedContributionProducts) : 0; const selectedContributionMaxProduct = selectedContributionProducts.length > 0 ? Math.max(...selectedContributionProducts) : 1; const selectedAttributeSchema = resolveAttributeSchema(worldType, customWorldProfile); const selectedMemberAffinity = selectedMember?.npcId ? npcStates[selectedMember.npcId]?.affinity ?? 0 : null; const selectedEquipmentRows = selectedMember ? selectedMember.isLeader ? buildLeaderEquipmentRows(playerCharacter, playerEquipment) : buildCompanionEquipmentRows(selectedMember.character, selectedMember.id) : []; const selectedAttributeRows = selectedMember ? formatAttributeList( resolveCharacterAttributeProfile(selectedMember.character, worldType, customWorldProfile), selectedAttributeSchema, ) : []; const selectedContributionAttributes = selectedContributionRow ? getBuildContributionAttributeRows(selectedContributionRow, selectedAttributeSchema) : []; const resourceLabels = getResourceLabelsForWorld(worldType); 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 ( <>
{activeQuests.length > 0 && (
褰撳墠濮旀墭
{activeQuests.map(quest => (
{quest.title}
{quest.summary}
))}
)}
队伍成员
{partyMembers.map(member => ( ))}
{selectedContributionRow && selectedMember && ( setSelectedContributionLabel(null)} > event.stopPropagation()} >
{'\u5c5e\u6027\u9002\u914d\u89e3\u6790'}
{selectedContributionRow.label}
{selectedMember.character.name}
{selectedContributionRow.label}
{getBuildSourceLabel(selectedContributionRow.source)} · {describeBuildContribution(selectedContributionRow, selectedAttributeSchema)}
{'\u52a0\u6210'} +{selectedContributionRow.bonusDelta.toFixed(2)}
{'\u9002\u914d\u5ea6'} {Math.round(selectedContributionRow.fitScore * 100)}%
bonusDelta = {'\u5404\u5c5e\u6027\u52a0\u6210\u4e4b\u548c'}
{'\u6bcf\u4e2a\u6807\u7b7e\u90fd\u4f1a\u5206\u522b\u5339\u914d\u5f53\u524d\u4e16\u754c\u7684\u5c5e\u6027\u8f74\uff0c\u518d\u548c\u89d2\u8272\u81ea\u5df1\u7684\u5c5e\u6027\u6743\u91cd\u9010\u9879\u76f8\u4e58\u3002\u6bcf\u6761\u5c5e\u6027\u5148\u751f\u6210\u5355\u72ec\u7684\u52a0\u6210\uff0c\u6700\u540e\u6c47\u603b\u6210\u8fd9\u4e2a\u6807\u7b7e\u7684\u6536\u76ca\u3002'}
{selectedContributionRow.label} = 0.12 x {'\u9002\u914d\u5ea6'} {selectedContributionRow.fitScore.toFixed(2)} x {'\u6765\u6e90\u7cfb\u6570'} {selectedContributionRow.sourceCoefficient.toFixed(2)} = {selectedContributionRow.bonusDelta.toFixed(2)}
{selectedContributionAttributes.length > 0 ? (
{selectedContributionAttributes.map(attribute => (
{attribute.label} {Math.round(attribute.percent * 100)}%
{attribute.definition}
{'\u6807\u7b7e\u4eb2\u548c'} {Math.round(attribute.similarity * 100)}%
{'\u89d2\u8272\u6743\u91cd'} {Math.round(attribute.weight * 100)}%
{'\u9002\u914d\u8d21\u732e'} {attribute.value.toFixed(4)}
{'\u5c5e\u6027\u52a0\u6210'} +{attribute.modifierDelta.toFixed(4)}
))}
) : (
{'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'}
)}
)} {selectedMember && ( setSelectedMemberId(null)} > event.stopPropagation()} >
角色详情
{selectedMember.character.name}
{selectedMember.character.title} {getGenderLabel(selectedMember.character.gender)}
{selectedMember.character.name}
{selectedMember.character.title}

{selectedMember.character.description}

状态
{selectedMemberAffinity != null && ( )} {selectedBuildBreakdown && ( setSelectedContributionLabel(row.label)} /> )}
{selectedAttributeRows.map(({ slot, value }) => (
{slot.name}: {value}
{slot.definition}
))}
背景故事
{selectedMember.character.backstory}
性格
{selectedMember.character.personality}
{'\u6280\u80fd'}
装备
{selectedEquipmentRows.map(item => (
{item.slotLabel}
{item.itemLabel}
{item.rarityLabel}
))}
)}
); }