Files
Genarrative/src/services/jump-hop/jumpHopRuntimeModel.test.ts

483 lines
14 KiB
TypeScript

import { expect, test } from 'vitest';
import type {
JumpHopPath,
JumpHopTileAsset,
} from '../../../packages/shared/src/contracts/jumpHop';
import {
buildJumpHopVisiblePlatforms,
getJumpHopCharacterVisualPosition,
getJumpHopJumpFeedbackLabel,
getJumpHopLandingAssistVisualPosition,
getJumpHopPlatformVisualSize,
getJumpHopStatusLabel,
isJumpHopLandingInsidePlatformFootprint,
resolveJumpHopCharacterCanvasPosition,
selectJumpHopTileAsset,
} from './jumpHopRuntimeModel';
test('跳一跳地块池按平台编号从 18 个素材中抽取而不是按类型压扁', () => {
const tileAssets = Array.from({ length: 18 }, (_, 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-${Math.floor(index / 3) + 1}-col-${(index % 3) + 1}`,
atlasRow: Math.floor(index / 3) + 1,
atlasCol: (index % 3) + 1,
visualWidth: 256,
visualHeight: 256,
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: 18 }, (_, 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-${Math.floor(index / 3) + 1}-col-${(index % 3) + 1}`,
atlasRow: Math.floor(index / 3) + 1,
atlasCol: (index % 3) + 1,
visualWidth: 256,
visualHeight: 256,
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(60);
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(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(330);
expect(canvasPosition?.y).toBeLessThan(370);
});
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 targetWorldDistance = Math.hypot(
target.platform.x - current.platform.x,
target.platform.y - current.platform.y,
);
const fullDragDistance =
targetWorldDistance / path.scoring.chargeToDistanceRatio;
const fullAssist = getJumpHopLandingAssistVisualPosition(
run,
visible,
character,
stageSize,
fullDragDistance,
);
const halfAssist = getJumpHopLandingAssistVisualPosition(
run,
visible,
character,
stageSize,
fullDragDistance / 2,
);
expect(fullAssist?.screenX).toBeCloseTo(target.screenX, 1);
expect(fullAssist?.screenY).toBeCloseTo(target.screenY - 3, 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,
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 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,
-999,
-999,
);
expect(assist?.screenX).toBeCloseTo(target.screenX, 1);
expect(assist?.screenY).toBeCloseTo(target.screenY - 3, 1);
expect(assist?.isOnTargetPlatform).toBe(true);
});
test('跳一跳落点预测用收缩后的视觉顶面 footprint 判断命中', () => {
const target = {
...platform(1, 0, 'normal'),
width: 2,
height: 0.6,
landingRadius: 0.2,
};
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.6, 0)).toBe(true);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1.8, 0)).toBe(false);
expect(isJumpHopLandingInsidePlatformFootprint(target, 1, 0.18)).toBe(false);
});
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.52,
landedY: 0.78,
result: 'hit',
},
startedAtMs: 1000,
finishedAtMs: null,
} as const;
const visible = buildJumpHopVisiblePlatforms(path, 1, []);
const character = getJumpHopCharacterVisualPosition(run, visible, {
width: 320,
height: 568,
});
const currentCenter = visible[0]!;
expect(character?.screenX).not.toBeCloseTo(currentCenter.screenX, 1);
expect(character?.screenY).not.toBeCloseTo(currentCenter.screenY - 3, 1);
expect(character?.screenX).toBeLessThan(currentCenter.screenX);
expect(character?.screenY).toBeGreaterThan(currentCenter.screenY - 3);
});
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,
};
}