fix: 优化跳一跳运行态与地块资源
This commit is contained in:
@@ -8,9 +8,12 @@ import type {
|
||||
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';
|
||||
import {
|
||||
JUMP_HOP_THREE_CAMERA_UP_Y,
|
||||
JumpHopRuntimeShell,
|
||||
getJumpHopThreeProjectedY,
|
||||
} from './JumpHopRuntimeShell';
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
|
||||
@@ -44,22 +47,10 @@ function dispatchPointerEvent(
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
test('跳一跳运行态松手时提交向后拖动向量', async () => {
|
||||
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
|
||||
@@ -85,6 +76,9 @@ test('跳一跳运行态松手时提交向后拖动向量', async () => {
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(360);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
@@ -96,28 +90,17 @@ test('跳一跳运行态松手时提交向后拖动向量', async () => {
|
||||
|
||||
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);
|
||||
expect(jumpPayload?.dragVectorX).toBeUndefined();
|
||||
expect(jumpPayload?.dragVectorY).toBeUndefined();
|
||||
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(360);
|
||||
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(380);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳运行态拖拽方向按手指起点到松手点计算', async () => {
|
||||
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
|
||||
@@ -143,6 +126,9 @@ test('跳一跳运行态拖拽方向按手指起点到松手点计算', async ()
|
||||
clientY: 20,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(240);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
@@ -152,15 +138,61 @@ test('跳一跳运行态拖拽方向按手指起点到松手点计算', async ()
|
||||
});
|
||||
|
||||
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);
|
||||
expect(jumpPayload?.dragVectorX).toBeUndefined();
|
||||
expect(jumpPayload?.dragVectorY).toBeUndefined();
|
||||
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240);
|
||||
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线', async () => {
|
||||
test('跳一跳运行态长按蓄力不会超过后端上限', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const baseRun = buildRun();
|
||||
const run: JumpHopRuntimeRunSnapshotResponse = {
|
||||
...baseRun,
|
||||
path: {
|
||||
...baseRun.path,
|
||||
scoring: {
|
||||
...baseRun.path.scoring,
|
||||
maxChargeMs: 300,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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 () => {
|
||||
await vi.advanceTimersByTimeAsync(780);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onJump.mock.calls[0]?.[0]?.dragDistance).toBe(300);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳运行态不再显示旧圆弧蓄力条而是显示长按蓄力引导', async () => {
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
@@ -183,10 +215,12 @@ test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线',
|
||||
|
||||
expect(screen.queryByText('起跳')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
|
||||
test('跳一跳蓄力时角色只做垂直压缩', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
@@ -205,11 +239,7 @@ test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
clientX: 132,
|
||||
clientY: 478,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(180);
|
||||
});
|
||||
|
||||
const character = screen.getByTestId('jump-hop-character-logo')
|
||||
@@ -221,12 +251,20 @@ test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
|
||||
.map((style) => style.textContent ?? '')
|
||||
.join('\n');
|
||||
|
||||
expect(stretchTransform).toContain('matrix(');
|
||||
expect(stretchTransform).not.toBe('matrix(1, 0, 0, 1, 0, 0)');
|
||||
expect(stretchTransform).toMatch(/^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/);
|
||||
const scaleMatch = stretchTransform.match(
|
||||
/^scale\((?<x>[\d.]+), (?<y>[\d.]+)\)$/,
|
||||
);
|
||||
const scaleX = Number(scaleMatch?.groups?.x ?? 1);
|
||||
const scaleY = Number(scaleMatch?.groups?.y ?? 1);
|
||||
expect(scaleX).toBeGreaterThan(1);
|
||||
expect(scaleY).toBeLessThan(1);
|
||||
expect(scaleY).toBeLessThan(scaleX);
|
||||
expect(styleText).toContain('var(--jump-hop-character-stretch-transform)');
|
||||
expect(styleText).not.toContain(
|
||||
'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))',
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () => {
|
||||
@@ -379,7 +417,7 @@ test('跳一跳草稿运行失败后不请求公开排行榜', () => {
|
||||
expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳角色层永远压在地块层之上', () => {
|
||||
test('跳一跳 Three.js 地板层位于 DOM 角色层下方', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
@@ -393,13 +431,19 @@ test('跳一跳角色层永远压在地块层之上', () => {
|
||||
const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0]
|
||||
?.parentElement?.parentElement as HTMLElement | undefined;
|
||||
|
||||
expect(threeScene.style.zIndex).toBe('100');
|
||||
expect(threeScene.style.zIndex).toBe('42');
|
||||
expect(Number(threeScene.style.zIndex)).toBeGreaterThan(
|
||||
Number(firstPlatform?.style.zIndex ?? 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async () => {
|
||||
test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', () => {
|
||||
expect(JUMP_HOP_THREE_CAMERA_UP_Y).toBe(1);
|
||||
expect(getJumpHopThreeProjectedY(360, 568)).toBeLessThan(284);
|
||||
expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284);
|
||||
});
|
||||
|
||||
test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => {
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
@@ -429,7 +473,7 @@ test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async ()
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
@@ -440,10 +484,11 @@ test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async ()
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳运行态直接渲染生成的地块切片图片', () => {
|
||||
test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
@@ -498,6 +543,48 @@ test('跳一跳运行态提前预加载下一屏地块且不在真实图片加
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳新 UV 地板资源会解析六张面贴图而不是复用单张图', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets({ withFaceAssets: true }) })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
|
||||
const faceImageSources = preloadImages
|
||||
.map((image) => image.getAttribute('src') ?? '')
|
||||
.filter((source) =>
|
||||
source.includes('/generated-jump-hop-assets/jump-hop-profile-test/tile-'),
|
||||
);
|
||||
|
||||
const firstTileMatch = faceImageSources[0]?.match(/tile-(\d{2})-/);
|
||||
const firstTileNumber = firstTileMatch?.[1];
|
||||
expect(firstTileNumber).toBeTruthy();
|
||||
expect(faceImageSources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-top/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-front/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-right/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-back/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-left/image.png`),
|
||||
expect.stringContaining(`/tile-${firstTileNumber}-bottom/image.png`),
|
||||
]),
|
||||
);
|
||||
const frontSource = `/tile-${firstTileNumber}-front/image.png`;
|
||||
const frontRefreshKey = `asset-object-${firstTileNumber}-front`;
|
||||
expect(
|
||||
vi
|
||||
.mocked(useResolvedAssetReadUrl)
|
||||
.mock.calls.some(
|
||||
([source, options]) =>
|
||||
source?.includes(frontSource) && options?.refreshKey === frontRefreshKey,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
@@ -513,9 +600,9 @@ test('跳一跳运行态首块地块落在中下方并且后续两块向中央
|
||||
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%');
|
||||
expect(first?.style.top).toBe('64%');
|
||||
expect(second?.style.top).toBe('47%');
|
||||
expect(third?.style.top).toBe('30%');
|
||||
});
|
||||
|
||||
test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => {
|
||||
@@ -604,11 +691,11 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
|
||||
successfulJumpCount: 1,
|
||||
score: 1,
|
||||
lastJump: {
|
||||
chargeMs: 150,
|
||||
jumpDistance: 1.44,
|
||||
chargeMs: 420,
|
||||
jumpDistance: 1.68,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 0.8,
|
||||
landedY: 1.2,
|
||||
landedX: 0.93,
|
||||
landedY: 1.4,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
@@ -636,6 +723,9 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(420);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
@@ -678,6 +768,63 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳成功落点偏移后下一跳视觉仍朝下一块地块方向', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
const run: JumpHopRuntimeRunSnapshotResponse = {
|
||||
...buildRun(),
|
||||
currentPlatformIndex: 1,
|
||||
successfulJumpCount: 1,
|
||||
score: 1,
|
||||
lastJump: {
|
||||
chargeMs: 300,
|
||||
jumpDistance: 1.0,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 0,
|
||||
landedY: 1.2,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={run}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const character = screen.getByTestId('jump-hop-character-logo')
|
||||
.parentElement as HTMLElement;
|
||||
const initialLeft = Number.parseFloat(character.style.left);
|
||||
const initialTop = Number.parseFloat(character.style.top);
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onJump).toHaveBeenCalledTimes(1);
|
||||
expect(Number.parseFloat(character.style.left)).toBeLessThan(initialLeft);
|
||||
expect(Number.parseFloat(character.style.top)).toBeLessThan(initialTop);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -688,11 +835,25 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
successfulJumpCount: 1,
|
||||
score: 1,
|
||||
lastJump: {
|
||||
chargeMs: 150,
|
||||
jumpDistance: 1.44,
|
||||
chargeMs: 420,
|
||||
jumpDistance: 1.68,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 0.8,
|
||||
landedY: 1.2,
|
||||
landedX: 0.93,
|
||||
landedY: 1.4,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
const runAfterSecondJump: JumpHopRuntimeRunSnapshotResponse = {
|
||||
...buildRunWithExtraPreviewPlatform(),
|
||||
currentPlatformIndex: 2,
|
||||
successfulJumpCount: 2,
|
||||
score: 2,
|
||||
lastJump: {
|
||||
chargeMs: 360,
|
||||
jumpDistance: 1.44,
|
||||
targetPlatformIndex: 2,
|
||||
landedX: -0.2,
|
||||
landedY: 2.4,
|
||||
result: 'hit',
|
||||
},
|
||||
};
|
||||
@@ -720,6 +881,9 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
clientY: 478,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(420);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
@@ -731,7 +895,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
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%',
|
||||
'64%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
||||
'true',
|
||||
@@ -753,7 +917,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
'true',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
'64%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
||||
'true',
|
||||
@@ -775,6 +939,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
const landedCharacter = screen.getByTestId('jump-hop-character-logo')
|
||||
.parentElement as HTMLElement;
|
||||
expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true');
|
||||
expect(Number.parseFloat(landedCharacter.style.left)).not.toBeCloseTo(50, 1);
|
||||
expect(Number.parseFloat(landedCharacter.style.top)).not.toBeCloseTo(75, 1);
|
||||
expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe(
|
||||
'0px',
|
||||
);
|
||||
@@ -783,18 +949,23 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
);
|
||||
const cameraLayer = screen.getByTestId('jump-hop-camera-layer');
|
||||
expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true');
|
||||
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-zoom')).toBe(
|
||||
'1.3',
|
||||
);
|
||||
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe(
|
||||
'-28%',
|
||||
'-17%',
|
||||
);
|
||||
expect(
|
||||
Number.parseFloat(
|
||||
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
|
||||
),
|
||||
).toBeCloseTo(12.29, 2);
|
||||
).toBeCloseTo(8.96, 2);
|
||||
const styleText = Array.from(document.querySelectorAll('style'))
|
||||
.map((style) => style.textContent ?? '')
|
||||
.join('\n');
|
||||
expect(styleText).toContain('@keyframes jump-hop-character-recoil');
|
||||
expect(styleText).not.toContain('@keyframes jump-hop-platform-exit-drift');
|
||||
expect(styleText).toContain('scale(var(--jump-hop-camera-zoom, 1))');
|
||||
expect(styleText).toMatch(
|
||||
/data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/,
|
||||
);
|
||||
@@ -826,7 +997,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
'p1',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'78%',
|
||||
'64%',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
|
||||
'1.08',
|
||||
@@ -835,7 +1006,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
'p2',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'50%',
|
||||
'47%',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
@@ -870,19 +1041,78 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
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%',
|
||||
const retainedOldPlatform = screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
|
||||
expect(retainedOldPlatform?.getAttribute('data-advance-state')).toBe(
|
||||
'exiting',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
|
||||
expect(retainedOldPlatform?.style.top).toBe('81%');
|
||||
const currentPlatform = screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-platform-id='p1']") as HTMLElement | null;
|
||||
expect(currentPlatform?.style.top).toBe('64%');
|
||||
expect(currentPlatform?.getAttribute('data-current')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
|
||||
expect(currentPlatform?.getAttribute('data-platform-id')).toBe(
|
||||
'p1',
|
||||
);
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
|
||||
'50%',
|
||||
expect(
|
||||
(
|
||||
screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-platform-id='p2']") as HTMLElement | null
|
||||
)?.style.top,
|
||||
).toBe('47%');
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: 2,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(160);
|
||||
});
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 2,
|
||||
clientX: 180,
|
||||
clientY: 420,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onJump).toHaveBeenCalledTimes(2);
|
||||
|
||||
rerender(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={runAfterSecondJump}
|
||||
onJump={onJump}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(580);
|
||||
});
|
||||
|
||||
const movedOldPlatform = screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
|
||||
if (movedOldPlatform) {
|
||||
expect(Number.parseFloat(movedOldPlatform.style.top)).toBeGreaterThan(81);
|
||||
}
|
||||
expect(
|
||||
(
|
||||
screen
|
||||
.getByTestId('jump-hop-stage')
|
||||
.querySelector("[data-current='true']") as HTMLElement | null
|
||||
)?.getAttribute('data-platform-id'),
|
||||
).toBe('p2');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -994,22 +1224,51 @@ function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function buildTileAssets() {
|
||||
return Array.from({ length: 25 }, (_, index) => {
|
||||
function buildTileAssets(options: { withFaceAssets?: boolean } = {}) {
|
||||
return Array.from({ length: 18 }, (_, index) => {
|
||||
const tileNumber = String(index + 1).padStart(2, '0');
|
||||
const atlasRow = Math.floor(index / 3) + 1;
|
||||
const atlasCol = (index % 3) + 1;
|
||||
const buildFaceAsset = (
|
||||
face: keyof NonNullable<
|
||||
JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
|
||||
>,
|
||||
) => ({
|
||||
face,
|
||||
assetId: `asset-${tileNumber}-${face}`,
|
||||
imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
|
||||
imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}-${face}/image.png`,
|
||||
assetObjectId: `asset-object-${tileNumber}-${face}`,
|
||||
generationProvider: 'vector-engine',
|
||||
prompt: `tile ${tileNumber} ${face}`,
|
||||
width: 256,
|
||||
height: 256,
|
||||
sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}/${face}`,
|
||||
});
|
||||
const faceAssets: NonNullable<
|
||||
JumpHopWorkProfileResponse['tileAssets'][number]['faceAssets']
|
||||
> = {
|
||||
top: buildFaceAsset('top'),
|
||||
front: buildFaceAsset('front'),
|
||||
right: buildFaceAsset('right'),
|
||||
back: buildFaceAsset('back'),
|
||||
left: buildFaceAsset('left'),
|
||||
bottom: buildFaceAsset('bottom'),
|
||||
};
|
||||
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,
|
||||
sourceAtlasCell: `row-${atlasRow}-col-${atlasCol}`,
|
||||
atlasRow,
|
||||
atlasCol,
|
||||
visualWidth: 256,
|
||||
visualHeight: 192,
|
||||
visualHeight: 256,
|
||||
topSurfaceRadius: 42,
|
||||
landingRadius: 34,
|
||||
faceAssets: options.withFaceAssets ? faceAssets : undefined,
|
||||
} satisfies JumpHopWorkProfileResponse['tileAssets'][number];
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1767,6 +1767,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
shouldRestoreCustomWorldAgentUiState(),
|
||||
);
|
||||
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
|
||||
const handledJumpHopRuntimeRestoreRef = useRef<string | null>(null);
|
||||
const selectionStageRef = useRef(selectionStage);
|
||||
const creationFlowReturnTargetRef =
|
||||
useRef<CreationFlowReturnTarget>('create');
|
||||
@@ -7465,6 +7466,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
profileId: string,
|
||||
options: {
|
||||
embedded?: boolean;
|
||||
preloadedWork?: JumpHopWorkProfileResponse | null;
|
||||
returnStage?: 'work-detail' | 'platform';
|
||||
} = {},
|
||||
) => {
|
||||
@@ -7497,7 +7499,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
: null,
|
||||
);
|
||||
const [detail, runResponse] = await Promise.all([
|
||||
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
|
||||
options.preloadedWork
|
||||
? Promise.resolve({ item: options.preloadedWork })
|
||||
: jumpHopClient
|
||||
.getWorkDetail(normalizedProfileId)
|
||||
.catch(() => null),
|
||||
jumpHopClient.startRun(normalizedProfileId, {
|
||||
...runtimeGuestOptions,
|
||||
runtimeMode: 'published',
|
||||
@@ -7529,6 +7535,78 @@ export function PlatformEntryFlowShellImpl({
|
||||
[authUi, setSelectionStage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage !== 'jump-hop-runtime' || jumpHopRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const publicWorkCode = initialPublicWorkCode?.trim() ?? '';
|
||||
const restoreKey = publicWorkCode || '__jump-hop-runtime-empty__';
|
||||
if (handledJumpHopRuntimeRestoreRef.current === restoreKey) {
|
||||
return;
|
||||
}
|
||||
handledJumpHopRuntimeRestoreRef.current = restoreKey;
|
||||
|
||||
if (!publicWorkCode) {
|
||||
setJumpHopError(null);
|
||||
setJumpHopRuntimeRequestOptions(null);
|
||||
setJumpHopRuntimeReturnStage('platform');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const restoreJumpHopRuntime = async () => {
|
||||
setIsJumpHopBusy(true);
|
||||
setJumpHopError(null);
|
||||
try {
|
||||
const detail = await jumpHopClient.getGalleryDetail(publicWorkCode);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const profileId = detail.item.summary.profileId;
|
||||
const started = await startJumpHopRunFromProfile(profileId, {
|
||||
preloadedWork: detail.item,
|
||||
returnStage: 'work-detail',
|
||||
});
|
||||
if (!started && !cancelled) {
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setJumpHopError(
|
||||
resolveRpgCreationErrorMessage(error, '恢复跳一跳玩法失败。'),
|
||||
);
|
||||
setJumpHopRun(null);
|
||||
setJumpHopRuntimeRequestOptions(null);
|
||||
setJumpHopRuntimeReturnStage('platform');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsJumpHopBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void restoreJumpHopRuntime();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
initialPublicWorkCode,
|
||||
jumpHopRun,
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
startJumpHopRunFromProfile,
|
||||
]);
|
||||
|
||||
const restartJumpHopRuntimeRun = useCallback(async () => {
|
||||
const runId = jumpHopRun?.runId;
|
||||
if (!runId) {
|
||||
@@ -13923,16 +14001,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
useEffect(() => {
|
||||
const publicWorkCode = initialPublicWorkCode?.trim();
|
||||
if (
|
||||
!publicWorkCode ||
|
||||
handledInitialPublicWorkCodeRef.current === publicWorkCode
|
||||
) {
|
||||
if (!publicWorkCode) {
|
||||
return;
|
||||
}
|
||||
if (selectionStage === 'jump-hop-runtime') {
|
||||
handledInitialPublicWorkCodeRef.current = publicWorkCode;
|
||||
return;
|
||||
}
|
||||
if (handledInitialPublicWorkCodeRef.current === publicWorkCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledInitialPublicWorkCodeRef.current = publicWorkCode;
|
||||
void handlePublicCodeSearch(publicWorkCode);
|
||||
}, [handlePublicCodeSearch, initialPublicWorkCode]);
|
||||
}, [handlePublicCodeSearch, initialPublicWorkCode, selectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'platform') {
|
||||
|
||||
@@ -1695,6 +1695,28 @@ function buildMockJumpHopWork(
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockJumpHopRuntimeRun(
|
||||
work: JumpHopWorkProfileResponse,
|
||||
overrides: Partial<JumpHopRuntimeRunSnapshotResponse> = {},
|
||||
): JumpHopRuntimeRunSnapshotResponse {
|
||||
return {
|
||||
runId: 'jump-hop-run-1',
|
||||
profileId: work.summary.profileId,
|
||||
ownerUserId: work.summary.ownerUserId,
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path: work.path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1_779_999_000_000,
|
||||
finishedAtMs: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockBarkBattleWork(
|
||||
overrides: Partial<BarkBattleWorkSummary> = {},
|
||||
): BarkBattleWorkSummary {
|
||||
@@ -6937,6 +6959,89 @@ test('logged out public jump-hop detail starts runtime without requireAuth', asy
|
||||
expect(requireAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('direct jump hop runtime route without work code returns platform home', async () => {
|
||||
window.history.replaceState(null, '', '/runtime/jump-hop');
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
expect(window.location.search).toBe('');
|
||||
expect(jumpHopClient.getGalleryDetail).not.toHaveBeenCalled();
|
||||
expect(jumpHopClient.startRun).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole('button', { name: '创作' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('direct jump hop runtime route with public work code starts published run', async () => {
|
||||
const publishedJumpHopWork = buildMockJumpHopWork({
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-direct-1',
|
||||
profileId: 'jump-hop-profile-direct-12345678',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'jump-hop-session-direct-1',
|
||||
themeText: '星星果园',
|
||||
workTitle: '星星果园跳一跳',
|
||||
workDescription: '沿着水果一路弹跳。',
|
||||
themeTags: ['果园', '星星'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
playCount: 8,
|
||||
updatedAt: '2026-06-07T10:00:00.000Z',
|
||||
publishedAt: '2026-06-07T10:00:00.000Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
});
|
||||
const publishedJumpHopRun = buildMockJumpHopRuntimeRun(publishedJumpHopWork, {
|
||||
runId: 'jump-hop-run-direct-1',
|
||||
ownerUserId: '',
|
||||
});
|
||||
|
||||
window.history.replaceState(null, '', '/runtime/jump-hop?work=JH-12345678');
|
||||
vi.mocked(jumpHopClient.getGalleryDetail).mockResolvedValue({
|
||||
item: publishedJumpHopWork,
|
||||
});
|
||||
vi.mocked(jumpHopClient.startRun).mockResolvedValue({
|
||||
run: publishedJumpHopRun,
|
||||
});
|
||||
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue({
|
||||
profileId: publishedJumpHopWork.summary.profileId,
|
||||
items: [],
|
||||
viewerBest: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: vi.fn(),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(jumpHopClient.getGalleryDetail).toHaveBeenCalledWith('JH-12345678');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(jumpHopClient.startRun).toHaveBeenCalledWith(
|
||||
publishedJumpHopWork.summary.profileId,
|
||||
expect.objectContaining({
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
runtimeMode: 'published',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(jumpHopClient.getWorkDetail).not.toHaveBeenCalled();
|
||||
expect(window.location.pathname).toBe('/runtime/jump-hop');
|
||||
expect(window.location.search).toContain('work=JH-12345678');
|
||||
});
|
||||
|
||||
test('owned public puzzle detail edits original draft instead of remixing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const ownedPuzzleWork = {
|
||||
|
||||
@@ -65,7 +65,7 @@ test('jump hop workspace submits theme payload after required field is filled',
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '内置默认 3D 角色',
|
||||
tilePrompt: '云朵跳台主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
tilePrompt: '云朵跳台主题的3D立方体主题身份方块包装图集',
|
||||
endMoodPrompt: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ function buildJumpHopWorkspacePayload(
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '内置默认 3D 角色',
|
||||
tilePrompt: `${themeText}主题的正面30度视角主题物体图集,物体本身作为跳跃落点`,
|
||||
tilePrompt: `${themeText}主题的3D立方体主题身份方块包装图集`,
|
||||
endMoodPrompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user