import { type CustomWorldNpcVisual, type CustomWorldNpcVisualGear, Encounter } from '../types'; import { getRuntimeCustomWorldProfile } from './customWorldRuntime'; import npcVisualOverridesJson from './npcVisualOverrides.json'; export type MedievalRace = 'human' | 'elf' | 'orc' | 'goblin'; export type MedievalAtlasSourceType = 'cloth' | 'leather' | 'metal' | 'melee' | 'magic' | 'ranged'; export type MedievalAtlasUsage = 'headgear' | 'mainHand' | 'offHand'; export interface AtlasTileSpec { src: string; frameIndex: number; columns: number; tileWidth?: number; tileHeight?: number; renderOffsetX?: number; renderOffsetY?: number; } export interface MedievalNpcVisualSpec { race: MedievalRace; bodySrc: string; headSrc: string; hairSrc: string; handSrc: string; facialHairSrc?: string; headgear?: AtlasTileSpec; mainHand?: AtlasTileSpec; offHand?: AtlasTileSpec; bodyFrames: number[]; headFrame: number; hairFrame: number; handFrame: number; facialHairFrame?: number; } export type MedievalNpcVisualOverride = Partial & { race?: MedievalRace; }; export interface MedievalAtlasAssetDefinition { file: string; label: string; src: string; columns: number; frameCount: number; tileWidth: number; tileHeight: number; } export interface MedievalPoseOption { value: number; label: string; } type NpcRoleStyle = 'warrior' | 'guardian' | 'ranger' | 'mystic' | 'civilian' | 'rogue' | 'bruiser'; const BODY_COLORS = [ 'black', 'blue', 'brown', 'gold', 'green', 'grey', 'orange', 'pink', 'purple', 'red', 'silver', 'yellow', ] as const; const RACE_SPRITE_COUNTS: Record = { human: { head: 7, hair: 8, facialHair: 8 }, elf: { head: 8, hair: 8, facialHair: 8 }, orc: { head: 4, hair: 8, facialHair: 8 }, goblin: { head: 4, hair: 8, facialHair: 8 }, }; const HEAD_TONE_LABELS_BY_RACE: Record = { human: ['象牙肤', '暖米肤', '小麦肤', '日晒肤', '古铜肤', '栗棕肤', '冷棕肤'], elf: ['月白肤', '晨光肤', '青杏肤', '薄金肤', '雾灰肤', '玫瑰肤', '银青肤', '古木肤'], orc: ['浅橄榄肤', '深橄榄肤', '岩绿色', '灰褐绿肤'], goblin: ['苔绿肤', '黄绿肤', '灰绿肤', '泥褐肤'], }; const CLOTH_HAT_ASSETS = { 'hat_black.png': createAtlasAsset('cloth', 'hat_black.png', '黑布便帽', 10, 49, 32, 32), 'hat_blue.png': createAtlasAsset('cloth', 'hat_blue.png', '靛蓝便帽', 10, 49, 32, 32), 'hat_green.png': createAtlasAsset('cloth', 'hat_green.png', '苔绿便帽', 10, 49, 32, 32), 'hat_orange.png': createAtlasAsset('cloth', 'hat_orange.png', '赭橙便帽', 10, 49, 32, 32), 'hat_pink.png': createAtlasAsset('cloth', 'hat_pink.png', '胭粉便帽', 10, 49, 32, 32), 'hat_purple.png': createAtlasAsset('cloth', 'hat_purple.png', '紫布便帽', 10, 49, 32, 32), 'hat_red.png': createAtlasAsset('cloth', 'hat_red.png', '赤布便帽', 10, 49, 32, 32), 'hat_straw.png': createAtlasAsset('cloth', 'hat_straw.png', '草编宽檐帽', 5, 5, 32, 32), 'hat_yellow.png': createAtlasAsset('cloth', 'hat_yellow.png', '土黄便帽', 10, 49, 32, 32), } as const; const LEATHER_ASSETS = { 'leather01.png': createAtlasAsset('leather', 'leather01.png', '轻皮头帽', 10, 37, 32, 32), 'leather02.png': createAtlasAsset('leather', 'leather02.png', '束带皮盔', 4, 4, 32, 48), } as const; const METAL_ASSETS = { 'metal.png': createAtlasAsset('metal', 'metal.png', '铁面头盔', 10, 47, 32, 32), 'metal_black.png': createAtlasAsset('metal', 'metal_black.png', '黑钢重盔', 10, 23, 32, 48), 'metal_blue.png': createAtlasAsset('metal', 'metal_blue.png', '蓝钢重盔', 10, 23, 32, 48), 'metal_green.png': createAtlasAsset('metal', 'metal_green.png', '青钢重盔', 10, 23, 32, 48), 'metal_orange.png': createAtlasAsset('metal', 'metal_orange.png', '铜色重盔', 10, 23, 32, 48), 'metal_pink.png': createAtlasAsset('metal', 'metal_pink.png', '粉漆重盔', 10, 23, 32, 48), 'metal_purple.png': createAtlasAsset('metal', 'metal_purple.png', '紫钢重盔', 10, 23, 32, 48), 'metal_red.png': createAtlasAsset('metal', 'metal_red.png', '赤钢重盔', 10, 23, 32, 48), 'metal_yellow.png': createAtlasAsset('metal', 'metal_yellow.png', '金黄重盔', 10, 23, 32, 48), } as const; const MELEE_ASSETS = { 'axe.png': createAtlasAsset('melee', 'axe.png', '单手战斧', 10, 19, 32, 32), 'axe_big.png': createAtlasAsset('melee', 'axe_big.png', '巨刃战斧', 5, 5, 32, 48), 'blunt.png': createAtlasAsset('melee', 'blunt.png', '钉头战锤', 10, 19, 32, 32), 'dagger.png': createAtlasAsset('melee', 'dagger.png', '短匕首', 7, 14, 32, 32), 'polearm.png': createAtlasAsset('melee', 'polearm.png', '长柄武器', 12, 35, 32, 64), 'shield.png': createAtlasAsset('melee', 'shield.png', '圆盾', 10, 56, 32, 32), 'sword.png': createAtlasAsset('melee', 'sword.png', '骑士长剑', 7, 13, 32, 32), 'sword_big.png': createAtlasAsset('melee', 'sword_big.png', '阔身巨剑', 10, 20, 32, 48), } as const; const MAGIC_ASSETS = { 'staff.png': createAtlasAsset('magic', 'staff.png', '长法杖', 13, 25, 32, 64), 'wand.png': createAtlasAsset('magic', 'wand.png', '短魔杖', 6, 12, 32, 32), } as const; const RANGED_ASSETS = { 'arquebus_shot.png': createAtlasAsset('ranged', 'arquebus_shot.png', '火绳枪射击组', 4, 8, 64, 32), 'blunderbuss.png': createAtlasAsset('ranged', 'blunderbuss.png', '喇叭火枪', 5, 10, 64, 32), 'bow.png': createAtlasAsset('ranged', 'bow.png', '短弓', 7, 7, 32, 32), 'bow_shot.png': createAtlasAsset('ranged', 'bow_shot.png', '短弓满弦组', 12, 84, 64, 32), 'crossbow.png': createAtlasAsset('ranged', 'crossbow.png', '十字弩', 4, 4, 32, 32), 'crossbow_shot.png': createAtlasAsset('ranged', 'crossbow_shot.png', '十字弩发射组', 17, 68, 32, 32), 'musket.png': createAtlasAsset('ranged', 'musket.png', '长火枪', 5, 10, 64, 32), 'pistol.png': createAtlasAsset('ranged', 'pistol.png', '单手火枪', 4, 24, 32, 32), 'repeater_musket.png': createAtlasAsset('ranged', 'repeater_musket.png', '连发火枪', 4, 8, 64, 32), 'sling.png': createAtlasAsset('ranged', 'sling.png', '投石索', 10, 20, 64, 64), 'stick_sling.png': createAtlasAsset('ranged', 'stick_sling.png', '杖式投石索', 11, 21, 64, 64), } as const; const ATLAS_ASSET_MAPS = { cloth: CLOTH_HAT_ASSETS, leather: LEATHER_ASSETS, metal: METAL_ASSETS, melee: MELEE_ASSETS, magic: MAGIC_ASSETS, ranged: RANGED_ASSETS, } satisfies Record>; const HEADGEAR_POSE_OPTIONS: MedievalPoseOption[] = [ { value: 0, label: '正戴平视' }, { value: 1, label: '低头压檐' }, { value: 2, label: '抬头回正' }, { value: 3, label: '侧肩偏戴' }, { value: 10, label: '行进稳戴' }, { value: 11, label: '行进压檐' }, { value: 20, label: '疾行前压' }, { value: 30, label: '跃起扬帽' }, { value: 40, label: '骑乘稳戴' }, ]; const MAIN_HAND_POSE_OPTIONS: MedievalPoseOption[] = [ { value: 0, label: '垂手持握' }, { value: 1, label: '斜举备战' }, { value: 2, label: '横持压迫' }, { value: 3, label: '高举蓄力' }, { value: 4, label: '前伸突进' }, { value: 5, label: '回收护身' }, { value: 6, label: '终势回摆' }, { value: 10, label: '行进持握' }, { value: 11, label: '行进前压' }, { value: 20, label: '冲刺挥击' }, { value: 30, label: '腾空猛挥' }, { value: 40, label: '高位压制' }, ]; const OFF_HAND_POSE_OPTIONS: MedievalPoseOption[] = [ { value: 0, label: '垂手副持' }, { value: 1, label: '贴身护侧' }, { value: 2, label: '前探协防' }, { value: 3, label: '抬臂护肩' }, { value: 4, label: '低位挡格' }, { value: 5, label: '回收守势' }, { value: 10, label: '行进协防' }, { value: 20, label: '冲刺护身' }, { value: 30, label: '跃起护面' }, ]; const SHIELD_POSE_OPTIONS: MedievalPoseOption[] = [ { value: 0, label: '垂盾待机' }, { value: 1, label: '侧盾待命' }, { value: 40, label: '正面举盾' }, { value: 41, label: '侧身护胸' }, { value: 42, label: '前架格挡' }, { value: 43, label: '抬盾压进' }, { value: 44, label: '护头防守' }, { value: 45, label: '回盾收势' }, { value: 50, label: '骑乘举盾' }, ]; const NPC_VISUAL_OVERRIDES = npcVisualOverridesJson as Record; export const MEDIEVAL_BODY_COLORS = [...BODY_COLORS]; export const MEDIEVAL_BODY_COLOR_LABELS: Record = { black: '墨黑布袍', blue: '深蓝布袍', brown: '棕褐布袍', gold: '暗金布袍', green: '苔绿布袍', grey: '灰布短衣', orange: '赭橙短衣', pink: '旧粉短衣', purple: '暗紫长衣', red: '深红长衣', silver: '银灰短衣', yellow: '土黄布袍', }; export const MEDIEVAL_RACE_LABELS: Record = { human: '人类', elf: '精灵', orc: '兽人', goblin: '地精', }; export const MEDIEVAL_HEAD_LABELS_BY_RACE = HEAD_TONE_LABELS_BY_RACE; export const MEDIEVAL_HAIR_COLOR_LABELS: string[] = ['乌黑', '浅金', '蓝黑', '浅棕', '银白', '赤铜', '深紫', '苍灰']; export const MEDIEVAL_FACIAL_HAIR_COLOR_LABELS: string[] = ['乌黑', '浅金', '蓝黑', '浅棕', '银白', '赤铜', '深紫', '苍灰']; export const MEDIEVAL_HAIR_STYLE_LABELS: string[] = [ '后梳短发', '偏分短发', '额前碎发', '束起短尾', '厚刘海', '蓬松短发', '短冠发束', '前额碎发', '披肩短发', '两侧内卷', '中分长发', '偏分垂发', '侧束长发', '齐耳短发', '小辫短发', '圆顶短发', '发带束发', '低束长发', '角状长发', '后坠卷发', '碎卷长发', '盘起短发', '散落短卷', '高束马尾', '披散长发', '双层短发', '弧形短刘海', '长辫后束', '半束长发', '短束碎发', ]; export const MEDIEVAL_FACIAL_HAIR_STYLE_LABELS: string[] = [ '短尖胡', '细短髭', '八字胡', '唇上短须', '弯弧胡', '短山羊胡', '络腮短须', '短圆胡', '下巴细须', '下颌短须', '方形短胡', '卷尾胡', '下颌垂须', '方口胡', '尖长胡', '细卷髭', '弯月胡', '短髯须', '厚嘴髭', '锥形胡', '大八字胡', '窄下巴须', '短络腮', '双尖胡', '长下巴胡', '卷尖胡', '长弯胡', '翼状胡', '细长胡', '短胡尾', ]; export const MEDIEVAL_CLOTH_HATS = Object.keys(CLOTH_HAT_ASSETS); export const MEDIEVAL_LEATHER_GEAR = Object.keys(LEATHER_ASSETS); export const MEDIEVAL_METAL_GEAR = Object.keys(METAL_ASSETS); export const MEDIEVAL_MELEE_WEAPONS = Object.keys(MELEE_ASSETS); export const MEDIEVAL_MAGIC_WEAPONS = Object.keys(MAGIC_ASSETS); export const MEDIEVAL_RANGED_WEAPONS = Object.keys(RANGED_ASSETS); export const MEDIEVAL_CLOTH_HAT_LABELS = toLabelMap(CLOTH_HAT_ASSETS); export const MEDIEVAL_LEATHER_GEAR_LABELS = toLabelMap(LEATHER_ASSETS); export const MEDIEVAL_METAL_GEAR_LABELS = toLabelMap(METAL_ASSETS); export const MEDIEVAL_MELEE_WEAPON_LABELS = toLabelMap(MELEE_ASSETS); export const MEDIEVAL_MAGIC_WEAPON_LABELS = toLabelMap(MAGIC_ASSETS); export const MEDIEVAL_RANGED_WEAPON_LABELS = toLabelMap(RANGED_ASSETS); export function getRaceSpriteCounts(race: MedievalRace) { return RACE_SPRITE_COUNTS[race]; } export function getMedievalHeadOptions(race: MedievalRace): Array<{ value: number; label: string }> { return HEAD_TONE_LABELS_BY_RACE[race].map((label, index) => ({ value: index + 1, label, })); } export function getMedievalAtlasAsset(type: MedievalAtlasSourceType, file: string) { const assetMap = ATLAS_ASSET_MAPS[type] as Record; return assetMap[file] ?? null; } export function getMedievalAtlasOptions(type: MedievalAtlasSourceType) { return Object.values(ATLAS_ASSET_MAPS[type]); } export function getMedievalPoseOptions( type: MedievalAtlasSourceType, file: string, usage: MedievalAtlasUsage, ): MedievalPoseOption[] { const asset = getMedievalAtlasAsset(type, file); if (!asset) return []; const baseOptions = usage === 'offHand' && file === 'shield.png' ? SHIELD_POSE_OPTIONS : usage === 'headgear' ? HEADGEAR_POSE_OPTIONS : usage === 'mainHand' ? MAIN_HAND_POSE_OPTIONS : OFF_HAND_POSE_OPTIONS; const filtered = baseOptions.filter(option => option.value < asset.frameCount); if (filtered.length > 0) { return filtered; } return buildFallbackPoseOptions(asset.frameCount, usage); } export function clampMedievalAtlasFrame(type: MedievalAtlasSourceType, file: string, frameIndex: number) { const asset = getMedievalAtlasAsset(type, file); if (!asset) return frameIndex; return Math.max(0, Math.min(frameIndex, asset.frameCount - 1)); } export function buildClothHatPath(file: string) { return CLOTH_HAT_ASSETS[file as keyof typeof CLOTH_HAT_ASSETS]?.src ?? `/character/MedievalFantasyCharacters/sprites/wardrobe/cloth/${file}`; } export function buildLeatherGearPath(file: string) { return LEATHER_ASSETS[file as keyof typeof LEATHER_ASSETS]?.src ?? `/character/MedievalFantasyCharacters/sprites/wardrobe/leather/${file}`; } export function buildMetalGearPath(file: string) { return METAL_ASSETS[file as keyof typeof METAL_ASSETS]?.src ?? `/character/MedievalFantasyCharacters/sprites/wardrobe/metal/${file}`; } export function buildMeleeWeaponPath(file: string) { return MELEE_ASSETS[file as keyof typeof MELEE_ASSETS]?.src ?? `/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/${file}`; } export function buildMagicWeaponPath(file: string) { return MAGIC_ASSETS[file as keyof typeof MAGIC_ASSETS]?.src ?? `/character/MedievalFantasyCharacters/sprites/weapons/magic weapons/${file}`; } export function buildRangedWeaponPath(file: string) { return RANGED_ASSETS[file as keyof typeof RANGED_ASSETS]?.src ?? `/character/MedievalFantasyCharacters/sprites/weapons/ranged weapons/${file}`; } export function buildMedievalAtlasSpec( type: MedievalAtlasSourceType, file: string, frameIndex: number, ): AtlasTileSpec | undefined { const asset = getMedievalAtlasAsset(type, file); if (!asset) return undefined; return { src: asset.src, frameIndex: clampMedievalAtlasFrame(type, file, frameIndex), columns: asset.columns, tileWidth: asset.tileWidth, tileHeight: asset.tileHeight, }; } function inferAtlasSourceType(src: string | undefined): MedievalAtlasSourceType | null { if (!src) return null; if (src.includes('/wardrobe/cloth/')) return 'cloth'; if (src.includes('/wardrobe/leather/')) return 'leather'; if (src.includes('/wardrobe/metal/')) return 'metal'; if (src.includes('/weapons/melee weapons/')) return 'melee'; if (src.includes('/weapons/magic weapons/')) return 'magic'; if (src.includes('/weapons/ranged weapons/')) return 'ranged'; return null; } function sanitizeCustomWorldNpcVisualGear( gear: CustomWorldNpcVisualGear | null | undefined, usage: MedievalAtlasUsage, ): CustomWorldNpcVisualGear | null { if (!gear?.file) return null; const poseOptions = getMedievalPoseOptions(gear.type, gear.file, usage); if (poseOptions.length === 0) { return { ...gear, frameIndex: clampMedievalAtlasFrame(gear.type, gear.file, gear.frameIndex), }; } const frameIndex = poseOptions.some(option => option.value === gear.frameIndex) ? clampMedievalAtlasFrame(gear.type, gear.file, gear.frameIndex) : poseOptions[0]!.value; return { ...gear, frameIndex, }; } function parseCustomWorldNpcVisualGear( spec: AtlasTileSpec | undefined, usage: MedievalAtlasUsage, ): CustomWorldNpcVisualGear | null { const type = inferAtlasSourceType(spec?.src); const file = spec?.src.split('/').pop(); if (!type || !file) { return null; } return sanitizeCustomWorldNpcVisualGear( { type, file, frameIndex: spec?.frameIndex ?? 0, }, usage, ); } export function sanitizeCustomWorldNpcVisual(visual: CustomWorldNpcVisual): CustomWorldNpcVisual { const spriteCounts = RACE_SPRITE_COUNTS[visual.race]; const bodyColor = BODY_COLORS.includes(visual.bodyColor as (typeof BODY_COLORS)[number]) ? visual.bodyColor : BODY_COLORS[0]; return { race: visual.race, bodyColor, headIndex: Math.max(1, Math.min(visual.headIndex, spriteCounts.head)), hairColorIndex: Math.max(1, Math.min(visual.hairColorIndex, spriteCounts.hair)), hairStyleFrame: Math.max(0, Math.min(visual.hairStyleFrame, MEDIEVAL_HAIR_STYLE_LABELS.length - 1)), facialHairEnabled: visual.facialHairEnabled, facialHairColorIndex: Math.max(1, Math.min(visual.facialHairColorIndex, spriteCounts.facialHair)), facialHairStyleFrame: Math.max(0, Math.min(visual.facialHairStyleFrame, MEDIEVAL_FACIAL_HAIR_STYLE_LABELS.length - 1)), headgear: sanitizeCustomWorldNpcVisualGear(visual.headgear, 'headgear'), mainHand: sanitizeCustomWorldNpcVisualGear(visual.mainHand, 'mainHand'), offHand: sanitizeCustomWorldNpcVisualGear(visual.offHand, 'offHand'), }; } export function parseCustomWorldNpcVisualFromSpec(spec: MedievalNpcVisualSpec): CustomWorldNpcVisual { const visual = { race: spec.race, bodyColor: spec.bodySrc.match(/body_(.+)\.png$/u)?.[1] ?? BODY_COLORS[0], headIndex: Number(spec.headSrc.match(/_(\d+)\.png$/u)?.[1] ?? '1'), hairColorIndex: Number(spec.hairSrc.match(/_(\d+)\.png$/u)?.[1] ?? '1'), hairStyleFrame: spec.hairFrame ?? 0, facialHairEnabled: Boolean(spec.facialHairSrc), facialHairColorIndex: Number(spec.facialHairSrc?.match(/_(\d+)\.png$/u)?.[1] ?? '1'), facialHairStyleFrame: spec.facialHairFrame ?? 0, headgear: parseCustomWorldNpcVisualGear(spec.headgear, 'headgear'), mainHand: parseCustomWorldNpcVisualGear(spec.mainHand, 'mainHand'), offHand: parseCustomWorldNpcVisualGear(spec.offHand, 'offHand'), } satisfies CustomWorldNpcVisual; return sanitizeCustomWorldNpcVisual(visual); } export function buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual: CustomWorldNpcVisual): MedievalNpcVisualOverride { const sanitizedVisual = sanitizeCustomWorldNpcVisual(visual); const bodyColor = BODY_COLORS.includes(sanitizedVisual.bodyColor as (typeof BODY_COLORS)[number]) ? sanitizedVisual.bodyColor as (typeof BODY_COLORS)[number] : BODY_COLORS[0]; return { race: sanitizedVisual.race, bodySrc: buildBodyPath(bodyColor), headSrc: buildRaceAssetPath(sanitizedVisual.race, 'head', sanitizedVisual.headIndex), hairSrc: buildRaceAssetPath(sanitizedVisual.race, 'hair', sanitizedVisual.hairColorIndex), handSrc: buildRaceAssetPath(sanitizedVisual.race, 'hand', 1), facialHairSrc: sanitizedVisual.facialHairEnabled ? buildRaceAssetPath(sanitizedVisual.race, 'facialHair', sanitizedVisual.facialHairColorIndex) : undefined, headgear: sanitizedVisual.headgear ? buildMedievalAtlasSpec(sanitizedVisual.headgear.type, sanitizedVisual.headgear.file, sanitizedVisual.headgear.frameIndex) : undefined, mainHand: sanitizedVisual.mainHand ? buildMedievalAtlasSpec(sanitizedVisual.mainHand.type, sanitizedVisual.mainHand.file, sanitizedVisual.mainHand.frameIndex) : undefined, offHand: sanitizedVisual.offHand ? buildMedievalAtlasSpec(sanitizedVisual.offHand.type, sanitizedVisual.offHand.file, sanitizedVisual.offHand.frameIndex) : undefined, bodyFrames: [0, 1, 2, 3], headFrame: 0, hairFrame: sanitizedVisual.hairStyleFrame, handFrame: 0, facialHairFrame: sanitizedVisual.facialHairEnabled ? sanitizedVisual.facialHairStyleFrame : undefined, }; } export function buildMedievalNpcVisualFromCustomWorldVisual( visual: CustomWorldNpcVisual, ): MedievalNpcVisualSpec { const override = buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual); const race = override.race ?? 'human'; return { race, bodySrc: override.bodySrc ?? buildBodyPath('black'), headSrc: override.headSrc ?? buildRaceAssetPath(race, 'head', 1), hairSrc: override.hairSrc ?? buildRaceAssetPath(race, 'hair', 1), handSrc: override.handSrc ?? buildRaceAssetPath(race, 'hand', 1), facialHairSrc: override.facialHairSrc, headgear: override.headgear, mainHand: override.mainHand, offHand: override.offHand, bodyFrames: override.bodyFrames ?? [0, 1, 2, 3], headFrame: override.headFrame ?? 0, hairFrame: override.hairFrame ?? 0, handFrame: override.handFrame ?? 0, facialHairFrame: override.facialHairFrame, }; } export function getNpcVisualOverrideById(overrideId: string) { return NPC_VISUAL_OVERRIDES[overrideId] ?? null; } function getRuntimeCustomWorldNpcOverride(encounter: Encounter) { if (!encounter.id) return null; const runtimeProfile = getRuntimeCustomWorldProfile(); const storyNpc = runtimeProfile?.storyNpcs.find(npc => npc.id === encounter.id); if (!storyNpc?.visual) { return null; } return buildMedievalNpcVisualOverrideFromCustomWorldVisual(storyNpc.visual); } function createAtlasAsset( type: MedievalAtlasSourceType, file: string, label: string, columns: number, frameCount: number, tileWidth: number, tileHeight: number, ): MedievalAtlasAssetDefinition { return { file, label, src: buildAtlasAssetPath(type, file), columns, frameCount, tileWidth, tileHeight, }; } function buildAtlasAssetPath(type: MedievalAtlasSourceType, file: string) { if (type === 'cloth') return `/character/MedievalFantasyCharacters/sprites/wardrobe/cloth/${file}`; if (type === 'leather') return `/character/MedievalFantasyCharacters/sprites/wardrobe/leather/${file}`; if (type === 'metal') return `/character/MedievalFantasyCharacters/sprites/wardrobe/metal/${file}`; if (type === 'melee') return `/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/${file}`; if (type === 'magic') return `/character/MedievalFantasyCharacters/sprites/weapons/magic weapons/${file}`; return `/character/MedievalFantasyCharacters/sprites/weapons/ranged weapons/${file}`; } function toLabelMap(definitions: Record) { return Object.fromEntries( Object.values(definitions).map(definition => [definition.file, definition.label]), ) as Record; } function buildFallbackPoseOptions(frameCount: number, usage: MedievalAtlasUsage): MedievalPoseOption[] { const labelsByUsage: Record = { headgear: ['平视佩戴', '轻压帽檐', '低头稳帽', '抬头回正', '侧身偏戴', '前行稳帽', '快步压檐', '跃起扬帽'], mainHand: ['基础持握', '低位收势', '中位平举', '高位抬手', '前伸出手', '横持压迫', '回收护身', '终势停稳'], offHand: ['副手待命', '贴身护侧', '前探协防', '抬臂护肩', '低位挡格', '回收守势', '前行护身', '高位封挡'], }; return Array.from({ length: frameCount }, (_, index) => ({ value: index, label: labelsByUsage[usage][index] ?? labelsByUsage[usage][labelsByUsage[usage].length - 1] ?? `${usage}-${index}`, })); } function hashString(value: string) { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return hash >>> 0; } function pickFromArray(items: readonly T[], seed: number, salt: number): T { if (items.length === 0) { throw new Error('Cannot pick from an empty array.'); } const picked = items[(seed + salt) % items.length]; const fallbackItem = items[0]; if (fallbackItem === undefined) { throw new Error('Expected a fallback item.'); } return picked ?? fallbackItem; } function pickPoseFrame(type: MedievalAtlasSourceType, file: string, usage: MedievalAtlasUsage, seed: number, salt: number) { const poseOptions = getMedievalPoseOptions(type, file, usage); if (poseOptions.length === 0) return 0; return pickFromArray(poseOptions, seed, salt).value; } export function buildRaceAssetPath(race: MedievalRace, section: 'head' | 'hair' | 'facialHair' | 'hand', index: number) { const base = '/character/MedievalFantasyCharacters/sprites/Characters'; if (section === 'head') { return `${base}/${race}/head/${race}_head_skin_${index}.png`; } if (section === 'hair') { return `${base}/${race}/hair/hairstyle/${race}_hair_${index}.png`; } if (section === 'facialHair') { return `${base}/${race}/hair/facial hair/${race}_facialhair_${index}.png`; } return `${base}/${race}/hand/${race}_hand.png`; } export function buildBodyPath(color: (typeof BODY_COLORS)[number]) { return `/character/MedievalFantasyCharacters/sprites/Characters/body/body_${color}.png`; } function buildHeadgear(roleStyle: NpcRoleStyle, seed: number): AtlasTileSpec | undefined { if (roleStyle === 'civilian') { const file = pickFromArray(MEDIEVAL_CLOTH_HATS, seed, 7); return buildMedievalAtlasSpec('cloth', file, pickPoseFrame('cloth', file, 'headgear', seed, 17)); } if (roleStyle === 'rogue') { const file = pickFromArray(MEDIEVAL_LEATHER_GEAR, seed, 11); return buildMedievalAtlasSpec('leather', file, pickPoseFrame('leather', file, 'headgear', seed, 19)); } if (roleStyle === 'warrior' || roleStyle === 'guardian') { const file = pickFromArray(MEDIEVAL_METAL_GEAR, seed, 13); return buildMedievalAtlasSpec('metal', file, pickPoseFrame('metal', file, 'headgear', seed, 23)); } if (roleStyle === 'mystic') { const file = pickFromArray(MEDIEVAL_CLOTH_HATS, seed, 17); return buildMedievalAtlasSpec('cloth', file, pickPoseFrame('cloth', file, 'headgear', seed, 29)); } return undefined; } function buildMainHand(roleStyle: NpcRoleStyle, seed: number): AtlasTileSpec | undefined { if (roleStyle === 'mystic') { const file = pickFromArray(MEDIEVAL_MAGIC_WEAPONS, seed, 23); return buildMedievalAtlasSpec('magic', file, pickPoseFrame('magic', file, 'mainHand', seed, 31)); } if (roleStyle === 'ranger') { const preferred = ['bow.png', 'crossbow.png', 'sling.png'] as const; const file = pickFromArray(preferred, seed, 29); return buildMedievalAtlasSpec('ranged', file, pickPoseFrame('ranged', file, 'mainHand', seed, 37)); } if (roleStyle === 'guardian') { return buildMedievalAtlasSpec('melee', 'polearm.png', pickPoseFrame('melee', 'polearm.png', 'mainHand', seed, 41)); } if (roleStyle === 'warrior' || roleStyle === 'rogue' || roleStyle === 'bruiser') { const preferred = ['sword.png', 'axe.png', 'dagger.png', 'blunt.png', 'sword_big.png', 'axe_big.png'] as const; const file = pickFromArray(preferred, seed, 31); return buildMedievalAtlasSpec('melee', file, pickPoseFrame('melee', file, 'mainHand', seed, 43)); } return undefined; } function buildOffHand(roleStyle: NpcRoleStyle, seed: number): AtlasTileSpec | undefined { if (roleStyle !== 'guardian') { return undefined; } return buildMedievalAtlasSpec('melee', 'shield.png', pickPoseFrame('melee', 'shield.png', 'offHand', seed, 47)); } function inferRoleStyle(encounter: Encounter): NpcRoleStyle { const source = `${encounter.id ?? ''} ${encounter.characterId ?? ''} ${encounter.npcName} ${encounter.context}`.toLowerCase(); if (source.includes('archer-hero') || source.includes('猎') || source.includes('弓') || source.includes('巡修')) { return 'ranger'; } if (source.includes('gate-disciple') || source.includes('门') || source.includes('守使') || source.includes('灵侍')) { return 'guardian'; } if (source.includes('sword-princess') || source.includes('守山') || source.includes('宫人') || source.includes('舵手')) { return 'warrior'; } if (source.includes('fighter-4') || source.includes('军需') || source.includes('铸匠') || source.includes('炼匠')) { return 'guardian'; } if (source.includes('punch-hero') || source.includes('矿') || source.includes('匠') || source.includes('渡工')) { return 'bruiser'; } if (source.includes('司录') || source.includes('学者') || source.includes('药师') || source.includes('书生') || source.includes('侍者')) { return 'mystic'; } if (source.includes('girl-hero') || source.includes('侍女') || source.includes('散修') || source.includes('访客') || source.includes('琴师')) { return 'rogue'; } return 'civilian'; } function inferRace(encounter: Encounter, roleStyle: NpcRoleStyle, seed: number): MedievalRace { const source = `${encounter.id ?? ''} ${encounter.characterId ?? ''} ${encounter.npcName} ${encounter.context}`.toLowerCase(); if (source.includes('archer-hero') || source.includes('精灵') || source.includes('琴师') || source.includes('云')) { return 'elf'; } if (source.includes('punch-hero') || source.includes('铸匠') || source.includes('矿') || source.includes('熔')) { return 'orc'; } if (source.includes('girl-hero') || source.includes('散修') || source.includes('访客')) { return 'goblin'; } if (roleStyle === 'bruiser') { return seed % 2 === 0 ? 'orc' : 'human'; } return 'human'; } function shouldUseFacialHair(race: MedievalRace, roleStyle: NpcRoleStyle, seed: number) { if (race === 'elf') return seed % 5 === 0; if (race === 'goblin') return seed % 4 === 0; if (roleStyle === 'civilian') return seed % 2 === 0; return seed % 3 !== 0; } export function buildMedievalNpcVisual(encounter: Encounter): MedievalNpcVisualSpec { const seed = hashString(`${encounter.id ?? encounter.npcName}:${encounter.context}:${encounter.characterId ?? ''}`); const override = getRuntimeCustomWorldNpcOverride(encounter) ?? (encounter.id ? NPC_VISUAL_OVERRIDES[encounter.id] : undefined); if (override) { return { race: override.race ?? 'human', bodySrc: override.bodySrc ?? buildBodyPath('black'), headSrc: override.headSrc ?? buildRaceAssetPath(override.race ?? 'human', 'head', 1), hairSrc: override.hairSrc ?? buildRaceAssetPath(override.race ?? 'human', 'hair', 1), handSrc: override.handSrc ?? buildRaceAssetPath(override.race ?? 'human', 'hand', 1), facialHairSrc: override.facialHairSrc, headgear: override.headgear, mainHand: override.mainHand, offHand: override.offHand, bodyFrames: override.bodyFrames ?? [0, 1, 2, 3], headFrame: override.headFrame ?? 0, hairFrame: override.hairFrame ?? 0, handFrame: override.handFrame ?? 0, facialHairFrame: override.facialHairFrame, }; } const roleStyle = inferRoleStyle(encounter); const race = inferRace(encounter, roleStyle, seed); const counts = RACE_SPRITE_COUNTS[race]; const bodyColor = pickFromArray(BODY_COLORS, seed, 3); const headIndex = (seed % counts.head) + 1; const hairIndex = ((seed >> 3) % counts.hair) + 1; const facialHairIndex = ((seed >> 5) % counts.facialHair) + 1; const useFacialHair = shouldUseFacialHair(race, roleStyle, seed); return { race, bodySrc: buildBodyPath(bodyColor), headSrc: buildRaceAssetPath(race, 'head', headIndex), hairSrc: buildRaceAssetPath(race, 'hair', hairIndex), handSrc: buildRaceAssetPath(race, 'hand', 1), facialHairSrc: useFacialHair ? buildRaceAssetPath(race, 'facialHair', facialHairIndex) : undefined, headgear: buildHeadgear(roleStyle, seed), mainHand: buildMainHand(roleStyle, seed), offHand: buildOffHand(roleStyle, seed), bodyFrames: [0, 1, 2, 3], headFrame: 0, hairFrame: 0, handFrame: 0, facialHairFrame: useFacialHair ? 0 : undefined, }; }