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

338 lines
8.9 KiB
TypeScript

import type {
InventoryItem,
ItemCatalogEntry,
ItemCatalogOverride,
ItemRarity,
WorldType,
} from '../types';
import {
EDITOR_ITEM_CATALOG_API_PATH,
} from '../editor/shared/editorApiClient';
import { buildDesignedItemMetadata } from './itemDesign';
export { EDITOR_ITEM_CATALOG_API_PATH as ITEM_CATALOG_API_PATH };
export const ITEM_CATEGORY_OPTIONS = [
'武器',
'护甲',
'饰品',
'消耗品',
'材料',
'稀有品',
'专属品',
] as const;
const CATEGORY_WEAPON = ITEM_CATEGORY_OPTIONS[0];
const CATEGORY_ARMOR = ITEM_CATEGORY_OPTIONS[1];
const CATEGORY_RELIC = ITEM_CATEGORY_OPTIONS[2];
const CATEGORY_CONSUMABLE = ITEM_CATEGORY_OPTIONS[3];
const CATEGORY_MATERIAL = ITEM_CATEGORY_OPTIONS[4];
const CATEGORY_RARE = ITEM_CATEGORY_OPTIONS[5];
const CATEGORY_EXCLUSIVE = ITEM_CATEGORY_OPTIONS[6];
const WEAPON_KEYWORDS = [
'weapon',
'sword',
'axe',
'bow',
'arrow',
'mace',
'wand',
'staff',
'pick',
'spade',
'blade',
'dagger',
'spear',
'hammer',
];
const ARMOR_KEYWORDS = [
'armor',
'armour',
'helm',
'helmet',
'chest',
'pants',
'boots',
'glove',
'glowes',
'shield',
'cloak',
'robe',
'cap',
];
const ACCESSORY_KEYWORDS = [
'ring',
'neck',
'amulet',
'jewel',
'jewelry',
'bracelet',
'relic',
'gem',
];
const CONSUMABLE_KEYWORDS = [
'potion',
'bottle',
'water',
'meat',
'apple',
'mushroom',
'bandage',
'torch',
'candle',
'food',
];
const MATERIAL_KEYWORDS = [
'wood',
'stone',
'leaf',
'flower',
'skin',
'rope',
'coin',
'silverbar',
'ore',
'bar',
'material',
];
const RARE_KEYWORDS = [
'scroll',
'book',
'bag',
'skull',
'cross',
'stairway',
'crystal',
'magic',
];
const EXCLUSIVE_KEYWORDS = [
'treasure',
'relic',
'artifact',
'legend',
'sacred',
];
function normalizeAssetPath(sourcePath: string) {
return sourcePath
.replace(/^public[\\/]/iu, '')
.replace(/\\/g, '/')
.replace(/^\/+/u, '');
}
function stripExtension(value: string) {
return value.replace(/\.[^.]+$/u, '');
}
function humanizeAssetPart(value: string) {
const cleaned = stripExtension(value)
.replace(/^\d+[_-]*/u, '')
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return '';
return cleaned
.split(' ')
.map(part => {
const firstCharacter = part[0];
return part && firstCharacter ? firstCharacter.toUpperCase() + part.slice(1) : '';
})
.join(' ');
}
function includesAnyKeyword(text: string, keywords: string[]) {
return keywords.some(keyword => text.includes(keyword));
}
function dedupeTags(tags: string[]) {
return [...new Set(tags.filter(Boolean))];
}
export function buildItemCatalogId(sourcePath: string) {
return stripExtension(normalizeAssetPath(sourcePath))
.toLowerCase()
.replace(/[^a-z0-9/]+/g, '-')
.replace(/\/+/g, '__')
.replace(/-+/g, '-')
.replace(/^[-_]+|[-_]+$/g, '');
}
export function buildItemCatalogName(sourcePath: string) {
const normalized = normalizeAssetPath(sourcePath);
const parts = normalized.split('/');
const leafName = humanizeAssetPart(parts[parts.length - 1] ?? '');
const parentName = humanizeAssetPart(parts[parts.length - 2] ?? '');
if (!leafName && parentName) return parentName;
if (!leafName) return '未命名物品';
if (/^(Items|Icons|Singles|Variants|Text)$/u.test(leafName) && parentName) {
return `${parentName} ${leafName}`;
}
return leafName;
}
export function inferItemCatalogCategory(sourcePath: string) {
const normalized = normalizeAssetPath(sourcePath).toLowerCase();
if (includesAnyKeyword(normalized, WEAPON_KEYWORDS)) return '武器';
if (includesAnyKeyword(normalized, ARMOR_KEYWORDS)) return '护甲';
if (includesAnyKeyword(normalized, ACCESSORY_KEYWORDS)) return '饰品';
if (includesAnyKeyword(normalized, CONSUMABLE_KEYWORDS)) return '消耗品';
if (includesAnyKeyword(normalized, MATERIAL_KEYWORDS)) return '材料';
if (includesAnyKeyword(normalized, EXCLUSIVE_KEYWORDS)) return '专属品';
if (includesAnyKeyword(normalized, RARE_KEYWORDS)) return '稀有品';
return '稀有品';
}
export function inferItemCatalogRarity(sourcePath: string, category: string): ItemRarity {
const normalized = normalizeAssetPath(sourcePath).toLowerCase();
if (includesAnyKeyword(normalized, EXCLUSIVE_KEYWORDS)) return 'legendary';
if (includesAnyKeyword(normalized, ['magic', 'crystal', 'wand', 'gem', 'gold'])) return 'epic';
if (category === '武器' || category === '护甲' || category === '饰品' || category === '专属品') return 'rare';
if (category === '消耗品' || category === '材料') return 'uncommon';
return 'common';
}
export function inferItemCatalogTags(sourcePath: string, category: string) {
const normalized = normalizeAssetPath(sourcePath).toLowerCase();
const tags: string[] = [];
if (category === '武器') tags.push('weapon');
if (category === '护甲') tags.push('armor');
if (category === '饰品' || category === '专属品') tags.push('relic');
if (category === '材料') tags.push('material');
if (includesAnyKeyword(normalized, ['potion', 'bandage', 'water', 'meat', 'apple', 'mushroom'])) {
tags.push('healing');
}
if (includesAnyKeyword(normalized, ['mana', 'magic', 'crystal', 'gem', 'wand'])) {
tags.push('mana');
}
return dedupeTags(tags);
}
export function buildItemCatalogDescription(
sourcePath: string,
category: string,
name: string,
) {
return `由图标素材 ${normalizeAssetPath(sourcePath)} 自动生成的${category}物品“${name}”,可在编辑器中继续调整名称、稀有度、标签与描述。`;
}
export function buildBaseItemCatalogEntry(sourcePath: string): ItemCatalogEntry {
const normalizedSourcePath = normalizeAssetPath(sourcePath);
const name = buildItemCatalogName(normalizedSourcePath);
const category = inferItemCatalogCategory(normalizedSourcePath);
const rarity = inferItemCatalogRarity(normalizedSourcePath, category);
const tags = inferItemCatalogTags(normalizedSourcePath, category);
const designed = buildDesignedItemMetadata(
normalizedSourcePath,
name,
category,
rarity,
tags,
{
weapon: CATEGORY_WEAPON,
armor: CATEGORY_ARMOR,
relic: CATEGORY_RELIC,
consumable: CATEGORY_CONSUMABLE,
material: CATEGORY_MATERIAL,
rare: CATEGORY_RARE,
exclusive: CATEGORY_EXCLUSIVE,
},
);
return {
id: buildItemCatalogId(normalizedSourcePath),
sourcePath: normalizedSourcePath,
iconSrc: `/${normalizedSourcePath}`,
name: designed.name ?? name,
category: designed.category ?? category,
rarity: designed.rarity ?? rarity,
tags: dedupeTags(designed.tags ?? tags),
description: designed.description ?? buildItemCatalogDescription(normalizedSourcePath, category, name),
worldAffinity: designed.worldAffinity ?? 'neutral',
equipmentSlotId: designed.equipmentSlotId ?? null,
worldProfiles: designed.worldProfiles,
statProfile: designed.statProfile ?? null,
useProfile: designed.useProfile ?? null,
buildProfile: designed.buildProfile ?? null,
value: designed.value,
};
}
export function applyItemCatalogOverride(
baseItem: ItemCatalogEntry,
override?: ItemCatalogOverride | null,
): ItemCatalogEntry {
if (!override) return baseItem;
return {
...baseItem,
...override,
tags: override.tags ? dedupeTags(override.tags) : baseItem.tags,
worldProfiles: override.worldProfiles ?? baseItem.worldProfiles,
statProfile: override.statProfile ?? baseItem.statProfile,
useProfile: override.useProfile ?? baseItem.useProfile,
buildProfile: override.buildProfile ?? baseItem.buildProfile,
};
}
export function buildItemCatalogFromAssetPaths(
assetPaths: string[],
overrideMap: Record<string, ItemCatalogOverride> = {},
) {
return assetPaths
.map(sourcePath => buildBaseItemCatalogEntry(sourcePath))
.map(item => applyItemCatalogOverride(item, overrideMap[item.id]))
.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
}
export function createInventoryItemFromCatalogEntry(
item: ItemCatalogEntry,
quantity = 1,
worldType: WorldType | null = null,
): InventoryItem {
const worldProfile = worldType ? item.worldProfiles?.[worldType] : null;
return {
id: `catalog:${item.id}`,
catalogId: item.id,
category: item.category,
name: worldProfile?.name ?? item.name,
quantity,
rarity: item.rarity,
tags: [...item.tags],
iconSrc: item.iconSrc,
description: worldProfile?.description ?? item.description,
worldAffinity: item.worldAffinity,
equipmentSlotId: item.equipmentSlotId,
worldProfiles: item.worldProfiles,
statProfile: item.statProfile,
useProfile: item.useProfile,
buildProfile: item.buildProfile,
value: item.value,
runtimeMetadata: {
origin: 'catalog',
generationChannel: 'discovery',
seedKey: `catalog:${item.id}`,
sourceReason: '来自静态物品目录。',
},
};
}