Files
cmr-mini/miniprogram/engine/map/mapEngine.ts

3971 lines
129 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
import { AccelerometerController } from '../sensor/accelerometerController'
import { CompassHeadingController, type CompassTuningProfile } from '../sensor/compassHeadingController'
import { DeviceMotionController } from '../sensor/deviceMotionController'
import { GyroscopeController } from '../sensor/gyroscopeController'
import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
import { HeartRateInputController } from '../sensor/heartRateInputController'
import { LocationController } from '../sensor/locationController'
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
import { type MapRendererStats } from '../renderer/mapRenderer'
import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
import { GameRuntime } from '../../game/core/gameRuntime'
import { type GameEffect, type GameResult } from '../../game/core/gameResult'
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime'
import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig'
const RENDER_MODE = 'Single WebGL Pipeline'
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
const MAP_NORTH_OFFSET_DEG = 0
let MAGNETIC_DECLINATION_DEG = -6.91
let MAGNETIC_DECLINATION_TEXT = '6.91˚ W'
const MIN_ZOOM = 15
const MAX_ZOOM = 20
const DEFAULT_ZOOM = 17
const DESIRED_VISIBLE_COLUMNS = 3
const OVERDRAW = 1
const DEFAULT_TOP_LEFT_TILE_X = 108132
const DEFAULT_TOP_LEFT_TILE_Y = 51199
const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1
const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1
const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png'
const OSM_TILE_SOURCE = 'https://tiles.mymarsgo.xyz/{z}/{x}/{y}.png'
const MAP_OVERLAY_OPACITY = 0.72
const GPS_MAP_CALIBRATION: MapCalibration = {
offsetEastMeters: 0,
offsetNorthMeters: 0,
rotationDeg: 0,
scale: 1,
}
const MIN_PREVIEW_SCALE = 0.55
const MAX_PREVIEW_SCALE = 1.85
const INERTIA_FRAME_MS = 16
const INERTIA_DECAY = 0.92
const INERTIA_MIN_SPEED = 0.02
const PREVIEW_RESET_DURATION_MS = 140
const UI_SYNC_INTERVAL_MS = 80
const ROTATE_STEP_DEG = 15
const AUTO_ROTATE_FRAME_MS = 8
const AUTO_ROTATE_EASE = 0.34
const AUTO_ROTATE_SNAP_DEG = 0.1
const AUTO_ROTATE_DEADZONE_DEG = 4
const AUTO_ROTATE_MAX_STEP_DEG = 0.75
const AUTO_ROTATE_HEADING_SMOOTHING = 0.46
const COMPASS_NEEDLE_FRAME_MS = 16
const COMPASS_NEEDLE_SNAP_DEG = 0.08
const COMPASS_TUNING_PRESETS: Record<CompassTuningProfile, {
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_MOVEMENT_SPEED_KMH = 3.0
const SMART_HEADING_MIN_DISTANCE_METERS = 12
const SMART_HEADING_MAX_ACCURACY_METERS = 25
const SMART_HEADING_MOVEMENT_MIN_SMOOTHING = 0.12
const SMART_HEADING_MOVEMENT_MAX_SMOOTHING = 0.24
const GPS_TRACK_MAX_POINTS = 200
const GPS_TRACK_MIN_STEP_METERS = 3
const MAP_TAP_MOVE_THRESHOLD_PX = 14
const MAP_TAP_DURATION_MS = 280
type TouchPoint = WechatMiniprogram.TouchDetail
type GestureMode = 'idle' | 'pan' | 'pinch'
type RotationMode = 'manual' | 'auto'
type OrientationMode = 'manual' | 'north-up' | 'heading-up'
type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion' | 'smart'
type SmartHeadingSource = 'sensor' | 'blended' | 'movement'
type NorthReferenceMode = 'magnetic' | 'true'
const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
export interface MapEngineStageRect {
width: number
height: number
left: number
top: number
}
export interface MapEngineViewState {
animationLevel: AnimationLevel
buildVersion: string
renderMode: string
projectionMode: string
mapReady: boolean
mapReadyText: string
mapName: string
configStatusText: string
zoom: number
rotationDeg: number
rotationText: string
rotationMode: RotationMode
rotationModeText: string
rotationToggleText: string
orientationMode: OrientationMode
orientationModeText: string
sensorHeadingText: string
deviceHeadingText: string
devicePoseText: string
headingConfidenceText: string
accelerometerText: string
gyroscopeText: string
deviceMotionText: string
compassSourceText: string
compassTuningProfile: CompassTuningProfile
compassTuningProfileText: string
compassDeclinationText: string
northReferenceMode: NorthReferenceMode
northReferenceButtonText: string
autoRotateSourceText: string
autoRotateCalibrationText: string
northReferenceText: string
compassNeedleDeg: number
centerTileX: number
centerTileY: number
centerText: string
tileSource: string
visibleColumnCount: number
visibleTileCount: number
readyTileCount: number
memoryTileCount: number
diskTileCount: number
memoryHitCount: number
diskHitCount: number
networkFetchCount: number
cacheHitRateText: string
tileTranslateX: number
tileTranslateY: number
tileSizePx: number
previewScale: number
stageWidth: number
stageHeight: number
stageLeft: number
stageTop: number
statusText: string
gpsTracking: boolean
gpsTrackingText: string
gpsLockEnabled: boolean
gpsLockAvailable: boolean
locationSourceMode: 'real' | 'mock'
locationSourceText: string
mockBridgeConnected: boolean
mockBridgeStatusText: string
mockBridgeUrlText: string
mockCoordText: string
mockSpeedText: string
gpsCoordText: string
heartRateSourceMode: 'real' | 'mock'
heartRateSourceText: string
heartRateConnected: boolean
heartRateStatusText: string
heartRateDeviceText: string
heartRateScanText: string
heartRateDiscoveredDevices: Array<{
deviceId: string
name: string
rssiText: string
preferred: boolean
connected: boolean
}>
mockHeartRateBridgeConnected: boolean
mockHeartRateBridgeStatusText: string
mockHeartRateBridgeUrlText: string
mockHeartRateText: string
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
gameModeText: string
panelTimerText: string
panelMileageText: string
panelActionTagText: string
panelDistanceTagText: string
panelDistanceValueText: string
panelDistanceUnitText: string
panelProgressText: string
panelSpeedValueText: string
panelTelemetryTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
panelHeartRateZoneNameText: string
panelHeartRateZoneRangeText: string
panelHeartRateValueText: string
panelHeartRateUnitText: string
panelCaloriesValueText: string
panelCaloriesUnitText: string
panelAverageSpeedValueText: string
panelAverageSpeedUnitText: string
panelAccuracyValueText: string
panelAccuracyUnitText: string
punchButtonText: string
punchButtonEnabled: boolean
skipButtonEnabled: boolean
punchHintText: string
punchFeedbackVisible: boolean
punchFeedbackText: string
punchFeedbackTone: 'neutral' | 'success' | 'warning'
contentCardVisible: boolean
contentCardTitle: string
contentCardBody: string
punchButtonFxClass: string
panelProgressFxClass: string
panelDistanceFxClass: string
punchFeedbackFxClass: string
contentCardFxClass: string
mapPulseVisible: boolean
mapPulseLeftPx: number
mapPulseTopPx: number
mapPulseFxClass: string
stageFxVisible: boolean
stageFxClass: string
osmReferenceEnabled: boolean
osmReferenceText: string
}
export interface MapEngineCallbacks {
onData: (patch: Partial<MapEngineViewState>) => void
}
export interface MapEngineGameInfoRow {
label: string
value: string
}
export interface MapEngineGameInfoSnapshot {
title: string
subtitle: string
localRows: MapEngineGameInfoRow[]
globalRows: MapEngineGameInfoRow[]
}
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'animationLevel',
'buildVersion',
'renderMode',
'projectionMode',
'mapReady',
'mapReadyText',
'mapName',
'configStatusText',
'zoom',
'centerTileX',
'centerTileY',
'rotationDeg',
'rotationText',
'rotationMode',
'rotationModeText',
'rotationToggleText',
'orientationMode',
'orientationModeText',
'sensorHeadingText',
'deviceHeadingText',
'devicePoseText',
'headingConfidenceText',
'accelerometerText',
'gyroscopeText',
'deviceMotionText',
'compassSourceText',
'compassTuningProfile',
'compassTuningProfileText',
'compassDeclinationText',
'northReferenceMode',
'northReferenceButtonText',
'autoRotateSourceText',
'autoRotateCalibrationText',
'northReferenceText',
'compassNeedleDeg',
'centerText',
'tileSource',
'visibleTileCount',
'readyTileCount',
'memoryTileCount',
'diskTileCount',
'memoryHitCount',
'diskHitCount',
'networkFetchCount',
'cacheHitRateText',
'tileSizePx',
'previewScale',
'stageWidth',
'stageHeight',
'stageLeft',
'stageTop',
'statusText',
'gpsTracking',
'gpsTrackingText',
'gpsLockEnabled',
'gpsLockAvailable',
'locationSourceMode',
'locationSourceText',
'mockBridgeConnected',
'mockBridgeStatusText',
'mockBridgeUrlText',
'mockCoordText',
'mockSpeedText',
'gpsCoordText',
'heartRateSourceMode',
'heartRateSourceText',
'heartRateConnected',
'heartRateStatusText',
'heartRateDeviceText',
'heartRateScanText',
'heartRateDiscoveredDevices',
'mockHeartRateBridgeConnected',
'mockHeartRateBridgeStatusText',
'mockHeartRateBridgeUrlText',
'mockHeartRateText',
'gameSessionStatus',
'gameModeText',
'panelTimerText',
'panelMileageText',
'panelActionTagText',
'panelDistanceTagText',
'panelDistanceValueText',
'panelDistanceUnitText',
'panelProgressText',
'panelSpeedValueText',
'panelTelemetryTone',
'panelHeartRateZoneNameText',
'panelHeartRateZoneRangeText',
'panelHeartRateValueText',
'panelHeartRateUnitText',
'panelCaloriesValueText',
'panelCaloriesUnitText',
'panelAverageSpeedValueText',
'panelAverageSpeedUnitText',
'panelAccuracyValueText',
'panelAccuracyUnitText',
'punchButtonText',
'punchButtonEnabled',
'skipButtonEnabled',
'punchHintText',
'punchFeedbackVisible',
'punchFeedbackText',
'punchFeedbackTone',
'contentCardVisible',
'contentCardTitle',
'contentCardBody',
'punchButtonFxClass',
'panelProgressFxClass',
'panelDistanceFxClass',
'punchFeedbackFxClass',
'contentCardFxClass',
'mapPulseVisible',
'mapPulseLeftPx',
'mapPulseTopPx',
'mapPulseFxClass',
'stageFxVisible',
'stageFxClass',
'osmReferenceEnabled',
'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 {
return `z${zoom} / x${x} / y${y}`
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function normalizeRotationDeg(rotationDeg: number): number {
const normalized = rotationDeg % 360
return normalized < 0 ? normalized + 360 : normalized
}
function normalizeAngleDeltaRad(angleDeltaRad: number): number {
let normalized = angleDeltaRad
while (normalized > Math.PI) {
normalized -= Math.PI * 2
}
while (normalized < -Math.PI) {
normalized += Math.PI * 2
}
return normalized
}
function normalizeAngleDeltaDeg(angleDeltaDeg: number): number {
let normalized = angleDeltaDeg
while (normalized > 180) {
normalized -= 360
}
while (normalized < -180) {
normalized += 360
}
return normalized
}
function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number {
return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
}
function getCompassNeedleSmoothingFactor(
currentDeg: number,
targetDeg: number,
profile: CompassTuningProfile,
): number {
const preset = COMPASS_TUNING_PRESETS[profile]
const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg))
if (deltaDeg <= 4) {
return preset.needleMinSmoothing
}
if (deltaDeg >= 36) {
return preset.needleMaxSmoothing
}
const progress = (deltaDeg - 4) / (36 - 4)
return preset.needleMinSmoothing
+ (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress
}
function getMovementHeadingSmoothingFactor(speedKmh: number | null): number {
if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
}
if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
return SMART_HEADING_MOVEMENT_MAX_SMOOTHING
}
const progress = (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH)
/ (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)
return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
+ (SMART_HEADING_MOVEMENT_MAX_SMOOTHING - SMART_HEADING_MOVEMENT_MIN_SMOOTHING) * progress
}
function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string {
if (status === 'running') {
return '进行中'
}
if (status === 'finished') {
return '已结束'
}
if (status === 'failed') {
return '已失败'
}
return '未开始'
}
function formatRotationText(rotationDeg: number): string {
return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
}
function normalizeDegreeDisplayText(text: string): string {
return text.replace(/[掳•˚]/g, '°')
}
function formatHeadingText(headingDeg: number | null): string {
if (headingDeg === null) {
return '--'
}
return `${Math.round(normalizeRotationDeg(headingDeg))}°`
}
function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
if (pose === 'flat') {
return '平放'
}
if (pose === 'tilted') {
return '倾斜'
}
return '竖持'
}
function formatHeadingConfidenceText(confidence: 'low' | 'medium' | 'high'): string {
if (confidence === 'high') {
return '高'
}
if (confidence === 'medium') {
return '中'
}
return '低'
}
function formatClockTime(timestamp: number | null): string {
if (!timestamp || !Number.isFinite(timestamp)) {
return '--:--:--'
}
const date = new Date(timestamp)
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
const ss = String(date.getSeconds()).padStart(2, '0')
return `${hh}:${mm}:${ss}`
}
function formatGyroscopeText(gyroscope: { x: number; y: number; z: number } | null): string {
if (!gyroscope) {
return '--'
}
return `x:${gyroscope.x.toFixed(2)} y:${gyroscope.y.toFixed(2)} z:${gyroscope.z.toFixed(2)}`
}
function formatDeviceMotionText(motion: { alpha: number | null; beta: number | null; gamma: number | null } | null): string {
if (!motion) {
return '--'
}
const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha))
const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta)
const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma)
return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}`
}
function formatOrientationModeText(mode: OrientationMode): string {
if (mode === 'north-up') {
return 'North Up'
}
if (mode === 'heading-up') {
return 'Heading Up'
}
return 'Manual Gesture'
}
function formatRotationModeText(mode: OrientationMode): string {
return formatOrientationModeText(mode)
}
function formatRotationToggleText(mode: OrientationMode): string {
if (mode === 'manual') {
return '切到北朝上'
}
if (mode === 'north-up') {
return '切到朝向朝上'
}
return '切到手动旋转'
}
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
if (mode === 'smart') {
return 'Smart / 手机朝向'
}
if (mode === 'sensor') {
return 'Sensor Only'
}
if (mode === 'course') {
return hasCourseHeading ? 'Course Only' : 'Course Pending'
}
return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
}
function formatSmartHeadingSourceText(source: SmartHeadingSource): string {
if (source === 'movement') {
return 'Smart / 前进方向'
}
if (source === 'blended') {
return 'Smart / 融合'
}
return 'Smart / 手机朝向'
}
function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
if (pending) {
return 'Pending'
}
if (offsetDeg === null) {
return '--'
}
return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg`
}
function getTrueHeadingDeg(magneticHeadingDeg: number): number {
return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
}
function getMagneticHeadingDeg(trueHeadingDeg: number): number {
return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
}
function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
return MAP_NORTH_OFFSET_DEG
}
function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
if (mode === 'true') {
return getTrueHeadingDeg(magneticHeadingDeg)
}
return normalizeRotationDeg(magneticHeadingDeg)
}
function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
if (mode === 'magnetic') {
return normalizeRotationDeg(magneticHeadingDeg)
}
return getTrueHeadingDeg(magneticHeadingDeg)
}
function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
if (mode === 'magnetic') {
return getMagneticHeadingDeg(trueHeadingDeg)
}
return normalizeRotationDeg(trueHeadingDeg)
}
function formatNorthReferenceText(mode: NorthReferenceMode): string {
if (mode === 'magnetic') {
return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
}
return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
}
function formatCompassDeclinationText(mode: NorthReferenceMode): string {
if (mode === 'true') {
return MAGNETIC_DECLINATION_TEXT
}
return ''
}
function formatCompassSourceText(source: 'compass' | 'motion' | null): string {
if (source === 'compass') {
return '罗盘'
}
if (source === 'motion') {
return '设备方向兜底'
}
return '无数据'
}
function formatCompassTuningProfileText(profile: CompassTuningProfile): string {
if (profile === 'smooth') {
return '顺滑'
}
if (profile === 'responsive') {
return '跟手'
}
return '平衡'
}
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北'
}
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
if (mode === 'magnetic') {
return '已切到磁北模式'
}
return '已切到真北模式'
}
function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
return mode === 'magnetic' ? 'true' : 'magnetic'
}
function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
if (magneticHeadingDeg === null) {
return 0
}
const referenceHeadingDeg = mode === 'true'
? getTrueHeadingDeg(magneticHeadingDeg)
: normalizeRotationDeg(magneticHeadingDeg)
return normalizeRotationDeg(360 - referenceHeadingDeg)
}
function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string {
const total = memoryHitCount + diskHitCount + networkFetchCount
if (!total) {
return '--'
}
const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
return `${Math.round(hitRate)}%`
}
function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string {
if (!point) {
return '--'
}
const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}`
if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) {
return base
}
return `${base} / 卤${Math.round(accuracyMeters)}m`
}
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
const dy = (b.lat - a.lat) * 110540
return Math.sqrt(dx * dx + dy * dy)
}
function resolveSmartHeadingSource(speedKmh: number | null, movementReliable: boolean): SmartHeadingSource {
if (!movementReliable || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
return 'sensor'
}
if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
return 'movement'
}
return 'blended'
}
function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
const fromLatRad = from.lat * Math.PI / 180
const toLatRad = to.lat * Math.PI / 180
const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
return normalizeRotationDeg(bearingDeg)
}
export class MapEngine {
buildVersion: string
animationLevel: AnimationLevel
renderer: WebGLMapRenderer
accelerometerController: AccelerometerController
compassController: CompassHeadingController
gyroscopeController: GyroscopeController
deviceMotionController: DeviceMotionController
locationController: LocationController
heartRateController: HeartRateInputController
feedbackDirector: FeedbackDirector
onData: (patch: Partial<MapEngineViewState>) => void
state: MapEngineViewState
accelerometerErrorText: string | null
previewScale: number
previewOriginX: number
previewOriginY: number
panLastX: number
panLastY: number
panLastTimestamp: number
tapStartX: number
tapStartY: number
tapStartAt: number
panVelocityX: number
panVelocityY: number
pinchStartDistance: number
pinchStartScale: number
pinchStartAngle: number
pinchStartRotationDeg: number
pinchAnchorWorldX: number
pinchAnchorWorldY: number
gestureMode: GestureMode
inertiaTimer: number
previewResetTimer: number
viewSyncTimer: number
autoRotateTimer: number
compassNeedleTimer: number
pendingViewPatch: Partial<MapEngineViewState>
mounted: boolean
diagnosticUiEnabled: boolean
northReferenceMode: NorthReferenceMode
sensorHeadingDeg: number | null
smoothedSensorHeadingDeg: number | null
compassDisplayHeadingDeg: number | null
targetCompassDisplayHeadingDeg: number | null
compassSource: 'compass' | 'motion' | null
compassTuningProfile: CompassTuningProfile
smoothedMovementHeadingDeg: number | null
autoRotateHeadingDeg: number | null
courseHeadingDeg: number | null
targetAutoRotationDeg: number | null
autoRotateSourceMode: AutoRotateSourceMode
autoRotateCalibrationOffsetDeg: number | null
autoRotateCalibrationPending: boolean
lastStatsUiSyncAt: number
minZoom: number
maxZoom: number
defaultZoom: number
defaultCenterTileX: number
defaultCenterTileY: number
tileBoundsByZoom: Record<number, TileZoomBounds> | null
currentGpsPoint: LonLatPoint | null
currentGpsTrack: LonLatPoint[]
currentGpsAccuracyMeters: number | null
currentGpsInsideMap: boolean
courseData: OrienteeringCourseData | null
courseOverlayVisible: boolean
cpRadiusMeters: number
configAppId: string
configSchemaVersion: string
configVersion: string
controlScoreOverrides: Record<string, number>
defaultControlScore: number | null
gameRuntime: GameRuntime
telemetryRuntime: TelemetryRuntime
gamePresentation: GamePresentationState
gameMode: 'classic-sequential' | 'score-o'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
requiresFocusSelection: boolean
skipEnabled: boolean
skipRadiusMeters: number
skipRequiresConfirm: boolean
autoFinishOnLastControl: boolean
punchFeedbackTimer: number
contentCardTimer: number
mapPulseTimer: number
stageFxTimer: number
sessionTimerInterval: number
hasGpsCenteredOnce: boolean
gpsLockEnabled: boolean
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
this.buildVersion = buildVersion
this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
this.compassTuningProfile = 'balanced'
this.onData = callbacks.onData
this.accelerometerErrorText = null
this.renderer = new WebGLMapRenderer(
(stats) => {
this.applyStats(stats)
},
(message) => {
this.setState({
statusText: `${message} (${this.buildVersion})`,
})
},
)
this.accelerometerController = new AccelerometerController({
onSample: (x, y, z) => {
this.accelerometerErrorText = null
this.telemetryRuntime.dispatch({
type: 'accelerometer_updated',
at: Date.now(),
x,
y,
z,
})
if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch())
}
},
onError: (message) => {
this.accelerometerErrorText = `不可用: ${message}`
if (this.diagnosticUiEnabled) {
this.setState({
...this.getTelemetrySensorViewPatch(),
statusText: `加速度计启动失败 (${this.buildVersion})`,
})
}
},
})
this.compassController = new CompassHeadingController({
onHeading: (headingDeg) => {
this.handleCompassHeading(headingDeg)
},
onError: (message) => {
this.handleCompassError(message)
},
})
this.compassController.setTuningProfile(this.compassTuningProfile)
this.gyroscopeController = new GyroscopeController({
onSample: (x, y, z) => {
this.telemetryRuntime.dispatch({
type: 'gyroscope_updated',
at: Date.now(),
x,
y,
z,
})
if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch())
}
},
onError: () => {
if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch())
}
},
})
this.deviceMotionController = new DeviceMotionController({
onSample: (alpha, beta, gamma) => {
this.telemetryRuntime.dispatch({
type: 'device_motion_updated',
at: Date.now(),
alpha,
beta,
gamma,
})
if (this.diagnosticUiEnabled) {
this.setState({
...this.getTelemetrySensorViewPatch(),
autoRotateSourceText: this.getAutoRotateSourceText(),
})
}
},
onError: () => {
if (this.diagnosticUiEnabled) {
this.setState(this.getTelemetrySensorViewPatch())
}
},
})
this.locationController = new LocationController({
onLocation: (update) => {
this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
},
onStatus: (message) => {
this.setState({
gpsTracking: this.locationController.listening,
gpsTrackingText: message,
...this.getLocationControllerViewPatch(),
})
},
onError: (message) => {
this.setState({
gpsTracking: this.locationController.listening,
gpsTrackingText: message,
...this.getLocationControllerViewPatch(),
statusText: `${message} (${this.buildVersion})`,
})
},
onDebugStateChange: () => {
if (this.diagnosticUiEnabled) {
this.setState(this.getLocationControllerViewPatch())
}
},
})
this.heartRateController = new HeartRateInputController({
onHeartRate: (bpm) => {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm,
})
this.syncSessionTimerText()
},
onStatus: (message) => {
const deviceName = this.heartRateController.currentDeviceName
|| (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null)
|| '--'
this.setState({
heartRateStatusText: message,
heartRateDeviceText: deviceName,
heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(),
})
},
onError: (message) => {
this.clearHeartRateSignal()
const deviceName = this.heartRateController.reconnecting
? (this.heartRateController.lastDeviceName || '--')
: '--'
this.setState({
heartRateConnected: false,
heartRateStatusText: message,
heartRateDeviceText: deviceName,
heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(),
statusText: `${message} (${this.buildVersion})`,
})
},
onConnectionChange: (connected, deviceName) => {
if (!connected) {
this.clearHeartRateSignal()
}
const resolvedDeviceName = connected
? (deviceName || '--')
: (this.heartRateController.reconnecting
? (this.heartRateController.lastDeviceName || '--')
: '--')
this.setState({
heartRateConnected: connected,
heartRateDeviceText: resolvedDeviceName,
heartRateStatusText: connected
? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
: (this.heartRateController.reconnecting ? '心率带自动重连中' : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接')),
heartRateScanText: this.getHeartRateScanText(),
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
...this.getHeartRateControllerViewPatch(),
})
},
onDeviceListChange: (devices) => {
if (this.diagnosticUiEnabled) {
this.setState({
heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(),
})
}
},
onDebugStateChange: () => {
if (this.diagnosticUiEnabled) {
this.setState(this.getHeartRateControllerViewPatch())
}
},
})
this.feedbackDirector = new FeedbackDirector({
showPunchFeedback: (text, tone, motionClass) => {
this.showPunchFeedback(text, tone, motionClass)
},
showContentCard: (title, body, motionClass) => {
this.showContentCard(title, body, motionClass)
},
setPunchButtonFxClass: (className) => {
this.setPunchButtonFxClass(className)
},
setHudProgressFxClass: (className) => {
this.setHudProgressFxClass(className)
},
setHudDistanceFxClass: (className) => {
this.setHudDistanceFxClass(className)
},
showMapPulse: (controlId, motionClass) => {
this.showMapPulse(controlId, motionClass)
},
showStageFx: (className) => {
this.showStageFx(className)
},
stopLocationTracking: () => {
if (this.locationController.listening) {
this.locationController.stop()
}
},
})
this.feedbackDirector.setAnimationLevel(this.animationLevel)
this.minZoom = MIN_ZOOM
this.maxZoom = MAX_ZOOM
this.defaultZoom = DEFAULT_ZOOM
this.defaultCenterTileX = DEFAULT_CENTER_TILE_X
this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y
this.tileBoundsByZoom = null
this.currentGpsPoint = null
this.currentGpsTrack = []
this.currentGpsAccuracyMeters = null
this.currentGpsInsideMap = false
this.courseData = null
this.courseOverlayVisible = false
this.cpRadiusMeters = 5
this.configAppId = ''
this.configSchemaVersion = '1'
this.configVersion = ''
this.controlScoreOverrides = {}
this.defaultControlScore = null
this.gameRuntime = new GameRuntime()
this.telemetryRuntime = new TelemetryRuntime()
this.telemetryRuntime.configure()
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
this.gameMode = 'classic-sequential'
this.punchPolicy = 'enter-confirm'
this.punchRadiusMeters = 5
this.requiresFocusSelection = false
this.skipEnabled = false
this.skipRadiusMeters = 30
this.skipRequiresConfirm = true
this.autoFinishOnLastControl = true
this.gpsLockEnabled = false
this.punchFeedbackTimer = 0
this.contentCardTimer = 0
this.mapPulseTimer = 0
this.stageFxTimer = 0
this.sessionTimerInterval = 0
this.hasGpsCenteredOnce = false
this.state = {
animationLevel: this.animationLevel,
buildVersion: this.buildVersion,
renderMode: RENDER_MODE,
projectionMode: PROJECTION_MODE,
mapReady: false,
mapReadyText: 'BOOTING',
mapName: '未命名配置',
configStatusText: '远程配置待加载',
zoom: DEFAULT_ZOOM,
rotationDeg: 0,
rotationText: formatRotationText(0),
rotationMode: 'manual',
rotationModeText: formatRotationModeText('manual'),
rotationToggleText: formatRotationToggleText('manual'),
orientationMode: 'manual',
orientationModeText: formatOrientationModeText('manual'),
sensorHeadingText: '--',
deviceHeadingText: '--',
devicePoseText: '竖持',
headingConfidenceText: '低',
accelerometerText: '未启用',
gyroscopeText: '--',
deviceMotionText: '--',
compassSourceText: '无数据',
compassTuningProfile: this.compassTuningProfile,
compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
northReferenceMode: DEFAULT_NORTH_REFERENCE_MODE,
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
autoRotateSourceText: formatAutoRotateSourceText('smart', false),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
compassNeedleDeg: 0,
centerTileX: DEFAULT_CENTER_TILE_X,
centerTileY: DEFAULT_CENTER_TILE_Y,
centerText: buildCenterText(DEFAULT_ZOOM, DEFAULT_CENTER_TILE_X, DEFAULT_CENTER_TILE_Y),
tileSource: TILE_SOURCE,
visibleColumnCount: DESIRED_VISIBLE_COLUMNS,
visibleTileCount: 0,
readyTileCount: 0,
memoryTileCount: 0,
diskTileCount: 0,
memoryHitCount: 0,
diskHitCount: 0,
networkFetchCount: 0,
cacheHitRateText: '--',
tileTranslateX: 0,
tileTranslateY: 0,
tileSizePx: 0,
previewScale: 1,
stageWidth: 0,
stageHeight: 0,
stageLeft: 0,
stageTop: 0,
statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
gpsTracking: false,
gpsTrackingText: '持续定位待启动',
gpsLockEnabled: false,
gpsLockAvailable: false,
locationSourceMode: 'real',
locationSourceText: '真实定位',
mockBridgeConnected: false,
mockBridgeStatusText: '未连接',
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
mockCoordText: '--',
mockSpeedText: '--',
gpsCoordText: '--',
heartRateSourceMode: 'real',
heartRateSourceText: '真实心率',
heartRateConnected: false,
heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--',
heartRateScanText: '未扫描',
heartRateDiscoveredDevices: [],
mockHeartRateBridgeConnected: false,
mockHeartRateBridgeStatusText: '未连接',
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
mockHeartRateText: '--',
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelActionTagText: '目标',
panelDistanceTagText: '点距',
panelDistanceValueText: '--',
panelDistanceUnitText: '',
panelProgressText: '0/0',
panelSpeedValueText: '0',
panelTelemetryTone: 'blue',
panelHeartRateZoneNameText: '激活放松',
panelHeartRateZoneRangeText: '<=39%',
panelHeartRateValueText: '--',
panelHeartRateUnitText: '',
panelCaloriesValueText: '0',
panelCaloriesUnitText: 'kcal',
panelAverageSpeedValueText: '0',
panelAverageSpeedUnitText: 'km/h',
panelAccuracyValueText: '--',
panelAccuracyUnitText: '',
punchButtonText: '打点',
gameSessionStatus: 'idle',
gameModeText: '顺序赛',
punchButtonEnabled: false,
skipButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
punchFeedbackFxClass: '',
contentCardFxClass: '',
mapPulseVisible: false,
mapPulseLeftPx: 0,
mapPulseTopPx: 0,
mapPulseFxClass: '',
stageFxVisible: false,
stageFxClass: '',
osmReferenceEnabled: false,
osmReferenceText: 'OSM参考关',
}
this.previewScale = 1
this.previewOriginX = 0
this.previewOriginY = 0
this.panLastX = 0
this.panLastY = 0
this.panLastTimestamp = 0
this.tapStartX = 0
this.tapStartY = 0
this.tapStartAt = 0
this.panVelocityX = 0
this.panVelocityY = 0
this.pinchStartDistance = 0
this.pinchStartScale = 1
this.pinchStartAngle = 0
this.pinchStartRotationDeg = 0
this.pinchAnchorWorldX = 0
this.pinchAnchorWorldY = 0
this.gestureMode = 'idle'
this.inertiaTimer = 0
this.previewResetTimer = 0
this.viewSyncTimer = 0
this.autoRotateTimer = 0
this.compassNeedleTimer = 0
this.pendingViewPatch = {}
this.mounted = false
this.diagnosticUiEnabled = false
this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
this.sensorHeadingDeg = null
this.smoothedSensorHeadingDeg = null
this.compassDisplayHeadingDeg = null
this.targetCompassDisplayHeadingDeg = null
this.compassSource = null
this.compassTuningProfile = 'balanced'
this.smoothedMovementHeadingDeg = null
this.autoRotateHeadingDeg = null
this.courseHeadingDeg = null
this.targetAutoRotationDeg = null
this.autoRotateSourceMode = 'smart'
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
this.autoRotateCalibrationPending = false
this.lastStatsUiSyncAt = 0
}
getInitialData(): MapEngineViewState {
return { ...this.state }
}
setDiagnosticUiEnabled(enabled: boolean): void {
if (this.diagnosticUiEnabled === enabled) {
return
}
this.diagnosticUiEnabled = enabled
if (!enabled) {
return
}
this.setState({
...this.getTelemetrySensorViewPatch(),
...this.getLocationControllerViewPatch(),
...this.getHeartRateControllerViewPatch(),
heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
autoRotateSourceText: this.getAutoRotateSourceText(),
visibleTileCount: this.state.visibleTileCount,
readyTileCount: this.state.readyTileCount,
memoryTileCount: this.state.memoryTileCount,
diskTileCount: this.state.diskTileCount,
memoryHitCount: this.state.memoryHitCount,
diskHitCount: this.state.diskHitCount,
networkFetchCount: this.state.networkFetchCount,
cacheHitRateText: this.state.cacheHitRateText,
}, true)
}
getGameInfoSnapshot(): MapEngineGameInfoSnapshot {
const definition = this.gameRuntime.definition
const sessionState = this.gameRuntime.state
const telemetryState = this.telemetryRuntime.state
const telemetryPresentation = this.telemetryRuntime.getPresentation()
const currentTarget = definition && sessionState
? definition.controls.find((control) => control.id === sessionState.currentTargetControlId) || null
: null
const currentTargetText = currentTarget
? `${currentTarget.label} / ${currentTarget.kind === 'start'
? '开始点'
: currentTarget.kind === 'finish'
? '结束点'
: '检查点'}`
: '--'
const title = this.state.mapName || (definition ? definition.title : '当前游戏')
const subtitle = `${this.getGameModeText()} / ${formatGameSessionStatusText(this.state.gameSessionStatus)}`
const localRows: MapEngineGameInfoRow[] = [
{ label: '比赛名称', value: title || '--' },
{ label: '配置版本', value: this.configVersion || '--' },
{ label: 'Schema版本', value: this.configSchemaVersion || '--' },
{ label: '活动ID', value: this.configAppId || '--' },
{ label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) },
{ label: '地图', value: this.state.mapName || '--' },
{ label: '模式', value: this.getGameModeText() },
{ label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) },
{ label: '当前目标', value: currentTargetText },
{ label: '进度', value: this.gamePresentation.hud.progressText || '--' },
{ label: '当前积分', value: sessionState ? String(sessionState.score) : '0' },
{ label: '已完成点', value: sessionState ? String(sessionState.completedControlIds.length) : '0' },
{ label: '已跳过点', value: sessionState ? String(sessionState.skippedControlIds.length) : '0' },
{ label: '打点规则', value: `${this.punchPolicy} / ${this.punchRadiusMeters}m` },
{ label: '跳点规则', value: this.skipEnabled ? `${this.skipRadiusMeters}m / ${this.skipRequiresConfirm ? '确认跳过' : '直接跳过'}` : '关闭' },
{ label: '定位源', value: this.state.locationSourceText || '--' },
{ label: '当前位置', value: this.state.gpsCoordText || '--' },
{ label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` },
{ label: '设备朝向', value: this.state.deviceHeadingText || '--' },
{ label: '设备姿态', value: this.state.devicePoseText || '--' },
{ label: '朝向可信度', value: this.state.headingConfidenceText || '--' },
{ label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' },
{ label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
{ label: '心率源', value: this.state.heartRateSourceText || '--' },
{ label: '当前心率', value: this.state.panelHeartRateValueText === '--' ? '--' : `${this.state.panelHeartRateValueText}${this.state.panelHeartRateUnitText}` },
{ label: '心率设备', value: this.state.heartRateDeviceText || '--' },
{ label: '心率分区', value: this.state.panelHeartRateZoneNameText === '--' ? '--' : `${this.state.panelHeartRateZoneNameText} ${this.state.panelHeartRateZoneRangeText}` },
{ label: '本局用时', value: telemetryPresentation.timerText },
{ label: '累计里程', value: telemetryPresentation.mileageText },
{ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
{ label: '提示状态', value: this.state.punchHintText || '--' },
]
const globalRows: MapEngineGameInfoRow[] = [
{ label: '全球积分', value: '未接入' },
{ label: '全球排名', value: '未接入' },
{ label: '在线人数', value: '未接入' },
{ label: '队伍状态', value: '未接入' },
{ label: '实时广播', value: '未接入' },
]
return {
title,
subtitle,
localRows,
globalRows,
}
}
destroy(): void {
this.clearInertiaTimer()
this.clearPreviewResetTimer()
this.clearViewSyncTimer()
this.clearAutoRotateTimer()
this.clearCompassNeedleTimer()
this.clearPunchFeedbackTimer()
this.clearContentCardTimer()
this.clearMapPulseTimer()
this.clearStageFxTimer()
this.clearSessionTimerInterval()
this.accelerometerController.destroy()
this.compassController.destroy()
this.gyroscopeController.destroy()
this.deviceMotionController.destroy()
this.locationController.destroy()
this.heartRateController.destroy()
this.feedbackDirector.destroy()
this.renderer.destroy()
this.mounted = false
}
handleAppShow(): void {
this.feedbackDirector.setAppAudioMode('foreground')
}
handleAppHide(): void {
this.feedbackDirector.setAppAudioMode('foreground')
}
clearGameRuntime(): void {
this.gameRuntime.clear()
this.telemetryRuntime.reset()
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
this.courseOverlayVisible = !!this.courseData
this.clearSessionTimerInterval()
this.setCourseHeading(null)
}
clearHeartRateSignal(): void {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm: null,
})
this.syncSessionTimerText()
}
clearFinishedTestOverlay(): void {
this.currentGpsPoint = null
this.currentGpsTrack = []
this.currentGpsAccuracyMeters = null
this.currentGpsInsideMap = false
this.smoothedMovementHeadingDeg = null
this.courseOverlayVisible = false
this.setCourseHeading(null)
}
clearStartSessionResidue(): void {
this.currentGpsTrack = []
this.smoothedMovementHeadingDeg = null
this.courseOverlayVisible = false
this.setCourseHeading(null)
}
handleClearMapTestArtifacts(): void {
this.clearFinishedTestOverlay()
this.setState({
gpsTracking: false,
gpsTrackingText: '测试痕迹已清空',
gpsCoordText: '--',
statusText: `已清空地图点位与轨迹 (${this.buildVersion})`,
}, true)
this.syncRenderer()
}
getHudTargetControlId(): string | null {
return this.gamePresentation.hud.hudTargetControlId
}
isSkipAvailable(): boolean {
const definition = this.gameRuntime.definition
const state = this.gameRuntime.state
if (!definition || !state || state.status !== 'running' || !definition.skipEnabled) {
return false
}
const currentTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
if (!currentTarget || currentTarget.kind !== 'control' || !this.currentGpsPoint) {
return false
}
const avgLatRad = ((currentTarget.point.lat + this.currentGpsPoint.lat) / 2) * Math.PI / 180
const dx = (this.currentGpsPoint.lon - currentTarget.point.lon) * 111320 * Math.cos(avgLatRad)
const dy = (this.currentGpsPoint.lat - currentTarget.point.lat) * 110540
const distanceMeters = Math.sqrt(dx * dx + dy * dy)
return distanceMeters <= definition.skipRadiusMeters
}
shouldConfirmSkipAction(): boolean {
return !!(this.gameRuntime.definition && this.gameRuntime.definition.skipRequiresConfirm)
}
getLocationControllerViewPatch(): Partial<MapEngineViewState> {
const debugState = this.locationController.getDebugState()
return {
gpsTracking: debugState.listening,
gpsLockEnabled: this.gpsLockEnabled,
gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
locationSourceMode: debugState.sourceMode,
locationSourceText: debugState.sourceModeText,
mockBridgeConnected: debugState.mockBridgeConnected,
mockBridgeStatusText: debugState.mockBridgeStatusText,
mockBridgeUrlText: debugState.mockBridgeUrlText,
mockCoordText: debugState.mockCoordText,
mockSpeedText: debugState.mockSpeedText,
}
}
getHeartRateControllerViewPatch(): Partial<MapEngineViewState> {
const debugState = this.heartRateController.getDebugState()
return {
heartRateSourceMode: debugState.sourceMode,
heartRateSourceText: debugState.sourceModeText,
mockHeartRateBridgeConnected: debugState.mockBridgeConnected,
mockHeartRateBridgeStatusText: debugState.mockBridgeStatusText,
mockHeartRateBridgeUrlText: debugState.mockBridgeUrlText,
mockHeartRateText: debugState.mockHeartRateText,
}
}
getTelemetrySensorViewPatch(): Partial<MapEngineViewState> {
const telemetryState = this.telemetryRuntime.state
return {
deviceHeadingText: formatHeadingText(
telemetryState.deviceHeadingDeg === null
? null
: getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg),
),
devicePoseText: formatDevicePoseText(telemetryState.devicePose),
headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence),
accelerometerText: telemetryState.accelerometer
? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}`
: '未启用',
gyroscopeText: formatGyroscopeText(telemetryState.gyroscope),
deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion),
compassSourceText: formatCompassSourceText(this.compassSource),
compassTuningProfile: this.compassTuningProfile,
compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
}
}
getGameModeText(): string {
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
}
loadGameDefinitionFromCourse(): GameResult | null {
if (!this.courseData) {
this.clearGameRuntime()
return null
}
const definition = buildGameDefinitionFromCourse(
this.courseData,
this.cpRadiusMeters,
this.gameMode,
this.autoFinishOnLastControl,
this.punchPolicy,
this.punchRadiusMeters,
this.requiresFocusSelection,
this.skipEnabled,
this.skipRadiusMeters,
this.skipRequiresConfirm,
this.controlScoreOverrides,
this.defaultControlScore,
)
const result = this.gameRuntime.loadDefinition(definition)
this.telemetryRuntime.loadDefinition(definition)
this.courseOverlayVisible = true
this.syncGameResultState(result)
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, result.presentation.hud.hudTargetControlId)
this.updateSessionTimerLoop()
return result
}
refreshCourseHeadingFromPresentation(): void {
if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) {
this.setCourseHeading(null)
return
}
const activeLegIndex = this.gamePresentation.map.activeLegIndices[0]
const activeLeg = this.courseData.layers.legs[activeLegIndex]
if (!activeLeg) {
this.setCourseHeading(null)
return
}
this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint))
}
resolveGameStatusText(effects: GameEffect[]): string | null {
const lastEffect = effects.length ? effects[effects.length - 1] : null
if (!lastEffect) {
return null
}
if (lastEffect.type === 'control_completed') {
const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId
return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})`
}
if (lastEffect.type === 'session_finished') {
return `璺嚎宸插畬鎴?(${this.buildVersion})`
}
if (lastEffect.type === 'session_started') {
return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})`
}
return null
}
getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
const telemetryPresentation = this.telemetryRuntime.getPresentation()
const patch: Partial<MapEngineViewState> = {
gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
gameModeText: this.getGameModeText(),
panelTimerText: telemetryPresentation.timerText,
panelMileageText: telemetryPresentation.mileageText,
panelActionTagText: this.gamePresentation.hud.actionTagText,
panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
panelSpeedValueText: telemetryPresentation.speedText,
panelTelemetryTone: telemetryPresentation.heartRateTone,
panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
panelHeartRateValueText: telemetryPresentation.heartRateValueText,
panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
panelCaloriesValueText: telemetryPresentation.caloriesValueText,
panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
panelProgressText: this.gamePresentation.hud.progressText,
punchButtonText: this.gamePresentation.hud.punchButtonText,
punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
skipButtonEnabled: this.isSkipAvailable(),
punchHintText: this.gamePresentation.hud.punchHintText,
gpsLockEnabled: this.gpsLockEnabled,
gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
}
if (statusText) {
patch.statusText = statusText
}
return patch
}
clearPunchFeedbackTimer(): void {
if (this.punchFeedbackTimer) {
clearTimeout(this.punchFeedbackTimer)
this.punchFeedbackTimer = 0
}
}
clearContentCardTimer(): void {
if (this.contentCardTimer) {
clearTimeout(this.contentCardTimer)
this.contentCardTimer = 0
}
}
clearMapPulseTimer(): void {
if (this.mapPulseTimer) {
clearTimeout(this.mapPulseTimer)
this.mapPulseTimer = 0
}
}
clearStageFxTimer(): void {
if (this.stageFxTimer) {
clearTimeout(this.stageFxTimer)
this.stageFxTimer = 0
}
}
resetTransientGameUiState(): void {
this.clearPunchFeedbackTimer()
this.clearContentCardTimer()
this.clearMapPulseTimer()
this.clearStageFxTimer()
this.setState({
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
punchFeedbackFxClass: '',
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
contentCardFxClass: '',
mapPulseVisible: false,
mapPulseFxClass: '',
stageFxVisible: false,
stageFxClass: '',
punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
}, true)
}
clearSessionTimerInterval(): void {
if (this.sessionTimerInterval) {
clearInterval(this.sessionTimerInterval)
this.sessionTimerInterval = 0
}
}
syncSessionTimerText(): void {
const telemetryPresentation = this.telemetryRuntime.getPresentation()
this.setState({
panelTimerText: telemetryPresentation.timerText,
panelMileageText: telemetryPresentation.mileageText,
panelActionTagText: this.gamePresentation.hud.actionTagText,
panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
panelSpeedValueText: telemetryPresentation.speedText,
panelTelemetryTone: telemetryPresentation.heartRateTone,
panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
panelHeartRateValueText: telemetryPresentation.heartRateValueText,
panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
panelCaloriesValueText: telemetryPresentation.caloriesValueText,
panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
})
}
updateSessionTimerLoop(): void {
const gameState = this.gameRuntime.state
const shouldRun = !!gameState && gameState.status === 'running' && gameState.endedAt === null
this.syncSessionTimerText()
if (!shouldRun) {
this.clearSessionTimerInterval()
return
}
if (this.sessionTimerInterval) {
return
}
this.sessionTimerInterval = setInterval(() => {
this.syncSessionTimerText()
}, 1000) as unknown as number
}
getControlScreenPoint(controlId: string): { x: number; y: number } | null {
if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
return null
}
const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
if (!control) {
return null
}
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const screenPoint = worldToScreen({
centerWorldX: exactCenter.x,
centerWorldY: exactCenter.y,
viewportWidth: this.state.stageWidth,
viewportHeight: this.state.stageHeight,
visibleColumns: DESIRED_VISIBLE_COLUMNS,
rotationRad: this.getRotationRad(this.state.rotationDeg),
}, lonLatToWorldTile(control.point, this.state.zoom), false)
if (screenPoint.x < -80 || screenPoint.x > this.state.stageWidth + 80 || screenPoint.y < -80 || screenPoint.y > this.state.stageHeight + 80) {
return null
}
return screenPoint
}
setPunchButtonFxClass(className: string): void {
this.setState({
punchButtonFxClass: className,
}, true)
}
setHudProgressFxClass(className: string): void {
this.setState({
panelProgressFxClass: className,
}, true)
}
setHudDistanceFxClass(className: string): void {
this.setState({
panelDistanceFxClass: className,
}, true)
}
showMapPulse(controlId: string, motionClass = ''): void {
const screenPoint = this.getControlScreenPoint(controlId)
if (!screenPoint) {
return
}
this.clearMapPulseTimer()
this.setState({
mapPulseVisible: true,
mapPulseLeftPx: screenPoint.x,
mapPulseTopPx: screenPoint.y,
mapPulseFxClass: motionClass,
}, true)
this.mapPulseTimer = setTimeout(() => {
this.mapPulseTimer = 0
this.setState({
mapPulseVisible: false,
mapPulseFxClass: '',
}, true)
}, 820) as unknown as number
}
showStageFx(className: string): void {
if (!className) {
return
}
this.clearStageFxTimer()
this.setState({
stageFxVisible: true,
stageFxClass: className,
}, true)
this.stageFxTimer = setTimeout(() => {
this.stageFxTimer = 0
this.setState({
stageFxVisible: false,
stageFxClass: '',
}, true)
}, 760) as unknown as number
}
showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning', motionClass = ''): void {
this.clearPunchFeedbackTimer()
this.setState({
punchFeedbackVisible: true,
punchFeedbackText: text,
punchFeedbackTone: tone,
punchFeedbackFxClass: motionClass,
}, true)
this.punchFeedbackTimer = setTimeout(() => {
this.punchFeedbackTimer = 0
this.setState({
punchFeedbackVisible: false,
punchFeedbackFxClass: '',
}, true)
}, 1400) as unknown as number
}
showContentCard(title: string, body: string, motionClass = ''): void {
this.clearContentCardTimer()
this.setState({
contentCardVisible: true,
contentCardTitle: title,
contentCardBody: body,
contentCardFxClass: motionClass,
}, true)
this.contentCardTimer = setTimeout(() => {
this.contentCardTimer = 0
this.setState({
contentCardVisible: false,
contentCardFxClass: '',
}, true)
}, 2600) as unknown as number
}
closeContentCard(): void {
this.clearContentCardTimer()
this.setState({
contentCardVisible: false,
contentCardFxClass: '',
}, true)
}
applyGameEffects(effects: GameEffect[]): string | null {
this.feedbackDirector.handleEffects(effects)
if (effects.some((effect) => effect.type === 'session_finished')) {
if (this.locationController.listening) {
this.locationController.stop()
}
this.setState({
gpsTracking: false,
gpsTrackingText: '测试结束,定位已停止',
}, true)
}
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
this.updateSessionTimerLoop()
return this.resolveGameStatusText(effects)
}
syncGameResultState(result: GameResult): void {
this.gamePresentation = result.presentation
this.refreshCourseHeadingFromPresentation()
}
resolveAppliedGameStatusText(result: GameResult, fallbackStatusText?: string | null): string | null {
return this.applyGameEffects(result.effects) || fallbackStatusText || this.resolveGameStatusText(result.effects)
}
commitGameResult(
result: GameResult,
fallbackStatusText?: string | null,
extraPatch: Partial<MapEngineViewState> = {},
syncRenderer = true,
): string | null {
this.syncGameResultState(result)
const gameStatusText = this.resolveAppliedGameStatusText(result, fallbackStatusText)
this.setState({
...this.getGameViewPatch(gameStatusText),
...extraPatch,
}, true)
if (syncRenderer) {
this.syncRenderer()
}
return gameStatusText
}
handleStartGame(): void {
if (!this.gameRuntime.definition || !this.gameRuntime.state) {
this.setState({
statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
}, true)
return
}
if (this.gameRuntime.state.status !== 'idle') {
return
}
this.feedbackDirector.reset()
this.resetTransientGameUiState()
this.clearStartSessionResidue()
if (!this.locationController.listening) {
this.locationController.start()
}
const startedAt = Date.now()
const startResult = this.gameRuntime.startSession(startedAt)
let gameResult = startResult
if (this.currentGpsPoint) {
const gpsResult = this.gameRuntime.dispatch({
type: 'gps_updated',
at: Date.now(),
lon: this.currentGpsPoint.lon,
lat: this.currentGpsPoint.lat,
accuracyMeters: this.currentGpsAccuracyMeters,
})
gameResult = {
nextState: gpsResult.nextState,
presentation: gpsResult.presentation,
effects: [...startResult.effects, ...gpsResult.effects],
}
}
this.courseOverlayVisible = true
const defaultStatusText = this.currentGpsPoint
? `顺序打点已开始 (${this.buildVersion})`
: `顺序打点已开始GPS定位启动中 (${this.buildVersion})`
this.commitGameResult(gameResult, defaultStatusText)
}
handleForceExitGame(): void {
this.feedbackDirector.reset()
if (this.locationController.listening) {
this.locationController.stop()
}
if (!this.courseData) {
this.clearGameRuntime()
this.resetTransientGameUiState()
this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
this.setState({
gpsTracking: false,
gpsTrackingText: '已退出对局,定位已停止',
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
}, true)
this.syncRenderer()
return
}
this.loadGameDefinitionFromCourse()
this.resetTransientGameUiState()
this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
this.setState({
gpsTracking: false,
gpsTrackingText: '已退出对局,定位已停止',
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
}, true)
this.syncRenderer()
}
handlePunchAction(): void {
const gameResult = this.gameRuntime.dispatch({
type: 'punch_requested',
at: Date.now(),
})
this.commitGameResult(gameResult)
}
handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
}
this.currentGpsPoint = nextPoint
this.currentGpsAccuracyMeters = accuracyMeters
this.updateMovementHeadingDeg()
const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
const gpsTileX = Math.floor(gpsWorldPoint.x)
const gpsTileY = Math.floor(gpsWorldPoint.y)
const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
this.currentGpsInsideMap = gpsInsideMap
let gameStatusText: string | null = null
if (!gpsInsideMap && this.gpsLockEnabled) {
this.gpsLockEnabled = false
gameStatusText = `GPS已超出地图范围锁定已关闭 (${this.buildVersion})`
}
if (this.courseData) {
const eventAt = Date.now()
const gameResult = this.gameRuntime.dispatch({
type: 'gps_updated',
at: eventAt,
lon: longitude,
lat: latitude,
accuracyMeters,
})
this.telemetryRuntime.dispatch({
type: 'gps_updated',
at: eventAt,
lon: longitude,
lat: latitude,
accuracyMeters,
})
this.syncGameResultState(gameResult)
gameStatusText = this.resolveAppliedGameStatusText(gameResult)
}
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
if (gpsInsideMap && (this.gpsLockEnabled || !this.hasGpsCenteredOnce)) {
this.hasGpsCenteredOnce = true
const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
this.commitViewport({
...lockedViewport,
gpsTracking: true,
gpsTrackingText: '持续定位进行中',
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
autoRotateSourceText: this.getAutoRotateSourceText(),
gpsLockEnabled: this.gpsLockEnabled,
gpsLockAvailable: true,
...this.getGameViewPatch(),
}, gameStatusText || (this.gpsLockEnabled ? `GPS锁定跟随中 (${this.buildVersion})` : `GPS定位成功已定位到当前位置 (${this.buildVersion})`), true)
return
}
this.setState({
gpsTracking: true,
gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
autoRotateSourceText: this.getAutoRotateSourceText(),
gpsLockEnabled: this.gpsLockEnabled,
gpsLockAvailable: gpsInsideMap,
...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
})
this.syncRenderer()
}
handleToggleGpsLock(): void {
if (!this.currentGpsPoint || !this.currentGpsInsideMap) {
this.setState({
gpsLockEnabled: false,
gpsLockAvailable: false,
statusText: this.currentGpsPoint
? `当前位置不在地图范围内,无法锁定 (${this.buildVersion})`
: `当前还没有可锁定的GPS位置 (${this.buildVersion})`,
}, true)
return
}
const nextEnabled = !this.gpsLockEnabled
this.gpsLockEnabled = nextEnabled
if (nextEnabled) {
const gpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
const gpsTileX = Math.floor(gpsWorldPoint.x)
const gpsTileY = Math.floor(gpsWorldPoint.y)
const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
if (gpsInsideMap) {
this.hasGpsCenteredOnce = true
const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
this.commitViewport({
...lockedViewport,
gpsLockEnabled: true,
gpsLockAvailable: true,
}, `GPS已锁定在屏幕中央 (${this.buildVersion})`, true)
return
}
this.setState({
gpsLockEnabled: true,
gpsLockAvailable: true,
statusText: `GPS锁定已开启等待进入地图范围 (${this.buildVersion})`,
}, true)
this.syncRenderer()
return
}
this.setState({
gpsLockEnabled: false,
gpsLockAvailable: true,
statusText: `GPS锁定已关闭 (${this.buildVersion})`,
}, true)
this.syncRenderer()
}
handleToggleOsmReference(): void {
const nextEnabled = !this.state.osmReferenceEnabled
this.setState({
osmReferenceEnabled: nextEnabled,
osmReferenceText: nextEnabled ? 'OSM参考开' : 'OSM参考关',
statusText: nextEnabled ? `OSM参考底图已开启 (${this.buildVersion})` : `OSM参考底图已关闭 (${this.buildVersion})`,
}, true)
this.syncRenderer()
}
handleToggleGpsTracking(): void {
if (this.locationController.listening) {
this.locationController.stop()
return
}
this.locationController.start()
}
handleSetRealLocationMode(): void {
this.locationController.setSourceMode('real')
}
handleSetMockLocationMode(): void {
this.locationController.setSourceMode('mock')
}
handleConnectMockLocationBridge(): void {
this.locationController.connectMockBridge()
}
handleDisconnectMockLocationBridge(): void {
this.locationController.disconnectMockBridge()
}
handleSetMockLocationBridgeUrl(url: string): void {
this.locationController.setMockBridgeUrl(url)
}
handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
if (this.gameMode === nextMode) {
return
}
this.gameMode = nextMode
const result = this.loadGameDefinitionFromCourse()
const modeText = this.getGameModeText()
if (!result) {
return
}
this.commitGameResult(result, `已切换到${modeText} (${this.buildVersion})`, {
gameModeText: modeText,
})
}
handleSkipAction(): void {
const gameResult = this.gameRuntime.dispatch({
type: 'skip_requested',
at: Date.now(),
lon: this.currentGpsPoint ? this.currentGpsPoint.lon : null,
lat: this.currentGpsPoint ? this.currentGpsPoint.lat : null,
})
this.commitGameResult(gameResult)
}
handleConnectHeartRate(): void {
this.heartRateController.startScanAndConnect()
}
handleDisconnectHeartRate(): void {
this.heartRateController.disconnect()
}
handleSetRealHeartRateMode(): void {
this.heartRateController.setSourceMode('real')
}
handleSetMockHeartRateMode(): void {
this.heartRateController.setSourceMode('mock')
}
handleConnectMockHeartRateBridge(): void {
this.heartRateController.connectMockBridge()
}
handleDisconnectMockHeartRateBridge(): void {
this.heartRateController.disconnectMockBridge()
}
handleSetMockHeartRateBridgeUrl(url: string): void {
this.heartRateController.setMockBridgeUrl(url)
}
handleConnectHeartRateDevice(deviceId: string): void {
this.heartRateController.connectToDiscoveredDevice(deviceId)
}
handleClearPreferredHeartRateDevice(): void {
this.heartRateController.clearPreferredDevice()
this.setState({
heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
heartRateScanText: this.getHeartRateScanText(),
})
}
handleDebugHeartRateTone(tone: HeartRateTone): void {
const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config)
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm: sampleBpm,
})
this.setState({
heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`,
})
this.syncSessionTimerText()
}
handleClearDebugHeartRate(): void {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm: null,
})
this.setState({
heartRateStatusText: this.heartRateController.connected
? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
: (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'),
heartRateScanText: this.getHeartRateScanText(),
...this.getHeartRateControllerViewPatch(),
})
this.syncSessionTimerText()
}
formatHeartRateDevices(devices: HeartRateDiscoveredDevice[]): Array<{ deviceId: string; name: string; rssiText: string; preferred: boolean; connected: boolean }> {
return devices.map((device) => ({
deviceId: device.deviceId,
name: device.name,
rssiText: device.rssi === null ? '--' : `${device.rssi} dBm`,
preferred: device.isPreferred,
connected: !!this.heartRateController.currentDeviceId && this.heartRateController.currentDeviceId === device.deviceId && this.heartRateController.connected,
}))
}
getHeartRateScanText(): string {
if (this.heartRateController.sourceMode === 'mock') {
if (this.heartRateController.connected) {
return '模拟源已连接'
}
if (this.heartRateController.connecting) {
return '模拟源连接中'
}
return '模拟模式'
}
if (this.heartRateController.connected) {
return '已连接'
}
if (this.heartRateController.connecting) {
return '连接中'
}
if (this.heartRateController.disconnecting) {
return '断开中'
}
if (this.heartRateController.scanning) {
return this.heartRateController.lastDeviceId ? '扫描中(优先首选)' : '扫描中(等待选择)'
}
return this.heartRateController.discoveredDevices.length
? `已发现 ${this.heartRateController.discoveredDevices.length} 个设备`
: '未扫描'
}
setStage(rect: MapEngineStageRect): void {
this.previewScale = 1
this.previewOriginX = rect.width / 2
this.previewOriginY = rect.height / 2
this.commitViewport(
{
stageWidth: rect.width,
stageHeight: rect.height,
stageLeft: rect.left,
stageTop: rect.top,
},
`地图视口已与 WebGL 引擎对齐 (${this.buildVersion})`,
true,
)
}
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
if (this.mounted) {
return
}
this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
this.mounted = true
this.state.mapReady = true
this.state.mapReadyText = 'READY'
this.onData({
mapReady: true,
mapReadyText: 'READY',
statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
})
this.syncRenderer()
this.accelerometerErrorText = null
this.compassController.start()
this.gyroscopeController.start()
this.deviceMotionController.start()
}
applyRemoteMapConfig(config: RemoteMapConfig): void {
MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg
MAGNETIC_DECLINATION_TEXT = normalizeDegreeDisplayText(config.magneticDeclinationText)
this.minZoom = config.minZoom
this.maxZoom = config.maxZoom
this.defaultZoom = config.defaultZoom
this.defaultCenterTileX = config.initialCenterTileX
this.defaultCenterTileY = config.initialCenterTileY
this.tileBoundsByZoom = config.tileBoundsByZoom
this.courseData = config.course
this.cpRadiusMeters = config.cpRadiusMeters
this.configAppId = config.configAppId
this.configSchemaVersion = config.configSchemaVersion
this.configVersion = config.configVersion
this.controlScoreOverrides = config.controlScoreOverrides
this.defaultControlScore = config.defaultControlScore
this.gameMode = config.gameMode
this.punchPolicy = config.punchPolicy
this.punchRadiusMeters = config.punchRadiusMeters
this.requiresFocusSelection = config.requiresFocusSelection
this.skipEnabled = config.skipEnabled
this.skipRadiusMeters = config.skipRadiusMeters
this.skipRequiresConfirm = config.skipRequiresConfirm
this.autoFinishOnLastControl = config.autoFinishOnLastControl
this.telemetryRuntime.configure(config.telemetryConfig)
this.feedbackDirector.configure({
audioConfig: config.audioConfig,
hapticsConfig: config.hapticsConfig,
uiEffectsConfig: config.uiEffectsConfig,
})
const gameResult = this.loadGameDefinitionFromCourse()
const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null
const statePatch: Partial<MapEngineViewState> = {
mapName: config.configTitle,
configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`,
projectionMode: config.projectionModeText,
tileSource: config.tileSource,
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
...this.getGameViewPatch(gameStatusText),
}
if (!this.state.stageWidth || !this.state.stageHeight) {
this.setState({
...statePatch,
zoom: this.defaultZoom,
centerTileX: this.defaultCenterTileX,
centerTileY: this.defaultCenterTileY,
centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
}, true)
return
}
this.commitViewport({
...statePatch,
zoom: this.defaultZoom,
centerTileX: this.defaultCenterTileX,
centerTileY: this.defaultCenterTileY,
tileTranslateX: 0,
tileTranslateY: 0,
}, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => {
this.resetPreviewState()
this.syncRenderer()
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
})
}
handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
this.clearInertiaTimer()
this.clearPreviewResetTimer()
this.panVelocityX = 0
this.panVelocityY = 0
if (event.touches.length >= 2) {
const origin = this.gpsLockEnabled
? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
: this.getStagePoint(event.touches)
this.gestureMode = 'pinch'
this.pinchStartDistance = this.getTouchDistance(event.touches)
this.pinchStartScale = this.previewScale || 1
this.pinchStartAngle = this.getTouchAngle(event.touches)
this.pinchStartRotationDeg = this.state.rotationDeg
const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
: screenToWorld(this.getCameraState(), origin, true)
this.pinchAnchorWorldX = anchorWorld.x
this.pinchAnchorWorldY = anchorWorld.y
this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
this.syncRenderer()
this.compassController.start()
return
}
if (event.touches.length === 1) {
this.gestureMode = 'pan'
this.panLastX = event.touches[0].pageX
this.panLastY = event.touches[0].pageY
this.panLastTimestamp = event.timeStamp || Date.now()
this.tapStartX = event.touches[0].pageX
this.tapStartY = event.touches[0].pageY
this.tapStartAt = event.timeStamp || Date.now()
}
}
handleTouchMove(event: WechatMiniprogram.TouchEvent): void {
if (event.touches.length >= 2) {
const distance = this.getTouchDistance(event.touches)
const angle = this.getTouchAngle(event.touches)
const origin = this.gpsLockEnabled
? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
: this.getStagePoint(event.touches)
if (!this.pinchStartDistance) {
this.pinchStartDistance = distance
this.pinchStartScale = this.previewScale || 1
this.pinchStartAngle = angle
this.pinchStartRotationDeg = this.state.rotationDeg
const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
: screenToWorld(this.getCameraState(), origin, true)
this.pinchAnchorWorldX = anchorWorld.x
this.pinchAnchorWorldY = anchorWorld.y
}
this.gestureMode = 'pinch'
const nextRotationDeg = this.state.orientationMode === 'heading-up'
? this.state.rotationDeg
: normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI)
const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg)
const resolvedViewport = this.resolveViewportForExactCenter(
this.pinchAnchorWorldX - anchorOffset.x,
this.pinchAnchorWorldY - anchorOffset.y,
nextRotationDeg,
)
this.setPreviewState(
clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE),
origin.x,
origin.y,
)
this.commitViewport(
{
...resolvedViewport,
rotationDeg: nextRotationDeg,
rotationText: formatRotationText(nextRotationDeg),
},
this.state.orientationMode === 'heading-up'
? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
: `双指缩放与旋转中 (${this.buildVersion})`,
)
return
}
if (this.gestureMode !== 'pan' || event.touches.length !== 1) {
return
}
const touch = event.touches[0]
const deltaX = touch.pageX - this.panLastX
const deltaY = touch.pageY - this.panLastY
const nextTimestamp = event.timeStamp || Date.now()
const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16)
const instantVelocityX = deltaX / elapsed
const instantVelocityY = deltaY / elapsed
this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28
this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28
this.panLastX = touch.pageX
this.panLastY = touch.pageY
this.panLastTimestamp = nextTimestamp
if (this.gpsLockEnabled) {
this.panVelocityX = 0
this.panVelocityY = 0
return
}
this.normalizeTranslate(
this.state.tileTranslateX + deltaX,
this.state.tileTranslateY + deltaY,
`宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
)
}
handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
const changedTouch = event.changedTouches && event.changedTouches.length ? event.changedTouches[0] : null
const endedAsTap = changedTouch
&& this.gestureMode === 'pan'
&& event.touches.length === 0
&& Math.abs(changedTouch.pageX - this.tapStartX) <= MAP_TAP_MOVE_THRESHOLD_PX
&& Math.abs(changedTouch.pageY - this.tapStartY) <= MAP_TAP_MOVE_THRESHOLD_PX
&& ((event.timeStamp || Date.now()) - this.tapStartAt) <= MAP_TAP_DURATION_MS
if (this.gestureMode === 'pinch' && event.touches.length < 2) {
const gestureScale = this.previewScale || 1
const zoomDelta = Math.round(Math.log2(gestureScale))
const originX = this.gpsLockEnabled ? this.state.stageWidth / 2 : (this.previewOriginX || this.state.stageWidth / 2)
const originY = this.gpsLockEnabled ? this.state.stageHeight / 2 : (this.previewOriginY || this.state.stageHeight / 2)
if (zoomDelta) {
const residualScale = gestureScale / Math.pow(2, zoomDelta)
this.zoomAroundPoint(zoomDelta, originX, originY, residualScale)
} else {
this.animatePreviewToRest()
}
this.resetPinchState()
this.panVelocityX = 0
this.panVelocityY = 0
if (event.touches.length === 1) {
this.gestureMode = 'pan'
this.panLastX = event.touches[0].pageX
this.panLastY = event.touches[0].pageY
this.panLastTimestamp = event.timeStamp || Date.now()
return
}
this.gestureMode = 'idle'
this.renderer.setAnimationPaused(false)
this.scheduleAutoRotate()
return
}
if (event.touches.length === 1) {
this.gestureMode = 'pan'
this.panLastX = event.touches[0].pageX
this.panLastY = event.touches[0].pageY
this.panLastTimestamp = event.timeStamp || Date.now()
return
}
if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) {
this.startInertia()
this.gestureMode = 'idle'
this.resetPinchState()
return
}
if (endedAsTap && changedTouch) {
this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop)
}
this.gestureMode = 'idle'
this.resetPinchState()
this.renderer.setAnimationPaused(false)
this.scheduleAutoRotate()
}
handleTouchCancel(): void {
this.gestureMode = 'idle'
this.resetPinchState()
this.panVelocityX = 0
this.panVelocityY = 0
this.clearInertiaTimer()
this.animatePreviewToRest()
this.renderer.setAnimationPaused(false)
this.scheduleAutoRotate()
}
handleMapTap(stageX: number, stageY: number): void {
if (!this.gameRuntime.definition || !this.gameRuntime.state || this.gameRuntime.definition.mode !== 'score-o') {
return
}
const focusedControlId = this.findFocusableControlAt(stageX, stageY)
if (focusedControlId === undefined) {
return
}
const gameResult = this.gameRuntime.dispatch({
type: 'control_focused',
at: Date.now(),
controlId: focusedControlId,
})
this.commitGameResult(
gameResult,
focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
)
}
findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
return undefined
}
const focusableControls = this.gameRuntime.definition.controls.filter((control) => (
this.gamePresentation.map.focusableControlIds.includes(control.id)
))
let matchedControlId: string | null | undefined
let matchedDistance = Number.POSITIVE_INFINITY
const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
for (const control of focusableControls) {
const screenPoint = this.getControlScreenPoint(control.id)
if (!screenPoint) {
continue
}
const distancePx = Math.sqrt(
Math.pow(screenPoint.x - stageX, 2)
+ Math.pow(screenPoint.y - stageY, 2),
)
if (distancePx <= hitRadiusPx && distancePx < matchedDistance) {
matchedDistance = distancePx
matchedControlId = control.id
}
}
if (matchedControlId === undefined) {
return undefined
}
return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
}
getControlHitRadiusPx(): number {
if (!this.state.tileSizePx) {
return 28
}
const centerLonLat = worldTileToLonLat({ x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5 }, this.state.zoom)
const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * 40075016.686 / Math.pow(2, this.state.zoom)
if (!metersPerTile) {
return 28
}
const pixelsPerMeter = this.state.tileSizePx / metersPerTile
return Math.max(28, this.cpRadiusMeters * pixelsPerMeter * 1.6)
}
handleRecenter(): void {
this.clearInertiaTimer()
this.clearPreviewResetTimer()
this.panVelocityX = 0
this.panVelocityY = 0
this.renderer.setAnimationPaused(false)
this.commitViewport(
{
zoom: this.defaultZoom,
centerTileX: this.defaultCenterTileX,
centerTileY: this.defaultCenterTileY,
tileTranslateX: 0,
tileTranslateY: 0,
},
`已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
this.syncRenderer()
this.compassController.start()
this.scheduleAutoRotate()
},
)
}
handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
if (this.state.rotationMode === 'auto') {
this.setState({
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
}, true)
return
}
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg)
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
this.clearInertiaTimer()
this.clearPreviewResetTimer()
this.panVelocityX = 0
this.panVelocityY = 0
this.renderer.setAnimationPaused(false)
this.commitViewport(
{
...resolvedViewport,
rotationDeg: nextRotationDeg,
rotationText: formatRotationText(nextRotationDeg),
},
`旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
this.syncRenderer()
this.compassController.start()
},
)
}
handleRotationReset(): void {
if (this.state.rotationMode === 'auto') {
this.setState({
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
}, true)
return
}
const targetRotationDeg = MAP_NORTH_OFFSET_DEG
if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) {
return
}
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg)
this.clearInertiaTimer()
this.clearPreviewResetTimer()
this.panVelocityX = 0
this.panVelocityY = 0
this.renderer.setAnimationPaused(false)
this.commitViewport(
{
...resolvedViewport,
rotationDeg: targetRotationDeg,
rotationText: formatRotationText(targetRotationDeg),
},
`旋转角度已回到真北参考 (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
this.syncRenderer()
this.compassController.start()
},
)
}
handleToggleRotationMode(): void {
if (this.state.orientationMode === 'manual') {
this.setNorthUpMode()
return
}
if (this.state.orientationMode === 'north-up') {
this.setHeadingUpMode()
return
}
this.setManualMode()
}
handleSetManualMode(): void {
this.setManualMode()
}
handleSetNorthUpMode(): void {
this.setNorthUpMode()
}
handleSetHeadingUpMode(): void {
this.setHeadingUpMode()
}
handleCycleNorthReferenceMode(): void {
this.cycleNorthReferenceMode()
}
handleSetNorthReferenceMode(mode: NorthReferenceMode): void {
this.setNorthReferenceMode(mode)
}
handleSetAnimationLevel(level: AnimationLevel): void {
if (this.animationLevel === level) {
return
}
this.animationLevel = level
this.feedbackDirector.setAnimationLevel(level)
this.setState({
animationLevel: level,
statusText: `动画性能已切换为${formatAnimationLevelText(level)} (${this.buildVersion})`,
})
this.syncRenderer()
}
handleSetCompassTuningProfile(profile: CompassTuningProfile): void {
if (this.compassTuningProfile === profile) {
return
}
this.compassTuningProfile = profile
this.compassController.setTuningProfile(profile)
this.setState({
compassTuningProfile: profile,
compassTuningProfileText: formatCompassTuningProfileText(profile),
statusText: `指北针响应已切换为${formatCompassTuningProfileText(profile)} (${this.buildVersion})`,
}, true)
}
handleAutoRotateCalibrate(): void {
if (this.state.orientationMode !== 'heading-up') {
this.setState({
statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
}, true)
return
}
if (!this.calibrateAutoRotateToCurrentOrientation()) {
this.setState({
statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
}, true)
return
}
this.setState({
statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
}, true)
this.scheduleAutoRotate()
}
setManualMode(): void {
this.clearAutoRotateTimer()
this.targetAutoRotationDeg = null
this.autoRotateCalibrationPending = false
this.setState({
rotationMode: 'manual',
rotationModeText: formatRotationModeText('manual'),
rotationToggleText: formatRotationToggleText('manual'),
orientationMode: 'manual',
orientationModeText: formatOrientationModeText('manual'),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
statusText: `已切回手动地图旋转 (${this.buildVersion})`,
}, true)
}
setNorthUpMode(): void {
this.clearAutoRotateTimer()
this.targetAutoRotationDeg = null
this.autoRotateCalibrationPending = false
const mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG
this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg)
this.commitViewport(
{
...resolvedViewport,
rotationDeg: mapNorthOffsetDeg,
rotationText: formatRotationText(mapNorthOffsetDeg),
rotationMode: 'manual',
rotationModeText: formatRotationModeText('north-up'),
rotationToggleText: formatRotationToggleText('north-up'),
orientationMode: 'north-up',
orientationModeText: formatOrientationModeText('north-up'),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
},
`地图已固定为真北朝上 (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
this.syncRenderer()
},
)
}
setHeadingUpMode(): void {
this.autoRotateCalibrationPending = false
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode)
this.targetAutoRotationDeg = null
this.setState({
rotationMode: 'auto',
rotationModeText: formatRotationModeText('heading-up'),
rotationToggleText: formatRotationToggleText('heading-up'),
orientationMode: 'heading-up',
orientationModeText: formatOrientationModeText('heading-up'),
autoRotateSourceText: this.getAutoRotateSourceText(),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
}, true)
if (this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
}
applyHeadingSample(headingDeg: number, source: 'compass' | 'motion'): void {
this.compassSource = source
this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
? this.sensorHeadingDeg
: interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
if (this.compassDisplayHeadingDeg === null) {
this.compassDisplayHeadingDeg = compassHeadingDeg
this.targetCompassDisplayHeadingDeg = compassHeadingDeg
this.syncCompassDisplayState()
} else {
this.targetCompassDisplayHeadingDeg = compassHeadingDeg
const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg))
if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) {
this.scheduleCompassNeedleFollow()
}
}
this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
this.setState({
compassSourceText: formatCompassSourceText(this.compassSource),
...(this.diagnosticUiEnabled
? {
...this.getTelemetrySensorViewPatch(),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
autoRotateSourceText: this.getAutoRotateSourceText(),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
}
: {}),
})
if (!this.refreshAutoRotateTarget()) {
return
}
if (this.state.orientationMode === 'heading-up') {
this.scheduleAutoRotate()
}
}
handleCompassHeading(headingDeg: number): void {
this.applyHeadingSample(headingDeg, 'compass')
}
handleCompassError(message: string): void {
this.clearAutoRotateTimer()
this.clearCompassNeedleTimer()
this.targetAutoRotationDeg = null
this.autoRotateCalibrationPending = false
this.compassSource = null
this.targetCompassDisplayHeadingDeg = null
this.setState({
compassSourceText: formatCompassSourceText(null),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
statusText: `${message} (${this.buildVersion})`,
}, true)
}
cycleNorthReferenceMode(): void {
this.setNorthReferenceMode(getNextNorthReferenceMode(this.northReferenceMode))
}
setNorthReferenceMode(nextMode: NorthReferenceMode): void {
if (nextMode === this.northReferenceMode) {
return
}
const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
? null
: getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
this.northReferenceMode = nextMode
this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
this.compassDisplayHeadingDeg = compassHeadingDeg
this.targetCompassDisplayHeadingDeg = compassHeadingDeg
if (this.state.orientationMode === 'north-up') {
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG)
this.commitViewport(
{
...resolvedViewport,
rotationDeg: MAP_NORTH_OFFSET_DEG,
rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
northReferenceText: formatNorthReferenceText(nextMode),
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
...this.getTelemetrySensorViewPatch(),
compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceMode: nextMode,
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
},
`${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
this.syncRenderer()
},
)
return
}
this.setState({
northReferenceText: formatNorthReferenceText(nextMode),
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
...this.getTelemetrySensorViewPatch(),
compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceMode: nextMode,
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
}, true)
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
if (this.compassDisplayHeadingDeg !== null) {
this.syncCompassDisplayState()
}
}
setCourseHeading(headingDeg: number | null): void {
this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
this.setState({
autoRotateSourceText: this.getAutoRotateSourceText(),
})
if (this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
}
getRawMovementHeadingDeg(): number | null {
if (!this.currentGpsInsideMap) {
return null
}
if (this.currentGpsAccuracyMeters !== null && this.currentGpsAccuracyMeters > SMART_HEADING_MAX_ACCURACY_METERS) {
return null
}
if (this.currentGpsTrack.length < 2) {
return null
}
const lastPoint = this.currentGpsTrack[this.currentGpsTrack.length - 1]
let accumulatedDistanceMeters = 0
for (let index = this.currentGpsTrack.length - 2; index >= 0; index -= 1) {
const nextPoint = this.currentGpsTrack[index + 1]
const point = this.currentGpsTrack[index]
accumulatedDistanceMeters += getApproxDistanceMeters(point, nextPoint)
if (accumulatedDistanceMeters >= SMART_HEADING_MIN_DISTANCE_METERS) {
return getInitialBearingDeg(point, lastPoint)
}
}
return null
}
updateMovementHeadingDeg(): void {
const rawMovementHeadingDeg = this.getRawMovementHeadingDeg()
if (rawMovementHeadingDeg === null) {
this.smoothedMovementHeadingDeg = null
return
}
const smoothingFactor = getMovementHeadingSmoothingFactor(this.telemetryRuntime.state.currentSpeedKmh)
this.smoothedMovementHeadingDeg = this.smoothedMovementHeadingDeg === null
? rawMovementHeadingDeg
: interpolateAngleDeg(this.smoothedMovementHeadingDeg, rawMovementHeadingDeg, smoothingFactor)
}
getMovementHeadingDeg(): number | null {
return this.smoothedMovementHeadingDeg
}
getPreferredSensorHeadingDeg(): number | null {
return this.smoothedSensorHeadingDeg === null
? null
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
}
getSmartAutoRotateHeadingDeg(): number | null {
const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
const movementHeadingDeg = this.getMovementHeadingDeg()
const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
if (smartSource === 'movement') {
return movementHeadingDeg === null ? sensorHeadingDeg : movementHeadingDeg
}
if (smartSource === 'blended' && sensorHeadingDeg !== null && movementHeadingDeg !== null && speedKmh !== null) {
const blend = Math.max(0, Math.min(1, (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH) / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)))
return interpolateAngleDeg(sensorHeadingDeg, movementHeadingDeg, blend)
}
return sensorHeadingDeg === null ? movementHeadingDeg : sensorHeadingDeg
}
getAutoRotateSourceText(): string {
if (this.autoRotateSourceMode !== 'smart') {
return formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null)
}
const smartSource = resolveSmartHeadingSource(
this.telemetryRuntime.state.currentSpeedKmh,
this.getMovementHeadingDeg() !== null,
)
return formatSmartHeadingSourceText(smartSource)
}
resolveAutoRotateInputHeadingDeg(): number | null {
if (this.autoRotateSourceMode === 'smart') {
return this.getSmartAutoRotateHeadingDeg()
}
const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
const courseHeadingDeg = this.courseHeadingDeg === null
? null
: getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
if (this.autoRotateSourceMode === 'sensor') {
return sensorHeadingDeg
}
if (this.autoRotateSourceMode === 'course') {
return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg
}
if (sensorHeadingDeg !== null && courseHeadingDeg !== null) {
return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35)
}
return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg
}
calibrateAutoRotateToCurrentOrientation(): boolean {
const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
if (inputHeadingDeg === null) {
return false
}
this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg)
this.autoRotateCalibrationPending = false
this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
this.setState({
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
})
return true
}
refreshAutoRotateTarget(): boolean {
const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
if (inputHeadingDeg === null) {
return false
}
if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) {
if (!this.calibrateAutoRotateToCurrentOrientation()) {
return false
}
return true
}
this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
this.setState({
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
})
return true
}
scheduleAutoRotate(): void {
if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
return
}
const step = () => {
this.autoRotateTimer = 0
if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
return
}
if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) {
this.scheduleAutoRotate()
return
}
const currentRotationDeg = this.state.rotationDeg
const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg)
if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) {
if (Math.abs(deltaDeg) > 0.01) {
this.applyAutoRotation(this.targetAutoRotationDeg)
}
this.scheduleAutoRotate()
return
}
if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) {
this.scheduleAutoRotate()
return
}
const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG)
this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg))
this.scheduleAutoRotate()
}
this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number
}
applyAutoRotation(nextRotationDeg: number): void {
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
this.setState({
...resolvedViewport,
rotationDeg: nextRotationDeg,
rotationText: formatRotationText(nextRotationDeg),
centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY),
})
this.syncRenderer()
}
applyStats(stats: MapRendererStats): void {
const statsPatch = {
visibleTileCount: stats.visibleTileCount,
readyTileCount: stats.readyTileCount,
memoryTileCount: stats.memoryTileCount,
diskTileCount: stats.diskTileCount,
memoryHitCount: stats.memoryHitCount,
diskHitCount: stats.diskHitCount,
networkFetchCount: stats.networkFetchCount,
cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount),
}
if (!this.diagnosticUiEnabled) {
this.state = {
...this.state,
...statsPatch,
}
return
}
const now = Date.now()
if (now - this.lastStatsUiSyncAt < 500) {
this.state = {
...this.state,
...statsPatch,
}
return
}
this.lastStatsUiSyncAt = now
this.setState(statsPatch)
}
setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {
this.state = {
...this.state,
...patch,
}
const viewPatch = this.pickViewPatch(patch)
if (!Object.keys(viewPatch).length) {
return
}
this.pendingViewPatch = {
...this.pendingViewPatch,
...viewPatch,
}
if (immediateUi) {
this.flushViewPatch()
return
}
if (this.viewSyncTimer) {
return
}
this.viewSyncTimer = setTimeout(() => {
this.viewSyncTimer = 0
this.flushViewPatch()
}, UI_SYNC_INTERVAL_MS) as unknown as number
}
commitViewport(
patch: Partial<MapEngineViewState>,
statusText: string,
immediateUi = false,
afterUpdate?: () => void,
): void {
const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom
const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX
const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY
const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth
const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight
const tileSizePx = getTileSizePx({
centerWorldX: nextCenterTileX,
centerWorldY: nextCenterTileY,
viewportWidth: nextStageWidth,
viewportHeight: nextStageHeight,
visibleColumns: DESIRED_VISIBLE_COLUMNS,
})
this.setState({
...patch,
tileSizePx,
centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY),
statusText,
}, immediateUi)
this.syncRenderer()
this.compassController.start()
if (afterUpdate) {
afterUpdate()
}
}
buildScene() {
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const readyControlSequences = this.resolveReadyControlSequences()
return {
tileSource: this.state.tileSource,
osmTileSource: OSM_TILE_SOURCE,
zoom: this.state.zoom,
centerTileX: this.state.centerTileX,
centerTileY: this.state.centerTileY,
exactCenterWorldX: exactCenter.x,
exactCenterWorldY: exactCenter.y,
tileBoundsByZoom: this.tileBoundsByZoom,
viewportWidth: this.state.stageWidth,
viewportHeight: this.state.stageHeight,
visibleColumns: DESIRED_VISIBLE_COLUMNS,
overdraw: OVERDRAW,
translateX: this.state.tileTranslateX,
translateY: this.state.tileTranslateY,
rotationRad: this.getRotationRad(this.state.rotationDeg),
animationLevel: this.state.animationLevel,
previewScale: this.previewScale || 1,
previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
track: this.currentGpsTrack,
gpsPoint: this.currentGpsPoint,
gpsCalibration: GPS_MAP_CALIBRATION,
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
course: this.courseOverlayVisible ? this.courseData : null,
cpRadiusMeters: this.cpRadiusMeters,
controlVisualMode: this.gamePresentation.map.controlVisualMode,
showCourseLegs: this.gamePresentation.map.showCourseLegs,
guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled,
focusableControlIds: this.gamePresentation.map.focusableControlIds,
focusedControlId: this.gamePresentation.map.focusedControlId,
focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
activeControlSequences: this.gamePresentation.map.activeControlSequences,
readyControlSequences,
activeStart: this.gamePresentation.map.activeStart,
completedStart: this.gamePresentation.map.completedStart,
activeFinish: this.gamePresentation.map.activeFinish,
focusedFinish: this.gamePresentation.map.focusedFinish,
completedFinish: this.gamePresentation.map.completedFinish,
revealFullCourse: this.gamePresentation.map.revealFullCourse,
activeLegIndices: this.gamePresentation.map.activeLegIndices,
completedLegIndices: this.gamePresentation.map.completedLegIndices,
completedControlSequences: this.gamePresentation.map.completedControlSequences,
skippedControlIds: this.gamePresentation.map.skippedControlIds,
skippedControlSequences: this.gamePresentation.map.skippedControlSequences,
osmReferenceEnabled: this.state.osmReferenceEnabled,
overlayOpacity: MAP_OVERLAY_OPACITY,
}
}
resolveReadyControlSequences(): number[] {
const punchableControlId = this.gamePresentation.hud.punchableControlId
const definition = this.gameRuntime.definition
if (!punchableControlId || !definition) {
return []
}
const control = definition.controls.find((item) => item.id === punchableControlId)
if (!control || control.sequence === null) {
return []
}
return [control.sequence]
}
syncRenderer(): void {
if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
return
}
this.renderer.updateScene(this.buildScene())
}
getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
return {
centerWorldX: this.state.centerTileX + 0.5,
centerWorldY: this.state.centerTileY + 0.5,
viewportWidth: this.state.stageWidth,
viewportHeight: this.state.stageHeight,
visibleColumns: DESIRED_VISIBLE_COLUMNS,
translateX: this.state.tileTranslateX,
translateY: this.state.tileTranslateY,
rotationRad: this.getRotationRad(rotationDeg),
}
}
getRotationRad(rotationDeg = this.state.rotationDeg): number {
return normalizeRotationDeg(rotationDeg) * Math.PI / 180
}
getBaseCamera(centerTileX = this.state.centerTileX, centerTileY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
return {
centerWorldX: centerTileX + 0.5,
centerWorldY: centerTileY + 0.5,
viewportWidth: this.state.stageWidth,
viewportHeight: this.state.stageHeight,
visibleColumns: DESIRED_VISIBLE_COLUMNS,
rotationRad: this.getRotationRad(rotationDeg),
}
}
getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } {
const baseCamera = {
centerWorldX: 0,
centerWorldY: 0,
viewportWidth: this.state.stageWidth,
viewportHeight: this.state.stageHeight,
visibleColumns: DESIRED_VISIBLE_COLUMNS,
rotationRad: this.getRotationRad(rotationDeg),
}
return screenToWorld(baseCamera, { x: stageX, y: stageY }, false)
}
getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
if (!this.state.stageWidth || !this.state.stageHeight) {
return {
x: this.state.centerTileX + 0.5,
y: this.state.centerTileY + 0.5,
}
}
const screenCenterX = this.state.stageWidth / 2
const screenCenterY = this.state.stageHeight / 2
return screenToWorld(this.getBaseCamera(), {
x: screenCenterX - translateX,
y: screenCenterY - translateY,
}, false)
}
resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): {
centerTileX: number
centerTileY: number
tileTranslateX: number
tileTranslateY: number
} {
const nextCenterTileX = Math.floor(centerWorldX)
const nextCenterTileY = Math.floor(centerWorldY)
if (!this.state.stageWidth || !this.state.stageHeight) {
return {
centerTileX: nextCenterTileX,
centerTileY: nextCenterTileY,
tileTranslateX: 0,
tileTranslateY: 0,
}
}
const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg)
const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false)
return {
centerTileX: nextCenterTileX,
centerTileY: nextCenterTileY,
tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x,
tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y,
}
}
setPreviewState(scale: number, originX: number, originY: number): void {
this.previewScale = scale
this.previewOriginX = originX
this.previewOriginY = originY
this.setState({
previewScale: scale,
}, true)
}
resetPreviewState(): void {
this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2)
}
resetPinchState(): void {
this.pinchStartDistance = 0
this.pinchStartScale = 1
this.pinchStartAngle = 0
this.pinchStartRotationDeg = this.state.rotationDeg
this.pinchAnchorWorldX = 0
this.pinchAnchorWorldY = 0
}
clearPreviewResetTimer(): void {
if (this.previewResetTimer) {
clearTimeout(this.previewResetTimer)
this.previewResetTimer = 0
}
}
clearInertiaTimer(): void {
if (this.inertiaTimer) {
clearTimeout(this.inertiaTimer)
this.inertiaTimer = 0
}
}
clearViewSyncTimer(): void {
if (this.viewSyncTimer) {
clearTimeout(this.viewSyncTimer)
this.viewSyncTimer = 0
}
}
clearAutoRotateTimer(): void {
if (this.autoRotateTimer) {
clearTimeout(this.autoRotateTimer)
this.autoRotateTimer = 0
}
}
clearCompassNeedleTimer(): void {
if (this.compassNeedleTimer) {
clearTimeout(this.compassNeedleTimer)
this.compassNeedleTimer = 0
}
}
syncCompassDisplayState(): void {
this.setState({
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
...(this.diagnosticUiEnabled
? {
...this.getTelemetrySensorViewPatch(),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
autoRotateSourceText: this.getAutoRotateSourceText(),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
}
: {}),
})
}
scheduleCompassNeedleFollow(): void {
if (
this.compassNeedleTimer
|| this.targetCompassDisplayHeadingDeg === null
|| this.compassDisplayHeadingDeg === null
) {
return
}
const step = () => {
this.compassNeedleTimer = 0
if (
this.targetCompassDisplayHeadingDeg === null
|| this.compassDisplayHeadingDeg === null
) {
return
}
const deltaDeg = normalizeAngleDeltaDeg(
this.targetCompassDisplayHeadingDeg - this.compassDisplayHeadingDeg,
)
const absDeltaDeg = Math.abs(deltaDeg)
if (absDeltaDeg <= COMPASS_NEEDLE_SNAP_DEG) {
if (absDeltaDeg > 0.001) {
this.compassDisplayHeadingDeg = this.targetCompassDisplayHeadingDeg
this.syncCompassDisplayState()
}
return
}
this.compassDisplayHeadingDeg = interpolateAngleDeg(
this.compassDisplayHeadingDeg,
this.targetCompassDisplayHeadingDeg,
getCompassNeedleSmoothingFactor(
this.compassDisplayHeadingDeg,
this.targetCompassDisplayHeadingDeg,
this.compassTuningProfile,
),
)
this.syncCompassDisplayState()
this.scheduleCompassNeedleFollow()
}
this.compassNeedleTimer = setTimeout(step, COMPASS_NEEDLE_FRAME_MS) as unknown as number
}
pickViewPatch(patch: Partial<MapEngineViewState>): Partial<MapEngineViewState> {
const viewPatch = {} as Partial<MapEngineViewState>
for (const key of VIEW_SYNC_KEYS) {
if (Object.prototype.hasOwnProperty.call(patch, key)) {
;(viewPatch as any)[key] = patch[key]
}
}
return viewPatch
}
flushViewPatch(): void {
if (!Object.keys(this.pendingViewPatch).length) {
return
}
const patch = this.pendingViewPatch
const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer
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 {
if (touches.length < 2) {
return 0
}
const first = touches[0]
const second = touches[1]
const deltaX = first.pageX - second.pageX
const deltaY = first.pageY - second.pageY
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
}
getTouchAngle(touches: TouchPoint[]): number {
if (touches.length < 2) {
return 0
}
const first = touches[0]
const second = touches[1]
return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX)
}
getStagePoint(touches: TouchPoint[]): { x: number; y: number } {
if (!touches.length) {
return {
x: this.state.stageWidth / 2,
y: this.state.stageHeight / 2,
}
}
let pageX = 0
let pageY = 0
for (const touch of touches) {
pageX += touch.pageX
pageY += touch.pageY
}
return {
x: pageX / touches.length - this.state.stageLeft,
y: pageY / touches.length - this.state.stageTop,
}
}
animatePreviewToRest(): void {
this.clearPreviewResetTimer()
const startScale = this.previewScale || 1
const originX = this.previewOriginX || this.state.stageWidth / 2
const originY = this.previewOriginY || this.state.stageHeight / 2
if (Math.abs(startScale - 1) < 0.01) {
this.resetPreviewState()
this.syncRenderer()
this.compassController.start()
this.scheduleAutoRotate()
return
}
const startAt = Date.now()
const step = () => {
const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS)
const eased = 1 - Math.pow(1 - progress, 3)
const nextScale = startScale + (1 - startScale) * eased
this.setPreviewState(nextScale, originX, originY)
this.syncRenderer()
this.compassController.start()
if (progress >= 1) {
this.resetPreviewState()
this.syncRenderer()
this.compassController.start()
this.previewResetTimer = 0
this.scheduleAutoRotate()
return
}
this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
}
step()
}
normalizeTranslate(translateX: number, translateY: number, statusText: string): void {
if (!this.state.stageWidth) {
this.setState({
tileTranslateX: translateX,
tileTranslateY: translateY,
})
this.syncRenderer()
this.compassController.start()
return
}
const exactCenter = this.getExactCenterFromTranslate(translateX, translateY)
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y)
const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY
if (centerChanged) {
this.commitViewport(resolvedViewport, statusText)
return
}
this.setState({
tileTranslateX: resolvedViewport.tileTranslateX,
tileTranslateY: resolvedViewport.tileTranslateY,
})
this.syncRenderer()
this.compassController.start()
}
zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
const nextZoom = clamp(this.state.zoom + zoomDelta, this.minZoom, this.maxZoom)
const appliedDelta = nextZoom - this.state.zoom
if (!appliedDelta) {
this.animatePreviewToRest()
return
}
if (this.gpsLockEnabled && this.currentGpsPoint) {
const nextGpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, nextZoom)
const resolvedViewport = this.resolveViewportForExactCenter(nextGpsWorldPoint.x, nextGpsWorldPoint.y)
this.commitViewport(
{
zoom: nextZoom,
...resolvedViewport,
},
`缩放级别调整到 ${nextZoom}`,
true,
() => {
this.setPreviewState(residualScale, this.state.stageWidth / 2, this.state.stageHeight / 2)
this.syncRenderer()
this.compassController.start()
this.animatePreviewToRest()
},
)
return
}
if (!this.state.stageWidth || !this.state.stageHeight) {
this.commitViewport(
{
zoom: nextZoom,
centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2),
centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2),
tileTranslateX: 0,
tileTranslateY: 0,
},
`缩放级别调整到 ${nextZoom}`,
true,
() => {
this.setPreviewState(residualScale, stageX, stageY)
this.syncRenderer()
this.compassController.start()
this.animatePreviewToRest()
},
)
return
}
const camera = this.getCameraState()
const world = screenToWorld(camera, { x: stageX, y: stageY }, true)
const zoomFactor = Math.pow(2, appliedDelta)
const nextWorldX = world.x * zoomFactor
const nextWorldY = world.y * zoomFactor
const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY)
const exactCenterX = nextWorldX - anchorOffset.x
const exactCenterY = nextWorldY - anchorOffset.y
const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY)
this.commitViewport(
{
zoom: nextZoom,
...resolvedViewport,
},
`缩放级别调整到 ${nextZoom}`,
true,
() => {
this.setPreviewState(residualScale, stageX, stageY)
this.syncRenderer()
this.compassController.start()
this.animatePreviewToRest()
},
)
}
startInertia(): void {
this.clearInertiaTimer()
const step = () => {
this.panVelocityX *= INERTIA_DECAY
this.panVelocityY *= INERTIA_DECAY
if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
this.setState({
statusText: `惯性滑动结束 (${this.buildVersion})`,
})
this.renderer.setAnimationPaused(false)
this.inertiaTimer = 0
this.scheduleAutoRotate()
return
}
this.normalizeTranslate(
this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
`惯性滑动中 (${this.buildVersion})`,
)
this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
}
this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
}
}