import { expect, test } from 'vitest'; import type { JumpHopPath, JumpHopTileAsset, } from '../../../packages/shared/src/contracts/jumpHop'; import { buildJumpHopVisiblePlatforms, getJumpHopCharacterTopFaceVisualPosition, 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('跳一跳可见平台窗口固定为当前块和下一个块并携带选中的地块素材', () => { 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(2); expect(visible[0]?.asset?.imageSrc).toMatch(/^asset-/); expect(visible[1]?.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).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(character?.screenX).toBeCloseTo(visible[0]?.screenX ?? 0, 1); expect(character?.screenY).toBeCloseTo(visible[0]?.screenY ?? 0, 1); }); 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', 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, ); 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('跳一跳三维角色画布坐标按下一块方向偏向场地侧边', () => { 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(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); expect(size.width).toBeCloseTo(116, 2); expect(size.height).toBeCloseTo(96, 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, 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 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, ); expect(assist?.screenX).toBeCloseTo(target.screenX, 1); expect(assist?.screenY).toBeCloseTo(target.screenY, 1); expect(assist?.isOnTargetPlatform).toBe(true); }); 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: 10, }; 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('跳一跳成功落地后保留真实落点偏移而不是吸附到地块中心', () => { 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, { width: 320, height: 568, }); const currentCenter = visible[0]!; expect(character?.screenX).not.toBeCloseTo(currentCenter.screenX, 1); expect(character?.screenY).not.toBeCloseTo(currentCenter.screenY, 1); expect(character?.screenX).toBeLessThan(currentCenter.screenX); expect(character?.screenY).toBeGreaterThan(currentCenter.screenY); }); 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, }; }