This commit is contained in:
2026-05-05 14:40:41 +08:00
parent e847fcea6f
commit 07e777fef8
76 changed files with 4246 additions and 444 deletions

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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;