This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -229,7 +229,92 @@ describe('buildBattlePlan', () => {
);
});
it('does not turn recovery fallback into a random player attack', () => {
it('keeps battle_attack_basic as a single basic attack instead of randomly selecting another skill', () => {
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 plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_attack_basic',
},
character: createTestCharacter(),
totalSequenceMs: 900,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 1,
});
const playerTurns = plan.turns.filter((turn) => turn.actor === 'player');
expect(playerTurns).toHaveLength(1);
expect(playerTurns[0]).toEqual(
expect.objectContaining({
selectedSkillId: 'battle-basic-attack',
}),
);
expect(plan.finalState.playerMana).toBe(state.playerMana);
});
it('resolves one full speed-ordered round when combat continues', () => {
const state = {
...createBaseState(),
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 120,
maxHp: 120,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_attack_basic',
},
character: createTestCharacter(),
totalSequenceMs: 6000,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 6,
});
expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']);
expect(plan.finalState.inBattle).toBe(true);
expect(plan.finalState.sceneHostileNpcs[0]?.hp).toBeGreaterThan(0);
});
it('keeps recovery as a player turn without converting it into an attack', () => {
const state = {
...createBaseState(),
playerHp: 40,
@@ -265,8 +350,77 @@ describe('buildBattlePlan', () => {
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);
const playerTurn = plan.turns.find((turn) => turn.actor === 'player');
expect(playerTurn).toEqual(
expect.objectContaining({
actor: 'player',
actionKind: 'recover',
selectedSkillId: null,
damage: 0,
}),
);
expect(plan.finalState.playerHp).toBeGreaterThan(state.playerHp);
expect(plan.finalState.playerMana).toBeGreaterThan(state.playerMana);
});
it('includes companion turns in fight mode and orders the round by speed', () => {
const state = {
...createBaseState(),
currentNpcBattleMode: 'fight' as const,
companions: [
{
npcId: 'companion-1',
characterId: 'archer-hero',
joinedAtAffinity: 10,
hp: 60,
maxHp: 60,
mana: 20,
maxMana: 20,
skillCooldowns: {},
},
],
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 0.5,
hp: 120,
maxHp: 120,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_attack_basic',
},
character: createTestCharacter(),
totalSequenceMs: 6000,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 6,
});
expect(plan.turns.map((turn) => turn.actor)).toEqual([
'companion',
'player',
'monster',
]);
expect(plan.turns[0]).toEqual(
expect.objectContaining({
actor: 'companion',
companionNpcId: 'companion-1',
}),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -185,4 +185,62 @@ describe('escapeFlow', () => {
expect(result.scrollWorld).toBe(false);
expect(result.playerFacing).toBe('right');
});
it('plays left exit and right-facing entry when escape targets a scene start', async () => {
const state = {
...createState(),
currentScenePreset: {
id: 'scene-bridge',
name: 'Bridge',
description: 'Bridge',
imageSrc: '/bridge.png',
worldType: WorldType.WUXIA,
connectedSceneIds: [],
connections: [],
npcs: [],
treasureHints: [],
},
};
const targetScene = {
...state.currentScenePreset!,
id: 'scene-east',
name: 'East Street',
};
const option = {
...createEscapeOption(),
runtimePayload: {
escapeTargetSceneId: targetScene.id,
escapeEntry: 'from_left',
},
};
const finalState = buildEscapeAfterSequence(state, option, targetScene);
const committedStates: GameState[] = [];
const result = await playEscapeSequenceWithStorySync({
setGameState: (nextState: GameState) => {
committedStates.push(nextState);
},
state,
option,
finalState,
sleepMs: async () => {
await Promise.resolve();
},
});
expect(committedStates[0]).toEqual(expect.objectContaining({
playerFacing: 'left',
animationState: AnimationState.RUN,
scrollWorld: true,
}));
expect(committedStates.some((committedState) =>
committedState.currentScenePreset?.id === 'scene-east' &&
committedState.playerX < 0 &&
committedState.playerFacing === 'right',
)).toBe(true);
expect(result.currentScenePreset?.id).toBe('scene-east');
expect(result.playerX).toBe(0);
expect(result.playerFacing).toBe('right');
expect(result.scrollWorld).toBe(false);
});
});

View File

@@ -1,9 +1,19 @@
import type { Dispatch, SetStateAction } from 'react';
import {
buildEncounterEntryState,
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import {
getFacingTowardPlayer,
settleHostileNpcAnimations,
} from '../../data/hostileNpcs';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { getFunctionEffect } from '../../data/stateFunctions';
import {
AnimationState,
@@ -15,6 +25,9 @@ import {
const ESCAPE_RUN_MS = 5000;
const ESCAPE_TICK_MS = 250;
const ESCAPE_TURN_PAUSE_MS = 180;
const ESCAPE_ENTRY_MS = 900;
const ESCAPE_ENTRY_TICK_MS = 90;
const ESCAPE_PLAYER_ENTRY_X = -1.4;
export type EscapePlaybackSync = {
waitForStoryResponse?: Promise<void>;
@@ -22,7 +35,7 @@ export type EscapePlaybackSync = {
type SetGameStateFn = Dispatch<SetStateAction<GameState>> | ((state: GameState) => void);
function sleep(ms: number) {
function sleep(ms: number): Promise<void> {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
@@ -35,6 +48,10 @@ function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
}));
}
function lerpMeters(start: number, end: number, progress: number) {
return Number((start + ((end - start) * progress)).toFixed(2));
}
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));
@@ -44,18 +61,22 @@ export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
export function buildEscapeAfterSequence(
state: GameState,
option: StoryOption,
nextScenePreset: GameState['currentScenePreset'] = state.currentScenePreset,
) {
const escapePlayerX = getEscapeSettlePlayerX(state, option);
return {
const shouldResetToSceneStart =
nextScenePreset?.id !== state.currentScenePreset?.id ||
option.runtimePayload?.escapeReturnToSceneStart === true;
const baseState = {
...state,
currentScenePreset: nextScenePreset ?? state.currentScenePreset,
currentEncounter: null,
npcInteractionActive: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sceneHostileNpcs: [],
playerX: escapePlayerX,
playerX: shouldResetToSceneStart ? 0 : escapePlayerX,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
@@ -66,6 +87,66 @@ export function buildEscapeAfterSequence(
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
} satisfies GameState;
const previewState = shouldResetToSceneStart
? ({
...baseState,
...createSceneEncounterPreview(baseState),
} satisfies GameState)
: baseState;
return hasEncounterEntity(previewState)
? resolveSceneEncounterPreview(previewState)
: baseState;
}
async function playEscapeEntrySequence(params: {
setGameState: SetGameStateFn;
finalState: GameState;
sleepMs: (ms: number) => Promise<void>;
}) {
const entryState = buildEncounterEntryState(
{
...params.finalState,
playerX: ESCAPE_PLAYER_ENTRY_X,
playerFacing: 'right',
animationState: AnimationState.RUN,
playerActionMode: 'idle',
scrollWorld: true,
},
CALL_OUT_ENTRY_X_METERS,
);
const runTicks = Math.max(1, Math.ceil(ESCAPE_ENTRY_MS / ESCAPE_ENTRY_TICK_MS));
const tickDurationMs = Math.max(1, Math.round(ESCAPE_ENTRY_MS / runTicks));
let currentState = entryState;
params.setGameState(currentState);
for (let tick = 1; tick <= runTicks; tick += 1) {
const progress = tick / runTicks;
const interpolatedState = interpolateEncounterTransitionState(
entryState,
params.finalState,
progress,
);
currentState = {
...interpolatedState,
playerX: lerpMeters(
ESCAPE_PLAYER_ENTRY_X,
params.finalState.playerX,
progress,
),
playerFacing: 'right',
animationState:
progress < 1 ? AnimationState.RUN : params.finalState.animationState,
playerActionMode: 'idle',
scrollWorld: progress < 1,
};
params.setGameState(currentState);
await params.sleepMs(tickDurationMs);
}
params.setGameState(params.finalState);
return params.finalState;
}
export async function playEscapeSequenceWithStorySync(params: {
@@ -93,6 +174,9 @@ export async function playEscapeSequenceWithStorySync(params: {
const settlePlayerX = finalState.playerX;
let storyResponseReady = !sync?.waitForStoryResponse;
let elapsedMs = 0;
const shouldPlayEntry =
finalState.currentScenePreset?.id !== state.currentScenePreset?.id ||
option.runtimePayload?.escapeReturnToSceneStart === true;
void sync?.waitForStoryResponse?.then(() => {
storyResponseReady = true;
@@ -127,19 +211,39 @@ export async function playEscapeSequenceWithStorySync(params: {
await sleepMs(ESCAPE_TICK_MS);
}
currentState = {
...finalState,
playerX: settlePlayerX,
playerFacing: 'left',
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
};
const settledExitState: GameState = shouldPlayEntry
? {
...finalState,
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, 0),
}
: {
...finalState,
playerX: settlePlayerX,
playerFacing: 'left' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
};
currentState = settledExitState;
setGameState(currentState);
await sleepMs(ESCAPE_TURN_PAUSE_MS);
if (shouldPlayEntry) {
return playEscapeEntrySequence({
setGameState,
finalState: settledExitState,
sleepMs,
});
}
currentState = {
...currentState,
playerFacing: 'right',

View File

@@ -52,6 +52,20 @@ function sleep(ms: number) {
}
function getSkillById(character: Character, skillId: string) {
if (skillId === 'battle-basic-attack') {
return {
id: 'battle-basic-attack',
name: '普通攻击',
animation: AnimationState.ATTACK,
damage: 0,
manaCost: 0,
cooldownTurns: 0,
range: 1,
style: 'steady',
delivery: 'melee',
} satisfies CharacterSkillDefinition;
}
return character.skills.find(skill => skill.id === skillId) ?? null;
}
@@ -192,6 +206,47 @@ async function playBattleSequence(params: CombatPlaybackParams & {
};
setGameState(currentState);
if (step.actionKind === 'recover') {
currentState = {
...currentState,
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
playerHp: step.playerHpAfterAction,
playerMana: step.playerManaAfterAction,
playerSkillCooldowns: step.appliedCooldowns,
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(resetStageMs);
continue;
}
if (step.actionKind === 'inventory') {
currentState = {
...currentState,
animationState: AnimationState.ACQUIRE,
playerActionMode: 'idle',
playerHp: step.playerHpAfterAction,
playerMana: step.playerManaAfterAction,
playerSkillCooldowns: step.appliedCooldowns,
playerInventory:
step.playerInventoryAfterAction ?? currentState.playerInventory,
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(Math.max(180, resetStageMs));
currentState = {
...currentState,
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(resetStageMs);
continue;
}
const skill = step.selectedSkillId ? getSkillById(character, step.selectedSkillId) : null;
if (!skill) {
await sleep(resetStageMs);
@@ -353,7 +408,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
};
setGameState(currentState);
if (step.delivery === 'melee' && step.strikeOffsetX > 0) {
if (step.delivery === 'melee' && Math.abs(step.strikeOffsetX) > 0.01) {
currentState = {
...currentState,
companions: updateCompanionState(
@@ -740,4 +795,3 @@ export function createCombatPlayback(params: CombatPlaybackParams) {
playResolvedChoice,
};
}

View File

@@ -240,6 +240,50 @@ describe('buildResolvedChoiceState', () => {
expect(resolved.afterSequence.playerFacing).toBe('right');
});
it('moves escape result to explicit target scene and resets player to scene start', () => {
const state = {
...createBaseState(),
currentScenePreset: scenes[0] as GameState['currentScenePreset'],
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 option = {
...createOption('battle_escape_breakout'),
runtimePayload: {
escapeTargetSceneId: 'scene-3',
escapeEntry: 'from_left',
},
};
const resolved = buildResolvedChoiceState({
state,
option,
character: createTestCharacter(),
buildBattlePlan: vi.fn(),
});
expect(resolved.optionKind).toBe('escape');
expect(resolved.afterSequence.currentScenePreset?.id).toBe('scene-3');
expect(resolved.afterSequence.playerX).toBe(0);
expect(resolved.afterSequence.playerFacing).toBe('right');
expect(resolved.afterSequence.inBattle).toBe(false);
});
it('keeps idle follow-up generation separate from combat planning', () => {
const state = {
...createBaseState(),

View File

@@ -58,15 +58,17 @@ function getSceneTargetForFunction(
): GameState['currentScenePreset'] {
if (!worldType) return currentScenePreset;
if (option.functionId === 'idle_travel_next_scene') {
const targetSceneId =
typeof option.runtimePayload?.targetSceneId === 'string'
? option.runtimePayload.targetSceneId
const targetSceneId =
typeof option.runtimePayload?.targetSceneId === 'string'
? option.runtimePayload.targetSceneId
: typeof option.runtimePayload?.escapeTargetSceneId === 'string'
? option.runtimePayload.escapeTargetSceneId
: null;
if (targetSceneId) {
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
}
if (targetSceneId) {
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
}
if (option.functionId === 'idle_travel_next_scene') {
return getTravelScenePreset(worldType, currentScenePreset?.id) ?? currentScenePreset;
}
@@ -114,7 +116,7 @@ export function buildResolvedChoiceState(params: {
return {
optionKind,
battlePlan: null,
afterSequence: buildEscapeAfterSequence(state, option),
afterSequence: buildEscapeAfterSequence(state, option, nextScenePreset),
} satisfies ResolvedChoiceState;
}