统一跳一跳三维地块与落点判定
修正跳一跳长按起跳预测为真实脚点指向下一块顶面中心 统一前端指示器飞行动画与后端顶面 footprint 判定 调整 Three.js 方块贴图与角色顶面投影表现 补充跳一跳 UV 图集切片与运行态规则文档
This commit is contained in:
@@ -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 和通关语义', () => {
|
||||
|
||||
Reference in New Issue
Block a user