Explorar el Código

完善地图交互、动画与罗盘调试

zhangyan hace 1 semana
padre
commit
5fc996dea1

+ 212 - 0
compass-debugging-notes.md

@@ -0,0 +1,212 @@
+# 罗盘问题排查记录
+
+## 背景
+
+本项目在微信小程序中使用罗盘驱动:
+
+- 指北针针头
+- 指北针顶部角度数字
+- `heading-up` 自动转图
+
+在一次围绕顶部提示窗、传感器显示链和性能优化的修改后,出现了以下问题:
+
+- iOS 端偶发正常,偶发异常
+- Android 端罗盘长期无样本
+- 指北针不转
+- `heading-up` 自动转图一起失效
+
+## 最终结论
+
+这次问题的主因不是算法本身,而是:
+
+**Android 微信环境下,罗盘监听需要被持续保活;之前将多处看似冗余的 `compassController.start()` 清理掉后,Android 的罗盘样本链被破坏了。**
+
+也就是说:
+
+- iOS 对罗盘监听更宽容
+- Android 对罗盘监听更脆弱
+- 之前稳定,不是因为链路更“干净”,而是因为老代码里存在一条实际有效的“罗盘保活链”
+
+## 现象总结
+
+### 失效期
+
+- Android 调试面板里 `Compass Source` 为 `无数据`
+- iOS 仍可能有 `罗盘` 样本
+- 若强行用 `DeviceMotion` 兜底,会出现:
+  - 指针会转
+  - 但方向不准
+  - 自动转图方向错误
+
+### 恢复后
+
+- Android `Compass Source` 恢复为 `罗盘`
+- 指北针针头恢复
+- 顶部角度数字恢复
+- `heading-up` 恢复
+
+## 误判过的方向
+
+以下方向在本次排查中都被考虑过,但最终不是根因或不是主要根因:
+
+### 1. `DeviceMotion` 兜底方案
+
+问题:
+
+- `DeviceMotion` 可以给出设备姿态角
+- 但不能稳定代替“指向北”的绝对罗盘
+- 用它兜底会导致:
+  - 能转
+  - 但方向明显不准
+
+结论:
+
+**`DeviceMotion` 不能作为正式指北针来源。**
+
+### 2. 加速度计 / 其他传感器互斥
+
+曾排查:
+
+- `Accelerometer`
+- `Gyroscope`
+- `DeviceMotion`
+- `Compass`
+
+结论:
+
+- 加速度计在当前微信 Android 环境下不稳定,已放弃
+- 但这不是这次罗盘彻底失效的主因
+
+### 3. 算法问题
+
+曾尝试调整:
+
+- 角度平滑
+- 设备方向单位解释
+- motion fallback 算法
+
+结论:
+
+这些会影响“顺不顺”、“准不准”,但**不能解释 Android 完全无罗盘样本**。
+
+## 真正修复的方法
+
+将之前被清理掉的多处 `this.compassController.start()` 恢复回去。
+
+这些调用点主要分布在:
+
+- `commitViewport(...)`
+- `handleTouchStart(...)`
+- `animatePreviewToRest(...)`
+- `normalizeTranslate(...)`
+- `zoomAroundPoint(...)`
+- `handleRecenter(...)`
+- `handleRotateStep(...)`
+- `handleRotationReset(...)`
+
+这些调用在代码审美上看起来像“重复启动”,但在 Android 微信环境里,它们实际上承担了:
+
+**重新拉起 / 保活罗盘监听**
+
+的作用。
+
+## 当前工程判断
+
+本项目当前应当采用以下原则:
+
+### 1. 罗盘主来源只使用 `Compass`
+
+不要再让:
+
+- `DeviceMotion`
+- 其它姿态角
+
+参与正式指北针和自动转图的主方向链。
+
+### 2. `DeviceMotion` 只保留为辅助或调试输入
+
+可用于:
+
+- 调试面板显示
+- 设备姿态观察
+- 未来原生端姿态融合参考
+
+但不要直接驱动指北针。
+
+### 3. Android 端罗盘需要保活
+
+后续不要再把这些 `compassController.start()` 当成纯冗余逻辑随意清掉。
+
+如果要优化代码,应该:
+
+- 保留现有行为
+- 将其收口为有明确语义的方法
+
+例如:
+
+- `ensureCompassAlive()`
+- `refreshCompassBinding()`
+
+而不是直接删掉。
+
+## 与生命周期相关的硬约束
+
+以下约束必须保持:
+
+### 单实例
+
+页面层必须保证任意时刻只有一个 `MapEngine` 活跃实例。
+
+### 完整销毁
+
+`MapEngine.destroy()` 中必须完整执行:
+
+- `compassController.destroy()`
+- 其它传感器 `destroy()`
+
+防止旧监听残留。
+
+### 调试状态不应影响罗盘主链
+
+调试面板开关不应再控制:
+
+- 罗盘是否启动
+- 罗盘是否停止
+
+否则容易再次引入平台差异问题。
+
+## 推荐保留的调试字段
+
+以下字段建议长期保留,便于后续定位:
+
+- `Compass Source`
+- `sensorHeadingText`
+- 顶部角度数字
+- `heading-up` 开关状态
+
+其中 `Compass Source` 至少应显示:
+
+- `罗盘`
+- `无数据`
+
+避免再次将问题误判为算法问题。
+
+## 后续优化建议
+
+如果后面要继续优化这段代码,推荐方向是:
+
+### 可做
+
+- 将分散的 `compassController.start()` 收口成命名明确的方法
+- 为 Android 罗盘链补一层更可读的“保活机制”注释
+- 保留当前稳定行为前提下做重构
+
+### 不建议
+
+- 再次移除这些重复 `start()` 调用
+- 用 `DeviceMotion` 正式兜底指北针
+- 让调试开关影响罗盘主链启动
+
+## 一句话经验
+
+**在微信小程序里,Android 罗盘监听的稳定性比 iOS 更脆;某些看似冗余的 `start()` 调用,实际是平台兼容补丁,不应该在没有真机回归的情况下清理。**

BIN
miniprogram/assets/btn_locked.png


BIN
miniprogram/assets/btn_settings.png


BIN
miniprogram/assets/btn_unlock.png


+ 307 - 71
miniprogram/engine/map/mapEngine.ts

@@ -1,6 +1,6 @@
 import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
 import { AccelerometerController } from '../sensor/accelerometerController'
-import { CompassHeadingController } from '../sensor/compassHeadingController'
+import { CompassHeadingController, type CompassTuningProfile } from '../sensor/compassHeadingController'
 import { DeviceMotionController } from '../sensor/deviceMotionController'
 import { GyroscopeController } from '../sensor/gyroscopeController'
 import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
@@ -11,6 +11,7 @@ import { type MapRendererStats } from '../renderer/mapRenderer'
 import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
 import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
 import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
+import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
 import { GameRuntime } from '../../game/core/gameRuntime'
 import { type GameEffect, type GameResult } from '../../game/core/gameResult'
 import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
@@ -56,8 +57,27 @@ const AUTO_ROTATE_SNAP_DEG = 0.1
 const AUTO_ROTATE_DEADZONE_DEG = 4
 const AUTO_ROTATE_MAX_STEP_DEG = 0.75
 const AUTO_ROTATE_HEADING_SMOOTHING = 0.46
-const COMPASS_NEEDLE_MIN_SMOOTHING = 0.24
-const COMPASS_NEEDLE_MAX_SMOOTHING = 0.56
+const COMPASS_TUNING_PRESETS: Record<CompassTuningProfile, {
+  needleMinSmoothing: number
+  needleMaxSmoothing: number
+  displayDeadzoneDeg: number
+}> = {
+  smooth: {
+    needleMinSmoothing: 0.16,
+    needleMaxSmoothing: 0.4,
+    displayDeadzoneDeg: 0.75,
+  },
+  balanced: {
+    needleMinSmoothing: 0.22,
+    needleMaxSmoothing: 0.52,
+    displayDeadzoneDeg: 0.45,
+  },
+  responsive: {
+    needleMinSmoothing: 0.3,
+    needleMaxSmoothing: 0.68,
+    displayDeadzoneDeg: 0.2,
+  },
+}
 const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2
 const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
 const SMART_HEADING_MIN_DISTANCE_METERS = 12
@@ -88,6 +108,7 @@ export interface MapEngineStageRect {
 }
 
 export interface MapEngineViewState {
+  animationLevel: AnimationLevel
   buildVersion: string
   renderMode: string
   projectionMode: string
@@ -110,7 +131,11 @@ export interface MapEngineViewState {
   accelerometerText: string
   gyroscopeText: string
   deviceMotionText: string
+  compassSourceText: string
+  compassTuningProfile: CompassTuningProfile
+  compassTuningProfileText: string
   compassDeclinationText: string
+  northReferenceMode: NorthReferenceMode
   northReferenceButtonText: string
   autoRotateSourceText: string
   autoRotateCalibrationText: string
@@ -199,6 +224,8 @@ export interface MapEngineViewState {
   contentCardTitle: string
   contentCardBody: string
   punchButtonFxClass: string
+  panelProgressFxClass: string
+  panelDistanceFxClass: string
   punchFeedbackFxClass: string
   contentCardFxClass: string
   mapPulseVisible: boolean
@@ -228,6 +255,7 @@ export interface MapEngineGameInfoSnapshot {
 }
 
 const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
+  'animationLevel',
   'buildVersion',
   'renderMode',
   'projectionMode',
@@ -252,7 +280,11 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'accelerometerText',
   'gyroscopeText',
   'deviceMotionText',
+  'compassSourceText',
+  'compassTuningProfile',
+  'compassTuningProfileText',
   'compassDeclinationText',
+  'northReferenceMode',
   'northReferenceButtonText',
   'autoRotateSourceText',
   'autoRotateCalibrationText',
@@ -330,6 +362,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'contentCardTitle',
   'contentCardBody',
   'punchButtonFxClass',
+  'panelProgressFxClass',
+  'panelDistanceFxClass',
   'punchFeedbackFxClass',
   'contentCardFxClass',
   'mapPulseVisible',
@@ -342,6 +376,38 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'osmReferenceText',
 ]
 
+const INTERACTION_DEFERRED_VIEW_KEYS = new Set<keyof MapEngineViewState>([
+  'rotationText',
+  'sensorHeadingText',
+  'deviceHeadingText',
+  'devicePoseText',
+  'headingConfidenceText',
+  'accelerometerText',
+  'gyroscopeText',
+  'deviceMotionText',
+  'compassSourceText',
+  'compassTuningProfile',
+  'compassTuningProfileText',
+  'compassDeclinationText',
+  'autoRotateSourceText',
+  'autoRotateCalibrationText',
+  'northReferenceText',
+  'centerText',
+  'gpsCoordText',
+  'visibleTileCount',
+  'readyTileCount',
+  'memoryTileCount',
+  'diskTileCount',
+  'memoryHitCount',
+  'diskHitCount',
+  'networkFetchCount',
+  'cacheHitRateText',
+  'heartRateDiscoveredDevices',
+  'mockCoordText',
+  'mockSpeedText',
+  'mockHeartRateText',
+])
+
 function buildCenterText(zoom: number, x: number, y: number): string {
   return `z${zoom} / x${x} / y${y}`
 }
@@ -387,18 +453,23 @@ function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: numb
   return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
 }
 
-function getCompassNeedleSmoothingFactor(currentDeg: number, targetDeg: number): number {
+function getCompassNeedleSmoothingFactor(
+  currentDeg: number,
+  targetDeg: number,
+  profile: CompassTuningProfile,
+): number {
+  const preset = COMPASS_TUNING_PRESETS[profile]
   const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg))
   if (deltaDeg <= 4) {
-    return COMPASS_NEEDLE_MIN_SMOOTHING
+    return preset.needleMinSmoothing
   }
   if (deltaDeg >= 36) {
-    return COMPASS_NEEDLE_MAX_SMOOTHING
+    return preset.needleMaxSmoothing
   }
 
   const progress = (deltaDeg - 4) / (36 - 4)
-  return COMPASS_NEEDLE_MIN_SMOOTHING
-    + (COMPASS_NEEDLE_MAX_SMOOTHING - COMPASS_NEEDLE_MIN_SMOOTHING) * progress
+  return preset.needleMinSmoothing
+    + (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress
 }
 
 function getMovementHeadingSmoothingFactor(speedKmh: number | null): number {
@@ -434,7 +505,7 @@ function formatRotationText(rotationDeg: number): string {
 }
 
 function normalizeDegreeDisplayText(text: string): string {
-  return text.replace(/[°掳•]/g, '˚')
+  return text.replace(/[掳•˚]/g, '°')
 }
 
 function formatHeadingText(headingDeg: number | null): string {
@@ -442,7 +513,7 @@ function formatHeadingText(headingDeg: number | null): string {
     return '--'
   }
 
-  return `${Math.round(normalizeRotationDeg(headingDeg))}˚`
+  return `${Math.round(normalizeRotationDeg(headingDeg))}°`
 }
 
 function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
@@ -494,9 +565,9 @@ function formatDeviceMotionText(motion: { alpha: number | null; beta: number | n
     return '--'
   }
 
-  const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha * 180 / Math.PI))
-  const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta * 180 / Math.PI)
-  const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma * 180 / Math.PI)
+  const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha))
+  const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta)
+  const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma)
   return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}`
 }
 
@@ -620,6 +691,26 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string {
   return ''
 }
 
+function formatCompassSourceText(source: 'compass' | 'motion' | null): string {
+  if (source === 'compass') {
+    return '罗盘'
+  }
+  if (source === 'motion') {
+    return '设备方向兜底'
+  }
+  return '无数据'
+}
+
+function formatCompassTuningProfileText(profile: CompassTuningProfile): string {
+  if (profile === 'smooth') {
+    return '顺滑'
+  }
+  if (profile === 'responsive') {
+    return '跟手'
+  }
+  return '平衡'
+}
+
 function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
   return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北'
 }
@@ -702,6 +793,7 @@ function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
 
 export class MapEngine {
   buildVersion: string
+  animationLevel: AnimationLevel
   renderer: WebGLMapRenderer
   accelerometerController: AccelerometerController
   compassController: CompassHeadingController
@@ -742,6 +834,8 @@ export class MapEngine {
   sensorHeadingDeg: number | null
   smoothedSensorHeadingDeg: number | null
   compassDisplayHeadingDeg: number | null
+  compassSource: 'compass' | 'motion' | null
+  compassTuningProfile: CompassTuningProfile
   smoothedMovementHeadingDeg: number | null
   autoRotateHeadingDeg: number | null
   courseHeadingDeg: number | null
@@ -789,6 +883,8 @@ export class MapEngine {
 
   constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
     this.buildVersion = buildVersion
+    this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
+    this.compassTuningProfile = 'balanced'
     this.onData = callbacks.onData
     this.accelerometerErrorText = null
     this.renderer = new WebGLMapRenderer(
@@ -812,7 +908,7 @@ export class MapEngine {
             z,
           })
           if (this.diagnosticUiEnabled) {
-            this.setState(this.getTelemetrySensorViewPatch(), true)
+            this.setState(this.getTelemetrySensorViewPatch())
           }
         },
         onError: (message) => {
@@ -821,7 +917,7 @@ export class MapEngine {
             this.setState({
               ...this.getTelemetrySensorViewPatch(),
               statusText: `加速度计启动失败 (${this.buildVersion})`,
-            }, true)
+            })
           }
         },
       })
@@ -833,6 +929,7 @@ export class MapEngine {
         this.handleCompassError(message)
       },
     })
+    this.compassController.setTuningProfile(this.compassTuningProfile)
     this.gyroscopeController = new GyroscopeController({
       onSample: (x, y, z) => {
         this.telemetryRuntime.dispatch({
@@ -843,12 +940,12 @@ export class MapEngine {
           z,
         })
         if (this.diagnosticUiEnabled) {
-          this.setState(this.getTelemetrySensorViewPatch(), true)
+          this.setState(this.getTelemetrySensorViewPatch())
         }
       },
       onError: () => {
         if (this.diagnosticUiEnabled) {
-          this.setState(this.getTelemetrySensorViewPatch(), true)
+          this.setState(this.getTelemetrySensorViewPatch())
         }
       },
     })
@@ -865,16 +962,12 @@ export class MapEngine {
           this.setState({
             ...this.getTelemetrySensorViewPatch(),
             autoRotateSourceText: this.getAutoRotateSourceText(),
-          }, true)
-        }
-
-        if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
-          this.scheduleAutoRotate()
+          })
         }
       },
       onError: () => {
         if (this.diagnosticUiEnabled) {
-          this.setState(this.getTelemetrySensorViewPatch(), true)
+          this.setState(this.getTelemetrySensorViewPatch())
         }
       },
     })
@@ -899,7 +992,7 @@ export class MapEngine {
       },
       onDebugStateChange: () => {
         if (this.diagnosticUiEnabled) {
-          this.setState(this.getLocationControllerViewPatch(), true)
+          this.setState(this.getLocationControllerViewPatch())
         }
       },
     })
@@ -963,12 +1056,12 @@ export class MapEngine {
               heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
               heartRateScanText: this.getHeartRateScanText(),
               ...this.getHeartRateControllerViewPatch(),
-            }, true)
+            })
           }
         },
         onDebugStateChange: () => {
           if (this.diagnosticUiEnabled) {
-            this.setState(this.getHeartRateControllerViewPatch(), true)
+            this.setState(this.getHeartRateControllerViewPatch())
           }
         },
       })
@@ -982,6 +1075,12 @@ export class MapEngine {
       setPunchButtonFxClass: (className) => {
         this.setPunchButtonFxClass(className)
       },
+      setHudProgressFxClass: (className) => {
+        this.setHudProgressFxClass(className)
+      },
+      setHudDistanceFxClass: (className) => {
+        this.setHudDistanceFxClass(className)
+      },
       showMapPulse: (controlId, motionClass) => {
         this.showMapPulse(controlId, motionClass)
       },
@@ -994,6 +1093,7 @@ export class MapEngine {
         }
       },
     })
+    this.feedbackDirector.setAnimationLevel(this.animationLevel)
     this.minZoom = MIN_ZOOM
     this.maxZoom = MAX_ZOOM
     this.defaultZoom = DEFAULT_ZOOM
@@ -1032,6 +1132,7 @@ export class MapEngine {
     this.sessionTimerInterval = 0
     this.hasGpsCenteredOnce = false
     this.state = {
+      animationLevel: this.animationLevel,
       buildVersion: this.buildVersion,
       renderMode: RENDER_MODE,
       projectionMode: PROJECTION_MODE,
@@ -1051,10 +1152,14 @@ export class MapEngine {
       deviceHeadingText: '--',
       devicePoseText: '竖持',
       headingConfidenceText: '低',
-        accelerometerText: '未启用',
+      accelerometerText: '未启用',
       gyroscopeText: '--',
       deviceMotionText: '--',
+      compassSourceText: '无数据',
+      compassTuningProfile: this.compassTuningProfile,
+      compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
       compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
+      northReferenceMode: DEFAULT_NORTH_REFERENCE_MODE,
       northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
       autoRotateSourceText: formatAutoRotateSourceText('smart', false),
       autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
@@ -1137,6 +1242,8 @@ export class MapEngine {
       contentCardTitle: '',
       contentCardBody: '',
       punchButtonFxClass: '',
+      panelProgressFxClass: '',
+      panelDistanceFxClass: '',
       punchFeedbackFxClass: '',
       contentCardFxClass: '',
       mapPulseVisible: false,
@@ -1177,6 +1284,8 @@ export class MapEngine {
     this.sensorHeadingDeg = null
     this.smoothedSensorHeadingDeg = null
     this.compassDisplayHeadingDeg = null
+    this.compassSource = null
+    this.compassTuningProfile = 'balanced'
     this.smoothedMovementHeadingDeg = null
     this.autoRotateHeadingDeg = null
     this.courseHeadingDeg = null
@@ -1241,6 +1350,7 @@ export class MapEngine {
       { label: '配置版本', value: this.configVersion || '--' },
       { label: 'Schema版本', value: this.configSchemaVersion || '--' },
       { label: '活动ID', value: this.configAppId || '--' },
+      { label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) },
       { label: '地图', value: this.state.mapName || '--' },
       { label: '模式', value: this.getGameModeText() },
       { label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) },
@@ -1417,20 +1527,23 @@ export class MapEngine {
 
   getTelemetrySensorViewPatch(): Partial<MapEngineViewState> {
     const telemetryState = this.telemetryRuntime.state
-      return {
-        deviceHeadingText: formatHeadingText(
-          telemetryState.deviceHeadingDeg === null
-            ? null
-            : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg),
-        ),
-        devicePoseText: formatDevicePoseText(telemetryState.devicePose),
-        headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence),
-        accelerometerText: telemetryState.accelerometer
-          ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}`
-          : '未启用',
-        gyroscopeText: formatGyroscopeText(telemetryState.gyroscope),
-        deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion),
-      }
+    return {
+      deviceHeadingText: formatHeadingText(
+        telemetryState.deviceHeadingDeg === null
+          ? null
+          : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg),
+      ),
+      devicePoseText: formatDevicePoseText(telemetryState.devicePose),
+      headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence),
+      accelerometerText: telemetryState.accelerometer
+        ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}`
+        : '未启用',
+      gyroscopeText: formatGyroscopeText(telemetryState.gyroscope),
+      deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion),
+      compassSourceText: formatCompassSourceText(this.compassSource),
+      compassTuningProfile: this.compassTuningProfile,
+      compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
+    }
   }
 
   getGameModeText(): string {
@@ -1589,6 +1702,8 @@ export class MapEngine {
       stageFxVisible: false,
       stageFxClass: '',
       punchButtonFxClass: '',
+      panelProgressFxClass: '',
+      panelDistanceFxClass: '',
     }, true)
   }
 
@@ -1675,6 +1790,18 @@ export class MapEngine {
     }, true)
   }
 
+  setHudProgressFxClass(className: string): void {
+    this.setState({
+      panelProgressFxClass: className,
+    }, true)
+  }
+
+  setHudDistanceFxClass(className: string): void {
+    this.setState({
+      panelDistanceFxClass: className,
+    }, true)
+  }
+
   showMapPulse(controlId: string, motionClass = ''): void {
     const screenPoint = this.getControlScreenPoint(controlId)
     if (!screenPoint) {
@@ -1761,6 +1888,9 @@ export class MapEngine {
   applyGameEffects(effects: GameEffect[]): string | null {
     this.feedbackDirector.handleEffects(effects)
     if (effects.some((effect) => effect.type === 'session_finished')) {
+      if (this.locationController.listening) {
+        this.locationController.stop()
+      }
       this.setState({
         gpsTracking: false,
         gpsTrackingText: '测试结束,定位已停止',
@@ -1845,12 +1975,17 @@ export class MapEngine {
 
   handleForceExitGame(): void {
     this.feedbackDirector.reset()
+    if (this.locationController.listening) {
+      this.locationController.stop()
+    }
 
     if (!this.courseData) {
       this.clearGameRuntime()
       this.resetTransientGameUiState()
       this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
       this.setState({
+        gpsTracking: false,
+        gpsTrackingText: '已退出对局,定位已停止',
         ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
       }, true)
       this.syncRenderer()
@@ -1861,6 +1996,8 @@ export class MapEngine {
     this.resetTransientGameUiState()
     this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
     this.setState({
+      gpsTracking: false,
+      gpsTrackingText: '已退出对局,定位已停止',
       ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
     }, true)
     this.syncRenderer()
@@ -1946,7 +2083,7 @@ export class MapEngine {
       gpsLockEnabled: this.gpsLockEnabled,
       gpsLockAvailable: gpsInsideMap,
       ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
-    }, true)
+    })
     this.syncRenderer()
   }
 
@@ -2100,7 +2237,7 @@ export class MapEngine {
     this.setState({
       heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
       heartRateScanText: this.getHeartRateScanText(),
-    }, true)
+    })
   }
 
   handleDebugHeartRateTone(tone: HeartRateTone): void {
@@ -2112,7 +2249,7 @@ export class MapEngine {
     })
     this.setState({
       heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`,
-    }, true)
+    })
     this.syncSessionTimerText()
   }
 
@@ -2128,7 +2265,7 @@ export class MapEngine {
         : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'),
       heartRateScanText: this.getHeartRateScanText(),
       ...this.getHeartRateControllerViewPatch(),
-    }, true)
+    })
     this.syncSessionTimerText()
   }
 
@@ -2250,7 +2387,7 @@ export class MapEngine {
       configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`,
       projectionMode: config.projectionModeText,
       tileSource: config.tileSource,
-      sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
+      sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
       compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
       northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
       northReferenceText: formatNorthReferenceText(this.northReferenceMode),
@@ -2308,7 +2445,7 @@ export class MapEngine {
       this.pinchAnchorWorldY = anchorWorld.y
       this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
       this.syncRenderer()
-    this.compassController.start()
+      this.compassController.start()
       return
     }
 
@@ -2567,7 +2704,7 @@ export class MapEngine {
       () => {
         this.resetPreviewState()
         this.syncRenderer()
-    this.compassController.start()
+        this.compassController.start()
         this.scheduleAutoRotate()
       },
     )
@@ -2601,7 +2738,7 @@ export class MapEngine {
       () => {
         this.resetPreviewState()
         this.syncRenderer()
-    this.compassController.start()
+        this.compassController.start()
       },
     )
   }
@@ -2638,7 +2775,7 @@ export class MapEngine {
       () => {
         this.resetPreviewState()
         this.syncRenderer()
-    this.compassController.start()
+        this.compassController.start()
       },
     )
   }
@@ -2673,6 +2810,38 @@ export class MapEngine {
     this.cycleNorthReferenceMode()
   }
 
+  handleSetNorthReferenceMode(mode: NorthReferenceMode): void {
+    this.setNorthReferenceMode(mode)
+  }
+
+  handleSetAnimationLevel(level: AnimationLevel): void {
+    if (this.animationLevel === level) {
+      return
+    }
+
+    this.animationLevel = level
+    this.feedbackDirector.setAnimationLevel(level)
+    this.setState({
+      animationLevel: level,
+      statusText: `动画性能已切换为${formatAnimationLevelText(level)} (${this.buildVersion})`,
+    })
+    this.syncRenderer()
+  }
+
+  handleSetCompassTuningProfile(profile: CompassTuningProfile): void {
+    if (this.compassTuningProfile === profile) {
+      return
+    }
+
+    this.compassTuningProfile = profile
+    this.compassController.setTuningProfile(profile)
+    this.setState({
+      compassTuningProfile: profile,
+      compassTuningProfileText: formatCompassTuningProfileText(profile),
+      statusText: `指北针响应已切换为${formatCompassTuningProfileText(profile)} (${this.buildVersion})`,
+    }, true)
+  }
+
   handleAutoRotateCalibrate(): void {
     if (this.state.orientationMode !== 'heading-up') {
       this.setState({
@@ -2761,30 +2930,40 @@ export class MapEngine {
     }
   }
 
-  handleCompassHeading(headingDeg: number): void {
+  applyHeadingSample(headingDeg: number, source: 'compass' | 'motion'): void {
+    this.compassSource = source
     this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
     this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
       ? this.sensorHeadingDeg
       : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
 
     const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
-    this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
-      ? compassHeadingDeg
-      : interpolateAngleDeg(
-        this.compassDisplayHeadingDeg,
-        compassHeadingDeg,
-        getCompassNeedleSmoothingFactor(this.compassDisplayHeadingDeg, compassHeadingDeg),
-      )
+    if (this.compassDisplayHeadingDeg === null) {
+      this.compassDisplayHeadingDeg = compassHeadingDeg
+    } else {
+      const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg))
+      if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) {
+        this.compassDisplayHeadingDeg = interpolateAngleDeg(
+          this.compassDisplayHeadingDeg,
+          compassHeadingDeg,
+          getCompassNeedleSmoothingFactor(
+            this.compassDisplayHeadingDeg,
+            compassHeadingDeg,
+            this.compassTuningProfile,
+          ),
+        )
+      }
+    }
 
     this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
 
     this.setState({
       compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
+      sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
+      compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
       ...(this.diagnosticUiEnabled
         ? {
-            sensorHeadingText: formatHeadingText(compassHeadingDeg),
             ...this.getTelemetrySensorViewPatch(),
-            compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
             northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
             autoRotateSourceText: this.getAutoRotateSourceText(),
             northReferenceText: formatNorthReferenceText(this.northReferenceMode),
@@ -2801,18 +2980,31 @@ export class MapEngine {
     }
   }
 
+  handleCompassHeading(headingDeg: number): void {
+    this.applyHeadingSample(headingDeg, 'compass')
+  }
+
   handleCompassError(message: string): void {
     this.clearAutoRotateTimer()
     this.targetAutoRotationDeg = null
     this.autoRotateCalibrationPending = false
+    this.compassSource = null
     this.setState({
+      compassSourceText: formatCompassSourceText(null),
       autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
       statusText: `${message} (${this.buildVersion})`,
     }, true)
   }
 
   cycleNorthReferenceMode(): void {
-    const nextMode = getNextNorthReferenceMode(this.northReferenceMode)
+    this.setNorthReferenceMode(getNextNorthReferenceMode(this.northReferenceMode))
+  }
+
+  setNorthReferenceMode(nextMode: NorthReferenceMode): void {
+    if (nextMode === this.northReferenceMode) {
+      return
+    }
+
     const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
     const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
       ? null
@@ -2831,9 +3023,10 @@ export class MapEngine {
           rotationDeg: MAP_NORTH_OFFSET_DEG,
           rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
           northReferenceText: formatNorthReferenceText(nextMode),
-          sensorHeadingText: formatHeadingText(compassHeadingDeg),
+          sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
           ...this.getTelemetrySensorViewPatch(),
           compassDeclinationText: formatCompassDeclinationText(nextMode),
+          northReferenceMode: nextMode,
           northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
           compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
           autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
@@ -2850,9 +3043,10 @@ export class MapEngine {
 
     this.setState({
       northReferenceText: formatNorthReferenceText(nextMode),
-      sensorHeadingText: formatHeadingText(compassHeadingDeg),
+      sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
       ...this.getTelemetrySensorViewPatch(),
       compassDeclinationText: formatCompassDeclinationText(nextMode),
+      northReferenceMode: nextMode,
       northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
       compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
       autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
@@ -3167,6 +3361,7 @@ export class MapEngine {
 
   buildScene() {
     const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
+    const readyControlSequences = this.resolveReadyControlSequences()
     return {
       tileSource: this.state.tileSource,
       osmTileSource: OSM_TILE_SOURCE,
@@ -3183,6 +3378,7 @@ export class MapEngine {
       translateX: this.state.tileTranslateX,
       translateY: this.state.tileTranslateY,
       rotationRad: this.getRotationRad(this.state.rotationDeg),
+      animationLevel: this.state.animationLevel,
       previewScale: this.previewScale || 1,
       previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
       previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
@@ -3199,6 +3395,7 @@ export class MapEngine {
       focusedControlId: this.gamePresentation.map.focusedControlId,
       focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
       activeControlSequences: this.gamePresentation.map.activeControlSequences,
+      readyControlSequences,
       activeStart: this.gamePresentation.map.activeStart,
       completedStart: this.gamePresentation.map.completedStart,
       activeFinish: this.gamePresentation.map.activeFinish,
@@ -3215,6 +3412,21 @@ export class MapEngine {
     }
   }
 
+  resolveReadyControlSequences(): number[] {
+    const punchableControlId = this.gamePresentation.hud.punchableControlId
+    const definition = this.gameRuntime.definition
+    if (!punchableControlId || !definition) {
+      return []
+    }
+
+    const control = definition.controls.find((item) => item.id === punchableControlId)
+    if (!control || control.sequence === null) {
+      return []
+    }
+
+    return [control.sequence]
+  }
+
   syncRenderer(): void {
     if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
       return
@@ -3374,8 +3586,32 @@ export class MapEngine {
     }
 
     const patch = this.pendingViewPatch
-    this.pendingViewPatch = {}
-    this.onData(patch)
+    const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer
+    const nextPendingPatch = {} as Partial<MapEngineViewState>
+    const outputPatch = {} as Partial<MapEngineViewState>
+
+    for (const [key, value] of Object.entries(patch) as Array<[keyof MapEngineViewState, MapEngineViewState[keyof MapEngineViewState]]>) {
+      if (shouldDeferForInteraction && INTERACTION_DEFERRED_VIEW_KEYS.has(key)) {
+        ;(nextPendingPatch as Record<string, unknown>)[key] = value
+        continue
+      }
+      ;(outputPatch as Record<string, unknown>)[key] = value
+    }
+
+    this.pendingViewPatch = nextPendingPatch
+
+    if (Object.keys(this.pendingViewPatch).length && !this.viewSyncTimer) {
+      this.viewSyncTimer = setTimeout(() => {
+        this.viewSyncTimer = 0
+        this.flushViewPatch()
+      }, UI_SYNC_INTERVAL_MS) as unknown as number
+    }
+
+    if (!Object.keys(outputPatch).length) {
+      return
+    }
+
+    this.onData(outputPatch)
   }
 
   getTouchDistance(touches: TouchPoint[]): number {
@@ -3431,7 +3667,7 @@ export class MapEngine {
     if (Math.abs(startScale - 1) < 0.01) {
       this.resetPreviewState()
       this.syncRenderer()
-    this.compassController.start()
+      this.compassController.start()
       this.scheduleAutoRotate()
       return
     }
@@ -3443,12 +3679,12 @@ export class MapEngine {
       const nextScale = startScale + (1 - startScale) * eased
       this.setPreviewState(nextScale, originX, originY)
       this.syncRenderer()
-    this.compassController.start()
+      this.compassController.start()
 
       if (progress >= 1) {
         this.resetPreviewState()
         this.syncRenderer()
-    this.compassController.start()
+        this.compassController.start()
         this.previewResetTimer = 0
         this.scheduleAutoRotate()
         return
@@ -3467,7 +3703,7 @@ export class MapEngine {
         tileTranslateY: translateY,
       })
       this.syncRenderer()
-    this.compassController.start()
+      this.compassController.start()
       return
     }
 
@@ -3530,7 +3766,7 @@ export class MapEngine {
         () => {
           this.setPreviewState(residualScale, stageX, stageY)
           this.syncRenderer()
-    this.compassController.start()
+          this.compassController.start()
           this.animatePreviewToRest()
         },
       )
@@ -3557,7 +3793,7 @@ export class MapEngine {
       () => {
         this.setPreviewState(residualScale, stageX, stageY)
         this.syncRenderer()
-    this.compassController.start()
+        this.compassController.start()
         this.animatePreviewToRest()
       },
     )

+ 13 - 2
miniprogram/engine/renderer/courseLabelRenderer.ts

@@ -9,11 +9,14 @@ const SCORE_LABEL_FONT_SIZE_RATIO = 0.7
 const SCORE_LABEL_OFFSET_Y_RATIO = 0.06
 const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)'
 const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)'
+const READY_LABEL_COLOR = 'rgba(98, 255, 214, 0.98)'
 const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)'
 const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)'
 const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)'
+const SKIPPED_LABEL_COLOR = 'rgba(152, 156, 162, 0.88)'
 const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)'
 const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)'
+const SCORE_SKIPPED_LABEL_COLOR = 'rgba(176, 182, 188, 0.9)'
 
 export class CourseLabelRenderer {
   courseLayer: CourseLayer
@@ -107,6 +110,10 @@ export class CourseLabelRenderer {
       return FOCUSED_LABEL_COLOR
     }
 
+    if (scene.readyControlSequences.includes(sequence)) {
+      return READY_LABEL_COLOR
+    }
+
     if (scene.activeControlSequences.includes(sequence)) {
       return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_LABEL_COLOR : ACTIVE_LABEL_COLOR
     }
@@ -116,7 +123,7 @@ export class CourseLabelRenderer {
     }
 
     if (scene.skippedControlSequences.includes(sequence)) {
-      return COMPLETED_LABEL_COLOR
+      return SKIPPED_LABEL_COLOR
     }
 
     return DEFAULT_LABEL_COLOR
@@ -127,12 +134,16 @@ export class CourseLabelRenderer {
       return FOCUSED_LABEL_COLOR
     }
 
+    if (scene.readyControlSequences.includes(sequence)) {
+      return READY_LABEL_COLOR
+    }
+
     if (scene.completedControlSequences.includes(sequence)) {
       return SCORE_COMPLETED_LABEL_COLOR
     }
 
     if (scene.skippedControlSequences.includes(sequence)) {
-      return SCORE_COMPLETED_LABEL_COLOR
+      return SCORE_SKIPPED_LABEL_COLOR
     }
 
     return SCORE_LABEL_COLOR

+ 3 - 0
miniprogram/engine/renderer/mapRenderer.ts

@@ -3,6 +3,7 @@ import { type TileStoreStats } from '../tile/tileStore'
 import { type LonLatPoint, type MapCalibration } from '../../utils/projection'
 import { type TileZoomBounds } from '../../utils/remoteMapConfig'
 import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
+import { type AnimationLevel } from '../../utils/animationLevel'
 
 export interface MapScene {
   tileSource: string
@@ -20,6 +21,7 @@ export interface MapScene {
   translateX: number
   translateY: number
   rotationRad: number
+  animationLevel: AnimationLevel
   previewScale: number
   previewOriginX: number
   previewOriginY: number
@@ -36,6 +38,7 @@ export interface MapScene {
   focusedControlId: string | null
   focusedControlSequences: number[]
   activeControlSequences: number[]
+  readyControlSequences: number[]
   activeStart: boolean
   completedStart: boolean
   activeFinish: boolean

+ 5 - 1
miniprogram/engine/renderer/webglMapRenderer.ts

@@ -135,12 +135,16 @@ export class WebGLMapRenderer implements MapRenderer {
         this.scheduleRender()
       }
 
-      this.animationTimer = setTimeout(tick, ANIMATION_FRAME_MS) as unknown as number
+      this.animationTimer = setTimeout(tick, this.getAnimationFrameMs()) as unknown as number
     }
 
     tick()
   }
 
+  getAnimationFrameMs(): number {
+    return this.scene && this.scene.animationLevel === 'lite' ? 48 : ANIMATION_FRAME_MS
+  }
+
   scheduleRender(): void {
     if (this.renderTimer || !this.scene || this.destroyed) {
       return

+ 127 - 6
miniprogram/engine/renderer/webglVectorRenderer.ts

@@ -7,11 +7,17 @@ import { GpsLayer } from '../layer/gpsLayer'
 
 const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96]
 const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82]
+const SKIPPED_ROUTE_COLOR: [number, number, number, number] = [0.38, 0.4, 0.44, 0.72]
 const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1]
+const READY_CONTROL_COLOR: [number, number, number, number] = [0.38, 1, 0.92, 1]
 const MULTI_ACTIVE_CONTROL_COLOR: [number, number, number, number] = [1, 0.8, 0.2, 0.98]
 const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1]
 const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86]
 const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88]
+const READY_PULSE_COLOR: [number, number, number, number] = [0.44, 1, 0.92, 0.98]
+const COMPLETED_SETTLE_COLOR: [number, number, number, number] = [0.86, 0.9, 0.94, 0.24]
+const SKIPPED_SETTLE_COLOR: [number, number, number, number] = [0.72, 0.76, 0.82, 0.18]
+const SKIPPED_SLASH_COLOR: [number, number, number, number] = [0.78, 0.82, 0.88, 0.9]
 const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5]
 const EARTH_CIRCUMFERENCE_METERS = 40075016.686
 const CONTROL_RING_WIDTH_RATIO = 0.2
@@ -196,6 +202,18 @@ export class WebGLVectorRenderer {
     gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2)
   }
 
+  isLite(scene: MapScene): boolean {
+    return scene.animationLevel === 'lite'
+  }
+
+  getRingSegments(scene: MapScene): number {
+    return this.isLite(scene) ? 24 : 36
+  }
+
+  getCircleSegments(scene: MapScene): number {
+    return this.isLite(scene) ? 14 : 20
+  }
+
   getPixelsPerMeter(scene: MapScene): number {
     const camera: CameraState = {
       centerWorldX: scene.exactCenterWorldX,
@@ -249,6 +267,18 @@ export class WebGLVectorRenderer {
       if (scene.activeStart) {
         this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame)
       }
+      if (scene.completedStart) {
+        this.pushRing(
+          positions,
+          colors,
+          start.point.x,
+          start.point.y,
+          this.getMetric(scene, controlRadiusMeters * 1.16),
+          this.getMetric(scene, controlRadiusMeters * 1.02),
+          COMPLETED_SETTLE_COLOR,
+          scene,
+        )
+      }
       this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene)
     }
     if (!scene.revealFullCourse) {
@@ -261,10 +291,29 @@ export class WebGLVectorRenderer {
           this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
         } else {
           this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR)
-          this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52])
+          if (!this.isLite(scene)) {
+            this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52])
+          }
         }
       }
 
+      if (scene.readyControlSequences.includes(control.sequence)) {
+        this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, READY_PULSE_COLOR)
+        if (!this.isLite(scene)) {
+          this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.22, scene, pulseFrame + 11, [0.92, 1, 1, 0.42])
+        }
+        this.pushRing(
+          positions,
+          colors,
+          control.point.x,
+          control.point.y,
+          this.getMetric(scene, controlRadiusMeters * 1.16),
+          this.getMetric(scene, controlRadiusMeters * 1.02),
+          READY_CONTROL_COLOR,
+          scene,
+        )
+      }
+
       this.pushRing(
         positions,
         colors,
@@ -278,7 +327,9 @@ export class WebGLVectorRenderer {
 
       if (scene.focusedControlSequences.includes(control.sequence)) {
         this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.02, scene, pulseFrame, FOCUSED_PULSE_COLOR)
-        this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5])
+        if (!this.isLite(scene)) {
+          this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5])
+        }
         this.pushRing(
           positions,
           colors,
@@ -290,6 +341,33 @@ export class WebGLVectorRenderer {
           scene,
         )
       }
+
+      if (scene.completedControlSequences.includes(control.sequence)) {
+        this.pushRing(
+          positions,
+          colors,
+          control.point.x,
+          control.point.y,
+          this.getMetric(scene, controlRadiusMeters * 1.14),
+          this.getMetric(scene, controlRadiusMeters * 1.02),
+          COMPLETED_SETTLE_COLOR,
+          scene,
+        )
+      }
+
+      if (this.isSkippedControl(scene, control.sequence)) {
+        this.pushRing(
+          positions,
+          colors,
+          control.point.x,
+          control.point.y,
+          this.getMetric(scene, controlRadiusMeters * 1.1),
+          this.getMetric(scene, controlRadiusMeters * 1.01),
+          SKIPPED_SETTLE_COLOR,
+          scene,
+        )
+        this.pushSkippedControlSlash(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene)
+      }
     }
 
     for (const finish of course.finishes) {
@@ -298,10 +376,24 @@ export class WebGLVectorRenderer {
       }
       if (scene.focusedFinish) {
         this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, FOCUSED_PULSE_COLOR)
-        this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46])
+        if (!this.isLite(scene)) {
+          this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46])
+        }
       }
 
       const finishColor = this.getFinishColor(scene)
+      if (scene.completedFinish) {
+        this.pushRing(
+          positions,
+          colors,
+          finish.point.x,
+          finish.point.y,
+          this.getMetric(scene, controlRadiusMeters * 1.18),
+          this.getMetric(scene, controlRadiusMeters * 1.02),
+          COMPLETED_SETTLE_COLOR,
+          scene,
+        )
+      }
       this.pushRing(
         positions,
         colors,
@@ -418,6 +510,27 @@ export class WebGLVectorRenderer {
     )
   }
 
+  pushSkippedControlSlash(
+    positions: number[],
+    colors: number[],
+    centerX: number,
+    centerY: number,
+    controlRadiusMeters: number,
+    scene: MapScene,
+  ): void {
+    const slashRadius = this.getMetric(scene, controlRadiusMeters * 0.72)
+    const slashWidth = this.getMetric(scene, controlRadiusMeters * 0.08)
+    this.pushSegment(
+      positions,
+      colors,
+      { x: centerX - slashRadius, y: centerY + slashRadius },
+      { x: centerX + slashRadius, y: centerY - slashRadius },
+      slashWidth,
+      SKIPPED_SLASH_COLOR,
+      scene,
+    )
+  }
+
   pushActiveStartPulse(
     positions: number[],
     colors: number[],
@@ -462,14 +575,22 @@ export class WebGLVectorRenderer {
   }
 
   getControlColor(scene: MapScene, sequence: number): RgbaColor {
+    if (scene.readyControlSequences.includes(sequence)) {
+      return READY_CONTROL_COLOR
+    }
+
     if (scene.activeControlSequences.includes(sequence)) {
       return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR
     }
 
-    if (scene.completedControlSequences.includes(sequence) || this.isSkippedControl(scene, sequence)) {
+    if (scene.completedControlSequences.includes(sequence)) {
       return COMPLETED_ROUTE_COLOR
     }
 
+    if (this.isSkippedControl(scene, sequence)) {
+      return SKIPPED_ROUTE_COLOR
+    }
+
     return COURSE_COLOR
   }
 
@@ -633,7 +754,7 @@ export class WebGLVectorRenderer {
     color: RgbaColor,
     scene: MapScene,
   ): void {
-    const segments = 36
+    const segments = this.getRingSegments(scene)
     for (let index = 0; index < segments; index += 1) {
       const startAngle = index / segments * Math.PI * 2
       const endAngle = (index + 1) / segments * Math.PI * 2
@@ -682,7 +803,7 @@ export class WebGLVectorRenderer {
     color: RgbaColor,
     scene: MapScene,
   ): void {
-    const segments = 20
+    const segments = this.getCircleSegments(scene)
     const center = this.toClip(centerX, centerY, scene)
     for (let index = 0; index < segments; index += 1) {
       const startAngle = index / segments * Math.PI * 2

+ 18 - 8
miniprogram/engine/sensor/compassHeadingController.ts

@@ -5,7 +5,13 @@ export interface CompassHeadingControllerCallbacks {
 
 type SensorSource = 'compass' | 'motion' | null
 
-const ABSOLUTE_HEADING_CORRECTION = 0.44
+export type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive'
+
+const HEADING_CORRECTION_BY_PROFILE: Record<CompassTuningProfile, number> = {
+  smooth: 0.3,
+  balanced: 0.4,
+  responsive: 0.54,
+}
 
 function normalizeHeadingDeg(headingDeg: number): number {
   const normalized = headingDeg % 360
@@ -41,6 +47,7 @@ export class CompassHeadingController {
   rollDeg: number | null
   motionReady: boolean
   compassReady: boolean
+  tuningProfile: CompassTuningProfile
 
   constructor(callbacks: CompassHeadingControllerCallbacks) {
     this.callbacks = callbacks
@@ -53,6 +60,7 @@ export class CompassHeadingController {
     this.rollDeg = null
     this.motionReady = false
     this.compassReady = false
+    this.tuningProfile = 'balanced'
   }
 
   start(): void {
@@ -99,6 +107,10 @@ export class CompassHeadingController {
     this.stop()
   }
 
+  setTuningProfile(profile: CompassTuningProfile): void {
+    this.tuningProfile = profile
+  }
+
   startMotionSource(previousMessage: string): void {
     if (typeof wx.startDeviceMotionListening !== 'function' || typeof wx.onDeviceMotionChange !== 'function') {
       this.callbacks.onError(previousMessage)
@@ -111,14 +123,13 @@ export class CompassHeadingController {
       }
 
       this.pitchDeg = typeof result.beta === 'number' && !Number.isNaN(result.beta)
-        ? result.beta * 180 / Math.PI
+        ? result.beta
         : null
       this.rollDeg = typeof result.gamma === 'number' && !Number.isNaN(result.gamma)
-        ? result.gamma * 180 / Math.PI
+        ? result.gamma
         : null
 
-      const alphaDeg = result.alpha * 180 / Math.PI
-      this.applyAbsoluteHeading(normalizeHeadingDeg(360 - alphaDeg), 'motion')
+      this.applyAbsoluteHeading(normalizeHeadingDeg(360 - result.alpha), 'motion')
     }
 
     this.motionCallback = callback
@@ -163,10 +174,11 @@ export class CompassHeadingController {
   }
 
   applyAbsoluteHeading(headingDeg: number, source: 'compass' | 'motion'): void {
+    const headingCorrection = HEADING_CORRECTION_BY_PROFILE[this.tuningProfile]
     if (this.absoluteHeadingDeg === null) {
       this.absoluteHeadingDeg = headingDeg
     } else {
-      this.absoluteHeadingDeg = interpolateHeadingDeg(this.absoluteHeadingDeg, headingDeg, ABSOLUTE_HEADING_CORRECTION)
+      this.absoluteHeadingDeg = interpolateHeadingDeg(this.absoluteHeadingDeg, headingDeg, headingCorrection)
     }
 
     this.source = source
@@ -200,5 +212,3 @@ export class CompassHeadingController {
     this.compassCallback = null
   }
 }
-
-

+ 24 - 10
miniprogram/game/feedback/feedbackConfig.ts

@@ -1,3 +1,5 @@
+import { type AnimationLevel } from '../../utils/animationLevel'
+
 export type FeedbackCueKey =
   | 'session_started'
   | 'session_finished'
@@ -14,7 +16,9 @@ export type UiPunchFeedbackMotion = 'none' | 'pop' | 'success' | 'warning'
 export type UiContentCardMotion = 'none' | 'pop' | 'finish'
 export type UiPunchButtonMotion = 'none' | 'ready' | 'warning'
 export type UiMapPulseMotion = 'none' | 'ready' | 'control' | 'finish'
-export type UiStageMotion = 'none' | 'finish'
+export type UiStageMotion = 'none' | 'control' | 'finish'
+export type UiHudProgressMotion = 'none' | 'success' | 'finish'
+export type UiHudDistanceMotion = 'none' | 'success'
 
 export interface HapticCueConfig {
   enabled: boolean
@@ -28,6 +32,8 @@ export interface UiCueConfig {
   punchButtonMotion: UiPunchButtonMotion
   mapPulseMotion: UiMapPulseMotion
   stageMotion: UiStageMotion
+  hudProgressMotion: UiHudProgressMotion
+  hudDistanceMotion: UiHudDistanceMotion
   durationMs: number
 }
 
@@ -41,6 +47,10 @@ export interface GameUiEffectsConfig {
   cues: Record<FeedbackCueKey, UiCueConfig>
 }
 
+export interface ResolvedGameUiEffectsConfig extends GameUiEffectsConfig {
+  animationLevel: AnimationLevel
+}
+
 export interface PartialHapticCueConfig {
   enabled?: boolean
   pattern?: HapticPattern
@@ -53,6 +63,8 @@ export interface PartialUiCueConfig {
   punchButtonMotion?: UiPunchButtonMotion
   mapPulseMotion?: UiMapPulseMotion
   stageMotion?: UiStageMotion
+  hudProgressMotion?: UiHudProgressMotion
+  hudDistanceMotion?: UiHudDistanceMotion
   durationMs?: number
 }
 
@@ -84,15 +96,15 @@ export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = {
 export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = {
   enabled: true,
   cues: {
-    session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
-    session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
-    'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 },
-    'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 },
-    'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', durationMs: 0 },
-    'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 560 },
-    'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
-    'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 },
-    'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', durationMs: 900 },
+    session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
+    session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
+    'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
+    'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 },
+    'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', hudProgressMotion: 'finish', hudDistanceMotion: 'success', durationMs: 680 },
+    'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 560 },
+    'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
+    'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 },
+    'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 900 },
   },
 }
 
@@ -115,6 +127,8 @@ function mergeUiCue(baseCue: UiCueConfig, override?: PartialUiCueConfig): UiCueC
     punchButtonMotion: override && override.punchButtonMotion ? override.punchButtonMotion : baseCue.punchButtonMotion,
     mapPulseMotion: override && override.mapPulseMotion ? override.mapPulseMotion : baseCue.mapPulseMotion,
     stageMotion: override && override.stageMotion ? override.stageMotion : baseCue.stageMotion,
+    hudProgressMotion: override && override.hudProgressMotion ? override.hudProgressMotion : baseCue.hudProgressMotion,
+    hudDistanceMotion: override && override.hudDistanceMotion ? override.hudDistanceMotion : baseCue.hudDistanceMotion,
     durationMs: clampDuration(Number(override && override.durationMs), baseCue.durationMs),
   }
 }

+ 8 - 0
miniprogram/game/feedback/feedbackDirector.ts

@@ -1,6 +1,7 @@
 import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig'
 import { SoundDirector } from '../audio/soundDirector'
 import { type GameEffect } from '../core/gameResult'
+import { type AnimationLevel } from '../../utils/animationLevel'
 import {
   DEFAULT_GAME_HAPTICS_CONFIG,
   DEFAULT_GAME_UI_EFFECTS_CONFIG,
@@ -41,6 +42,9 @@ export class FeedbackDirector {
 
   reset(): void {
     this.soundDirector.resetContexts()
+    this.uiEffectDirector.clearPunchButtonMotion()
+    this.uiEffectDirector.clearHudProgressMotion()
+    this.uiEffectDirector.clearHudDistanceMotion()
   }
 
   destroy(): void {
@@ -49,6 +53,10 @@ export class FeedbackDirector {
     this.uiEffectDirector.destroy()
   }
 
+  setAnimationLevel(level: AnimationLevel): void {
+    this.uiEffectDirector.setAnimationLevel(level)
+  }
+
   setAppAudioMode(mode: 'foreground' | 'background'): void {
     this.soundDirector.setAppAudioMode(mode)
   }

+ 107 - 2
miniprogram/game/feedback/uiEffectDirector.ts

@@ -1,12 +1,16 @@
 import { type GameEffect } from '../core/gameResult'
+import { type AnimationLevel } from '../../utils/animationLevel'
 import {
   DEFAULT_GAME_UI_EFFECTS_CONFIG,
   type FeedbackCueKey,
   type GameUiEffectsConfig,
   type UiContentCardMotion,
+  type UiHudDistanceMotion,
+  type UiHudProgressMotion,
   type UiMapPulseMotion,
   type UiPunchButtonMotion,
   type UiPunchFeedbackMotion,
+  type UiCueConfig,
   type UiStageMotion,
 } from './feedbackConfig'
 
@@ -14,6 +18,8 @@ export interface UiEffectHost {
   showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void
   showContentCard: (title: string, body: string, motionClass?: string) => void
   setPunchButtonFxClass: (className: string) => void
+  setHudProgressFxClass: (className: string) => void
+  setHudDistanceFxClass: (className: string) => void
   showMapPulse: (controlId: string, motionClass?: string) => void
   showStageFx: (className: string) => void
 }
@@ -23,30 +29,46 @@ export class UiEffectDirector {
   config: GameUiEffectsConfig
   host: UiEffectHost
   punchButtonMotionTimer: number
+  hudProgressMotionTimer: number
+  hudDistanceMotionTimer: number
   punchButtonMotionToggle: boolean
+  animationLevel: AnimationLevel
 
   constructor(host: UiEffectHost, config: GameUiEffectsConfig = DEFAULT_GAME_UI_EFFECTS_CONFIG) {
     this.enabled = true
     this.host = host
     this.config = config
     this.punchButtonMotionTimer = 0
+    this.hudProgressMotionTimer = 0
+    this.hudDistanceMotionTimer = 0
     this.punchButtonMotionToggle = false
+    this.animationLevel = 'standard'
   }
 
   configure(config: GameUiEffectsConfig): void {
     this.config = config
     this.clearPunchButtonMotion()
+    this.clearHudProgressMotion()
+    this.clearHudDistanceMotion()
   }
 
   setEnabled(enabled: boolean): void {
     this.enabled = enabled
     if (!enabled) {
       this.clearPunchButtonMotion()
+      this.clearHudProgressMotion()
+      this.clearHudDistanceMotion()
     }
   }
 
+  setAnimationLevel(level: AnimationLevel): void {
+    this.animationLevel = level
+  }
+
   destroy(): void {
     this.clearPunchButtonMotion()
+    this.clearHudProgressMotion()
+    this.clearHudDistanceMotion()
   }
 
   clearPunchButtonMotion(): void {
@@ -57,6 +79,22 @@ export class UiEffectDirector {
     this.host.setPunchButtonFxClass('')
   }
 
+  clearHudProgressMotion(): void {
+    if (this.hudProgressMotionTimer) {
+      clearTimeout(this.hudProgressMotionTimer)
+      this.hudProgressMotionTimer = 0
+    }
+    this.host.setHudProgressFxClass('')
+  }
+
+  clearHudDistanceMotion(): void {
+    if (this.hudDistanceMotionTimer) {
+      clearTimeout(this.hudDistanceMotionTimer)
+      this.hudDistanceMotionTimer = 0
+    }
+    this.host.setHudDistanceFxClass('')
+  }
+
   getPunchFeedbackMotionClass(motion: UiPunchFeedbackMotion): string {
     if (motion === 'warning') {
       return 'game-punch-feedback--fx-warning'
@@ -94,12 +132,32 @@ export class UiEffectDirector {
   }
 
   getStageMotionClass(motion: UiStageMotion): string {
+    if (motion === 'control') {
+      return 'map-stage__stage-fx--control'
+    }
     if (motion === 'finish') {
       return 'map-stage__stage-fx--finish'
     }
     return ''
   }
 
+  getHudProgressMotionClass(motion: UiHudProgressMotion): string {
+    if (motion === 'finish') {
+      return 'race-panel__progress--fx-finish'
+    }
+    if (motion === 'success') {
+      return 'race-panel__progress--fx-success'
+    }
+    return ''
+  }
+
+  getHudDistanceMotionClass(motion: UiHudDistanceMotion): string {
+    if (motion === 'success') {
+      return 'race-panel__metric-group--fx-distance-success'
+    }
+    return ''
+  }
+
   triggerPunchButtonMotion(motion: UiPunchButtonMotion, durationMs: number): void {
     if (motion === 'none') {
       return
@@ -121,7 +179,37 @@ export class UiEffectDirector {
     }, durationMs) as unknown as number
   }
 
-  getCue(key: FeedbackCueKey) {
+  triggerHudProgressMotion(motion: UiHudProgressMotion, durationMs: number): void {
+    const className = this.getHudProgressMotionClass(motion)
+    if (!className) {
+      return
+    }
+    this.host.setHudProgressFxClass(className)
+    if (this.hudProgressMotionTimer) {
+      clearTimeout(this.hudProgressMotionTimer)
+    }
+    this.hudProgressMotionTimer = setTimeout(() => {
+      this.hudProgressMotionTimer = 0
+      this.host.setHudProgressFxClass('')
+    }, durationMs) as unknown as number
+  }
+
+  triggerHudDistanceMotion(motion: UiHudDistanceMotion, durationMs: number): void {
+    const className = this.getHudDistanceMotionClass(motion)
+    if (!className) {
+      return
+    }
+    this.host.setHudDistanceFxClass(className)
+    if (this.hudDistanceMotionTimer) {
+      clearTimeout(this.hudDistanceMotionTimer)
+    }
+    this.hudDistanceMotionTimer = setTimeout(() => {
+      this.hudDistanceMotionTimer = 0
+      this.host.setHudDistanceFxClass('')
+    }, durationMs) as unknown as number
+  }
+
+  getCue(key: FeedbackCueKey): UiCueConfig | null {
     if (!this.enabled || !this.config.enabled) {
       return null
     }
@@ -131,7 +219,16 @@ export class UiEffectDirector {
       return null
     }
 
-    return cue
+    if (this.animationLevel === 'standard') {
+      return cue
+    }
+
+    return {
+      ...cue,
+      stageMotion: 'none' as const,
+      hudDistanceMotion: 'none' as const,
+      durationMs: cue.durationMs > 0 ? Math.max(260, Math.round(cue.durationMs * 0.6)) : 0,
+    }
   }
 
   handleEffects(effects: GameEffect[]): void {
@@ -172,6 +269,10 @@ export class UiEffectDirector {
         if (cue && cue.stageMotion !== 'none') {
           this.host.showStageFx(this.getStageMotionClass(cue.stageMotion))
         }
+        if (cue) {
+          this.triggerHudProgressMotion(cue.hudProgressMotion, cue.durationMs)
+          this.triggerHudDistanceMotion(cue.hudDistanceMotion, cue.durationMs)
+        }
         continue
       }
 
@@ -188,10 +289,14 @@ export class UiEffectDirector {
 
       if (effect.type === 'session_finished') {
         this.clearPunchButtonMotion()
+        this.clearHudProgressMotion()
+        this.clearHudDistanceMotion()
       }
 
       if (effect.type === 'session_cancelled') {
         this.clearPunchButtonMotion()
+        this.clearHudProgressMotion()
+        this.clearHudDistanceMotion()
       }
     }
   }

+ 42 - 4
miniprogram/game/telemetry/telemetryRuntime.ts

@@ -52,6 +52,44 @@ function interpolateHeadingDeg(currentDeg: number, targetDeg: number, factor: nu
   return normalizeHeadingDeg(currentDeg + normalizeHeadingDeltaDeg(targetDeg - currentDeg) * factor)
 }
 
+function resolveMotionCompassHeadingDeg(
+  alpha: number | null,
+  beta: number | null,
+  gamma: number | null,
+): number | null {
+  if (alpha === null) {
+    return null
+  }
+
+  if (beta === null || gamma === null) {
+    return normalizeHeadingDeg(360 - alpha)
+  }
+
+  const alphaRad = alpha * Math.PI / 180
+  const betaRad = beta * Math.PI / 180
+  const gammaRad = gamma * Math.PI / 180
+
+  const cA = Math.cos(alphaRad)
+  const sA = Math.sin(alphaRad)
+  const sB = Math.sin(betaRad)
+  const cG = Math.cos(gammaRad)
+  const sG = Math.sin(gammaRad)
+
+  const headingX = -cA * sG - sA * sB * cG
+  const headingY = -sA * sG + cA * sB * cG
+
+  if (Math.abs(headingX) < 1e-6 && Math.abs(headingY) < 1e-6) {
+    return normalizeHeadingDeg(360 - alpha)
+  }
+
+  let headingRad = Math.atan2(headingX, headingY)
+  if (headingRad < 0) {
+    headingRad += Math.PI * 2
+  }
+
+  return normalizeHeadingDeg(headingRad * 180 / Math.PI)
+}
+
 function getApproxDistanceMeters(
   a: { lon: number; lat: number },
   b: { lon: number; lat: number },
@@ -530,13 +568,13 @@ export class TelemetryRuntime {
     }
 
     if (event.type === 'device_motion_updated') {
-      const nextDeviceHeadingDeg = event.alpha === null
+      const motionHeadingDeg = resolveMotionCompassHeadingDeg(event.alpha, event.beta, event.gamma)
+      const nextDeviceHeadingDeg = motionHeadingDeg === null
         ? this.state.deviceHeadingDeg
         : (() => {
-          const nextHeadingDeg = normalizeHeadingDeg(360 - event.alpha * 180 / Math.PI)
           return this.state.deviceHeadingDeg === null
-            ? nextHeadingDeg
-            : interpolateHeadingDeg(this.state.deviceHeadingDeg, nextHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA)
+            ? motionHeadingDeg
+            : interpolateHeadingDeg(this.state.deviceHeadingDeg, motionHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA)
         })()
 
       this.state = {

+ 407 - 33
miniprogram/pages/map/map.ts

@@ -6,6 +6,7 @@ import {
   type MapEngineViewState,
 } from '../../engine/map/mapEngine'
 import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
+import { type AnimationLevel } from '../../utils/animationLevel'
 type CompassTickData = {
   angle: number
   long: boolean
@@ -31,9 +32,17 @@ type ScaleRulerMajorMarkData = {
 type SideButtonMode = 'all' | 'left' | 'right' | 'hidden'
 type SideActionButtonState = 'muted' | 'default' | 'active'
 type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center'
+type UserNorthReferenceMode = 'magnetic' | 'true'
+type StoredUserSettings = {
+  animationLevel?: AnimationLevel
+  northReferenceMode?: UserNorthReferenceMode
+  showCenterScaleRuler?: boolean
+  centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode
+}
 type MapPageData = MapEngineViewState & {
   showDebugPanel: boolean
   showGameInfoPanel: boolean
+  showSystemSettingsPanel: boolean
   showCenterScaleRuler: boolean
   showPunchHintBanner: boolean
   centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode
@@ -52,6 +61,10 @@ type MapPageData = MapEngineViewState & {
   panelDistanceValueText: string
   panelProgressText: string
   panelSpeedValueText: string
+  panelTimerFxClass: string
+  panelMileageFxClass: string
+  panelSpeedFxClass: string
+  panelHeartRateFxClass: string
   compassTicks: CompassTickData[]
   compassLabels: CompassLabelData[]
   sideButtonMode: SideButtonMode
@@ -59,6 +72,7 @@ type MapPageData = MapEngineViewState & {
   sideButton2Class: string
   sideButton4Class: string
   sideButton11Class: string
+  sideButton12Class: string
   sideButton13Class: string
   sideButton14Class: string
   sideButton16Class: string
@@ -75,7 +89,8 @@ type MapPageData = MapEngineViewState & {
   showRightButtonGroups: boolean
   showBottomDebugButton: boolean
 }
-const INTERNAL_BUILD_VERSION = 'map-build-261'
+const INTERNAL_BUILD_VERSION = 'map-build-282'
+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
@@ -83,7 +98,43 @@ 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<Pick<
+  MapPageData,
+  'stageWidth'
+  | 'stageHeight'
+  | 'zoom'
+  | 'centerTileY'
+  | 'tileSizePx'
+  | 'previewScale'
+>> = {}
 
 const DEBUG_ONLY_VIEW_KEYS = new Set<string>([
   'buildVersion',
@@ -93,14 +144,15 @@ const DEBUG_ONLY_VIEW_KEYS = new Set<string>([
   'mapReadyText',
   'mapName',
   'configStatusText',
-  'sensorHeadingText',
   'deviceHeadingText',
   'devicePoseText',
   'headingConfidenceText',
   'accelerometerText',
   'gyroscopeText',
   'deviceMotionText',
-  'compassDeclinationText',
+  'compassSourceText',
+  'compassTuningProfile',
+  'compassTuningProfileText',
   'northReferenceButtonText',
   'autoRotateSourceText',
   'autoRotateCalibrationText',
@@ -148,6 +200,15 @@ const CENTER_SCALE_RULER_DEP_KEYS = new Set<string>([
   'previewScale',
 ])
 
+const CENTER_SCALE_RULER_CACHE_KEYS: Array<keyof typeof centerScaleRulerInputCache> = [
+  'stageWidth',
+  'stageHeight',
+  'zoom',
+  'centerTileY',
+  'tileSizePx',
+  'previewScale',
+]
+
 const RULER_ONLY_VIEW_KEYS = new Set<string>([
   'zoom',
   'centerTileX',
@@ -213,12 +274,83 @@ function clearCenterScaleRulerSyncTimer() {
   }
 }
 
+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<MapPageData>) {
+  for (const key of CENTER_SCALE_RULER_CACHE_KEYS) {
+    if (Object.prototype.hasOwnProperty.call(patch, key)) {
+      ;(centerScaleRulerInputCache as Record<string, unknown>)[key] =
+        (patch as Record<string, unknown>)[key]
+    }
+  }
+}
+
+function loadStoredUserSettings(): StoredUserSettings {
+  try {
+    const stored = wx.getStorageSync(USER_SETTINGS_STORAGE_KEY)
+    if (!stored || typeof stored !== 'object') {
+      return {}
+    }
+
+    const normalized = stored as Record<string, unknown>
+    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.showCenterScaleRuler === 'boolean') {
+      settings.showCenterScaleRuler = normalized.showCenterScaleRuler
+    }
+    if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') {
+      settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode
+    }
+    return settings
+  } catch {
+    return {}
+  }
+}
+
+function persistStoredUserSettings(settings: StoredUserSettings) {
+  try {
+    wx.setStorageSync(USER_SETTINGS_STORAGE_KEY, settings)
+  } catch {}
+}
 function buildSideButtonVisibility(mode: SideButtonMode) {
   return {
     sideButtonMode: mode,
@@ -296,7 +428,7 @@ function getSideActionButtonClass(state: SideActionButtonState): string {
   return 'map-side-button map-side-button--default'
 }
 
-function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'skipButtonEnabled' | 'gameSessionStatus' | 'gpsLockEnabled' | 'gpsLockAvailable'>) {
+function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'showSystemSettingsPanel' | 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'skipButtonEnabled' | 'gameSessionStatus' | 'gpsLockEnabled' | 'gpsLockAvailable'>) {
   const sideButton2State: SideActionButtonState = !data.gpsLockAvailable
     ? 'muted'
     : data.gpsLockEnabled
@@ -304,6 +436,7 @@ function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGa
       : 'default'
   const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'idle' ? 'default' : 'active'
   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'
@@ -317,6 +450,7 @@ function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGa
     sideButton2Class: getSideActionButtonClass(sideButton2State),
     sideButton4Class: getSideActionButtonClass(sideButton4State),
     sideButton11Class: getSideActionButtonClass(sideButton11State),
+    sideButton12Class: getSideActionButtonClass(sideButton12State),
     sideButton13Class: getSideActionButtonClass(sideButton13State),
     sideButton14Class: getSideActionButtonClass(sideButton14State),
     sideButton16Class: getSideActionButtonClass(sideButton16State),
@@ -367,7 +501,7 @@ function formatScaleDistanceLabel(distanceMeters: number): string {
 
 function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'stageWidth' | 'stageHeight' | 'topInsetHeight' | 'zoom' | 'centerTileY' | 'tileSizePx' | 'previewScale'>) {
   if (!data.showCenterScaleRuler) {
-    return {
+    lastCenterScaleRulerStablePatch = {
       centerScaleRulerVisible: false,
       centerScaleRulerCenterXPx: 0,
       centerScaleRulerZeroYPx: 0,
@@ -378,20 +512,11 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
       centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
       centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
     }
+    return { ...lastCenterScaleRulerStablePatch }
   }
 
   if (!data.stageWidth || !data.stageHeight) {
-    return {
-      centerScaleRulerVisible: false,
-      centerScaleRulerCenterXPx: 0,
-      centerScaleRulerZeroYPx: 0,
-      centerScaleRulerHeightPx: 0,
-      centerScaleRulerAxisBottomPx: 0,
-      centerScaleRulerZeroVisible: false,
-      centerScaleRulerZeroLabel: '0 m',
-      centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
-      centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
-    }
+    return { ...lastCenterScaleRulerStablePatch }
   }
 
   const topPadding = 12
@@ -414,15 +539,13 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
     || !Number.isFinite(data.centerTileY)
   ) {
     return {
+      ...lastCenterScaleRulerStablePatch,
       centerScaleRulerVisible: true,
       centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
       centerScaleRulerZeroYPx: zeroYPx,
-      centerScaleRulerHeightPx: fallbackHeight,
+      centerScaleRulerHeightPx: lastCenterScaleRulerStablePatch.centerScaleRulerHeightPx || fallbackHeight,
       centerScaleRulerAxisBottomPx: coveredBottomPx,
       centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
-      centerScaleRulerZeroLabel: '0 m',
-      centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
-      centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
     }
   }
 
@@ -435,15 +558,13 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
 
   if (!Number.isFinite(effectiveMetersPerPixel) || effectiveMetersPerPixel <= 0 || rulerHeight < 120) {
     return {
+      ...lastCenterScaleRulerStablePatch,
       centerScaleRulerVisible: true,
       centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
       centerScaleRulerZeroYPx: zeroYPx,
-      centerScaleRulerHeightPx: Math.max(rulerHeight, fallbackHeight),
+      centerScaleRulerHeightPx: lastCenterScaleRulerStablePatch.centerScaleRulerHeightPx || fallbackHeight,
       centerScaleRulerAxisBottomPx: coveredBottomPx,
       centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
-      centerScaleRulerZeroLabel: '0 m',
-      centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
-      centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
     }
   }
 
@@ -480,7 +601,7 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
     }
   }
 
-  return {
+  lastCenterScaleRulerStablePatch = {
     centerScaleRulerVisible: true,
     centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
     centerScaleRulerZeroYPx: zeroYPx,
@@ -491,6 +612,7 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
     centerScaleRulerMinorTicks: minorTicks,
     centerScaleRulerMajorMarks: majorMarks,
   }
+  return { ...lastCenterScaleRulerStablePatch }
 }
 
 function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot {
@@ -512,6 +634,7 @@ Page({
   data: {
     showDebugPanel: false,
     showGameInfoPanel: false,
+    showSystemSettingsPanel: false,
     showCenterScaleRuler: false,
     statusBarHeight: 0,
     topInsetHeight: 12,
@@ -572,6 +695,9 @@ Page({
     accelerometerText: '--',
     gyroscopeText: '--',
     deviceMotionText: '--',
+    compassSourceText: '无数据',
+    compassTuningProfile: 'balanced',
+    compassTuningProfileText: '平衡',
     punchButtonText: '打点',
     punchButtonEnabled: false,
     skipButtonEnabled: false,
@@ -583,6 +709,8 @@ Page({
     contentCardTitle: '',
     contentCardBody: '',
     punchButtonFxClass: '',
+    panelProgressFxClass: '',
+    panelDistanceFxClass: '',
     punchFeedbackFxClass: '',
     contentCardFxClass: '',
     mapPulseVisible: false,
@@ -606,6 +734,7 @@ Page({
     ...buildSideButtonState({
       sideButtonMode: 'left',
       showGameInfoPanel: false,
+      showSystemSettingsPanel: false,
       showCenterScaleRuler: false,
       centerScaleRulerAnchorMode: 'screen-center',
       skipButtonEnabled: false,
@@ -649,7 +778,10 @@ Page({
           nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
         }
 
+        updateCenterScaleRulerInputCache(nextPatch)
+
         const mergedData = {
+          ...centerScaleRulerInputCache,
           ...this.data,
           ...nextData,
         } as MapPageData
@@ -659,6 +791,7 @@ Page({
           this.data.showCenterScaleRuler
           && hasAnyPatchKey(nextPatch as Record<string, unknown>, CENTER_SCALE_RULER_DEP_KEYS)
         ) {
+          clearCenterScaleRulerUpdateTimer()
           Object.assign(derivedPatch, buildCenterScaleRulerPatch(mergedData))
         }
 
@@ -685,6 +818,57 @@ Page({
           }
         }
 
+        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 (Object.keys(nextData).length || Object.keys(derivedPatch).length) {
           this.setData({
             ...nextData,
@@ -698,22 +882,46 @@ Page({
       },
     })
 
+    const storedUserSettings = loadStoredUserSettings()
+    if (storedUserSettings.animationLevel) {
+      mapEngine.handleSetAnimationLevel(storedUserSettings.animationLevel)
+    }
+    if (storedUserSettings.northReferenceMode) {
+      mapEngine.handleSetNorthReferenceMode(storedUserSettings.northReferenceMode)
+    }
+
     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,
       gameInfoTitle: '当前游戏',
       gameInfoSubtitle: '未开始',
       gameInfoLocalRows: [],
       gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
       panelTimerText: '00:00:00',
+      panelTimerFxClass: '',
       panelMileageText: '0m',
+      panelMileageFxClass: '',
       panelActionTagText: '目标',
       panelDistanceTagText: '点距',
       panelDistanceValueText: '--',
@@ -740,6 +948,7 @@ Page({
       mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
       mockHeartRateText: '--',
       panelSpeedValueText: '0',
+      panelSpeedFxClass: '',
       panelTelemetryTone: 'blue',
       panelHeartRateZoneNameText: '--',
       panelHeartRateZoneRangeText: '',
@@ -747,6 +956,7 @@ Page({
       heartRateStatusText: '心率带未连接',
       heartRateDeviceText: '--',
       panelHeartRateValueText: '--',
+      panelHeartRateFxClass: '',
       panelHeartRateUnitText: '',
       panelCaloriesValueText: '0',
       panelCaloriesUnitText: 'kcal',
@@ -760,6 +970,9 @@ Page({
       accelerometerText: '--',
       gyroscopeText: '--',
       deviceMotionText: '--',
+      compassSourceText: '无数据',
+      compassTuningProfile: 'balanced',
+      compassTuningProfileText: '平衡',
       punchButtonText: '打点',
       punchButtonEnabled: false,
       skipButtonEnabled: false,
@@ -771,6 +984,8 @@ Page({
       contentCardTitle: '',
       contentCardBody: '',
       punchButtonFxClass: '',
+      panelProgressFxClass: '',
+      panelDistanceFxClass: '',
       punchFeedbackFxClass: '',
       contentCardFxClass: '',
       mapPulseVisible: false,
@@ -785,8 +1000,9 @@ Page({
       ...buildSideButtonState({
         sideButtonMode: 'left',
         showGameInfoPanel: false,
-        showCenterScaleRuler: false,
-        centerScaleRulerAnchorMode: 'screen-center',
+        showSystemSettingsPanel: false,
+        showCenterScaleRuler: initialShowCenterScaleRuler,
+        centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode,
         skipButtonEnabled: false,
         gameSessionStatus: 'idle',
         gpsLockEnabled: false,
@@ -794,8 +1010,8 @@ Page({
       }),
       ...buildCenterScaleRulerPatch({
         ...(mapEngine.getInitialData() as MapPageData),
-        showCenterScaleRuler: false,
-        centerScaleRulerAnchorMode: 'screen-center',
+        showCenterScaleRuler: initialShowCenterScaleRuler,
+        centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode,
         stageWidth: 0,
         stageHeight: 0,
         topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
@@ -827,7 +1043,12 @@ Page({
   onUnload() {
     clearGameInfoPanelSyncTimer()
     clearCenterScaleRulerSyncTimer()
+    clearCenterScaleRulerUpdateTimer()
     clearPunchHintDismissTimer()
+    clearHudFxTimer('timer')
+    clearHudFxTimer('mileage')
+    clearHudFxTimer('speed')
+    clearHudFxTimer('heartRate')
     if (mapEngine) {
       mapEngine.destroy()
       mapEngine = null
@@ -997,6 +1218,24 @@ Page({
     }
   },
 
+  handleSetCompassTuningSmooth() {
+    if (mapEngine) {
+      mapEngine.handleSetCompassTuningProfile('smooth')
+    }
+  },
+
+  handleSetCompassTuningBalanced() {
+    if (mapEngine) {
+      mapEngine.handleSetCompassTuningProfile('balanced')
+    }
+  },
+
+  handleSetCompassTuningResponsive() {
+    if (mapEngine) {
+      mapEngine.handleSetCompassTuningProfile('responsive')
+    }
+  },
+
   handleAutoRotateCalibrate() {
     if (mapEngine) {
       mapEngine.handleAutoRotateCalibrate()
@@ -1260,10 +1499,12 @@ Page({
     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,
@@ -1281,6 +1522,7 @@ Page({
       ...buildSideButtonState({
         sideButtonMode: this.data.sideButtonMode,
         showGameInfoPanel: false,
+        showSystemSettingsPanel: this.data.showSystemSettingsPanel,
         showCenterScaleRuler: this.data.showCenterScaleRuler,
         centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
         skipButtonEnabled: this.data.skipButtonEnabled,
@@ -1293,6 +1535,89 @@ Page({
 
   handleGameInfoPanelTap() {},
 
+  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 (!mapEngine) {
+      return
+    }
+    mapEngine.handleSetAnimationLevel('standard')
+    persistStoredUserSettings({
+      ...loadStoredUserSettings(),
+      animationLevel: 'standard',
+    })
+  },
+
+  handleSetAnimationLevelLite() {
+    if (!mapEngine) {
+      return
+    }
+    mapEngine.handleSetAnimationLevel('lite')
+    persistStoredUserSettings({
+      ...loadStoredUserSettings(),
+      animationLevel: 'lite',
+    })
+  },
+
+  handleSetNorthReferenceMagnetic() {
+    if (!mapEngine) {
+      return
+    }
+    mapEngine.handleSetNorthReferenceMode('magnetic')
+    persistStoredUserSettings({
+      ...loadStoredUserSettings(),
+      northReferenceMode: 'magnetic',
+    })
+  },
+
+  handleSetNorthReferenceTrue() {
+    if (!mapEngine) {
+      return
+    }
+    mapEngine.handleSetNorthReferenceMode('true')
+    persistStoredUserSettings({
+      ...loadStoredUserSettings(),
+      northReferenceMode: 'true',
+    })
+  },
+
   handleOverlayTouch() {},
 
   handlePunchAction() {
@@ -1318,6 +1643,8 @@ Page({
     })
   },
 
+  handlePunchHintTap() {},
+
   handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) {
     this.setData({
       hudPanelIndex: event.detail.current || 0,
@@ -1331,6 +1658,7 @@ Page({
       ...buildSideButtonState({
         sideButtonMode: nextMode,
         showGameInfoPanel: this.data.showGameInfoPanel,
+        showSystemSettingsPanel: this.data.showSystemSettingsPanel,
         showCenterScaleRuler: this.data.showCenterScaleRuler,
         centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
         skipButtonEnabled: this.data.skipButtonEnabled,
@@ -1368,9 +1696,11 @@ Page({
     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,
@@ -1390,6 +1720,7 @@ Page({
       ...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,
@@ -1400,25 +1731,29 @@ Page({
     })
   },
 
-  handleToggleCenterScaleRuler() {
-    const nextEnabled = !this.data.showCenterScaleRuler
+  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<MapPageData>
+      updateCenterScaleRulerInputCache(engineSnapshot)
       const mergedData = {
-        ...engineSnapshot,
+        ...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),
       })
@@ -1431,9 +1766,11 @@ Page({
 
     this.setData({
       showCenterScaleRuler: true,
+      centerScaleRulerAnchorMode: nextAnchorMode,
       ...buildSideButtonState({
         ...this.data,
         showCenterScaleRuler: true,
+        centerScaleRulerAnchorMode: nextAnchorMode,
       } as MapPageData),
     })
 
@@ -1450,6 +1787,42 @@ Page({
     }, 96) as unknown as number
   },
 
+  handleSetCenterScaleRulerVisibleOn() {
+    this.applyCenterScaleRulerSettings(true, this.data.centerScaleRulerAnchorMode)
+    persistStoredUserSettings({
+      ...loadStoredUserSettings(),
+      showCenterScaleRuler: true,
+      centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
+    })
+  },
+
+  handleSetCenterScaleRulerVisibleOff() {
+    this.applyCenterScaleRulerSettings(false, this.data.centerScaleRulerAnchorMode)
+    persistStoredUserSettings({
+      ...loadStoredUserSettings(),
+      showCenterScaleRuler: false,
+      centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
+    })
+  },
+
+  handleSetCenterScaleRulerAnchorScreenCenter() {
+    this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'screen-center')
+    persistStoredUserSettings({
+      ...loadStoredUserSettings(),
+      showCenterScaleRuler: this.data.showCenterScaleRuler,
+      centerScaleRulerAnchorMode: 'screen-center',
+    })
+  },
+
+  handleSetCenterScaleRulerAnchorCompassCenter() {
+    this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'compass-center')
+    persistStoredUserSettings({
+      ...loadStoredUserSettings(),
+      showCenterScaleRuler: this.data.showCenterScaleRuler,
+      centerScaleRulerAnchorMode: 'compass-center',
+    })
+  },
+
   handleToggleCenterScaleRulerAnchor() {
     if (!this.data.showCenterScaleRuler) {
       return
@@ -1459,9 +1832,10 @@ Page({
       ? 'compass-center'
       : 'screen-center'
     const engineSnapshot = mapEngine ? (mapEngine.getInitialData() as Partial<MapPageData>) : {}
+    updateCenterScaleRulerInputCache(engineSnapshot)
     this.data.centerScaleRulerAnchorMode = nextAnchorMode
     const mergedData = {
-      ...engineSnapshot,
+      ...centerScaleRulerInputCache,
       ...this.data,
       centerScaleRulerAnchorMode: nextAnchorMode,
     } as MapPageData

+ 125 - 24
miniprogram/pages/map/map.wxml

@@ -28,10 +28,6 @@
     <view class="map-stage__map-pulse {{mapPulseFxClass}}" wx:if="{{mapPulseVisible}}" style="left: {{mapPulseLeftPx}}px; top: {{mapPulseTopPx}}px;"></view>
     <view class="map-stage__stage-fx {{stageFxClass}}" wx:if="{{stageFxVisible}}"></view>
 
-    <view class="game-punch-hint" wx:if="{{showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;">
-      <view class="game-punch-hint__text">{{punchHintText}}</view>
-      <view class="game-punch-hint__close" bindtap="handleClosePunchHint">×</view>
-    </view>
     <view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}} {{punchFeedbackFxClass}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view>
     <view class="game-content-card {{contentCardFxClass}}" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
       <view class="game-content-card__title">{{contentCardTitle}}</view>
@@ -40,7 +36,7 @@
     </view>
 
 
-    <view class="map-stage__overlay-center-layer" wx:if="{{!showDebugPanel && !showGameInfoPanel}}">
+    <view class="map-stage__overlay-center-layer" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}">
       <view class="center-scale-ruler" wx:if="{{centerScaleRulerVisible}}" style="left: {{centerScaleRulerCenterXPx}}px; top: {{centerScaleRulerZeroYPx}}px; height: {{centerScaleRulerHeightPx}}px;">
         <view class="center-scale-ruler__axis" style="bottom: {{centerScaleRulerAxisBottomPx}}px;"></view>
         <view class="center-scale-ruler__arrow"></view>
@@ -84,13 +80,18 @@
     </view>
   </view>
 
-  <cover-view class="map-side-toggle" wx:if="{{!showDebugPanel && !showGameInfoPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons">
+  <view class="game-punch-hint" wx:if="{{showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
+    <view class="game-punch-hint__text">{{punchHintText}}</view>
+    <view class="game-punch-hint__close" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap" catchtap="handleClosePunchHint">×</view>
+  </view>
+
+  <cover-view class="map-side-toggle" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons">
     <cover-view class="map-side-button map-side-button--icon">
       <cover-image class="map-side-button__image" src="{{sideToggleIconSrc}}"></cover-image>
     </cover-view>
   </cover-view>
 
-  <cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && !showGameInfoPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
+  <cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
     <cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view>
     <cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">1</cover-view></cover-view>
     <cover-view class="{{sideButton2Class}}" bindtap="handleToggleGpsLock"><cover-view class="map-side-button__text">2</cover-view></cover-view>
@@ -98,7 +99,7 @@
     <cover-view class="{{sideButton4Class}}" bindtap="handleForceExitGame"><cover-image class="map-side-button__action-image" src="../../assets/btn_exit.png"></cover-image></cover-view>
   </cover-view>
 
-  <cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && !showGameInfoPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
+  <cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
     <cover-view class="map-side-button"><cover-view class="map-side-button__text">5</cover-view></cover-view>
     <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">6</cover-view></cover-view>
     <cover-view class="map-side-button"><cover-view class="map-side-button__text">7</cover-view></cover-view>
@@ -107,24 +108,24 @@
     <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">10</cover-view></cover-view>
   </cover-view>
 
-  <cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && !showGameInfoPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
+  <cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
     <cover-view class="{{sideButton11Class}}" bindtap="handleOpenGameInfoPanel"><cover-image class="map-side-button__action-image" src="../../assets/btn_info.png"></cover-image></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">12</cover-view></cover-view>
-    <cover-view class="{{sideButton13Class}}" bindtap="handleToggleCenterScaleRuler"><cover-view class="map-side-button__text">13</cover-view></cover-view>
-    <cover-view class="{{sideButton14Class}}" bindtap="handleToggleCenterScaleRulerAnchor"><cover-view class="map-side-button__text">14</cover-view></cover-view>
+    <cover-view class="{{sideButton12Class}}" bindtap="handleOpenSystemSettingsPanel"><cover-view class="map-side-button__text">12</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">13</cover-view></cover-view>
+    <cover-view class="map-side-button"><cover-view class="map-side-button__text">14</cover-view></cover-view>
     <cover-view class="map-side-button"><cover-view class="map-side-button__text">15</cover-view></cover-view>
     <cover-view class="{{sideButton16Class}}" bindtap="handleSkipAction"><cover-image class="map-side-button__action-image" src="../../assets/btn_skip_cp.png"></cover-image></cover-view>
   </cover-view>
 
-  <cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel && !showGameInfoPanel}}" bindtap="handlePunchAction">
+  <cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}" bindtap="handlePunchAction">
     <cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
   </cover-view>
 
-  <cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame">
+  <cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame">
     <cover-view class="screen-button-layer__text screen-button-layer__text--start">开始</cover-view>
   </cover-view>
 
-  <cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
+  <cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
     <cover-view class="screen-button-layer__icon">
       <cover-view class="screen-button-layer__line"></cover-view>
       <cover-view class="screen-button-layer__stand"></cover-view>
@@ -132,7 +133,7 @@
     <cover-view class="screen-button-layer__text">调试</cover-view>
   </cover-view>
 
-  <swiper wx:if="{{!showGameInfoPanel}}" class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
+  <swiper wx:if="{{!showGameInfoPanel && !showSystemSettingsPanel}}" class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
     <swiper-item>
       <view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
         <view class="race-panel__tag race-panel__tag--top-left">{{panelActionTagText}}</view>
@@ -155,10 +156,10 @@
             </view>
           </view>
           <view class="race-panel__cell race-panel__cell--timer">
-            <text class="race-panel__timer">{{panelTimerText}}</text>
+            <text class="race-panel__timer {{panelTimerFxClass}}">{{panelTimerText}}</text>
           </view>
           <view class="race-panel__cell race-panel__cell--mileage">
-            <view class="race-panel__mileage-wrap">
+            <view class="race-panel__mileage-wrap {{panelMileageFxClass}}">
               <text class="race-panel__mileage">{{panelMileageText}}</text>
               <view class="race-panel__chevrons">
                 <view class="race-panel__chevron"></view>
@@ -167,16 +168,16 @@
             </view>
           </view>
           <view class="race-panel__cell race-panel__cell--distance">
-            <view class="race-panel__metric-group race-panel__metric-group--left">
+            <view class="race-panel__metric-group race-panel__metric-group--left {{panelDistanceFxClass}}">
               <text class="race-panel__metric-value race-panel__metric-value--distance">{{panelDistanceValueText}}</text>
               <text class="race-panel__metric-unit race-panel__metric-unit--distance">{{panelDistanceUnitText}}</text>
             </view>
           </view>
           <view class="race-panel__cell race-panel__cell--progress">
-            <text class="race-panel__progress">{{panelProgressText}}</text>
+            <text class="race-panel__progress {{panelProgressFxClass}}">{{panelProgressText}}</text>
           </view>
           <view class="race-panel__cell race-panel__cell--speed">
-            <view class="race-panel__metric-group race-panel__metric-group--right">
+            <view class="race-panel__metric-group race-panel__metric-group--right {{panelSpeedFxClass}}">
               <text class="race-panel__metric-value race-panel__metric-value--speed">{{panelSpeedValueText}}</text>
               <text class="race-panel__metric-unit race-panel__metric-unit--speed">km/h</text>
             </view>
@@ -201,13 +202,13 @@
 
         <view class="race-panel__grid">
           <view class="race-panel__cell race-panel__cell--action">
-            <view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel">
+            <view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel {{panelHeartRateFxClass}}">
               <text class="race-panel__metric-value race-panel__metric-value--telemetry">{{panelHeartRateValueText}}</text>
               <text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelHeartRateUnitText}}</text>
             </view>
           </view>
           <view class="race-panel__cell race-panel__cell--timer">
-            <text class="race-panel__timer">{{panelTimerText}}</text>
+            <text class="race-panel__timer {{panelTimerFxClass}}">{{panelTimerText}}</text>
           </view>
           <view class="race-panel__cell race-panel__cell--mileage">
             <view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
@@ -237,7 +238,7 @@
       </view>
     </swiper-item>
   </swiper>
-  <view class="race-panel-pager" wx:if="{{!showDebugPanel && !showGameInfoPanel}}">
+  <view class="race-panel-pager" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}">
     <view class="race-panel-pager__dot {{hudPanelIndex === 0 ? 'race-panel-pager__dot--active' : ''}}"></view>
     <view class="race-panel-pager__dot {{hudPanelIndex === 1 ? 'race-panel-pager__dot--active' : ''}}"></view>
   </view>
@@ -281,6 +282,93 @@
     </view>
   </view>
 
+  <view class="game-info-modal" wx:if="{{showSystemSettingsPanel}}" bindtap="handleCloseSystemSettingsPanel">
+    <view class="game-info-modal__dialog" catchtap="handleSystemSettingsPanelTap">
+      <view class="game-info-modal__header">
+        <view class="game-info-modal__header-main">
+          <view class="game-info-modal__eyebrow">SYSTEM SETTINGS</view>
+          <view class="game-info-modal__title">系统设置</view>
+          <view class="game-info-modal__subtitle">用户端偏好与设备级选项</view>
+        </view>
+        <view class="game-info-modal__header-actions">
+          <view class="game-info-modal__close" bindtap="handleCloseSystemSettingsPanel">关闭</view>
+        </view>
+      </view>
+
+      <scroll-view class="game-info-modal__content" scroll-y enhanced show-scrollbar="true">
+        <view class="debug-section debug-section--info">
+          <view class="debug-section__header">
+            <view class="debug-section__title">01. 动画性能</view>
+            <view class="debug-section__desc">根据设备性能切换动画强度,低端机建议精简</view>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">当前级别</text>
+            <text class="info-panel__value">{{animationLevel === 'lite' ? '精简' : '标准'}}</text>
+          </view>
+          <view class="control-row">
+            <view class="control-chip {{animationLevel === 'standard' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetAnimationLevelStandard">标准</view>
+            <view class="control-chip {{animationLevel === 'lite' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetAnimationLevelLite">精简</view>
+          </view>
+        </view>
+
+        <view class="debug-section debug-section--info">
+          <view class="debug-section__header">
+            <view class="debug-section__title">02. 比例尺显示</view>
+            <view class="debug-section__desc">控制比例尺显示与否,默认沿用你的本地偏好</view>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">当前状态</text>
+            <text class="info-panel__value">{{showCenterScaleRuler ? '显示' : '隐藏'}}</text>
+          </view>
+          <view class="control-row">
+            <view class="control-chip {{showCenterScaleRuler ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCenterScaleRulerVisibleOn">显示</view>
+            <view class="control-chip {{!showCenterScaleRuler ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCenterScaleRulerVisibleOff">隐藏</view>
+          </view>
+        </view>
+
+        <view class="debug-section debug-section--info">
+          <view class="debug-section__header">
+            <view class="debug-section__title">03. 比例尺基准点</view>
+            <view class="debug-section__desc">设置比例尺零点锚定位置,可跟随屏幕中心或指北针圆心</view>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">当前锚点</text>
+            <text class="info-panel__value">{{centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心'}}</text>
+          </view>
+          <view class="control-row">
+            <view class="control-chip {{centerScaleRulerAnchorMode === 'screen-center' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCenterScaleRulerAnchorScreenCenter">屏幕中心</view>
+            <view class="control-chip {{centerScaleRulerAnchorMode === 'compass-center' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCenterScaleRulerAnchorCompassCenter">指北针圆心</view>
+          </view>
+        </view>
+
+        <view class="debug-section debug-section--info">
+          <view class="debug-section__header">
+            <view class="debug-section__title">04. 北参考</view>
+            <view class="debug-section__desc">切换磁北/真北作为地图与指北针参考</view>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">当前参考</text>
+            <text class="info-panel__value">{{northReferenceText}}</text>
+          </view>
+          <view class="control-row">
+            <view class="control-chip {{northReferenceMode === 'magnetic' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetNorthReferenceMagnetic">磁北</view>
+            <view class="control-chip {{northReferenceMode === 'true' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetNorthReferenceTrue">真北</view>
+          </view>
+        </view>
+
+        <view class="debug-section debug-section--info">
+          <view class="debug-section__header">
+            <view class="debug-section__title">05. 心率设备</view>
+            <view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
+          </view>
+          <view class="control-row">
+            <view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选设备</view>
+          </view>
+        </view>
+      </scroll-view>
+    </view>
+  </view>
+
   <view class="debug-modal" wx:if="{{showDebugPanel}}" bindtap="handleCloseDebugPanel">
     <view class="debug-modal__dialog" catchtap="handleDebugPanelTap">
       <view class="debug-modal__header">
@@ -464,6 +552,19 @@
             <text class="info-panel__label">Heading Confidence</text>
             <text class="info-panel__value">{{headingConfidenceText}}</text>
           </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Compass Source</text>
+            <text class="info-panel__value">{{compassSourceText}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Compass Tune</text>
+            <text class="info-panel__value">{{compassTuningProfileText}}</text>
+          </view>
+          <view class="control-row">
+            <view class="control-chip {{compassTuningProfile === 'smooth' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCompassTuningSmooth">顺滑</view>
+            <view class="control-chip {{compassTuningProfile === 'balanced' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCompassTuningBalanced">平衡</view>
+            <view class="control-chip {{compassTuningProfile === 'responsive' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCompassTuningResponsive">跟手</view>
+          </view>
           <view class="info-panel__row info-panel__row--stack">
             <text class="info-panel__label">Accel</text>
             <text class="info-panel__value">{{accelerometerText}}</text>

+ 144 - 4
miniprogram/pages/map/map.wxss

@@ -85,6 +85,10 @@
   animation: stage-fx-finish 0.76s ease-out 1;
 }
 
+.map-stage__stage-fx--control {
+  animation: stage-fx-control 0.52s ease-out 1;
+}
+
 .map-stage__overlay {
   position: absolute;
   inset: 0;
@@ -834,6 +838,10 @@
   text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.2);
 }
 
+.race-panel__timer--fx-tick {
+  animation: race-panel-timer-tick 0.32s cubic-bezier(0.24, 0.86, 0.3, 1) 1;
+}
+
 .race-panel__mileage {
   max-width: 100%;
   box-sizing: border-box;
@@ -851,6 +859,10 @@
   transform: translateX(-16rpx);
 }
 
+.race-panel__mileage-wrap--fx-update {
+  animation: race-panel-mileage-update 0.36s cubic-bezier(0.22, 0.88, 0.34, 1) 1;
+}
+
 .race-panel__metric-group {
   max-width: 100%;
   box-sizing: border-box;
@@ -864,11 +876,23 @@
   transform: translateX(16rpx);
 }
 
+.race-panel__metric-group--fx-distance-success {
+  animation: race-panel-distance-success 0.56s cubic-bezier(0.22, 0.88, 0.34, 1) 1;
+}
+
 .race-panel__metric-group--right {
   justify-content: center;
   transform: translateX(-16rpx);
 }
 
+.race-panel__metric-group--fx-speed-update {
+  animation: race-panel-speed-update 0.36s cubic-bezier(0.22, 0.88, 0.34, 1) 1;
+}
+
+.race-panel__metric-group--fx-heart-rate-update {
+  animation: race-panel-heart-rate-update 0.4s cubic-bezier(0.2, 0.9, 0.3, 1) 1;
+}
+
 .race-panel__metric-value {
   line-height: 1;
   text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.16);
@@ -924,6 +948,38 @@
   text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.16);
 }
 
+.race-panel__progress--fx-success {
+  animation: race-panel-progress-success 0.56s cubic-bezier(0.2, 0.88, 0.32, 1) 1;
+}
+
+.race-panel__progress--fx-finish {
+  animation: race-panel-progress-finish 0.68s cubic-bezier(0.18, 0.92, 0.28, 1) 1;
+}
+
+@keyframes race-panel-timer-tick {
+  0% { transform: translateY(0) scale(1); opacity: 0.94; }
+  35% { transform: translateY(-2rpx) scale(1.04); opacity: 1; }
+  100% { transform: translateY(0) scale(1); opacity: 1; }
+}
+
+@keyframes race-panel-mileage-update {
+  0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; }
+  40% { transform: translateX(-16rpx) scale(1.05); opacity: 1; }
+  100% { transform: translateX(-16rpx) scale(1); opacity: 1; }
+}
+
+@keyframes race-panel-speed-update {
+  0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; }
+  40% { transform: translateX(-16rpx) scale(1.06); opacity: 1; }
+  100% { transform: translateX(-16rpx) scale(1); opacity: 1; }
+}
+
+@keyframes race-panel-heart-rate-update {
+  0% { transform: translateX(16rpx) scale(1); opacity: 0.94; }
+  38% { transform: translateX(16rpx) scale(1.05); opacity: 1; }
+  100% { transform: translateX(16rpx) scale(1); opacity: 1; }
+}
+
 .race-panel__zone {
   display: flex;
   flex-direction: column;
@@ -982,6 +1038,72 @@
   right: 0;
   bottom: 0;
 }
+
+@keyframes race-panel-distance-success {
+  0% {
+    transform: translateX(16rpx) scale(1);
+    opacity: 1;
+  }
+
+  28% {
+    transform: translateX(16rpx) scale(1.09);
+    opacity: 1;
+  }
+
+  62% {
+    transform: translateX(16rpx) scale(0.98);
+    opacity: 0.96;
+  }
+
+  100% {
+    transform: translateX(16rpx) scale(1);
+    opacity: 1;
+  }
+}
+
+@keyframes race-panel-progress-success {
+  0% {
+    transform: scale(1) translateY(0);
+    opacity: 1;
+  }
+
+  24% {
+    transform: scale(1.16) translateY(-4rpx);
+    opacity: 1;
+  }
+
+  60% {
+    transform: scale(0.98) translateY(0);
+    opacity: 0.96;
+  }
+
+  100% {
+    transform: scale(1) translateY(0);
+    opacity: 1;
+  }
+}
+
+@keyframes race-panel-progress-finish {
+  0% {
+    transform: scale(1) translateY(0);
+    opacity: 1;
+  }
+
+  20% {
+    transform: scale(1.2) translateY(-6rpx);
+    opacity: 1;
+  }
+
+  46% {
+    transform: scale(1.08) translateY(-2rpx);
+    opacity: 1;
+  }
+
+  100% {
+    transform: scale(1) translateY(0);
+    opacity: 1;
+  }
+}
 .map-punch-button {
   position: absolute;
   right: 24rpx;
@@ -1593,7 +1715,7 @@
   font-size: 24rpx;
   line-height: 1.2;
   text-align: left;
-  z-index: 16;
+  z-index: 40;
   pointer-events: auto;
 }
 
@@ -1603,9 +1725,9 @@
 }
 
 .game-punch-hint__close {
-  width: 40rpx;
-  height: 40rpx;
-  flex: 0 0 40rpx;
+  width: 56rpx;
+  height: 56rpx;
+  flex: 0 0 56rpx;
   border-radius: 999rpx;
   display: flex;
   align-items: center;
@@ -1939,3 +2061,21 @@
     backdrop-filter: brightness(1);
   }
 }
+
+@keyframes stage-fx-control {
+  0% {
+    opacity: 0;
+    background: radial-gradient(circle at 50% 50%, rgba(138, 255, 235, 0.16) 0%, rgba(138, 255, 235, 0.06) 26%, rgba(255, 255, 255, 0) 60%);
+    backdrop-filter: brightness(1);
+  }
+  36% {
+    opacity: 1;
+    background: radial-gradient(circle at 50% 50%, rgba(138, 255, 235, 0.24) 0%, rgba(138, 255, 235, 0.1) 32%, rgba(255, 255, 255, 0.03) 72%);
+    backdrop-filter: brightness(1.03);
+  }
+  100% {
+    opacity: 0;
+    background: radial-gradient(circle at 50% 50%, rgba(138, 255, 235, 0) 0%, rgba(138, 255, 235, 0) 100%);
+    backdrop-filter: brightness(1);
+  }
+}

+ 24 - 0
miniprogram/utils/animationLevel.ts

@@ -0,0 +1,24 @@
+export type AnimationLevel = 'standard' | 'lite'
+
+const LITE_BENCHMARK_THRESHOLD = 18
+const LITE_DEVICE_MEMORY_GB = 3
+
+export function resolveAnimationLevel(systemInfo?: WechatMiniprogram.SystemInfo): AnimationLevel {
+  const info = systemInfo || wx.getSystemInfoSync()
+  const benchmarkLevel = Number((info as WechatMiniprogram.SystemInfo & { benchmarkLevel?: number }).benchmarkLevel)
+  const deviceMemory = Number((info as WechatMiniprogram.SystemInfo & { deviceMemory?: number }).deviceMemory)
+
+  if (Number.isFinite(benchmarkLevel) && benchmarkLevel > 0 && benchmarkLevel <= LITE_BENCHMARK_THRESHOLD) {
+    return 'lite'
+  }
+
+  if (Number.isFinite(deviceMemory) && deviceMemory > 0 && deviceMemory <= LITE_DEVICE_MEMORY_GB) {
+    return 'lite'
+  }
+
+  return 'standard'
+}
+
+export function formatAnimationLevelText(level: AnimationLevel): string {
+  return level === 'lite' ? '精简' : '标准'
+}