Files
Genarrative/src/components/AdventureEntityModal.tsx
kdletters 06bf03a28c 继续收口平台空态与动作按钮
作品架异步状态切换复用 PlatformAsyncStatePanel
复制反馈动作外观改为组合 PlatformActionButton
结果页与调试面板空态继续收口到 PlatformEmptyState
暗色私聊与工坊按钮改为复用 PlatformActionButton
更新 PlatformUiKit 收口文档与团队决策记录
2026-06-11 01:41:15 +08:00

1547 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 { 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 {
getBuildContributionAttributeRows,
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,
type SceneHostileNpc,
} 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,
getSkillDeliveryLabel,
getSkillStyleLabel,
} from './CharacterInfoHelpers';
import {
BuildContributionDetailPanel,
CharacterAttributeGrid,
CharacterIdentityBadges,
CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress,
StatusRow,
} from './CharacterInfoShared';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformSubpanel } from './common/PlatformSubpanel';
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 { PixelCloseButton } from './PixelCloseButton';
import { ResolvedAssetImage } from './ResolvedAssetImage';
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 (
<PlatformSubpanel surface="dark" radius="sm" padding="md">
<div className="mb-3 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
{title}
</div>
{children}
</PlatformSubpanel>
);
}
function SkillMetricCard({ label, value }: { label: string; value: number }) {
return (
<PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="px-3 py-3"
>
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
{label}
</div>
<div className="mt-1 font-semibold text-white">{value}</div>
</PlatformSubpanel>
);
}
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 buildSelectionRenderKey(selection: GameCanvasEntitySelection | null) {
if (!selection) {
return 'none';
}
if (selection.kind === 'player') {
return 'player';
}
if (selection.kind === 'companion') {
return `companion-${selection.companion.npcId}`;
}
const encounter = selection.encounter;
return `npc-${
encounter.id ||
selection.battleState?.id ||
encounter.characterId ||
encounter.monsterPresetId ||
encounter.npcName
}`;
}
function buildStableRenderKey(
parts: Array<string | number | null | undefined>,
) {
return parts
.map((part, index) => {
const normalized = String(part ?? '').trim();
return normalized || `empty-${index}`;
})
.join(':');
}
function normalizeInventoryItemRenderIds(
items: InventoryItem[],
ownerKey: string,
) {
const seenIds = new Map<string, number>();
return items.map((item, index) => {
// 运行时 NPC 背包可能带空 id这里只修正展示层 key不改写原始状态。
const rawId = item.id.trim();
const baseId =
rawId ||
buildStableRenderKey([
'inventory',
ownerKey,
item.category,
item.name,
index,
]);
const repeatedCount = seenIds.get(baseId) ?? 0;
seenIds.set(baseId, repeatedCount + 1);
if (rawId && repeatedCount === 0) {
return item;
}
return {
...item,
id:
repeatedCount === 0
? baseId
: buildStableRenderKey([baseId, repeatedCount]),
};
});
}
function NpcEncounterPortrait({
encounter,
character,
hostileNpcPreset,
battleState,
}: {
encounter: Encounter;
character: Character | null;
hostileNpcPreset: ReturnType<typeof getHostileNpcPresetById> | null;
battleState: SceneHostileNpc | null;
}) {
// 详情立绘必须优先服从当前遭遇实例,否则会和画布上点击到的 NPC 形象错位。
if (encounter.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
encounter.visual,
)}
scale={2.08}
/>
);
}
if (encounter.imageSrc?.trim()) {
return (
<ResolvedAssetImage
src={encounter.imageSrc}
alt={encounter.npcName}
className="h-full w-full object-contain object-bottom"
style={{ imageRendering: 'pixelated' }}
/>
);
}
if (hostileNpcPreset) {
return (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
animation={battleState?.animation ?? 'idle'}
flip={(battleState?.facing ?? 'left') === 'right'}
/>
);
}
if (character?.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
character.visual,
)}
scale={2.08}
/>
);
}
if (character) {
return (
<CharacterAnimator
state={AnimationState.IDLE}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(character)}
/>
);
}
return (
<MedievalNpcAnimator
encounter={encounter}
scale={GENERIC_NPC_SCENE_SCALE / 3}
/>
);
}
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 selectionRenderKey = buildSelectionRenderKey(selection);
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(() => {
const rawInventory =
selection?.kind === 'player'
? gameState.playerInventory
: selection?.kind === 'companion' && companionCharacter
? buildCharacterInventoryPreviewItems(
companionCharacter,
gameState.worldType,
)
: (npcState?.inventory ?? []);
return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey);
}, [
companionCharacter,
gameState.playerInventory,
gameState.worldType,
npcState?.inventory,
selection?.kind,
selectionRenderKey,
]);
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
key={`entity-modal-${selectionRenderKey}`}
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="relative flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="min-w-0 pr-10">
<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>
<PixelCloseButton onClick={onClose} label="关闭冒险详情" />
</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,
)}
/>
)
) : npcEncounter ? (
<NpcEncounterPortrait
encounter={npcEncounter}
character={npcCharacter}
hostileNpcPreset={hostileNpcPreset}
battleState={npcBattleState}
/>
) : 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="私聊">
<PlatformSubpanel
surface="darkSky"
radius="lg"
padding="md"
as="div"
data-testid="private-chat-panel"
>
<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>
<PlatformActionButton
surface="editorDark"
tone="ghost"
disabled={
!privateChatUnlocked || !onOpenCharacterChat
}
onClick={() => {
if (
!privateChatUnlocked ||
!onOpenCharacterChat
) {
return;
}
onClose();
onOpenCharacterChat(companionChatTarget);
}}
className="rounded-xl border-sky-300/40 bg-sky-400/15 text-sky-50 hover:bg-sky-400/22 disabled:cursor-not-allowed disabled:border-white/8 disabled:bg-black/20 disabled:text-zinc-500 disabled:opacity-100"
>
{privateChatUnlocked
? '聊天'
: `聊天(${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 解锁)`}
</PlatformActionButton>
</div>
</PlatformSubpanel>
</Section>
) : null}
{(recentChronicleEntries.length > 0 ||
recentCarrierEchoes.length > 0 ||
sceneResidues.length > 0 ||
relatedConsequences.length > 0 ||
Boolean(selectedCompanionResolution)) && (
<Section title="最近回响">
<div className="space-y-3">
{selectedCompanionResolution && (
<PlatformSubpanel
surface="darkEmerald"
radius="xs"
padding="row"
as="div"
className="text-xs"
data-testid="companion-resolution-echo"
>
{selectedCompanionResolution.resolutionType} ·{' '}
{selectedCompanionResolution.summary}
</PlatformSubpanel>
)}
{relatedConsequences.length > 0 && (
<div className="space-y-1">
{relatedConsequences.map((record, index) => (
<PlatformSubpanel
key={
record.id ||
`consequence-${record.title}-${index}`
}
surface="dark"
radius="xs"
padding="row"
className="text-xs text-zinc-400"
data-testid="recent-consequence-echo"
>
<span className="text-white">
{record.title}
</span>
{''}
{record.summary}
</PlatformSubpanel>
))}
</div>
)}
{recentChronicleEntries.length > 0 && (
<div className="space-y-1">
{recentChronicleEntries.map((entry, index) => (
<PlatformSubpanel
key={
entry.id ||
`chronicle-${entry.title}-${index}`
}
surface="dark"
radius="xs"
padding="row"
data-testid="recent-chronicle-echo"
>
<div className="text-sm font-medium text-white">
{entry.title}
</div>
<div className="mt-1 text-xs text-zinc-400">
{entry.summary}
</div>
</PlatformSubpanel>
))}
</div>
)}
{recentCarrierEchoes.length > 0 && (
<PlatformSubpanel
surface="dark"
radius="xs"
padding="row"
className="text-xs text-amber-100/85"
data-testid="recent-carrier-echo"
>
{recentCarrierEchoes.join('')}
</PlatformSubpanel>
)}
{sceneResidues.length > 0 && (
<div className="space-y-1">
{sceneResidues.map((residue, index) => (
<PlatformSubpanel
key={
residue.id ||
`residue-${residue.title}-${index}`
}
surface="dark"
radius="xs"
padding="row"
className="text-xs text-zinc-400"
data-testid="recent-scene-residue-echo"
>
<span className="text-white">
{residue.title}
</span>
{''}
{residue.visibleClue}
</PlatformSubpanel>
))}
</div>
)}
</div>
</Section>
)}
<Section title="属性">
<div className="space-y-4">
{selection.kind === 'player' ? (
<PlatformSubpanel
surface="darkAmber"
radius="xs"
padding="sm"
as="div"
data-testid="player-level-panel"
>
<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
}
/>
</PlatformSubpanel>
) : 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)}
/>
) : (
<PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
>
</PlatformEmptyState>
)}
</Section>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
{selectedContributionRow && detailCharacter && (
<motion.div
key={`contribution-modal-${selectionRenderKey}-${selectedContributionRow.label}`}
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>
<PixelCloseButton
onClick={() => setSelectedContributionLabel(null)}
label="关闭标签效果"
/>
</div>
<div className="overflow-y-auto p-4 sm:p-5">
<BuildContributionDetailPanel
row={selectedContributionRow}
attributes={selectedContributionAttributes}
/>
</div>
</motion.div>
</motion.div>
)}
{selectedSkill ? (
<motion.div
key={`skill-modal-${selectionRenderKey}-${buildCharacterSkillRenderId(
selectedSkill,
displayedSkills.indexOf(selectedSkill),
)}`}
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>
<PixelCloseButton
onClick={() => setSelectedSkillId(null)}
label="关闭技能详情"
/>
</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
}
/>
) : (
<PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="px-4 py-3 text-sm text-zinc-400"
>
{detailCharacter
? '当前未进入具体世界,暂时无法恢复技能预览。'
: '该 NPC 当前没有独立技能演示模型,先展示真实技能数据。'}
</PlatformSubpanel>
)}
<div className="flex flex-wrap gap-2">
<PlatformPillBadge tone="darkSoft" size="xxs" className="px-2">
{getSkillDeliveryLabel(selectedSkill)}
</PlatformPillBadge>
<PlatformPillBadge tone="darkSky" size="xxs" className="px-2">
{getSkillStyleLabel(selectedSkill)}
</PlatformPillBadge>
{selectedSkill.buildBuffs?.length ? (
<PlatformPillBadge
tone="darkEmerald"
size="xxs"
className="px-2"
>
{selectedSkill.buildBuffs.length}
</PlatformPillBadge>
) : null}
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300 sm:grid-cols-4">
<SkillMetricCard label="伤害" value={selectedSkill.damage} />
<SkillMetricCard label="法力" value={selectedSkill.manaCost} />
<SkillMetricCard
label="冷却"
value={selectedSkill.cooldownTurns}
/>
<SkillMetricCard label="距离" value={selectedSkill.range} />
</div>
<PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="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} 段战斗特效。`
: ''}
</PlatformSubpanel>
{selectedSkill.buildBuffs?.length ? (
<PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="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, index) => (
<PlatformPillBadge
key={buildStableRenderKey([
'skill-buff',
selectedSkill.id,
buff.id,
buff.name,
index,
])}
tone="darkSky"
size="xxs"
className="px-2"
>
{buff.name} / {buff.tags.join('、')} /{' '}
{buff.durationTurns}
</PlatformPillBadge>
))}
</div>
</PlatformSubpanel>
) : 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>
);
}