Files
Genarrative/src/components/CompanionCampModal.tsx
T
kdletters fe951a8819 继续扩展暗色弹窗底部框架
扩展 PlatformDarkModalFooter 接入营地编组的内容型 footer
补充 CompanionCampModal 对共享 footer frame 的回归测试
更新 PlatformUiKit 收口计划与共享决策记录
2026-06-11 03:34:45 +08:00

358 lines
15 KiB
TypeScript

import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { getCharacterById } from '../data/characterPresets';
import { MAX_COMPANIONS } from '../data/npcInteractions';
import { Character, CompanionState } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDarkModalFooter } from './common/PlatformDarkModalFooter';
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformMediaFrame } from './common/PlatformMediaFrame';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PixelCloseButton } from './PixelCloseButton';
interface CompanionCampModalProps {
isOpen: boolean;
playerCharacter: Character | null;
companions: CompanionState[];
roster: CompanionState[];
inBattle: boolean;
onClose: () => void;
onBenchCompanion: (npcId: string) => void;
onActivateCompanion: (npcId: string, swapNpcId?: string | null) => void;
}
type CompanionCardData = {
companion: CompanionState;
character: Character;
};
function StatusPill({ label, value }: { label: string; value: string }) {
return (
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="font-normal text-zinc-300"
>
{label} {value}
</PlatformPillBadge>
);
}
function buildCampMoments(
playerCharacter: Character | null,
activeCompanions: CompanionCardData[],
reserveCompanions: CompanionCardData[],
) {
if (!playerCharacter) {
return ['营地尚未准备完毕。'];
}
const moments: string[] = [];
if (activeCompanions.length === 0 && reserveCompanions.length === 0) {
moments.push(`${playerCharacter.name}独自坐在营火旁,暂时还没有固定同行者。`);
}
if (activeCompanions.length >= 2) {
const firstCompanion = activeCompanions[0];
const secondCompanion = activeCompanions[1];
if (firstCompanion && secondCompanion) {
moments.push(`${firstCompanion.character.name}${secondCompanion.character.name}正低声商量下一段路怎么走。`);
}
}
const trustedCompanion = activeCompanions.find(item => item.companion.joinedAtAffinity >= 70);
if (trustedCompanion) {
moments.push(`${trustedCompanion.character.name}熟练地清点补给,看起来已经像能交托后背的同伴了。`);
}
if (reserveCompanions.length > 0) {
const reserveCompanion = reserveCompanions[0];
if (reserveCompanion) {
moments.push(`${reserveCompanion.character.name}正在营地里待命,随时都能重新归队。`);
}
}
if (moments.length === 0) {
moments.push(`${playerCharacter.name}环视营地,确认众人都已经各就各位。`);
}
return moments.slice(0, 3);
}
export function CompanionCampModal({
isOpen,
playerCharacter,
companions,
roster,
inBattle,
onClose,
onBenchCompanion,
onActivateCompanion,
}: CompanionCampModalProps) {
const [selectedSwapNpcId, setSelectedSwapNpcId] = useState<string | null>(null);
const activeCompanionCards = useMemo<CompanionCardData[]>(
() => companions
.map(companion => {
const character = getCharacterById(companion.characterId);
return character ? { companion, character } : null;
})
.filter(Boolean) as CompanionCardData[],
[companions],
);
const reserveCompanionCards = useMemo<CompanionCardData[]>(
() => roster
.map(companion => {
const character = getCharacterById(companion.characterId);
return character ? { companion, character } : null;
})
.filter(Boolean) as CompanionCardData[],
[roster],
);
const campMoments = useMemo(
() => buildCampMoments(playerCharacter, activeCompanionCards, reserveCompanionCards),
[activeCompanionCards, playerCharacter, reserveCompanionCards],
);
useEffect(() => {
if (!isOpen) return;
if (companions.length >= MAX_COMPANIONS) {
setSelectedSwapNpcId(companions[0]?.npcId ?? null);
return;
}
setSelectedSwapNpcId(null);
}, [companions, isOpen]);
return (
<AnimatePresence>
{isOpen && (
<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,58rem)] w-full max-w-5xl 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-sm font-semibold text-white">营地编组</div>
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
</div>
</div>
<PixelCloseButton onClick={onClose} label="关闭营地编组" />
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden">
<PlatformSubpanel
as="section"
surface="dark"
radius="sm"
padding="md"
className="lg:min-h-0 lg:overflow-y-auto"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-bold text-white">当前队伍</div>
<div className="mt-1 text-xs text-zinc-400">
可直接把同行者转入后备,或先选定替换位,再让后备成员归队。
</div>
</div>
<StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} />
</div>
{inBattle && (
<PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="mb-3"
>
战斗中无法调整编组。
</PlatformStatusMessage>
)}
<div className="space-y-3">
{activeCompanionCards.length > 0 ? activeCompanionCards.map(({ companion, character }) => {
const selectedForSwap = selectedSwapNpcId === companion.npcId;
return (
<PlatformSubpanel
as="div"
key={companion.npcId}
data-testid={`active-companion-card-${companion.npcId}`}
surface={selectedForSwap ? 'darkSky' : 'dark'}
radius="xs"
padding="md"
>
<div className="flex items-center gap-3">
<PlatformMediaFrame
src={character.portrait}
alt={character.name}
fallbackLabel={character.name}
aspect="square"
surface="editorDark"
className="h-16 w-16 shrink-0 rounded-xl"
imageClassName="h-full w-full scale-125 object-contain"
imageProps={{ style: { imageRendering: 'pixelated' } }}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<PlatformDarkOptionCard
disabled={inBattle}
onClick={() => setSelectedSwapNpcId(companion.npcId)}
selected={selectedForSwap}
tone="sky"
radius="sm"
padding="sm"
className="text-xs"
>
设为替换位
</PlatformDarkOptionCard>
<PlatformActionButton
surface="editorDark"
tone="secondary"
size="xs"
disabled={inBattle}
onClick={() => onBenchCompanion(companion.npcId)}
>
转入后备
</PlatformActionButton>
</div>
</PlatformSubpanel>
);
}) : (
<PlatformEmptyState
surface="editorDark"
size="inline"
className="rounded-xl py-6 font-normal text-zinc-400"
>
当前没有已出战的同行者。
</PlatformEmptyState>
)}
</div>
</PlatformSubpanel>
<PlatformSubpanel
as="section"
surface="dark"
radius="sm"
padding="md"
className="lg:min-h-0 lg:overflow-y-auto"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-bold text-white">后备队伍</div>
<div className="mt-1 text-xs text-zinc-400">
后备同行者会在营地待命,随时可以重新召回。
</div>
</div>
<StatusPill label="后备" value={`${reserveCompanionCards.length}`} />
</div>
<div className="space-y-3">
{reserveCompanionCards.length > 0 ? reserveCompanionCards.map(({ companion, character }) => {
const needsSwap = companions.length >= MAX_COMPANIONS;
return (
<PlatformSubpanel
as="div"
key={companion.npcId}
data-testid={`reserve-companion-card-${companion.npcId}`}
surface="dark"
radius="xs"
padding="md"
>
<div className="flex items-center gap-3">
<PlatformMediaFrame
src={character.portrait}
alt={character.name}
fallbackLabel={character.name}
aspect="square"
surface="editorDark"
className="h-16 w-16 shrink-0 rounded-xl"
imageClassName="h-full w-full scale-125 object-contain"
imageProps={{ style: { imageRendering: 'pixelated' } }}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
</div>
</div>
</div>
<PlatformActionButton
surface="editorDark"
tone={inBattle || (needsSwap && !selectedSwapNpcId) ? 'ghost' : 'success'}
size="xs"
fullWidth
disabled={inBattle || (needsSwap && !selectedSwapNpcId)}
onClick={() => onActivateCompanion(companion.npcId, needsSwap ? selectedSwapNpcId : null)}
className="mt-3"
>
{needsSwap ? '换入队伍' : '编入队伍'}
</PlatformActionButton>
</PlatformSubpanel>
);
}) : (
<PlatformEmptyState
surface="editorDark"
size="inline"
className="rounded-xl py-6 font-normal text-zinc-400"
>
当前还没有后备同行者。
</PlatformEmptyState>
)}
</div>
</PlatformSubpanel>
</div>
<PlatformDarkModalFooter layout="content" padding="roomy">
<div className="mb-3 text-xs font-bold text-white">营地气氛</div>
<div className="grid gap-3 md:grid-cols-3">
{campMoments.map((moment, index) => (
<PlatformSubpanel
as="div"
key={`camp-moment-${index}-${moment}`}
surface="dark"
radius="xs"
padding="md"
className="text-sm leading-relaxed text-zinc-300"
>
{moment}
</PlatformSubpanel>
))}
</div>
</PlatformDarkModalFooter>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}