804
src/components/AdventureEntityModal.tsx
Normal file
804
src/components/AdventureEntityModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user