1433 lines
56 KiB
TypeScript
1433 lines
56 KiB
TypeScript
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>
|
||
);
|
||
}
|