This commit is contained in:
429
src/hooks/combatStoryUtils.ts
Normal file
429
src/hooks/combatStoryUtils.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { createFallbackOption } from '../data/hostileNpcs';
|
||||
import {
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
getFunctionById,
|
||||
getFunctionEffect,
|
||||
getFunctionSkillWeights,
|
||||
resolveFunctionOption,
|
||||
} from '../data/stateFunctions';
|
||||
import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '../types';
|
||||
|
||||
const FALLBACK_STORY: StoryMoment = {
|
||||
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
|
||||
options: [
|
||||
createFallbackOption('battle_attack_basic', '普通攻击', AnimationState.ATTACK, 0, false),
|
||||
createFallbackOption('battle_recover_breath', '恢复', AnimationState.IDLE, 0, false),
|
||||
createFallbackOption('battle_escape_breakout', '逃跑', AnimationState.RUN, -0.6, false),
|
||||
],
|
||||
};
|
||||
|
||||
type CombatStyle = 'all_in' | 'steady' | 'escape';
|
||||
|
||||
function getDefaultSkillWeight(skill: Character['skills'][number], style: CombatStyle) {
|
||||
if (style === 'escape') return 0;
|
||||
|
||||
if (style === 'all_in') {
|
||||
if (skill.style === 'finisher') return 5;
|
||||
if (skill.style === 'burst') return 4;
|
||||
if (skill.style === 'mobility') return 2.5;
|
||||
if (skill.style === 'projectile') return 2;
|
||||
return 1.5;
|
||||
}
|
||||
|
||||
if (skill.style === 'steady') return 4;
|
||||
if (skill.style === 'mobility') return 2.5;
|
||||
if (skill.style === 'projectile') return 2.2;
|
||||
if (skill.style === 'burst') return 2;
|
||||
return 1.4;
|
||||
}
|
||||
|
||||
export function classifyCombatOption(option: StoryOption) {
|
||||
const functionMeta = getFunctionById(option.functionId);
|
||||
if (functionMeta?.category === 'escape') return 'escape' as const;
|
||||
if (functionMeta?.state === 'idle') return 'idle' as const;
|
||||
return 'battle' as const;
|
||||
}
|
||||
|
||||
export function inferCombatStyle(option: StoryOption): CombatStyle {
|
||||
const category = getFunctionById(option.functionId)?.category;
|
||||
const text = option.text ?? option.actionText;
|
||||
if (category === 'escape') return 'escape';
|
||||
if (option.functionId === 'battle_all_in_crush' || option.functionId === 'battle_finisher_window') return 'all_in';
|
||||
if (classifyCombatOption(option) === 'escape') return 'escape';
|
||||
if (text.includes('全力') || text.includes('压上') || text.includes('猛攻')) return 'all_in';
|
||||
return 'steady';
|
||||
}
|
||||
|
||||
function getAvailableSkills(
|
||||
character: Character,
|
||||
mana: number,
|
||||
cooldowns: Record<string, number>,
|
||||
option: StoryOption,
|
||||
) {
|
||||
return character.skills
|
||||
.filter(skill => (cooldowns[skill.id] ?? 0) <= 0 && mana >= skill.manaCost)
|
||||
.map(skill => ({
|
||||
skill,
|
||||
weight: option.skillProbabilities?.[skill.id] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function chooseWeightedSkill(
|
||||
character: Character,
|
||||
mana: number,
|
||||
cooldowns: Record<string, number>,
|
||||
option: StoryOption,
|
||||
) {
|
||||
const available = getAvailableSkills(character, mana, cooldowns, option);
|
||||
if (available.length === 0) return null;
|
||||
|
||||
const total = available.reduce((sum, item) => sum + Math.max(item.weight, 0.01), 0);
|
||||
let roll = Math.random() * total;
|
||||
|
||||
for (const item of available) {
|
||||
roll -= Math.max(item.weight, 0.01);
|
||||
if (roll <= 0) {
|
||||
return item.skill;
|
||||
}
|
||||
}
|
||||
|
||||
return available[available.length - 1]?.skill ?? null;
|
||||
}
|
||||
|
||||
export function chooseWeightedSkillForStyle(
|
||||
character: Character,
|
||||
mana: number,
|
||||
cooldowns: Record<string, number>,
|
||||
style: CombatStyle,
|
||||
) {
|
||||
const available = character.skills
|
||||
.filter(skill => (cooldowns[skill.id] ?? 0) <= 0 && mana >= skill.manaCost)
|
||||
.map(skill => ({
|
||||
skill,
|
||||
weight: getDefaultSkillWeight(skill, style),
|
||||
}));
|
||||
|
||||
if (available.length === 0) return null;
|
||||
|
||||
const total = available.reduce((sum, item) => sum + Math.max(item.weight, 0.01), 0);
|
||||
let roll = Math.random() * total;
|
||||
|
||||
for (const item of available) {
|
||||
roll -= Math.max(item.weight, 0.01);
|
||||
if (roll <= 0) {
|
||||
return item.skill;
|
||||
}
|
||||
}
|
||||
|
||||
return available[available.length - 1]?.skill ?? null;
|
||||
}
|
||||
|
||||
export function normalizeSkillProbabilities(option: StoryOption, character: Character) {
|
||||
const functionWeights = getFunctionSkillWeights(option.functionId);
|
||||
const style = inferCombatStyle(option);
|
||||
const weighted = character.skills.map(skill => {
|
||||
const styleWeight = functionWeights?.[skill.style];
|
||||
const rawWeight = typeof styleWeight === 'number' ? styleWeight : option.skillProbabilities?.[skill.id];
|
||||
const fallbackWeight = getDefaultSkillWeight(skill, style);
|
||||
return {
|
||||
skillId: skill.id,
|
||||
weight: Math.max(0, typeof rawWeight === 'number' ? rawWeight : fallbackWeight),
|
||||
};
|
||||
});
|
||||
|
||||
const total = weighted.reduce((sum, item) => sum + item.weight, 0);
|
||||
const normalized = total > 0
|
||||
? Object.fromEntries(weighted.map(item => [item.skillId, Number((item.weight / total).toFixed(4))]))
|
||||
: Object.fromEntries(character.skills.map(skill => [skill.id, Number((1 / character.skills.length).toFixed(4))]));
|
||||
|
||||
return {
|
||||
...option,
|
||||
skillProbabilities: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function createSingleActionBattleOption(
|
||||
functionId: string,
|
||||
actionText: string,
|
||||
playerAnimation: AnimationState,
|
||||
detailText?: string,
|
||||
extras: Partial<StoryOption> = {},
|
||||
) {
|
||||
return {
|
||||
...createFallbackOption(functionId, actionText, playerAnimation, functionId === 'battle_escape_breakout' ? -0.6 : 0, functionId === 'battle_escape_breakout'),
|
||||
detailText,
|
||||
...extras,
|
||||
} satisfies StoryOption;
|
||||
}
|
||||
|
||||
function getBasicAttackDamage(character: Character) {
|
||||
return Math.max(
|
||||
8,
|
||||
Math.round(
|
||||
character.attributes.strength * 0.85 + character.attributes.agility * 0.45,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function pickPreferredBattleItem(state: GameState, character: Character) {
|
||||
const hasCoolingSkill = Object.values(state.playerSkillCooldowns).some(
|
||||
(turns) => turns > 0,
|
||||
);
|
||||
const playerHpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
|
||||
const playerManaRatio = state.playerMana / Math.max(state.playerMaxMana, 1);
|
||||
|
||||
return state.playerInventory
|
||||
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
|
||||
.map((item) => {
|
||||
const effect = resolveInventoryItemUseEffect(item, character);
|
||||
if (!effect) return null;
|
||||
|
||||
const score =
|
||||
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
|
||||
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
|
||||
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
|
||||
effect.buildBuffs.length * 8;
|
||||
|
||||
return { item, effect, score };
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
candidate,
|
||||
): candidate is {
|
||||
item: GameState['playerInventory'][number];
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
|
||||
score: number;
|
||||
} => Boolean(candidate),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
right.effect.hpRestore - left.effect.hpRestore ||
|
||||
right.effect.manaRestore - left.effect.manaRestore ||
|
||||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
|
||||
)[0] ?? null;
|
||||
}
|
||||
|
||||
function buildBattleItemSummary(
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
|
||||
) {
|
||||
const parts = [
|
||||
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
|
||||
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
|
||||
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
|
||||
effect.buildBuffs.length > 0
|
||||
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(' / ') || '立即结算一次物品效果';
|
||||
}
|
||||
|
||||
function buildSingleActionBattleOptions(state: GameState, character: Character) {
|
||||
const preferredItem = pickPreferredBattleItem(state, character);
|
||||
|
||||
return [
|
||||
createSingleActionBattleOption(
|
||||
'battle_attack_basic',
|
||||
'普通攻击',
|
||||
AnimationState.ATTACK,
|
||||
`不耗蓝 / 伤害 ${getBasicAttackDamage(character)}`,
|
||||
),
|
||||
createSingleActionBattleOption(
|
||||
'battle_recover_breath',
|
||||
'恢复',
|
||||
AnimationState.IDLE,
|
||||
'回血 12 / 回蓝 9 / 冷却 -1',
|
||||
),
|
||||
preferredItem
|
||||
? createSingleActionBattleOption(
|
||||
'inventory_use',
|
||||
`使用物品:${preferredItem.item.name}`,
|
||||
AnimationState.ACQUIRE,
|
||||
buildBattleItemSummary(preferredItem.effect),
|
||||
{
|
||||
runtimePayload: { itemId: preferredItem.item.id },
|
||||
},
|
||||
)
|
||||
: createSingleActionBattleOption(
|
||||
'inventory_use',
|
||||
'使用物品',
|
||||
AnimationState.ACQUIRE,
|
||||
'当前没有可直接结算的战斗消耗品',
|
||||
{
|
||||
disabled: true,
|
||||
disabledReason: '暂无可用物品',
|
||||
},
|
||||
),
|
||||
...character.skills.map((skill) => {
|
||||
const remainingCooldown = state.playerSkillCooldowns[skill.id] ?? 0;
|
||||
const detailText = [
|
||||
`耗蓝 ${skill.manaCost}`,
|
||||
`伤害 ${skill.damage}`,
|
||||
`冷却 ${skill.cooldownTurns}`,
|
||||
].join(' / ');
|
||||
|
||||
if (remainingCooldown > 0) {
|
||||
return createSingleActionBattleOption(
|
||||
'battle_use_skill',
|
||||
skill.name,
|
||||
skill.animation,
|
||||
detailText,
|
||||
{
|
||||
runtimePayload: { skillId: skill.id },
|
||||
disabled: true,
|
||||
disabledReason: `冷却中,还需 ${remainingCooldown} 回合`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (skill.manaCost > state.playerMana) {
|
||||
return createSingleActionBattleOption(
|
||||
'battle_use_skill',
|
||||
skill.name,
|
||||
skill.animation,
|
||||
detailText,
|
||||
{
|
||||
runtimePayload: { skillId: skill.id },
|
||||
disabled: true,
|
||||
disabledReason: '灵力不足',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return createSingleActionBattleOption(
|
||||
'battle_use_skill',
|
||||
skill.name,
|
||||
skill.animation,
|
||||
detailText,
|
||||
{
|
||||
runtimePayload: { skillId: skill.id },
|
||||
},
|
||||
);
|
||||
}),
|
||||
createSingleActionBattleOption(
|
||||
'battle_escape_breakout',
|
||||
'逃跑',
|
||||
AnimationState.RUN,
|
||||
'立刻脱离当前战斗',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function getFallbackOptionsForState(state: GameState, character: Character) {
|
||||
if (state.inBattle) {
|
||||
return buildSingleActionBattleOptions(state, character);
|
||||
}
|
||||
|
||||
if (!state.worldType) {
|
||||
return FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
|
||||
}
|
||||
|
||||
const functionContext = {
|
||||
worldType: state.worldType,
|
||||
playerCharacter: character,
|
||||
inBattle: state.inBattle,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
monsters: state.sceneHostileNpcs,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
};
|
||||
|
||||
const options = getDefaultFunctionIdsForContext(functionContext)
|
||||
.map(functionId => resolveFunctionOption(functionId, functionContext))
|
||||
.filter(Boolean) as StoryOption[];
|
||||
|
||||
return options.length > 0
|
||||
? options.map(option => normalizeSkillProbabilities(option, character))
|
||||
: FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
|
||||
}
|
||||
|
||||
export function buildFallbackStoryMoment(state: GameState, character: Character): StoryMoment {
|
||||
const primaryMonster = state.sceneHostileNpcs.find(monster => monster.hp > 0) ?? state.sceneHostileNpcs[0];
|
||||
const text = state.inBattle && primaryMonster
|
||||
? `${primaryMonster.name}${primaryMonster.action},战斗还没有结束。`
|
||||
: `${state.currentScenePreset?.name ?? '前方区域'}暂时平静下来,你可以继续探索或前往新的地点。`;
|
||||
|
||||
return {
|
||||
text,
|
||||
options: getFallbackOptionsForState(state, character),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOptionImpactSummary(
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
hp: number,
|
||||
maxHp: number,
|
||||
mana: number,
|
||||
maxMana: number,
|
||||
cooldowns: Record<string, number>,
|
||||
currentNpcBattleMode: GameState['currentNpcBattleMode'] = null,
|
||||
) {
|
||||
if (option.functionId === 'battle_attack_basic') {
|
||||
return currentNpcBattleMode === 'spar'
|
||||
? '切磋伤害 1'
|
||||
: `耗蓝 0 / 伤害 ${getBasicAttackDamage(character)}`;
|
||||
}
|
||||
|
||||
if (option.functionId === 'battle_use_skill') {
|
||||
const skillId =
|
||||
typeof option.runtimePayload?.skillId === 'string'
|
||||
? option.runtimePayload.skillId
|
||||
: '';
|
||||
const skill = character.skills.find((candidate) => candidate.id === skillId);
|
||||
if (!skill) return null;
|
||||
|
||||
return currentNpcBattleMode === 'spar'
|
||||
? '切磋伤害 1'
|
||||
: `耗蓝 ${skill.manaCost} / 伤害 ${skill.damage}`;
|
||||
}
|
||||
|
||||
const functionMeta = getFunctionById(option.functionId);
|
||||
if (!functionMeta) return null;
|
||||
|
||||
if (functionMeta.category === 'recovery') {
|
||||
const effect = getFunctionEffect(option.functionId);
|
||||
const parts: string[] = [];
|
||||
|
||||
if ((effect.healAmount ?? 0) > 0) {
|
||||
const healAmount = Math.max(0, Math.min(effect.healAmount ?? 0, maxHp - hp));
|
||||
parts.push(`回血 ${healAmount}`);
|
||||
}
|
||||
|
||||
if ((effect.manaRestore ?? 0) > 0) {
|
||||
const manaRestore = Math.max(0, Math.min(effect.manaRestore ?? 0, maxMana - mana));
|
||||
parts.push(`回蓝 ${manaRestore}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0 && (effect.cooldownTickBonus ?? 0) > 0) {
|
||||
parts.push(`减冷却 ${effect.cooldownTickBonus} 回合`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
}
|
||||
|
||||
if (functionMeta.category !== 'battle') return null;
|
||||
|
||||
if (currentNpcBattleMode === 'spar') {
|
||||
return '切磋伤害 1';
|
||||
}
|
||||
|
||||
const normalizedOption = normalizeSkillProbabilities(option, character);
|
||||
const availableSkills = getAvailableSkills(character, mana, cooldowns, normalizedOption).sort(
|
||||
(a, b) => b.weight - a.weight,
|
||||
);
|
||||
const topSkill = availableSkills[0]?.skill;
|
||||
if (!topSkill) return '耗蓝 -- / 伤害 --';
|
||||
|
||||
const damageMultiplier = getFunctionEffect(option.functionId).damageMultiplier ?? 1;
|
||||
const damage = Math.max(1, Math.round(topSkill.damage * damageMultiplier));
|
||||
return `耗蓝 ${topSkill.manaCost} / 伤害 ${damage}`;
|
||||
}
|
||||
Reference in New Issue
Block a user