feat(jump-hop): redesign sling platform gameplay
This commit is contained in:
919
src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx
Normal file
919
src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx
Normal file
@@ -0,0 +1,919 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { buildJumpHopVisiblePlatforms } from '../../services/jump-hop/jumpHopRuntimeModel';
|
||||
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
||||
import { JumpHopRuntimeShell } from './JumpHopRuntimeShell';
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
|
||||
resolvedUrl: source?.trim() ?? '',
|
||||
isResolving: false,
|
||||
shouldResolve: Boolean(source?.trim().startsWith('/generated-')),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
|
||||
useJumpHopLeaderboard: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
|
||||
leaderboard: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
function dispatchPointerEvent(
|
||||
target: HTMLElement,
|
||||
type: string,
|
||||
options: { pointerId: number; clientX: number; clientY: number },
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.assign(event, options);
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
test('跳一跳运行态松手时提交向后拖动向量', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const run = buildRun();
|
||||
const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []);
|
||||
const current = visiblePlatforms[0]!;
|
||||
const target = visiblePlatforms[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const xPixelsPerWorldUnit =
|
||||
Math.abs(
|
||||
((target.screenX - current.screenX) / 100) * stageSize.width,
|
||||
) / Math.abs(target.platform.x - current.platform.x);
|
||||
const yPixelsPerWorldUnit =
|
||||
Math.abs(
|
||||
((target.screenY - current.screenY) / 100) * stageSize.height,
|
||||
) / Math.abs(target.platform.y - current.platform.y);
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile()}
|
||||
run={run}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onJump).toHaveBeenCalledTimes(1);
|
||||
const jumpPayload = onJump.mock.calls[0]?.[0];
|
||||
expect(jumpPayload?.dragVectorX).toBeCloseTo(-48 / xPixelsPerWorldUnit, 2);
|
||||
expect(jumpPayload?.dragVectorY).toBeCloseTo(58 / yPixelsPerWorldUnit, 2);
|
||||
expect(jumpPayload?.dragDistance).toBeGreaterThan(74);
|
||||
expect(jumpPayload?.dragDistance).toBeLessThan(76);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳运行态拖拽方向按手指起点到松手点计算', async () => {
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const run = buildRun();
|
||||
const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []);
|
||||
const current = visiblePlatforms[0]!;
|
||||
const target = visiblePlatforms[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const xPixelsPerWorldUnit =
|
||||
Math.abs(
|
||||
((target.screenX - current.screenX) / 100) * stageSize.width,
|
||||
) / Math.abs(target.platform.x - current.platform.x);
|
||||
const yPixelsPerWorldUnit =
|
||||
Math.abs(
|
||||
((target.screenY - current.screenY) / 100) * stageSize.height,
|
||||
) / Math.abs(target.platform.y - current.platform.y);
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile()}
|
||||
run={run}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
});
|
||||
});
|
||||
|
||||
const jumpPayload = onJump.mock.calls[0]?.[0];
|
||||
expect(jumpPayload?.dragVectorX).toBeLessThan(0);
|
||||
expect(jumpPayload?.dragVectorY).toBeLessThan(0);
|
||||
expect(Math.abs(jumpPayload?.dragVectorX ?? 0)).toBeLessThan(30);
|
||||
expect(Math.abs(jumpPayload?.dragVectorY ?? 0)).toBeLessThan(20);
|
||||
expect(jumpPayload?.dragVectorX).toBeCloseTo(-30 / xPixelsPerWorldUnit, 2);
|
||||
expect(jumpPayload?.dragVectorY).toBeCloseTo(-20 / yPixelsPerWorldUnit, 2);
|
||||
});
|
||||
|
||||
test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线', async () => {
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile()}
|
||||
run={buildRun()}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByText('起跳')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
|
||||
const character = screen.getByTestId('jump-hop-character-logo')
|
||||
.parentElement as HTMLElement;
|
||||
const stretchTransform = character.style.getPropertyValue(
|
||||
'--jump-hop-character-stretch-transform',
|
||||
);
|
||||
const styleText = Array.from(document.querySelectorAll('style'))
|
||||
.map((style) => style.textContent ?? '')
|
||||
.join('\n');
|
||||
|
||||
expect(stretchTransform).toContain('matrix(');
|
||||
expect(stretchTransform).not.toBe('matrix(1, 0, 0, 1, 0, 0)');
|
||||
expect(styleText).toContain('var(--jump-hop-character-stretch-transform)');
|
||||
expect(styleText).not.toContain(
|
||||
'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))',
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳运行态需要三维场景宿主和排行榜面板', () => {
|
||||
const runtimeRequestOptions = {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
};
|
||||
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
|
||||
leaderboard: {
|
||||
profileId: 'jump-hop-profile-test',
|
||||
items: [
|
||||
{
|
||||
rank: 1,
|
||||
playerId: 'player-1',
|
||||
successfulJumpCount: 8,
|
||||
durationMs: 8123,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
},
|
||||
],
|
||||
viewerBest: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile()}
|
||||
run={buildRun()}
|
||||
runtimeRequestOptions={runtimeRequestOptions}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(useJumpHopLeaderboard).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-test',
|
||||
runtimeRequestOptions,
|
||||
);
|
||||
expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy();
|
||||
expect(screen.getByTestId('jump-hop-runtime-leaderboard')).toBeTruthy();
|
||||
expect(screen.getByText('player-1')).toBeTruthy();
|
||||
expect(screen.getByText('8 跳')).toBeTruthy();
|
||||
expect(screen.getByText('00:08')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳角色层永远压在地块层之上', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const threeScene = screen.getByTestId('jump-hop-three-scene');
|
||||
const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0]
|
||||
?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
|
||||
expect(threeScene.style.zIndex).toBe('100');
|
||||
expect(Number(threeScene.style.zIndex)).toBeGreaterThan(
|
||||
Number(firstPlatform?.style.zIndex ?? 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', async () => {
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={buildRun()}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 148,
|
||||
clientY: 454,
|
||||
});
|
||||
});
|
||||
|
||||
const firstAssist = screen.getByTestId('jump-hop-landing-assist');
|
||||
const firstLeft = firstAssist.style.left;
|
||||
const firstTop = firstAssist.style.top;
|
||||
expect(firstAssist.getAttribute('data-target-index')).toBe('1');
|
||||
expect(firstLeft).not.toBe('62.288%');
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 112,
|
||||
clientY: 492,
|
||||
});
|
||||
});
|
||||
|
||||
const secondAssist = screen.getByTestId('jump-hop-landing-assist');
|
||||
expect(secondAssist.style.left).not.toBe(firstLeft);
|
||||
expect(secondAssist.style.top).not.toBe(firstTop);
|
||||
});
|
||||
|
||||
test('跳一跳运行态直接渲染生成的地块切片图片', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
|
||||
expect(tileImages).toHaveLength(3);
|
||||
const generatedReadUrlCalls = vi
|
||||
.mocked(useResolvedAssetReadUrl)
|
||||
.mock.calls.filter(([source]) =>
|
||||
source?.includes('/generated-jump-hop-assets/'),
|
||||
);
|
||||
expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(3);
|
||||
for (const [, options] of generatedReadUrlCalls) {
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
refreshKey: expect.stringMatching(/^asset-object-/),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const image of tileImages) {
|
||||
expect(image.getAttribute('src')).toContain(
|
||||
'/generated-jump-hop-assets/jump-hop-profile-test/tile-',
|
||||
);
|
||||
fireEvent.load(image);
|
||||
expect(image.getAttribute('data-loaded')).toBe('true');
|
||||
}
|
||||
});
|
||||
|
||||
test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
|
||||
expect(tileImages).toHaveLength(3);
|
||||
const first = tileImages[0]?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
const second = tileImages[1]?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
const third = tileImages[2]?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
expect(first?.style.top).toBe('78%');
|
||||
expect(second?.style.top).toBe('50%');
|
||||
expect(third?.style.top).toBe('22%');
|
||||
});
|
||||
|
||||
test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstTile = screen.getAllByTestId('jump-hop-tile-image')[0]
|
||||
?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
|
||||
expect(firstTile?.style.width).toBe('116px');
|
||||
expect(firstTile?.style.height).toBe('96px');
|
||||
expect(firstTile?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
|
||||
'1.08',
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳运行态使用陶泥儿透明 logo 作为角色形象', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logo = screen.getByTestId('jump-hop-character-logo');
|
||||
expect(logo.getAttribute('src')).toBe(
|
||||
'/branding/jump-hop-taonier-character.png',
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('jump-hop-character-fallback-shape'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳蓄力和计时刷新不会重建三维画布宿主', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
const canvas = screen.getByTestId('jump-hop-three-canvas');
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(520);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 160,
|
||||
clientY: 460,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('jump-hop-three-canvas')).toBe(canvas);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳后端回包较慢时角色停在目标点等待推进', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const initialRun = buildRun();
|
||||
const nextRun: JumpHopRuntimeRunSnapshotResponse = {
|
||||
...buildRun(),
|
||||
currentPlatformIndex: 1,
|
||||
successfulJumpCount: 1,
|
||||
score: 1,
|
||||
lastJump: {
|
||||
chargeMs: 150,
|
||||
jumpDistance: 1.44,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 0.8,
|
||||
landedY: 1.2,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
const { rerender } = render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={initialRun}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(580);
|
||||
});
|
||||
|
||||
const character = screen.getByTestId('jump-hop-character-logo')
|
||||
.parentElement as HTMLElement;
|
||||
expect(stage.getAttribute('data-jump-animating')).toBe('true');
|
||||
expect(stage.getAttribute('data-platform-advancing')).toBe('false');
|
||||
expect(Number.parseFloat(character.style.left)).not.toBeCloseTo(50, 2);
|
||||
expect(character.style.getPropertyValue('--jump-hop-flight-from-x')).not.toBe(
|
||||
'0px',
|
||||
);
|
||||
expect(character.style.getPropertyValue('--jump-hop-flight-from-y')).not.toBe(
|
||||
'0px',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={nextRun}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
||||
'true',
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const initialRun = buildRun();
|
||||
const nextRun: JumpHopRuntimeRunSnapshotResponse = {
|
||||
...buildRun(),
|
||||
currentPlatformIndex: 1,
|
||||
successfulJumpCount: 1,
|
||||
score: 1,
|
||||
lastJump: {
|
||||
chargeMs: 150,
|
||||
jumpDistance: 1.44,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 0.8,
|
||||
landedY: 1.2,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
const { rerender } = render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={initialRun}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onJump).toHaveBeenCalledTimes(1);
|
||||
expect(stage.getAttribute('data-jump-animating')).toBe('true');
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
||||
'p0',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={nextRun}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
||||
'p0',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(580);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
||||
'true',
|
||||
);
|
||||
const landedCharacter = screen.getByTestId('jump-hop-character-logo')
|
||||
.parentElement as HTMLElement;
|
||||
expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true');
|
||||
expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe(
|
||||
'0px',
|
||||
);
|
||||
expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-y')).not.toBe(
|
||||
'0px',
|
||||
);
|
||||
const cameraLayer = screen.getByTestId('jump-hop-camera-layer');
|
||||
expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true');
|
||||
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe(
|
||||
'-28%',
|
||||
);
|
||||
expect(
|
||||
Number.parseFloat(
|
||||
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
|
||||
),
|
||||
).toBeCloseTo(12.29, 2);
|
||||
const styleText = Array.from(document.querySelectorAll('style'))
|
||||
.map((style) => style.textContent ?? '')
|
||||
.join('\n');
|
||||
expect(styleText).toContain('@keyframes jump-hop-character-recoil');
|
||||
expect(styleText).toMatch(
|
||||
/data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/,
|
||||
);
|
||||
expect(screen.getByTestId('jump-hop-three-scene').parentElement).toBe(
|
||||
cameraLayer,
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-advance-state='settling']"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-advance-state='entering']"),
|
||||
).toBeNull();
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
||||
'p0',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
||||
'p1',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
|
||||
'1.08',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
||||
'p2',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'50%',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(720);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(
|
||||
(
|
||||
screen.getByTestId('jump-hop-character-logo')
|
||||
.parentElement as HTMLElement
|
||||
).getAttribute('data-landing-recoil'),
|
||||
).toBe('false');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(660);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
||||
'true',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
||||
'p1',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'50%',
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function buildRun(): JumpHopRuntimeRunSnapshotResponse {
|
||||
return {
|
||||
runId: 'jump-hop-run-test',
|
||||
profileId: 'jump-hop-profile-test',
|
||||
ownerUserId: 'user-test',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path: {
|
||||
seed: 'test',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 4294967295,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
{
|
||||
platformId: 'p0',
|
||||
tileType: 'start',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
landingRadius: 0.5,
|
||||
perfectRadius: 0.2,
|
||||
scoreValue: 1,
|
||||
},
|
||||
{
|
||||
platformId: 'p1',
|
||||
tileType: 'normal',
|
||||
x: 0.8,
|
||||
y: 1.2,
|
||||
width: 1,
|
||||
height: 1,
|
||||
landingRadius: 0.5,
|
||||
perfectRadius: 0.2,
|
||||
scoreValue: 1,
|
||||
},
|
||||
{
|
||||
platformId: 'p2',
|
||||
tileType: 'target',
|
||||
x: -0.2,
|
||||
y: 2.4,
|
||||
width: 1,
|
||||
height: 1,
|
||||
landingRadius: 0.5,
|
||||
perfectRadius: 0.2,
|
||||
scoreValue: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTileAssets() {
|
||||
return Array.from({ length: 25 }, (_, index) => {
|
||||
const tileNumber = String(index + 1).padStart(2, '0');
|
||||
return {
|
||||
tileType: index === 0 ? 'start' : 'normal',
|
||||
tileId: `tile-${tileNumber}`,
|
||||
imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
|
||||
imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
|
||||
assetObjectId: `asset-object-${tileNumber}`,
|
||||
sourceAtlasCell: `row-${Math.floor(index / 5) + 1}-col-${(index % 5) + 1}`,
|
||||
atlasRow: Math.floor(index / 5) + 1,
|
||||
atlasCol: (index % 5) + 1,
|
||||
visualWidth: 256,
|
||||
visualHeight: 192,
|
||||
topSurfaceRadius: 42,
|
||||
landingRadius: 34,
|
||||
} satisfies JumpHopWorkProfileResponse['tileAssets'][number];
|
||||
});
|
||||
}
|
||||
|
||||
function buildProfile(options: {
|
||||
tileAssets?: JumpHopWorkProfileResponse['tileAssets'];
|
||||
} = {}): JumpHopWorkProfileResponse {
|
||||
const characterAsset = {
|
||||
assetId: 'builtin',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'builtin',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
return {
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-profile-test',
|
||||
profileId: 'jump-hop-profile-test',
|
||||
ownerUserId: 'user-test',
|
||||
sourceSessionId: 'jump-hop-session-test',
|
||||
themeText: '测试',
|
||||
workTitle: '测试',
|
||||
workDescription: '测试',
|
||||
themeTags: ['测试'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
draft: {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'jump-hop-profile-test',
|
||||
themeText: '测试',
|
||||
workTitle: '测试',
|
||||
workDescription: '测试',
|
||||
themeTags: ['测试'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
defaultCharacter: {
|
||||
characterId: 'jump-hop-default-runner',
|
||||
displayName: '默认角色',
|
||||
modelKind: 'builtin-three',
|
||||
bodyColor: '#f59e0b',
|
||||
accentColor: '#2563eb',
|
||||
},
|
||||
characterPrompt: '默认角色',
|
||||
tilePrompt: '地块',
|
||||
endMoodPrompt: null,
|
||||
characterAsset,
|
||||
tileAtlasAsset: characterAsset,
|
||||
tileAssets: options.tileAssets ?? [],
|
||||
path: buildRun().path,
|
||||
coverComposite: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
path: buildRun().path,
|
||||
defaultCharacter: {
|
||||
characterId: 'jump-hop-default-runner',
|
||||
displayName: '默认角色',
|
||||
modelKind: 'builtin-three',
|
||||
bodyColor: '#f59e0b',
|
||||
accentColor: '#2563eb',
|
||||
},
|
||||
characterAsset,
|
||||
tileAtlasAsset: characterAsset,
|
||||
tileAssets: options.tileAssets ?? [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user