This commit is contained in:
2026-04-28 02:05:12 +08:00
parent 271db02e4a
commit 1eb090e4a5
39 changed files with 2671 additions and 165 deletions

View File

@@ -14,6 +14,7 @@ import {
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMonsterWorldLeft,
getMirroredStageEntityLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
@@ -173,6 +174,59 @@ describe('GameCanvasEntityLayer', () => {
expect(getNpcCombatHpTop(null, null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
});
it('does not apply scene visual ground offset twice for custom character enemies in battle', () => {
const hostileNpc = createHostileNpc({
encounter: createEncounter({
id: 'npc-shark-elder',
npcName: '珊瑚长老',
characterId: 'hero',
imageSrc: '/generated-custom-world-npc/shark-elder.png',
}),
});
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[hostileNpc]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).toContain('bottom:calc(calc(18% + 68px) + -78px)');
expect(html).not.toContain('bottom:calc(calc(18% + 68px - 78px) + -78px)');
});
it('renders affinity effect on the matching hostile npc', () => {
const html = renderEntityLayer('npc-liu');
@@ -283,4 +337,160 @@ describe('GameCanvasEntityLayer', () => {
expect(html).toContain('查看后排甲详情');
expect(html).toContain('查看后排乙详情');
});
it('keeps hostile combatant identity stable while attack position changes', () => {
const sideAnchor = '15%';
const cameraAnchorX = 0;
const monsterAnchorMeters = 3.2;
const attackingNpc = createHostileNpc({
id: 'npc-attacker',
xMeters: 0.1,
animation: 'attack',
combatMode: 'melee',
encounter: createEncounter({
id: 'npc-attacker',
npcName: '突进敌人',
}),
});
const renderedLeft = getMonsterWorldLeft(
sideAnchor,
attackingNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
);
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[attackingNpc]}
monsters={[]}
getHostileNpcOuterLeft={(hostileNpc) =>
getMonsterWorldLeft(
sideAnchor,
hostileNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)
}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor={sideAnchor}
cameraAnchorX={cameraAnchorX}
monsterAnchorMeters={monsterAnchorMeters}
playerX={0}
/>,
);
expect(html).toContain(`left:${renderedLeft}`);
});
it('keeps enemy formation positions when battle starts before any attack dash', () => {
const sideAnchor = '15%';
const cameraAnchorX = 0;
const monsterAnchorMeters = 3.2;
const frontNpc = createHostileNpc({
id: 'npc-front',
xMeters: 3.2,
encounter: createEncounter({
id: 'npc-front',
npcName: '前排敌人',
}),
});
const backNpc = createHostileNpc({
id: 'npc-back',
xMeters: 4.28,
yOffset: 62,
encounter: createEncounter({
id: 'npc-back',
npcName: '后排敌人',
}),
});
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[frontNpc, backNpc]}
monsters={[]}
getHostileNpcOuterLeft={(hostileNpc) =>
getMonsterWorldLeft(
sideAnchor,
hostileNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)
}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor={sideAnchor}
cameraAnchorX={cameraAnchorX}
monsterAnchorMeters={monsterAnchorMeters}
playerX={0}
/>,
);
const frontLeft = `left:${getMonsterWorldLeft(
sideAnchor,
frontNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)}`;
const backLeft = `left:${getMonsterWorldLeft(
sideAnchor,
backNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)}`;
expect(html).toContain(frontLeft);
expect(html).toContain(backLeft);
});
});

View File

@@ -445,8 +445,6 @@ export function GameCanvasEntityLayer({
const hostileRenderKey = [
hostileNpc.id,
npcEncounter.id ?? npcEncounter.npcName,
hostileNpc.xMeters,
hostileNpc.yOffset ?? 0,
index,
].join(':');
const config = monsters.find(item => item.id === hostileNpc.id);
@@ -469,18 +467,25 @@ export function GameCanvasEntityLayer({
npcMonsterConfig
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
: getSceneNpcVisualBottomOffsetPx(npcEncounter);
// 中文注释:带 characterId 的自定义世界角色在 getEncounterCharacterOpponentBottom()
// 里已经按场景立绘脚底锚点完成了一次落地修正。
// 若这里再把 getSceneNpcVisualBottomOffsetPx() 叠加到战斗实体底边,
// 就会在刚进入战斗时整队额外下沉 78px表现成敌方瞬间偏到右下角。
const battleEntityVisualOffsetPx = npcCharacter
? 0
: hostileNpcBottomOffsetPx;
const opponentBottom = npcCharacter
? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter)
: `calc(${groundBottom} + ${stageLiftPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx}px)`;
const entityBottomOffsetPx = npcCharacter
? getEncounterCharacterBottomOffsetPx(
stageLiftPx,
npcEncounter,
npcCharacter,
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
(hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx,
)
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
: stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx;
return (
<div

View File

@@ -18,7 +18,6 @@ import {
getMirroredStageEntityLeft,
getMonsterWorldLeft,
getPlayerWorldLeft,
HOSTILE_NPC_SCENE_INSET_PX,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_SPEED_PX_PER_S,
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
@@ -119,7 +118,6 @@ export function GameCanvasRuntime({
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
const playerStageLeft = getMirroredStageEntityLeft(sideAnchor, 'player');
const opponentStageLeft = getMirroredStageEntityLeft(sideAnchor, 'opponent');
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
@@ -132,15 +130,16 @@ export function GameCanvasRuntime({
: playerStageLeft;
const monsterAnchorMeters = 3.2;
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
if (!scrollWorld && hostileNpc.animation !== 'attack') {
return opponentStageLeft;
if (hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld) {
return monsterMeleeLeft;
}
const baseLeft =
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`;
return getMonsterWorldLeft(
sideAnchor,
hostileNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
);
};
const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
const base = playerActionMode === 'melee' && !scrollWorld

View File

@@ -4,10 +4,11 @@ import type {
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile } from '../../types';
export type CustomWorldRuntimeLaunchMode = 'play' | 'test';
export type CustomWorldRuntimeLaunchMode = 'play';
export type CustomWorldRuntimeLaunchOptions = {
mode?: CustomWorldRuntimeLaunchMode;
disablePersistence?: boolean;
returnStage?: SelectionStage | null;
};

View File

@@ -1,11 +1,16 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { AuthUiContext } from '../auth/AuthUiContext';
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
import {
PuzzleRuntimeShell,
resolveDraggedMergedGroupLayer,
resolveDraggedPieceCellLayer,
resolveDraggedPieceLayer,
} from './PuzzleRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: () => ({
@@ -247,3 +252,16 @@ test('合并块按实际拼块外轮廓描边', () => {
expect(outlinedPieces[2]?.className).toContain('border-t-0');
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
});
test('拖拽层级辅助函数只提升当前被拖动对象', () => {
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', false)).toBe(80);
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-1', false)).toBeUndefined();
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', true)).toBeUndefined();
expect(resolveDraggedPieceLayer('piece-0', 'piece-0', false)).toBe(81);
expect(resolveDraggedPieceLayer('piece-0', null, false)).toBeUndefined();
expect(resolveDraggedPieceLayer('piece-0', 'piece-0', true)).toBeUndefined();
expect(resolveDraggedMergedGroupLayer('group-1', 'group-1')).toBe(90);
expect(resolveDraggedMergedGroupLayer('group-1', 'group-2')).toBeUndefined();
});

View File

@@ -65,6 +65,35 @@ function buildLocalCellKey(row: number, col: number) {
return `${row}:${col}`;
}
export function resolveDraggedPieceCellLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 80;
}
export function resolveDraggedPieceLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 81;
}
export function resolveDraggedMergedGroupLayer(
groupId: string,
draggingGroupId: string | null,
) {
return groupId === draggingGroupId ? 90 : undefined;
}
function resolveMergedPieceOutlineClass(
group: PuzzleMergedGroupViewModel,
piece: PuzzleMergedGroupViewModel['pieces'][number],
@@ -186,8 +215,13 @@ export function PuzzleRuntimeShell({
} | null>(null);
const dragVisualFrameRef = useRef<number | null>(null);
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
const pieceCellElementRefMap = useRef(new Map<string, HTMLDivElement>());
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
const [dragRenderTarget, setDragRenderTarget] = useState<{
pieceId: string;
groupId: string | null;
} | null>(null);
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
null,
);
@@ -250,8 +284,18 @@ export function PuzzleRuntimeShell({
[pieces],
);
const resolvePieceCellElement = (pieceId: string) => {
const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null;
const pieceCellElement =
(pieceElement?.parentElement as HTMLDivElement | null) ??
pieceCellElementRefMap.current.get(pieceId) ??
null;
return pieceCellElement;
};
const resetDragVisualTarget = () => {
const dragVisualTarget = dragVisualTargetRef.current;
setDragRenderTarget(null);
if (!dragVisualTarget) {
return;
}
@@ -259,6 +303,10 @@ export function PuzzleRuntimeShell({
const pieceElement = pieceElementRefMap.current.get(
dragVisualTarget.pieceId,
);
const pieceCellElement = resolvePieceCellElement(dragVisualTarget.pieceId);
if (pieceCellElement) {
pieceCellElement.style.zIndex = '';
}
if (pieceElement) {
pieceElement.style.transform = '';
pieceElement.style.willChange = '';
@@ -319,6 +367,15 @@ export function PuzzleRuntimeShell({
resetDragVisualTarget();
}
dragVisualTargetRef.current = nextTarget;
setDragRenderTarget((currentTarget) => {
if (
currentTarget?.pieceId === nextTarget.pieceId &&
currentTarget.groupId === nextTarget.groupId
) {
return currentTarget;
}
return nextTarget;
});
const offsetX = dragSession.currentX - dragSession.startX;
const offsetY = dragSession.currentY - dragSession.startY;
@@ -327,11 +384,16 @@ export function PuzzleRuntimeShell({
if (groupId) {
const groupElement = groupElementRefMap.current.get(groupId);
if (groupElement) {
// 合并块拖动时直接提升整个组容器层级,确保完整拼块永远压在单块之上。
groupElement.style.willChange = 'transform';
groupElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.02)`;
groupElement.style.zIndex = '80';
groupElement.style.zIndex = '90';
groupElement.style.opacity = '0.95';
}
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
if (pieceCellElement) {
pieceCellElement.style.zIndex = '';
}
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
if (pieceElement) {
pieceElement.style.transform = '';
@@ -342,11 +404,16 @@ export function PuzzleRuntimeShell({
return;
}
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
if (pieceCellElement) {
// 单块拖动时提升所属格子的堆叠层级,避免被后绘制的拼块或合并块遮住。
pieceCellElement.style.zIndex = '80';
}
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
if (pieceElement) {
pieceElement.style.willChange = 'transform';
pieceElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.03)`;
pieceElement.style.zIndex = '70';
pieceElement.style.zIndex = '81';
pieceElement.style.opacity = '0.95';
}
};
@@ -559,7 +626,8 @@ export function PuzzleRuntimeShell({
return;
}
// 拖动中的视觉更新直接写入 DOM transform避免 pointermove 触发整盘 React 重渲染导致跟手延迟
// 首帧拖拽反馈立即落到 DOM确保层级提升不会滞后一帧后续仍保留 raf 兜底连续刷新
flushDragVisual();
scheduleDragVisual();
};
@@ -575,6 +643,8 @@ export function PuzzleRuntimeShell({
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
const draggingGroupId = dragRenderTarget?.groupId ?? null;
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
@@ -643,7 +713,28 @@ export function PuzzleRuntimeShell({
const isSelected = piece?.pieceId === selectedPieceId;
return (
<div key={`${cell.row}:${cell.col}`} className="relative p-1">
<div
key={`${cell.row}:${cell.col}`}
ref={(node) => {
if (!piece) {
return;
}
if (node) {
pieceCellElementRefMap.current.set(piece.pieceId, node);
return;
}
pieceCellElementRefMap.current.delete(piece.pieceId);
}}
data-piece-cell-id={piece?.pieceId ?? undefined}
className="relative p-1"
style={{
zIndex: resolveDraggedPieceCellLayer(
piece?.pieceId,
draggingPieceId,
isMerged,
),
}}
>
<div
ref={(node) => {
if (!piece) {
@@ -656,7 +747,7 @@ export function PuzzleRuntimeShell({
pieceElementRefMap.current.delete(piece.pieceId);
}}
data-piece-id={piece?.pieceId ?? undefined}
className={`flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
className={`relative flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
occupied
? isSelected
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
@@ -669,6 +760,13 @@ export function PuzzleRuntimeShell({
? 'transition-colors'
: 'transition-[background-color,border-color,box-shadow,opacity]'
}`}
style={{
zIndex: resolveDraggedPieceLayer(
piece?.pieceId,
draggingPieceId,
isMerged,
),
}}
onPointerDown={(event) => {
if (!piece || isMerged) {
return;
@@ -734,8 +832,13 @@ export function PuzzleRuntimeShell({
}
groupElementRefMap.current.delete(group.groupId);
}}
data-merged-group-id={group.groupId}
className="pointer-events-none absolute z-10 p-1"
style={{
zIndex: resolveDraggedMergedGroupLayer(
group.groupId,
draggingGroupId,
),
left: `${(group.minCol / board.cols) * 100}%`,
top: `${(group.minRow / board.rows) * 100}%`,
width: `${(group.colSpan / board.cols) * 100}%`,

View File

@@ -2070,8 +2070,9 @@ function SceneActPreviewRuntime({
...current,
worldType: WorldType.CUSTOM,
customWorldProfile: profile,
// 中文注释:幕预览只复用运行时表现,不应进入正式存档和个人游玩记录。
runtimeMode: 'preview',
// 中文注释:幕预览也统一复用正式 play 运行链,
// 只通过禁持久化控制“不写正式存档”。
runtimeMode: 'play',
runtimePersistenceDisabled: true,
currentScene: 'Story',
currentScenePreset: previewScenePreset,

View File

@@ -2527,7 +2527,8 @@ test('agent draft result test button enters current draft without publish gate',
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({ name: '潮雾列岛' }),
expect.objectContaining({
mode: 'test',
mode: 'play',
disablePersistence: true,
returnStage: 'custom-world-result',
}),
);

View File

@@ -114,10 +114,10 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
}
describe('useRpgCreationEnterWorld', () => {
it('Agent 草稿测试进入游戏时使用结果页当前 profile 的角色形象', async () => {
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => {
const staleResultProfile = buildProfile({
id: 'stale-result',
name: '旧结果页快照',
id: 'session-profile',
name: '会话旧快照',
imageSrc: '/template/old-role.png',
});
const resultProfile = buildProfile({
@@ -133,8 +133,8 @@ describe('useRpgCreationEnterWorld', () => {
const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
generatedCustomWorldProfile: staleResultProfile,
agentSessionProfile: resultProfile,
generatedCustomWorldProfile: resultProfile,
agentSessionProfile: staleResultProfile,
agentSession: buildSession(),
handleCustomWorldSelect,
executePublishWorld,
@@ -158,9 +158,11 @@ describe('useRpgCreationEnterWorld', () => {
expect(executePublishWorld).not.toHaveBeenCalled();
expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, {
mode: 'test',
mode: 'play',
disablePersistence: true,
returnStage: 'custom-world-result',
});
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(resultProfile);
expect(
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
).toBe('/generated-characters/draft-role/portrait.png');

View File

@@ -21,7 +21,7 @@ type UseRpgCreationEnterWorldParams = {
/**
* 统一“进入世界”前的最终同步策略。
* Agent 草稿结果进入游戏时只读当前结果页 profile不再静默回退到基础 draftProfile
* Agent 草稿结果进入游戏时只读当前结果页 profile不再静默回退到会话侧旧快照
*/
export function useRpgCreationEnterWorld(
params: UseRpgCreationEnterWorldParams,
@@ -42,26 +42,22 @@ export function useRpgCreationEnterWorld(
return;
}
if (!isAgentDraftResultView || !activeAgentSessionId) {
handleCustomWorldSelect(generatedCustomWorldProfile, {
mode: 'test',
returnStage: 'custom-world-result',
});
return;
// 中文注释:作品测试必须复用“结果页当前真相源”。
// 用户在结果页看到并可能继续编辑的是 generatedCustomWorldProfile
// 如果这里又回退成会话里的 agentSessionProfile就会出现
// “结果页看起来已经是新版,但作品测试实际进入的是旧版快照”的错位。
if (isAgentDraftResultView && activeAgentSessionId) {
setGeneratedCustomWorldProfile(generatedCustomWorldProfile);
}
if (!agentSessionProfile) {
return;
}
setGeneratedCustomWorldProfile(agentSessionProfile);
handleCustomWorldSelect(agentSessionProfile, {
mode: 'test',
handleCustomWorldSelect(generatedCustomWorldProfile, {
// 中文注释:作品测试现在复用正式 play 运行链,只保留
// “返回结果页 + 禁止写正式持久化”的入口语义。
mode: 'play',
disablePersistence: true,
returnStage: 'custom-world-result',
});
}, [
activeAgentSessionId,
agentSessionProfile,
generatedCustomWorldProfile,
handleCustomWorldSelect,
isAgentDraftResultView,

View File

@@ -83,7 +83,10 @@ vi.mock('./RpgRuntimeStageRouter', () => ({
let mockVisibleGameState: GameState;
function createGameState(runtimeMode: GameState['runtimeMode']): GameState {
function createGameState(
runtimeMode: GameState['runtimeMode'],
runtimePersistenceDisabled?: boolean,
): GameState {
return {
worldType: WorldType.CUSTOM,
customWorldProfile: null,
@@ -112,7 +115,7 @@ function createGameState(runtimeMode: GameState['runtimeMode']): GameState {
initialItems: [],
},
runtimeMode,
runtimePersistenceDisabled: runtimeMode !== 'play',
runtimePersistenceDisabled: runtimePersistenceDisabled ?? false,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
@@ -168,8 +171,11 @@ function createGameState(runtimeMode: GameState['runtimeMode']): GameState {
};
}
function buildProps(runtimeMode: GameState['runtimeMode']): RpgRuntimeShellProps {
const gameState = createGameState(runtimeMode);
function buildProps(
runtimeMode: GameState['runtimeMode'],
runtimePersistenceDisabled?: boolean,
): RpgRuntimeShellProps {
const gameState = createGameState(runtimeMode, runtimePersistenceDisabled);
mockVisibleGameState = gameState;
return {
session: {
@@ -254,24 +260,25 @@ beforeEach(() => {
mockVisibleGameState = createGameState('play');
});
test('测试态显示结束测试按钮并触发退出回调', async () => {
test('结果页测试入口可显示结束测试按钮并触发退出回调', async () => {
const user = userEvent.setup();
const onExitTestRuntime = vi.fn();
const onExitRuntimePreview = vi.fn();
render(
<RpgRuntimeShell
{...buildProps('test')}
onExitTestRuntime={onExitTestRuntime}
{...buildProps('play', true)}
onExitRuntimePreview={onExitRuntimePreview}
showRuntimePreviewExit
/>,
);
await user.click(screen.getByRole('button', { name: '结束测试' }));
expect(onExitTestRuntime).toHaveBeenCalledTimes(1);
expect(onExitRuntimePreview).toHaveBeenCalledTimes(1);
});
test('正式运行态不显示结束测试按钮', () => {
render(<RpgRuntimeShell {...buildProps('play')} onExitTestRuntime={() => {}} />);
render(<RpgRuntimeShell {...buildProps('play')} />);
expect(screen.queryByRole('button', { name: '结束测试' })).toBeNull();
});

View File

@@ -37,7 +37,8 @@ export function RpgRuntimeShell({
companions,
audio,
chrome,
onExitTestRuntime,
onExitRuntimePreview,
showRuntimePreviewExit,
}: RpgRuntimeShellComponentProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
@@ -133,7 +134,10 @@ export function RpgRuntimeShell({
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
),
);
const isTestRuntime = gameState.runtimeMode === 'test';
const canExitRuntimePreview =
Boolean(gameState.worldType) &&
Boolean(showRuntimePreviewExit) &&
Boolean(onExitRuntimePreview);
useEffect(() => {
if (gameState.worldType && !gameState.playerCharacter) {
@@ -209,7 +213,7 @@ export function RpgRuntimeShell({
</div>
)}
{gameState.worldType && isTestRuntime && onExitTestRuntime ? (
{canExitRuntimePreview ? (
<div
className="fixed inset-x-0 z-[170] flex justify-center px-4"
style={{
@@ -218,7 +222,7 @@ export function RpgRuntimeShell({
>
<button
type="button"
onClick={onExitTestRuntime}
onClick={onExitRuntimePreview}
className="inline-flex min-h-[2.75rem] items-center justify-center rounded-full border border-white/15 bg-black/65 px-5 text-sm font-semibold text-white shadow-[0_12px_30px_rgba(0,0,0,0.38)] backdrop-blur-sm transition hover:border-white/28 hover:bg-black/78"
>

View File

@@ -111,5 +111,6 @@ export interface RpgRuntimeShellProps {
companions: RpgRuntimeCompanionProps;
audio: RpgRuntimeAudioProps;
chrome?: RpgRuntimeShellChromeOptions;
onExitTestRuntime?: () => void;
onExitRuntimePreview?: () => void;
showRuntimePreviewExit?: boolean;
}