Files
Genarrative/src/components/AdventureEntityModal.tsx
2026-04-21 18:27:46 +08:00

1433 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
} from '../data/affinityLevels';
import {
buildRelationState,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import {
formatBuildContributionPercent,
getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
resolveMonsterOutgoingDamage,
} from '../data/buildDamage';
import {
getCharacterById,
getCharacterMaxHp,
getCharacterMaxMana,
getCharacterPrivateChatUnlockAffinity,
getCharacterPublicBackstorySummary,
getInventoryItems,
getLockedCharacterBackstoryChapters,
getUnlockedCharacterBackstoryChapters,
} from '../data/characterPresets';
import {
getHostileNpcPresetById,
getMonsterPresetsByWorld,
} from '../data/hostileNpcPresets';
import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals';
import {
buildEncounterAttributeRumors,
resolveEncounterAttributeProfile,
} from '../data/npcAttributeInsights';
import {
buildInitialNpcState,
createNpcBattleMonster,
normalizeNpcPersistentState,
} from '../data/npcInteractions';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import { getSceneHostileNpcPresetIds } from '../data/scenePresets';
import type { CharacterChatTarget } from '../hooks/rpg-runtime-story';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
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 {
BackstoryArchive,
type BackstoryLockedChapter,
type BackstoryUnlockedChapter,
} from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import {
buildCharacterSkillRenderId,
getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getSkillDeliveryLabel,
getSkillStyleLabel,
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterIdentityBadges,
CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress,
StatusRow,
} from './CharacterInfoShared';
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { HostileNpcAnimator } from './HostileNpcAnimator';
import {
InventoryItemDetailModal,
InventoryItemGrid,
} from './InventoryItemViews';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { SkillEffectPreview } from './SkillEffectPreview';
interface AdventureEntityModalProps {
selection: GameCanvasEntitySelection | null;
gameState: GameState;
onClose: () => void;
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
}
const [
BACKSTORY_UNLOCK_AFFINITY_EASED = 15,
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY = 30,
BACKSTORY_UNLOCK_AFFINITY_TRUSTED = 60,
BACKSTORY_UNLOCK_AFFINITY_CLOSE = 90,
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
function estimateCharacterMaxHp(
character: Character,
worldType: GameState['worldType'],
customWorldProfile: GameState['customWorldProfile'],
) {
return getCharacterMaxHp(character, worldType, customWorldProfile);
}
function estimateNpcMaxHp(
character: Character | null,
worldType: GameState['worldType'],
customWorldProfile: GameState['customWorldProfile'],
) {
return character
? estimateCharacterMaxHp(character, worldType, customWorldProfile)
: 120;
}
function estimateNpcMaxMana(character: Character | null) {
return character ? getCharacterMaxMana(character) : 0;
}
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 resolveSkillPreviewMonsterId(gameState: GameState) {
if (!gameState.worldType) {
return null;
}
const sceneMonsterId =
getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
if (sceneMonsterId) {
return sceneMonsterId;
}
return getMonsterPresetsByWorld(gameState.worldType)[0]?.id ?? null;
}
function buildPreviewInventoryDescription(
characterName: string,
item: { category: string; name: string; quantity: number },
) {
const quantityText = item.quantity > 1 ? `,当前数量 x${item.quantity}` : '';
switch (item.category) {
case '消耗品':
return `${characterName} 随身准备的消耗品,适合在关键时刻快速补给${quantityText}`;
case '稀有品':
return `${characterName} 妥善保管的稀有物件,通常和经历、身份或交易筹码有关${quantityText}`;
case '专属品':
return `${characterName} 不轻易示人的专属信物,往往带着明显的个人痕迹${quantityText}`;
case '材料':
return `${characterName} 随身收着的制作材料,可用于后续锻造或交换${quantityText}`;
default:
return `${characterName} 携带的${item.category}${quantityText}`;
}
}
function getPreviewInventoryRarity(category: string): InventoryItem['rarity'] {
switch (category) {
case '专属品':
return 'epic';
case '稀有品':
return 'rare';
case '材料':
return 'uncommon';
default:
return 'common';
}
}
function buildCharacterInventoryPreviewItems(
character: Character,
worldType: GameState['worldType'],
) {
return getInventoryItems(character, worldType).map(
(item, index) =>
({
id: `preview:${character.id}:${index}:${item.category}:${item.name}`,
category: item.category,
name: item.name,
quantity: item.quantity,
rarity: getPreviewInventoryRarity(item.category),
tags: [],
description: buildPreviewInventoryDescription(character.name, item),
}) satisfies InventoryItem,
);
}
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 truncateBackstoryTeaser(text: string, maxLength = 42) {
const normalized = text.replace(/\s+/g, ' ').trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function buildGenericNpcBackstoryArchive(
encounter: Encounter,
npcState: NpcPersistentState,
rumors: string[],
battleStatePresent: boolean,
): {
publicSummary: string;
unlockedChapters: BackstoryUnlockedChapter[];
lockedChapters: BackstoryLockedChapter[];
} {
const publicSummary =
npcState.affinity < 0 || encounter.hostile || battleStatePresent
? `${encounter.npcName}表面上仍以“${encounter.context}”的身份行动,但此刻已把你视作敌人。${encounter.npcDescription}`
: `${encounter.npcName}以“${encounter.context}”的身份出现在你面前。${encounter.npcDescription}`;
const relationText = describeRelationStance(npcState.affinity);
const rumorText =
rumors.length > 0
? rumors.join('')
: `${encounter.npcName}当前显露出来的大多仍是“${encounter.context}”这一层身份。`;
const contactText = npcState.firstMeaningfulContactResolved
? '你们已经越过最初的表面试探,对方开始显露更稳定的行事轮廓。'
: '目前仍停留在初见观察阶段,对方真正的来历和立场还没有完全摊开。';
const chapterBlueprints: Array<
BackstoryUnlockedChapter & Pick<BackstoryLockedChapter, 'affinityRequired'>
> = [
{
id: 'surface-role',
title: '表层身份',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
content: `${contactText}你最先能确认的是,${encounter.npcName}眼下确实以“${encounter.context}”的身份处理局面。${encounter.npcDescription}`,
},
{
id: 'behavior-clues',
title: '行事线索',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
content: `随着接触增加,你已经能从细节里摸到一些稳定线索:${rumorText}`,
},
{
id: 'true-stance',
title: '真实立场',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
content:
npcState.affinity < 0 || encounter.hostile || battleStatePresent
? `${encounter.npcName}对你的态度已经明确落到“${relationText}”一侧,很多原本还能试探的空间都转成了直接敌意。`
: `相处到这一步,你已能判断 ${encounter.npcName} 对你的态度偏“${relationText}”,不再只是维持表面身份,而是开始露出更真实的站位。`,
},
{
id: 'deep-concern',
title: '深层牵挂',
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
content:
npcState.affinity < 0 || encounter.hostile || battleStatePresent
? `即使局势已经转向对立,你仍能感觉到,${encounter.npcName}真正死守的东西,多半和“${encounter.context}”这一身份背后的职责、利益或旧牵连有关。`
: `继续相处后,你大致能确认,${encounter.npcName}最在意的仍是“${encounter.context}”这一身份背后的职责、利益或牵挂。虽然未必会全盘交底,但已经肯让你看到更核心的顾虑。`,
},
];
return {
publicSummary,
unlockedChapters: chapterBlueprints
.filter((chapter) => npcState.affinity >= chapter.affinityRequired)
.map(({ affinityRequired: _affinityRequired, ...chapter }) => chapter),
lockedChapters: chapterBlueprints
.filter((chapter) => npcState.affinity < chapter.affinityRequired)
.map((chapter) => ({
id: chapter.id,
title: chapter.title,
affinityRequired: chapter.affinityRequired,
teaser: truncateBackstoryTeaser(chapter.content),
})),
};
}
function buildGenericNpcSkillCatalog(params: {
encounter: Encounter;
npcState: NpcPersistentState;
worldType: GameState['worldType'];
customWorldProfile: GameState['customWorldProfile'];
}): Character['skills'] {
const { encounter, npcState, worldType, customWorldProfile } = params;
const battleMonster = createNpcBattleMonster(encounter, npcState, 'fight', {
worldType,
customWorldProfile,
});
const damage = resolveMonsterOutgoingDamage(
battleMonster,
9,
1,
worldType,
customWorldProfile,
);
const isRanged = Boolean(
battleMonster.attackRange >= 3 ||
battleMonster.combatTags?.some((tag) =>
['远射', '法修', '符阵', '雷法'].includes(tag),
),
);
const basicAttack: Character['skills'][number] = {
id: `npc-basic-attack:${encounter.id ?? encounter.npcName}`,
name: '普通攻击',
animation: AnimationState.ATTACK,
casterAnimation: AnimationState.ATTACK,
damage,
manaCost: 0,
cooldownTurns: 0,
range: Number(battleMonster.attackRange.toFixed(1)),
style: isRanged ? 'projectile' : 'steady',
delivery: isRanged ? 'ranged' : 'melee',
releaseDelayMs: isRanged ? 220 : 120,
};
return [basicAttack];
}
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 [selectedSkillId, setSelectedSkillId] = useState<string | null>(null);
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
string | null
>(null);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
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, gameState),
)
: null;
const monsterPresetId = npcEncounter?.monsterPresetId ?? null;
const hostileNpcPreset =
monsterPresetId && gameState.worldType
? getHostileNpcPresetById(gameState.worldType, monsterPresetId)
: 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 detailCharacter =
selection?.kind === 'player'
? playerCharacter
: selection?.kind === 'companion'
? companionCharacter
: npcCharacter;
const archiveAffinity = archiveNpcState?.affinity ?? 0;
const archivePublicSummary = archiveCharacter
? getCharacterPublicBackstorySummary(archiveCharacter, gameState.worldType)
: null;
const unlockedBackstoryChapters: BackstoryUnlockedChapter[] = archiveCharacter
? getUnlockedCharacterBackstoryChapters(
archiveCharacter,
archiveAffinity,
gameState.worldType,
).map((chapter) => ({
id: chapter.id,
title: chapter.title,
content: chapter.content,
}))
: [];
const lockedBackstoryChapters: BackstoryLockedChapter[] = archiveCharacter
? getLockedCharacterBackstoryChapters(
archiveCharacter,
archiveAffinity,
gameState.worldType,
).map((chapter) => ({
id: chapter.id,
title: chapter.title,
teaser: chapter.teaser,
affinityRequired: chapter.affinityRequired,
}))
: [];
const privateChatUnlockAffinity = companionCharacter
? getCharacterPrivateChatUnlockAffinity(
companionCharacter,
gameState.worldType,
)
: null;
const privateChatUnlocked = Boolean(
selection?.kind === 'companion' &&
companionCharacter &&
companionNpcState?.recruited &&
privateChatUnlockAffinity != null &&
companionNpcState.affinity >= privateChatUnlockAffinity,
);
const normalizedPlayerProgression = normalizePlayerProgressionState(
gameState.playerProgression ?? null,
);
const selectedNpcLevel =
npcEncounter?.levelProfile?.level ??
npcBattleState?.levelProfile?.level ??
null;
const selectionRoleLabel =
selection?.kind === 'player'
? '队长'
: selection?.kind === 'companion'
? '同行'
: selection?.kind === 'npc' && npcEncounter && npcState
? getNpcBadge(
npcEncounter,
npcState.affinity,
Boolean(npcBattleState),
)
: null;
const selectionLevelText =
selection?.kind === 'player'
? `Lv.${normalizedPlayerProgression.level}`
: selection?.kind === 'companion'
? `参考 Lv.${normalizedPlayerProgression.level}`
: typeof selectedNpcLevel === 'number'
? `Lv.${selectedNpcLevel}`
: null;
const selectionRoleTone: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc' =
selection?.kind === 'player'
? 'amber'
: selection?.kind === 'companion'
? 'sky'
: selection?.kind === 'npc' && npcEncounter && npcState
? npcEncounter.hostile ||
Boolean(npcBattleState) ||
npcState.affinity < 0
? 'rose'
: 'emerald'
: 'zinc';
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,
gameState.worldType,
gameState.customWorldProfile,
)
: 0))
: (npcBattleState?.hp ??
estimateNpcMaxHp(
npcCharacter,
gameState.worldType,
gameState.customWorldProfile,
));
const maxHp =
selection?.kind === 'player'
? gameState.playerMaxHp
: selection?.kind === 'companion'
? (companion?.maxHp ??
(companionCharacter
? estimateCharacterMaxHp(
companionCharacter,
gameState.worldType,
gameState.customWorldProfile,
)
: 0))
: (npcBattleState?.maxHp ??
estimateNpcMaxHp(
npcCharacter,
gameState.worldType,
gameState.customWorldProfile,
));
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 = useMemo(
() =>
selection?.kind === 'player'
? gameState.playerInventory
: selection?.kind === 'companion' && companionCharacter
? buildCharacterInventoryPreviewItems(
companionCharacter,
gameState.worldType,
)
: (npcState?.inventory ?? []),
[
companionCharacter,
gameState.playerInventory,
gameState.worldType,
npcState?.inventory,
selection?.kind,
],
);
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 resourceLabels = getResourceLabelsForWorld(
gameState.worldType,
gameState.customWorldProfile,
);
const genericNpcRumors =
npcEncounter && !npcCharacter
? buildEncounterAttributeRumors(npcEncounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
limit: 3,
})
: [];
const genericNpcArchive =
npcEncounter && npcState && !npcCharacter
? buildGenericNpcBackstoryArchive(
npcEncounter,
npcState,
genericNpcRumors,
Boolean(npcBattleState),
)
: null;
const genericNpcSkills = useMemo(
() =>
npcEncounter && npcState && !npcCharacter
? buildGenericNpcSkillCatalog({
encounter: npcEncounter,
npcState,
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
})
: [],
[
gameState.customWorldProfile,
gameState.worldType,
npcCharacter,
npcEncounter,
npcState,
],
);
const displayedSkills = detailCharacter?.skills ?? genericNpcSkills;
const buildBreakdown = useMemo(
() =>
selection?.kind === 'player' && playerCharacter
? getPlayerBuildDamageBreakdown(gameState, playerCharacter)
: detailCharacter
? getCompanionBuildDamageBreakdown(
detailCharacter,
gameState.worldType,
gameState.customWorldProfile,
)
: null,
[detailCharacter, gameState, playerCharacter, selection?.kind],
);
const selectedContributionRow =
buildBreakdown?.rows.find(
(row) => row.label === selectedContributionLabel,
) ?? null;
const selectedContributionAttributes = selectedContributionRow
? getBuildContributionAttributeRows(
selectedContributionRow,
attributeSchema,
{ resourceLabels },
)
: [];
const selectedSkill =
displayedSkills.find(
(skill, index) =>
buildCharacterSkillRenderId(skill, index) === selectedSkillId,
) ?? null;
const selectedSkillPreviewWorldType = gameState.worldType ?? null;
const selectedSkillPreviewMonsterId = selectedSkillPreviewWorldType
? resolveSkillPreviewMonsterId(gameState)
: null;
const selectedSkillPreviewMode =
selection?.kind === 'npc' && npcEncounter && npcCharacter
? 'npc'
: 'player';
const selectedInventoryItem =
inventory.find((item) => item.id === selectedItemId) ?? null;
const selectedSkillOwnerName =
detailCharacter?.name ?? npcEncounter?.npcName ?? title;
const recentChronicleEntries =
gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
const recentCarrierEchoes = (
gameState.storyEngineMemory?.recentCarrierIds ?? []
)
.map(
(carrierId) =>
gameState.playerInventory.find((item) => item.id === carrierId)
?.runtimeMetadata?.storyFingerprint?.visibleClue ??
gameState.playerInventory.find((item) => item.id === carrierId)?.name ??
'',
)
.filter(Boolean)
.slice(0, 3);
const sceneResidues =
gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
const selectedCompanionResolution = detailCharacter
? (gameState.storyEngineMemory?.companionResolutions?.find(
(resolution) => resolution.characterId === detailCharacter.id,
) ?? null)
: null;
const relatedConsequences = (
gameState.storyEngineMemory?.consequenceLedger ?? []
)
.filter((record) =>
detailCharacter
? record.relatedIds.includes(detailCharacter.id)
: npcEncounter
? record.relatedIds.includes(npcEncounter.id ?? npcEncounter.npcName)
: false,
)
.slice(-3);
useEffect(() => {
setSelectedSkillId(null);
setSelectedContributionLabel(null);
setSelectedItemId(null);
}, [selection?.kind, title]);
useEffect(() => {
if (!selectedContributionLabel || selectedContributionRow) return;
setSelectedContributionLabel(null);
}, [selectedContributionLabel, selectedContributionRow]);
useEffect(() => {
if (!selectedSkillId || selectedSkill) return;
setSelectedSkillId(null);
}, [selectedSkill, selectedSkillId]);
useEffect(() => {
if (!selectedItemId || selectedInventoryItem) return;
setSelectedItemId(null);
}, [selectedInventoryItem, selectedItemId]);
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>
{selectionRoleLabel ? (
<CharacterIdentityBadges
roleLabel={selectionRoleLabel}
levelText={selectionLevelText}
roleTone={selectionRoleTone}
className="mt-2"
/>
) : null}
</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 ? (
playerCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
playerCharacter.visual,
)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
playerCharacter,
)}
/>
)
) : selection.kind === 'companion' &&
companionCharacter ? (
companionCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
companionCharacter.visual,
)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
companionCharacter,
)}
/>
)
) : npcCharacter ? (
npcCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
npcCharacter.visual,
)}
scale={2.08}
/>
) : (
<CharacterAnimator
state={AnimationState.IDLE}
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
npcCharacter,
)}
/>
)
) : hostileNpcPreset ? (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
animation={npcBattleState?.animation ?? 'idle'}
flip={
(npcBattleState?.facing ?? 'left') === 'right'
}
/>
) : npcEncounter ? (
<MedievalNpcAnimator
encounter={npcEncounter}
scale={GENERIC_NPC_SCENE_SCALE / 3}
/>
) : null}
</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
{description}
</p>
</div>
</Section>
</div>
<div className="space-y-4">
{archiveCharacter && archiveNpcState ? (
<Section title="关系">
<div className="space-y-3">
<AffinityStatusCard
affinity={archiveNpcState.affinity}
/>
<BackstoryArchive
publicSummary={archivePublicSummary}
unlockedChapters={unlockedBackstoryChapters}
lockedChapters={lockedBackstoryChapters}
/>
</div>
</Section>
) : selection.kind === 'npc' && npcState ? (
<Section title="关系">
<div className="space-y-3">
<AffinityStatusCard affinity={npcState.affinity} />
{genericNpcArchive ? (
<BackstoryArchive
publicSummary={genericNpcArchive.publicSummary}
unlockedChapters={
genericNpcArchive.unlockedChapters
}
lockedChapters={genericNpcArchive.lockedChapters}
/>
) : null}
</div>
</Section>
) : null}
{selection.kind === 'companion' && companionChatTarget ? (
<Section title="私聊">
<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 ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 后解锁,当前 ${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 ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 解锁)`}
</button>
</div>
</div>
</Section>
) : null}
{(recentChronicleEntries.length > 0 ||
recentCarrierEchoes.length > 0 ||
sceneResidues.length > 0 ||
relatedConsequences.length > 0 ||
Boolean(selectedCompanionResolution)) && (
<Section title="最近回响">
<div className="space-y-3">
{selectedCompanionResolution && (
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-100/85">
{selectedCompanionResolution.resolutionType} ·{' '}
{selectedCompanionResolution.summary}
</div>
)}
{relatedConsequences.length > 0 && (
<div className="space-y-1">
{relatedConsequences.map((record, index) => (
<div
key={
record.id ||
`consequence-${record.title}-${index}`
}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">
{record.title}
</span>
{''}
{record.summary}
</div>
))}
</div>
)}
{recentChronicleEntries.length > 0 && (
<div className="space-y-1">
{recentChronicleEntries.map((entry, index) => (
<div
key={
entry.id ||
`chronicle-${entry.title}-${index}`
}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm font-medium text-white">
{entry.title}
</div>
<div className="mt-1 text-xs text-zinc-400">
{entry.summary}
</div>
</div>
))}
</div>
)}
{recentCarrierEchoes.length > 0 && (
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-amber-100/85">
{recentCarrierEchoes.join('')}
</div>
)}
{sceneResidues.length > 0 && (
<div className="space-y-1">
{sceneResidues.map((residue, index) => (
<div
key={
residue.id ||
`residue-${residue.title}-${index}`
}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">
{residue.title}
</span>
{''}
{residue.visibleClue}
</div>
))}
</div>
)}
</div>
</Section>
)}
<Section title="属性">
<div className="space-y-4">
{selection.kind === 'player' ? (
<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>
) : null}
<div className="space-y-3">
<StatusRow
label={resourceLabels.hp}
current={hp}
max={maxHp}
tone="hp"
/>
{maxMana > 0 ? (
<StatusRow
label={resourceLabels.mp}
current={mana}
max={maxMana}
tone="mp"
/>
) : null}
</div>
{buildBreakdown ? (
<MultiplierContributionList
breakdown={buildBreakdown}
onSelectContribution={(row) =>
setSelectedContributionLabel(row.label)
}
/>
) : null}
<CharacterAttributeGrid
attributeProfile={selectedAttributeProfile}
attributeSchema={attributeSchema}
buildBreakdown={buildBreakdown}
resourceLabels={resourceLabels}
gridClassName="grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2"
cardClassName="rounded-xl border border-white/8 bg-black/25 px-3 py-2"
/>
</div>
</Section>
{detailCharacter ? (
<Section title="技能">
<CharacterSkillsList
skills={displayedSkills}
onSelectSkill={setSelectedSkillId}
/>
</Section>
) : displayedSkills.length > 0 ? (
<Section title="技能">
<CharacterSkillsList
skills={displayedSkills}
onSelectSkill={setSelectedSkillId}
/>
</Section>
) : null}
<Section title="物品">
{inventory.length > 0 ? (
<InventoryItemGrid
items={inventory}
selectedItemId={selectedItemId}
minimumSlotCount={8}
onSelectItem={(item) => setSelectedItemId(item.id)}
/>
) : (
<div className="text-sm text-zinc-500"></div>
)}
</Section>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
{selectedContributionRow && detailCharacter && (
<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">
</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">
{detailCharacter.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"
>
<X 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">
{' '}
{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">
</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">
</div>
)}
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
{selectedSkill ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={() => setSelectedSkillId(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(90vh,54rem)] w-full max-w-4xl 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">
{selectedSkill.name}
</div>
<div className="mt-1 text-[10px] tracking-[0.18em] text-zinc-500">
{selectedSkillOwnerName}
</div>
</div>
<button
type="button"
onClick={() => setSelectedSkillId(null)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 overflow-y-auto p-4 sm:p-5">
{detailCharacter && selectedSkillPreviewWorldType ? (
<SkillEffectPreview
mode={selectedSkillPreviewMode}
worldType={selectedSkillPreviewWorldType}
character={detailCharacter}
skill={selectedSkill}
targetMonsterId={selectedSkillPreviewMonsterId}
npcEncounter={
selectedSkillPreviewMode === 'npc' ? npcEncounter : null
}
targetCharacter={
selectedSkillPreviewMode === 'npc' ? playerCharacter : null
}
/>
) : (
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-400">
{detailCharacter
? '当前未进入具体世界,暂时无法恢复技能预览。'
: '该 NPC 当前没有独立技能演示模型,先展示真实技能数据。'}
</div>
)}
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] text-zinc-100">
{getSkillDeliveryLabel(selectedSkill)}
</span>
<span className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100">
{getSkillStyleLabel(selectedSkill)}
</span>
{selectedSkill.buildBuffs?.length ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-1 text-[10px] text-emerald-100">
{selectedSkill.buildBuffs.length}
</span>
) : null}
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300 sm:grid-cols-4">
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 font-semibold text-white">
{selectedSkill.damage}
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 font-semibold text-white">
{selectedSkill.manaCost}
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 font-semibold text-white">
{selectedSkill.cooldownTurns}
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 font-semibold text-white">
{selectedSkill.range}
</div>
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{selectedSkill.name} {getSkillStyleLabel(selectedSkill)}
线{getSkillDeliveryLabel(selectedSkill)}
{selectedSkill.damage} {' '}
{selectedSkill.manaCost} {' '}
{selectedSkill.cooldownTurns}
{selectedSkill.effects?.length
? ` 该技能还会触发 ${selectedSkill.effects.length} 段战斗特效。`
: ''}
</div>
{selectedSkill.buildBuffs?.length ? (
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-3 flex flex-wrap gap-2">
{selectedSkill.buildBuffs.map((buff) => (
<span
key={buff.id}
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
>
{buff.name} / {buff.tags.join('、')} /{' '}
{buff.durationTurns}
</span>
))}
</div>
</div>
) : null}
</div>
</motion.div>
</motion.div>
) : null}
{(gameState.playerCharacter ?? detailCharacter) ? (
<InventoryItemDetailModal
item={selectedInventoryItem}
playerCharacter={gameState.playerCharacter ?? detailCharacter!}
worldType={gameState.worldType}
ownerLabel={detailCharacter?.name ?? title}
onClose={() => setSelectedItemId(null)}
/>
) : null}
</AnimatePresence>
);
}