import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera' import { AccelerometerController } from '../sensor/accelerometerController' import { CompassHeadingController, type CompassTuningProfile } from '../sensor/compassHeadingController' import { DeviceMotionController } from '../sensor/deviceMotionController' import { GyroscopeController } from '../sensor/gyroscopeController' import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController' import { HeartRateInputController } from '../sensor/heartRateInputController' import { LocationController } from '../sensor/locationController' import { WebGLMapRenderer } from '../renderer/webglMapRenderer' import { type MapRendererStats } from '../renderer/mapRenderer' import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig' import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel' import { GameRuntime } from '../../game/core/gameRuntime' import { type GameControlDisplayContentOverride } from '../../game/core/gameDefinition' import { type GameEffect, type GameResult } from '../../game/core/gameResult' import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition' import { FeedbackDirector } from '../../game/feedback/feedbackDirector' import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState' import { buildResultSummarySnapshot, type ResultSummarySnapshot } from '../../game/result/resultSummary' import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime' import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig' const RENDER_MODE = 'Single WebGL Pipeline' const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen' const MAP_NORTH_OFFSET_DEG = 0 let MAGNETIC_DECLINATION_DEG = -6.91 let MAGNETIC_DECLINATION_TEXT = '6.91˚ W' const MIN_ZOOM = 15 const MAX_ZOOM = 20 const DEFAULT_ZOOM = 17 const DESIRED_VISIBLE_COLUMNS = 3 const OVERDRAW = 1 const DEFAULT_TOP_LEFT_TILE_X = 108132 const DEFAULT_TOP_LEFT_TILE_Y = 51199 const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1 const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1 const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png' const OSM_TILE_SOURCE = 'https://tiles.mymarsgo.xyz/{z}/{x}/{y}.png' const MAP_OVERLAY_OPACITY = 0.72 const GPS_MAP_CALIBRATION: MapCalibration = { offsetEastMeters: 0, offsetNorthMeters: 0, rotationDeg: 0, scale: 1, } const MIN_PREVIEW_SCALE = 0.55 const MAX_PREVIEW_SCALE = 1.85 const INERTIA_FRAME_MS = 16 const INERTIA_DECAY = 0.92 const INERTIA_MIN_SPEED = 0.02 const PREVIEW_RESET_DURATION_MS = 140 const UI_SYNC_INTERVAL_MS = 80 const ROTATE_STEP_DEG = 15 const AUTO_ROTATE_FRAME_MS = 8 const AUTO_ROTATE_EASE = 0.34 const AUTO_ROTATE_SNAP_DEG = 0.1 const AUTO_ROTATE_DEADZONE_DEG = 4 const AUTO_ROTATE_MAX_STEP_DEG = 0.75 const AUTO_ROTATE_HEADING_SMOOTHING = 0.46 const COMPASS_NEEDLE_FRAME_MS = 16 const COMPASS_NEEDLE_SNAP_DEG = 0.08 const COMPASS_BOOTSTRAP_RETRY_DELAY_MS = 700 const COMPASS_TUNING_PRESETS: Record = { smooth: { needleMinSmoothing: 0.16, needleMaxSmoothing: 0.4, displayDeadzoneDeg: 0.75, }, balanced: { needleMinSmoothing: 0.22, needleMaxSmoothing: 0.52, displayDeadzoneDeg: 0.45, }, responsive: { needleMinSmoothing: 0.3, needleMaxSmoothing: 0.68, displayDeadzoneDeg: 0.2, }, } const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2 const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0 const SMART_HEADING_MIN_DISTANCE_METERS = 12 const SMART_HEADING_MAX_ACCURACY_METERS = 25 const SMART_HEADING_MOVEMENT_MIN_SMOOTHING = 0.12 const SMART_HEADING_MOVEMENT_MAX_SMOOTHING = 0.24 const GPS_TRACK_MAX_POINTS = 200 const GPS_TRACK_MIN_STEP_METERS = 3 const MAP_TAP_MOVE_THRESHOLD_PX = 14 const MAP_TAP_DURATION_MS = 280 type TouchPoint = WechatMiniprogram.TouchDetail type GestureMode = 'idle' | 'pan' | 'pinch' type RotationMode = 'manual' | 'auto' type OrientationMode = 'manual' | 'north-up' | 'heading-up' type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion' | 'smart' type SmartHeadingSource = 'sensor' | 'blended' | 'movement' type NorthReferenceMode = 'magnetic' | 'true' const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic' export interface MapEngineStageRect { width: number height: number left: number top: number } export interface MapEngineViewState { animationLevel: AnimationLevel buildVersion: string renderMode: string projectionMode: string mapReady: boolean mapReadyText: string mapName: string configStatusText: string zoom: number rotationDeg: number rotationText: string rotationMode: RotationMode rotationModeText: string rotationToggleText: string orientationMode: OrientationMode orientationModeText: string sensorHeadingText: string deviceHeadingText: string devicePoseText: string headingConfidenceText: string accelerometerText: string gyroscopeText: string deviceMotionText: string compassSourceText: string compassTuningProfile: CompassTuningProfile compassTuningProfileText: string compassDeclinationText: string northReferenceMode: NorthReferenceMode northReferenceButtonText: string autoRotateSourceText: string autoRotateCalibrationText: string northReferenceText: string compassNeedleDeg: number centerTileX: number centerTileY: number centerText: string tileSource: string visibleColumnCount: number visibleTileCount: number readyTileCount: number memoryTileCount: number diskTileCount: number memoryHitCount: number diskHitCount: number networkFetchCount: number cacheHitRateText: string tileTranslateX: number tileTranslateY: number tileSizePx: number previewScale: number stageWidth: number stageHeight: number stageLeft: number stageTop: number statusText: string gpsTracking: boolean gpsTrackingText: string gpsLockEnabled: boolean gpsLockAvailable: boolean locationSourceMode: 'real' | 'mock' locationSourceText: string mockBridgeConnected: boolean mockBridgeStatusText: string mockBridgeUrlText: string mockCoordText: string mockSpeedText: string gpsCoordText: string heartRateSourceMode: 'real' | 'mock' heartRateSourceText: string heartRateConnected: boolean heartRateStatusText: string heartRateDeviceText: string heartRateScanText: string heartRateDiscoveredDevices: Array<{ deviceId: string name: string rssiText: string preferred: boolean connected: boolean }> mockHeartRateBridgeConnected: boolean mockHeartRateBridgeStatusText: string mockHeartRateBridgeUrlText: string mockHeartRateText: string gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed' gameModeText: string panelTimerText: string panelMileageText: string panelActionTagText: string panelDistanceTagText: string panelDistanceValueText: string panelDistanceUnitText: string panelProgressText: string panelSpeedValueText: string panelTelemetryTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red' panelHeartRateZoneNameText: string panelHeartRateZoneRangeText: string panelHeartRateValueText: string panelHeartRateUnitText: string panelCaloriesValueText: string panelCaloriesUnitText: string panelAverageSpeedValueText: string panelAverageSpeedUnitText: string panelAccuracyValueText: string panelAccuracyUnitText: string punchButtonText: string punchButtonEnabled: boolean skipButtonEnabled: boolean punchHintText: string punchFeedbackVisible: boolean punchFeedbackText: string punchFeedbackTone: 'neutral' | 'success' | 'warning' contentCardVisible: boolean contentCardTitle: string contentCardBody: string punchButtonFxClass: string panelProgressFxClass: string panelDistanceFxClass: string punchFeedbackFxClass: string contentCardFxClass: string mapPulseVisible: boolean mapPulseLeftPx: number mapPulseTopPx: number mapPulseFxClass: string stageFxVisible: boolean stageFxClass: string osmReferenceEnabled: boolean osmReferenceText: string } export interface MapEngineCallbacks { onData: (patch: Partial) => void } export interface MapEngineGameInfoRow { label: string value: string } export interface MapEngineGameInfoSnapshot { title: string subtitle: string localRows: MapEngineGameInfoRow[] globalRows: MapEngineGameInfoRow[] } export type MapEngineResultSnapshot = ResultSummarySnapshot const VIEW_SYNC_KEYS: Array = [ 'animationLevel', 'buildVersion', 'renderMode', 'projectionMode', 'mapReady', 'mapReadyText', 'mapName', 'configStatusText', 'zoom', 'centerTileX', 'centerTileY', 'rotationDeg', 'rotationText', 'rotationMode', 'rotationModeText', 'rotationToggleText', 'orientationMode', 'orientationModeText', 'sensorHeadingText', 'deviceHeadingText', 'devicePoseText', 'headingConfidenceText', 'accelerometerText', 'gyroscopeText', 'deviceMotionText', 'compassSourceText', 'compassTuningProfile', 'compassTuningProfileText', 'compassDeclinationText', 'northReferenceMode', 'northReferenceButtonText', 'autoRotateSourceText', 'autoRotateCalibrationText', 'northReferenceText', 'compassNeedleDeg', 'centerText', 'tileSource', 'visibleTileCount', 'readyTileCount', 'memoryTileCount', 'diskTileCount', 'memoryHitCount', 'diskHitCount', 'networkFetchCount', 'cacheHitRateText', 'tileSizePx', 'previewScale', 'stageWidth', 'stageHeight', 'stageLeft', 'stageTop', 'statusText', 'gpsTracking', 'gpsTrackingText', 'gpsLockEnabled', 'gpsLockAvailable', 'locationSourceMode', 'locationSourceText', 'mockBridgeConnected', 'mockBridgeStatusText', 'mockBridgeUrlText', 'mockCoordText', 'mockSpeedText', 'gpsCoordText', 'heartRateSourceMode', 'heartRateSourceText', 'heartRateConnected', 'heartRateStatusText', 'heartRateDeviceText', 'heartRateScanText', 'heartRateDiscoveredDevices', 'mockHeartRateBridgeConnected', 'mockHeartRateBridgeStatusText', 'mockHeartRateBridgeUrlText', 'mockHeartRateText', 'gameSessionStatus', 'gameModeText', 'panelTimerText', 'panelMileageText', 'panelActionTagText', 'panelDistanceTagText', 'panelDistanceValueText', 'panelDistanceUnitText', 'panelProgressText', 'panelSpeedValueText', 'panelTelemetryTone', 'panelHeartRateZoneNameText', 'panelHeartRateZoneRangeText', 'panelHeartRateValueText', 'panelHeartRateUnitText', 'panelCaloriesValueText', 'panelCaloriesUnitText', 'panelAverageSpeedValueText', 'panelAverageSpeedUnitText', 'panelAccuracyValueText', 'panelAccuracyUnitText', 'punchButtonText', 'punchButtonEnabled', 'skipButtonEnabled', 'punchHintText', 'punchFeedbackVisible', 'punchFeedbackText', 'punchFeedbackTone', 'contentCardVisible', 'contentCardTitle', 'contentCardBody', 'punchButtonFxClass', 'panelProgressFxClass', 'panelDistanceFxClass', 'punchFeedbackFxClass', 'contentCardFxClass', 'mapPulseVisible', 'mapPulseLeftPx', 'mapPulseTopPx', 'mapPulseFxClass', 'stageFxVisible', 'stageFxClass', 'osmReferenceEnabled', 'osmReferenceText', ] const INTERACTION_DEFERRED_VIEW_KEYS = new Set([ 'rotationText', 'sensorHeadingText', 'deviceHeadingText', 'devicePoseText', 'headingConfidenceText', 'accelerometerText', 'gyroscopeText', 'deviceMotionText', 'compassSourceText', 'compassTuningProfile', 'compassTuningProfileText', 'compassDeclinationText', 'autoRotateSourceText', 'autoRotateCalibrationText', 'northReferenceText', 'centerText', 'gpsCoordText', 'visibleTileCount', 'readyTileCount', 'memoryTileCount', 'diskTileCount', 'memoryHitCount', 'diskHitCount', 'networkFetchCount', 'cacheHitRateText', 'heartRateDiscoveredDevices', 'mockCoordText', 'mockSpeedText', 'mockHeartRateText', ]) function buildCenterText(zoom: number, x: number, y: number): string { return `z${zoom} / x${x} / y${y}` } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) } function normalizeRotationDeg(rotationDeg: number): number { const normalized = rotationDeg % 360 return normalized < 0 ? normalized + 360 : normalized } function normalizeAngleDeltaRad(angleDeltaRad: number): number { let normalized = angleDeltaRad while (normalized > Math.PI) { normalized -= Math.PI * 2 } while (normalized < -Math.PI) { normalized += Math.PI * 2 } return normalized } function normalizeAngleDeltaDeg(angleDeltaDeg: number): number { let normalized = angleDeltaDeg while (normalized > 180) { normalized -= 360 } while (normalized < -180) { normalized += 360 } return normalized } function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number { return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor) } function getCompassNeedleSmoothingFactor( currentDeg: number, targetDeg: number, profile: CompassTuningProfile, ): number { const preset = COMPASS_TUNING_PRESETS[profile] const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg)) if (deltaDeg <= 4) { return preset.needleMinSmoothing } if (deltaDeg >= 36) { return preset.needleMaxSmoothing } const progress = (deltaDeg - 4) / (36 - 4) return preset.needleMinSmoothing + (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress } function getMovementHeadingSmoothingFactor(speedKmh: number | null): number { if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) { return SMART_HEADING_MOVEMENT_MIN_SMOOTHING } if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) { return SMART_HEADING_MOVEMENT_MAX_SMOOTHING } const progress = (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH) / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH) return SMART_HEADING_MOVEMENT_MIN_SMOOTHING + (SMART_HEADING_MOVEMENT_MAX_SMOOTHING - SMART_HEADING_MOVEMENT_MIN_SMOOTHING) * progress } function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string { if (status === 'running') { return '进行中' } if (status === 'finished') { return '已结束' } if (status === 'failed') { return '已失败' } return '未开始' } function formatRotationText(rotationDeg: number): string { return `${Math.round(normalizeRotationDeg(rotationDeg))}deg` } function normalizeDegreeDisplayText(text: string): string { return text.replace(/[掳•˚]/g, '°') } function formatHeadingText(headingDeg: number | null): string { if (headingDeg === null) { return '--' } return `${Math.round(normalizeRotationDeg(headingDeg))}°` } function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string { if (pose === 'flat') { return '平放' } if (pose === 'tilted') { return '倾斜' } return '竖持' } function formatHeadingConfidenceText(confidence: 'low' | 'medium' | 'high'): string { if (confidence === 'high') { return '高' } if (confidence === 'medium') { return '中' } return '低' } function formatClockTime(timestamp: number | null): string { if (!timestamp || !Number.isFinite(timestamp)) { return '--:--:--' } const date = new Date(timestamp) const hh = String(date.getHours()).padStart(2, '0') const mm = String(date.getMinutes()).padStart(2, '0') const ss = String(date.getSeconds()).padStart(2, '0') return `${hh}:${mm}:${ss}` } function formatGyroscopeText(gyroscope: { x: number; y: number; z: number } | null): string { if (!gyroscope) { return '--' } return `x:${gyroscope.x.toFixed(2)} y:${gyroscope.y.toFixed(2)} z:${gyroscope.z.toFixed(2)}` } function formatDeviceMotionText(motion: { alpha: number | null; beta: number | null; gamma: number | null } | null): string { if (!motion) { return '--' } const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha)) const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta) const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma) return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}` } function formatOrientationModeText(mode: OrientationMode): string { if (mode === 'north-up') { return 'North Up' } if (mode === 'heading-up') { return 'Heading Up' } return 'Manual Gesture' } function formatRotationModeText(mode: OrientationMode): string { return formatOrientationModeText(mode) } function formatRotationToggleText(mode: OrientationMode): string { if (mode === 'manual') { return '切到北朝上' } if (mode === 'north-up') { return '切到朝向朝上' } return '切到手动旋转' } function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string { if (mode === 'smart') { return 'Smart / 手机朝向' } if (mode === 'sensor') { return 'Sensor Only' } if (mode === 'course') { return hasCourseHeading ? 'Course Only' : 'Course Pending' } return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only' } function formatSmartHeadingSourceText(source: SmartHeadingSource): string { if (source === 'movement') { return 'Smart / 前进方向' } if (source === 'blended') { return 'Smart / 融合' } return 'Smart / 手机朝向' } function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string { if (pending) { return 'Pending' } if (offsetDeg === null) { return '--' } return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg` } function getTrueHeadingDeg(magneticHeadingDeg: number): number { return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG) } function getMagneticHeadingDeg(trueHeadingDeg: number): number { return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG) } function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number { return MAP_NORTH_OFFSET_DEG } function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number { if (mode === 'true') { return getTrueHeadingDeg(magneticHeadingDeg) } return normalizeRotationDeg(magneticHeadingDeg) } function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number { if (mode === 'magnetic') { return normalizeRotationDeg(magneticHeadingDeg) } return getTrueHeadingDeg(magneticHeadingDeg) } function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number { if (mode === 'magnetic') { return getMagneticHeadingDeg(trueHeadingDeg) } return normalizeRotationDeg(trueHeadingDeg) } function formatNorthReferenceText(mode: NorthReferenceMode): string { if (mode === 'magnetic') { return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})` } return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})` } function formatCompassDeclinationText(mode: NorthReferenceMode): string { if (mode === 'true') { return MAGNETIC_DECLINATION_TEXT } return '' } function formatCompassSourceText(source: 'compass' | 'motion' | null): string { if (source === 'compass') { return '罗盘' } if (source === 'motion') { return '设备方向兜底' } return '无数据' } function formatCompassTuningProfileText(profile: CompassTuningProfile): string { if (profile === 'smooth') { return '顺滑' } if (profile === 'responsive') { return '跟手' } return '平衡' } function formatNorthReferenceButtonText(mode: NorthReferenceMode): string { return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北' } function formatNorthReferenceStatusText(mode: NorthReferenceMode): string { if (mode === 'magnetic') { return '已切到磁北模式' } return '已切到真北模式' } function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode { return mode === 'magnetic' ? 'true' : 'magnetic' } function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number { if (magneticHeadingDeg === null) { return 0 } const referenceHeadingDeg = mode === 'true' ? getTrueHeadingDeg(magneticHeadingDeg) : normalizeRotationDeg(magneticHeadingDeg) return normalizeRotationDeg(360 - referenceHeadingDeg) } function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string { const total = memoryHitCount + diskHitCount + networkFetchCount if (!total) { return '--' } const hitRate = ((memoryHitCount + diskHitCount) / total) * 100 return `${Math.round(hitRate)}%` } function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string { if (!point) { return '--' } const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}` if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) { return base } return `${base} / 卤${Math.round(accuracyMeters)}m` } function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number { const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180 const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad) const dy = (b.lat - a.lat) * 110540 return Math.sqrt(dx * dx + dy * dy) } function resolveSmartHeadingSource(speedKmh: number | null, movementReliable: boolean): SmartHeadingSource { if (!movementReliable || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) { return 'sensor' } if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) { return 'movement' } return 'blended' } function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number { const fromLatRad = from.lat * Math.PI / 180 const toLatRad = to.lat * Math.PI / 180 const deltaLonRad = (to.lon - from.lon) * Math.PI / 180 const y = Math.sin(deltaLonRad) * Math.cos(toLatRad) const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad) const bearingDeg = Math.atan2(y, x) * 180 / Math.PI return normalizeRotationDeg(bearingDeg) } export class MapEngine { buildVersion: string animationLevel: AnimationLevel renderer: WebGLMapRenderer accelerometerController: AccelerometerController compassController: CompassHeadingController gyroscopeController: GyroscopeController deviceMotionController: DeviceMotionController locationController: LocationController heartRateController: HeartRateInputController feedbackDirector: FeedbackDirector onData: (patch: Partial) => void state: MapEngineViewState accelerometerErrorText: string | null previewScale: number previewOriginX: number previewOriginY: number panLastX: number panLastY: number panLastTimestamp: number tapStartX: number tapStartY: number tapStartAt: number panVelocityX: number panVelocityY: number pinchStartDistance: number pinchStartScale: number pinchStartAngle: number pinchStartRotationDeg: number pinchAnchorWorldX: number pinchAnchorWorldY: number gestureMode: GestureMode inertiaTimer: number previewResetTimer: number viewSyncTimer: number autoRotateTimer: number compassNeedleTimer: number compassBootstrapRetryTimer: number pendingViewPatch: Partial mounted: boolean diagnosticUiEnabled: boolean northReferenceMode: NorthReferenceMode sensorHeadingDeg: number | null smoothedSensorHeadingDeg: number | null compassDisplayHeadingDeg: number | null targetCompassDisplayHeadingDeg: number | null lastCompassSampleAt: number compassSource: 'compass' | 'motion' | null compassTuningProfile: CompassTuningProfile smoothedMovementHeadingDeg: number | null autoRotateHeadingDeg: number | null courseHeadingDeg: number | null targetAutoRotationDeg: number | null autoRotateSourceMode: AutoRotateSourceMode autoRotateCalibrationOffsetDeg: number | null autoRotateCalibrationPending: boolean lastStatsUiSyncAt: number minZoom: number maxZoom: number defaultZoom: number defaultCenterTileX: number defaultCenterTileY: number tileBoundsByZoom: Record | null currentGpsPoint: LonLatPoint | null currentGpsTrack: LonLatPoint[] currentGpsAccuracyMeters: number | null currentGpsInsideMap: boolean courseData: OrienteeringCourseData | null courseOverlayVisible: boolean cpRadiusMeters: number configAppId: string configSchemaVersion: string configVersion: string controlScoreOverrides: Record controlContentOverrides: Record defaultControlScore: number | null gameRuntime: GameRuntime telemetryRuntime: TelemetryRuntime gamePresentation: GamePresentationState gameMode: 'classic-sequential' | 'score-o' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number requiresFocusSelection: boolean skipEnabled: boolean skipRadiusMeters: number skipRequiresConfirm: boolean autoFinishOnLastControl: boolean punchFeedbackTimer: number contentCardTimer: number currentContentCardPriority: number shownContentCardKeys: Record mapPulseTimer: number stageFxTimer: number sessionTimerInterval: number hasGpsCenteredOnce: boolean gpsLockEnabled: boolean constructor(buildVersion: string, callbacks: MapEngineCallbacks) { this.buildVersion = buildVersion this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync()) this.compassTuningProfile = 'balanced' this.onData = callbacks.onData this.accelerometerErrorText = null this.renderer = new WebGLMapRenderer( (stats) => { this.applyStats(stats) }, (message) => { this.setState({ statusText: `${message} (${this.buildVersion})`, }) }, ) this.accelerometerController = new AccelerometerController({ onSample: (x, y, z) => { this.accelerometerErrorText = null this.telemetryRuntime.dispatch({ type: 'accelerometer_updated', at: Date.now(), x, y, z, }) if (this.diagnosticUiEnabled) { this.setState(this.getTelemetrySensorViewPatch()) } }, onError: (message) => { this.accelerometerErrorText = `不可用: ${message}` if (this.diagnosticUiEnabled) { this.setState({ ...this.getTelemetrySensorViewPatch(), statusText: `加速度计启动失败 (${this.buildVersion})`, }) } }, }) this.compassController = new CompassHeadingController({ onHeading: (headingDeg) => { this.handleCompassHeading(headingDeg) }, onError: (message) => { this.handleCompassError(message) }, }) this.compassController.setTuningProfile(this.compassTuningProfile) this.gyroscopeController = new GyroscopeController({ onSample: (x, y, z) => { this.telemetryRuntime.dispatch({ type: 'gyroscope_updated', at: Date.now(), x, y, z, }) if (this.diagnosticUiEnabled) { this.setState(this.getTelemetrySensorViewPatch()) } }, onError: () => { if (this.diagnosticUiEnabled) { this.setState(this.getTelemetrySensorViewPatch()) } }, }) this.deviceMotionController = new DeviceMotionController({ onSample: (alpha, beta, gamma) => { this.telemetryRuntime.dispatch({ type: 'device_motion_updated', at: Date.now(), alpha, beta, gamma, }) if (this.diagnosticUiEnabled) { this.setState({ ...this.getTelemetrySensorViewPatch(), autoRotateSourceText: this.getAutoRotateSourceText(), }) } }, onError: () => { if (this.diagnosticUiEnabled) { this.setState(this.getTelemetrySensorViewPatch()) } }, }) this.locationController = new LocationController({ onLocation: (update) => { this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null) }, onStatus: (message) => { this.setState({ gpsTracking: this.locationController.listening, gpsTrackingText: message, ...this.getLocationControllerViewPatch(), }) }, onError: (message) => { this.setState({ gpsTracking: this.locationController.listening, gpsTrackingText: message, ...this.getLocationControllerViewPatch(), statusText: `${message} (${this.buildVersion})`, }) }, onDebugStateChange: () => { if (this.diagnosticUiEnabled) { this.setState(this.getLocationControllerViewPatch()) } }, }) this.heartRateController = new HeartRateInputController({ onHeartRate: (bpm) => { this.telemetryRuntime.dispatch({ type: 'heart_rate_updated', at: Date.now(), bpm, }) this.syncSessionTimerText() }, onStatus: (message) => { const deviceName = this.heartRateController.currentDeviceName || (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null) || '--' this.setState({ heartRateStatusText: message, heartRateDeviceText: deviceName, heartRateScanText: this.getHeartRateScanText(), ...this.getHeartRateControllerViewPatch(), }) }, onError: (message) => { this.clearHeartRateSignal() const deviceName = this.heartRateController.reconnecting ? (this.heartRateController.lastDeviceName || '--') : '--' this.setState({ heartRateConnected: false, heartRateStatusText: message, heartRateDeviceText: deviceName, heartRateScanText: this.getHeartRateScanText(), ...this.getHeartRateControllerViewPatch(), statusText: `${message} (${this.buildVersion})`, }) }, onConnectionChange: (connected, deviceName) => { if (!connected) { this.clearHeartRateSignal() } const resolvedDeviceName = connected ? (deviceName || '--') : (this.heartRateController.reconnecting ? (this.heartRateController.lastDeviceName || '--') : '--') this.setState({ heartRateConnected: connected, heartRateDeviceText: resolvedDeviceName, heartRateStatusText: connected ? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接') : (this.heartRateController.reconnecting ? '心率带自动重连中' : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接')), heartRateScanText: this.getHeartRateScanText(), heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices), ...this.getHeartRateControllerViewPatch(), }) }, onDeviceListChange: (devices) => { if (this.diagnosticUiEnabled) { this.setState({ heartRateDiscoveredDevices: this.formatHeartRateDevices(devices), heartRateScanText: this.getHeartRateScanText(), ...this.getHeartRateControllerViewPatch(), }) } }, onDebugStateChange: () => { if (this.diagnosticUiEnabled) { this.setState(this.getHeartRateControllerViewPatch()) } }, }) this.feedbackDirector = new FeedbackDirector({ showPunchFeedback: (text, tone, motionClass) => { this.showPunchFeedback(text, tone, motionClass) }, showContentCard: (title, body, motionClass, options) => { this.showContentCard(title, body, motionClass, options) }, setPunchButtonFxClass: (className) => { this.setPunchButtonFxClass(className) }, setHudProgressFxClass: (className) => { this.setHudProgressFxClass(className) }, setHudDistanceFxClass: (className) => { this.setHudDistanceFxClass(className) }, showMapPulse: (controlId, motionClass) => { this.showMapPulse(controlId, motionClass) }, showStageFx: (className) => { this.showStageFx(className) }, stopLocationTracking: () => { if (this.locationController.listening) { this.locationController.stop() } }, }) this.feedbackDirector.setAnimationLevel(this.animationLevel) this.minZoom = MIN_ZOOM this.maxZoom = MAX_ZOOM this.defaultZoom = DEFAULT_ZOOM this.defaultCenterTileX = DEFAULT_CENTER_TILE_X this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y this.tileBoundsByZoom = null this.currentGpsPoint = null this.currentGpsTrack = [] this.currentGpsAccuracyMeters = null this.currentGpsInsideMap = false this.courseData = null this.courseOverlayVisible = false this.cpRadiusMeters = 5 this.configAppId = '' this.configSchemaVersion = '1' this.configVersion = '' this.controlScoreOverrides = {} this.controlContentOverrides = {} this.defaultControlScore = null this.gameRuntime = new GameRuntime() this.telemetryRuntime = new TelemetryRuntime() this.telemetryRuntime.configure() this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE this.gameMode = 'classic-sequential' this.punchPolicy = 'enter-confirm' this.punchRadiusMeters = 5 this.requiresFocusSelection = false this.skipEnabled = false this.skipRadiusMeters = 30 this.skipRequiresConfirm = true this.autoFinishOnLastControl = true this.gpsLockEnabled = false this.punchFeedbackTimer = 0 this.contentCardTimer = 0 this.currentContentCardPriority = 0 this.shownContentCardKeys = {} this.mapPulseTimer = 0 this.stageFxTimer = 0 this.sessionTimerInterval = 0 this.hasGpsCenteredOnce = false this.state = { animationLevel: this.animationLevel, buildVersion: this.buildVersion, renderMode: RENDER_MODE, projectionMode: PROJECTION_MODE, mapReady: false, mapReadyText: 'BOOTING', mapName: '未命名配置', configStatusText: '远程配置待加载', zoom: DEFAULT_ZOOM, rotationDeg: 0, rotationText: formatRotationText(0), rotationMode: 'manual', rotationModeText: formatRotationModeText('manual'), rotationToggleText: formatRotationToggleText('manual'), orientationMode: 'manual', orientationModeText: formatOrientationModeText('manual'), sensorHeadingText: '--', deviceHeadingText: '--', devicePoseText: '竖持', headingConfidenceText: '低', accelerometerText: '未启用', gyroscopeText: '--', deviceMotionText: '--', compassSourceText: '无数据', compassTuningProfile: this.compassTuningProfile, compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile), compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE), northReferenceMode: DEFAULT_NORTH_REFERENCE_MODE, northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE), autoRotateSourceText: formatAutoRotateSourceText('smart', false), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)), northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE), compassNeedleDeg: 0, centerTileX: DEFAULT_CENTER_TILE_X, centerTileY: DEFAULT_CENTER_TILE_Y, centerText: buildCenterText(DEFAULT_ZOOM, DEFAULT_CENTER_TILE_X, DEFAULT_CENTER_TILE_Y), tileSource: TILE_SOURCE, visibleColumnCount: DESIRED_VISIBLE_COLUMNS, visibleTileCount: 0, readyTileCount: 0, memoryTileCount: 0, diskTileCount: 0, memoryHitCount: 0, diskHitCount: 0, networkFetchCount: 0, cacheHitRateText: '--', tileTranslateX: 0, tileTranslateY: 0, tileSizePx: 0, previewScale: 1, stageWidth: 0, stageHeight: 0, stageLeft: 0, stageTop: 0, statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`, gpsTracking: false, gpsTrackingText: '持续定位待启动', gpsLockEnabled: false, gpsLockAvailable: false, locationSourceMode: 'real', locationSourceText: '真实定位', mockBridgeConnected: false, mockBridgeStatusText: '未连接', mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', mockCoordText: '--', mockSpeedText: '--', gpsCoordText: '--', heartRateSourceMode: 'real', heartRateSourceText: '真实心率', heartRateConnected: false, heartRateStatusText: '心率带未连接', heartRateDeviceText: '--', heartRateScanText: '未扫描', heartRateDiscoveredDevices: [], mockHeartRateBridgeConnected: false, mockHeartRateBridgeStatusText: '未连接', mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', mockHeartRateText: '--', panelTimerText: '00:00:00', panelMileageText: '0m', panelActionTagText: '目标', panelDistanceTagText: '点距', panelDistanceValueText: '--', panelDistanceUnitText: '', panelProgressText: '0/0', panelSpeedValueText: '0', panelTelemetryTone: 'blue', panelHeartRateZoneNameText: '激活放松', panelHeartRateZoneRangeText: '<=39%', panelHeartRateValueText: '--', panelHeartRateUnitText: '', panelCaloriesValueText: '0', panelCaloriesUnitText: 'kcal', panelAverageSpeedValueText: '0', panelAverageSpeedUnitText: 'km/h', panelAccuracyValueText: '--', panelAccuracyUnitText: '', punchButtonText: '打点', gameSessionStatus: 'idle', gameModeText: '顺序赛', punchButtonEnabled: false, skipButtonEnabled: false, punchHintText: '等待进入检查点范围', punchFeedbackVisible: false, punchFeedbackText: '', punchFeedbackTone: 'neutral', contentCardVisible: false, contentCardTitle: '', contentCardBody: '', punchButtonFxClass: '', panelProgressFxClass: '', panelDistanceFxClass: '', punchFeedbackFxClass: '', contentCardFxClass: '', mapPulseVisible: false, mapPulseLeftPx: 0, mapPulseTopPx: 0, mapPulseFxClass: '', stageFxVisible: false, stageFxClass: '', osmReferenceEnabled: false, osmReferenceText: 'OSM参考:关', } this.previewScale = 1 this.previewOriginX = 0 this.previewOriginY = 0 this.panLastX = 0 this.panLastY = 0 this.panLastTimestamp = 0 this.tapStartX = 0 this.tapStartY = 0 this.tapStartAt = 0 this.panVelocityX = 0 this.panVelocityY = 0 this.pinchStartDistance = 0 this.pinchStartScale = 1 this.pinchStartAngle = 0 this.pinchStartRotationDeg = 0 this.pinchAnchorWorldX = 0 this.pinchAnchorWorldY = 0 this.gestureMode = 'idle' this.inertiaTimer = 0 this.previewResetTimer = 0 this.viewSyncTimer = 0 this.autoRotateTimer = 0 this.compassNeedleTimer = 0 this.compassBootstrapRetryTimer = 0 this.pendingViewPatch = {} this.mounted = false this.diagnosticUiEnabled = false this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE this.sensorHeadingDeg = null this.smoothedSensorHeadingDeg = null this.compassDisplayHeadingDeg = null this.targetCompassDisplayHeadingDeg = null this.lastCompassSampleAt = 0 this.compassSource = null this.compassTuningProfile = 'balanced' this.smoothedMovementHeadingDeg = null this.autoRotateHeadingDeg = null this.courseHeadingDeg = null this.targetAutoRotationDeg = null this.autoRotateSourceMode = 'smart' this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE) this.autoRotateCalibrationPending = false this.lastStatsUiSyncAt = 0 } getInitialData(): MapEngineViewState { return { ...this.state } } setDiagnosticUiEnabled(enabled: boolean): void { if (this.diagnosticUiEnabled === enabled) { return } this.diagnosticUiEnabled = enabled if (!enabled) { return } this.setState({ ...this.getTelemetrySensorViewPatch(), ...this.getLocationControllerViewPatch(), ...this.getHeartRateControllerViewPatch(), heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices), autoRotateSourceText: this.getAutoRotateSourceText(), visibleTileCount: this.state.visibleTileCount, readyTileCount: this.state.readyTileCount, memoryTileCount: this.state.memoryTileCount, diskTileCount: this.state.diskTileCount, memoryHitCount: this.state.memoryHitCount, diskHitCount: this.state.diskHitCount, networkFetchCount: this.state.networkFetchCount, cacheHitRateText: this.state.cacheHitRateText, }, true) } getGameInfoSnapshot(): MapEngineGameInfoSnapshot { const definition = this.gameRuntime.definition const sessionState = this.gameRuntime.state const telemetryState = this.telemetryRuntime.state const telemetryPresentation = this.telemetryRuntime.getPresentation() const currentTarget = definition && sessionState ? definition.controls.find((control) => control.id === sessionState.currentTargetControlId) || null : null const currentTargetText = currentTarget ? `${currentTarget.label} / ${currentTarget.kind === 'start' ? '开始点' : currentTarget.kind === 'finish' ? '结束点' : '检查点'}` : '--' const title = this.state.mapName || (definition ? definition.title : '当前游戏') const subtitle = `${this.getGameModeText()} / ${formatGameSessionStatusText(this.state.gameSessionStatus)}` const localRows: MapEngineGameInfoRow[] = [ { label: '比赛名称', value: title || '--' }, { label: '配置版本', value: this.configVersion || '--' }, { label: 'Schema版本', value: this.configSchemaVersion || '--' }, { label: '活动ID', value: this.configAppId || '--' }, { label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) }, { label: '地图', value: this.state.mapName || '--' }, { label: '模式', value: this.getGameModeText() }, { label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) }, { label: '当前目标', value: currentTargetText }, { label: '进度', value: this.gamePresentation.hud.progressText || '--' }, { label: '当前积分', value: sessionState ? String(sessionState.score) : '0' }, { label: '已完成点', value: sessionState ? String(sessionState.completedControlIds.length) : '0' }, { label: '已跳过点', value: sessionState ? String(sessionState.skippedControlIds.length) : '0' }, { label: '打点规则', value: `${this.punchPolicy} / ${this.punchRadiusMeters}m` }, { label: '跳点规则', value: this.skipEnabled ? `${this.skipRadiusMeters}m / ${this.skipRequiresConfirm ? '确认跳过' : '直接跳过'}` : '关闭' }, { label: '定位源', value: this.state.locationSourceText || '--' }, { label: '当前位置', value: this.state.gpsCoordText || '--' }, { label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` }, { label: '设备朝向', value: this.state.deviceHeadingText || '--' }, { label: '设备姿态', value: this.state.devicePoseText || '--' }, { label: '朝向可信度', value: this.state.headingConfidenceText || '--' }, { label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' }, { label: '当前速度', value: `${telemetryPresentation.speedText} km/h` }, { label: '心率源', value: this.state.heartRateSourceText || '--' }, { label: '当前心率', value: this.state.panelHeartRateValueText === '--' ? '--' : `${this.state.panelHeartRateValueText}${this.state.panelHeartRateUnitText}` }, { label: '心率设备', value: this.state.heartRateDeviceText || '--' }, { label: '心率分区', value: this.state.panelHeartRateZoneNameText === '--' ? '--' : `${this.state.panelHeartRateZoneNameText} ${this.state.panelHeartRateZoneRangeText}` }, { label: '本局用时', value: telemetryPresentation.timerText }, { label: '累计里程', value: telemetryPresentation.mileageText }, { label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` }, { label: '提示状态', value: this.state.punchHintText || '--' }, ] const globalRows: MapEngineGameInfoRow[] = [ { label: '全球积分', value: '未接入' }, { label: '全球排名', value: '未接入' }, { label: '在线人数', value: '未接入' }, { label: '队伍状态', value: '未接入' }, { label: '实时广播', value: '未接入' }, ] return { title, subtitle, localRows, globalRows, } } getResultSceneSnapshot(): MapEngineResultSnapshot { return buildResultSummarySnapshot( this.gameRuntime.definition, this.gameRuntime.state, this.telemetryRuntime.getPresentation(), this.state.mapName || (this.gameRuntime.definition ? this.gameRuntime.definition.title : '本局结果'), ) } destroy(): void { this.clearInertiaTimer() this.clearPreviewResetTimer() this.clearViewSyncTimer() this.clearAutoRotateTimer() this.clearCompassNeedleTimer() this.clearCompassBootstrapRetryTimer() this.clearPunchFeedbackTimer() this.clearContentCardTimer() this.clearMapPulseTimer() this.clearStageFxTimer() this.clearSessionTimerInterval() this.accelerometerController.destroy() this.compassController.destroy() this.gyroscopeController.destroy() this.deviceMotionController.destroy() this.locationController.destroy() this.heartRateController.destroy() this.feedbackDirector.destroy() this.renderer.destroy() this.mounted = false } handleAppShow(): void { this.feedbackDirector.setAppAudioMode('foreground') if (this.mounted) { this.lastCompassSampleAt = 0 this.compassController.start() this.scheduleCompassBootstrapRetry() } } handleAppHide(): void { this.feedbackDirector.setAppAudioMode('foreground') } clearGameRuntime(): void { this.gameRuntime.clear() this.telemetryRuntime.reset() this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE this.courseOverlayVisible = !!this.courseData this.clearSessionTimerInterval() this.setCourseHeading(null) } clearHeartRateSignal(): void { this.telemetryRuntime.dispatch({ type: 'heart_rate_updated', at: Date.now(), bpm: null, }) this.syncSessionTimerText() } clearFinishedTestOverlay(): void { this.currentGpsPoint = null this.currentGpsTrack = [] this.currentGpsAccuracyMeters = null this.currentGpsInsideMap = false this.smoothedMovementHeadingDeg = null this.courseOverlayVisible = false this.setCourseHeading(null) } clearStartSessionResidue(): void { this.currentGpsTrack = [] this.smoothedMovementHeadingDeg = null this.courseOverlayVisible = false this.setCourseHeading(null) } handleClearMapTestArtifacts(): void { this.clearFinishedTestOverlay() this.setState({ gpsTracking: false, gpsTrackingText: '测试痕迹已清空', gpsCoordText: '--', statusText: `已清空地图点位与轨迹 (${this.buildVersion})`, }, true) this.syncRenderer() } getHudTargetControlId(): string | null { return this.gamePresentation.hud.hudTargetControlId } isSkipAvailable(): boolean { const definition = this.gameRuntime.definition const state = this.gameRuntime.state if (!definition || !state || state.status !== 'running' || !definition.skipEnabled) { return false } const currentTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null if (!currentTarget || currentTarget.kind !== 'control' || !this.currentGpsPoint) { return false } const avgLatRad = ((currentTarget.point.lat + this.currentGpsPoint.lat) / 2) * Math.PI / 180 const dx = (this.currentGpsPoint.lon - currentTarget.point.lon) * 111320 * Math.cos(avgLatRad) const dy = (this.currentGpsPoint.lat - currentTarget.point.lat) * 110540 const distanceMeters = Math.sqrt(dx * dx + dy * dy) return distanceMeters <= definition.skipRadiusMeters } shouldConfirmSkipAction(): boolean { return !!(this.gameRuntime.definition && this.gameRuntime.definition.skipRequiresConfirm) } getLocationControllerViewPatch(): Partial { const debugState = this.locationController.getDebugState() return { gpsTracking: debugState.listening, gpsLockEnabled: this.gpsLockEnabled, gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap, locationSourceMode: debugState.sourceMode, locationSourceText: debugState.sourceModeText, mockBridgeConnected: debugState.mockBridgeConnected, mockBridgeStatusText: debugState.mockBridgeStatusText, mockBridgeUrlText: debugState.mockBridgeUrlText, mockCoordText: debugState.mockCoordText, mockSpeedText: debugState.mockSpeedText, } } getHeartRateControllerViewPatch(): Partial { const debugState = this.heartRateController.getDebugState() return { heartRateSourceMode: debugState.sourceMode, heartRateSourceText: debugState.sourceModeText, mockHeartRateBridgeConnected: debugState.mockBridgeConnected, mockHeartRateBridgeStatusText: debugState.mockBridgeStatusText, mockHeartRateBridgeUrlText: debugState.mockBridgeUrlText, mockHeartRateText: debugState.mockHeartRateText, } } getTelemetrySensorViewPatch(): Partial { const telemetryState = this.telemetryRuntime.state return { deviceHeadingText: formatHeadingText( telemetryState.deviceHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg), ), devicePoseText: formatDevicePoseText(telemetryState.devicePose), headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence), accelerometerText: telemetryState.accelerometer ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}` : '未启用', gyroscopeText: formatGyroscopeText(telemetryState.gyroscope), deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion), compassSourceText: formatCompassSourceText(this.compassSource), compassTuningProfile: this.compassTuningProfile, compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile), } } getGameModeText(): string { return this.gameMode === 'score-o' ? '积分赛' : '顺序赛' } loadGameDefinitionFromCourse(): GameResult | null { if (!this.courseData) { this.clearGameRuntime() return null } const definition = buildGameDefinitionFromCourse( this.courseData, this.cpRadiusMeters, this.gameMode, this.autoFinishOnLastControl, this.punchPolicy, this.punchRadiusMeters, this.requiresFocusSelection, this.skipEnabled, this.skipRadiusMeters, this.skipRequiresConfirm, this.controlScoreOverrides, this.controlContentOverrides, this.defaultControlScore, ) const result = this.gameRuntime.loadDefinition(definition) this.telemetryRuntime.loadDefinition(definition) this.courseOverlayVisible = true this.syncGameResultState(result) this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, result.presentation.hud.hudTargetControlId) this.updateSessionTimerLoop() return result } refreshCourseHeadingFromPresentation(): void { if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) { this.setCourseHeading(null) return } const activeLegIndex = this.gamePresentation.map.activeLegIndices[0] const activeLeg = this.courseData.layers.legs[activeLegIndex] if (!activeLeg) { this.setCourseHeading(null) return } this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint)) } resolveGameStatusText(effects: GameEffect[]): string | null { const lastEffect = effects.length ? effects[effects.length - 1] : null if (!lastEffect) { return null } if (lastEffect.type === 'control_completed') { const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})` } if (lastEffect.type === 'session_finished') { return `璺嚎宸插畬鎴?(${this.buildVersion})` } if (lastEffect.type === 'session_started') { return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})` } return null } getGameViewPatch(statusText?: string | null): Partial { const telemetryPresentation = this.telemetryRuntime.getPresentation() const patch: Partial = { gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle', gameModeText: this.getGameModeText(), panelTimerText: telemetryPresentation.timerText, panelMileageText: telemetryPresentation.mileageText, panelActionTagText: this.gamePresentation.hud.actionTagText, panelDistanceTagText: this.gamePresentation.hud.distanceTagText, panelDistanceValueText: telemetryPresentation.distanceToTargetValueText, panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText, panelSpeedValueText: telemetryPresentation.speedText, panelTelemetryTone: telemetryPresentation.heartRateTone, panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText, panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText, panelHeartRateValueText: telemetryPresentation.heartRateValueText, panelHeartRateUnitText: telemetryPresentation.heartRateUnitText, panelCaloriesValueText: telemetryPresentation.caloriesValueText, panelCaloriesUnitText: telemetryPresentation.caloriesUnitText, panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText, panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText, panelAccuracyValueText: telemetryPresentation.accuracyValueText, panelAccuracyUnitText: telemetryPresentation.accuracyUnitText, panelProgressText: this.gamePresentation.hud.progressText, punchButtonText: this.gamePresentation.hud.punchButtonText, punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled, skipButtonEnabled: this.isSkipAvailable(), punchHintText: this.gamePresentation.hud.punchHintText, gpsLockEnabled: this.gpsLockEnabled, gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap, } if (statusText) { patch.statusText = statusText } return patch } clearPunchFeedbackTimer(): void { if (this.punchFeedbackTimer) { clearTimeout(this.punchFeedbackTimer) this.punchFeedbackTimer = 0 } } clearContentCardTimer(): void { if (this.contentCardTimer) { clearTimeout(this.contentCardTimer) this.contentCardTimer = 0 } } clearMapPulseTimer(): void { if (this.mapPulseTimer) { clearTimeout(this.mapPulseTimer) this.mapPulseTimer = 0 } } clearStageFxTimer(): void { if (this.stageFxTimer) { clearTimeout(this.stageFxTimer) this.stageFxTimer = 0 } } resetTransientGameUiState(): void { this.clearPunchFeedbackTimer() this.clearContentCardTimer() this.clearMapPulseTimer() this.clearStageFxTimer() this.setState({ punchFeedbackVisible: false, punchFeedbackText: '', punchFeedbackTone: 'neutral', punchFeedbackFxClass: '', contentCardVisible: false, contentCardTitle: '', contentCardBody: '', contentCardFxClass: '', mapPulseVisible: false, mapPulseFxClass: '', stageFxVisible: false, stageFxClass: '', punchButtonFxClass: '', panelProgressFxClass: '', panelDistanceFxClass: '', }, true) this.currentContentCardPriority = 0 } resetSessionContentExperienceState(): void { this.shownContentCardKeys = {} this.currentContentCardPriority = 0 } clearSessionTimerInterval(): void { if (this.sessionTimerInterval) { clearInterval(this.sessionTimerInterval) this.sessionTimerInterval = 0 } } syncSessionTimerText(): void { const telemetryPresentation = this.telemetryRuntime.getPresentation() this.setState({ panelTimerText: telemetryPresentation.timerText, panelMileageText: telemetryPresentation.mileageText, panelActionTagText: this.gamePresentation.hud.actionTagText, panelDistanceTagText: this.gamePresentation.hud.distanceTagText, panelDistanceValueText: telemetryPresentation.distanceToTargetValueText, panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText, panelSpeedValueText: telemetryPresentation.speedText, panelTelemetryTone: telemetryPresentation.heartRateTone, panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText, panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText, panelHeartRateValueText: telemetryPresentation.heartRateValueText, panelHeartRateUnitText: telemetryPresentation.heartRateUnitText, panelCaloriesValueText: telemetryPresentation.caloriesValueText, panelCaloriesUnitText: telemetryPresentation.caloriesUnitText, panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText, panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText, panelAccuracyValueText: telemetryPresentation.accuracyValueText, panelAccuracyUnitText: telemetryPresentation.accuracyUnitText, }) } updateSessionTimerLoop(): void { const gameState = this.gameRuntime.state const shouldRun = !!gameState && gameState.status === 'running' && gameState.endedAt === null this.syncSessionTimerText() if (!shouldRun) { this.clearSessionTimerInterval() return } if (this.sessionTimerInterval) { return } this.sessionTimerInterval = setInterval(() => { this.syncSessionTimerText() }, 1000) as unknown as number } getControlScreenPoint(controlId: string): { x: number; y: number } | null { if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) { return null } const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId) if (!control) { return null } const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const screenPoint = worldToScreen({ centerWorldX: exactCenter.x, centerWorldY: exactCenter.y, viewportWidth: this.state.stageWidth, viewportHeight: this.state.stageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, rotationRad: this.getRotationRad(this.state.rotationDeg), }, lonLatToWorldTile(control.point, this.state.zoom), false) if (screenPoint.x < -80 || screenPoint.x > this.state.stageWidth + 80 || screenPoint.y < -80 || screenPoint.y > this.state.stageHeight + 80) { return null } return screenPoint } setPunchButtonFxClass(className: string): void { this.setState({ punchButtonFxClass: className, }, true) } setHudProgressFxClass(className: string): void { this.setState({ panelProgressFxClass: className, }, true) } setHudDistanceFxClass(className: string): void { this.setState({ panelDistanceFxClass: className, }, true) } showMapPulse(controlId: string, motionClass = ''): void { const screenPoint = this.getControlScreenPoint(controlId) if (!screenPoint) { return } this.clearMapPulseTimer() this.setState({ mapPulseVisible: true, mapPulseLeftPx: screenPoint.x, mapPulseTopPx: screenPoint.y, mapPulseFxClass: motionClass, }, true) this.mapPulseTimer = setTimeout(() => { this.mapPulseTimer = 0 this.setState({ mapPulseVisible: false, mapPulseFxClass: '', }, true) }, 820) as unknown as number } showStageFx(className: string): void { if (!className) { return } this.clearStageFxTimer() this.setState({ stageFxVisible: true, stageFxClass: className, }, true) this.stageFxTimer = setTimeout(() => { this.stageFxTimer = 0 this.setState({ stageFxVisible: false, stageFxClass: '', }, true) }, 760) as unknown as number } showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning', motionClass = ''): void { this.clearPunchFeedbackTimer() this.setState({ punchFeedbackVisible: true, punchFeedbackText: text, punchFeedbackTone: tone, punchFeedbackFxClass: motionClass, }, true) this.punchFeedbackTimer = setTimeout(() => { this.punchFeedbackTimer = 0 this.setState({ punchFeedbackVisible: false, punchFeedbackFxClass: '', }, true) }, 1400) as unknown as number } showContentCard(title: string, body: string, motionClass = '', options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }): void { const autoPopup = !options || options.autoPopup !== false const once = !!(options && options.once) const priority = options && typeof options.priority === 'number' ? options.priority : 0 const contentKey = options && options.contentKey ? options.contentKey : '' if (!autoPopup) { return } if (once && contentKey && this.shownContentCardKeys[contentKey]) { return } if (this.state.contentCardVisible && priority < this.currentContentCardPriority) { return } this.clearContentCardTimer() this.setState({ contentCardVisible: true, contentCardTitle: title, contentCardBody: body, contentCardFxClass: motionClass, }, true) this.currentContentCardPriority = priority if (once && contentKey) { this.shownContentCardKeys[contentKey] = true } this.contentCardTimer = setTimeout(() => { this.contentCardTimer = 0 this.currentContentCardPriority = 0 this.setState({ contentCardVisible: false, contentCardFxClass: '', }, true) }, 2600) as unknown as number } closeContentCard(): void { this.clearContentCardTimer() this.currentContentCardPriority = 0 this.setState({ contentCardVisible: false, contentCardFxClass: '', }, true) } applyGameEffects(effects: GameEffect[]): string | null { this.feedbackDirector.handleEffects(effects) if (effects.some((effect) => effect.type === 'session_finished')) { if (this.locationController.listening) { this.locationController.stop() } this.setState({ gpsTracking: false, gpsTrackingText: '测试结束,定位已停止', }, true) } this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId()) this.updateSessionTimerLoop() return this.resolveGameStatusText(effects) } syncGameResultState(result: GameResult): void { this.gamePresentation = result.presentation this.refreshCourseHeadingFromPresentation() } resolveAppliedGameStatusText(result: GameResult, fallbackStatusText?: string | null): string | null { return this.applyGameEffects(result.effects) || fallbackStatusText || this.resolveGameStatusText(result.effects) } commitGameResult( result: GameResult, fallbackStatusText?: string | null, extraPatch: Partial = {}, syncRenderer = true, ): string | null { this.syncGameResultState(result) const gameStatusText = this.resolveAppliedGameStatusText(result, fallbackStatusText) this.setState({ ...this.getGameViewPatch(gameStatusText), ...extraPatch, }, true) if (syncRenderer) { this.syncRenderer() } return gameStatusText } handleStartGame(): void { if (!this.gameRuntime.definition || !this.gameRuntime.state) { this.setState({ statusText: `当前还没有可开始的路线 (${this.buildVersion})`, }, true) return } if (this.gameRuntime.state.status !== 'idle') { if (this.gameRuntime.state.status === 'finished' || this.gameRuntime.state.status === 'failed') { const reloadedResult = this.loadGameDefinitionFromCourse() if (!reloadedResult || !this.gameRuntime.state) { return } } else { return } } this.feedbackDirector.reset() this.resetTransientGameUiState() this.resetSessionContentExperienceState() this.clearStartSessionResidue() if (!this.locationController.listening) { this.locationController.start() } const startedAt = Date.now() const startResult = this.gameRuntime.startSession(startedAt) let gameResult = startResult if (this.currentGpsPoint) { const gpsResult = this.gameRuntime.dispatch({ type: 'gps_updated', at: Date.now(), lon: this.currentGpsPoint.lon, lat: this.currentGpsPoint.lat, accuracyMeters: this.currentGpsAccuracyMeters, }) gameResult = { nextState: gpsResult.nextState, presentation: gpsResult.presentation, effects: [...startResult.effects, ...gpsResult.effects], } } this.courseOverlayVisible = true const gameModeText = this.gameMode === 'score-o' ? '积分赛' : '顺序打点' const defaultStatusText = this.currentGpsPoint ? `${gameModeText}已开始 (${this.buildVersion})` : `${gameModeText}已开始,GPS定位启动中 (${this.buildVersion})` this.commitGameResult(gameResult, defaultStatusText) } handleForceExitGame(): void { this.feedbackDirector.reset() if (this.locationController.listening) { this.locationController.stop() } if (!this.courseData) { this.clearGameRuntime() this.resetTransientGameUiState() this.resetSessionContentExperienceState() this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.setState({ gpsTracking: false, gpsTrackingText: '已退出对局,定位已停止', ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), }, true) this.syncRenderer() return } this.loadGameDefinitionFromCourse() this.resetTransientGameUiState() this.resetSessionContentExperienceState() this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.setState({ gpsTracking: false, gpsTrackingText: '已退出对局,定位已停止', ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), }, true) this.syncRenderer() } handlePunchAction(): void { const gameResult = this.gameRuntime.dispatch({ type: 'punch_requested', at: Date.now(), }) this.commitGameResult(gameResult) } handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void { const nextPoint: LonLatPoint = { lon: longitude, lat: latitude } const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) { this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS) } this.currentGpsPoint = nextPoint this.currentGpsAccuracyMeters = accuracyMeters this.updateMovementHeadingDeg() const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom) const gpsTileX = Math.floor(gpsWorldPoint.x) const gpsTileY = Math.floor(gpsWorldPoint.y) const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY) this.currentGpsInsideMap = gpsInsideMap let gameStatusText: string | null = null if (!gpsInsideMap && this.gpsLockEnabled) { this.gpsLockEnabled = false gameStatusText = `GPS已超出地图范围,锁定已关闭 (${this.buildVersion})` } if (this.courseData) { const eventAt = Date.now() const gameResult = this.gameRuntime.dispatch({ type: 'gps_updated', at: eventAt, lon: longitude, lat: latitude, accuracyMeters, }) this.telemetryRuntime.dispatch({ type: 'gps_updated', at: eventAt, lon: longitude, lat: latitude, accuracyMeters, }) this.syncGameResultState(gameResult) gameStatusText = this.resolveAppliedGameStatusText(gameResult) } if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } if (gpsInsideMap && (this.gpsLockEnabled || !this.hasGpsCenteredOnce)) { this.hasGpsCenteredOnce = true const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y) this.commitViewport({ ...lockedViewport, gpsTracking: true, gpsTrackingText: '持续定位进行中', gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters), autoRotateSourceText: this.getAutoRotateSourceText(), gpsLockEnabled: this.gpsLockEnabled, gpsLockAvailable: true, ...this.getGameViewPatch(), }, gameStatusText || (this.gpsLockEnabled ? `GPS锁定跟随中 (${this.buildVersion})` : `GPS定位成功,已定位到当前位置 (${this.buildVersion})`), true) return } this.setState({ gpsTracking: true, gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内', gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters), autoRotateSourceText: this.getAutoRotateSourceText(), gpsLockEnabled: this.gpsLockEnabled, gpsLockAvailable: gpsInsideMap, ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)), }) this.syncRenderer() } handleToggleGpsLock(): void { if (!this.currentGpsPoint || !this.currentGpsInsideMap) { this.setState({ gpsLockEnabled: false, gpsLockAvailable: false, statusText: this.currentGpsPoint ? `当前位置不在地图范围内,无法锁定 (${this.buildVersion})` : `当前还没有可锁定的GPS位置 (${this.buildVersion})`, }, true) return } const nextEnabled = !this.gpsLockEnabled this.gpsLockEnabled = nextEnabled if (nextEnabled) { const gpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, this.state.zoom) const gpsTileX = Math.floor(gpsWorldPoint.x) const gpsTileY = Math.floor(gpsWorldPoint.y) const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY) if (gpsInsideMap) { this.hasGpsCenteredOnce = true const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y) this.commitViewport({ ...lockedViewport, gpsLockEnabled: true, gpsLockAvailable: true, }, `GPS已锁定在屏幕中央 (${this.buildVersion})`, true) return } this.setState({ gpsLockEnabled: true, gpsLockAvailable: true, statusText: `GPS锁定已开启,等待进入地图范围 (${this.buildVersion})`, }, true) this.syncRenderer() return } this.setState({ gpsLockEnabled: false, gpsLockAvailable: true, statusText: `GPS锁定已关闭 (${this.buildVersion})`, }, true) this.syncRenderer() } handleToggleOsmReference(): void { const nextEnabled = !this.state.osmReferenceEnabled this.setState({ osmReferenceEnabled: nextEnabled, osmReferenceText: nextEnabled ? 'OSM参考:开' : 'OSM参考:关', statusText: nextEnabled ? `OSM参考底图已开启 (${this.buildVersion})` : `OSM参考底图已关闭 (${this.buildVersion})`, }, true) this.syncRenderer() } handleToggleGpsTracking(): void { if (this.locationController.listening) { this.locationController.stop() return } this.locationController.start() } handleSetRealLocationMode(): void { this.locationController.setSourceMode('real') } handleSetMockLocationMode(): void { this.locationController.setSourceMode('mock') } handleConnectMockLocationBridge(): void { this.locationController.connectMockBridge() } handleDisconnectMockLocationBridge(): void { this.locationController.disconnectMockBridge() } handleSetMockLocationBridgeUrl(url: string): void { this.locationController.setMockBridgeUrl(url) } handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void { if (this.gameMode === nextMode) { return } this.gameMode = nextMode const result = this.loadGameDefinitionFromCourse() const modeText = this.getGameModeText() if (!result) { return } this.commitGameResult(result, `已切换到${modeText} (${this.buildVersion})`, { gameModeText: modeText, }) } handleSkipAction(): void { const gameResult = this.gameRuntime.dispatch({ type: 'skip_requested', at: Date.now(), lon: this.currentGpsPoint ? this.currentGpsPoint.lon : null, lat: this.currentGpsPoint ? this.currentGpsPoint.lat : null, }) this.commitGameResult(gameResult) } handleConnectHeartRate(): void { this.heartRateController.startScanAndConnect() } handleDisconnectHeartRate(): void { this.heartRateController.disconnect() } handleSetRealHeartRateMode(): void { this.heartRateController.setSourceMode('real') } handleSetMockHeartRateMode(): void { this.heartRateController.setSourceMode('mock') } handleConnectMockHeartRateBridge(): void { this.heartRateController.connectMockBridge() } handleDisconnectMockHeartRateBridge(): void { this.heartRateController.disconnectMockBridge() } handleSetMockHeartRateBridgeUrl(url: string): void { this.heartRateController.setMockBridgeUrl(url) } handleConnectHeartRateDevice(deviceId: string): void { this.heartRateController.connectToDiscoveredDevice(deviceId) } handleClearPreferredHeartRateDevice(): void { this.heartRateController.clearPreferredDevice() this.setState({ heartRateDeviceText: this.heartRateController.currentDeviceName || '--', heartRateScanText: this.getHeartRateScanText(), }) } handleDebugHeartRateTone(tone: HeartRateTone): void { const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config) this.telemetryRuntime.dispatch({ type: 'heart_rate_updated', at: Date.now(), bpm: sampleBpm, }) this.setState({ heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`, }) this.syncSessionTimerText() } handleClearDebugHeartRate(): void { this.telemetryRuntime.dispatch({ type: 'heart_rate_updated', at: Date.now(), bpm: null, }) this.setState({ heartRateStatusText: this.heartRateController.connected ? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接') : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'), heartRateScanText: this.getHeartRateScanText(), ...this.getHeartRateControllerViewPatch(), }) this.syncSessionTimerText() } formatHeartRateDevices(devices: HeartRateDiscoveredDevice[]): Array<{ deviceId: string; name: string; rssiText: string; preferred: boolean; connected: boolean }> { return devices.map((device) => ({ deviceId: device.deviceId, name: device.name, rssiText: device.rssi === null ? '--' : `${device.rssi} dBm`, preferred: device.isPreferred, connected: !!this.heartRateController.currentDeviceId && this.heartRateController.currentDeviceId === device.deviceId && this.heartRateController.connected, })) } getHeartRateScanText(): string { if (this.heartRateController.sourceMode === 'mock') { if (this.heartRateController.connected) { return '模拟源已连接' } if (this.heartRateController.connecting) { return '模拟源连接中' } return '模拟模式' } if (this.heartRateController.connected) { return '已连接' } if (this.heartRateController.connecting) { return '连接中' } if (this.heartRateController.disconnecting) { return '断开中' } if (this.heartRateController.scanning) { return this.heartRateController.lastDeviceId ? '扫描中(优先首选)' : '扫描中(等待选择)' } return this.heartRateController.discoveredDevices.length ? `已发现 ${this.heartRateController.discoveredDevices.length} 个设备` : '未扫描' } setStage(rect: MapEngineStageRect): void { this.previewScale = 1 this.previewOriginX = rect.width / 2 this.previewOriginY = rect.height / 2 this.commitViewport( { stageWidth: rect.width, stageHeight: rect.height, stageLeft: rect.left, stageTop: rect.top, }, `地图视口已与 WebGL 引擎对齐 (${this.buildVersion})`, true, ) } attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void { if (this.mounted) { return } this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode) this.mounted = true this.state.mapReady = true this.state.mapReadyText = 'READY' this.onData({ mapReady: true, mapReadyText: 'READY', statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`, }) this.syncRenderer() this.accelerometerErrorText = null this.lastCompassSampleAt = 0 this.compassController.start() this.scheduleCompassBootstrapRetry() this.gyroscopeController.start() this.deviceMotionController.start() } applyRemoteMapConfig(config: RemoteMapConfig): void { MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg MAGNETIC_DECLINATION_TEXT = normalizeDegreeDisplayText(config.magneticDeclinationText) this.minZoom = config.minZoom this.maxZoom = config.maxZoom this.defaultZoom = config.defaultZoom this.defaultCenterTileX = config.initialCenterTileX this.defaultCenterTileY = config.initialCenterTileY this.tileBoundsByZoom = config.tileBoundsByZoom this.courseData = config.course this.cpRadiusMeters = config.cpRadiusMeters this.configAppId = config.configAppId this.configSchemaVersion = config.configSchemaVersion this.configVersion = config.configVersion this.controlScoreOverrides = config.controlScoreOverrides this.controlContentOverrides = config.controlContentOverrides this.defaultControlScore = config.defaultControlScore this.gameMode = config.gameMode this.punchPolicy = config.punchPolicy this.punchRadiusMeters = config.punchRadiusMeters this.requiresFocusSelection = config.requiresFocusSelection this.skipEnabled = config.skipEnabled this.skipRadiusMeters = config.skipRadiusMeters this.skipRequiresConfirm = config.skipRequiresConfirm this.autoFinishOnLastControl = config.autoFinishOnLastControl this.telemetryRuntime.configure(config.telemetryConfig) this.feedbackDirector.configure({ audioConfig: config.audioConfig, hapticsConfig: config.hapticsConfig, uiEffectsConfig: config.uiEffectsConfig, }) const gameResult = this.loadGameDefinitionFromCourse() const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null const statePatch: Partial = { mapName: config.configTitle, configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`, projectionMode: config.projectionModeText, tileSource: config.tileSource, sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode), compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg), ...this.getGameViewPatch(gameStatusText), } if (!this.state.stageWidth || !this.state.stageHeight) { this.setState({ ...statePatch, zoom: this.defaultZoom, centerTileX: this.defaultCenterTileX, centerTileY: this.defaultCenterTileY, centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY), statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, }, true) return } this.commitViewport({ ...statePatch, zoom: this.defaultZoom, centerTileX: this.defaultCenterTileX, centerTileY: this.defaultCenterTileY, tileTranslateX: 0, tileTranslateY: 0, }, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } }) } handleTouchStart(event: WechatMiniprogram.TouchEvent): void { this.clearInertiaTimer() this.clearPreviewResetTimer() this.panVelocityX = 0 this.panVelocityY = 0 if (event.touches.length >= 2) { const origin = this.gpsLockEnabled ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 } : this.getStagePoint(event.touches) this.gestureMode = 'pinch' this.pinchStartDistance = this.getTouchDistance(event.touches) this.pinchStartScale = this.previewScale || 1 this.pinchStartAngle = this.getTouchAngle(event.touches) this.pinchStartRotationDeg = this.state.rotationDeg const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom) : screenToWorld(this.getCameraState(), origin, true) this.pinchAnchorWorldX = anchorWorld.x this.pinchAnchorWorldY = anchorWorld.y this.setPreviewState(this.pinchStartScale, origin.x, origin.y) this.syncRenderer() this.compassController.start() return } if (event.touches.length === 1) { this.gestureMode = 'pan' this.panLastX = event.touches[0].pageX this.panLastY = event.touches[0].pageY this.panLastTimestamp = event.timeStamp || Date.now() this.tapStartX = event.touches[0].pageX this.tapStartY = event.touches[0].pageY this.tapStartAt = event.timeStamp || Date.now() } } handleTouchMove(event: WechatMiniprogram.TouchEvent): void { if (event.touches.length >= 2) { const distance = this.getTouchDistance(event.touches) const angle = this.getTouchAngle(event.touches) const origin = this.gpsLockEnabled ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 } : this.getStagePoint(event.touches) if (!this.pinchStartDistance) { this.pinchStartDistance = distance this.pinchStartScale = this.previewScale || 1 this.pinchStartAngle = angle this.pinchStartRotationDeg = this.state.rotationDeg const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom) : screenToWorld(this.getCameraState(), origin, true) this.pinchAnchorWorldX = anchorWorld.x this.pinchAnchorWorldY = anchorWorld.y } this.gestureMode = 'pinch' const nextRotationDeg = this.state.orientationMode === 'heading-up' ? this.state.rotationDeg : normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI) const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg) const resolvedViewport = this.resolveViewportForExactCenter( this.pinchAnchorWorldX - anchorOffset.x, this.pinchAnchorWorldY - anchorOffset.y, nextRotationDeg, ) this.setPreviewState( clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE), origin.x, origin.y, ) this.commitViewport( { ...resolvedViewport, rotationDeg: nextRotationDeg, rotationText: formatRotationText(nextRotationDeg), }, this.state.orientationMode === 'heading-up' ? `双指缩放中,自动朝向保持开启 (${this.buildVersion})` : `双指缩放与旋转中 (${this.buildVersion})`, ) return } if (this.gestureMode !== 'pan' || event.touches.length !== 1) { return } const touch = event.touches[0] const deltaX = touch.pageX - this.panLastX const deltaY = touch.pageY - this.panLastY const nextTimestamp = event.timeStamp || Date.now() const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16) const instantVelocityX = deltaX / elapsed const instantVelocityY = deltaY / elapsed this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28 this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28 this.panLastX = touch.pageX this.panLastY = touch.pageY this.panLastTimestamp = nextTimestamp if (this.gpsLockEnabled) { this.panVelocityX = 0 this.panVelocityY = 0 return } this.normalizeTranslate( this.state.tileTranslateX + deltaX, this.state.tileTranslateY + deltaY, `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`, ) } handleTouchEnd(event: WechatMiniprogram.TouchEvent): void { const changedTouch = event.changedTouches && event.changedTouches.length ? event.changedTouches[0] : null const endedAsTap = changedTouch && this.gestureMode === 'pan' && event.touches.length === 0 && Math.abs(changedTouch.pageX - this.tapStartX) <= MAP_TAP_MOVE_THRESHOLD_PX && Math.abs(changedTouch.pageY - this.tapStartY) <= MAP_TAP_MOVE_THRESHOLD_PX && ((event.timeStamp || Date.now()) - this.tapStartAt) <= MAP_TAP_DURATION_MS if (this.gestureMode === 'pinch' && event.touches.length < 2) { const gestureScale = this.previewScale || 1 const zoomDelta = Math.round(Math.log2(gestureScale)) const originX = this.gpsLockEnabled ? this.state.stageWidth / 2 : (this.previewOriginX || this.state.stageWidth / 2) const originY = this.gpsLockEnabled ? this.state.stageHeight / 2 : (this.previewOriginY || this.state.stageHeight / 2) if (zoomDelta) { const residualScale = gestureScale / Math.pow(2, zoomDelta) this.zoomAroundPoint(zoomDelta, originX, originY, residualScale) } else { this.animatePreviewToRest() } this.resetPinchState() this.panVelocityX = 0 this.panVelocityY = 0 if (event.touches.length === 1) { this.gestureMode = 'pan' this.panLastX = event.touches[0].pageX this.panLastY = event.touches[0].pageY this.panLastTimestamp = event.timeStamp || Date.now() return } this.gestureMode = 'idle' this.renderer.setAnimationPaused(false) this.scheduleAutoRotate() return } if (event.touches.length === 1) { this.gestureMode = 'pan' this.panLastX = event.touches[0].pageX this.panLastY = event.touches[0].pageY this.panLastTimestamp = event.timeStamp || Date.now() return } if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) { this.startInertia() this.gestureMode = 'idle' this.resetPinchState() return } if (endedAsTap && changedTouch) { this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop) } this.gestureMode = 'idle' this.resetPinchState() this.renderer.setAnimationPaused(false) this.scheduleAutoRotate() } handleTouchCancel(): void { this.gestureMode = 'idle' this.resetPinchState() this.panVelocityX = 0 this.panVelocityY = 0 this.clearInertiaTimer() this.animatePreviewToRest() this.renderer.setAnimationPaused(false) this.scheduleAutoRotate() } handleMapTap(stageX: number, stageY: number): void { if (!this.gameRuntime.definition || !this.gameRuntime.state || this.gameRuntime.definition.mode !== 'score-o') { return } const focusedControlId = this.findFocusableControlAt(stageX, stageY) if (focusedControlId === undefined) { return } const gameResult = this.gameRuntime.dispatch({ type: 'control_focused', at: Date.now(), controlId: focusedControlId, }) this.commitGameResult( gameResult, focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`, ) } findFocusableControlAt(stageX: number, stageY: number): string | null | undefined { if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) { return undefined } const focusableControls = this.gameRuntime.definition.controls.filter((control) => ( this.gamePresentation.map.focusableControlIds.includes(control.id) )) let matchedControlId: string | null | undefined let matchedDistance = Number.POSITIVE_INFINITY const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx()) for (const control of focusableControls) { const screenPoint = this.getControlScreenPoint(control.id) if (!screenPoint) { continue } const distancePx = Math.sqrt( Math.pow(screenPoint.x - stageX, 2) + Math.pow(screenPoint.y - stageY, 2), ) if (distancePx <= hitRadiusPx && distancePx < matchedDistance) { matchedDistance = distancePx matchedControlId = control.id } } if (matchedControlId === undefined) { return undefined } return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId } getControlHitRadiusPx(): number { if (!this.state.tileSizePx) { return 28 } const centerLonLat = worldTileToLonLat({ x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5 }, this.state.zoom) const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * 40075016.686 / Math.pow(2, this.state.zoom) if (!metersPerTile) { return 28 } const pixelsPerMeter = this.state.tileSizePx / metersPerTile return Math.max(28, this.cpRadiusMeters * pixelsPerMeter * 1.6) } handleRecenter(): void { this.clearInertiaTimer() this.clearPreviewResetTimer() this.panVelocityX = 0 this.panVelocityY = 0 this.renderer.setAnimationPaused(false) this.commitViewport( { zoom: this.defaultZoom, centerTileX: this.defaultCenterTileX, centerTileY: this.defaultCenterTileY, tileTranslateX: 0, tileTranslateY: 0, }, `已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() this.compassController.start() this.scheduleAutoRotate() }, ) } handleRotateStep(stepDeg = ROTATE_STEP_DEG): void { if (this.state.rotationMode === 'auto') { this.setState({ statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`, }, true) return } const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg) this.clearInertiaTimer() this.clearPreviewResetTimer() this.panVelocityX = 0 this.panVelocityY = 0 this.renderer.setAnimationPaused(false) this.commitViewport( { ...resolvedViewport, rotationDeg: nextRotationDeg, rotationText: formatRotationText(nextRotationDeg), }, `旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() this.compassController.start() }, ) } handleRotationReset(): void { if (this.state.rotationMode === 'auto') { this.setState({ statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`, }, true) return } const targetRotationDeg = MAP_NORTH_OFFSET_DEG if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) { return } const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg) this.clearInertiaTimer() this.clearPreviewResetTimer() this.panVelocityX = 0 this.panVelocityY = 0 this.renderer.setAnimationPaused(false) this.commitViewport( { ...resolvedViewport, rotationDeg: targetRotationDeg, rotationText: formatRotationText(targetRotationDeg), }, `旋转角度已回到真北参考 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() this.compassController.start() }, ) } handleToggleRotationMode(): void { if (this.state.orientationMode === 'manual') { this.setNorthUpMode() return } if (this.state.orientationMode === 'north-up') { this.setHeadingUpMode() return } this.setManualMode() } handleSetManualMode(): void { this.setManualMode() } handleSetNorthUpMode(): void { this.setNorthUpMode() } handleSetHeadingUpMode(): void { this.setHeadingUpMode() } handleCycleNorthReferenceMode(): void { this.cycleNorthReferenceMode() } handleSetNorthReferenceMode(mode: NorthReferenceMode): void { this.setNorthReferenceMode(mode) } handleSetAnimationLevel(level: AnimationLevel): void { if (this.animationLevel === level) { return } this.animationLevel = level this.feedbackDirector.setAnimationLevel(level) this.setState({ animationLevel: level, statusText: `动画性能已切换为${formatAnimationLevelText(level)} (${this.buildVersion})`, }) this.syncRenderer() } handleSetCompassTuningProfile(profile: CompassTuningProfile): void { if (this.compassTuningProfile === profile) { return } this.compassTuningProfile = profile this.compassController.setTuningProfile(profile) this.setState({ compassTuningProfile: profile, compassTuningProfileText: formatCompassTuningProfileText(profile), statusText: `指北针响应已切换为${formatCompassTuningProfileText(profile)} (${this.buildVersion})`, }, true) } handleAutoRotateCalibrate(): void { if (this.state.orientationMode !== 'heading-up') { this.setState({ statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`, }, true) return } if (!this.calibrateAutoRotateToCurrentOrientation()) { this.setState({ statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`, }, true) return } this.setState({ statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`, }, true) this.scheduleAutoRotate() } setManualMode(): void { this.clearAutoRotateTimer() this.targetAutoRotationDeg = null this.autoRotateCalibrationPending = false this.setState({ rotationMode: 'manual', rotationModeText: formatRotationModeText('manual'), rotationToggleText: formatRotationToggleText('manual'), orientationMode: 'manual', orientationModeText: formatOrientationModeText('manual'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), statusText: `已切回手动地图旋转 (${this.buildVersion})`, }, true) } setNorthUpMode(): void { this.clearAutoRotateTimer() this.targetAutoRotationDeg = null this.autoRotateCalibrationPending = false const mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg) this.commitViewport( { ...resolvedViewport, rotationDeg: mapNorthOffsetDeg, rotationText: formatRotationText(mapNorthOffsetDeg), rotationMode: 'manual', rotationModeText: formatRotationModeText('north-up'), rotationToggleText: formatRotationToggleText('north-up'), orientationMode: 'north-up', orientationModeText: formatOrientationModeText('north-up'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg), northReferenceText: formatNorthReferenceText(this.northReferenceMode), }, `地图已固定为真北朝上 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() }, ) } setHeadingUpMode(): void { this.autoRotateCalibrationPending = false this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode) this.targetAutoRotationDeg = null this.setState({ rotationMode: 'auto', rotationModeText: formatRotationModeText('heading-up'), rotationToggleText: formatRotationToggleText('heading-up'), orientationMode: 'heading-up', orientationModeText: formatOrientationModeText('heading-up'), autoRotateSourceText: this.getAutoRotateSourceText(), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), northReferenceText: formatNorthReferenceText(this.northReferenceMode), statusText: `正在启用朝向朝上模式 (${this.buildVersion})`, }, true) if (this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } } applyHeadingSample(headingDeg: number, source: 'compass' | 'motion'): void { this.compassSource = source this.sensorHeadingDeg = normalizeRotationDeg(headingDeg) this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null ? this.sensorHeadingDeg : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING) const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg) if (this.compassDisplayHeadingDeg === null) { this.compassDisplayHeadingDeg = compassHeadingDeg this.targetCompassDisplayHeadingDeg = compassHeadingDeg this.syncCompassDisplayState() } else { this.targetCompassDisplayHeadingDeg = compassHeadingDeg const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg)) if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) { this.scheduleCompassNeedleFollow() } } this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg() this.setState({ compassSourceText: formatCompassSourceText(this.compassSource), ...(this.diagnosticUiEnabled ? { ...this.getTelemetrySensorViewPatch(), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), autoRotateSourceText: this.getAutoRotateSourceText(), northReferenceText: formatNorthReferenceText(this.northReferenceMode), } : {}), }) if (!this.refreshAutoRotateTarget()) { return } if (this.state.orientationMode === 'heading-up') { this.scheduleAutoRotate() } } handleCompassHeading(headingDeg: number): void { this.lastCompassSampleAt = Date.now() this.clearCompassBootstrapRetryTimer() this.applyHeadingSample(headingDeg, 'compass') } handleCompassError(message: string): void { this.clearAutoRotateTimer() this.clearCompassNeedleTimer() this.targetAutoRotationDeg = null this.autoRotateCalibrationPending = false this.compassSource = null this.targetCompassDisplayHeadingDeg = null this.setState({ compassSourceText: formatCompassSourceText(null), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), statusText: `${message} (${this.buildVersion})`, }, true) } cycleNorthReferenceMode(): void { this.setNorthReferenceMode(getNextNorthReferenceMode(this.northReferenceMode)) } setNorthReferenceMode(nextMode: NorthReferenceMode): void { if (nextMode === this.northReferenceMode) { return } const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode) const compassHeadingDeg = this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg) this.northReferenceMode = nextMode this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg this.compassDisplayHeadingDeg = compassHeadingDeg this.targetCompassDisplayHeadingDeg = compassHeadingDeg if (this.state.orientationMode === 'north-up') { const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG) this.commitViewport( { ...resolvedViewport, rotationDeg: MAP_NORTH_OFFSET_DEG, rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG), northReferenceText: formatNorthReferenceText(nextMode), sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), ...this.getTelemetrySensorViewPatch(), compassDeclinationText: formatCompassDeclinationText(nextMode), northReferenceMode: nextMode, northReferenceButtonText: formatNorthReferenceButtonText(nextMode), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), }, `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() }, ) return } this.setState({ northReferenceText: formatNorthReferenceText(nextMode), sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), ...this.getTelemetrySensorViewPatch(), compassDeclinationText: formatCompassDeclinationText(nextMode), northReferenceMode: nextMode, northReferenceButtonText: formatNorthReferenceButtonText(nextMode), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`, }, true) if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } if (this.compassDisplayHeadingDeg !== null) { this.syncCompassDisplayState() } } setCourseHeading(headingDeg: number | null): void { this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg) this.setState({ autoRotateSourceText: this.getAutoRotateSourceText(), }) if (this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } } getRawMovementHeadingDeg(): number | null { if (!this.currentGpsInsideMap) { return null } if (this.currentGpsAccuracyMeters !== null && this.currentGpsAccuracyMeters > SMART_HEADING_MAX_ACCURACY_METERS) { return null } if (this.currentGpsTrack.length < 2) { return null } const lastPoint = this.currentGpsTrack[this.currentGpsTrack.length - 1] let accumulatedDistanceMeters = 0 for (let index = this.currentGpsTrack.length - 2; index >= 0; index -= 1) { const nextPoint = this.currentGpsTrack[index + 1] const point = this.currentGpsTrack[index] accumulatedDistanceMeters += getApproxDistanceMeters(point, nextPoint) if (accumulatedDistanceMeters >= SMART_HEADING_MIN_DISTANCE_METERS) { return getInitialBearingDeg(point, lastPoint) } } return null } updateMovementHeadingDeg(): void { const rawMovementHeadingDeg = this.getRawMovementHeadingDeg() if (rawMovementHeadingDeg === null) { this.smoothedMovementHeadingDeg = null return } const smoothingFactor = getMovementHeadingSmoothingFactor(this.telemetryRuntime.state.currentSpeedKmh) this.smoothedMovementHeadingDeg = this.smoothedMovementHeadingDeg === null ? rawMovementHeadingDeg : interpolateAngleDeg(this.smoothedMovementHeadingDeg, rawMovementHeadingDeg, smoothingFactor) } getMovementHeadingDeg(): number | null { return this.smoothedMovementHeadingDeg } getPreferredSensorHeadingDeg(): number | null { return this.smoothedSensorHeadingDeg === null ? null : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg) } getSmartAutoRotateHeadingDeg(): number | null { const sensorHeadingDeg = this.getPreferredSensorHeadingDeg() const movementHeadingDeg = this.getMovementHeadingDeg() const speedKmh = this.telemetryRuntime.state.currentSpeedKmh const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null) if (smartSource === 'movement') { return movementHeadingDeg === null ? sensorHeadingDeg : movementHeadingDeg } if (smartSource === 'blended' && sensorHeadingDeg !== null && movementHeadingDeg !== null && speedKmh !== null) { const blend = Math.max(0, Math.min(1, (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH) / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH))) return interpolateAngleDeg(sensorHeadingDeg, movementHeadingDeg, blend) } return sensorHeadingDeg === null ? movementHeadingDeg : sensorHeadingDeg } getAutoRotateSourceText(): string { if (this.autoRotateSourceMode !== 'smart') { return formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null) } const smartSource = resolveSmartHeadingSource( this.telemetryRuntime.state.currentSpeedKmh, this.getMovementHeadingDeg() !== null, ) return formatSmartHeadingSourceText(smartSource) } resolveAutoRotateInputHeadingDeg(): number | null { if (this.autoRotateSourceMode === 'smart') { return this.getSmartAutoRotateHeadingDeg() } const sensorHeadingDeg = this.getPreferredSensorHeadingDeg() const courseHeadingDeg = this.courseHeadingDeg === null ? null : getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg) if (this.autoRotateSourceMode === 'sensor') { return sensorHeadingDeg } if (this.autoRotateSourceMode === 'course') { return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg } if (sensorHeadingDeg !== null && courseHeadingDeg !== null) { return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35) } return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg } calibrateAutoRotateToCurrentOrientation(): boolean { const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg() if (inputHeadingDeg === null) { return false } this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg) this.autoRotateCalibrationPending = false this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg) this.setState({ autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), }) return true } refreshAutoRotateTarget(): boolean { const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg() if (inputHeadingDeg === null) { return false } if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) { if (!this.calibrateAutoRotateToCurrentOrientation()) { return false } return true } this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg) this.setState({ autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), }) return true } scheduleAutoRotate(): void { if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) { return } const step = () => { this.autoRotateTimer = 0 if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) { return } if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) { this.scheduleAutoRotate() return } const currentRotationDeg = this.state.rotationDeg const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg) if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) { if (Math.abs(deltaDeg) > 0.01) { this.applyAutoRotation(this.targetAutoRotationDeg) } this.scheduleAutoRotate() return } if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) { this.scheduleAutoRotate() return } const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG) this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg)) this.scheduleAutoRotate() } this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number } applyAutoRotation(nextRotationDeg: number): void { const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg) this.setState({ ...resolvedViewport, rotationDeg: nextRotationDeg, rotationText: formatRotationText(nextRotationDeg), centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY), }) this.syncRenderer() } applyStats(stats: MapRendererStats): void { const statsPatch = { visibleTileCount: stats.visibleTileCount, readyTileCount: stats.readyTileCount, memoryTileCount: stats.memoryTileCount, diskTileCount: stats.diskTileCount, memoryHitCount: stats.memoryHitCount, diskHitCount: stats.diskHitCount, networkFetchCount: stats.networkFetchCount, cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount), } if (!this.diagnosticUiEnabled) { this.state = { ...this.state, ...statsPatch, } return } const now = Date.now() if (now - this.lastStatsUiSyncAt < 500) { this.state = { ...this.state, ...statsPatch, } return } this.lastStatsUiSyncAt = now this.setState(statsPatch) } setState(patch: Partial, immediateUi = false): void { this.state = { ...this.state, ...patch, } const viewPatch = this.pickViewPatch(patch) if (!Object.keys(viewPatch).length) { return } this.pendingViewPatch = { ...this.pendingViewPatch, ...viewPatch, } if (immediateUi) { this.flushViewPatch() return } if (this.viewSyncTimer) { return } this.viewSyncTimer = setTimeout(() => { this.viewSyncTimer = 0 this.flushViewPatch() }, UI_SYNC_INTERVAL_MS) as unknown as number } commitViewport( patch: Partial, statusText: string, immediateUi = false, afterUpdate?: () => void, ): void { const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight const tileSizePx = getTileSizePx({ centerWorldX: nextCenterTileX, centerWorldY: nextCenterTileY, viewportWidth: nextStageWidth, viewportHeight: nextStageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, }) this.setState({ ...patch, tileSizePx, centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY), statusText, }, immediateUi) this.syncRenderer() this.compassController.start() if (afterUpdate) { afterUpdate() } } buildScene() { const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const readyControlSequences = this.resolveReadyControlSequences() return { tileSource: this.state.tileSource, osmTileSource: OSM_TILE_SOURCE, zoom: this.state.zoom, centerTileX: this.state.centerTileX, centerTileY: this.state.centerTileY, exactCenterWorldX: exactCenter.x, exactCenterWorldY: exactCenter.y, tileBoundsByZoom: this.tileBoundsByZoom, viewportWidth: this.state.stageWidth, viewportHeight: this.state.stageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, overdraw: OVERDRAW, translateX: this.state.tileTranslateX, translateY: this.state.tileTranslateY, rotationRad: this.getRotationRad(this.state.rotationDeg), animationLevel: this.state.animationLevel, previewScale: this.previewScale || 1, previewOriginX: this.previewOriginX || this.state.stageWidth / 2, previewOriginY: this.previewOriginY || this.state.stageHeight / 2, track: this.currentGpsTrack, gpsPoint: this.currentGpsPoint, gpsCalibration: GPS_MAP_CALIBRATION, gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom), course: this.courseOverlayVisible ? this.courseData : null, cpRadiusMeters: this.cpRadiusMeters, controlVisualMode: this.gamePresentation.map.controlVisualMode, showCourseLegs: this.gamePresentation.map.showCourseLegs, guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled, focusableControlIds: this.gamePresentation.map.focusableControlIds, focusedControlId: this.gamePresentation.map.focusedControlId, focusedControlSequences: this.gamePresentation.map.focusedControlSequences, activeControlSequences: this.gamePresentation.map.activeControlSequences, readyControlSequences, activeStart: this.gamePresentation.map.activeStart, completedStart: this.gamePresentation.map.completedStart, activeFinish: this.gamePresentation.map.activeFinish, focusedFinish: this.gamePresentation.map.focusedFinish, completedFinish: this.gamePresentation.map.completedFinish, revealFullCourse: this.gamePresentation.map.revealFullCourse, activeLegIndices: this.gamePresentation.map.activeLegIndices, completedLegIndices: this.gamePresentation.map.completedLegIndices, completedControlSequences: this.gamePresentation.map.completedControlSequences, skippedControlIds: this.gamePresentation.map.skippedControlIds, skippedControlSequences: this.gamePresentation.map.skippedControlSequences, osmReferenceEnabled: this.state.osmReferenceEnabled, overlayOpacity: MAP_OVERLAY_OPACITY, } } resolveReadyControlSequences(): number[] { const punchableControlId = this.gamePresentation.hud.punchableControlId const definition = this.gameRuntime.definition if (!punchableControlId || !definition) { return [] } const control = definition.controls.find((item) => item.id === punchableControlId) if (!control || control.sequence === null) { return [] } return [control.sequence] } syncRenderer(): void { if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) { return } this.renderer.updateScene(this.buildScene()) } getCameraState(rotationDeg = this.state.rotationDeg): CameraState { return { centerWorldX: this.state.centerTileX + 0.5, centerWorldY: this.state.centerTileY + 0.5, viewportWidth: this.state.stageWidth, viewportHeight: this.state.stageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, translateX: this.state.tileTranslateX, translateY: this.state.tileTranslateY, rotationRad: this.getRotationRad(rotationDeg), } } getRotationRad(rotationDeg = this.state.rotationDeg): number { return normalizeRotationDeg(rotationDeg) * Math.PI / 180 } getBaseCamera(centerTileX = this.state.centerTileX, centerTileY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState { return { centerWorldX: centerTileX + 0.5, centerWorldY: centerTileY + 0.5, viewportWidth: this.state.stageWidth, viewportHeight: this.state.stageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, rotationRad: this.getRotationRad(rotationDeg), } } getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } { const baseCamera = { centerWorldX: 0, centerWorldY: 0, viewportWidth: this.state.stageWidth, viewportHeight: this.state.stageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, rotationRad: this.getRotationRad(rotationDeg), } return screenToWorld(baseCamera, { x: stageX, y: stageY }, false) } getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } { if (!this.state.stageWidth || !this.state.stageHeight) { return { x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5, } } const screenCenterX = this.state.stageWidth / 2 const screenCenterY = this.state.stageHeight / 2 return screenToWorld(this.getBaseCamera(), { x: screenCenterX - translateX, y: screenCenterY - translateY, }, false) } resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): { centerTileX: number centerTileY: number tileTranslateX: number tileTranslateY: number } { const nextCenterTileX = Math.floor(centerWorldX) const nextCenterTileY = Math.floor(centerWorldY) if (!this.state.stageWidth || !this.state.stageHeight) { return { centerTileX: nextCenterTileX, centerTileY: nextCenterTileY, tileTranslateX: 0, tileTranslateY: 0, } } const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg) const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false) return { centerTileX: nextCenterTileX, centerTileY: nextCenterTileY, tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x, tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y, } } setPreviewState(scale: number, originX: number, originY: number): void { this.previewScale = scale this.previewOriginX = originX this.previewOriginY = originY this.setState({ previewScale: scale, }, true) } resetPreviewState(): void { this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2) } resetPinchState(): void { this.pinchStartDistance = 0 this.pinchStartScale = 1 this.pinchStartAngle = 0 this.pinchStartRotationDeg = this.state.rotationDeg this.pinchAnchorWorldX = 0 this.pinchAnchorWorldY = 0 } clearPreviewResetTimer(): void { if (this.previewResetTimer) { clearTimeout(this.previewResetTimer) this.previewResetTimer = 0 } } clearInertiaTimer(): void { if (this.inertiaTimer) { clearTimeout(this.inertiaTimer) this.inertiaTimer = 0 } } clearViewSyncTimer(): void { if (this.viewSyncTimer) { clearTimeout(this.viewSyncTimer) this.viewSyncTimer = 0 } } clearAutoRotateTimer(): void { if (this.autoRotateTimer) { clearTimeout(this.autoRotateTimer) this.autoRotateTimer = 0 } } clearCompassNeedleTimer(): void { if (this.compassNeedleTimer) { clearTimeout(this.compassNeedleTimer) this.compassNeedleTimer = 0 } } clearCompassBootstrapRetryTimer(): void { if (this.compassBootstrapRetryTimer) { clearTimeout(this.compassBootstrapRetryTimer) this.compassBootstrapRetryTimer = 0 } } scheduleCompassBootstrapRetry(): void { this.clearCompassBootstrapRetryTimer() if (!this.mounted) { return } this.compassBootstrapRetryTimer = setTimeout(() => { this.compassBootstrapRetryTimer = 0 if (!this.mounted || this.lastCompassSampleAt > 0) { return } this.compassController.stop() this.compassController.start() }, COMPASS_BOOTSTRAP_RETRY_DELAY_MS) as unknown as number } syncCompassDisplayState(): void { this.setState({ compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg), sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), ...(this.diagnosticUiEnabled ? { ...this.getTelemetrySensorViewPatch(), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), autoRotateSourceText: this.getAutoRotateSourceText(), northReferenceText: formatNorthReferenceText(this.northReferenceMode), } : {}), }) } scheduleCompassNeedleFollow(): void { if ( this.compassNeedleTimer || this.targetCompassDisplayHeadingDeg === null || this.compassDisplayHeadingDeg === null ) { return } const step = () => { this.compassNeedleTimer = 0 if ( this.targetCompassDisplayHeadingDeg === null || this.compassDisplayHeadingDeg === null ) { return } const deltaDeg = normalizeAngleDeltaDeg( this.targetCompassDisplayHeadingDeg - this.compassDisplayHeadingDeg, ) const absDeltaDeg = Math.abs(deltaDeg) if (absDeltaDeg <= COMPASS_NEEDLE_SNAP_DEG) { if (absDeltaDeg > 0.001) { this.compassDisplayHeadingDeg = this.targetCompassDisplayHeadingDeg this.syncCompassDisplayState() } return } this.compassDisplayHeadingDeg = interpolateAngleDeg( this.compassDisplayHeadingDeg, this.targetCompassDisplayHeadingDeg, getCompassNeedleSmoothingFactor( this.compassDisplayHeadingDeg, this.targetCompassDisplayHeadingDeg, this.compassTuningProfile, ), ) this.syncCompassDisplayState() this.scheduleCompassNeedleFollow() } this.compassNeedleTimer = setTimeout(step, COMPASS_NEEDLE_FRAME_MS) as unknown as number } pickViewPatch(patch: Partial): Partial { const viewPatch = {} as Partial for (const key of VIEW_SYNC_KEYS) { if (Object.prototype.hasOwnProperty.call(patch, key)) { ;(viewPatch as any)[key] = patch[key] } } return viewPatch } flushViewPatch(): void { if (!Object.keys(this.pendingViewPatch).length) { return } const patch = this.pendingViewPatch const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer const nextPendingPatch = {} as Partial const outputPatch = {} as Partial for (const [key, value] of Object.entries(patch) as Array<[keyof MapEngineViewState, MapEngineViewState[keyof MapEngineViewState]]>) { if (shouldDeferForInteraction && INTERACTION_DEFERRED_VIEW_KEYS.has(key)) { ;(nextPendingPatch as Record)[key] = value continue } ;(outputPatch as Record)[key] = value } this.pendingViewPatch = nextPendingPatch if (Object.keys(this.pendingViewPatch).length && !this.viewSyncTimer) { this.viewSyncTimer = setTimeout(() => { this.viewSyncTimer = 0 this.flushViewPatch() }, UI_SYNC_INTERVAL_MS) as unknown as number } if (!Object.keys(outputPatch).length) { return } this.onData(outputPatch) } getTouchDistance(touches: TouchPoint[]): number { if (touches.length < 2) { return 0 } const first = touches[0] const second = touches[1] const deltaX = first.pageX - second.pageX const deltaY = first.pageY - second.pageY return Math.sqrt(deltaX * deltaX + deltaY * deltaY) } getTouchAngle(touches: TouchPoint[]): number { if (touches.length < 2) { return 0 } const first = touches[0] const second = touches[1] return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX) } getStagePoint(touches: TouchPoint[]): { x: number; y: number } { if (!touches.length) { return { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2, } } let pageX = 0 let pageY = 0 for (const touch of touches) { pageX += touch.pageX pageY += touch.pageY } return { x: pageX / touches.length - this.state.stageLeft, y: pageY / touches.length - this.state.stageTop, } } animatePreviewToRest(): void { this.clearPreviewResetTimer() const startScale = this.previewScale || 1 const originX = this.previewOriginX || this.state.stageWidth / 2 const originY = this.previewOriginY || this.state.stageHeight / 2 if (Math.abs(startScale - 1) < 0.01) { this.resetPreviewState() this.syncRenderer() this.compassController.start() this.scheduleAutoRotate() return } const startAt = Date.now() const step = () => { const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS) const eased = 1 - Math.pow(1 - progress, 3) const nextScale = startScale + (1 - startScale) * eased this.setPreviewState(nextScale, originX, originY) this.syncRenderer() this.compassController.start() if (progress >= 1) { this.resetPreviewState() this.syncRenderer() this.compassController.start() this.previewResetTimer = 0 this.scheduleAutoRotate() return } this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number } step() } normalizeTranslate(translateX: number, translateY: number, statusText: string): void { if (!this.state.stageWidth) { this.setState({ tileTranslateX: translateX, tileTranslateY: translateY, }) this.syncRenderer() this.compassController.start() return } const exactCenter = this.getExactCenterFromTranslate(translateX, translateY) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y) const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY if (centerChanged) { this.commitViewport(resolvedViewport, statusText) return } this.setState({ tileTranslateX: resolvedViewport.tileTranslateX, tileTranslateY: resolvedViewport.tileTranslateY, }) this.syncRenderer() this.compassController.start() } zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void { const nextZoom = clamp(this.state.zoom + zoomDelta, this.minZoom, this.maxZoom) const appliedDelta = nextZoom - this.state.zoom if (!appliedDelta) { this.animatePreviewToRest() return } if (this.gpsLockEnabled && this.currentGpsPoint) { const nextGpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, nextZoom) const resolvedViewport = this.resolveViewportForExactCenter(nextGpsWorldPoint.x, nextGpsWorldPoint.y) this.commitViewport( { zoom: nextZoom, ...resolvedViewport, }, `缩放级别调整到 ${nextZoom}`, true, () => { this.setPreviewState(residualScale, this.state.stageWidth / 2, this.state.stageHeight / 2) this.syncRenderer() this.compassController.start() this.animatePreviewToRest() }, ) return } if (!this.state.stageWidth || !this.state.stageHeight) { this.commitViewport( { zoom: nextZoom, centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2), centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2), tileTranslateX: 0, tileTranslateY: 0, }, `缩放级别调整到 ${nextZoom}`, true, () => { this.setPreviewState(residualScale, stageX, stageY) this.syncRenderer() this.compassController.start() this.animatePreviewToRest() }, ) return } const camera = this.getCameraState() const world = screenToWorld(camera, { x: stageX, y: stageY }, true) const zoomFactor = Math.pow(2, appliedDelta) const nextWorldX = world.x * zoomFactor const nextWorldY = world.y * zoomFactor const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY) const exactCenterX = nextWorldX - anchorOffset.x const exactCenterY = nextWorldY - anchorOffset.y const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY) this.commitViewport( { zoom: nextZoom, ...resolvedViewport, }, `缩放级别调整到 ${nextZoom}`, true, () => { this.setPreviewState(residualScale, stageX, stageY) this.syncRenderer() this.compassController.start() this.animatePreviewToRest() }, ) } startInertia(): void { this.clearInertiaTimer() const step = () => { this.panVelocityX *= INERTIA_DECAY this.panVelocityY *= INERTIA_DECAY if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) { this.setState({ statusText: `惯性滑动结束 (${this.buildVersion})`, }) this.renderer.setAnimationPaused(false) this.inertiaTimer = 0 this.scheduleAutoRotate() return } this.normalizeTranslate( this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS, this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS, `惯性滑动中 (${this.buildVersion})`, ) this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number } this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number } }