import { resolveRoleCombatStats } from '../../data/attributeCombat'; import { resolveCharacterAttributeProfile } from '../../data/attributeResolver'; import { appendBuildBuffs, resolveCompanionOutgoingDamageResult, resolveMonsterOutgoingDamageResult, resolvePlayerOutgoingDamageResult, tickBuildBuffs, } from '../../data/buildDamage'; import { getSkillDelivery, } from '../../data/characterCombat'; import { getCharacterById, getCharacterMaxMana, } from '../../data/characterPresets'; import { getEquipmentBonuses } from '../../data/equipmentEffects'; import { getClosestHostileNpc, getFacingTowardPlayer, settleHostileNpcAnimations, } from '../../data/hostileNpcs'; import { getFunctionEffect } from '../../data/stateFunctions'; import type { Character, CharacterSkillDefinition, CombatDelivery, CompanionState, GameState, SceneHostileNpc, StoryOption, } from '../../types'; import { AnimationState, } from '../../types'; import { chooseWeightedSkill, chooseWeightedSkillForStyle, inferCombatStyle, normalizeSkillProbabilities, } from '../combatStoryUtils'; type TurnActor = 'player' | 'companion' | 'monster'; export type BattlePlanStep = | { actor: 'player'; targetHostileNpcId: string; originalPlayerX: number; strikeX: number; cooledDown: Record; selectedSkillId: string | null; appliedCooldowns: Record; damage: number; criticalHit?: boolean; defeated: boolean; endsBattle: boolean; delivery: CombatDelivery; } | { actor: 'companion'; companionNpcId: string; targetHostileNpcId: string; strikeOffsetX: number; cooledDown: Record; selectedSkillId: string | null; appliedCooldowns: Record; damage: number; criticalHit?: boolean; defeated: boolean; endsBattle: boolean; delivery: CombatDelivery; } | { actor: 'monster'; monsterId: string; originalMonsterX: number; strikeX: number; target: 'player' | 'companion'; targetCompanionNpcId?: string; targetX: number; damage: number; criticalHit?: boolean; endsBattle: boolean; selectedSkillId: string | null; npcCharacterId: string | null; delivery: CombatDelivery; }; export type BattlePlan = { preparedState: GameState; turns: BattlePlanStep[]; finalState: GameState; }; function createEmptyCooldowns(character: Character) { return Object.fromEntries(character.skills.map(skill => [skill.id, 0])); } function normalizeCooldowns(character: Character, cooldowns: Record) { return Object.fromEntries(character.skills.map(skill => [skill.id, Math.max(0, cooldowns[skill.id] ?? 0)])); } function isCompanionAlive(companion: CompanionState) { return companion.hp > 0; } export function resetCompanionCombatPresentation(companions: CompanionState[]) { return companions.map(companion => ({ ...companion, animationState: companion.hp > 0 ? AnimationState.IDLE : AnimationState.DIE, actionMode: 'idle' as const, offsetX: 0, offsetY: 0, transitionMs: 0, })); } export function updateCompanionState( companions: CompanionState[], npcId: string, updater: (companion: CompanionState) => CompanionState, ) { return companions.map(companion => companion.npcId === npcId ? updater(companion) : companion); } function getCompanionSlotIndex(companions: CompanionState[], npcId: string) { return Math.max(0, companions.findIndex(companion => companion.npcId === npcId)); } export function getCompanionAnchorX(playerX: number, companions: CompanionState[], npcId: string) { const slotIndex = getCompanionSlotIndex(companions, npcId); return Number((playerX - (slotIndex % 2 === 0 ? 0.38 : 0.18)).toFixed(2)); } function getCompanionStrikeOffset(companions: CompanionState[], npcId: string) { const slotIndex = getCompanionSlotIndex(companions, npcId); return slotIndex % 2 === 0 ? 54 : 44; } function getLivingPartyTargets(state: GameState) { const targets: Array<{ kind: 'player' } | { kind: 'companion'; npcId: string }> = []; if (state.playerHp > 0) { targets.push({ kind: 'player' }); } if (state.currentNpcBattleMode === 'spar') { return targets; } state.companions.filter(isCompanionAlive).forEach(companion => { targets.push({ kind: 'companion', npcId: companion.npcId }); }); return targets; } function chooseRandomPartyTarget(state: GameState) { const targets = getLivingPartyTargets(state); if (targets.length === 0) return null; return targets[Math.floor(Math.random() * targets.length)] ?? null; } function getCombatActorKey(actor: TurnActor, id?: string) { return id ? `${actor}:${id}` : actor; } function buildCombatTurnOrder( state: GameState, playerCharacter: Character, sequenceMs: number, turnVisualMs: number, resetStageMs: number, minTurnCount: number, ) { const actorTimings = new Map(); actorTimings.set(getCombatActorKey('player'), { actor: 'player', nextAt: 0, cadence: 1400 / Math.max( resolveRoleCombatStats( resolveCharacterAttributeProfile( playerCharacter, state.worldType, state.customWorldProfile, ), ).turnSpeed, 1, ), }); state.companions .filter(companion => state.currentNpcBattleMode !== 'spar' && isCompanionAlive(companion)) .forEach(companion => { const companionCharacter = getCharacterById(companion.characterId); if (!companionCharacter) return; actorTimings.set(getCombatActorKey('companion', companion.npcId), { actor: 'companion', id: companion.npcId, nextAt: 0, cadence: 1400 / Math.max( resolveRoleCombatStats( resolveCharacterAttributeProfile( companionCharacter, state.worldType, state.customWorldProfile, ), ).turnSpeed, 1, ), }); }); state.sceneHostileNpcs.forEach(monster => { actorTimings.set(getCombatActorKey('monster', monster.id), { actor: 'monster', id: monster.id, nextAt: 0, cadence: 1400 / Math.max(monster.speed, 1), }); }); const turnOrder: Array<{ actor: TurnActor; id?: string }> = []; while (turnOrder.length < minTurnCount || turnOrder.length * (turnVisualMs + resetStageMs) < sequenceMs) { const availableActors = [...actorTimings.values()].filter(item => { if (item.actor === 'player') return state.playerHp > 0; if (item.actor === 'companion') { return state.companions.some(companion => companion.npcId === item.id && isCompanionAlive(companion)); } return state.sceneHostileNpcs.some(monster => monster.id === item.id && monster.hp > 0); }); if (availableActors.length === 0) break; availableActors.sort((a, b) => a.nextAt - b.nextAt || a.cadence - b.cadence); const nextActor = availableActors[0]; if (!nextActor) break; turnOrder.push({ actor: nextActor.actor, id: nextActor.id }); nextActor.nextAt += nextActor.cadence; } return turnOrder; } export function applyDamageToPartyTarget( state: GameState, target: { kind: 'player' } | { kind: 'companion'; npcId: string }, damage: number, ) { if (target.kind === 'player') { const adjustedDamage = Math.max( 1, Math.round(damage * getEquipmentBonuses(state.playerEquipment).incomingDamageMultiplier), ); return { ...state, playerHp: Math.max(0, state.playerHp - adjustedDamage), }; } return { ...state, companions: updateCompanionState( state.companions, target.npcId, companion => ({ ...companion, hp: Math.max(0, companion.hp - damage), }), ), }; } function tickSkillCooldowns(character: Character, cooldowns: Record) { const normalized = normalizeCooldowns(character, cooldowns); return Object.fromEntries( Object.entries(normalized).map(([skillId, turns]) => [skillId, Math.max(0, turns - 1)]), ); } function getRequestedSkillId(option: StoryOption) { return typeof option.runtimePayload?.skillId === 'string' ? option.runtimePayload.skillId : null; } function choosePlayerSkillForOption( character: Character, mana: number, cooldowns: Record, option: StoryOption, ) { const requestedSkillId = getRequestedSkillId(option); if (requestedSkillId) { const requestedSkill = character.skills.find(skill => skill.id === requestedSkillId) ?? null; if (!requestedSkill) return null; if ((cooldowns[requestedSkill.id] ?? 0) > 0 || mana < requestedSkill.manaCost) return null; return requestedSkill; } return chooseWeightedSkill(character, mana, cooldowns, option); } export function getFacingForPlayer(playerX: number, monster: SceneHostileNpc | null) { if (!monster) return 'right' as const; return monster.xMeters >= playerX ? 'right' : 'left'; } export function getMeleeStrikeX(attackerX: number, defenderX: number) { return defenderX > attackerX ? Number((defenderX - 0.1).toFixed(1)) : Number((defenderX + 0.1).toFixed(1)); } export function getSkillStrikeX(skill: CharacterSkillDefinition, attackerX: number, defenderX: number) { return getSkillDelivery(skill) === 'ranged' ? attackerX : getMeleeStrikeX(attackerX, defenderX); } export function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) { return settleHostileNpcAnimations(monsters).map(monster => ({ ...monster, facing: getFacingTowardPlayer(monster.xMeters, playerX), characterAnimation: undefined, combatMode: undefined, })); } export function applyRecoveryEffectToState( state: GameState, character: Character, functionId: string, ) { const effect = getFunctionEffect(functionId); if ( (effect.healAmount ?? 0) <= 0 && (effect.manaRestore ?? 0) <= 0 && (effect.cooldownTickBonus ?? 0) <= 0 ) { return state; } let cooldowns = state.playerSkillCooldowns; for (let index = 0; index < (effect.cooldownTickBonus ?? 0); index += 1) { cooldowns = tickSkillCooldowns(character, cooldowns); } return { ...state, playerHp: Math.min(state.playerMaxHp, state.playerHp + (effect.healAmount ?? 0)), playerMana: Math.min(state.playerMaxMana, state.playerMana + (effect.manaRestore ?? 0)), playerSkillCooldowns: cooldowns, }; } export function buildBattlePlan({ state, option, character, totalSequenceMs, turnVisualMs, resetStageMs, minTurnCount, }: { state: GameState; option: StoryOption; character: Character; totalSequenceMs: number; turnVisualMs: number; resetStageMs: number; minTurnCount: number; }): BattlePlan { const battleState: GameState = { ...state, }; const targetMonster = getClosestHostileNpc( battleState.playerX, battleState.sceneHostileNpcs, ); if (!targetMonster) { return { preparedState: battleState, turns: [], finalState: { ...battleState, inBattle: false, sceneHostileNpcs: [], companions: resetCompanionCombatPresentation(state.companions), animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, }, }; } const functionEffect = getFunctionEffect(option.functionId); const isRecoveryAction = option.functionId === 'battle_recover_breath'; const isNpcSpar = battleState.currentNpcBattleMode === 'spar'; const sequenceMs = Math.round(totalSequenceMs * (functionEffect.turnTimeMultiplier ?? 1)); const turnOrder = buildCombatTurnOrder( battleState, character, sequenceMs, turnVisualMs, resetStageMs, minTurnCount, ); const normalizedOption = normalizeSkillProbabilities(option, character); const npcBattleResources = new Map; }>(); battleState.sceneHostileNpcs.forEach(monster => { const npcCharacterId = monster.encounter?.characterId ?? null; const npcCharacter = npcCharacterId ? getCharacterById(npcCharacterId) : null; if (!npcCharacter) return; npcBattleResources.set(monster.id, { character: npcCharacter, mana: getCharacterMaxMana(npcCharacter), cooldowns: createEmptyCooldowns(npcCharacter), }); }); let simulatedState: GameState = { ...applyRecoveryEffectToState(battleState, character, option.functionId), companions: resetCompanionCombatPresentation(battleState.companions), sceneHostileNpcs: resetCombatPresentation( battleState.sceneHostileNpcs, battleState.playerX, ), activeCombatEffects: [], playerActionMode: 'idle' as const, currentNpcBattleOutcome: null, }; const preparedState = simulatedState; const turns: BattlePlanStep[] = []; for (const [turnIndex, turn] of turnOrder.entries()) { const currentTarget = getClosestHostileNpc(simulatedState.playerX, simulatedState.sceneHostileNpcs); if (!currentTarget) break; if (turn.actor === 'player') { if (simulatedState.playerHp <= 0) continue; const cooledDown = tickSkillCooldowns(character, simulatedState.playerSkillCooldowns); simulatedState = { ...simulatedState, playerSkillCooldowns: cooledDown, }; // 后端单技能按钮通过 runtimePayload.skillId 指定技能,本地兜底也必须保持同一语义。 const selectedSkill = isRecoveryAction ? null : choosePlayerSkillForOption(character, simulatedState.playerMana, cooledDown, normalizedOption); if (!selectedSkill) { continue; } const originalPlayerX = simulatedState.playerX; const delivery = getSkillDelivery(selectedSkill); const strikeX = getSkillStrikeX(selectedSkill, originalPlayerX, currentTarget.xMeters); const appliedCooldowns = { ...cooledDown, [selectedSkill.id]: selectedSkill.cooldownTurns, }; const damageResult = isNpcSpar ? null : resolvePlayerOutgoingDamageResult( simulatedState, character, selectedSkill.damage, functionEffect.damageMultiplier ?? 1, `${option.functionId}:player:${turnIndex}:${selectedSkill.id}:${currentTarget.id}`, ); const damage = isNpcSpar ? 1 : damageResult!.damage; const wouldEndSpar = isNpcSpar && currentTarget.hp - damage <= 1; const resolvedMonsters = simulatedState.sceneHostileNpcs.map(monster => monster.id === currentTarget.id ? { ...monster, hp: isNpcSpar ? Math.max(1, monster.hp - damage) : Math.max(0, monster.hp - damage), } : monster, ); const defeated = !isNpcSpar && resolvedMonsters.some(monster => monster.id === currentTarget.id && monster.hp <= 0); const remainingMonsters = defeated ? resolvedMonsters.filter(monster => !(monster.id === currentTarget.id && monster.hp <= 0)) : resolvedMonsters; const nextTarget = getClosestHostileNpc(originalPlayerX, remainingMonsters); simulatedState = { ...simulatedState, playerX: originalPlayerX, playerFacing: getFacingForPlayer(originalPlayerX, nextTarget ?? null), animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeBuildBuffs: appendBuildBuffs( tickBuildBuffs(simulatedState.activeBuildBuffs), selectedSkill.buildBuffs, ), activeCombatEffects: [], playerMana: Math.max(0, simulatedState.playerMana - selectedSkill.manaCost), playerSkillCooldowns: appliedCooldowns, sceneHostileNpcs: remainingMonsters.map(monster => ({ ...monster, characterAnimation: undefined, combatMode: undefined, })), companions: resetCompanionCombatPresentation(simulatedState.companions), inBattle: isNpcSpar ? !wouldEndSpar : remainingMonsters.length > 0, currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : (!isNpcSpar && remainingMonsters.length === 0 && simulatedState.currentBattleNpcId ? 'fight_victory' : simulatedState.currentNpcBattleOutcome), }; turns.push({ actor: 'player', targetHostileNpcId: currentTarget.id, originalPlayerX, strikeX, cooledDown, selectedSkillId: selectedSkill.id, appliedCooldowns, damage, criticalHit: damageResult?.isCritical ?? false, defeated, endsBattle: wouldEndSpar, delivery, }); if (!simulatedState.inBattle) { break; } continue; } if (turn.actor === 'companion') { const companion = simulatedState.companions.find(item => item.npcId === turn.id && isCompanionAlive(item)); if (!companion) continue; const companionCharacter = getCharacterById(companion.characterId); if (!companionCharacter) continue; const companionX = getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, companion.npcId); const targetMonster = getClosestHostileNpc(companionX, simulatedState.sceneHostileNpcs); if (!targetMonster) break; const cooledDown = tickSkillCooldowns(companionCharacter, companion.skillCooldowns); simulatedState = { ...simulatedState, companions: updateCompanionState( simulatedState.companions, companion.npcId, currentCompanion => ({ ...currentCompanion, skillCooldowns: cooledDown, }), ), }; const selectedSkill = chooseWeightedSkillForStyle( companionCharacter, companion.mana, cooledDown, inferCombatStyle(option), ); if (!selectedSkill) { continue; } const delivery = getSkillDelivery(selectedSkill); const strikeOffsetX = delivery === 'melee' ? getCompanionStrikeOffset(simulatedState.companions, companion.npcId) : 0; const appliedCooldowns = { ...cooledDown, [selectedSkill.id]: selectedSkill.cooldownTurns, }; const damageResult = isNpcSpar ? null : resolveCompanionOutgoingDamageResult( companionCharacter, selectedSkill.damage, functionEffect.damageMultiplier ?? 1, state.worldType, state.customWorldProfile, `${option.functionId}:companion:${turnIndex}:${companion.npcId}:${selectedSkill.id}:${targetMonster.id}`, ); const damage = isNpcSpar ? 1 : damageResult!.damage; const wouldEndSpar = isNpcSpar && targetMonster.hp - damage <= 1; const resolvedMonsters = simulatedState.sceneHostileNpcs.map(monster => monster.id === targetMonster.id ? { ...monster, hp: isNpcSpar ? Math.max(1, monster.hp - damage) : Math.max(0, monster.hp - damage), } : monster, ); const defeated = !isNpcSpar && resolvedMonsters.some(monster => monster.id === targetMonster.id && monster.hp <= 0); const remainingMonsters = defeated ? resolvedMonsters.filter(monster => !(monster.id === targetMonster.id && monster.hp <= 0)) : resolvedMonsters; simulatedState = { ...simulatedState, companions: updateCompanionState( resetCompanionCombatPresentation(simulatedState.companions), companion.npcId, currentCompanion => ({ ...currentCompanion, skillCooldowns: appliedCooldowns, }), ), sceneHostileNpcs: remainingMonsters.map(monster => ({ ...monster, characterAnimation: undefined, combatMode: undefined, })), inBattle: isNpcSpar ? !wouldEndSpar : remainingMonsters.length > 0, currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : (!isNpcSpar && remainingMonsters.length === 0 && simulatedState.currentBattleNpcId ? 'fight_victory' : simulatedState.currentNpcBattleOutcome), }; turns.push({ actor: 'companion', companionNpcId: companion.npcId, targetHostileNpcId: targetMonster.id, strikeOffsetX, cooledDown, selectedSkillId: selectedSkill.id, appliedCooldowns, damage, criticalHit: damageResult?.isCritical ?? false, defeated, endsBattle: wouldEndSpar, delivery, }); if (!simulatedState.inBattle) { break; } continue; } const actingMonster = simulatedState.sceneHostileNpcs.find(monster => monster.id === turn.id && monster.hp > 0); if (!actingMonster) continue; const randomTarget = chooseRandomPartyTarget(simulatedState); if (!randomTarget) break; const originalMonsterX = actingMonster.xMeters; const targetX = randomTarget.kind === 'player' ? simulatedState.playerX : getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, randomTarget.npcId); const npcCombatant = npcBattleResources.get(actingMonster.id); if (npcCombatant) { const cooledDown = tickSkillCooldowns(npcCombatant.character, npcCombatant.cooldowns); const selectedSkill = chooseWeightedSkillForStyle( npcCombatant.character, npcCombatant.mana, cooledDown, inferCombatStyle(option), ); npcBattleResources.set(actingMonster.id, { ...npcCombatant, cooldowns: cooledDown, }); if (selectedSkill) { const delivery = getSkillDelivery(selectedSkill); const strikeX = getSkillStrikeX(selectedSkill, originalMonsterX, targetX); const damageResult = isNpcSpar ? null : resolveCompanionOutgoingDamageResult( npcCombatant.character, selectedSkill.damage, functionEffect.incomingDamageMultiplier ?? 1, state.worldType, state.customWorldProfile, `${option.functionId}:monster-skill:${turnIndex}:${actingMonster.id}:${selectedSkill.id}:${randomTarget.kind}:${randomTarget.kind === 'companion' ? randomTarget.npcId : 'player'}`, ); const damage = isNpcSpar ? 1 : damageResult!.damage; const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1; npcBattleResources.set(actingMonster.id, { character: npcCombatant.character, mana: npcCombatant.mana, cooldowns: { ...cooledDown, [selectedSkill.id]: selectedSkill.cooldownTurns, }, }); const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage); simulatedState = { ...damagedState, companions: resetCompanionCombatPresentation(damagedState.companions), sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({ ...monster, xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters, animation: 'idle' as const, facing: getFacingTowardPlayer( monster.id === actingMonster.id ? originalMonsterX : monster.xMeters, simulatedState.playerX, ), characterAnimation: undefined, combatMode: undefined, })), playerHp: isNpcSpar && randomTarget.kind === 'player' ? Math.max(1, damagedState.playerHp) : damagedState.playerHp, inBattle: isNpcSpar ? !wouldEndSpar : damagedState.inBattle, currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : simulatedState.currentNpcBattleOutcome, }; turns.push({ actor: 'monster', monsterId: actingMonster.id, originalMonsterX, strikeX, target: randomTarget.kind, targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined, targetX, damage, criticalHit: damageResult?.isCritical ?? false, endsBattle: wouldEndSpar, selectedSkillId: selectedSkill.id, npcCharacterId: npcCombatant.character.id, delivery, }); continue; } } const strikeX = getMeleeStrikeX(originalMonsterX, targetX); const damageResult = isNpcSpar ? null : resolveMonsterOutgoingDamageResult( actingMonster, 9, functionEffect.incomingDamageMultiplier ?? 1, state.worldType, state.customWorldProfile, `${option.functionId}:monster:${turnIndex}:${actingMonster.id}:${randomTarget.kind}:${randomTarget.kind === 'companion' ? randomTarget.npcId : 'player'}`, ); const damage = isNpcSpar ? 1 : damageResult!.damage; const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1; const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage); simulatedState = { ...damagedState, companions: resetCompanionCombatPresentation(damagedState.companions), sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({ ...monster, xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters, animation: 'idle' as const, facing: getFacingTowardPlayer( monster.id === actingMonster.id ? originalMonsterX : monster.xMeters, simulatedState.playerX, ), characterAnimation: undefined, combatMode: undefined, })), playerHp: isNpcSpar && randomTarget.kind === 'player' ? Math.max(1, damagedState.playerHp) : damagedState.playerHp, inBattle: isNpcSpar ? !wouldEndSpar : damagedState.inBattle, currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : simulatedState.currentNpcBattleOutcome, }; turns.push({ actor: 'monster', monsterId: actingMonster.id, originalMonsterX, strikeX, target: randomTarget.kind, targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined, targetX, damage, criticalHit: damageResult?.isCritical ?? false, endsBattle: wouldEndSpar, selectedSkillId: null, npcCharacterId: null, delivery: 'melee', }); } return { preparedState, turns, finalState: { ...simulatedState, companions: resetCompanionCombatPresentation(simulatedState.companions), animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: simulatedState.currentNpcBattleOutcome === 'spar_complete' ? false : simulatedState.sceneHostileNpcs.length > 0, sceneHostileNpcs: resetCombatPresentation(simulatedState.sceneHostileNpcs, simulatedState.playerX), }, }; }