init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
import {describe, expect, it} from 'vitest';
import {AnimationState, type Character, type GameState, type StoryOption, WorldType} from '../../types';
import {buildBattlePlan} from './battlePlan';
function createTestCharacter(): Character {
return {
id: 'test-hero',
name: 'Test Hero',
title: 'Hero',
description: 'A test character',
backstory: 'A test backstory',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [
{
id: 'skill-basic',
name: 'Basic Strike',
animation: AnimationState.ATTACK,
damage: 10,
manaCost: 0,
cooldownTurns: 1,
range: 1,
style: 'steady',
},
{
id: 'skill-heavy',
name: 'Heavy Strike',
animation: AnimationState.SKILL1,
damage: 18,
manaCost: 4,
cooldownTurns: 2,
range: 1,
style: 'burst',
},
],
adventureOpenings: {},
};
}
function createBaseState(): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createTestCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: true,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
function createBattleOption(): StoryOption {
return {
functionId: 'battle_all_in_crush',
actionText: 'Attack',
visuals: {
playerAnimation: AnimationState.ATTACK,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
describe('buildBattlePlan', () => {
it('short-circuits when there are no monsters', () => {
const state = createBaseState();
const plan = buildBattlePlan({
state,
option: createBattleOption(),
character: createTestCharacter(),
totalSequenceMs: 6000,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 6,
});
expect(plan.turns).toEqual([]);
expect(plan.finalState.inBattle).toBe(false);
expect(plan.finalState.sceneHostileNpcs).toEqual([]);
expect(plan.finalState.animationState).toBe(AnimationState.IDLE);
});
it('builds a battle plan when npc battle entry already provides sceneHostileNpcs', () => {
const state = {
...createBaseState(),
currentBattleNpcId: 'npc-opponent',
currentNpcBattleMode: 'fight' as const,
sceneHostileNpcs: [
{
id: 'npc-opponent',
name: '山道客',
action: '摆开架势,随时准备出手',
description: '拦路的江湖客',
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.8,
speed: 7,
hp: 12,
maxHp: 12,
renderKind: 'npc' as const,
encounter: {
id: 'npc-opponent',
kind: 'npc' as const,
npcName: '山道客',
npcDescription: '拦路的江湖客',
npcAvatar: '/npc.png',
context: '山道客',
xMeters: 3.2,
},
},
],
};
const plan = buildBattlePlan({
state,
option: createBattleOption(),
character: createTestCharacter(),
totalSequenceMs: 6000,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 6,
});
expect(plan.turns.length).toBeGreaterThan(0);
expect(plan.preparedState.sceneHostileNpcs).toHaveLength(1);
});
it('uses runtimePayload skillId for local battle fallback skill resolution', () => {
const state = {
...createBaseState(),
playerMana: 20,
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 80,
maxHp: 80,
},
],
};
const option = {
...createBattleOption(),
functionId: 'battle_use_skill',
runtimePayload: { skillId: 'skill-heavy' },
};
const plan = buildBattlePlan({
state,
option,
character: createTestCharacter(),
totalSequenceMs: 900,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 1,
});
const playerTurn = plan.turns.find((turn) => turn.actor === 'player');
expect(playerTurn).toEqual(
expect.objectContaining({
selectedSkillId: 'skill-heavy',
appliedCooldowns: expect.objectContaining({ 'skill-heavy': 2 }),
}),
);
});
it('does not turn recovery fallback into a random player attack', () => {
const state = {
...createBaseState(),
playerHp: 40,
playerMana: 3,
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 80,
maxHp: 80,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_recover_breath',
},
character: createTestCharacter(),
totalSequenceMs: 900,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 1,
});
expect(plan.turns.some((turn) => turn.actor === 'player')).toBe(false);
expect(plan.preparedState.playerHp).toBeGreaterThan(state.playerHp);
expect(plan.preparedState.playerMana).toBeGreaterThan(state.playerMana);
});
});

View File

@@ -0,0 +1,823 @@
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<string, number>;
selectedSkillId: string | null;
appliedCooldowns: Record<string, number>;
damage: number;
criticalHit?: boolean;
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;
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<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(
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<string, number>) {
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<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 || 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<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 = {
...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),
},
};
}

View File

@@ -0,0 +1,188 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../data/stateFunctions', () => ({
getFunctionEffect: () => ({
escapeDistance: 5,
escapeDurationMs: 5000,
}),
}));
import {
AnimationState,
type Character,
type GameState,
type SceneHostileNpc,
type StoryOption,
WorldType,
} from '../../types';
import {
buildEscapeAfterSequence,
playEscapeSequenceWithStorySync,
} from './escapeFlow';
function createCharacter(): Character {
return {
id: 'hero',
name: 'Hero',
title: 'Wanderer',
description: 'A reliable test hero.',
backstory: 'Travels the land.',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 9,
intelligence: 8,
spirit: 7,
},
personality: 'steady',
skills: [],
adventureOpenings: {},
};
}
function createMonster(): SceneHostileNpc {
return {
id: 'wolf',
name: 'Wolf',
action: 'Growls',
description: 'A test wolf.',
animation: 'idle',
xMeters: 3.5,
yOffset: 0,
facing: 'left',
attackRange: 1.2,
speed: 1,
hp: 10,
maxHp: 10,
};
}
function createState(): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: {
id: 'npc-1',
kind: 'npc',
npcName: 'Bandit',
npcDescription: 'A bandit',
npcAvatar: 'B',
context: 'bandit',
},
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [createMonster()],
playerX: 0.2,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: true,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-1',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
function createEscapeOption(): StoryOption {
return {
functionId: 'battle_escape_breakout',
actionText: 'Run',
text: 'Run',
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterChanges: [],
},
};
}
describe('escapeFlow', () => {
it('builds a deterministic escape state without playback timing state', () => {
const state = createState();
const resolved = buildEscapeAfterSequence(state, createEscapeOption());
expect(resolved.inBattle).toBe(false);
expect(resolved.currentEncounter).toBeNull();
expect(resolved.currentBattleNpcId).toBeNull();
expect(resolved.sceneHostileNpcs).toEqual([]);
expect(resolved.playerFacing).toBe('right');
expect(resolved.scrollWorld).toBe(false);
expect(resolved.playerX).toBeLessThan(0.2);
});
it('waits for the story response before settling the escape presentation', async () => {
const state = createState();
const option = createEscapeOption();
const finalState = buildEscapeAfterSequence(state, option);
const committedStates: GameState[] = [];
let sleepCalls = 0;
let resolveStoryResponse!: () => void;
const waitForStoryResponse = new Promise<void>(resolve => {
resolveStoryResponse = resolve;
});
const result = await playEscapeSequenceWithStorySync({
setGameState: (nextState: GameState) => {
committedStates.push(nextState);
},
state,
option,
finalState,
sync: { waitForStoryResponse },
sleepMs: async () => {
sleepCalls += 1;
if (sleepCalls === 22) {
resolveStoryResponse();
}
await Promise.resolve();
},
});
expect(sleepCalls).toBeGreaterThan(21);
expect(committedStates[0]?.animationState).toBe(AnimationState.RUN);
expect(committedStates.at(-1)?.playerFacing).toBe('right');
expect(result.scrollWorld).toBe(false);
expect(result.playerFacing).toBe('right');
});
});

View File

@@ -0,0 +1,150 @@
import type { Dispatch, SetStateAction } from 'react';
import {
getFacingTowardPlayer,
settleHostileNpcAnimations,
} from '../../data/hostileNpcs';
import { getFunctionEffect } from '../../data/stateFunctions';
import {
AnimationState,
type GameState,
type SceneHostileNpc,
type StoryOption,
} from '../../types';
const ESCAPE_RUN_MS = 5000;
const ESCAPE_TICK_MS = 250;
const ESCAPE_TURN_PAUSE_MS = 180;
export type EscapePlaybackSync = {
waitForStoryResponse?: Promise<void>;
};
type SetGameStateFn = Dispatch<SetStateAction<GameState>> | ((state: GameState) => void);
function sleep(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
return settleHostileNpcAnimations(monsters).map(monster => ({
...monster,
facing: getFacingTowardPlayer(monster.xMeters, playerX),
characterAnimation: undefined,
combatMode: undefined,
}));
}
export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
const escapeDistance = getFunctionEffect(option.functionId).escapeDistance ?? 5;
const settleOffset = Math.max(1, Math.min(1.4, escapeDistance * 0.24));
return Number(Math.max(-1.6, Math.min(state.playerX - settleOffset, -1)).toFixed(2));
}
export function buildEscapeAfterSequence(
state: GameState,
option: StoryOption,
) {
const escapePlayerX = getEscapeSettlePlayerX(state, option);
return {
...state,
currentEncounter: null,
npcInteractionActive: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sceneHostileNpcs: [],
playerX: escapePlayerX,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
} satisfies GameState;
}
export async function playEscapeSequenceWithStorySync(params: {
setGameState: SetGameStateFn;
state: GameState;
option: StoryOption;
finalState: GameState;
sync?: EscapePlaybackSync;
sleepMs?: (ms: number) => Promise<void>;
}) {
const {
setGameState,
state,
option,
finalState,
sync,
sleepMs = sleep,
} = params;
let currentState = state;
const functionEffect = getFunctionEffect(option.functionId);
const escapeDurationMs = functionEffect.escapeDurationMs ?? ESCAPE_RUN_MS;
const escapeDistance = functionEffect.escapeDistance ?? 5;
const runTicks = Math.max(1, Math.ceil(escapeDurationMs / ESCAPE_TICK_MS));
const playerStep = Number((-escapeDistance / runTicks).toFixed(2));
const settlePlayerX = finalState.playerX;
let storyResponseReady = !sync?.waitForStoryResponse;
let elapsedMs = 0;
void sync?.waitForStoryResponse?.then(() => {
storyResponseReady = true;
});
currentState = {
...currentState,
playerFacing: 'left',
animationState: AnimationState.RUN,
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: true,
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, currentState.playerX),
};
setGameState(currentState);
while (elapsedMs < escapeDurationMs || !storyResponseReady) {
const nextPlayerX = Number((currentState.playerX + playerStep).toFixed(2));
currentState = {
...currentState,
playerX: nextPlayerX,
playerFacing: 'left',
animationState: AnimationState.RUN,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: true,
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, nextPlayerX),
};
setGameState(currentState);
elapsedMs += ESCAPE_TICK_MS;
await sleepMs(ESCAPE_TICK_MS);
}
currentState = {
...finalState,
playerX: settlePlayerX,
playerFacing: 'left',
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
};
setGameState(currentState);
await sleepMs(ESCAPE_TURN_PAUSE_MS);
currentState = {
...currentState,
playerFacing: 'right',
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, settlePlayerX),
};
setGameState(currentState);
return currentState;
}

View File

@@ -0,0 +1,743 @@
import type { Dispatch, SetStateAction } from 'react';
import {
appendBuildBuffs,
tickBuildBuffs,
} from '../../data/buildDamage';
import {
getCharacterAnimationDurationMs,
getSkillCasterAnimation,
} from '../../data/characterCombat';
import {
getCharacterById,
} from '../../data/characterPresets';
import {
getClosestHostileNpc,
getFacingTowardPlayer,
MONSTERS_BY_WORLD,
} from '../../data/hostileNpcs';
import {
AnimationState,
type Character,
type CharacterSkillDefinition,
type GameState,
type SceneHostileNpc,
type StoryOption,
type WorldType,
} from '../../types';
import {
classifyCombatOption,
} from '../combatStoryUtils';
import { playIdleSequence } from '../idleAdventureFlow';
import {
applyDamageToPartyTarget,
applyRecoveryEffectToState,
type BattlePlan,
getFacingForPlayer,
resetCompanionCombatPresentation,
updateCompanionState,
} from './battlePlan';
import {
type EscapePlaybackSync,
playEscapeSequenceWithStorySync as playEscapeSequenceWithStorySyncFromEngine,
} from './escapeFlow';
import type { ResolvedChoiceState } from './resolvedChoice';
import {
buildSkillEffects,
getSkillReleaseDelayMs,
} from './skillEffects';
function sleep(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function getSkillById(character: Character, skillId: string) {
return character.skills.find(skill => skill.id === skillId) ?? null;
}
function getMonsterAnimationDurationMs(
worldType: WorldType | null,
monsterId: string,
animation: 'idle' | 'move' | 'attack' | 'die',
turnVisualMs: number,
) {
if (!worldType) return turnVisualMs;
const config = MONSTERS_BY_WORLD[worldType].find(monster => monster.id === monsterId);
const clip = config?.animations[animation];
if (!clip) return turnVisualMs;
const fps = clip.fps ?? 12;
const stepCount = Math.max(1, clip.frames - 1);
return Math.max(turnVisualMs, Math.ceil((stepCount * 1000) / fps + 120));
}
function buildVisibleMonsterChanges(option: StoryOption, monsters: SceneHostileNpc[]) {
if (option.visuals.monsterChanges.length > 0) return option.visuals.monsterChanges;
const fallbackMonster = monsters[0];
if (!fallbackMonster) return [];
return [
{
id: fallbackMonster.id,
action: classifyCombatOption(option) === 'escape'
? `${fallbackMonster.name}立刻追了上来`
: `${fallbackMonster.name}振身迎击玩家的进攻`,
animation: classifyCombatOption(option) === 'escape' ? 'move' as const : 'attack' as const,
moveMeters: classifyCombatOption(option) === 'escape' ? -0.2 : 0,
},
];
}
type CombatPlaybackParams = {
setGameState: Dispatch<SetStateAction<GameState>>;
turnVisualMs: number;
resetStageMs: number;
};
async function playSkillEffects(params: {
setGameState: Dispatch<SetStateAction<GameState>>;
currentState: GameState;
attacker: {
character: Character;
xMeters: number;
origin: 'player' | 'monster';
facing: 'left' | 'right';
monsterId?: string;
};
target: {
xMeters: number;
origin: 'player' | 'monster';
monsterId?: string;
};
skill: CharacterSkillDefinition;
}) {
const {
setGameState,
attacker,
target,
skill,
} = params;
let currentState = params.currentState;
const phases = buildSkillEffects(attacker, target, skill);
if (phases.cast.length > 0) {
currentState = {
...currentState,
activeCombatEffects: phases.cast,
};
setGameState(currentState);
await sleep(phases.castDurationMs);
}
if (phases.travel.length > 0) {
currentState = {
...currentState,
activeCombatEffects: phases.travel,
};
setGameState(currentState);
await sleep(phases.travelDurationMs);
}
if (phases.impact.length > 0) {
currentState = {
...currentState,
activeCombatEffects: phases.impact,
};
setGameState(currentState);
await sleep(phases.impactDurationMs);
}
if (phases.cast.length > 0 || phases.travel.length > 0 || phases.impact.length > 0) {
currentState = {
...currentState,
activeCombatEffects: [],
};
setGameState(currentState);
}
return currentState;
}
async function playBattleSequence(params: CombatPlaybackParams & {
state: GameState;
option: StoryOption;
character: Character;
plan: BattlePlan;
}) {
const {
setGameState,
state,
option,
character,
plan,
turnVisualMs,
resetStageMs,
} = params;
let currentState = state;
if (plan.preparedState !== state) {
currentState = plan.preparedState;
setGameState(currentState);
await sleep(resetStageMs);
}
for (const step of plan.turns) {
if (step.actor === 'player') {
currentState = {
...currentState,
playerSkillCooldowns: step.cooledDown,
companions: resetCompanionCombatPresentation(currentState.companions),
activeCombatEffects: [],
};
setGameState(currentState);
const skill = step.selectedSkillId ? getSkillById(character, step.selectedSkillId) : null;
if (!skill) {
await sleep(resetStageMs);
continue;
}
const currentTarget = currentState.sceneHostileNpcs.find(monster => monster.id === step.targetHostileNpcId);
if (!currentTarget) {
break;
}
const casterAnimation = getSkillCasterAnimation(skill);
const releaseDelay = (skill.effects?.length ?? 0) > 0
? getSkillReleaseDelayMs(character, skill)
: getCharacterAnimationDurationMs(character, casterAnimation);
const shouldDashIntoMelee =
step.delivery === 'melee' && Math.abs(step.strikeX - step.originalPlayerX) > 0.01;
if (shouldDashIntoMelee) {
const dashDuration = Math.max(120, getCharacterAnimationDurationMs(character, AnimationState.DASH));
currentState = {
...currentState,
playerX: step.strikeX,
playerFacing: getFacingForPlayer(step.strikeX, currentTarget),
animationState: AnimationState.DASH,
playerActionMode: 'melee',
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(dashDuration);
}
currentState = {
...currentState,
playerX: step.strikeX,
playerFacing: getFacingForPlayer(step.strikeX, currentTarget),
animationState: casterAnimation,
playerActionMode: step.delivery,
activeBuildBuffs: appendBuildBuffs(
tickBuildBuffs(currentState.activeBuildBuffs),
skill.buildBuffs,
),
activeCombatEffects: [],
playerMana: currentState.playerMana,
playerSkillCooldowns: step.appliedCooldowns,
};
setGameState(currentState);
await sleep(releaseDelay);
currentState = await playSkillEffects({
setGameState,
currentState,
attacker: {
character,
xMeters: step.strikeX,
origin: 'player',
facing: currentState.playerFacing,
},
target: {
xMeters: currentTarget.xMeters,
origin: 'monster',
monsterId: currentTarget.id,
},
skill,
});
const resolvedMonsters = currentState.sceneHostileNpcs.map(monster =>
monster.id === step.targetHostileNpcId
? {
...monster,
hp: Math.max(0, monster.hp - step.damage),
action: step.defeated
? `${monster.name}${skill.name}击败了`
: `${monster.name}受到了${skill.name}的打击`,
animation: step.defeated ? ('die' as const) : monster.animation,
characterAnimation: undefined,
combatMode: undefined,
}
: monster,
);
currentState = {
...currentState,
sceneHostileNpcs: resolvedMonsters,
inBattle: step.endsBattle ? false : currentState.inBattle,
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
};
setGameState(currentState);
if (step.defeated) {
await sleep(getMonsterAnimationDurationMs(currentState.worldType, step.targetHostileNpcId, 'die', turnVisualMs));
const remainingMonsters = resolvedMonsters.filter(
monster => !(monster.id === step.targetHostileNpcId && monster.hp <= 0),
);
currentState = {
...currentState,
sceneHostileNpcs: remainingMonsters,
inBattle: remainingMonsters.length > 0,
};
setGameState(currentState);
}
if (step.endsBattle) {
break;
}
const nextTarget = getClosestHostileNpc(step.originalPlayerX, currentState.sceneHostileNpcs);
currentState = {
...currentState,
playerX: step.originalPlayerX,
playerFacing: getFacingForPlayer(step.originalPlayerX, nextTarget ?? null),
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
companions: resetCompanionCombatPresentation(currentState.companions),
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(resetStageMs);
if (!currentState.inBattle) {
break;
}
continue;
}
if (step.actor === 'companion') {
const companion = currentState.companions.find(item => item.npcId === step.companionNpcId);
if (!companion || companion.hp <= 0) continue;
const companionCharacter = getCharacterById(companion.characterId);
const currentTarget = currentState.sceneHostileNpcs.find(monster => monster.id === step.targetHostileNpcId);
const skill = companionCharacter && step.selectedSkillId ? getSkillById(companionCharacter, step.selectedSkillId) : null;
if (!companionCharacter || !currentTarget || !skill) {
await sleep(resetStageMs);
continue;
}
const dashAnimation = companionCharacter.animationMap?.[AnimationState.DASH]
? AnimationState.DASH
: AnimationState.RUN;
const dashDuration = Math.max(120, getCharacterAnimationDurationMs(companionCharacter, dashAnimation));
const casterAnimation = getSkillCasterAnimation(skill);
const releaseDelay = (skill.effects?.length ?? 0) > 0
? getSkillReleaseDelayMs(companionCharacter, skill)
: getCharacterAnimationDurationMs(companionCharacter, casterAnimation);
currentState = {
...currentState,
companions: updateCompanionState(
resetCompanionCombatPresentation(currentState.companions),
step.companionNpcId,
currentCompanion => ({
...currentCompanion,
skillCooldowns: step.cooledDown,
}),
),
activeCombatEffects: [],
};
setGameState(currentState);
if (step.delivery === 'melee' && step.strikeOffsetX > 0) {
currentState = {
...currentState,
companions: updateCompanionState(
currentState.companions,
step.companionNpcId,
currentCompanion => ({
...currentCompanion,
animationState: dashAnimation,
actionMode: 'melee',
offsetX: step.strikeOffsetX,
transitionMs: dashDuration,
}),
),
};
setGameState(currentState);
await sleep(dashDuration);
}
currentState = {
...currentState,
companions: updateCompanionState(
currentState.companions,
step.companionNpcId,
currentCompanion => ({
...currentCompanion,
animationState: casterAnimation,
actionMode: step.delivery,
offsetX: step.delivery === 'melee' ? step.strikeOffsetX : 0,
transitionMs: 0,
mana: currentCompanion.mana,
skillCooldowns: step.appliedCooldowns,
}),
),
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(releaseDelay);
const resolvedMonsters = currentState.sceneHostileNpcs.map(monster =>
monster.id === step.targetHostileNpcId
? {
...monster,
hp: Math.max(0, monster.hp - step.damage),
action: step.defeated
? `${monster.name}${companionCharacter.name}当场击败了`
: `${monster.name}${companionCharacter.name}逼退了半步`,
animation: step.defeated ? ('die' as const) : monster.animation,
characterAnimation: undefined,
combatMode: undefined,
}
: monster,
);
currentState = {
...currentState,
sceneHostileNpcs: resolvedMonsters,
inBattle: step.endsBattle ? false : currentState.inBattle,
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
};
setGameState(currentState);
if (step.defeated) {
await sleep(getMonsterAnimationDurationMs(currentState.worldType, step.targetHostileNpcId, 'die', turnVisualMs));
const remainingMonsters = resolvedMonsters.filter(
monster => !(monster.id === step.targetHostileNpcId && monster.hp <= 0),
);
currentState = {
...currentState,
sceneHostileNpcs: remainingMonsters,
inBattle: remainingMonsters.length > 0,
};
setGameState(currentState);
}
if (step.endsBattle) {
break;
}
currentState = {
...currentState,
companions: resetCompanionCombatPresentation(currentState.companions),
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(resetStageMs);
if (!currentState.inBattle) {
break;
}
continue;
}
const actingMonster = currentState.sceneHostileNpcs.find(monster => monster.id === step.monsterId);
if (!actingMonster) continue;
const npcCharacter = step.npcCharacterId ? getCharacterById(step.npcCharacterId) : null;
const npcSkill = npcCharacter && step.selectedSkillId ? getSkillById(npcCharacter, step.selectedSkillId) : null;
if (npcCharacter && npcSkill) {
const casterAnimation = getSkillCasterAnimation(npcSkill);
const releaseDelay = (npcSkill.effects?.length ?? 0) > 0
? getSkillReleaseDelayMs(npcCharacter, npcSkill)
: getCharacterAnimationDurationMs(npcCharacter, casterAnimation);
const attackedMonsters = currentState.sceneHostileNpcs.map(monster => {
if (monster.id !== step.monsterId) {
return {
...monster,
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
};
}
return {
...monster,
xMeters: step.strikeX,
animation: step.delivery === 'melee' ? ('attack' as const) : ('idle' as const),
action: step.target === 'companion' && step.targetCompanionNpcId
? `${monster.name}${npcSkill.name}的目标发起突袭`
: `${monster.name}施展了${npcSkill.name}`,
facing: getFacingTowardPlayer(step.strikeX, step.targetX),
characterAnimation: casterAnimation,
combatMode: step.delivery,
};
});
currentState = {
...currentState,
sceneHostileNpcs: attackedMonsters,
companions: step.target === 'companion' && step.targetCompanionNpcId
? updateCompanionState(
currentState.companions,
step.targetCompanionNpcId,
companion => ({
...companion,
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
actionMode: 'idle',
}),
)
: resetCompanionCombatPresentation(currentState.companions),
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
playerFacing: getFacingForPlayer(
currentState.playerX,
attackedMonsters.find(monster => monster.id === step.monsterId) ?? actingMonster,
),
};
setGameState(currentState);
await sleep(releaseDelay);
currentState = await playSkillEffects({
setGameState,
currentState,
attacker: {
character: npcCharacter,
xMeters: step.strikeX,
origin: 'monster',
facing: getFacingTowardPlayer(step.strikeX, step.targetX),
monsterId: step.monsterId,
},
target: {
xMeters: step.targetX,
origin: 'player',
},
skill: npcSkill,
});
const damagedState = applyDamageToPartyTarget(
currentState,
step.target === 'player'
? { kind: 'player' as const }
: { kind: 'companion' as const, npcId: step.targetCompanionNpcId! },
step.damage,
);
currentState = {
...damagedState,
companions: step.target === 'companion' && step.targetCompanionNpcId
? updateCompanionState(
resetCompanionCombatPresentation(damagedState.companions),
step.targetCompanionNpcId,
companion => ({
...companion,
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
}),
)
: resetCompanionCombatPresentation(damagedState.companions),
inBattle: step.endsBattle ? false : currentState.inBattle,
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
};
setGameState(currentState);
if (step.endsBattle) {
break;
}
currentState = {
...currentState,
sceneHostileNpcs: attackedMonsters.map(monster =>
monster.id === step.monsterId
? {
...monster,
xMeters: step.originalMonsterX,
animation: 'idle' as const,
facing: getFacingTowardPlayer(step.originalMonsterX, currentState.playerX),
characterAnimation: undefined,
combatMode: undefined,
}
: {
...monster,
animation: 'idle' as const,
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
characterAnimation: undefined,
combatMode: undefined,
},
),
};
setGameState(currentState);
await sleep(resetStageMs);
currentState = {
...currentState,
companions: resetCompanionCombatPresentation(currentState.companions),
};
setGameState(currentState);
continue;
}
const baseChanges = buildVisibleMonsterChanges(option, currentState.sceneHostileNpcs);
const attackedMonsters = currentState.sceneHostileNpcs.map(monster => {
if (monster.id !== step.monsterId) {
return {
...monster,
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
};
}
const sourceChange = baseChanges.find(change => change.id === monster.id);
return {
...monster,
xMeters: step.strikeX,
animation: 'attack' as const,
action: step.target === 'companion' && step.targetCompanionNpcId
? `${monster.name}閻氭稒澧ら崥鎴濇倱娴肩⒒`
: (sourceChange?.action ?? `${monster.name}閻氭稒澧ゆ稉濠冩降`),
facing: getFacingTowardPlayer(step.strikeX, step.targetX),
};
});
currentState = {
...currentState,
sceneHostileNpcs: attackedMonsters,
companions: step.target === 'companion' && step.targetCompanionNpcId
? updateCompanionState(
currentState.companions,
step.targetCompanionNpcId,
companion => ({
...companion,
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
actionMode: 'idle',
}),
)
: resetCompanionCombatPresentation(currentState.companions),
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
playerFacing: getFacingForPlayer(
currentState.playerX,
attackedMonsters.find(monster => monster.id === step.monsterId) ?? actingMonster,
),
};
setGameState(currentState);
await sleep(turnVisualMs);
const damagedState = applyDamageToPartyTarget(
currentState,
step.target === 'player'
? { kind: 'player' as const }
: { kind: 'companion' as const, npcId: step.targetCompanionNpcId! },
step.damage,
);
currentState = {
...damagedState,
companions: step.target === 'companion' && step.targetCompanionNpcId
? updateCompanionState(
resetCompanionCombatPresentation(damagedState.companions),
step.targetCompanionNpcId,
companion => ({
...companion,
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
}),
)
: resetCompanionCombatPresentation(damagedState.companions),
inBattle: step.endsBattle ? false : currentState.inBattle,
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
};
setGameState(currentState);
if (step.endsBattle) {
break;
}
currentState = {
...currentState,
sceneHostileNpcs: attackedMonsters.map(monster =>
monster.id === step.monsterId
? {
...monster,
xMeters: step.originalMonsterX,
animation: 'idle' as const,
facing: getFacingTowardPlayer(step.originalMonsterX, currentState.playerX),
characterAnimation: undefined,
combatMode: undefined,
}
: {
...monster,
animation: 'idle' as const,
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
characterAnimation: undefined,
combatMode: undefined,
},
),
};
setGameState(currentState);
await sleep(resetStageMs);
currentState = {
...currentState,
companions: resetCompanionCombatPresentation(currentState.companions),
};
setGameState(currentState);
}
setGameState(plan.finalState);
return plan.finalState;
}
export function createCombatPlayback(params: CombatPlaybackParams) {
const {
setGameState,
turnVisualMs,
resetStageMs,
} = params;
const playResolvedChoice = async (
state: GameState,
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: EscapePlaybackSync,
) => {
if (resolvedChoice.optionKind === 'battle') {
return playBattleSequence({
setGameState,
state,
option,
character,
plan: resolvedChoice.battlePlan!,
turnVisualMs,
resetStageMs,
});
}
if (resolvedChoice.optionKind === 'escape') {
return playEscapeSequenceWithStorySyncFromEngine({
setGameState,
state,
option,
finalState: resolvedChoice.afterSequence,
sync,
});
}
return playIdleSequence({
setGameState,
state,
option,
character,
finalState: resolvedChoice.afterSequence,
applyRecoveryEffectToState,
});
};
return {
playResolvedChoice,
};
}

View File

@@ -0,0 +1,284 @@
import { describe, expect, it, vi } from 'vitest';
const { scenes } = vi.hoisted(() => ({
scenes: [
{
id: 'scene-1',
name: 'Camp',
description: 'A quiet camp.',
imageSrc: '/camp.png',
forwardSceneId: 'scene-2',
connectedSceneIds: ['scene-2', 'scene-3'],
connections: [],
npcs: [],
treasureHints: [],
},
{
id: 'scene-2',
name: 'Trail',
description: 'A mountain trail.',
imageSrc: '/trail.png',
forwardSceneId: 'scene-3',
connectedSceneIds: ['scene-3'],
connections: [],
npcs: [],
treasureHints: [],
},
{
id: 'scene-3',
name: 'Ruin',
description: 'A ruined gate.',
imageSrc: '/ruin.png',
connectedSceneIds: [],
connections: [],
npcs: [],
treasureHints: [],
},
],
}));
vi.mock('../../data/scenePresets', () => ({
getForwardScenePreset: () => scenes[1],
getScenePresetById: (_worldType: unknown, sceneId: string) =>
scenes.find((scene) => scene.id === sceneId) ?? null,
getScenePresetsByWorld: () => scenes,
getSceneFriendlyNpcs: () => [],
getSceneHostileNpcs: () => [],
getSceneHostileNpcPresetIds: () => [],
getTravelScenePreset: () => scenes[1],
getWorldCampScenePreset: () => null,
}));
vi.mock('../../data/stateFunctions', () => ({
getFunctionById: (functionId: string) => ({
id: functionId,
state: functionId.startsWith('idle_') ? 'idle' : 'battle',
category: functionId === 'battle_escape_breakout' ? 'escape' : functionId.startsWith('idle_') ? 'idle' : 'battle',
}),
getFunctionEffect: () => ({
sceneShift: 0,
escapeDistance: 5,
escapeDurationMs: 5000,
}),
}));
import {
AnimationState,
type Character,
type GameState,
type StoryOption,
WorldType,
} from '../../types';
import type { BattlePlan } from './battlePlan';
import { buildResolvedChoiceState } from './resolvedChoice';
function createTestCharacter(): Character {
return {
id: 'test-hero',
name: 'Test Hero',
title: 'Hero',
description: 'A test character',
backstory: 'A test backstory',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [
{
id: 'skill-basic',
name: 'Basic Strike',
animation: AnimationState.ATTACK,
damage: 10,
manaCost: 0,
cooldownTurns: 1,
range: 1,
style: 'steady',
},
],
adventureOpenings: {},
};
}
function createBaseState(): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createTestCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: true,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
function createOption(functionId: string): StoryOption {
return {
functionId,
actionText: functionId,
text: functionId,
visuals: {
playerAnimation: AnimationState.ATTACK,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
describe('buildResolvedChoiceState', () => {
it('keeps battle planning in the pure resolution layer', () => {
const state = createBaseState();
const character = createTestCharacter();
const battlePlan: BattlePlan = {
preparedState: state,
turns: [],
finalState: {
...state,
inBattle: false,
},
};
const buildBattlePlan = vi.fn(() => battlePlan);
const resolved = buildResolvedChoiceState({
state,
option: createOption('battle_all_in_crush'),
character,
buildBattlePlan,
});
expect(buildBattlePlan).toHaveBeenCalledWith(state, expect.objectContaining({ functionId: 'battle_all_in_crush' }), character);
expect(resolved.optionKind).toBe('battle');
expect(resolved.battlePlan).toBe(battlePlan);
expect(resolved.afterSequence.inBattle).toBe(false);
});
it('builds escape results without playback timing concerns', () => {
const state = {
...createBaseState(),
sceneHostileNpcs: [
{
id: 'monster-1',
name: 'Wolf',
action: 'growls',
description: 'A wolf',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const resolved = buildResolvedChoiceState({
state,
option: createOption('battle_escape_breakout'),
character: createTestCharacter(),
buildBattlePlan: vi.fn(),
});
expect(resolved.optionKind).toBe('escape');
expect(resolved.battlePlan).toBeNull();
expect(resolved.afterSequence.inBattle).toBe(false);
expect(resolved.afterSequence.currentEncounter).toBeNull();
expect(resolved.afterSequence.sceneHostileNpcs).toEqual([]);
expect(resolved.afterSequence.playerFacing).toBe('right');
});
it('keeps idle follow-up generation separate from combat planning', () => {
const state = {
...createBaseState(),
inBattle: false,
};
const resolved = buildResolvedChoiceState({
state,
option: createOption('idle_observe_signs'),
character: createTestCharacter(),
buildBattlePlan: vi.fn(),
});
expect(resolved.optionKind).toBe('idle');
expect(resolved.battlePlan).toBeNull();
expect(resolved.afterSequence.inBattle).toBe(false);
expect(resolved.afterSequence.currentEncounter).toBeNull();
});
it('uses explicit target scene id from travel option payload', () => {
const state = {
...createBaseState(),
currentScenePreset: scenes[0] as GameState['currentScenePreset'],
inBattle: false,
};
const option = {
...createOption('idle_travel_next_scene'),
runtimePayload: {
targetSceneId: 'scene-3',
},
};
const resolved = buildResolvedChoiceState({
state,
option,
character: createTestCharacter(),
buildBattlePlan: vi.fn(),
});
expect(resolved.afterSequence.currentScenePreset?.id).toBe('scene-3');
});
});

View File

@@ -0,0 +1,132 @@
import {
getForwardScenePreset,
getScenePresetById,
getScenePresetsByWorld,
getTravelScenePreset,
} from '../../data/scenePresets';
import { getFunctionEffect } from '../../data/stateFunctions';
import type {
Character,
GameState,
StoryOption,
WorldType,
} from '../../types';
import { classifyCombatOption } from '../combatStoryUtils';
import { buildIdleAfterSequence } from '../idleAdventureFlow';
import {
applyRecoveryEffectToState,
type BattlePlan,
} from './battlePlan';
import { buildEscapeAfterSequence } from './escapeFlow';
type OptionKind = 'battle' | 'escape' | 'idle';
export type ResolvedChoiceState = {
optionKind: OptionKind;
battlePlan: BattlePlan | null;
afterSequence: GameState;
};
function getShiftedScenePreset(
worldType: WorldType | null,
currentScenePreset: GameState['currentScenePreset'],
sceneShift: number | undefined,
): GameState['currentScenePreset'] {
if (!worldType || sceneShift === undefined || sceneShift === 0) return currentScenePreset;
if (!currentScenePreset) return getScenePresetsByWorld(worldType)[0] ?? null;
if (sceneShift > 0) {
let nextScene = currentScenePreset;
for (let index = 0; index < sceneShift; index += 1) {
nextScene = getForwardScenePreset(worldType, nextScene.id) ?? nextScene;
}
return nextScene;
}
const scenes = getScenePresetsByWorld(worldType);
if (scenes.length === 0) return currentScenePreset;
const currentIndex = scenes.findIndex(scene => scene.id === currentScenePreset.id);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex = ((safeIndex + sceneShift) % scenes.length + scenes.length) % scenes.length;
return scenes[nextIndex] ?? null;
}
function getSceneTargetForFunction(
worldType: WorldType | null,
currentScenePreset: GameState['currentScenePreset'],
option: StoryOption,
): GameState['currentScenePreset'] {
if (!worldType) return currentScenePreset;
if (option.functionId === 'idle_travel_next_scene') {
const targetSceneId =
typeof option.runtimePayload?.targetSceneId === 'string'
? option.runtimePayload.targetSceneId
: null;
if (targetSceneId) {
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
}
return getTravelScenePreset(worldType, currentScenePreset?.id) ?? currentScenePreset;
}
return getShiftedScenePreset(
worldType,
currentScenePreset,
getFunctionEffect(option.functionId).sceneShift,
);
}
export function buildResolvedChoiceState(params: {
state: GameState;
option: StoryOption;
character: Character;
buildBattlePlan: (state: GameState, option: StoryOption, character: Character) => BattlePlan;
}) {
const {
state,
option,
character,
buildBattlePlan,
} = params;
const nextScenePreset = getSceneTargetForFunction(
state.worldType,
state.currentScenePreset,
option,
);
const optionKind = classifyCombatOption(option);
if (optionKind === 'battle') {
const battlePlan = buildBattlePlan(state, option, character);
const battleResult: GameState = {
...battlePlan.finalState,
currentScenePreset: nextScenePreset ?? battlePlan.finalState.currentScenePreset,
};
return {
optionKind,
battlePlan,
afterSequence: battleResult,
} satisfies ResolvedChoiceState;
}
if (optionKind === 'escape') {
return {
optionKind,
battlePlan: null,
afterSequence: buildEscapeAfterSequence(state, option),
} satisfies ResolvedChoiceState;
}
return {
optionKind,
battlePlan: null,
afterSequence: buildIdleAfterSequence({
state,
option,
character,
nextScenePreset,
applyRecoveryEffectToState,
}),
} satisfies ResolvedChoiceState;
}

View File

@@ -0,0 +1,105 @@
import {
getCharacterAnimationDurationMs,
getSequenceDurationMs,
getSkillCasterAnimation,
getSkillDelivery,
resolveSequenceFrames,
} from '../../data/characterCombat';
import type {
Character,
CharacterSkillDefinition,
CombatVisualEffect,
} from '../../types';
const RANGED_MONSTER_FOOT_LOCK_OFFSET_Y = -56;
function createCombatEffectId() {
return `combat-effect-${Math.random().toString(36).slice(2, 10)}`;
}
export function getSkillReleaseDelayMs(character: Character, skill: CharacterSkillDefinition) {
if (typeof skill.releaseDelayMs === 'number') return skill.releaseDelayMs;
const animationDuration = getCharacterAnimationDurationMs(character, getSkillCasterAnimation(skill));
return Math.min(260, Math.max(120, Math.round(animationDuration * 0.45)));
}
export function buildSkillEffects(
attacker: {
character: Character;
xMeters: number;
origin: 'player' | 'monster';
facing: 'left' | 'right';
monsterId?: string;
},
target: {
xMeters: number;
origin: 'player' | 'monster';
monsterId?: string;
},
skill: CharacterSkillDefinition,
) {
const phases = {
cast: [] as CombatVisualEffect[],
travel: [] as CombatVisualEffect[],
impact: [] as CombatVisualEffect[],
castDurationMs: 0,
travelDurationMs: 0,
impactDurationMs: 0,
};
const deliveryRanged = getSkillDelivery(skill) === 'ranged';
for (const effect of skill.effects ?? []) {
const frames = resolveSequenceFrames(attacker.character, effect.sequence);
if (frames.length === 0) continue;
const durationMs = effect.durationMs ?? getSequenceDurationMs(effect.sequence, frames.length);
const origin = effect.anchor === 'target' ? target : attacker;
const isProjectile =
effect.motion === 'projectile'
|| (effect.phase === 'travel' && deliveryRanged);
const fallbackProjectileStartOffsetX = attacker.facing === 'right' ? 18 : -18;
const startOffsetX = effect.startOffsetX ?? (isProjectile ? fallbackProjectileStartOffsetX : 0);
const endOffsetX = effect.endOffsetX ?? (isProjectile ? 0 : startOffsetX);
const startAnchorOffsetY = !isProjectile
&& deliveryRanged
&& origin.origin === 'monster'
? RANGED_MONSTER_FOOT_LOCK_OFFSET_Y
: 0;
const endAnchorOffsetY = isProjectile
&& deliveryRanged
&& target.origin === 'monster'
? RANGED_MONSTER_FOOT_LOCK_OFFSET_Y
: startAnchorOffsetY;
const instance: CombatVisualEffect = {
id: createCombatEffectId(),
frames,
fps: effect.sequence.fps ?? 10,
startX: isProjectile ? attacker.xMeters : origin.xMeters,
endX: isProjectile ? target.xMeters : origin.xMeters,
startOrigin: isProjectile ? attacker.origin : origin.origin,
endOrigin: isProjectile ? target.origin : origin.origin,
startMonsterId: isProjectile ? attacker.monsterId : origin.monsterId,
endMonsterId: isProjectile ? target.monsterId : origin.monsterId,
startAnchorOffsetY,
endAnchorOffsetY,
startOffsetX,
endOffsetX,
startYOffset: effect.startYOffset ?? 56,
endYOffset: effect.endYOffset ?? effect.startYOffset ?? 56,
durationMs,
sizePx: effect.sizePx ?? 96,
scale: effect.scale ?? 1,
facing: attacker.facing,
zIndex: effect.phase === 'impact' ? 28 : 24,
traveling: isProjectile,
};
phases[effect.phase].push(instance);
if (effect.phase === 'cast') phases.castDurationMs = Math.max(phases.castDurationMs, durationMs);
if (effect.phase === 'travel') phases.travelDurationMs = Math.max(phases.travelDurationMs, durationMs);
if (effect.phase === 'impact') phases.impactDurationMs = Math.max(phases.impactDurationMs, durationMs);
}
return phases;
}