import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera' import { CompassHeadingController } from '../sensor/compassHeadingController' 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 { GameRuntime } from '../../game/core/gameRuntime' import { type GameEffect } 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' 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.32 const COMPASS_NEEDLE_SMOOTHING = 0.12 const GPS_TRACK_MAX_POINTS = 200 const GPS_TRACK_MIN_STEP_METERS = 3 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' 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 { 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 compassDeclinationText: string 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 stageWidth: number stageHeight: number stageLeft: number stageTop: number statusText: string gpsTracking: boolean gpsTrackingText: string gpsCoordText: string gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed' panelProgressText: string punchButtonText: string punchButtonEnabled: boolean punchHintText: string punchFeedbackVisible: boolean punchFeedbackText: string punchFeedbackTone: 'neutral' | 'success' | 'warning' contentCardVisible: boolean contentCardTitle: string contentCardBody: string punchButtonFxClass: 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 } const VIEW_SYNC_KEYS: Array = [ 'buildVersion', 'renderMode', 'projectionMode', 'mapReady', 'mapReadyText', 'mapName', 'configStatusText', 'zoom', 'rotationDeg', 'rotationText', 'rotationMode', 'rotationModeText', 'rotationToggleText', 'orientationMode', 'orientationModeText', 'sensorHeadingText', 'compassDeclinationText', 'northReferenceButtonText', 'autoRotateSourceText', 'autoRotateCalibrationText', 'northReferenceText', 'compassNeedleDeg', 'centerText', 'tileSource', 'visibleTileCount', 'readyTileCount', 'memoryTileCount', 'diskTileCount', 'memoryHitCount', 'diskHitCount', 'networkFetchCount', 'cacheHitRateText', 'tileSizePx', 'statusText', 'gpsTracking', 'gpsTrackingText', 'gpsCoordText', 'gameSessionStatus', 'panelProgressText', 'punchButtonText', 'punchButtonEnabled', 'punchHintText', 'punchFeedbackVisible', 'punchFeedbackText', 'punchFeedbackTone', 'contentCardVisible', 'contentCardTitle', 'contentCardBody', 'punchButtonFxClass', 'punchFeedbackFxClass', 'contentCardFxClass', 'mapPulseVisible', 'mapPulseLeftPx', 'mapPulseTopPx', 'mapPulseFxClass', 'stageFxVisible', 'stageFxClass', 'osmReferenceEnabled', 'osmReferenceText', ] 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 formatRotationText(rotationDeg: number): string { return `${Math.round(normalizeRotationDeg(rotationDeg))}deg` } function formatHeadingText(headingDeg: number | null): string { if (headingDeg === null) { return '--' } return `${Math.round(normalizeRotationDeg(headingDeg))}掳` } 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 === 'sensor') { return 'Sensor Only' } if (mode === 'course') { return hasCourseHeading ? 'Course Only' : 'Course Pending' } return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only' } 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 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 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 renderer: WebGLMapRenderer compassController: CompassHeadingController locationController: LocationController feedbackDirector: FeedbackDirector onData: (patch: Partial) => void state: MapEngineViewState previewScale: number previewOriginX: number previewOriginY: number panLastX: number panLastY: number panLastTimestamp: 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 pendingViewPatch: Partial mounted: boolean northReferenceMode: NorthReferenceMode sensorHeadingDeg: number | null smoothedSensorHeadingDeg: number | null compassDisplayHeadingDeg: number | null autoRotateHeadingDeg: number | null courseHeadingDeg: number | null targetAutoRotationDeg: number | null autoRotateSourceMode: AutoRotateSourceMode autoRotateCalibrationOffsetDeg: number | null autoRotateCalibrationPending: boolean minZoom: number maxZoom: number defaultZoom: number defaultCenterTileX: number defaultCenterTileY: number tileBoundsByZoom: Record | null currentGpsPoint: LonLatPoint | null currentGpsTrack: LonLatPoint[] currentGpsAccuracyMeters: number | null courseData: OrienteeringCourseData | null cpRadiusMeters: number gameRuntime: GameRuntime gamePresentation: GamePresentationState gameMode: 'classic-sequential' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number autoFinishOnLastControl: boolean punchFeedbackTimer: number contentCardTimer: number mapPulseTimer: number stageFxTimer: number hasGpsCenteredOnce: boolean constructor(buildVersion: string, callbacks: MapEngineCallbacks) { this.buildVersion = buildVersion this.onData = callbacks.onData this.renderer = new WebGLMapRenderer( (stats) => { this.applyStats(stats) }, (message) => { this.setState({ statusText: `${message} (${this.buildVersion})`, }) }, ) this.compassController = new CompassHeadingController({ onHeading: (headingDeg) => { this.handleCompassHeading(headingDeg) }, onError: (message) => { this.handleCompassError(message) }, }) 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, }, true) }, onError: (message) => { this.setState({ gpsTracking: false, gpsTrackingText: message, statusText: `${message} (${this.buildVersion})`, }, true) }, }) this.feedbackDirector = new FeedbackDirector({ showPunchFeedback: (text, tone, motionClass) => { this.showPunchFeedback(text, tone, motionClass) }, showContentCard: (title, body, motionClass) => { this.showContentCard(title, body, motionClass) }, setPunchButtonFxClass: (className) => { this.setPunchButtonFxClass(className) }, showMapPulse: (controlId, motionClass) => { this.showMapPulse(controlId, motionClass) }, showStageFx: (className) => { this.showStageFx(className) }, stopLocationTracking: () => { if (this.locationController.listening) { this.locationController.stop() } }, }) 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.courseData = null this.cpRadiusMeters = 5 this.gameRuntime = new GameRuntime() this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE this.gameMode = 'classic-sequential' this.punchPolicy = 'enter-confirm' this.punchRadiusMeters = 5 this.autoFinishOnLastControl = true this.punchFeedbackTimer = 0 this.contentCardTimer = 0 this.mapPulseTimer = 0 this.stageFxTimer = 0 this.hasGpsCenteredOnce = false this.state = { buildVersion: this.buildVersion, renderMode: RENDER_MODE, projectionMode: PROJECTION_MODE, mapReady: false, mapReadyText: 'BOOTING', mapName: 'LCX 娴嬭瘯鍦板浘', configStatusText: '远程配置待加载', zoom: DEFAULT_ZOOM, rotationDeg: 0, rotationText: formatRotationText(0), rotationMode: 'manual', rotationModeText: formatRotationModeText('manual'), rotationToggleText: formatRotationToggleText('manual'), orientationMode: 'manual', orientationModeText: formatOrientationModeText('manual'), sensorHeadingText: '--', compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE), northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE), autoRotateSourceText: formatAutoRotateSourceText('sensor', 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, stageWidth: 0, stageHeight: 0, stageLeft: 0, stageTop: 0, statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`, gpsTracking: false, gpsTrackingText: '持续定位待启动', gpsCoordText: '--', panelProgressText: '0/0', punchButtonText: '鎵撶偣', gameSessionStatus: 'idle', punchButtonEnabled: false, punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿', punchFeedbackVisible: false, punchFeedbackText: '', punchFeedbackTone: 'neutral', contentCardVisible: false, contentCardTitle: '', contentCardBody: '', punchButtonFxClass: '', 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.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.pendingViewPatch = {} this.mounted = false this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE this.sensorHeadingDeg = null this.smoothedSensorHeadingDeg = null this.compassDisplayHeadingDeg = null this.autoRotateHeadingDeg = null this.courseHeadingDeg = null this.targetAutoRotationDeg = null this.autoRotateSourceMode = 'sensor' this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE) this.autoRotateCalibrationPending = false } getInitialData(): MapEngineViewState { return { ...this.state } } destroy(): void { this.clearInertiaTimer() this.clearPreviewResetTimer() this.clearViewSyncTimer() this.clearAutoRotateTimer() this.clearPunchFeedbackTimer() this.clearContentCardTimer() this.clearMapPulseTimer() this.clearStageFxTimer() this.compassController.destroy() this.locationController.destroy() this.feedbackDirector.destroy() this.renderer.destroy() this.mounted = false } clearGameRuntime(): void { this.gameRuntime.clear() this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE this.setCourseHeading(null) } loadGameDefinitionFromCourse(): GameEffect[] { if (!this.courseData) { this.clearGameRuntime() return [] } const definition = buildGameDefinitionFromCourse( this.courseData, this.cpRadiusMeters, this.gameMode, this.autoFinishOnLastControl, this.punchPolicy, this.punchRadiusMeters, ) const result = this.gameRuntime.loadDefinition(definition) this.gamePresentation = result.presentation this.refreshCourseHeadingFromPresentation() return result.effects } refreshCourseHeadingFromPresentation(): void { if (!this.courseData || !this.gamePresentation.activeLegIndices.length) { this.setCourseHeading(null) return } const activeLegIndex = this.gamePresentation.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 patch: Partial = { gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle', panelProgressText: this.gamePresentation.progressText, punchButtonText: this.gamePresentation.punchButtonText, punchButtonEnabled: this.gamePresentation.punchButtonEnabled, punchHintText: this.gamePresentation.punchHintText, } 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 } } 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) } 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 = ''): void { this.clearContentCardTimer() this.setState({ contentCardVisible: true, contentCardTitle: title, contentCardBody: body, contentCardFxClass: motionClass, }, true) this.contentCardTimer = setTimeout(() => { this.contentCardTimer = 0 this.setState({ contentCardVisible: false, contentCardFxClass: '', }, true) }, 2600) as unknown as number } closeContentCard(): void { this.clearContentCardTimer() this.setState({ contentCardVisible: false, contentCardFxClass: '', }, true) } applyGameEffects(effects: GameEffect[]): string | null { this.feedbackDirector.handleEffects(effects) return this.resolveGameStatusText(effects) } handleStartGame(): void { if (!this.gameRuntime.definition || !this.gameRuntime.state) { this.setState({ statusText: `当前还没有可开始的路线 (${this.buildVersion})`, }, true) return } if (this.gameRuntime.state.status !== 'idle') { return } if (!this.locationController.listening) { this.locationController.start() } const startedAt = Date.now() let gameResult = this.gameRuntime.startSession(startedAt) if (this.currentGpsPoint) { gameResult = this.gameRuntime.dispatch({ type: 'gps_updated', at: Date.now(), lon: this.currentGpsPoint.lon, lat: this.currentGpsPoint.lat, accuracyMeters: this.currentGpsAccuracyMeters, }) } this.gamePresentation = this.gameRuntime.getPresentation() this.refreshCourseHeadingFromPresentation() const defaultStatusText = this.currentGpsPoint ? `顺序打点已开始 (${this.buildVersion})` : `顺序打点已开始,GPS定位启动中 (${this.buildVersion})` const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText this.setState({ ...this.getGameViewPatch(gameStatusText), }, true) this.syncRenderer() } handlePunchAction(): void { const gameResult = this.gameRuntime.dispatch({ type: 'punch_requested', at: Date.now(), }) this.gamePresentation = gameResult.presentation this.refreshCourseHeadingFromPresentation() const gameStatusText = this.applyGameEffects(gameResult.effects) this.setState({ ...this.getGameViewPatch(gameStatusText), }, true) this.syncRenderer() } 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 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) let gameStatusText: string | null = null if (this.courseData) { const gameResult = this.gameRuntime.dispatch({ type: 'gps_updated', at: Date.now(), lon: longitude, lat: latitude, accuracyMeters, }) this.gamePresentation = gameResult.presentation this.refreshCourseHeadingFromPresentation() gameStatusText = this.applyGameEffects(gameResult.effects) } if (gpsInsideMap && !this.hasGpsCenteredOnce) { this.hasGpsCenteredOnce = true this.commitViewport({ centerTileX: gpsWorldPoint.x, centerTileY: gpsWorldPoint.y, tileTranslateX: 0, tileTranslateY: 0, gpsTracking: true, gpsTrackingText: '持续定位进行中', gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters), ...this.getGameViewPatch(), }, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true) return } this.setState({ gpsTracking: true, gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内', gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters), ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `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() } 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 { 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.compassController.start() } applyRemoteMapConfig(config: RemoteMapConfig): void { MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg MAGNETIC_DECLINATION_TEXT = 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.gameMode = config.gameMode this.punchPolicy = config.punchPolicy this.punchRadiusMeters = config.punchRadiusMeters this.autoFinishOnLastControl = config.autoFinishOnLastControl this.feedbackDirector.configure({ audioConfig: config.audioConfig, hapticsConfig: config.hapticsConfig, uiEffectsConfig: config.uiEffectsConfig, }) const gameEffects = this.loadGameDefinitionFromCourse() const gameStatusText = this.applyGameEffects(gameEffects) const statePatch: Partial = { configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`, projectionMode: config.projectionModeText, tileSource: config.tileSource, sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)), compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode), compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg), ...this.getGameViewPatch(), } 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.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 = 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() } } 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.getStagePoint(event.touches) if (!this.pinchStartDistance) { this.pinchStartDistance = distance this.pinchStartScale = this.previewScale || 1 this.pinchStartAngle = angle this.pinchStartRotationDeg = this.state.rotationDeg const anchorWorld = 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 this.normalizeTranslate( this.state.tileTranslateX + deltaX, this.state.tileTranslateY + deltaY, `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`, ) } handleTouchEnd(event: WechatMiniprogram.TouchEvent): void { if (this.gestureMode === 'pinch' && event.touches.length < 2) { const gestureScale = this.previewScale || 1 const zoomDelta = Math.round(Math.log2(gestureScale)) const originX = this.previewOriginX || this.state.stageWidth / 2 const originY = 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 } 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() } 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() } 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'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), northReferenceText: formatNorthReferenceText(this.northReferenceMode), statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`, }, true) if (this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } } handleCompassHeading(headingDeg: number): void { 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) this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null ? compassHeadingDeg : interpolateAngleDeg(this.compassDisplayHeadingDeg, compassHeadingDeg, COMPASS_NEEDLE_SMOOTHING) this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg() this.setState({ sensorHeadingText: formatHeadingText(compassHeadingDeg), compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null), compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg), northReferenceText: formatNorthReferenceText(this.northReferenceMode), }) if (!this.refreshAutoRotateTarget()) { return } if (this.state.orientationMode === 'heading-up') { this.scheduleAutoRotate() } } handleCompassError(message: string): void { this.clearAutoRotateTimer() this.targetAutoRotationDeg = null this.autoRotateCalibrationPending = false this.setState({ autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), statusText: `${message} (${this.buildVersion})`, }, true) } cycleNorthReferenceMode(): void { const nextMode = getNextNorthReferenceMode(this.northReferenceMode) const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode) const compassHeadingDeg = this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg) this.northReferenceMode = nextMode this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg this.compassDisplayHeadingDeg = 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(compassHeadingDeg), compassDeclinationText: formatCompassDeclinationText(nextMode), northReferenceButtonText: formatNorthReferenceButtonText(nextMode), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), }, `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() }, ) return } this.setState({ northReferenceText: formatNorthReferenceText(nextMode), sensorHeadingText: formatHeadingText(compassHeadingDeg), compassDeclinationText: formatCompassDeclinationText(nextMode), northReferenceButtonText: formatNorthReferenceButtonText(nextMode), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`, }, true) if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } } setCourseHeading(headingDeg: number | null): void { this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg) this.setState({ autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null), }) if (this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } } resolveAutoRotateInputHeadingDeg(): number | null { const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null ? null : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg) 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 { this.setState({ 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), }) } 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) 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), 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.courseData, cpRadiusMeters: this.cpRadiusMeters, activeControlSequences: this.gamePresentation.activeControlSequences, activeStart: this.gamePresentation.activeStart, completedStart: this.gamePresentation.completedStart, activeFinish: this.gamePresentation.activeFinish, completedFinish: this.gamePresentation.completedFinish, revealFullCourse: this.gamePresentation.revealFullCourse, activeLegIndices: this.gamePresentation.activeLegIndices, completedLegIndices: this.gamePresentation.completedLegIndices, completedControlSequences: this.gamePresentation.completedControlSequences, osmReferenceEnabled: this.state.osmReferenceEnabled, overlayOpacity: MAP_OVERLAY_OPACITY, } } 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 } 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 } } 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 this.pendingViewPatch = {} this.onData(patch) } 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.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 } }