Refine sensor integration strategy

This commit is contained in:
2026-03-25 17:42:16 +08:00
parent a19342d61f
commit f7d4499e36
11 changed files with 1509 additions and 8 deletions

View File

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

View 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
}
}

View 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
}
}

View 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
}
}

View File

@@ -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 }

View File

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

View File

@@ -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,

View File

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

View File

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