741
src/hooks/combat/battlePlan.ts
Normal file
741
src/hooks/combat/battlePlan.ts
Normal file
@@ -0,0 +1,741 @@
|
||||
import { resolveCharacterAttributeProfile } from '../../data/attributeResolver';
|
||||
import { appendBuildBuffs, resolveCompanionOutgoingDamage, resolveMonsterOutgoingDamage, resolvePlayerOutgoingDamage, tickBuildBuffs } from '../../data/buildDamage';
|
||||
import {
|
||||
getSkillDelivery,
|
||||
} from '../../data/characterCombat';
|
||||
import {
|
||||
getCharacterById,
|
||||
getCharacterMaxMana,
|
||||
} from '../../data/characterPresets';
|
||||
import { getEquipmentBonuses } from '../../data/equipmentEffects';
|
||||
import {
|
||||
getClosestMonster,
|
||||
getFacingTowardPlayer,
|
||||
settleMonsterAnimations,
|
||||
} from '../../data/hostileNpcs';
|
||||
import { getFunctionEffect } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CombatDelivery,
|
||||
CompanionState,
|
||||
GameState,
|
||||
SceneMonster,
|
||||
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<string, number>;
|
||||
selectedSkillId: string | null;
|
||||
appliedCooldowns: Record<string, number>;
|
||||
damage: number;
|
||||
defeated: boolean;
|
||||
endsBattle: boolean;
|
||||
delivery: CombatDelivery;
|
||||
}
|
||||
| {
|
||||
actor: 'companion';
|
||||
companionNpcId: string;
|
||||
targetHostileNpcId: string;
|
||||
strikeOffsetX: number;
|
||||
cooledDown: Record<string, number>;
|
||||
selectedSkillId: string | null;
|
||||
appliedCooldowns: Record<string, number>;
|
||||
damage: number;
|
||||
defeated: boolean;
|
||||
endsBattle: boolean;
|
||||
delivery: CombatDelivery;
|
||||
}
|
||||
| {
|
||||
actor: 'monster';
|
||||
monsterId: string;
|
||||
originalMonsterX: number;
|
||||
strikeX: number;
|
||||
target: 'player' | 'companion';
|
||||
targetCompanionNpcId?: string;
|
||||
targetX: number;
|
||||
damage: number;
|
||||
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<string, number>) {
|
||||
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<string, { actor: TurnActor; id?: string; nextAt: number; cadence: number }>();
|
||||
|
||||
actorTimings.set(getCombatActorKey('player'), {
|
||||
actor: 'player',
|
||||
nextAt: 0,
|
||||
cadence: 1400 / Math.max((resolveCharacterAttributeProfile(playerCharacter, state.worldType, state.customWorldProfile)?.values.axis_b ?? 48) / 12, 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((resolveCharacterAttributeProfile(companionCharacter, state.worldType, state.customWorldProfile)?.values.axis_b ?? 48) / 12, 1),
|
||||
});
|
||||
});
|
||||
|
||||
state.sceneMonsters.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.sceneMonsters.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<string, number>) {
|
||||
const normalized = normalizeCooldowns(character, cooldowns);
|
||||
return Object.fromEntries(
|
||||
Object.entries(normalized).map(([skillId, turns]) => [skillId, Math.max(0, turns - 1)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function getFacingForPlayer(playerX: number, monster: SceneMonster | 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: SceneMonster[], playerX: number) {
|
||||
return settleMonsterAnimations(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 targetMonster = getClosestMonster(state.playerX, state.sceneMonsters);
|
||||
if (!targetMonster) {
|
||||
return {
|
||||
preparedState: state,
|
||||
turns: [],
|
||||
finalState: {
|
||||
...state,
|
||||
inBattle: false,
|
||||
sceneMonsters: [],
|
||||
companions: resetCompanionCombatPresentation(state.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const functionEffect = getFunctionEffect(option.functionId);
|
||||
const isNpcSpar = state.currentNpcBattleMode === 'spar';
|
||||
const sequenceMs = Math.round(totalSequenceMs * (functionEffect.turnTimeMultiplier ?? 1));
|
||||
const turnOrder = buildCombatTurnOrder(state, character, sequenceMs, turnVisualMs, resetStageMs, minTurnCount);
|
||||
const normalizedOption = normalizeSkillProbabilities(option, character);
|
||||
const npcBattleResources = new Map<string, {
|
||||
character: Character;
|
||||
mana: number;
|
||||
cooldowns: Record<string, number>;
|
||||
}>();
|
||||
|
||||
state.sceneMonsters.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(state, character, option.functionId),
|
||||
companions: resetCompanionCombatPresentation(state.companions),
|
||||
sceneMonsters: resetCombatPresentation(state.sceneMonsters, state.playerX),
|
||||
activeCombatEffects: [],
|
||||
playerActionMode: 'idle' as const,
|
||||
currentNpcBattleOutcome: null,
|
||||
};
|
||||
const preparedState = simulatedState;
|
||||
const turns: BattlePlanStep[] = [];
|
||||
|
||||
for (const turn of turnOrder) {
|
||||
const currentTarget = getClosestMonster(simulatedState.playerX, simulatedState.sceneMonsters);
|
||||
if (!currentTarget) break;
|
||||
|
||||
if (turn.actor === 'player') {
|
||||
if (simulatedState.playerHp <= 0) continue;
|
||||
|
||||
const cooledDown = tickSkillCooldowns(character, simulatedState.playerSkillCooldowns);
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
playerSkillCooldowns: cooledDown,
|
||||
};
|
||||
|
||||
const selectedSkill = chooseWeightedSkill(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 damage = isNpcSpar
|
||||
? 1
|
||||
: resolvePlayerOutgoingDamage(
|
||||
simulatedState,
|
||||
character,
|
||||
selectedSkill.damage,
|
||||
functionEffect.damageMultiplier ?? 1,
|
||||
);
|
||||
const wouldEndSpar = isNpcSpar && currentTarget.hp - damage <= 1;
|
||||
|
||||
const resolvedMonsters = simulatedState.sceneMonsters.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 = getClosestMonster(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,
|
||||
sceneMonsters: 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,
|
||||
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 = getClosestMonster(companionX, simulatedState.sceneMonsters);
|
||||
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 damage = isNpcSpar
|
||||
? 1
|
||||
: resolveCompanionOutgoingDamage(
|
||||
companionCharacter,
|
||||
selectedSkill.damage,
|
||||
functionEffect.damageMultiplier ?? 1,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
);
|
||||
const wouldEndSpar = isNpcSpar && targetMonster.hp - damage <= 1;
|
||||
|
||||
const resolvedMonsters = simulatedState.sceneMonsters.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,
|
||||
}),
|
||||
),
|
||||
sceneMonsters: 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,
|
||||
defeated,
|
||||
endsBattle: wouldEndSpar,
|
||||
delivery,
|
||||
});
|
||||
|
||||
if (!simulatedState.inBattle) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const actingMonster = simulatedState.sceneMonsters.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 damage = isNpcSpar
|
||||
? 1
|
||||
: resolveCompanionOutgoingDamage(
|
||||
npcCombatant.character,
|
||||
selectedSkill.damage,
|
||||
functionEffect.incomingDamageMultiplier ?? 1,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
);
|
||||
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),
|
||||
sceneMonsters: simulatedState.sceneMonsters.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,
|
||||
endsBattle: wouldEndSpar,
|
||||
selectedSkillId: selectedSkill.id,
|
||||
npcCharacterId: npcCombatant.character.id,
|
||||
delivery,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const strikeX = getMeleeStrikeX(originalMonsterX, targetX);
|
||||
const damage = isNpcSpar
|
||||
? 1
|
||||
: resolveMonsterOutgoingDamage(
|
||||
actingMonster,
|
||||
9,
|
||||
functionEffect.incomingDamageMultiplier ?? 1,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
);
|
||||
const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1;
|
||||
|
||||
const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage);
|
||||
simulatedState = {
|
||||
...damagedState,
|
||||
companions: resetCompanionCombatPresentation(damagedState.companions),
|
||||
sceneMonsters: simulatedState.sceneMonsters.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,
|
||||
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.sceneMonsters.length > 0,
|
||||
sceneMonsters: resetCombatPresentation(simulatedState.sceneMonsters, simulatedState.playerX),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user