1
This commit is contained in:
@@ -151,6 +151,7 @@ export function useRpgRuntimeShellViewModel(
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
onDeferredAutoChoice: (option) => handleChoice(option),
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
@@ -222,12 +223,24 @@ export function useRpgRuntimeShellViewModel(
|
||||
const handleSceneTransitionChoice = useCallback(
|
||||
(option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
const shouldBeginTransition =
|
||||
transitionMode &&
|
||||
(option.functionId !== 'story_continue_adventure' ||
|
||||
Boolean(
|
||||
currentStory?.deferredAutoChoice ||
|
||||
currentStory?.deferredRuntimeState,
|
||||
));
|
||||
if (shouldBeginTransition) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
},
|
||||
[beginSceneTransition, handleChoice],
|
||||
[
|
||||
beginSceneTransition,
|
||||
currentStory?.deferredAutoChoice,
|
||||
currentStory?.deferredRuntimeState,
|
||||
handleChoice,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { useRpgSceneTransitionModel } from './useRpgSceneTransitionModel';
|
||||
|
||||
function createGameState(actId: string): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
name: '测试主角',
|
||||
title: '游侠',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '',
|
||||
portrait: '',
|
||||
assetFolder: '',
|
||||
assetVariant: '',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: '沉稳',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
},
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [{ text: '旧幕', options: [] }],
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: 'scene-1',
|
||||
chapterId: 'chapter-1',
|
||||
currentActId: actId,
|
||||
currentActIndex: actId === 'act-1' ? 0 : 1,
|
||||
completedActIds: actId === 'act-1' ? [] : ['act-1'],
|
||||
visitedActIds: [actId],
|
||||
},
|
||||
},
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'scene-1',
|
||||
name: '断桥旧哨',
|
||||
description: '测试场景',
|
||||
imageSrc: '/scene.png',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
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 createStory(
|
||||
text: string,
|
||||
options: StoryOption[] = [],
|
||||
deferredAutoChoice?: StoryOption,
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
deferredAutoChoice,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRpgSceneTransitionModel', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('fires deferred auto choice only after entry and through the latest callback', () => {
|
||||
vi.useFakeTimers();
|
||||
const autoChoice: StoryOption = {
|
||||
functionId: 'npc_preview_talk',
|
||||
actionText: '与新角色交谈',
|
||||
text: '与新角色交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const firstCallback = vi.fn();
|
||||
const latestCallback = vi.fn();
|
||||
const initialState = createGameState('act-1');
|
||||
const initialStory = createStory('旧幕收束', [
|
||||
{
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '继续冒险',
|
||||
text: '继续冒险',
|
||||
visuals: autoChoice.visuals,
|
||||
},
|
||||
]);
|
||||
const nextStory = createStory('新幕入口', [autoChoice], autoChoice);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment;
|
||||
onDeferredAutoChoice: (option: StoryOption) => void;
|
||||
}) =>
|
||||
useRpgSceneTransitionModel({
|
||||
gameState: props.gameState,
|
||||
currentStory: props.currentStory,
|
||||
openingCampSceneId: null,
|
||||
onDeferredAutoChoice: props.onDeferredAutoChoice,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
gameState: initialState,
|
||||
currentStory: initialStory,
|
||||
onDeferredAutoChoice: firstCallback,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSceneTransitionDurations({ exitMs: 20, entryMs: 30 });
|
||||
});
|
||||
act(() => {
|
||||
result.current.beginSceneTransition('content-change');
|
||||
});
|
||||
|
||||
expect(result.current.sceneTransitionPhase).toBe('exiting');
|
||||
|
||||
rerender({
|
||||
gameState: createGameState('act-2'),
|
||||
currentStory: nextStory,
|
||||
onDeferredAutoChoice: latestCallback,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
|
||||
expect(result.current.sceneTransitionPhase).toBe('entering');
|
||||
expect(latestCallback).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30);
|
||||
});
|
||||
|
||||
expect(result.current.sceneTransitionPhase).toBe('idle');
|
||||
expect(firstCallback).not.toHaveBeenCalled();
|
||||
expect(latestCallback).toHaveBeenCalledWith(autoChoice);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
|
||||
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
|
||||
@@ -18,6 +18,7 @@ const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930;
|
||||
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<
|
||||
Record<string, SceneTransitionTriggerMode>
|
||||
> = {
|
||||
story_continue_adventure: 'content-change',
|
||||
idle_travel_next_scene: 'scene-change',
|
||||
camp_travel_home_scene: 'scene-change',
|
||||
idle_explore_forward: 'content-change',
|
||||
@@ -29,6 +30,9 @@ function buildSceneTransitionContentKey(
|
||||
currentStory: StoryMoment | null,
|
||||
) {
|
||||
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
|
||||
const sceneActId =
|
||||
gameState.storyEngineMemory?.currentSceneActState?.currentActId ??
|
||||
'act:none';
|
||||
const encounterKey = gameState.currentEncounter
|
||||
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
|
||||
: 'encounter:none';
|
||||
@@ -39,9 +43,9 @@ function buildSceneTransitionContentKey(
|
||||
)
|
||||
.join('|');
|
||||
const storyKey = currentStory
|
||||
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
|
||||
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}:${currentStory.options.map((option) => option.functionId).join('|')}:${currentStory.deferredAutoChoice?.functionId ?? 'auto:none'}`
|
||||
: 'story:none';
|
||||
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
|
||||
return [sceneId, sceneActId, encounterKey, monsterKey, storyKey].join('::');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,8 +56,14 @@ export function useRpgSceneTransitionModel(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
openingCampSceneId: string | null;
|
||||
onDeferredAutoChoice?: ((option: StoryOption) => void) | null;
|
||||
}) {
|
||||
const { gameState, currentStory, openingCampSceneId } = params;
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
onDeferredAutoChoice = null,
|
||||
} = params;
|
||||
const [renderGameState, setRenderGameState] = useState(gameState);
|
||||
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
|
||||
const [sceneTransitionPhase, setSceneTransitionPhase] =
|
||||
@@ -73,6 +83,13 @@ export function useRpgSceneTransitionModel(params: {
|
||||
});
|
||||
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
|
||||
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
|
||||
const pendingDeferredAutoChoiceRef =
|
||||
useRef<StoryOption | null>(null);
|
||||
const onDeferredAutoChoiceRef = useRef(onDeferredAutoChoice);
|
||||
|
||||
useEffect(() => {
|
||||
onDeferredAutoChoiceRef.current = onDeferredAutoChoice;
|
||||
}, [onDeferredAutoChoice]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -81,6 +98,7 @@ export function useRpgSceneTransitionModel(params: {
|
||||
);
|
||||
sceneTransitionTimerIdsRef.current = [];
|
||||
sceneTransitionRequestRef.current = null;
|
||||
pendingDeferredAutoChoiceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -98,6 +116,15 @@ export function useRpgSceneTransitionModel(params: {
|
||||
|
||||
const entryTimerId = window.setTimeout(() => {
|
||||
setSceneTransitionPhase('idle');
|
||||
const autoChoice =
|
||||
payload.currentStory?.deferredAutoChoice ??
|
||||
pendingDeferredAutoChoiceRef.current;
|
||||
if (autoChoice) {
|
||||
pendingDeferredAutoChoiceRef.current = null;
|
||||
// 中文注释:入场计时器可能跨过一次 currentStory/gameState 更新,
|
||||
// 必须读取最新回调,避免用点击“继续冒险”前的旧状态自动开聊。
|
||||
onDeferredAutoChoiceRef.current?.(autoChoice);
|
||||
}
|
||||
}, sceneTransitionDurations.entryMs);
|
||||
sceneTransitionTimerIdsRef.current.push(entryTimerId);
|
||||
},
|
||||
@@ -109,6 +136,7 @@ export function useRpgSceneTransitionModel(params: {
|
||||
if (sceneTransitionPhase !== 'idle') return;
|
||||
|
||||
pendingScenePayloadRef.current = { gameState, currentStory };
|
||||
pendingDeferredAutoChoiceRef.current = null;
|
||||
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
|
||||
window.clearTimeout(timerId),
|
||||
);
|
||||
@@ -170,6 +198,8 @@ export function useRpgSceneTransitionModel(params: {
|
||||
: buildSceneTransitionContentKey(gameState, currentStory) !==
|
||||
request.baselineContentKey;
|
||||
if (isReady) {
|
||||
pendingDeferredAutoChoiceRef.current =
|
||||
currentStory?.deferredAutoChoice ?? null;
|
||||
startSceneEntering({ gameState, currentStory });
|
||||
}
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user