import { MapEngine, type MapEngineGameInfoRow, type MapEngineGameInfoSnapshot, type MapEngineResultSnapshot, type MapEngineStageRect, type MapEngineViewState, } from '../../engine/map/mapEngine' import { loadRemoteMapConfig } from '../../utils/remoteMapConfig' import { type AnimationLevel } from '../../utils/animationLevel' import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience' type CompassTickData = { angle: number long: boolean major: boolean } type CompassLabelData = { text: string angle: number rotateBack: number radius: number className: string } type ScaleRulerMinorTickData = { key: string topPx: number long: boolean } type ScaleRulerMajorMarkData = { key: string topPx: number label: string } type SideButtonMode = 'shown' | 'hidden' type SideActionButtonState = 'muted' | 'default' | 'active' type SideButtonPlacement = 'left' | 'right' type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center' type UserNorthReferenceMode = 'magnetic' | 'true' type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive' type SettingLockKey = | 'lockAnimationLevel' | 'lockSideButtonPlacement' | 'lockAutoRotate' | 'lockCompassTuning' | 'lockScaleRulerVisible' | 'lockScaleRulerAnchor' | 'lockNorthReference' | 'lockHeartRateDevice' type StoredUserSettings = { animationLevel?: AnimationLevel autoRotateEnabled?: boolean compassTuningProfile?: CompassTuningProfile northReferenceMode?: UserNorthReferenceMode sideButtonPlacement?: SideButtonPlacement showCenterScaleRuler?: boolean centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode lockAnimationLevel?: boolean lockSideButtonPlacement?: boolean lockAutoRotate?: boolean lockCompassTuning?: boolean lockScaleRulerVisible?: boolean lockScaleRulerAnchor?: boolean lockNorthReference?: boolean lockHeartRateDevice?: boolean } type MapPageData = MapEngineViewState & { showDebugPanel: boolean showGameInfoPanel: boolean showResultScene: boolean showSystemSettingsPanel: boolean showCenterScaleRuler: boolean showPunchHintBanner: boolean centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode statusBarHeight: number topInsetHeight: number hudPanelIndex: number configSourceText: string mockBridgeUrlDraft: string mockHeartRateBridgeUrlDraft: string gameInfoTitle: string gameInfoSubtitle: string gameInfoLocalRows: MapEngineGameInfoRow[] gameInfoGlobalRows: MapEngineGameInfoRow[] resultSceneTitle: string resultSceneSubtitle: string resultSceneHeroLabel: string resultSceneHeroValue: string resultSceneRows: MapEngineGameInfoRow[] panelTimerText: string panelMileageText: string panelDistanceValueText: string panelProgressText: string panelSpeedValueText: string panelTimerFxClass: string panelMileageFxClass: string panelSpeedFxClass: string panelHeartRateFxClass: string compassTicks: CompassTickData[] compassLabels: CompassLabelData[] sideButtonMode: SideButtonMode sideButtonPlacement: SideButtonPlacement autoRotateEnabled: boolean lockAnimationLevel: boolean lockSideButtonPlacement: boolean lockAutoRotate: boolean lockCompassTuning: boolean lockScaleRulerVisible: boolean lockScaleRulerAnchor: boolean lockNorthReference: boolean lockHeartRateDevice: boolean sideToggleIconSrc: string sideButton2Class: string sideButton4Class: string sideButton11Class: string sideButton12Class: string sideButton13Class: string sideButton14Class: string sideButton16Class: string centerScaleRulerVisible: boolean centerScaleRulerCenterXPx: number centerScaleRulerZeroYPx: number centerScaleRulerHeightPx: number centerScaleRulerAxisBottomPx: number centerScaleRulerZeroVisible: boolean centerScaleRulerZeroLabel: string centerScaleRulerMinorTicks: ScaleRulerMinorTickData[] centerScaleRulerMajorMarks: ScaleRulerMajorMarkData[] showLeftButtonGroup: boolean showRightButtonGroups: boolean showBottomDebugButton: boolean } const INTERNAL_BUILD_VERSION = 'map-build-291' const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1' const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json' const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json' const PUNCH_HINT_AUTO_HIDE_MS = 30000 let mapEngine: MapEngine | null = null let stageCanvasAttached = false let gameInfoPanelSyncTimer = 0 let centerScaleRulerSyncTimer = 0 let centerScaleRulerUpdateTimer = 0 let punchHintDismissTimer = 0 let panelTimerFxTimer = 0 let panelMileageFxTimer = 0 let panelSpeedFxTimer = 0 let panelHeartRateFxTimer = 0 let lastCenterScaleRulerStablePatch: Pick< MapPageData, | 'centerScaleRulerVisible' | 'centerScaleRulerCenterXPx' | 'centerScaleRulerZeroYPx' | 'centerScaleRulerHeightPx' | 'centerScaleRulerAxisBottomPx' | 'centerScaleRulerZeroVisible' | 'centerScaleRulerZeroLabel' | 'centerScaleRulerMinorTicks' | 'centerScaleRulerMajorMarks' > = { centerScaleRulerVisible: false, centerScaleRulerCenterXPx: 0, centerScaleRulerZeroYPx: 0, centerScaleRulerHeightPx: 0, centerScaleRulerAxisBottomPx: 0, centerScaleRulerZeroVisible: false, centerScaleRulerZeroLabel: '0 m', centerScaleRulerMinorTicks: [], centerScaleRulerMajorMarks: [], } let centerScaleRulerInputCache: Partial> = {} const DEBUG_ONLY_VIEW_KEYS = new Set([ 'buildVersion', 'renderMode', 'projectionMode', 'mapReady', 'mapReadyText', 'mapName', 'configStatusText', 'deviceHeadingText', 'devicePoseText', 'headingConfidenceText', 'accelerometerText', 'gyroscopeText', 'deviceMotionText', 'compassSourceText', 'compassTuningProfile', 'compassTuningProfileText', 'northReferenceButtonText', 'autoRotateSourceText', 'autoRotateCalibrationText', 'northReferenceText', 'centerText', 'tileSource', 'visibleTileCount', 'readyTileCount', 'memoryTileCount', 'diskTileCount', 'memoryHitCount', 'diskHitCount', 'networkFetchCount', 'cacheHitRateText', 'locationSourceMode', 'locationSourceText', 'mockBridgeConnected', 'mockBridgeStatusText', 'mockBridgeUrlText', 'mockCoordText', 'mockSpeedText', 'gpsCoordText', 'heartRateSourceMode', 'heartRateSourceText', 'heartRateConnected', 'heartRateStatusText', 'heartRateDeviceText', 'heartRateScanText', 'heartRateDiscoveredDevices', 'mockHeartRateBridgeConnected', 'mockHeartRateBridgeStatusText', 'mockHeartRateBridgeUrlText', 'mockHeartRateText', ]) const CENTER_SCALE_RULER_DEP_KEYS = new Set([ 'showCenterScaleRuler', 'centerScaleRulerAnchorMode', 'stageWidth', 'stageHeight', 'topInsetHeight', 'zoom', 'centerTileY', 'tileSizePx', 'previewScale', ]) const CENTER_SCALE_RULER_CACHE_KEYS: Array = [ 'stageWidth', 'stageHeight', 'zoom', 'centerTileY', 'tileSizePx', 'previewScale', ] const RULER_ONLY_VIEW_KEYS = new Set([ 'zoom', 'centerTileX', 'centerTileY', 'tileSizePx', 'previewScale', 'stageWidth', 'stageHeight', 'stageLeft', 'stageTop', ]) const SIDE_BUTTON_DEP_KEYS = new Set([ 'sideButtonMode', 'showGameInfoPanel', 'showCenterScaleRuler', 'centerScaleRulerAnchorMode', 'skipButtonEnabled', 'gameSessionStatus', 'gpsLockEnabled', 'gpsLockAvailable', ]) function hasAnyPatchKey(patch: Record, keys: Set): boolean { return Object.keys(patch).some((key) => keys.has(key)) } function filterDebugOnlyPatch( patch: Partial, includeDebugFields: boolean, includeRulerFields: boolean, ): Partial { if (includeDebugFields && includeRulerFields) { return patch } const filteredPatch: Partial = {} for (const [key, value] of Object.entries(patch)) { if (!includeDebugFields && DEBUG_ONLY_VIEW_KEYS.has(key)) { continue } if (!includeRulerFields && RULER_ONLY_VIEW_KEYS.has(key)) { continue } { ;(filteredPatch as Record)[key] = value } } return filteredPatch } function clearGameInfoPanelSyncTimer() { if (gameInfoPanelSyncTimer) { clearTimeout(gameInfoPanelSyncTimer) gameInfoPanelSyncTimer = 0 } } function clearCenterScaleRulerSyncTimer() { if (centerScaleRulerSyncTimer) { clearTimeout(centerScaleRulerSyncTimer) centerScaleRulerSyncTimer = 0 } } function clearCenterScaleRulerUpdateTimer() { if (centerScaleRulerUpdateTimer) { clearTimeout(centerScaleRulerUpdateTimer) centerScaleRulerUpdateTimer = 0 } } function clearPunchHintDismissTimer() { if (punchHintDismissTimer) { clearTimeout(punchHintDismissTimer) punchHintDismissTimer = 0 } } function clearHudFxTimer(key: 'timer' | 'mileage' | 'speed' | 'heartRate') { const timerMap = { timer: panelTimerFxTimer, mileage: panelMileageFxTimer, speed: panelSpeedFxTimer, heartRate: panelHeartRateFxTimer, } const timer = timerMap[key] if (timer) { clearTimeout(timer) } if (key === 'timer') { panelTimerFxTimer = 0 } else if (key === 'mileage') { panelMileageFxTimer = 0 } else if (key === 'speed') { panelSpeedFxTimer = 0 } else { panelHeartRateFxTimer = 0 } } function updateCenterScaleRulerInputCache(patch: Partial) { for (const key of CENTER_SCALE_RULER_CACHE_KEYS) { if (Object.prototype.hasOwnProperty.call(patch, key)) { ;(centerScaleRulerInputCache as Record)[key] = (patch as Record)[key] } } } function loadStoredUserSettings(): StoredUserSettings { try { const stored = wx.getStorageSync(USER_SETTINGS_STORAGE_KEY) if (!stored || typeof stored !== 'object') { return {} } const normalized = stored as Record const settings: StoredUserSettings = {} if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') { settings.animationLevel = normalized.animationLevel } if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') { settings.northReferenceMode = normalized.northReferenceMode } if (typeof normalized.autoRotateEnabled === 'boolean') { settings.autoRotateEnabled = normalized.autoRotateEnabled } if (normalized.compassTuningProfile === 'smooth' || normalized.compassTuningProfile === 'balanced' || normalized.compassTuningProfile === 'responsive') { settings.compassTuningProfile = normalized.compassTuningProfile } if (normalized.sideButtonPlacement === 'left' || normalized.sideButtonPlacement === 'right') { settings.sideButtonPlacement = normalized.sideButtonPlacement } if (typeof normalized.showCenterScaleRuler === 'boolean') { settings.showCenterScaleRuler = normalized.showCenterScaleRuler } if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') { settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode } if (typeof normalized.lockAnimationLevel === 'boolean') { settings.lockAnimationLevel = normalized.lockAnimationLevel } if (typeof normalized.lockSideButtonPlacement === 'boolean') { settings.lockSideButtonPlacement = normalized.lockSideButtonPlacement } if (typeof normalized.lockAutoRotate === 'boolean') { settings.lockAutoRotate = normalized.lockAutoRotate } if (typeof normalized.lockCompassTuning === 'boolean') { settings.lockCompassTuning = normalized.lockCompassTuning } if (typeof normalized.lockScaleRulerVisible === 'boolean') { settings.lockScaleRulerVisible = normalized.lockScaleRulerVisible } if (typeof normalized.lockScaleRulerAnchor === 'boolean') { settings.lockScaleRulerAnchor = normalized.lockScaleRulerAnchor } if (typeof normalized.lockNorthReference === 'boolean') { settings.lockNorthReference = normalized.lockNorthReference } if (typeof normalized.lockHeartRateDevice === 'boolean') { settings.lockHeartRateDevice = normalized.lockHeartRateDevice } return settings } catch { return {} } } function persistStoredUserSettings(settings: StoredUserSettings) { try { wx.setStorageSync(USER_SETTINGS_STORAGE_KEY, settings) } catch {} } function toggleStoredSettingLock(settings: StoredUserSettings, key: SettingLockKey): StoredUserSettings { return { ...settings, [key]: !settings[key], } } function buildSideButtonVisibility(mode: SideButtonMode) { return { sideButtonMode: mode, showLeftButtonGroup: mode === 'shown', showRightButtonGroups: false, showBottomDebugButton: true, } } function getNextSideButtonMode(currentMode: SideButtonMode): SideButtonMode { return currentMode === 'shown' ? 'hidden' : 'shown' } function buildCompassTicks(): CompassTickData[] { const ticks: CompassTickData[] = [] for (let angle = 0; angle < 360; angle += 5) { ticks.push({ angle, long: angle % 15 === 0, major: angle % 45 === 0, }) } return ticks } function buildCompassLabels(): CompassLabelData[] { return [ { text: '\u5317', angle: 0, rotateBack: 0, radius: 68, className: 'compass-widget__mark--cardinal compass-widget__mark--north' }, { text: '\u4e1c\u5317', angle: 45, rotateBack: 0, radius: 58, className: 'compass-widget__mark--intermediate compass-widget__mark--northeast' }, { text: '\u4e1c', angle: 90, rotateBack: 0, radius: 68, className: 'compass-widget__mark--cardinal' }, { text: '\u4e1c\u5357', angle: 135, rotateBack: 0, radius: 58, className: 'compass-widget__mark--intermediate' }, { text: '\u5357', angle: 180, rotateBack: 0, radius: 68, className: 'compass-widget__mark--cardinal' }, { text: '\u897f\u5357', angle: 225, rotateBack: 0, radius: 58, className: 'compass-widget__mark--intermediate' }, { text: '\u897f', angle: 270, rotateBack: 0, radius: 68, className: 'compass-widget__mark--cardinal' }, { text: '\u897f\u5317', angle: 315, rotateBack: 0, radius: 58, className: 'compass-widget__mark--intermediate compass-widget__mark--northwest' }, ] } function getFallbackStageRect(): MapEngineStageRect { const systemInfo = wx.getSystemInfoSync() const width = Math.max(320, systemInfo.windowWidth) const height = Math.max(280, systemInfo.windowHeight) return { width, height, left: 0, top: 0, } } function getSideToggleIconSrc(mode: SideButtonMode): string { if (mode === 'hidden') { return '../../assets/btn_more1.png' } return '../../assets/btn_more3.png' } function getSideActionButtonClass(state: SideActionButtonState): string { if (state === 'muted') { return 'map-side-button map-side-button--muted' } if (state === 'active') { return 'map-side-button map-side-button--active' } return 'map-side-button map-side-button--default' } function buildSideButtonState(data: Pick) { const sideButton2State: SideActionButtonState = !data.gpsLockAvailable ? 'muted' : data.gpsLockEnabled ? 'active' : 'default' const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'running' ? 'active' : 'muted' const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default' const sideButton12State: SideActionButtonState = data.showSystemSettingsPanel ? 'active' : 'default' const sideButton13State: SideActionButtonState = data.showCenterScaleRuler ? 'active' : 'default' const sideButton14State: SideActionButtonState = !data.showCenterScaleRuler ? 'muted' : data.centerScaleRulerAnchorMode === 'compass-center' ? 'active' : 'default' const sideButton16State: SideActionButtonState = data.skipButtonEnabled ? 'default' : 'muted' return { sideToggleIconSrc: getSideToggleIconSrc(data.sideButtonMode), sideButton2Class: getSideActionButtonClass(sideButton2State), sideButton4Class: getSideActionButtonClass(sideButton4State), sideButton11Class: getSideActionButtonClass(sideButton11State), sideButton12Class: getSideActionButtonClass(sideButton12State), sideButton13Class: getSideActionButtonClass(sideButton13State), sideButton14Class: getSideActionButtonClass(sideButton14State), sideButton16Class: getSideActionButtonClass(sideButton16State), } } function getRpxUnitInPx(): number { const systemInfo = wx.getSystemInfoSync() return systemInfo.windowWidth / 750 } function worldTileYToLat(worldTileY: number, zoom: number): number { const scale = Math.pow(2, zoom) const n = Math.PI - (2 * Math.PI * worldTileY) / scale return (180 / Math.PI) * Math.atan(Math.sinh(n)) } function getNiceDistanceMeters(rawDistanceMeters: number): number { if (!Number.isFinite(rawDistanceMeters) || rawDistanceMeters <= 0) { return 50 } const exponent = Math.floor(Math.log10(rawDistanceMeters)) const base = Math.pow(10, exponent) const normalized = rawDistanceMeters / base if (normalized <= 1) { return base } if (normalized <= 2) { return 2 * base } if (normalized <= 5) { return 5 * base } return 10 * base } function formatScaleDistanceLabel(distanceMeters: number): string { if (distanceMeters >= 1000) { const distanceKm = distanceMeters / 1000 const formatted = distanceKm >= 10 ? distanceKm.toFixed(0) : distanceKm.toFixed(1) return `${formatted.replace(/\.0$/, '')} km` } return `${Math.round(distanceMeters)} m` } function buildCenterScaleRulerPatch(data: Pick) { if (!data.showCenterScaleRuler) { lastCenterScaleRulerStablePatch = { centerScaleRulerVisible: false, centerScaleRulerCenterXPx: 0, centerScaleRulerZeroYPx: 0, centerScaleRulerHeightPx: 0, centerScaleRulerAxisBottomPx: 0, centerScaleRulerZeroVisible: false, centerScaleRulerZeroLabel: '0 m', centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[], centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[], } return { ...lastCenterScaleRulerStablePatch } } if (!data.stageWidth || !data.stageHeight) { return { ...lastCenterScaleRulerStablePatch } } const topPadding = 12 const rpxUnitPx = getRpxUnitInPx() const compassBottomPaddingPx = 248 * rpxUnitPx const compassDialRadiusPx = (196 * rpxUnitPx) / 2 const compassHeadingOverlayHeightPx = 40 * rpxUnitPx const compassOcclusionPaddingPx = 10 * rpxUnitPx const zeroYPx = data.centerScaleRulerAnchorMode === 'compass-center' ? Math.round(data.stageHeight - compassBottomPaddingPx - compassDialRadiusPx) : Math.round(data.stageHeight / 2) const fallbackHeight = Math.max(zeroYPx - topPadding, 160) const coveredBottomPx = data.centerScaleRulerAnchorMode === 'compass-center' ? Math.round(compassDialRadiusPx + compassHeadingOverlayHeightPx + compassOcclusionPaddingPx) : 0 if ( !data.tileSizePx || !Number.isFinite(data.zoom) || !Number.isFinite(data.centerTileY) ) { return { ...lastCenterScaleRulerStablePatch, centerScaleRulerVisible: true, centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2), centerScaleRulerZeroYPx: zeroYPx, centerScaleRulerHeightPx: lastCenterScaleRulerStablePatch.centerScaleRulerHeightPx || fallbackHeight, centerScaleRulerAxisBottomPx: coveredBottomPx, centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center', } } const centerLat = worldTileYToLat(data.centerTileY + 0.5, data.zoom) const metersPerTile = Math.cos(centerLat * Math.PI / 180) * 40075016.686 / Math.pow(2, data.zoom) const metersPerPixel = metersPerTile / data.tileSizePx const effectivePreviewScale = Number.isFinite(data.previewScale) && data.previewScale > 0 ? data.previewScale : 1 const effectiveMetersPerPixel = metersPerPixel / effectivePreviewScale const rulerHeight = Math.floor(zeroYPx - topPadding) if (!Number.isFinite(effectiveMetersPerPixel) || effectiveMetersPerPixel <= 0 || rulerHeight < 120) { return { ...lastCenterScaleRulerStablePatch, centerScaleRulerVisible: true, centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2), centerScaleRulerZeroYPx: zeroYPx, centerScaleRulerHeightPx: lastCenterScaleRulerStablePatch.centerScaleRulerHeightPx || fallbackHeight, centerScaleRulerAxisBottomPx: coveredBottomPx, centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center', } } const labelDistanceMeters = getNiceDistanceMeters(effectiveMetersPerPixel * 80) const minorDistanceMeters = labelDistanceMeters / 8 const minorStepPx = minorDistanceMeters / effectiveMetersPerPixel const visibleTopLimitPx = rulerHeight - coveredBottomPx const minorTicks: ScaleRulerMinorTickData[] = [] const majorMarks: ScaleRulerMajorMarkData[] = [] for (let index = 1; index <= 200; index += 1) { const topPx = Math.round(rulerHeight - index * minorStepPx) if (topPx < 0) { break } if (topPx >= visibleTopLimitPx) { continue } const isHalfMajor = index % 4 === 0 const isLabelMajor = index % 8 === 0 minorTicks.push({ key: `minor-${index}`, topPx, long: isHalfMajor, }) if (isLabelMajor) { majorMarks.push({ key: `major-${index}`, topPx, label: formatScaleDistanceLabel((index / 8) * labelDistanceMeters), }) } } lastCenterScaleRulerStablePatch = { centerScaleRulerVisible: true, centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2), centerScaleRulerZeroYPx: zeroYPx, centerScaleRulerHeightPx: rulerHeight, centerScaleRulerAxisBottomPx: coveredBottomPx, centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center', centerScaleRulerZeroLabel: '0 m', centerScaleRulerMinorTicks: minorTicks, centerScaleRulerMajorMarks: majorMarks, } return { ...lastCenterScaleRulerStablePatch } } function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot { return { title: '当前游戏', subtitle: '未开始', localRows: [], globalRows: [ { label: '全球积分', value: '未接入' }, { label: '全球排名', value: '未接入' }, { label: '在线人数', value: '未接入' }, { label: '队伍状态', value: '未接入' }, { label: '实时广播', value: '未接入' }, ], } } function buildEmptyResultSceneSnapshot(): MapEngineResultSnapshot { return { title: '本局结果', subtitle: '未开始', heroLabel: '本局用时', heroValue: '--', rows: [], } } Page({ data: { showDebugPanel: false, showGameInfoPanel: false, showResultScene: false, showSystemSettingsPanel: false, showCenterScaleRuler: false, statusBarHeight: 0, topInsetHeight: 12, hudPanelIndex: 0, configSourceText: '顺序赛配置', centerScaleRulerAnchorMode: 'screen-center', autoRotateEnabled: false, lockAnimationLevel: false, lockSideButtonPlacement: false, lockAutoRotate: false, lockCompassTuning: false, lockScaleRulerVisible: false, lockScaleRulerAnchor: false, lockNorthReference: false, lockHeartRateDevice: false, gameInfoTitle: '当前游戏', gameInfoSubtitle: '未开始', gameInfoLocalRows: [], gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, resultSceneTitle: '本局结果', resultSceneSubtitle: '未开始', resultSceneHeroLabel: '本局用时', resultSceneHeroValue: '--', resultSceneRows: buildEmptyResultSceneSnapshot().rows, panelTimerText: '00:00:00', panelMileageText: '0m', panelActionTagText: '目标', panelDistanceTagText: '点距', panelDistanceValueText: '--', panelDistanceUnitText: '', panelProgressText: '0/0', showPunchHintBanner: true, sideButtonPlacement: 'left', gameSessionStatus: 'idle', gameModeText: '顺序赛', gpsLockEnabled: false, gpsLockAvailable: false, locationSourceMode: 'real', locationSourceText: '真实定位', mockBridgeConnected: false, mockBridgeStatusText: '未连接', mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockCoordText: '--', mockSpeedText: '--', heartRateSourceMode: 'real', heartRateSourceText: '真实心率', mockHeartRateBridgeConnected: false, mockHeartRateBridgeStatusText: '未连接', mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockHeartRateText: '--', heartRateScanText: '未扫描', heartRateDiscoveredDevices: [], panelSpeedValueText: '0', panelTelemetryTone: 'blue', panelHeartRateZoneNameText: '--', panelHeartRateZoneRangeText: '', heartRateConnected: false, heartRateStatusText: '心率带未连接', heartRateDeviceText: '--', panelHeartRateValueText: '--', panelHeartRateUnitText: '', panelCaloriesValueText: '0', panelCaloriesUnitText: 'kcal', panelAverageSpeedValueText: '0', panelAverageSpeedUnitText: 'km/h', panelAccuracyValueText: '--', panelAccuracyUnitText: '', deviceHeadingText: '--', devicePoseText: '竖持', headingConfidenceText: '低', accelerometerText: '--', gyroscopeText: '--', deviceMotionText: '--', compassSourceText: '无数据', compassTuningProfile: 'balanced', compassTuningProfileText: '平衡', punchButtonText: '打点', punchButtonEnabled: false, skipButtonEnabled: false, punchHintText: '等待进入检查点范围', punchFeedbackVisible: false, punchFeedbackText: '', punchFeedbackTone: 'neutral', contentCardVisible: false, contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', contentCardActionVisible: false, contentCardActionText: '查看详情', punchButtonFxClass: '', panelProgressFxClass: '', panelDistanceFxClass: '', punchFeedbackFxClass: '', contentCardFxClass: '', mapPulseVisible: false, mapPulseLeftPx: 0, mapPulseTopPx: 0, mapPulseFxClass: '', stageFxVisible: false, stageFxClass: '', centerScaleRulerVisible: false, centerScaleRulerCenterXPx: 0, centerScaleRulerZeroYPx: 0, centerScaleRulerHeightPx: 0, centerScaleRulerAxisBottomPx: 0, centerScaleRulerZeroVisible: false, centerScaleRulerZeroLabel: '0 m', centerScaleRulerMinorTicks: [], centerScaleRulerMajorMarks: [], compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), ...buildSideButtonVisibility('shown'), ...buildSideButtonState({ sideButtonMode: 'shown', showGameInfoPanel: false, showSystemSettingsPanel: false, showCenterScaleRuler: false, centerScaleRulerAnchorMode: 'screen-center', skipButtonEnabled: false, gameSessionStatus: 'idle', gpsLockEnabled: false, gpsLockAvailable: false, }), } as unknown as MapPageData, onLoad() { const systemInfo = wx.getSystemInfoSync() const statusBarHeight = systemInfo.statusBarHeight || 0 const menuButtonRect = wx.getMenuButtonBoundingClientRect() const menuButtonBottom = menuButtonRect && typeof menuButtonRect.bottom === 'number' ? menuButtonRect.bottom : statusBarHeight if (mapEngine) { mapEngine.destroy() mapEngine = null } mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, { onData: (patch) => { const nextPatch = patch as Partial const includeDebugFields = this.data.showDebugPanel const includeRulerFields = this.data.showCenterScaleRuler const nextData: Partial = filterDebugOnlyPatch({ ...nextPatch, }, includeDebugFields, includeRulerFields) if ( typeof nextPatch.mockBridgeUrlText === 'string' && this.data.mockBridgeUrlDraft === this.data.mockBridgeUrlText ) { nextData.mockBridgeUrlDraft = nextPatch.mockBridgeUrlText } if ( typeof nextPatch.mockHeartRateBridgeUrlText === 'string' && this.data.mockHeartRateBridgeUrlDraft === this.data.mockHeartRateBridgeUrlText ) { nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText } updateCenterScaleRulerInputCache(nextPatch) const mergedData = { ...centerScaleRulerInputCache, ...this.data, ...nextData, } as MapPageData const derivedPatch: Partial = {} if (typeof nextPatch.orientationMode === 'string') { nextData.autoRotateEnabled = nextPatch.orientationMode === 'heading-up' } if ( this.data.showCenterScaleRuler && hasAnyPatchKey(nextPatch as Record, CENTER_SCALE_RULER_DEP_KEYS) ) { clearCenterScaleRulerUpdateTimer() Object.assign(derivedPatch, buildCenterScaleRulerPatch(mergedData)) } if (hasAnyPatchKey(nextPatch as Record, SIDE_BUTTON_DEP_KEYS)) { Object.assign(derivedPatch, buildSideButtonState(mergedData)) } if (typeof nextPatch.punchHintText === 'string') { const nextHintText = nextPatch.punchHintText.trim() if (nextHintText !== this.data.punchHintText) { clearPunchHintDismissTimer() nextData.showPunchHintBanner = nextHintText.length > 0 if (nextHintText.length > 0) { punchHintDismissTimer = setTimeout(() => { punchHintDismissTimer = 0 this.setData({ showPunchHintBanner: false, }) }, PUNCH_HINT_AUTO_HIDE_MS) as unknown as number } } else if (!nextHintText) { clearPunchHintDismissTimer() nextData.showPunchHintBanner = false } } const nextAnimationLevel = typeof nextPatch.animationLevel === 'string' ? nextPatch.animationLevel : this.data.animationLevel if (nextAnimationLevel === 'lite') { clearHudFxTimer('timer') clearHudFxTimer('mileage') clearHudFxTimer('speed') clearHudFxTimer('heartRate') nextData.panelTimerFxClass = '' nextData.panelMileageFxClass = '' nextData.panelSpeedFxClass = '' nextData.panelHeartRateFxClass = '' } else { if (typeof nextPatch.panelTimerText === 'string' && nextPatch.panelTimerText !== this.data.panelTimerText && this.data.panelTimerText !== '00:00:00') { clearHudFxTimer('timer') nextData.panelTimerFxClass = 'race-panel__timer--fx-tick' panelTimerFxTimer = setTimeout(() => { panelTimerFxTimer = 0 this.setData({ panelTimerFxClass: '' }) }, 320) as unknown as number } if (typeof nextPatch.panelMileageText === 'string' && nextPatch.panelMileageText !== this.data.panelMileageText && this.data.panelMileageText !== '0m') { clearHudFxTimer('mileage') nextData.panelMileageFxClass = 'race-panel__mileage-wrap--fx-update' panelMileageFxTimer = setTimeout(() => { panelMileageFxTimer = 0 this.setData({ panelMileageFxClass: '' }) }, 360) as unknown as number } if (typeof nextPatch.panelSpeedValueText === 'string' && nextPatch.panelSpeedValueText !== this.data.panelSpeedValueText && this.data.panelSpeedValueText !== '0') { clearHudFxTimer('speed') nextData.panelSpeedFxClass = 'race-panel__metric-group--fx-speed-update' panelSpeedFxTimer = setTimeout(() => { panelSpeedFxTimer = 0 this.setData({ panelSpeedFxClass: '' }) }, 360) as unknown as number } if (typeof nextPatch.panelHeartRateValueText === 'string' && nextPatch.panelHeartRateValueText !== this.data.panelHeartRateValueText && this.data.panelHeartRateValueText !== '--') { clearHudFxTimer('heartRate') nextData.panelHeartRateFxClass = 'race-panel__metric-group--fx-heart-rate-update' panelHeartRateFxTimer = setTimeout(() => { panelHeartRateFxTimer = 0 this.setData({ panelHeartRateFxClass: '' }) }, 400) as unknown as number } } if (typeof nextPatch.gameSessionStatus === 'string') { if ( nextPatch.gameSessionStatus !== this.data.gameSessionStatus && (nextPatch.gameSessionStatus === 'finished' || nextPatch.gameSessionStatus === 'failed') ) { this.syncResultSceneSnapshot() nextData.showResultScene = true nextData.showDebugPanel = false nextData.showGameInfoPanel = false nextData.showSystemSettingsPanel = false clearGameInfoPanelSyncTimer() } else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') { nextData.showResultScene = false } } if (Object.keys(nextData).length || Object.keys(derivedPatch).length) { this.setData({ ...nextData, ...derivedPatch, }) } if (this.data.showGameInfoPanel) { this.scheduleGameInfoPanelSnapshotSync() } }, onOpenH5Experience: (request) => { this.openH5Experience(request) }, }) const storedUserSettings = loadStoredUserSettings() if (storedUserSettings.animationLevel) { mapEngine.handleSetAnimationLevel(storedUserSettings.animationLevel) } const initialAutoRotateEnabled = storedUserSettings.autoRotateEnabled !== false if (initialAutoRotateEnabled) { mapEngine.handleSetHeadingUpMode() } else { mapEngine.handleSetManualMode() } if (storedUserSettings.compassTuningProfile) { mapEngine.handleSetCompassTuningProfile(storedUserSettings.compassTuningProfile) } if (storedUserSettings.northReferenceMode) { mapEngine.handleSetNorthReferenceMode(storedUserSettings.northReferenceMode) } const initialSideButtonPlacement = storedUserSettings.sideButtonPlacement || 'left' mapEngine.setDiagnosticUiEnabled(false) centerScaleRulerInputCache = { stageWidth: 0, stageHeight: 0, zoom: 0, centerTileY: 0, tileSizePx: 0, previewScale: 1, } const initialShowCenterScaleRuler = !!storedUserSettings.showCenterScaleRuler const initialCenterScaleRulerAnchorMode = storedUserSettings.centerScaleRulerAnchorMode || 'screen-center' this.setData({ ...mapEngine.getInitialData(), showDebugPanel: false, showGameInfoPanel: false, showSystemSettingsPanel: false, showCenterScaleRuler: initialShowCenterScaleRuler, statusBarHeight, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), hudPanelIndex: 0, configSourceText: '顺序赛配置', centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, autoRotateEnabled: initialAutoRotateEnabled, lockAnimationLevel: !!storedUserSettings.lockAnimationLevel, lockSideButtonPlacement: !!storedUserSettings.lockSideButtonPlacement, lockAutoRotate: !!storedUserSettings.lockAutoRotate, lockCompassTuning: !!storedUserSettings.lockCompassTuning, lockScaleRulerVisible: !!storedUserSettings.lockScaleRulerVisible, lockScaleRulerAnchor: !!storedUserSettings.lockScaleRulerAnchor, lockNorthReference: !!storedUserSettings.lockNorthReference, lockHeartRateDevice: !!storedUserSettings.lockHeartRateDevice, sideButtonPlacement: initialSideButtonPlacement, gameInfoTitle: '当前游戏', gameInfoSubtitle: '未开始', gameInfoLocalRows: [], gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, panelTimerText: '00:00:00', panelTimerFxClass: '', panelMileageText: '0m', panelMileageFxClass: '', panelActionTagText: '目标', panelDistanceTagText: '点距', panelDistanceValueText: '--', panelDistanceUnitText: '', panelProgressText: '0/0', showPunchHintBanner: true, gameSessionStatus: 'idle', gameModeText: '顺序赛', gpsLockEnabled: false, gpsLockAvailable: false, locationSourceMode: 'real', locationSourceText: '真实定位', mockBridgeConnected: false, mockBridgeStatusText: '未连接', mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockCoordText: '--', mockSpeedText: '--', heartRateSourceMode: 'real', heartRateSourceText: '真实心率', mockHeartRateBridgeConnected: false, mockHeartRateBridgeStatusText: '未连接', mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockHeartRateText: '--', panelSpeedValueText: '0', panelSpeedFxClass: '', panelTelemetryTone: 'blue', panelHeartRateZoneNameText: '--', panelHeartRateZoneRangeText: '', heartRateConnected: false, heartRateStatusText: '心率带未连接', heartRateDeviceText: '--', panelHeartRateValueText: '--', panelHeartRateFxClass: '', panelHeartRateUnitText: '', panelCaloriesValueText: '0', panelCaloriesUnitText: 'kcal', panelAverageSpeedValueText: '0', panelAverageSpeedUnitText: 'km/h', panelAccuracyValueText: '--', panelAccuracyUnitText: '', deviceHeadingText: '--', devicePoseText: '竖持', headingConfidenceText: '低', accelerometerText: '--', gyroscopeText: '--', deviceMotionText: '--', compassSourceText: '无数据', compassTuningProfile: 'balanced', compassTuningProfileText: '平衡', punchButtonText: '打点', punchButtonEnabled: false, skipButtonEnabled: false, punchHintText: '等待进入检查点范围', punchFeedbackVisible: false, punchFeedbackText: '', punchFeedbackTone: 'neutral', contentCardVisible: false, contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', contentCardActionVisible: false, contentCardActionText: '查看详情', punchButtonFxClass: '', panelProgressFxClass: '', panelDistanceFxClass: '', punchFeedbackFxClass: '', contentCardFxClass: '', mapPulseVisible: false, mapPulseLeftPx: 0, mapPulseTopPx: 0, mapPulseFxClass: '', stageFxVisible: false, stageFxClass: '', compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), ...buildSideButtonVisibility('shown'), ...buildSideButtonState({ sideButtonMode: 'shown', showGameInfoPanel: false, showSystemSettingsPanel: false, showCenterScaleRuler: initialShowCenterScaleRuler, centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, skipButtonEnabled: false, gameSessionStatus: 'idle', gpsLockEnabled: false, gpsLockAvailable: false, }), ...buildCenterScaleRulerPatch({ ...(mapEngine.getInitialData() as MapPageData), showCenterScaleRuler: initialShowCenterScaleRuler, centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, stageWidth: 0, stageHeight: 0, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), zoom: 0, centerTileY: 0, tileSizePx: 0, }), }) }, onReady() { stageCanvasAttached = false this.measureStageAndCanvas() this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置') }, onShow() { if (mapEngine) { mapEngine.handleAppShow() } }, onHide() { if (mapEngine) { mapEngine.handleAppHide() } }, onUnload() { clearGameInfoPanelSyncTimer() clearCenterScaleRulerSyncTimer() clearCenterScaleRulerUpdateTimer() clearPunchHintDismissTimer() clearHudFxTimer('timer') clearHudFxTimer('mileage') clearHudFxTimer('speed') clearHudFxTimer('heartRate') if (mapEngine) { mapEngine.destroy() mapEngine = null } stageCanvasAttached = false }, loadMapConfigFromRemote(configUrl: string, configLabel: string) { const currentEngine = mapEngine if (!currentEngine) { return } this.setData({ configSourceText: configLabel, configStatusText: `加载中: ${configLabel}`, }) loadRemoteMapConfig(configUrl) .then((config) => { if (mapEngine !== currentEngine) { return } currentEngine.applyRemoteMapConfig(config) }) .catch((error) => { if (mapEngine !== currentEngine) { return } const errorMessage = error && error.message ? error.message : '未知错误' this.setData({ configStatusText: `载入失败: ${errorMessage}`, statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`, }) }) }, measureStageAndCanvas(onApplied?: () => void) { const page = this const applyStage = (rawRect?: Partial) => { const fallbackRect = getFallbackStageRect() const rect: MapEngineStageRect = { width: rawRect && typeof rawRect.width === 'number' ? rawRect.width : fallbackRect.width, height: rawRect && typeof rawRect.height === 'number' ? rawRect.height : fallbackRect.height, left: rawRect && typeof rawRect.left === 'number' ? rawRect.left : fallbackRect.left, top: rawRect && typeof rawRect.top === 'number' ? rawRect.top : fallbackRect.top, } const currentEngine = mapEngine if (!currentEngine) { return } currentEngine.setStage(rect) if (onApplied) { onApplied() } if (stageCanvasAttached) { return } const canvasQuery = wx.createSelectorQuery().in(page) canvasQuery.select('#mapCanvas').fields({ node: true, size: true }) canvasQuery.select('#routeLabelCanvas').fields({ node: true, size: true }) canvasQuery.exec((canvasRes) => { const canvasRef = canvasRes[0] as any const labelCanvasRef = canvasRes[1] as any if (!canvasRef || !canvasRef.node) { page.setData({ statusText: `WebGL 引擎初始化失败 (${INTERNAL_BUILD_VERSION})`, }) return } const dpr = wx.getSystemInfoSync().pixelRatio || 1 try { currentEngine.attachCanvas( canvasRef.node, rect.width, rect.height, dpr, labelCanvasRef && labelCanvasRef.node ? labelCanvasRef.node : undefined, ) stageCanvasAttached = true } catch (error) { page.setData({ statusText: `WebGL 鍒濆鍖栧け璐?(${INTERNAL_BUILD_VERSION})`, }) } }) } const query = wx.createSelectorQuery().in(page) query.select('.map-stage').boundingClientRect() query.exec((res) => { const rect = res[0] as WechatMiniprogram.BoundingClientRectCallbackResult | undefined applyStage(rect) }) }, handleTouchStart(event: WechatMiniprogram.TouchEvent) { if (mapEngine) { mapEngine.handleTouchStart(event) } }, handleTouchMove(event: WechatMiniprogram.TouchEvent) { if (mapEngine) { mapEngine.handleTouchMove(event) } }, handleTouchEnd(event: WechatMiniprogram.TouchEvent) { if (mapEngine) { mapEngine.handleTouchEnd(event) } }, handleTouchCancel() { if (mapEngine) { mapEngine.handleTouchCancel() } }, handleRecenter() { if (mapEngine) { mapEngine.handleRecenter() } }, handleRotateStep() { if (mapEngine) { mapEngine.handleRotateStep() } }, handleRotationReset() { if (mapEngine) { mapEngine.handleRotationReset() } }, handleSetManualMode() { if (mapEngine) { mapEngine.handleSetManualMode() } }, handleSetNorthUpMode() { if (mapEngine) { mapEngine.handleSetNorthUpMode() } }, handleSetHeadingUpMode() { if (mapEngine) { mapEngine.handleSetHeadingUpMode() } }, handleCycleNorthReferenceMode() { if (mapEngine) { mapEngine.handleCycleNorthReferenceMode() } }, handleAutoRotateCalibrate() { if (mapEngine) { mapEngine.handleAutoRotateCalibrate() } }, handleToggleGpsTracking() { if (mapEngine) { mapEngine.handleToggleGpsTracking() } }, handleSetRealLocationMode() { if (mapEngine) { mapEngine.handleSetRealLocationMode() } }, handleSetMockLocationMode() { if (mapEngine) { mapEngine.handleSetMockLocationMode() } }, handleConnectMockLocationBridge() { if (mapEngine) { mapEngine.handleConnectMockLocationBridge() } }, handleConnectAllMockSources() { if (!mapEngine) { return } mapEngine.handleConnectMockLocationBridge() mapEngine.handleSetMockLocationMode() mapEngine.handleSetMockHeartRateMode() mapEngine.handleConnectMockHeartRateBridge() }, handleOpenWebViewTest() { wx.navigateTo({ url: '/pages/webview-test/webview-test', }) }, handleMockBridgeUrlInput(event: WechatMiniprogram.Input) { this.setData({ mockBridgeUrlDraft: event.detail.value, }) }, handleSaveMockBridgeUrl() { if (mapEngine) { mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft) } }, handleDisconnectMockLocationBridge() { if (mapEngine) { mapEngine.handleDisconnectMockLocationBridge() } }, handleSetRealHeartRateMode() { if (mapEngine) { mapEngine.handleSetRealHeartRateMode() } }, handleSetMockHeartRateMode() { if (mapEngine) { mapEngine.handleSetMockHeartRateMode() } }, handleMockHeartRateBridgeUrlInput(event: WechatMiniprogram.Input) { this.setData({ mockHeartRateBridgeUrlDraft: event.detail.value, }) }, handleSaveMockHeartRateBridgeUrl() { if (mapEngine) { mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft) } }, handleConnectMockHeartRateBridge() { if (mapEngine) { mapEngine.handleConnectMockHeartRateBridge() } }, handleDisconnectMockHeartRateBridge() { if (mapEngine) { mapEngine.handleDisconnectMockHeartRateBridge() } }, handleConnectHeartRate() { if (mapEngine) { mapEngine.handleConnectHeartRate() } }, handleDisconnectHeartRate() { if (mapEngine) { mapEngine.handleDisconnectHeartRate() } }, handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) { if (mapEngine && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.deviceId) { mapEngine.handleConnectHeartRateDevice(event.currentTarget.dataset.deviceId) } }, handleClearPreferredHeartRateDevice() { if (this.data.lockHeartRateDevice) { return } if (mapEngine) { mapEngine.handleClearPreferredHeartRateDevice() } }, handleDebugHeartRateBlue() { if (mapEngine) { mapEngine.handleDebugHeartRateTone('blue') } }, handleDebugHeartRatePurple() { if (mapEngine) { mapEngine.handleDebugHeartRateTone('purple') } }, handleDebugHeartRateGreen() { if (mapEngine) { mapEngine.handleDebugHeartRateTone('green') } }, handleDebugHeartRateYellow() { if (mapEngine) { mapEngine.handleDebugHeartRateTone('yellow') } }, handleDebugHeartRateOrange() { if (mapEngine) { mapEngine.handleDebugHeartRateTone('orange') } }, handleDebugHeartRateRed() { if (mapEngine) { mapEngine.handleDebugHeartRateTone('red') } }, handleClearDebugHeartRate() { if (mapEngine) { mapEngine.handleClearDebugHeartRate() } }, handleToggleOsmReference() { if (mapEngine) { mapEngine.handleToggleOsmReference() } }, handleStartGame() { if (mapEngine) { mapEngine.handleStartGame() } }, handleLoadClassicConfig() { this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置') }, handleLoadScoreOConfig() { this.loadMapConfigFromRemote(SCORE_O_REMOTE_GAME_CONFIG_URL, '积分赛配置') }, handleForceExitGame() { if (!mapEngine || this.data.gameSessionStatus !== 'running') { return } wx.showModal({ title: '确认退出', content: '确认强制结束当前对局并返回开始前状态?', confirmText: '确认退出', cancelText: '取消', success: (result) => { if (result.confirm && mapEngine) { mapEngine.handleForceExitGame() } }, }) }, handleSkipAction() { if (!mapEngine || !this.data.skipButtonEnabled) { return } if (!mapEngine.shouldConfirmSkipAction()) { mapEngine.handleSkipAction() return } wx.showModal({ title: '确认跳点', content: '确认跳过当前检查点并切换到下一个目标点?', confirmText: '确认跳过', cancelText: '取消', success: (result) => { if (result.confirm && mapEngine) { mapEngine.handleSkipAction() } }, }) }, handleClearMapTestArtifacts() { if (mapEngine) { mapEngine.handleClearMapTestArtifacts() } }, syncGameInfoPanelSnapshot() { if (!mapEngine) { return } const snapshot = mapEngine.getGameInfoSnapshot() const localRows = snapshot.localRows.concat([ { label: '比例尺开关', value: this.data.showCenterScaleRuler ? '开启' : '关闭' }, { label: '比例尺锚点', value: this.data.centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心' }, { label: '按钮习惯', value: this.data.sideButtonPlacement === 'right' ? '右手' : '左手' }, { label: '比例尺可见', value: this.data.centerScaleRulerVisible ? 'true' : 'false' }, { label: '比例尺中心X', value: `${this.data.centerScaleRulerCenterXPx}px` }, { label: '比例尺零点Y', value: `${this.data.centerScaleRulerZeroYPx}px` }, { label: '比例尺高度', value: `${this.data.centerScaleRulerHeightPx}px` }, { label: '比例尺主刻度数', value: String(this.data.centerScaleRulerMajorMarks.length) }, ]) this.setData({ gameInfoTitle: snapshot.title, gameInfoSubtitle: snapshot.subtitle, gameInfoLocalRows: localRows, gameInfoGlobalRows: snapshot.globalRows, }) }, syncResultSceneSnapshot() { if (!mapEngine) { return } const snapshot = mapEngine.getResultSceneSnapshot() this.setData({ resultSceneTitle: snapshot.title, resultSceneSubtitle: snapshot.subtitle, resultSceneHeroLabel: snapshot.heroLabel, resultSceneHeroValue: snapshot.heroValue, resultSceneRows: snapshot.rows, }) }, scheduleGameInfoPanelSnapshotSync() { if (!this.data.showGameInfoPanel) { clearGameInfoPanelSyncTimer() return } if (gameInfoPanelSyncTimer) { return } gameInfoPanelSyncTimer = setTimeout(() => { gameInfoPanelSyncTimer = 0 if (this.data.showGameInfoPanel) { this.syncGameInfoPanelSnapshot() } }, 400) as unknown as number }, handleOpenGameInfoPanel() { clearGameInfoPanelSyncTimer() this.syncGameInfoPanelSnapshot() this.setData({ showDebugPanel: false, showSystemSettingsPanel: false, showGameInfoPanel: true, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: true, showSystemSettingsPanel: false, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, gameSessionStatus: this.data.gameSessionStatus, gpsLockEnabled: this.data.gpsLockEnabled, gpsLockAvailable: this.data.gpsLockAvailable, }), }) }, handleCloseGameInfoPanel() { clearGameInfoPanelSyncTimer() this.setData({ showGameInfoPanel: false, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: false, showSystemSettingsPanel: this.data.showSystemSettingsPanel, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, gameSessionStatus: this.data.gameSessionStatus, gpsLockEnabled: this.data.gpsLockEnabled, gpsLockAvailable: this.data.gpsLockAvailable, }), }) }, handleGameInfoPanelTap() {}, handleResultSceneTap() {}, handleCloseResultScene() { this.setData({ showResultScene: false, }) }, handleRestartFromResult() { if (!mapEngine) { return } this.setData({ showResultScene: false, }, () => { if (mapEngine) { mapEngine.handleStartGame() } }) }, handleOpenSystemSettingsPanel() { clearGameInfoPanelSyncTimer() this.setData({ showDebugPanel: false, showGameInfoPanel: false, showSystemSettingsPanel: true, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: false, showSystemSettingsPanel: true, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, gameSessionStatus: this.data.gameSessionStatus, gpsLockEnabled: this.data.gpsLockEnabled, gpsLockAvailable: this.data.gpsLockAvailable, }), }) }, handleCloseSystemSettingsPanel() { this.setData({ showSystemSettingsPanel: false, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: this.data.showGameInfoPanel, showSystemSettingsPanel: false, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, gameSessionStatus: this.data.gameSessionStatus, gpsLockEnabled: this.data.gpsLockEnabled, gpsLockAvailable: this.data.gpsLockAvailable, }), }) }, handleSystemSettingsPanelTap() {}, handleSetAnimationLevelStandard() { if (this.data.lockAnimationLevel || !mapEngine) { return } mapEngine.handleSetAnimationLevel('standard') persistStoredUserSettings({ ...loadStoredUserSettings(), animationLevel: 'standard', }) }, handleSetAnimationLevelLite() { if (this.data.lockAnimationLevel || !mapEngine) { return } mapEngine.handleSetAnimationLevel('lite') persistStoredUserSettings({ ...loadStoredUserSettings(), animationLevel: 'lite', }) }, handleSetSideButtonPlacementLeft() { if (this.data.lockSideButtonPlacement) { return } this.setData({ sideButtonPlacement: 'left', }) persistStoredUserSettings({ ...loadStoredUserSettings(), sideButtonPlacement: 'left', }) }, handleSetSideButtonPlacementRight() { if (this.data.lockSideButtonPlacement) { return } this.setData({ sideButtonPlacement: 'right', }) persistStoredUserSettings({ ...loadStoredUserSettings(), sideButtonPlacement: 'right', }) }, handleSetAutoRotateEnabledOn() { if (this.data.lockAutoRotate || !mapEngine) { return } mapEngine.handleSetHeadingUpMode() persistStoredUserSettings({ ...loadStoredUserSettings(), autoRotateEnabled: true, }) }, handleSetAutoRotateEnabledOff() { if (this.data.lockAutoRotate || !mapEngine) { return } mapEngine.handleSetManualMode() persistStoredUserSettings({ ...loadStoredUserSettings(), autoRotateEnabled: false, }) }, handleSetCompassTuningSmooth() { if (this.data.lockCompassTuning || !mapEngine) { return } mapEngine.handleSetCompassTuningProfile('smooth') persistStoredUserSettings({ ...loadStoredUserSettings(), compassTuningProfile: 'smooth', }) }, handleSetCompassTuningBalanced() { if (this.data.lockCompassTuning || !mapEngine) { return } mapEngine.handleSetCompassTuningProfile('balanced') persistStoredUserSettings({ ...loadStoredUserSettings(), compassTuningProfile: 'balanced', }) }, handleSetCompassTuningResponsive() { if (this.data.lockCompassTuning || !mapEngine) { return } mapEngine.handleSetCompassTuningProfile('responsive') persistStoredUserSettings({ ...loadStoredUserSettings(), compassTuningProfile: 'responsive', }) }, handleSetNorthReferenceMagnetic() { if (this.data.lockNorthReference || !mapEngine) { return } mapEngine.handleSetNorthReferenceMode('magnetic') persistStoredUserSettings({ ...loadStoredUserSettings(), northReferenceMode: 'magnetic', }) }, handleSetNorthReferenceTrue() { if (this.data.lockNorthReference || !mapEngine) { return } mapEngine.handleSetNorthReferenceMode('true') persistStoredUserSettings({ ...loadStoredUserSettings(), northReferenceMode: 'true', }) }, handleToggleSettingLock(event: WechatMiniprogram.TouchEvent) { const key = event.currentTarget.dataset.key as SettingLockKey | undefined if (!key) { return } const nextValue = !this.data[key] this.setData({ [key]: nextValue, } as Record) persistStoredUserSettings(toggleStoredSettingLock(loadStoredUserSettings(), key)) }, handleOverlayTouch() {}, handlePunchAction() { if (!this.data.punchButtonEnabled) { return } if (mapEngine) { mapEngine.handlePunchAction() } }, handleOpenPendingContentCard() { if (mapEngine) { mapEngine.openPendingContentCard() } }, handleOpenContentCardDetail() { if (mapEngine) { wx.showToast({ title: '打开详情', icon: 'none', duration: 900, }) mapEngine.openCurrentContentCardDetail() } }, handleContentCardTap() {}, openH5Experience(request: H5ExperienceRequest) { wx.navigateTo({ url: '/pages/experience-webview/experience-webview', success: (result) => { const eventChannel = result.eventChannel eventChannel.on('fallback', (payload: H5ExperienceFallbackPayload) => { if (mapEngine) { mapEngine.handleH5ExperienceFallback(payload) } }) eventChannel.on('close', () => { if (mapEngine) { mapEngine.handleH5ExperienceClosed() } }) eventChannel.on('submitResult', () => { if (mapEngine) { mapEngine.handleH5ExperienceClosed() } }) eventChannel.emit('init', request) }, fail: () => { if (mapEngine) { mapEngine.handleH5ExperienceFallback(request.fallback) } }, }) }, handleCloseContentCard() { if (mapEngine) { mapEngine.closeContentCard() } }, handleClosePunchHint() { clearPunchHintDismissTimer() this.setData({ showPunchHintBanner: false, }) }, handlePunchHintTap() {}, handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) { this.setData({ hudPanelIndex: event.detail.current || 0, }) }, handleCycleSideButtons() { const nextMode = getNextSideButtonMode(this.data.sideButtonMode) this.setData({ ...buildSideButtonVisibility(nextMode), ...buildSideButtonState({ sideButtonMode: nextMode, showGameInfoPanel: this.data.showGameInfoPanel, showSystemSettingsPanel: this.data.showSystemSettingsPanel, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, gameSessionStatus: this.data.gameSessionStatus, gpsLockEnabled: this.data.gpsLockEnabled, gpsLockAvailable: this.data.gpsLockAvailable, }), }) }, handleToggleGpsLock() { if (mapEngine) { mapEngine.handleToggleGpsLock() } }, handleToggleMapRotateMode() { if (!mapEngine || this.data.lockAutoRotate) { return } if (this.data.orientationMode === 'heading-up') { mapEngine.handleSetManualMode() persistStoredUserSettings({ ...loadStoredUserSettings(), autoRotateEnabled: false, }) return } mapEngine.handleSetHeadingUpMode() persistStoredUserSettings({ ...loadStoredUserSettings(), autoRotateEnabled: true, }) }, handleToggleDebugPanel() { const nextShowDebugPanel = !this.data.showDebugPanel if (!nextShowDebugPanel) { clearGameInfoPanelSyncTimer() } if (mapEngine) { mapEngine.setDiagnosticUiEnabled(nextShowDebugPanel) } this.setData({ showDebugPanel: nextShowDebugPanel, showGameInfoPanel: false, showSystemSettingsPanel: false, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: false, showSystemSettingsPanel: false, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, gameSessionStatus: this.data.gameSessionStatus, gpsLockEnabled: this.data.gpsLockEnabled, gpsLockAvailable: this.data.gpsLockAvailable, }), }) }, handleCloseDebugPanel() { if (mapEngine) { mapEngine.setDiagnosticUiEnabled(false) } this.setData({ showDebugPanel: false, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: this.data.showGameInfoPanel, showSystemSettingsPanel: this.data.showSystemSettingsPanel, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, gameSessionStatus: this.data.gameSessionStatus, gpsLockEnabled: this.data.gpsLockEnabled, gpsLockAvailable: this.data.gpsLockAvailable, }), }) }, applyCenterScaleRulerSettings(nextEnabled: boolean, nextAnchorMode: CenterScaleRulerAnchorMode) { this.data.showCenterScaleRuler = nextEnabled this.data.centerScaleRulerAnchorMode = nextAnchorMode clearCenterScaleRulerSyncTimer() clearCenterScaleRulerUpdateTimer() const syncRulerFromEngine = () => { if (!mapEngine) { return } const engineSnapshot = mapEngine.getInitialData() as Partial updateCenterScaleRulerInputCache(engineSnapshot) const mergedData = { ...centerScaleRulerInputCache, ...this.data, showCenterScaleRuler: nextEnabled, centerScaleRulerAnchorMode: nextAnchorMode, } as MapPageData this.setData({ ...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, nextEnabled), showCenterScaleRuler: nextEnabled, centerScaleRulerAnchorMode: nextAnchorMode, ...buildCenterScaleRulerPatch(mergedData), ...buildSideButtonState(mergedData), }) } if (!nextEnabled) { syncRulerFromEngine() return } this.setData({ showCenterScaleRuler: true, centerScaleRulerAnchorMode: nextAnchorMode, ...buildSideButtonState({ ...this.data, showCenterScaleRuler: true, centerScaleRulerAnchorMode: nextAnchorMode, } as MapPageData), }) this.measureStageAndCanvas(() => { syncRulerFromEngine() }) centerScaleRulerSyncTimer = setTimeout(() => { centerScaleRulerSyncTimer = 0 if (!this.data.showCenterScaleRuler) { return } syncRulerFromEngine() }, 96) as unknown as number }, handleSetCenterScaleRulerVisibleOn() { if (this.data.lockScaleRulerVisible) { return } this.applyCenterScaleRulerSettings(true, this.data.centerScaleRulerAnchorMode) persistStoredUserSettings({ ...loadStoredUserSettings(), showCenterScaleRuler: true, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, }) }, handleSetCenterScaleRulerVisibleOff() { if (this.data.lockScaleRulerVisible) { return } this.applyCenterScaleRulerSettings(false, this.data.centerScaleRulerAnchorMode) persistStoredUserSettings({ ...loadStoredUserSettings(), showCenterScaleRuler: false, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, }) }, handleSetCenterScaleRulerAnchorScreenCenter() { if (this.data.lockScaleRulerAnchor) { return } this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'screen-center') persistStoredUserSettings({ ...loadStoredUserSettings(), showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: 'screen-center', }) }, handleSetCenterScaleRulerAnchorCompassCenter() { if (this.data.lockScaleRulerAnchor) { return } this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'compass-center') persistStoredUserSettings({ ...loadStoredUserSettings(), showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: 'compass-center', }) }, handleToggleCenterScaleRulerAnchor() { if (!this.data.showCenterScaleRuler) { return } const nextAnchorMode: CenterScaleRulerAnchorMode = this.data.centerScaleRulerAnchorMode === 'screen-center' ? 'compass-center' : 'screen-center' const engineSnapshot = mapEngine ? (mapEngine.getInitialData() as Partial) : {} updateCenterScaleRulerInputCache(engineSnapshot) this.data.centerScaleRulerAnchorMode = nextAnchorMode const mergedData = { ...centerScaleRulerInputCache, ...this.data, centerScaleRulerAnchorMode: nextAnchorMode, } as MapPageData this.setData({ ...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, true), centerScaleRulerAnchorMode: nextAnchorMode, ...buildCenterScaleRulerPatch(mergedData), ...buildSideButtonState(mergedData), }) if (this.data.showGameInfoPanel) { this.syncGameInfoPanelSnapshot() } }, handleDebugPanelTap() {}, })