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

2138 lines
67 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 { 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 { SoundDirector } from '../../game/audio/soundDirector'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
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
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
panelProgressText: string
punchButtonText: string
punchButtonEnabled: boolean
punchHintText: string
punchFeedbackVisible: boolean
punchFeedbackText: string
punchFeedbackTone: 'neutral' | 'success' | 'warning'
contentCardVisible: boolean
contentCardTitle: string
contentCardBody: 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',
'gameSessionStatus',
'panelProgressText',
'punchButtonText',
'punchButtonEnabled',
'punchHintText',
'punchFeedbackVisible',
'punchFeedbackText',
'punchFeedbackTone',
'contentCardVisible',
'contentCardTitle',
'contentCardBody',
'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
soundDirector: SoundDirector
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
gamePresentation: GamePresentationState
gameMode: 'classic-sequential'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
punchFeedbackTimer: number
contentCardTimer: 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.soundDirector = new SoundDirector()
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.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.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: '--',
panelProgressText: '0/0',
punchButtonText: '鎵撶偣',
gameSessionStatus: 'idle',
punchButtonEnabled: false,
punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿',
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
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.compassController.destroy()
this.locationController.destroy()
this.soundDirector.destroy()
this.renderer.destroy()
this.mounted = false
}
clearGameRuntime(): void {
this.gameRuntime.clear()
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
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.gamePresentation = result.presentation
this.refreshCourseHeadingFromPresentation()
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 patch: Partial<MapEngineViewState> = {
gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
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
}
}
showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning'): void {
this.clearPunchFeedbackTimer()
this.setState({
punchFeedbackVisible: true,
punchFeedbackText: text,
punchFeedbackTone: tone,
}, true)
this.punchFeedbackTimer = setTimeout(() => {
this.punchFeedbackTimer = 0
this.setState({
punchFeedbackVisible: false,
}, true)
}, 1400) as unknown as number
}
showContentCard(title: string, body: string): void {
this.clearContentCardTimer()
this.setState({
contentCardVisible: true,
contentCardTitle: title,
contentCardBody: body,
}, true)
this.contentCardTimer = setTimeout(() => {
this.contentCardTimer = 0
this.setState({
contentCardVisible: false,
}, true)
}, 2600) as unknown as number
}
closeContentCard(): void {
this.clearContentCardTimer()
this.setState({
contentCardVisible: false,
}, true)
}
applyGameEffects(effects: GameEffect[]): string | null {
this.soundDirector.handleEffects(effects)
const statusText = this.resolveGameStatusText(effects)
for (const effect of effects) {
if (effect.type === 'punch_feedback') {
this.showPunchFeedback(effect.text, effect.tone)
}
if (effect.type === 'control_completed') {
this.showPunchFeedback(`完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`, 'success')
this.showContentCard(effect.displayTitle, effect.displayBody)
}
if (effect.type === 'session_finished' && this.locationController.listening) {
this.locationController.stop()
}
}
return statusText
}
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 gameResult = this.gameRuntime.dispatch({
type: 'gps_updated',
at: Date.now(),
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()
}
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
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: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${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
}
}