mapEngine.ts 145 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483
  1. import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
  2. import { AccelerometerController } from '../sensor/accelerometerController'
  3. import { CompassHeadingController, type CompassTuningProfile } from '../sensor/compassHeadingController'
  4. import { DeviceMotionController } from '../sensor/deviceMotionController'
  5. import { GyroscopeController } from '../sensor/gyroscopeController'
  6. import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
  7. import { HeartRateInputController } from '../sensor/heartRateInputController'
  8. import { LocationController } from '../sensor/locationController'
  9. import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
  10. import { type MapRendererStats } from '../renderer/mapRenderer'
  11. import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
  12. import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
  13. import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
  14. import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
  15. import { GameRuntime } from '../../game/core/gameRuntime'
  16. import { type GameControl, type GameControlDisplayContentOverride } from '../../game/core/gameDefinition'
  17. import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
  18. import { type GameEffect, type GameResult } from '../../game/core/gameResult'
  19. import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
  20. import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
  21. import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
  22. import { buildResultSummarySnapshot, type ResultSummarySnapshot } from '../../game/result/resultSummary'
  23. import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime'
  24. import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig'
  25. const RENDER_MODE = 'Single WebGL Pipeline'
  26. const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
  27. const MAP_NORTH_OFFSET_DEG = 0
  28. let MAGNETIC_DECLINATION_DEG = -6.91
  29. let MAGNETIC_DECLINATION_TEXT = '6.91˚ W'
  30. const MIN_ZOOM = 15
  31. const MAX_ZOOM = 20
  32. const DEFAULT_ZOOM = 17
  33. const DESIRED_VISIBLE_COLUMNS = 3
  34. const OVERDRAW = 1
  35. const DEFAULT_TOP_LEFT_TILE_X = 108132
  36. const DEFAULT_TOP_LEFT_TILE_Y = 51199
  37. const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1
  38. const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1
  39. const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png'
  40. const OSM_TILE_SOURCE = 'https://tiles.mymarsgo.xyz/{z}/{x}/{y}.png'
  41. const MAP_OVERLAY_OPACITY = 0.72
  42. const GPS_MAP_CALIBRATION: MapCalibration = {
  43. offsetEastMeters: 0,
  44. offsetNorthMeters: 0,
  45. rotationDeg: 0,
  46. scale: 1,
  47. }
  48. const MIN_PREVIEW_SCALE = 0.55
  49. const MAX_PREVIEW_SCALE = 1.85
  50. const INERTIA_FRAME_MS = 16
  51. const INERTIA_DECAY = 0.92
  52. const INERTIA_MIN_SPEED = 0.02
  53. const PREVIEW_RESET_DURATION_MS = 140
  54. const UI_SYNC_INTERVAL_MS = 80
  55. const ROTATE_STEP_DEG = 15
  56. const AUTO_ROTATE_FRAME_MS = 8
  57. const AUTO_ROTATE_EASE = 0.34
  58. const AUTO_ROTATE_SNAP_DEG = 0.1
  59. const AUTO_ROTATE_DEADZONE_DEG = 4
  60. const AUTO_ROTATE_MAX_STEP_DEG = 0.75
  61. const AUTO_ROTATE_HEADING_SMOOTHING = 0.46
  62. const COMPASS_NEEDLE_FRAME_MS = 16
  63. const COMPASS_NEEDLE_SNAP_DEG = 0.08
  64. const COMPASS_BOOTSTRAP_RETRY_DELAY_MS = 700
  65. const COMPASS_TUNING_PRESETS: Record<CompassTuningProfile, {
  66. needleMinSmoothing: number
  67. needleMaxSmoothing: number
  68. displayDeadzoneDeg: number
  69. }> = {
  70. smooth: {
  71. needleMinSmoothing: 0.16,
  72. needleMaxSmoothing: 0.4,
  73. displayDeadzoneDeg: 0.75,
  74. },
  75. balanced: {
  76. needleMinSmoothing: 0.22,
  77. needleMaxSmoothing: 0.52,
  78. displayDeadzoneDeg: 0.45,
  79. },
  80. responsive: {
  81. needleMinSmoothing: 0.3,
  82. needleMaxSmoothing: 0.68,
  83. displayDeadzoneDeg: 0.2,
  84. },
  85. }
  86. const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2
  87. const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
  88. const SMART_HEADING_MIN_DISTANCE_METERS = 12
  89. const SMART_HEADING_MAX_ACCURACY_METERS = 25
  90. const SMART_HEADING_MOVEMENT_MIN_SMOOTHING = 0.12
  91. const SMART_HEADING_MOVEMENT_MAX_SMOOTHING = 0.24
  92. const GPS_TRACK_MAX_POINTS = 200
  93. const GPS_TRACK_MIN_STEP_METERS = 3
  94. const MAP_TAP_MOVE_THRESHOLD_PX = 14
  95. const MAP_TAP_DURATION_MS = 280
  96. type TouchPoint = WechatMiniprogram.TouchDetail
  97. type GestureMode = 'idle' | 'pan' | 'pinch'
  98. type RotationMode = 'manual' | 'auto'
  99. type OrientationMode = 'manual' | 'north-up' | 'heading-up'
  100. type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion' | 'smart'
  101. type SmartHeadingSource = 'sensor' | 'blended' | 'movement'
  102. type NorthReferenceMode = 'magnetic' | 'true'
  103. const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
  104. export interface MapEngineStageRect {
  105. width: number
  106. height: number
  107. left: number
  108. top: number
  109. }
  110. export interface MapEngineViewState {
  111. animationLevel: AnimationLevel
  112. buildVersion: string
  113. renderMode: string
  114. projectionMode: string
  115. mapReady: boolean
  116. mapReadyText: string
  117. mapName: string
  118. configStatusText: string
  119. zoom: number
  120. rotationDeg: number
  121. rotationText: string
  122. rotationMode: RotationMode
  123. rotationModeText: string
  124. rotationToggleText: string
  125. orientationMode: OrientationMode
  126. orientationModeText: string
  127. sensorHeadingText: string
  128. deviceHeadingText: string
  129. devicePoseText: string
  130. headingConfidenceText: string
  131. accelerometerText: string
  132. gyroscopeText: string
  133. deviceMotionText: string
  134. compassSourceText: string
  135. compassTuningProfile: CompassTuningProfile
  136. compassTuningProfileText: string
  137. compassDeclinationText: string
  138. northReferenceMode: NorthReferenceMode
  139. northReferenceButtonText: string
  140. autoRotateSourceText: string
  141. autoRotateCalibrationText: string
  142. northReferenceText: string
  143. compassNeedleDeg: number
  144. centerTileX: number
  145. centerTileY: number
  146. centerText: string
  147. tileSource: string
  148. visibleColumnCount: number
  149. visibleTileCount: number
  150. readyTileCount: number
  151. memoryTileCount: number
  152. diskTileCount: number
  153. memoryHitCount: number
  154. diskHitCount: number
  155. networkFetchCount: number
  156. cacheHitRateText: string
  157. tileTranslateX: number
  158. tileTranslateY: number
  159. tileSizePx: number
  160. previewScale: number
  161. stageWidth: number
  162. stageHeight: number
  163. stageLeft: number
  164. stageTop: number
  165. statusText: string
  166. gpsTracking: boolean
  167. gpsTrackingText: string
  168. gpsLockEnabled: boolean
  169. gpsLockAvailable: boolean
  170. locationSourceMode: 'real' | 'mock'
  171. locationSourceText: string
  172. mockBridgeConnected: boolean
  173. mockBridgeStatusText: string
  174. mockBridgeUrlText: string
  175. mockCoordText: string
  176. mockSpeedText: string
  177. gpsCoordText: string
  178. heartRateSourceMode: 'real' | 'mock'
  179. heartRateSourceText: string
  180. heartRateConnected: boolean
  181. heartRateStatusText: string
  182. heartRateDeviceText: string
  183. heartRateScanText: string
  184. heartRateDiscoveredDevices: Array<{
  185. deviceId: string
  186. name: string
  187. rssiText: string
  188. preferred: boolean
  189. connected: boolean
  190. }>
  191. mockHeartRateBridgeConnected: boolean
  192. mockHeartRateBridgeStatusText: string
  193. mockHeartRateBridgeUrlText: string
  194. mockHeartRateText: string
  195. gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
  196. gameModeText: string
  197. panelTimerText: string
  198. panelMileageText: string
  199. panelActionTagText: string
  200. panelDistanceTagText: string
  201. panelDistanceValueText: string
  202. panelDistanceUnitText: string
  203. panelProgressText: string
  204. panelSpeedValueText: string
  205. panelTelemetryTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
  206. panelHeartRateZoneNameText: string
  207. panelHeartRateZoneRangeText: string
  208. panelHeartRateValueText: string
  209. panelHeartRateUnitText: string
  210. panelCaloriesValueText: string
  211. panelCaloriesUnitText: string
  212. panelAverageSpeedValueText: string
  213. panelAverageSpeedUnitText: string
  214. panelAccuracyValueText: string
  215. panelAccuracyUnitText: string
  216. punchButtonText: string
  217. punchButtonEnabled: boolean
  218. skipButtonEnabled: boolean
  219. punchHintText: string
  220. punchFeedbackVisible: boolean
  221. punchFeedbackText: string
  222. punchFeedbackTone: 'neutral' | 'success' | 'warning'
  223. contentCardVisible: boolean
  224. contentCardTitle: string
  225. contentCardBody: string
  226. pendingContentEntryVisible: boolean
  227. pendingContentEntryText: string
  228. punchButtonFxClass: string
  229. panelProgressFxClass: string
  230. panelDistanceFxClass: string
  231. punchFeedbackFxClass: string
  232. contentCardFxClass: string
  233. mapPulseVisible: boolean
  234. mapPulseLeftPx: number
  235. mapPulseTopPx: number
  236. mapPulseFxClass: string
  237. stageFxVisible: boolean
  238. stageFxClass: string
  239. osmReferenceEnabled: boolean
  240. osmReferenceText: string
  241. }
  242. export interface MapEngineCallbacks {
  243. onData: (patch: Partial<MapEngineViewState>) => void
  244. onOpenH5Experience?: (request: H5ExperienceRequest) => void
  245. }
  246. interface ContentCardEntry {
  247. title: string
  248. body: string
  249. motionClass: string
  250. contentKey: string
  251. once: boolean
  252. priority: number
  253. autoPopup: boolean
  254. h5Request: H5ExperienceRequest | null
  255. }
  256. export interface MapEngineGameInfoRow {
  257. label: string
  258. value: string
  259. }
  260. export interface MapEngineGameInfoSnapshot {
  261. title: string
  262. subtitle: string
  263. localRows: MapEngineGameInfoRow[]
  264. globalRows: MapEngineGameInfoRow[]
  265. }
  266. export type MapEngineResultSnapshot = ResultSummarySnapshot
  267. const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
  268. 'animationLevel',
  269. 'buildVersion',
  270. 'renderMode',
  271. 'projectionMode',
  272. 'mapReady',
  273. 'mapReadyText',
  274. 'mapName',
  275. 'configStatusText',
  276. 'zoom',
  277. 'centerTileX',
  278. 'centerTileY',
  279. 'rotationDeg',
  280. 'rotationText',
  281. 'rotationMode',
  282. 'rotationModeText',
  283. 'rotationToggleText',
  284. 'orientationMode',
  285. 'orientationModeText',
  286. 'sensorHeadingText',
  287. 'deviceHeadingText',
  288. 'devicePoseText',
  289. 'headingConfidenceText',
  290. 'accelerometerText',
  291. 'gyroscopeText',
  292. 'deviceMotionText',
  293. 'compassSourceText',
  294. 'compassTuningProfile',
  295. 'compassTuningProfileText',
  296. 'compassDeclinationText',
  297. 'northReferenceMode',
  298. 'northReferenceButtonText',
  299. 'autoRotateSourceText',
  300. 'autoRotateCalibrationText',
  301. 'northReferenceText',
  302. 'compassNeedleDeg',
  303. 'centerText',
  304. 'tileSource',
  305. 'visibleTileCount',
  306. 'readyTileCount',
  307. 'memoryTileCount',
  308. 'diskTileCount',
  309. 'memoryHitCount',
  310. 'diskHitCount',
  311. 'networkFetchCount',
  312. 'cacheHitRateText',
  313. 'tileSizePx',
  314. 'previewScale',
  315. 'stageWidth',
  316. 'stageHeight',
  317. 'stageLeft',
  318. 'stageTop',
  319. 'statusText',
  320. 'gpsTracking',
  321. 'gpsTrackingText',
  322. 'gpsLockEnabled',
  323. 'gpsLockAvailable',
  324. 'locationSourceMode',
  325. 'locationSourceText',
  326. 'mockBridgeConnected',
  327. 'mockBridgeStatusText',
  328. 'mockBridgeUrlText',
  329. 'mockCoordText',
  330. 'mockSpeedText',
  331. 'gpsCoordText',
  332. 'heartRateSourceMode',
  333. 'heartRateSourceText',
  334. 'heartRateConnected',
  335. 'heartRateStatusText',
  336. 'heartRateDeviceText',
  337. 'heartRateScanText',
  338. 'heartRateDiscoveredDevices',
  339. 'mockHeartRateBridgeConnected',
  340. 'mockHeartRateBridgeStatusText',
  341. 'mockHeartRateBridgeUrlText',
  342. 'mockHeartRateText',
  343. 'gameSessionStatus',
  344. 'gameModeText',
  345. 'panelTimerText',
  346. 'panelMileageText',
  347. 'panelActionTagText',
  348. 'panelDistanceTagText',
  349. 'panelDistanceValueText',
  350. 'panelDistanceUnitText',
  351. 'panelProgressText',
  352. 'panelSpeedValueText',
  353. 'panelTelemetryTone',
  354. 'panelHeartRateZoneNameText',
  355. 'panelHeartRateZoneRangeText',
  356. 'panelHeartRateValueText',
  357. 'panelHeartRateUnitText',
  358. 'panelCaloriesValueText',
  359. 'panelCaloriesUnitText',
  360. 'panelAverageSpeedValueText',
  361. 'panelAverageSpeedUnitText',
  362. 'panelAccuracyValueText',
  363. 'panelAccuracyUnitText',
  364. 'punchButtonText',
  365. 'punchButtonEnabled',
  366. 'skipButtonEnabled',
  367. 'punchHintText',
  368. 'punchFeedbackVisible',
  369. 'punchFeedbackText',
  370. 'punchFeedbackTone',
  371. 'contentCardVisible',
  372. 'contentCardTitle',
  373. 'contentCardBody',
  374. 'pendingContentEntryVisible',
  375. 'pendingContentEntryText',
  376. 'punchButtonFxClass',
  377. 'panelProgressFxClass',
  378. 'panelDistanceFxClass',
  379. 'punchFeedbackFxClass',
  380. 'contentCardFxClass',
  381. 'mapPulseVisible',
  382. 'mapPulseLeftPx',
  383. 'mapPulseTopPx',
  384. 'mapPulseFxClass',
  385. 'stageFxVisible',
  386. 'stageFxClass',
  387. 'osmReferenceEnabled',
  388. 'osmReferenceText',
  389. ]
  390. const INTERACTION_DEFERRED_VIEW_KEYS = new Set<keyof MapEngineViewState>([
  391. 'rotationText',
  392. 'sensorHeadingText',
  393. 'deviceHeadingText',
  394. 'devicePoseText',
  395. 'headingConfidenceText',
  396. 'accelerometerText',
  397. 'gyroscopeText',
  398. 'deviceMotionText',
  399. 'compassSourceText',
  400. 'compassTuningProfile',
  401. 'compassTuningProfileText',
  402. 'compassDeclinationText',
  403. 'autoRotateSourceText',
  404. 'autoRotateCalibrationText',
  405. 'northReferenceText',
  406. 'centerText',
  407. 'gpsCoordText',
  408. 'visibleTileCount',
  409. 'readyTileCount',
  410. 'memoryTileCount',
  411. 'diskTileCount',
  412. 'memoryHitCount',
  413. 'diskHitCount',
  414. 'networkFetchCount',
  415. 'cacheHitRateText',
  416. 'heartRateDiscoveredDevices',
  417. 'mockCoordText',
  418. 'mockSpeedText',
  419. 'mockHeartRateText',
  420. ])
  421. function buildCenterText(zoom: number, x: number, y: number): string {
  422. return `z${zoom} / x${x} / y${y}`
  423. }
  424. function clamp(value: number, min: number, max: number): number {
  425. return Math.max(min, Math.min(max, value))
  426. }
  427. function normalizeRotationDeg(rotationDeg: number): number {
  428. const normalized = rotationDeg % 360
  429. return normalized < 0 ? normalized + 360 : normalized
  430. }
  431. function normalizeAngleDeltaRad(angleDeltaRad: number): number {
  432. let normalized = angleDeltaRad
  433. while (normalized > Math.PI) {
  434. normalized -= Math.PI * 2
  435. }
  436. while (normalized < -Math.PI) {
  437. normalized += Math.PI * 2
  438. }
  439. return normalized
  440. }
  441. function normalizeAngleDeltaDeg(angleDeltaDeg: number): number {
  442. let normalized = angleDeltaDeg
  443. while (normalized > 180) {
  444. normalized -= 360
  445. }
  446. while (normalized < -180) {
  447. normalized += 360
  448. }
  449. return normalized
  450. }
  451. function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number {
  452. return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
  453. }
  454. function getCompassNeedleSmoothingFactor(
  455. currentDeg: number,
  456. targetDeg: number,
  457. profile: CompassTuningProfile,
  458. ): number {
  459. const preset = COMPASS_TUNING_PRESETS[profile]
  460. const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg))
  461. if (deltaDeg <= 4) {
  462. return preset.needleMinSmoothing
  463. }
  464. if (deltaDeg >= 36) {
  465. return preset.needleMaxSmoothing
  466. }
  467. const progress = (deltaDeg - 4) / (36 - 4)
  468. return preset.needleMinSmoothing
  469. + (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress
  470. }
  471. function getMovementHeadingSmoothingFactor(speedKmh: number | null): number {
  472. if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
  473. return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
  474. }
  475. if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
  476. return SMART_HEADING_MOVEMENT_MAX_SMOOTHING
  477. }
  478. const progress = (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH)
  479. / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)
  480. return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
  481. + (SMART_HEADING_MOVEMENT_MAX_SMOOTHING - SMART_HEADING_MOVEMENT_MIN_SMOOTHING) * progress
  482. }
  483. function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string {
  484. if (status === 'running') {
  485. return '进行中'
  486. }
  487. if (status === 'finished') {
  488. return '已结束'
  489. }
  490. if (status === 'failed') {
  491. return '已失败'
  492. }
  493. return '未开始'
  494. }
  495. function formatRotationText(rotationDeg: number): string {
  496. return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
  497. }
  498. function normalizeDegreeDisplayText(text: string): string {
  499. return text.replace(/[掳•˚]/g, '°')
  500. }
  501. function formatHeadingText(headingDeg: number | null): string {
  502. if (headingDeg === null) {
  503. return '--'
  504. }
  505. return `${Math.round(normalizeRotationDeg(headingDeg))}°`
  506. }
  507. function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
  508. if (pose === 'flat') {
  509. return '平放'
  510. }
  511. if (pose === 'tilted') {
  512. return '倾斜'
  513. }
  514. return '竖持'
  515. }
  516. function formatHeadingConfidenceText(confidence: 'low' | 'medium' | 'high'): string {
  517. if (confidence === 'high') {
  518. return '高'
  519. }
  520. if (confidence === 'medium') {
  521. return '中'
  522. }
  523. return '低'
  524. }
  525. function formatClockTime(timestamp: number | null): string {
  526. if (!timestamp || !Number.isFinite(timestamp)) {
  527. return '--:--:--'
  528. }
  529. const date = new Date(timestamp)
  530. const hh = String(date.getHours()).padStart(2, '0')
  531. const mm = String(date.getMinutes()).padStart(2, '0')
  532. const ss = String(date.getSeconds()).padStart(2, '0')
  533. return `${hh}:${mm}:${ss}`
  534. }
  535. function formatGyroscopeText(gyroscope: { x: number; y: number; z: number } | null): string {
  536. if (!gyroscope) {
  537. return '--'
  538. }
  539. return `x:${gyroscope.x.toFixed(2)} y:${gyroscope.y.toFixed(2)} z:${gyroscope.z.toFixed(2)}`
  540. }
  541. function formatDeviceMotionText(motion: { alpha: number | null; beta: number | null; gamma: number | null } | null): string {
  542. if (!motion) {
  543. return '--'
  544. }
  545. const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha))
  546. const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta)
  547. const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma)
  548. return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}`
  549. }
  550. function formatOrientationModeText(mode: OrientationMode): string {
  551. if (mode === 'north-up') {
  552. return 'North Up'
  553. }
  554. if (mode === 'heading-up') {
  555. return 'Heading Up'
  556. }
  557. return 'Manual Gesture'
  558. }
  559. function formatRotationModeText(mode: OrientationMode): string {
  560. return formatOrientationModeText(mode)
  561. }
  562. function formatRotationToggleText(mode: OrientationMode): string {
  563. if (mode === 'manual') {
  564. return '切到北朝上'
  565. }
  566. if (mode === 'north-up') {
  567. return '切到朝向朝上'
  568. }
  569. return '切到手动旋转'
  570. }
  571. function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
  572. if (mode === 'smart') {
  573. return 'Smart / 手机朝向'
  574. }
  575. if (mode === 'sensor') {
  576. return 'Sensor Only'
  577. }
  578. if (mode === 'course') {
  579. return hasCourseHeading ? 'Course Only' : 'Course Pending'
  580. }
  581. return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
  582. }
  583. function formatSmartHeadingSourceText(source: SmartHeadingSource): string {
  584. if (source === 'movement') {
  585. return 'Smart / 前进方向'
  586. }
  587. if (source === 'blended') {
  588. return 'Smart / 融合'
  589. }
  590. return 'Smart / 手机朝向'
  591. }
  592. function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
  593. if (pending) {
  594. return 'Pending'
  595. }
  596. if (offsetDeg === null) {
  597. return '--'
  598. }
  599. return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg`
  600. }
  601. function getTrueHeadingDeg(magneticHeadingDeg: number): number {
  602. return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
  603. }
  604. function getMagneticHeadingDeg(trueHeadingDeg: number): number {
  605. return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
  606. }
  607. function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
  608. return MAP_NORTH_OFFSET_DEG
  609. }
  610. function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  611. if (mode === 'true') {
  612. return getTrueHeadingDeg(magneticHeadingDeg)
  613. }
  614. return normalizeRotationDeg(magneticHeadingDeg)
  615. }
  616. function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  617. if (mode === 'magnetic') {
  618. return normalizeRotationDeg(magneticHeadingDeg)
  619. }
  620. return getTrueHeadingDeg(magneticHeadingDeg)
  621. }
  622. function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
  623. if (mode === 'magnetic') {
  624. return getMagneticHeadingDeg(trueHeadingDeg)
  625. }
  626. return normalizeRotationDeg(trueHeadingDeg)
  627. }
  628. function formatNorthReferenceText(mode: NorthReferenceMode): string {
  629. if (mode === 'magnetic') {
  630. return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
  631. }
  632. return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
  633. }
  634. function formatCompassDeclinationText(mode: NorthReferenceMode): string {
  635. if (mode === 'true') {
  636. return MAGNETIC_DECLINATION_TEXT
  637. }
  638. return ''
  639. }
  640. function formatCompassSourceText(source: 'compass' | 'motion' | null): string {
  641. if (source === 'compass') {
  642. return '罗盘'
  643. }
  644. if (source === 'motion') {
  645. return '设备方向兜底'
  646. }
  647. return '无数据'
  648. }
  649. function formatCompassTuningProfileText(profile: CompassTuningProfile): string {
  650. if (profile === 'smooth') {
  651. return '顺滑'
  652. }
  653. if (profile === 'responsive') {
  654. return '跟手'
  655. }
  656. return '平衡'
  657. }
  658. function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
  659. return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北'
  660. }
  661. function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
  662. if (mode === 'magnetic') {
  663. return '已切到磁北模式'
  664. }
  665. return '已切到真北模式'
  666. }
  667. function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
  668. return mode === 'magnetic' ? 'true' : 'magnetic'
  669. }
  670. function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
  671. if (magneticHeadingDeg === null) {
  672. return 0
  673. }
  674. const referenceHeadingDeg = mode === 'true'
  675. ? getTrueHeadingDeg(magneticHeadingDeg)
  676. : normalizeRotationDeg(magneticHeadingDeg)
  677. return normalizeRotationDeg(360 - referenceHeadingDeg)
  678. }
  679. function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string {
  680. const total = memoryHitCount + diskHitCount + networkFetchCount
  681. if (!total) {
  682. return '--'
  683. }
  684. const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
  685. return `${Math.round(hitRate)}%`
  686. }
  687. function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string {
  688. if (!point) {
  689. return '--'
  690. }
  691. const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}`
  692. if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) {
  693. return base
  694. }
  695. return `${base} / 卤${Math.round(accuracyMeters)}m`
  696. }
  697. function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
  698. const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  699. const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  700. const dy = (b.lat - a.lat) * 110540
  701. return Math.sqrt(dx * dx + dy * dy)
  702. }
  703. function resolveSmartHeadingSource(speedKmh: number | null, movementReliable: boolean): SmartHeadingSource {
  704. if (!movementReliable || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
  705. return 'sensor'
  706. }
  707. if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
  708. return 'movement'
  709. }
  710. return 'blended'
  711. }
  712. function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
  713. const fromLatRad = from.lat * Math.PI / 180
  714. const toLatRad = to.lat * Math.PI / 180
  715. const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
  716. const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
  717. const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
  718. const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
  719. return normalizeRotationDeg(bearingDeg)
  720. }
  721. export class MapEngine {
  722. buildVersion: string
  723. animationLevel: AnimationLevel
  724. renderer: WebGLMapRenderer
  725. accelerometerController: AccelerometerController
  726. compassController: CompassHeadingController
  727. gyroscopeController: GyroscopeController
  728. deviceMotionController: DeviceMotionController
  729. locationController: LocationController
  730. heartRateController: HeartRateInputController
  731. feedbackDirector: FeedbackDirector
  732. onData: (patch: Partial<MapEngineViewState>) => void
  733. state: MapEngineViewState
  734. accelerometerErrorText: string | null
  735. previewScale: number
  736. previewOriginX: number
  737. previewOriginY: number
  738. panLastX: number
  739. panLastY: number
  740. panLastTimestamp: number
  741. tapStartX: number
  742. tapStartY: number
  743. tapStartAt: number
  744. panVelocityX: number
  745. panVelocityY: number
  746. pinchStartDistance: number
  747. pinchStartScale: number
  748. pinchStartAngle: number
  749. pinchStartRotationDeg: number
  750. pinchAnchorWorldX: number
  751. pinchAnchorWorldY: number
  752. gestureMode: GestureMode
  753. inertiaTimer: number
  754. previewResetTimer: number
  755. viewSyncTimer: number
  756. autoRotateTimer: number
  757. compassNeedleTimer: number
  758. compassBootstrapRetryTimer: number
  759. pendingViewPatch: Partial<MapEngineViewState>
  760. mounted: boolean
  761. diagnosticUiEnabled: boolean
  762. northReferenceMode: NorthReferenceMode
  763. sensorHeadingDeg: number | null
  764. smoothedSensorHeadingDeg: number | null
  765. compassDisplayHeadingDeg: number | null
  766. targetCompassDisplayHeadingDeg: number | null
  767. lastCompassSampleAt: number
  768. compassSource: 'compass' | 'motion' | null
  769. compassTuningProfile: CompassTuningProfile
  770. smoothedMovementHeadingDeg: number | null
  771. autoRotateHeadingDeg: number | null
  772. courseHeadingDeg: number | null
  773. targetAutoRotationDeg: number | null
  774. autoRotateSourceMode: AutoRotateSourceMode
  775. autoRotateCalibrationOffsetDeg: number | null
  776. autoRotateCalibrationPending: boolean
  777. lastStatsUiSyncAt: number
  778. minZoom: number
  779. maxZoom: number
  780. defaultZoom: number
  781. defaultCenterTileX: number
  782. defaultCenterTileY: number
  783. tileBoundsByZoom: Record<number, TileZoomBounds> | null
  784. currentGpsPoint: LonLatPoint | null
  785. currentGpsTrack: LonLatPoint[]
  786. currentGpsAccuracyMeters: number | null
  787. currentGpsInsideMap: boolean
  788. courseData: OrienteeringCourseData | null
  789. courseOverlayVisible: boolean
  790. cpRadiusMeters: number
  791. configAppId: string
  792. configSchemaVersion: string
  793. configVersion: string
  794. controlScoreOverrides: Record<string, number>
  795. controlContentOverrides: Record<string, GameControlDisplayContentOverride>
  796. defaultControlScore: number | null
  797. gameRuntime: GameRuntime
  798. telemetryRuntime: TelemetryRuntime
  799. gamePresentation: GamePresentationState
  800. gameMode: 'classic-sequential' | 'score-o'
  801. punchPolicy: 'enter' | 'enter-confirm'
  802. punchRadiusMeters: number
  803. requiresFocusSelection: boolean
  804. skipEnabled: boolean
  805. skipRadiusMeters: number
  806. skipRequiresConfirm: boolean
  807. autoFinishOnLastControl: boolean
  808. punchFeedbackTimer: number
  809. contentCardTimer: number
  810. currentContentCardPriority: number
  811. shownContentCardKeys: Record<string, true>
  812. currentContentCard: ContentCardEntry | null
  813. pendingContentCards: ContentCardEntry[]
  814. currentH5ExperienceOpen: boolean
  815. mapPulseTimer: number
  816. stageFxTimer: number
  817. sessionTimerInterval: number
  818. hasGpsCenteredOnce: boolean
  819. gpsLockEnabled: boolean
  820. onOpenH5Experience?: (request: H5ExperienceRequest) => void
  821. constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
  822. this.buildVersion = buildVersion
  823. this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
  824. this.compassTuningProfile = 'balanced'
  825. this.onData = callbacks.onData
  826. this.onOpenH5Experience = callbacks.onOpenH5Experience
  827. this.accelerometerErrorText = null
  828. this.renderer = new WebGLMapRenderer(
  829. (stats) => {
  830. this.applyStats(stats)
  831. },
  832. (message) => {
  833. this.setState({
  834. statusText: `${message} (${this.buildVersion})`,
  835. })
  836. },
  837. )
  838. this.accelerometerController = new AccelerometerController({
  839. onSample: (x, y, z) => {
  840. this.accelerometerErrorText = null
  841. this.telemetryRuntime.dispatch({
  842. type: 'accelerometer_updated',
  843. at: Date.now(),
  844. x,
  845. y,
  846. z,
  847. })
  848. if (this.diagnosticUiEnabled) {
  849. this.setState(this.getTelemetrySensorViewPatch())
  850. }
  851. },
  852. onError: (message) => {
  853. this.accelerometerErrorText = `不可用: ${message}`
  854. if (this.diagnosticUiEnabled) {
  855. this.setState({
  856. ...this.getTelemetrySensorViewPatch(),
  857. statusText: `加速度计启动失败 (${this.buildVersion})`,
  858. })
  859. }
  860. },
  861. })
  862. this.compassController = new CompassHeadingController({
  863. onHeading: (headingDeg) => {
  864. this.handleCompassHeading(headingDeg)
  865. },
  866. onError: (message) => {
  867. this.handleCompassError(message)
  868. },
  869. })
  870. this.compassController.setTuningProfile(this.compassTuningProfile)
  871. this.gyroscopeController = new GyroscopeController({
  872. onSample: (x, y, z) => {
  873. this.telemetryRuntime.dispatch({
  874. type: 'gyroscope_updated',
  875. at: Date.now(),
  876. x,
  877. y,
  878. z,
  879. })
  880. if (this.diagnosticUiEnabled) {
  881. this.setState(this.getTelemetrySensorViewPatch())
  882. }
  883. },
  884. onError: () => {
  885. if (this.diagnosticUiEnabled) {
  886. this.setState(this.getTelemetrySensorViewPatch())
  887. }
  888. },
  889. })
  890. this.deviceMotionController = new DeviceMotionController({
  891. onSample: (alpha, beta, gamma) => {
  892. this.telemetryRuntime.dispatch({
  893. type: 'device_motion_updated',
  894. at: Date.now(),
  895. alpha,
  896. beta,
  897. gamma,
  898. })
  899. if (this.diagnosticUiEnabled) {
  900. this.setState({
  901. ...this.getTelemetrySensorViewPatch(),
  902. autoRotateSourceText: this.getAutoRotateSourceText(),
  903. })
  904. }
  905. },
  906. onError: () => {
  907. if (this.diagnosticUiEnabled) {
  908. this.setState(this.getTelemetrySensorViewPatch())
  909. }
  910. },
  911. })
  912. this.locationController = new LocationController({
  913. onLocation: (update) => {
  914. this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
  915. },
  916. onStatus: (message) => {
  917. this.setState({
  918. gpsTracking: this.locationController.listening,
  919. gpsTrackingText: message,
  920. ...this.getLocationControllerViewPatch(),
  921. })
  922. },
  923. onError: (message) => {
  924. this.setState({
  925. gpsTracking: this.locationController.listening,
  926. gpsTrackingText: message,
  927. ...this.getLocationControllerViewPatch(),
  928. statusText: `${message} (${this.buildVersion})`,
  929. })
  930. },
  931. onDebugStateChange: () => {
  932. if (this.diagnosticUiEnabled) {
  933. this.setState(this.getLocationControllerViewPatch())
  934. }
  935. },
  936. })
  937. this.heartRateController = new HeartRateInputController({
  938. onHeartRate: (bpm) => {
  939. this.telemetryRuntime.dispatch({
  940. type: 'heart_rate_updated',
  941. at: Date.now(),
  942. bpm,
  943. })
  944. this.syncSessionTimerText()
  945. },
  946. onStatus: (message) => {
  947. const deviceName = this.heartRateController.currentDeviceName
  948. || (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null)
  949. || '--'
  950. this.setState({
  951. heartRateStatusText: message,
  952. heartRateDeviceText: deviceName,
  953. heartRateScanText: this.getHeartRateScanText(),
  954. ...this.getHeartRateControllerViewPatch(),
  955. })
  956. },
  957. onError: (message) => {
  958. this.clearHeartRateSignal()
  959. const deviceName = this.heartRateController.reconnecting
  960. ? (this.heartRateController.lastDeviceName || '--')
  961. : '--'
  962. this.setState({
  963. heartRateConnected: false,
  964. heartRateStatusText: message,
  965. heartRateDeviceText: deviceName,
  966. heartRateScanText: this.getHeartRateScanText(),
  967. ...this.getHeartRateControllerViewPatch(),
  968. statusText: `${message} (${this.buildVersion})`,
  969. })
  970. },
  971. onConnectionChange: (connected, deviceName) => {
  972. if (!connected) {
  973. this.clearHeartRateSignal()
  974. }
  975. const resolvedDeviceName = connected
  976. ? (deviceName || '--')
  977. : (this.heartRateController.reconnecting
  978. ? (this.heartRateController.lastDeviceName || '--')
  979. : '--')
  980. this.setState({
  981. heartRateConnected: connected,
  982. heartRateDeviceText: resolvedDeviceName,
  983. heartRateStatusText: connected
  984. ? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
  985. : (this.heartRateController.reconnecting ? '心率带自动重连中' : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接')),
  986. heartRateScanText: this.getHeartRateScanText(),
  987. heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
  988. ...this.getHeartRateControllerViewPatch(),
  989. })
  990. },
  991. onDeviceListChange: (devices) => {
  992. if (this.diagnosticUiEnabled) {
  993. this.setState({
  994. heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
  995. heartRateScanText: this.getHeartRateScanText(),
  996. ...this.getHeartRateControllerViewPatch(),
  997. })
  998. }
  999. },
  1000. onDebugStateChange: () => {
  1001. if (this.diagnosticUiEnabled) {
  1002. this.setState(this.getHeartRateControllerViewPatch())
  1003. }
  1004. },
  1005. })
  1006. this.feedbackDirector = new FeedbackDirector({
  1007. showPunchFeedback: (text, tone, motionClass) => {
  1008. this.showPunchFeedback(text, tone, motionClass)
  1009. },
  1010. showContentCard: (title, body, motionClass, options) => {
  1011. this.showContentCard(title, body, motionClass, options)
  1012. },
  1013. setPunchButtonFxClass: (className) => {
  1014. this.setPunchButtonFxClass(className)
  1015. },
  1016. setHudProgressFxClass: (className) => {
  1017. this.setHudProgressFxClass(className)
  1018. },
  1019. setHudDistanceFxClass: (className) => {
  1020. this.setHudDistanceFxClass(className)
  1021. },
  1022. showMapPulse: (controlId, motionClass) => {
  1023. this.showMapPulse(controlId, motionClass)
  1024. },
  1025. showStageFx: (className) => {
  1026. this.showStageFx(className)
  1027. },
  1028. stopLocationTracking: () => {
  1029. if (this.locationController.listening) {
  1030. this.locationController.stop()
  1031. }
  1032. },
  1033. })
  1034. this.feedbackDirector.setAnimationLevel(this.animationLevel)
  1035. this.minZoom = MIN_ZOOM
  1036. this.maxZoom = MAX_ZOOM
  1037. this.defaultZoom = DEFAULT_ZOOM
  1038. this.defaultCenterTileX = DEFAULT_CENTER_TILE_X
  1039. this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y
  1040. this.tileBoundsByZoom = null
  1041. this.currentGpsPoint = null
  1042. this.currentGpsTrack = []
  1043. this.currentGpsAccuracyMeters = null
  1044. this.currentGpsInsideMap = false
  1045. this.courseData = null
  1046. this.courseOverlayVisible = false
  1047. this.cpRadiusMeters = 5
  1048. this.configAppId = ''
  1049. this.configSchemaVersion = '1'
  1050. this.configVersion = ''
  1051. this.controlScoreOverrides = {}
  1052. this.controlContentOverrides = {}
  1053. this.defaultControlScore = null
  1054. this.gameRuntime = new GameRuntime()
  1055. this.telemetryRuntime = new TelemetryRuntime()
  1056. this.telemetryRuntime.configure()
  1057. this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
  1058. this.gameMode = 'classic-sequential'
  1059. this.punchPolicy = 'enter-confirm'
  1060. this.punchRadiusMeters = 5
  1061. this.requiresFocusSelection = false
  1062. this.skipEnabled = false
  1063. this.skipRadiusMeters = 30
  1064. this.skipRequiresConfirm = true
  1065. this.autoFinishOnLastControl = true
  1066. this.gpsLockEnabled = false
  1067. this.punchFeedbackTimer = 0
  1068. this.contentCardTimer = 0
  1069. this.currentContentCardPriority = 0
  1070. this.shownContentCardKeys = {}
  1071. this.currentContentCard = null
  1072. this.pendingContentCards = []
  1073. this.currentH5ExperienceOpen = false
  1074. this.mapPulseTimer = 0
  1075. this.stageFxTimer = 0
  1076. this.sessionTimerInterval = 0
  1077. this.hasGpsCenteredOnce = false
  1078. this.state = {
  1079. animationLevel: this.animationLevel,
  1080. buildVersion: this.buildVersion,
  1081. renderMode: RENDER_MODE,
  1082. projectionMode: PROJECTION_MODE,
  1083. mapReady: false,
  1084. mapReadyText: 'BOOTING',
  1085. mapName: '未命名配置',
  1086. configStatusText: '远程配置待加载',
  1087. zoom: DEFAULT_ZOOM,
  1088. rotationDeg: 0,
  1089. rotationText: formatRotationText(0),
  1090. rotationMode: 'manual',
  1091. rotationModeText: formatRotationModeText('manual'),
  1092. rotationToggleText: formatRotationToggleText('manual'),
  1093. orientationMode: 'manual',
  1094. orientationModeText: formatOrientationModeText('manual'),
  1095. sensorHeadingText: '--',
  1096. deviceHeadingText: '--',
  1097. devicePoseText: '竖持',
  1098. headingConfidenceText: '低',
  1099. accelerometerText: '未启用',
  1100. gyroscopeText: '--',
  1101. deviceMotionText: '--',
  1102. compassSourceText: '无数据',
  1103. compassTuningProfile: this.compassTuningProfile,
  1104. compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
  1105. compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
  1106. northReferenceMode: DEFAULT_NORTH_REFERENCE_MODE,
  1107. northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
  1108. autoRotateSourceText: formatAutoRotateSourceText('smart', false),
  1109. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
  1110. northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
  1111. compassNeedleDeg: 0,
  1112. centerTileX: DEFAULT_CENTER_TILE_X,
  1113. centerTileY: DEFAULT_CENTER_TILE_Y,
  1114. centerText: buildCenterText(DEFAULT_ZOOM, DEFAULT_CENTER_TILE_X, DEFAULT_CENTER_TILE_Y),
  1115. tileSource: TILE_SOURCE,
  1116. visibleColumnCount: DESIRED_VISIBLE_COLUMNS,
  1117. visibleTileCount: 0,
  1118. readyTileCount: 0,
  1119. memoryTileCount: 0,
  1120. diskTileCount: 0,
  1121. memoryHitCount: 0,
  1122. diskHitCount: 0,
  1123. networkFetchCount: 0,
  1124. cacheHitRateText: '--',
  1125. tileTranslateX: 0,
  1126. tileTranslateY: 0,
  1127. tileSizePx: 0,
  1128. previewScale: 1,
  1129. stageWidth: 0,
  1130. stageHeight: 0,
  1131. stageLeft: 0,
  1132. stageTop: 0,
  1133. statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
  1134. gpsTracking: false,
  1135. gpsTrackingText: '持续定位待启动',
  1136. gpsLockEnabled: false,
  1137. gpsLockAvailable: false,
  1138. locationSourceMode: 'real',
  1139. locationSourceText: '真实定位',
  1140. mockBridgeConnected: false,
  1141. mockBridgeStatusText: '未连接',
  1142. mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
  1143. mockCoordText: '--',
  1144. mockSpeedText: '--',
  1145. gpsCoordText: '--',
  1146. heartRateSourceMode: 'real',
  1147. heartRateSourceText: '真实心率',
  1148. heartRateConnected: false,
  1149. heartRateStatusText: '心率带未连接',
  1150. heartRateDeviceText: '--',
  1151. heartRateScanText: '未扫描',
  1152. heartRateDiscoveredDevices: [],
  1153. mockHeartRateBridgeConnected: false,
  1154. mockHeartRateBridgeStatusText: '未连接',
  1155. mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
  1156. mockHeartRateText: '--',
  1157. panelTimerText: '00:00:00',
  1158. panelMileageText: '0m',
  1159. panelActionTagText: '目标',
  1160. panelDistanceTagText: '点距',
  1161. panelDistanceValueText: '--',
  1162. panelDistanceUnitText: '',
  1163. panelProgressText: '0/0',
  1164. panelSpeedValueText: '0',
  1165. panelTelemetryTone: 'blue',
  1166. panelHeartRateZoneNameText: '激活放松',
  1167. panelHeartRateZoneRangeText: '<=39%',
  1168. panelHeartRateValueText: '--',
  1169. panelHeartRateUnitText: '',
  1170. panelCaloriesValueText: '0',
  1171. panelCaloriesUnitText: 'kcal',
  1172. panelAverageSpeedValueText: '0',
  1173. panelAverageSpeedUnitText: 'km/h',
  1174. panelAccuracyValueText: '--',
  1175. panelAccuracyUnitText: '',
  1176. punchButtonText: '打点',
  1177. gameSessionStatus: 'idle',
  1178. gameModeText: '顺序赛',
  1179. punchButtonEnabled: false,
  1180. skipButtonEnabled: false,
  1181. punchHintText: '等待进入检查点范围',
  1182. punchFeedbackVisible: false,
  1183. punchFeedbackText: '',
  1184. punchFeedbackTone: 'neutral',
  1185. contentCardVisible: false,
  1186. contentCardTitle: '',
  1187. contentCardBody: '',
  1188. pendingContentEntryVisible: false,
  1189. pendingContentEntryText: '',
  1190. punchButtonFxClass: '',
  1191. panelProgressFxClass: '',
  1192. panelDistanceFxClass: '',
  1193. punchFeedbackFxClass: '',
  1194. contentCardFxClass: '',
  1195. mapPulseVisible: false,
  1196. mapPulseLeftPx: 0,
  1197. mapPulseTopPx: 0,
  1198. mapPulseFxClass: '',
  1199. stageFxVisible: false,
  1200. stageFxClass: '',
  1201. osmReferenceEnabled: false,
  1202. osmReferenceText: 'OSM参考:关',
  1203. }
  1204. this.previewScale = 1
  1205. this.previewOriginX = 0
  1206. this.previewOriginY = 0
  1207. this.panLastX = 0
  1208. this.panLastY = 0
  1209. this.panLastTimestamp = 0
  1210. this.tapStartX = 0
  1211. this.tapStartY = 0
  1212. this.tapStartAt = 0
  1213. this.panVelocityX = 0
  1214. this.panVelocityY = 0
  1215. this.pinchStartDistance = 0
  1216. this.pinchStartScale = 1
  1217. this.pinchStartAngle = 0
  1218. this.pinchStartRotationDeg = 0
  1219. this.pinchAnchorWorldX = 0
  1220. this.pinchAnchorWorldY = 0
  1221. this.gestureMode = 'idle'
  1222. this.inertiaTimer = 0
  1223. this.previewResetTimer = 0
  1224. this.viewSyncTimer = 0
  1225. this.autoRotateTimer = 0
  1226. this.compassNeedleTimer = 0
  1227. this.compassBootstrapRetryTimer = 0
  1228. this.pendingViewPatch = {}
  1229. this.mounted = false
  1230. this.diagnosticUiEnabled = false
  1231. this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
  1232. this.sensorHeadingDeg = null
  1233. this.smoothedSensorHeadingDeg = null
  1234. this.compassDisplayHeadingDeg = null
  1235. this.targetCompassDisplayHeadingDeg = null
  1236. this.lastCompassSampleAt = 0
  1237. this.compassSource = null
  1238. this.compassTuningProfile = 'balanced'
  1239. this.smoothedMovementHeadingDeg = null
  1240. this.autoRotateHeadingDeg = null
  1241. this.courseHeadingDeg = null
  1242. this.targetAutoRotationDeg = null
  1243. this.autoRotateSourceMode = 'smart'
  1244. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
  1245. this.autoRotateCalibrationPending = false
  1246. this.lastStatsUiSyncAt = 0
  1247. }
  1248. getInitialData(): MapEngineViewState {
  1249. return { ...this.state }
  1250. }
  1251. setDiagnosticUiEnabled(enabled: boolean): void {
  1252. if (this.diagnosticUiEnabled === enabled) {
  1253. return
  1254. }
  1255. this.diagnosticUiEnabled = enabled
  1256. if (!enabled) {
  1257. return
  1258. }
  1259. this.setState({
  1260. ...this.getTelemetrySensorViewPatch(),
  1261. ...this.getLocationControllerViewPatch(),
  1262. ...this.getHeartRateControllerViewPatch(),
  1263. heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
  1264. autoRotateSourceText: this.getAutoRotateSourceText(),
  1265. visibleTileCount: this.state.visibleTileCount,
  1266. readyTileCount: this.state.readyTileCount,
  1267. memoryTileCount: this.state.memoryTileCount,
  1268. diskTileCount: this.state.diskTileCount,
  1269. memoryHitCount: this.state.memoryHitCount,
  1270. diskHitCount: this.state.diskHitCount,
  1271. networkFetchCount: this.state.networkFetchCount,
  1272. cacheHitRateText: this.state.cacheHitRateText,
  1273. }, true)
  1274. }
  1275. getGameInfoSnapshot(): MapEngineGameInfoSnapshot {
  1276. const definition = this.gameRuntime.definition
  1277. const sessionState = this.gameRuntime.state
  1278. const telemetryState = this.telemetryRuntime.state
  1279. const telemetryPresentation = this.telemetryRuntime.getPresentation()
  1280. const currentTarget = definition && sessionState
  1281. ? definition.controls.find((control) => control.id === sessionState.currentTargetControlId) || null
  1282. : null
  1283. const currentTargetText = currentTarget
  1284. ? `${currentTarget.label} / ${currentTarget.kind === 'start'
  1285. ? '开始点'
  1286. : currentTarget.kind === 'finish'
  1287. ? '结束点'
  1288. : '检查点'}`
  1289. : '--'
  1290. const title = this.state.mapName || (definition ? definition.title : '当前游戏')
  1291. const subtitle = `${this.getGameModeText()} / ${formatGameSessionStatusText(this.state.gameSessionStatus)}`
  1292. const localRows: MapEngineGameInfoRow[] = [
  1293. { label: '比赛名称', value: title || '--' },
  1294. { label: '配置版本', value: this.configVersion || '--' },
  1295. { label: 'Schema版本', value: this.configSchemaVersion || '--' },
  1296. { label: '活动ID', value: this.configAppId || '--' },
  1297. { label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) },
  1298. { label: '地图', value: this.state.mapName || '--' },
  1299. { label: '模式', value: this.getGameModeText() },
  1300. { label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) },
  1301. { label: '当前目标', value: currentTargetText },
  1302. { label: '进度', value: this.gamePresentation.hud.progressText || '--' },
  1303. { label: '当前积分', value: sessionState ? String(sessionState.score) : '0' },
  1304. { label: '已完成点', value: sessionState ? String(sessionState.completedControlIds.length) : '0' },
  1305. { label: '已跳过点', value: sessionState ? String(sessionState.skippedControlIds.length) : '0' },
  1306. { label: '打点规则', value: `${this.punchPolicy} / ${this.punchRadiusMeters}m` },
  1307. { label: '跳点规则', value: this.skipEnabled ? `${this.skipRadiusMeters}m / ${this.skipRequiresConfirm ? '确认跳过' : '直接跳过'}` : '关闭' },
  1308. { label: '定位源', value: this.state.locationSourceText || '--' },
  1309. { label: '当前位置', value: this.state.gpsCoordText || '--' },
  1310. { label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` },
  1311. { label: '设备朝向', value: this.state.deviceHeadingText || '--' },
  1312. { label: '设备姿态', value: this.state.devicePoseText || '--' },
  1313. { label: '朝向可信度', value: this.state.headingConfidenceText || '--' },
  1314. { label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' },
  1315. { label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
  1316. { label: '心率源', value: this.state.heartRateSourceText || '--' },
  1317. { label: '当前心率', value: this.state.panelHeartRateValueText === '--' ? '--' : `${this.state.panelHeartRateValueText}${this.state.panelHeartRateUnitText}` },
  1318. { label: '心率设备', value: this.state.heartRateDeviceText || '--' },
  1319. { label: '心率分区', value: this.state.panelHeartRateZoneNameText === '--' ? '--' : `${this.state.panelHeartRateZoneNameText} ${this.state.panelHeartRateZoneRangeText}` },
  1320. { label: '本局用时', value: telemetryPresentation.timerText },
  1321. { label: '累计里程', value: telemetryPresentation.mileageText },
  1322. { label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
  1323. { label: '提示状态', value: this.state.punchHintText || '--' },
  1324. ]
  1325. const globalRows: MapEngineGameInfoRow[] = [
  1326. { label: '全球积分', value: '未接入' },
  1327. { label: '全球排名', value: '未接入' },
  1328. { label: '在线人数', value: '未接入' },
  1329. { label: '队伍状态', value: '未接入' },
  1330. { label: '实时广播', value: '未接入' },
  1331. ]
  1332. return {
  1333. title,
  1334. subtitle,
  1335. localRows,
  1336. globalRows,
  1337. }
  1338. }
  1339. getResultSceneSnapshot(): MapEngineResultSnapshot {
  1340. return buildResultSummarySnapshot(
  1341. this.gameRuntime.definition,
  1342. this.gameRuntime.state,
  1343. this.telemetryRuntime.getPresentation(),
  1344. this.state.mapName || (this.gameRuntime.definition ? this.gameRuntime.definition.title : '本局结果'),
  1345. )
  1346. }
  1347. destroy(): void {
  1348. this.clearInertiaTimer()
  1349. this.clearPreviewResetTimer()
  1350. this.clearViewSyncTimer()
  1351. this.clearAutoRotateTimer()
  1352. this.clearCompassNeedleTimer()
  1353. this.clearCompassBootstrapRetryTimer()
  1354. this.clearPunchFeedbackTimer()
  1355. this.clearContentCardTimer()
  1356. this.clearMapPulseTimer()
  1357. this.clearStageFxTimer()
  1358. this.clearSessionTimerInterval()
  1359. this.accelerometerController.destroy()
  1360. this.compassController.destroy()
  1361. this.gyroscopeController.destroy()
  1362. this.deviceMotionController.destroy()
  1363. this.locationController.destroy()
  1364. this.heartRateController.destroy()
  1365. this.feedbackDirector.destroy()
  1366. this.renderer.destroy()
  1367. this.mounted = false
  1368. }
  1369. handleAppShow(): void {
  1370. this.feedbackDirector.setAppAudioMode('foreground')
  1371. if (this.mounted) {
  1372. this.lastCompassSampleAt = 0
  1373. this.compassController.start()
  1374. this.scheduleCompassBootstrapRetry()
  1375. }
  1376. }
  1377. handleAppHide(): void {
  1378. this.feedbackDirector.setAppAudioMode('foreground')
  1379. }
  1380. clearGameRuntime(): void {
  1381. this.gameRuntime.clear()
  1382. this.telemetryRuntime.reset()
  1383. this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
  1384. this.courseOverlayVisible = !!this.courseData
  1385. this.clearSessionTimerInterval()
  1386. this.setCourseHeading(null)
  1387. }
  1388. clearHeartRateSignal(): void {
  1389. this.telemetryRuntime.dispatch({
  1390. type: 'heart_rate_updated',
  1391. at: Date.now(),
  1392. bpm: null,
  1393. })
  1394. this.syncSessionTimerText()
  1395. }
  1396. clearFinishedTestOverlay(): void {
  1397. this.currentGpsPoint = null
  1398. this.currentGpsTrack = []
  1399. this.currentGpsAccuracyMeters = null
  1400. this.currentGpsInsideMap = false
  1401. this.smoothedMovementHeadingDeg = null
  1402. this.courseOverlayVisible = false
  1403. this.setCourseHeading(null)
  1404. }
  1405. clearStartSessionResidue(): void {
  1406. this.currentGpsTrack = []
  1407. this.smoothedMovementHeadingDeg = null
  1408. this.courseOverlayVisible = false
  1409. this.setCourseHeading(null)
  1410. }
  1411. handleClearMapTestArtifacts(): void {
  1412. this.clearFinishedTestOverlay()
  1413. this.setState({
  1414. gpsTracking: false,
  1415. gpsTrackingText: '测试痕迹已清空',
  1416. gpsCoordText: '--',
  1417. statusText: `已清空地图点位与轨迹 (${this.buildVersion})`,
  1418. }, true)
  1419. this.syncRenderer()
  1420. }
  1421. getHudTargetControlId(): string | null {
  1422. return this.gamePresentation.hud.hudTargetControlId
  1423. }
  1424. isSkipAvailable(): boolean {
  1425. const definition = this.gameRuntime.definition
  1426. const state = this.gameRuntime.state
  1427. if (!definition || !state || state.status !== 'running' || !definition.skipEnabled) {
  1428. return false
  1429. }
  1430. const currentTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
  1431. if (!currentTarget || currentTarget.kind !== 'control' || !this.currentGpsPoint) {
  1432. return false
  1433. }
  1434. const avgLatRad = ((currentTarget.point.lat + this.currentGpsPoint.lat) / 2) * Math.PI / 180
  1435. const dx = (this.currentGpsPoint.lon - currentTarget.point.lon) * 111320 * Math.cos(avgLatRad)
  1436. const dy = (this.currentGpsPoint.lat - currentTarget.point.lat) * 110540
  1437. const distanceMeters = Math.sqrt(dx * dx + dy * dy)
  1438. return distanceMeters <= definition.skipRadiusMeters
  1439. }
  1440. shouldConfirmSkipAction(): boolean {
  1441. return !!(this.gameRuntime.definition && this.gameRuntime.definition.skipRequiresConfirm)
  1442. }
  1443. getLocationControllerViewPatch(): Partial<MapEngineViewState> {
  1444. const debugState = this.locationController.getDebugState()
  1445. return {
  1446. gpsTracking: debugState.listening,
  1447. gpsLockEnabled: this.gpsLockEnabled,
  1448. gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
  1449. locationSourceMode: debugState.sourceMode,
  1450. locationSourceText: debugState.sourceModeText,
  1451. mockBridgeConnected: debugState.mockBridgeConnected,
  1452. mockBridgeStatusText: debugState.mockBridgeStatusText,
  1453. mockBridgeUrlText: debugState.mockBridgeUrlText,
  1454. mockCoordText: debugState.mockCoordText,
  1455. mockSpeedText: debugState.mockSpeedText,
  1456. }
  1457. }
  1458. getHeartRateControllerViewPatch(): Partial<MapEngineViewState> {
  1459. const debugState = this.heartRateController.getDebugState()
  1460. return {
  1461. heartRateSourceMode: debugState.sourceMode,
  1462. heartRateSourceText: debugState.sourceModeText,
  1463. mockHeartRateBridgeConnected: debugState.mockBridgeConnected,
  1464. mockHeartRateBridgeStatusText: debugState.mockBridgeStatusText,
  1465. mockHeartRateBridgeUrlText: debugState.mockBridgeUrlText,
  1466. mockHeartRateText: debugState.mockHeartRateText,
  1467. }
  1468. }
  1469. getTelemetrySensorViewPatch(): Partial<MapEngineViewState> {
  1470. const telemetryState = this.telemetryRuntime.state
  1471. return {
  1472. deviceHeadingText: formatHeadingText(
  1473. telemetryState.deviceHeadingDeg === null
  1474. ? null
  1475. : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg),
  1476. ),
  1477. devicePoseText: formatDevicePoseText(telemetryState.devicePose),
  1478. headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence),
  1479. accelerometerText: telemetryState.accelerometer
  1480. ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}`
  1481. : '未启用',
  1482. gyroscopeText: formatGyroscopeText(telemetryState.gyroscope),
  1483. deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion),
  1484. compassSourceText: formatCompassSourceText(this.compassSource),
  1485. compassTuningProfile: this.compassTuningProfile,
  1486. compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
  1487. }
  1488. }
  1489. getGameModeText(): string {
  1490. return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
  1491. }
  1492. loadGameDefinitionFromCourse(): GameResult | null {
  1493. if (!this.courseData) {
  1494. this.clearGameRuntime()
  1495. return null
  1496. }
  1497. const definition = buildGameDefinitionFromCourse(
  1498. this.courseData,
  1499. this.cpRadiusMeters,
  1500. this.gameMode,
  1501. this.autoFinishOnLastControl,
  1502. this.punchPolicy,
  1503. this.punchRadiusMeters,
  1504. this.requiresFocusSelection,
  1505. this.skipEnabled,
  1506. this.skipRadiusMeters,
  1507. this.skipRequiresConfirm,
  1508. this.controlScoreOverrides,
  1509. this.controlContentOverrides,
  1510. this.defaultControlScore,
  1511. )
  1512. const result = this.gameRuntime.loadDefinition(definition)
  1513. this.telemetryRuntime.loadDefinition(definition)
  1514. this.courseOverlayVisible = true
  1515. this.syncGameResultState(result)
  1516. this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, result.presentation.hud.hudTargetControlId)
  1517. this.updateSessionTimerLoop()
  1518. return result
  1519. }
  1520. refreshCourseHeadingFromPresentation(): void {
  1521. if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) {
  1522. this.setCourseHeading(null)
  1523. return
  1524. }
  1525. const activeLegIndex = this.gamePresentation.map.activeLegIndices[0]
  1526. const activeLeg = this.courseData.layers.legs[activeLegIndex]
  1527. if (!activeLeg) {
  1528. this.setCourseHeading(null)
  1529. return
  1530. }
  1531. this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint))
  1532. }
  1533. resolveGameStatusText(effects: GameEffect[]): string | null {
  1534. const lastEffect = effects.length ? effects[effects.length - 1] : null
  1535. if (!lastEffect) {
  1536. return null
  1537. }
  1538. if (lastEffect.type === 'control_completed') {
  1539. const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId
  1540. return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})`
  1541. }
  1542. if (lastEffect.type === 'session_finished') {
  1543. return `璺嚎宸插畬鎴?(${this.buildVersion})`
  1544. }
  1545. if (lastEffect.type === 'session_started') {
  1546. return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})`
  1547. }
  1548. return null
  1549. }
  1550. getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
  1551. const telemetryPresentation = this.telemetryRuntime.getPresentation()
  1552. const patch: Partial<MapEngineViewState> = {
  1553. gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
  1554. gameModeText: this.getGameModeText(),
  1555. panelTimerText: telemetryPresentation.timerText,
  1556. panelMileageText: telemetryPresentation.mileageText,
  1557. panelActionTagText: this.gamePresentation.hud.actionTagText,
  1558. panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
  1559. panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
  1560. panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
  1561. panelSpeedValueText: telemetryPresentation.speedText,
  1562. panelTelemetryTone: telemetryPresentation.heartRateTone,
  1563. panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
  1564. panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
  1565. panelHeartRateValueText: telemetryPresentation.heartRateValueText,
  1566. panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
  1567. panelCaloriesValueText: telemetryPresentation.caloriesValueText,
  1568. panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
  1569. panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
  1570. panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
  1571. panelAccuracyValueText: telemetryPresentation.accuracyValueText,
  1572. panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
  1573. panelProgressText: this.gamePresentation.hud.progressText,
  1574. punchButtonText: this.gamePresentation.hud.punchButtonText,
  1575. punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
  1576. skipButtonEnabled: this.isSkipAvailable(),
  1577. punchHintText: this.gamePresentation.hud.punchHintText,
  1578. gpsLockEnabled: this.gpsLockEnabled,
  1579. gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
  1580. }
  1581. if (statusText) {
  1582. patch.statusText = statusText
  1583. }
  1584. return patch
  1585. }
  1586. clearPunchFeedbackTimer(): void {
  1587. if (this.punchFeedbackTimer) {
  1588. clearTimeout(this.punchFeedbackTimer)
  1589. this.punchFeedbackTimer = 0
  1590. }
  1591. }
  1592. clearContentCardTimer(): void {
  1593. if (this.contentCardTimer) {
  1594. clearTimeout(this.contentCardTimer)
  1595. this.contentCardTimer = 0
  1596. }
  1597. }
  1598. getPendingManualContentCount(): number {
  1599. return this.pendingContentCards.filter((item) => !item.autoPopup).length
  1600. }
  1601. buildPendingContentEntryText(): string {
  1602. const count = this.getPendingManualContentCount()
  1603. if (count <= 1) {
  1604. return count === 1 ? '查看内容' : ''
  1605. }
  1606. return `查看内容(${count})`
  1607. }
  1608. syncPendingContentEntryState(immediate = true): void {
  1609. const count = this.getPendingManualContentCount()
  1610. this.setState({
  1611. pendingContentEntryVisible: count > 0,
  1612. pendingContentEntryText: this.buildPendingContentEntryText(),
  1613. }, immediate)
  1614. }
  1615. resolveContentControlByKey(contentKey: string): { control: GameControl; displayMode: 'auto' | 'click' } | null {
  1616. if (!contentKey || !this.gameRuntime.definition) {
  1617. return null
  1618. }
  1619. const isClickContent = contentKey.indexOf(':click') >= 0
  1620. const controlId = isClickContent ? contentKey.replace(/:click$/, '') : contentKey
  1621. const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
  1622. if (!control || !control.displayContent) {
  1623. return null
  1624. }
  1625. return {
  1626. control,
  1627. displayMode: isClickContent ? 'click' : 'auto',
  1628. }
  1629. }
  1630. buildContentH5Request(
  1631. contentKey: string,
  1632. title: string,
  1633. body: string,
  1634. motionClass: string,
  1635. once: boolean,
  1636. priority: number,
  1637. autoPopup: boolean,
  1638. ): H5ExperienceRequest | null {
  1639. const resolved = this.resolveContentControlByKey(contentKey)
  1640. if (!resolved) {
  1641. return null
  1642. }
  1643. const displayContent = resolved.control.displayContent
  1644. if (!displayContent) {
  1645. return null
  1646. }
  1647. const experienceConfig = resolved.displayMode === 'click'
  1648. ? displayContent.clickExperience
  1649. : displayContent.contentExperience
  1650. if (!experienceConfig || experienceConfig.type !== 'h5' || !experienceConfig.url) {
  1651. return null
  1652. }
  1653. return {
  1654. kind: 'content',
  1655. title: title || resolved.control.label || '内容体验',
  1656. url: experienceConfig.url,
  1657. bridgeVersion: experienceConfig.bridge || 'content-v1',
  1658. context: {
  1659. eventId: this.configAppId || '',
  1660. configTitle: this.state.mapName || '',
  1661. configVersion: this.configVersion || '',
  1662. mode: this.gameMode,
  1663. sessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
  1664. controlId: resolved.control.id,
  1665. controlKind: resolved.control.kind,
  1666. controlCode: resolved.control.code,
  1667. controlLabel: resolved.control.label,
  1668. controlSequence: resolved.control.sequence,
  1669. displayMode: resolved.displayMode,
  1670. title,
  1671. body,
  1672. },
  1673. fallback: {
  1674. title,
  1675. body,
  1676. motionClass,
  1677. contentKey,
  1678. once,
  1679. priority,
  1680. autoPopup,
  1681. },
  1682. }
  1683. }
  1684. hasActiveContentExperience(): boolean {
  1685. return this.state.contentCardVisible || this.currentH5ExperienceOpen
  1686. }
  1687. enqueueContentCard(item: ContentCardEntry): void {
  1688. if (item.once && item.contentKey && this.shownContentCardKeys[item.contentKey]) {
  1689. return
  1690. }
  1691. if (item.contentKey && this.pendingContentCards.some((pending) => pending.contentKey === item.contentKey && pending.autoPopup === item.autoPopup)) {
  1692. return
  1693. }
  1694. this.pendingContentCards.push(item)
  1695. this.syncPendingContentEntryState()
  1696. }
  1697. openContentCardEntry(item: ContentCardEntry): void {
  1698. this.clearContentCardTimer()
  1699. if (item.h5Request && this.onOpenH5Experience) {
  1700. this.setState({
  1701. contentCardVisible: false,
  1702. contentCardFxClass: '',
  1703. pendingContentEntryVisible: false,
  1704. pendingContentEntryText: '',
  1705. }, true)
  1706. this.currentContentCardPriority = item.priority
  1707. this.currentContentCard = item
  1708. this.currentH5ExperienceOpen = true
  1709. if (item.once && item.contentKey) {
  1710. this.shownContentCardKeys[item.contentKey] = true
  1711. }
  1712. try {
  1713. this.onOpenH5Experience(item.h5Request)
  1714. return
  1715. } catch {
  1716. this.currentH5ExperienceOpen = false
  1717. this.currentContentCardPriority = 0
  1718. this.currentContentCard = null
  1719. }
  1720. }
  1721. this.setState({
  1722. contentCardVisible: true,
  1723. contentCardTitle: item.title,
  1724. contentCardBody: item.body,
  1725. contentCardFxClass: item.motionClass,
  1726. pendingContentEntryVisible: false,
  1727. pendingContentEntryText: '',
  1728. }, true)
  1729. this.currentContentCardPriority = item.priority
  1730. this.currentContentCard = item
  1731. if (item.once && item.contentKey) {
  1732. this.shownContentCardKeys[item.contentKey] = true
  1733. }
  1734. this.contentCardTimer = setTimeout(() => {
  1735. this.contentCardTimer = 0
  1736. this.currentContentCardPriority = 0
  1737. this.currentContentCard = null
  1738. this.setState({
  1739. contentCardVisible: false,
  1740. contentCardFxClass: '',
  1741. }, true)
  1742. this.flushQueuedContentCards()
  1743. }, 2600) as unknown as number
  1744. }
  1745. flushQueuedContentCards(): void {
  1746. if (this.state.contentCardVisible || !this.pendingContentCards.length) {
  1747. this.syncPendingContentEntryState()
  1748. return
  1749. }
  1750. let candidateIndex = -1
  1751. let candidatePriority = Number.NEGATIVE_INFINITY
  1752. for (let index = 0; index < this.pendingContentCards.length; index += 1) {
  1753. const item = this.pendingContentCards[index]
  1754. if (!item.autoPopup) {
  1755. continue
  1756. }
  1757. if (item.priority > candidatePriority) {
  1758. candidatePriority = item.priority
  1759. candidateIndex = index
  1760. }
  1761. }
  1762. if (candidateIndex < 0) {
  1763. this.syncPendingContentEntryState()
  1764. return
  1765. }
  1766. const nextItem = this.pendingContentCards.splice(candidateIndex, 1)[0]
  1767. this.openContentCardEntry(nextItem)
  1768. }
  1769. clearMapPulseTimer(): void {
  1770. if (this.mapPulseTimer) {
  1771. clearTimeout(this.mapPulseTimer)
  1772. this.mapPulseTimer = 0
  1773. }
  1774. }
  1775. clearStageFxTimer(): void {
  1776. if (this.stageFxTimer) {
  1777. clearTimeout(this.stageFxTimer)
  1778. this.stageFxTimer = 0
  1779. }
  1780. }
  1781. resetTransientGameUiState(): void {
  1782. this.clearPunchFeedbackTimer()
  1783. this.clearContentCardTimer()
  1784. this.clearMapPulseTimer()
  1785. this.clearStageFxTimer()
  1786. this.setState({
  1787. punchFeedbackVisible: false,
  1788. punchFeedbackText: '',
  1789. punchFeedbackTone: 'neutral',
  1790. punchFeedbackFxClass: '',
  1791. contentCardVisible: false,
  1792. contentCardTitle: '',
  1793. contentCardBody: '',
  1794. pendingContentEntryVisible: this.getPendingManualContentCount() > 0,
  1795. pendingContentEntryText: this.buildPendingContentEntryText(),
  1796. contentCardFxClass: '',
  1797. mapPulseVisible: false,
  1798. mapPulseFxClass: '',
  1799. stageFxVisible: false,
  1800. stageFxClass: '',
  1801. punchButtonFxClass: '',
  1802. panelProgressFxClass: '',
  1803. panelDistanceFxClass: '',
  1804. }, true)
  1805. this.currentContentCardPriority = 0
  1806. this.currentContentCard = null
  1807. this.currentH5ExperienceOpen = false
  1808. }
  1809. resetSessionContentExperienceState(): void {
  1810. this.shownContentCardKeys = {}
  1811. this.currentContentCardPriority = 0
  1812. this.currentContentCard = null
  1813. this.pendingContentCards = []
  1814. this.currentH5ExperienceOpen = false
  1815. this.setState({
  1816. pendingContentEntryVisible: false,
  1817. pendingContentEntryText: '',
  1818. })
  1819. }
  1820. clearSessionTimerInterval(): void {
  1821. if (this.sessionTimerInterval) {
  1822. clearInterval(this.sessionTimerInterval)
  1823. this.sessionTimerInterval = 0
  1824. }
  1825. }
  1826. syncSessionTimerText(): void {
  1827. const telemetryPresentation = this.telemetryRuntime.getPresentation()
  1828. this.setState({
  1829. panelTimerText: telemetryPresentation.timerText,
  1830. panelMileageText: telemetryPresentation.mileageText,
  1831. panelActionTagText: this.gamePresentation.hud.actionTagText,
  1832. panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
  1833. panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
  1834. panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
  1835. panelSpeedValueText: telemetryPresentation.speedText,
  1836. panelTelemetryTone: telemetryPresentation.heartRateTone,
  1837. panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
  1838. panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
  1839. panelHeartRateValueText: telemetryPresentation.heartRateValueText,
  1840. panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
  1841. panelCaloriesValueText: telemetryPresentation.caloriesValueText,
  1842. panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
  1843. panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
  1844. panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
  1845. panelAccuracyValueText: telemetryPresentation.accuracyValueText,
  1846. panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
  1847. })
  1848. }
  1849. updateSessionTimerLoop(): void {
  1850. const gameState = this.gameRuntime.state
  1851. const shouldRun = !!gameState && gameState.status === 'running' && gameState.endedAt === null
  1852. this.syncSessionTimerText()
  1853. if (!shouldRun) {
  1854. this.clearSessionTimerInterval()
  1855. return
  1856. }
  1857. if (this.sessionTimerInterval) {
  1858. return
  1859. }
  1860. this.sessionTimerInterval = setInterval(() => {
  1861. this.syncSessionTimerText()
  1862. }, 1000) as unknown as number
  1863. }
  1864. getControlScreenPoint(controlId: string): { x: number; y: number } | null {
  1865. if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
  1866. return null
  1867. }
  1868. const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
  1869. if (!control) {
  1870. return null
  1871. }
  1872. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1873. const screenPoint = worldToScreen({
  1874. centerWorldX: exactCenter.x,
  1875. centerWorldY: exactCenter.y,
  1876. viewportWidth: this.state.stageWidth,
  1877. viewportHeight: this.state.stageHeight,
  1878. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1879. rotationRad: this.getRotationRad(this.state.rotationDeg),
  1880. }, lonLatToWorldTile(control.point, this.state.zoom), false)
  1881. if (screenPoint.x < -80 || screenPoint.x > this.state.stageWidth + 80 || screenPoint.y < -80 || screenPoint.y > this.state.stageHeight + 80) {
  1882. return null
  1883. }
  1884. return screenPoint
  1885. }
  1886. setPunchButtonFxClass(className: string): void {
  1887. this.setState({
  1888. punchButtonFxClass: className,
  1889. }, true)
  1890. }
  1891. setHudProgressFxClass(className: string): void {
  1892. this.setState({
  1893. panelProgressFxClass: className,
  1894. }, true)
  1895. }
  1896. setHudDistanceFxClass(className: string): void {
  1897. this.setState({
  1898. panelDistanceFxClass: className,
  1899. }, true)
  1900. }
  1901. showMapPulse(controlId: string, motionClass = ''): void {
  1902. const screenPoint = this.getControlScreenPoint(controlId)
  1903. if (!screenPoint) {
  1904. return
  1905. }
  1906. this.clearMapPulseTimer()
  1907. this.setState({
  1908. mapPulseVisible: true,
  1909. mapPulseLeftPx: screenPoint.x,
  1910. mapPulseTopPx: screenPoint.y,
  1911. mapPulseFxClass: motionClass,
  1912. }, true)
  1913. this.mapPulseTimer = setTimeout(() => {
  1914. this.mapPulseTimer = 0
  1915. this.setState({
  1916. mapPulseVisible: false,
  1917. mapPulseFxClass: '',
  1918. }, true)
  1919. }, 820) as unknown as number
  1920. }
  1921. showStageFx(className: string): void {
  1922. if (!className) {
  1923. return
  1924. }
  1925. this.clearStageFxTimer()
  1926. this.setState({
  1927. stageFxVisible: true,
  1928. stageFxClass: className,
  1929. }, true)
  1930. this.stageFxTimer = setTimeout(() => {
  1931. this.stageFxTimer = 0
  1932. this.setState({
  1933. stageFxVisible: false,
  1934. stageFxClass: '',
  1935. }, true)
  1936. }, 760) as unknown as number
  1937. }
  1938. showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning', motionClass = ''): void {
  1939. this.clearPunchFeedbackTimer()
  1940. this.setState({
  1941. punchFeedbackVisible: true,
  1942. punchFeedbackText: text,
  1943. punchFeedbackTone: tone,
  1944. punchFeedbackFxClass: motionClass,
  1945. }, true)
  1946. this.punchFeedbackTimer = setTimeout(() => {
  1947. this.punchFeedbackTimer = 0
  1948. this.setState({
  1949. punchFeedbackVisible: false,
  1950. punchFeedbackFxClass: '',
  1951. }, true)
  1952. }, 1400) as unknown as number
  1953. }
  1954. showContentCard(title: string, body: string, motionClass = '', options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }): void {
  1955. const autoPopup = !options || options.autoPopup !== false
  1956. const once = !!(options && options.once)
  1957. const priority = options && typeof options.priority === 'number' ? options.priority : 0
  1958. const contentKey = options && options.contentKey ? options.contentKey : ''
  1959. const entry = {
  1960. title,
  1961. body,
  1962. motionClass,
  1963. contentKey,
  1964. once,
  1965. priority,
  1966. autoPopup,
  1967. h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup),
  1968. }
  1969. if (once && contentKey && this.shownContentCardKeys[contentKey]) {
  1970. return
  1971. }
  1972. if (!autoPopup) {
  1973. this.enqueueContentCard(entry)
  1974. return
  1975. }
  1976. if (this.currentH5ExperienceOpen) {
  1977. this.enqueueContentCard(entry)
  1978. return
  1979. }
  1980. if (this.state.contentCardVisible) {
  1981. if (priority > this.currentContentCardPriority) {
  1982. this.openContentCardEntry(entry)
  1983. return
  1984. }
  1985. this.enqueueContentCard(entry)
  1986. return
  1987. }
  1988. this.openContentCardEntry(entry)
  1989. }
  1990. closeContentCard(): void {
  1991. this.clearContentCardTimer()
  1992. this.currentContentCardPriority = 0
  1993. this.currentContentCard = null
  1994. this.currentH5ExperienceOpen = false
  1995. this.setState({
  1996. contentCardVisible: false,
  1997. contentCardFxClass: '',
  1998. }, true)
  1999. this.flushQueuedContentCards()
  2000. }
  2001. openPendingContentCard(): void {
  2002. if (!this.pendingContentCards.length) {
  2003. return
  2004. }
  2005. let candidateIndex = -1
  2006. let candidatePriority = Number.NEGATIVE_INFINITY
  2007. for (let index = 0; index < this.pendingContentCards.length; index += 1) {
  2008. const item = this.pendingContentCards[index]
  2009. if (item.autoPopup) {
  2010. continue
  2011. }
  2012. if (item.priority > candidatePriority) {
  2013. candidatePriority = item.priority
  2014. candidateIndex = index
  2015. }
  2016. }
  2017. if (candidateIndex < 0) {
  2018. return
  2019. }
  2020. const pending = this.pendingContentCards.splice(candidateIndex, 1)[0]
  2021. this.openContentCardEntry({
  2022. ...pending,
  2023. autoPopup: true,
  2024. })
  2025. }
  2026. handleH5ExperienceClosed(): void {
  2027. this.currentH5ExperienceOpen = false
  2028. this.currentContentCardPriority = 0
  2029. this.currentContentCard = null
  2030. this.flushQueuedContentCards()
  2031. }
  2032. handleH5ExperienceFallback(fallback: H5ExperienceFallbackPayload): void {
  2033. this.currentH5ExperienceOpen = false
  2034. this.currentContentCardPriority = 0
  2035. this.currentContentCard = null
  2036. this.openContentCardEntry({
  2037. ...fallback,
  2038. h5Request: null,
  2039. })
  2040. }
  2041. applyGameEffects(effects: GameEffect[]): string | null {
  2042. this.feedbackDirector.handleEffects(effects)
  2043. if (effects.some((effect) => effect.type === 'session_finished')) {
  2044. if (this.locationController.listening) {
  2045. this.locationController.stop()
  2046. }
  2047. this.setState({
  2048. gpsTracking: false,
  2049. gpsTrackingText: '测试结束,定位已停止',
  2050. }, true)
  2051. }
  2052. this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
  2053. this.updateSessionTimerLoop()
  2054. return this.resolveGameStatusText(effects)
  2055. }
  2056. syncGameResultState(result: GameResult): void {
  2057. this.gamePresentation = result.presentation
  2058. this.refreshCourseHeadingFromPresentation()
  2059. }
  2060. resolveAppliedGameStatusText(result: GameResult, fallbackStatusText?: string | null): string | null {
  2061. return this.applyGameEffects(result.effects) || fallbackStatusText || this.resolveGameStatusText(result.effects)
  2062. }
  2063. commitGameResult(
  2064. result: GameResult,
  2065. fallbackStatusText?: string | null,
  2066. extraPatch: Partial<MapEngineViewState> = {},
  2067. syncRenderer = true,
  2068. ): string | null {
  2069. this.syncGameResultState(result)
  2070. const gameStatusText = this.resolveAppliedGameStatusText(result, fallbackStatusText)
  2071. this.setState({
  2072. ...this.getGameViewPatch(gameStatusText),
  2073. ...extraPatch,
  2074. }, true)
  2075. if (syncRenderer) {
  2076. this.syncRenderer()
  2077. }
  2078. return gameStatusText
  2079. }
  2080. handleStartGame(): void {
  2081. if (!this.gameRuntime.definition || !this.gameRuntime.state) {
  2082. this.setState({
  2083. statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
  2084. }, true)
  2085. return
  2086. }
  2087. if (this.gameRuntime.state.status !== 'idle') {
  2088. if (this.gameRuntime.state.status === 'finished' || this.gameRuntime.state.status === 'failed') {
  2089. const reloadedResult = this.loadGameDefinitionFromCourse()
  2090. if (!reloadedResult || !this.gameRuntime.state) {
  2091. return
  2092. }
  2093. } else {
  2094. return
  2095. }
  2096. }
  2097. this.feedbackDirector.reset()
  2098. this.resetTransientGameUiState()
  2099. this.resetSessionContentExperienceState()
  2100. this.clearStartSessionResidue()
  2101. if (!this.locationController.listening) {
  2102. this.locationController.start()
  2103. }
  2104. const startedAt = Date.now()
  2105. const startResult = this.gameRuntime.startSession(startedAt)
  2106. let gameResult = startResult
  2107. if (this.currentGpsPoint) {
  2108. const gpsResult = this.gameRuntime.dispatch({
  2109. type: 'gps_updated',
  2110. at: Date.now(),
  2111. lon: this.currentGpsPoint.lon,
  2112. lat: this.currentGpsPoint.lat,
  2113. accuracyMeters: this.currentGpsAccuracyMeters,
  2114. })
  2115. gameResult = {
  2116. nextState: gpsResult.nextState,
  2117. presentation: gpsResult.presentation,
  2118. effects: [...startResult.effects, ...gpsResult.effects],
  2119. }
  2120. }
  2121. this.courseOverlayVisible = true
  2122. const gameModeText = this.gameMode === 'score-o' ? '积分赛' : '顺序打点'
  2123. const defaultStatusText = this.currentGpsPoint
  2124. ? `${gameModeText}已开始 (${this.buildVersion})`
  2125. : `${gameModeText}已开始,GPS定位启动中 (${this.buildVersion})`
  2126. this.commitGameResult(gameResult, defaultStatusText)
  2127. }
  2128. handleForceExitGame(): void {
  2129. this.feedbackDirector.reset()
  2130. if (this.locationController.listening) {
  2131. this.locationController.stop()
  2132. }
  2133. if (!this.courseData) {
  2134. this.clearGameRuntime()
  2135. this.resetTransientGameUiState()
  2136. this.resetSessionContentExperienceState()
  2137. this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
  2138. this.setState({
  2139. gpsTracking: false,
  2140. gpsTrackingText: '已退出对局,定位已停止',
  2141. ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
  2142. }, true)
  2143. this.syncRenderer()
  2144. return
  2145. }
  2146. this.loadGameDefinitionFromCourse()
  2147. this.resetTransientGameUiState()
  2148. this.resetSessionContentExperienceState()
  2149. this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
  2150. this.setState({
  2151. gpsTracking: false,
  2152. gpsTrackingText: '已退出对局,定位已停止',
  2153. ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
  2154. }, true)
  2155. this.syncRenderer()
  2156. }
  2157. handlePunchAction(): void {
  2158. const gameResult = this.gameRuntime.dispatch({
  2159. type: 'punch_requested',
  2160. at: Date.now(),
  2161. })
  2162. this.commitGameResult(gameResult)
  2163. }
  2164. handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
  2165. const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
  2166. const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
  2167. if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
  2168. this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
  2169. }
  2170. this.currentGpsPoint = nextPoint
  2171. this.currentGpsAccuracyMeters = accuracyMeters
  2172. this.updateMovementHeadingDeg()
  2173. const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
  2174. const gpsTileX = Math.floor(gpsWorldPoint.x)
  2175. const gpsTileY = Math.floor(gpsWorldPoint.y)
  2176. const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
  2177. this.currentGpsInsideMap = gpsInsideMap
  2178. let gameStatusText: string | null = null
  2179. if (!gpsInsideMap && this.gpsLockEnabled) {
  2180. this.gpsLockEnabled = false
  2181. gameStatusText = `GPS已超出地图范围,锁定已关闭 (${this.buildVersion})`
  2182. }
  2183. if (this.courseData) {
  2184. const eventAt = Date.now()
  2185. const gameResult = this.gameRuntime.dispatch({
  2186. type: 'gps_updated',
  2187. at: eventAt,
  2188. lon: longitude,
  2189. lat: latitude,
  2190. accuracyMeters,
  2191. })
  2192. this.telemetryRuntime.dispatch({
  2193. type: 'gps_updated',
  2194. at: eventAt,
  2195. lon: longitude,
  2196. lat: latitude,
  2197. accuracyMeters,
  2198. })
  2199. this.syncGameResultState(gameResult)
  2200. gameStatusText = this.resolveAppliedGameStatusText(gameResult)
  2201. }
  2202. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  2203. this.scheduleAutoRotate()
  2204. }
  2205. if (gpsInsideMap && (this.gpsLockEnabled || !this.hasGpsCenteredOnce)) {
  2206. this.hasGpsCenteredOnce = true
  2207. const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
  2208. this.commitViewport({
  2209. ...lockedViewport,
  2210. gpsTracking: true,
  2211. gpsTrackingText: '持续定位进行中',
  2212. gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
  2213. autoRotateSourceText: this.getAutoRotateSourceText(),
  2214. gpsLockEnabled: this.gpsLockEnabled,
  2215. gpsLockAvailable: true,
  2216. ...this.getGameViewPatch(),
  2217. }, gameStatusText || (this.gpsLockEnabled ? `GPS锁定跟随中 (${this.buildVersion})` : `GPS定位成功,已定位到当前位置 (${this.buildVersion})`), true)
  2218. return
  2219. }
  2220. this.setState({
  2221. gpsTracking: true,
  2222. gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
  2223. gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
  2224. autoRotateSourceText: this.getAutoRotateSourceText(),
  2225. gpsLockEnabled: this.gpsLockEnabled,
  2226. gpsLockAvailable: gpsInsideMap,
  2227. ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
  2228. })
  2229. this.syncRenderer()
  2230. }
  2231. handleToggleGpsLock(): void {
  2232. if (!this.currentGpsPoint || !this.currentGpsInsideMap) {
  2233. this.setState({
  2234. gpsLockEnabled: false,
  2235. gpsLockAvailable: false,
  2236. statusText: this.currentGpsPoint
  2237. ? `当前位置不在地图范围内,无法锁定 (${this.buildVersion})`
  2238. : `当前还没有可锁定的GPS位置 (${this.buildVersion})`,
  2239. }, true)
  2240. return
  2241. }
  2242. const nextEnabled = !this.gpsLockEnabled
  2243. this.gpsLockEnabled = nextEnabled
  2244. if (nextEnabled) {
  2245. const gpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
  2246. const gpsTileX = Math.floor(gpsWorldPoint.x)
  2247. const gpsTileY = Math.floor(gpsWorldPoint.y)
  2248. const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
  2249. if (gpsInsideMap) {
  2250. this.hasGpsCenteredOnce = true
  2251. const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
  2252. this.commitViewport({
  2253. ...lockedViewport,
  2254. gpsLockEnabled: true,
  2255. gpsLockAvailable: true,
  2256. }, `GPS已锁定在屏幕中央 (${this.buildVersion})`, true)
  2257. return
  2258. }
  2259. this.setState({
  2260. gpsLockEnabled: true,
  2261. gpsLockAvailable: true,
  2262. statusText: `GPS锁定已开启,等待进入地图范围 (${this.buildVersion})`,
  2263. }, true)
  2264. this.syncRenderer()
  2265. return
  2266. }
  2267. this.setState({
  2268. gpsLockEnabled: false,
  2269. gpsLockAvailable: true,
  2270. statusText: `GPS锁定已关闭 (${this.buildVersion})`,
  2271. }, true)
  2272. this.syncRenderer()
  2273. }
  2274. handleToggleOsmReference(): void {
  2275. const nextEnabled = !this.state.osmReferenceEnabled
  2276. this.setState({
  2277. osmReferenceEnabled: nextEnabled,
  2278. osmReferenceText: nextEnabled ? 'OSM参考:开' : 'OSM参考:关',
  2279. statusText: nextEnabled ? `OSM参考底图已开启 (${this.buildVersion})` : `OSM参考底图已关闭 (${this.buildVersion})`,
  2280. }, true)
  2281. this.syncRenderer()
  2282. }
  2283. handleToggleGpsTracking(): void {
  2284. if (this.locationController.listening) {
  2285. this.locationController.stop()
  2286. return
  2287. }
  2288. this.locationController.start()
  2289. }
  2290. handleSetRealLocationMode(): void {
  2291. this.locationController.setSourceMode('real')
  2292. }
  2293. handleSetMockLocationMode(): void {
  2294. this.locationController.setSourceMode('mock')
  2295. }
  2296. handleConnectMockLocationBridge(): void {
  2297. this.locationController.connectMockBridge()
  2298. }
  2299. handleDisconnectMockLocationBridge(): void {
  2300. this.locationController.disconnectMockBridge()
  2301. }
  2302. handleSetMockLocationBridgeUrl(url: string): void {
  2303. this.locationController.setMockBridgeUrl(url)
  2304. }
  2305. handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
  2306. if (this.gameMode === nextMode) {
  2307. return
  2308. }
  2309. this.gameMode = nextMode
  2310. const result = this.loadGameDefinitionFromCourse()
  2311. const modeText = this.getGameModeText()
  2312. if (!result) {
  2313. return
  2314. }
  2315. this.commitGameResult(result, `已切换到${modeText} (${this.buildVersion})`, {
  2316. gameModeText: modeText,
  2317. })
  2318. }
  2319. handleSkipAction(): void {
  2320. const gameResult = this.gameRuntime.dispatch({
  2321. type: 'skip_requested',
  2322. at: Date.now(),
  2323. lon: this.currentGpsPoint ? this.currentGpsPoint.lon : null,
  2324. lat: this.currentGpsPoint ? this.currentGpsPoint.lat : null,
  2325. })
  2326. this.commitGameResult(gameResult)
  2327. }
  2328. handleConnectHeartRate(): void {
  2329. this.heartRateController.startScanAndConnect()
  2330. }
  2331. handleDisconnectHeartRate(): void {
  2332. this.heartRateController.disconnect()
  2333. }
  2334. handleSetRealHeartRateMode(): void {
  2335. this.heartRateController.setSourceMode('real')
  2336. }
  2337. handleSetMockHeartRateMode(): void {
  2338. this.heartRateController.setSourceMode('mock')
  2339. }
  2340. handleConnectMockHeartRateBridge(): void {
  2341. this.heartRateController.connectMockBridge()
  2342. }
  2343. handleDisconnectMockHeartRateBridge(): void {
  2344. this.heartRateController.disconnectMockBridge()
  2345. }
  2346. handleSetMockHeartRateBridgeUrl(url: string): void {
  2347. this.heartRateController.setMockBridgeUrl(url)
  2348. }
  2349. handleConnectHeartRateDevice(deviceId: string): void {
  2350. this.heartRateController.connectToDiscoveredDevice(deviceId)
  2351. }
  2352. handleClearPreferredHeartRateDevice(): void {
  2353. this.heartRateController.clearPreferredDevice()
  2354. this.setState({
  2355. heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
  2356. heartRateScanText: this.getHeartRateScanText(),
  2357. })
  2358. }
  2359. handleDebugHeartRateTone(tone: HeartRateTone): void {
  2360. const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config)
  2361. this.telemetryRuntime.dispatch({
  2362. type: 'heart_rate_updated',
  2363. at: Date.now(),
  2364. bpm: sampleBpm,
  2365. })
  2366. this.setState({
  2367. heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`,
  2368. })
  2369. this.syncSessionTimerText()
  2370. }
  2371. handleClearDebugHeartRate(): void {
  2372. this.telemetryRuntime.dispatch({
  2373. type: 'heart_rate_updated',
  2374. at: Date.now(),
  2375. bpm: null,
  2376. })
  2377. this.setState({
  2378. heartRateStatusText: this.heartRateController.connected
  2379. ? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
  2380. : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'),
  2381. heartRateScanText: this.getHeartRateScanText(),
  2382. ...this.getHeartRateControllerViewPatch(),
  2383. })
  2384. this.syncSessionTimerText()
  2385. }
  2386. formatHeartRateDevices(devices: HeartRateDiscoveredDevice[]): Array<{ deviceId: string; name: string; rssiText: string; preferred: boolean; connected: boolean }> {
  2387. return devices.map((device) => ({
  2388. deviceId: device.deviceId,
  2389. name: device.name,
  2390. rssiText: device.rssi === null ? '--' : `${device.rssi} dBm`,
  2391. preferred: device.isPreferred,
  2392. connected: !!this.heartRateController.currentDeviceId && this.heartRateController.currentDeviceId === device.deviceId && this.heartRateController.connected,
  2393. }))
  2394. }
  2395. getHeartRateScanText(): string {
  2396. if (this.heartRateController.sourceMode === 'mock') {
  2397. if (this.heartRateController.connected) {
  2398. return '模拟源已连接'
  2399. }
  2400. if (this.heartRateController.connecting) {
  2401. return '模拟源连接中'
  2402. }
  2403. return '模拟模式'
  2404. }
  2405. if (this.heartRateController.connected) {
  2406. return '已连接'
  2407. }
  2408. if (this.heartRateController.connecting) {
  2409. return '连接中'
  2410. }
  2411. if (this.heartRateController.disconnecting) {
  2412. return '断开中'
  2413. }
  2414. if (this.heartRateController.scanning) {
  2415. return this.heartRateController.lastDeviceId ? '扫描中(优先首选)' : '扫描中(等待选择)'
  2416. }
  2417. return this.heartRateController.discoveredDevices.length
  2418. ? `已发现 ${this.heartRateController.discoveredDevices.length} 个设备`
  2419. : '未扫描'
  2420. }
  2421. setStage(rect: MapEngineStageRect): void {
  2422. this.previewScale = 1
  2423. this.previewOriginX = rect.width / 2
  2424. this.previewOriginY = rect.height / 2
  2425. this.commitViewport(
  2426. {
  2427. stageWidth: rect.width,
  2428. stageHeight: rect.height,
  2429. stageLeft: rect.left,
  2430. stageTop: rect.top,
  2431. },
  2432. `地图视口已与 WebGL 引擎对齐 (${this.buildVersion})`,
  2433. true,
  2434. )
  2435. }
  2436. attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
  2437. if (this.mounted) {
  2438. return
  2439. }
  2440. this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
  2441. this.mounted = true
  2442. this.state.mapReady = true
  2443. this.state.mapReadyText = 'READY'
  2444. this.onData({
  2445. mapReady: true,
  2446. mapReadyText: 'READY',
  2447. statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
  2448. })
  2449. this.syncRenderer()
  2450. this.accelerometerErrorText = null
  2451. this.lastCompassSampleAt = 0
  2452. this.compassController.start()
  2453. this.scheduleCompassBootstrapRetry()
  2454. this.gyroscopeController.start()
  2455. this.deviceMotionController.start()
  2456. }
  2457. applyRemoteMapConfig(config: RemoteMapConfig): void {
  2458. MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg
  2459. MAGNETIC_DECLINATION_TEXT = normalizeDegreeDisplayText(config.magneticDeclinationText)
  2460. this.minZoom = config.minZoom
  2461. this.maxZoom = config.maxZoom
  2462. this.defaultZoom = config.defaultZoom
  2463. this.defaultCenterTileX = config.initialCenterTileX
  2464. this.defaultCenterTileY = config.initialCenterTileY
  2465. this.tileBoundsByZoom = config.tileBoundsByZoom
  2466. this.courseData = config.course
  2467. this.cpRadiusMeters = config.cpRadiusMeters
  2468. this.configAppId = config.configAppId
  2469. this.configSchemaVersion = config.configSchemaVersion
  2470. this.configVersion = config.configVersion
  2471. this.controlScoreOverrides = config.controlScoreOverrides
  2472. this.controlContentOverrides = config.controlContentOverrides
  2473. this.defaultControlScore = config.defaultControlScore
  2474. this.gameMode = config.gameMode
  2475. this.punchPolicy = config.punchPolicy
  2476. this.punchRadiusMeters = config.punchRadiusMeters
  2477. this.requiresFocusSelection = config.requiresFocusSelection
  2478. this.skipEnabled = config.skipEnabled
  2479. this.skipRadiusMeters = config.skipRadiusMeters
  2480. this.skipRequiresConfirm = config.skipRequiresConfirm
  2481. this.autoFinishOnLastControl = config.autoFinishOnLastControl
  2482. this.telemetryRuntime.configure(config.telemetryConfig)
  2483. this.feedbackDirector.configure({
  2484. audioConfig: config.audioConfig,
  2485. hapticsConfig: config.hapticsConfig,
  2486. uiEffectsConfig: config.uiEffectsConfig,
  2487. })
  2488. const gameResult = this.loadGameDefinitionFromCourse()
  2489. const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null
  2490. const statePatch: Partial<MapEngineViewState> = {
  2491. mapName: config.configTitle,
  2492. configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`,
  2493. projectionMode: config.projectionModeText,
  2494. tileSource: config.tileSource,
  2495. sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
  2496. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  2497. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  2498. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  2499. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
  2500. ...this.getGameViewPatch(gameStatusText),
  2501. }
  2502. if (!this.state.stageWidth || !this.state.stageHeight) {
  2503. this.setState({
  2504. ...statePatch,
  2505. zoom: this.defaultZoom,
  2506. centerTileX: this.defaultCenterTileX,
  2507. centerTileY: this.defaultCenterTileY,
  2508. centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
  2509. statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
  2510. }, true)
  2511. return
  2512. }
  2513. this.commitViewport({
  2514. ...statePatch,
  2515. zoom: this.defaultZoom,
  2516. centerTileX: this.defaultCenterTileX,
  2517. centerTileY: this.defaultCenterTileY,
  2518. tileTranslateX: 0,
  2519. tileTranslateY: 0,
  2520. }, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => {
  2521. this.resetPreviewState()
  2522. this.syncRenderer()
  2523. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  2524. this.scheduleAutoRotate()
  2525. }
  2526. })
  2527. }
  2528. handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
  2529. this.clearInertiaTimer()
  2530. this.clearPreviewResetTimer()
  2531. this.panVelocityX = 0
  2532. this.panVelocityY = 0
  2533. if (event.touches.length >= 2) {
  2534. const origin = this.gpsLockEnabled
  2535. ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
  2536. : this.getStagePoint(event.touches)
  2537. this.gestureMode = 'pinch'
  2538. this.pinchStartDistance = this.getTouchDistance(event.touches)
  2539. this.pinchStartScale = this.previewScale || 1
  2540. this.pinchStartAngle = this.getTouchAngle(event.touches)
  2541. this.pinchStartRotationDeg = this.state.rotationDeg
  2542. const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
  2543. ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
  2544. : screenToWorld(this.getCameraState(), origin, true)
  2545. this.pinchAnchorWorldX = anchorWorld.x
  2546. this.pinchAnchorWorldY = anchorWorld.y
  2547. this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
  2548. this.syncRenderer()
  2549. this.compassController.start()
  2550. return
  2551. }
  2552. if (event.touches.length === 1) {
  2553. this.gestureMode = 'pan'
  2554. this.panLastX = event.touches[0].pageX
  2555. this.panLastY = event.touches[0].pageY
  2556. this.panLastTimestamp = event.timeStamp || Date.now()
  2557. this.tapStartX = event.touches[0].pageX
  2558. this.tapStartY = event.touches[0].pageY
  2559. this.tapStartAt = event.timeStamp || Date.now()
  2560. }
  2561. }
  2562. handleTouchMove(event: WechatMiniprogram.TouchEvent): void {
  2563. if (event.touches.length >= 2) {
  2564. const distance = this.getTouchDistance(event.touches)
  2565. const angle = this.getTouchAngle(event.touches)
  2566. const origin = this.gpsLockEnabled
  2567. ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
  2568. : this.getStagePoint(event.touches)
  2569. if (!this.pinchStartDistance) {
  2570. this.pinchStartDistance = distance
  2571. this.pinchStartScale = this.previewScale || 1
  2572. this.pinchStartAngle = angle
  2573. this.pinchStartRotationDeg = this.state.rotationDeg
  2574. const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
  2575. ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
  2576. : screenToWorld(this.getCameraState(), origin, true)
  2577. this.pinchAnchorWorldX = anchorWorld.x
  2578. this.pinchAnchorWorldY = anchorWorld.y
  2579. }
  2580. this.gestureMode = 'pinch'
  2581. const nextRotationDeg = this.state.orientationMode === 'heading-up'
  2582. ? this.state.rotationDeg
  2583. : normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI)
  2584. const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg)
  2585. const resolvedViewport = this.resolveViewportForExactCenter(
  2586. this.pinchAnchorWorldX - anchorOffset.x,
  2587. this.pinchAnchorWorldY - anchorOffset.y,
  2588. nextRotationDeg,
  2589. )
  2590. this.setPreviewState(
  2591. clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE),
  2592. origin.x,
  2593. origin.y,
  2594. )
  2595. this.commitViewport(
  2596. {
  2597. ...resolvedViewport,
  2598. rotationDeg: nextRotationDeg,
  2599. rotationText: formatRotationText(nextRotationDeg),
  2600. },
  2601. this.state.orientationMode === 'heading-up'
  2602. ? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
  2603. : `双指缩放与旋转中 (${this.buildVersion})`,
  2604. )
  2605. return
  2606. }
  2607. if (this.gestureMode !== 'pan' || event.touches.length !== 1) {
  2608. return
  2609. }
  2610. const touch = event.touches[0]
  2611. const deltaX = touch.pageX - this.panLastX
  2612. const deltaY = touch.pageY - this.panLastY
  2613. const nextTimestamp = event.timeStamp || Date.now()
  2614. const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16)
  2615. const instantVelocityX = deltaX / elapsed
  2616. const instantVelocityY = deltaY / elapsed
  2617. this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28
  2618. this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28
  2619. this.panLastX = touch.pageX
  2620. this.panLastY = touch.pageY
  2621. this.panLastTimestamp = nextTimestamp
  2622. if (this.gpsLockEnabled) {
  2623. this.panVelocityX = 0
  2624. this.panVelocityY = 0
  2625. return
  2626. }
  2627. this.normalizeTranslate(
  2628. this.state.tileTranslateX + deltaX,
  2629. this.state.tileTranslateY + deltaY,
  2630. `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
  2631. )
  2632. }
  2633. handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
  2634. const changedTouch = event.changedTouches && event.changedTouches.length ? event.changedTouches[0] : null
  2635. const endedAsTap = changedTouch
  2636. && this.gestureMode === 'pan'
  2637. && event.touches.length === 0
  2638. && Math.abs(changedTouch.pageX - this.tapStartX) <= MAP_TAP_MOVE_THRESHOLD_PX
  2639. && Math.abs(changedTouch.pageY - this.tapStartY) <= MAP_TAP_MOVE_THRESHOLD_PX
  2640. && ((event.timeStamp || Date.now()) - this.tapStartAt) <= MAP_TAP_DURATION_MS
  2641. if (this.gestureMode === 'pinch' && event.touches.length < 2) {
  2642. const gestureScale = this.previewScale || 1
  2643. const zoomDelta = Math.round(Math.log2(gestureScale))
  2644. const originX = this.gpsLockEnabled ? this.state.stageWidth / 2 : (this.previewOriginX || this.state.stageWidth / 2)
  2645. const originY = this.gpsLockEnabled ? this.state.stageHeight / 2 : (this.previewOriginY || this.state.stageHeight / 2)
  2646. if (zoomDelta) {
  2647. const residualScale = gestureScale / Math.pow(2, zoomDelta)
  2648. this.zoomAroundPoint(zoomDelta, originX, originY, residualScale)
  2649. } else {
  2650. this.animatePreviewToRest()
  2651. }
  2652. this.resetPinchState()
  2653. this.panVelocityX = 0
  2654. this.panVelocityY = 0
  2655. if (event.touches.length === 1) {
  2656. this.gestureMode = 'pan'
  2657. this.panLastX = event.touches[0].pageX
  2658. this.panLastY = event.touches[0].pageY
  2659. this.panLastTimestamp = event.timeStamp || Date.now()
  2660. return
  2661. }
  2662. this.gestureMode = 'idle'
  2663. this.renderer.setAnimationPaused(false)
  2664. this.scheduleAutoRotate()
  2665. return
  2666. }
  2667. if (event.touches.length === 1) {
  2668. this.gestureMode = 'pan'
  2669. this.panLastX = event.touches[0].pageX
  2670. this.panLastY = event.touches[0].pageY
  2671. this.panLastTimestamp = event.timeStamp || Date.now()
  2672. return
  2673. }
  2674. if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) {
  2675. this.startInertia()
  2676. this.gestureMode = 'idle'
  2677. this.resetPinchState()
  2678. return
  2679. }
  2680. if (endedAsTap && changedTouch) {
  2681. this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop)
  2682. }
  2683. this.gestureMode = 'idle'
  2684. this.resetPinchState()
  2685. this.renderer.setAnimationPaused(false)
  2686. this.scheduleAutoRotate()
  2687. }
  2688. handleTouchCancel(): void {
  2689. this.gestureMode = 'idle'
  2690. this.resetPinchState()
  2691. this.panVelocityX = 0
  2692. this.panVelocityY = 0
  2693. this.clearInertiaTimer()
  2694. this.animatePreviewToRest()
  2695. this.renderer.setAnimationPaused(false)
  2696. this.scheduleAutoRotate()
  2697. }
  2698. handleMapTap(stageX: number, stageY: number): void {
  2699. if (!this.gameRuntime.definition || !this.gameRuntime.state) {
  2700. return
  2701. }
  2702. if (this.gameRuntime.definition.mode === 'score-o') {
  2703. const focusedControlId = this.findFocusableControlAt(stageX, stageY)
  2704. if (focusedControlId !== undefined) {
  2705. const gameResult = this.gameRuntime.dispatch({
  2706. type: 'control_focused',
  2707. at: Date.now(),
  2708. controlId: focusedControlId,
  2709. })
  2710. this.commitGameResult(
  2711. gameResult,
  2712. focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
  2713. )
  2714. }
  2715. }
  2716. const contentControlId = this.findContentControlAt(stageX, stageY)
  2717. if (contentControlId) {
  2718. this.openControlClickContent(contentControlId)
  2719. }
  2720. }
  2721. findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
  2722. if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
  2723. return undefined
  2724. }
  2725. const focusableControls = this.gameRuntime.definition.controls.filter((control) => (
  2726. this.gamePresentation.map.focusableControlIds.includes(control.id)
  2727. ))
  2728. let matchedControlId: string | null | undefined
  2729. let matchedDistance = Number.POSITIVE_INFINITY
  2730. const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
  2731. for (const control of focusableControls) {
  2732. const screenPoint = this.getControlScreenPoint(control.id)
  2733. if (!screenPoint) {
  2734. continue
  2735. }
  2736. const distancePx = Math.sqrt(
  2737. Math.pow(screenPoint.x - stageX, 2)
  2738. + Math.pow(screenPoint.y - stageY, 2),
  2739. )
  2740. if (distancePx <= hitRadiusPx && distancePx < matchedDistance) {
  2741. matchedDistance = distancePx
  2742. matchedControlId = control.id
  2743. }
  2744. }
  2745. if (matchedControlId === undefined) {
  2746. return undefined
  2747. }
  2748. return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
  2749. }
  2750. findContentControlAt(stageX: number, stageY: number): string | undefined {
  2751. if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
  2752. return undefined
  2753. }
  2754. let matchedControlId: string | undefined
  2755. let matchedDistance = Number.POSITIVE_INFINITY
  2756. let matchedPriority = Number.NEGATIVE_INFINITY
  2757. const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
  2758. for (const control of this.gameRuntime.definition.controls) {
  2759. if (
  2760. !control.displayContent
  2761. || (
  2762. !control.displayContent.clickTitle
  2763. && !control.displayContent.clickBody
  2764. && !(control.displayContent.clickExperience && control.displayContent.clickExperience.type === 'h5')
  2765. && !(control.displayContent.contentExperience && control.displayContent.contentExperience.type === 'h5')
  2766. )
  2767. ) {
  2768. continue
  2769. }
  2770. if (!this.isControlTapContentVisible(control)) {
  2771. continue
  2772. }
  2773. const screenPoint = this.getControlScreenPoint(control.id)
  2774. if (!screenPoint) {
  2775. continue
  2776. }
  2777. const distancePx = Math.sqrt(
  2778. Math.pow(screenPoint.x - stageX, 2)
  2779. + Math.pow(screenPoint.y - stageY, 2),
  2780. )
  2781. if (distancePx > hitRadiusPx) {
  2782. continue
  2783. }
  2784. const controlPriority = this.getControlTapContentPriority(control)
  2785. const sameDistance = Math.abs(distancePx - matchedDistance) <= 2
  2786. if (
  2787. distancePx < matchedDistance
  2788. || (sameDistance && controlPriority > matchedPriority)
  2789. ) {
  2790. matchedDistance = distancePx
  2791. matchedPriority = controlPriority
  2792. matchedControlId = control.id
  2793. }
  2794. }
  2795. return matchedControlId
  2796. }
  2797. getControlTapContentPriority(control: { kind: 'start' | 'control' | 'finish'; id: string }): number {
  2798. if (!this.gameRuntime.state || !this.gamePresentation.map) {
  2799. return 0
  2800. }
  2801. const currentTargetControlId = this.gameRuntime.state.currentTargetControlId
  2802. const completedControlIds = this.gameRuntime.state.completedControlIds
  2803. if (currentTargetControlId === control.id) {
  2804. return 100
  2805. }
  2806. if (control.kind === 'start') {
  2807. return completedControlIds.includes(control.id) ? 10 : 90
  2808. }
  2809. if (control.kind === 'finish') {
  2810. return completedControlIds.includes(control.id)
  2811. ? 80
  2812. : (this.gamePresentation.map.completedStart ? 85 : 5)
  2813. }
  2814. return completedControlIds.includes(control.id) ? 40 : 60
  2815. }
  2816. isControlTapContentVisible(control: { kind: 'start' | 'control' | 'finish'; sequence: number | null; id: string }): boolean {
  2817. if (this.gamePresentation.map.revealFullCourse) {
  2818. return true
  2819. }
  2820. if (control.kind === 'start') {
  2821. return this.gamePresentation.map.activeStart || this.gamePresentation.map.completedStart
  2822. }
  2823. if (control.kind === 'finish') {
  2824. return this.gamePresentation.map.activeFinish || this.gamePresentation.map.focusedFinish || this.gamePresentation.map.completedFinish
  2825. }
  2826. if (control.sequence === null) {
  2827. return false
  2828. }
  2829. const readyControlSequences = this.resolveReadyControlSequences()
  2830. return this.gamePresentation.map.activeControlSequences.includes(control.sequence)
  2831. || this.gamePresentation.map.completedControlSequences.includes(control.sequence)
  2832. || this.gamePresentation.map.skippedControlSequences.includes(control.sequence)
  2833. || this.gamePresentation.map.focusedControlSequences.includes(control.sequence)
  2834. || readyControlSequences.includes(control.sequence)
  2835. }
  2836. openControlClickContent(controlId: string): void {
  2837. if (!this.gameRuntime.definition) {
  2838. return
  2839. }
  2840. const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
  2841. if (!control || !control.displayContent) {
  2842. return
  2843. }
  2844. const title = control.displayContent.clickTitle || control.displayContent.title || control.label || '内容体验'
  2845. const body = control.displayContent.clickBody || control.displayContent.body || ''
  2846. if (!title && !body) {
  2847. return
  2848. }
  2849. this.showContentCard(title, body, 'game-content-card--fx-pop', {
  2850. contentKey: `${control.id}:click`,
  2851. autoPopup: true,
  2852. once: false,
  2853. priority: control.displayContent.priority,
  2854. })
  2855. }
  2856. getControlHitRadiusPx(): number {
  2857. if (!this.state.tileSizePx) {
  2858. return 28
  2859. }
  2860. const centerLonLat = worldTileToLonLat({ x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5 }, this.state.zoom)
  2861. const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * 40075016.686 / Math.pow(2, this.state.zoom)
  2862. if (!metersPerTile) {
  2863. return 28
  2864. }
  2865. const pixelsPerMeter = this.state.tileSizePx / metersPerTile
  2866. return Math.max(28, this.cpRadiusMeters * pixelsPerMeter * 1.6)
  2867. }
  2868. handleRecenter(): void {
  2869. this.clearInertiaTimer()
  2870. this.clearPreviewResetTimer()
  2871. this.panVelocityX = 0
  2872. this.panVelocityY = 0
  2873. this.renderer.setAnimationPaused(false)
  2874. this.commitViewport(
  2875. {
  2876. zoom: this.defaultZoom,
  2877. centerTileX: this.defaultCenterTileX,
  2878. centerTileY: this.defaultCenterTileY,
  2879. tileTranslateX: 0,
  2880. tileTranslateY: 0,
  2881. },
  2882. `已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
  2883. true,
  2884. () => {
  2885. this.resetPreviewState()
  2886. this.syncRenderer()
  2887. this.compassController.start()
  2888. this.scheduleAutoRotate()
  2889. },
  2890. )
  2891. }
  2892. handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
  2893. if (this.state.rotationMode === 'auto') {
  2894. this.setState({
  2895. statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
  2896. }, true)
  2897. return
  2898. }
  2899. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  2900. const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg)
  2901. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  2902. this.clearInertiaTimer()
  2903. this.clearPreviewResetTimer()
  2904. this.panVelocityX = 0
  2905. this.panVelocityY = 0
  2906. this.renderer.setAnimationPaused(false)
  2907. this.commitViewport(
  2908. {
  2909. ...resolvedViewport,
  2910. rotationDeg: nextRotationDeg,
  2911. rotationText: formatRotationText(nextRotationDeg),
  2912. },
  2913. `旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
  2914. true,
  2915. () => {
  2916. this.resetPreviewState()
  2917. this.syncRenderer()
  2918. this.compassController.start()
  2919. },
  2920. )
  2921. }
  2922. handleRotationReset(): void {
  2923. if (this.state.rotationMode === 'auto') {
  2924. this.setState({
  2925. statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
  2926. }, true)
  2927. return
  2928. }
  2929. const targetRotationDeg = MAP_NORTH_OFFSET_DEG
  2930. if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) {
  2931. return
  2932. }
  2933. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  2934. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg)
  2935. this.clearInertiaTimer()
  2936. this.clearPreviewResetTimer()
  2937. this.panVelocityX = 0
  2938. this.panVelocityY = 0
  2939. this.renderer.setAnimationPaused(false)
  2940. this.commitViewport(
  2941. {
  2942. ...resolvedViewport,
  2943. rotationDeg: targetRotationDeg,
  2944. rotationText: formatRotationText(targetRotationDeg),
  2945. },
  2946. `旋转角度已回到真北参考 (${this.buildVersion})`,
  2947. true,
  2948. () => {
  2949. this.resetPreviewState()
  2950. this.syncRenderer()
  2951. this.compassController.start()
  2952. },
  2953. )
  2954. }
  2955. handleToggleRotationMode(): void {
  2956. if (this.state.orientationMode === 'manual') {
  2957. this.setNorthUpMode()
  2958. return
  2959. }
  2960. if (this.state.orientationMode === 'north-up') {
  2961. this.setHeadingUpMode()
  2962. return
  2963. }
  2964. this.setManualMode()
  2965. }
  2966. handleSetManualMode(): void {
  2967. this.setManualMode()
  2968. }
  2969. handleSetNorthUpMode(): void {
  2970. this.setNorthUpMode()
  2971. }
  2972. handleSetHeadingUpMode(): void {
  2973. this.setHeadingUpMode()
  2974. }
  2975. handleCycleNorthReferenceMode(): void {
  2976. this.cycleNorthReferenceMode()
  2977. }
  2978. handleSetNorthReferenceMode(mode: NorthReferenceMode): void {
  2979. this.setNorthReferenceMode(mode)
  2980. }
  2981. handleSetAnimationLevel(level: AnimationLevel): void {
  2982. if (this.animationLevel === level) {
  2983. return
  2984. }
  2985. this.animationLevel = level
  2986. this.feedbackDirector.setAnimationLevel(level)
  2987. this.setState({
  2988. animationLevel: level,
  2989. statusText: `动画性能已切换为${formatAnimationLevelText(level)} (${this.buildVersion})`,
  2990. })
  2991. this.syncRenderer()
  2992. }
  2993. handleSetCompassTuningProfile(profile: CompassTuningProfile): void {
  2994. if (this.compassTuningProfile === profile) {
  2995. return
  2996. }
  2997. this.compassTuningProfile = profile
  2998. this.compassController.setTuningProfile(profile)
  2999. this.setState({
  3000. compassTuningProfile: profile,
  3001. compassTuningProfileText: formatCompassTuningProfileText(profile),
  3002. statusText: `指北针响应已切换为${formatCompassTuningProfileText(profile)} (${this.buildVersion})`,
  3003. }, true)
  3004. }
  3005. handleAutoRotateCalibrate(): void {
  3006. if (this.state.orientationMode !== 'heading-up') {
  3007. this.setState({
  3008. statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
  3009. }, true)
  3010. return
  3011. }
  3012. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  3013. this.setState({
  3014. statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
  3015. }, true)
  3016. return
  3017. }
  3018. this.setState({
  3019. statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
  3020. }, true)
  3021. this.scheduleAutoRotate()
  3022. }
  3023. setManualMode(): void {
  3024. this.clearAutoRotateTimer()
  3025. this.targetAutoRotationDeg = null
  3026. this.autoRotateCalibrationPending = false
  3027. this.setState({
  3028. rotationMode: 'manual',
  3029. rotationModeText: formatRotationModeText('manual'),
  3030. rotationToggleText: formatRotationToggleText('manual'),
  3031. orientationMode: 'manual',
  3032. orientationModeText: formatOrientationModeText('manual'),
  3033. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  3034. statusText: `已切回手动地图旋转 (${this.buildVersion})`,
  3035. }, true)
  3036. }
  3037. setNorthUpMode(): void {
  3038. this.clearAutoRotateTimer()
  3039. this.targetAutoRotationDeg = null
  3040. this.autoRotateCalibrationPending = false
  3041. const mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG
  3042. this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg
  3043. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  3044. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg)
  3045. this.commitViewport(
  3046. {
  3047. ...resolvedViewport,
  3048. rotationDeg: mapNorthOffsetDeg,
  3049. rotationText: formatRotationText(mapNorthOffsetDeg),
  3050. rotationMode: 'manual',
  3051. rotationModeText: formatRotationModeText('north-up'),
  3052. rotationToggleText: formatRotationToggleText('north-up'),
  3053. orientationMode: 'north-up',
  3054. orientationModeText: formatOrientationModeText('north-up'),
  3055. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
  3056. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  3057. },
  3058. `地图已固定为真北朝上 (${this.buildVersion})`,
  3059. true,
  3060. () => {
  3061. this.resetPreviewState()
  3062. this.syncRenderer()
  3063. },
  3064. )
  3065. }
  3066. setHeadingUpMode(): void {
  3067. this.autoRotateCalibrationPending = false
  3068. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode)
  3069. this.targetAutoRotationDeg = null
  3070. this.setState({
  3071. rotationMode: 'auto',
  3072. rotationModeText: formatRotationModeText('heading-up'),
  3073. rotationToggleText: formatRotationToggleText('heading-up'),
  3074. orientationMode: 'heading-up',
  3075. orientationModeText: formatOrientationModeText('heading-up'),
  3076. autoRotateSourceText: this.getAutoRotateSourceText(),
  3077. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  3078. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  3079. statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
  3080. }, true)
  3081. if (this.refreshAutoRotateTarget()) {
  3082. this.scheduleAutoRotate()
  3083. }
  3084. }
  3085. applyHeadingSample(headingDeg: number, source: 'compass' | 'motion'): void {
  3086. this.compassSource = source
  3087. this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
  3088. this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
  3089. ? this.sensorHeadingDeg
  3090. : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
  3091. const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  3092. if (this.compassDisplayHeadingDeg === null) {
  3093. this.compassDisplayHeadingDeg = compassHeadingDeg
  3094. this.targetCompassDisplayHeadingDeg = compassHeadingDeg
  3095. this.syncCompassDisplayState()
  3096. } else {
  3097. this.targetCompassDisplayHeadingDeg = compassHeadingDeg
  3098. const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg))
  3099. if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) {
  3100. this.scheduleCompassNeedleFollow()
  3101. }
  3102. }
  3103. this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  3104. this.setState({
  3105. compassSourceText: formatCompassSourceText(this.compassSource),
  3106. ...(this.diagnosticUiEnabled
  3107. ? {
  3108. ...this.getTelemetrySensorViewPatch(),
  3109. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  3110. autoRotateSourceText: this.getAutoRotateSourceText(),
  3111. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  3112. }
  3113. : {}),
  3114. })
  3115. if (!this.refreshAutoRotateTarget()) {
  3116. return
  3117. }
  3118. if (this.state.orientationMode === 'heading-up') {
  3119. this.scheduleAutoRotate()
  3120. }
  3121. }
  3122. handleCompassHeading(headingDeg: number): void {
  3123. this.lastCompassSampleAt = Date.now()
  3124. this.clearCompassBootstrapRetryTimer()
  3125. this.applyHeadingSample(headingDeg, 'compass')
  3126. }
  3127. handleCompassError(message: string): void {
  3128. this.clearAutoRotateTimer()
  3129. this.clearCompassNeedleTimer()
  3130. this.targetAutoRotationDeg = null
  3131. this.autoRotateCalibrationPending = false
  3132. this.compassSource = null
  3133. this.targetCompassDisplayHeadingDeg = null
  3134. this.setState({
  3135. compassSourceText: formatCompassSourceText(null),
  3136. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  3137. statusText: `${message} (${this.buildVersion})`,
  3138. }, true)
  3139. }
  3140. cycleNorthReferenceMode(): void {
  3141. this.setNorthReferenceMode(getNextNorthReferenceMode(this.northReferenceMode))
  3142. }
  3143. setNorthReferenceMode(nextMode: NorthReferenceMode): void {
  3144. if (nextMode === this.northReferenceMode) {
  3145. return
  3146. }
  3147. const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
  3148. const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
  3149. ? null
  3150. : getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
  3151. this.northReferenceMode = nextMode
  3152. this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
  3153. this.compassDisplayHeadingDeg = compassHeadingDeg
  3154. this.targetCompassDisplayHeadingDeg = compassHeadingDeg
  3155. if (this.state.orientationMode === 'north-up') {
  3156. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  3157. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG)
  3158. this.commitViewport(
  3159. {
  3160. ...resolvedViewport,
  3161. rotationDeg: MAP_NORTH_OFFSET_DEG,
  3162. rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
  3163. northReferenceText: formatNorthReferenceText(nextMode),
  3164. sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
  3165. ...this.getTelemetrySensorViewPatch(),
  3166. compassDeclinationText: formatCompassDeclinationText(nextMode),
  3167. northReferenceMode: nextMode,
  3168. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  3169. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
  3170. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  3171. },
  3172. `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  3173. true,
  3174. () => {
  3175. this.resetPreviewState()
  3176. this.syncRenderer()
  3177. },
  3178. )
  3179. return
  3180. }
  3181. this.setState({
  3182. northReferenceText: formatNorthReferenceText(nextMode),
  3183. sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
  3184. ...this.getTelemetrySensorViewPatch(),
  3185. compassDeclinationText: formatCompassDeclinationText(nextMode),
  3186. northReferenceMode: nextMode,
  3187. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  3188. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
  3189. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  3190. statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  3191. }, true)
  3192. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  3193. this.scheduleAutoRotate()
  3194. }
  3195. if (this.compassDisplayHeadingDeg !== null) {
  3196. this.syncCompassDisplayState()
  3197. }
  3198. }
  3199. setCourseHeading(headingDeg: number | null): void {
  3200. this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
  3201. this.setState({
  3202. autoRotateSourceText: this.getAutoRotateSourceText(),
  3203. })
  3204. if (this.refreshAutoRotateTarget()) {
  3205. this.scheduleAutoRotate()
  3206. }
  3207. }
  3208. getRawMovementHeadingDeg(): number | null {
  3209. if (!this.currentGpsInsideMap) {
  3210. return null
  3211. }
  3212. if (this.currentGpsAccuracyMeters !== null && this.currentGpsAccuracyMeters > SMART_HEADING_MAX_ACCURACY_METERS) {
  3213. return null
  3214. }
  3215. if (this.currentGpsTrack.length < 2) {
  3216. return null
  3217. }
  3218. const lastPoint = this.currentGpsTrack[this.currentGpsTrack.length - 1]
  3219. let accumulatedDistanceMeters = 0
  3220. for (let index = this.currentGpsTrack.length - 2; index >= 0; index -= 1) {
  3221. const nextPoint = this.currentGpsTrack[index + 1]
  3222. const point = this.currentGpsTrack[index]
  3223. accumulatedDistanceMeters += getApproxDistanceMeters(point, nextPoint)
  3224. if (accumulatedDistanceMeters >= SMART_HEADING_MIN_DISTANCE_METERS) {
  3225. return getInitialBearingDeg(point, lastPoint)
  3226. }
  3227. }
  3228. return null
  3229. }
  3230. updateMovementHeadingDeg(): void {
  3231. const rawMovementHeadingDeg = this.getRawMovementHeadingDeg()
  3232. if (rawMovementHeadingDeg === null) {
  3233. this.smoothedMovementHeadingDeg = null
  3234. return
  3235. }
  3236. const smoothingFactor = getMovementHeadingSmoothingFactor(this.telemetryRuntime.state.currentSpeedKmh)
  3237. this.smoothedMovementHeadingDeg = this.smoothedMovementHeadingDeg === null
  3238. ? rawMovementHeadingDeg
  3239. : interpolateAngleDeg(this.smoothedMovementHeadingDeg, rawMovementHeadingDeg, smoothingFactor)
  3240. }
  3241. getMovementHeadingDeg(): number | null {
  3242. return this.smoothedMovementHeadingDeg
  3243. }
  3244. getPreferredSensorHeadingDeg(): number | null {
  3245. return this.smoothedSensorHeadingDeg === null
  3246. ? null
  3247. : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  3248. }
  3249. getSmartAutoRotateHeadingDeg(): number | null {
  3250. const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
  3251. const movementHeadingDeg = this.getMovementHeadingDeg()
  3252. const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
  3253. const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
  3254. if (smartSource === 'movement') {
  3255. return movementHeadingDeg === null ? sensorHeadingDeg : movementHeadingDeg
  3256. }
  3257. if (smartSource === 'blended' && sensorHeadingDeg !== null && movementHeadingDeg !== null && speedKmh !== null) {
  3258. const blend = Math.max(0, Math.min(1, (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH) / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)))
  3259. return interpolateAngleDeg(sensorHeadingDeg, movementHeadingDeg, blend)
  3260. }
  3261. return sensorHeadingDeg === null ? movementHeadingDeg : sensorHeadingDeg
  3262. }
  3263. getAutoRotateSourceText(): string {
  3264. if (this.autoRotateSourceMode !== 'smart') {
  3265. return formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null)
  3266. }
  3267. const smartSource = resolveSmartHeadingSource(
  3268. this.telemetryRuntime.state.currentSpeedKmh,
  3269. this.getMovementHeadingDeg() !== null,
  3270. )
  3271. return formatSmartHeadingSourceText(smartSource)
  3272. }
  3273. resolveAutoRotateInputHeadingDeg(): number | null {
  3274. if (this.autoRotateSourceMode === 'smart') {
  3275. return this.getSmartAutoRotateHeadingDeg()
  3276. }
  3277. const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
  3278. const courseHeadingDeg = this.courseHeadingDeg === null
  3279. ? null
  3280. : getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
  3281. if (this.autoRotateSourceMode === 'sensor') {
  3282. return sensorHeadingDeg
  3283. }
  3284. if (this.autoRotateSourceMode === 'course') {
  3285. return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg
  3286. }
  3287. if (sensorHeadingDeg !== null && courseHeadingDeg !== null) {
  3288. return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35)
  3289. }
  3290. return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg
  3291. }
  3292. calibrateAutoRotateToCurrentOrientation(): boolean {
  3293. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  3294. if (inputHeadingDeg === null) {
  3295. return false
  3296. }
  3297. this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg)
  3298. this.autoRotateCalibrationPending = false
  3299. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  3300. this.setState({
  3301. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  3302. })
  3303. return true
  3304. }
  3305. refreshAutoRotateTarget(): boolean {
  3306. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  3307. if (inputHeadingDeg === null) {
  3308. return false
  3309. }
  3310. if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) {
  3311. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  3312. return false
  3313. }
  3314. return true
  3315. }
  3316. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  3317. this.setState({
  3318. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  3319. })
  3320. return true
  3321. }
  3322. scheduleAutoRotate(): void {
  3323. if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  3324. return
  3325. }
  3326. const step = () => {
  3327. this.autoRotateTimer = 0
  3328. if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  3329. return
  3330. }
  3331. if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) {
  3332. this.scheduleAutoRotate()
  3333. return
  3334. }
  3335. const currentRotationDeg = this.state.rotationDeg
  3336. const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg)
  3337. if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) {
  3338. if (Math.abs(deltaDeg) > 0.01) {
  3339. this.applyAutoRotation(this.targetAutoRotationDeg)
  3340. }
  3341. this.scheduleAutoRotate()
  3342. return
  3343. }
  3344. if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) {
  3345. this.scheduleAutoRotate()
  3346. return
  3347. }
  3348. const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG)
  3349. this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg))
  3350. this.scheduleAutoRotate()
  3351. }
  3352. this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number
  3353. }
  3354. applyAutoRotation(nextRotationDeg: number): void {
  3355. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  3356. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  3357. this.setState({
  3358. ...resolvedViewport,
  3359. rotationDeg: nextRotationDeg,
  3360. rotationText: formatRotationText(nextRotationDeg),
  3361. centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY),
  3362. })
  3363. this.syncRenderer()
  3364. }
  3365. applyStats(stats: MapRendererStats): void {
  3366. const statsPatch = {
  3367. visibleTileCount: stats.visibleTileCount,
  3368. readyTileCount: stats.readyTileCount,
  3369. memoryTileCount: stats.memoryTileCount,
  3370. diskTileCount: stats.diskTileCount,
  3371. memoryHitCount: stats.memoryHitCount,
  3372. diskHitCount: stats.diskHitCount,
  3373. networkFetchCount: stats.networkFetchCount,
  3374. cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount),
  3375. }
  3376. if (!this.diagnosticUiEnabled) {
  3377. this.state = {
  3378. ...this.state,
  3379. ...statsPatch,
  3380. }
  3381. return
  3382. }
  3383. const now = Date.now()
  3384. if (now - this.lastStatsUiSyncAt < 500) {
  3385. this.state = {
  3386. ...this.state,
  3387. ...statsPatch,
  3388. }
  3389. return
  3390. }
  3391. this.lastStatsUiSyncAt = now
  3392. this.setState(statsPatch)
  3393. }
  3394. setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {
  3395. this.state = {
  3396. ...this.state,
  3397. ...patch,
  3398. }
  3399. const viewPatch = this.pickViewPatch(patch)
  3400. if (!Object.keys(viewPatch).length) {
  3401. return
  3402. }
  3403. this.pendingViewPatch = {
  3404. ...this.pendingViewPatch,
  3405. ...viewPatch,
  3406. }
  3407. if (immediateUi) {
  3408. this.flushViewPatch()
  3409. return
  3410. }
  3411. if (this.viewSyncTimer) {
  3412. return
  3413. }
  3414. this.viewSyncTimer = setTimeout(() => {
  3415. this.viewSyncTimer = 0
  3416. this.flushViewPatch()
  3417. }, UI_SYNC_INTERVAL_MS) as unknown as number
  3418. }
  3419. commitViewport(
  3420. patch: Partial<MapEngineViewState>,
  3421. statusText: string,
  3422. immediateUi = false,
  3423. afterUpdate?: () => void,
  3424. ): void {
  3425. const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom
  3426. const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX
  3427. const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY
  3428. const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth
  3429. const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight
  3430. const tileSizePx = getTileSizePx({
  3431. centerWorldX: nextCenterTileX,
  3432. centerWorldY: nextCenterTileY,
  3433. viewportWidth: nextStageWidth,
  3434. viewportHeight: nextStageHeight,
  3435. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  3436. })
  3437. this.setState({
  3438. ...patch,
  3439. tileSizePx,
  3440. centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY),
  3441. statusText,
  3442. }, immediateUi)
  3443. this.syncRenderer()
  3444. this.compassController.start()
  3445. if (afterUpdate) {
  3446. afterUpdate()
  3447. }
  3448. }
  3449. buildScene() {
  3450. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  3451. const readyControlSequences = this.resolveReadyControlSequences()
  3452. return {
  3453. tileSource: this.state.tileSource,
  3454. osmTileSource: OSM_TILE_SOURCE,
  3455. zoom: this.state.zoom,
  3456. centerTileX: this.state.centerTileX,
  3457. centerTileY: this.state.centerTileY,
  3458. exactCenterWorldX: exactCenter.x,
  3459. exactCenterWorldY: exactCenter.y,
  3460. tileBoundsByZoom: this.tileBoundsByZoom,
  3461. viewportWidth: this.state.stageWidth,
  3462. viewportHeight: this.state.stageHeight,
  3463. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  3464. overdraw: OVERDRAW,
  3465. translateX: this.state.tileTranslateX,
  3466. translateY: this.state.tileTranslateY,
  3467. rotationRad: this.getRotationRad(this.state.rotationDeg),
  3468. animationLevel: this.state.animationLevel,
  3469. previewScale: this.previewScale || 1,
  3470. previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
  3471. previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
  3472. track: this.currentGpsTrack,
  3473. gpsPoint: this.currentGpsPoint,
  3474. gpsCalibration: GPS_MAP_CALIBRATION,
  3475. gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
  3476. course: this.courseOverlayVisible ? this.courseData : null,
  3477. cpRadiusMeters: this.cpRadiusMeters,
  3478. controlVisualMode: this.gamePresentation.map.controlVisualMode,
  3479. showCourseLegs: this.gamePresentation.map.showCourseLegs,
  3480. guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled,
  3481. focusableControlIds: this.gamePresentation.map.focusableControlIds,
  3482. focusedControlId: this.gamePresentation.map.focusedControlId,
  3483. focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
  3484. activeControlSequences: this.gamePresentation.map.activeControlSequences,
  3485. readyControlSequences,
  3486. activeStart: this.gamePresentation.map.activeStart,
  3487. completedStart: this.gamePresentation.map.completedStart,
  3488. activeFinish: this.gamePresentation.map.activeFinish,
  3489. focusedFinish: this.gamePresentation.map.focusedFinish,
  3490. completedFinish: this.gamePresentation.map.completedFinish,
  3491. revealFullCourse: this.gamePresentation.map.revealFullCourse,
  3492. activeLegIndices: this.gamePresentation.map.activeLegIndices,
  3493. completedLegIndices: this.gamePresentation.map.completedLegIndices,
  3494. completedControlSequences: this.gamePresentation.map.completedControlSequences,
  3495. skippedControlIds: this.gamePresentation.map.skippedControlIds,
  3496. skippedControlSequences: this.gamePresentation.map.skippedControlSequences,
  3497. osmReferenceEnabled: this.state.osmReferenceEnabled,
  3498. overlayOpacity: MAP_OVERLAY_OPACITY,
  3499. }
  3500. }
  3501. resolveReadyControlSequences(): number[] {
  3502. const punchableControlId = this.gamePresentation.hud.punchableControlId
  3503. const definition = this.gameRuntime.definition
  3504. if (!punchableControlId || !definition) {
  3505. return []
  3506. }
  3507. const control = definition.controls.find((item) => item.id === punchableControlId)
  3508. if (!control || control.sequence === null) {
  3509. return []
  3510. }
  3511. return [control.sequence]
  3512. }
  3513. syncRenderer(): void {
  3514. if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
  3515. return
  3516. }
  3517. this.renderer.updateScene(this.buildScene())
  3518. }
  3519. getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
  3520. return {
  3521. centerWorldX: this.state.centerTileX + 0.5,
  3522. centerWorldY: this.state.centerTileY + 0.5,
  3523. viewportWidth: this.state.stageWidth,
  3524. viewportHeight: this.state.stageHeight,
  3525. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  3526. translateX: this.state.tileTranslateX,
  3527. translateY: this.state.tileTranslateY,
  3528. rotationRad: this.getRotationRad(rotationDeg),
  3529. }
  3530. }
  3531. getRotationRad(rotationDeg = this.state.rotationDeg): number {
  3532. return normalizeRotationDeg(rotationDeg) * Math.PI / 180
  3533. }
  3534. getBaseCamera(centerTileX = this.state.centerTileX, centerTileY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
  3535. return {
  3536. centerWorldX: centerTileX + 0.5,
  3537. centerWorldY: centerTileY + 0.5,
  3538. viewportWidth: this.state.stageWidth,
  3539. viewportHeight: this.state.stageHeight,
  3540. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  3541. rotationRad: this.getRotationRad(rotationDeg),
  3542. }
  3543. }
  3544. getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } {
  3545. const baseCamera = {
  3546. centerWorldX: 0,
  3547. centerWorldY: 0,
  3548. viewportWidth: this.state.stageWidth,
  3549. viewportHeight: this.state.stageHeight,
  3550. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  3551. rotationRad: this.getRotationRad(rotationDeg),
  3552. }
  3553. return screenToWorld(baseCamera, { x: stageX, y: stageY }, false)
  3554. }
  3555. getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
  3556. if (!this.state.stageWidth || !this.state.stageHeight) {
  3557. return {
  3558. x: this.state.centerTileX + 0.5,
  3559. y: this.state.centerTileY + 0.5,
  3560. }
  3561. }
  3562. const screenCenterX = this.state.stageWidth / 2
  3563. const screenCenterY = this.state.stageHeight / 2
  3564. return screenToWorld(this.getBaseCamera(), {
  3565. x: screenCenterX - translateX,
  3566. y: screenCenterY - translateY,
  3567. }, false)
  3568. }
  3569. resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): {
  3570. centerTileX: number
  3571. centerTileY: number
  3572. tileTranslateX: number
  3573. tileTranslateY: number
  3574. } {
  3575. const nextCenterTileX = Math.floor(centerWorldX)
  3576. const nextCenterTileY = Math.floor(centerWorldY)
  3577. if (!this.state.stageWidth || !this.state.stageHeight) {
  3578. return {
  3579. centerTileX: nextCenterTileX,
  3580. centerTileY: nextCenterTileY,
  3581. tileTranslateX: 0,
  3582. tileTranslateY: 0,
  3583. }
  3584. }
  3585. const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg)
  3586. const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false)
  3587. return {
  3588. centerTileX: nextCenterTileX,
  3589. centerTileY: nextCenterTileY,
  3590. tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x,
  3591. tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y,
  3592. }
  3593. }
  3594. setPreviewState(scale: number, originX: number, originY: number): void {
  3595. this.previewScale = scale
  3596. this.previewOriginX = originX
  3597. this.previewOriginY = originY
  3598. this.setState({
  3599. previewScale: scale,
  3600. }, true)
  3601. }
  3602. resetPreviewState(): void {
  3603. this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2)
  3604. }
  3605. resetPinchState(): void {
  3606. this.pinchStartDistance = 0
  3607. this.pinchStartScale = 1
  3608. this.pinchStartAngle = 0
  3609. this.pinchStartRotationDeg = this.state.rotationDeg
  3610. this.pinchAnchorWorldX = 0
  3611. this.pinchAnchorWorldY = 0
  3612. }
  3613. clearPreviewResetTimer(): void {
  3614. if (this.previewResetTimer) {
  3615. clearTimeout(this.previewResetTimer)
  3616. this.previewResetTimer = 0
  3617. }
  3618. }
  3619. clearInertiaTimer(): void {
  3620. if (this.inertiaTimer) {
  3621. clearTimeout(this.inertiaTimer)
  3622. this.inertiaTimer = 0
  3623. }
  3624. }
  3625. clearViewSyncTimer(): void {
  3626. if (this.viewSyncTimer) {
  3627. clearTimeout(this.viewSyncTimer)
  3628. this.viewSyncTimer = 0
  3629. }
  3630. }
  3631. clearAutoRotateTimer(): void {
  3632. if (this.autoRotateTimer) {
  3633. clearTimeout(this.autoRotateTimer)
  3634. this.autoRotateTimer = 0
  3635. }
  3636. }
  3637. clearCompassNeedleTimer(): void {
  3638. if (this.compassNeedleTimer) {
  3639. clearTimeout(this.compassNeedleTimer)
  3640. this.compassNeedleTimer = 0
  3641. }
  3642. }
  3643. clearCompassBootstrapRetryTimer(): void {
  3644. if (this.compassBootstrapRetryTimer) {
  3645. clearTimeout(this.compassBootstrapRetryTimer)
  3646. this.compassBootstrapRetryTimer = 0
  3647. }
  3648. }
  3649. scheduleCompassBootstrapRetry(): void {
  3650. this.clearCompassBootstrapRetryTimer()
  3651. if (!this.mounted) {
  3652. return
  3653. }
  3654. this.compassBootstrapRetryTimer = setTimeout(() => {
  3655. this.compassBootstrapRetryTimer = 0
  3656. if (!this.mounted || this.lastCompassSampleAt > 0) {
  3657. return
  3658. }
  3659. this.compassController.stop()
  3660. this.compassController.start()
  3661. }, COMPASS_BOOTSTRAP_RETRY_DELAY_MS) as unknown as number
  3662. }
  3663. syncCompassDisplayState(): void {
  3664. this.setState({
  3665. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
  3666. sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
  3667. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  3668. ...(this.diagnosticUiEnabled
  3669. ? {
  3670. ...this.getTelemetrySensorViewPatch(),
  3671. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  3672. autoRotateSourceText: this.getAutoRotateSourceText(),
  3673. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  3674. }
  3675. : {}),
  3676. })
  3677. }
  3678. scheduleCompassNeedleFollow(): void {
  3679. if (
  3680. this.compassNeedleTimer
  3681. || this.targetCompassDisplayHeadingDeg === null
  3682. || this.compassDisplayHeadingDeg === null
  3683. ) {
  3684. return
  3685. }
  3686. const step = () => {
  3687. this.compassNeedleTimer = 0
  3688. if (
  3689. this.targetCompassDisplayHeadingDeg === null
  3690. || this.compassDisplayHeadingDeg === null
  3691. ) {
  3692. return
  3693. }
  3694. const deltaDeg = normalizeAngleDeltaDeg(
  3695. this.targetCompassDisplayHeadingDeg - this.compassDisplayHeadingDeg,
  3696. )
  3697. const absDeltaDeg = Math.abs(deltaDeg)
  3698. if (absDeltaDeg <= COMPASS_NEEDLE_SNAP_DEG) {
  3699. if (absDeltaDeg > 0.001) {
  3700. this.compassDisplayHeadingDeg = this.targetCompassDisplayHeadingDeg
  3701. this.syncCompassDisplayState()
  3702. }
  3703. return
  3704. }
  3705. this.compassDisplayHeadingDeg = interpolateAngleDeg(
  3706. this.compassDisplayHeadingDeg,
  3707. this.targetCompassDisplayHeadingDeg,
  3708. getCompassNeedleSmoothingFactor(
  3709. this.compassDisplayHeadingDeg,
  3710. this.targetCompassDisplayHeadingDeg,
  3711. this.compassTuningProfile,
  3712. ),
  3713. )
  3714. this.syncCompassDisplayState()
  3715. this.scheduleCompassNeedleFollow()
  3716. }
  3717. this.compassNeedleTimer = setTimeout(step, COMPASS_NEEDLE_FRAME_MS) as unknown as number
  3718. }
  3719. pickViewPatch(patch: Partial<MapEngineViewState>): Partial<MapEngineViewState> {
  3720. const viewPatch = {} as Partial<MapEngineViewState>
  3721. for (const key of VIEW_SYNC_KEYS) {
  3722. if (Object.prototype.hasOwnProperty.call(patch, key)) {
  3723. ;(viewPatch as any)[key] = patch[key]
  3724. }
  3725. }
  3726. return viewPatch
  3727. }
  3728. flushViewPatch(): void {
  3729. if (!Object.keys(this.pendingViewPatch).length) {
  3730. return
  3731. }
  3732. const patch = this.pendingViewPatch
  3733. const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer
  3734. const nextPendingPatch = {} as Partial<MapEngineViewState>
  3735. const outputPatch = {} as Partial<MapEngineViewState>
  3736. for (const [key, value] of Object.entries(patch) as Array<[keyof MapEngineViewState, MapEngineViewState[keyof MapEngineViewState]]>) {
  3737. if (shouldDeferForInteraction && INTERACTION_DEFERRED_VIEW_KEYS.has(key)) {
  3738. ;(nextPendingPatch as Record<string, unknown>)[key] = value
  3739. continue
  3740. }
  3741. ;(outputPatch as Record<string, unknown>)[key] = value
  3742. }
  3743. this.pendingViewPatch = nextPendingPatch
  3744. if (Object.keys(this.pendingViewPatch).length && !this.viewSyncTimer) {
  3745. this.viewSyncTimer = setTimeout(() => {
  3746. this.viewSyncTimer = 0
  3747. this.flushViewPatch()
  3748. }, UI_SYNC_INTERVAL_MS) as unknown as number
  3749. }
  3750. if (!Object.keys(outputPatch).length) {
  3751. return
  3752. }
  3753. this.onData(outputPatch)
  3754. }
  3755. getTouchDistance(touches: TouchPoint[]): number {
  3756. if (touches.length < 2) {
  3757. return 0
  3758. }
  3759. const first = touches[0]
  3760. const second = touches[1]
  3761. const deltaX = first.pageX - second.pageX
  3762. const deltaY = first.pageY - second.pageY
  3763. return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
  3764. }
  3765. getTouchAngle(touches: TouchPoint[]): number {
  3766. if (touches.length < 2) {
  3767. return 0
  3768. }
  3769. const first = touches[0]
  3770. const second = touches[1]
  3771. return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX)
  3772. }
  3773. getStagePoint(touches: TouchPoint[]): { x: number; y: number } {
  3774. if (!touches.length) {
  3775. return {
  3776. x: this.state.stageWidth / 2,
  3777. y: this.state.stageHeight / 2,
  3778. }
  3779. }
  3780. let pageX = 0
  3781. let pageY = 0
  3782. for (const touch of touches) {
  3783. pageX += touch.pageX
  3784. pageY += touch.pageY
  3785. }
  3786. return {
  3787. x: pageX / touches.length - this.state.stageLeft,
  3788. y: pageY / touches.length - this.state.stageTop,
  3789. }
  3790. }
  3791. animatePreviewToRest(): void {
  3792. this.clearPreviewResetTimer()
  3793. const startScale = this.previewScale || 1
  3794. const originX = this.previewOriginX || this.state.stageWidth / 2
  3795. const originY = this.previewOriginY || this.state.stageHeight / 2
  3796. if (Math.abs(startScale - 1) < 0.01) {
  3797. this.resetPreviewState()
  3798. this.syncRenderer()
  3799. this.compassController.start()
  3800. this.scheduleAutoRotate()
  3801. return
  3802. }
  3803. const startAt = Date.now()
  3804. const step = () => {
  3805. const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS)
  3806. const eased = 1 - Math.pow(1 - progress, 3)
  3807. const nextScale = startScale + (1 - startScale) * eased
  3808. this.setPreviewState(nextScale, originX, originY)
  3809. this.syncRenderer()
  3810. this.compassController.start()
  3811. if (progress >= 1) {
  3812. this.resetPreviewState()
  3813. this.syncRenderer()
  3814. this.compassController.start()
  3815. this.previewResetTimer = 0
  3816. this.scheduleAutoRotate()
  3817. return
  3818. }
  3819. this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  3820. }
  3821. step()
  3822. }
  3823. normalizeTranslate(translateX: number, translateY: number, statusText: string): void {
  3824. if (!this.state.stageWidth) {
  3825. this.setState({
  3826. tileTranslateX: translateX,
  3827. tileTranslateY: translateY,
  3828. })
  3829. this.syncRenderer()
  3830. this.compassController.start()
  3831. return
  3832. }
  3833. const exactCenter = this.getExactCenterFromTranslate(translateX, translateY)
  3834. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y)
  3835. const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY
  3836. if (centerChanged) {
  3837. this.commitViewport(resolvedViewport, statusText)
  3838. return
  3839. }
  3840. this.setState({
  3841. tileTranslateX: resolvedViewport.tileTranslateX,
  3842. tileTranslateY: resolvedViewport.tileTranslateY,
  3843. })
  3844. this.syncRenderer()
  3845. this.compassController.start()
  3846. }
  3847. zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
  3848. const nextZoom = clamp(this.state.zoom + zoomDelta, this.minZoom, this.maxZoom)
  3849. const appliedDelta = nextZoom - this.state.zoom
  3850. if (!appliedDelta) {
  3851. this.animatePreviewToRest()
  3852. return
  3853. }
  3854. if (this.gpsLockEnabled && this.currentGpsPoint) {
  3855. const nextGpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, nextZoom)
  3856. const resolvedViewport = this.resolveViewportForExactCenter(nextGpsWorldPoint.x, nextGpsWorldPoint.y)
  3857. this.commitViewport(
  3858. {
  3859. zoom: nextZoom,
  3860. ...resolvedViewport,
  3861. },
  3862. `缩放级别调整到 ${nextZoom}`,
  3863. true,
  3864. () => {
  3865. this.setPreviewState(residualScale, this.state.stageWidth / 2, this.state.stageHeight / 2)
  3866. this.syncRenderer()
  3867. this.compassController.start()
  3868. this.animatePreviewToRest()
  3869. },
  3870. )
  3871. return
  3872. }
  3873. if (!this.state.stageWidth || !this.state.stageHeight) {
  3874. this.commitViewport(
  3875. {
  3876. zoom: nextZoom,
  3877. centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2),
  3878. centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2),
  3879. tileTranslateX: 0,
  3880. tileTranslateY: 0,
  3881. },
  3882. `缩放级别调整到 ${nextZoom}`,
  3883. true,
  3884. () => {
  3885. this.setPreviewState(residualScale, stageX, stageY)
  3886. this.syncRenderer()
  3887. this.compassController.start()
  3888. this.animatePreviewToRest()
  3889. },
  3890. )
  3891. return
  3892. }
  3893. const camera = this.getCameraState()
  3894. const world = screenToWorld(camera, { x: stageX, y: stageY }, true)
  3895. const zoomFactor = Math.pow(2, appliedDelta)
  3896. const nextWorldX = world.x * zoomFactor
  3897. const nextWorldY = world.y * zoomFactor
  3898. const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY)
  3899. const exactCenterX = nextWorldX - anchorOffset.x
  3900. const exactCenterY = nextWorldY - anchorOffset.y
  3901. const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY)
  3902. this.commitViewport(
  3903. {
  3904. zoom: nextZoom,
  3905. ...resolvedViewport,
  3906. },
  3907. `缩放级别调整到 ${nextZoom}`,
  3908. true,
  3909. () => {
  3910. this.setPreviewState(residualScale, stageX, stageY)
  3911. this.syncRenderer()
  3912. this.compassController.start()
  3913. this.animatePreviewToRest()
  3914. },
  3915. )
  3916. }
  3917. startInertia(): void {
  3918. this.clearInertiaTimer()
  3919. const step = () => {
  3920. this.panVelocityX *= INERTIA_DECAY
  3921. this.panVelocityY *= INERTIA_DECAY
  3922. if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
  3923. this.setState({
  3924. statusText: `惯性滑动结束 (${this.buildVersion})`,
  3925. })
  3926. this.renderer.setAnimationPaused(false)
  3927. this.inertiaTimer = 0
  3928. this.scheduleAutoRotate()
  3929. return
  3930. }
  3931. this.normalizeTranslate(
  3932. this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
  3933. this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
  3934. `惯性滑动中 (${this.buildVersion})`,
  3935. )
  3936. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  3937. }
  3938. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  3939. }
  3940. }