Selaa lähdekoodia

Refine sensor integration strategy

zhangyan 2 viikkoa sitten
vanhempi
commit
f7d4499e36

+ 179 - 5
miniprogram/engine/map/mapEngine.ts

@@ -1,5 +1,8 @@
 import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
+import { AccelerometerController } from '../sensor/accelerometerController'
 import { CompassHeadingController } from '../sensor/compassHeadingController'
+import { DeviceMotionController } from '../sensor/deviceMotionController'
+import { GyroscopeController } from '../sensor/gyroscopeController'
 import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
 import { HeartRateInputController } from '../sensor/heartRateInputController'
 import { LocationController } from '../sensor/locationController'
@@ -98,6 +101,12 @@ export interface MapEngineViewState {
   orientationMode: OrientationMode
   orientationModeText: string
   sensorHeadingText: string
+  deviceHeadingText: string
+  devicePoseText: string
+  headingConfidenceText: string
+  accelerometerText: string
+  gyroscopeText: string
+  deviceMotionText: string
   compassDeclinationText: string
   northReferenceButtonText: string
   autoRotateSourceText: string
@@ -231,6 +240,12 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'orientationMode',
   'orientationModeText',
   'sensorHeadingText',
+  'deviceHeadingText',
+  'devicePoseText',
+  'headingConfidenceText',
+  'accelerometerText',
+  'gyroscopeText',
+  'deviceMotionText',
   'compassDeclinationText',
   'northReferenceButtonText',
   'autoRotateSourceText',
@@ -386,6 +401,61 @@ function formatHeadingText(headingDeg: number | null): string {
   return `${Math.round(normalizeRotationDeg(headingDeg))}掳`
 }
 
+function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
+  if (pose === 'flat') {
+    return '平放'
+  }
+
+  if (pose === 'tilted') {
+    return '倾斜'
+  }
+
+  return '竖持'
+}
+
+function formatHeadingConfidenceText(confidence: 'low' | 'medium' | 'high'): string {
+  if (confidence === 'high') {
+    return '高'
+  }
+
+  if (confidence === 'medium') {
+    return '中'
+  }
+
+  return '低'
+}
+
+function formatClockTime(timestamp: number | null): string {
+  if (!timestamp || !Number.isFinite(timestamp)) {
+    return '--:--:--'
+  }
+
+  const date = new Date(timestamp)
+  const hh = String(date.getHours()).padStart(2, '0')
+  const mm = String(date.getMinutes()).padStart(2, '0')
+  const ss = String(date.getSeconds()).padStart(2, '0')
+  return `${hh}:${mm}:${ss}`
+}
+
+function formatGyroscopeText(gyroscope: { x: number; y: number; z: number } | null): string {
+  if (!gyroscope) {
+    return '--'
+  }
+
+  return `x:${gyroscope.x.toFixed(2)} y:${gyroscope.y.toFixed(2)} z:${gyroscope.z.toFixed(2)}`
+}
+
+function formatDeviceMotionText(motion: { alpha: number | null; beta: number | null; gamma: number | null } | null): string {
+  if (!motion) {
+    return '--'
+  }
+
+  const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha * 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)
+  return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}`
+}
+
 function formatOrientationModeText(mode: OrientationMode): string {
   if (mode === 'north-up') {
     return 'North Up'
@@ -589,12 +659,16 @@ function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
 export class MapEngine {
   buildVersion: string
   renderer: WebGLMapRenderer
+  accelerometerController: AccelerometerController
   compassController: CompassHeadingController
+  gyroscopeController: GyroscopeController
+  deviceMotionController: DeviceMotionController
   locationController: LocationController
   heartRateController: HeartRateInputController
   feedbackDirector: FeedbackDirector
   onData: (patch: Partial<MapEngineViewState>) => void
   state: MapEngineViewState
+  accelerometerErrorText: string | null
   previewScale: number
   previewOriginX: number
   previewOriginY: number
@@ -669,6 +743,7 @@ export class MapEngine {
   constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
     this.buildVersion = buildVersion
     this.onData = callbacks.onData
+    this.accelerometerErrorText = null
     this.renderer = new WebGLMapRenderer(
       (stats) => {
         this.applyStats(stats)
@@ -679,6 +754,26 @@ export class MapEngine {
         })
       },
     )
+      this.accelerometerController = new AccelerometerController({
+        onSample: (x, y, z) => {
+          this.accelerometerErrorText = null
+          this.telemetryRuntime.dispatch({
+            type: 'accelerometer_updated',
+            at: Date.now(),
+            x,
+            y,
+            z,
+          })
+          this.setState(this.getTelemetrySensorViewPatch(), true)
+        },
+        onError: (message) => {
+          this.accelerometerErrorText = `不可用: ${message}`
+          this.setState({
+            ...this.getTelemetrySensorViewPatch(),
+            statusText: `加速度计启动失败 (${this.buildVersion})`,
+          }, true)
+        },
+      })
     this.compassController = new CompassHeadingController({
       onHeading: (headingDeg) => {
         this.handleCompassHeading(headingDeg)
@@ -687,6 +782,43 @@ export class MapEngine {
         this.handleCompassError(message)
       },
     })
+    this.gyroscopeController = new GyroscopeController({
+      onSample: (x, y, z) => {
+        this.telemetryRuntime.dispatch({
+          type: 'gyroscope_updated',
+          at: Date.now(),
+          x,
+          y,
+          z,
+        })
+        this.setState(this.getTelemetrySensorViewPatch(), true)
+      },
+      onError: () => {
+        this.setState(this.getTelemetrySensorViewPatch(), true)
+      },
+    })
+    this.deviceMotionController = new DeviceMotionController({
+      onSample: (alpha, beta, gamma) => {
+        this.telemetryRuntime.dispatch({
+          type: 'device_motion_updated',
+          at: Date.now(),
+          alpha,
+          beta,
+          gamma,
+        })
+        this.setState({
+          ...this.getTelemetrySensorViewPatch(),
+          autoRotateSourceText: this.getAutoRotateSourceText(),
+        }, true)
+
+        if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
+          this.scheduleAutoRotate()
+        }
+      },
+      onError: () => {
+        this.setState(this.getTelemetrySensorViewPatch(), true)
+      },
+    })
     this.locationController = new LocationController({
       onLocation: (update) => {
         this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
@@ -851,6 +983,12 @@ export class MapEngine {
       orientationMode: 'manual',
       orientationModeText: formatOrientationModeText('manual'),
       sensorHeadingText: '--',
+      deviceHeadingText: '--',
+      devicePoseText: '竖持',
+      headingConfidenceText: '低',
+        accelerometerText: '未启用',
+      gyroscopeText: '--',
+      deviceMotionText: '--',
       compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
       northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
       autoRotateSourceText: formatAutoRotateSourceText('smart', false),
@@ -1019,6 +1157,9 @@ export class MapEngine {
       { label: '定位源', value: this.state.locationSourceText || '--' },
       { label: '当前位置', value: this.state.gpsCoordText || '--' },
       { label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` },
+      { label: '设备朝向', value: this.state.deviceHeadingText || '--' },
+      { label: '设备姿态', value: this.state.devicePoseText || '--' },
+      { label: '朝向可信度', value: this.state.headingConfidenceText || '--' },
       { label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' },
       { label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
       { label: '心率源', value: this.state.heartRateSourceText || '--' },
@@ -1056,7 +1197,10 @@ export class MapEngine {
     this.clearMapPulseTimer()
     this.clearStageFxTimer()
     this.clearSessionTimerInterval()
+    this.accelerometerController.destroy()
     this.compassController.destroy()
+    this.gyroscopeController.destroy()
+    this.deviceMotionController.destroy()
     this.locationController.destroy()
     this.heartRateController.destroy()
     this.feedbackDirector.destroy()
@@ -1172,6 +1316,24 @@ 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),
+      }
+  }
+
   getGameModeText(): string {
     return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
   }
@@ -1930,6 +2092,10 @@ export class MapEngine {
   }
 
   attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
+    if (this.mounted) {
+      return
+    }
+
     this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
     this.mounted = true
     this.state.mapReady = true
@@ -1940,7 +2106,10 @@ export class MapEngine {
       statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
     })
     this.syncRenderer()
+    this.accelerometerErrorText = null
     this.compassController.start()
+    this.gyroscopeController.start()
+    this.deviceMotionController.start()
   }
 
   applyRemoteMapConfig(config: RemoteMapConfig): void {
@@ -2507,6 +2676,7 @@ export class MapEngine {
 
     this.setState({
       sensorHeadingText: formatHeadingText(compassHeadingDeg),
+      ...this.getTelemetrySensorViewPatch(),
       compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
       northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
       autoRotateSourceText: this.getAutoRotateSourceText(),
@@ -2554,6 +2724,7 @@ export class MapEngine {
           rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
           northReferenceText: formatNorthReferenceText(nextMode),
           sensorHeadingText: formatHeadingText(compassHeadingDeg),
+          ...this.getTelemetrySensorViewPatch(),
           compassDeclinationText: formatCompassDeclinationText(nextMode),
           northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
           compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
@@ -2572,6 +2743,7 @@ export class MapEngine {
     this.setState({
       northReferenceText: formatNorthReferenceText(nextMode),
       sensorHeadingText: formatHeadingText(compassHeadingDeg),
+      ...this.getTelemetrySensorViewPatch(),
       compassDeclinationText: formatCompassDeclinationText(nextMode),
       northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
       compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
@@ -2624,10 +2796,14 @@ export class MapEngine {
     return null
   }
 
-  getSmartAutoRotateHeadingDeg(): number | null {
-    const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
+  getPreferredSensorHeadingDeg(): number | null {
+    return this.smoothedSensorHeadingDeg === null
       ? null
       : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
+  }
+
+  getSmartAutoRotateHeadingDeg(): number | null {
+    const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
     const movementHeadingDeg = this.getMovementHeadingDeg()
     const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
     const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
@@ -2661,9 +2837,7 @@ export class MapEngine {
       return this.getSmartAutoRotateHeadingDeg()
     }
 
-    const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
-      ? null
-      : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
+    const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
     const courseHeadingDeg = this.courseHeadingDeg === null
       ? null
       : getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)

+ 124 - 0
miniprogram/engine/sensor/accelerometerController.ts

@@ -0,0 +1,124 @@
+export interface AccelerometerControllerCallbacks {
+  onSample: (x: number, y: number, z: number) => void
+  onError: (message: string) => void
+}
+
+const ACCELEROMETER_START_RETRY_DELAY_MS = 120
+
+export class AccelerometerController {
+  callbacks: AccelerometerControllerCallbacks
+  listening: boolean
+  starting: boolean
+  accelerometerCallback: ((result: WechatMiniprogram.OnAccelerometerChangeCallbackResult) => void) | null
+  retryTimer: number
+
+  constructor(callbacks: AccelerometerControllerCallbacks) {
+    this.callbacks = callbacks
+    this.listening = false
+    this.starting = false
+    this.accelerometerCallback = null
+    this.retryTimer = 0
+  }
+
+  start(): void {
+    if (this.listening || this.starting) {
+      return
+    }
+
+    if (typeof wx.startAccelerometer !== 'function' || typeof wx.onAccelerometerChange !== 'function') {
+      this.callbacks.onError('当前环境不支持加速度计监听')
+      return
+    }
+
+    this.clearRetryTimer()
+    this.starting = true
+    this.detachCallback()
+    wx.stopAccelerometer({
+      complete: () => {
+        this.startAfterStop(false)
+      },
+    })
+  }
+
+  private startAfterStop(retried: boolean): void {
+    const callback = (result: WechatMiniprogram.OnAccelerometerChangeCallbackResult) => {
+      if (
+        typeof result.x !== 'number'
+        || typeof result.y !== 'number'
+        || typeof result.z !== 'number'
+        || Number.isNaN(result.x)
+        || Number.isNaN(result.y)
+        || Number.isNaN(result.z)
+      ) {
+        return
+      }
+
+      this.callbacks.onSample(result.x, result.y, result.z)
+    }
+
+    this.accelerometerCallback = callback
+    wx.onAccelerometerChange(callback)
+    wx.startAccelerometer({
+      interval: 'ui',
+      success: () => {
+        this.starting = false
+        this.listening = true
+      },
+      fail: (res) => {
+        const errorMessage = res && res.errMsg ? res.errMsg : 'startAccelerometer failed'
+        if (!retried && errorMessage.indexOf('has enable') >= 0) {
+          this.detachCallback()
+          this.clearRetryTimer()
+          this.retryTimer = setTimeout(() => {
+            this.retryTimer = 0
+            wx.stopAccelerometer({
+              complete: () => {
+                this.startAfterStop(true)
+              },
+            })
+          }, ACCELEROMETER_START_RETRY_DELAY_MS) as unknown as number
+          return
+        }
+
+        this.starting = false
+        this.detachCallback()
+        this.callbacks.onError(errorMessage)
+      },
+    })
+  }
+
+  stop(): void {
+    this.clearRetryTimer()
+    this.detachCallback()
+    wx.stopAccelerometer({
+      complete: () => {},
+    })
+    this.starting = false
+    this.listening = false
+  }
+
+  destroy(): void {
+    this.stop()
+  }
+
+  private clearRetryTimer(): void {
+    if (!this.retryTimer) {
+      return
+    }
+
+    clearTimeout(this.retryTimer)
+    this.retryTimer = 0
+  }
+
+  private detachCallback(): void {
+    if (!this.accelerometerCallback) {
+      return
+    }
+
+    if (typeof wx.offAccelerometerChange === 'function') {
+      wx.offAccelerometerChange(this.accelerometerCallback)
+    }
+
+    this.accelerometerCallback = null
+  }
+}

+ 77 - 0
miniprogram/engine/sensor/deviceMotionController.ts

@@ -0,0 +1,77 @@
+export interface DeviceMotionControllerCallbacks {
+  onSample: (alpha: number | null, beta: number | null, gamma: number | null) => void
+  onError: (message: string) => void
+}
+
+export class DeviceMotionController {
+  callbacks: DeviceMotionControllerCallbacks
+  listening: boolean
+  starting: boolean
+  motionCallback: ((result: WechatMiniprogram.OnDeviceMotionChangeCallbackResult) => void) | null
+
+  constructor(callbacks: DeviceMotionControllerCallbacks) {
+    this.callbacks = callbacks
+    this.listening = false
+    this.starting = false
+    this.motionCallback = null
+  }
+
+  start(): void {
+    if (this.listening || this.starting) {
+      return
+    }
+
+    if (typeof wx.startDeviceMotionListening !== 'function' || typeof wx.onDeviceMotionChange !== 'function') {
+      this.callbacks.onError('当前环境不支持设备方向监听')
+      return
+    }
+
+    const callback = (result: WechatMiniprogram.OnDeviceMotionChangeCallbackResult) => {
+      const alpha = typeof result.alpha === 'number' && !Number.isNaN(result.alpha) ? result.alpha : null
+      const beta = typeof result.beta === 'number' && !Number.isNaN(result.beta) ? result.beta : null
+      const gamma = typeof result.gamma === 'number' && !Number.isNaN(result.gamma) ? result.gamma : null
+      this.callbacks.onSample(alpha, beta, gamma)
+    }
+
+    this.motionCallback = callback
+    wx.onDeviceMotionChange(callback)
+    this.starting = true
+    wx.startDeviceMotionListening({
+      interval: 'game',
+      success: () => {
+        this.starting = false
+        this.listening = true
+      },
+      fail: (res) => {
+        this.starting = false
+        this.detachCallback()
+        this.callbacks.onError(res && res.errMsg ? res.errMsg : 'startDeviceMotionListening failed')
+      },
+    })
+  }
+
+  stop(): void {
+    this.detachCallback()
+    wx.stopDeviceMotionListening({
+      complete: () => {},
+    })
+    this.starting = false
+    this.listening = false
+  }
+
+  destroy(): void {
+    this.stop()
+  }
+
+  private detachCallback(): void {
+    if (!this.motionCallback) {
+      return
+    }
+
+    if (typeof wx.offDeviceMotionChange === 'function') {
+      wx.offDeviceMotionChange(this.motionCallback)
+    }
+
+    this.motionCallback = null
+  }
+}

+ 85 - 0
miniprogram/engine/sensor/gyroscopeController.ts

@@ -0,0 +1,85 @@
+export interface GyroscopeControllerCallbacks {
+  onSample: (x: number, y: number, z: number) => void
+  onError: (message: string) => void
+}
+
+export class GyroscopeController {
+  callbacks: GyroscopeControllerCallbacks
+  listening: boolean
+  starting: boolean
+  gyroCallback: ((result: WechatMiniprogram.OnGyroscopeChangeCallbackResult) => void) | null
+
+  constructor(callbacks: GyroscopeControllerCallbacks) {
+    this.callbacks = callbacks
+    this.listening = false
+    this.starting = false
+    this.gyroCallback = null
+  }
+
+  start(): void {
+    if (this.listening || this.starting) {
+      return
+    }
+
+    if (typeof wx.startGyroscope !== 'function' || typeof wx.onGyroscopeChange !== 'function') {
+      this.callbacks.onError('当前环境不支持陀螺仪监听')
+      return
+    }
+
+    const callback = (result: WechatMiniprogram.OnGyroscopeChangeCallbackResult) => {
+      if (
+        typeof result.x !== 'number'
+        || typeof result.y !== 'number'
+        || typeof result.z !== 'number'
+        || Number.isNaN(result.x)
+        || Number.isNaN(result.y)
+        || Number.isNaN(result.z)
+      ) {
+        return
+      }
+
+      this.callbacks.onSample(result.x, result.y, result.z)
+    }
+
+    this.gyroCallback = callback
+    wx.onGyroscopeChange(callback)
+    this.starting = true
+    wx.startGyroscope({
+      interval: 'game',
+      success: () => {
+        this.starting = false
+        this.listening = true
+      },
+      fail: (res) => {
+        this.starting = false
+        this.detachCallback()
+        this.callbacks.onError(res && res.errMsg ? res.errMsg : 'startGyroscope failed')
+      },
+    })
+  }
+
+  stop(): void {
+    this.detachCallback()
+    wx.stopGyroscope({
+      complete: () => {},
+    })
+    this.starting = false
+    this.listening = false
+  }
+
+  destroy(): void {
+    this.stop()
+  }
+
+  private detachCallback(): void {
+    if (!this.gyroCallback) {
+      return
+    }
+
+    if (typeof wx.offGyroscopeChange === 'function') {
+      wx.offGyroscopeChange(this.gyroCallback)
+    }
+
+    this.gyroCallback = null
+  }
+}

+ 3 - 0
miniprogram/game/telemetry/telemetryEvent.ts

@@ -6,4 +6,7 @@ export type TelemetryEvent =
   | { type: 'session_state_updated'; at: number; status: GameSessionStatus; startedAt: number | null; endedAt: number | null }
   | { type: 'target_updated'; controlId: string | null; point: LonLatPoint | null }
   | { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
+  | { type: 'accelerometer_updated'; at: number; x: number; y: number; z: number }
+  | { type: 'gyroscope_updated'; at: number; x: number; y: number; z: number }
+  | { type: 'device_motion_updated'; at: number; alpha: number | null; beta: number | null; gamma: number | null }
   | { type: 'heart_rate_updated'; at: number; bpm: number | null }

+ 209 - 2
miniprogram/game/telemetry/telemetryRuntime.ts

@@ -11,8 +11,46 @@ import {
 import { type GameSessionState } from '../core/gameSessionState'
 import { type TelemetryEvent } from './telemetryEvent'
 import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
-import { EMPTY_TELEMETRY_STATE, type TelemetryState } from './telemetryState'
+import {
+  EMPTY_TELEMETRY_STATE,
+  type DevicePose,
+  type HeadingConfidence,
+  type TelemetryState,
+} from './telemetryState'
 const SPEED_SMOOTHING_ALPHA = 0.35
+const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28
+const ACCELEROMETER_SMOOTHING_ALPHA = 0.2
+const DEVICE_POSE_FLAT_ENTER_Z = 0.82
+const DEVICE_POSE_FLAT_EXIT_Z = 0.7
+const DEVICE_POSE_UPRIGHT_ENTER_Z = 0.42
+const DEVICE_POSE_UPRIGHT_EXIT_Z = 0.55
+const DEVICE_POSE_UPRIGHT_AXIS_ENTER = 0.78
+const DEVICE_POSE_UPRIGHT_AXIS_EXIT = 0.65
+const HEADING_CONFIDENCE_HIGH_TURN_RATE_RAD = 0.35
+const HEADING_CONFIDENCE_MEDIUM_TURN_RATE_RAD = 1.05
+
+function normalizeHeadingDeg(headingDeg: number): number {
+  const normalized = headingDeg % 360
+  return normalized < 0 ? normalized + 360 : normalized
+}
+
+function normalizeHeadingDeltaDeg(deltaDeg: number): number {
+  let normalized = deltaDeg
+
+  while (normalized > 180) {
+    normalized -= 360
+  }
+
+  while (normalized < -180) {
+    normalized += 360
+  }
+
+  return normalized
+}
+
+function interpolateHeadingDeg(currentDeg: number, targetDeg: number, factor: number): number {
+  return normalizeHeadingDeg(currentDeg + normalizeHeadingDeltaDeg(targetDeg - currentDeg) * factor)
+}
 
 function getApproxDistanceMeters(
   a: { lon: number; lat: number },
@@ -76,6 +114,99 @@ function smoothSpeedKmh(previousSpeedKmh: number | null, nextSpeedKmh: number):
   return previousSpeedKmh + (nextSpeedKmh - previousSpeedKmh) * SPEED_SMOOTHING_ALPHA
 }
 
+function resolveDevicePose(
+  previousPose: DevicePose,
+  accelerometer: TelemetryState['accelerometer'],
+): DevicePose {
+  if (!accelerometer) {
+    return previousPose
+  }
+
+  const magnitude = Math.sqrt(
+    accelerometer.x * accelerometer.x
+      + accelerometer.y * accelerometer.y
+      + accelerometer.z * accelerometer.z,
+  )
+
+  if (!Number.isFinite(magnitude) || magnitude <= 0.001) {
+    return previousPose
+  }
+
+  const normalizedX = Math.abs(accelerometer.x / magnitude)
+  const normalizedY = Math.abs(accelerometer.y / magnitude)
+  const normalizedZ = Math.abs(accelerometer.z / magnitude)
+  const verticalAxis = Math.max(normalizedX, normalizedY)
+
+  const withinFlatEnter = normalizedZ >= DEVICE_POSE_FLAT_ENTER_Z
+  const withinFlatExit = normalizedZ >= DEVICE_POSE_FLAT_EXIT_Z
+  const withinUprightEnter = normalizedZ <= DEVICE_POSE_UPRIGHT_ENTER_Z && verticalAxis >= DEVICE_POSE_UPRIGHT_AXIS_ENTER
+  const withinUprightExit = normalizedZ <= DEVICE_POSE_UPRIGHT_EXIT_Z && verticalAxis >= DEVICE_POSE_UPRIGHT_AXIS_EXIT
+
+  if (previousPose === 'flat') {
+    if (withinFlatExit) {
+      return 'flat'
+    }
+
+    if (withinUprightEnter) {
+      return 'upright'
+    }
+
+    return 'tilted'
+  }
+
+  if (previousPose === 'upright') {
+    if (withinUprightExit) {
+      return 'upright'
+    }
+
+    if (withinFlatEnter) {
+      return 'flat'
+    }
+
+    return 'tilted'
+  }
+
+  if (withinFlatEnter) {
+    return 'flat'
+  }
+
+  if (withinUprightEnter) {
+    return 'upright'
+  }
+
+  return 'tilted'
+}
+
+function resolveHeadingConfidence(
+  headingDeg: number | null,
+  pose: DevicePose,
+  gyroscope: TelemetryState['gyroscope'],
+): HeadingConfidence {
+  if (headingDeg === null || pose === 'flat') {
+    return 'low'
+  }
+
+  if (!gyroscope) {
+    return pose === 'upright' ? 'medium' : 'low'
+  }
+
+  const turnRate = Math.sqrt(
+    gyroscope.x * gyroscope.x
+      + gyroscope.y * gyroscope.y
+      + gyroscope.z * gyroscope.z,
+  )
+
+  if (turnRate <= HEADING_CONFIDENCE_HIGH_TURN_RATE_RAD) {
+    return pose === 'upright' ? 'high' : 'medium'
+  }
+
+  if (turnRate <= HEADING_CONFIDENCE_MEDIUM_TURN_RATE_RAD) {
+    return 'medium'
+  }
+
+  return 'low'
+}
+
 function getHeartRateTone(
   heartRateBpm: number | null,
   telemetryConfig: TelemetryConfig,
@@ -257,7 +388,17 @@ export class TelemetryRuntime {
   }
 
   reset(): void {
-    this.state = { ...EMPTY_TELEMETRY_STATE }
+      this.state = {
+        ...EMPTY_TELEMETRY_STATE,
+        accelerometer: this.state.accelerometer,
+        accelerometerUpdatedAt: this.state.accelerometerUpdatedAt,
+        accelerometerSampleCount: this.state.accelerometerSampleCount,
+        gyroscope: this.state.gyroscope,
+        deviceMotion: this.state.deviceMotion,
+        deviceHeadingDeg: this.state.deviceHeadingDeg,
+      devicePose: this.state.devicePose,
+      headingConfidence: this.state.headingConfidence,
+    }
   }
 
   configure(config?: Partial<TelemetryConfig> | null): void {
@@ -353,6 +494,64 @@ export class TelemetryRuntime {
       return
     }
 
+    if (event.type === 'accelerometer_updated') {
+      const previous = this.state.accelerometer
+        this.state = {
+          ...this.state,
+          accelerometer: previous === null
+          ? {
+            x: event.x,
+            y: event.y,
+            z: event.z,
+          }
+            : {
+              x: previous.x + (event.x - previous.x) * ACCELEROMETER_SMOOTHING_ALPHA,
+              y: previous.y + (event.y - previous.y) * ACCELEROMETER_SMOOTHING_ALPHA,
+              z: previous.z + (event.z - previous.z) * ACCELEROMETER_SMOOTHING_ALPHA,
+            },
+          accelerometerUpdatedAt: event.at,
+          accelerometerSampleCount: this.state.accelerometerSampleCount + 1,
+        }
+        this.recomputeDerivedState()
+        return
+    }
+
+    if (event.type === 'gyroscope_updated') {
+      this.state = {
+        ...this.state,
+        gyroscope: {
+          x: event.x,
+          y: event.y,
+          z: event.z,
+        },
+      }
+      this.recomputeDerivedState()
+      return
+    }
+
+    if (event.type === 'device_motion_updated') {
+      const nextDeviceHeadingDeg = event.alpha === 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)
+        })()
+
+      this.state = {
+        ...this.state,
+        deviceMotion: {
+          alpha: event.alpha,
+          beta: event.beta,
+          gamma: event.gamma,
+        },
+        deviceHeadingDeg: nextDeviceHeadingDeg,
+      }
+      this.recomputeDerivedState()
+      return
+    }
+
     if (event.type === 'heart_rate_updated') {
       this.syncCalorieAccumulation(event.at)
       this.state = {
@@ -374,12 +573,20 @@ export class TelemetryRuntime {
     const averageSpeedKmh = elapsedMs > 0
       ? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6
       : null
+    const devicePose = resolveDevicePose(this.state.devicePose, this.state.accelerometer)
+    const headingConfidence = resolveHeadingConfidence(
+      this.state.deviceHeadingDeg,
+      devicePose,
+      this.state.gyroscope,
+    )
 
     this.state = {
       ...this.state,
       elapsedMs,
       distanceToTargetMeters,
       averageSpeedKmh,
+      devicePose,
+      headingConfidence,
     }
   }
 

+ 19 - 0
miniprogram/game/telemetry/telemetryState.ts

@@ -1,6 +1,9 @@
 import { type LonLatPoint } from '../../utils/projection'
 import { type GameSessionStatus } from '../core/gameSessionState'
 
+export type DevicePose = 'upright' | 'tilted' | 'flat'
+export type HeadingConfidence = 'low' | 'medium' | 'high'
+
 export interface TelemetryState {
   sessionStatus: GameSessionStatus
   sessionStartedAt: number | null
@@ -15,6 +18,14 @@ export interface TelemetryState {
   lastGpsPoint: LonLatPoint | null
   lastGpsAt: number | null
   lastGpsAccuracyMeters: number | null
+  accelerometer: { x: number; y: number; z: number } | null
+  accelerometerUpdatedAt: number | null
+  accelerometerSampleCount: number
+  gyroscope: { x: number; y: number; z: number } | null
+  deviceMotion: { alpha: number | null; beta: number | null; gamma: number | null } | null
+  deviceHeadingDeg: number | null
+  devicePose: DevicePose
+  headingConfidence: HeadingConfidence
   heartRateBpm: number | null
   caloriesKcal: number | null
   calorieTrackingAt: number | null
@@ -34,6 +45,14 @@ export const EMPTY_TELEMETRY_STATE: TelemetryState = {
   lastGpsPoint: null,
   lastGpsAt: null,
   lastGpsAccuracyMeters: null,
+  accelerometer: null,
+  accelerometerUpdatedAt: null,
+  accelerometerSampleCount: 0,
+  gyroscope: null,
+  deviceMotion: null,
+  deviceHeadingDeg: null,
+  devicePose: 'upright',
+  headingConfidence: 'low',
   heartRateBpm: null,
   caloriesKcal: null,
   calorieTrackingAt: null,

+ 18 - 1
miniprogram/pages/map/map.ts

@@ -50,7 +50,7 @@ type MapPageData = MapEngineViewState & {
   showRightButtonGroups: boolean
   showBottomDebugButton: boolean
 }
-const INTERNAL_BUILD_VERSION = 'map-build-213'
+const INTERNAL_BUILD_VERSION = 'map-build-232'
 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'
 let mapEngine: MapEngine | null = null
@@ -221,6 +221,12 @@ Page({
     panelAverageSpeedUnitText: 'km/h',
     panelAccuracyValueText: '--',
     panelAccuracyUnitText: '',
+    deviceHeadingText: '--',
+    devicePoseText: '竖持',
+    headingConfidenceText: '低',
+    accelerometerText: '--',
+    gyroscopeText: '--',
+    deviceMotionText: '--',
     punchButtonText: '打点',
     punchButtonEnabled: false,
     skipButtonEnabled: false,
@@ -259,6 +265,11 @@ Page({
     const menuButtonRect = wx.getMenuButtonBoundingClientRect()
     const menuButtonBottom = menuButtonRect && typeof menuButtonRect.bottom === 'number' ? menuButtonRect.bottom : statusBarHeight
 
+    if (mapEngine) {
+      mapEngine.destroy()
+      mapEngine = null
+    }
+
     mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
       onData: (patch) => {
         const nextPatch = patch as Partial<MapPageData>
@@ -349,6 +360,12 @@ Page({
       panelAverageSpeedUnitText: 'km/h',
       panelAccuracyValueText: '--',
       panelAccuracyUnitText: '',
+      deviceHeadingText: '--',
+      devicePoseText: '竖持',
+      headingConfidenceText: '低',
+      accelerometerText: '--',
+      gyroscopeText: '--',
+      deviceMotionText: '--',
       punchButtonText: '打点',
       punchButtonEnabled: false,
       skipButtonEnabled: false,

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

@@ -435,6 +435,30 @@
             <text class="info-panel__label">Sensor Heading</text>
             <text class="info-panel__value">{{sensorHeadingText}}</text>
           </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Device Heading</text>
+            <text class="info-panel__value">{{deviceHeadingText}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Pose</text>
+            <text class="info-panel__value">{{devicePoseText}}</text>
+          </view>
+          <view class="info-panel__row">
+            <text class="info-panel__label">Heading Confidence</text>
+            <text class="info-panel__value">{{headingConfidenceText}}</text>
+          </view>
+          <view class="info-panel__row info-panel__row--stack">
+            <text class="info-panel__label">Accel</text>
+            <text class="info-panel__value">{{accelerometerText}}</text>
+          </view>
+          <view class="info-panel__row info-panel__row--stack">
+            <text class="info-panel__label">Gyro</text>
+            <text class="info-panel__value">{{gyroscopeText}}</text>
+          </view>
+          <view class="info-panel__row info-panel__row--stack">
+            <text class="info-panel__label">Motion</text>
+            <text class="info-panel__value">{{deviceMotionText}}</text>
+          </view>
           <view class="info-panel__row info-panel__row--stack">
             <text class="info-panel__label">North Ref</text>
             <text class="info-panel__value">{{northReferenceText}}</text>

+ 201 - 0
sensor-current-summary.md

@@ -0,0 +1,201 @@
+# 传感器现状总结
+
+本文档用于说明当前小程序版本已经接入并实际使用的传感器/输入源、它们在系统中的作用,以及当前阶段的稳定边界。
+
+## 1. 当前正式在用的传感器/输入源
+
+### 1.1 GPS 定位
+
+当前作用:
+
+- 当前定位点显示
+- GPS 轨迹线绘制
+- 到目标点距离计算
+- 打点半径判定
+- 地图锁定跟随
+- 自动转图中的前进方向判断
+- 速度、里程、卡路里等 telemetry 统计
+
+当前涉及层:
+
+- `LocationController`
+- `TelemetryRuntime`
+- `MapEngine`
+- `RuleEngine`
+
+说明:
+
+- 这是当前地图和玩法系统最核心的输入源。
+
+### 1.2 Compass 罗盘
+
+当前作用:
+
+- 指北针
+- 静止或低速时的地图朝向
+- 真北 / 磁北参考切换相关显示
+
+当前涉及层:
+
+- `CompassHeadingController`
+- `MapEngine`
+
+说明:
+
+- 当前自动转图的稳定主来源之一。
+
+### 1.3 Gyroscope 陀螺仪
+
+当前作用:
+
+- 提供设备旋转速率调试数据
+- 为设备朝向可信度提供辅助参考
+- 为后续更稳的自动转图平滑能力预留输入
+
+当前涉及层:
+
+- `GyroscopeController`
+- `TelemetryRuntime`
+
+说明:
+
+- 当前已接入并显示,但还没有直接主导地图旋转。
+
+### 1.4 DeviceMotion 设备方向
+
+当前作用:
+
+- 提供设备朝向角参考
+- 参与 `deviceHeadingDeg`
+- 参与 `headingConfidence`
+- 用于调试观察姿态相关信息
+
+当前涉及层:
+
+- `DeviceMotionController`
+- `TelemetryRuntime`
+
+说明:
+
+- 当前不直接接管自动转图,主要作为辅助与调试输入。
+
+### 1.5 BLE 心率带
+
+虽然不是手机内置传感器,但当前已经是正式输入源。
+
+当前作用:
+
+- 实时心率采集
+- HUD 颜色分区
+- 卡路里估算
+- 橙 / 红警戒边框
+- 后续心率相关玩法
+
+当前涉及层:
+
+- `HeartRateController`
+- `HeartRateInputController`
+- `TelemetryRuntime`
+- HUD / Feedback
+
+## 2. 当前正式在用的模拟输入源
+
+### 2.1 模拟 GPS
+
+当前作用:
+
+- 室内测试路线与打点
+- 模拟移动
+- 测试规则、HUD、自动转图
+
+说明:
+
+- 与真实 GPS 并列,是正式的开发调试输入源。
+
+### 2.2 模拟心率
+
+当前作用:
+
+- 测试心率颜色区间
+- 测试卡路里累计
+- 测试边框警示
+- 测试第二块 HUD
+
+说明:
+
+- 与真实心率带并列,是正式的开发调试输入源。
+
+## 3. 当前没有纳入正式能力的传感器
+
+### 3.1 Accelerometer 加速度计
+
+当前状态:
+
+- 在当前微信小程序运行时 / 设备环境下不稳定
+- 启动时出现 `startAccelerometer:fail, has enable, should stop pre operation`
+- 已从当前第一阶段正式方案中移出
+
+结论:
+
+- 当前小程序版本不依赖加速度计
+- 后续更完整的姿态 / 运动融合,建议放到原生 Flutter 端实现
+
+## 4. 当前地图上真正直接起作用的核心输入
+
+如果只看当前会直接影响地图行为和玩法行为的核心输入,主要是:
+
+- `GPS`
+- `Compass`
+- `Heart Rate (BLE)`
+
+其中:
+
+- `GPS` 负责位置、轨迹、速度、距离、打点、跟随、前进方向
+- `Compass` 负责当前稳定的地图朝向与指北针
+- `Heart Rate` 负责 HUD 颜色、卡路里和警戒反馈
+
+而:
+
+- `Gyroscope`
+- `DeviceMotion`
+
+当前更多是为后续更稳的朝向融合能力做准备。
+
+## 5. 当前阶段的稳定边界
+
+小程序第一阶段推荐稳定边界如下:
+
+- 保留:
+  - `Location`
+  - `Compass`
+  - `Gyroscope`
+  - `DeviceMotion`
+  - `BLE Heart Rate`
+  - `Mock GPS`
+  - `Mock Heart Rate`
+- 放弃:
+  - `Accelerometer`
+
+结论:
+
+- 小程序端以稳定为优先
+- 更完整的原始传感器融合,放在原生 Flutter 端推进
+
+## 6. 一句话总结
+
+当前小程序版本已经正式使用的核心传感器 / 输入源是:
+
+- `GPS`
+- `Compass`
+- `Gyroscope`
+- `DeviceMotion`
+- `Heart Rate (BLE)`
+- `Mock GPS`
+- `Mock Heart Rate`
+
+其中真正直接驱动地图行为的核心仍然是:
+
+- `GPS`
+- `Compass`
+
+其余能力更多承担辅助、调试、反馈和后续扩展输入的角色。

+ 570 - 0
todo-sensor-integration-plan.md

@@ -0,0 +1,570 @@
+# 传感器接入待开发方案
+
+本文档用于整理当前项目后续可利用的传感器能力,分为:
+
+- 微信小程序能力边界
+- 原生 Flutter App 能力边界
+- 两端统一的抽象建议
+- 推荐落地顺序
+
+目标不是一次性接入所有传感器,而是优先接入对当前地图玩法、自动转图、运动状态识别、HUD/反馈最有价值的能力。
+
+---
+
+## 1. 总体原则
+
+传感器接入必须遵守以下原则:
+
+- 原始传感器数据只放在 `engine/sensor`
+- 融合后的高级状态放在 `telemetry`
+- 地图引擎只消费“对地图有意义的结果”
+- 规则引擎只在玩法确实需要时消费高级状态
+- 不要把原始三轴值直接喂给地图或玩法逻辑
+
+推荐统一产出的高级状态包括:
+
+- `movementState`
+- `headingSource`
+- `devicePose`
+- `headingConfidence`
+- `cadenceSpm`
+- `motionIntensity`
+
+---
+
+## 2. 微信小程序可用传感器
+
+### 2.1 当前确认可用
+
+基于微信小程序官方 API 与项目内 typings,当前可直接使用的能力包括:
+
+- `Location`
+  - `wx.startLocationUpdate`
+  - `wx.startLocationUpdateBackground`
+  - `wx.onLocationChange`
+- `Accelerometer`
+  - `wx.startAccelerometer`
+  - `wx.onAccelerometerChange`
+- `Compass`
+  - `wx.startCompass`
+  - `wx.onCompassChange`
+- `DeviceMotion`
+  - `wx.startDeviceMotionListening`
+  - `wx.onDeviceMotionChange`
+- `Gyroscope`
+  - `wx.startGyroscope`
+  - `wx.onGyroscopeChange`
+- `WeRunData`
+  - `wx.getWeRunData`
+
+### 2.2 当前确认不可直接获得的原始能力
+
+微信小程序没有直接开放以下原始传感器接口:
+
+- `Gravity`
+- `Linear Acceleration`
+- `Rotation Vector`
+- `Geomagnetic Field` 原始三轴
+- `Proximity`
+- 原始 `Step Counter`
+
+说明:
+
+- `wx.getWeRunData` 不是实时步数传感器流
+- 它更适合中长期统计,不适合实时地图玩法
+
+---
+
+## 3. 微信小程序推荐应用方案
+
+### 3.1 第一优先级
+
+#### A. Gyroscope
+
+用途:
+
+- 提升自动转图平滑度
+- 降低跑步中手机晃动导致的朝向抖动
+- 增强指北针和地图旋转过渡体验
+
+推荐产出:
+
+- `turnRate`
+- `headingSmoothFactor`
+- `headingStability`
+
+#### B. DeviceMotion
+
+用途:
+
+- 识别手机姿态
+- 判断设备是竖持、倾斜还是接近平放
+- 配合 gyro 增强朝向可信度
+
+推荐产出:
+
+- `devicePose`
+- `orientationConfidence`
+- `tiltState`
+
+#### C. Compass
+
+用途:
+
+- 静止或低速时,作为持机朝向基准
+- 指北针展示
+
+推荐角色:
+
+- 继续保留
+- 作为“静止朝向输入”
+- 不再单独承担跑动中的全部朝向逻辑
+
+### 3.2 第二优先级
+
+#### D. Accelerometer
+
+用途:
+
+- 辅助识别是否真的在移动
+- 识别急停、抖动、运动强度变化
+
+推荐产出:
+
+- `motionIntensity`
+- `movementConfidence`
+
+说明:
+
+- 不建议直接用原始加速度驱动地图行为
+- 应和 GPS、gyro 一起融合使用
+
+#### E. Location
+
+用途:
+
+- 当前定位
+- 轨迹
+- 目标距离
+- movement heading
+- 速度估计
+
+推荐角色:
+
+- 继续作为地图和玩法核心输入
+- 后续更多与 gyro / accelerometer 配合使用
+
+### 3.3 当前不建议优先投入
+
+#### F. WeRunData
+
+用途:
+
+- 日级步数统计
+- 长周期运动数据
+
+当前不建议投入原因:
+
+- 不是实时传感器
+- 不适合当前地图实时玩法主链
+
+---
+
+## 4. 微信小程序推荐先产出的高级状态
+
+### A. movementState
+
+建议值:
+
+- `idle`
+- `walk`
+- `run`
+
+来源:
+
+- GPS 速度
+- accelerometer
+- device motion
+
+### B. headingSource
+
+建议值:
+
+- `sensor`
+- `blended`
+- `movement`
+
+来源:
+
+- compass
+- gyroscope
+- GPS track
+
+### C. devicePose
+
+建议值:
+
+- `upright`
+- `tilted`
+- `flat`
+
+来源:
+
+- device motion
+- gyroscope
+
+### D. headingConfidence
+
+建议值:
+
+- `low`
+- `medium`
+- `high`
+
+来源:
+
+- compass
+- gyroscope
+- GPS 精度
+- movement heading 是否可靠
+
+---
+
+## 5. 原生 Flutter App 可用传感器
+
+原生 Flutter App 的能力边界明显更强,后续如果迁移或并行开发,可直接利用系统原始传感器。
+
+### 5.1 可考虑直接接入
+
+- `Location / GNSS`
+- `Compass / Magnetometer`
+- `Gyroscope`
+- `Accelerometer`
+- `Linear Acceleration`
+- `Gravity`
+- `Rotation Vector`
+- `Step Counter / Pedometer`
+- `Barometer`(如设备支持)
+- `Proximity`(视玩法需求)
+
+说明:
+
+- Flutter 本身一般通过插件获取这些能力
+- 具体以 Android / iOS 可用性差异为准
+
+### 5.2 Flutter 相对小程序的主要优势
+
+- 能直接拿到更完整的原始传感器矩阵
+- 更适合做高质量姿态融合
+- 更适合做步数、步频、跑动状态识别
+- 可更深度控制后台行为和采样频率
+
+---
+
+## 6. Flutter 推荐应用方案
+
+### 6.1 第一优先级
+
+#### A. Rotation Vector
+
+用途:
+
+- 作为地图自动转图的高质量姿态输入
+- 优于单纯磁力计 + 罗盘
+
+推荐产出:
+
+- `deviceHeadingDeg`
+- `devicePose`
+- `headingConfidence`
+
+#### B. Gyroscope
+
+用途:
+
+- 旋转平滑
+- 快速转身检测
+- 姿态短时补偿
+
+#### C. Linear Acceleration
+
+用途:
+
+- 识别运动状态
+- 急停、冲刺、抖动判定
+
+推荐产出:
+
+- `motionIntensity`
+- `movementState`
+
+#### D. Step Counter
+
+用途:
+
+- 实时步数
+- 步频
+- 跑步状态识别
+- 训练/卡路里模型增强
+
+推荐产出:
+
+- `stepCount`
+- `cadenceSpm`
+- `movementState`
+
+### 6.2 第二优先级
+
+#### E. Gravity
+
+用途:
+
+- 持机姿态识别
+- 平放/竖持策略切换
+
+#### F. Magnetometer
+
+用途:
+
+- 作为姿态融合底层输入
+
+建议:
+
+- 不建议单独直接映射到业务逻辑
+- 主要与 rotation vector / gyro 融合
+
+#### G. Barometer
+
+用途:
+
+- 海拔变化
+- 爬升检测
+
+适合:
+
+- 户外定向训练
+- 赛后统计
+
+---
+
+## 7. Flutter 推荐先产出的高级状态
+
+### A. movementState
+
+建议值:
+
+- `idle`
+- `walk`
+- `run`
+- `sprint`
+
+来源:
+
+- GPS
+- step counter
+- linear acceleration
+
+### B. cadenceSpm
+
+用途:
+
+- 训练分析
+- 卡路里估算增强
+- 玩法资源逻辑
+
+### C. devicePose
+
+建议值:
+
+- `upright`
+- `tilted`
+- `flat`
+
+### D. headingSource
+
+建议值:
+
+- `sensor`
+- `blended`
+- `movement`
+
+### E. headingConfidence
+
+建议值:
+
+- `low`
+- `medium`
+- `high`
+
+### F. elevationTrend
+
+建议值:
+
+- `flat`
+- `ascending`
+- `descending`
+
+来源:
+
+- barometer
+- GPS altitude
+
+---
+
+## 8. 两端统一抽象建议
+
+尽管两端可用传感器不同,但建议统一抽象,不让上层感知平台差异。
+
+### 8.1 原始层
+
+放在:
+
+- `engine/sensor`
+
+职责:
+
+- 读取平台原始传感器
+- 做最基础的节流、归一化、权限处理
+
+### 8.2 融合层
+
+放在:
+
+- `telemetry`
+
+职责:
+
+- 生成统一高级状态
+- 对外屏蔽平台差异
+
+建议统一输出:
+
+- `movementState`
+- `devicePose`
+- `headingSource`
+- `headingConfidence`
+- `cadenceSpm`
+- `motionIntensity`
+
+### 8.3 消费层
+
+#### 地图引擎消费
+
+- `headingSource`
+- `devicePose`
+- `headingConfidence`
+
+#### 规则层消费
+
+- `movementState`
+- `cadenceSpm`
+- `motionIntensity`
+
+#### HUD / Feedback 消费
+
+- `movementState`
+- `cadenceSpm`
+- 心率 / 卡路里 / 训练强度
+
+---
+
+## 9. 推荐接入顺序
+
+### 微信小程序第一阶段
+
+先接:
+
+- `Gyroscope`
+- `DeviceMotion`
+
+目标:
+
+- 提升自动转图质量
+- 产出更稳定的姿态与朝向可信度
+
+### 微信小程序第二阶段
+
+再接:
+
+- `Accelerometer`
+
+目标:
+
+- 提升 movement state 识别
+
+### Flutter 第一阶段
+
+先接:
+
+- `Rotation Vector`
+- `Gyroscope`
+- `Linear Acceleration`
+
+目标:
+
+- 直接建立高质量朝向与运动状态底座
+
+### Flutter 第二阶段
+
+再接:
+
+- `Step Counter`
+- `Gravity`
+
+目标:
+
+- 增强运动统计与姿态判断
+
+---
+
+## 10. 当前最值得优先投入的方向
+
+如果只从当前项目收益看,最值得优先做的是:
+
+### 微信小程序
+
+- `Gyroscope`
+- `DeviceMotion`
+
+### Flutter
+
+- `Rotation Vector`
+- `Gyroscope`
+- `Linear Acceleration`
+
+原因:
+
+- 这些能力最直接影响地图体验
+- 最贴近当前自动转图、前进方向、姿态识别需求
+- 复用价值高
+
+---
+
+## 11. 一句话结论
+
+### 微信小程序
+
+可用传感器有限,但足够继续做:
+
+- 更稳的自动转图
+- 更好的朝向平滑
+- 更好的运动状态识别
+
+最值得优先接入的是:
+
+- `Gyroscope`
+- `DeviceMotion`
+- `Accelerometer`
+
+### 原生 Flutter App
+
+可利用的原始传感器更完整,建议未来重点发挥:
+
+- `Rotation Vector`
+- `Gyroscope`
+- `Linear Acceleration`
+- `Step Counter`
+
+两端都应遵守同一个原则:
+
+**原始传感器进 `engine/sensor`,高级状态进 `telemetry`,上层只消费统一状态。**