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 (
);
}
function SpriteFramePreview({
src,
frameIndex = 0,
tileSize = 32,
scale = 1,
}: {
src: string;
frameIndex?: number;
tileSize?: number;
scale?: number;
}) {
return (
);
}
function AtlasFramePreview({
type,
file,
frameIndex,
}: {
type: MedievalAtlasSourceType;
file: string;
frameIndex: number;
}) {
const asset = getMedievalAtlasAsset(type, file);
if (!asset) {
return 无
;
}
const col = frameIndex % asset.columns;
const row = Math.floor(frameIndex / asset.columns);
return (
32 || asset.tileHeight > 32 ? 'scale(0.75)' : undefined,
transformOrigin: 'center',
}}
/>
);
}
function EmptyPreview({ label }: { label: string }) {
return (
{label}
);
}
function PortraitOptionPreview({
npc,
visual,
}: {
npc: EditableNpcSource;
visual: CustomWorldNpcVisual;
}) {
return (
);
}
function OptionCard({
label,
selected,
onClick,
preview,
}: {
key?: string;
label: string;
selected: boolean;
onClick: () => void;
preview: ReactNode;
}) {
return (
);
}
function OptionSection({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: ReactNode;
}) {
return (
{title}
{subtitle ?
{subtitle}
: null}
{children}
);
}
function ActionButton({
label,
onClick,
tone = 'default',
}: {
label: string;
onClick: () => void;
tone?: 'default' | 'sky';
}) {
return (
);
}
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 (
{preferredImageSrc ? (
) : monsterPreset ? (
) : (
)}
);
}
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
) => (
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 (
{getGearSummary(effectiveVisual)}
onChange(buildDefaultCustomWorldNpcVisual(npc))} />
{(Object.entries(MEDIEVAL_RACE_LABELS) as Array<[MedievalRace, string]>).map(([race, label]) => {
const previewVisual = buildPatchedVisual({ race });
return (
updateVisual(previewVisual)}
preview={}
/>
);
})}
{MEDIEVAL_BODY_COLORS.map(color => (
updateVisual(buildPatchedVisual({ bodyColor: color }))}
preview={(
)}
/>
))}
{headOptions.map(option => (
updateVisual(buildPatchedVisual({ headIndex: option.value }))}
preview={(
)}
/>
))}
{MEDIEVAL_HAIR_STYLE_LABELS.map((label, index) => (
updateVisual(buildPatchedVisual({ hairStyleFrame: index }))}
preview={(
)}
/>
))}
{Array.from({ length: spriteCounts.hair }, (_, index) => {
const value = index + 1;
return (
updateVisual(buildPatchedVisual({ hairColorIndex: value }))}
preview={(
)}
/>
);
})}
updateVisual(buildPatchedVisual({ facialHairEnabled: false, facialHairStyleFrame: 0 }))}
preview={(
)}
/>
{MEDIEVAL_FACIAL_HAIR_STYLE_LABELS.map((label, index) => (
updateVisual(buildPatchedVisual({ facialHairEnabled: true, facialHairStyleFrame: index }))}
preview={(
)}
/>
))}
{effectiveVisual.facialHairEnabled ? (
{Array.from({ length: spriteCounts.facialHair }, (_, index) => {
const value = index + 1;
return (
updateVisual(buildPatchedVisual({ facialHairColorIndex: value }))}
preview={(
)}
/>
);
})}
) : null}
updateGearType('headgear', 'none')}
preview={(
)}
/>
{([
['cloth', '布帽'],
['leather', '皮具'],
['metal', '金属头盔'],
] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => {
const gear = buildDefaultGear(type, 'headgear');
return (
updateGearType('headgear', type)}
preview={(
{gear ? : }
)}
/>
);
})}
{effectiveVisual.headgear ? (
<>
{headgearAssets.map(asset => (
updateGearFile('headgear', asset.file)}
preview={(
)}
/>
))}
{headgearPoseOptions.map(option => (
updateGearFrame('headgear', option.value)}
preview={(
)}
/>
))}
>
) : null}
updateGearType('mainHand', 'none')}
preview={(
)}
/>
{([
['melee', '近战武器'],
['magic', '法器'],
['ranged', '远程武器'],
] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => {
const gear = buildDefaultGear(type, 'mainHand');
return (
updateGearType('mainHand', type)}
preview={(
{gear ? : }
)}
/>
);
})}
{effectiveVisual.mainHand ? (
<>
{mainHandAssets.map(asset => (
updateGearFile('mainHand', asset.file)}
preview={(
)}
/>
))}
{mainHandPoseOptions.map(option => (
updateGearFrame('mainHand', option.value)}
preview={(
)}
/>
))}
>
) : null}
updateGearType('offHand', 'none')}
preview={(
)}
/>
{(() => {
const gear = buildDefaultGear('melee', 'offHand');
return (
updateGearType('offHand', 'melee')}
preview={(
{gear ? : }
)}
/>
);
})()}
{effectiveVisual.offHand ? (
<>
{offHandAssets.map(asset => (
updateGearFile('offHand', asset.file)}
preview={(
)}
/>
))}
{offHandPoseOptions.map(option => (
updateGearFrame('offHand', option.value)}
preview={(
)}
/>
))}
>
) : null}
);
}