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

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

统一前端指示器飞行动画与后端顶面 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
@@ -53,6 +53,25 @@ describe('jumpHopClient runtime requests', () => {
);
});
it('passes draft runtime mode in the start run request body', async () => {
await startJumpHopRuntimeRun('profile/1', {
runtimeMode: 'draft',
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/runtime/jump-hop/runs',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
profileId: 'profile/1',
runtimeMode: 'draft',
}),
}),
'启动跳一跳运行态失败',
expect.anything(),
);
});
it('submits jump input with a generated client event id', async () => {
await submitJumpHopJump(
'run/1',
+7 -4
View File
@@ -8,6 +8,7 @@ import type {
JumpHopJumpRequest,
JumpHopLeaderboardResponse,
JumpHopRunResponse,
JumpHopStartRunRequest,
JumpHopRuntimeRunSnapshotResponse,
JumpHopSessionResponse,
JumpHopSessionSnapshotResponse,
@@ -47,7 +48,7 @@ type JumpHopStartRunOptions = JumpHopRuntimeRequestOptions & {
};
type JumpHopJumpPayload = Pick<
JumpHopJumpRequest,
'dragDistance' | 'dragVectorX' | 'dragVectorY'
'dragDistance'
>;
export type {
@@ -263,10 +264,14 @@ export async function startJumpHopRuntimeRun(
profileId: string,
options: JumpHopStartRunOptions = {},
) {
const requestBody: JumpHopStartRunRequest = {
profileId,
...(options.runtimeMode ? { runtimeMode: options.runtimeMode } : {}),
};
return requestRuntimeJson<JumpHopRunResponse>({
url: buildRuntimeApiPath(JUMP_HOP_RUNTIME_API_BASE, 'runs'),
method: 'POST',
jsonBody: { profileId },
jsonBody: requestBody,
fallbackMessage: '启动跳一跳运行态失败',
requestOptions: options,
});
@@ -279,8 +284,6 @@ export async function submitJumpHopJump(
) {
const requestPayload = {
dragDistance: payload.dragDistance,
dragVectorX: payload.dragVectorX,
dragVectorY: payload.dragVectorY,
clientEventId: `jump-${runId}-${Date.now()}`,
};
+240 -38
View File
@@ -6,6 +6,7 @@ import type {
} from '../../../packages/shared/src/contracts/jumpHop';
import {
buildJumpHopVisiblePlatforms,
getJumpHopCharacterTopFaceVisualPosition,
getJumpHopCharacterVisualPosition,
getJumpHopJumpFeedbackLabel,
getJumpHopLandingAssistVisualPosition,
@@ -40,7 +41,7 @@ test('跳一跳地块池按平台编号从 18 个素材中抽取而不是按类
expect(second?.imageSrc).toMatch(/^asset-/);
});
test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材', () => {
test('跳一跳可见平台窗口固定为当前块和下一个块并携带选中的地块素材', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -76,13 +77,12 @@ test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材',
const visible = buildJumpHopVisiblePlatforms(path, 1, tileAssets);
expect(visible).toHaveLength(3);
expect(visible).toHaveLength(2);
expect(visible[0]?.asset?.imageSrc).toMatch(/^asset-/);
expect(visible[1]?.asset?.imageSrc).toMatch(/^asset-/);
expect(visible[2]?.asset?.imageSrc).toMatch(/^asset-/);
});
test('跳一跳三块可见地块按下方中部上方展开且角色落在当前地块上', () => {
test('跳一跳当前块按目标方向偏向场地侧边且角色落在当前地块上', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -125,15 +125,103 @@ test('跳一跳三块可见地块按下方中部上方展开且角色落在当
expect(visible[0]?.screenY).toBeLessThanOrEqual(66);
expect(visible[1]?.screenY).toBeGreaterThanOrEqual(40);
expect(visible[1]?.screenY).toBeLessThan(visible[0]?.screenY ?? 0);
expect(visible[2]?.screenY).toBeLessThan(visible[1]?.screenY ?? 0);
expect(visible[2]?.screenY).toBeLessThanOrEqual(32);
expect(visible).toHaveLength(2);
expect(visible[0]?.screenX).toBeLessThan(50);
expect(visible[1]?.screenX).toBeGreaterThan(50);
expect(Math.abs((visible[1]?.screenX ?? 0) - (visible[0]?.screenX ?? 0))).toBeGreaterThan(5);
expect(Math.abs((visible[2]?.screenX ?? 0) - (visible[1]?.screenX ?? 0))).toBeGreaterThan(5);
expect(character?.screenX).toBeCloseTo(visible[0]?.screenX ?? 0, 1);
expect(character?.screenY).toBeCloseTo((visible[0]?.screenY ?? 0) - 3, 1);
expect(character?.screenY).toBeCloseTo(visible[0]?.screenY ?? 0, 1);
});
test('跳一跳可见地块按深度保留不同视觉尺寸', () => {
test('跳一跳目标地块始终显示在当前脚下块的正负 45 度方向', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
finishIndex: 999,
cameraPreset: 'portrait-isometric-9x16',
scoring: {
chargeToDistanceRatio: 0.004,
maxChargeMs: 900,
hitBonus: 20,
perfectBonus: 60,
},
platforms: [
platform(0, 0, 'start'),
platform(1.78, 1.78, 'normal'),
platform(0, 3.56, 'target'),
],
};
const stageSize = { width: 320, height: (320 * 16) / 9 };
const firstVisible = buildJumpHopVisiblePlatforms(path, 0, []);
const firstDx = Math.abs(
((firstVisible[1]!.screenX - firstVisible[0]!.screenX) / 100) *
stageSize.width,
);
const firstDy = Math.abs(
((firstVisible[1]!.screenY - firstVisible[0]!.screenY) / 100) *
stageSize.height,
);
expect(firstVisible[1]!.screenX).toBeGreaterThan(firstVisible[0]!.screenX);
expect(firstVisible[0]!.screenX).toBeLessThan(50);
expect(firstVisible[1]!.screenX).toBeGreaterThan(50);
expect(firstDx).toBeCloseTo(firstDy, 5);
const secondVisible = buildJumpHopVisiblePlatforms(path, 1, []);
const secondDx = Math.abs(
((secondVisible[1]!.screenX - secondVisible[0]!.screenX) / 100) *
stageSize.width,
);
const secondDy = Math.abs(
((secondVisible[1]!.screenY - secondVisible[0]!.screenY) / 100) *
stageSize.height,
);
expect(secondVisible[1]!.screenX).toBeLessThan(secondVisible[0]!.screenX);
expect(secondVisible[0]!.screenX).toBeGreaterThan(50);
expect(secondVisible[1]!.screenX).toBeLessThan(50);
expect(secondDx).toBeCloseTo(secondDy, 5);
});
test('跳一跳目标地块按真实距离在最小和当前最大间距之间投影', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
finishIndex: 999,
cameraPreset: 'portrait-isometric-9x16',
scoring: {
chargeToDistanceRatio: 0.004,
maxChargeMs: 900,
hitBonus: 20,
perfectBonus: 60,
},
platforms: [
platform(0, 0, 'start'),
platform(0.979, 0.979, 'normal'),
],
};
const stageSize = { width: 320, height: (320 * 16) / 9 };
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
const dx = Math.abs(
((visible[1]!.screenX - visible[0]!.screenX) / 100) *
stageSize.width,
);
const dy = Math.abs(
((visible[1]!.screenY - visible[0]!.screenY) / 100) *
stageSize.height,
);
expect(visible[1]!.screenY).toBeGreaterThan(47);
expect(visible[1]!.screenY).toBeLessThan(64);
expect(Math.abs(visible[1]!.screenX - visible[0]!.screenX)).toBeLessThan(
30.3,
);
expect(dx).toBeCloseTo(dy, 5);
});
test('跳一跳可见地块不再按深度做倍率缩放', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -161,18 +249,14 @@ test('跳一跳可见地块按深度保留不同视觉尺寸', () => {
visible[1]!.platform,
visible[1]!.scale,
);
const previewSize = getJumpHopPlatformVisualSize(
visible[2]!.platform,
visible[2]!.scale,
);
expect(currentSize.width).toBeGreaterThan(targetSize.width);
expect(targetSize.width).toBeGreaterThan(previewSize.width);
expect(currentSize.height).toBeGreaterThan(targetSize.height);
expect(targetSize.height).toBeGreaterThan(previewSize.height);
expect(visible[0]?.scale).toBe(1);
expect(visible[1]?.scale).toBe(1);
expect(currentSize.width).toBe(targetSize.width);
expect(currentSize.height).toBe(targetSize.height);
});
test('跳一跳三维角色画布坐标与屏幕坐标同向映射到下方起始地块', () => {
test('跳一跳三维角色画布坐标按下一块方向偏向场地侧边', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
@@ -216,17 +300,17 @@ test('跳一跳三维角色画布坐标与屏幕坐标同向映射到下方起
height: 568,
});
expect(canvasPosition?.x).toBeGreaterThan(140);
expect(canvasPosition?.x).toBeLessThan(180);
expect(canvasPosition?.x).toBeGreaterThan(100);
expect(canvasPosition?.x).toBeLessThan(125);
expect(canvasPosition?.y).toBeGreaterThan(330);
expect(canvasPosition?.y).toBeLessThan(370);
});
test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍', () => {
const size = getJumpHopPlatformVisualSize(platform(0, 0, 'start'), 1.08);
test('跳一跳运行态当前地块视觉尺寸使用真实规格', () => {
const size = getJumpHopPlatformVisualSize(platform(0, 0, 'start'), 1);
expect(size.width).toBeCloseTo(125.28, 2);
expect(size.height).toBeCloseTo(103.68, 2);
expect(size.width).toBeCloseTo(116, 2);
expect(size.height).toBeCloseTo(96, 2);
});
test('跳一跳落点预测按蓄力值沿下一地块中心方向投影', () => {
@@ -290,13 +374,13 @@ test('跳一跳落点预测按蓄力值沿下一地块中心方向投影', () =>
);
expect(fullAssist?.screenX).toBeCloseTo(target.screenX, 1);
expect(fullAssist?.screenY).toBeCloseTo(target.screenY - 3, 1);
expect(fullAssist?.screenY).toBeCloseTo(target.screenY, 1);
expect(halfAssist?.screenX).toBeCloseTo(
current.screenX + (target.screenX - current.screenX) / 2,
1,
);
expect(halfAssist?.screenY).toBeCloseTo(
current.screenY + (target.screenY - current.screenY) / 2 - 3,
current.screenY + (target.screenY - current.screenY) / 2,
1,
);
});
@@ -352,26 +436,144 @@ test('跳一跳落点预测忽略旧客户端拖拽方向', () => {
character,
stageSize,
fullDragDistance,
-999,
-999,
);
expect(assist?.screenX).toBeCloseTo(target.screenX, 1);
expect(assist?.screenY).toBeCloseTo(target.screenY - 3, 1);
expect(assist?.screenY).toBeCloseTo(target.screenY, 1);
expect(assist?.isOnTargetPlatform).toBe(true);
});
test('跳一跳落点预测用收缩后的视觉顶面 footprint 判断命中', () => {
test('跳一跳落点预测从角色真实脚点指向下一地块顶面中心', () => {
const path: JumpHopPath = {
seed: 'forest-tea',
difficulty: 'standard',
finishIndex: 999,
cameraPreset: 'portrait-isometric-9x16',
scoring: {
chargeToDistanceRatio: 0.004,
maxChargeMs: 900,
hitBonus: 20,
perfectBonus: 60,
},
platforms: [
platform(0, 0, 'start'),
platform(0.8, 1.2, 'normal'),
platform(-0.2, 2.4, 'target'),
],
};
const run = {
runId: 'run-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
status: 'playing',
currentPlatformIndex: 1,
successfulJumpCount: 1,
durationMs: 0,
score: 1,
combo: 0,
path,
lastJump: {
chargeMs: 300,
jumpDistance: 1.0,
targetPlatformIndex: 1,
landedX: 0.72,
landedY: 1.16,
result: 'hit',
},
startedAtMs: 1000,
finishedAtMs: null,
} as const;
const visible = buildJumpHopVisiblePlatforms(path, 1, []);
const character = getJumpHopCharacterVisualPosition(run, visible);
const current = visible[0]!;
const target = visible[1]!;
const targetPosition = getJumpHopCharacterTopFaceVisualPosition(target);
const stageSize = { width: 320, height: 568 };
const targetWorldDistance = Math.hypot(
target.platform.x - run.lastJump.landedX,
target.platform.y - run.lastJump.landedY,
);
const fullDragDistance =
targetWorldDistance / path.scoring.chargeToDistanceRatio;
const fullAssist = getJumpHopLandingAssistVisualPosition(
run,
visible,
character,
stageSize,
fullDragDistance,
);
const halfAssist = getJumpHopLandingAssistVisualPosition(
run,
visible,
character,
stageSize,
fullDragDistance / 2,
);
expect(character?.screenX).not.toBeCloseTo(current.screenX, 1);
expect(fullAssist?.landedWorldX).toBeCloseTo(target.platform.x, 5);
expect(fullAssist?.landedWorldY).toBeCloseTo(target.platform.y, 5);
expect(fullAssist?.screenX).toBeCloseTo(targetPosition.screenX, 1);
expect(fullAssist?.screenY).toBeCloseTo(targetPosition.screenY, 1);
expect(fullAssist?.isOnTargetPlatform).toBe(true);
expect(halfAssist?.landedWorldX).toBeCloseTo(
(run.lastJump.landedX + target.platform.x) / 2,
5,
);
expect(halfAssist?.landedWorldY).toBeCloseTo(
(run.lastJump.landedY + target.platform.y) / 2,
5,
);
expect(halfAssist?.screenX).toBeCloseTo(
(character!.screenX + targetPosition.screenX) / 2,
1,
);
expect(halfAssist?.screenY).toBeCloseTo(
(character!.screenY + targetPosition.screenY) / 2,
1,
);
expect(halfAssist?.screenX).not.toBeCloseTo(
current.screenX + (target.screenX - current.screenX) / 2,
1,
);
});
test('跳一跳落点预测用完整视觉顶面 footprint 判断命中', () => {
const target = {
...platform(1, 0, 'normal'),
width: 2,
height: 0.6,
landingRadius: 0.2,
landingRadius: 10,
};
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.6, 0)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.8, 0)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1, 0.18)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.99, 0)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 2.01, 0)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1, 0.29)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1, 0.31)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.6, 0.12)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.7, 0.12)).toBe(false);
expect(
isJumpHopLandingInsidePlatformFootprint(
{ ...platform(0.8, 1.2, 'normal'), width: 2, height: 2 },
1.3,
1.6,
),
).toBe(true);
expect(
isJumpHopLandingInsidePlatformFootprint(
{ ...platform(0.8, 1.2, 'normal'), width: 2, height: 2 },
1.4,
1.8,
),
).toBe(false);
expect(
isJumpHopLandingInsidePlatformFootprint(
{ ...platform(0.8, 1.2, 'normal'), width: 2, height: 2 },
-0.19,
1.2,
),
).toBe(true);
});
test('跳一跳成功落地后保留真实落点偏移而不是吸附到地块中心', () => {
@@ -407,8 +609,8 @@ test('跳一跳成功落地后保留真实落点偏移而不是吸附到地块
chargeMs: 300,
jumpDistance: 1.0,
targetPlatformIndex: 1,
landedX: 0.52,
landedY: 0.78,
landedX: 0.72,
landedY: 1.16,
result: 'hit',
},
startedAtMs: 1000,
@@ -422,9 +624,9 @@ test('跳一跳成功落地后保留真实落点偏移而不是吸附到地块
const currentCenter = visible[0]!;
expect(character?.screenX).not.toBeCloseTo(currentCenter.screenX, 1);
expect(character?.screenY).not.toBeCloseTo(currentCenter.screenY - 3, 1);
expect(character?.screenY).not.toBeCloseTo(currentCenter.screenY, 1);
expect(character?.screenX).toBeLessThan(currentCenter.screenX);
expect(character?.screenY).toBeGreaterThan(currentCenter.screenY - 3);
expect(character?.screenY).toBeGreaterThan(currentCenter.screenY);
});
test('跳一跳运行态公开反馈不再展示旧 perfect 和通关语义', () => {
File diff suppressed because it is too large Load Diff