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, 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, 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, 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 = {}, ) { 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>; 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>, ) { 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, 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}`; }