071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
433 lines
17 KiB
TypeScript
433 lines
17 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>
|
|
);
|
|
}
|