1
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}%`,
|
||||
|
||||
@@ -2070,8 +2070,9 @@ function SceneActPreviewRuntime({
|
||||
...current,
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: profile,
|
||||
// 中文注释:幕预览只复用运行时表现,不应进入正式存档和个人游玩记录。
|
||||
runtimeMode: 'preview',
|
||||
// 中文注释:幕预览也统一复用正式 play 运行链,
|
||||
// 只通过禁持久化控制“不写正式存档”。
|
||||
runtimeMode: 'play',
|
||||
runtimePersistenceDisabled: true,
|
||||
currentScene: 'Story',
|
||||
currentScenePreset: previewScenePreset,
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
结束测试
|
||||
|
||||
@@ -111,5 +111,6 @@ export interface RpgRuntimeShellProps {
|
||||
companions: RpgRuntimeCompanionProps;
|
||||
audio: RpgRuntimeAudioProps;
|
||||
chrome?: RpgRuntimeShellChromeOptions;
|
||||
onExitTestRuntime?: () => void;
|
||||
onExitRuntimePreview?: () => void;
|
||||
showRuntimePreviewExit?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user