diff --git a/compass-debugging-notes.md b/compass-debugging-notes.md new file mode 100644 index 0000000..536d676 --- /dev/null +++ b/compass-debugging-notes.md @@ -0,0 +1,212 @@ +# 罗盘问题排查记录 + +## 背景 + +本项目在微信小程序中使用罗盘驱动: + +- 指北针针头 +- 指北针顶部角度数字 +- `heading-up` 自动转图 + +在一次围绕顶部提示窗、传感器显示链和性能优化的修改后,出现了以下问题: + +- iOS 端偶发正常,偶发异常 +- Android 端罗盘长期无样本 +- 指北针不转 +- `heading-up` 自动转图一起失效 + +## 最终结论 + +这次问题的主因不是算法本身,而是: + +**Android 微信环境下,罗盘监听需要被持续保活;之前将多处看似冗余的 `compassController.start()` 清理掉后,Android 的罗盘样本链被破坏了。** + +也就是说: + +- iOS 对罗盘监听更宽容 +- Android 对罗盘监听更脆弱 +- 之前稳定,不是因为链路更“干净”,而是因为老代码里存在一条实际有效的“罗盘保活链” + +## 现象总结 + +### 失效期 + +- Android 调试面板里 `Compass Source` 为 `无数据` +- iOS 仍可能有 `罗盘` 样本 +- 若强行用 `DeviceMotion` 兜底,会出现: + - 指针会转 + - 但方向不准 + - 自动转图方向错误 + +### 恢复后 + +- Android `Compass Source` 恢复为 `罗盘` +- 指北针针头恢复 +- 顶部角度数字恢复 +- `heading-up` 恢复 + +## 误判过的方向 + +以下方向在本次排查中都被考虑过,但最终不是根因或不是主要根因: + +### 1. `DeviceMotion` 兜底方案 + +问题: + +- `DeviceMotion` 可以给出设备姿态角 +- 但不能稳定代替“指向北”的绝对罗盘 +- 用它兜底会导致: + - 能转 + - 但方向明显不准 + +结论: + +**`DeviceMotion` 不能作为正式指北针来源。** + +### 2. 加速度计 / 其他传感器互斥 + +曾排查: + +- `Accelerometer` +- `Gyroscope` +- `DeviceMotion` +- `Compass` + +结论: + +- 加速度计在当前微信 Android 环境下不稳定,已放弃 +- 但这不是这次罗盘彻底失效的主因 + +### 3. 算法问题 + +曾尝试调整: + +- 角度平滑 +- 设备方向单位解释 +- motion fallback 算法 + +结论: + +这些会影响“顺不顺”、“准不准”,但**不能解释 Android 完全无罗盘样本**。 + +## 真正修复的方法 + +将之前被清理掉的多处 `this.compassController.start()` 恢复回去。 + +这些调用点主要分布在: + +- `commitViewport(...)` +- `handleTouchStart(...)` +- `animatePreviewToRest(...)` +- `normalizeTranslate(...)` +- `zoomAroundPoint(...)` +- `handleRecenter(...)` +- `handleRotateStep(...)` +- `handleRotationReset(...)` + +这些调用在代码审美上看起来像“重复启动”,但在 Android 微信环境里,它们实际上承担了: + +**重新拉起 / 保活罗盘监听** + +的作用。 + +## 当前工程判断 + +本项目当前应当采用以下原则: + +### 1. 罗盘主来源只使用 `Compass` + +不要再让: + +- `DeviceMotion` +- 其它姿态角 + +参与正式指北针和自动转图的主方向链。 + +### 2. `DeviceMotion` 只保留为辅助或调试输入 + +可用于: + +- 调试面板显示 +- 设备姿态观察 +- 未来原生端姿态融合参考 + +但不要直接驱动指北针。 + +### 3. Android 端罗盘需要保活 + +后续不要再把这些 `compassController.start()` 当成纯冗余逻辑随意清掉。 + +如果要优化代码,应该: + +- 保留现有行为 +- 将其收口为有明确语义的方法 + +例如: + +- `ensureCompassAlive()` +- `refreshCompassBinding()` + +而不是直接删掉。 + +## 与生命周期相关的硬约束 + +以下约束必须保持: + +### 单实例 + +页面层必须保证任意时刻只有一个 `MapEngine` 活跃实例。 + +### 完整销毁 + +`MapEngine.destroy()` 中必须完整执行: + +- `compassController.destroy()` +- 其它传感器 `destroy()` + +防止旧监听残留。 + +### 调试状态不应影响罗盘主链 + +调试面板开关不应再控制: + +- 罗盘是否启动 +- 罗盘是否停止 + +否则容易再次引入平台差异问题。 + +## 推荐保留的调试字段 + +以下字段建议长期保留,便于后续定位: + +- `Compass Source` +- `sensorHeadingText` +- 顶部角度数字 +- `heading-up` 开关状态 + +其中 `Compass Source` 至少应显示: + +- `罗盘` +- `无数据` + +避免再次将问题误判为算法问题。 + +## 后续优化建议 + +如果后面要继续优化这段代码,推荐方向是: + +### 可做 + +- 将分散的 `compassController.start()` 收口成命名明确的方法 +- 为 Android 罗盘链补一层更可读的“保活机制”注释 +- 保留当前稳定行为前提下做重构 + +### 不建议 + +- 再次移除这些重复 `start()` 调用 +- 用 `DeviceMotion` 正式兜底指北针 +- 让调试开关影响罗盘主链启动 + +## 一句话经验 + +**在微信小程序里,Android 罗盘监听的稳定性比 iOS 更脆;某些看似冗余的 `start()` 调用,实际是平台兼容补丁,不应该在没有真机回归的情况下清理。** diff --git a/miniprogram/assets/btn_locked.png b/miniprogram/assets/btn_locked.png new file mode 100644 index 0000000..6dff728 Binary files /dev/null and b/miniprogram/assets/btn_locked.png differ diff --git a/miniprogram/assets/btn_settings.png b/miniprogram/assets/btn_settings.png new file mode 100644 index 0000000..33903a3 Binary files /dev/null and b/miniprogram/assets/btn_settings.png differ diff --git a/miniprogram/assets/btn_unlock.png b/miniprogram/assets/btn_unlock.png new file mode 100644 index 0000000..aa56d66 Binary files /dev/null and b/miniprogram/assets/btn_unlock.png differ diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 70f4041..f700dca 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -1,6 +1,6 @@ import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera' import { AccelerometerController } from '../sensor/accelerometerController' -import { CompassHeadingController } from '../sensor/compassHeadingController' +import { CompassHeadingController, type CompassTuningProfile } from '../sensor/compassHeadingController' import { DeviceMotionController } from '../sensor/deviceMotionController' import { GyroscopeController } from '../sensor/gyroscopeController' import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController' @@ -11,6 +11,7 @@ import { type MapRendererStats } from '../renderer/mapRenderer' import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig' +import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel' import { GameRuntime } from '../../game/core/gameRuntime' import { type GameEffect, type GameResult } from '../../game/core/gameResult' import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition' @@ -56,8 +57,27 @@ const AUTO_ROTATE_SNAP_DEG = 0.1 const AUTO_ROTATE_DEADZONE_DEG = 4 const AUTO_ROTATE_MAX_STEP_DEG = 0.75 const AUTO_ROTATE_HEADING_SMOOTHING = 0.46 -const COMPASS_NEEDLE_MIN_SMOOTHING = 0.24 -const COMPASS_NEEDLE_MAX_SMOOTHING = 0.56 +const COMPASS_TUNING_PRESETS: Record = { + smooth: { + needleMinSmoothing: 0.16, + needleMaxSmoothing: 0.4, + displayDeadzoneDeg: 0.75, + }, + balanced: { + needleMinSmoothing: 0.22, + needleMaxSmoothing: 0.52, + displayDeadzoneDeg: 0.45, + }, + responsive: { + needleMinSmoothing: 0.3, + needleMaxSmoothing: 0.68, + displayDeadzoneDeg: 0.2, + }, +} const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2 const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0 const SMART_HEADING_MIN_DISTANCE_METERS = 12 @@ -88,6 +108,7 @@ export interface MapEngineStageRect { } export interface MapEngineViewState { + animationLevel: AnimationLevel buildVersion: string renderMode: string projectionMode: string @@ -110,7 +131,11 @@ export interface MapEngineViewState { accelerometerText: string gyroscopeText: string deviceMotionText: string + compassSourceText: string + compassTuningProfile: CompassTuningProfile + compassTuningProfileText: string compassDeclinationText: string + northReferenceMode: NorthReferenceMode northReferenceButtonText: string autoRotateSourceText: string autoRotateCalibrationText: string @@ -199,6 +224,8 @@ export interface MapEngineViewState { contentCardTitle: string contentCardBody: string punchButtonFxClass: string + panelProgressFxClass: string + panelDistanceFxClass: string punchFeedbackFxClass: string contentCardFxClass: string mapPulseVisible: boolean @@ -228,6 +255,7 @@ export interface MapEngineGameInfoSnapshot { } const VIEW_SYNC_KEYS: Array = [ + 'animationLevel', 'buildVersion', 'renderMode', 'projectionMode', @@ -252,7 +280,11 @@ const VIEW_SYNC_KEYS: Array = [ 'accelerometerText', 'gyroscopeText', 'deviceMotionText', + 'compassSourceText', + 'compassTuningProfile', + 'compassTuningProfileText', 'compassDeclinationText', + 'northReferenceMode', 'northReferenceButtonText', 'autoRotateSourceText', 'autoRotateCalibrationText', @@ -330,6 +362,8 @@ const VIEW_SYNC_KEYS: Array = [ 'contentCardTitle', 'contentCardBody', 'punchButtonFxClass', + 'panelProgressFxClass', + 'panelDistanceFxClass', 'punchFeedbackFxClass', 'contentCardFxClass', 'mapPulseVisible', @@ -342,6 +376,38 @@ const VIEW_SYNC_KEYS: Array = [ 'osmReferenceText', ] +const INTERACTION_DEFERRED_VIEW_KEYS = new Set([ + 'rotationText', + 'sensorHeadingText', + 'deviceHeadingText', + 'devicePoseText', + 'headingConfidenceText', + 'accelerometerText', + 'gyroscopeText', + 'deviceMotionText', + 'compassSourceText', + 'compassTuningProfile', + 'compassTuningProfileText', + 'compassDeclinationText', + 'autoRotateSourceText', + 'autoRotateCalibrationText', + 'northReferenceText', + 'centerText', + 'gpsCoordText', + 'visibleTileCount', + 'readyTileCount', + 'memoryTileCount', + 'diskTileCount', + 'memoryHitCount', + 'diskHitCount', + 'networkFetchCount', + 'cacheHitRateText', + 'heartRateDiscoveredDevices', + 'mockCoordText', + 'mockSpeedText', + 'mockHeartRateText', +]) + function buildCenterText(zoom: number, x: number, y: number): string { return `z${zoom} / x${x} / y${y}` } @@ -387,18 +453,23 @@ function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: numb return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor) } -function getCompassNeedleSmoothingFactor(currentDeg: number, targetDeg: number): number { +function getCompassNeedleSmoothingFactor( + currentDeg: number, + targetDeg: number, + profile: CompassTuningProfile, +): number { + const preset = COMPASS_TUNING_PRESETS[profile] const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg)) if (deltaDeg <= 4) { - return COMPASS_NEEDLE_MIN_SMOOTHING + return preset.needleMinSmoothing } if (deltaDeg >= 36) { - return COMPASS_NEEDLE_MAX_SMOOTHING + return preset.needleMaxSmoothing } const progress = (deltaDeg - 4) / (36 - 4) - return COMPASS_NEEDLE_MIN_SMOOTHING - + (COMPASS_NEEDLE_MAX_SMOOTHING - COMPASS_NEEDLE_MIN_SMOOTHING) * progress + return preset.needleMinSmoothing + + (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress } function getMovementHeadingSmoothingFactor(speedKmh: number | null): number { @@ -434,7 +505,7 @@ function formatRotationText(rotationDeg: number): string { } function normalizeDegreeDisplayText(text: string): string { - return text.replace(/[°掳•]/g, '˚') + return text.replace(/[掳•˚]/g, '°') } function formatHeadingText(headingDeg: number | null): string { @@ -442,7 +513,7 @@ function formatHeadingText(headingDeg: number | null): string { return '--' } - return `${Math.round(normalizeRotationDeg(headingDeg))}˚` + return `${Math.round(normalizeRotationDeg(headingDeg))}°` } function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string { @@ -494,9 +565,9 @@ function formatDeviceMotionText(motion: { alpha: number | null; beta: number | n return '--' } - const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha * 180 / Math.PI)) - const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta * 180 / Math.PI) - const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma * 180 / Math.PI) + const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha)) + const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta) + const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma) return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}` } @@ -620,6 +691,26 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string { return '' } +function formatCompassSourceText(source: 'compass' | 'motion' | null): string { + if (source === 'compass') { + return '罗盘' + } + if (source === 'motion') { + return '设备方向兜底' + } + return '无数据' +} + +function formatCompassTuningProfileText(profile: CompassTuningProfile): string { + if (profile === 'smooth') { + return '顺滑' + } + if (profile === 'responsive') { + return '跟手' + } + return '平衡' +} + function formatNorthReferenceButtonText(mode: NorthReferenceMode): string { return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北' } @@ -702,6 +793,7 @@ function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number { export class MapEngine { buildVersion: string + animationLevel: AnimationLevel renderer: WebGLMapRenderer accelerometerController: AccelerometerController compassController: CompassHeadingController @@ -742,6 +834,8 @@ export class MapEngine { sensorHeadingDeg: number | null smoothedSensorHeadingDeg: number | null compassDisplayHeadingDeg: number | null + compassSource: 'compass' | 'motion' | null + compassTuningProfile: CompassTuningProfile smoothedMovementHeadingDeg: number | null autoRotateHeadingDeg: number | null courseHeadingDeg: number | null @@ -789,6 +883,8 @@ export class MapEngine { constructor(buildVersion: string, callbacks: MapEngineCallbacks) { this.buildVersion = buildVersion + this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync()) + this.compassTuningProfile = 'balanced' this.onData = callbacks.onData this.accelerometerErrorText = null this.renderer = new WebGLMapRenderer( @@ -812,7 +908,7 @@ export class MapEngine { z, }) if (this.diagnosticUiEnabled) { - this.setState(this.getTelemetrySensorViewPatch(), true) + this.setState(this.getTelemetrySensorViewPatch()) } }, onError: (message) => { @@ -821,7 +917,7 @@ export class MapEngine { this.setState({ ...this.getTelemetrySensorViewPatch(), statusText: `加速度计启动失败 (${this.buildVersion})`, - }, true) + }) } }, }) @@ -833,6 +929,7 @@ export class MapEngine { this.handleCompassError(message) }, }) + this.compassController.setTuningProfile(this.compassTuningProfile) this.gyroscopeController = new GyroscopeController({ onSample: (x, y, z) => { this.telemetryRuntime.dispatch({ @@ -843,12 +940,12 @@ export class MapEngine { z, }) if (this.diagnosticUiEnabled) { - this.setState(this.getTelemetrySensorViewPatch(), true) + this.setState(this.getTelemetrySensorViewPatch()) } }, onError: () => { if (this.diagnosticUiEnabled) { - this.setState(this.getTelemetrySensorViewPatch(), true) + this.setState(this.getTelemetrySensorViewPatch()) } }, }) @@ -865,16 +962,12 @@ export class MapEngine { this.setState({ ...this.getTelemetrySensorViewPatch(), autoRotateSourceText: this.getAutoRotateSourceText(), - }, true) - } - - if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { - this.scheduleAutoRotate() + }) } }, onError: () => { if (this.diagnosticUiEnabled) { - this.setState(this.getTelemetrySensorViewPatch(), true) + this.setState(this.getTelemetrySensorViewPatch()) } }, }) @@ -899,7 +992,7 @@ export class MapEngine { }, onDebugStateChange: () => { if (this.diagnosticUiEnabled) { - this.setState(this.getLocationControllerViewPatch(), true) + this.setState(this.getLocationControllerViewPatch()) } }, }) @@ -963,12 +1056,12 @@ export class MapEngine { heartRateDiscoveredDevices: this.formatHeartRateDevices(devices), heartRateScanText: this.getHeartRateScanText(), ...this.getHeartRateControllerViewPatch(), - }, true) + }) } }, onDebugStateChange: () => { if (this.diagnosticUiEnabled) { - this.setState(this.getHeartRateControllerViewPatch(), true) + this.setState(this.getHeartRateControllerViewPatch()) } }, }) @@ -982,6 +1075,12 @@ export class MapEngine { setPunchButtonFxClass: (className) => { this.setPunchButtonFxClass(className) }, + setHudProgressFxClass: (className) => { + this.setHudProgressFxClass(className) + }, + setHudDistanceFxClass: (className) => { + this.setHudDistanceFxClass(className) + }, showMapPulse: (controlId, motionClass) => { this.showMapPulse(controlId, motionClass) }, @@ -994,6 +1093,7 @@ export class MapEngine { } }, }) + this.feedbackDirector.setAnimationLevel(this.animationLevel) this.minZoom = MIN_ZOOM this.maxZoom = MAX_ZOOM this.defaultZoom = DEFAULT_ZOOM @@ -1032,6 +1132,7 @@ export class MapEngine { this.sessionTimerInterval = 0 this.hasGpsCenteredOnce = false this.state = { + animationLevel: this.animationLevel, buildVersion: this.buildVersion, renderMode: RENDER_MODE, projectionMode: PROJECTION_MODE, @@ -1051,10 +1152,14 @@ export class MapEngine { deviceHeadingText: '--', devicePoseText: '竖持', headingConfidenceText: '低', - accelerometerText: '未启用', + accelerometerText: '未启用', gyroscopeText: '--', deviceMotionText: '--', + compassSourceText: '无数据', + compassTuningProfile: this.compassTuningProfile, + compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile), compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE), + northReferenceMode: DEFAULT_NORTH_REFERENCE_MODE, northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE), autoRotateSourceText: formatAutoRotateSourceText('smart', false), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)), @@ -1137,6 +1242,8 @@ export class MapEngine { contentCardTitle: '', contentCardBody: '', punchButtonFxClass: '', + panelProgressFxClass: '', + panelDistanceFxClass: '', punchFeedbackFxClass: '', contentCardFxClass: '', mapPulseVisible: false, @@ -1177,6 +1284,8 @@ export class MapEngine { this.sensorHeadingDeg = null this.smoothedSensorHeadingDeg = null this.compassDisplayHeadingDeg = null + this.compassSource = null + this.compassTuningProfile = 'balanced' this.smoothedMovementHeadingDeg = null this.autoRotateHeadingDeg = null this.courseHeadingDeg = null @@ -1241,6 +1350,7 @@ export class MapEngine { { label: '配置版本', value: this.configVersion || '--' }, { label: 'Schema版本', value: this.configSchemaVersion || '--' }, { label: '活动ID', value: this.configAppId || '--' }, + { label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) }, { label: '地图', value: this.state.mapName || '--' }, { label: '模式', value: this.getGameModeText() }, { label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) }, @@ -1417,20 +1527,23 @@ export class MapEngine { getTelemetrySensorViewPatch(): Partial { const telemetryState = this.telemetryRuntime.state - return { - deviceHeadingText: formatHeadingText( - telemetryState.deviceHeadingDeg === null - ? null - : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg), - ), - devicePoseText: formatDevicePoseText(telemetryState.devicePose), - headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence), - accelerometerText: telemetryState.accelerometer - ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}` - : '未启用', - gyroscopeText: formatGyroscopeText(telemetryState.gyroscope), - deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion), - } + return { + deviceHeadingText: formatHeadingText( + telemetryState.deviceHeadingDeg === null + ? null + : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg), + ), + devicePoseText: formatDevicePoseText(telemetryState.devicePose), + headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence), + accelerometerText: telemetryState.accelerometer + ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}` + : '未启用', + gyroscopeText: formatGyroscopeText(telemetryState.gyroscope), + deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion), + compassSourceText: formatCompassSourceText(this.compassSource), + compassTuningProfile: this.compassTuningProfile, + compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile), + } } getGameModeText(): string { @@ -1589,6 +1702,8 @@ export class MapEngine { stageFxVisible: false, stageFxClass: '', punchButtonFxClass: '', + panelProgressFxClass: '', + panelDistanceFxClass: '', }, true) } @@ -1675,6 +1790,18 @@ export class MapEngine { }, true) } + setHudProgressFxClass(className: string): void { + this.setState({ + panelProgressFxClass: className, + }, true) + } + + setHudDistanceFxClass(className: string): void { + this.setState({ + panelDistanceFxClass: className, + }, true) + } + showMapPulse(controlId: string, motionClass = ''): void { const screenPoint = this.getControlScreenPoint(controlId) if (!screenPoint) { @@ -1761,6 +1888,9 @@ export class MapEngine { applyGameEffects(effects: GameEffect[]): string | null { this.feedbackDirector.handleEffects(effects) if (effects.some((effect) => effect.type === 'session_finished')) { + if (this.locationController.listening) { + this.locationController.stop() + } this.setState({ gpsTracking: false, gpsTrackingText: '测试结束,定位已停止', @@ -1845,12 +1975,17 @@ export class MapEngine { handleForceExitGame(): void { this.feedbackDirector.reset() + if (this.locationController.listening) { + this.locationController.stop() + } if (!this.courseData) { this.clearGameRuntime() this.resetTransientGameUiState() this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.setState({ + gpsTracking: false, + gpsTrackingText: '已退出对局,定位已停止', ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), }, true) this.syncRenderer() @@ -1861,6 +1996,8 @@ export class MapEngine { this.resetTransientGameUiState() this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.setState({ + gpsTracking: false, + gpsTrackingText: '已退出对局,定位已停止', ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), }, true) this.syncRenderer() @@ -1946,7 +2083,7 @@ export class MapEngine { gpsLockEnabled: this.gpsLockEnabled, gpsLockAvailable: gpsInsideMap, ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)), - }, true) + }) this.syncRenderer() } @@ -2100,7 +2237,7 @@ export class MapEngine { this.setState({ heartRateDeviceText: this.heartRateController.currentDeviceName || '--', heartRateScanText: this.getHeartRateScanText(), - }, true) + }) } handleDebugHeartRateTone(tone: HeartRateTone): void { @@ -2112,7 +2249,7 @@ export class MapEngine { }) this.setState({ heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`, - }, true) + }) this.syncSessionTimerText() } @@ -2128,7 +2265,7 @@ export class MapEngine { : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'), heartRateScanText: this.getHeartRateScanText(), ...this.getHeartRateControllerViewPatch(), - }, true) + }) this.syncSessionTimerText() } @@ -2250,7 +2387,7 @@ export class MapEngine { configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`, projectionMode: config.projectionModeText, tileSource: config.tileSource, - sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)), + sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode), @@ -2308,7 +2445,7 @@ export class MapEngine { this.pinchAnchorWorldY = anchorWorld.y this.setPreviewState(this.pinchStartScale, origin.x, origin.y) this.syncRenderer() - this.compassController.start() + this.compassController.start() return } @@ -2567,7 +2704,7 @@ export class MapEngine { () => { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() this.scheduleAutoRotate() }, ) @@ -2601,7 +2738,7 @@ export class MapEngine { () => { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() }, ) } @@ -2638,7 +2775,7 @@ export class MapEngine { () => { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() }, ) } @@ -2673,6 +2810,38 @@ export class MapEngine { this.cycleNorthReferenceMode() } + handleSetNorthReferenceMode(mode: NorthReferenceMode): void { + this.setNorthReferenceMode(mode) + } + + handleSetAnimationLevel(level: AnimationLevel): void { + if (this.animationLevel === level) { + return + } + + this.animationLevel = level + this.feedbackDirector.setAnimationLevel(level) + this.setState({ + animationLevel: level, + statusText: `动画性能已切换为${formatAnimationLevelText(level)} (${this.buildVersion})`, + }) + this.syncRenderer() + } + + handleSetCompassTuningProfile(profile: CompassTuningProfile): void { + if (this.compassTuningProfile === profile) { + return + } + + this.compassTuningProfile = profile + this.compassController.setTuningProfile(profile) + this.setState({ + compassTuningProfile: profile, + compassTuningProfileText: formatCompassTuningProfileText(profile), + statusText: `指北针响应已切换为${formatCompassTuningProfileText(profile)} (${this.buildVersion})`, + }, true) + } + handleAutoRotateCalibrate(): void { if (this.state.orientationMode !== 'heading-up') { this.setState({ @@ -2761,30 +2930,40 @@ export class MapEngine { } } - handleCompassHeading(headingDeg: number): void { + applyHeadingSample(headingDeg: number, source: 'compass' | 'motion'): void { + this.compassSource = source this.sensorHeadingDeg = normalizeRotationDeg(headingDeg) this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null ? this.sensorHeadingDeg : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING) const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg) - this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null - ? compassHeadingDeg - : interpolateAngleDeg( - this.compassDisplayHeadingDeg, - compassHeadingDeg, - getCompassNeedleSmoothingFactor(this.compassDisplayHeadingDeg, compassHeadingDeg), - ) + if (this.compassDisplayHeadingDeg === null) { + this.compassDisplayHeadingDeg = compassHeadingDeg + } else { + const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg)) + if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) { + this.compassDisplayHeadingDeg = interpolateAngleDeg( + this.compassDisplayHeadingDeg, + compassHeadingDeg, + getCompassNeedleSmoothingFactor( + this.compassDisplayHeadingDeg, + compassHeadingDeg, + this.compassTuningProfile, + ), + ) + } + } this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg() this.setState({ compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg), + sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), + compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), ...(this.diagnosticUiEnabled ? { - sensorHeadingText: formatHeadingText(compassHeadingDeg), ...this.getTelemetrySensorViewPatch(), - compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), autoRotateSourceText: this.getAutoRotateSourceText(), northReferenceText: formatNorthReferenceText(this.northReferenceMode), @@ -2801,18 +2980,31 @@ export class MapEngine { } } + handleCompassHeading(headingDeg: number): void { + this.applyHeadingSample(headingDeg, 'compass') + } + handleCompassError(message: string): void { this.clearAutoRotateTimer() this.targetAutoRotationDeg = null this.autoRotateCalibrationPending = false + this.compassSource = null this.setState({ + compassSourceText: formatCompassSourceText(null), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), statusText: `${message} (${this.buildVersion})`, }, true) } cycleNorthReferenceMode(): void { - const nextMode = getNextNorthReferenceMode(this.northReferenceMode) + this.setNorthReferenceMode(getNextNorthReferenceMode(this.northReferenceMode)) + } + + setNorthReferenceMode(nextMode: NorthReferenceMode): void { + if (nextMode === this.northReferenceMode) { + return + } + const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode) const compassHeadingDeg = this.smoothedSensorHeadingDeg === null ? null @@ -2831,9 +3023,10 @@ export class MapEngine { rotationDeg: MAP_NORTH_OFFSET_DEG, rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG), northReferenceText: formatNorthReferenceText(nextMode), - sensorHeadingText: formatHeadingText(compassHeadingDeg), + sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), ...this.getTelemetrySensorViewPatch(), compassDeclinationText: formatCompassDeclinationText(nextMode), + northReferenceMode: nextMode, northReferenceButtonText: formatNorthReferenceButtonText(nextMode), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), @@ -2850,9 +3043,10 @@ export class MapEngine { this.setState({ northReferenceText: formatNorthReferenceText(nextMode), - sensorHeadingText: formatHeadingText(compassHeadingDeg), + sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), ...this.getTelemetrySensorViewPatch(), compassDeclinationText: formatCompassDeclinationText(nextMode), + northReferenceMode: nextMode, northReferenceButtonText: formatNorthReferenceButtonText(nextMode), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), @@ -3167,6 +3361,7 @@ export class MapEngine { buildScene() { const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) + const readyControlSequences = this.resolveReadyControlSequences() return { tileSource: this.state.tileSource, osmTileSource: OSM_TILE_SOURCE, @@ -3183,6 +3378,7 @@ export class MapEngine { translateX: this.state.tileTranslateX, translateY: this.state.tileTranslateY, rotationRad: this.getRotationRad(this.state.rotationDeg), + animationLevel: this.state.animationLevel, previewScale: this.previewScale || 1, previewOriginX: this.previewOriginX || this.state.stageWidth / 2, previewOriginY: this.previewOriginY || this.state.stageHeight / 2, @@ -3199,6 +3395,7 @@ export class MapEngine { focusedControlId: this.gamePresentation.map.focusedControlId, focusedControlSequences: this.gamePresentation.map.focusedControlSequences, activeControlSequences: this.gamePresentation.map.activeControlSequences, + readyControlSequences, activeStart: this.gamePresentation.map.activeStart, completedStart: this.gamePresentation.map.completedStart, activeFinish: this.gamePresentation.map.activeFinish, @@ -3215,6 +3412,21 @@ export class MapEngine { } } + resolveReadyControlSequences(): number[] { + const punchableControlId = this.gamePresentation.hud.punchableControlId + const definition = this.gameRuntime.definition + if (!punchableControlId || !definition) { + return [] + } + + const control = definition.controls.find((item) => item.id === punchableControlId) + if (!control || control.sequence === null) { + return [] + } + + return [control.sequence] + } + syncRenderer(): void { if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) { return @@ -3374,8 +3586,32 @@ export class MapEngine { } const patch = this.pendingViewPatch - this.pendingViewPatch = {} - this.onData(patch) + const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer + const nextPendingPatch = {} as Partial + const outputPatch = {} as Partial + + for (const [key, value] of Object.entries(patch) as Array<[keyof MapEngineViewState, MapEngineViewState[keyof MapEngineViewState]]>) { + if (shouldDeferForInteraction && INTERACTION_DEFERRED_VIEW_KEYS.has(key)) { + ;(nextPendingPatch as Record)[key] = value + continue + } + ;(outputPatch as Record)[key] = value + } + + this.pendingViewPatch = nextPendingPatch + + if (Object.keys(this.pendingViewPatch).length && !this.viewSyncTimer) { + this.viewSyncTimer = setTimeout(() => { + this.viewSyncTimer = 0 + this.flushViewPatch() + }, UI_SYNC_INTERVAL_MS) as unknown as number + } + + if (!Object.keys(outputPatch).length) { + return + } + + this.onData(outputPatch) } getTouchDistance(touches: TouchPoint[]): number { @@ -3431,7 +3667,7 @@ export class MapEngine { if (Math.abs(startScale - 1) < 0.01) { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() this.scheduleAutoRotate() return } @@ -3443,12 +3679,12 @@ export class MapEngine { const nextScale = startScale + (1 - startScale) * eased this.setPreviewState(nextScale, originX, originY) this.syncRenderer() - this.compassController.start() + this.compassController.start() if (progress >= 1) { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() this.previewResetTimer = 0 this.scheduleAutoRotate() return @@ -3467,7 +3703,7 @@ export class MapEngine { tileTranslateY: translateY, }) this.syncRenderer() - this.compassController.start() + this.compassController.start() return } @@ -3530,7 +3766,7 @@ export class MapEngine { () => { this.setPreviewState(residualScale, stageX, stageY) this.syncRenderer() - this.compassController.start() + this.compassController.start() this.animatePreviewToRest() }, ) @@ -3557,7 +3793,7 @@ export class MapEngine { () => { this.setPreviewState(residualScale, stageX, stageY) this.syncRenderer() - this.compassController.start() + this.compassController.start() this.animatePreviewToRest() }, ) diff --git a/miniprogram/engine/renderer/courseLabelRenderer.ts b/miniprogram/engine/renderer/courseLabelRenderer.ts index e883949..14635e2 100644 --- a/miniprogram/engine/renderer/courseLabelRenderer.ts +++ b/miniprogram/engine/renderer/courseLabelRenderer.ts @@ -9,11 +9,14 @@ const SCORE_LABEL_FONT_SIZE_RATIO = 0.7 const SCORE_LABEL_OFFSET_Y_RATIO = 0.06 const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)' const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)' +const READY_LABEL_COLOR = 'rgba(98, 255, 214, 0.98)' const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)' const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)' const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)' +const SKIPPED_LABEL_COLOR = 'rgba(152, 156, 162, 0.88)' const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)' const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)' +const SCORE_SKIPPED_LABEL_COLOR = 'rgba(176, 182, 188, 0.9)' export class CourseLabelRenderer { courseLayer: CourseLayer @@ -107,6 +110,10 @@ export class CourseLabelRenderer { return FOCUSED_LABEL_COLOR } + if (scene.readyControlSequences.includes(sequence)) { + return READY_LABEL_COLOR + } + if (scene.activeControlSequences.includes(sequence)) { return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_LABEL_COLOR : ACTIVE_LABEL_COLOR } @@ -116,7 +123,7 @@ export class CourseLabelRenderer { } if (scene.skippedControlSequences.includes(sequence)) { - return COMPLETED_LABEL_COLOR + return SKIPPED_LABEL_COLOR } return DEFAULT_LABEL_COLOR @@ -127,12 +134,16 @@ export class CourseLabelRenderer { return FOCUSED_LABEL_COLOR } + if (scene.readyControlSequences.includes(sequence)) { + return READY_LABEL_COLOR + } + if (scene.completedControlSequences.includes(sequence)) { return SCORE_COMPLETED_LABEL_COLOR } if (scene.skippedControlSequences.includes(sequence)) { - return SCORE_COMPLETED_LABEL_COLOR + return SCORE_SKIPPED_LABEL_COLOR } return SCORE_LABEL_COLOR diff --git a/miniprogram/engine/renderer/mapRenderer.ts b/miniprogram/engine/renderer/mapRenderer.ts index 3bb4a89..1eaad93 100644 --- a/miniprogram/engine/renderer/mapRenderer.ts +++ b/miniprogram/engine/renderer/mapRenderer.ts @@ -3,6 +3,7 @@ import { type TileStoreStats } from '../tile/tileStore' import { type LonLatPoint, type MapCalibration } from '../../utils/projection' import { type TileZoomBounds } from '../../utils/remoteMapConfig' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' +import { type AnimationLevel } from '../../utils/animationLevel' export interface MapScene { tileSource: string @@ -20,6 +21,7 @@ export interface MapScene { translateX: number translateY: number rotationRad: number + animationLevel: AnimationLevel previewScale: number previewOriginX: number previewOriginY: number @@ -36,6 +38,7 @@ export interface MapScene { focusedControlId: string | null focusedControlSequences: number[] activeControlSequences: number[] + readyControlSequences: number[] activeStart: boolean completedStart: boolean activeFinish: boolean diff --git a/miniprogram/engine/renderer/webglMapRenderer.ts b/miniprogram/engine/renderer/webglMapRenderer.ts index 51d28c0..d37b67f 100644 --- a/miniprogram/engine/renderer/webglMapRenderer.ts +++ b/miniprogram/engine/renderer/webglMapRenderer.ts @@ -135,12 +135,16 @@ export class WebGLMapRenderer implements MapRenderer { this.scheduleRender() } - this.animationTimer = setTimeout(tick, ANIMATION_FRAME_MS) as unknown as number + this.animationTimer = setTimeout(tick, this.getAnimationFrameMs()) as unknown as number } tick() } + getAnimationFrameMs(): number { + return this.scene && this.scene.animationLevel === 'lite' ? 48 : ANIMATION_FRAME_MS + } + scheduleRender(): void { if (this.renderTimer || !this.scene || this.destroyed) { return diff --git a/miniprogram/engine/renderer/webglVectorRenderer.ts b/miniprogram/engine/renderer/webglVectorRenderer.ts index b3ceabb..c5bf361 100644 --- a/miniprogram/engine/renderer/webglVectorRenderer.ts +++ b/miniprogram/engine/renderer/webglVectorRenderer.ts @@ -7,11 +7,17 @@ import { GpsLayer } from '../layer/gpsLayer' const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96] const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82] +const SKIPPED_ROUTE_COLOR: [number, number, number, number] = [0.38, 0.4, 0.44, 0.72] const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1] +const READY_CONTROL_COLOR: [number, number, number, number] = [0.38, 1, 0.92, 1] const MULTI_ACTIVE_CONTROL_COLOR: [number, number, number, number] = [1, 0.8, 0.2, 0.98] const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1] const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86] const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88] +const READY_PULSE_COLOR: [number, number, number, number] = [0.44, 1, 0.92, 0.98] +const COMPLETED_SETTLE_COLOR: [number, number, number, number] = [0.86, 0.9, 0.94, 0.24] +const SKIPPED_SETTLE_COLOR: [number, number, number, number] = [0.72, 0.76, 0.82, 0.18] +const SKIPPED_SLASH_COLOR: [number, number, number, number] = [0.78, 0.82, 0.88, 0.9] const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5] const EARTH_CIRCUMFERENCE_METERS = 40075016.686 const CONTROL_RING_WIDTH_RATIO = 0.2 @@ -196,6 +202,18 @@ export class WebGLVectorRenderer { gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2) } + isLite(scene: MapScene): boolean { + return scene.animationLevel === 'lite' + } + + getRingSegments(scene: MapScene): number { + return this.isLite(scene) ? 24 : 36 + } + + getCircleSegments(scene: MapScene): number { + return this.isLite(scene) ? 14 : 20 + } + getPixelsPerMeter(scene: MapScene): number { const camera: CameraState = { centerWorldX: scene.exactCenterWorldX, @@ -249,6 +267,18 @@ export class WebGLVectorRenderer { if (scene.activeStart) { this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame) } + if (scene.completedStart) { + this.pushRing( + positions, + colors, + start.point.x, + start.point.y, + this.getMetric(scene, controlRadiusMeters * 1.16), + this.getMetric(scene, controlRadiusMeters * 1.02), + COMPLETED_SETTLE_COLOR, + scene, + ) + } this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene) } if (!scene.revealFullCourse) { @@ -261,10 +291,29 @@ export class WebGLVectorRenderer { this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame) } else { this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR) - this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52]) + if (!this.isLite(scene)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52]) + } } } + if (scene.readyControlSequences.includes(control.sequence)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, READY_PULSE_COLOR) + if (!this.isLite(scene)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.22, scene, pulseFrame + 11, [0.92, 1, 1, 0.42]) + } + this.pushRing( + positions, + colors, + control.point.x, + control.point.y, + this.getMetric(scene, controlRadiusMeters * 1.16), + this.getMetric(scene, controlRadiusMeters * 1.02), + READY_CONTROL_COLOR, + scene, + ) + } + this.pushRing( positions, colors, @@ -278,7 +327,9 @@ export class WebGLVectorRenderer { if (scene.focusedControlSequences.includes(control.sequence)) { this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.02, scene, pulseFrame, FOCUSED_PULSE_COLOR) - this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5]) + if (!this.isLite(scene)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5]) + } this.pushRing( positions, colors, @@ -290,6 +341,33 @@ export class WebGLVectorRenderer { scene, ) } + + if (scene.completedControlSequences.includes(control.sequence)) { + this.pushRing( + positions, + colors, + control.point.x, + control.point.y, + this.getMetric(scene, controlRadiusMeters * 1.14), + this.getMetric(scene, controlRadiusMeters * 1.02), + COMPLETED_SETTLE_COLOR, + scene, + ) + } + + if (this.isSkippedControl(scene, control.sequence)) { + this.pushRing( + positions, + colors, + control.point.x, + control.point.y, + this.getMetric(scene, controlRadiusMeters * 1.1), + this.getMetric(scene, controlRadiusMeters * 1.01), + SKIPPED_SETTLE_COLOR, + scene, + ) + this.pushSkippedControlSlash(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene) + } } for (const finish of course.finishes) { @@ -298,10 +376,24 @@ export class WebGLVectorRenderer { } if (scene.focusedFinish) { this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, FOCUSED_PULSE_COLOR) - this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46]) + if (!this.isLite(scene)) { + this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46]) + } } const finishColor = this.getFinishColor(scene) + if (scene.completedFinish) { + this.pushRing( + positions, + colors, + finish.point.x, + finish.point.y, + this.getMetric(scene, controlRadiusMeters * 1.18), + this.getMetric(scene, controlRadiusMeters * 1.02), + COMPLETED_SETTLE_COLOR, + scene, + ) + } this.pushRing( positions, colors, @@ -418,6 +510,27 @@ export class WebGLVectorRenderer { ) } + pushSkippedControlSlash( + positions: number[], + colors: number[], + centerX: number, + centerY: number, + controlRadiusMeters: number, + scene: MapScene, + ): void { + const slashRadius = this.getMetric(scene, controlRadiusMeters * 0.72) + const slashWidth = this.getMetric(scene, controlRadiusMeters * 0.08) + this.pushSegment( + positions, + colors, + { x: centerX - slashRadius, y: centerY + slashRadius }, + { x: centerX + slashRadius, y: centerY - slashRadius }, + slashWidth, + SKIPPED_SLASH_COLOR, + scene, + ) + } + pushActiveStartPulse( positions: number[], colors: number[], @@ -462,14 +575,22 @@ export class WebGLVectorRenderer { } getControlColor(scene: MapScene, sequence: number): RgbaColor { + if (scene.readyControlSequences.includes(sequence)) { + return READY_CONTROL_COLOR + } + if (scene.activeControlSequences.includes(sequence)) { return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR } - if (scene.completedControlSequences.includes(sequence) || this.isSkippedControl(scene, sequence)) { + if (scene.completedControlSequences.includes(sequence)) { return COMPLETED_ROUTE_COLOR } + if (this.isSkippedControl(scene, sequence)) { + return SKIPPED_ROUTE_COLOR + } + return COURSE_COLOR } @@ -633,7 +754,7 @@ export class WebGLVectorRenderer { color: RgbaColor, scene: MapScene, ): void { - const segments = 36 + const segments = this.getRingSegments(scene) for (let index = 0; index < segments; index += 1) { const startAngle = index / segments * Math.PI * 2 const endAngle = (index + 1) / segments * Math.PI * 2 @@ -682,7 +803,7 @@ export class WebGLVectorRenderer { color: RgbaColor, scene: MapScene, ): void { - const segments = 20 + const segments = this.getCircleSegments(scene) const center = this.toClip(centerX, centerY, scene) for (let index = 0; index < segments; index += 1) { const startAngle = index / segments * Math.PI * 2 diff --git a/miniprogram/engine/sensor/compassHeadingController.ts b/miniprogram/engine/sensor/compassHeadingController.ts index 0eb0bb5..30d8c27 100644 --- a/miniprogram/engine/sensor/compassHeadingController.ts +++ b/miniprogram/engine/sensor/compassHeadingController.ts @@ -5,7 +5,13 @@ export interface CompassHeadingControllerCallbacks { type SensorSource = 'compass' | 'motion' | null -const ABSOLUTE_HEADING_CORRECTION = 0.44 +export type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive' + +const HEADING_CORRECTION_BY_PROFILE: Record = { + smooth: 0.3, + balanced: 0.4, + responsive: 0.54, +} function normalizeHeadingDeg(headingDeg: number): number { const normalized = headingDeg % 360 @@ -41,6 +47,7 @@ export class CompassHeadingController { rollDeg: number | null motionReady: boolean compassReady: boolean + tuningProfile: CompassTuningProfile constructor(callbacks: CompassHeadingControllerCallbacks) { this.callbacks = callbacks @@ -53,6 +60,7 @@ export class CompassHeadingController { this.rollDeg = null this.motionReady = false this.compassReady = false + this.tuningProfile = 'balanced' } start(): void { @@ -99,6 +107,10 @@ export class CompassHeadingController { this.stop() } + setTuningProfile(profile: CompassTuningProfile): void { + this.tuningProfile = profile + } + startMotionSource(previousMessage: string): void { if (typeof wx.startDeviceMotionListening !== 'function' || typeof wx.onDeviceMotionChange !== 'function') { this.callbacks.onError(previousMessage) @@ -111,14 +123,13 @@ export class CompassHeadingController { } this.pitchDeg = typeof result.beta === 'number' && !Number.isNaN(result.beta) - ? result.beta * 180 / Math.PI + ? result.beta : null this.rollDeg = typeof result.gamma === 'number' && !Number.isNaN(result.gamma) - ? result.gamma * 180 / Math.PI + ? result.gamma : null - const alphaDeg = result.alpha * 180 / Math.PI - this.applyAbsoluteHeading(normalizeHeadingDeg(360 - alphaDeg), 'motion') + this.applyAbsoluteHeading(normalizeHeadingDeg(360 - result.alpha), 'motion') } this.motionCallback = callback @@ -163,10 +174,11 @@ export class CompassHeadingController { } applyAbsoluteHeading(headingDeg: number, source: 'compass' | 'motion'): void { + const headingCorrection = HEADING_CORRECTION_BY_PROFILE[this.tuningProfile] if (this.absoluteHeadingDeg === null) { this.absoluteHeadingDeg = headingDeg } else { - this.absoluteHeadingDeg = interpolateHeadingDeg(this.absoluteHeadingDeg, headingDeg, ABSOLUTE_HEADING_CORRECTION) + this.absoluteHeadingDeg = interpolateHeadingDeg(this.absoluteHeadingDeg, headingDeg, headingCorrection) } this.source = source @@ -200,5 +212,3 @@ export class CompassHeadingController { this.compassCallback = null } } - - diff --git a/miniprogram/game/feedback/feedbackConfig.ts b/miniprogram/game/feedback/feedbackConfig.ts index 1372970..4228425 100644 --- a/miniprogram/game/feedback/feedbackConfig.ts +++ b/miniprogram/game/feedback/feedbackConfig.ts @@ -1,3 +1,5 @@ +import { type AnimationLevel } from '../../utils/animationLevel' + export type FeedbackCueKey = | 'session_started' | 'session_finished' @@ -14,7 +16,9 @@ export type UiPunchFeedbackMotion = 'none' | 'pop' | 'success' | 'warning' export type UiContentCardMotion = 'none' | 'pop' | 'finish' export type UiPunchButtonMotion = 'none' | 'ready' | 'warning' export type UiMapPulseMotion = 'none' | 'ready' | 'control' | 'finish' -export type UiStageMotion = 'none' | 'finish' +export type UiStageMotion = 'none' | 'control' | 'finish' +export type UiHudProgressMotion = 'none' | 'success' | 'finish' +export type UiHudDistanceMotion = 'none' | 'success' export interface HapticCueConfig { enabled: boolean @@ -28,6 +32,8 @@ export interface UiCueConfig { punchButtonMotion: UiPunchButtonMotion mapPulseMotion: UiMapPulseMotion stageMotion: UiStageMotion + hudProgressMotion: UiHudProgressMotion + hudDistanceMotion: UiHudDistanceMotion durationMs: number } @@ -41,6 +47,10 @@ export interface GameUiEffectsConfig { cues: Record } +export interface ResolvedGameUiEffectsConfig extends GameUiEffectsConfig { + animationLevel: AnimationLevel +} + export interface PartialHapticCueConfig { enabled?: boolean pattern?: HapticPattern @@ -53,6 +63,8 @@ export interface PartialUiCueConfig { punchButtonMotion?: UiPunchButtonMotion mapPulseMotion?: UiMapPulseMotion stageMotion?: UiStageMotion + hudProgressMotion?: UiHudProgressMotion + hudDistanceMotion?: UiHudDistanceMotion durationMs?: number } @@ -84,15 +96,15 @@ export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = { export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = { enabled: true, cues: { - session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 }, - session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 }, - 'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 }, - 'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 }, - 'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', durationMs: 0 }, - 'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 560 }, - 'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 }, - 'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 }, - 'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', durationMs: 900 }, + session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + 'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 }, + 'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 }, + 'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', hudProgressMotion: 'finish', hudDistanceMotion: 'success', durationMs: 680 }, + 'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 560 }, + 'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + 'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + 'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 900 }, }, } @@ -115,6 +127,8 @@ function mergeUiCue(baseCue: UiCueConfig, override?: PartialUiCueConfig): UiCueC punchButtonMotion: override && override.punchButtonMotion ? override.punchButtonMotion : baseCue.punchButtonMotion, mapPulseMotion: override && override.mapPulseMotion ? override.mapPulseMotion : baseCue.mapPulseMotion, stageMotion: override && override.stageMotion ? override.stageMotion : baseCue.stageMotion, + hudProgressMotion: override && override.hudProgressMotion ? override.hudProgressMotion : baseCue.hudProgressMotion, + hudDistanceMotion: override && override.hudDistanceMotion ? override.hudDistanceMotion : baseCue.hudDistanceMotion, durationMs: clampDuration(Number(override && override.durationMs), baseCue.durationMs), } } diff --git a/miniprogram/game/feedback/feedbackDirector.ts b/miniprogram/game/feedback/feedbackDirector.ts index c3d4b31..e741f4d 100644 --- a/miniprogram/game/feedback/feedbackDirector.ts +++ b/miniprogram/game/feedback/feedbackDirector.ts @@ -1,6 +1,7 @@ import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig' import { SoundDirector } from '../audio/soundDirector' import { type GameEffect } from '../core/gameResult' +import { type AnimationLevel } from '../../utils/animationLevel' import { DEFAULT_GAME_HAPTICS_CONFIG, DEFAULT_GAME_UI_EFFECTS_CONFIG, @@ -41,6 +42,9 @@ export class FeedbackDirector { reset(): void { this.soundDirector.resetContexts() + this.uiEffectDirector.clearPunchButtonMotion() + this.uiEffectDirector.clearHudProgressMotion() + this.uiEffectDirector.clearHudDistanceMotion() } destroy(): void { @@ -49,6 +53,10 @@ export class FeedbackDirector { this.uiEffectDirector.destroy() } + setAnimationLevel(level: AnimationLevel): void { + this.uiEffectDirector.setAnimationLevel(level) + } + setAppAudioMode(mode: 'foreground' | 'background'): void { this.soundDirector.setAppAudioMode(mode) } diff --git a/miniprogram/game/feedback/uiEffectDirector.ts b/miniprogram/game/feedback/uiEffectDirector.ts index 00f5e19..3b45535 100644 --- a/miniprogram/game/feedback/uiEffectDirector.ts +++ b/miniprogram/game/feedback/uiEffectDirector.ts @@ -1,12 +1,16 @@ import { type GameEffect } from '../core/gameResult' +import { type AnimationLevel } from '../../utils/animationLevel' import { DEFAULT_GAME_UI_EFFECTS_CONFIG, type FeedbackCueKey, type GameUiEffectsConfig, type UiContentCardMotion, + type UiHudDistanceMotion, + type UiHudProgressMotion, type UiMapPulseMotion, type UiPunchButtonMotion, type UiPunchFeedbackMotion, + type UiCueConfig, type UiStageMotion, } from './feedbackConfig' @@ -14,6 +18,8 @@ export interface UiEffectHost { showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void showContentCard: (title: string, body: string, motionClass?: string) => void setPunchButtonFxClass: (className: string) => void + setHudProgressFxClass: (className: string) => void + setHudDistanceFxClass: (className: string) => void showMapPulse: (controlId: string, motionClass?: string) => void showStageFx: (className: string) => void } @@ -23,30 +29,46 @@ export class UiEffectDirector { config: GameUiEffectsConfig host: UiEffectHost punchButtonMotionTimer: number + hudProgressMotionTimer: number + hudDistanceMotionTimer: number punchButtonMotionToggle: boolean + animationLevel: AnimationLevel constructor(host: UiEffectHost, config: GameUiEffectsConfig = DEFAULT_GAME_UI_EFFECTS_CONFIG) { this.enabled = true this.host = host this.config = config this.punchButtonMotionTimer = 0 + this.hudProgressMotionTimer = 0 + this.hudDistanceMotionTimer = 0 this.punchButtonMotionToggle = false + this.animationLevel = 'standard' } configure(config: GameUiEffectsConfig): void { this.config = config this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } setEnabled(enabled: boolean): void { this.enabled = enabled if (!enabled) { this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } } + setAnimationLevel(level: AnimationLevel): void { + this.animationLevel = level + } + destroy(): void { this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } clearPunchButtonMotion(): void { @@ -57,6 +79,22 @@ export class UiEffectDirector { this.host.setPunchButtonFxClass('') } + clearHudProgressMotion(): void { + if (this.hudProgressMotionTimer) { + clearTimeout(this.hudProgressMotionTimer) + this.hudProgressMotionTimer = 0 + } + this.host.setHudProgressFxClass('') + } + + clearHudDistanceMotion(): void { + if (this.hudDistanceMotionTimer) { + clearTimeout(this.hudDistanceMotionTimer) + this.hudDistanceMotionTimer = 0 + } + this.host.setHudDistanceFxClass('') + } + getPunchFeedbackMotionClass(motion: UiPunchFeedbackMotion): string { if (motion === 'warning') { return 'game-punch-feedback--fx-warning' @@ -94,12 +132,32 @@ export class UiEffectDirector { } getStageMotionClass(motion: UiStageMotion): string { + if (motion === 'control') { + return 'map-stage__stage-fx--control' + } if (motion === 'finish') { return 'map-stage__stage-fx--finish' } return '' } + getHudProgressMotionClass(motion: UiHudProgressMotion): string { + if (motion === 'finish') { + return 'race-panel__progress--fx-finish' + } + if (motion === 'success') { + return 'race-panel__progress--fx-success' + } + return '' + } + + getHudDistanceMotionClass(motion: UiHudDistanceMotion): string { + if (motion === 'success') { + return 'race-panel__metric-group--fx-distance-success' + } + return '' + } + triggerPunchButtonMotion(motion: UiPunchButtonMotion, durationMs: number): void { if (motion === 'none') { return @@ -121,7 +179,37 @@ export class UiEffectDirector { }, durationMs) as unknown as number } - getCue(key: FeedbackCueKey) { + triggerHudProgressMotion(motion: UiHudProgressMotion, durationMs: number): void { + const className = this.getHudProgressMotionClass(motion) + if (!className) { + return + } + this.host.setHudProgressFxClass(className) + if (this.hudProgressMotionTimer) { + clearTimeout(this.hudProgressMotionTimer) + } + this.hudProgressMotionTimer = setTimeout(() => { + this.hudProgressMotionTimer = 0 + this.host.setHudProgressFxClass('') + }, durationMs) as unknown as number + } + + triggerHudDistanceMotion(motion: UiHudDistanceMotion, durationMs: number): void { + const className = this.getHudDistanceMotionClass(motion) + if (!className) { + return + } + this.host.setHudDistanceFxClass(className) + if (this.hudDistanceMotionTimer) { + clearTimeout(this.hudDistanceMotionTimer) + } + this.hudDistanceMotionTimer = setTimeout(() => { + this.hudDistanceMotionTimer = 0 + this.host.setHudDistanceFxClass('') + }, durationMs) as unknown as number + } + + getCue(key: FeedbackCueKey): UiCueConfig | null { if (!this.enabled || !this.config.enabled) { return null } @@ -131,7 +219,16 @@ export class UiEffectDirector { return null } - return cue + if (this.animationLevel === 'standard') { + return cue + } + + return { + ...cue, + stageMotion: 'none' as const, + hudDistanceMotion: 'none' as const, + durationMs: cue.durationMs > 0 ? Math.max(260, Math.round(cue.durationMs * 0.6)) : 0, + } } handleEffects(effects: GameEffect[]): void { @@ -172,6 +269,10 @@ export class UiEffectDirector { if (cue && cue.stageMotion !== 'none') { this.host.showStageFx(this.getStageMotionClass(cue.stageMotion)) } + if (cue) { + this.triggerHudProgressMotion(cue.hudProgressMotion, cue.durationMs) + this.triggerHudDistanceMotion(cue.hudDistanceMotion, cue.durationMs) + } continue } @@ -188,10 +289,14 @@ export class UiEffectDirector { if (effect.type === 'session_finished') { this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } if (effect.type === 'session_cancelled') { this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } } } diff --git a/miniprogram/game/telemetry/telemetryRuntime.ts b/miniprogram/game/telemetry/telemetryRuntime.ts index 3f78e21..be712ca 100644 --- a/miniprogram/game/telemetry/telemetryRuntime.ts +++ b/miniprogram/game/telemetry/telemetryRuntime.ts @@ -52,6 +52,44 @@ function interpolateHeadingDeg(currentDeg: number, targetDeg: number, factor: nu return normalizeHeadingDeg(currentDeg + normalizeHeadingDeltaDeg(targetDeg - currentDeg) * factor) } +function resolveMotionCompassHeadingDeg( + alpha: number | null, + beta: number | null, + gamma: number | null, +): number | null { + if (alpha === null) { + return null + } + + if (beta === null || gamma === null) { + return normalizeHeadingDeg(360 - alpha) + } + + const alphaRad = alpha * Math.PI / 180 + const betaRad = beta * Math.PI / 180 + const gammaRad = gamma * Math.PI / 180 + + const cA = Math.cos(alphaRad) + const sA = Math.sin(alphaRad) + const sB = Math.sin(betaRad) + const cG = Math.cos(gammaRad) + const sG = Math.sin(gammaRad) + + const headingX = -cA * sG - sA * sB * cG + const headingY = -sA * sG + cA * sB * cG + + if (Math.abs(headingX) < 1e-6 && Math.abs(headingY) < 1e-6) { + return normalizeHeadingDeg(360 - alpha) + } + + let headingRad = Math.atan2(headingX, headingY) + if (headingRad < 0) { + headingRad += Math.PI * 2 + } + + return normalizeHeadingDeg(headingRad * 180 / Math.PI) +} + function getApproxDistanceMeters( a: { lon: number; lat: number }, b: { lon: number; lat: number }, @@ -530,13 +568,13 @@ export class TelemetryRuntime { } if (event.type === 'device_motion_updated') { - const nextDeviceHeadingDeg = event.alpha === null + const motionHeadingDeg = resolveMotionCompassHeadingDeg(event.alpha, event.beta, event.gamma) + const nextDeviceHeadingDeg = motionHeadingDeg === null ? this.state.deviceHeadingDeg : (() => { - const nextHeadingDeg = normalizeHeadingDeg(360 - event.alpha * 180 / Math.PI) return this.state.deviceHeadingDeg === null - ? nextHeadingDeg - : interpolateHeadingDeg(this.state.deviceHeadingDeg, nextHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA) + ? motionHeadingDeg + : interpolateHeadingDeg(this.state.deviceHeadingDeg, motionHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA) })() this.state = { diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 8d86656..52a3a00 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -6,6 +6,7 @@ import { type MapEngineViewState, } from '../../engine/map/mapEngine' import { loadRemoteMapConfig } from '../../utils/remoteMapConfig' +import { type AnimationLevel } from '../../utils/animationLevel' type CompassTickData = { angle: number long: boolean @@ -31,9 +32,17 @@ type ScaleRulerMajorMarkData = { type SideButtonMode = 'all' | 'left' | 'right' | 'hidden' type SideActionButtonState = 'muted' | 'default' | 'active' type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center' +type UserNorthReferenceMode = 'magnetic' | 'true' +type StoredUserSettings = { + animationLevel?: AnimationLevel + northReferenceMode?: UserNorthReferenceMode + showCenterScaleRuler?: boolean + centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode +} type MapPageData = MapEngineViewState & { showDebugPanel: boolean showGameInfoPanel: boolean + showSystemSettingsPanel: boolean showCenterScaleRuler: boolean showPunchHintBanner: boolean centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode @@ -52,6 +61,10 @@ type MapPageData = MapEngineViewState & { panelDistanceValueText: string panelProgressText: string panelSpeedValueText: string + panelTimerFxClass: string + panelMileageFxClass: string + panelSpeedFxClass: string + panelHeartRateFxClass: string compassTicks: CompassTickData[] compassLabels: CompassLabelData[] sideButtonMode: SideButtonMode @@ -59,6 +72,7 @@ type MapPageData = MapEngineViewState & { sideButton2Class: string sideButton4Class: string sideButton11Class: string + sideButton12Class: string sideButton13Class: string sideButton14Class: string sideButton16Class: string @@ -75,7 +89,8 @@ type MapPageData = MapEngineViewState & { showRightButtonGroups: boolean showBottomDebugButton: boolean } -const INTERNAL_BUILD_VERSION = 'map-build-261' +const INTERNAL_BUILD_VERSION = 'map-build-282' +const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1' const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json' const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json' const PUNCH_HINT_AUTO_HIDE_MS = 30000 @@ -83,7 +98,43 @@ let mapEngine: MapEngine | null = null let stageCanvasAttached = false let gameInfoPanelSyncTimer = 0 let centerScaleRulerSyncTimer = 0 +let centerScaleRulerUpdateTimer = 0 let punchHintDismissTimer = 0 +let panelTimerFxTimer = 0 +let panelMileageFxTimer = 0 +let panelSpeedFxTimer = 0 +let panelHeartRateFxTimer = 0 +let lastCenterScaleRulerStablePatch: Pick< + MapPageData, + | 'centerScaleRulerVisible' + | 'centerScaleRulerCenterXPx' + | 'centerScaleRulerZeroYPx' + | 'centerScaleRulerHeightPx' + | 'centerScaleRulerAxisBottomPx' + | 'centerScaleRulerZeroVisible' + | 'centerScaleRulerZeroLabel' + | 'centerScaleRulerMinorTicks' + | 'centerScaleRulerMajorMarks' +> = { + centerScaleRulerVisible: false, + centerScaleRulerCenterXPx: 0, + centerScaleRulerZeroYPx: 0, + centerScaleRulerHeightPx: 0, + centerScaleRulerAxisBottomPx: 0, + centerScaleRulerZeroVisible: false, + centerScaleRulerZeroLabel: '0 m', + centerScaleRulerMinorTicks: [], + centerScaleRulerMajorMarks: [], +} +let centerScaleRulerInputCache: Partial> = {} const DEBUG_ONLY_VIEW_KEYS = new Set([ 'buildVersion', @@ -93,14 +144,15 @@ const DEBUG_ONLY_VIEW_KEYS = new Set([ 'mapReadyText', 'mapName', 'configStatusText', - 'sensorHeadingText', 'deviceHeadingText', 'devicePoseText', 'headingConfidenceText', 'accelerometerText', 'gyroscopeText', 'deviceMotionText', - 'compassDeclinationText', + 'compassSourceText', + 'compassTuningProfile', + 'compassTuningProfileText', 'northReferenceButtonText', 'autoRotateSourceText', 'autoRotateCalibrationText', @@ -148,6 +200,15 @@ const CENTER_SCALE_RULER_DEP_KEYS = new Set([ 'previewScale', ]) +const CENTER_SCALE_RULER_CACHE_KEYS: Array = [ + 'stageWidth', + 'stageHeight', + 'zoom', + 'centerTileY', + 'tileSizePx', + 'previewScale', +] + const RULER_ONLY_VIEW_KEYS = new Set([ 'zoom', 'centerTileX', @@ -213,12 +274,83 @@ function clearCenterScaleRulerSyncTimer() { } } +function clearCenterScaleRulerUpdateTimer() { + if (centerScaleRulerUpdateTimer) { + clearTimeout(centerScaleRulerUpdateTimer) + centerScaleRulerUpdateTimer = 0 + } +} + function clearPunchHintDismissTimer() { if (punchHintDismissTimer) { clearTimeout(punchHintDismissTimer) punchHintDismissTimer = 0 } } + +function clearHudFxTimer(key: 'timer' | 'mileage' | 'speed' | 'heartRate') { + const timerMap = { + timer: panelTimerFxTimer, + mileage: panelMileageFxTimer, + speed: panelSpeedFxTimer, + heartRate: panelHeartRateFxTimer, + } + const timer = timerMap[key] + if (timer) { + clearTimeout(timer) + } + if (key === 'timer') { + panelTimerFxTimer = 0 + } else if (key === 'mileage') { + panelMileageFxTimer = 0 + } else if (key === 'speed') { + panelSpeedFxTimer = 0 + } else { + panelHeartRateFxTimer = 0 + } +} + +function updateCenterScaleRulerInputCache(patch: Partial) { + for (const key of CENTER_SCALE_RULER_CACHE_KEYS) { + if (Object.prototype.hasOwnProperty.call(patch, key)) { + ;(centerScaleRulerInputCache as Record)[key] = + (patch as Record)[key] + } + } +} + +function loadStoredUserSettings(): StoredUserSettings { + try { + const stored = wx.getStorageSync(USER_SETTINGS_STORAGE_KEY) + if (!stored || typeof stored !== 'object') { + return {} + } + + const normalized = stored as Record + const settings: StoredUserSettings = {} + if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') { + settings.animationLevel = normalized.animationLevel + } + if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') { + settings.northReferenceMode = normalized.northReferenceMode + } + if (typeof normalized.showCenterScaleRuler === 'boolean') { + settings.showCenterScaleRuler = normalized.showCenterScaleRuler + } + if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') { + settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode + } + return settings + } catch { + return {} + } +} + +function persistStoredUserSettings(settings: StoredUserSettings) { + try { + wx.setStorageSync(USER_SETTINGS_STORAGE_KEY, settings) + } catch {} +} function buildSideButtonVisibility(mode: SideButtonMode) { return { sideButtonMode: mode, @@ -296,7 +428,7 @@ function getSideActionButtonClass(state: SideActionButtonState): string { return 'map-side-button map-side-button--default' } -function buildSideButtonState(data: Pick) { +function buildSideButtonState(data: Pick) { const sideButton2State: SideActionButtonState = !data.gpsLockAvailable ? 'muted' : data.gpsLockEnabled @@ -304,6 +436,7 @@ function buildSideButtonState(data: Pick) { if (!data.showCenterScaleRuler) { - return { + lastCenterScaleRulerStablePatch = { centerScaleRulerVisible: false, centerScaleRulerCenterXPx: 0, centerScaleRulerZeroYPx: 0, @@ -378,20 +512,11 @@ function buildCenterScaleRulerPatch(data: Pick, CENTER_SCALE_RULER_DEP_KEYS) ) { + clearCenterScaleRulerUpdateTimer() Object.assign(derivedPatch, buildCenterScaleRulerPatch(mergedData)) } @@ -685,6 +818,57 @@ Page({ } } + const nextAnimationLevel = typeof nextPatch.animationLevel === 'string' + ? nextPatch.animationLevel + : this.data.animationLevel + + if (nextAnimationLevel === 'lite') { + clearHudFxTimer('timer') + clearHudFxTimer('mileage') + clearHudFxTimer('speed') + clearHudFxTimer('heartRate') + nextData.panelTimerFxClass = '' + nextData.panelMileageFxClass = '' + nextData.panelSpeedFxClass = '' + nextData.panelHeartRateFxClass = '' + } else { + if (typeof nextPatch.panelTimerText === 'string' && nextPatch.panelTimerText !== this.data.panelTimerText && this.data.panelTimerText !== '00:00:00') { + clearHudFxTimer('timer') + nextData.panelTimerFxClass = 'race-panel__timer--fx-tick' + panelTimerFxTimer = setTimeout(() => { + panelTimerFxTimer = 0 + this.setData({ panelTimerFxClass: '' }) + }, 320) as unknown as number + } + + if (typeof nextPatch.panelMileageText === 'string' && nextPatch.panelMileageText !== this.data.panelMileageText && this.data.panelMileageText !== '0m') { + clearHudFxTimer('mileage') + nextData.panelMileageFxClass = 'race-panel__mileage-wrap--fx-update' + panelMileageFxTimer = setTimeout(() => { + panelMileageFxTimer = 0 + this.setData({ panelMileageFxClass: '' }) + }, 360) as unknown as number + } + + if (typeof nextPatch.panelSpeedValueText === 'string' && nextPatch.panelSpeedValueText !== this.data.panelSpeedValueText && this.data.panelSpeedValueText !== '0') { + clearHudFxTimer('speed') + nextData.panelSpeedFxClass = 'race-panel__metric-group--fx-speed-update' + panelSpeedFxTimer = setTimeout(() => { + panelSpeedFxTimer = 0 + this.setData({ panelSpeedFxClass: '' }) + }, 360) as unknown as number + } + + if (typeof nextPatch.panelHeartRateValueText === 'string' && nextPatch.panelHeartRateValueText !== this.data.panelHeartRateValueText && this.data.panelHeartRateValueText !== '--') { + clearHudFxTimer('heartRate') + nextData.panelHeartRateFxClass = 'race-panel__metric-group--fx-heart-rate-update' + panelHeartRateFxTimer = setTimeout(() => { + panelHeartRateFxTimer = 0 + this.setData({ panelHeartRateFxClass: '' }) + }, 400) as unknown as number + } + } + if (Object.keys(nextData).length || Object.keys(derivedPatch).length) { this.setData({ ...nextData, @@ -698,22 +882,46 @@ Page({ }, }) + const storedUserSettings = loadStoredUserSettings() + if (storedUserSettings.animationLevel) { + mapEngine.handleSetAnimationLevel(storedUserSettings.animationLevel) + } + if (storedUserSettings.northReferenceMode) { + mapEngine.handleSetNorthReferenceMode(storedUserSettings.northReferenceMode) + } + mapEngine.setDiagnosticUiEnabled(false) + centerScaleRulerInputCache = { + stageWidth: 0, + stageHeight: 0, + zoom: 0, + centerTileY: 0, + tileSizePx: 0, + previewScale: 1, + } + + const initialShowCenterScaleRuler = !!storedUserSettings.showCenterScaleRuler + const initialCenterScaleRulerAnchorMode = storedUserSettings.centerScaleRulerAnchorMode || 'screen-center' this.setData({ ...mapEngine.getInitialData(), showDebugPanel: false, showGameInfoPanel: false, + showSystemSettingsPanel: false, + showCenterScaleRuler: initialShowCenterScaleRuler, statusBarHeight, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), hudPanelIndex: 0, configSourceText: '顺序赛配置', + centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, gameInfoTitle: '当前游戏', gameInfoSubtitle: '未开始', gameInfoLocalRows: [], gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, panelTimerText: '00:00:00', + panelTimerFxClass: '', panelMileageText: '0m', + panelMileageFxClass: '', panelActionTagText: '目标', panelDistanceTagText: '点距', panelDistanceValueText: '--', @@ -740,6 +948,7 @@ Page({ mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockHeartRateText: '--', panelSpeedValueText: '0', + panelSpeedFxClass: '', panelTelemetryTone: 'blue', panelHeartRateZoneNameText: '--', panelHeartRateZoneRangeText: '', @@ -747,6 +956,7 @@ Page({ heartRateStatusText: '心率带未连接', heartRateDeviceText: '--', panelHeartRateValueText: '--', + panelHeartRateFxClass: '', panelHeartRateUnitText: '', panelCaloriesValueText: '0', panelCaloriesUnitText: 'kcal', @@ -760,6 +970,9 @@ Page({ accelerometerText: '--', gyroscopeText: '--', deviceMotionText: '--', + compassSourceText: '无数据', + compassTuningProfile: 'balanced', + compassTuningProfileText: '平衡', punchButtonText: '打点', punchButtonEnabled: false, skipButtonEnabled: false, @@ -771,6 +984,8 @@ Page({ contentCardTitle: '', contentCardBody: '', punchButtonFxClass: '', + panelProgressFxClass: '', + panelDistanceFxClass: '', punchFeedbackFxClass: '', contentCardFxClass: '', mapPulseVisible: false, @@ -785,8 +1000,9 @@ Page({ ...buildSideButtonState({ sideButtonMode: 'left', showGameInfoPanel: false, - showCenterScaleRuler: false, - centerScaleRulerAnchorMode: 'screen-center', + showSystemSettingsPanel: false, + showCenterScaleRuler: initialShowCenterScaleRuler, + centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, skipButtonEnabled: false, gameSessionStatus: 'idle', gpsLockEnabled: false, @@ -794,8 +1010,8 @@ Page({ }), ...buildCenterScaleRulerPatch({ ...(mapEngine.getInitialData() as MapPageData), - showCenterScaleRuler: false, - centerScaleRulerAnchorMode: 'screen-center', + showCenterScaleRuler: initialShowCenterScaleRuler, + centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, stageWidth: 0, stageHeight: 0, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), @@ -827,7 +1043,12 @@ Page({ onUnload() { clearGameInfoPanelSyncTimer() clearCenterScaleRulerSyncTimer() + clearCenterScaleRulerUpdateTimer() clearPunchHintDismissTimer() + clearHudFxTimer('timer') + clearHudFxTimer('mileage') + clearHudFxTimer('speed') + clearHudFxTimer('heartRate') if (mapEngine) { mapEngine.destroy() mapEngine = null @@ -997,6 +1218,24 @@ Page({ } }, + handleSetCompassTuningSmooth() { + if (mapEngine) { + mapEngine.handleSetCompassTuningProfile('smooth') + } + }, + + handleSetCompassTuningBalanced() { + if (mapEngine) { + mapEngine.handleSetCompassTuningProfile('balanced') + } + }, + + handleSetCompassTuningResponsive() { + if (mapEngine) { + mapEngine.handleSetCompassTuningProfile('responsive') + } + }, + handleAutoRotateCalibrate() { if (mapEngine) { mapEngine.handleAutoRotateCalibrate() @@ -1260,10 +1499,12 @@ Page({ this.syncGameInfoPanelSnapshot() this.setData({ showDebugPanel: false, + showSystemSettingsPanel: false, showGameInfoPanel: true, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: true, + showSystemSettingsPanel: false, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1281,6 +1522,7 @@ Page({ ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: false, + showSystemSettingsPanel: this.data.showSystemSettingsPanel, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1293,6 +1535,89 @@ Page({ handleGameInfoPanelTap() {}, + handleOpenSystemSettingsPanel() { + clearGameInfoPanelSyncTimer() + this.setData({ + showDebugPanel: false, + showGameInfoPanel: false, + showSystemSettingsPanel: true, + ...buildSideButtonState({ + sideButtonMode: this.data.sideButtonMode, + showGameInfoPanel: false, + showSystemSettingsPanel: true, + showCenterScaleRuler: this.data.showCenterScaleRuler, + centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + skipButtonEnabled: this.data.skipButtonEnabled, + gameSessionStatus: this.data.gameSessionStatus, + gpsLockEnabled: this.data.gpsLockEnabled, + gpsLockAvailable: this.data.gpsLockAvailable, + }), + }) + }, + + handleCloseSystemSettingsPanel() { + this.setData({ + showSystemSettingsPanel: false, + ...buildSideButtonState({ + sideButtonMode: this.data.sideButtonMode, + showGameInfoPanel: this.data.showGameInfoPanel, + showSystemSettingsPanel: false, + showCenterScaleRuler: this.data.showCenterScaleRuler, + centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + skipButtonEnabled: this.data.skipButtonEnabled, + gameSessionStatus: this.data.gameSessionStatus, + gpsLockEnabled: this.data.gpsLockEnabled, + gpsLockAvailable: this.data.gpsLockAvailable, + }), + }) + }, + + handleSystemSettingsPanelTap() {}, + + handleSetAnimationLevelStandard() { + if (!mapEngine) { + return + } + mapEngine.handleSetAnimationLevel('standard') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + animationLevel: 'standard', + }) + }, + + handleSetAnimationLevelLite() { + if (!mapEngine) { + return + } + mapEngine.handleSetAnimationLevel('lite') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + animationLevel: 'lite', + }) + }, + + handleSetNorthReferenceMagnetic() { + if (!mapEngine) { + return + } + mapEngine.handleSetNorthReferenceMode('magnetic') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + northReferenceMode: 'magnetic', + }) + }, + + handleSetNorthReferenceTrue() { + if (!mapEngine) { + return + } + mapEngine.handleSetNorthReferenceMode('true') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + northReferenceMode: 'true', + }) + }, + handleOverlayTouch() {}, handlePunchAction() { @@ -1318,6 +1643,8 @@ Page({ }) }, + handlePunchHintTap() {}, + handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) { this.setData({ hudPanelIndex: event.detail.current || 0, @@ -1331,6 +1658,7 @@ Page({ ...buildSideButtonState({ sideButtonMode: nextMode, showGameInfoPanel: this.data.showGameInfoPanel, + showSystemSettingsPanel: this.data.showSystemSettingsPanel, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1368,9 +1696,11 @@ Page({ this.setData({ showDebugPanel: nextShowDebugPanel, showGameInfoPanel: false, + showSystemSettingsPanel: false, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: false, + showSystemSettingsPanel: false, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1390,6 +1720,7 @@ Page({ ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: this.data.showGameInfoPanel, + showSystemSettingsPanel: this.data.showSystemSettingsPanel, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1400,25 +1731,29 @@ Page({ }) }, - handleToggleCenterScaleRuler() { - const nextEnabled = !this.data.showCenterScaleRuler + applyCenterScaleRulerSettings(nextEnabled: boolean, nextAnchorMode: CenterScaleRulerAnchorMode) { this.data.showCenterScaleRuler = nextEnabled + this.data.centerScaleRulerAnchorMode = nextAnchorMode clearCenterScaleRulerSyncTimer() + clearCenterScaleRulerUpdateTimer() const syncRulerFromEngine = () => { if (!mapEngine) { return } const engineSnapshot = mapEngine.getInitialData() as Partial + updateCenterScaleRulerInputCache(engineSnapshot) const mergedData = { - ...engineSnapshot, + ...centerScaleRulerInputCache, ...this.data, showCenterScaleRuler: nextEnabled, + centerScaleRulerAnchorMode: nextAnchorMode, } as MapPageData this.setData({ ...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, nextEnabled), showCenterScaleRuler: nextEnabled, + centerScaleRulerAnchorMode: nextAnchorMode, ...buildCenterScaleRulerPatch(mergedData), ...buildSideButtonState(mergedData), }) @@ -1431,9 +1766,11 @@ Page({ this.setData({ showCenterScaleRuler: true, + centerScaleRulerAnchorMode: nextAnchorMode, ...buildSideButtonState({ ...this.data, showCenterScaleRuler: true, + centerScaleRulerAnchorMode: nextAnchorMode, } as MapPageData), }) @@ -1450,6 +1787,42 @@ Page({ }, 96) as unknown as number }, + handleSetCenterScaleRulerVisibleOn() { + this.applyCenterScaleRulerSettings(true, this.data.centerScaleRulerAnchorMode) + persistStoredUserSettings({ + ...loadStoredUserSettings(), + showCenterScaleRuler: true, + centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + }) + }, + + handleSetCenterScaleRulerVisibleOff() { + this.applyCenterScaleRulerSettings(false, this.data.centerScaleRulerAnchorMode) + persistStoredUserSettings({ + ...loadStoredUserSettings(), + showCenterScaleRuler: false, + centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + }) + }, + + handleSetCenterScaleRulerAnchorScreenCenter() { + this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'screen-center') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + showCenterScaleRuler: this.data.showCenterScaleRuler, + centerScaleRulerAnchorMode: 'screen-center', + }) + }, + + handleSetCenterScaleRulerAnchorCompassCenter() { + this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'compass-center') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + showCenterScaleRuler: this.data.showCenterScaleRuler, + centerScaleRulerAnchorMode: 'compass-center', + }) + }, + handleToggleCenterScaleRulerAnchor() { if (!this.data.showCenterScaleRuler) { return @@ -1459,9 +1832,10 @@ Page({ ? 'compass-center' : 'screen-center' const engineSnapshot = mapEngine ? (mapEngine.getInitialData() as Partial) : {} + updateCenterScaleRulerInputCache(engineSnapshot) this.data.centerScaleRulerAnchorMode = nextAnchorMode const mergedData = { - ...engineSnapshot, + ...centerScaleRulerInputCache, ...this.data, centerScaleRulerAnchorMode: nextAnchorMode, } as MapPageData diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index b80bc67..752e29b 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -28,10 +28,6 @@ - - {{punchHintText}} - × - {{punchFeedbackText}} {{contentCardTitle}} @@ -40,7 +36,7 @@ - + @@ -84,13 +80,18 @@ - + + {{punchHintText}} + × + + + - + 1 2 @@ -98,7 +99,7 @@ - + 5 6 7 @@ -107,24 +108,24 @@ 10 - + - 12 - 13 - 14 + 12 + 13 + 14 15 - + {{punchButtonText}} - + 开始 - + @@ -132,7 +133,7 @@ 调试 - + {{panelActionTagText}} @@ -155,10 +156,10 @@ - {{panelTimerText}} + {{panelTimerText}} - + {{panelMileageText}} @@ -167,16 +168,16 @@ - + {{panelDistanceValueText}} {{panelDistanceUnitText}} - {{panelProgressText}} + {{panelProgressText}} - + {{panelSpeedValueText}} km/h @@ -201,13 +202,13 @@ - + {{panelHeartRateValueText}} {{panelHeartRateUnitText}} - {{panelTimerText}} + {{panelTimerText}} @@ -237,7 +238,7 @@ - + @@ -281,6 +282,93 @@ + + + + + SYSTEM SETTINGS + 系统设置 + 用户端偏好与设备级选项 + + + 关闭 + + + + + + + + + + + + + + + + + @@ -464,6 +552,19 @@ Heading Confidence {{headingConfidenceText}} + + Compass Source + {{compassSourceText}} + + + Compass Tune + {{compassTuningProfileText}} + + + 顺滑 + 平衡 + 跟手 + Accel {{accelerometerText}} diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 89350a3..3864139 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -85,6 +85,10 @@ animation: stage-fx-finish 0.76s ease-out 1; } +.map-stage__stage-fx--control { + animation: stage-fx-control 0.52s ease-out 1; +} + .map-stage__overlay { position: absolute; inset: 0; @@ -834,6 +838,10 @@ text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.2); } +.race-panel__timer--fx-tick { + animation: race-panel-timer-tick 0.32s cubic-bezier(0.24, 0.86, 0.3, 1) 1; +} + .race-panel__mileage { max-width: 100%; box-sizing: border-box; @@ -851,6 +859,10 @@ transform: translateX(-16rpx); } +.race-panel__mileage-wrap--fx-update { + animation: race-panel-mileage-update 0.36s cubic-bezier(0.22, 0.88, 0.34, 1) 1; +} + .race-panel__metric-group { max-width: 100%; box-sizing: border-box; @@ -864,11 +876,23 @@ transform: translateX(16rpx); } +.race-panel__metric-group--fx-distance-success { + animation: race-panel-distance-success 0.56s cubic-bezier(0.22, 0.88, 0.34, 1) 1; +} + .race-panel__metric-group--right { justify-content: center; transform: translateX(-16rpx); } +.race-panel__metric-group--fx-speed-update { + animation: race-panel-speed-update 0.36s cubic-bezier(0.22, 0.88, 0.34, 1) 1; +} + +.race-panel__metric-group--fx-heart-rate-update { + animation: race-panel-heart-rate-update 0.4s cubic-bezier(0.2, 0.9, 0.3, 1) 1; +} + .race-panel__metric-value { line-height: 1; text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.16); @@ -924,6 +948,38 @@ text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.16); } +.race-panel__progress--fx-success { + animation: race-panel-progress-success 0.56s cubic-bezier(0.2, 0.88, 0.32, 1) 1; +} + +.race-panel__progress--fx-finish { + animation: race-panel-progress-finish 0.68s cubic-bezier(0.18, 0.92, 0.28, 1) 1; +} + +@keyframes race-panel-timer-tick { + 0% { transform: translateY(0) scale(1); opacity: 0.94; } + 35% { transform: translateY(-2rpx) scale(1.04); opacity: 1; } + 100% { transform: translateY(0) scale(1); opacity: 1; } +} + +@keyframes race-panel-mileage-update { + 0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; } + 40% { transform: translateX(-16rpx) scale(1.05); opacity: 1; } + 100% { transform: translateX(-16rpx) scale(1); opacity: 1; } +} + +@keyframes race-panel-speed-update { + 0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; } + 40% { transform: translateX(-16rpx) scale(1.06); opacity: 1; } + 100% { transform: translateX(-16rpx) scale(1); opacity: 1; } +} + +@keyframes race-panel-heart-rate-update { + 0% { transform: translateX(16rpx) scale(1); opacity: 0.94; } + 38% { transform: translateX(16rpx) scale(1.05); opacity: 1; } + 100% { transform: translateX(16rpx) scale(1); opacity: 1; } +} + .race-panel__zone { display: flex; flex-direction: column; @@ -982,6 +1038,72 @@ right: 0; bottom: 0; } + +@keyframes race-panel-distance-success { + 0% { + transform: translateX(16rpx) scale(1); + opacity: 1; + } + + 28% { + transform: translateX(16rpx) scale(1.09); + opacity: 1; + } + + 62% { + transform: translateX(16rpx) scale(0.98); + opacity: 0.96; + } + + 100% { + transform: translateX(16rpx) scale(1); + opacity: 1; + } +} + +@keyframes race-panel-progress-success { + 0% { + transform: scale(1) translateY(0); + opacity: 1; + } + + 24% { + transform: scale(1.16) translateY(-4rpx); + opacity: 1; + } + + 60% { + transform: scale(0.98) translateY(0); + opacity: 0.96; + } + + 100% { + transform: scale(1) translateY(0); + opacity: 1; + } +} + +@keyframes race-panel-progress-finish { + 0% { + transform: scale(1) translateY(0); + opacity: 1; + } + + 20% { + transform: scale(1.2) translateY(-6rpx); + opacity: 1; + } + + 46% { + transform: scale(1.08) translateY(-2rpx); + opacity: 1; + } + + 100% { + transform: scale(1) translateY(0); + opacity: 1; + } +} .map-punch-button { position: absolute; right: 24rpx; @@ -1593,7 +1715,7 @@ font-size: 24rpx; line-height: 1.2; text-align: left; - z-index: 16; + z-index: 40; pointer-events: auto; } @@ -1603,9 +1725,9 @@ } .game-punch-hint__close { - width: 40rpx; - height: 40rpx; - flex: 0 0 40rpx; + width: 56rpx; + height: 56rpx; + flex: 0 0 56rpx; border-radius: 999rpx; display: flex; align-items: center; @@ -1939,3 +2061,21 @@ backdrop-filter: brightness(1); } } + +@keyframes stage-fx-control { + 0% { + opacity: 0; + background: radial-gradient(circle at 50% 50%, rgba(138, 255, 235, 0.16) 0%, rgba(138, 255, 235, 0.06) 26%, rgba(255, 255, 255, 0) 60%); + backdrop-filter: brightness(1); + } + 36% { + opacity: 1; + background: radial-gradient(circle at 50% 50%, rgba(138, 255, 235, 0.24) 0%, rgba(138, 255, 235, 0.1) 32%, rgba(255, 255, 255, 0.03) 72%); + backdrop-filter: brightness(1.03); + } + 100% { + opacity: 0; + background: radial-gradient(circle at 50% 50%, rgba(138, 255, 235, 0) 0%, rgba(138, 255, 235, 0) 100%); + backdrop-filter: brightness(1); + } +} diff --git a/miniprogram/utils/animationLevel.ts b/miniprogram/utils/animationLevel.ts new file mode 100644 index 0000000..aa7ca62 --- /dev/null +++ b/miniprogram/utils/animationLevel.ts @@ -0,0 +1,24 @@ +export type AnimationLevel = 'standard' | 'lite' + +const LITE_BENCHMARK_THRESHOLD = 18 +const LITE_DEVICE_MEMORY_GB = 3 + +export function resolveAnimationLevel(systemInfo?: WechatMiniprogram.SystemInfo): AnimationLevel { + const info = systemInfo || wx.getSystemInfoSync() + const benchmarkLevel = Number((info as WechatMiniprogram.SystemInfo & { benchmarkLevel?: number }).benchmarkLevel) + const deviceMemory = Number((info as WechatMiniprogram.SystemInfo & { deviceMemory?: number }).deviceMemory) + + if (Number.isFinite(benchmarkLevel) && benchmarkLevel > 0 && benchmarkLevel <= LITE_BENCHMARK_THRESHOLD) { + return 'lite' + } + + if (Number.isFinite(deviceMemory) && deviceMemory > 0 && deviceMemory <= LITE_DEVICE_MEMORY_GB) { + return 'lite' + } + + return 'standard' +} + +export function formatAnimationLevelText(level: AnimationLevel): string { + return level === 'lite' ? '精简' : '标准' +}