完善地图交互、动画与罗盘调试

This commit is contained in:
2026-03-26 16:58:53 +08:00
parent d695308a55
commit 5fc996dea1
18 changed files with 1566 additions and 165 deletions

212
compass-debugging-notes.md Normal file
View File

@@ -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()` 调用,实际是平台兼容补丁,不应该在没有真机回归的情况下清理。**

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -1,6 +1,6 @@
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera' import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
import { AccelerometerController } from '../sensor/accelerometerController' import { AccelerometerController } from '../sensor/accelerometerController'
import { CompassHeadingController } from '../sensor/compassHeadingController' import { CompassHeadingController, type CompassTuningProfile } from '../sensor/compassHeadingController'
import { DeviceMotionController } from '../sensor/deviceMotionController' import { DeviceMotionController } from '../sensor/deviceMotionController'
import { GyroscopeController } from '../sensor/gyroscopeController' import { GyroscopeController } from '../sensor/gyroscopeController'
import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController' 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 { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig' import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
import { GameRuntime } from '../../game/core/gameRuntime' import { GameRuntime } from '../../game/core/gameRuntime'
import { type GameEffect, type GameResult } from '../../game/core/gameResult' import { type GameEffect, type GameResult } from '../../game/core/gameResult'
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition' 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_DEADZONE_DEG = 4
const AUTO_ROTATE_MAX_STEP_DEG = 0.75 const AUTO_ROTATE_MAX_STEP_DEG = 0.75
const AUTO_ROTATE_HEADING_SMOOTHING = 0.46 const AUTO_ROTATE_HEADING_SMOOTHING = 0.46
const COMPASS_NEEDLE_MIN_SMOOTHING = 0.24 const COMPASS_TUNING_PRESETS: Record<CompassTuningProfile, {
const COMPASS_NEEDLE_MAX_SMOOTHING = 0.56 needleMinSmoothing: number
needleMaxSmoothing: number
displayDeadzoneDeg: number
}> = {
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_BLEND_START_SPEED_KMH = 1.2
const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0 const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
const SMART_HEADING_MIN_DISTANCE_METERS = 12 const SMART_HEADING_MIN_DISTANCE_METERS = 12
@@ -88,6 +108,7 @@ export interface MapEngineStageRect {
} }
export interface MapEngineViewState { export interface MapEngineViewState {
animationLevel: AnimationLevel
buildVersion: string buildVersion: string
renderMode: string renderMode: string
projectionMode: string projectionMode: string
@@ -110,7 +131,11 @@ export interface MapEngineViewState {
accelerometerText: string accelerometerText: string
gyroscopeText: string gyroscopeText: string
deviceMotionText: string deviceMotionText: string
compassSourceText: string
compassTuningProfile: CompassTuningProfile
compassTuningProfileText: string
compassDeclinationText: string compassDeclinationText: string
northReferenceMode: NorthReferenceMode
northReferenceButtonText: string northReferenceButtonText: string
autoRotateSourceText: string autoRotateSourceText: string
autoRotateCalibrationText: string autoRotateCalibrationText: string
@@ -199,6 +224,8 @@ export interface MapEngineViewState {
contentCardTitle: string contentCardTitle: string
contentCardBody: string contentCardBody: string
punchButtonFxClass: string punchButtonFxClass: string
panelProgressFxClass: string
panelDistanceFxClass: string
punchFeedbackFxClass: string punchFeedbackFxClass: string
contentCardFxClass: string contentCardFxClass: string
mapPulseVisible: boolean mapPulseVisible: boolean
@@ -228,6 +255,7 @@ export interface MapEngineGameInfoSnapshot {
} }
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'animationLevel',
'buildVersion', 'buildVersion',
'renderMode', 'renderMode',
'projectionMode', 'projectionMode',
@@ -252,7 +280,11 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'accelerometerText', 'accelerometerText',
'gyroscopeText', 'gyroscopeText',
'deviceMotionText', 'deviceMotionText',
'compassSourceText',
'compassTuningProfile',
'compassTuningProfileText',
'compassDeclinationText', 'compassDeclinationText',
'northReferenceMode',
'northReferenceButtonText', 'northReferenceButtonText',
'autoRotateSourceText', 'autoRotateSourceText',
'autoRotateCalibrationText', 'autoRotateCalibrationText',
@@ -330,6 +362,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'contentCardTitle', 'contentCardTitle',
'contentCardBody', 'contentCardBody',
'punchButtonFxClass', 'punchButtonFxClass',
'panelProgressFxClass',
'panelDistanceFxClass',
'punchFeedbackFxClass', 'punchFeedbackFxClass',
'contentCardFxClass', 'contentCardFxClass',
'mapPulseVisible', 'mapPulseVisible',
@@ -342,6 +376,38 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'osmReferenceText', 'osmReferenceText',
] ]
const INTERACTION_DEFERRED_VIEW_KEYS = new Set<keyof MapEngineViewState>([
'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 { function buildCenterText(zoom: number, x: number, y: number): string {
return `z${zoom} / x${x} / y${y}` 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) 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)) const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg))
if (deltaDeg <= 4) { if (deltaDeg <= 4) {
return COMPASS_NEEDLE_MIN_SMOOTHING return preset.needleMinSmoothing
} }
if (deltaDeg >= 36) { if (deltaDeg >= 36) {
return COMPASS_NEEDLE_MAX_SMOOTHING return preset.needleMaxSmoothing
} }
const progress = (deltaDeg - 4) / (36 - 4) const progress = (deltaDeg - 4) / (36 - 4)
return COMPASS_NEEDLE_MIN_SMOOTHING return preset.needleMinSmoothing
+ (COMPASS_NEEDLE_MAX_SMOOTHING - COMPASS_NEEDLE_MIN_SMOOTHING) * progress + (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress
} }
function getMovementHeadingSmoothingFactor(speedKmh: number | null): number { function getMovementHeadingSmoothingFactor(speedKmh: number | null): number {
@@ -434,7 +505,7 @@ function formatRotationText(rotationDeg: number): string {
} }
function normalizeDegreeDisplayText(text: string): string { function normalizeDegreeDisplayText(text: string): string {
return text.replace(/[°掳•]/g, '˚') return text.replace(/[掳•˚]/g, '°')
} }
function formatHeadingText(headingDeg: number | null): string { function formatHeadingText(headingDeg: number | null): string {
@@ -442,7 +513,7 @@ function formatHeadingText(headingDeg: number | null): string {
return '--' return '--'
} }
return `${Math.round(normalizeRotationDeg(headingDeg))}˚` return `${Math.round(normalizeRotationDeg(headingDeg))}°`
} }
function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string { function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
@@ -494,9 +565,9 @@ function formatDeviceMotionText(motion: { alpha: number | null; beta: number | n
return '--' return '--'
} }
const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha * 180 / Math.PI)) const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha))
const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta * 180 / Math.PI) const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta)
const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma * 180 / Math.PI) const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma)
return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}` return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}`
} }
@@ -620,6 +691,26 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string {
return '' 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 { function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北' return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北'
} }
@@ -702,6 +793,7 @@ function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
export class MapEngine { export class MapEngine {
buildVersion: string buildVersion: string
animationLevel: AnimationLevel
renderer: WebGLMapRenderer renderer: WebGLMapRenderer
accelerometerController: AccelerometerController accelerometerController: AccelerometerController
compassController: CompassHeadingController compassController: CompassHeadingController
@@ -742,6 +834,8 @@ export class MapEngine {
sensorHeadingDeg: number | null sensorHeadingDeg: number | null
smoothedSensorHeadingDeg: number | null smoothedSensorHeadingDeg: number | null
compassDisplayHeadingDeg: number | null compassDisplayHeadingDeg: number | null
compassSource: 'compass' | 'motion' | null
compassTuningProfile: CompassTuningProfile
smoothedMovementHeadingDeg: number | null smoothedMovementHeadingDeg: number | null
autoRotateHeadingDeg: number | null autoRotateHeadingDeg: number | null
courseHeadingDeg: number | null courseHeadingDeg: number | null
@@ -789,6 +883,8 @@ export class MapEngine {
constructor(buildVersion: string, callbacks: MapEngineCallbacks) { constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
this.buildVersion = buildVersion this.buildVersion = buildVersion
this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
this.compassTuningProfile = 'balanced'
this.onData = callbacks.onData this.onData = callbacks.onData
this.accelerometerErrorText = null this.accelerometerErrorText = null
this.renderer = new WebGLMapRenderer( this.renderer = new WebGLMapRenderer(
@@ -812,7 +908,7 @@ export class MapEngine {
z, z,
}) })
if (this.diagnosticUiEnabled) { if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch(), true) this.setState(this.getTelemetrySensorViewPatch())
} }
}, },
onError: (message) => { onError: (message) => {
@@ -821,7 +917,7 @@ export class MapEngine {
this.setState({ this.setState({
...this.getTelemetrySensorViewPatch(), ...this.getTelemetrySensorViewPatch(),
statusText: `加速度计启动失败 (${this.buildVersion})`, statusText: `加速度计启动失败 (${this.buildVersion})`,
}, true) })
} }
}, },
}) })
@@ -833,6 +929,7 @@ export class MapEngine {
this.handleCompassError(message) this.handleCompassError(message)
}, },
}) })
this.compassController.setTuningProfile(this.compassTuningProfile)
this.gyroscopeController = new GyroscopeController({ this.gyroscopeController = new GyroscopeController({
onSample: (x, y, z) => { onSample: (x, y, z) => {
this.telemetryRuntime.dispatch({ this.telemetryRuntime.dispatch({
@@ -843,12 +940,12 @@ export class MapEngine {
z, z,
}) })
if (this.diagnosticUiEnabled) { if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch(), true) this.setState(this.getTelemetrySensorViewPatch())
} }
}, },
onError: () => { onError: () => {
if (this.diagnosticUiEnabled) { if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch(), true) this.setState(this.getTelemetrySensorViewPatch())
} }
}, },
}) })
@@ -865,16 +962,12 @@ export class MapEngine {
this.setState({ this.setState({
...this.getTelemetrySensorViewPatch(), ...this.getTelemetrySensorViewPatch(),
autoRotateSourceText: this.getAutoRotateSourceText(), autoRotateSourceText: this.getAutoRotateSourceText(),
}, true) })
}
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
} }
}, },
onError: () => { onError: () => {
if (this.diagnosticUiEnabled) { if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch(), true) this.setState(this.getTelemetrySensorViewPatch())
} }
}, },
}) })
@@ -899,7 +992,7 @@ export class MapEngine {
}, },
onDebugStateChange: () => { onDebugStateChange: () => {
if (this.diagnosticUiEnabled) { if (this.diagnosticUiEnabled) {
this.setState(this.getLocationControllerViewPatch(), true) this.setState(this.getLocationControllerViewPatch())
} }
}, },
}) })
@@ -963,12 +1056,12 @@ export class MapEngine {
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices), heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
heartRateScanText: this.getHeartRateScanText(), heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(), ...this.getHeartRateControllerViewPatch(),
}, true) })
} }
}, },
onDebugStateChange: () => { onDebugStateChange: () => {
if (this.diagnosticUiEnabled) { if (this.diagnosticUiEnabled) {
this.setState(this.getHeartRateControllerViewPatch(), true) this.setState(this.getHeartRateControllerViewPatch())
} }
}, },
}) })
@@ -982,6 +1075,12 @@ export class MapEngine {
setPunchButtonFxClass: (className) => { setPunchButtonFxClass: (className) => {
this.setPunchButtonFxClass(className) this.setPunchButtonFxClass(className)
}, },
setHudProgressFxClass: (className) => {
this.setHudProgressFxClass(className)
},
setHudDistanceFxClass: (className) => {
this.setHudDistanceFxClass(className)
},
showMapPulse: (controlId, motionClass) => { showMapPulse: (controlId, motionClass) => {
this.showMapPulse(controlId, motionClass) this.showMapPulse(controlId, motionClass)
}, },
@@ -994,6 +1093,7 @@ export class MapEngine {
} }
}, },
}) })
this.feedbackDirector.setAnimationLevel(this.animationLevel)
this.minZoom = MIN_ZOOM this.minZoom = MIN_ZOOM
this.maxZoom = MAX_ZOOM this.maxZoom = MAX_ZOOM
this.defaultZoom = DEFAULT_ZOOM this.defaultZoom = DEFAULT_ZOOM
@@ -1032,6 +1132,7 @@ export class MapEngine {
this.sessionTimerInterval = 0 this.sessionTimerInterval = 0
this.hasGpsCenteredOnce = false this.hasGpsCenteredOnce = false
this.state = { this.state = {
animationLevel: this.animationLevel,
buildVersion: this.buildVersion, buildVersion: this.buildVersion,
renderMode: RENDER_MODE, renderMode: RENDER_MODE,
projectionMode: PROJECTION_MODE, projectionMode: PROJECTION_MODE,
@@ -1054,7 +1155,11 @@ export class MapEngine {
accelerometerText: '未启用', accelerometerText: '未启用',
gyroscopeText: '--', gyroscopeText: '--',
deviceMotionText: '--', deviceMotionText: '--',
compassSourceText: '无数据',
compassTuningProfile: this.compassTuningProfile,
compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE), compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
northReferenceMode: DEFAULT_NORTH_REFERENCE_MODE,
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE), northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
autoRotateSourceText: formatAutoRotateSourceText('smart', false), autoRotateSourceText: formatAutoRotateSourceText('smart', false),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
@@ -1137,6 +1242,8 @@ export class MapEngine {
contentCardTitle: '', contentCardTitle: '',
contentCardBody: '', contentCardBody: '',
punchButtonFxClass: '', punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
punchFeedbackFxClass: '', punchFeedbackFxClass: '',
contentCardFxClass: '', contentCardFxClass: '',
mapPulseVisible: false, mapPulseVisible: false,
@@ -1177,6 +1284,8 @@ export class MapEngine {
this.sensorHeadingDeg = null this.sensorHeadingDeg = null
this.smoothedSensorHeadingDeg = null this.smoothedSensorHeadingDeg = null
this.compassDisplayHeadingDeg = null this.compassDisplayHeadingDeg = null
this.compassSource = null
this.compassTuningProfile = 'balanced'
this.smoothedMovementHeadingDeg = null this.smoothedMovementHeadingDeg = null
this.autoRotateHeadingDeg = null this.autoRotateHeadingDeg = null
this.courseHeadingDeg = null this.courseHeadingDeg = null
@@ -1241,6 +1350,7 @@ export class MapEngine {
{ label: '配置版本', value: this.configVersion || '--' }, { label: '配置版本', value: this.configVersion || '--' },
{ label: 'Schema版本', value: this.configSchemaVersion || '--' }, { label: 'Schema版本', value: this.configSchemaVersion || '--' },
{ label: '活动ID', value: this.configAppId || '--' }, { label: '活动ID', value: this.configAppId || '--' },
{ label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) },
{ label: '地图', value: this.state.mapName || '--' }, { label: '地图', value: this.state.mapName || '--' },
{ label: '模式', value: this.getGameModeText() }, { label: '模式', value: this.getGameModeText() },
{ label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) }, { label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) },
@@ -1430,6 +1540,9 @@ export class MapEngine {
: '未启用', : '未启用',
gyroscopeText: formatGyroscopeText(telemetryState.gyroscope), gyroscopeText: formatGyroscopeText(telemetryState.gyroscope),
deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion), deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion),
compassSourceText: formatCompassSourceText(this.compassSource),
compassTuningProfile: this.compassTuningProfile,
compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
} }
} }
@@ -1589,6 +1702,8 @@ export class MapEngine {
stageFxVisible: false, stageFxVisible: false,
stageFxClass: '', stageFxClass: '',
punchButtonFxClass: '', punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
}, true) }, true)
} }
@@ -1675,6 +1790,18 @@ export class MapEngine {
}, true) }, true)
} }
setHudProgressFxClass(className: string): void {
this.setState({
panelProgressFxClass: className,
}, true)
}
setHudDistanceFxClass(className: string): void {
this.setState({
panelDistanceFxClass: className,
}, true)
}
showMapPulse(controlId: string, motionClass = ''): void { showMapPulse(controlId: string, motionClass = ''): void {
const screenPoint = this.getControlScreenPoint(controlId) const screenPoint = this.getControlScreenPoint(controlId)
if (!screenPoint) { if (!screenPoint) {
@@ -1761,6 +1888,9 @@ export class MapEngine {
applyGameEffects(effects: GameEffect[]): string | null { applyGameEffects(effects: GameEffect[]): string | null {
this.feedbackDirector.handleEffects(effects) this.feedbackDirector.handleEffects(effects)
if (effects.some((effect) => effect.type === 'session_finished')) { if (effects.some((effect) => effect.type === 'session_finished')) {
if (this.locationController.listening) {
this.locationController.stop()
}
this.setState({ this.setState({
gpsTracking: false, gpsTracking: false,
gpsTrackingText: '测试结束,定位已停止', gpsTrackingText: '测试结束,定位已停止',
@@ -1845,12 +1975,17 @@ export class MapEngine {
handleForceExitGame(): void { handleForceExitGame(): void {
this.feedbackDirector.reset() this.feedbackDirector.reset()
if (this.locationController.listening) {
this.locationController.stop()
}
if (!this.courseData) { if (!this.courseData) {
this.clearGameRuntime() this.clearGameRuntime()
this.resetTransientGameUiState() this.resetTransientGameUiState()
this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
this.setState({ this.setState({
gpsTracking: false,
gpsTrackingText: '已退出对局,定位已停止',
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
}, true) }, true)
this.syncRenderer() this.syncRenderer()
@@ -1861,6 +1996,8 @@ export class MapEngine {
this.resetTransientGameUiState() this.resetTransientGameUiState()
this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
this.setState({ this.setState({
gpsTracking: false,
gpsTrackingText: '已退出对局,定位已停止',
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
}, true) }, true)
this.syncRenderer() this.syncRenderer()
@@ -1946,7 +2083,7 @@ export class MapEngine {
gpsLockEnabled: this.gpsLockEnabled, gpsLockEnabled: this.gpsLockEnabled,
gpsLockAvailable: gpsInsideMap, gpsLockAvailable: gpsInsideMap,
...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)), ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
}, true) })
this.syncRenderer() this.syncRenderer()
} }
@@ -2100,7 +2237,7 @@ export class MapEngine {
this.setState({ this.setState({
heartRateDeviceText: this.heartRateController.currentDeviceName || '--', heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
heartRateScanText: this.getHeartRateScanText(), heartRateScanText: this.getHeartRateScanText(),
}, true) })
} }
handleDebugHeartRateTone(tone: HeartRateTone): void { handleDebugHeartRateTone(tone: HeartRateTone): void {
@@ -2112,7 +2249,7 @@ export class MapEngine {
}) })
this.setState({ this.setState({
heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`, heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`,
}, true) })
this.syncSessionTimerText() this.syncSessionTimerText()
} }
@@ -2128,7 +2265,7 @@ export class MapEngine {
: (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'), : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'),
heartRateScanText: this.getHeartRateScanText(), heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(), ...this.getHeartRateControllerViewPatch(),
}, true) })
this.syncSessionTimerText() this.syncSessionTimerText()
} }
@@ -2250,7 +2387,7 @@ export class MapEngine {
configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`, configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`,
projectionMode: config.projectionModeText, projectionMode: config.projectionModeText,
tileSource: config.tileSource, tileSource: config.tileSource,
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)), sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
northReferenceText: formatNorthReferenceText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode),
@@ -2673,6 +2810,38 @@ export class MapEngine {
this.cycleNorthReferenceMode() 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 { handleAutoRotateCalibrate(): void {
if (this.state.orientationMode !== 'heading-up') { if (this.state.orientationMode !== 'heading-up') {
this.setState({ 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.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
? this.sensorHeadingDeg ? this.sensorHeadingDeg
: interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING) : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg) const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null if (this.compassDisplayHeadingDeg === null) {
? compassHeadingDeg this.compassDisplayHeadingDeg = compassHeadingDeg
: interpolateAngleDeg( } else {
const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg))
if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) {
this.compassDisplayHeadingDeg = interpolateAngleDeg(
this.compassDisplayHeadingDeg, this.compassDisplayHeadingDeg,
compassHeadingDeg, compassHeadingDeg,
getCompassNeedleSmoothingFactor(this.compassDisplayHeadingDeg, compassHeadingDeg), getCompassNeedleSmoothingFactor(
this.compassDisplayHeadingDeg,
compassHeadingDeg,
this.compassTuningProfile,
),
) )
}
}
this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg() this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
this.setState({ this.setState({
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg), compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
...(this.diagnosticUiEnabled ...(this.diagnosticUiEnabled
? { ? {
sensorHeadingText: formatHeadingText(compassHeadingDeg),
...this.getTelemetrySensorViewPatch(), ...this.getTelemetrySensorViewPatch(),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
autoRotateSourceText: this.getAutoRotateSourceText(), autoRotateSourceText: this.getAutoRotateSourceText(),
northReferenceText: formatNorthReferenceText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode),
@@ -2801,18 +2980,31 @@ export class MapEngine {
} }
} }
handleCompassHeading(headingDeg: number): void {
this.applyHeadingSample(headingDeg, 'compass')
}
handleCompassError(message: string): void { handleCompassError(message: string): void {
this.clearAutoRotateTimer() this.clearAutoRotateTimer()
this.targetAutoRotationDeg = null this.targetAutoRotationDeg = null
this.autoRotateCalibrationPending = false this.autoRotateCalibrationPending = false
this.compassSource = null
this.setState({ this.setState({
compassSourceText: formatCompassSourceText(null),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
statusText: `${message} (${this.buildVersion})`, statusText: `${message} (${this.buildVersion})`,
}, true) }, true)
} }
cycleNorthReferenceMode(): void { 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 nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
const compassHeadingDeg = this.smoothedSensorHeadingDeg === null const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
? null ? null
@@ -2831,9 +3023,10 @@ export class MapEngine {
rotationDeg: MAP_NORTH_OFFSET_DEG, rotationDeg: MAP_NORTH_OFFSET_DEG,
rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG), rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
northReferenceText: formatNorthReferenceText(nextMode), northReferenceText: formatNorthReferenceText(nextMode),
sensorHeadingText: formatHeadingText(compassHeadingDeg), sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
...this.getTelemetrySensorViewPatch(), ...this.getTelemetrySensorViewPatch(),
compassDeclinationText: formatCompassDeclinationText(nextMode), compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceMode: nextMode,
northReferenceButtonText: formatNorthReferenceButtonText(nextMode), northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
@@ -2850,9 +3043,10 @@ export class MapEngine {
this.setState({ this.setState({
northReferenceText: formatNorthReferenceText(nextMode), northReferenceText: formatNorthReferenceText(nextMode),
sensorHeadingText: formatHeadingText(compassHeadingDeg), sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
...this.getTelemetrySensorViewPatch(), ...this.getTelemetrySensorViewPatch(),
compassDeclinationText: formatCompassDeclinationText(nextMode), compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceMode: nextMode,
northReferenceButtonText: formatNorthReferenceButtonText(nextMode), northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
@@ -3167,6 +3361,7 @@ export class MapEngine {
buildScene() { buildScene() {
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const readyControlSequences = this.resolveReadyControlSequences()
return { return {
tileSource: this.state.tileSource, tileSource: this.state.tileSource,
osmTileSource: OSM_TILE_SOURCE, osmTileSource: OSM_TILE_SOURCE,
@@ -3183,6 +3378,7 @@ export class MapEngine {
translateX: this.state.tileTranslateX, translateX: this.state.tileTranslateX,
translateY: this.state.tileTranslateY, translateY: this.state.tileTranslateY,
rotationRad: this.getRotationRad(this.state.rotationDeg), rotationRad: this.getRotationRad(this.state.rotationDeg),
animationLevel: this.state.animationLevel,
previewScale: this.previewScale || 1, previewScale: this.previewScale || 1,
previewOriginX: this.previewOriginX || this.state.stageWidth / 2, previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
previewOriginY: this.previewOriginY || this.state.stageHeight / 2, previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
@@ -3199,6 +3395,7 @@ export class MapEngine {
focusedControlId: this.gamePresentation.map.focusedControlId, focusedControlId: this.gamePresentation.map.focusedControlId,
focusedControlSequences: this.gamePresentation.map.focusedControlSequences, focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
activeControlSequences: this.gamePresentation.map.activeControlSequences, activeControlSequences: this.gamePresentation.map.activeControlSequences,
readyControlSequences,
activeStart: this.gamePresentation.map.activeStart, activeStart: this.gamePresentation.map.activeStart,
completedStart: this.gamePresentation.map.completedStart, completedStart: this.gamePresentation.map.completedStart,
activeFinish: this.gamePresentation.map.activeFinish, 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 { syncRenderer(): void {
if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) { if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
return return
@@ -3374,8 +3586,32 @@ export class MapEngine {
} }
const patch = this.pendingViewPatch const patch = this.pendingViewPatch
this.pendingViewPatch = {} const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer
this.onData(patch) const nextPendingPatch = {} as Partial<MapEngineViewState>
const outputPatch = {} as Partial<MapEngineViewState>
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<string, unknown>)[key] = value
continue
}
;(outputPatch as Record<string, unknown>)[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 { getTouchDistance(touches: TouchPoint[]): number {

View File

@@ -9,11 +9,14 @@ const SCORE_LABEL_FONT_SIZE_RATIO = 0.7
const SCORE_LABEL_OFFSET_Y_RATIO = 0.06 const SCORE_LABEL_OFFSET_Y_RATIO = 0.06
const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)' const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)'
const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 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 MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)'
const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)' const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)'
const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)' 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_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)'
const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)' 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 { export class CourseLabelRenderer {
courseLayer: CourseLayer courseLayer: CourseLayer
@@ -107,6 +110,10 @@ export class CourseLabelRenderer {
return FOCUSED_LABEL_COLOR return FOCUSED_LABEL_COLOR
} }
if (scene.readyControlSequences.includes(sequence)) {
return READY_LABEL_COLOR
}
if (scene.activeControlSequences.includes(sequence)) { if (scene.activeControlSequences.includes(sequence)) {
return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_LABEL_COLOR : ACTIVE_LABEL_COLOR return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_LABEL_COLOR : ACTIVE_LABEL_COLOR
} }
@@ -116,7 +123,7 @@ export class CourseLabelRenderer {
} }
if (scene.skippedControlSequences.includes(sequence)) { if (scene.skippedControlSequences.includes(sequence)) {
return COMPLETED_LABEL_COLOR return SKIPPED_LABEL_COLOR
} }
return DEFAULT_LABEL_COLOR return DEFAULT_LABEL_COLOR
@@ -127,12 +134,16 @@ export class CourseLabelRenderer {
return FOCUSED_LABEL_COLOR return FOCUSED_LABEL_COLOR
} }
if (scene.readyControlSequences.includes(sequence)) {
return READY_LABEL_COLOR
}
if (scene.completedControlSequences.includes(sequence)) { if (scene.completedControlSequences.includes(sequence)) {
return SCORE_COMPLETED_LABEL_COLOR return SCORE_COMPLETED_LABEL_COLOR
} }
if (scene.skippedControlSequences.includes(sequence)) { if (scene.skippedControlSequences.includes(sequence)) {
return SCORE_COMPLETED_LABEL_COLOR return SCORE_SKIPPED_LABEL_COLOR
} }
return SCORE_LABEL_COLOR return SCORE_LABEL_COLOR

View File

@@ -3,6 +3,7 @@ import { type TileStoreStats } from '../tile/tileStore'
import { type LonLatPoint, type MapCalibration } from '../../utils/projection' import { type LonLatPoint, type MapCalibration } from '../../utils/projection'
import { type TileZoomBounds } from '../../utils/remoteMapConfig' import { type TileZoomBounds } from '../../utils/remoteMapConfig'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
import { type AnimationLevel } from '../../utils/animationLevel'
export interface MapScene { export interface MapScene {
tileSource: string tileSource: string
@@ -20,6 +21,7 @@ export interface MapScene {
translateX: number translateX: number
translateY: number translateY: number
rotationRad: number rotationRad: number
animationLevel: AnimationLevel
previewScale: number previewScale: number
previewOriginX: number previewOriginX: number
previewOriginY: number previewOriginY: number
@@ -36,6 +38,7 @@ export interface MapScene {
focusedControlId: string | null focusedControlId: string | null
focusedControlSequences: number[] focusedControlSequences: number[]
activeControlSequences: number[] activeControlSequences: number[]
readyControlSequences: number[]
activeStart: boolean activeStart: boolean
completedStart: boolean completedStart: boolean
activeFinish: boolean activeFinish: boolean

View File

@@ -135,12 +135,16 @@ export class WebGLMapRenderer implements MapRenderer {
this.scheduleRender() this.scheduleRender()
} }
this.animationTimer = setTimeout(tick, ANIMATION_FRAME_MS) as unknown as number this.animationTimer = setTimeout(tick, this.getAnimationFrameMs()) as unknown as number
} }
tick() tick()
} }
getAnimationFrameMs(): number {
return this.scene && this.scene.animationLevel === 'lite' ? 48 : ANIMATION_FRAME_MS
}
scheduleRender(): void { scheduleRender(): void {
if (this.renderTimer || !this.scene || this.destroyed) { if (this.renderTimer || !this.scene || this.destroyed) {
return return

View File

@@ -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 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 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 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 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 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 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 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 ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5]
const EARTH_CIRCUMFERENCE_METERS = 40075016.686 const EARTH_CIRCUMFERENCE_METERS = 40075016.686
const CONTROL_RING_WIDTH_RATIO = 0.2 const CONTROL_RING_WIDTH_RATIO = 0.2
@@ -196,6 +202,18 @@ export class WebGLVectorRenderer {
gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2) 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 { getPixelsPerMeter(scene: MapScene): number {
const camera: CameraState = { const camera: CameraState = {
centerWorldX: scene.exactCenterWorldX, centerWorldX: scene.exactCenterWorldX,
@@ -249,6 +267,18 @@ export class WebGLVectorRenderer {
if (scene.activeStart) { if (scene.activeStart) {
this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame) 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) this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene)
} }
if (!scene.revealFullCourse) { if (!scene.revealFullCourse) {
@@ -261,9 +291,28 @@ export class WebGLVectorRenderer {
this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame) this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
} else { } 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, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR)
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]) 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( this.pushRing(
positions, positions,
@@ -278,7 +327,9 @@ export class WebGLVectorRenderer {
if (scene.focusedControlSequences.includes(control.sequence)) { 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.02, scene, pulseFrame, FOCUSED_PULSE_COLOR)
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.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5])
}
this.pushRing( this.pushRing(
positions, positions,
colors, colors,
@@ -290,6 +341,33 @@ export class WebGLVectorRenderer {
scene, 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) { for (const finish of course.finishes) {
@@ -298,10 +376,24 @@ export class WebGLVectorRenderer {
} }
if (scene.focusedFinish) { 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.04, scene, pulseFrame, FOCUSED_PULSE_COLOR)
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]) 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) 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( this.pushRing(
positions, positions,
colors, 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( pushActiveStartPulse(
positions: number[], positions: number[],
colors: number[], colors: number[],
@@ -462,14 +575,22 @@ export class WebGLVectorRenderer {
} }
getControlColor(scene: MapScene, sequence: number): RgbaColor { getControlColor(scene: MapScene, sequence: number): RgbaColor {
if (scene.readyControlSequences.includes(sequence)) {
return READY_CONTROL_COLOR
}
if (scene.activeControlSequences.includes(sequence)) { if (scene.activeControlSequences.includes(sequence)) {
return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR 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 return COMPLETED_ROUTE_COLOR
} }
if (this.isSkippedControl(scene, sequence)) {
return SKIPPED_ROUTE_COLOR
}
return COURSE_COLOR return COURSE_COLOR
} }
@@ -633,7 +754,7 @@ export class WebGLVectorRenderer {
color: RgbaColor, color: RgbaColor,
scene: MapScene, scene: MapScene,
): void { ): void {
const segments = 36 const segments = this.getRingSegments(scene)
for (let index = 0; index < segments; index += 1) { for (let index = 0; index < segments; index += 1) {
const startAngle = index / segments * Math.PI * 2 const startAngle = index / segments * Math.PI * 2
const endAngle = (index + 1) / segments * Math.PI * 2 const endAngle = (index + 1) / segments * Math.PI * 2
@@ -682,7 +803,7 @@ export class WebGLVectorRenderer {
color: RgbaColor, color: RgbaColor,
scene: MapScene, scene: MapScene,
): void { ): void {
const segments = 20 const segments = this.getCircleSegments(scene)
const center = this.toClip(centerX, centerY, scene) const center = this.toClip(centerX, centerY, scene)
for (let index = 0; index < segments; index += 1) { for (let index = 0; index < segments; index += 1) {
const startAngle = index / segments * Math.PI * 2 const startAngle = index / segments * Math.PI * 2

View File

@@ -5,7 +5,13 @@ export interface CompassHeadingControllerCallbacks {
type SensorSource = 'compass' | 'motion' | null type SensorSource = 'compass' | 'motion' | null
const ABSOLUTE_HEADING_CORRECTION = 0.44 export type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive'
const HEADING_CORRECTION_BY_PROFILE: Record<CompassTuningProfile, number> = {
smooth: 0.3,
balanced: 0.4,
responsive: 0.54,
}
function normalizeHeadingDeg(headingDeg: number): number { function normalizeHeadingDeg(headingDeg: number): number {
const normalized = headingDeg % 360 const normalized = headingDeg % 360
@@ -41,6 +47,7 @@ export class CompassHeadingController {
rollDeg: number | null rollDeg: number | null
motionReady: boolean motionReady: boolean
compassReady: boolean compassReady: boolean
tuningProfile: CompassTuningProfile
constructor(callbacks: CompassHeadingControllerCallbacks) { constructor(callbacks: CompassHeadingControllerCallbacks) {
this.callbacks = callbacks this.callbacks = callbacks
@@ -53,6 +60,7 @@ export class CompassHeadingController {
this.rollDeg = null this.rollDeg = null
this.motionReady = false this.motionReady = false
this.compassReady = false this.compassReady = false
this.tuningProfile = 'balanced'
} }
start(): void { start(): void {
@@ -99,6 +107,10 @@ export class CompassHeadingController {
this.stop() this.stop()
} }
setTuningProfile(profile: CompassTuningProfile): void {
this.tuningProfile = profile
}
startMotionSource(previousMessage: string): void { startMotionSource(previousMessage: string): void {
if (typeof wx.startDeviceMotionListening !== 'function' || typeof wx.onDeviceMotionChange !== 'function') { if (typeof wx.startDeviceMotionListening !== 'function' || typeof wx.onDeviceMotionChange !== 'function') {
this.callbacks.onError(previousMessage) this.callbacks.onError(previousMessage)
@@ -111,14 +123,13 @@ export class CompassHeadingController {
} }
this.pitchDeg = typeof result.beta === 'number' && !Number.isNaN(result.beta) this.pitchDeg = typeof result.beta === 'number' && !Number.isNaN(result.beta)
? result.beta * 180 / Math.PI ? result.beta
: null : null
this.rollDeg = typeof result.gamma === 'number' && !Number.isNaN(result.gamma) this.rollDeg = typeof result.gamma === 'number' && !Number.isNaN(result.gamma)
? result.gamma * 180 / Math.PI ? result.gamma
: null : null
const alphaDeg = result.alpha * 180 / Math.PI this.applyAbsoluteHeading(normalizeHeadingDeg(360 - result.alpha), 'motion')
this.applyAbsoluteHeading(normalizeHeadingDeg(360 - alphaDeg), 'motion')
} }
this.motionCallback = callback this.motionCallback = callback
@@ -163,10 +174,11 @@ export class CompassHeadingController {
} }
applyAbsoluteHeading(headingDeg: number, source: 'compass' | 'motion'): void { applyAbsoluteHeading(headingDeg: number, source: 'compass' | 'motion'): void {
const headingCorrection = HEADING_CORRECTION_BY_PROFILE[this.tuningProfile]
if (this.absoluteHeadingDeg === null) { if (this.absoluteHeadingDeg === null) {
this.absoluteHeadingDeg = headingDeg this.absoluteHeadingDeg = headingDeg
} else { } else {
this.absoluteHeadingDeg = interpolateHeadingDeg(this.absoluteHeadingDeg, headingDeg, ABSOLUTE_HEADING_CORRECTION) this.absoluteHeadingDeg = interpolateHeadingDeg(this.absoluteHeadingDeg, headingDeg, headingCorrection)
} }
this.source = source this.source = source
@@ -200,5 +212,3 @@ export class CompassHeadingController {
this.compassCallback = null this.compassCallback = null
} }
} }

View File

@@ -1,3 +1,5 @@
import { type AnimationLevel } from '../../utils/animationLevel'
export type FeedbackCueKey = export type FeedbackCueKey =
| 'session_started' | 'session_started'
| 'session_finished' | 'session_finished'
@@ -14,7 +16,9 @@ export type UiPunchFeedbackMotion = 'none' | 'pop' | 'success' | 'warning'
export type UiContentCardMotion = 'none' | 'pop' | 'finish' export type UiContentCardMotion = 'none' | 'pop' | 'finish'
export type UiPunchButtonMotion = 'none' | 'ready' | 'warning' export type UiPunchButtonMotion = 'none' | 'ready' | 'warning'
export type UiMapPulseMotion = 'none' | 'ready' | 'control' | 'finish' 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 { export interface HapticCueConfig {
enabled: boolean enabled: boolean
@@ -28,6 +32,8 @@ export interface UiCueConfig {
punchButtonMotion: UiPunchButtonMotion punchButtonMotion: UiPunchButtonMotion
mapPulseMotion: UiMapPulseMotion mapPulseMotion: UiMapPulseMotion
stageMotion: UiStageMotion stageMotion: UiStageMotion
hudProgressMotion: UiHudProgressMotion
hudDistanceMotion: UiHudDistanceMotion
durationMs: number durationMs: number
} }
@@ -41,6 +47,10 @@ export interface GameUiEffectsConfig {
cues: Record<FeedbackCueKey, UiCueConfig> cues: Record<FeedbackCueKey, UiCueConfig>
} }
export interface ResolvedGameUiEffectsConfig extends GameUiEffectsConfig {
animationLevel: AnimationLevel
}
export interface PartialHapticCueConfig { export interface PartialHapticCueConfig {
enabled?: boolean enabled?: boolean
pattern?: HapticPattern pattern?: HapticPattern
@@ -53,6 +63,8 @@ export interface PartialUiCueConfig {
punchButtonMotion?: UiPunchButtonMotion punchButtonMotion?: UiPunchButtonMotion
mapPulseMotion?: UiMapPulseMotion mapPulseMotion?: UiMapPulseMotion
stageMotion?: UiStageMotion stageMotion?: UiStageMotion
hudProgressMotion?: UiHudProgressMotion
hudDistanceMotion?: UiHudDistanceMotion
durationMs?: number durationMs?: number
} }
@@ -84,15 +96,15 @@ export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = {
export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = { export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = {
enabled: true, enabled: true,
cues: { cues: {
session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 }, 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', 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: '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: 'none', durationMs: 0 }, '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', durationMs: 0 }, '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', durationMs: 560 }, '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', durationMs: 0 }, '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', 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', durationMs: 900 }, '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, punchButtonMotion: override && override.punchButtonMotion ? override.punchButtonMotion : baseCue.punchButtonMotion,
mapPulseMotion: override && override.mapPulseMotion ? override.mapPulseMotion : baseCue.mapPulseMotion, mapPulseMotion: override && override.mapPulseMotion ? override.mapPulseMotion : baseCue.mapPulseMotion,
stageMotion: override && override.stageMotion ? override.stageMotion : baseCue.stageMotion, 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), durationMs: clampDuration(Number(override && override.durationMs), baseCue.durationMs),
} }
} }

View File

@@ -1,6 +1,7 @@
import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig' import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig'
import { SoundDirector } from '../audio/soundDirector' import { SoundDirector } from '../audio/soundDirector'
import { type GameEffect } from '../core/gameResult' import { type GameEffect } from '../core/gameResult'
import { type AnimationLevel } from '../../utils/animationLevel'
import { import {
DEFAULT_GAME_HAPTICS_CONFIG, DEFAULT_GAME_HAPTICS_CONFIG,
DEFAULT_GAME_UI_EFFECTS_CONFIG, DEFAULT_GAME_UI_EFFECTS_CONFIG,
@@ -41,6 +42,9 @@ export class FeedbackDirector {
reset(): void { reset(): void {
this.soundDirector.resetContexts() this.soundDirector.resetContexts()
this.uiEffectDirector.clearPunchButtonMotion()
this.uiEffectDirector.clearHudProgressMotion()
this.uiEffectDirector.clearHudDistanceMotion()
} }
destroy(): void { destroy(): void {
@@ -49,6 +53,10 @@ export class FeedbackDirector {
this.uiEffectDirector.destroy() this.uiEffectDirector.destroy()
} }
setAnimationLevel(level: AnimationLevel): void {
this.uiEffectDirector.setAnimationLevel(level)
}
setAppAudioMode(mode: 'foreground' | 'background'): void { setAppAudioMode(mode: 'foreground' | 'background'): void {
this.soundDirector.setAppAudioMode(mode) this.soundDirector.setAppAudioMode(mode)
} }

View File

@@ -1,12 +1,16 @@
import { type GameEffect } from '../core/gameResult' import { type GameEffect } from '../core/gameResult'
import { type AnimationLevel } from '../../utils/animationLevel'
import { import {
DEFAULT_GAME_UI_EFFECTS_CONFIG, DEFAULT_GAME_UI_EFFECTS_CONFIG,
type FeedbackCueKey, type FeedbackCueKey,
type GameUiEffectsConfig, type GameUiEffectsConfig,
type UiContentCardMotion, type UiContentCardMotion,
type UiHudDistanceMotion,
type UiHudProgressMotion,
type UiMapPulseMotion, type UiMapPulseMotion,
type UiPunchButtonMotion, type UiPunchButtonMotion,
type UiPunchFeedbackMotion, type UiPunchFeedbackMotion,
type UiCueConfig,
type UiStageMotion, type UiStageMotion,
} from './feedbackConfig' } from './feedbackConfig'
@@ -14,6 +18,8 @@ export interface UiEffectHost {
showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void
showContentCard: (title: string, body: string, motionClass?: string) => void showContentCard: (title: string, body: string, motionClass?: string) => void
setPunchButtonFxClass: (className: string) => void setPunchButtonFxClass: (className: string) => void
setHudProgressFxClass: (className: string) => void
setHudDistanceFxClass: (className: string) => void
showMapPulse: (controlId: string, motionClass?: string) => void showMapPulse: (controlId: string, motionClass?: string) => void
showStageFx: (className: string) => void showStageFx: (className: string) => void
} }
@@ -23,30 +29,46 @@ export class UiEffectDirector {
config: GameUiEffectsConfig config: GameUiEffectsConfig
host: UiEffectHost host: UiEffectHost
punchButtonMotionTimer: number punchButtonMotionTimer: number
hudProgressMotionTimer: number
hudDistanceMotionTimer: number
punchButtonMotionToggle: boolean punchButtonMotionToggle: boolean
animationLevel: AnimationLevel
constructor(host: UiEffectHost, config: GameUiEffectsConfig = DEFAULT_GAME_UI_EFFECTS_CONFIG) { constructor(host: UiEffectHost, config: GameUiEffectsConfig = DEFAULT_GAME_UI_EFFECTS_CONFIG) {
this.enabled = true this.enabled = true
this.host = host this.host = host
this.config = config this.config = config
this.punchButtonMotionTimer = 0 this.punchButtonMotionTimer = 0
this.hudProgressMotionTimer = 0
this.hudDistanceMotionTimer = 0
this.punchButtonMotionToggle = false this.punchButtonMotionToggle = false
this.animationLevel = 'standard'
} }
configure(config: GameUiEffectsConfig): void { configure(config: GameUiEffectsConfig): void {
this.config = config this.config = config
this.clearPunchButtonMotion() this.clearPunchButtonMotion()
this.clearHudProgressMotion()
this.clearHudDistanceMotion()
} }
setEnabled(enabled: boolean): void { setEnabled(enabled: boolean): void {
this.enabled = enabled this.enabled = enabled
if (!enabled) { if (!enabled) {
this.clearPunchButtonMotion() this.clearPunchButtonMotion()
this.clearHudProgressMotion()
this.clearHudDistanceMotion()
} }
} }
setAnimationLevel(level: AnimationLevel): void {
this.animationLevel = level
}
destroy(): void { destroy(): void {
this.clearPunchButtonMotion() this.clearPunchButtonMotion()
this.clearHudProgressMotion()
this.clearHudDistanceMotion()
} }
clearPunchButtonMotion(): void { clearPunchButtonMotion(): void {
@@ -57,6 +79,22 @@ export class UiEffectDirector {
this.host.setPunchButtonFxClass('') 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 { getPunchFeedbackMotionClass(motion: UiPunchFeedbackMotion): string {
if (motion === 'warning') { if (motion === 'warning') {
return 'game-punch-feedback--fx-warning' return 'game-punch-feedback--fx-warning'
@@ -94,12 +132,32 @@ export class UiEffectDirector {
} }
getStageMotionClass(motion: UiStageMotion): string { getStageMotionClass(motion: UiStageMotion): string {
if (motion === 'control') {
return 'map-stage__stage-fx--control'
}
if (motion === 'finish') { if (motion === 'finish') {
return 'map-stage__stage-fx--finish' return 'map-stage__stage-fx--finish'
} }
return '' 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 { triggerPunchButtonMotion(motion: UiPunchButtonMotion, durationMs: number): void {
if (motion === 'none') { if (motion === 'none') {
return return
@@ -121,7 +179,37 @@ export class UiEffectDirector {
}, durationMs) as unknown as number }, 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) { if (!this.enabled || !this.config.enabled) {
return null return null
} }
@@ -131,9 +219,18 @@ export class UiEffectDirector {
return null return null
} }
if (this.animationLevel === 'standard') {
return cue 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 { handleEffects(effects: GameEffect[]): void {
for (const effect of effects) { for (const effect of effects) {
if (effect.type === 'punch_feedback' && effect.tone === 'warning') { if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
@@ -172,6 +269,10 @@ export class UiEffectDirector {
if (cue && cue.stageMotion !== 'none') { if (cue && cue.stageMotion !== 'none') {
this.host.showStageFx(this.getStageMotionClass(cue.stageMotion)) this.host.showStageFx(this.getStageMotionClass(cue.stageMotion))
} }
if (cue) {
this.triggerHudProgressMotion(cue.hudProgressMotion, cue.durationMs)
this.triggerHudDistanceMotion(cue.hudDistanceMotion, cue.durationMs)
}
continue continue
} }
@@ -188,10 +289,14 @@ export class UiEffectDirector {
if (effect.type === 'session_finished') { if (effect.type === 'session_finished') {
this.clearPunchButtonMotion() this.clearPunchButtonMotion()
this.clearHudProgressMotion()
this.clearHudDistanceMotion()
} }
if (effect.type === 'session_cancelled') { if (effect.type === 'session_cancelled') {
this.clearPunchButtonMotion() this.clearPunchButtonMotion()
this.clearHudProgressMotion()
this.clearHudDistanceMotion()
} }
} }
} }

View File

@@ -52,6 +52,44 @@ function interpolateHeadingDeg(currentDeg: number, targetDeg: number, factor: nu
return normalizeHeadingDeg(currentDeg + normalizeHeadingDeltaDeg(targetDeg - currentDeg) * factor) 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( function getApproxDistanceMeters(
a: { lon: number; lat: number }, a: { lon: number; lat: number },
b: { lon: number; lat: number }, b: { lon: number; lat: number },
@@ -530,13 +568,13 @@ export class TelemetryRuntime {
} }
if (event.type === 'device_motion_updated') { 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 ? this.state.deviceHeadingDeg
: (() => { : (() => {
const nextHeadingDeg = normalizeHeadingDeg(360 - event.alpha * 180 / Math.PI)
return this.state.deviceHeadingDeg === null return this.state.deviceHeadingDeg === null
? nextHeadingDeg ? motionHeadingDeg
: interpolateHeadingDeg(this.state.deviceHeadingDeg, nextHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA) : interpolateHeadingDeg(this.state.deviceHeadingDeg, motionHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA)
})() })()
this.state = { this.state = {

View File

@@ -6,6 +6,7 @@ import {
type MapEngineViewState, type MapEngineViewState,
} from '../../engine/map/mapEngine' } from '../../engine/map/mapEngine'
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig' import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
import { type AnimationLevel } from '../../utils/animationLevel'
type CompassTickData = { type CompassTickData = {
angle: number angle: number
long: boolean long: boolean
@@ -31,9 +32,17 @@ type ScaleRulerMajorMarkData = {
type SideButtonMode = 'all' | 'left' | 'right' | 'hidden' type SideButtonMode = 'all' | 'left' | 'right' | 'hidden'
type SideActionButtonState = 'muted' | 'default' | 'active' type SideActionButtonState = 'muted' | 'default' | 'active'
type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center' type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center'
type UserNorthReferenceMode = 'magnetic' | 'true'
type StoredUserSettings = {
animationLevel?: AnimationLevel
northReferenceMode?: UserNorthReferenceMode
showCenterScaleRuler?: boolean
centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode
}
type MapPageData = MapEngineViewState & { type MapPageData = MapEngineViewState & {
showDebugPanel: boolean showDebugPanel: boolean
showGameInfoPanel: boolean showGameInfoPanel: boolean
showSystemSettingsPanel: boolean
showCenterScaleRuler: boolean showCenterScaleRuler: boolean
showPunchHintBanner: boolean showPunchHintBanner: boolean
centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode
@@ -52,6 +61,10 @@ type MapPageData = MapEngineViewState & {
panelDistanceValueText: string panelDistanceValueText: string
panelProgressText: string panelProgressText: string
panelSpeedValueText: string panelSpeedValueText: string
panelTimerFxClass: string
panelMileageFxClass: string
panelSpeedFxClass: string
panelHeartRateFxClass: string
compassTicks: CompassTickData[] compassTicks: CompassTickData[]
compassLabels: CompassLabelData[] compassLabels: CompassLabelData[]
sideButtonMode: SideButtonMode sideButtonMode: SideButtonMode
@@ -59,6 +72,7 @@ type MapPageData = MapEngineViewState & {
sideButton2Class: string sideButton2Class: string
sideButton4Class: string sideButton4Class: string
sideButton11Class: string sideButton11Class: string
sideButton12Class: string
sideButton13Class: string sideButton13Class: string
sideButton14Class: string sideButton14Class: string
sideButton16Class: string sideButton16Class: string
@@ -75,7 +89,8 @@ type MapPageData = MapEngineViewState & {
showRightButtonGroups: boolean showRightButtonGroups: boolean
showBottomDebugButton: 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 CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json' const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
const PUNCH_HINT_AUTO_HIDE_MS = 30000 const PUNCH_HINT_AUTO_HIDE_MS = 30000
@@ -83,7 +98,43 @@ let mapEngine: MapEngine | null = null
let stageCanvasAttached = false let stageCanvasAttached = false
let gameInfoPanelSyncTimer = 0 let gameInfoPanelSyncTimer = 0
let centerScaleRulerSyncTimer = 0 let centerScaleRulerSyncTimer = 0
let centerScaleRulerUpdateTimer = 0
let punchHintDismissTimer = 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<Pick<
MapPageData,
'stageWidth'
| 'stageHeight'
| 'zoom'
| 'centerTileY'
| 'tileSizePx'
| 'previewScale'
>> = {}
const DEBUG_ONLY_VIEW_KEYS = new Set<string>([ const DEBUG_ONLY_VIEW_KEYS = new Set<string>([
'buildVersion', 'buildVersion',
@@ -93,14 +144,15 @@ const DEBUG_ONLY_VIEW_KEYS = new Set<string>([
'mapReadyText', 'mapReadyText',
'mapName', 'mapName',
'configStatusText', 'configStatusText',
'sensorHeadingText',
'deviceHeadingText', 'deviceHeadingText',
'devicePoseText', 'devicePoseText',
'headingConfidenceText', 'headingConfidenceText',
'accelerometerText', 'accelerometerText',
'gyroscopeText', 'gyroscopeText',
'deviceMotionText', 'deviceMotionText',
'compassDeclinationText', 'compassSourceText',
'compassTuningProfile',
'compassTuningProfileText',
'northReferenceButtonText', 'northReferenceButtonText',
'autoRotateSourceText', 'autoRotateSourceText',
'autoRotateCalibrationText', 'autoRotateCalibrationText',
@@ -148,6 +200,15 @@ const CENTER_SCALE_RULER_DEP_KEYS = new Set<string>([
'previewScale', 'previewScale',
]) ])
const CENTER_SCALE_RULER_CACHE_KEYS: Array<keyof typeof centerScaleRulerInputCache> = [
'stageWidth',
'stageHeight',
'zoom',
'centerTileY',
'tileSizePx',
'previewScale',
]
const RULER_ONLY_VIEW_KEYS = new Set<string>([ const RULER_ONLY_VIEW_KEYS = new Set<string>([
'zoom', 'zoom',
'centerTileX', 'centerTileX',
@@ -213,12 +274,83 @@ function clearCenterScaleRulerSyncTimer() {
} }
} }
function clearCenterScaleRulerUpdateTimer() {
if (centerScaleRulerUpdateTimer) {
clearTimeout(centerScaleRulerUpdateTimer)
centerScaleRulerUpdateTimer = 0
}
}
function clearPunchHintDismissTimer() { function clearPunchHintDismissTimer() {
if (punchHintDismissTimer) { if (punchHintDismissTimer) {
clearTimeout(punchHintDismissTimer) clearTimeout(punchHintDismissTimer)
punchHintDismissTimer = 0 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<MapPageData>) {
for (const key of CENTER_SCALE_RULER_CACHE_KEYS) {
if (Object.prototype.hasOwnProperty.call(patch, key)) {
;(centerScaleRulerInputCache as Record<string, unknown>)[key] =
(patch as Record<string, unknown>)[key]
}
}
}
function loadStoredUserSettings(): StoredUserSettings {
try {
const stored = wx.getStorageSync(USER_SETTINGS_STORAGE_KEY)
if (!stored || typeof stored !== 'object') {
return {}
}
const normalized = stored as Record<string, unknown>
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) { function buildSideButtonVisibility(mode: SideButtonMode) {
return { return {
sideButtonMode: mode, sideButtonMode: mode,
@@ -296,7 +428,7 @@ function getSideActionButtonClass(state: SideActionButtonState): string {
return 'map-side-button map-side-button--default' return 'map-side-button map-side-button--default'
} }
function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'skipButtonEnabled' | 'gameSessionStatus' | 'gpsLockEnabled' | 'gpsLockAvailable'>) { function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'showSystemSettingsPanel' | 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'skipButtonEnabled' | 'gameSessionStatus' | 'gpsLockEnabled' | 'gpsLockAvailable'>) {
const sideButton2State: SideActionButtonState = !data.gpsLockAvailable const sideButton2State: SideActionButtonState = !data.gpsLockAvailable
? 'muted' ? 'muted'
: data.gpsLockEnabled : data.gpsLockEnabled
@@ -304,6 +436,7 @@ function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGa
: 'default' : 'default'
const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'idle' ? 'default' : 'active' const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'idle' ? 'default' : 'active'
const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default' const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default'
const sideButton12State: SideActionButtonState = data.showSystemSettingsPanel ? 'active' : 'default'
const sideButton13State: SideActionButtonState = data.showCenterScaleRuler ? 'active' : 'default' const sideButton13State: SideActionButtonState = data.showCenterScaleRuler ? 'active' : 'default'
const sideButton14State: SideActionButtonState = !data.showCenterScaleRuler const sideButton14State: SideActionButtonState = !data.showCenterScaleRuler
? 'muted' ? 'muted'
@@ -317,6 +450,7 @@ function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGa
sideButton2Class: getSideActionButtonClass(sideButton2State), sideButton2Class: getSideActionButtonClass(sideButton2State),
sideButton4Class: getSideActionButtonClass(sideButton4State), sideButton4Class: getSideActionButtonClass(sideButton4State),
sideButton11Class: getSideActionButtonClass(sideButton11State), sideButton11Class: getSideActionButtonClass(sideButton11State),
sideButton12Class: getSideActionButtonClass(sideButton12State),
sideButton13Class: getSideActionButtonClass(sideButton13State), sideButton13Class: getSideActionButtonClass(sideButton13State),
sideButton14Class: getSideActionButtonClass(sideButton14State), sideButton14Class: getSideActionButtonClass(sideButton14State),
sideButton16Class: getSideActionButtonClass(sideButton16State), sideButton16Class: getSideActionButtonClass(sideButton16State),
@@ -367,7 +501,7 @@ function formatScaleDistanceLabel(distanceMeters: number): string {
function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'stageWidth' | 'stageHeight' | 'topInsetHeight' | 'zoom' | 'centerTileY' | 'tileSizePx' | 'previewScale'>) { function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'stageWidth' | 'stageHeight' | 'topInsetHeight' | 'zoom' | 'centerTileY' | 'tileSizePx' | 'previewScale'>) {
if (!data.showCenterScaleRuler) { if (!data.showCenterScaleRuler) {
return { lastCenterScaleRulerStablePatch = {
centerScaleRulerVisible: false, centerScaleRulerVisible: false,
centerScaleRulerCenterXPx: 0, centerScaleRulerCenterXPx: 0,
centerScaleRulerZeroYPx: 0, centerScaleRulerZeroYPx: 0,
@@ -378,20 +512,11 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[], centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[], centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
} }
return { ...lastCenterScaleRulerStablePatch }
} }
if (!data.stageWidth || !data.stageHeight) { if (!data.stageWidth || !data.stageHeight) {
return { return { ...lastCenterScaleRulerStablePatch }
centerScaleRulerVisible: false,
centerScaleRulerCenterXPx: 0,
centerScaleRulerZeroYPx: 0,
centerScaleRulerHeightPx: 0,
centerScaleRulerAxisBottomPx: 0,
centerScaleRulerZeroVisible: false,
centerScaleRulerZeroLabel: '0 m',
centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
}
} }
const topPadding = 12 const topPadding = 12
@@ -414,15 +539,13 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
|| !Number.isFinite(data.centerTileY) || !Number.isFinite(data.centerTileY)
) { ) {
return { return {
...lastCenterScaleRulerStablePatch,
centerScaleRulerVisible: true, centerScaleRulerVisible: true,
centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2), centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
centerScaleRulerZeroYPx: zeroYPx, centerScaleRulerZeroYPx: zeroYPx,
centerScaleRulerHeightPx: fallbackHeight, centerScaleRulerHeightPx: lastCenterScaleRulerStablePatch.centerScaleRulerHeightPx || fallbackHeight,
centerScaleRulerAxisBottomPx: coveredBottomPx, centerScaleRulerAxisBottomPx: coveredBottomPx,
centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center', centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
centerScaleRulerZeroLabel: '0 m',
centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
} }
} }
@@ -435,15 +558,13 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
if (!Number.isFinite(effectiveMetersPerPixel) || effectiveMetersPerPixel <= 0 || rulerHeight < 120) { if (!Number.isFinite(effectiveMetersPerPixel) || effectiveMetersPerPixel <= 0 || rulerHeight < 120) {
return { return {
...lastCenterScaleRulerStablePatch,
centerScaleRulerVisible: true, centerScaleRulerVisible: true,
centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2), centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
centerScaleRulerZeroYPx: zeroYPx, centerScaleRulerZeroYPx: zeroYPx,
centerScaleRulerHeightPx: Math.max(rulerHeight, fallbackHeight), centerScaleRulerHeightPx: lastCenterScaleRulerStablePatch.centerScaleRulerHeightPx || fallbackHeight,
centerScaleRulerAxisBottomPx: coveredBottomPx, centerScaleRulerAxisBottomPx: coveredBottomPx,
centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center', centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
centerScaleRulerZeroLabel: '0 m',
centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
} }
} }
@@ -480,7 +601,7 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
} }
} }
return { lastCenterScaleRulerStablePatch = {
centerScaleRulerVisible: true, centerScaleRulerVisible: true,
centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2), centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
centerScaleRulerZeroYPx: zeroYPx, centerScaleRulerZeroYPx: zeroYPx,
@@ -491,6 +612,7 @@ function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRule
centerScaleRulerMinorTicks: minorTicks, centerScaleRulerMinorTicks: minorTicks,
centerScaleRulerMajorMarks: majorMarks, centerScaleRulerMajorMarks: majorMarks,
} }
return { ...lastCenterScaleRulerStablePatch }
} }
function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot { function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot {
@@ -512,6 +634,7 @@ Page({
data: { data: {
showDebugPanel: false, showDebugPanel: false,
showGameInfoPanel: false, showGameInfoPanel: false,
showSystemSettingsPanel: false,
showCenterScaleRuler: false, showCenterScaleRuler: false,
statusBarHeight: 0, statusBarHeight: 0,
topInsetHeight: 12, topInsetHeight: 12,
@@ -572,6 +695,9 @@ Page({
accelerometerText: '--', accelerometerText: '--',
gyroscopeText: '--', gyroscopeText: '--',
deviceMotionText: '--', deviceMotionText: '--',
compassSourceText: '无数据',
compassTuningProfile: 'balanced',
compassTuningProfileText: '平衡',
punchButtonText: '打点', punchButtonText: '打点',
punchButtonEnabled: false, punchButtonEnabled: false,
skipButtonEnabled: false, skipButtonEnabled: false,
@@ -583,6 +709,8 @@ Page({
contentCardTitle: '', contentCardTitle: '',
contentCardBody: '', contentCardBody: '',
punchButtonFxClass: '', punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
punchFeedbackFxClass: '', punchFeedbackFxClass: '',
contentCardFxClass: '', contentCardFxClass: '',
mapPulseVisible: false, mapPulseVisible: false,
@@ -606,6 +734,7 @@ Page({
...buildSideButtonState({ ...buildSideButtonState({
sideButtonMode: 'left', sideButtonMode: 'left',
showGameInfoPanel: false, showGameInfoPanel: false,
showSystemSettingsPanel: false,
showCenterScaleRuler: false, showCenterScaleRuler: false,
centerScaleRulerAnchorMode: 'screen-center', centerScaleRulerAnchorMode: 'screen-center',
skipButtonEnabled: false, skipButtonEnabled: false,
@@ -649,7 +778,10 @@ Page({
nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
} }
updateCenterScaleRulerInputCache(nextPatch)
const mergedData = { const mergedData = {
...centerScaleRulerInputCache,
...this.data, ...this.data,
...nextData, ...nextData,
} as MapPageData } as MapPageData
@@ -659,6 +791,7 @@ Page({
this.data.showCenterScaleRuler this.data.showCenterScaleRuler
&& hasAnyPatchKey(nextPatch as Record<string, unknown>, CENTER_SCALE_RULER_DEP_KEYS) && hasAnyPatchKey(nextPatch as Record<string, unknown>, CENTER_SCALE_RULER_DEP_KEYS)
) { ) {
clearCenterScaleRulerUpdateTimer()
Object.assign(derivedPatch, buildCenterScaleRulerPatch(mergedData)) 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) { if (Object.keys(nextData).length || Object.keys(derivedPatch).length) {
this.setData({ this.setData({
...nextData, ...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) 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({ this.setData({
...mapEngine.getInitialData(), ...mapEngine.getInitialData(),
showDebugPanel: false, showDebugPanel: false,
showGameInfoPanel: false, showGameInfoPanel: false,
showSystemSettingsPanel: false,
showCenterScaleRuler: initialShowCenterScaleRuler,
statusBarHeight, statusBarHeight,
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
hudPanelIndex: 0, hudPanelIndex: 0,
configSourceText: '顺序赛配置', configSourceText: '顺序赛配置',
centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode,
gameInfoTitle: '当前游戏', gameInfoTitle: '当前游戏',
gameInfoSubtitle: '未开始', gameInfoSubtitle: '未开始',
gameInfoLocalRows: [], gameInfoLocalRows: [],
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
panelTimerText: '00:00:00', panelTimerText: '00:00:00',
panelTimerFxClass: '',
panelMileageText: '0m', panelMileageText: '0m',
panelMileageFxClass: '',
panelActionTagText: '目标', panelActionTagText: '目标',
panelDistanceTagText: '点距', panelDistanceTagText: '点距',
panelDistanceValueText: '--', panelDistanceValueText: '--',
@@ -740,6 +948,7 @@ Page({
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
mockHeartRateText: '--', mockHeartRateText: '--',
panelSpeedValueText: '0', panelSpeedValueText: '0',
panelSpeedFxClass: '',
panelTelemetryTone: 'blue', panelTelemetryTone: 'blue',
panelHeartRateZoneNameText: '--', panelHeartRateZoneNameText: '--',
panelHeartRateZoneRangeText: '', panelHeartRateZoneRangeText: '',
@@ -747,6 +956,7 @@ Page({
heartRateStatusText: '心率带未连接', heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--', heartRateDeviceText: '--',
panelHeartRateValueText: '--', panelHeartRateValueText: '--',
panelHeartRateFxClass: '',
panelHeartRateUnitText: '', panelHeartRateUnitText: '',
panelCaloriesValueText: '0', panelCaloriesValueText: '0',
panelCaloriesUnitText: 'kcal', panelCaloriesUnitText: 'kcal',
@@ -760,6 +970,9 @@ Page({
accelerometerText: '--', accelerometerText: '--',
gyroscopeText: '--', gyroscopeText: '--',
deviceMotionText: '--', deviceMotionText: '--',
compassSourceText: '无数据',
compassTuningProfile: 'balanced',
compassTuningProfileText: '平衡',
punchButtonText: '打点', punchButtonText: '打点',
punchButtonEnabled: false, punchButtonEnabled: false,
skipButtonEnabled: false, skipButtonEnabled: false,
@@ -771,6 +984,8 @@ Page({
contentCardTitle: '', contentCardTitle: '',
contentCardBody: '', contentCardBody: '',
punchButtonFxClass: '', punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
punchFeedbackFxClass: '', punchFeedbackFxClass: '',
contentCardFxClass: '', contentCardFxClass: '',
mapPulseVisible: false, mapPulseVisible: false,
@@ -785,8 +1000,9 @@ Page({
...buildSideButtonState({ ...buildSideButtonState({
sideButtonMode: 'left', sideButtonMode: 'left',
showGameInfoPanel: false, showGameInfoPanel: false,
showCenterScaleRuler: false, showSystemSettingsPanel: false,
centerScaleRulerAnchorMode: 'screen-center', showCenterScaleRuler: initialShowCenterScaleRuler,
centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode,
skipButtonEnabled: false, skipButtonEnabled: false,
gameSessionStatus: 'idle', gameSessionStatus: 'idle',
gpsLockEnabled: false, gpsLockEnabled: false,
@@ -794,8 +1010,8 @@ Page({
}), }),
...buildCenterScaleRulerPatch({ ...buildCenterScaleRulerPatch({
...(mapEngine.getInitialData() as MapPageData), ...(mapEngine.getInitialData() as MapPageData),
showCenterScaleRuler: false, showCenterScaleRuler: initialShowCenterScaleRuler,
centerScaleRulerAnchorMode: 'screen-center', centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode,
stageWidth: 0, stageWidth: 0,
stageHeight: 0, stageHeight: 0,
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
@@ -827,7 +1043,12 @@ Page({
onUnload() { onUnload() {
clearGameInfoPanelSyncTimer() clearGameInfoPanelSyncTimer()
clearCenterScaleRulerSyncTimer() clearCenterScaleRulerSyncTimer()
clearCenterScaleRulerUpdateTimer()
clearPunchHintDismissTimer() clearPunchHintDismissTimer()
clearHudFxTimer('timer')
clearHudFxTimer('mileage')
clearHudFxTimer('speed')
clearHudFxTimer('heartRate')
if (mapEngine) { if (mapEngine) {
mapEngine.destroy() mapEngine.destroy()
mapEngine = null 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() { handleAutoRotateCalibrate() {
if (mapEngine) { if (mapEngine) {
mapEngine.handleAutoRotateCalibrate() mapEngine.handleAutoRotateCalibrate()
@@ -1260,10 +1499,12 @@ Page({
this.syncGameInfoPanelSnapshot() this.syncGameInfoPanelSnapshot()
this.setData({ this.setData({
showDebugPanel: false, showDebugPanel: false,
showSystemSettingsPanel: false,
showGameInfoPanel: true, showGameInfoPanel: true,
...buildSideButtonState({ ...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode, sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: true, showGameInfoPanel: true,
showSystemSettingsPanel: false,
showCenterScaleRuler: this.data.showCenterScaleRuler, showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled, skipButtonEnabled: this.data.skipButtonEnabled,
@@ -1281,6 +1522,7 @@ Page({
...buildSideButtonState({ ...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode, sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: false, showGameInfoPanel: false,
showSystemSettingsPanel: this.data.showSystemSettingsPanel,
showCenterScaleRuler: this.data.showCenterScaleRuler, showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled, skipButtonEnabled: this.data.skipButtonEnabled,
@@ -1293,6 +1535,89 @@ Page({
handleGameInfoPanelTap() {}, 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() {}, handleOverlayTouch() {},
handlePunchAction() { handlePunchAction() {
@@ -1318,6 +1643,8 @@ Page({
}) })
}, },
handlePunchHintTap() {},
handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) { handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) {
this.setData({ this.setData({
hudPanelIndex: event.detail.current || 0, hudPanelIndex: event.detail.current || 0,
@@ -1331,6 +1658,7 @@ Page({
...buildSideButtonState({ ...buildSideButtonState({
sideButtonMode: nextMode, sideButtonMode: nextMode,
showGameInfoPanel: this.data.showGameInfoPanel, showGameInfoPanel: this.data.showGameInfoPanel,
showSystemSettingsPanel: this.data.showSystemSettingsPanel,
showCenterScaleRuler: this.data.showCenterScaleRuler, showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled, skipButtonEnabled: this.data.skipButtonEnabled,
@@ -1368,9 +1696,11 @@ Page({
this.setData({ this.setData({
showDebugPanel: nextShowDebugPanel, showDebugPanel: nextShowDebugPanel,
showGameInfoPanel: false, showGameInfoPanel: false,
showSystemSettingsPanel: false,
...buildSideButtonState({ ...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode, sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: false, showGameInfoPanel: false,
showSystemSettingsPanel: false,
showCenterScaleRuler: this.data.showCenterScaleRuler, showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled, skipButtonEnabled: this.data.skipButtonEnabled,
@@ -1390,6 +1720,7 @@ Page({
...buildSideButtonState({ ...buildSideButtonState({
sideButtonMode: this.data.sideButtonMode, sideButtonMode: this.data.sideButtonMode,
showGameInfoPanel: this.data.showGameInfoPanel, showGameInfoPanel: this.data.showGameInfoPanel,
showSystemSettingsPanel: this.data.showSystemSettingsPanel,
showCenterScaleRuler: this.data.showCenterScaleRuler, showCenterScaleRuler: this.data.showCenterScaleRuler,
centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
skipButtonEnabled: this.data.skipButtonEnabled, skipButtonEnabled: this.data.skipButtonEnabled,
@@ -1400,25 +1731,29 @@ Page({
}) })
}, },
handleToggleCenterScaleRuler() { applyCenterScaleRulerSettings(nextEnabled: boolean, nextAnchorMode: CenterScaleRulerAnchorMode) {
const nextEnabled = !this.data.showCenterScaleRuler
this.data.showCenterScaleRuler = nextEnabled this.data.showCenterScaleRuler = nextEnabled
this.data.centerScaleRulerAnchorMode = nextAnchorMode
clearCenterScaleRulerSyncTimer() clearCenterScaleRulerSyncTimer()
clearCenterScaleRulerUpdateTimer()
const syncRulerFromEngine = () => { const syncRulerFromEngine = () => {
if (!mapEngine) { if (!mapEngine) {
return return
} }
const engineSnapshot = mapEngine.getInitialData() as Partial<MapPageData> const engineSnapshot = mapEngine.getInitialData() as Partial<MapPageData>
updateCenterScaleRulerInputCache(engineSnapshot)
const mergedData = { const mergedData = {
...engineSnapshot, ...centerScaleRulerInputCache,
...this.data, ...this.data,
showCenterScaleRuler: nextEnabled, showCenterScaleRuler: nextEnabled,
centerScaleRulerAnchorMode: nextAnchorMode,
} as MapPageData } as MapPageData
this.setData({ this.setData({
...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, nextEnabled), ...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, nextEnabled),
showCenterScaleRuler: nextEnabled, showCenterScaleRuler: nextEnabled,
centerScaleRulerAnchorMode: nextAnchorMode,
...buildCenterScaleRulerPatch(mergedData), ...buildCenterScaleRulerPatch(mergedData),
...buildSideButtonState(mergedData), ...buildSideButtonState(mergedData),
}) })
@@ -1431,9 +1766,11 @@ Page({
this.setData({ this.setData({
showCenterScaleRuler: true, showCenterScaleRuler: true,
centerScaleRulerAnchorMode: nextAnchorMode,
...buildSideButtonState({ ...buildSideButtonState({
...this.data, ...this.data,
showCenterScaleRuler: true, showCenterScaleRuler: true,
centerScaleRulerAnchorMode: nextAnchorMode,
} as MapPageData), } as MapPageData),
}) })
@@ -1450,6 +1787,42 @@ Page({
}, 96) as unknown as number }, 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() { handleToggleCenterScaleRulerAnchor() {
if (!this.data.showCenterScaleRuler) { if (!this.data.showCenterScaleRuler) {
return return
@@ -1459,9 +1832,10 @@ Page({
? 'compass-center' ? 'compass-center'
: 'screen-center' : 'screen-center'
const engineSnapshot = mapEngine ? (mapEngine.getInitialData() as Partial<MapPageData>) : {} const engineSnapshot = mapEngine ? (mapEngine.getInitialData() as Partial<MapPageData>) : {}
updateCenterScaleRulerInputCache(engineSnapshot)
this.data.centerScaleRulerAnchorMode = nextAnchorMode this.data.centerScaleRulerAnchorMode = nextAnchorMode
const mergedData = { const mergedData = {
...engineSnapshot, ...centerScaleRulerInputCache,
...this.data, ...this.data,
centerScaleRulerAnchorMode: nextAnchorMode, centerScaleRulerAnchorMode: nextAnchorMode,
} as MapPageData } as MapPageData

View File

@@ -28,10 +28,6 @@
<view class="map-stage__map-pulse {{mapPulseFxClass}}" wx:if="{{mapPulseVisible}}" style="left: {{mapPulseLeftPx}}px; top: {{mapPulseTopPx}}px;"></view> <view class="map-stage__map-pulse {{mapPulseFxClass}}" wx:if="{{mapPulseVisible}}" style="left: {{mapPulseLeftPx}}px; top: {{mapPulseTopPx}}px;"></view>
<view class="map-stage__stage-fx {{stageFxClass}}" wx:if="{{stageFxVisible}}"></view> <view class="map-stage__stage-fx {{stageFxClass}}" wx:if="{{stageFxVisible}}"></view>
<view class="game-punch-hint" wx:if="{{showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;">
<view class="game-punch-hint__text">{{punchHintText}}</view>
<view class="game-punch-hint__close" bindtap="handleClosePunchHint">×</view>
</view>
<view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}} {{punchFeedbackFxClass}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view> <view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}} {{punchFeedbackFxClass}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view>
<view class="game-content-card {{contentCardFxClass}}" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard"> <view class="game-content-card {{contentCardFxClass}}" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
<view class="game-content-card__title">{{contentCardTitle}}</view> <view class="game-content-card__title">{{contentCardTitle}}</view>
@@ -40,7 +36,7 @@
</view> </view>
<view class="map-stage__overlay-center-layer" wx:if="{{!showDebugPanel && !showGameInfoPanel}}"> <view class="map-stage__overlay-center-layer" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}">
<view class="center-scale-ruler" wx:if="{{centerScaleRulerVisible}}" style="left: {{centerScaleRulerCenterXPx}}px; top: {{centerScaleRulerZeroYPx}}px; height: {{centerScaleRulerHeightPx}}px;"> <view class="center-scale-ruler" wx:if="{{centerScaleRulerVisible}}" style="left: {{centerScaleRulerCenterXPx}}px; top: {{centerScaleRulerZeroYPx}}px; height: {{centerScaleRulerHeightPx}}px;">
<view class="center-scale-ruler__axis" style="bottom: {{centerScaleRulerAxisBottomPx}}px;"></view> <view class="center-scale-ruler__axis" style="bottom: {{centerScaleRulerAxisBottomPx}}px;"></view>
<view class="center-scale-ruler__arrow"></view> <view class="center-scale-ruler__arrow"></view>
@@ -84,13 +80,18 @@
</view> </view>
</view> </view>
<cover-view class="map-side-toggle" wx:if="{{!showDebugPanel && !showGameInfoPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons"> <view class="game-punch-hint" wx:if="{{showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
<view class="game-punch-hint__text">{{punchHintText}}</view>
<view class="game-punch-hint__close" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap" catchtap="handleClosePunchHint">×</view>
</view>
<cover-view class="map-side-toggle" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons">
<cover-view class="map-side-button map-side-button--icon"> <cover-view class="map-side-button map-side-button--icon">
<cover-image class="map-side-button__image" src="{{sideToggleIconSrc}}"></cover-image> <cover-image class="map-side-button__image" src="{{sideToggleIconSrc}}"></cover-image>
</cover-view> </cover-view>
</cover-view> </cover-view>
<cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && !showGameInfoPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;"> <cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
<cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view> <cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view>
<cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">1</cover-view></cover-view> <cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">1</cover-view></cover-view>
<cover-view class="{{sideButton2Class}}" bindtap="handleToggleGpsLock"><cover-view class="map-side-button__text">2</cover-view></cover-view> <cover-view class="{{sideButton2Class}}" bindtap="handleToggleGpsLock"><cover-view class="map-side-button__text">2</cover-view></cover-view>
@@ -98,7 +99,7 @@
<cover-view class="{{sideButton4Class}}" bindtap="handleForceExitGame"><cover-image class="map-side-button__action-image" src="../../assets/btn_exit.png"></cover-image></cover-view> <cover-view class="{{sideButton4Class}}" bindtap="handleForceExitGame"><cover-image class="map-side-button__action-image" src="../../assets/btn_exit.png"></cover-image></cover-view>
</cover-view> </cover-view>
<cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && !showGameInfoPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;"> <cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
<cover-view class="map-side-button"><cover-view class="map-side-button__text">5</cover-view></cover-view> <cover-view class="map-side-button"><cover-view class="map-side-button__text">5</cover-view></cover-view>
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">6</cover-view></cover-view> <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">6</cover-view></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">7</cover-view></cover-view> <cover-view class="map-side-button"><cover-view class="map-side-button__text">7</cover-view></cover-view>
@@ -107,24 +108,24 @@
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">10</cover-view></cover-view> <cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">10</cover-view></cover-view>
</cover-view> </cover-view>
<cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && !showGameInfoPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;"> <cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
<cover-view class="{{sideButton11Class}}" bindtap="handleOpenGameInfoPanel"><cover-image class="map-side-button__action-image" src="../../assets/btn_info.png"></cover-image></cover-view> <cover-view class="{{sideButton11Class}}" bindtap="handleOpenGameInfoPanel"><cover-image class="map-side-button__action-image" src="../../assets/btn_info.png"></cover-image></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">12</cover-view></cover-view> <cover-view class="{{sideButton12Class}}" bindtap="handleOpenSystemSettingsPanel"><cover-view class="map-side-button__text">12</cover-view></cover-view>
<cover-view class="{{sideButton13Class}}" bindtap="handleToggleCenterScaleRuler"><cover-view class="map-side-button__text">13</cover-view></cover-view> <cover-view class="map-side-button"><cover-view class="map-side-button__text">13</cover-view></cover-view>
<cover-view class="{{sideButton14Class}}" bindtap="handleToggleCenterScaleRulerAnchor"><cover-view class="map-side-button__text">14</cover-view></cover-view> <cover-view class="map-side-button"><cover-view class="map-side-button__text">14</cover-view></cover-view>
<cover-view class="map-side-button"><cover-view class="map-side-button__text">15</cover-view></cover-view> <cover-view class="map-side-button"><cover-view class="map-side-button__text">15</cover-view></cover-view>
<cover-view class="{{sideButton16Class}}" bindtap="handleSkipAction"><cover-image class="map-side-button__action-image" src="../../assets/btn_skip_cp.png"></cover-image></cover-view> <cover-view class="{{sideButton16Class}}" bindtap="handleSkipAction"><cover-image class="map-side-button__action-image" src="../../assets/btn_skip_cp.png"></cover-image></cover-view>
</cover-view> </cover-view>
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel && !showGameInfoPanel}}" bindtap="handlePunchAction"> <cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}" bindtap="handlePunchAction">
<cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view> <cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
</cover-view> </cover-view>
<cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame"> <cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame">
<cover-view class="screen-button-layer__text screen-button-layer__text--start">开始</cover-view> <cover-view class="screen-button-layer__text screen-button-layer__text--start">开始</cover-view>
</cover-view> </cover-view>
<cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel"> <cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
<cover-view class="screen-button-layer__icon"> <cover-view class="screen-button-layer__icon">
<cover-view class="screen-button-layer__line"></cover-view> <cover-view class="screen-button-layer__line"></cover-view>
<cover-view class="screen-button-layer__stand"></cover-view> <cover-view class="screen-button-layer__stand"></cover-view>
@@ -132,7 +133,7 @@
<cover-view class="screen-button-layer__text">调试</cover-view> <cover-view class="screen-button-layer__text">调试</cover-view>
</cover-view> </cover-view>
<swiper wx:if="{{!showGameInfoPanel}}" class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic"> <swiper wx:if="{{!showGameInfoPanel && !showSystemSettingsPanel}}" class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
<swiper-item> <swiper-item>
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}"> <view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
<view class="race-panel__tag race-panel__tag--top-left">{{panelActionTagText}}</view> <view class="race-panel__tag race-panel__tag--top-left">{{panelActionTagText}}</view>
@@ -155,10 +156,10 @@
</view> </view>
</view> </view>
<view class="race-panel__cell race-panel__cell--timer"> <view class="race-panel__cell race-panel__cell--timer">
<text class="race-panel__timer">{{panelTimerText}}</text> <text class="race-panel__timer {{panelTimerFxClass}}">{{panelTimerText}}</text>
</view> </view>
<view class="race-panel__cell race-panel__cell--mileage"> <view class="race-panel__cell race-panel__cell--mileage">
<view class="race-panel__mileage-wrap"> <view class="race-panel__mileage-wrap {{panelMileageFxClass}}">
<text class="race-panel__mileage">{{panelMileageText}}</text> <text class="race-panel__mileage">{{panelMileageText}}</text>
<view class="race-panel__chevrons"> <view class="race-panel__chevrons">
<view class="race-panel__chevron"></view> <view class="race-panel__chevron"></view>
@@ -167,16 +168,16 @@
</view> </view>
</view> </view>
<view class="race-panel__cell race-panel__cell--distance"> <view class="race-panel__cell race-panel__cell--distance">
<view class="race-panel__metric-group race-panel__metric-group--left"> <view class="race-panel__metric-group race-panel__metric-group--left {{panelDistanceFxClass}}">
<text class="race-panel__metric-value race-panel__metric-value--distance">{{panelDistanceValueText}}</text> <text class="race-panel__metric-value race-panel__metric-value--distance">{{panelDistanceValueText}}</text>
<text class="race-panel__metric-unit race-panel__metric-unit--distance">{{panelDistanceUnitText}}</text> <text class="race-panel__metric-unit race-panel__metric-unit--distance">{{panelDistanceUnitText}}</text>
</view> </view>
</view> </view>
<view class="race-panel__cell race-panel__cell--progress"> <view class="race-panel__cell race-panel__cell--progress">
<text class="race-panel__progress">{{panelProgressText}}</text> <text class="race-panel__progress {{panelProgressFxClass}}">{{panelProgressText}}</text>
</view> </view>
<view class="race-panel__cell race-panel__cell--speed"> <view class="race-panel__cell race-panel__cell--speed">
<view class="race-panel__metric-group race-panel__metric-group--right"> <view class="race-panel__metric-group race-panel__metric-group--right {{panelSpeedFxClass}}">
<text class="race-panel__metric-value race-panel__metric-value--speed">{{panelSpeedValueText}}</text> <text class="race-panel__metric-value race-panel__metric-value--speed">{{panelSpeedValueText}}</text>
<text class="race-panel__metric-unit race-panel__metric-unit--speed">km/h</text> <text class="race-panel__metric-unit race-panel__metric-unit--speed">km/h</text>
</view> </view>
@@ -201,13 +202,13 @@
<view class="race-panel__grid"> <view class="race-panel__grid">
<view class="race-panel__cell race-panel__cell--action"> <view class="race-panel__cell race-panel__cell--action">
<view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel"> <view class="race-panel__metric-group race-panel__metric-group--left race-panel__metric-group--panel {{panelHeartRateFxClass}}">
<text class="race-panel__metric-value race-panel__metric-value--telemetry">{{panelHeartRateValueText}}</text> <text class="race-panel__metric-value race-panel__metric-value--telemetry">{{panelHeartRateValueText}}</text>
<text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelHeartRateUnitText}}</text> <text class="race-panel__metric-unit race-panel__metric-unit--telemetry">{{panelHeartRateUnitText}}</text>
</view> </view>
</view> </view>
<view class="race-panel__cell race-panel__cell--timer"> <view class="race-panel__cell race-panel__cell--timer">
<text class="race-panel__timer">{{panelTimerText}}</text> <text class="race-panel__timer {{panelTimerFxClass}}">{{panelTimerText}}</text>
</view> </view>
<view class="race-panel__cell race-panel__cell--mileage"> <view class="race-panel__cell race-panel__cell--mileage">
<view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel"> <view class="race-panel__metric-group race-panel__metric-group--right race-panel__metric-group--panel">
@@ -237,7 +238,7 @@
</view> </view>
</swiper-item> </swiper-item>
</swiper> </swiper>
<view class="race-panel-pager" wx:if="{{!showDebugPanel && !showGameInfoPanel}}"> <view class="race-panel-pager" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showSystemSettingsPanel}}">
<view class="race-panel-pager__dot {{hudPanelIndex === 0 ? 'race-panel-pager__dot--active' : ''}}"></view> <view class="race-panel-pager__dot {{hudPanelIndex === 0 ? 'race-panel-pager__dot--active' : ''}}"></view>
<view class="race-panel-pager__dot {{hudPanelIndex === 1 ? 'race-panel-pager__dot--active' : ''}}"></view> <view class="race-panel-pager__dot {{hudPanelIndex === 1 ? 'race-panel-pager__dot--active' : ''}}"></view>
</view> </view>
@@ -281,6 +282,93 @@
</view> </view>
</view> </view>
<view class="game-info-modal" wx:if="{{showSystemSettingsPanel}}" bindtap="handleCloseSystemSettingsPanel">
<view class="game-info-modal__dialog" catchtap="handleSystemSettingsPanelTap">
<view class="game-info-modal__header">
<view class="game-info-modal__header-main">
<view class="game-info-modal__eyebrow">SYSTEM SETTINGS</view>
<view class="game-info-modal__title">系统设置</view>
<view class="game-info-modal__subtitle">用户端偏好与设备级选项</view>
</view>
<view class="game-info-modal__header-actions">
<view class="game-info-modal__close" bindtap="handleCloseSystemSettingsPanel">关闭</view>
</view>
</view>
<scroll-view class="game-info-modal__content" scroll-y enhanced show-scrollbar="true">
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__title">01. 动画性能</view>
<view class="debug-section__desc">根据设备性能切换动画强度,低端机建议精简</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前级别</text>
<text class="info-panel__value">{{animationLevel === 'lite' ? '精简' : '标准'}}</text>
</view>
<view class="control-row">
<view class="control-chip {{animationLevel === 'standard' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetAnimationLevelStandard">标准</view>
<view class="control-chip {{animationLevel === 'lite' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetAnimationLevelLite">精简</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__title">02. 比例尺显示</view>
<view class="debug-section__desc">控制比例尺显示与否,默认沿用你的本地偏好</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前状态</text>
<text class="info-panel__value">{{showCenterScaleRuler ? '显示' : '隐藏'}}</text>
</view>
<view class="control-row">
<view class="control-chip {{showCenterScaleRuler ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCenterScaleRulerVisibleOn">显示</view>
<view class="control-chip {{!showCenterScaleRuler ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCenterScaleRulerVisibleOff">隐藏</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__title">03. 比例尺基准点</view>
<view class="debug-section__desc">设置比例尺零点锚定位置,可跟随屏幕中心或指北针圆心</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前锚点</text>
<text class="info-panel__value">{{centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心'}}</text>
</view>
<view class="control-row">
<view class="control-chip {{centerScaleRulerAnchorMode === 'screen-center' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCenterScaleRulerAnchorScreenCenter">屏幕中心</view>
<view class="control-chip {{centerScaleRulerAnchorMode === 'compass-center' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCenterScaleRulerAnchorCompassCenter">指北针圆心</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__title">04. 北参考</view>
<view class="debug-section__desc">切换磁北/真北作为地图与指北针参考</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前参考</text>
<text class="info-panel__value">{{northReferenceText}}</text>
</view>
<view class="control-row">
<view class="control-chip {{northReferenceMode === 'magnetic' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetNorthReferenceMagnetic">磁北</view>
<view class="control-chip {{northReferenceMode === 'true' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetNorthReferenceTrue">真北</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__title">05. 心率设备</view>
<view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
</view>
<view class="control-row">
<view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选设备</view>
</view>
</view>
</scroll-view>
</view>
</view>
<view class="debug-modal" wx:if="{{showDebugPanel}}" bindtap="handleCloseDebugPanel"> <view class="debug-modal" wx:if="{{showDebugPanel}}" bindtap="handleCloseDebugPanel">
<view class="debug-modal__dialog" catchtap="handleDebugPanelTap"> <view class="debug-modal__dialog" catchtap="handleDebugPanelTap">
<view class="debug-modal__header"> <view class="debug-modal__header">
@@ -464,6 +552,19 @@
<text class="info-panel__label">Heading Confidence</text> <text class="info-panel__label">Heading Confidence</text>
<text class="info-panel__value">{{headingConfidenceText}}</text> <text class="info-panel__value">{{headingConfidenceText}}</text>
</view> </view>
<view class="info-panel__row">
<text class="info-panel__label">Compass Source</text>
<text class="info-panel__value">{{compassSourceText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">Compass Tune</text>
<text class="info-panel__value">{{compassTuningProfileText}}</text>
</view>
<view class="control-row">
<view class="control-chip {{compassTuningProfile === 'smooth' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCompassTuningSmooth">顺滑</view>
<view class="control-chip {{compassTuningProfile === 'balanced' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCompassTuningBalanced">平衡</view>
<view class="control-chip {{compassTuningProfile === 'responsive' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetCompassTuningResponsive">跟手</view>
</view>
<view class="info-panel__row info-panel__row--stack"> <view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">Accel</text> <text class="info-panel__label">Accel</text>
<text class="info-panel__value">{{accelerometerText}}</text> <text class="info-panel__value">{{accelerometerText}}</text>

View File

@@ -85,6 +85,10 @@
animation: stage-fx-finish 0.76s ease-out 1; 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 { .map-stage__overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -834,6 +838,10 @@
text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.2); 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 { .race-panel__mileage {
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -851,6 +859,10 @@
transform: translateX(-16rpx); 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 { .race-panel__metric-group {
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -864,11 +876,23 @@
transform: translateX(16rpx); 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 { .race-panel__metric-group--right {
justify-content: center; justify-content: center;
transform: translateX(-16rpx); 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 { .race-panel__metric-value {
line-height: 1; line-height: 1;
text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.16); 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); 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 { .race-panel__zone {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -982,6 +1038,72 @@
right: 0; right: 0;
bottom: 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 { .map-punch-button {
position: absolute; position: absolute;
right: 24rpx; right: 24rpx;
@@ -1593,7 +1715,7 @@
font-size: 24rpx; font-size: 24rpx;
line-height: 1.2; line-height: 1.2;
text-align: left; text-align: left;
z-index: 16; z-index: 40;
pointer-events: auto; pointer-events: auto;
} }
@@ -1603,9 +1725,9 @@
} }
.game-punch-hint__close { .game-punch-hint__close {
width: 40rpx; width: 56rpx;
height: 40rpx; height: 56rpx;
flex: 0 0 40rpx; flex: 0 0 56rpx;
border-radius: 999rpx; border-radius: 999rpx;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1939,3 +2061,21 @@
backdrop-filter: brightness(1); 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);
}
}

View File

@@ -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' ? '精简' : '标准'
}