初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

View File

@@ -0,0 +1,804 @@
import {X} from 'lucide-react';
import {AnimatePresence, motion} from 'motion/react';
import type {ReactNode} from 'react';
import {buildRelationState, formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile} from '../data/attributeResolver';
import {
getCharacterById,
getCharacterEquipment,
getCharacterMaxMana,
getCharacterPrivateChatUnlockAffinity,
getCharacterPublicBackstorySummary,
getLockedCharacterBackstoryChapters,
getUnlockedCharacterBackstoryChapters,
} from '../data/characterPresets';
import {getEquipmentSlotFromItem, getEquipmentSlotLabel} from '../data/equipmentEffects';
import {getHostileNpcPresetById} from '../data/hostileNpcPresets';
import {buildEncounterAttributeRumors, resolveEncounterAttributeProfile} from '../data/npcAttributeInsights';
import {buildInitialNpcState, getRarityLabel, normalizeNpcPersistentState} from '../data/npcInteractions';
import type {CharacterChatTarget} from '../hooks/useStoryGeneration';
import {AnimationState, type Character, type Encounter, type GameState, type InventoryItem, type NpcPersistentState} from '../types';
import {getNineSliceStyle, UI_CHROME} from '../uiAssets';
import {AffinityStatusCard} from './AffinityStatusCard';
import {CharacterAnimator} from './CharacterAnimator';
import type {GameCanvasEntitySelection} from './GameCanvas';
import {HostileNpcAnimator} from './HostileNpcAnimator';
import {MedievalNpcAnimator} from './MedievalNpcAnimator';
interface AdventureEntityModalProps {
selection: GameCanvasEntitySelection | null;
gameState: GameState;
onClose: () => void;
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
}
function estimateCharacterMaxHp(character: Character) {
return Math.max(120, 90 + character.attributes.strength * 10 + character.attributes.spirit * 4);
}
function estimateNpcMaxHp(character: Character | null) {
return character ? estimateCharacterMaxHp(character) : 120;
}
function estimateNpcMaxMana(character: Character | null) {
return character ? getCharacterMaxMana(character) : 0;
}
function StatBar({
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 (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">{current} / {max}</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div className={`h-full bg-gradient-to-r ${fillClass}`} style={{width: `${ratio * 100}%`}} />
</div>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="mb-3 text-[10px] uppercase tracking-[0.22em] text-zinc-500">{title}</div>
{children}
</div>
);
}
function ItemList({items}: {items: InventoryItem[]}) {
if (items.length === 0) {
return <div className="text-sm text-zinc-500"></div>;
}
return (
<div className="space-y-2">
{items.map(item => {
const slot = getEquipmentSlotFromItem(item);
return (
<div
key={item.id}
className="flex items-center justify-between gap-3 rounded-xl border border-white/8 bg-black/25 px-3 py-2 text-sm text-zinc-300"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{item.name}
{item.quantity > 1 ? ` x${item.quantity}` : ''}
</div>
<div className="mt-1 text-[10px] text-zinc-500">
{item.category} / {getRarityLabel(item.rarity)}{slot ? ` / ${getEquipmentSlotLabel(slot)}` : ''}
</div>
</div>
</div>
);
})}
</div>
);
}
const SKILL_STYLE_LABELS = {
burst: '爆发',
steady: '稳态',
mobility: '机动',
finisher: '终结',
projectile: '投射',
} satisfies Record<Character['skills'][number]['style'], string>;
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
return skill.delivery === 'ranged' || skill.style === 'projectile' ? '远程' : '近战';
}
function getSkillStyleLabel(skill: Character['skills'][number]) {
return SKILL_STYLE_LABELS[skill.style];
}
function CharacterSkills({character}: {character: Character}) {
if (character.skills.length === 0) {
return <div className="text-sm text-zinc-500"></div>;
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{character.skills.map(skill => (
<div
key={skill.id}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-sm text-zinc-300"
>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
{getSkillDeliveryLabel(skill)}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
<div>{skill.manaCost}</div>
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-3 text-[10px] tracking-[0.16em] text-sky-200/85">
{getSkillStyleLabel(skill)}
</div>
</div>
))}
</div>
);
}
function CharacterEquipment({character}: {character: Character}) {
const equipment = getCharacterEquipment(character);
if (equipment.length === 0) {
return <div className="text-sm text-zinc-500"></div>;
}
return (
<div className="space-y-2 text-sm text-zinc-300">
{equipment.map(item => (
<div
key={`${character.id}-${item.slot}-${item.item}`}
className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-3"
>
<div>
<div className="text-[10px] tracking-[0.16em] text-zinc-500">{item.slot}</div>
<div className="mt-1 font-medium text-white">{item.item}</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.rarity}
</span>
</div>
))}
</div>
);
}
function getNpcBadge(encounter: Encounter, affinity: number, battleStatePresent: boolean) {
if (encounter.hostile || battleStatePresent || affinity < 0) {
return '敌对角色';
}
return '相遇角色';
}
function describeRelationStance(affinity: number) {
switch (buildRelationState(affinity).stance) {
case 'hostile':
return '敌对';
case 'guarded':
return '戒备';
case 'neutral':
return '试探';
case 'cooperative':
return '合作';
case 'bonded':
return '深信';
default:
return '未知';
}
}
function buildGenericNpcArchiveSummary(
encounter: Encounter,
npcState: NpcPersistentState,
rumors: string[],
) {
const contactSummary = npcState.firstMeaningfulContactResolved
? '你们已经越过最初的表面试探,对方开始显露更稳定的行事轮廓。'
: '目前仍停留在初见观察阶段,对方真正的来历和立场还没有完全摊开。';
const rumorSummary = rumors.length > 0
? `从细节里能确认的线索有:${rumors.join('')}`
: `${encounter.npcName}当前显露出来的大多只是“${encounter.context}”这一层身份。`;
return {
publicSummary: `${encounter.npcName}以“${encounter.context}”的身份出现在你面前。${encounter.npcDescription}`,
clueSummary: `${contactSummary}${rumorSummary}`,
};
}
function buildGenericNpcPersonalitySummary(
encounter: Encounter,
affinity: number,
rumors: string[],
) {
const stanceSummary = affinity >= 60
? '说话会更直接,也更愿意表明自己的立场。'
: affinity >= 30
? '已经不再把你完全当外人,但依旧保留自己的边界。'
: affinity >= 15
? '愿意正常交流,不过还在观察你的来意。'
: '习惯先判断风险,再决定透露多少。';
const rumorSummary = rumors[0]
? `从表现上看,最鲜明的一面是:${rumors[0]}`
: `${encounter.context}这层身份几乎决定了对方当前的处事方式。`;
return `${encounter.npcName}当前对你的态度偏${describeRelationStance(affinity)}${stanceSummary}${rumorSummary}`;
}
function buildGenericNpcTechniqueCards(params: {
encounter: Encounter;
npcState: NpcPersistentState;
hasBattleState: boolean;
inventoryCount: number;
rumors: string[];
}) {
const {encounter, npcState, hasBattleState, inventoryCount, rumors} = params;
const cards: Array<{title: string; detail: string}> = [
{
title: '身份手段',
detail: `${encounter.npcName}主要以“${encounter.context}”这一身份处理眼前局面,通常会先观察、试探,再决定是否继续合作或对抗。`,
},
{
title: '交涉倾向',
detail: `当前好感为 ${npcState.affinity},关系阶段偏${describeRelationStance(npcState.affinity)},会直接影响对方在交谈、帮助与进一步表态上的松紧度。`,
},
];
if (hasBattleState || encounter.hostile) {
cards.push({
title: '战斗方式',
detail: hasBattleState
? '当前已进入可直接交锋的状态,对方随时可能以战斗方式解决问题。'
: '一旦局势恶化,对方更倾向于用强硬手段而不是继续周旋。',
});
}
if (inventoryCount > 0) {
cards.push({
title: '携行资源',
detail: `身上带着 ${inventoryCount} 类随身物资,可被用于交易、帮助、赠礼交换,或作为进一步试探其身份的线索。`,
});
}
if (rumors.length > 0) {
cards.push({
title: '擅长领域',
detail: rumors.join(''),
});
}
return cards.slice(0, 4);
}
function buildFallbackCompanionNpcState(affinity: number): NpcPersistentState {
return normalizeNpcPersistentState({
affinity,
relationState: buildRelationState(affinity),
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: true,
revealedFacts: [],
knownAttributeRumors: [],
firstMeaningfulContactResolved: true,
seenBackstoryChapterIds: [],
});
}
export function AdventureEntityModal({
selection,
gameState,
onClose,
onOpenCharacterChat,
}: AdventureEntityModalProps) {
const playerCharacter = selection?.kind === 'player' ? gameState.playerCharacter : null;
const companion = selection?.kind === 'companion' ? selection.companion : null;
const companionCharacter = companion?.character ?? null;
const companionRosterState = companion
? gameState.companions.find(item => item.npcId === companion.npcId)
?? gameState.roster.find(item => item.npcId === companion.npcId)
?? null
: null;
const companionNpcState = companion
? normalizeNpcPersistentState(
gameState.npcStates[companion.npcId]
?? buildFallbackCompanionNpcState(companionRosterState?.joinedAtAffinity ?? 0),
)
: null;
const npcEncounter = selection?.kind === 'npc' ? selection.encounter : null;
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
const npcId = npcEncounter?.id ?? npcEncounter?.npcName ?? null;
const npcState = npcEncounter
? normalizeNpcPersistentState(
gameState.npcStates[npcId ?? ''] ?? buildInitialNpcState(npcEncounter, gameState.worldType),
)
: null;
const hostileNpcPresetId = npcEncounter?.hostileNpcPresetId ?? npcEncounter?.hostileNpcPresetId;
const hostileNpcPreset = hostileNpcPresetId && gameState.worldType
? getHostileNpcPresetById(gameState.worldType, hostileNpcPresetId)
: null;
const npcBattleState = selection?.kind === 'npc' ? selection.battleState ?? null : null;
const archiveCharacter = selection?.kind === 'companion'
? companionCharacter
: selection?.kind === 'npc'
? npcCharacter
: null;
const archiveNpcState = selection?.kind === 'companion'
? companionNpcState
: selection?.kind === 'npc'
? npcState
: null;
const archiveAffinity = archiveNpcState?.affinity ?? 0;
const archivePublicSummary = archiveCharacter
? getCharacterPublicBackstorySummary(archiveCharacter, gameState.worldType)
: null;
const unlockedBackstoryChapters = archiveCharacter
? getUnlockedCharacterBackstoryChapters(
archiveCharacter,
archiveAffinity,
gameState.worldType,
)
: [];
const lockedBackstoryChapters = archiveCharacter
? getLockedCharacterBackstoryChapters(
archiveCharacter,
archiveAffinity,
gameState.worldType,
)
: [];
const privateChatUnlockAffinity = companionCharacter
? getCharacterPrivateChatUnlockAffinity(companionCharacter, gameState.worldType)
: null;
const privateChatUnlocked = Boolean(
selection?.kind === 'companion'
&& companionCharacter
&& companionNpcState?.recruited
&& privateChatUnlockAffinity != null
&& companionNpcState.affinity >= privateChatUnlockAffinity,
);
const title = selection?.kind === 'player'
? playerCharacter?.name ?? '主角'
: selection?.kind === 'companion'
? companionCharacter?.name ?? '同行角色'
: npcEncounter?.npcName ?? '相遇角色';
const subtitle = selection?.kind === 'player'
? playerCharacter?.title ?? '主角'
: selection?.kind === 'companion'
? companionCharacter?.title ?? '同行角色'
: npcEncounter?.context ?? '相遇角色';
const description = selection?.kind === 'player'
? playerCharacter?.description ?? ''
: selection?.kind === 'companion'
? companionCharacter?.description ?? ''
: npcEncounter?.npcDescription ?? '';
const hp = selection?.kind === 'player'
? gameState.playerHp
: selection?.kind === 'companion'
? companion?.hp ?? (companionCharacter ? estimateCharacterMaxHp(companionCharacter) : 0)
: npcBattleState?.hp ?? estimateNpcMaxHp(npcCharacter);
const maxHp = selection?.kind === 'player'
? gameState.playerMaxHp
: selection?.kind === 'companion'
? companion?.maxHp ?? (companionCharacter ? estimateCharacterMaxHp(companionCharacter) : 0)
: npcBattleState?.maxHp ?? estimateNpcMaxHp(npcCharacter);
const mana = selection?.kind === 'player'
? gameState.playerMana
: selection?.kind === 'companion'
? companion?.mana ?? (companionCharacter ? getCharacterMaxMana(companionCharacter) : 0)
: estimateNpcMaxMana(npcCharacter);
const maxMana = selection?.kind === 'player'
? gameState.playerMaxMana
: selection?.kind === 'companion'
? companion?.maxMana ?? (companionCharacter ? getCharacterMaxMana(companionCharacter) : 0)
: estimateNpcMaxMana(npcCharacter);
const companionChatTarget = selection?.kind === 'companion' && companionCharacter
? {
character: companionCharacter,
npcId: companion?.npcId ?? null,
roleLabel: '同行角色',
hp,
maxHp,
mana,
maxMana,
affinity: companionNpcState?.affinity ?? null,
} satisfies CharacterChatTarget
: null;
const inventory = selection?.kind === 'player'
? gameState.playerInventory
: selection?.kind === 'companion'
? []
: npcState?.inventory ?? [];
const attributeSchema = resolveAttributeSchema(gameState.worldType, gameState.customWorldProfile);
const selectedAttributeProfile = selection?.kind === 'player'
? (playerCharacter ? resolveCharacterAttributeProfile(playerCharacter, gameState.worldType, gameState.customWorldProfile) : null)
: selection?.kind === 'companion'
? (companionCharacter ? resolveCharacterAttributeProfile(companionCharacter, gameState.worldType, gameState.customWorldProfile) : null)
: npcCharacter
? resolveCharacterAttributeProfile(npcCharacter, gameState.worldType, gameState.customWorldProfile)
: npcEncounter
? resolveEncounterAttributeProfile(npcEncounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
})
: null;
const attributeRows = selectedAttributeProfile
? formatAttributeList(selectedAttributeProfile, attributeSchema)
: [];
const genericNpcRumors = npcEncounter && !npcCharacter
? buildEncounterAttributeRumors(npcEncounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
limit: 3,
})
: [];
const genericNpcArchive = npcEncounter && npcState && !npcCharacter
? buildGenericNpcArchiveSummary(npcEncounter, npcState, genericNpcRumors)
: null;
const genericNpcPersonality = npcEncounter && npcState && !npcCharacter
? buildGenericNpcPersonalitySummary(npcEncounter, npcState.affinity, genericNpcRumors)
: null;
const genericNpcTechniqueCards = npcEncounter && npcState && !npcCharacter
? buildGenericNpcTechniqueCards({
encounter: npcEncounter,
npcState,
hasBattleState: Boolean(npcBattleState),
inventoryCount: npcState.inventory.length,
rumors: genericNpcRumors,
})
: [];
return (
<AnimatePresence>
{selection && (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
className="fixed inset-0 z-[72] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={onClose}
>
<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,48rem)] 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="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-[10px] uppercase tracking-[0.24em] text-zinc-500"></div>
<div className="mt-1 text-lg font-semibold text-white">{title}</div>
<div className="mt-1 text-sm text-zinc-400">{subtitle}</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)]">
<div className="space-y-4">
<Section title="立绘">
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
{selection.kind === 'player' && playerCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : selection.kind === 'companion' && companionCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : npcCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : hostileNpcPreset ? (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
animation={npcBattleState?.animation ?? 'idle'}
flip={(npcBattleState?.facing ?? 'left') === 'right'}
/>
) : npcEncounter ? (
<MedievalNpcAnimator encounter={npcEncounter} className="origin-bottom scale-[1.72]" />
) : null}
</div>
{selection.kind === 'npc' && npcEncounter && npcState && (
<div className="mt-3 rounded-full border border-rose-400/25 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-rose-100">
{getNpcBadge(npcEncounter, npcState.affinity, Boolean(npcBattleState))}
</div>
)}
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{description}</p>
</div>
</Section>
<Section title="状态">
<div className="space-y-3">
<StatBar label="生命值" current={hp} max={maxHp} tone="hp" />
{maxMana > 0 ? <StatBar label="灵力" current={mana} max={maxMana} tone="mp" /> : null}
</div>
</Section>
{archiveCharacter && archiveNpcState ? (
<Section title="关系与档案">
<div className="space-y-3">
<AffinityStatusCard affinity={archiveNpcState.affinity} />
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1 text-base font-semibold text-white">{archiveNpcState.affinity}</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1 text-base font-semibold text-white">
{describeRelationStance(archiveNpcState.affinity)}
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1 text-sm text-white">
{archiveNpcState.firstMeaningfulContactResolved
? '已完成第一次正式对接'
: '初次接触未完成'}
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1 text-sm text-white">
{unlockedBackstoryChapters.length} / {unlockedBackstoryChapters.length + lockedBackstoryChapters.length}
</div>
</div>
</div>
{selection.kind === 'companion' && companionChatTarget ? (
<div className="rounded-2xl border border-sky-400/18 bg-sky-500/8 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-[10px] uppercase tracking-[0.18em] text-sky-200/80"></div>
<div className="mt-1 text-sm text-zinc-300">
{privateChatUnlocked
? '已解锁,可直接与该同伴单独交谈。'
: `好感达到 ${privateChatUnlockAffinity ?? 70} 后解锁,当前 ${companionNpcState?.affinity ?? 0}`}
</div>
</div>
<button
type="button"
disabled={!privateChatUnlocked || !onOpenCharacterChat}
onClick={() => {
if (!privateChatUnlocked || !onOpenCharacterChat) {
return;
}
onClose();
onOpenCharacterChat(companionChatTarget);
}}
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
privateChatUnlocked && onOpenCharacterChat
? 'border border-sky-300/40 bg-sky-400/15 text-sky-50 hover:bg-sky-400/22'
: 'cursor-not-allowed border border-white/8 bg-black/20 text-zinc-500'
}`}
>
{privateChatUnlocked ? '聊天' : `聊天(${privateChatUnlockAffinity ?? 70} 解锁)`}
</button>
</div>
</div>
) : null}
</div>
</Section>
) : selection.kind === 'npc' && npcState ? (
<Section title="关系">
<div className="space-y-3">
<AffinityStatusCard affinity={npcState.affinity} />
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div>: {npcState.affinity}</div>
<div className="mt-2">: {npcState.recruited ? '是' : '否'}</div>
</div>
</div>
</Section>
) : null}
</div>
<div className="space-y-4">
{selection.kind === 'player' && playerCharacter ? (
<>
<Section title="背景故事">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-200">
{playerCharacter.backstory}
</div>
</Section>
<Section title="性格">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-200">
{playerCharacter.personality}
</div>
</Section>
<Section title="技能">
<CharacterSkills character={playerCharacter} />
</Section>
<Section title="装备">
<CharacterEquipment character={playerCharacter} />
</Section>
</>
) : archiveCharacter && archiveNpcState ? (
<>
<Section title="背景档案">
<div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-2 text-sm leading-relaxed text-zinc-200">{archivePublicSummary}</div>
</div>
{unlockedBackstoryChapters.map(chapter => (
<div
key={chapter.id}
className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-4 py-3"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-emerald-50">{chapter.title}</div>
<span className="rounded-full border border-emerald-300/20 bg-black/20 px-2 py-0.5 text-[10px] text-emerald-100">
</span>
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-200">{chapter.content}</div>
</div>
))}
{lockedBackstoryChapters.map(chapter => (
<div
key={chapter.id}
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-zinc-400"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-200">{chapter.title}</div>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] text-zinc-300">
{chapter.affinityRequired}
</span>
</div>
<div className="mt-2 text-sm leading-relaxed">{chapter.teaser}</div>
</div>
))}
</div>
</Section>
<Section title="性格">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-200">
{archiveCharacter.personality}
</div>
</Section>
<Section title="技能">
<CharacterSkills character={archiveCharacter} />
</Section>
<Section title="装备">
<CharacterEquipment character={archiveCharacter} />
</Section>
</>
) : genericNpcArchive && genericNpcPersonality ? (
<>
<Section title="背景档案">
<div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-2 text-sm leading-relaxed text-zinc-200">{genericNpcArchive.publicSummary}</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">线</div>
<div className="mt-2 text-sm leading-relaxed">{genericNpcArchive.clueSummary}</div>
</div>
</div>
</Section>
<Section title="性格">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-200">
{genericNpcPersonality}
</div>
</Section>
<Section title="技能与手段">
<div className="space-y-2">
{genericNpcTechniqueCards.map(card => (
<div
key={`${npcEncounter?.id ?? npcEncounter?.npcName}-${card.title}`}
className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm text-zinc-300"
>
<div className="text-sm font-semibold text-white">{card.title}</div>
<div className="mt-2 leading-relaxed">{card.detail}</div>
</div>
))}
</div>
</Section>
</>
) : null}
{attributeRows.length > 0 ? (
<Section title="属性">
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
{attributeRows.map(({slot, value}) => (
<div key={slot.slotId} className="rounded-xl border border-white/8 bg-black/25 px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">{slot.name}</div>
<div className="mt-1 font-medium text-white">{value}</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
</div>
))}
</div>
</Section>
) : null}
{selection.kind === 'npc' && npcEncounter ? (
<Section title="遭遇信息">
<div className="space-y-2 text-sm text-zinc-300">
<div>: {npcEncounter.npcName}</div>
<div>: {npcEncounter.context}</div>
<div>: {getNpcBadge(npcEncounter, npcState?.affinity ?? 0, Boolean(npcBattleState))}</div>
{npcBattleState ? <div>: {npcBattleState.combatMode === 'melee' ? '近战' : '远程'}</div> : null}
</div>
</Section>
) : null}
<Section title="携带物品">
<ItemList items={inventory} />
</Section>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}