799 lines
28 KiB
TypeScript
799 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 } from '../types';
|
|
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
|
import { HostileNpcAnimator } from './HostileNpcAnimator';
|
|
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
|
|
|
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>
|
|
& 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"
|
|
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,
|
|
visual,
|
|
className = '',
|
|
scale = 2.05,
|
|
}: {
|
|
npc: EditableNpcSource;
|
|
visual?: CustomWorldNpcVisual;
|
|
className?: string;
|
|
scale?: number;
|
|
}) {
|
|
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
|
|
const monsterPreset = visual
|
|
? null
|
|
: resolveCustomWorldNpcMonsterPreset(npc);
|
|
|
|
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 min-h-[7rem] items-center justify-center p-3">
|
|
{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,
|
|
value,
|
|
onChange,
|
|
onAiGenerate,
|
|
}: {
|
|
npc: EditableNpcSource;
|
|
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}
|
|
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>
|
|
);
|
|
}
|