831 lines
31 KiB
TypeScript
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,
|
|
};
|
|
}
|