统一跳一跳三维地块与落点判定

修正跳一跳长按起跳预测为真实脚点指向下一块顶面中心

统一前端指示器飞行动画与后端顶面 footprint 判定

调整 Three.js 方块贴图与角色顶面投影表现

补充跳一跳 UV 图集切片与运行态规则文档
This commit is contained in:
2026-06-12 22:42:39 +08:00
parent 6bdf84dc0d
commit 6ee55707e1
15 changed files with 1915 additions and 646 deletions
@@ -11,9 +11,22 @@ import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import {
JUMP_HOP_THREE_CAMERA_UP_Y,
JUMP_HOP_THREE_CHARACTER_RENDER_ORDER,
JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT,
JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE,
JUMP_HOP_THREE_PLATFORM_YAW_RAD,
JUMP_HOP_THREE_MATERIAL_FACE_ORDER,
JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET,
JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER,
JUMP_HOP_THREE_TEXTURE_TRANSFORMS,
JumpHopRuntimeShell,
getJumpHopThreeCharacterFootZ,
getJumpHopThreeCubeSide,
getJumpHopThreeProjectedY,
getJumpHopThreeWorldYForScreenY,
getJumpHopTileTextureSignature,
hasCompleteJumpHopTileFaceAssets,
resolveJumpHopThreeCharacterFrame,
} from './JumpHopRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
@@ -91,16 +104,14 @@ test('跳一跳运行态松手时提交长按蓄力值和后端方向向量', as
expect(onJump).toHaveBeenCalledTimes(1);
const jumpPayload = onJump.mock.calls[0]?.[0];
expect(typeof jumpPayload?.dragVectorX).toBe('number');
expect(typeof jumpPayload?.dragVectorY).toBe('number');
expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true);
expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true);
expect(jumpPayload).not.toHaveProperty('dragVectorX');
expect(jumpPayload).not.toHaveProperty('dragVectorY');
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();
@@ -141,10 +152,8 @@ test('跳一跳运行态手指移动不改变蓄力时长但仍提交方向向
});
const jumpPayload = onJump.mock.calls[0]?.[0];
expect(typeof jumpPayload?.dragVectorX).toBe('number');
expect(typeof jumpPayload?.dragVectorY).toBe('number');
expect(Number.isFinite(jumpPayload?.dragVectorX)).toBe(true);
expect(Number.isFinite(jumpPayload?.dragVectorY)).toBe(true);
expect(jumpPayload).not.toHaveProperty('dragVectorX');
expect(jumpPayload).not.toHaveProperty('dragVectorY');
expect(jumpPayload?.dragDistance).toBeGreaterThanOrEqual(240);
expect(jumpPayload?.dragDistance).toBeLessThanOrEqual(260);
vi.useRealTimers();
@@ -448,49 +457,133 @@ test('跳一跳 Three.js 平台层和 DOM 角色层保持同向屏幕坐标', ()
expect(getJumpHopThreeProjectedY(200, 568)).toBeGreaterThan(284);
});
test('跳一跳蓄力时隐藏落点辅助标识但保留蓄力引导', async () => {
const onJump = vi.fn().mockResolvedValue(undefined);
test('跳一跳 Three.js 顶面脚点投影会扣除立方体高度偏移', () => {
const projectedTopY = getJumpHopThreeProjectedY(360, 568);
const cubeTopWorldY = getJumpHopThreeWorldYForScreenY(360, 568, 48);
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={onJump}
onRestart={() => {}}
/>,
expect(cubeTopWorldY).toBeLessThan(projectedTopY);
expect(getJumpHopThreeWorldYForScreenY(360, 568, 0)).toBe(projectedTopY);
});
test('跳一跳 Three.js 方块只放大模型尺寸不改变屏幕间距规则', () => {
const run = buildRun();
const platform = run.path.platforms[0]!;
expect(JUMP_HOP_THREE_PLATFORM_CUBE_SIZE_MULTIPLIER).toBe(2);
expect(getJumpHopThreeCubeSide(platform, 1)).toBeCloseTo(165.12, 2);
expect(run.path.platforms[1]!.x - run.path.platforms[0]!.x).toBeCloseTo(
1.78,
);
expect(run.path.platforms[1]!.y - run.path.platforms[0]!.y).toBeCloseTo(
1.78,
);
});
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
test('跳一跳 Three.js 角色帧沿真实预测落点插值并保留飞行弧线', () => {
const from = {
screenX: 50,
screenY: 64,
sceneX: 0,
sceneY: 0.84,
sceneZ: 0,
isMiss: false,
};
const to = {
screenX: 64,
screenY: 47,
sceneX: 1.1,
sceneY: 0.84,
sceneZ: 3.4,
isMiss: true,
};
const midFrame = resolveJumpHopThreeCharacterFrame({
characterPosition: null,
visualJump: { from, to },
jumpAnimationProgress: 0.5,
isJumpAnimating: true,
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 148,
clientY: 454,
});
expect(midFrame?.progress).toBe(0.5);
expect(midFrame?.position.screenX).toBeGreaterThan(from.screenX);
expect(midFrame?.position.screenX).toBeLessThan(to.screenX);
expect(midFrame?.position.screenY).toBeLessThan(to.screenY);
expect(midFrame?.position.sceneZ).toBeGreaterThan(from.sceneZ);
expect(midFrame?.position.sceneZ).toBeLessThan(to.sceneZ);
expect(midFrame?.position.isMiss).toBe(true);
const landedFrame = resolveJumpHopThreeCharacterFrame({
characterPosition: from,
visualJump: { from, to },
jumpAnimationProgress: 2,
isJumpAnimating: true,
});
expect(landedFrame?.progress).toBe(1);
expect(landedFrame?.position).toMatchObject(to);
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 112,
clientY: 492,
});
expect(
resolveJumpHopThreeCharacterFrame({
characterPosition: from,
visualJump: { from, to },
jumpAnimationProgress: 0.5,
isJumpAnimating: false,
}),
).toEqual({
position: from,
progress: 1,
});
});
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
test('跳一跳蓄力时实时显示松手后的预测落点', async () => {
vi.useFakeTimers();
try {
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,
});
});
await act(async () => {
vi.advanceTimersByTime(240);
});
const firstAssist = screen.getByTestId('jump-hop-landing-assist');
expect(firstAssist).toBeTruthy();
expect(firstAssist.getAttribute('data-on-target')).toMatch(/^(true|false)$/);
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
const firstLeft = firstAssist.style.getPropertyValue('--jump-hop-landing-x');
const firstTop = firstAssist.style.getPropertyValue('--jump-hop-landing-y');
await act(async () => {
vi.advanceTimersByTime(260);
});
const secondAssist = screen.getByTestId('jump-hop-landing-assist');
expect(secondAssist.style.getPropertyValue('--jump-hop-landing-x')).not.toBe(
firstLeft,
);
expect(secondAssist.style.getPropertyValue('--jump-hop-landing-y')).not.toBe(
firstTop,
);
expect(stage.querySelector('.jump-hop-runtime__charge-guide')).toBeTruthy();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeNull();
} finally {
vi.useRealTimers();
}
});
test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => {
@@ -504,14 +597,14 @@ test('跳一跳运行态直接渲染生成的地板贴图切片图片', () => {
);
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
expect(tileImages).toHaveLength(3);
expect(tileImages).toHaveLength(2);
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
const generatedReadUrlCalls = vi
.mocked(useResolvedAssetReadUrl)
.mock.calls.filter(([source]) =>
source?.includes('/generated-jump-hop-assets/'),
);
expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(3);
expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(2);
for (const [, options] of generatedReadUrlCalls) {
expect(options).toEqual(
expect.objectContaining({
@@ -539,7 +632,7 @@ test('跳一跳运行态提前预加载下一屏地块且不在真实图片加
/>,
);
expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(3);
expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(2);
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
expect(preloadImages.length).toBeGreaterThan(0);
@@ -613,7 +706,63 @@ test('跳一跳 Three.js 地板贴图签名包含六面贴图 URL', () => {
expect(signature).toContain('bottom-url');
});
test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
test('跳一跳 Three 立方体按玩法 Z 轴顶面映射 UV 六面贴图', () => {
expect(JUMP_HOP_THREE_PLATFORM_YAW_RAD).toBeCloseTo(Math.PI / 4);
expect(JUMP_HOP_THREE_MATERIAL_FACE_ORDER).toEqual([
'right',
'left',
'back',
'front',
'top',
'bottom',
]);
expect(JUMP_HOP_THREE_TEXTURE_TRANSFORMS).toMatchObject({
top: { flipX: true, flipY: false },
front: { flipX: true, flipY: true },
right: { flipX: false, flipY: true },
back: { flipX: false, flipY: true },
left: { flipX: true, flipY: true },
bottom: { flipX: true, flipY: true },
});
});
test('跳一跳 Three 角色绘制层级高于地块并且脚点落在顶面中心高度', () => {
expect(JUMP_HOP_THREE_PLATFORM_MATERIAL_TRANSPARENT).toBe(false);
expect(JUMP_HOP_THREE_CHARACTER_RENDER_ORDER).toBeGreaterThan(
JUMP_HOP_THREE_PLATFORM_MESH_RENDER_ORDER_BASE + 18,
);
expect(JUMP_HOP_THREE_CHARACTER_TOP_FACE_Z_OFFSET).toBe(0);
expect(getJumpHopThreeCharacterFootZ(96, false)).toBe(48);
expect(getJumpHopThreeCharacterFootZ(96, true)).toBeLessThan(
getJumpHopThreeCharacterFootZ(96, false),
);
expect(getJumpHopThreeCharacterFootZ(40, false)).toBe(20);
});
test('跳一跳旧单图地块资源不启用 Three UV 贴面层', () => {
const oldAssets = buildTileAssets();
const uvAssets = buildTileAssets({ withFaceAssets: true });
expect(hasCompleteJumpHopTileFaceAssets(oldAssets[0])).toBe(false);
expect(hasCompleteJumpHopTileFaceAssets(uvAssets[0])).toBe(true);
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: oldAssets })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
expect(
screen
.getByTestId('jump-hop-camera-layer')
.getAttribute('data-three-platform-ready'),
).toBe('false');
expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(2);
});
test('跳一跳运行态只展示当前块和下一块并给下一块留出视野空间', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
@@ -624,16 +773,16 @@ test('跳一跳运行态首块地块落在中下方并且后续两块向中央
);
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
expect(tileImages).toHaveLength(3);
expect(tileImages).toHaveLength(2);
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('64%');
expect(second?.style.top).toBe('47%');
expect(third?.style.top).toBe('30%');
expect(Number.parseFloat(first?.style.left ?? '0')).toBeLessThan(50);
expect(Number.parseFloat(second?.style.left ?? '0')).toBeGreaterThan(50);
});
test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => {
test('跳一跳运行态地块从出现开始保持真实尺寸', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
@@ -649,7 +798,7 @@ test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸'
expect(firstTile?.style.width).toBe('116px');
expect(firstTile?.style.height).toBe('96px');
expect(firstTile?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
'1.08',
'',
);
});
@@ -722,8 +871,8 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
chargeMs: 420,
jumpDistance: 1.68,
targetPlatformIndex: 1,
landedX: 0.93,
landedY: 1.4,
landedX: 1.7,
landedY: 1.74,
result: 'hit',
},
};
@@ -787,6 +936,17 @@ test('跳一跳后端回包较慢时角色停在目标点等待推进', async ()
/>,
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'true',
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'false',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'false',
);
@@ -808,8 +968,8 @@ test('跳一跳成功落点偏移后下一跳视觉仍朝下一块地块方向',
chargeMs: 300,
jumpDistance: 1.0,
targetPlatformIndex: 1,
landedX: 0,
landedY: 1.2,
landedX: 1.7,
landedY: 1.74,
result: 'hit',
},
};
@@ -866,8 +1026,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
chargeMs: 420,
jumpDistance: 1.68,
targetPlatformIndex: 1,
landedX: 0.93,
landedY: 1.4,
landedX: 1.7,
landedY: 1.74,
result: 'hit',
},
};
@@ -880,8 +1040,8 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
chargeMs: 360,
jumpDistance: 1.44,
targetPlatformIndex: 2,
landedX: -0.2,
landedY: 2.4,
landedX: 0,
landedY: 3.56,
result: 'hit',
},
};
@@ -958,6 +1118,17 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
await vi.advanceTimersByTimeAsync(580);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'true',
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'false',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'false',
);
@@ -978,16 +1149,19 @@ 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',
'1.69',
);
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe(
'-17%',
);
// 下一块在反向时,当前块会切换到场地反侧锚点,横向镜头推进应被侧边视野校正抵消。
expect(
Number.parseFloat(
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
Math.abs(
Number.parseFloat(
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
),
),
).toBeCloseTo(8.96, 2);
).toBeLessThan(1);
const styleText = Array.from(document.querySelectorAll('style'))
.map((style) => style.textContent ?? '')
.join('\n');
@@ -1028,7 +1202,7 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
'64%',
);
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',
@@ -1127,6 +1301,14 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
await vi.advanceTimersByTimeAsync(580);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'false',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
const movedOldPlatform = screen
.getByTestId('jump-hop-stage')
.querySelector("[data-platform-id='p0']") as HTMLElement | null;
@@ -1181,8 +1363,8 @@ function buildRun(): JumpHopRuntimeRunSnapshotResponse {
{
platformId: 'p1',
tileType: 'normal',
x: 0.8,
y: 1.2,
x: 1.78,
y: 1.78,
width: 1,
height: 1,
landingRadius: 0.5,
@@ -1192,8 +1374,8 @@ function buildRun(): JumpHopRuntimeRunSnapshotResponse {
{
platformId: 'p2',
tileType: 'target',
x: -0.2,
y: 2.4,
x: 0,
y: 3.56,
width: 1,
height: 1,
landingRadius: 0.5,
@@ -1239,8 +1421,8 @@ function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse {
{
platformId: 'p3',
tileType: 'normal',
x: 0.5,
y: 3.6,
x: 1.78,
y: 5.34,
width: 1,
height: 1,
landingRadius: 0.5,
File diff suppressed because it is too large Load Diff