1216 lines
34 KiB
TypeScript
1216 lines
34 KiB
TypeScript
import {
|
|
appendBuildBuffs,
|
|
resolveCompanionOutgoingDamageResult,
|
|
resolveMonsterOutgoingDamageResult,
|
|
resolvePlayerOutgoingDamageResult,
|
|
tickBuildBuffs,
|
|
} from '../../data/buildDamage';
|
|
import { getSkillDelivery } from '../../data/characterCombat';
|
|
import {
|
|
getCharacterById,
|
|
getCharacterCombatStats,
|
|
getCharacterMaxMana,
|
|
} from '../../data/characterPresets';
|
|
import { getEquipmentBonuses } from '../../data/equipmentEffects';
|
|
import { getClosestHostileNpc, getFacingTowardPlayer, settleHostileNpcAnimations } from '../../data/hostileNpcs';
|
|
import { resolveInventoryItemUseEffect } from '../../data/inventoryEffects';
|
|
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';
|
|
|
|
export type BattlePlanStep =
|
|
| {
|
|
actor: 'player';
|
|
actionKind: 'attack' | 'recover' | 'inventory';
|
|
targetHostileNpcId: string;
|
|
originalPlayerX: number;
|
|
strikeX: number;
|
|
cooledDown: Record<string, number>;
|
|
selectedSkillId: string | null;
|
|
appliedCooldowns: Record<string, number>;
|
|
damage: number;
|
|
criticalHit?: boolean;
|
|
defeated: boolean;
|
|
endsBattle: boolean;
|
|
delivery: CombatDelivery;
|
|
playerHpAfterAction: number;
|
|
playerManaAfterAction: number;
|
|
playerInventoryAfterAction?: GameState['playerInventory'];
|
|
}
|
|
| {
|
|
actor: 'companion';
|
|
companionNpcId: string;
|
|
targetHostileNpcId: string;
|
|
strikeOffsetX: number;
|
|
cooledDown: Record<string, number>;
|
|
selectedSkillId: string | null;
|
|
appliedCooldowns: Record<string, number>;
|
|
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 resolveFightBattleOutcome(state: GameState): GameState['currentNpcBattleOutcome'] {
|
|
if (state.currentNpcBattleMode === 'spar') {
|
|
return state.currentNpcBattleOutcome;
|
|
}
|
|
if (state.playerHp <= 0) {
|
|
return state.currentBattleNpcId ? 'fight_defeat' : state.currentNpcBattleOutcome;
|
|
}
|
|
if (
|
|
state.currentBattleNpcId &&
|
|
state.sceneHostileNpcs.every((monster) => monster.hp <= 0)
|
|
) {
|
|
return 'fight_victory';
|
|
}
|
|
return state.currentNpcBattleOutcome;
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
type BattleTurnActor =
|
|
| {
|
|
actor: 'player';
|
|
speed: number;
|
|
tieBreaker: number;
|
|
}
|
|
| {
|
|
actor: 'companion';
|
|
companionNpcId: string;
|
|
speed: number;
|
|
tieBreaker: number;
|
|
}
|
|
| {
|
|
actor: 'monster';
|
|
monsterId: string;
|
|
speed: number;
|
|
tieBreaker: number;
|
|
};
|
|
|
|
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),
|
|
]),
|
|
);
|
|
}
|
|
|
|
function tickCooldownsByCount(
|
|
character: Character,
|
|
cooldowns: Record<string, number>,
|
|
count: number,
|
|
) {
|
|
let nextCooldowns = cooldowns;
|
|
|
|
for (let index = 0; index < count; index += 1) {
|
|
nextCooldowns = tickSkillCooldowns(character, nextCooldowns);
|
|
}
|
|
|
|
return nextCooldowns;
|
|
}
|
|
|
|
function getRequestedSkillId(option: StoryOption) {
|
|
return typeof option.runtimePayload?.skillId === 'string'
|
|
? option.runtimePayload.skillId
|
|
: null;
|
|
}
|
|
|
|
function getRequestedItemId(option: StoryOption) {
|
|
return typeof option.runtimePayload?.itemId === 'string'
|
|
? option.runtimePayload.itemId
|
|
: null;
|
|
}
|
|
|
|
function choosePlayerSkillForOption(
|
|
character: Character,
|
|
mana: number,
|
|
cooldowns: Record<string, number>,
|
|
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) return null;
|
|
if (mana < requestedSkill.manaCost) return null;
|
|
return requestedSkill;
|
|
}
|
|
|
|
return chooseWeightedSkill(character, mana, cooldowns, option);
|
|
}
|
|
|
|
function choosePlayerActionSkill(
|
|
character: Character,
|
|
mana: number,
|
|
cooldowns: Record<string, number>,
|
|
option: StoryOption,
|
|
) {
|
|
if (option.functionId === 'battle_attack_basic') {
|
|
return buildBasicAttackSkill(character);
|
|
}
|
|
|
|
return choosePlayerSkillForOption(character, mana, cooldowns, option);
|
|
}
|
|
|
|
function buildBasicAttackSkill(character: Character): CharacterSkillDefinition {
|
|
return {
|
|
id: 'battle-basic-attack',
|
|
name: '普通攻击',
|
|
animation: AnimationState.ATTACK,
|
|
damage: Math.max(
|
|
8,
|
|
Math.round(
|
|
character.attributes.strength * 0.85 +
|
|
character.attributes.agility * 0.45,
|
|
),
|
|
),
|
|
manaCost: 0,
|
|
cooldownTurns: 0,
|
|
range: 1,
|
|
style: 'steady',
|
|
delivery: 'melee',
|
|
};
|
|
}
|
|
|
|
function consumeInventoryItem(
|
|
inventory: GameState['playerInventory'],
|
|
itemId: string,
|
|
) {
|
|
return inventory
|
|
.map((item) =>
|
|
item.id === itemId
|
|
? {
|
|
...item,
|
|
quantity: Math.max(0, item.quantity - 1),
|
|
}
|
|
: item,
|
|
)
|
|
.filter((item) => item.quantity > 0);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function getPlayerTurnSpeed(state: GameState, character: Character) {
|
|
return getCharacterCombatStats(
|
|
character,
|
|
state.worldType,
|
|
state.customWorldProfile,
|
|
).turnSpeed;
|
|
}
|
|
|
|
function getCompanionTurnSpeed(state: GameState, companion: CompanionState) {
|
|
const companionCharacter = getCharacterById(companion.characterId);
|
|
if (!companionCharacter) return 0;
|
|
|
|
return getCharacterCombatStats(
|
|
companionCharacter,
|
|
state.worldType,
|
|
state.customWorldProfile,
|
|
).turnSpeed;
|
|
}
|
|
|
|
function buildRoundTurnOrder(state: GameState, character: Character) {
|
|
const turnOrder: BattleTurnActor[] = [];
|
|
|
|
if (state.playerHp > 0) {
|
|
turnOrder.push({
|
|
actor: 'player',
|
|
speed: getPlayerTurnSpeed(state, character),
|
|
tieBreaker: 0,
|
|
});
|
|
}
|
|
|
|
if (state.currentNpcBattleMode !== 'spar') {
|
|
state.companions
|
|
.filter(isCompanionAlive)
|
|
.forEach((companion, index) => {
|
|
turnOrder.push({
|
|
actor: 'companion',
|
|
companionNpcId: companion.npcId,
|
|
speed: getCompanionTurnSpeed(state, companion),
|
|
tieBreaker: 100 + index,
|
|
});
|
|
});
|
|
}
|
|
|
|
state.sceneHostileNpcs
|
|
.filter((monster) => monster.hp > 0)
|
|
.forEach((monster, index) => {
|
|
turnOrder.push({
|
|
actor: 'monster',
|
|
monsterId: monster.id,
|
|
speed: monster.speed,
|
|
tieBreaker: 1000 + index,
|
|
});
|
|
});
|
|
|
|
return turnOrder.sort((left, right) => {
|
|
if (right.speed !== left.speed) {
|
|
return right.speed - left.speed;
|
|
}
|
|
|
|
return left.tieBreaker - right.tieBreaker;
|
|
});
|
|
}
|
|
|
|
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 {
|
|
void totalSequenceMs;
|
|
void turnVisualMs;
|
|
void resetStageMs;
|
|
void minTurnCount;
|
|
|
|
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 isInventoryAction = option.functionId === 'inventory_use';
|
|
const isNpcSpar = battleState.currentNpcBattleMode === 'spar';
|
|
const normalizedOption = normalizeSkillProbabilities(option, character);
|
|
const npcBattleResources = new Map<
|
|
string,
|
|
{
|
|
character: Character;
|
|
mana: number;
|
|
cooldowns: Record<string, number>;
|
|
}
|
|
>();
|
|
|
|
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 = {
|
|
...battleState,
|
|
companions: resetCompanionCombatPresentation(battleState.companions),
|
|
sceneHostileNpcs: resetCombatPresentation(
|
|
battleState.sceneHostileNpcs,
|
|
battleState.playerX,
|
|
),
|
|
activeCombatEffects: [],
|
|
playerActionMode: 'idle' as const,
|
|
currentNpcBattleOutcome: null,
|
|
};
|
|
const preparedState = simulatedState;
|
|
const turns: BattlePlanStep[] = [];
|
|
const turnOrder = buildRoundTurnOrder(simulatedState, character);
|
|
const pendingMonsterTurnIds = new Set(
|
|
turnOrder
|
|
.filter(
|
|
(turnActor): turnActor is Extract<BattleTurnActor, {actor: 'monster'}> =>
|
|
turnActor.actor === 'monster',
|
|
)
|
|
.map((turnActor) => turnActor.monsterId),
|
|
);
|
|
|
|
for (const turnActor of turnOrder) {
|
|
if (
|
|
simulatedState.playerHp <= 0 ||
|
|
simulatedState.currentNpcBattleOutcome === 'spar_complete' ||
|
|
simulatedState.currentNpcBattleOutcome === 'fight_defeat'
|
|
) {
|
|
break;
|
|
}
|
|
if (!simulatedState.inBattle && pendingMonsterTurnIds.size === 0) {
|
|
break;
|
|
}
|
|
if (turnActor.actor === 'monster') {
|
|
pendingMonsterTurnIds.delete(turnActor.monsterId);
|
|
}
|
|
|
|
if (
|
|
turnActor.actor === 'player' &&
|
|
simulatedState.playerHp > 0
|
|
) {
|
|
const currentTarget = getClosestHostileNpc(
|
|
simulatedState.playerX,
|
|
simulatedState.sceneHostileNpcs,
|
|
);
|
|
if (!currentTarget) {
|
|
simulatedState = {
|
|
...simulatedState,
|
|
inBattle: false,
|
|
};
|
|
break;
|
|
}
|
|
|
|
const originalPlayerX = simulatedState.playerX;
|
|
const cooledDown = tickSkillCooldowns(
|
|
character,
|
|
simulatedState.playerSkillCooldowns,
|
|
);
|
|
|
|
if (isRecoveryAction) {
|
|
const recoveredState = applyRecoveryEffectToState(
|
|
{
|
|
...simulatedState,
|
|
playerSkillCooldowns: cooledDown,
|
|
},
|
|
character,
|
|
option.functionId,
|
|
);
|
|
simulatedState = {
|
|
...recoveredState,
|
|
companions: resetCompanionCombatPresentation(
|
|
recoveredState.companions,
|
|
),
|
|
activeCombatEffects: [],
|
|
};
|
|
turns.push({
|
|
actor: 'player',
|
|
actionKind: 'recover',
|
|
targetHostileNpcId: currentTarget.id,
|
|
originalPlayerX,
|
|
strikeX: originalPlayerX,
|
|
cooledDown,
|
|
selectedSkillId: null,
|
|
appliedCooldowns: simulatedState.playerSkillCooldowns,
|
|
damage: 0,
|
|
defeated: false,
|
|
endsBattle: false,
|
|
delivery: 'melee',
|
|
playerHpAfterAction: simulatedState.playerHp,
|
|
playerManaAfterAction: simulatedState.playerMana,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (isInventoryAction) {
|
|
const itemId = getRequestedItemId(option);
|
|
const item = itemId
|
|
? simulatedState.playerInventory.find(
|
|
(candidate) => candidate.id === itemId,
|
|
) ?? null
|
|
: null;
|
|
const itemEffect = item
|
|
? resolveInventoryItemUseEffect(item, character)
|
|
: null;
|
|
let appliedCooldowns = cooledDown;
|
|
|
|
if (item && itemEffect) {
|
|
appliedCooldowns = tickCooldownsByCount(
|
|
character,
|
|
cooledDown,
|
|
itemEffect.cooldownReduction ?? 0,
|
|
);
|
|
simulatedState = {
|
|
...simulatedState,
|
|
playerHp: Math.min(
|
|
simulatedState.playerMaxHp,
|
|
simulatedState.playerHp + itemEffect.hpRestore,
|
|
),
|
|
playerMana: Math.min(
|
|
simulatedState.playerMaxMana,
|
|
simulatedState.playerMana + itemEffect.manaRestore,
|
|
),
|
|
playerSkillCooldowns: appliedCooldowns,
|
|
playerInventory: consumeInventoryItem(
|
|
simulatedState.playerInventory,
|
|
item.id,
|
|
),
|
|
activeBuildBuffs: appendBuildBuffs(
|
|
tickBuildBuffs(simulatedState.activeBuildBuffs),
|
|
itemEffect.buildBuffs,
|
|
),
|
|
};
|
|
} else {
|
|
simulatedState = {
|
|
...simulatedState,
|
|
playerSkillCooldowns: appliedCooldowns,
|
|
};
|
|
}
|
|
|
|
turns.push({
|
|
actor: 'player',
|
|
actionKind: 'inventory',
|
|
targetHostileNpcId: currentTarget.id,
|
|
originalPlayerX,
|
|
strikeX: originalPlayerX,
|
|
cooledDown,
|
|
selectedSkillId: null,
|
|
appliedCooldowns,
|
|
damage: 0,
|
|
defeated: false,
|
|
endsBattle: false,
|
|
delivery: 'melee',
|
|
playerHpAfterAction: simulatedState.playerHp,
|
|
playerManaAfterAction: simulatedState.playerMana,
|
|
playerInventoryAfterAction: simulatedState.playerInventory,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const selectedSkill = choosePlayerActionSkill(
|
|
character,
|
|
simulatedState.playerMana,
|
|
cooledDown,
|
|
normalizedOption,
|
|
);
|
|
|
|
if (!selectedSkill) {
|
|
simulatedState = {
|
|
...simulatedState,
|
|
playerSkillCooldowns: cooledDown,
|
|
companions: resetCompanionCombatPresentation(
|
|
simulatedState.companions,
|
|
),
|
|
activeCombatEffects: [],
|
|
};
|
|
continue;
|
|
}
|
|
|
|
const appliedCooldowns =
|
|
selectedSkill.id === 'battle-basic-attack'
|
|
? cooledDown
|
|
: {
|
|
...cooledDown,
|
|
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
|
};
|
|
const damageResult = isNpcSpar
|
|
? null
|
|
: resolvePlayerOutgoingDamageResult(
|
|
simulatedState,
|
|
character,
|
|
selectedSkill.damage,
|
|
functionEffect.damageMultiplier ?? 1,
|
|
`${option.functionId}:player:${selectedSkill.id}:${currentTarget.id}`,
|
|
);
|
|
const playerDamage = isNpcSpar ? 1 : damageResult!.damage;
|
|
const playerCriticalHit = damageResult?.isCritical ?? false;
|
|
const playerDelivery = getSkillDelivery(selectedSkill);
|
|
const wouldEndSpar = isNpcSpar && currentTarget.hp - playerDamage <= 1;
|
|
const strikeX = getSkillStrikeX(
|
|
selectedSkill,
|
|
originalPlayerX,
|
|
currentTarget.xMeters,
|
|
);
|
|
const resolvedMonsters = simulatedState.sceneHostileNpcs.map((monster) =>
|
|
monster.id === currentTarget.id
|
|
? {
|
|
...monster,
|
|
hp: isNpcSpar
|
|
? Math.max(1, monster.hp - playerDamage)
|
|
: Math.max(0, monster.hp - playerDamage),
|
|
}
|
|
: monster,
|
|
);
|
|
const targetDefeated =
|
|
!isNpcSpar &&
|
|
resolvedMonsters.some(
|
|
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
|
|
);
|
|
const nextTarget = getClosestHostileNpc(
|
|
originalPlayerX,
|
|
resolvedMonsters.filter((monster) => monster.hp > 0),
|
|
);
|
|
|
|
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: resolvedMonsters.map((monster) => ({
|
|
...monster,
|
|
characterAnimation: undefined,
|
|
combatMode: undefined,
|
|
})),
|
|
companions: resetCompanionCombatPresentation(simulatedState.companions),
|
|
inBattle:
|
|
isNpcSpar
|
|
? !wouldEndSpar
|
|
: (resolvedMonsters.some((monster) => monster.hp > 0) ||
|
|
pendingMonsterTurnIds.size > 0) &&
|
|
simulatedState.playerHp > 0,
|
|
currentNpcBattleOutcome: wouldEndSpar
|
|
? 'spar_complete'
|
|
: pendingMonsterTurnIds.size > 0
|
|
? simulatedState.currentNpcBattleOutcome
|
|
: resolveFightBattleOutcome({
|
|
...simulatedState,
|
|
sceneHostileNpcs: resolvedMonsters,
|
|
}),
|
|
};
|
|
|
|
turns.push({
|
|
actor: 'player',
|
|
actionKind: 'attack',
|
|
targetHostileNpcId: currentTarget.id,
|
|
originalPlayerX,
|
|
strikeX,
|
|
cooledDown,
|
|
selectedSkillId: selectedSkill.id,
|
|
appliedCooldowns,
|
|
damage: playerDamage,
|
|
criticalHit: playerCriticalHit,
|
|
defeated: targetDefeated,
|
|
endsBattle: wouldEndSpar,
|
|
delivery: playerDelivery,
|
|
playerHpAfterAction: simulatedState.playerHp,
|
|
playerManaAfterAction: simulatedState.playerMana,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (turnActor.actor === 'companion') {
|
|
const companion = simulatedState.companions.find(
|
|
(item) => item.npcId === turnActor.companionNpcId,
|
|
);
|
|
if (!companion || companion.hp <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const companionCharacter = getCharacterById(companion.characterId);
|
|
if (!companionCharacter) {
|
|
continue;
|
|
}
|
|
|
|
const companionAnchorX = getCompanionAnchorX(
|
|
simulatedState.playerX,
|
|
simulatedState.companions,
|
|
companion.npcId,
|
|
);
|
|
const currentTarget = getClosestHostileNpc(
|
|
companionAnchorX,
|
|
simulatedState.sceneHostileNpcs,
|
|
);
|
|
if (!currentTarget) {
|
|
simulatedState = {
|
|
...simulatedState,
|
|
inBattle: false,
|
|
};
|
|
break;
|
|
}
|
|
|
|
const cooledDown = tickSkillCooldowns(
|
|
companionCharacter,
|
|
companion.skillCooldowns,
|
|
);
|
|
const selectedSkill =
|
|
chooseWeightedSkillForStyle(
|
|
companionCharacter,
|
|
companion.mana,
|
|
cooledDown,
|
|
inferCombatStyle(option),
|
|
) ?? buildBasicAttackSkill(companionCharacter);
|
|
const appliedCooldowns =
|
|
selectedSkill.id === 'battle-basic-attack'
|
|
? cooledDown
|
|
: {
|
|
...cooledDown,
|
|
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
|
};
|
|
const damageResult = resolveCompanionOutgoingDamageResult(
|
|
companionCharacter,
|
|
selectedSkill.damage,
|
|
functionEffect.damageMultiplier ?? 1,
|
|
state.worldType,
|
|
state.customWorldProfile,
|
|
`${option.functionId}:companion:${companion.npcId}:${selectedSkill.id}:${currentTarget.id}`,
|
|
);
|
|
const damage = damageResult.damage;
|
|
const strikeX = getSkillStrikeX(
|
|
selectedSkill,
|
|
companionAnchorX,
|
|
currentTarget.xMeters,
|
|
);
|
|
const strikeOffsetX = Number((strikeX - companionAnchorX).toFixed(2));
|
|
const resolvedMonsters = simulatedState.sceneHostileNpcs.map((monster) =>
|
|
monster.id === currentTarget.id
|
|
? {
|
|
...monster,
|
|
hp: Math.max(0, monster.hp - damage),
|
|
}
|
|
: monster,
|
|
);
|
|
const defeated = resolvedMonsters.some(
|
|
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
|
|
);
|
|
simulatedState = {
|
|
...simulatedState,
|
|
sceneHostileNpcs: resolvedMonsters.map((monster) => ({
|
|
...monster,
|
|
characterAnimation: undefined,
|
|
combatMode: undefined,
|
|
})),
|
|
companions: updateCompanionState(
|
|
resetCompanionCombatPresentation(simulatedState.companions),
|
|
companion.npcId,
|
|
(currentCompanion) => ({
|
|
...currentCompanion,
|
|
mana: Math.max(0, currentCompanion.mana - selectedSkill.manaCost),
|
|
skillCooldowns: appliedCooldowns,
|
|
}),
|
|
),
|
|
inBattle:
|
|
(resolvedMonsters.some((monster) => monster.hp > 0) ||
|
|
pendingMonsterTurnIds.size > 0) &&
|
|
simulatedState.playerHp > 0,
|
|
currentNpcBattleOutcome:
|
|
pendingMonsterTurnIds.size > 0
|
|
? simulatedState.currentNpcBattleOutcome
|
|
: resolveFightBattleOutcome({
|
|
...simulatedState,
|
|
sceneHostileNpcs: resolvedMonsters,
|
|
}),
|
|
};
|
|
|
|
turns.push({
|
|
actor: 'companion',
|
|
companionNpcId: companion.npcId,
|
|
targetHostileNpcId: currentTarget.id,
|
|
strikeOffsetX,
|
|
cooledDown,
|
|
selectedSkillId: selectedSkill.id,
|
|
appliedCooldowns,
|
|
damage,
|
|
criticalHit: damageResult.isCritical,
|
|
defeated,
|
|
endsBattle: false,
|
|
delivery: getSkillDelivery(selectedSkill),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (turnActor.actor !== 'monster') {
|
|
continue;
|
|
}
|
|
|
|
const actingMonster = simulatedState.sceneHostileNpcs.find(
|
|
(monster) =>
|
|
monster.id === turnActor.monsterId,
|
|
);
|
|
if (!actingMonster) {
|
|
continue;
|
|
}
|
|
|
|
const randomTarget = chooseRandomPartyTarget(simulatedState);
|
|
if (!randomTarget) {
|
|
simulatedState = {
|
|
...simulatedState,
|
|
inBattle: false,
|
|
};
|
|
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:${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: Math.max(0, npcCombatant.mana - selectedSkill.manaCost),
|
|
cooldowns: {
|
|
...cooledDown,
|
|
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
|
},
|
|
});
|
|
|
|
const damagedState = applyDamageToPartyTarget(
|
|
simulatedState,
|
|
randomTarget,
|
|
damage,
|
|
);
|
|
const nextPlayerHp =
|
|
isNpcSpar && randomTarget.kind === 'player'
|
|
? Math.max(1, damagedState.playerHp)
|
|
: damagedState.playerHp;
|
|
const endsBattle = isNpcSpar
|
|
? wouldEndSpar
|
|
: nextPlayerHp <= 0;
|
|
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: nextPlayerHp,
|
|
inBattle:
|
|
isNpcSpar
|
|
? !wouldEndSpar
|
|
: nextPlayerHp > 0 &&
|
|
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
|
|
pendingMonsterTurnIds.size > 0),
|
|
currentNpcBattleOutcome: wouldEndSpar
|
|
? 'spar_complete'
|
|
: pendingMonsterTurnIds.size > 0
|
|
? simulatedState.currentNpcBattleOutcome
|
|
: resolveFightBattleOutcome({
|
|
...simulatedState,
|
|
...damagedState,
|
|
playerHp: nextPlayerHp,
|
|
}),
|
|
};
|
|
|
|
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,
|
|
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:${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,
|
|
);
|
|
const nextPlayerHp =
|
|
isNpcSpar && randomTarget.kind === 'player'
|
|
? Math.max(1, damagedState.playerHp)
|
|
: damagedState.playerHp;
|
|
const endsBattle = isNpcSpar ? wouldEndSpar : nextPlayerHp <= 0;
|
|
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: nextPlayerHp,
|
|
inBattle:
|
|
isNpcSpar
|
|
? !wouldEndSpar
|
|
: nextPlayerHp > 0 &&
|
|
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
|
|
pendingMonsterTurnIds.size > 0),
|
|
currentNpcBattleOutcome: wouldEndSpar
|
|
? 'spar_complete'
|
|
: pendingMonsterTurnIds.size > 0
|
|
? simulatedState.currentNpcBattleOutcome
|
|
: resolveFightBattleOutcome({
|
|
...simulatedState,
|
|
...damagedState,
|
|
playerHp: nextPlayerHp,
|
|
}),
|
|
};
|
|
|
|
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,
|
|
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' ||
|
|
simulatedState.playerHp <= 0
|
|
? false
|
|
: simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0),
|
|
sceneHostileNpcs: resetCombatPresentation(
|
|
simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
|
|
simulatedState.playerX,
|
|
),
|
|
currentNpcBattleOutcome: resolveFightBattleOutcome({
|
|
...simulatedState,
|
|
sceneHostileNpcs: simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
|
|
}),
|
|
},
|
|
};
|
|
}
|