import { MapEngine, type MapEngineGameInfoRow, type MapEngineGameInfoSnapshot, type MapEngineResultSnapshot, type MapEngineStageRect, type MapEngineViewState, } from '../../engine/map/mapEngine' import { getBackendSessionContextFromLaunchEnvelope, getDemoGameLaunchEnvelope, resolveGameLaunchEnvelope, type GameLaunchEnvelope, type MapPageLaunchOptions, } from '../../utils/gameLaunch' import { finishSession, startSession, type BackendSessionFinishSummaryPayload } from '../../utils/backendApi' import { loadBackendBaseUrl } from '../../utils/backendAuth' import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig' import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience' import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig' import { type GpsMarkerColorPreset } from '../../game/presentation/gpsMarkerStyleConfig' import { type PlayerTelemetryProfile } from '../../game/telemetry/playerTelemetryProfile' import { DEFAULT_SETTING_LOCKS, DEFAULT_STORED_USER_SETTINGS, loadStoredUserSettings, mergeStoredUserSettings, persistStoredUserSettings, resolveSystemSettingsState, type SystemSettingsConfig, type CenterScaleRulerAnchorMode, type ResolvedSystemSettingsState, type SideButtonPlacement, type StoredUserSettings, } from '../../game/core/systemSettingsState' import { compileRuntimeProfile, } from '../../game/core/runtimeProfileCompiler' import { clearSessionRecoverySnapshot, loadSessionRecoverySnapshot, saveSessionRecoverySnapshot, type SessionRecoverySnapshot, } from '../../game/core/sessionRecovery' 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 MapPageData = MapEngineViewState & { showDebugPanel: boolean showGameInfoPanel: boolean showResultScene: boolean showSystemSettingsPanel: boolean showHeartRateDevicePicker: boolean showCenterScaleRuler: boolean showPunchHintBanner: boolean punchHintFxClass: string centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode statusBarHeight: number topInsetHeight: number hudPanelIndex: number configSourceText: string mockBridgeUrlDraft: string mockHeartRateBridgeUrlDraft: string mockDebugLogBridgeUrlDraft: string mockChannelIdDraft: string gameInfoTitle: string gameInfoSubtitle: string gameInfoLocalRows: MapEngineGameInfoRow[] gameInfoGlobalRows: MapEngineGameInfoRow[] resultSceneTitle: string resultSceneSubtitle: string resultSceneHeroLabel: string resultSceneHeroValue: string resultSceneRows: MapEngineGameInfoRow[] resultSceneCountdownText: string panelTimerText: string panelTimerMode: 'elapsed' | 'countdown' panelMileageText: string panelTargetSummaryText: 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 lockTrackMode: boolean lockTrackTailLength: boolean lockTrackColor: boolean lockTrackStyle: boolean lockGpsMarkerVisible: boolean lockGpsMarkerStyle: boolean lockGpsMarkerSize: boolean lockGpsMarkerColor: 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 } function getGlobalTelemetryProfile(): PlayerTelemetryProfile | null { const app = getApp() const profile = app.globalData && app.globalData.telemetryPlayerProfile return profile ? { ...profile } : null } const INTERNAL_BUILD_VERSION = 'map-build-293' const PUNCH_HINT_AUTO_HIDE_MS = 30000 const PUNCH_HINT_FX_DURATION_MS = 420 const PUNCH_HINT_HAPTIC_GAP_MS = 2400 const SESSION_RECOVERY_PERSIST_INTERVAL_MS = 5000 const RESULT_EXIT_REDIRECT_DELAY_MS = 3000 let currentGameLaunchEnvelope: GameLaunchEnvelope = getDemoGameLaunchEnvelope() let mapEngine: MapEngine | null = null let stageCanvasAttached = false let gameInfoPanelSyncTimer = 0 let centerScaleRulerSyncTimer = 0 let contentAudioRecorder: WechatMiniprogram.RecorderManager | null = null let contentAudioRecording = false let centerScaleRulerUpdateTimer = 0 let punchHintDismissTimer = 0 let punchHintFxTimer = 0 let panelTimerFxTimer = 0 let panelMileageFxTimer = 0 let panelSpeedFxTimer = 0 let panelHeartRateFxTimer = 0 let sessionRecoveryPersistTimer = 0 let resultExitRedirectTimer = 0 let resultExitCountdownTimer = 0 let lastPunchHintHapticAt = 0 let currentSystemSettingsConfig: SystemSettingsConfig | undefined let currentRemoteMapConfig: RemoteMapConfig | undefined let systemSettingsLockLifetimeActive = false let syncedBackendSessionStartId = '' let syncedBackendSessionFinishId = '' let shouldAutoRestoreRecoverySnapshot = false let redirectedToResultPage = false let pendingHeartRateSwitchDeviceName: string | null = null const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1' const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1' 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 clearPunchHintFxTimer() { if (punchHintFxTimer) { clearTimeout(punchHintFxTimer) punchHintFxTimer = 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 updateStoredUserSettings(patch: Partial) { persistStoredUserSettings( mergeStoredUserSettings(loadStoredUserSettings(), patch), ) } function loadStoredMockChannelId(): string { try { const value = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY) if (typeof value === 'string' && value.trim().length > 0) { return value.trim() } } catch (_error) { // Ignore storage read failures and fall back to default. } return 'default' } function persistMockChannelId(channelId: string) { try { wx.setStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY, channelId) } catch (_error) { // Ignore storage write failures in debug preference persistence. } } function loadMockAutoConnectEnabled(): boolean { try { return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true } catch (_error) { return false } } function persistMockAutoConnectEnabled(enabled: boolean) { try { wx.setStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY, enabled) } catch (_error) { // Ignore storage write failures in debug preference persistence. } } function buildResolvedSystemSettingsPatch( resolvedSettings: ResolvedSystemSettingsState, ): Partial { return { ...resolvedSettings.values, ...resolvedSettings.locks, autoRotateEnabled: resolvedSettings.values.autoRotateEnabled, sideButtonPlacement: resolvedSettings.values.sideButtonPlacement, showCenterScaleRuler: resolvedSettings.values.showCenterScaleRuler, centerScaleRulerAnchorMode: resolvedSettings.values.centerScaleRulerAnchorMode, } } function isSystemSettingsLockLifetimeActive(): boolean { return systemSettingsLockLifetimeActive } function clearSessionRecoveryPersistTimer() { if (sessionRecoveryPersistTimer) { clearInterval(sessionRecoveryPersistTimer) sessionRecoveryPersistTimer = 0 } } function clearResultExitRedirectTimer() { if (resultExitRedirectTimer) { clearTimeout(resultExitRedirectTimer) resultExitRedirectTimer = 0 } } function clearResultExitCountdownTimer() { if (resultExitCountdownTimer) { clearInterval(resultExitCountdownTimer) resultExitCountdownTimer = 0 } } function navigateAwayFromMapAfterCancel() { const pages = getCurrentPages() if (pages.length > 1) { wx.navigateBack({ delta: 1, }) return } wx.redirectTo({ url: '/pages/home/home', }) } function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolean { if (!options) { return false } return !!( options.launchId || options.preset || options.configUrl || options.competitionId || options.eventId || options.sessionId || options.launchRequestId ) } function getCurrentBackendSessionContext(): { sessionId: string; sessionToken: string } | null { return getBackendSessionContextFromLaunchEnvelope(currentGameLaunchEnvelope) } function getCurrentBackendBaseUrl(): string { const app = getApp() if (app.globalData && app.globalData.backendBaseUrl) { return app.globalData.backendBaseUrl } return loadBackendBaseUrl() } 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: [], } } function buildRuntimeSummaryRows(envelope: GameLaunchEnvelope): MapEngineGameInfoRow[] { const runtime = envelope.runtime const variantName = envelope.variant ? (envelope.variant.variantName || envelope.variant.variantId || null) : null const variantRouteCode = envelope.variant ? (envelope.variant.routeCode || null) : null if (!runtime) { return [] } const rows: MapEngineGameInfoRow[] = [] rows.push({ label: '运行绑定', value: runtime.runtimeBindingId || '--' }) rows.push({ label: '地点', value: runtime.placeName || runtime.placeId || '--' }) rows.push({ label: '地图', value: runtime.mapName || runtime.mapId || '--' }) rows.push({ label: '赛道集', value: runtime.courseSetId || '--' }) rows.push({ label: '赛道版本', value: runtime.courseVariantId || variantName || '--' }) rows.push({ label: 'RouteCode', value: runtime.routeCode || variantRouteCode || '--' }) rows.push({ label: '瓦片版本', value: runtime.tileReleaseId || '--' }) return rows } Page({ data: { showDebugPanel: false, showGameInfoPanel: false, showResultScene: false, showSystemSettingsPanel: false, showHeartRateDevicePicker: false, showCenterScaleRuler: false, statusBarHeight: 0, topInsetHeight: 12, hudPanelIndex: 0, configSourceText: '顺序赛配置', centerScaleRulerAnchorMode: DEFAULT_STORED_USER_SETTINGS.centerScaleRulerAnchorMode, punchHintFxClass: '', autoRotateEnabled: DEFAULT_STORED_USER_SETTINGS.autoRotateEnabled, ...DEFAULT_SETTING_LOCKS, gameInfoTitle: '当前游戏', gameInfoSubtitle: '未开始', gameInfoLocalRows: [], gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, resultSceneTitle: '本局结果', resultSceneSubtitle: '未开始', resultSceneHeroLabel: '本局用时', resultSceneHeroValue: '--', resultSceneRows: buildEmptyResultSceneSnapshot().rows, resultSceneCountdownText: '', panelTimerText: '00:00:00', panelTimerMode: 'elapsed', panelMileageText: '0m', panelActionTagText: '目标', panelDistanceTagText: '点距', panelTargetSummaryText: '等待选择目标', 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', mockChannelIdText: 'default', mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockChannelIdDraft: 'default', mockCoordText: '--', mockSpeedText: '--', heartRateSourceMode: 'real', heartRateSourceText: '真实心率', mockHeartRateBridgeConnected: false, mockHeartRateBridgeStatusText: '未连接', mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr', mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr', mockHeartRateText: '--', mockDebugLogBridgeConnected: false, mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)', mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log', mockDebugLogBridgeUrlDraft: 'wss://gs.gotomars.xyz/debug-log', heartRateScanText: '未扫描', heartRateDiscoveredDevices: [], panelSpeedValueText: '0', panelTelemetryTone: 'blue', trackDisplayMode: DEFAULT_STORED_USER_SETTINGS.trackDisplayMode, trackTailLength: DEFAULT_STORED_USER_SETTINGS.trackTailLength, trackColorPreset: DEFAULT_STORED_USER_SETTINGS.trackColorPreset, trackStyleProfile: DEFAULT_STORED_USER_SETTINGS.trackStyleProfile, gpsMarkerVisible: DEFAULT_STORED_USER_SETTINGS.gpsMarkerVisible, gpsMarkerStyle: DEFAULT_STORED_USER_SETTINGS.gpsMarkerStyle, gpsMarkerSize: DEFAULT_STORED_USER_SETTINGS.gpsMarkerSize, gpsMarkerColorPreset: DEFAULT_STORED_USER_SETTINGS.gpsMarkerColorPreset, gpsLogoStatusText: '未配置', gpsLogoSourceText: '--', 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: DEFAULT_STORED_USER_SETTINGS.compassTuningProfile, compassTuningProfileText: '平衡', punchButtonText: '打点', punchButtonEnabled: false, skipButtonEnabled: false, punchHintText: '等待进入检查点范围', punchFeedbackVisible: false, punchFeedbackText: '', punchFeedbackTone: 'neutral', contentCardVisible: false, contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', contentCardActions: [], contentQuizVisible: false, contentQuizQuestionText: '', contentQuizCountdownText: '', contentQuizOptions: [], contentQuizFeedbackVisible: false, contentQuizFeedbackText: '', contentQuizFeedbackTone: 'neutral', 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(options: MapPageLaunchOptions) { clearSessionRecoveryPersistTimer() clearResultExitRedirectTimer() clearResultExitCountdownTimer() syncedBackendSessionStartId = '' syncedBackendSessionFinishId = '' redirectedToResultPage = false shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1' currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options) if (!hasExplicitLaunchOptions(options)) { const recoverySnapshot = loadSessionRecoverySnapshot() if (recoverySnapshot) { currentGameLaunchEnvelope = recoverySnapshot.launchEnvelope } } currentSystemSettingsConfig = undefined currentRemoteMapConfig = undefined systemSettingsLockLifetimeActive = false const storedMockChannelId = loadStoredMockChannelId() const shouldAutoConnectMockSources = loadMockAutoConnectEnabled() 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 let shouldSyncRuntimeSystemSettings = false let nextLockLifetimeActive = isSystemSettingsLockLifetimeActive() let heartRateSwitchToastText = '' 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 } if ( typeof nextPatch.mockDebugLogBridgeUrlText === 'string' && this.data.mockDebugLogBridgeUrlDraft === this.data.mockDebugLogBridgeUrlText ) { nextData.mockDebugLogBridgeUrlDraft = nextPatch.mockDebugLogBridgeUrlText } if ( typeof nextPatch.mockChannelIdText === 'string' && this.data.mockChannelIdDraft === this.data.mockChannelIdText ) { nextData.mockChannelIdDraft = nextPatch.mockChannelIdText } 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() clearPunchHintFxTimer() nextData.showPunchHintBanner = nextHintText.length > 0 if (nextHintText.length > 0) { nextData.punchHintFxClass = 'game-punch-hint--fx-enter' punchHintFxTimer = setTimeout(() => { punchHintFxTimer = 0 this.setData({ punchHintFxClass: '', }) }, PUNCH_HINT_FX_DURATION_MS) as unknown as number const now = Date.now() if (mapEngine && now - lastPunchHintHapticAt >= PUNCH_HINT_HAPTIC_GAP_MS) { mapEngine.playPunchHintHaptic() lastPunchHintHapticAt = now } punchHintDismissTimer = setTimeout(() => { punchHintDismissTimer = 0 this.setData({ showPunchHintBanner: false, }) }, PUNCH_HINT_AUTO_HIDE_MS) as unknown as number } } else if (!nextHintText) { clearPunchHintDismissTimer() clearPunchHintFxTimer() nextData.showPunchHintBanner = false nextData.punchHintFxClass = '' } } const nextAnimationLevel = typeof nextPatch.animationLevel === 'string' ? nextPatch.animationLevel : this.data.animationLevel let shouldSyncBackendSessionStart = false let backendSessionFinishStatus: 'finished' | 'failed' | null = null let shouldOpenResultExitPrompt = false let resultPageSnapshot: MapEngineResultSnapshot | null = null 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') ) { systemSettingsLockLifetimeActive = false nextLockLifetimeActive = false shouldSyncRuntimeSystemSettings = true clearSessionRecoverySnapshot() clearSessionRecoveryPersistTimer() clearResultExitRedirectTimer() clearResultExitCountdownTimer() resultPageSnapshot = mapEngine ? mapEngine.getResultSceneSnapshot() : null nextData.showResultScene = true nextData.showDebugPanel = false nextData.showGameInfoPanel = false nextData.showSystemSettingsPanel = false clearGameInfoPanelSyncTimer() backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed' shouldOpenResultExitPrompt = true if (resultPageSnapshot) { nextData.resultSceneTitle = resultPageSnapshot.title nextData.resultSceneSubtitle = resultPageSnapshot.subtitle nextData.resultSceneHeroLabel = resultPageSnapshot.heroLabel nextData.resultSceneHeroValue = resultPageSnapshot.heroValue nextData.resultSceneRows = resultPageSnapshot.rows } nextData.resultSceneCountdownText = '3 秒后自动进入成绩页' } else if ( nextPatch.gameSessionStatus !== this.data.gameSessionStatus && nextPatch.gameSessionStatus === 'idle' && !isSystemSettingsLockLifetimeActive() ) { nextLockLifetimeActive = false shouldSyncRuntimeSystemSettings = true clearSessionRecoverySnapshot() clearSessionRecoveryPersistTimer() clearResultExitRedirectTimer() clearResultExitCountdownTimer() } else if ( nextPatch.gameSessionStatus !== this.data.gameSessionStatus && nextPatch.gameSessionStatus === 'running' ) { shouldSyncBackendSessionStart = true } else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') { nextData.showResultScene = false } } if ( pendingHeartRateSwitchDeviceName && nextPatch.heartRateConnected === true && typeof nextPatch.heartRateDeviceText === 'string' ) { const connectedDeviceName = nextPatch.heartRateDeviceText.trim() if (connectedDeviceName && connectedDeviceName === pendingHeartRateSwitchDeviceName) { heartRateSwitchToastText = `已切换到 ${connectedDeviceName}` nextData.statusText = `已切换心率带:${connectedDeviceName}` pendingHeartRateSwitchDeviceName = null } } if (Object.keys(nextData).length || Object.keys(derivedPatch).length) { this.setData({ ...nextData, ...derivedPatch, }, () => { if (typeof nextPatch.gameSessionStatus === 'string') { this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus) } if (shouldSyncBackendSessionStart) { this.syncBackendSessionStart() } if (backendSessionFinishStatus) { this.syncBackendSessionFinish(backendSessionFinishStatus) } if (shouldOpenResultExitPrompt && resultPageSnapshot) { this.stashPendingResultSnapshot(resultPageSnapshot) this.presentResultExitPrompt() } if (heartRateSwitchToastText) { wx.showToast({ title: `${heartRateSwitchToastText},并设为首选设备`, icon: 'none', duration: 1800, }) } if (shouldSyncRuntimeSystemSettings) { this.applyRuntimeSystemSettings(nextLockLifetimeActive) } if (this.data.showGameInfoPanel) { this.scheduleGameInfoPanelSnapshotSync() } }) } else { if (typeof nextPatch.gameSessionStatus === 'string') { this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus) } if (shouldSyncBackendSessionStart) { this.syncBackendSessionStart() } if (backendSessionFinishStatus) { this.syncBackendSessionFinish(backendSessionFinishStatus) } if (shouldOpenResultExitPrompt && resultPageSnapshot) { this.stashPendingResultSnapshot(resultPageSnapshot) this.presentResultExitPrompt() } if (shouldSyncRuntimeSystemSettings) { this.applyRuntimeSystemSettings(nextLockLifetimeActive) } if (this.data.showGameInfoPanel) { this.scheduleGameInfoPanelSnapshotSync() } } }, onOpenH5Experience: (request) => { this.openH5Experience(request) }, }) mapEngine.applyTelemetryPlayerProfile(getGlobalTelemetryProfile()) const systemSettingsState = resolveSystemSettingsState(undefined, undefined, false) const initialSystemSettings = systemSettingsState.values mapEngine.applyCompiledSettingsProfile({ values: initialSystemSettings, locks: systemSettingsState.locks, lockLifetimeActive: false, }) mapEngine.setDiagnosticUiEnabled(false) centerScaleRulerInputCache = { stageWidth: 0, stageHeight: 0, zoom: 0, centerTileY: 0, tileSizePx: 0, previewScale: 1, } const initialEngineData = mapEngine.getInitialData() this.setData({ ...initialEngineData, ...buildResolvedSystemSettingsPatch(systemSettingsState), showDebugPanel: false, showGameInfoPanel: false, showResultScene: false, showSystemSettingsPanel: false, statusBarHeight, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), hudPanelIndex: 0, configSourceText: currentGameLaunchEnvelope.config.configLabel, gameInfoTitle: '当前游戏', gameInfoSubtitle: '未开始', gameInfoLocalRows: [], gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, resultSceneTitle: '本局结果', resultSceneSubtitle: '未开始', resultSceneHeroLabel: '本局用时', resultSceneHeroValue: '--', resultSceneRows: buildEmptyResultSceneSnapshot().rows, resultSceneCountdownText: '', panelTimerText: '00:00:00', panelTimerMode: 'elapsed', panelTimerFxClass: '', panelMileageText: '0m', panelMileageFxClass: '', panelActionTagText: '目标', panelDistanceTagText: '点距', panelTargetSummaryText: '等待选择目标', 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', mockChannelIdText: storedMockChannelId, mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockChannelIdDraft: storedMockChannelId, mockCoordText: '--', mockSpeedText: '--', heartRateSourceMode: 'real', heartRateSourceText: '真实心率', mockHeartRateBridgeConnected: false, mockHeartRateBridgeStatusText: '未连接', mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr', mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr', mockHeartRateText: '--', mockDebugLogBridgeConnected: false, mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)', mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log', mockDebugLogBridgeUrlDraft: 'wss://gs.gotomars.xyz/debug-log', panelSpeedValueText: '0', panelSpeedFxClass: '', panelTelemetryTone: 'blue', gpsLogoStatusText: '未配置', gpsLogoSourceText: '--', 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: '无数据', compassTuningProfileText: initialEngineData.compassTuningProfileText || '平衡', punchButtonText: '打点', punchButtonEnabled: false, skipButtonEnabled: false, punchHintText: '等待进入检查点范围', punchHintFxClass: '', punchFeedbackVisible: false, punchFeedbackText: '', punchFeedbackTone: 'neutral', contentCardVisible: false, contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', contentCardActions: [], contentQuizVisible: false, contentQuizQuestionText: '', contentQuizCountdownText: '', contentQuizOptions: [], contentQuizFeedbackVisible: false, contentQuizFeedbackText: '', contentQuizFeedbackTone: 'neutral', 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: initialSystemSettings.showCenterScaleRuler, centerScaleRulerAnchorMode: initialSystemSettings.centerScaleRulerAnchorMode, skipButtonEnabled: false, gameSessionStatus: 'idle', gpsLockEnabled: false, gpsLockAvailable: false, }), ...buildCenterScaleRulerPatch({ ...(mapEngine.getInitialData() as MapPageData), showCenterScaleRuler: initialSystemSettings.showCenterScaleRuler, centerScaleRulerAnchorMode: initialSystemSettings.centerScaleRulerAnchorMode, stageWidth: 0, stageHeight: 0, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), zoom: 0, centerTileY: 0, tileSizePx: 0, }), }, () => { if (shouldAutoConnectMockSources) { this.handleConnectAllMockSources() } }) }, onReady() { stageCanvasAttached = false this.measureStageAndCanvas() this.loadGameLaunchEnvelope(currentGameLaunchEnvelope) const app = getApp() const pendingHeartRateAutoConnect = app.globalData ? app.globalData.pendingHeartRateAutoConnect : null if (pendingHeartRateAutoConnect && pendingHeartRateAutoConnect.enabled && mapEngine) { const pendingDeviceName = pendingHeartRateAutoConnect.deviceName || '心率带' app.globalData.pendingHeartRateAutoConnect = null mapEngine.handleConnectHeartRate() this.setData({ statusText: `正在自动连接局前设备:${pendingDeviceName}`, heartRateStatusText: `正在自动连接 ${pendingDeviceName}`, heartRateDeviceText: pendingDeviceName, }) } }, onShow() { if (mapEngine) { this.applyCompiledRuntimeProfiles() mapEngine.handleAppShow() } }, onHide() { this.persistSessionRecoverySnapshot() clearResultExitRedirectTimer() clearResultExitCountdownTimer() if (mapEngine) { mapEngine.handleAppHide() } }, onUnload() { this.persistSessionRecoverySnapshot() clearSessionRecoveryPersistTimer() clearResultExitRedirectTimer() clearResultExitCountdownTimer() syncedBackendSessionStartId = '' syncedBackendSessionFinishId = '' clearGameInfoPanelSyncTimer() clearCenterScaleRulerSyncTimer() clearCenterScaleRulerUpdateTimer() clearPunchHintDismissTimer() clearPunchHintFxTimer() clearHudFxTimer('timer') clearHudFxTimer('mileage') clearHudFxTimer('speed') clearHudFxTimer('heartRate') if (mapEngine) { mapEngine.destroy() mapEngine = null } currentSystemSettingsConfig = undefined currentRemoteMapConfig = undefined systemSettingsLockLifetimeActive = false currentGameLaunchEnvelope = getDemoGameLaunchEnvelope() shouldAutoRestoreRecoverySnapshot = false redirectedToResultPage = false stageCanvasAttached = false }, loadGameLaunchEnvelope(envelope: GameLaunchEnvelope) { this.loadMapConfigFromRemote( envelope.config.configUrl, envelope.config.configLabel, ) }, persistSessionRecoverySnapshot() { if (!mapEngine || !currentRemoteMapConfig) { return false } const runtimeSnapshot = mapEngine.buildSessionRecoveryRuntimeSnapshot() if (!runtimeSnapshot) { return false } const snapshot: SessionRecoverySnapshot = { schemaVersion: 1, savedAt: Date.now(), launchEnvelope: currentGameLaunchEnvelope, configAppId: currentRemoteMapConfig.configAppId, configVersion: currentRemoteMapConfig.configVersion, runtime: runtimeSnapshot, } saveSessionRecoverySnapshot(snapshot) return true }, syncBackendSessionStart() { const sessionContext = getCurrentBackendSessionContext() if (!sessionContext || syncedBackendSessionStartId === sessionContext.sessionId) { return } startSession({ baseUrl: getCurrentBackendBaseUrl(), sessionId: sessionContext.sessionId, sessionToken: sessionContext.sessionToken, }) .then(() => { syncedBackendSessionStartId = sessionContext.sessionId }) .catch((error) => { const message = error && error.message ? error.message : '未知错误' this.setData({ statusText: `session start 上报失败: ${message}`, }) }) }, syncBackendSessionFinish(statusOverride?: 'finished' | 'failed' | 'cancelled') { const sessionContext = getCurrentBackendSessionContext() if (!sessionContext || syncedBackendSessionFinishId === sessionContext.sessionId || !mapEngine) { return } const finishSummary = mapEngine.getSessionFinishSummary(statusOverride) if (!finishSummary) { return } const summaryPayload: BackendSessionFinishSummaryPayload = {} if (typeof finishSummary.finalDurationSec === 'number') { summaryPayload.finalDurationSec = finishSummary.finalDurationSec } if (typeof finishSummary.finalScore === 'number') { summaryPayload.finalScore = finishSummary.finalScore } if (typeof finishSummary.completedControls === 'number') { summaryPayload.completedControls = finishSummary.completedControls } if (typeof finishSummary.totalControls === 'number') { summaryPayload.totalControls = finishSummary.totalControls } if (typeof finishSummary.distanceMeters === 'number') { summaryPayload.distanceMeters = finishSummary.distanceMeters } if (typeof finishSummary.averageSpeedKmh === 'number') { summaryPayload.averageSpeedKmh = finishSummary.averageSpeedKmh } finishSession({ baseUrl: getCurrentBackendBaseUrl(), sessionId: sessionContext.sessionId, sessionToken: sessionContext.sessionToken, status: finishSummary.status, summary: summaryPayload, }) .then(() => { syncedBackendSessionFinishId = sessionContext.sessionId }) .catch((error) => { const message = error && error.message ? error.message : '未知错误' this.setData({ statusText: `session finish 上报失败: ${message}`, }) }) }, reportAbandonedRecoverySnapshot(snapshot: SessionRecoverySnapshot) { const sessionContext = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope) if (!sessionContext) { clearSessionRecoverySnapshot() return } finishSession({ baseUrl: getCurrentBackendBaseUrl(), sessionId: sessionContext.sessionId, sessionToken: sessionContext.sessionToken, status: 'cancelled', summary: {}, }) .then(() => { syncedBackendSessionFinishId = sessionContext.sessionId clearSessionRecoverySnapshot() wx.showToast({ title: '已放弃上次对局', icon: 'none', duration: 1400, }) }) .catch((error) => { clearSessionRecoverySnapshot() const message = error && error.message ? error.message : '未知错误' this.setData({ statusText: `放弃恢复已生效,后端取消上报失败: ${message}`, }) wx.showToast({ title: '已放弃上次对局', icon: 'none', duration: 1400, }) }) }, stashPendingResultSnapshot(snapshot: MapEngineResultSnapshot) { const app = getApp() if (app.globalData) { app.globalData.pendingResultSnapshot = snapshot app.globalData.pendingResultLaunchEnvelope = currentGameLaunchEnvelope } }, redirectToResultPage() { if (redirectedToResultPage) { return } clearResultExitRedirectTimer() clearResultExitCountdownTimer() redirectedToResultPage = true const sessionContext = getCurrentBackendSessionContext() const resultUrl = sessionContext ? `/pages/result/result?sessionId=${encodeURIComponent(sessionContext.sessionId)}` : '/pages/result/result' wx.redirectTo({ url: resultUrl, }) }, presentResultExitPrompt() { clearResultExitRedirectTimer() clearResultExitCountdownTimer() let remainingSeconds = Math.ceil(RESULT_EXIT_REDIRECT_DELAY_MS / 1000) this.setData({ showResultScene: true, resultSceneCountdownText: `${remainingSeconds} 秒后自动进入成绩页`, }) resultExitCountdownTimer = setInterval(() => { remainingSeconds -= 1 if (remainingSeconds <= 0) { clearResultExitCountdownTimer() return } this.setData({ resultSceneCountdownText: `${remainingSeconds} 秒后自动进入成绩页`, }) }, 1000) as unknown as number resultExitRedirectTimer = setTimeout(() => { resultExitRedirectTimer = 0 this.redirectToResultPage() }, RESULT_EXIT_REDIRECT_DELAY_MS) as unknown as number }, restoreRecoverySnapshot(snapshot: SessionRecoverySnapshot) { systemSettingsLockLifetimeActive = true this.applyRuntimeSystemSettings(true) const restored = mapEngine ? mapEngine.restoreSessionRecoveryRuntimeSnapshot(snapshot.runtime) : false if (!restored) { clearSessionRecoverySnapshot() wx.showToast({ title: '恢复失败,已回到初始状态', icon: 'none', duration: 1600, }) return false } this.setData({ showResultScene: false, showDebugPanel: false, showGameInfoPanel: false, showSystemSettingsPanel: false, }) const sessionContext = getCurrentBackendSessionContext() if (sessionContext) { syncedBackendSessionStartId = sessionContext.sessionId } this.syncSessionRecoveryLifecycle('running') return true }, syncSessionRecoveryLifecycle(status: MapPageData['gameSessionStatus']) { if (status === 'running') { this.persistSessionRecoverySnapshot() if (!sessionRecoveryPersistTimer) { sessionRecoveryPersistTimer = setInterval(() => { this.persistSessionRecoverySnapshot() }, SESSION_RECOVERY_PERSIST_INTERVAL_MS) as unknown as number } return } clearSessionRecoveryPersistTimer() }, maybePromptSessionRecoveryRestore(config: RemoteMapConfig) { const snapshot = loadSessionRecoverySnapshot() if (!snapshot || !mapEngine) { return } if ( snapshot.launchEnvelope.config.configUrl !== currentGameLaunchEnvelope.config.configUrl || snapshot.configAppId !== config.configAppId || snapshot.configVersion !== config.configVersion ) { clearSessionRecoverySnapshot() return } if (shouldAutoRestoreRecoverySnapshot) { shouldAutoRestoreRecoverySnapshot = false this.restoreRecoverySnapshot(snapshot) return } wx.showModal({ title: '恢复对局', content: '检测到上次有未正常结束的对局,是否继续恢复?', confirmText: '继续恢复', cancelText: '放弃', success: (result) => { if (!result.confirm) { this.reportAbandonedRecoverySnapshot(snapshot) return } this.restoreRecoverySnapshot(snapshot) }, }) }, compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) { if (!currentRemoteMapConfig) { return null } return compileRuntimeProfile(currentRemoteMapConfig, { playerTelemetryProfile: getGlobalTelemetryProfile(), settingsLockLifetimeActive: lockLifetimeActive, }) }, applyCompiledRuntimeProfiles( lockLifetimeActive = isSystemSettingsLockLifetimeActive(), options?: { includeSettings?: boolean includeMap?: boolean includeGame?: boolean includePresentation?: boolean includeTelemetry?: boolean includeFeedback?: boolean }, ) { const currentEngine = mapEngine if (!currentEngine) { return null } const compiledProfile = this.compileCurrentRuntimeProfile(lockLifetimeActive) if (!compiledProfile) { return null } if (options && options.includeMap) { currentEngine.applyCompiledMapProfile(compiledProfile.map) } if (options && options.includeSettings) { currentEngine.applyCompiledSettingsProfile(compiledProfile.settings) } if (options && options.includeGame) { currentEngine.applyCompiledGameProfile(compiledProfile.game) } if (options && options.includePresentation) { currentEngine.applyCompiledPresentationProfile(compiledProfile.presentation) } if (!options || options.includeTelemetry !== false) { currentEngine.applyCompiledTelemetryProfile(compiledProfile.telemetry) } if (!options || options.includeFeedback !== false) { currentEngine.applyCompiledFeedbackProfile(compiledProfile.feedback) } return compiledProfile }, applyRuntimeSystemSettings(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) { const currentEngine = mapEngine if (!currentEngine) { return null } const compiledProfile = this.applyCompiledRuntimeProfiles(lockLifetimeActive, { includeSettings: true, }) || { settings: resolveSystemSettingsState( currentSystemSettingsConfig, undefined, lockLifetimeActive, ), } const resolvedSettings = compiledProfile.settings const engineSnapshot = currentEngine.getInitialData() as Partial updateCenterScaleRulerInputCache(engineSnapshot) const resolvedPatch = buildResolvedSystemSettingsPatch(resolvedSettings) const mergedData = { ...centerScaleRulerInputCache, ...this.data, ...engineSnapshot, ...resolvedPatch, } as MapPageData this.setData({ ...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, resolvedSettings.values.showCenterScaleRuler), ...resolvedPatch, ...buildCenterScaleRulerPatch(mergedData), ...buildSideButtonState(mergedData), }) return resolvedSettings }, persistAndApplySystemSettings( patch: Partial, options?: { applyCenterScaleRuler?: boolean }, ) { updateStoredUserSettings(patch) const lockLifetimeActive = isSystemSettingsLockLifetimeActive() const resolvedSettings = this.applyRuntimeSystemSettings(lockLifetimeActive) if (!resolvedSettings || !(options && options.applyCenterScaleRuler)) { return resolvedSettings } this.applyCenterScaleRulerSettings( resolvedSettings.values.showCenterScaleRuler, resolvedSettings.values.centerScaleRulerAnchorMode, ) return resolvedSettings }, 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) this.applyConfiguredSystemSettings(config) this.applyCompiledRuntimeProfiles(true, { includeMap: true, includeGame: true, includePresentation: true, }) this.maybePromptSessionRecoveryRestore(config) }) .catch((error) => { if (mapEngine !== currentEngine) { return } const rawErrorMessage = error && error.message ? error.message : '未知错误' const errorMessage = rawErrorMessage.indexOf('404') >= 0 ? `release manifest 不存在或未发布 (${configLabel})` : rawErrorMessage this.setData({ configStatusText: `载入失败: ${errorMessage}`, statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`, }) }) }, applyConfiguredSystemSettings(config: RemoteMapConfig) { currentRemoteMapConfig = config currentSystemSettingsConfig = config.systemSettingsConfig systemSettingsLockLifetimeActive = true this.applyRuntimeSystemSettings(true) }, 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 } const channelId = (this.data.mockChannelIdDraft || '').trim() || 'default' this.setData({ mockChannelIdDraft: channelId, }) persistMockChannelId(channelId) persistMockAutoConnectEnabled(true) mapEngine.handleSetMockChannelId(channelId) mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft) mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft) mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) mapEngine.handleConnectMockLocationBridge() mapEngine.handleSetMockLocationMode() mapEngine.handleSetMockHeartRateMode() mapEngine.handleConnectMockHeartRateBridge() mapEngine.handleConnectMockDebugLogBridge() }, handleOpenWebViewTest() { wx.navigateTo({ url: '/pages/webview-test/webview-test', }) }, handleMockChannelIdInput(event: WechatMiniprogram.Input) { this.setData({ mockChannelIdDraft: event.detail.value, }) }, handleSaveMockChannelId() { const channelId = (this.data.mockChannelIdDraft || '').trim() || 'default' this.setData({ mockChannelIdDraft: channelId, }) persistMockChannelId(channelId) if (mapEngine) { mapEngine.handleSetMockChannelId(channelId) } }, handleMockBridgeUrlInput(event: WechatMiniprogram.Input) { this.setData({ mockBridgeUrlDraft: event.detail.value, }) }, handleSaveMockBridgeUrl() { if (mapEngine) { mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft) } }, handleDisconnectMockLocationBridge() { persistMockAutoConnectEnabled(false) 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) } }, handleMockDebugLogBridgeUrlInput(event: WechatMiniprogram.Input) { this.setData({ mockDebugLogBridgeUrlDraft: event.detail.value, }) }, handleSaveMockDebugLogBridgeUrl() { if (mapEngine) { mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) } }, handleConnectMockDebugLogBridge() { if (mapEngine) { mapEngine.handleConnectMockDebugLogBridge() } }, handleDisconnectMockDebugLogBridge() { persistMockAutoConnectEnabled(false) if (mapEngine) { mapEngine.handleDisconnectMockDebugLogBridge() } }, handleConnectMockHeartRateBridge() { if (mapEngine) { mapEngine.handleConnectMockHeartRateBridge() } }, handleDisconnectMockHeartRateBridge() { persistMockAutoConnectEnabled(false) if (mapEngine) { mapEngine.handleDisconnectMockHeartRateBridge() } }, handleConnectHeartRate() { if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') { return } if (mapEngine) { mapEngine.handleConnectHeartRate() } }, handleOpenHeartRateDevicePicker() { if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') { return } this.setData({ showHeartRateDevicePicker: true, }) if (mapEngine) { mapEngine.handleConnectHeartRate() } }, handleCloseHeartRateDevicePicker() { this.setData({ showHeartRateDevicePicker: false, }) }, handleDisconnectHeartRate() { if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') { return } if (mapEngine) { mapEngine.handleDisconnectHeartRate() } }, handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) { if (mapEngine && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.deviceId) { const targetDeviceId = event.currentTarget.dataset.deviceId const targetDevice = this.data.heartRateDiscoveredDevices.find((item) => item.deviceId === targetDeviceId) pendingHeartRateSwitchDeviceName = targetDevice ? targetDevice.name : null mapEngine.handleConnectHeartRateDevice(targetDeviceId) this.setData({ showHeartRateDevicePicker: false, statusText: targetDevice ? `正在切换到 ${targetDevice.name}` : '正在切换心率带设备', }) } }, 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') } }, handleDebugSetSessionRemainingWarning() { if (mapEngine) { mapEngine.handleDebugSetSessionRemainingWarning() } }, handleDebugSetSessionRemainingOneMinute() { if (mapEngine) { mapEngine.handleDebugSetSessionRemainingOneMinute() } }, handleDebugTimeoutSession() { if (mapEngine) { mapEngine.handleDebugTimeoutSession() } }, handleClearDebugHeartRate() { if (mapEngine) { mapEngine.handleClearDebugHeartRate() } }, handleToggleOsmReference() { if (mapEngine) { mapEngine.handleToggleOsmReference() } }, handleStartGame() { if (mapEngine) { systemSettingsLockLifetimeActive = true this.applyRuntimeSystemSettings(true) mapEngine.handleStartGame() } }, handleLoadClassicConfig() { currentGameLaunchEnvelope = getDemoGameLaunchEnvelope('classic') this.loadGameLaunchEnvelope(currentGameLaunchEnvelope) }, handleLoadScoreOConfig() { currentGameLaunchEnvelope = getDemoGameLaunchEnvelope('score-o') this.loadGameLaunchEnvelope(currentGameLaunchEnvelope) }, handleForceExitGame() { if (!mapEngine || this.data.gameSessionStatus !== 'running') { return } wx.showModal({ title: '确认退出', content: '确认强制结束当前对局并返回开始前状态?', confirmText: '确认退出', cancelText: '取消', success: (result) => { if (result.confirm && mapEngine) { clearResultExitRedirectTimer() clearResultExitCountdownTimer() this.syncBackendSessionFinish('cancelled') clearSessionRecoverySnapshot() clearSessionRecoveryPersistTimer() systemSettingsLockLifetimeActive = false mapEngine.handleForceExitGame() wx.showToast({ title: '已退出当前对局', icon: 'none', duration: 1000, }) setTimeout(() => { navigateAwayFromMapAfterCancel() }, 180) } }, }) }, 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([ ...buildRuntimeSummaryRows(currentGameLaunchEnvelope), { 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.concat(buildRuntimeSummaryRows(currentGameLaunchEnvelope)), }) }, 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.redirectToResultPage() }, handleRestartFromResult() { this.redirectToResultPage() }, 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 } this.persistAndApplySystemSettings({ animationLevel: 'standard', }) }, handleSetAnimationLevelLite() { if (this.data.lockAnimationLevel || !mapEngine) { return } this.persistAndApplySystemSettings({ animationLevel: 'lite', }) }, handleSetTrackModeNone() { if (this.data.lockTrackMode || !mapEngine) { return } this.persistAndApplySystemSettings({ trackDisplayMode: 'none', }) }, handleSetTrackModeTail() { if (this.data.lockTrackMode || !mapEngine) { return } this.persistAndApplySystemSettings({ trackDisplayMode: 'tail', }) }, handleSetTrackModeFull() { if (this.data.lockTrackMode || !mapEngine) { return } this.persistAndApplySystemSettings({ trackDisplayMode: 'full', }) }, handleSetTrackTailLengthShort() { if (this.data.lockTrackTailLength || !mapEngine) { return } this.persistAndApplySystemSettings({ trackTailLength: 'short', }) }, handleSetTrackTailLengthMedium() { if (this.data.lockTrackTailLength || !mapEngine) { return } this.persistAndApplySystemSettings({ trackTailLength: 'medium', }) }, handleSetTrackTailLengthLong() { if (this.data.lockTrackTailLength || !mapEngine) { return } this.persistAndApplySystemSettings({ trackTailLength: 'long', }) }, handleSetTrackColorPreset(event: WechatMiniprogram.TouchEvent) { if (this.data.lockTrackColor || !mapEngine) { return } const color = event.currentTarget.dataset.color as TrackColorPreset | undefined if (!color) { return } this.persistAndApplySystemSettings({ trackColorPreset: color, }) }, handleSetTrackStyleClassic() { if (this.data.lockTrackStyle || !mapEngine) { return } this.persistAndApplySystemSettings({ trackStyleProfile: 'classic', }) }, handleSetTrackStyleNeon() { if (this.data.lockTrackStyle || !mapEngine) { return } this.persistAndApplySystemSettings({ trackStyleProfile: 'neon', }) }, handleSetGpsMarkerVisibleOn() { if (this.data.lockGpsMarkerVisible || !mapEngine) { return } this.persistAndApplySystemSettings({ gpsMarkerVisible: true, }) }, handleSetGpsMarkerVisibleOff() { if (this.data.lockGpsMarkerVisible || !mapEngine) { return } this.persistAndApplySystemSettings({ gpsMarkerVisible: false, }) }, handleSetGpsMarkerStyleDot() { if (this.data.lockGpsMarkerStyle || !mapEngine) { return } this.persistAndApplySystemSettings({ gpsMarkerStyle: 'dot', }) }, handleSetGpsMarkerStyleBeacon() { if (this.data.lockGpsMarkerStyle || !mapEngine) { return } this.persistAndApplySystemSettings({ gpsMarkerStyle: 'beacon', }) }, handleSetGpsMarkerStyleDisc() { if (this.data.lockGpsMarkerStyle || !mapEngine) { return } this.persistAndApplySystemSettings({ gpsMarkerStyle: 'disc', }) }, handleSetGpsMarkerStyleBadge() { if (this.data.lockGpsMarkerStyle || !mapEngine) { return } this.persistAndApplySystemSettings({ gpsMarkerStyle: 'badge', }) }, handleSetGpsMarkerSizeSmall() { if (this.data.lockGpsMarkerSize || !mapEngine) { return } this.persistAndApplySystemSettings({ gpsMarkerSize: 'small', }) }, handleSetGpsMarkerSizeMedium() { if (this.data.lockGpsMarkerSize || !mapEngine) { return } this.persistAndApplySystemSettings({ gpsMarkerSize: 'medium', }) }, handleSetGpsMarkerSizeLarge() { if (this.data.lockGpsMarkerSize || !mapEngine) { return } this.persistAndApplySystemSettings({ gpsMarkerSize: 'large', }) }, handleSetGpsMarkerColorPreset(event: WechatMiniprogram.TouchEvent) { if (this.data.lockGpsMarkerColor || !mapEngine) { return } const color = event.currentTarget.dataset.color as GpsMarkerColorPreset | undefined if (!color) { return } this.persistAndApplySystemSettings({ gpsMarkerColorPreset: color, }) }, handleSetSideButtonPlacementLeft() { if (this.data.lockSideButtonPlacement) { return } this.persistAndApplySystemSettings({ sideButtonPlacement: 'left', }) }, handleSetSideButtonPlacementRight() { if (this.data.lockSideButtonPlacement) { return } this.persistAndApplySystemSettings({ sideButtonPlacement: 'right', }) }, handleSetAutoRotateEnabledOn() { if (this.data.lockAutoRotate || !mapEngine) { return } this.persistAndApplySystemSettings({ autoRotateEnabled: true, }) }, handleSetAutoRotateEnabledOff() { if (this.data.lockAutoRotate || !mapEngine) { return } this.persistAndApplySystemSettings({ autoRotateEnabled: false, }) }, handleSetCompassTuningSmooth() { if (this.data.lockCompassTuning || !mapEngine) { return } this.persistAndApplySystemSettings({ compassTuningProfile: 'smooth', }) }, handleSetCompassTuningBalanced() { if (this.data.lockCompassTuning || !mapEngine) { return } this.persistAndApplySystemSettings({ compassTuningProfile: 'balanced', }) }, handleSetCompassTuningResponsive() { if (this.data.lockCompassTuning || !mapEngine) { return } this.persistAndApplySystemSettings({ compassTuningProfile: 'responsive', }) }, handleSetNorthReferenceMagnetic() { if (this.data.lockNorthReference || !mapEngine) { return } this.persistAndApplySystemSettings({ northReferenceMode: 'magnetic', }) }, handleSetNorthReferenceTrue() { if (this.data.lockNorthReference || !mapEngine) { return } this.persistAndApplySystemSettings({ northReferenceMode: 'true', }) }, handleOverlayTouch() {}, handlePunchAction() { if (!this.data.punchButtonEnabled) { return } if (mapEngine) { mapEngine.handlePunchAction() } }, handleOpenPendingContentCard() { if (mapEngine) { mapEngine.openPendingContentCard() } }, handleOpenContentCardAction(event: WechatMiniprogram.BaseEvent) { if (!mapEngine) { return } wx.showToast({ title: '点击CTA', icon: 'none', duration: 900, }) const actionType = event.currentTarget.dataset.type const action = typeof actionType === 'string' ? mapEngine.openCurrentContentCardAction(actionType) : null if (action === 'detail') { wx.showToast({ title: '打开详情', icon: 'none', duration: 900, }) return } if (action === 'quiz') { return } if (action === 'photo') { wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['camera'], success: () => { if (mapEngine) { mapEngine.handleContentCardPhotoCaptured() } }, }) return } if (action === 'audio') { if (!contentAudioRecorder) { contentAudioRecorder = wx.getRecorderManager() contentAudioRecorder.onStop(() => { contentAudioRecording = false if (mapEngine) { mapEngine.handleContentCardAudioRecorded() } }) } const recorder = contentAudioRecorder if (!contentAudioRecording) { contentAudioRecording = true recorder.start({ duration: 8000, format: 'mp3', } as any) wx.showToast({ title: '开始录音', icon: 'none', duration: 800, }) } else { recorder.stop() } } }, handleContentQuizAnswer(event: WechatMiniprogram.BaseEvent) { if (!mapEngine) { return } const optionKey = event.currentTarget.dataset.key if (typeof optionKey === 'string') { mapEngine.handleContentCardQuizAnswer(optionKey) } }, handleDismissTransientContentCard() { if (mapEngine) { mapEngine.closeContentCard() } }, handleContentCardTap() { if (!mapEngine) { return } if (!this.data.contentCardActions.length) { mapEngine.closeContentCard() } }, 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') { this.persistAndApplySystemSettings({ autoRotateEnabled: false, }) return } this.persistAndApplySystemSettings({ 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.persistAndApplySystemSettings({ showCenterScaleRuler: true, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, }, { applyCenterScaleRuler: true, }) }, handleSetCenterScaleRulerVisibleOff() { if (this.data.lockScaleRulerVisible) { return } this.persistAndApplySystemSettings({ showCenterScaleRuler: false, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, }, { applyCenterScaleRuler: true, }) }, handleSetCenterScaleRulerAnchorScreenCenter() { if (this.data.lockScaleRulerAnchor) { return } this.persistAndApplySystemSettings({ showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: 'screen-center', }, { applyCenterScaleRuler: true, }) }, handleSetCenterScaleRulerAnchorCompassCenter() { if (this.data.lockScaleRulerAnchor) { return } this.persistAndApplySystemSettings({ showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: 'compass-center', }, { applyCenterScaleRuler: true, }) }, handleToggleCenterScaleRulerAnchor() { if (!this.data.showCenterScaleRuler || this.data.lockScaleRulerAnchor) { return } const nextAnchorMode: CenterScaleRulerAnchorMode = this.data.centerScaleRulerAnchorMode === 'screen-center' ? 'compass-center' : 'screen-center' this.persistAndApplySystemSettings({ centerScaleRulerAnchorMode: nextAnchorMode, showCenterScaleRuler: this.data.showCenterScaleRuler, }, { applyCenterScaleRuler: true, }) }, handleDebugPanelTap() {}, })