mapEngine.ts 71 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273
  1. import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
  2. import { CompassHeadingController } from '../sensor/compassHeadingController'
  3. import { LocationController } from '../sensor/locationController'
  4. import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
  5. import { type MapRendererStats } from '../renderer/mapRenderer'
  6. import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
  7. import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
  8. import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
  9. import { GameRuntime } from '../../game/core/gameRuntime'
  10. import { type GameEffect } from '../../game/core/gameResult'
  11. import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
  12. import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
  13. import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
  14. const RENDER_MODE = 'Single WebGL Pipeline'
  15. const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
  16. const MAP_NORTH_OFFSET_DEG = 0
  17. let MAGNETIC_DECLINATION_DEG = -6.91
  18. let MAGNETIC_DECLINATION_TEXT = '6.91掳 W'
  19. const MIN_ZOOM = 15
  20. const MAX_ZOOM = 20
  21. const DEFAULT_ZOOM = 17
  22. const DESIRED_VISIBLE_COLUMNS = 3
  23. const OVERDRAW = 1
  24. const DEFAULT_TOP_LEFT_TILE_X = 108132
  25. const DEFAULT_TOP_LEFT_TILE_Y = 51199
  26. const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1
  27. const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1
  28. const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png'
  29. const OSM_TILE_SOURCE = 'https://tiles.mymarsgo.xyz/{z}/{x}/{y}.png'
  30. const MAP_OVERLAY_OPACITY = 0.72
  31. const GPS_MAP_CALIBRATION: MapCalibration = {
  32. offsetEastMeters: 0,
  33. offsetNorthMeters: 0,
  34. rotationDeg: 0,
  35. scale: 1,
  36. }
  37. const MIN_PREVIEW_SCALE = 0.55
  38. const MAX_PREVIEW_SCALE = 1.85
  39. const INERTIA_FRAME_MS = 16
  40. const INERTIA_DECAY = 0.92
  41. const INERTIA_MIN_SPEED = 0.02
  42. const PREVIEW_RESET_DURATION_MS = 140
  43. const UI_SYNC_INTERVAL_MS = 80
  44. const ROTATE_STEP_DEG = 15
  45. const AUTO_ROTATE_FRAME_MS = 8
  46. const AUTO_ROTATE_EASE = 0.34
  47. const AUTO_ROTATE_SNAP_DEG = 0.1
  48. const AUTO_ROTATE_DEADZONE_DEG = 4
  49. const AUTO_ROTATE_MAX_STEP_DEG = 0.75
  50. const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
  51. const COMPASS_NEEDLE_SMOOTHING = 0.12
  52. const GPS_TRACK_MAX_POINTS = 200
  53. const GPS_TRACK_MIN_STEP_METERS = 3
  54. type TouchPoint = WechatMiniprogram.TouchDetail
  55. type GestureMode = 'idle' | 'pan' | 'pinch'
  56. type RotationMode = 'manual' | 'auto'
  57. type OrientationMode = 'manual' | 'north-up' | 'heading-up'
  58. type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion'
  59. type NorthReferenceMode = 'magnetic' | 'true'
  60. const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
  61. export interface MapEngineStageRect {
  62. width: number
  63. height: number
  64. left: number
  65. top: number
  66. }
  67. export interface MapEngineViewState {
  68. buildVersion: string
  69. renderMode: string
  70. projectionMode: string
  71. mapReady: boolean
  72. mapReadyText: string
  73. mapName: string
  74. configStatusText: string
  75. zoom: number
  76. rotationDeg: number
  77. rotationText: string
  78. rotationMode: RotationMode
  79. rotationModeText: string
  80. rotationToggleText: string
  81. orientationMode: OrientationMode
  82. orientationModeText: string
  83. sensorHeadingText: string
  84. compassDeclinationText: string
  85. northReferenceButtonText: string
  86. autoRotateSourceText: string
  87. autoRotateCalibrationText: string
  88. northReferenceText: string
  89. compassNeedleDeg: number
  90. centerTileX: number
  91. centerTileY: number
  92. centerText: string
  93. tileSource: string
  94. visibleColumnCount: number
  95. visibleTileCount: number
  96. readyTileCount: number
  97. memoryTileCount: number
  98. diskTileCount: number
  99. memoryHitCount: number
  100. diskHitCount: number
  101. networkFetchCount: number
  102. cacheHitRateText: string
  103. tileTranslateX: number
  104. tileTranslateY: number
  105. tileSizePx: number
  106. stageWidth: number
  107. stageHeight: number
  108. stageLeft: number
  109. stageTop: number
  110. statusText: string
  111. gpsTracking: boolean
  112. gpsTrackingText: string
  113. gpsCoordText: string
  114. gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
  115. panelProgressText: string
  116. punchButtonText: string
  117. punchButtonEnabled: boolean
  118. punchHintText: string
  119. punchFeedbackVisible: boolean
  120. punchFeedbackText: string
  121. punchFeedbackTone: 'neutral' | 'success' | 'warning'
  122. contentCardVisible: boolean
  123. contentCardTitle: string
  124. contentCardBody: string
  125. punchButtonFxClass: string
  126. punchFeedbackFxClass: string
  127. contentCardFxClass: string
  128. mapPulseVisible: boolean
  129. mapPulseLeftPx: number
  130. mapPulseTopPx: number
  131. mapPulseFxClass: string
  132. stageFxVisible: boolean
  133. stageFxClass: string
  134. osmReferenceEnabled: boolean
  135. osmReferenceText: string
  136. }
  137. export interface MapEngineCallbacks {
  138. onData: (patch: Partial<MapEngineViewState>) => void
  139. }
  140. const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
  141. 'buildVersion',
  142. 'renderMode',
  143. 'projectionMode',
  144. 'mapReady',
  145. 'mapReadyText',
  146. 'mapName',
  147. 'configStatusText',
  148. 'zoom',
  149. 'rotationDeg',
  150. 'rotationText',
  151. 'rotationMode',
  152. 'rotationModeText',
  153. 'rotationToggleText',
  154. 'orientationMode',
  155. 'orientationModeText',
  156. 'sensorHeadingText',
  157. 'compassDeclinationText',
  158. 'northReferenceButtonText',
  159. 'autoRotateSourceText',
  160. 'autoRotateCalibrationText',
  161. 'northReferenceText',
  162. 'compassNeedleDeg',
  163. 'centerText',
  164. 'tileSource',
  165. 'visibleTileCount',
  166. 'readyTileCount',
  167. 'memoryTileCount',
  168. 'diskTileCount',
  169. 'memoryHitCount',
  170. 'diskHitCount',
  171. 'networkFetchCount',
  172. 'cacheHitRateText',
  173. 'tileSizePx',
  174. 'statusText',
  175. 'gpsTracking',
  176. 'gpsTrackingText',
  177. 'gpsCoordText',
  178. 'gameSessionStatus',
  179. 'panelProgressText',
  180. 'punchButtonText',
  181. 'punchButtonEnabled',
  182. 'punchHintText',
  183. 'punchFeedbackVisible',
  184. 'punchFeedbackText',
  185. 'punchFeedbackTone',
  186. 'contentCardVisible',
  187. 'contentCardTitle',
  188. 'contentCardBody',
  189. 'punchButtonFxClass',
  190. 'punchFeedbackFxClass',
  191. 'contentCardFxClass',
  192. 'mapPulseVisible',
  193. 'mapPulseLeftPx',
  194. 'mapPulseTopPx',
  195. 'mapPulseFxClass',
  196. 'stageFxVisible',
  197. 'stageFxClass',
  198. 'osmReferenceEnabled',
  199. 'osmReferenceText',
  200. ]
  201. function buildCenterText(zoom: number, x: number, y: number): string {
  202. return `z${zoom} / x${x} / y${y}`
  203. }
  204. function clamp(value: number, min: number, max: number): number {
  205. return Math.max(min, Math.min(max, value))
  206. }
  207. function normalizeRotationDeg(rotationDeg: number): number {
  208. const normalized = rotationDeg % 360
  209. return normalized < 0 ? normalized + 360 : normalized
  210. }
  211. function normalizeAngleDeltaRad(angleDeltaRad: number): number {
  212. let normalized = angleDeltaRad
  213. while (normalized > Math.PI) {
  214. normalized -= Math.PI * 2
  215. }
  216. while (normalized < -Math.PI) {
  217. normalized += Math.PI * 2
  218. }
  219. return normalized
  220. }
  221. function normalizeAngleDeltaDeg(angleDeltaDeg: number): number {
  222. let normalized = angleDeltaDeg
  223. while (normalized > 180) {
  224. normalized -= 360
  225. }
  226. while (normalized < -180) {
  227. normalized += 360
  228. }
  229. return normalized
  230. }
  231. function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number {
  232. return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
  233. }
  234. function formatRotationText(rotationDeg: number): string {
  235. return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
  236. }
  237. function formatHeadingText(headingDeg: number | null): string {
  238. if (headingDeg === null) {
  239. return '--'
  240. }
  241. return `${Math.round(normalizeRotationDeg(headingDeg))}掳`
  242. }
  243. function formatOrientationModeText(mode: OrientationMode): string {
  244. if (mode === 'north-up') {
  245. return 'North Up'
  246. }
  247. if (mode === 'heading-up') {
  248. return 'Heading Up'
  249. }
  250. return 'Manual Gesture'
  251. }
  252. function formatRotationModeText(mode: OrientationMode): string {
  253. return formatOrientationModeText(mode)
  254. }
  255. function formatRotationToggleText(mode: OrientationMode): string {
  256. if (mode === 'manual') {
  257. return '切到北朝上'
  258. }
  259. if (mode === 'north-up') {
  260. return '切到朝向朝上'
  261. }
  262. return '鍒囧埌鎵嬪姩鏃嬭浆'
  263. }
  264. function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
  265. if (mode === 'sensor') {
  266. return 'Sensor Only'
  267. }
  268. if (mode === 'course') {
  269. return hasCourseHeading ? 'Course Only' : 'Course Pending'
  270. }
  271. return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
  272. }
  273. function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
  274. if (pending) {
  275. return 'Pending'
  276. }
  277. if (offsetDeg === null) {
  278. return '--'
  279. }
  280. return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg`
  281. }
  282. function getTrueHeadingDeg(magneticHeadingDeg: number): number {
  283. return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
  284. }
  285. function getMagneticHeadingDeg(trueHeadingDeg: number): number {
  286. return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
  287. }
  288. function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
  289. return MAP_NORTH_OFFSET_DEG
  290. }
  291. function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  292. if (mode === 'true') {
  293. return getTrueHeadingDeg(magneticHeadingDeg)
  294. }
  295. return normalizeRotationDeg(magneticHeadingDeg)
  296. }
  297. function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  298. if (mode === 'magnetic') {
  299. return normalizeRotationDeg(magneticHeadingDeg)
  300. }
  301. return getTrueHeadingDeg(magneticHeadingDeg)
  302. }
  303. function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
  304. if (mode === 'magnetic') {
  305. return getMagneticHeadingDeg(trueHeadingDeg)
  306. }
  307. return normalizeRotationDeg(trueHeadingDeg)
  308. }
  309. function formatNorthReferenceText(mode: NorthReferenceMode): string {
  310. if (mode === 'magnetic') {
  311. return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
  312. }
  313. return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
  314. }
  315. function formatCompassDeclinationText(mode: NorthReferenceMode): string {
  316. if (mode === 'true') {
  317. return MAGNETIC_DECLINATION_TEXT
  318. }
  319. return ''
  320. }
  321. function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
  322. return mode === 'magnetic' ? '鍖楀弬鑰冿細纾佸寳' : '鍖楀弬鑰冿細鐪熷寳'
  323. }
  324. function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
  325. if (mode === 'magnetic') {
  326. return '已切到磁北模式'
  327. }
  328. return '已切到真北模式'
  329. }
  330. function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
  331. return mode === 'magnetic' ? 'true' : 'magnetic'
  332. }
  333. function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
  334. if (magneticHeadingDeg === null) {
  335. return 0
  336. }
  337. const referenceHeadingDeg = mode === 'true'
  338. ? getTrueHeadingDeg(magneticHeadingDeg)
  339. : normalizeRotationDeg(magneticHeadingDeg)
  340. return normalizeRotationDeg(360 - referenceHeadingDeg)
  341. }
  342. function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string {
  343. const total = memoryHitCount + diskHitCount + networkFetchCount
  344. if (!total) {
  345. return '--'
  346. }
  347. const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
  348. return `${Math.round(hitRate)}%`
  349. }
  350. function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string {
  351. if (!point) {
  352. return '--'
  353. }
  354. const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}`
  355. if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) {
  356. return base
  357. }
  358. return `${base} / 卤${Math.round(accuracyMeters)}m`
  359. }
  360. function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
  361. const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  362. const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  363. const dy = (b.lat - a.lat) * 110540
  364. return Math.sqrt(dx * dx + dy * dy)
  365. }
  366. function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
  367. const fromLatRad = from.lat * Math.PI / 180
  368. const toLatRad = to.lat * Math.PI / 180
  369. const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
  370. const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
  371. const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
  372. const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
  373. return normalizeRotationDeg(bearingDeg)
  374. }
  375. export class MapEngine {
  376. buildVersion: string
  377. renderer: WebGLMapRenderer
  378. compassController: CompassHeadingController
  379. locationController: LocationController
  380. feedbackDirector: FeedbackDirector
  381. onData: (patch: Partial<MapEngineViewState>) => void
  382. state: MapEngineViewState
  383. previewScale: number
  384. previewOriginX: number
  385. previewOriginY: number
  386. panLastX: number
  387. panLastY: number
  388. panLastTimestamp: number
  389. panVelocityX: number
  390. panVelocityY: number
  391. pinchStartDistance: number
  392. pinchStartScale: number
  393. pinchStartAngle: number
  394. pinchStartRotationDeg: number
  395. pinchAnchorWorldX: number
  396. pinchAnchorWorldY: number
  397. gestureMode: GestureMode
  398. inertiaTimer: number
  399. previewResetTimer: number
  400. viewSyncTimer: number
  401. autoRotateTimer: number
  402. pendingViewPatch: Partial<MapEngineViewState>
  403. mounted: boolean
  404. northReferenceMode: NorthReferenceMode
  405. sensorHeadingDeg: number | null
  406. smoothedSensorHeadingDeg: number | null
  407. compassDisplayHeadingDeg: number | null
  408. autoRotateHeadingDeg: number | null
  409. courseHeadingDeg: number | null
  410. targetAutoRotationDeg: number | null
  411. autoRotateSourceMode: AutoRotateSourceMode
  412. autoRotateCalibrationOffsetDeg: number | null
  413. autoRotateCalibrationPending: boolean
  414. minZoom: number
  415. maxZoom: number
  416. defaultZoom: number
  417. defaultCenterTileX: number
  418. defaultCenterTileY: number
  419. tileBoundsByZoom: Record<number, TileZoomBounds> | null
  420. currentGpsPoint: LonLatPoint | null
  421. currentGpsTrack: LonLatPoint[]
  422. currentGpsAccuracyMeters: number | null
  423. courseData: OrienteeringCourseData | null
  424. cpRadiusMeters: number
  425. gameRuntime: GameRuntime
  426. gamePresentation: GamePresentationState
  427. gameMode: 'classic-sequential'
  428. punchPolicy: 'enter' | 'enter-confirm'
  429. punchRadiusMeters: number
  430. autoFinishOnLastControl: boolean
  431. punchFeedbackTimer: number
  432. contentCardTimer: number
  433. mapPulseTimer: number
  434. stageFxTimer: number
  435. hasGpsCenteredOnce: boolean
  436. constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
  437. this.buildVersion = buildVersion
  438. this.onData = callbacks.onData
  439. this.renderer = new WebGLMapRenderer(
  440. (stats) => {
  441. this.applyStats(stats)
  442. },
  443. (message) => {
  444. this.setState({
  445. statusText: `${message} (${this.buildVersion})`,
  446. })
  447. },
  448. )
  449. this.compassController = new CompassHeadingController({
  450. onHeading: (headingDeg) => {
  451. this.handleCompassHeading(headingDeg)
  452. },
  453. onError: (message) => {
  454. this.handleCompassError(message)
  455. },
  456. })
  457. this.locationController = new LocationController({
  458. onLocation: (update) => {
  459. this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
  460. },
  461. onStatus: (message) => {
  462. this.setState({
  463. gpsTracking: this.locationController.listening,
  464. gpsTrackingText: message,
  465. }, true)
  466. },
  467. onError: (message) => {
  468. this.setState({
  469. gpsTracking: false,
  470. gpsTrackingText: message,
  471. statusText: `${message} (${this.buildVersion})`,
  472. }, true)
  473. },
  474. })
  475. this.feedbackDirector = new FeedbackDirector({
  476. showPunchFeedback: (text, tone, motionClass) => {
  477. this.showPunchFeedback(text, tone, motionClass)
  478. },
  479. showContentCard: (title, body, motionClass) => {
  480. this.showContentCard(title, body, motionClass)
  481. },
  482. setPunchButtonFxClass: (className) => {
  483. this.setPunchButtonFxClass(className)
  484. },
  485. showMapPulse: (controlId, motionClass) => {
  486. this.showMapPulse(controlId, motionClass)
  487. },
  488. showStageFx: (className) => {
  489. this.showStageFx(className)
  490. },
  491. stopLocationTracking: () => {
  492. if (this.locationController.listening) {
  493. this.locationController.stop()
  494. }
  495. },
  496. })
  497. this.minZoom = MIN_ZOOM
  498. this.maxZoom = MAX_ZOOM
  499. this.defaultZoom = DEFAULT_ZOOM
  500. this.defaultCenterTileX = DEFAULT_CENTER_TILE_X
  501. this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y
  502. this.tileBoundsByZoom = null
  503. this.currentGpsPoint = null
  504. this.currentGpsTrack = []
  505. this.currentGpsAccuracyMeters = null
  506. this.courseData = null
  507. this.cpRadiusMeters = 5
  508. this.gameRuntime = new GameRuntime()
  509. this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
  510. this.gameMode = 'classic-sequential'
  511. this.punchPolicy = 'enter-confirm'
  512. this.punchRadiusMeters = 5
  513. this.autoFinishOnLastControl = true
  514. this.punchFeedbackTimer = 0
  515. this.contentCardTimer = 0
  516. this.mapPulseTimer = 0
  517. this.stageFxTimer = 0
  518. this.hasGpsCenteredOnce = false
  519. this.state = {
  520. buildVersion: this.buildVersion,
  521. renderMode: RENDER_MODE,
  522. projectionMode: PROJECTION_MODE,
  523. mapReady: false,
  524. mapReadyText: 'BOOTING',
  525. mapName: 'LCX 娴嬭瘯鍦板浘',
  526. configStatusText: '远程配置待加载',
  527. zoom: DEFAULT_ZOOM,
  528. rotationDeg: 0,
  529. rotationText: formatRotationText(0),
  530. rotationMode: 'manual',
  531. rotationModeText: formatRotationModeText('manual'),
  532. rotationToggleText: formatRotationToggleText('manual'),
  533. orientationMode: 'manual',
  534. orientationModeText: formatOrientationModeText('manual'),
  535. sensorHeadingText: '--',
  536. compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
  537. northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
  538. autoRotateSourceText: formatAutoRotateSourceText('sensor', false),
  539. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
  540. northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
  541. compassNeedleDeg: 0,
  542. centerTileX: DEFAULT_CENTER_TILE_X,
  543. centerTileY: DEFAULT_CENTER_TILE_Y,
  544. centerText: buildCenterText(DEFAULT_ZOOM, DEFAULT_CENTER_TILE_X, DEFAULT_CENTER_TILE_Y),
  545. tileSource: TILE_SOURCE,
  546. visibleColumnCount: DESIRED_VISIBLE_COLUMNS,
  547. visibleTileCount: 0,
  548. readyTileCount: 0,
  549. memoryTileCount: 0,
  550. diskTileCount: 0,
  551. memoryHitCount: 0,
  552. diskHitCount: 0,
  553. networkFetchCount: 0,
  554. cacheHitRateText: '--',
  555. tileTranslateX: 0,
  556. tileTranslateY: 0,
  557. tileSizePx: 0,
  558. stageWidth: 0,
  559. stageHeight: 0,
  560. stageLeft: 0,
  561. stageTop: 0,
  562. statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`,
  563. gpsTracking: false,
  564. gpsTrackingText: '持续定位待启动',
  565. gpsCoordText: '--',
  566. panelProgressText: '0/0',
  567. punchButtonText: '鎵撶偣',
  568. gameSessionStatus: 'idle',
  569. punchButtonEnabled: false,
  570. punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿',
  571. punchFeedbackVisible: false,
  572. punchFeedbackText: '',
  573. punchFeedbackTone: 'neutral',
  574. contentCardVisible: false,
  575. contentCardTitle: '',
  576. contentCardBody: '',
  577. punchButtonFxClass: '',
  578. punchFeedbackFxClass: '',
  579. contentCardFxClass: '',
  580. mapPulseVisible: false,
  581. mapPulseLeftPx: 0,
  582. mapPulseTopPx: 0,
  583. mapPulseFxClass: '',
  584. stageFxVisible: false,
  585. stageFxClass: '',
  586. osmReferenceEnabled: false,
  587. osmReferenceText: 'OSM参考:关',
  588. }
  589. this.previewScale = 1
  590. this.previewOriginX = 0
  591. this.previewOriginY = 0
  592. this.panLastX = 0
  593. this.panLastY = 0
  594. this.panLastTimestamp = 0
  595. this.panVelocityX = 0
  596. this.panVelocityY = 0
  597. this.pinchStartDistance = 0
  598. this.pinchStartScale = 1
  599. this.pinchStartAngle = 0
  600. this.pinchStartRotationDeg = 0
  601. this.pinchAnchorWorldX = 0
  602. this.pinchAnchorWorldY = 0
  603. this.gestureMode = 'idle'
  604. this.inertiaTimer = 0
  605. this.previewResetTimer = 0
  606. this.viewSyncTimer = 0
  607. this.autoRotateTimer = 0
  608. this.pendingViewPatch = {}
  609. this.mounted = false
  610. this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
  611. this.sensorHeadingDeg = null
  612. this.smoothedSensorHeadingDeg = null
  613. this.compassDisplayHeadingDeg = null
  614. this.autoRotateHeadingDeg = null
  615. this.courseHeadingDeg = null
  616. this.targetAutoRotationDeg = null
  617. this.autoRotateSourceMode = 'sensor'
  618. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
  619. this.autoRotateCalibrationPending = false
  620. }
  621. getInitialData(): MapEngineViewState {
  622. return { ...this.state }
  623. }
  624. destroy(): void {
  625. this.clearInertiaTimer()
  626. this.clearPreviewResetTimer()
  627. this.clearViewSyncTimer()
  628. this.clearAutoRotateTimer()
  629. this.clearPunchFeedbackTimer()
  630. this.clearContentCardTimer()
  631. this.clearMapPulseTimer()
  632. this.clearStageFxTimer()
  633. this.compassController.destroy()
  634. this.locationController.destroy()
  635. this.feedbackDirector.destroy()
  636. this.renderer.destroy()
  637. this.mounted = false
  638. }
  639. clearGameRuntime(): void {
  640. this.gameRuntime.clear()
  641. this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
  642. this.setCourseHeading(null)
  643. }
  644. loadGameDefinitionFromCourse(): GameEffect[] {
  645. if (!this.courseData) {
  646. this.clearGameRuntime()
  647. return []
  648. }
  649. const definition = buildGameDefinitionFromCourse(
  650. this.courseData,
  651. this.cpRadiusMeters,
  652. this.gameMode,
  653. this.autoFinishOnLastControl,
  654. this.punchPolicy,
  655. this.punchRadiusMeters,
  656. )
  657. const result = this.gameRuntime.loadDefinition(definition)
  658. this.gamePresentation = result.presentation
  659. this.refreshCourseHeadingFromPresentation()
  660. return result.effects
  661. }
  662. refreshCourseHeadingFromPresentation(): void {
  663. if (!this.courseData || !this.gamePresentation.activeLegIndices.length) {
  664. this.setCourseHeading(null)
  665. return
  666. }
  667. const activeLegIndex = this.gamePresentation.activeLegIndices[0]
  668. const activeLeg = this.courseData.layers.legs[activeLegIndex]
  669. if (!activeLeg) {
  670. this.setCourseHeading(null)
  671. return
  672. }
  673. this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint))
  674. }
  675. resolveGameStatusText(effects: GameEffect[]): string | null {
  676. const lastEffect = effects.length ? effects[effects.length - 1] : null
  677. if (!lastEffect) {
  678. return null
  679. }
  680. if (lastEffect.type === 'control_completed') {
  681. const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId
  682. return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})`
  683. }
  684. if (lastEffect.type === 'session_finished') {
  685. return `璺嚎宸插畬鎴?(${this.buildVersion})`
  686. }
  687. if (lastEffect.type === 'session_started') {
  688. return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})`
  689. }
  690. return null
  691. }
  692. getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
  693. const patch: Partial<MapEngineViewState> = {
  694. gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
  695. panelProgressText: this.gamePresentation.progressText,
  696. punchButtonText: this.gamePresentation.punchButtonText,
  697. punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
  698. punchHintText: this.gamePresentation.punchHintText,
  699. }
  700. if (statusText) {
  701. patch.statusText = statusText
  702. }
  703. return patch
  704. }
  705. clearPunchFeedbackTimer(): void {
  706. if (this.punchFeedbackTimer) {
  707. clearTimeout(this.punchFeedbackTimer)
  708. this.punchFeedbackTimer = 0
  709. }
  710. }
  711. clearContentCardTimer(): void {
  712. if (this.contentCardTimer) {
  713. clearTimeout(this.contentCardTimer)
  714. this.contentCardTimer = 0
  715. }
  716. }
  717. clearMapPulseTimer(): void {
  718. if (this.mapPulseTimer) {
  719. clearTimeout(this.mapPulseTimer)
  720. this.mapPulseTimer = 0
  721. }
  722. }
  723. clearStageFxTimer(): void {
  724. if (this.stageFxTimer) {
  725. clearTimeout(this.stageFxTimer)
  726. this.stageFxTimer = 0
  727. }
  728. }
  729. getControlScreenPoint(controlId: string): { x: number; y: number } | null {
  730. if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
  731. return null
  732. }
  733. const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
  734. if (!control) {
  735. return null
  736. }
  737. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  738. const screenPoint = worldToScreen({
  739. centerWorldX: exactCenter.x,
  740. centerWorldY: exactCenter.y,
  741. viewportWidth: this.state.stageWidth,
  742. viewportHeight: this.state.stageHeight,
  743. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  744. rotationRad: this.getRotationRad(this.state.rotationDeg),
  745. }, lonLatToWorldTile(control.point, this.state.zoom), false)
  746. if (screenPoint.x < -80 || screenPoint.x > this.state.stageWidth + 80 || screenPoint.y < -80 || screenPoint.y > this.state.stageHeight + 80) {
  747. return null
  748. }
  749. return screenPoint
  750. }
  751. setPunchButtonFxClass(className: string): void {
  752. this.setState({
  753. punchButtonFxClass: className,
  754. }, true)
  755. }
  756. showMapPulse(controlId: string, motionClass = ''): void {
  757. const screenPoint = this.getControlScreenPoint(controlId)
  758. if (!screenPoint) {
  759. return
  760. }
  761. this.clearMapPulseTimer()
  762. this.setState({
  763. mapPulseVisible: true,
  764. mapPulseLeftPx: screenPoint.x,
  765. mapPulseTopPx: screenPoint.y,
  766. mapPulseFxClass: motionClass,
  767. }, true)
  768. this.mapPulseTimer = setTimeout(() => {
  769. this.mapPulseTimer = 0
  770. this.setState({
  771. mapPulseVisible: false,
  772. mapPulseFxClass: '',
  773. }, true)
  774. }, 820) as unknown as number
  775. }
  776. showStageFx(className: string): void {
  777. if (!className) {
  778. return
  779. }
  780. this.clearStageFxTimer()
  781. this.setState({
  782. stageFxVisible: true,
  783. stageFxClass: className,
  784. }, true)
  785. this.stageFxTimer = setTimeout(() => {
  786. this.stageFxTimer = 0
  787. this.setState({
  788. stageFxVisible: false,
  789. stageFxClass: '',
  790. }, true)
  791. }, 760) as unknown as number
  792. }
  793. showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning', motionClass = ''): void {
  794. this.clearPunchFeedbackTimer()
  795. this.setState({
  796. punchFeedbackVisible: true,
  797. punchFeedbackText: text,
  798. punchFeedbackTone: tone,
  799. punchFeedbackFxClass: motionClass,
  800. }, true)
  801. this.punchFeedbackTimer = setTimeout(() => {
  802. this.punchFeedbackTimer = 0
  803. this.setState({
  804. punchFeedbackVisible: false,
  805. punchFeedbackFxClass: '',
  806. }, true)
  807. }, 1400) as unknown as number
  808. }
  809. showContentCard(title: string, body: string, motionClass = ''): void {
  810. this.clearContentCardTimer()
  811. this.setState({
  812. contentCardVisible: true,
  813. contentCardTitle: title,
  814. contentCardBody: body,
  815. contentCardFxClass: motionClass,
  816. }, true)
  817. this.contentCardTimer = setTimeout(() => {
  818. this.contentCardTimer = 0
  819. this.setState({
  820. contentCardVisible: false,
  821. contentCardFxClass: '',
  822. }, true)
  823. }, 2600) as unknown as number
  824. }
  825. closeContentCard(): void {
  826. this.clearContentCardTimer()
  827. this.setState({
  828. contentCardVisible: false,
  829. contentCardFxClass: '',
  830. }, true)
  831. }
  832. applyGameEffects(effects: GameEffect[]): string | null {
  833. this.feedbackDirector.handleEffects(effects)
  834. return this.resolveGameStatusText(effects)
  835. }
  836. handleStartGame(): void {
  837. if (!this.gameRuntime.definition || !this.gameRuntime.state) {
  838. this.setState({
  839. statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
  840. }, true)
  841. return
  842. }
  843. if (this.gameRuntime.state.status !== 'idle') {
  844. return
  845. }
  846. if (!this.locationController.listening) {
  847. this.locationController.start()
  848. }
  849. const startedAt = Date.now()
  850. let gameResult = this.gameRuntime.startSession(startedAt)
  851. if (this.currentGpsPoint) {
  852. gameResult = this.gameRuntime.dispatch({
  853. type: 'gps_updated',
  854. at: Date.now(),
  855. lon: this.currentGpsPoint.lon,
  856. lat: this.currentGpsPoint.lat,
  857. accuracyMeters: this.currentGpsAccuracyMeters,
  858. })
  859. }
  860. this.gamePresentation = this.gameRuntime.getPresentation()
  861. this.refreshCourseHeadingFromPresentation()
  862. const defaultStatusText = this.currentGpsPoint
  863. ? `顺序打点已开始 (${this.buildVersion})`
  864. : `顺序打点已开始,GPS定位启动中 (${this.buildVersion})`
  865. const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText
  866. this.setState({
  867. ...this.getGameViewPatch(gameStatusText),
  868. }, true)
  869. this.syncRenderer()
  870. }
  871. handlePunchAction(): void {
  872. const gameResult = this.gameRuntime.dispatch({
  873. type: 'punch_requested',
  874. at: Date.now(),
  875. })
  876. this.gamePresentation = gameResult.presentation
  877. this.refreshCourseHeadingFromPresentation()
  878. const gameStatusText = this.applyGameEffects(gameResult.effects)
  879. this.setState({
  880. ...this.getGameViewPatch(gameStatusText),
  881. }, true)
  882. this.syncRenderer()
  883. }
  884. handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
  885. const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
  886. const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
  887. if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
  888. this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
  889. }
  890. this.currentGpsPoint = nextPoint
  891. this.currentGpsAccuracyMeters = accuracyMeters
  892. const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
  893. const gpsTileX = Math.floor(gpsWorldPoint.x)
  894. const gpsTileY = Math.floor(gpsWorldPoint.y)
  895. const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
  896. let gameStatusText: string | null = null
  897. if (this.courseData) {
  898. const gameResult = this.gameRuntime.dispatch({
  899. type: 'gps_updated',
  900. at: Date.now(),
  901. lon: longitude,
  902. lat: latitude,
  903. accuracyMeters,
  904. })
  905. this.gamePresentation = gameResult.presentation
  906. this.refreshCourseHeadingFromPresentation()
  907. gameStatusText = this.applyGameEffects(gameResult.effects)
  908. }
  909. if (gpsInsideMap && !this.hasGpsCenteredOnce) {
  910. this.hasGpsCenteredOnce = true
  911. this.commitViewport({
  912. centerTileX: gpsWorldPoint.x,
  913. centerTileY: gpsWorldPoint.y,
  914. tileTranslateX: 0,
  915. tileTranslateY: 0,
  916. gpsTracking: true,
  917. gpsTrackingText: '持续定位进行中',
  918. gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
  919. ...this.getGameViewPatch(),
  920. }, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
  921. return
  922. }
  923. this.setState({
  924. gpsTracking: true,
  925. gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
  926. gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
  927. ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
  928. }, true)
  929. this.syncRenderer()
  930. }
  931. handleToggleOsmReference(): void {
  932. const nextEnabled = !this.state.osmReferenceEnabled
  933. this.setState({
  934. osmReferenceEnabled: nextEnabled,
  935. osmReferenceText: nextEnabled ? 'OSM参考:开' : 'OSM参考:关',
  936. statusText: nextEnabled ? `OSM参考底图已开启 (${this.buildVersion})` : `OSM参考底图已关闭 (${this.buildVersion})`,
  937. }, true)
  938. this.syncRenderer()
  939. }
  940. handleToggleGpsTracking(): void {
  941. if (this.locationController.listening) {
  942. this.locationController.stop()
  943. return
  944. }
  945. this.locationController.start()
  946. }
  947. setStage(rect: MapEngineStageRect): void {
  948. this.previewScale = 1
  949. this.previewOriginX = rect.width / 2
  950. this.previewOriginY = rect.height / 2
  951. this.commitViewport(
  952. {
  953. stageWidth: rect.width,
  954. stageHeight: rect.height,
  955. stageLeft: rect.left,
  956. stageTop: rect.top,
  957. },
  958. `鍦板浘瑙嗗彛涓?WebGL 寮曟搸宸插榻?(${this.buildVersion})`,
  959. true,
  960. )
  961. }
  962. attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
  963. this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
  964. this.mounted = true
  965. this.state.mapReady = true
  966. this.state.mapReadyText = 'READY'
  967. this.onData({
  968. mapReady: true,
  969. mapReadyText: 'READY',
  970. statusText: `鍗?WebGL 绠$嚎宸插氨缁紝鍙垏鎹㈡墜鍔ㄦ垨鑷姩鏈濆悜 (${this.buildVersion})`,
  971. })
  972. this.syncRenderer()
  973. this.compassController.start()
  974. }
  975. applyRemoteMapConfig(config: RemoteMapConfig): void {
  976. MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg
  977. MAGNETIC_DECLINATION_TEXT = config.magneticDeclinationText
  978. this.minZoom = config.minZoom
  979. this.maxZoom = config.maxZoom
  980. this.defaultZoom = config.defaultZoom
  981. this.defaultCenterTileX = config.initialCenterTileX
  982. this.defaultCenterTileY = config.initialCenterTileY
  983. this.tileBoundsByZoom = config.tileBoundsByZoom
  984. this.courseData = config.course
  985. this.cpRadiusMeters = config.cpRadiusMeters
  986. this.gameMode = config.gameMode
  987. this.punchPolicy = config.punchPolicy
  988. this.punchRadiusMeters = config.punchRadiusMeters
  989. this.autoFinishOnLastControl = config.autoFinishOnLastControl
  990. this.feedbackDirector.configure({
  991. audioConfig: config.audioConfig,
  992. hapticsConfig: config.hapticsConfig,
  993. uiEffectsConfig: config.uiEffectsConfig,
  994. })
  995. const gameEffects = this.loadGameDefinitionFromCourse()
  996. const gameStatusText = this.applyGameEffects(gameEffects)
  997. const statePatch: Partial<MapEngineViewState> = {
  998. configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`,
  999. projectionMode: config.projectionModeText,
  1000. tileSource: config.tileSource,
  1001. sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
  1002. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  1003. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  1004. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  1005. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
  1006. ...this.getGameViewPatch(),
  1007. }
  1008. if (!this.state.stageWidth || !this.state.stageHeight) {
  1009. this.setState({
  1010. ...statePatch,
  1011. zoom: this.defaultZoom,
  1012. centerTileX: this.defaultCenterTileX,
  1013. centerTileY: this.defaultCenterTileY,
  1014. centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
  1015. statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
  1016. }, true)
  1017. return
  1018. }
  1019. this.commitViewport({
  1020. ...statePatch,
  1021. zoom: this.defaultZoom,
  1022. centerTileX: this.defaultCenterTileX,
  1023. centerTileY: this.defaultCenterTileY,
  1024. tileTranslateX: 0,
  1025. tileTranslateY: 0,
  1026. }, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => {
  1027. this.resetPreviewState()
  1028. this.syncRenderer()
  1029. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  1030. this.scheduleAutoRotate()
  1031. }
  1032. })
  1033. }
  1034. handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
  1035. this.clearInertiaTimer()
  1036. this.clearPreviewResetTimer()
  1037. this.panVelocityX = 0
  1038. this.panVelocityY = 0
  1039. if (event.touches.length >= 2) {
  1040. const origin = this.getStagePoint(event.touches)
  1041. this.gestureMode = 'pinch'
  1042. this.pinchStartDistance = this.getTouchDistance(event.touches)
  1043. this.pinchStartScale = this.previewScale || 1
  1044. this.pinchStartAngle = this.getTouchAngle(event.touches)
  1045. this.pinchStartRotationDeg = this.state.rotationDeg
  1046. const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
  1047. this.pinchAnchorWorldX = anchorWorld.x
  1048. this.pinchAnchorWorldY = anchorWorld.y
  1049. this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
  1050. this.syncRenderer()
  1051. this.compassController.start()
  1052. return
  1053. }
  1054. if (event.touches.length === 1) {
  1055. this.gestureMode = 'pan'
  1056. this.panLastX = event.touches[0].pageX
  1057. this.panLastY = event.touches[0].pageY
  1058. this.panLastTimestamp = event.timeStamp || Date.now()
  1059. }
  1060. }
  1061. handleTouchMove(event: WechatMiniprogram.TouchEvent): void {
  1062. if (event.touches.length >= 2) {
  1063. const distance = this.getTouchDistance(event.touches)
  1064. const angle = this.getTouchAngle(event.touches)
  1065. const origin = this.getStagePoint(event.touches)
  1066. if (!this.pinchStartDistance) {
  1067. this.pinchStartDistance = distance
  1068. this.pinchStartScale = this.previewScale || 1
  1069. this.pinchStartAngle = angle
  1070. this.pinchStartRotationDeg = this.state.rotationDeg
  1071. const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
  1072. this.pinchAnchorWorldX = anchorWorld.x
  1073. this.pinchAnchorWorldY = anchorWorld.y
  1074. }
  1075. this.gestureMode = 'pinch'
  1076. const nextRotationDeg = this.state.orientationMode === 'heading-up'
  1077. ? this.state.rotationDeg
  1078. : normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI)
  1079. const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg)
  1080. const resolvedViewport = this.resolveViewportForExactCenter(
  1081. this.pinchAnchorWorldX - anchorOffset.x,
  1082. this.pinchAnchorWorldY - anchorOffset.y,
  1083. nextRotationDeg,
  1084. )
  1085. this.setPreviewState(
  1086. clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE),
  1087. origin.x,
  1088. origin.y,
  1089. )
  1090. this.commitViewport(
  1091. {
  1092. ...resolvedViewport,
  1093. rotationDeg: nextRotationDeg,
  1094. rotationText: formatRotationText(nextRotationDeg),
  1095. },
  1096. this.state.orientationMode === 'heading-up'
  1097. ? `鍙屾寚缂╂斁涓紝鑷姩鏈濆悜淇濇寔寮€鍚?(${this.buildVersion})`
  1098. : `鍙屾寚缂╂斁涓庢棆杞腑 (${this.buildVersion})`,
  1099. )
  1100. return
  1101. }
  1102. if (this.gestureMode !== 'pan' || event.touches.length !== 1) {
  1103. return
  1104. }
  1105. const touch = event.touches[0]
  1106. const deltaX = touch.pageX - this.panLastX
  1107. const deltaY = touch.pageY - this.panLastY
  1108. const nextTimestamp = event.timeStamp || Date.now()
  1109. const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16)
  1110. const instantVelocityX = deltaX / elapsed
  1111. const instantVelocityY = deltaY / elapsed
  1112. this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28
  1113. this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28
  1114. this.panLastX = touch.pageX
  1115. this.panLastY = touch.pageY
  1116. this.panLastTimestamp = nextTimestamp
  1117. this.normalizeTranslate(
  1118. this.state.tileTranslateX + deltaX,
  1119. this.state.tileTranslateY + deltaY,
  1120. `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
  1121. )
  1122. }
  1123. handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
  1124. if (this.gestureMode === 'pinch' && event.touches.length < 2) {
  1125. const gestureScale = this.previewScale || 1
  1126. const zoomDelta = Math.round(Math.log2(gestureScale))
  1127. const originX = this.previewOriginX || this.state.stageWidth / 2
  1128. const originY = this.previewOriginY || this.state.stageHeight / 2
  1129. if (zoomDelta) {
  1130. const residualScale = gestureScale / Math.pow(2, zoomDelta)
  1131. this.zoomAroundPoint(zoomDelta, originX, originY, residualScale)
  1132. } else {
  1133. this.animatePreviewToRest()
  1134. }
  1135. this.resetPinchState()
  1136. this.panVelocityX = 0
  1137. this.panVelocityY = 0
  1138. if (event.touches.length === 1) {
  1139. this.gestureMode = 'pan'
  1140. this.panLastX = event.touches[0].pageX
  1141. this.panLastY = event.touches[0].pageY
  1142. this.panLastTimestamp = event.timeStamp || Date.now()
  1143. return
  1144. }
  1145. this.gestureMode = 'idle'
  1146. this.renderer.setAnimationPaused(false)
  1147. this.scheduleAutoRotate()
  1148. return
  1149. }
  1150. if (event.touches.length === 1) {
  1151. this.gestureMode = 'pan'
  1152. this.panLastX = event.touches[0].pageX
  1153. this.panLastY = event.touches[0].pageY
  1154. this.panLastTimestamp = event.timeStamp || Date.now()
  1155. return
  1156. }
  1157. if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) {
  1158. this.startInertia()
  1159. this.gestureMode = 'idle'
  1160. this.resetPinchState()
  1161. return
  1162. }
  1163. this.gestureMode = 'idle'
  1164. this.resetPinchState()
  1165. this.renderer.setAnimationPaused(false)
  1166. this.scheduleAutoRotate()
  1167. }
  1168. handleTouchCancel(): void {
  1169. this.gestureMode = 'idle'
  1170. this.resetPinchState()
  1171. this.panVelocityX = 0
  1172. this.panVelocityY = 0
  1173. this.clearInertiaTimer()
  1174. this.animatePreviewToRest()
  1175. this.renderer.setAnimationPaused(false)
  1176. this.scheduleAutoRotate()
  1177. }
  1178. handleRecenter(): void {
  1179. this.clearInertiaTimer()
  1180. this.clearPreviewResetTimer()
  1181. this.panVelocityX = 0
  1182. this.panVelocityY = 0
  1183. this.renderer.setAnimationPaused(false)
  1184. this.commitViewport(
  1185. {
  1186. zoom: this.defaultZoom,
  1187. centerTileX: this.defaultCenterTileX,
  1188. centerTileY: this.defaultCenterTileY,
  1189. tileTranslateX: 0,
  1190. tileTranslateY: 0,
  1191. },
  1192. `宸插洖鍒板崟 WebGL 寮曟搸榛樿棣栧睆 (${this.buildVersion})`,
  1193. true,
  1194. () => {
  1195. this.resetPreviewState()
  1196. this.syncRenderer()
  1197. this.compassController.start()
  1198. this.scheduleAutoRotate()
  1199. },
  1200. )
  1201. }
  1202. handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
  1203. if (this.state.rotationMode === 'auto') {
  1204. this.setState({
  1205. statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
  1206. }, true)
  1207. return
  1208. }
  1209. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1210. const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg)
  1211. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  1212. this.clearInertiaTimer()
  1213. this.clearPreviewResetTimer()
  1214. this.panVelocityX = 0
  1215. this.panVelocityY = 0
  1216. this.renderer.setAnimationPaused(false)
  1217. this.commitViewport(
  1218. {
  1219. ...resolvedViewport,
  1220. rotationDeg: nextRotationDeg,
  1221. rotationText: formatRotationText(nextRotationDeg),
  1222. },
  1223. `鏃嬭浆瑙掑害璋冩暣鍒?${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
  1224. true,
  1225. () => {
  1226. this.resetPreviewState()
  1227. this.syncRenderer()
  1228. this.compassController.start()
  1229. },
  1230. )
  1231. }
  1232. handleRotationReset(): void {
  1233. if (this.state.rotationMode === 'auto') {
  1234. this.setState({
  1235. statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
  1236. }, true)
  1237. return
  1238. }
  1239. const targetRotationDeg = MAP_NORTH_OFFSET_DEG
  1240. if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) {
  1241. return
  1242. }
  1243. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1244. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg)
  1245. this.clearInertiaTimer()
  1246. this.clearPreviewResetTimer()
  1247. this.panVelocityX = 0
  1248. this.panVelocityY = 0
  1249. this.renderer.setAnimationPaused(false)
  1250. this.commitViewport(
  1251. {
  1252. ...resolvedViewport,
  1253. rotationDeg: targetRotationDeg,
  1254. rotationText: formatRotationText(targetRotationDeg),
  1255. },
  1256. `鏃嬭浆瑙掑害宸插洖鍒扮湡鍖楀弬鑰?(${this.buildVersion})`,
  1257. true,
  1258. () => {
  1259. this.resetPreviewState()
  1260. this.syncRenderer()
  1261. this.compassController.start()
  1262. },
  1263. )
  1264. }
  1265. handleToggleRotationMode(): void {
  1266. if (this.state.orientationMode === 'manual') {
  1267. this.setNorthUpMode()
  1268. return
  1269. }
  1270. if (this.state.orientationMode === 'north-up') {
  1271. this.setHeadingUpMode()
  1272. return
  1273. }
  1274. this.setManualMode()
  1275. }
  1276. handleSetManualMode(): void {
  1277. this.setManualMode()
  1278. }
  1279. handleSetNorthUpMode(): void {
  1280. this.setNorthUpMode()
  1281. }
  1282. handleSetHeadingUpMode(): void {
  1283. this.setHeadingUpMode()
  1284. }
  1285. handleCycleNorthReferenceMode(): void {
  1286. this.cycleNorthReferenceMode()
  1287. }
  1288. handleAutoRotateCalibrate(): void {
  1289. if (this.state.orientationMode !== 'heading-up') {
  1290. this.setState({
  1291. statusText: `璇峰厛鍒囧埌鏈濆悜鏈濅笂妯″紡鍐嶆牎鍑?(${this.buildVersion})`,
  1292. }, true)
  1293. return
  1294. }
  1295. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  1296. this.setState({
  1297. statusText: `褰撳墠杩樻病鏈変紶鎰熷櫒鏂瑰悜鏁版嵁锛屾殏鏃舵棤娉曟牎鍑?(${this.buildVersion})`,
  1298. }, true)
  1299. return
  1300. }
  1301. this.setState({
  1302. statusText: `宸叉寜褰撳墠鎸佹満鏂瑰悜瀹屾垚鏈濆悜鏍″噯 (${this.buildVersion})`,
  1303. }, true)
  1304. this.scheduleAutoRotate()
  1305. }
  1306. setManualMode(): void {
  1307. this.clearAutoRotateTimer()
  1308. this.targetAutoRotationDeg = null
  1309. this.autoRotateCalibrationPending = false
  1310. this.setState({
  1311. rotationMode: 'manual',
  1312. rotationModeText: formatRotationModeText('manual'),
  1313. rotationToggleText: formatRotationToggleText('manual'),
  1314. orientationMode: 'manual',
  1315. orientationModeText: formatOrientationModeText('manual'),
  1316. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1317. statusText: `宸插垏鍥炴墜鍔ㄥ湴鍥炬棆杞?(${this.buildVersion})`,
  1318. }, true)
  1319. }
  1320. setNorthUpMode(): void {
  1321. this.clearAutoRotateTimer()
  1322. this.targetAutoRotationDeg = null
  1323. this.autoRotateCalibrationPending = false
  1324. const mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG
  1325. this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg
  1326. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1327. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg)
  1328. this.commitViewport(
  1329. {
  1330. ...resolvedViewport,
  1331. rotationDeg: mapNorthOffsetDeg,
  1332. rotationText: formatRotationText(mapNorthOffsetDeg),
  1333. rotationMode: 'manual',
  1334. rotationModeText: formatRotationModeText('north-up'),
  1335. rotationToggleText: formatRotationToggleText('north-up'),
  1336. orientationMode: 'north-up',
  1337. orientationModeText: formatOrientationModeText('north-up'),
  1338. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
  1339. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  1340. },
  1341. `鍦板浘宸插浐瀹氫负鐪熷寳鏈濅笂 (${this.buildVersion})`,
  1342. true,
  1343. () => {
  1344. this.resetPreviewState()
  1345. this.syncRenderer()
  1346. },
  1347. )
  1348. }
  1349. setHeadingUpMode(): void {
  1350. this.autoRotateCalibrationPending = false
  1351. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode)
  1352. this.targetAutoRotationDeg = null
  1353. this.setState({
  1354. rotationMode: 'auto',
  1355. rotationModeText: formatRotationModeText('heading-up'),
  1356. rotationToggleText: formatRotationToggleText('heading-up'),
  1357. orientationMode: 'heading-up',
  1358. orientationModeText: formatOrientationModeText('heading-up'),
  1359. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1360. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  1361. statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`,
  1362. }, true)
  1363. if (this.refreshAutoRotateTarget()) {
  1364. this.scheduleAutoRotate()
  1365. }
  1366. }
  1367. handleCompassHeading(headingDeg: number): void {
  1368. this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
  1369. this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
  1370. ? this.sensorHeadingDeg
  1371. : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
  1372. const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  1373. this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
  1374. ? compassHeadingDeg
  1375. : interpolateAngleDeg(this.compassDisplayHeadingDeg, compassHeadingDeg, COMPASS_NEEDLE_SMOOTHING)
  1376. this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  1377. this.setState({
  1378. sensorHeadingText: formatHeadingText(compassHeadingDeg),
  1379. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  1380. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  1381. autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
  1382. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
  1383. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  1384. })
  1385. if (!this.refreshAutoRotateTarget()) {
  1386. return
  1387. }
  1388. if (this.state.orientationMode === 'heading-up') {
  1389. this.scheduleAutoRotate()
  1390. }
  1391. }
  1392. handleCompassError(message: string): void {
  1393. this.clearAutoRotateTimer()
  1394. this.targetAutoRotationDeg = null
  1395. this.autoRotateCalibrationPending = false
  1396. this.setState({
  1397. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1398. statusText: `${message} (${this.buildVersion})`,
  1399. }, true)
  1400. }
  1401. cycleNorthReferenceMode(): void {
  1402. const nextMode = getNextNorthReferenceMode(this.northReferenceMode)
  1403. const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
  1404. const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
  1405. ? null
  1406. : getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
  1407. this.northReferenceMode = nextMode
  1408. this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
  1409. this.compassDisplayHeadingDeg = compassHeadingDeg
  1410. if (this.state.orientationMode === 'north-up') {
  1411. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1412. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG)
  1413. this.commitViewport(
  1414. {
  1415. ...resolvedViewport,
  1416. rotationDeg: MAP_NORTH_OFFSET_DEG,
  1417. rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
  1418. northReferenceText: formatNorthReferenceText(nextMode),
  1419. sensorHeadingText: formatHeadingText(compassHeadingDeg),
  1420. compassDeclinationText: formatCompassDeclinationText(nextMode),
  1421. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  1422. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
  1423. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  1424. },
  1425. `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  1426. true,
  1427. () => {
  1428. this.resetPreviewState()
  1429. this.syncRenderer()
  1430. },
  1431. )
  1432. return
  1433. }
  1434. this.setState({
  1435. northReferenceText: formatNorthReferenceText(nextMode),
  1436. sensorHeadingText: formatHeadingText(compassHeadingDeg),
  1437. compassDeclinationText: formatCompassDeclinationText(nextMode),
  1438. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  1439. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
  1440. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  1441. statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  1442. }, true)
  1443. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  1444. this.scheduleAutoRotate()
  1445. }
  1446. }
  1447. setCourseHeading(headingDeg: number | null): void {
  1448. this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
  1449. this.setState({
  1450. autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
  1451. })
  1452. if (this.refreshAutoRotateTarget()) {
  1453. this.scheduleAutoRotate()
  1454. }
  1455. }
  1456. resolveAutoRotateInputHeadingDeg(): number | null {
  1457. const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
  1458. ? null
  1459. : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  1460. const courseHeadingDeg = this.courseHeadingDeg === null
  1461. ? null
  1462. : getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
  1463. if (this.autoRotateSourceMode === 'sensor') {
  1464. return sensorHeadingDeg
  1465. }
  1466. if (this.autoRotateSourceMode === 'course') {
  1467. return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg
  1468. }
  1469. if (sensorHeadingDeg !== null && courseHeadingDeg !== null) {
  1470. return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35)
  1471. }
  1472. return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg
  1473. }
  1474. calibrateAutoRotateToCurrentOrientation(): boolean {
  1475. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  1476. if (inputHeadingDeg === null) {
  1477. return false
  1478. }
  1479. this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg)
  1480. this.autoRotateCalibrationPending = false
  1481. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  1482. this.setState({
  1483. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1484. })
  1485. return true
  1486. }
  1487. refreshAutoRotateTarget(): boolean {
  1488. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  1489. if (inputHeadingDeg === null) {
  1490. return false
  1491. }
  1492. if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) {
  1493. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  1494. return false
  1495. }
  1496. return true
  1497. }
  1498. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  1499. this.setState({
  1500. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1501. })
  1502. return true
  1503. }
  1504. scheduleAutoRotate(): void {
  1505. if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  1506. return
  1507. }
  1508. const step = () => {
  1509. this.autoRotateTimer = 0
  1510. if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  1511. return
  1512. }
  1513. if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) {
  1514. this.scheduleAutoRotate()
  1515. return
  1516. }
  1517. const currentRotationDeg = this.state.rotationDeg
  1518. const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg)
  1519. if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) {
  1520. if (Math.abs(deltaDeg) > 0.01) {
  1521. this.applyAutoRotation(this.targetAutoRotationDeg)
  1522. }
  1523. this.scheduleAutoRotate()
  1524. return
  1525. }
  1526. if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) {
  1527. this.scheduleAutoRotate()
  1528. return
  1529. }
  1530. const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG)
  1531. this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg))
  1532. this.scheduleAutoRotate()
  1533. }
  1534. this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number
  1535. }
  1536. applyAutoRotation(nextRotationDeg: number): void {
  1537. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1538. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  1539. this.setState({
  1540. ...resolvedViewport,
  1541. rotationDeg: nextRotationDeg,
  1542. rotationText: formatRotationText(nextRotationDeg),
  1543. centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY),
  1544. })
  1545. this.syncRenderer()
  1546. }
  1547. applyStats(stats: MapRendererStats): void {
  1548. this.setState({
  1549. visibleTileCount: stats.visibleTileCount,
  1550. readyTileCount: stats.readyTileCount,
  1551. memoryTileCount: stats.memoryTileCount,
  1552. diskTileCount: stats.diskTileCount,
  1553. memoryHitCount: stats.memoryHitCount,
  1554. diskHitCount: stats.diskHitCount,
  1555. networkFetchCount: stats.networkFetchCount,
  1556. cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount),
  1557. })
  1558. }
  1559. setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {
  1560. this.state = {
  1561. ...this.state,
  1562. ...patch,
  1563. }
  1564. const viewPatch = this.pickViewPatch(patch)
  1565. if (!Object.keys(viewPatch).length) {
  1566. return
  1567. }
  1568. this.pendingViewPatch = {
  1569. ...this.pendingViewPatch,
  1570. ...viewPatch,
  1571. }
  1572. if (immediateUi) {
  1573. this.flushViewPatch()
  1574. return
  1575. }
  1576. if (this.viewSyncTimer) {
  1577. return
  1578. }
  1579. this.viewSyncTimer = setTimeout(() => {
  1580. this.viewSyncTimer = 0
  1581. this.flushViewPatch()
  1582. }, UI_SYNC_INTERVAL_MS) as unknown as number
  1583. }
  1584. commitViewport(
  1585. patch: Partial<MapEngineViewState>,
  1586. statusText: string,
  1587. immediateUi = false,
  1588. afterUpdate?: () => void,
  1589. ): void {
  1590. const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom
  1591. const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX
  1592. const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY
  1593. const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth
  1594. const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight
  1595. const tileSizePx = getTileSizePx({
  1596. centerWorldX: nextCenterTileX,
  1597. centerWorldY: nextCenterTileY,
  1598. viewportWidth: nextStageWidth,
  1599. viewportHeight: nextStageHeight,
  1600. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1601. })
  1602. this.setState({
  1603. ...patch,
  1604. tileSizePx,
  1605. centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY),
  1606. statusText,
  1607. }, immediateUi)
  1608. this.syncRenderer()
  1609. this.compassController.start()
  1610. if (afterUpdate) {
  1611. afterUpdate()
  1612. }
  1613. }
  1614. buildScene() {
  1615. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1616. return {
  1617. tileSource: this.state.tileSource,
  1618. osmTileSource: OSM_TILE_SOURCE,
  1619. zoom: this.state.zoom,
  1620. centerTileX: this.state.centerTileX,
  1621. centerTileY: this.state.centerTileY,
  1622. exactCenterWorldX: exactCenter.x,
  1623. exactCenterWorldY: exactCenter.y,
  1624. tileBoundsByZoom: this.tileBoundsByZoom,
  1625. viewportWidth: this.state.stageWidth,
  1626. viewportHeight: this.state.stageHeight,
  1627. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1628. overdraw: OVERDRAW,
  1629. translateX: this.state.tileTranslateX,
  1630. translateY: this.state.tileTranslateY,
  1631. rotationRad: this.getRotationRad(this.state.rotationDeg),
  1632. previewScale: this.previewScale || 1,
  1633. previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
  1634. previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
  1635. track: this.currentGpsTrack,
  1636. gpsPoint: this.currentGpsPoint,
  1637. gpsCalibration: GPS_MAP_CALIBRATION,
  1638. gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
  1639. course: this.courseData,
  1640. cpRadiusMeters: this.cpRadiusMeters,
  1641. activeControlSequences: this.gamePresentation.activeControlSequences,
  1642. activeStart: this.gamePresentation.activeStart,
  1643. completedStart: this.gamePresentation.completedStart,
  1644. activeFinish: this.gamePresentation.activeFinish,
  1645. completedFinish: this.gamePresentation.completedFinish,
  1646. revealFullCourse: this.gamePresentation.revealFullCourse,
  1647. activeLegIndices: this.gamePresentation.activeLegIndices,
  1648. completedLegIndices: this.gamePresentation.completedLegIndices,
  1649. completedControlSequences: this.gamePresentation.completedControlSequences,
  1650. osmReferenceEnabled: this.state.osmReferenceEnabled,
  1651. overlayOpacity: MAP_OVERLAY_OPACITY,
  1652. }
  1653. }
  1654. syncRenderer(): void {
  1655. if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
  1656. return
  1657. }
  1658. this.renderer.updateScene(this.buildScene())
  1659. }
  1660. getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
  1661. return {
  1662. centerWorldX: this.state.centerTileX + 0.5,
  1663. centerWorldY: this.state.centerTileY + 0.5,
  1664. viewportWidth: this.state.stageWidth,
  1665. viewportHeight: this.state.stageHeight,
  1666. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1667. translateX: this.state.tileTranslateX,
  1668. translateY: this.state.tileTranslateY,
  1669. rotationRad: this.getRotationRad(rotationDeg),
  1670. }
  1671. }
  1672. getRotationRad(rotationDeg = this.state.rotationDeg): number {
  1673. return normalizeRotationDeg(rotationDeg) * Math.PI / 180
  1674. }
  1675. getBaseCamera(centerTileX = this.state.centerTileX, centerTileY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
  1676. return {
  1677. centerWorldX: centerTileX + 0.5,
  1678. centerWorldY: centerTileY + 0.5,
  1679. viewportWidth: this.state.stageWidth,
  1680. viewportHeight: this.state.stageHeight,
  1681. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1682. rotationRad: this.getRotationRad(rotationDeg),
  1683. }
  1684. }
  1685. getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } {
  1686. const baseCamera = {
  1687. centerWorldX: 0,
  1688. centerWorldY: 0,
  1689. viewportWidth: this.state.stageWidth,
  1690. viewportHeight: this.state.stageHeight,
  1691. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1692. rotationRad: this.getRotationRad(rotationDeg),
  1693. }
  1694. return screenToWorld(baseCamera, { x: stageX, y: stageY }, false)
  1695. }
  1696. getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
  1697. if (!this.state.stageWidth || !this.state.stageHeight) {
  1698. return {
  1699. x: this.state.centerTileX + 0.5,
  1700. y: this.state.centerTileY + 0.5,
  1701. }
  1702. }
  1703. const screenCenterX = this.state.stageWidth / 2
  1704. const screenCenterY = this.state.stageHeight / 2
  1705. return screenToWorld(this.getBaseCamera(), {
  1706. x: screenCenterX - translateX,
  1707. y: screenCenterY - translateY,
  1708. }, false)
  1709. }
  1710. resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): {
  1711. centerTileX: number
  1712. centerTileY: number
  1713. tileTranslateX: number
  1714. tileTranslateY: number
  1715. } {
  1716. const nextCenterTileX = Math.floor(centerWorldX)
  1717. const nextCenterTileY = Math.floor(centerWorldY)
  1718. if (!this.state.stageWidth || !this.state.stageHeight) {
  1719. return {
  1720. centerTileX: nextCenterTileX,
  1721. centerTileY: nextCenterTileY,
  1722. tileTranslateX: 0,
  1723. tileTranslateY: 0,
  1724. }
  1725. }
  1726. const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg)
  1727. const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false)
  1728. return {
  1729. centerTileX: nextCenterTileX,
  1730. centerTileY: nextCenterTileY,
  1731. tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x,
  1732. tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y,
  1733. }
  1734. }
  1735. setPreviewState(scale: number, originX: number, originY: number): void {
  1736. this.previewScale = scale
  1737. this.previewOriginX = originX
  1738. this.previewOriginY = originY
  1739. }
  1740. resetPreviewState(): void {
  1741. this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2)
  1742. }
  1743. resetPinchState(): void {
  1744. this.pinchStartDistance = 0
  1745. this.pinchStartScale = 1
  1746. this.pinchStartAngle = 0
  1747. this.pinchStartRotationDeg = this.state.rotationDeg
  1748. this.pinchAnchorWorldX = 0
  1749. this.pinchAnchorWorldY = 0
  1750. }
  1751. clearPreviewResetTimer(): void {
  1752. if (this.previewResetTimer) {
  1753. clearTimeout(this.previewResetTimer)
  1754. this.previewResetTimer = 0
  1755. }
  1756. }
  1757. clearInertiaTimer(): void {
  1758. if (this.inertiaTimer) {
  1759. clearTimeout(this.inertiaTimer)
  1760. this.inertiaTimer = 0
  1761. }
  1762. }
  1763. clearViewSyncTimer(): void {
  1764. if (this.viewSyncTimer) {
  1765. clearTimeout(this.viewSyncTimer)
  1766. this.viewSyncTimer = 0
  1767. }
  1768. }
  1769. clearAutoRotateTimer(): void {
  1770. if (this.autoRotateTimer) {
  1771. clearTimeout(this.autoRotateTimer)
  1772. this.autoRotateTimer = 0
  1773. }
  1774. }
  1775. pickViewPatch(patch: Partial<MapEngineViewState>): Partial<MapEngineViewState> {
  1776. const viewPatch = {} as Partial<MapEngineViewState>
  1777. for (const key of VIEW_SYNC_KEYS) {
  1778. if (Object.prototype.hasOwnProperty.call(patch, key)) {
  1779. ;(viewPatch as any)[key] = patch[key]
  1780. }
  1781. }
  1782. return viewPatch
  1783. }
  1784. flushViewPatch(): void {
  1785. if (!Object.keys(this.pendingViewPatch).length) {
  1786. return
  1787. }
  1788. const patch = this.pendingViewPatch
  1789. this.pendingViewPatch = {}
  1790. this.onData(patch)
  1791. }
  1792. getTouchDistance(touches: TouchPoint[]): number {
  1793. if (touches.length < 2) {
  1794. return 0
  1795. }
  1796. const first = touches[0]
  1797. const second = touches[1]
  1798. const deltaX = first.pageX - second.pageX
  1799. const deltaY = first.pageY - second.pageY
  1800. return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
  1801. }
  1802. getTouchAngle(touches: TouchPoint[]): number {
  1803. if (touches.length < 2) {
  1804. return 0
  1805. }
  1806. const first = touches[0]
  1807. const second = touches[1]
  1808. return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX)
  1809. }
  1810. getStagePoint(touches: TouchPoint[]): { x: number; y: number } {
  1811. if (!touches.length) {
  1812. return {
  1813. x: this.state.stageWidth / 2,
  1814. y: this.state.stageHeight / 2,
  1815. }
  1816. }
  1817. let pageX = 0
  1818. let pageY = 0
  1819. for (const touch of touches) {
  1820. pageX += touch.pageX
  1821. pageY += touch.pageY
  1822. }
  1823. return {
  1824. x: pageX / touches.length - this.state.stageLeft,
  1825. y: pageY / touches.length - this.state.stageTop,
  1826. }
  1827. }
  1828. animatePreviewToRest(): void {
  1829. this.clearPreviewResetTimer()
  1830. const startScale = this.previewScale || 1
  1831. const originX = this.previewOriginX || this.state.stageWidth / 2
  1832. const originY = this.previewOriginY || this.state.stageHeight / 2
  1833. if (Math.abs(startScale - 1) < 0.01) {
  1834. this.resetPreviewState()
  1835. this.syncRenderer()
  1836. this.compassController.start()
  1837. this.scheduleAutoRotate()
  1838. return
  1839. }
  1840. const startAt = Date.now()
  1841. const step = () => {
  1842. const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS)
  1843. const eased = 1 - Math.pow(1 - progress, 3)
  1844. const nextScale = startScale + (1 - startScale) * eased
  1845. this.setPreviewState(nextScale, originX, originY)
  1846. this.syncRenderer()
  1847. this.compassController.start()
  1848. if (progress >= 1) {
  1849. this.resetPreviewState()
  1850. this.syncRenderer()
  1851. this.compassController.start()
  1852. this.previewResetTimer = 0
  1853. this.scheduleAutoRotate()
  1854. return
  1855. }
  1856. this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  1857. }
  1858. step()
  1859. }
  1860. normalizeTranslate(translateX: number, translateY: number, statusText: string): void {
  1861. if (!this.state.stageWidth) {
  1862. this.setState({
  1863. tileTranslateX: translateX,
  1864. tileTranslateY: translateY,
  1865. })
  1866. this.syncRenderer()
  1867. this.compassController.start()
  1868. return
  1869. }
  1870. const exactCenter = this.getExactCenterFromTranslate(translateX, translateY)
  1871. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y)
  1872. const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY
  1873. if (centerChanged) {
  1874. this.commitViewport(resolvedViewport, statusText)
  1875. return
  1876. }
  1877. this.setState({
  1878. tileTranslateX: resolvedViewport.tileTranslateX,
  1879. tileTranslateY: resolvedViewport.tileTranslateY,
  1880. })
  1881. this.syncRenderer()
  1882. this.compassController.start()
  1883. }
  1884. zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
  1885. const nextZoom = clamp(this.state.zoom + zoomDelta, this.minZoom, this.maxZoom)
  1886. const appliedDelta = nextZoom - this.state.zoom
  1887. if (!appliedDelta) {
  1888. this.animatePreviewToRest()
  1889. return
  1890. }
  1891. if (!this.state.stageWidth || !this.state.stageHeight) {
  1892. this.commitViewport(
  1893. {
  1894. zoom: nextZoom,
  1895. centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2),
  1896. centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2),
  1897. tileTranslateX: 0,
  1898. tileTranslateY: 0,
  1899. },
  1900. `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
  1901. true,
  1902. () => {
  1903. this.setPreviewState(residualScale, stageX, stageY)
  1904. this.syncRenderer()
  1905. this.compassController.start()
  1906. this.animatePreviewToRest()
  1907. },
  1908. )
  1909. return
  1910. }
  1911. const camera = this.getCameraState()
  1912. const world = screenToWorld(camera, { x: stageX, y: stageY }, true)
  1913. const zoomFactor = Math.pow(2, appliedDelta)
  1914. const nextWorldX = world.x * zoomFactor
  1915. const nextWorldY = world.y * zoomFactor
  1916. const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY)
  1917. const exactCenterX = nextWorldX - anchorOffset.x
  1918. const exactCenterY = nextWorldY - anchorOffset.y
  1919. const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY)
  1920. this.commitViewport(
  1921. {
  1922. zoom: nextZoom,
  1923. ...resolvedViewport,
  1924. },
  1925. `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
  1926. true,
  1927. () => {
  1928. this.setPreviewState(residualScale, stageX, stageY)
  1929. this.syncRenderer()
  1930. this.compassController.start()
  1931. this.animatePreviewToRest()
  1932. },
  1933. )
  1934. }
  1935. startInertia(): void {
  1936. this.clearInertiaTimer()
  1937. const step = () => {
  1938. this.panVelocityX *= INERTIA_DECAY
  1939. this.panVelocityY *= INERTIA_DECAY
  1940. if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
  1941. this.setState({
  1942. statusText: `鎯€ф粦鍔ㄧ粨鏉?(${this.buildVersion})`,
  1943. })
  1944. this.renderer.setAnimationPaused(false)
  1945. this.inertiaTimer = 0
  1946. this.scheduleAutoRotate()
  1947. return
  1948. }
  1949. this.normalizeTranslate(
  1950. this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
  1951. this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
  1952. `鎯€ф粦鍔ㄤ腑 (${this.buildVersion})`,
  1953. )
  1954. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  1955. }
  1956. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  1957. }
  1958. }