From f7d4499e36b4692e7e4eab097b8c25b6efc12835 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Wed, 25 Mar 2026 17:42:16 +0800 Subject: [PATCH] Refine sensor integration strategy --- miniprogram/engine/map/mapEngine.ts | 184 +++++- .../engine/sensor/accelerometerController.ts | 124 ++++ .../engine/sensor/deviceMotionController.ts | 77 +++ .../engine/sensor/gyroscopeController.ts | 85 +++ miniprogram/game/telemetry/telemetryEvent.ts | 3 + .../game/telemetry/telemetryRuntime.ts | 211 ++++++- miniprogram/game/telemetry/telemetryState.ts | 19 + miniprogram/pages/map/map.ts | 19 +- miniprogram/pages/map/map.wxml | 24 + sensor-current-summary.md | 201 ++++++ todo-sensor-integration-plan.md | 570 ++++++++++++++++++ 11 files changed, 1509 insertions(+), 8 deletions(-) create mode 100644 miniprogram/engine/sensor/accelerometerController.ts create mode 100644 miniprogram/engine/sensor/deviceMotionController.ts create mode 100644 miniprogram/engine/sensor/gyroscopeController.ts create mode 100644 sensor-current-summary.md create mode 100644 todo-sensor-integration-plan.md diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 8c27064..44cc5e1 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/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 = [ '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) => 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 { + 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) diff --git a/miniprogram/engine/sensor/accelerometerController.ts b/miniprogram/engine/sensor/accelerometerController.ts new file mode 100644 index 0000000..0b32245 --- /dev/null +++ b/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 + } +} diff --git a/miniprogram/engine/sensor/deviceMotionController.ts b/miniprogram/engine/sensor/deviceMotionController.ts new file mode 100644 index 0000000..bfc1021 --- /dev/null +++ b/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 + } +} diff --git a/miniprogram/engine/sensor/gyroscopeController.ts b/miniprogram/engine/sensor/gyroscopeController.ts new file mode 100644 index 0000000..339333f --- /dev/null +++ b/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 + } +} diff --git a/miniprogram/game/telemetry/telemetryEvent.ts b/miniprogram/game/telemetry/telemetryEvent.ts index beafeda..c1eb5ec 100644 --- a/miniprogram/game/telemetry/telemetryEvent.ts +++ b/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 } diff --git a/miniprogram/game/telemetry/telemetryRuntime.ts b/miniprogram/game/telemetry/telemetryRuntime.ts index c24642b..3f78e21 100644 --- a/miniprogram/game/telemetry/telemetryRuntime.ts +++ b/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 | 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, } } diff --git a/miniprogram/game/telemetry/telemetryState.ts b/miniprogram/game/telemetry/telemetryState.ts index afd29a9..17159c3 100644 --- a/miniprogram/game/telemetry/telemetryState.ts +++ b/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, diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 708c37b..549cff3 100644 --- a/miniprogram/pages/map/map.ts +++ b/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 @@ -349,6 +360,12 @@ Page({ panelAverageSpeedUnitText: 'km/h', panelAccuracyValueText: '--', panelAccuracyUnitText: '', + deviceHeadingText: '--', + devicePoseText: '竖持', + headingConfidenceText: '低', + accelerometerText: '--', + gyroscopeText: '--', + deviceMotionText: '--', punchButtonText: '打点', punchButtonEnabled: false, skipButtonEnabled: false, diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 5cad661..d3ccf8b 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -435,6 +435,30 @@ Sensor Heading {{sensorHeadingText}} + + Device Heading + {{deviceHeadingText}} + + + Pose + {{devicePoseText}} + + + Heading Confidence + {{headingConfidenceText}} + + + Accel + {{accelerometerText}} + + + Gyro + {{gyroscopeText}} + + + Motion + {{deviceMotionText}} + North Ref {{northReferenceText}} diff --git a/sensor-current-summary.md b/sensor-current-summary.md new file mode 100644 index 0000000..f616ab7 --- /dev/null +++ b/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` + +其余能力更多承担辅助、调试、反馈和后续扩展输入的角色。 diff --git a/todo-sensor-integration-plan.md b/todo-sensor-integration-plan.md new file mode 100644 index 0000000..1b540b1 --- /dev/null +++ b/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`,上层只消费统一状态。**