Files
Genarrative/src/components/CustomWorldNpcVisualEditor.tsx

832 lines
28 KiB
TypeScript

import type { ReactNode } from 'react';
import { resolveCustomWorldNpcMonsterPreset } from '../data/customWorldNpcMonsters';
import {
buildBodyPath,
buildMedievalNpcVisual,
buildMedievalNpcVisualOverrideFromCustomWorldVisual,
buildRaceAssetPath,
getMedievalAtlasAsset,
getMedievalAtlasOptions,
getMedievalHeadOptions,
getMedievalPoseOptions,
getRaceSpriteCounts,
MEDIEVAL_BODY_COLOR_LABELS,
MEDIEVAL_BODY_COLORS,
MEDIEVAL_FACIAL_HAIR_COLOR_LABELS,
MEDIEVAL_FACIAL_HAIR_STYLE_LABELS,
MEDIEVAL_HAIR_COLOR_LABELS,
MEDIEVAL_HAIR_STYLE_LABELS,
MEDIEVAL_RACE_LABELS,
type MedievalAtlasSourceType,
type MedievalAtlasUsage,
type MedievalRace,
sanitizeCustomWorldNpcVisual,
} from '../data/medievalNpcVisuals';
import {
type CustomWorldNpc,
type CustomWorldNpcVisual,
type CustomWorldProfile,
} from '../types';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import { HostileNpcAnimator } from './HostileNpcAnimator';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
import { ResolvedAssetImage } from './ResolvedAssetImage';
type EditableNpcSource = Pick<
CustomWorldNpc,
'id' | 'name' | 'role' | 'description' | 'imageSrc'
>
& Partial<
Pick<
CustomWorldNpc,
| 'title'
| 'backstory'
| 'personality'
| 'motivation'
| 'combatStyle'
| 'initialAffinity'
| 'relationshipHooks'
| 'tags'
>
>;
type GearSlot = 'headgear' | 'mainHand' | 'offHand';
function buildCustomWorldNpcEncounter(npc: EditableNpcSource) {
return {
id: npc.id,
kind: 'npc' as const,
npcName: npc.name,
npcDescription: npc.description,
npcAvatar: npc.name.slice(0, 1) || '角',
context: npc.role,
};
}
function buildPreviewSpec(npc: EditableNpcSource, visual?: CustomWorldNpcVisual) {
const encounter = buildCustomWorldNpcEncounter(npc);
const baseSpec = buildMedievalNpcVisual(encounter);
if (!visual) {
return baseSpec;
}
return {
...baseSpec,
...buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual),
};
}
function getGearUsage(slot: GearSlot): MedievalAtlasUsage {
if (slot === 'headgear') return 'headgear';
if (slot === 'mainHand') return 'mainHand';
return 'offHand';
}
function getDefaultFileForType(type: MedievalAtlasSourceType, usage: MedievalAtlasUsage) {
const assets = getMedievalAtlasOptions(type);
if (usage === 'offHand' && type === 'melee') {
return assets.find(asset => asset.file === 'shield.png')?.file ?? assets[0]?.file ?? '';
}
return assets[0]?.file ?? '';
}
function getDefaultFrameForSelection(type: MedievalAtlasSourceType, file: string, usage: MedievalAtlasUsage) {
return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0;
}
function buildDefaultGear(type: MedievalAtlasSourceType, usage: MedievalAtlasUsage) {
const file = getDefaultFileForType(type, usage);
if (!file) return null;
return {
type,
file,
frameIndex: getDefaultFrameForSelection(type, file, usage),
};
}
function getGearSummary(visual: CustomWorldNpcVisual) {
return [
visual.headgear ? getMedievalAtlasAsset(visual.headgear.type, visual.headgear.file)?.label ?? '头饰' : '无头饰',
visual.mainHand ? getMedievalAtlasAsset(visual.mainHand.type, visual.mainHand.file)?.label ?? '主手' : '无主手',
visual.offHand ? getMedievalAtlasAsset(visual.offHand.type, visual.offHand.file)?.label ?? '副手' : '无副手',
].join(' / ');
}
function PreviewFrame({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_52%),linear-gradient(180deg,rgba(19,24,39,0.94),rgba(8,10,17,0.92))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:10px_10px]" />
<div className="relative z-[1] flex items-center justify-center">
{children}
</div>
</div>
);
}
function SpriteFramePreview({
src,
frameIndex = 0,
tileSize = 32,
scale = 1,
}: {
src: string;
frameIndex?: number;
tileSize?: number;
scale?: number;
}) {
return (
<div
style={{
width: `${tileSize}px`,
height: `${tileSize}px`,
backgroundImage: `url("${encodeURI(src)}")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: `-${frameIndex * tileSize}px 0px`,
imageRendering: 'pixelated',
transform: `scale(${scale})`,
transformOrigin: 'center',
}}
/>
);
}
function AtlasFramePreview({
type,
file,
frameIndex,
}: {
type: MedievalAtlasSourceType;
file: string;
frameIndex: number;
}) {
const asset = getMedievalAtlasAsset(type, file);
if (!asset) {
return <div className="text-[10px] font-semibold text-zinc-500"></div>;
}
const col = frameIndex % asset.columns;
const row = Math.floor(frameIndex / asset.columns);
return (
<div
style={{
width: `${asset.tileWidth}px`,
height: `${asset.tileHeight}px`,
backgroundImage: `url("${encodeURI(asset.src)}")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: `-${col * asset.tileWidth}px -${row * asset.tileHeight}px`,
backgroundSize: 'auto',
imageRendering: 'pixelated',
transform: asset.tileWidth > 32 || asset.tileHeight > 32 ? 'scale(0.75)' : undefined,
transformOrigin: 'center',
}}
/>
);
}
function EmptyPreview({ label }: { label: string }) {
return (
<div className="text-[10px] font-semibold tracking-[0.08em] text-zinc-500">
{label}
</div>
);
}
function PortraitOptionPreview({
npc,
visual,
}: {
npc: EditableNpcSource;
visual: CustomWorldNpcVisual;
}) {
return (
<PreviewFrame className="h-14 w-14">
<MedievalNpcAnimator
visualSpec={buildPreviewSpec(npc, visual)}
scale={1.1}
className="origin-center"
/>
</PreviewFrame>
);
}
function OptionCard({
label,
selected,
onClick,
preview,
}: {
key?: string;
label: string;
selected: boolean;
onClick: () => void;
preview: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
selected
? 'border-sky-300/45 bg-sky-500/12 text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:border-white/20 hover:text-white'
}`}
>
<div className="flex items-center gap-3">
{preview}
<div className="min-w-0">
<div className="text-sm font-semibold leading-5">{label}</div>
</div>
</div>
</button>
);
}
function OptionSection({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: ReactNode;
}) {
return (
<section className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-4">
<div>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-200">{title}</div>
{subtitle ? <div className="mt-1 text-xs leading-5 text-zinc-500">{subtitle}</div> : null}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{children}
</div>
</section>
);
}
function ActionButton({
label,
onClick,
tone = 'default',
}: {
label: string;
onClick: () => void;
tone?: 'default' | 'sky';
}) {
return (
<button
type="button"
onPointerDown={(event) => {
event.stopPropagation();
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
onClick={onClick}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white'
}`}
>
{label}
</button>
);
}
export function CustomWorldNpcPortrait({
npc,
profile,
visual,
className = '',
contentClassName = 'min-h-[7rem] p-3',
scale = 2.05,
preferImageSrc = false,
}: {
npc: EditableNpcSource;
profile?: CustomWorldProfile | null;
visual?: CustomWorldNpcVisual;
className?: string;
contentClassName?: string;
scale?: number;
preferImageSrc?: boolean;
}) {
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
const monsterPreset = visual
? null
: resolveCustomWorldNpcMonsterPreset(npc, undefined, profile ?? null);
const preferredImageSrc =
preferImageSrc && npc.imageSrc?.trim() ? npc.imageSrc.trim() : '';
return (
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
<div
className={`relative flex h-full items-center justify-center ${contentClassName}`}
>
{preferredImageSrc ? (
<ResolvedAssetImage
src={preferredImageSrc}
alt={npc.name}
className="h-full w-full object-contain drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
/>
) : monsterPreset ? (
<div
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
style={{
transform: `scale(${Math.max(1, scale * 0.72)})`,
transformOrigin: 'center',
}}
>
<HostileNpcAnimator hostileNpc={monsterPreset} />
</div>
) : (
<MedievalNpcAnimator
visualSpec={previewSpec}
scale={scale}
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
/>
)}
</div>
</div>
);
}
export function CustomWorldNpcVisualEditor({
npc,
profile,
value,
onChange,
onAiGenerate,
}: {
npc: EditableNpcSource;
profile?: CustomWorldProfile | null;
value?: CustomWorldNpcVisual;
onChange: (value: CustomWorldNpcVisual) => void;
onAiGenerate: () => void;
}) {
const effectiveVisual = sanitizeCustomWorldNpcVisual(value ?? buildDefaultCustomWorldNpcVisual(npc));
const spriteCounts = getRaceSpriteCounts(effectiveVisual.race);
const headOptions = getMedievalHeadOptions(effectiveVisual.race);
const headgearAssets = effectiveVisual.headgear ? getMedievalAtlasOptions(effectiveVisual.headgear.type) : [];
const mainHandAssets = effectiveVisual.mainHand ? getMedievalAtlasOptions(effectiveVisual.mainHand.type) : [];
const offHandAssets = effectiveVisual.offHand ? getMedievalAtlasOptions(effectiveVisual.offHand.type) : [];
const headgearPoseOptions = effectiveVisual.headgear ? getMedievalPoseOptions(effectiveVisual.headgear.type, effectiveVisual.headgear.file, 'headgear') : [];
const mainHandPoseOptions = effectiveVisual.mainHand ? getMedievalPoseOptions(effectiveVisual.mainHand.type, effectiveVisual.mainHand.file, 'mainHand') : [];
const offHandPoseOptions = effectiveVisual.offHand ? getMedievalPoseOptions(effectiveVisual.offHand.type, effectiveVisual.offHand.file, 'offHand') : [];
const updateVisual = (nextVisual: CustomWorldNpcVisual) => {
onChange(sanitizeCustomWorldNpcVisual(nextVisual));
};
const buildPatchedVisual = (patch: Partial<CustomWorldNpcVisual>) => (
sanitizeCustomWorldNpcVisual({
...effectiveVisual,
...patch,
})
);
const updateGearType = (slot: GearSlot, nextType: MedievalAtlasSourceType | 'none') => {
if (nextType === 'none') {
updateVisual({
...effectiveVisual,
[slot]: null,
});
return;
}
updateVisual({
...effectiveVisual,
[slot]: buildDefaultGear(nextType, getGearUsage(slot)),
});
};
const updateGearFile = (slot: GearSlot, nextFile: string) => {
const currentGear = effectiveVisual[slot];
if (!currentGear) return;
updateVisual({
...effectiveVisual,
[slot]: {
...currentGear,
file: nextFile,
frameIndex: getDefaultFrameForSelection(currentGear.type, nextFile, getGearUsage(slot)),
},
});
};
const updateGearFrame = (slot: GearSlot, nextFrameIndex: number) => {
const currentGear = effectiveVisual[slot];
if (!currentGear) return;
updateVisual({
...effectiveVisual,
[slot]: {
...currentGear,
frameIndex: nextFrameIndex,
},
});
};
return (
<div className="grid gap-5 lg:grid-cols-[12rem_minmax(0,1fr)]">
<div className="self-start lg:sticky lg:top-0">
<div className="mx-auto w-full max-w-[9.5rem] space-y-3">
<CustomWorldNpcPortrait
npc={npc}
profile={profile}
visual={effectiveVisual}
className="aspect-square"
scale={2.05}
/>
<div className="rounded-2xl border border-white/10 bg-black/25 px-3 py-3 text-center text-xs leading-5 text-zinc-300">
{getGearSummary(effectiveVisual)}
</div>
<div className="flex flex-col gap-2">
<ActionButton label="恢复默认组合" onClick={() => onChange(buildDefaultCustomWorldNpcVisual(npc))} />
<ActionButton label="智能生成" onClick={onAiGenerate} tone="sky" />
</div>
</div>
</div>
<div className="space-y-5">
<OptionSection title="种族" subtitle="切换基础种族,并预览对应的整体轮廓。">
{(Object.entries(MEDIEVAL_RACE_LABELS) as Array<[MedievalRace, string]>).map(([race, label]) => {
const previewVisual = buildPatchedVisual({ race });
return (
<OptionCard
key={`race-${race}`}
label={label}
selected={effectiveVisual.race === race}
onClick={() => updateVisual(previewVisual)}
preview={<PortraitOptionPreview npc={npc} visual={previewVisual} />}
/>
);
})}
</OptionSection>
<OptionSection title="服装颜色" subtitle="预览身体部位素材。">
{MEDIEVAL_BODY_COLORS.map(color => (
<OptionCard
key={`body-${color}`}
label={MEDIEVAL_BODY_COLOR_LABELS[color] ?? color}
selected={effectiveVisual.bodyColor === color}
onClick={() => updateVisual(buildPatchedVisual({ bodyColor: color }))}
preview={(
<PreviewFrame>
<SpriteFramePreview src={buildBodyPath(color)} frameIndex={0} />
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="肤色" subtitle="预览头部部位素材。">
{headOptions.map(option => (
<OptionCard
key={`head-${option.value}`}
label={option.label}
selected={effectiveVisual.headIndex === option.value}
onClick={() => updateVisual(buildPatchedVisual({ headIndex: option.value }))}
preview={(
<PreviewFrame>
<SpriteFramePreview src={buildRaceAssetPath(effectiveVisual.race, 'head', option.value)} frameIndex={0} />
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="发型" subtitle="文字和发型部位预览同步显示。">
{MEDIEVAL_HAIR_STYLE_LABELS.map((label, index) => (
<OptionCard
key={`hair-style-${index}`}
label={label}
selected={effectiveVisual.hairStyleFrame === index}
onClick={() => updateVisual(buildPatchedVisual({ hairStyleFrame: index }))}
preview={(
<PreviewFrame>
<SpriteFramePreview
src={buildRaceAssetPath(effectiveVisual.race, 'hair', effectiveVisual.hairColorIndex)}
frameIndex={index}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="发色" subtitle="基于当前发型预览不同发色。">
{Array.from({ length: spriteCounts.hair }, (_, index) => {
const value = index + 1;
return (
<OptionCard
key={`hair-color-${value}`}
label={MEDIEVAL_HAIR_COLOR_LABELS[index] ?? `发色 ${value}`}
selected={effectiveVisual.hairColorIndex === value}
onClick={() => updateVisual(buildPatchedVisual({ hairColorIndex: value }))}
preview={(
<PreviewFrame>
<SpriteFramePreview
src={buildRaceAssetPath(effectiveVisual.race, 'hair', value)}
frameIndex={effectiveVisual.hairStyleFrame}
/>
</PreviewFrame>
)}
/>
);
})}
</OptionSection>
<OptionSection title="胡须样式" subtitle="可直接切换为不显示,也可预览每种胡须部位。">
<OptionCard
label="不显示"
selected={!effectiveVisual.facialHairEnabled}
onClick={() => updateVisual(buildPatchedVisual({ facialHairEnabled: false, facialHairStyleFrame: 0 }))}
preview={(
<PreviewFrame>
<EmptyPreview label="无" />
</PreviewFrame>
)}
/>
{MEDIEVAL_FACIAL_HAIR_STYLE_LABELS.map((label, index) => (
<OptionCard
key={`facial-style-${index}`}
label={label}
selected={effectiveVisual.facialHairEnabled && effectiveVisual.facialHairStyleFrame === index}
onClick={() => updateVisual(buildPatchedVisual({ facialHairEnabled: true, facialHairStyleFrame: index }))}
preview={(
<PreviewFrame>
<SpriteFramePreview
src={buildRaceAssetPath(effectiveVisual.race, 'facialHair', effectiveVisual.facialHairColorIndex)}
frameIndex={index}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
{effectiveVisual.facialHairEnabled ? (
<OptionSection title="胡须颜色" subtitle="预览当前胡须样式下的颜色变化。">
{Array.from({ length: spriteCounts.facialHair }, (_, index) => {
const value = index + 1;
return (
<OptionCard
key={`facial-color-${value}`}
label={MEDIEVAL_FACIAL_HAIR_COLOR_LABELS[index] ?? `胡须颜色 ${value}`}
selected={effectiveVisual.facialHairColorIndex === value}
onClick={() => updateVisual(buildPatchedVisual({ facialHairColorIndex: value }))}
preview={(
<PreviewFrame>
<SpriteFramePreview
src={buildRaceAssetPath(effectiveVisual.race, 'facialHair', value)}
frameIndex={effectiveVisual.facialHairStyleFrame}
/>
</PreviewFrame>
)}
/>
);
})}
</OptionSection>
) : null}
<OptionSection title="头饰类型" subtitle="先选装备类型,再挑具体素材和姿态。">
<OptionCard
label="不装备"
selected={!effectiveVisual.headgear}
onClick={() => updateGearType('headgear', 'none')}
preview={(
<PreviewFrame>
<EmptyPreview label="无" />
</PreviewFrame>
)}
/>
{([
['cloth', '布帽'],
['leather', '皮具'],
['metal', '金属头盔'],
] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => {
const gear = buildDefaultGear(type, 'headgear');
return (
<OptionCard
key={`headgear-type-${type}`}
label={label}
selected={effectiveVisual.headgear?.type === type}
onClick={() => updateGearType('headgear', type)}
preview={(
<PreviewFrame>
{gear ? <AtlasFramePreview type={gear.type} file={gear.file} frameIndex={gear.frameIndex} /> : <EmptyPreview label="无" />}
</PreviewFrame>
)}
/>
);
})}
</OptionSection>
{effectiveVisual.headgear ? (
<>
<OptionSection title="头饰素材" subtitle="素材卡片同时展示名称和头饰部位预览。">
{headgearAssets.map(asset => (
<OptionCard
key={`headgear-file-${asset.file}`}
label={asset.label}
selected={effectiveVisual.headgear?.file === asset.file}
onClick={() => updateGearFile('headgear', asset.file)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.headgear!.type}
file={asset.file}
frameIndex={getDefaultFrameForSelection(effectiveVisual.headgear!.type, asset.file, 'headgear')}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="头饰姿态" subtitle="预览当前头饰素材在不同姿态下的部位变化。">
{headgearPoseOptions.map(option => (
<OptionCard
key={`headgear-pose-${option.value}`}
label={option.label}
selected={effectiveVisual.headgear?.frameIndex === option.value}
onClick={() => updateGearFrame('headgear', option.value)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.headgear!.type}
file={effectiveVisual.headgear!.file}
frameIndex={option.value}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
</>
) : null}
<OptionSection title="主手类型" subtitle="预览不同主手武器类型。">
<OptionCard
label="不装备"
selected={!effectiveVisual.mainHand}
onClick={() => updateGearType('mainHand', 'none')}
preview={(
<PreviewFrame>
<EmptyPreview label="无" />
</PreviewFrame>
)}
/>
{([
['melee', '近战武器'],
['magic', '法器'],
['ranged', '远程武器'],
] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => {
const gear = buildDefaultGear(type, 'mainHand');
return (
<OptionCard
key={`main-hand-type-${type}`}
label={label}
selected={effectiveVisual.mainHand?.type === type}
onClick={() => updateGearType('mainHand', type)}
preview={(
<PreviewFrame>
{gear ? <AtlasFramePreview type={gear.type} file={gear.file} frameIndex={gear.frameIndex} /> : <EmptyPreview label="无" />}
</PreviewFrame>
)}
/>
);
})}
</OptionSection>
{effectiveVisual.mainHand ? (
<>
<OptionSection title="主手素材" subtitle="用当前武器姿态预览每个素材。">
{mainHandAssets.map(asset => (
<OptionCard
key={`main-hand-file-${asset.file}`}
label={asset.label}
selected={effectiveVisual.mainHand?.file === asset.file}
onClick={() => updateGearFile('mainHand', asset.file)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.mainHand!.type}
file={asset.file}
frameIndex={getDefaultFrameForSelection(effectiveVisual.mainHand!.type, asset.file, 'mainHand')}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="主手姿态" subtitle="预览当前主手素材在不同姿态下的部位。">
{mainHandPoseOptions.map(option => (
<OptionCard
key={`main-hand-pose-${option.value}`}
label={option.label}
selected={effectiveVisual.mainHand?.frameIndex === option.value}
onClick={() => updateGearFrame('mainHand', option.value)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.mainHand!.type}
file={effectiveVisual.mainHand!.file}
frameIndex={option.value}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
</>
) : null}
<OptionSection title="副手类型" subtitle="可选择不装备,或为副手配置盾牌 / 近战部件。">
<OptionCard
label="不装备"
selected={!effectiveVisual.offHand}
onClick={() => updateGearType('offHand', 'none')}
preview={(
<PreviewFrame>
<EmptyPreview label="无" />
</PreviewFrame>
)}
/>
{(() => {
const gear = buildDefaultGear('melee', 'offHand');
return (
<OptionCard
label="盾牌 / 近战副手"
selected={effectiveVisual.offHand?.type === 'melee'}
onClick={() => updateGearType('offHand', 'melee')}
preview={(
<PreviewFrame>
{gear ? <AtlasFramePreview type={gear.type} file={gear.file} frameIndex={gear.frameIndex} /> : <EmptyPreview label="无" />}
</PreviewFrame>
)}
/>
);
})()}
</OptionSection>
{effectiveVisual.offHand ? (
<>
<OptionSection title="副手素材" subtitle="素材卡片展示副手部件预览。">
{offHandAssets.map(asset => (
<OptionCard
key={`off-hand-file-${asset.file}`}
label={asset.label}
selected={effectiveVisual.offHand?.file === asset.file}
onClick={() => updateGearFile('offHand', asset.file)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.offHand!.type}
file={asset.file}
frameIndex={getDefaultFrameForSelection(effectiveVisual.offHand!.type, asset.file, 'offHand')}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="副手姿态" subtitle="预览当前副手素材在不同姿态下的部位。">
{offHandPoseOptions.map(option => (
<OptionCard
key={`off-hand-pose-${option.value}`}
label={option.label}
selected={effectiveVisual.offHand?.frameIndex === option.value}
onClick={() => updateGearFrame('offHand', option.value)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.offHand!.type}
file={effectiveVisual.offHand!.file}
frameIndex={option.value}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
</>
) : null}
</div>
</div>
);
}