430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
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}`;
|
|
}
|