499 lines
14 KiB
TypeScript
499 lines
14 KiB
TypeScript
import { expect, test } from 'vitest';
|
|
|
|
import type {
|
|
JumpHopPath,
|
|
JumpHopTileAsset,
|
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
|
import {
|
|
buildJumpHopVisiblePlatforms,
|
|
getJumpHopBackendDragVector,
|
|
getJumpHopCharacterVisualPosition,
|
|
getJumpHopJumpFeedbackLabel,
|
|
getJumpHopLandingAssistVisualPosition,
|
|
getJumpHopPlatformVisualSize,
|
|
getJumpHopStatusLabel,
|
|
resolveJumpHopCharacterCanvasPosition,
|
|
selectJumpHopTileAsset,
|
|
} from './jumpHopRuntimeModel';
|
|
|
|
test('跳一跳地块池按平台编号从 25 个素材中抽取而不是按类型压扁', () => {
|
|
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
|
|
tileType: 'normal',
|
|
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
|
|
imageSrc: `asset-${index + 1}`,
|
|
imageObjectKey: `key-${index + 1}`,
|
|
assetObjectId: `object-${index + 1}`,
|
|
sourceAtlasCell: `row-1-col-${index + 1}`,
|
|
atlasRow: 1,
|
|
atlasCol: index + 1,
|
|
visualWidth: 256,
|
|
visualHeight: 192,
|
|
topSurfaceRadius: 42,
|
|
landingRadius: 34,
|
|
})) satisfies JumpHopTileAsset[];
|
|
|
|
const first = selectJumpHopTileAsset(tileAssets, '森林茶馆', 1, 'platform-1');
|
|
const second = selectJumpHopTileAsset(tileAssets, '森林茶馆', 2, 'platform-2');
|
|
|
|
expect(first?.imageSrc).not.toBe(second?.imageSrc);
|
|
expect(first?.imageSrc).toMatch(/^asset-/);
|
|
expect(second?.imageSrc).toMatch(/^asset-/);
|
|
});
|
|
|
|
test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材', () => {
|
|
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.2, 1.8, 'normal'),
|
|
platform(-0.3, 3.5, 'target'),
|
|
platform(0.8, 5.1, 'normal'),
|
|
],
|
|
};
|
|
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
|
|
tileType: 'normal',
|
|
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
|
|
imageSrc: `asset-${index + 1}`,
|
|
imageObjectKey: `key-${index + 1}`,
|
|
assetObjectId: `object-${index + 1}`,
|
|
sourceAtlasCell: `row-1-col-${index + 1}`,
|
|
visualWidth: 256,
|
|
visualHeight: 192,
|
|
topSurfaceRadius: 42,
|
|
landingRadius: 34,
|
|
})) satisfies JumpHopTileAsset[];
|
|
|
|
const visible = buildJumpHopVisiblePlatforms(path, 1, tileAssets);
|
|
|
|
expect(visible).toHaveLength(3);
|
|
expect(visible[0]?.asset?.imageSrc).toMatch(/^asset-/);
|
|
expect(visible[1]?.asset?.imageSrc).toMatch(/^asset-/);
|
|
expect(visible[2]?.asset?.imageSrc).toMatch(/^asset-/);
|
|
});
|
|
|
|
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 visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
|
const character = getJumpHopCharacterVisualPosition(
|
|
{
|
|
runId: 'run-1',
|
|
profileId: 'profile-1',
|
|
ownerUserId: 'user-1',
|
|
status: 'playing',
|
|
currentPlatformIndex: 0,
|
|
successfulJumpCount: 0,
|
|
durationMs: 0,
|
|
score: 0,
|
|
combo: 0,
|
|
path,
|
|
lastJump: null,
|
|
startedAtMs: 1000,
|
|
finishedAtMs: null,
|
|
},
|
|
visible,
|
|
);
|
|
|
|
expect(visible[0]?.screenY).toBeGreaterThanOrEqual(68);
|
|
expect(visible[0]?.screenY).toBeLessThanOrEqual(80);
|
|
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(26);
|
|
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);
|
|
});
|
|
|
|
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 visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
|
const currentSize = getJumpHopPlatformVisualSize(
|
|
visible[0]!.platform,
|
|
visible[0]!.scale,
|
|
);
|
|
const targetSize = getJumpHopPlatformVisualSize(
|
|
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);
|
|
});
|
|
|
|
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 visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
|
const character = getJumpHopCharacterVisualPosition(
|
|
{
|
|
runId: 'run-1',
|
|
profileId: 'profile-1',
|
|
ownerUserId: 'user-1',
|
|
status: 'playing',
|
|
currentPlatformIndex: 0,
|
|
successfulJumpCount: 0,
|
|
durationMs: 0,
|
|
score: 0,
|
|
combo: 0,
|
|
path,
|
|
lastJump: null,
|
|
startedAtMs: 1000,
|
|
finishedAtMs: null,
|
|
},
|
|
visible,
|
|
);
|
|
|
|
const canvasPosition = resolveJumpHopCharacterCanvasPosition(character, {
|
|
width: 320,
|
|
height: 568,
|
|
});
|
|
|
|
expect(canvasPosition?.x).toBeGreaterThan(140);
|
|
expect(canvasPosition?.x).toBeLessThan(180);
|
|
expect(canvasPosition?.y).toBeGreaterThan(380);
|
|
expect(canvasPosition?.y).toBeLessThan(450);
|
|
});
|
|
|
|
test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍', () => {
|
|
const size = getJumpHopPlatformVisualSize(platform(0, 0, 'start'), 1.08);
|
|
|
|
expect(size.width).toBeCloseTo(125.28, 2);
|
|
expect(size.height).toBeCloseTo(103.68, 2);
|
|
});
|
|
|
|
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: 0,
|
|
successfulJumpCount: 0,
|
|
durationMs: 0,
|
|
score: 0,
|
|
combo: 0,
|
|
path,
|
|
lastJump: null,
|
|
startedAtMs: 1000,
|
|
finishedAtMs: null,
|
|
} as const;
|
|
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
|
const character = getJumpHopCharacterVisualPosition(run, visible);
|
|
const current = visible[0]!;
|
|
const target = visible[1]!;
|
|
const stageSize = { width: 320, height: 568 };
|
|
const currentCanvasPosition = {
|
|
x: (current.screenX / 100) * stageSize.width,
|
|
y: (current.screenY / 100) * stageSize.height,
|
|
};
|
|
const targetCanvasPosition = {
|
|
x: (target.screenX / 100) * stageSize.width,
|
|
y: (target.screenY / 100) * stageSize.height,
|
|
};
|
|
const targetWorldDistance = Math.hypot(
|
|
target.platform.x - current.platform.x,
|
|
target.platform.y - current.platform.y,
|
|
);
|
|
const fullDragDistance =
|
|
targetWorldDistance / path.scoring.chargeToDistanceRatio;
|
|
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
|
const dragVectorY = -(targetCanvasPosition.y - currentCanvasPosition.y);
|
|
|
|
const fullAssist = getJumpHopLandingAssistVisualPosition(
|
|
run,
|
|
visible,
|
|
character,
|
|
stageSize,
|
|
fullDragDistance,
|
|
dragVectorX,
|
|
dragVectorY,
|
|
);
|
|
const halfAssist = getJumpHopLandingAssistVisualPosition(
|
|
run,
|
|
visible,
|
|
character,
|
|
stageSize,
|
|
fullDragDistance / 2,
|
|
dragVectorX,
|
|
dragVectorY,
|
|
);
|
|
|
|
expect(fullAssist?.screenX).toBeCloseTo(target.screenX, 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,
|
|
1,
|
|
);
|
|
});
|
|
|
|
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: 0,
|
|
successfulJumpCount: 0,
|
|
durationMs: 0,
|
|
score: 0,
|
|
combo: 0,
|
|
path,
|
|
lastJump: null,
|
|
startedAtMs: 1000,
|
|
finishedAtMs: null,
|
|
} as const;
|
|
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
|
const character = getJumpHopCharacterVisualPosition(run, visible);
|
|
const current = visible[0]!;
|
|
const target = visible[1]!;
|
|
const stageSize = { width: 320, height: 568 };
|
|
const currentCanvasPosition = {
|
|
x: (current.screenX / 100) * stageSize.width,
|
|
y: (current.screenY / 100) * stageSize.height,
|
|
};
|
|
const targetCanvasPosition = {
|
|
x: (target.screenX / 100) * stageSize.width,
|
|
y: (target.screenY / 100) * stageSize.height,
|
|
};
|
|
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
|
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
|
|
const targetWorldDistance = Math.hypot(
|
|
target.platform.x - current.platform.x,
|
|
target.platform.y - current.platform.y,
|
|
);
|
|
const fullDragDistance =
|
|
targetWorldDistance / path.scoring.chargeToDistanceRatio;
|
|
|
|
const assist = getJumpHopLandingAssistVisualPosition(
|
|
run,
|
|
visible,
|
|
character,
|
|
stageSize,
|
|
fullDragDistance,
|
|
dragVectorX,
|
|
dragVectorY,
|
|
);
|
|
|
|
expect(dragVectorY).toBeGreaterThan(0);
|
|
expect(assist?.screenX).toBeCloseTo(target.screenX, 1);
|
|
expect(assist?.screenY).toBeCloseTo(target.screenY, 1);
|
|
});
|
|
|
|
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: 0,
|
|
successfulJumpCount: 0,
|
|
durationMs: 0,
|
|
score: 0,
|
|
combo: 0,
|
|
path,
|
|
lastJump: null,
|
|
startedAtMs: 1000,
|
|
finishedAtMs: null,
|
|
} as const;
|
|
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
|
const current = visible[0]!;
|
|
const target = visible[1]!;
|
|
const stageSize = { width: 320, height: 568 };
|
|
const currentCanvasPosition = {
|
|
x: (current.screenX / 100) * stageSize.width,
|
|
y: (current.screenY / 100) * stageSize.height,
|
|
};
|
|
const targetCanvasPosition = {
|
|
x: (target.screenX / 100) * stageSize.width,
|
|
y: (target.screenY / 100) * stageSize.height,
|
|
};
|
|
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
|
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
|
|
const backendVector = getJumpHopBackendDragVector(
|
|
run,
|
|
visible,
|
|
stageSize,
|
|
dragVectorX,
|
|
dragVectorY,
|
|
);
|
|
|
|
expect(backendVector.dragVectorX).toBeLessThan(0);
|
|
expect(backendVector.dragVectorY).toBeGreaterThan(0);
|
|
expect(Math.abs(backendVector.dragVectorY)).toBeLessThan(Math.abs(dragVectorY));
|
|
});
|
|
|
|
test('跳一跳运行态公开反馈不再展示旧 perfect 和通关语义', () => {
|
|
expect(getJumpHopStatusLabel('cleared')).toBe('结束');
|
|
expect(
|
|
getJumpHopJumpFeedbackLabel({
|
|
runId: 'run-1',
|
|
profileId: 'profile-1',
|
|
ownerUserId: 'user-1',
|
|
status: 'playing',
|
|
currentPlatformIndex: 1,
|
|
successfulJumpCount: 1,
|
|
durationMs: 0,
|
|
score: 1,
|
|
combo: 0,
|
|
path: {
|
|
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.2, 1.8, 'normal')],
|
|
},
|
|
lastJump: {
|
|
chargeMs: 300,
|
|
jumpDistance: 1.2,
|
|
targetPlatformIndex: 1,
|
|
landedX: 1.2,
|
|
landedY: 1.8,
|
|
result: 'perfect',
|
|
},
|
|
startedAtMs: 1000,
|
|
finishedAtMs: null,
|
|
}),
|
|
).toBe('落地');
|
|
});
|
|
|
|
function platform(x: number, y: number, tileType: 'start' | 'normal' | 'target') {
|
|
return {
|
|
platformId: `platform-${x}-${y}`,
|
|
tileType,
|
|
x,
|
|
y,
|
|
width: 1,
|
|
height: 1,
|
|
landingRadius: 0.5,
|
|
perfectRadius: 0.2,
|
|
scoreValue: 1,
|
|
};
|
|
}
|