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

2487 lines
79 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 { CompassHeadingController } from '../sensor/compassHeadingController'
import { HeartRateController } from '../sensor/heartRateController'
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 { GameRuntime } from '../../game/core/gameRuntime'
import { type GameEffect } 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.32
const COMPASS_NEEDLE_SMOOTHING = 0.12
const GPS_TRACK_MAX_POINTS = 200
const GPS_TRACK_MIN_STEP_METERS = 3
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'
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 {
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
compassDeclinationText: string
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
stageWidth: number
stageHeight: number
stageLeft: number
stageTop: number
statusText: string
gpsTracking: boolean
gpsTrackingText: string
gpsCoordText: string
heartRateConnected: boolean
heartRateStatusText: string
heartRateDeviceText: string
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
panelTimerText: string
panelMileageText: 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
punchHintText: string
punchFeedbackVisible: boolean
punchFeedbackText: string
punchFeedbackTone: 'neutral' | 'success' | 'warning'
contentCardVisible: boolean
contentCardTitle: string
contentCardBody: string
punchButtonFxClass: 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
}
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'buildVersion',
'renderMode',
'projectionMode',
'mapReady',
'mapReadyText',
'mapName',
'configStatusText',
'zoom',
'rotationDeg',
'rotationText',
'rotationMode',
'rotationModeText',
'rotationToggleText',
'orientationMode',
'orientationModeText',
'sensorHeadingText',
'compassDeclinationText',
'northReferenceButtonText',
'autoRotateSourceText',
'autoRotateCalibrationText',
'northReferenceText',
'compassNeedleDeg',
'centerText',
'tileSource',
'visibleTileCount',
'readyTileCount',
'memoryTileCount',
'diskTileCount',
'memoryHitCount',
'diskHitCount',
'networkFetchCount',
'cacheHitRateText',
'tileSizePx',
'statusText',
'gpsTracking',
'gpsTrackingText',
'gpsCoordText',
'heartRateConnected',
'heartRateStatusText',
'heartRateDeviceText',
'gameSessionStatus',
'panelTimerText',
'panelMileageText',
'panelDistanceValueText',
'panelDistanceUnitText',
'panelProgressText',
'panelSpeedValueText',
'panelTelemetryTone',
'panelHeartRateZoneNameText',
'panelHeartRateZoneRangeText',
'panelHeartRateValueText',
'panelHeartRateUnitText',
'panelCaloriesValueText',
'panelCaloriesUnitText',
'panelAverageSpeedValueText',
'panelAverageSpeedUnitText',
'panelAccuracyValueText',
'panelAccuracyUnitText',
'punchButtonText',
'punchButtonEnabled',
'punchHintText',
'punchFeedbackVisible',
'punchFeedbackText',
'punchFeedbackTone',
'contentCardVisible',
'contentCardTitle',
'contentCardBody',
'punchButtonFxClass',
'punchFeedbackFxClass',
'contentCardFxClass',
'mapPulseVisible',
'mapPulseLeftPx',
'mapPulseTopPx',
'mapPulseFxClass',
'stageFxVisible',
'stageFxClass',
'osmReferenceEnabled',
'osmReferenceText',
]
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 formatRotationText(rotationDeg: number): string {
return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
}
function formatHeadingText(headingDeg: number | null): string {
if (headingDeg === null) {
return '--'
}
return `${Math.round(normalizeRotationDeg(headingDeg))}`
}
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 === 'sensor') {
return 'Sensor Only'
}
if (mode === 'course') {
return hasCourseHeading ? 'Course Only' : 'Course Pending'
}
return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
}
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 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 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
renderer: WebGLMapRenderer
compassController: CompassHeadingController
locationController: LocationController
heartRateController: HeartRateController
feedbackDirector: FeedbackDirector
onData: (patch: Partial<MapEngineViewState>) => void
state: MapEngineViewState
previewScale: number
previewOriginX: number
previewOriginY: number
panLastX: number
panLastY: number
panLastTimestamp: 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
pendingViewPatch: Partial<MapEngineViewState>
mounted: boolean
northReferenceMode: NorthReferenceMode
sensorHeadingDeg: number | null
smoothedSensorHeadingDeg: number | null
compassDisplayHeadingDeg: number | null
autoRotateHeadingDeg: number | null
courseHeadingDeg: number | null
targetAutoRotationDeg: number | null
autoRotateSourceMode: AutoRotateSourceMode
autoRotateCalibrationOffsetDeg: number | null
autoRotateCalibrationPending: boolean
minZoom: number
maxZoom: number
defaultZoom: number
defaultCenterTileX: number
defaultCenterTileY: number
tileBoundsByZoom: Record<number, TileZoomBounds> | null
currentGpsPoint: LonLatPoint | null
currentGpsTrack: LonLatPoint[]
currentGpsAccuracyMeters: number | null
courseData: OrienteeringCourseData | null
cpRadiusMeters: number
gameRuntime: GameRuntime
telemetryRuntime: TelemetryRuntime
gamePresentation: GamePresentationState
gameMode: 'classic-sequential'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
punchFeedbackTimer: number
contentCardTimer: number
mapPulseTimer: number
stageFxTimer: number
sessionTimerInterval: number
hasGpsCenteredOnce: boolean
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
this.buildVersion = buildVersion
this.onData = callbacks.onData
this.renderer = new WebGLMapRenderer(
(stats) => {
this.applyStats(stats)
},
(message) => {
this.setState({
statusText: `${message} (${this.buildVersion})`,
})
},
)
this.compassController = new CompassHeadingController({
onHeading: (headingDeg) => {
this.handleCompassHeading(headingDeg)
},
onError: (message) => {
this.handleCompassError(message)
},
})
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,
}, true)
},
onError: (message) => {
this.setState({
gpsTracking: false,
gpsTrackingText: message,
statusText: `${message} (${this.buildVersion})`,
}, true)
},
})
this.heartRateController = new HeartRateController({
onHeartRate: (bpm) => {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm,
})
this.syncSessionTimerText()
},
onStatus: (message) => {
this.setState({
heartRateStatusText: message,
heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
}, true)
},
onError: (message) => {
this.setState({
heartRateConnected: false,
heartRateStatusText: message,
heartRateDeviceText: '--',
statusText: `${message} (${this.buildVersion})`,
}, true)
},
onConnectionChange: (connected, deviceName) => {
this.setState({
heartRateConnected: connected,
heartRateDeviceText: deviceName || '--',
heartRateStatusText: connected ? '心率带已连接' : '心率带未连接',
}, true)
},
})
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)
},
showMapPulse: (controlId, motionClass) => {
this.showMapPulse(controlId, motionClass)
},
showStageFx: (className) => {
this.showStageFx(className)
},
stopLocationTracking: () => {
if (this.locationController.listening) {
this.locationController.stop()
}
},
})
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.courseData = null
this.cpRadiusMeters = 5
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.autoFinishOnLastControl = true
this.punchFeedbackTimer = 0
this.contentCardTimer = 0
this.mapPulseTimer = 0
this.stageFxTimer = 0
this.sessionTimerInterval = 0
this.hasGpsCenteredOnce = false
this.state = {
buildVersion: this.buildVersion,
renderMode: RENDER_MODE,
projectionMode: PROJECTION_MODE,
mapReady: false,
mapReadyText: 'BOOTING',
mapName: 'LCX 测试地图',
configStatusText: '远程配置待加载',
zoom: DEFAULT_ZOOM,
rotationDeg: 0,
rotationText: formatRotationText(0),
rotationMode: 'manual',
rotationModeText: formatRotationModeText('manual'),
rotationToggleText: formatRotationToggleText('manual'),
orientationMode: 'manual',
orientationModeText: formatOrientationModeText('manual'),
sensorHeadingText: '--',
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
autoRotateSourceText: formatAutoRotateSourceText('sensor', 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,
stageWidth: 0,
stageHeight: 0,
stageLeft: 0,
stageTop: 0,
statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
gpsTracking: false,
gpsTrackingText: '持续定位待启动',
gpsCoordText: '--',
heartRateConnected: false,
heartRateStatusText: '心率带未连接',
heartRateDeviceText: '--',
panelTimerText: '00:00:00',
panelMileageText: '0m',
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',
punchButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
punchButtonFxClass: '',
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.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.pendingViewPatch = {}
this.mounted = false
this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
this.sensorHeadingDeg = null
this.smoothedSensorHeadingDeg = null
this.compassDisplayHeadingDeg = null
this.autoRotateHeadingDeg = null
this.courseHeadingDeg = null
this.targetAutoRotationDeg = null
this.autoRotateSourceMode = 'sensor'
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
this.autoRotateCalibrationPending = false
}
getInitialData(): MapEngineViewState {
return { ...this.state }
}
destroy(): void {
this.clearInertiaTimer()
this.clearPreviewResetTimer()
this.clearViewSyncTimer()
this.clearAutoRotateTimer()
this.clearPunchFeedbackTimer()
this.clearContentCardTimer()
this.clearMapPulseTimer()
this.clearStageFxTimer()
this.clearSessionTimerInterval()
this.compassController.destroy()
this.locationController.destroy()
this.heartRateController.destroy()
this.feedbackDirector.destroy()
this.renderer.destroy()
this.mounted = false
}
clearGameRuntime(): void {
this.gameRuntime.clear()
this.telemetryRuntime.reset()
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
this.clearSessionTimerInterval()
this.setCourseHeading(null)
}
loadGameDefinitionFromCourse(): GameEffect[] {
if (!this.courseData) {
this.clearGameRuntime()
return []
}
const definition = buildGameDefinitionFromCourse(
this.courseData,
this.cpRadiusMeters,
this.gameMode,
this.autoFinishOnLastControl,
this.punchPolicy,
this.punchRadiusMeters,
)
const result = this.gameRuntime.loadDefinition(definition)
this.telemetryRuntime.loadDefinition(definition)
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState)
this.gamePresentation = result.presentation
this.refreshCourseHeadingFromPresentation()
this.updateSessionTimerLoop()
return result.effects
}
refreshCourseHeadingFromPresentation(): void {
if (!this.courseData || !this.gamePresentation.activeLegIndices.length) {
this.setCourseHeading(null)
return
}
const activeLegIndex = this.gamePresentation.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',
panelTimerText: telemetryPresentation.timerText,
panelMileageText: telemetryPresentation.mileageText,
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.progressText,
punchButtonText: this.gamePresentation.punchButtonText,
punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
punchHintText: this.gamePresentation.punchHintText,
}
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
}
}
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,
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,
}, true)
}
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)
}
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)
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state)
this.updateSessionTimerLoop()
return this.resolveGameStatusText(effects)
}
handleStartGame(): void {
if (!this.gameRuntime.definition || !this.gameRuntime.state) {
this.setState({
statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
}, true)
return
}
if (this.gameRuntime.state.status !== 'idle') {
return
}
if (!this.locationController.listening) {
this.locationController.start()
}
const startedAt = Date.now()
let gameResult = this.gameRuntime.startSession(startedAt)
if (this.currentGpsPoint) {
gameResult = this.gameRuntime.dispatch({
type: 'gps_updated',
at: Date.now(),
lon: this.currentGpsPoint.lon,
lat: this.currentGpsPoint.lat,
accuracyMeters: this.currentGpsAccuracyMeters,
})
}
this.gamePresentation = this.gameRuntime.getPresentation()
this.refreshCourseHeadingFromPresentation()
const defaultStatusText = this.currentGpsPoint
? `顺序打点已开始 (${this.buildVersion})`
: `顺序打点已开始GPS定位启动中 (${this.buildVersion})`
const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText
this.setState({
...this.getGameViewPatch(gameStatusText),
}, true)
this.syncRenderer()
}
handlePunchAction(): void {
const gameResult = this.gameRuntime.dispatch({
type: 'punch_requested',
at: Date.now(),
})
this.gamePresentation = gameResult.presentation
this.refreshCourseHeadingFromPresentation()
const gameStatusText = this.applyGameEffects(gameResult.effects)
this.setState({
...this.getGameViewPatch(gameStatusText),
}, true)
this.syncRenderer()
}
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
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)
let gameStatusText: string | null = null
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.gamePresentation = gameResult.presentation
this.refreshCourseHeadingFromPresentation()
gameStatusText = this.applyGameEffects(gameResult.effects)
}
if (gpsInsideMap && !this.hasGpsCenteredOnce) {
this.hasGpsCenteredOnce = true
this.commitViewport({
centerTileX: gpsWorldPoint.x,
centerTileY: gpsWorldPoint.y,
tileTranslateX: 0,
tileTranslateY: 0,
gpsTracking: true,
gpsTrackingText: '持续定位进行中',
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
...this.getGameViewPatch(),
}, gameStatusText || `GPS定位成功已定位到当前位置 (${this.buildVersion})`, true)
return
}
this.setState({
gpsTracking: true,
gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `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()
}
handleConnectHeartRate(): void {
this.heartRateController.startScanAndConnect()
}
handleDisconnectHeartRate(): void {
this.heartRateController.disconnect()
}
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()}`,
}, true)
this.syncSessionTimerText()
}
handleClearDebugHeartRate(): void {
this.telemetryRuntime.dispatch({
type: 'heart_rate_updated',
at: Date.now(),
bpm: null,
})
this.setState({
heartRateStatusText: this.heartRateController.connected ? '心率带已连接' : '心率带未连接',
}, true)
this.syncSessionTimerText()
}
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 {
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.compassController.start()
}
applyRemoteMapConfig(config: RemoteMapConfig): void {
MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg
MAGNETIC_DECLINATION_TEXT = 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.gameMode = config.gameMode
this.punchPolicy = config.punchPolicy
this.punchRadiusMeters = config.punchRadiusMeters
this.autoFinishOnLastControl = config.autoFinishOnLastControl
this.telemetryRuntime.configure(config.telemetryConfig)
this.feedbackDirector.configure({
audioConfig: config.audioConfig,
hapticsConfig: config.hapticsConfig,
uiEffectsConfig: config.uiEffectsConfig,
})
const gameEffects = this.loadGameDefinitionFromCourse()
const gameStatusText = this.applyGameEffects(gameEffects)
const statePatch: Partial<MapEngineViewState> = {
configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
projectionMode: config.projectionModeText,
tileSource: config.tileSource,
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
...this.getGameViewPatch(),
}
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.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 = 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()
}
}
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.getStagePoint(event.touches)
if (!this.pinchStartDistance) {
this.pinchStartDistance = distance
this.pinchStartScale = this.previewScale || 1
this.pinchStartAngle = angle
this.pinchStartRotationDeg = this.state.rotationDeg
const anchorWorld = 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
this.normalizeTranslate(
this.state.tileTranslateX + deltaX,
this.state.tileTranslateY + deltaY,
`宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
)
}
handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
if (this.gestureMode === 'pinch' && event.touches.length < 2) {
const gestureScale = this.previewScale || 1
const zoomDelta = Math.round(Math.log2(gestureScale))
const originX = this.previewOriginX || this.state.stageWidth / 2
const originY = 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
}
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()
}
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()
}
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'),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
}, true)
if (this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
}
handleCompassHeading(headingDeg: number): void {
this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
? this.sensorHeadingDeg
: interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
? compassHeadingDeg
: interpolateAngleDeg(this.compassDisplayHeadingDeg, compassHeadingDeg, COMPASS_NEEDLE_SMOOTHING)
this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
this.setState({
sensorHeadingText: formatHeadingText(compassHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
})
if (!this.refreshAutoRotateTarget()) {
return
}
if (this.state.orientationMode === 'heading-up') {
this.scheduleAutoRotate()
}
}
handleCompassError(message: string): void {
this.clearAutoRotateTimer()
this.targetAutoRotationDeg = null
this.autoRotateCalibrationPending = false
this.setState({
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
statusText: `${message} (${this.buildVersion})`,
}, true)
}
cycleNorthReferenceMode(): void {
const nextMode = getNextNorthReferenceMode(this.northReferenceMode)
const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
? null
: getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
this.northReferenceMode = nextMode
this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
this.compassDisplayHeadingDeg = 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(compassHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
},
`${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
this.syncRenderer()
},
)
return
}
this.setState({
northReferenceText: formatNorthReferenceText(nextMode),
sensorHeadingText: formatHeadingText(compassHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
}, true)
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
}
setCourseHeading(headingDeg: number | null): void {
this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
this.setState({
autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
})
if (this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
}
resolveAutoRotateInputHeadingDeg(): number | null {
const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
? null
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
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 {
this.setState({
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),
})
}
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)
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),
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.courseData,
cpRadiusMeters: this.cpRadiusMeters,
activeControlSequences: this.gamePresentation.activeControlSequences,
activeStart: this.gamePresentation.activeStart,
completedStart: this.gamePresentation.completedStart,
activeFinish: this.gamePresentation.activeFinish,
completedFinish: this.gamePresentation.completedFinish,
revealFullCourse: this.gamePresentation.revealFullCourse,
activeLegIndices: this.gamePresentation.activeLegIndices,
completedLegIndices: this.gamePresentation.completedLegIndices,
completedControlSequences: this.gamePresentation.completedControlSequences,
osmReferenceEnabled: this.state.osmReferenceEnabled,
overlayOpacity: MAP_OVERLAY_OPACITY,
}
}
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
}
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
}
}
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
this.pendingViewPatch = {}
this.onData(patch)
}
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.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
}
}