This commit is contained in:
534
src/data/forgeSystem.ts
Normal file
534
src/data/forgeSystem.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import type {
|
||||
EquipmentSlotId,
|
||||
InventoryItem,
|
||||
ItemBuildProfile,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getSimilarBuildTags, normalizeBuildRole, normalizeBuildTags } from './buildTags';
|
||||
import { formatCurrency } from './economy';
|
||||
import { getEquipmentSlotFromItem } from './equipmentEffects';
|
||||
import { addInventoryItems, removeInventoryItem } from './npcInteractions';
|
||||
|
||||
export type ForgeRecipeKind = 'synthesis' | 'forge';
|
||||
|
||||
type ForgeRequirement = {
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
matches: (item: InventoryItem) => boolean;
|
||||
};
|
||||
|
||||
type ForgeRecipeDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ForgeRecipeKind;
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
requirements: ForgeRequirement[];
|
||||
createResult: (worldType: WorldType | null) => InventoryItem;
|
||||
};
|
||||
|
||||
export type ForgeRecipeView = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ForgeRecipeKind;
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
currencyText: string;
|
||||
requirements: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
owned: number;
|
||||
}>;
|
||||
canCraft: boolean;
|
||||
};
|
||||
|
||||
export type ForgeExecutionResult = {
|
||||
inventory: InventoryItem[];
|
||||
currency: number;
|
||||
createdItem: InventoryItem;
|
||||
};
|
||||
|
||||
export type DismantleExecutionResult = {
|
||||
inventory: InventoryItem[];
|
||||
outputs: InventoryItem[];
|
||||
};
|
||||
|
||||
export type ReforgeExecutionResult = {
|
||||
inventory: InventoryItem[];
|
||||
reforgedItem: InventoryItem;
|
||||
currencyCost: number;
|
||||
};
|
||||
|
||||
function createItemId(prefix: string) {
|
||||
return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function normalizeQuantity(quantity: number) {
|
||||
return Math.max(1, Math.floor(quantity));
|
||||
}
|
||||
|
||||
function buildMaterialItem(
|
||||
name: string,
|
||||
quantity: number,
|
||||
tags: string[],
|
||||
rarity: ItemRarity = 'uncommon',
|
||||
description?: string,
|
||||
): InventoryItem {
|
||||
return {
|
||||
id: createItemId(`forge-material:${name}`),
|
||||
category: '材料',
|
||||
name,
|
||||
quantity: normalizeQuantity(quantity),
|
||||
rarity,
|
||||
tags: ['material', ...normalizeBuildTags(tags)],
|
||||
description,
|
||||
buildProfile: {
|
||||
role: normalizeBuildRole('工巧'),
|
||||
tags: normalizeBuildTags(tags),
|
||||
craftTags: normalizeBuildTags(tags),
|
||||
forgeRank: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEquipmentItem(params: {
|
||||
name: string;
|
||||
slot: EquipmentSlotId;
|
||||
rarity: ItemRarity;
|
||||
description: string;
|
||||
role: string;
|
||||
tags: string[];
|
||||
setId: string;
|
||||
setName: string;
|
||||
pieceName: string;
|
||||
synergy: string[];
|
||||
statProfile: ItemStatProfile;
|
||||
}) {
|
||||
return {
|
||||
id: createItemId(`forge-equip:${params.name}`),
|
||||
category: params.slot === 'weapon' ? '武器' : params.slot === 'armor' ? '护甲' : '饰品',
|
||||
name: params.name,
|
||||
quantity: 1,
|
||||
rarity: params.rarity,
|
||||
tags: [
|
||||
params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
|
||||
...normalizeBuildTags(params.tags),
|
||||
],
|
||||
description: params.description,
|
||||
equipmentSlotId: params.slot,
|
||||
statProfile: params.statProfile,
|
||||
buildProfile: {
|
||||
role: normalizeBuildRole(params.role),
|
||||
tags: normalizeBuildTags(params.tags),
|
||||
setId: params.setId,
|
||||
setName: params.setName,
|
||||
pieceName: params.pieceName,
|
||||
synergy: params.synergy,
|
||||
craftTags: normalizeBuildTags(params.tags),
|
||||
forgeRank: 1,
|
||||
} satisfies ItemBuildProfile,
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
|
||||
function buildRefinedIngot() {
|
||||
return buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare', '经过二次锻压的通用金属锭材,可用于武器与护甲锻造。');
|
||||
}
|
||||
|
||||
function buildCondensedSilk() {
|
||||
return buildMaterialItem('凝光纱', 1, ['工巧', '法力'], 'rare', '适合饰品与法器类配方的高阶纤维材料。');
|
||||
}
|
||||
|
||||
function buildTagEssence(tag: string) {
|
||||
return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare', `从旧装备中提炼出的 ${tag} 构筑精粹。`);
|
||||
}
|
||||
|
||||
function buildAnyMaterialRequirement(id: string, label: string, quantity: number): ForgeRequirement {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
quantity,
|
||||
matches: item => item.tags.includes('material') || item.category.includes('材料'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNamedMaterialRequirement(name: string, quantity: number): ForgeRequirement {
|
||||
return {
|
||||
id: `name:${name}`,
|
||||
label: name,
|
||||
quantity,
|
||||
matches: item => item.name === name,
|
||||
};
|
||||
}
|
||||
|
||||
const FORGE_RECIPES: ForgeRecipeDefinition[] = [
|
||||
{
|
||||
id: 'synthesis-refined-ingot',
|
||||
name: '压炼锭材',
|
||||
kind: 'synthesis',
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
requirements: [
|
||||
buildAnyMaterialRequirement('material:any', '任意材料', 3),
|
||||
],
|
||||
createResult: () => buildRefinedIngot(),
|
||||
},
|
||||
{
|
||||
id: 'synthesis-condensed-silk',
|
||||
name: '凝光纺丝',
|
||||
kind: 'synthesis',
|
||||
description: '用灵性残材与粉末纺出适合饰品锻造的凝光纱。',
|
||||
resultLabel: '凝光纱',
|
||||
currencyCost: 24,
|
||||
requirements: [
|
||||
buildAnyMaterialRequirement('material:any', '任意材料', 2),
|
||||
{
|
||||
id: 'tag:mana',
|
||||
label: '含法力标签材料',
|
||||
quantity: 1,
|
||||
matches: item => (item.tags.includes('material') || item.category.includes('材料')) && item.tags.includes('mana'),
|
||||
},
|
||||
],
|
||||
createResult: () => buildCondensedSilk(),
|
||||
},
|
||||
{
|
||||
id: 'forge-duelist-blade',
|
||||
name: '锻造 百炼追风剑',
|
||||
kind: 'forge',
|
||||
description: '围绕快剑、突进、追击构筑的轻灵主武器。',
|
||||
resultLabel: '百炼追风剑',
|
||||
currencyCost: 72,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement('精炼锭材', 2),
|
||||
buildNamedMaterialRequirement('快剑精粹', 1),
|
||||
buildNamedMaterialRequirement('突进精粹', 1),
|
||||
],
|
||||
createResult: () => buildEquipmentItem({
|
||||
name: '百炼追风剑',
|
||||
slot: 'weapon',
|
||||
rarity: 'epic',
|
||||
description: '为快剑与追身构筑准备的锻造兵刃,挥动时更容易连续压进对手空门。',
|
||||
role: '快剑',
|
||||
tags: ['快剑', '突进', '追击'],
|
||||
setId: 'forge-set-duelist',
|
||||
setName: '追风连锋',
|
||||
pieceName: 'weapon',
|
||||
synergy: ['快剑', '突进', '追击'],
|
||||
statProfile: {
|
||||
maxManaBonus: 10,
|
||||
outgoingDamageBonus: 0.2,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'forge-ward-armor',
|
||||
name: '锻造 镇岳护甲',
|
||||
kind: 'forge',
|
||||
description: '面向前排承压的护甲,适合守御与护体构筑。',
|
||||
resultLabel: '镇岳护甲',
|
||||
currencyCost: 78,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement('精炼锭材', 2),
|
||||
buildNamedMaterialRequirement('守御精粹', 1),
|
||||
buildNamedMaterialRequirement('护体精粹', 1),
|
||||
],
|
||||
createResult: () => buildEquipmentItem({
|
||||
name: '镇岳护甲',
|
||||
slot: 'armor',
|
||||
rarity: 'epic',
|
||||
description: '厚重但稳定的护甲套件,适合顶住正面压力后再伺机反打。',
|
||||
role: '守御',
|
||||
tags: ['守御', '护体', '先锋'],
|
||||
setId: 'forge-set-ward',
|
||||
setName: '镇岳守阵',
|
||||
pieceName: 'armor',
|
||||
synergy: ['守御', '护体', '先锋'],
|
||||
statProfile: {
|
||||
maxHpBonus: 56,
|
||||
maxManaBonus: 8,
|
||||
outgoingDamageBonus: 0.08,
|
||||
incomingDamageMultiplier: 0.84,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'forge-thunder-relic',
|
||||
name: '锻造 雷纹灵坠',
|
||||
kind: 'forge',
|
||||
description: '为法修、雷法、过载 build 提供资源与爆发补强。',
|
||||
resultLabel: '雷纹灵坠',
|
||||
currencyCost: 88,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement('凝光纱', 2),
|
||||
buildNamedMaterialRequirement('法力精粹', 1),
|
||||
buildNamedMaterialRequirement('雷法精粹', 1),
|
||||
],
|
||||
createResult: () => buildEquipmentItem({
|
||||
name: '雷纹灵坠',
|
||||
slot: 'relic',
|
||||
rarity: 'epic',
|
||||
description: '内封雷纹与灵引回路的饰品,能在短窗口内快速放大法术节奏。',
|
||||
role: '法修',
|
||||
tags: ['法修', '雷法', '过载'],
|
||||
setId: 'forge-set-thunder',
|
||||
setName: '雷纹御法',
|
||||
pieceName: 'relic',
|
||||
synergy: ['法修', '雷法', '过载'],
|
||||
statProfile: {
|
||||
maxHpBonus: 8,
|
||||
maxManaBonus: 42,
|
||||
outgoingDamageBonus: 0.14,
|
||||
incomingDamageMultiplier: 0.92,
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function countMatchingItems(inventory: InventoryItem[], requirement: ForgeRequirement) {
|
||||
return inventory
|
||||
.filter(item => requirement.matches(item))
|
||||
.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
|
||||
function consumeRequirement(inventory: InventoryItem[], requirement: ForgeRequirement) {
|
||||
let remaining = requirement.quantity;
|
||||
let nextInventory = [...inventory];
|
||||
|
||||
for (const item of inventory) {
|
||||
if (remaining <= 0) break;
|
||||
if (!requirement.matches(item)) continue;
|
||||
|
||||
const consumed = Math.min(item.quantity, remaining);
|
||||
nextInventory = removeInventoryItem(nextInventory, item.id, consumed);
|
||||
remaining -= consumed;
|
||||
}
|
||||
|
||||
return remaining === 0 ? nextInventory : null;
|
||||
}
|
||||
|
||||
function applyRequirementsIfPossible(inventory: InventoryItem[], requirements: ForgeRequirement[]) {
|
||||
let nextInventory = [...inventory];
|
||||
for (const requirement of requirements) {
|
||||
const consumedInventory = consumeRequirement(nextInventory, requirement);
|
||||
if (!consumedInventory) return null;
|
||||
nextInventory = consumedInventory;
|
||||
}
|
||||
return nextInventory;
|
||||
}
|
||||
|
||||
function buildDismantleBaseMaterials(item: InventoryItem, slot: EquipmentSlotId | null) {
|
||||
const rarityScale: Record<ItemRarity, number> = {
|
||||
common: 1,
|
||||
uncommon: 2,
|
||||
rare: 3,
|
||||
epic: 4,
|
||||
legendary: 5,
|
||||
};
|
||||
|
||||
const amount = rarityScale[item.rarity];
|
||||
if (slot === 'weapon') {
|
||||
return [buildMaterialItem('武器残片', amount, ['工巧', '重击'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
}
|
||||
if (slot === 'armor') {
|
||||
return [buildMaterialItem('甲片', amount, ['工巧', '守御'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
}
|
||||
if (slot === 'relic') {
|
||||
return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
}
|
||||
return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'], 'common')];
|
||||
}
|
||||
|
||||
function buildDismantleEssences(item: InventoryItem) {
|
||||
const buildTags = normalizeBuildTags([
|
||||
...(item.buildProfile?.tags ?? []),
|
||||
item.buildProfile?.role ?? '',
|
||||
]).slice(0, item.rarity === 'legendary' ? 3 : 2);
|
||||
|
||||
return buildTags.map(tag => buildTagEssence(tag));
|
||||
}
|
||||
|
||||
function enhanceStatProfile(statProfile: ItemStatProfile | null | undefined, slot: EquipmentSlotId | null) {
|
||||
const nextProfile = { ...(statProfile ?? {}) };
|
||||
nextProfile.maxHpBonus = (nextProfile.maxHpBonus ?? 0) + (slot === 'armor' ? 10 : 4);
|
||||
nextProfile.maxManaBonus = (nextProfile.maxManaBonus ?? 0) + (slot === 'relic' ? 10 : 4);
|
||||
nextProfile.outgoingDamageBonus = Number(((nextProfile.outgoingDamageBonus ?? 0) + 0.03).toFixed(3));
|
||||
|
||||
if (typeof nextProfile.incomingDamageMultiplier === 'number') {
|
||||
nextProfile.incomingDamageMultiplier = Number(Math.max(0.72, nextProfile.incomingDamageMultiplier - 0.03).toFixed(3));
|
||||
} else if (slot === 'armor' || slot === 'relic') {
|
||||
nextProfile.incomingDamageMultiplier = slot === 'armor' ? 0.94 : 0.97;
|
||||
}
|
||||
|
||||
return nextProfile;
|
||||
}
|
||||
|
||||
function buildReforgedItem(item: InventoryItem) {
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
if (!slot || !item.buildProfile) return null;
|
||||
|
||||
const currentTags = normalizeBuildTags(item.buildProfile.tags);
|
||||
const primaryTag = currentTags[0];
|
||||
const replacement = primaryTag
|
||||
? getSimilarBuildTags(primaryTag, 0.6).find(tag => !currentTags.includes(tag)) ?? primaryTag
|
||||
: null;
|
||||
|
||||
const nextTags = normalizeBuildTags([
|
||||
...(replacement ? [replacement] : []),
|
||||
...currentTags.slice(replacement && replacement !== primaryTag ? 1 : 0),
|
||||
]).slice(0, 3);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: createItemId(`reforge:${item.name}`),
|
||||
name: item.name.includes('重铸') ? item.name : `${item.name}·重铸`,
|
||||
statProfile: enhanceStatProfile(item.statProfile, slot),
|
||||
buildProfile: {
|
||||
...item.buildProfile,
|
||||
role: normalizeBuildRole(item.buildProfile.role),
|
||||
tags: nextTags,
|
||||
forgeRank: (item.buildProfile.forgeRank ?? 0) + 1,
|
||||
synergy: nextTags,
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
|
||||
function getReforgeCost(slot: EquipmentSlotId | null) {
|
||||
if (slot === 'relic') {
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement('凝光纱', 1)],
|
||||
currencyCost: 52,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement('精炼锭材', 1)],
|
||||
currencyCost: 46,
|
||||
};
|
||||
}
|
||||
|
||||
export function getForgeRecipeViews(
|
||||
inventory: InventoryItem[],
|
||||
playerCurrency = 0,
|
||||
worldType: WorldType | null = null,
|
||||
) {
|
||||
return FORGE_RECIPES.map(recipe => ({
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
kind: recipe.kind,
|
||||
description: recipe.description,
|
||||
resultLabel: recipe.resultLabel,
|
||||
currencyCost: recipe.currencyCost,
|
||||
currencyText: formatCurrency(recipe.currencyCost, worldType),
|
||||
requirements: recipe.requirements.map(requirement => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
owned: countMatchingItems(inventory, requirement),
|
||||
})),
|
||||
canCraft:
|
||||
playerCurrency >= recipe.currencyCost &&
|
||||
recipe.requirements.every(requirement => countMatchingItems(inventory, requirement) >= requirement.quantity),
|
||||
})) satisfies ForgeRecipeView[];
|
||||
}
|
||||
|
||||
export function executeForgeRecipe(
|
||||
inventory: InventoryItem[],
|
||||
recipeId: string,
|
||||
worldType: WorldType | null,
|
||||
playerCurrency: number,
|
||||
): ForgeExecutionResult | null {
|
||||
const recipe = FORGE_RECIPES.find(candidate => candidate.id === recipeId);
|
||||
if (!recipe || playerCurrency < recipe.currencyCost) return null;
|
||||
|
||||
const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements);
|
||||
if (!consumedInventory) return null;
|
||||
|
||||
const createdItem = recipe.createResult(worldType);
|
||||
return {
|
||||
inventory: addInventoryItems(consumedInventory, [createdItem]),
|
||||
currency: playerCurrency - recipe.currencyCost,
|
||||
createdItem,
|
||||
};
|
||||
}
|
||||
|
||||
export function executeDismantleItem(inventory: InventoryItem[], itemId: string): DismantleExecutionResult | null {
|
||||
const targetItem = inventory.find(item => item.id === itemId);
|
||||
if (!targetItem || targetItem.quantity <= 0) return null;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(targetItem);
|
||||
if (!slot && !targetItem.buildProfile) return null;
|
||||
|
||||
const outputs = [
|
||||
...buildDismantleBaseMaterials(targetItem, slot),
|
||||
...buildDismantleEssences(targetItem),
|
||||
];
|
||||
|
||||
return {
|
||||
inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs),
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
|
||||
export function executeReforgeItem(
|
||||
inventory: InventoryItem[],
|
||||
itemId: string,
|
||||
playerCurrency: number,
|
||||
): ReforgeExecutionResult | null {
|
||||
const targetItem = inventory.find(item => item.id === itemId);
|
||||
if (!targetItem || targetItem.quantity <= 0) return null;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(targetItem);
|
||||
const reforgedItem = buildReforgedItem(targetItem);
|
||||
const reforgeCost = getReforgeCost(slot);
|
||||
if (!reforgedItem || playerCurrency < reforgeCost.currencyCost) return null;
|
||||
|
||||
const consumedInventory = applyRequirementsIfPossible(
|
||||
removeInventoryItem(inventory, itemId, 1),
|
||||
reforgeCost.requirements,
|
||||
);
|
||||
if (!consumedInventory) return null;
|
||||
|
||||
return {
|
||||
inventory: addInventoryItems(consumedInventory, [reforgedItem]),
|
||||
reforgedItem,
|
||||
currencyCost: reforgeCost.currencyCost,
|
||||
};
|
||||
}
|
||||
|
||||
export function getReforgeCostView(item: InventoryItem, worldType: WorldType | null) {
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
const cost = getReforgeCost(slot);
|
||||
return {
|
||||
currencyCost: cost.currencyCost,
|
||||
currencyText: formatCurrency(cost.currencyCost, worldType),
|
||||
requirements: cost.requirements.map(requirement => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildForgeSuccessText(action: 'craft' | 'dismantle' | 'reforge', params: {
|
||||
sourceItemName?: string;
|
||||
recipeName?: string;
|
||||
createdItemName?: string;
|
||||
outputNames?: string[];
|
||||
currencyText?: string;
|
||||
}) {
|
||||
if (action === 'craft') {
|
||||
return `你在工坊中完成了${params.recipeName},获得了${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}。`;
|
||||
}
|
||||
|
||||
if (action === 'reforge') {
|
||||
return `你消耗材料重新淬炼了${params.sourceItemName},最终得到${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}。`;
|
||||
}
|
||||
|
||||
return `你拆解了${params.sourceItemName},回收出${(params.outputNames ?? []).join('、')}。`;
|
||||
}
|
||||
Reference in New Issue
Block a user