Files
Genarrative/src/data/medievalNpcVisuals.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

831 lines
31 KiB
TypeScript

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<MedievalNpcVisualSpec> & {
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<MedievalRace, { head: number; hair: number; facialHair: number }> = {
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<MedievalRace, string[]> = {
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<MedievalAtlasSourceType, Record<string, MedievalAtlasAssetDefinition>>;
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<string, MedievalNpcVisualOverride>;
export const MEDIEVAL_BODY_COLORS = [...BODY_COLORS];
export const MEDIEVAL_BODY_COLOR_LABELS: Record<string, string> = {
black: '墨黑布袍',
blue: '深蓝布袍',
brown: '棕褐布袍',
gold: '暗金布袍',
green: '苔绿布袍',
grey: '灰布短衣',
orange: '赭橙短衣',
pink: '旧粉短衣',
purple: '暗紫长衣',
red: '深红长衣',
silver: '银灰短衣',
yellow: '土黄布袍',
};
export const MEDIEVAL_RACE_LABELS: Record<MedievalRace, string> = {
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<string, MedievalAtlasAssetDefinition>;
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<string, MedievalAtlasAssetDefinition>) {
return Object.fromEntries(
Object.values(definitions).map(definition => [definition.file, definition.label]),
) as Record<string, string>;
}
function buildFallbackPoseOptions(frameCount: number, usage: MedievalAtlasUsage): MedievalPoseOption[] {
const labelsByUsage: Record<MedievalAtlasUsage, string[]> = {
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<T>(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,
};
}