Refine sensor integration strategy
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
||||||
|
import { AccelerometerController } from '../sensor/accelerometerController'
|
||||||
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
||||||
|
import { DeviceMotionController } from '../sensor/deviceMotionController'
|
||||||
|
import { GyroscopeController } from '../sensor/gyroscopeController'
|
||||||
import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
||||||
import { HeartRateInputController } from '../sensor/heartRateInputController'
|
import { HeartRateInputController } from '../sensor/heartRateInputController'
|
||||||
import { LocationController } from '../sensor/locationController'
|
import { LocationController } from '../sensor/locationController'
|
||||||
@@ -98,6 +101,12 @@ export interface MapEngineViewState {
|
|||||||
orientationMode: OrientationMode
|
orientationMode: OrientationMode
|
||||||
orientationModeText: string
|
orientationModeText: string
|
||||||
sensorHeadingText: string
|
sensorHeadingText: string
|
||||||
|
deviceHeadingText: string
|
||||||
|
devicePoseText: string
|
||||||
|
headingConfidenceText: string
|
||||||
|
accelerometerText: string
|
||||||
|
gyroscopeText: string
|
||||||
|
deviceMotionText: string
|
||||||
compassDeclinationText: string
|
compassDeclinationText: string
|
||||||
northReferenceButtonText: string
|
northReferenceButtonText: string
|
||||||
autoRotateSourceText: string
|
autoRotateSourceText: string
|
||||||
@@ -231,6 +240,12 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'orientationMode',
|
'orientationMode',
|
||||||
'orientationModeText',
|
'orientationModeText',
|
||||||
'sensorHeadingText',
|
'sensorHeadingText',
|
||||||
|
'deviceHeadingText',
|
||||||
|
'devicePoseText',
|
||||||
|
'headingConfidenceText',
|
||||||
|
'accelerometerText',
|
||||||
|
'gyroscopeText',
|
||||||
|
'deviceMotionText',
|
||||||
'compassDeclinationText',
|
'compassDeclinationText',
|
||||||
'northReferenceButtonText',
|
'northReferenceButtonText',
|
||||||
'autoRotateSourceText',
|
'autoRotateSourceText',
|
||||||
@@ -386,6 +401,61 @@ function formatHeadingText(headingDeg: number | null): string {
|
|||||||
return `${Math.round(normalizeRotationDeg(headingDeg))}掳`
|
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 {
|
function formatOrientationModeText(mode: OrientationMode): string {
|
||||||
if (mode === 'north-up') {
|
if (mode === 'north-up') {
|
||||||
return 'North Up'
|
return 'North Up'
|
||||||
@@ -589,12 +659,16 @@ function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
|
|||||||
export class MapEngine {
|
export class MapEngine {
|
||||||
buildVersion: string
|
buildVersion: string
|
||||||
renderer: WebGLMapRenderer
|
renderer: WebGLMapRenderer
|
||||||
|
accelerometerController: AccelerometerController
|
||||||
compassController: CompassHeadingController
|
compassController: CompassHeadingController
|
||||||
|
gyroscopeController: GyroscopeController
|
||||||
|
deviceMotionController: DeviceMotionController
|
||||||
locationController: LocationController
|
locationController: LocationController
|
||||||
heartRateController: HeartRateInputController
|
heartRateController: HeartRateInputController
|
||||||
feedbackDirector: FeedbackDirector
|
feedbackDirector: FeedbackDirector
|
||||||
onData: (patch: Partial<MapEngineViewState>) => void
|
onData: (patch: Partial<MapEngineViewState>) => void
|
||||||
state: MapEngineViewState
|
state: MapEngineViewState
|
||||||
|
accelerometerErrorText: string | null
|
||||||
previewScale: number
|
previewScale: number
|
||||||
previewOriginX: number
|
previewOriginX: number
|
||||||
previewOriginY: number
|
previewOriginY: number
|
||||||
@@ -669,6 +743,7 @@ export class MapEngine {
|
|||||||
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
||||||
this.buildVersion = buildVersion
|
this.buildVersion = buildVersion
|
||||||
this.onData = callbacks.onData
|
this.onData = callbacks.onData
|
||||||
|
this.accelerometerErrorText = null
|
||||||
this.renderer = new WebGLMapRenderer(
|
this.renderer = new WebGLMapRenderer(
|
||||||
(stats) => {
|
(stats) => {
|
||||||
this.applyStats(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({
|
this.compassController = new CompassHeadingController({
|
||||||
onHeading: (headingDeg) => {
|
onHeading: (headingDeg) => {
|
||||||
this.handleCompassHeading(headingDeg)
|
this.handleCompassHeading(headingDeg)
|
||||||
@@ -687,6 +782,43 @@ export class MapEngine {
|
|||||||
this.handleCompassError(message)
|
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({
|
this.locationController = new LocationController({
|
||||||
onLocation: (update) => {
|
onLocation: (update) => {
|
||||||
this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
|
this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
|
||||||
@@ -851,6 +983,12 @@ export class MapEngine {
|
|||||||
orientationMode: 'manual',
|
orientationMode: 'manual',
|
||||||
orientationModeText: formatOrientationModeText('manual'),
|
orientationModeText: formatOrientationModeText('manual'),
|
||||||
sensorHeadingText: '--',
|
sensorHeadingText: '--',
|
||||||
|
deviceHeadingText: '--',
|
||||||
|
devicePoseText: '竖持',
|
||||||
|
headingConfidenceText: '低',
|
||||||
|
accelerometerText: '未启用',
|
||||||
|
gyroscopeText: '--',
|
||||||
|
deviceMotionText: '--',
|
||||||
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
|
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
|
||||||
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
|
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
|
||||||
autoRotateSourceText: formatAutoRotateSourceText('smart', false),
|
autoRotateSourceText: formatAutoRotateSourceText('smart', false),
|
||||||
@@ -1019,6 +1157,9 @@ export class MapEngine {
|
|||||||
{ label: '定位源', value: this.state.locationSourceText || '--' },
|
{ label: '定位源', value: this.state.locationSourceText || '--' },
|
||||||
{ label: '当前位置', value: this.state.gpsCoordText || '--' },
|
{ label: '当前位置', value: this.state.gpsCoordText || '--' },
|
||||||
{ label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` },
|
{ 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.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' },
|
||||||
{ label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
|
{ label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
|
||||||
{ label: '心率源', value: this.state.heartRateSourceText || '--' },
|
{ label: '心率源', value: this.state.heartRateSourceText || '--' },
|
||||||
@@ -1056,7 +1197,10 @@ export class MapEngine {
|
|||||||
this.clearMapPulseTimer()
|
this.clearMapPulseTimer()
|
||||||
this.clearStageFxTimer()
|
this.clearStageFxTimer()
|
||||||
this.clearSessionTimerInterval()
|
this.clearSessionTimerInterval()
|
||||||
|
this.accelerometerController.destroy()
|
||||||
this.compassController.destroy()
|
this.compassController.destroy()
|
||||||
|
this.gyroscopeController.destroy()
|
||||||
|
this.deviceMotionController.destroy()
|
||||||
this.locationController.destroy()
|
this.locationController.destroy()
|
||||||
this.heartRateController.destroy()
|
this.heartRateController.destroy()
|
||||||
this.feedbackDirector.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 {
|
getGameModeText(): string {
|
||||||
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
||||||
}
|
}
|
||||||
@@ -1930,6 +2092,10 @@ export class MapEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
|
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.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
|
||||||
this.mounted = true
|
this.mounted = true
|
||||||
this.state.mapReady = true
|
this.state.mapReady = true
|
||||||
@@ -1940,7 +2106,10 @@ export class MapEngine {
|
|||||||
statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
|
statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
|
||||||
})
|
})
|
||||||
this.syncRenderer()
|
this.syncRenderer()
|
||||||
|
this.accelerometerErrorText = null
|
||||||
this.compassController.start()
|
this.compassController.start()
|
||||||
|
this.gyroscopeController.start()
|
||||||
|
this.deviceMotionController.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
applyRemoteMapConfig(config: RemoteMapConfig): void {
|
applyRemoteMapConfig(config: RemoteMapConfig): void {
|
||||||
@@ -2507,6 +2676,7 @@ export class MapEngine {
|
|||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
||||||
|
...this.getTelemetrySensorViewPatch(),
|
||||||
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||||||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||||||
autoRotateSourceText: this.getAutoRotateSourceText(),
|
autoRotateSourceText: this.getAutoRotateSourceText(),
|
||||||
@@ -2554,6 +2724,7 @@ export class MapEngine {
|
|||||||
rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
|
rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
|
||||||
northReferenceText: formatNorthReferenceText(nextMode),
|
northReferenceText: formatNorthReferenceText(nextMode),
|
||||||
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
||||||
|
...this.getTelemetrySensorViewPatch(),
|
||||||
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
||||||
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
||||||
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
||||||
@@ -2572,6 +2743,7 @@ export class MapEngine {
|
|||||||
this.setState({
|
this.setState({
|
||||||
northReferenceText: formatNorthReferenceText(nextMode),
|
northReferenceText: formatNorthReferenceText(nextMode),
|
||||||
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
||||||
|
...this.getTelemetrySensorViewPatch(),
|
||||||
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
||||||
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
||||||
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
||||||
@@ -2624,10 +2796,14 @@ export class MapEngine {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
getSmartAutoRotateHeadingDeg(): number | null {
|
getPreferredSensorHeadingDeg(): number | null {
|
||||||
const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
|
return this.smoothedSensorHeadingDeg === null
|
||||||
? null
|
? null
|
||||||
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSmartAutoRotateHeadingDeg(): number | null {
|
||||||
|
const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
|
||||||
const movementHeadingDeg = this.getMovementHeadingDeg()
|
const movementHeadingDeg = this.getMovementHeadingDeg()
|
||||||
const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
|
const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
|
||||||
const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
|
const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
|
||||||
@@ -2661,9 +2837,7 @@ export class MapEngine {
|
|||||||
return this.getSmartAutoRotateHeadingDeg()
|
return this.getSmartAutoRotateHeadingDeg()
|
||||||
}
|
}
|
||||||
|
|
||||||
const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
|
const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
|
||||||
? null
|
|
||||||
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
|
||||||
const courseHeadingDeg = this.courseHeadingDeg === null
|
const courseHeadingDeg = this.courseHeadingDeg === null
|
||||||
? null
|
? null
|
||||||
: getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
|
: getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
|
||||||
|
|||||||
124
miniprogram/engine/sensor/accelerometerController.ts
Normal file
124
miniprogram/engine/sensor/accelerometerController.ts
Normal file
@@ -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
miniprogram/engine/sensor/deviceMotionController.ts
Normal file
77
miniprogram/engine/sensor/deviceMotionController.ts
Normal file
@@ -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
miniprogram/engine/sensor/gyroscopeController.ts
Normal file
85
miniprogram/engine/sensor/gyroscopeController.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,7 @@ export type TelemetryEvent =
|
|||||||
| { type: 'session_state_updated'; at: number; status: GameSessionStatus; startedAt: number | null; endedAt: number | null }
|
| { type: 'session_state_updated'; at: number; status: GameSessionStatus; startedAt: number | null; endedAt: number | null }
|
||||||
| { type: 'target_updated'; controlId: string | null; point: LonLatPoint | null }
|
| { type: 'target_updated'; controlId: string | null; point: LonLatPoint | null }
|
||||||
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | 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 }
|
| { type: 'heart_rate_updated'; at: number; bpm: number | null }
|
||||||
|
|||||||
@@ -11,8 +11,46 @@ import {
|
|||||||
import { type GameSessionState } from '../core/gameSessionState'
|
import { type GameSessionState } from '../core/gameSessionState'
|
||||||
import { type TelemetryEvent } from './telemetryEvent'
|
import { type TelemetryEvent } from './telemetryEvent'
|
||||||
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
|
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 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(
|
function getApproxDistanceMeters(
|
||||||
a: { lon: number; lat: number },
|
a: { lon: number; lat: number },
|
||||||
@@ -76,6 +114,99 @@ function smoothSpeedKmh(previousSpeedKmh: number | null, nextSpeedKmh: number):
|
|||||||
return previousSpeedKmh + (nextSpeedKmh - previousSpeedKmh) * SPEED_SMOOTHING_ALPHA
|
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(
|
function getHeartRateTone(
|
||||||
heartRateBpm: number | null,
|
heartRateBpm: number | null,
|
||||||
telemetryConfig: TelemetryConfig,
|
telemetryConfig: TelemetryConfig,
|
||||||
@@ -257,7 +388,17 @@ export class TelemetryRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reset(): void {
|
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 {
|
configure(config?: Partial<TelemetryConfig> | null): void {
|
||||||
@@ -353,6 +494,64 @@ export class TelemetryRuntime {
|
|||||||
return
|
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') {
|
if (event.type === 'heart_rate_updated') {
|
||||||
this.syncCalorieAccumulation(event.at)
|
this.syncCalorieAccumulation(event.at)
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -374,12 +573,20 @@ export class TelemetryRuntime {
|
|||||||
const averageSpeedKmh = elapsedMs > 0
|
const averageSpeedKmh = elapsedMs > 0
|
||||||
? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6
|
? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6
|
||||||
: null
|
: null
|
||||||
|
const devicePose = resolveDevicePose(this.state.devicePose, this.state.accelerometer)
|
||||||
|
const headingConfidence = resolveHeadingConfidence(
|
||||||
|
this.state.deviceHeadingDeg,
|
||||||
|
devicePose,
|
||||||
|
this.state.gyroscope,
|
||||||
|
)
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
distanceToTargetMeters,
|
distanceToTargetMeters,
|
||||||
averageSpeedKmh,
|
averageSpeedKmh,
|
||||||
|
devicePose,
|
||||||
|
headingConfidence,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { type LonLatPoint } from '../../utils/projection'
|
import { type LonLatPoint } from '../../utils/projection'
|
||||||
import { type GameSessionStatus } from '../core/gameSessionState'
|
import { type GameSessionStatus } from '../core/gameSessionState'
|
||||||
|
|
||||||
|
export type DevicePose = 'upright' | 'tilted' | 'flat'
|
||||||
|
export type HeadingConfidence = 'low' | 'medium' | 'high'
|
||||||
|
|
||||||
export interface TelemetryState {
|
export interface TelemetryState {
|
||||||
sessionStatus: GameSessionStatus
|
sessionStatus: GameSessionStatus
|
||||||
sessionStartedAt: number | null
|
sessionStartedAt: number | null
|
||||||
@@ -15,6 +18,14 @@ export interface TelemetryState {
|
|||||||
lastGpsPoint: LonLatPoint | null
|
lastGpsPoint: LonLatPoint | null
|
||||||
lastGpsAt: number | null
|
lastGpsAt: number | null
|
||||||
lastGpsAccuracyMeters: 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
|
heartRateBpm: number | null
|
||||||
caloriesKcal: number | null
|
caloriesKcal: number | null
|
||||||
calorieTrackingAt: number | null
|
calorieTrackingAt: number | null
|
||||||
@@ -34,6 +45,14 @@ export const EMPTY_TELEMETRY_STATE: TelemetryState = {
|
|||||||
lastGpsPoint: null,
|
lastGpsPoint: null,
|
||||||
lastGpsAt: null,
|
lastGpsAt: null,
|
||||||
lastGpsAccuracyMeters: null,
|
lastGpsAccuracyMeters: null,
|
||||||
|
accelerometer: null,
|
||||||
|
accelerometerUpdatedAt: null,
|
||||||
|
accelerometerSampleCount: 0,
|
||||||
|
gyroscope: null,
|
||||||
|
deviceMotion: null,
|
||||||
|
deviceHeadingDeg: null,
|
||||||
|
devicePose: 'upright',
|
||||||
|
headingConfidence: 'low',
|
||||||
heartRateBpm: null,
|
heartRateBpm: null,
|
||||||
caloriesKcal: null,
|
caloriesKcal: null,
|
||||||
calorieTrackingAt: null,
|
calorieTrackingAt: null,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showRightButtonGroups: boolean
|
showRightButtonGroups: boolean
|
||||||
showBottomDebugButton: 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 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 SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
|
||||||
let mapEngine: MapEngine | null = null
|
let mapEngine: MapEngine | null = null
|
||||||
@@ -221,6 +221,12 @@ Page({
|
|||||||
panelAverageSpeedUnitText: 'km/h',
|
panelAverageSpeedUnitText: 'km/h',
|
||||||
panelAccuracyValueText: '--',
|
panelAccuracyValueText: '--',
|
||||||
panelAccuracyUnitText: '',
|
panelAccuracyUnitText: '',
|
||||||
|
deviceHeadingText: '--',
|
||||||
|
devicePoseText: '竖持',
|
||||||
|
headingConfidenceText: '低',
|
||||||
|
accelerometerText: '--',
|
||||||
|
gyroscopeText: '--',
|
||||||
|
deviceMotionText: '--',
|
||||||
punchButtonText: '打点',
|
punchButtonText: '打点',
|
||||||
punchButtonEnabled: false,
|
punchButtonEnabled: false,
|
||||||
skipButtonEnabled: false,
|
skipButtonEnabled: false,
|
||||||
@@ -259,6 +265,11 @@ Page({
|
|||||||
const menuButtonRect = wx.getMenuButtonBoundingClientRect()
|
const menuButtonRect = wx.getMenuButtonBoundingClientRect()
|
||||||
const menuButtonBottom = menuButtonRect && typeof menuButtonRect.bottom === 'number' ? menuButtonRect.bottom : statusBarHeight
|
const menuButtonBottom = menuButtonRect && typeof menuButtonRect.bottom === 'number' ? menuButtonRect.bottom : statusBarHeight
|
||||||
|
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.destroy()
|
||||||
|
mapEngine = null
|
||||||
|
}
|
||||||
|
|
||||||
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
||||||
onData: (patch) => {
|
onData: (patch) => {
|
||||||
const nextPatch = patch as Partial<MapPageData>
|
const nextPatch = patch as Partial<MapPageData>
|
||||||
@@ -349,6 +360,12 @@ Page({
|
|||||||
panelAverageSpeedUnitText: 'km/h',
|
panelAverageSpeedUnitText: 'km/h',
|
||||||
panelAccuracyValueText: '--',
|
panelAccuracyValueText: '--',
|
||||||
panelAccuracyUnitText: '',
|
panelAccuracyUnitText: '',
|
||||||
|
deviceHeadingText: '--',
|
||||||
|
devicePoseText: '竖持',
|
||||||
|
headingConfidenceText: '低',
|
||||||
|
accelerometerText: '--',
|
||||||
|
gyroscopeText: '--',
|
||||||
|
deviceMotionText: '--',
|
||||||
punchButtonText: '打点',
|
punchButtonText: '打点',
|
||||||
punchButtonEnabled: false,
|
punchButtonEnabled: false,
|
||||||
skipButtonEnabled: false,
|
skipButtonEnabled: false,
|
||||||
|
|||||||
@@ -435,6 +435,30 @@
|
|||||||
<text class="info-panel__label">Sensor Heading</text>
|
<text class="info-panel__label">Sensor Heading</text>
|
||||||
<text class="info-panel__value">{{sensorHeadingText}}</text>
|
<text class="info-panel__value">{{sensorHeadingText}}</text>
|
||||||
</view>
|
</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">
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
<text class="info-panel__label">North Ref</text>
|
<text class="info-panel__label">North Ref</text>
|
||||||
<text class="info-panel__value">{{northReferenceText}}</text>
|
<text class="info-panel__value">{{northReferenceText}}</text>
|
||||||
|
|||||||
201
sensor-current-summary.md
Normal file
201
sensor-current-summary.md
Normal file
@@ -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
todo-sensor-integration-plan.md
Normal file
570
todo-sensor-integration-plan.md
Normal file
@@ -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`,上层只消费统一状态。**
|
||||||
Reference in New Issue
Block a user