Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -25,6 +25,25 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
const mocapMock = vi.hoisted(() => ({
|
||||
state: 'grab',
|
||||
x: 0.42,
|
||||
y: 0.58,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/useMocapInput', () => ({
|
||||
useMocapInput: () => ({
|
||||
status: 'connected',
|
||||
latestCommand: {
|
||||
actions: [mocapMock.state],
|
||||
primaryHand: {x: mocapMock.x, y: mocapMock.y, state: mocapMock.state},
|
||||
parseWarnings: [],
|
||||
},
|
||||
rawPacketPreview: {text: '{"hands":[{"state":"grab"}]}', receivedAtMs: 1},
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createAuthValue() {
|
||||
return {
|
||||
user: null,
|
||||
@@ -138,6 +157,150 @@ const clearedRun: PuzzleRunSnapshot = {
|
||||
},
|
||||
};
|
||||
|
||||
test('拼图界面显示 mocap 连接状态和最近动作调试信息', () => {
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
},
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const debugPanel = screen.getByTestId('puzzle-mocap-debug');
|
||||
expect(within(debugPanel).getByText('mocap: connected')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('动作: grab')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('手势: grab @ 0.42, 0.58')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('解析: 无')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText(/原始:/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('拼图界面在 mocap open_palm 时显示体感光标', () => {
|
||||
mocapMock.state = 'open_palm';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cursor = screen.getByTestId('puzzle-mocap-cursor');
|
||||
expect(cursor).toBeTruthy();
|
||||
expect(cursor).toHaveStyle({left: '42%', top: '58%'});
|
||||
mocapMock.state = 'grab';
|
||||
});
|
||||
|
||||
test('抓握时会触发拖拽提交并在松开时落子', () => {
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.34;
|
||||
mocapMock.y = 0.34;
|
||||
const onDragPiece = vi.fn();
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
|
||||
piece.pieceId === 'piece-0'
|
||||
? {...piece, currentRow: 0, currentCol: 0}
|
||||
: piece,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={onDragPiece}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const piece = container.querySelector('[data-piece-id="piece-0"]') as HTMLElement | null;
|
||||
if (!piece) {
|
||||
throw new Error('缺少测试拼图片');
|
||||
}
|
||||
const board = container.querySelector('[data-testid="puzzle-board"]') as HTMLElement | null;
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
board.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerdown', {
|
||||
pointerId: 11,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 11,
|
||||
clientX: 70,
|
||||
clientY: 70,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 11,
|
||||
clientX: 140,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerup', {
|
||||
pointerId: 11,
|
||||
clientX: 140,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onDragPiece).toHaveBeenCalledTimes(1);
|
||||
expect(onDragPiece).toHaveBeenCalledWith(
|
||||
expect.objectContaining({pieceId: 'piece-0'}),
|
||||
);
|
||||
});
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
@@ -283,6 +284,12 @@ type PuzzleHintDemoState = {
|
||||
offsetYPercent: number;
|
||||
};
|
||||
|
||||
type PuzzleMocapCursorState = {
|
||||
x: number;
|
||||
y: number;
|
||||
state: string;
|
||||
};
|
||||
|
||||
function triggerPuzzlePiecePressHapticFeedback() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return;
|
||||
@@ -367,6 +374,10 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
|
||||
null,
|
||||
);
|
||||
const mocapDragRef = useRef<{pieceId: string} | null>(null);
|
||||
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -397,6 +408,18 @@ export function PuzzleRuntimeShell({
|
||||
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.coverImageSrc ?? null,
|
||||
);
|
||||
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
|
||||
const mocapActionsLabel =
|
||||
mocapInput.latestCommand?.actions.length
|
||||
? mocapInput.latestCommand.actions.join(', ')
|
||||
: '无';
|
||||
const mocapHandLabel = mocapInput.latestCommand?.primaryHand
|
||||
? `${mocapInput.latestCommand.primaryHand.state} @ ${mocapInput.latestCommand.primaryHand.x.toFixed(2)}, ${mocapInput.latestCommand.primaryHand.y.toFixed(2)}`
|
||||
: '无';
|
||||
const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length
|
||||
? mocapInput.latestCommand.parseWarnings.join(';')
|
||||
: '无';
|
||||
const mocapRawPacketLabel = mocapInput.rawPacketPreview?.text ?? '未收到';
|
||||
|
||||
useEffect(() => {
|
||||
currentLevelRef.current = currentLevel;
|
||||
@@ -850,6 +873,49 @@ export function PuzzleRuntimeShell({
|
||||
return { row, col };
|
||||
};
|
||||
|
||||
const resolveMocapTargetCell = (x: number, y: number) => ({
|
||||
row: Math.min(board.rows - 1, Math.max(0, Math.floor(y * board.rows))),
|
||||
col: Math.min(board.cols - 1, Math.max(0, Math.floor(x * board.cols))),
|
||||
});
|
||||
|
||||
const handleMocapInputCommand = () => {
|
||||
const hand = mocapInput.latestCommand?.primaryHand;
|
||||
if (runtimeStatus !== 'playing' || isInteractionLocked || !hand) {
|
||||
mocapDragRef.current = null;
|
||||
setMocapCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setMocapCursor({x: hand.x, y: hand.y, state: hand.state});
|
||||
if (hand.state === 'grab') {
|
||||
if (mocapDragRef.current) {
|
||||
return;
|
||||
}
|
||||
const sourceCell = resolveMocapTargetCell(hand.x, hand.y);
|
||||
const sourcePiece = pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null;
|
||||
if (!sourcePiece || sourcePiece.mergedGroupId) {
|
||||
return;
|
||||
}
|
||||
mocapDragRef.current = {pieceId: sourcePiece.pieceId};
|
||||
setSelectedPieceId(sourcePiece.pieceId);
|
||||
triggerPuzzlePiecePressHapticFeedback();
|
||||
return;
|
||||
}
|
||||
|
||||
const draggingPiece = mocapDragRef.current;
|
||||
if (!draggingPiece) {
|
||||
return;
|
||||
}
|
||||
const targetCell = resolveMocapTargetCell(hand.x, hand.y);
|
||||
mocapDragRef.current = null;
|
||||
setSelectedPieceId(null);
|
||||
onDragPiece({
|
||||
pieceId: draggingPiece.pieceId,
|
||||
targetRow: targetCell.row,
|
||||
targetCol: targetCell.col,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePiecePointerUp = (
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
@@ -973,7 +1039,6 @@ export function PuzzleRuntimeShell({
|
||||
isClearResultReady;
|
||||
const isInteractionLocked =
|
||||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||||
|
||||
const handleBackRequest = () => {
|
||||
if (hideExitControls) {
|
||||
return;
|
||||
@@ -1085,6 +1150,10 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleMocapInputCommand();
|
||||
}, [mocapInput.latestCommand?.primaryHand]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
@@ -1445,6 +1514,21 @@ export function PuzzleRuntimeShell({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{mocapCursor ? (
|
||||
<div
|
||||
data-testid="puzzle-mocap-cursor"
|
||||
className={`pointer-events-none absolute z-[70] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 ${
|
||||
mocapCursor.state === 'grab'
|
||||
? 'border-amber-200 bg-amber-400/90 text-amber-950'
|
||||
: 'border-cyan-200 bg-cyan-300/90 text-cyan-950'
|
||||
} shadow-[0_10px_24px_rgba(15,23,42,0.25)]`}
|
||||
style={{left: `${mocapCursor.x * 100}%`, top: `${mocapCursor.y * 100}%`}}
|
||||
>
|
||||
<span className="text-[10px] font-black leading-none">
|
||||
{mocapCursor.state === 'grab' ? '抓' : '手'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{mergeFlash ? (
|
||||
<div
|
||||
key={mergeFlash.key}
|
||||
@@ -1472,6 +1556,19 @@ export function PuzzleRuntimeShell({
|
||||
已选择
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-testid="puzzle-mocap-debug"
|
||||
className="w-[min(92vw,34rem)] rounded-[0.9rem] border border-white/20 bg-slate-950/70 px-3 py-2 font-mono text-[10px] leading-4 text-white shadow-[0_12px_32px_rgba(15,23,42,0.25)] backdrop-blur"
|
||||
>
|
||||
<div>mocap: {mocapInput.status}</div>
|
||||
<div>动作: {mocapActionsLabel}</div>
|
||||
<div>手势: {mocapHandLabel}</div>
|
||||
<div>解析: {mocapParseWarningLabel}</div>
|
||||
<div className="max-h-20 overflow-auto break-all text-white/75">
|
||||
原始: {mocapRawPacketLabel}
|
||||
</div>
|
||||
{mocapInput.error ? <div>错误: {mocapInput.error}</div> : null}
|
||||
</div>
|
||||
{canShowNextAction ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user