import type { JumpHopPath, JumpHopPlatform, JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopTileAsset, JumpHopTileType, } from '../../../packages/shared/src/contracts/jumpHop'; export type JumpHopVisiblePlatform = { platform: JumpHopPlatform; index: number; screenX: number; screenY: number; sceneX: number; sceneY: number; sceneZ: number; scale: number; asset: JumpHopTileAsset | null; }; export type JumpHopCharacterVisualPosition = { screenX: number; screenY: number; sceneX: number; sceneY: number; sceneZ: number; isMiss: boolean; }; export type JumpHopCanvasSize = { width: number; height: number; }; export type JumpHopPlatformVisualSize = { width: number; height: number; }; export type JumpHopLandingAssistVisualPosition = { screenX: number; screenY: number; targetPlatformIndex: number; }; export type JumpHopBackendDragVector = { dragVectorX: number; dragVectorY: number; }; const VISIBLE_PLATFORM_COUNT = 3; const JUMP_HOP_STAGE_WORLD_SCALE = 4.2; const JUMP_HOP_STAGE_FORWARD_SCALE = 3; const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [78, 50, 22] as const; const JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER = 2; const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 16 * 0.96; const tileToneByType: Record = { accent: '#e0f2fe', bonus: '#fef3c7', finish: '#dcfce7', normal: '#f8fafc', start: '#e0f2fe', target: '#fee2e2', }; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function hashJumpHopString(value: string) { let hash = 0x811c9dc5; for (const character of value) { hash ^= character.codePointAt(0) ?? 0; hash = Math.imul(hash, 0x01000193); } return hash >>> 0; } export function selectJumpHopTileAsset( tileAssets: JumpHopTileAsset[] | null | undefined, seedText: string | null | undefined, platformIndex: number, platformId: string, ) { const pool = (tileAssets ?? []).filter(Boolean); if (pool.length === 0) { return null; } const normalizedSeed = seedText?.trim() || 'jump-hop'; const signature = `${normalizedSeed}:${platformIndex}:${platformId}`; const selectedIndex = hashJumpHopString(signature) % pool.length; return pool[selectedIndex] ?? null; } export function buildJumpHopVisiblePlatforms( path: JumpHopPath | null | undefined, currentPlatformIndex: number, tileAssets: JumpHopTileAsset[] | null | undefined, ) { const platforms = path?.platforms ?? []; const current = platforms[currentPlatformIndex] ?? platforms[0]; if (!current) { return []; } const start = Math.max(0, currentPlatformIndex); const end = Math.min(platforms.length, currentPlatformIndex + VISIBLE_PLATFORM_COUNT); const visible = platforms.slice(start, end); const worldScale = 0.96; return visible.map((platform, offset): JumpHopVisiblePlatform => { const index = start + offset; const dx = platform.x - current.x; const dy = platform.y - current.y; const depth = index - currentPlatformIndex; const asset = selectJumpHopTileAsset( tileAssets, path?.seed ?? null, index, platform.platformId, ); const screenY = depth <= 0 ? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0] : depth === 1 ? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1] : JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[2]; const screenX = clamp(50 + dx * 16 * worldScale, 14, 86); return { platform, index, screenX, screenY, sceneX: dx * JUMP_HOP_STAGE_WORLD_SCALE, sceneY: 0, sceneZ: dy * JUMP_HOP_STAGE_FORWARD_SCALE, scale: clamp(1.08 - Math.max(0, depth) * 0.12, 0.8, 1.1), asset, }; }); } export function getJumpHopPlatformVisualSize( platform: JumpHopPlatform, scale: number, ): JumpHopPlatformVisualSize { return { width: clamp(platform.width * 0.96, 58, 118) * scale * JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER, height: clamp(platform.height * 0.78, 48, 92) * scale * JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER, }; } function getJumpHopCurrentTargetPlatforms( run: JumpHopRuntimeRunSnapshotResponse | null, platforms: JumpHopVisiblePlatform[], ) { if (!run) { return null; } const currentIndex = run.currentPlatformIndex; const currentPlatform = platforms.find((item) => item.index === currentIndex) ?? platforms[0] ?? null; const targetPlatform = platforms.find((item) => item.index === currentIndex + 1) ?? platforms[1] ?? null; if (!currentPlatform || !targetPlatform) { return null; } return { currentPlatform, targetPlatform, }; } function getJumpHopCanvasPosition( platform: JumpHopVisiblePlatform, stageSize: JumpHopCanvasSize, ) { return { x: (platform.screenX / 100) * stageSize.width, y: (platform.screenY / 100) * stageSize.height, }; } function getJumpHopScreenWorldScales( currentPlatform: JumpHopVisiblePlatform, targetPlatform: JumpHopVisiblePlatform, stageSize: JumpHopCanvasSize, ) { const currentCanvasPosition = getJumpHopCanvasPosition( currentPlatform, stageSize, ); const targetCanvasPosition = getJumpHopCanvasPosition( targetPlatform, stageSize, ); const targetWorldDeltaX = targetPlatform.platform.x - currentPlatform.platform.x; const targetWorldDeltaY = targetPlatform.platform.y - currentPlatform.platform.y; const targetScreenDeltaX = targetCanvasPosition.x - currentCanvasPosition.x; const targetScreenDeltaY = targetCanvasPosition.y - currentCanvasPosition.y; const targetWorldDistance = Math.hypot(targetWorldDeltaX, targetWorldDeltaY); const targetScreenDistance = Math.hypot( targetScreenDeltaX, targetScreenDeltaY, ); const fallbackPixelsPerWorldUnit = targetWorldDistance > 0.0001 && targetScreenDistance > 0.0001 ? targetScreenDistance / targetWorldDistance : stageSize.height * 0.18; const xPixelsPerWorldUnit = Math.abs(targetWorldDeltaX) > 0.0001 && Math.abs(targetScreenDeltaX) > 0.0001 ? Math.abs(targetScreenDeltaX / targetWorldDeltaX) : Math.max(stageSize.width * (JUMP_HOP_SCREEN_X_WORLD_PERCENT / 100), 1); const yPixelsPerWorldUnit = Math.abs(targetWorldDeltaY) > 0.0001 && Math.abs(targetScreenDeltaY) > 0.0001 ? Math.abs(targetScreenDeltaY / targetWorldDeltaY) : fallbackPixelsPerWorldUnit; const signedXScreenPerWorld = Math.abs(targetWorldDeltaX) > 0.0001 && Math.abs(targetScreenDeltaX) > 0.0001 ? targetScreenDeltaX / targetWorldDeltaX : xPixelsPerWorldUnit; const signedYScreenPerWorld = Math.abs(targetWorldDeltaY) > 0.0001 && Math.abs(targetScreenDeltaY) > 0.0001 ? targetScreenDeltaY / targetWorldDeltaY : -yPixelsPerWorldUnit; return { currentCanvasPosition, targetPlatform, xPixelsPerWorldUnit, yPixelsPerWorldUnit: Math.max(yPixelsPerWorldUnit, 1), signedXScreenPerWorld, signedYScreenPerWorld, }; } export function getJumpHopBackendDragVector( run: JumpHopRuntimeRunSnapshotResponse | null, platforms: JumpHopVisiblePlatform[], stageSize: JumpHopCanvasSize, dragVectorX: number, dragVectorY: number, ): JumpHopBackendDragVector { const pair = getJumpHopCurrentTargetPlatforms(run, platforms); if (!pair || stageSize.width <= 0 || stageSize.height <= 0) { return { dragVectorX, dragVectorY, }; } const scales = getJumpHopScreenWorldScales( pair.currentPlatform, pair.targetPlatform, stageSize, ); return { dragVectorX: dragVectorX / scales.xPixelsPerWorldUnit, dragVectorY: dragVectorY / scales.yPixelsPerWorldUnit, }; } export function getJumpHopLandingAssistVisualPosition( run: JumpHopRuntimeRunSnapshotResponse | null, platforms: JumpHopVisiblePlatform[], characterPosition: JumpHopCharacterVisualPosition | null, stageSize: JumpHopCanvasSize, dragDistance: number, dragVectorX: number | null, dragVectorY: number | null, ) { if ( !run || run.status !== 'playing' || !characterPosition || stageSize.width <= 0 || stageSize.height <= 0 || dragDistance <= 0 ) { return null; } const pair = getJumpHopCurrentTargetPlatforms(run, platforms); if (!pair) { return null; } const { currentPlatform, targetPlatform } = pair; const dragX = dragVectorX ?? 0; const dragY = dragVectorY ?? 0; const dragLength = Math.hypot(dragX, dragY); if (dragLength < 0.0001) { return null; } const scales = getJumpHopScreenWorldScales( currentPlatform, targetPlatform, stageSize, ); const backendDragVector = getJumpHopBackendDragVector( run, platforms, stageSize, dragX, dragY, ); const jumpWorldX = -backendDragVector.dragVectorX; const jumpWorldY = backendDragVector.dragVectorY; const jumpWorldLength = Math.hypot(jumpWorldX, jumpWorldY); if (jumpWorldLength < 0.0001) { return null; } const maxDragDistance = run.path.scoring.maxChargeMs > 0 ? run.path.scoring.maxChargeMs : 180; const chargeToDistanceRatio = run.path.scoring.chargeToDistanceRatio > 0 ? run.path.scoring.chargeToDistanceRatio : 0.008; const projectedWorldDistance = clamp(dragDistance, 0, maxDragDistance) * chargeToDistanceRatio; const landedWorldDeltaX = (jumpWorldX / jumpWorldLength) * projectedWorldDistance; const landedWorldDeltaY = (jumpWorldY / jumpWorldLength) * projectedWorldDistance; const landedPixelX = scales.currentCanvasPosition.x + landedWorldDeltaX * scales.signedXScreenPerWorld; const landedPixelY = scales.currentCanvasPosition.y + landedWorldDeltaY * scales.signedYScreenPerWorld; return { screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94), screenY: clamp((landedPixelY / stageSize.height) * 100, 10, 92), targetPlatformIndex: targetPlatform.index, }; } export function resolveJumpHopCharacterCanvasPosition( characterPosition: JumpHopCharacterVisualPosition | null, size: JumpHopCanvasSize, ) { if (!characterPosition) { return null; } return { x: (characterPosition.screenX / 100) * size.width, y: (characterPosition.screenY / 100) * size.height, }; } export function getJumpHopCharacterVisualPosition( run: JumpHopRuntimeRunSnapshotResponse | null, platforms: JumpHopVisiblePlatform[], ) { if (!run) { return null; } const landedPlatform = platforms.find( (item) => item.index === run.currentPlatformIndex, ); if (landedPlatform) { return { screenX: landedPlatform.screenX, screenY: landedPlatform.screenY - 3, sceneX: landedPlatform.sceneX, sceneY: landedPlatform.sceneY + 0.84, sceneZ: landedPlatform.sceneZ, isMiss: false, }; } const lastJump = run.lastJump; if (lastJump && run.status === 'failed') { const targetPlatform = platforms.find( (item) => item.index === lastJump.targetPlatformIndex, ); if (targetPlatform) { return { screenX: targetPlatform.screenX + 8, screenY: targetPlatform.screenY - 2, sceneX: targetPlatform.sceneX + 0.7, sceneY: targetPlatform.sceneY + 0.48, sceneZ: targetPlatform.sceneZ - 0.4, isMiss: true, }; } } return null; } export function getJumpHopRunDurationMs( run: JumpHopRuntimeRunSnapshotResponse | null, nowMs: number, ) { if (!run) { return 0; } if (run.status === 'playing' && run.startedAtMs > 0) { return Math.max(0, nowMs - run.startedAtMs); } return run.durationMs; } export function formatJumpHopDurationLabel(durationMs: number) { const safeDuration = Math.max(0, Math.floor(durationMs)); const totalSeconds = Math.floor(safeDuration / 1000); const minutes = Math.floor(totalSeconds / 60) .toString() .padStart(2, '0'); const seconds = (totalSeconds % 60).toString().padStart(2, '0'); return `${minutes}:${seconds}`; } export function getJumpHopStatusLabel( status: JumpHopRunStatus | undefined, ) { if (status === 'cleared') { return '结束'; } if (status === 'failed') { return '失败'; } return '进行中'; } export function getJumpHopJumpFeedbackLabel( run: JumpHopRuntimeRunSnapshotResponse | null, ) { const result = run?.lastJump?.result; if (result === 'perfect') { return '落地'; } if (result === 'finish') { return '落地'; } if (result === 'hit') { return '落地'; } if (result === 'miss') { return '落空'; } return null; } export function getJumpHopTileTone(tileType: JumpHopTileType) { return tileToneByType[tileType]; }