mapEngine.ts 201 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094
  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 { MockSimulatorDebugLogger } from '../debug/mockSimulatorDebugLogger'
  6. import { GyroscopeController } from '../sensor/gyroscopeController'
  7. import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
  8. import { HeartRateInputController } from '../sensor/heartRateInputController'
  9. import { LocationController } from '../sensor/locationController'
  10. import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
  11. import { type MapRendererStats } from '../renderer/mapRenderer'
  12. import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
  13. import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
  14. import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
  15. import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
  16. import { GameRuntime } from '../../game/core/gameRuntime'
  17. import { type GameControl, type GameControlDisplayContentOverride } from '../../game/core/gameDefinition'
  18. import {
  19. buildDefaultContentCardCtaLabel,
  20. buildDefaultContentCardQuizConfig,
  21. type ContentCardActionViewModel,
  22. type ContentCardCtaConfig,
  23. type ContentCardQuizConfig,
  24. type ContentCardTemplate,
  25. } from '../../game/experience/contentCard'
  26. import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
  27. import { type GameEffect, type GameResult } from '../../game/core/gameResult'
  28. import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
  29. import { getDefaultSkipRadiusMeters, getGameModeDefaults } from '../../game/core/gameModeDefaults'
  30. import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
  31. import { DEFAULT_COURSE_STYLE_CONFIG, type ControlPointStyleEntry, type CourseLegStyleEntry, type CourseStyleConfig } from '../../game/presentation/courseStyleConfig'
  32. import {
  33. DEFAULT_TRACK_VISUALIZATION_CONFIG,
  34. TRACK_COLOR_PRESET_MAP,
  35. TRACK_TAIL_LENGTH_METERS,
  36. type TrackColorPreset,
  37. type TrackDisplayMode,
  38. type TrackStyleProfile,
  39. type TrackTailLengthPreset,
  40. type TrackVisualizationConfig,
  41. } from '../../game/presentation/trackStyleConfig'
  42. import {
  43. DEFAULT_GPS_MARKER_STYLE_CONFIG,
  44. GPS_MARKER_COLOR_PRESET_MAP,
  45. type GpsMarkerColorPreset,
  46. type GpsMarkerSizePreset,
  47. type GpsMarkerStyleId,
  48. type GpsMarkerStyleConfig,
  49. } from '../../game/presentation/gpsMarkerStyleConfig'
  50. import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
  51. import { buildResultSummarySnapshot, type ResultSummarySnapshot } from '../../game/result/resultSummary'
  52. import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime'
  53. import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig'
  54. import { type PlayerTelemetryProfile } from '../../game/telemetry/playerTelemetryProfile'
  55. import {
  56. type RuntimeMapProfile,
  57. type RuntimeGameProfile,
  58. type RuntimeFeedbackProfile,
  59. type RuntimePresentationProfile,
  60. type RuntimeSettingsProfile,
  61. type RuntimeTelemetryProfile,
  62. } from '../../game/core/runtimeProfileCompiler'
  63. import {
  64. type RecoveryRuntimeSnapshot,
  65. } from '../../game/core/sessionRecovery'
  66. const RENDER_MODE = 'Single WebGL Pipeline'
  67. const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
  68. const MAP_NORTH_OFFSET_DEG = 0
  69. let MAGNETIC_DECLINATION_DEG = -6.91
  70. let MAGNETIC_DECLINATION_TEXT = '6.91˚ W'
  71. const MIN_ZOOM = 15
  72. const MAX_ZOOM = 20
  73. const DEFAULT_ZOOM = 17
  74. const DESIRED_VISIBLE_COLUMNS = 3
  75. const OVERDRAW = 1
  76. const DEFAULT_TOP_LEFT_TILE_X = 108132
  77. const DEFAULT_TOP_LEFT_TILE_Y = 51199
  78. const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1
  79. const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1
  80. const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png'
  81. const OSM_TILE_SOURCE = 'https://tiles.mymarsgo.xyz/{z}/{x}/{y}.png'
  82. const MAP_OVERLAY_OPACITY = 0.72
  83. const GPS_MAP_CALIBRATION: MapCalibration = {
  84. offsetEastMeters: 0,
  85. offsetNorthMeters: 0,
  86. rotationDeg: 0,
  87. scale: 1,
  88. }
  89. const MIN_PREVIEW_SCALE = 0.55
  90. const MAX_PREVIEW_SCALE = 1.85
  91. const INERTIA_FRAME_MS = 16
  92. const INERTIA_DECAY = 0.92
  93. const INERTIA_MIN_SPEED = 0.02
  94. const PREVIEW_RESET_DURATION_MS = 140
  95. const UI_SYNC_INTERVAL_MS = 80
  96. const ROTATE_STEP_DEG = 15
  97. const AUTO_ROTATE_FRAME_MS = 8
  98. const AUTO_ROTATE_EASE = 0.34
  99. const AUTO_ROTATE_SNAP_DEG = 0.1
  100. const AUTO_ROTATE_DEADZONE_DEG = 4
  101. const AUTO_ROTATE_MAX_STEP_DEG = 0.75
  102. const AUTO_ROTATE_HEADING_SMOOTHING = 0.46
  103. const COMPASS_NEEDLE_FRAME_MS = 16
  104. const COMPASS_NEEDLE_SNAP_DEG = 0.08
  105. const COMPASS_BOOTSTRAP_RETRY_DELAY_MS = 700
  106. const COMPASS_TUNING_PRESETS: Record<CompassTuningProfile, {
  107. needleMinSmoothing: number
  108. needleMaxSmoothing: number
  109. displayDeadzoneDeg: number
  110. }> = {
  111. smooth: {
  112. needleMinSmoothing: 0.16,
  113. needleMaxSmoothing: 0.4,
  114. displayDeadzoneDeg: 0.75,
  115. },
  116. balanced: {
  117. needleMinSmoothing: 0.22,
  118. needleMaxSmoothing: 0.52,
  119. displayDeadzoneDeg: 0.45,
  120. },
  121. responsive: {
  122. needleMinSmoothing: 0.3,
  123. needleMaxSmoothing: 0.68,
  124. displayDeadzoneDeg: 0.2,
  125. },
  126. }
  127. const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2
  128. const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
  129. const SMART_HEADING_MIN_DISTANCE_METERS = 12
  130. const SMART_HEADING_MAX_ACCURACY_METERS = 25
  131. const SMART_HEADING_MOVEMENT_MIN_SMOOTHING = 0.12
  132. const SMART_HEADING_MOVEMENT_MAX_SMOOTHING = 0.24
  133. const GPS_TRACK_MAX_POINTS = 200
  134. const GPS_TRACK_MIN_STEP_METERS = 3
  135. const MAP_TAP_MOVE_THRESHOLD_PX = 14
  136. const MAP_TAP_DURATION_MS = 280
  137. function clampNumber(value: number, min: number, max: number): number {
  138. return Math.max(min, Math.min(max, value))
  139. }
  140. function hexToRgb(hex: string): { r: number; g: number; b: number } {
  141. const normalized = hex.replace('#', '')
  142. const full = normalized.length === 3
  143. ? normalized.split('').map((segment) => segment + segment).join('')
  144. : normalized.padEnd(6, '0').slice(0, 6)
  145. const parsed = Number.parseInt(full, 16)
  146. return {
  147. r: (parsed >> 16) & 0xff,
  148. g: (parsed >> 8) & 0xff,
  149. b: parsed & 0xff,
  150. }
  151. }
  152. function rgbToHex(r: number, g: number, b: number): string {
  153. const toHex = (value: number) => clampNumber(Math.round(value), 0, 255).toString(16).padStart(2, '0')
  154. return `#${toHex(r)}${toHex(g)}${toHex(b)}`
  155. }
  156. function mixHexColor(fromHex: string, toHex: string, amount: number): string {
  157. const from = hexToRgb(fromHex)
  158. const to = hexToRgb(toHex)
  159. const factor = clampNumber(amount, 0, 1)
  160. return rgbToHex(
  161. from.r + (to.r - from.r) * factor,
  162. from.g + (to.g - from.g) * factor,
  163. from.b + (to.b - from.b) * factor,
  164. )
  165. }
  166. type TouchPoint = WechatMiniprogram.TouchDetail
  167. type GestureMode = 'idle' | 'pan' | 'pinch'
  168. type RotationMode = 'manual' | 'auto'
  169. type OrientationMode = 'manual' | 'north-up' | 'heading-up'
  170. type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion' | 'smart'
  171. type SmartHeadingSource = 'sensor' | 'blended' | 'movement'
  172. type NorthReferenceMode = 'magnetic' | 'true'
  173. const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
  174. export interface MapEngineStageRect {
  175. width: number
  176. height: number
  177. left: number
  178. top: number
  179. }
  180. export interface MapEngineViewState {
  181. animationLevel: AnimationLevel
  182. buildVersion: string
  183. renderMode: string
  184. projectionMode: string
  185. mapReady: boolean
  186. mapReadyText: string
  187. mapName: string
  188. configStatusText: string
  189. zoom: number
  190. rotationDeg: number
  191. rotationText: string
  192. rotationMode: RotationMode
  193. rotationModeText: string
  194. rotationToggleText: string
  195. orientationMode: OrientationMode
  196. orientationModeText: string
  197. sensorHeadingText: string
  198. deviceHeadingText: string
  199. devicePoseText: string
  200. headingConfidenceText: string
  201. accelerometerText: string
  202. gyroscopeText: string
  203. deviceMotionText: string
  204. compassSourceText: string
  205. compassTuningProfile: CompassTuningProfile
  206. compassTuningProfileText: string
  207. compassDeclinationText: string
  208. northReferenceMode: NorthReferenceMode
  209. northReferenceButtonText: string
  210. autoRotateSourceText: string
  211. autoRotateCalibrationText: string
  212. northReferenceText: string
  213. compassNeedleDeg: number
  214. centerTileX: number
  215. centerTileY: number
  216. centerText: string
  217. tileSource: string
  218. visibleColumnCount: number
  219. visibleTileCount: number
  220. readyTileCount: number
  221. memoryTileCount: number
  222. diskTileCount: number
  223. memoryHitCount: number
  224. diskHitCount: number
  225. networkFetchCount: number
  226. cacheHitRateText: string
  227. tileTranslateX: number
  228. tileTranslateY: number
  229. tileSizePx: number
  230. previewScale: number
  231. stageWidth: number
  232. stageHeight: number
  233. stageLeft: number
  234. stageTop: number
  235. statusText: string
  236. gpsTracking: boolean
  237. gpsTrackingText: string
  238. gpsLockEnabled: boolean
  239. gpsLockAvailable: boolean
  240. locationSourceMode: 'real' | 'mock'
  241. locationSourceText: string
  242. mockBridgeConnected: boolean
  243. mockBridgeStatusText: string
  244. mockBridgeUrlText: string
  245. mockChannelIdText: string
  246. mockCoordText: string
  247. mockSpeedText: string
  248. gpsCoordText: string
  249. heartRateSourceMode: 'real' | 'mock'
  250. heartRateSourceText: string
  251. heartRateConnected: boolean
  252. heartRateStatusText: string
  253. heartRateDeviceText: string
  254. heartRateScanText: string
  255. heartRateDiscoveredDevices: Array<{
  256. deviceId: string
  257. name: string
  258. rssiText: string
  259. preferred: boolean
  260. connected: boolean
  261. }>
  262. mockHeartRateBridgeConnected: boolean
  263. mockHeartRateBridgeStatusText: string
  264. mockHeartRateBridgeUrlText: string
  265. mockHeartRateText: string
  266. mockDebugLogBridgeConnected: boolean
  267. mockDebugLogBridgeStatusText: string
  268. mockDebugLogBridgeUrlText: string
  269. gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
  270. gameModeText: string
  271. panelTimerText: string
  272. panelTimerMode: 'elapsed' | 'countdown'
  273. panelMileageText: string
  274. panelActionTagText: string
  275. panelDistanceTagText: string
  276. panelTargetSummaryText: string
  277. panelDistanceValueText: string
  278. panelDistanceUnitText: string
  279. panelProgressText: string
  280. panelSpeedValueText: string
  281. panelTelemetryTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
  282. trackDisplayMode: TrackDisplayMode
  283. trackTailLength: TrackTailLengthPreset
  284. trackColorPreset: TrackColorPreset
  285. trackStyleProfile: TrackStyleProfile
  286. gpsMarkerVisible: boolean
  287. gpsMarkerStyle: GpsMarkerStyleId
  288. gpsMarkerSize: GpsMarkerSizePreset
  289. gpsMarkerColorPreset: GpsMarkerColorPreset
  290. gpsLogoStatusText: string
  291. gpsLogoSourceText: string
  292. panelHeartRateZoneNameText: string
  293. panelHeartRateZoneRangeText: string
  294. panelHeartRateValueText: string
  295. panelHeartRateUnitText: string
  296. panelCaloriesValueText: string
  297. panelCaloriesUnitText: string
  298. panelAverageSpeedValueText: string
  299. panelAverageSpeedUnitText: string
  300. panelAccuracyValueText: string
  301. panelAccuracyUnitText: string
  302. punchButtonText: string
  303. punchButtonEnabled: boolean
  304. skipButtonEnabled: boolean
  305. punchHintText: string
  306. punchFeedbackVisible: boolean
  307. punchFeedbackText: string
  308. punchFeedbackTone: 'neutral' | 'success' | 'warning'
  309. contentCardVisible: boolean
  310. contentCardTemplate: ContentCardTemplate
  311. contentCardTitle: string
  312. contentCardBody: string
  313. contentCardActions: ContentCardActionViewModel[]
  314. contentQuizVisible: boolean
  315. contentQuizQuestionText: string
  316. contentQuizCountdownText: string
  317. contentQuizOptions: ContentCardQuizOptionViewModel[]
  318. contentQuizFeedbackVisible: boolean
  319. contentQuizFeedbackText: string
  320. contentQuizFeedbackTone: 'success' | 'error' | 'neutral'
  321. pendingContentEntryVisible: boolean
  322. pendingContentEntryText: string
  323. punchButtonFxClass: string
  324. panelProgressFxClass: string
  325. panelDistanceFxClass: string
  326. punchFeedbackFxClass: string
  327. contentCardFxClass: string
  328. mapPulseVisible: boolean
  329. mapPulseLeftPx: number
  330. mapPulseTopPx: number
  331. mapPulseFxClass: string
  332. stageFxVisible: boolean
  333. stageFxClass: string
  334. osmReferenceEnabled: boolean
  335. osmReferenceText: string
  336. }
  337. export interface MapEngineCallbacks {
  338. onData: (patch: Partial<MapEngineViewState>) => void
  339. onOpenH5Experience?: (request: H5ExperienceRequest) => void
  340. }
  341. interface GpsTrackSample {
  342. point: LonLatPoint
  343. at: number
  344. }
  345. interface ContentCardEntry {
  346. template: ContentCardTemplate
  347. title: string
  348. body: string
  349. motionClass: string
  350. contentKey: string
  351. once: boolean
  352. priority: number
  353. autoPopup: boolean
  354. ctas: ContentCardCtaConfig[]
  355. h5Request: H5ExperienceRequest | null
  356. }
  357. export interface ContentCardQuizOptionViewModel {
  358. key: string
  359. label: string
  360. }
  361. export interface MapEngineGameInfoRow {
  362. label: string
  363. value: string
  364. }
  365. export interface MapEngineGameInfoSnapshot {
  366. title: string
  367. subtitle: string
  368. localRows: MapEngineGameInfoRow[]
  369. globalRows: MapEngineGameInfoRow[]
  370. }
  371. export type MapEngineResultSnapshot = ResultSummarySnapshot
  372. const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
  373. 'animationLevel',
  374. 'buildVersion',
  375. 'renderMode',
  376. 'projectionMode',
  377. 'mapReady',
  378. 'mapReadyText',
  379. 'mapName',
  380. 'configStatusText',
  381. 'zoom',
  382. 'centerTileX',
  383. 'centerTileY',
  384. 'rotationDeg',
  385. 'rotationText',
  386. 'rotationMode',
  387. 'rotationModeText',
  388. 'rotationToggleText',
  389. 'orientationMode',
  390. 'orientationModeText',
  391. 'sensorHeadingText',
  392. 'deviceHeadingText',
  393. 'devicePoseText',
  394. 'headingConfidenceText',
  395. 'accelerometerText',
  396. 'gyroscopeText',
  397. 'deviceMotionText',
  398. 'compassSourceText',
  399. 'compassTuningProfile',
  400. 'compassTuningProfileText',
  401. 'compassDeclinationText',
  402. 'northReferenceMode',
  403. 'northReferenceButtonText',
  404. 'autoRotateSourceText',
  405. 'autoRotateCalibrationText',
  406. 'northReferenceText',
  407. 'compassNeedleDeg',
  408. 'centerText',
  409. 'tileSource',
  410. 'visibleTileCount',
  411. 'readyTileCount',
  412. 'memoryTileCount',
  413. 'diskTileCount',
  414. 'memoryHitCount',
  415. 'diskHitCount',
  416. 'networkFetchCount',
  417. 'cacheHitRateText',
  418. 'tileSizePx',
  419. 'previewScale',
  420. 'stageWidth',
  421. 'stageHeight',
  422. 'stageLeft',
  423. 'stageTop',
  424. 'statusText',
  425. 'gpsTracking',
  426. 'gpsTrackingText',
  427. 'gpsLockEnabled',
  428. 'gpsLockAvailable',
  429. 'locationSourceMode',
  430. 'locationSourceText',
  431. 'mockBridgeConnected',
  432. 'mockBridgeStatusText',
  433. 'mockBridgeUrlText',
  434. 'mockChannelIdText',
  435. 'mockCoordText',
  436. 'mockSpeedText',
  437. 'gpsCoordText',
  438. 'heartRateSourceMode',
  439. 'heartRateSourceText',
  440. 'heartRateConnected',
  441. 'heartRateStatusText',
  442. 'heartRateDeviceText',
  443. 'heartRateScanText',
  444. 'heartRateDiscoveredDevices',
  445. 'mockHeartRateBridgeConnected',
  446. 'mockHeartRateBridgeStatusText',
  447. 'mockHeartRateBridgeUrlText',
  448. 'mockHeartRateText',
  449. 'mockDebugLogBridgeConnected',
  450. 'mockDebugLogBridgeStatusText',
  451. 'mockDebugLogBridgeUrlText',
  452. 'gameSessionStatus',
  453. 'gameModeText',
  454. 'panelTimerText',
  455. 'panelTimerMode',
  456. 'panelMileageText',
  457. 'panelActionTagText',
  458. 'panelDistanceTagText',
  459. 'panelTargetSummaryText',
  460. 'panelDistanceValueText',
  461. 'panelDistanceUnitText',
  462. 'panelProgressText',
  463. 'panelSpeedValueText',
  464. 'panelTelemetryTone',
  465. 'trackDisplayMode',
  466. 'trackTailLength',
  467. 'trackColorPreset',
  468. 'trackStyleProfile',
  469. 'gpsMarkerVisible',
  470. 'gpsMarkerStyle',
  471. 'gpsMarkerSize',
  472. 'gpsMarkerColorPreset',
  473. 'gpsLogoStatusText',
  474. 'gpsLogoSourceText',
  475. 'panelHeartRateZoneNameText',
  476. 'panelHeartRateZoneRangeText',
  477. 'panelHeartRateValueText',
  478. 'panelHeartRateUnitText',
  479. 'panelCaloriesValueText',
  480. 'panelCaloriesUnitText',
  481. 'panelAverageSpeedValueText',
  482. 'panelAverageSpeedUnitText',
  483. 'panelAccuracyValueText',
  484. 'panelAccuracyUnitText',
  485. 'punchButtonText',
  486. 'punchButtonEnabled',
  487. 'skipButtonEnabled',
  488. 'punchHintText',
  489. 'punchFeedbackVisible',
  490. 'punchFeedbackText',
  491. 'punchFeedbackTone',
  492. 'contentCardVisible',
  493. 'contentCardTemplate',
  494. 'contentCardTitle',
  495. 'contentCardBody',
  496. 'contentCardActions',
  497. 'contentQuizVisible',
  498. 'contentQuizQuestionText',
  499. 'contentQuizCountdownText',
  500. 'contentQuizOptions',
  501. 'contentQuizFeedbackVisible',
  502. 'contentQuizFeedbackText',
  503. 'contentQuizFeedbackTone',
  504. 'pendingContentEntryVisible',
  505. 'pendingContentEntryText',
  506. 'punchButtonFxClass',
  507. 'panelProgressFxClass',
  508. 'panelDistanceFxClass',
  509. 'punchFeedbackFxClass',
  510. 'contentCardFxClass',
  511. 'mapPulseVisible',
  512. 'mapPulseLeftPx',
  513. 'mapPulseTopPx',
  514. 'mapPulseFxClass',
  515. 'stageFxVisible',
  516. 'stageFxClass',
  517. 'osmReferenceEnabled',
  518. 'osmReferenceText',
  519. ]
  520. const INTERACTION_DEFERRED_VIEW_KEYS = new Set<keyof MapEngineViewState>([
  521. 'rotationText',
  522. 'sensorHeadingText',
  523. 'deviceHeadingText',
  524. 'devicePoseText',
  525. 'headingConfidenceText',
  526. 'accelerometerText',
  527. 'gyroscopeText',
  528. 'deviceMotionText',
  529. 'compassSourceText',
  530. 'compassTuningProfile',
  531. 'compassTuningProfileText',
  532. 'compassDeclinationText',
  533. 'autoRotateSourceText',
  534. 'autoRotateCalibrationText',
  535. 'northReferenceText',
  536. 'centerText',
  537. 'gpsCoordText',
  538. 'visibleTileCount',
  539. 'readyTileCount',
  540. 'memoryTileCount',
  541. 'diskTileCount',
  542. 'memoryHitCount',
  543. 'diskHitCount',
  544. 'networkFetchCount',
  545. 'cacheHitRateText',
  546. 'heartRateDiscoveredDevices',
  547. 'mockCoordText',
  548. 'mockSpeedText',
  549. 'mockHeartRateText',
  550. ])
  551. function buildCenterText(zoom: number, x: number, y: number): string {
  552. return `z${zoom} / x${x} / y${y}`
  553. }
  554. function clamp(value: number, min: number, max: number): number {
  555. return Math.max(min, Math.min(max, value))
  556. }
  557. function normalizeRotationDeg(rotationDeg: number): number {
  558. const normalized = rotationDeg % 360
  559. return normalized < 0 ? normalized + 360 : normalized
  560. }
  561. function normalizeAngleDeltaRad(angleDeltaRad: number): number {
  562. let normalized = angleDeltaRad
  563. while (normalized > Math.PI) {
  564. normalized -= Math.PI * 2
  565. }
  566. while (normalized < -Math.PI) {
  567. normalized += Math.PI * 2
  568. }
  569. return normalized
  570. }
  571. function normalizeAngleDeltaDeg(angleDeltaDeg: number): number {
  572. let normalized = angleDeltaDeg
  573. while (normalized > 180) {
  574. normalized -= 360
  575. }
  576. while (normalized < -180) {
  577. normalized += 360
  578. }
  579. return normalized
  580. }
  581. function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number {
  582. return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
  583. }
  584. function getCompassNeedleSmoothingFactor(
  585. currentDeg: number,
  586. targetDeg: number,
  587. profile: CompassTuningProfile,
  588. ): number {
  589. const preset = COMPASS_TUNING_PRESETS[profile]
  590. const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg))
  591. if (deltaDeg <= 4) {
  592. return preset.needleMinSmoothing
  593. }
  594. if (deltaDeg >= 36) {
  595. return preset.needleMaxSmoothing
  596. }
  597. const progress = (deltaDeg - 4) / (36 - 4)
  598. return preset.needleMinSmoothing
  599. + (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress
  600. }
  601. function getMovementHeadingSmoothingFactor(speedKmh: number | null): number {
  602. if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
  603. return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
  604. }
  605. if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
  606. return SMART_HEADING_MOVEMENT_MAX_SMOOTHING
  607. }
  608. const progress = (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH)
  609. / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)
  610. return SMART_HEADING_MOVEMENT_MIN_SMOOTHING
  611. + (SMART_HEADING_MOVEMENT_MAX_SMOOTHING - SMART_HEADING_MOVEMENT_MIN_SMOOTHING) * progress
  612. }
  613. function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string {
  614. if (status === 'running') {
  615. return '进行中'
  616. }
  617. if (status === 'finished') {
  618. return '已结束'
  619. }
  620. if (status === 'failed') {
  621. return '已失败'
  622. }
  623. return '未开始'
  624. }
  625. function formatRotationText(rotationDeg: number): string {
  626. return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
  627. }
  628. function normalizeDegreeDisplayText(text: string): string {
  629. return text.replace(/[掳•˚]/g, '°')
  630. }
  631. function formatHeadingText(headingDeg: number | null): string {
  632. if (headingDeg === null) {
  633. return '--'
  634. }
  635. return `${Math.round(normalizeRotationDeg(headingDeg))}°`
  636. }
  637. function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
  638. if (pose === 'flat') {
  639. return '平放'
  640. }
  641. if (pose === 'tilted') {
  642. return '倾斜'
  643. }
  644. return '竖持'
  645. }
  646. function formatHeadingConfidenceText(confidence: 'low' | 'medium' | 'high'): string {
  647. if (confidence === 'high') {
  648. return '高'
  649. }
  650. if (confidence === 'medium') {
  651. return '中'
  652. }
  653. return '低'
  654. }
  655. function formatClockTime(timestamp: number | null): string {
  656. if (!timestamp || !Number.isFinite(timestamp)) {
  657. return '--:--:--'
  658. }
  659. const date = new Date(timestamp)
  660. const hh = String(date.getHours()).padStart(2, '0')
  661. const mm = String(date.getMinutes()).padStart(2, '0')
  662. const ss = String(date.getSeconds()).padStart(2, '0')
  663. return `${hh}:${mm}:${ss}`
  664. }
  665. function formatGyroscopeText(gyroscope: { x: number; y: number; z: number } | null): string {
  666. if (!gyroscope) {
  667. return '--'
  668. }
  669. return `x:${gyroscope.x.toFixed(2)} y:${gyroscope.y.toFixed(2)} z:${gyroscope.z.toFixed(2)}`
  670. }
  671. function formatDeviceMotionText(motion: { alpha: number | null; beta: number | null; gamma: number | null } | null): string {
  672. if (!motion) {
  673. return '--'
  674. }
  675. const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha))
  676. const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta)
  677. const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma)
  678. return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}`
  679. }
  680. function formatOrientationModeText(mode: OrientationMode): string {
  681. if (mode === 'north-up') {
  682. return 'North Up'
  683. }
  684. if (mode === 'heading-up') {
  685. return 'Heading Up'
  686. }
  687. return 'Manual Gesture'
  688. }
  689. function formatRotationModeText(mode: OrientationMode): string {
  690. return formatOrientationModeText(mode)
  691. }
  692. function formatRotationToggleText(mode: OrientationMode): string {
  693. if (mode === 'manual') {
  694. return '切到北朝上'
  695. }
  696. if (mode === 'north-up') {
  697. return '切到朝向朝上'
  698. }
  699. return '切到手动旋转'
  700. }
  701. function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
  702. if (mode === 'smart') {
  703. return 'Smart / 手机朝向'
  704. }
  705. if (mode === 'sensor') {
  706. return 'Sensor Only'
  707. }
  708. if (mode === 'course') {
  709. return hasCourseHeading ? 'Course Only' : 'Course Pending'
  710. }
  711. return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
  712. }
  713. function formatSmartHeadingSourceText(source: SmartHeadingSource): string {
  714. if (source === 'movement') {
  715. return 'Smart / 前进方向'
  716. }
  717. if (source === 'blended') {
  718. return 'Smart / 融合'
  719. }
  720. return 'Smart / 手机朝向'
  721. }
  722. function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
  723. if (pending) {
  724. return 'Pending'
  725. }
  726. if (offsetDeg === null) {
  727. return '--'
  728. }
  729. return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg`
  730. }
  731. function getTrueHeadingDeg(magneticHeadingDeg: number): number {
  732. return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
  733. }
  734. function getMagneticHeadingDeg(trueHeadingDeg: number): number {
  735. return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
  736. }
  737. function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
  738. return MAP_NORTH_OFFSET_DEG
  739. }
  740. function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  741. if (mode === 'true') {
  742. return getTrueHeadingDeg(magneticHeadingDeg)
  743. }
  744. return normalizeRotationDeg(magneticHeadingDeg)
  745. }
  746. function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  747. if (mode === 'magnetic') {
  748. return normalizeRotationDeg(magneticHeadingDeg)
  749. }
  750. return getTrueHeadingDeg(magneticHeadingDeg)
  751. }
  752. function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
  753. if (mode === 'magnetic') {
  754. return getMagneticHeadingDeg(trueHeadingDeg)
  755. }
  756. return normalizeRotationDeg(trueHeadingDeg)
  757. }
  758. function formatNorthReferenceText(mode: NorthReferenceMode): string {
  759. if (mode === 'magnetic') {
  760. return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
  761. }
  762. return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
  763. }
  764. function formatCompassDeclinationText(mode: NorthReferenceMode): string {
  765. if (mode === 'true') {
  766. return MAGNETIC_DECLINATION_TEXT
  767. }
  768. return ''
  769. }
  770. function formatCompassSourceText(source: 'compass' | 'motion' | null): string {
  771. if (source === 'compass') {
  772. return '罗盘'
  773. }
  774. if (source === 'motion') {
  775. return '设备方向兜底'
  776. }
  777. return '无数据'
  778. }
  779. function formatCompassTuningProfileText(profile: CompassTuningProfile): string {
  780. if (profile === 'smooth') {
  781. return '顺滑'
  782. }
  783. if (profile === 'responsive') {
  784. return '跟手'
  785. }
  786. return '平衡'
  787. }
  788. function formatTrackDisplayModeText(mode: TrackDisplayMode): string {
  789. if (mode === 'none') {
  790. return '无'
  791. }
  792. if (mode === 'tail') {
  793. return '彗尾'
  794. }
  795. return '全轨迹'
  796. }
  797. function formatTrackTailLengthText(length: TrackTailLengthPreset): string {
  798. if (length === 'short') {
  799. return '短'
  800. }
  801. if (length === 'long') {
  802. return '长'
  803. }
  804. return '中'
  805. }
  806. function formatTrackColorPresetText(colorPreset: TrackColorPreset): string {
  807. const labels: Record<TrackColorPreset, string> = {
  808. mint: '薄荷',
  809. cyan: '青绿',
  810. sky: '天蓝',
  811. blue: '深蓝',
  812. violet: '紫罗兰',
  813. pink: '玫红',
  814. orange: '橙色',
  815. yellow: '亮黄',
  816. }
  817. return labels[colorPreset]
  818. }
  819. function formatGpsMarkerSizeText(size: GpsMarkerSizePreset): string {
  820. if (size === 'small') {
  821. return '小'
  822. }
  823. if (size === 'large') {
  824. return '大'
  825. }
  826. return '中'
  827. }
  828. function formatGpsMarkerStyleText(style: GpsMarkerStyleId): string {
  829. if (style === 'dot') {
  830. return '圆点'
  831. }
  832. if (style === 'disc') {
  833. return '圆盘'
  834. }
  835. if (style === 'badge') {
  836. return '徽章'
  837. }
  838. return '信标'
  839. }
  840. function formatGpsMarkerColorPresetText(colorPreset: GpsMarkerColorPreset): string {
  841. const labels: Record<GpsMarkerColorPreset, string> = {
  842. mint: '薄荷',
  843. cyan: '青绿',
  844. sky: '天蓝',
  845. blue: '深蓝',
  846. violet: '紫罗兰',
  847. pink: '玫红',
  848. orange: '橙色',
  849. yellow: '亮黄',
  850. }
  851. return labels[colorPreset]
  852. }
  853. function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
  854. return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北'
  855. }
  856. function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
  857. if (mode === 'magnetic') {
  858. return '已切到磁北模式'
  859. }
  860. return '已切到真北模式'
  861. }
  862. function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
  863. return mode === 'magnetic' ? 'true' : 'magnetic'
  864. }
  865. function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
  866. if (magneticHeadingDeg === null) {
  867. return 0
  868. }
  869. const referenceHeadingDeg = mode === 'true'
  870. ? getTrueHeadingDeg(magneticHeadingDeg)
  871. : normalizeRotationDeg(magneticHeadingDeg)
  872. return normalizeRotationDeg(360 - referenceHeadingDeg)
  873. }
  874. function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string {
  875. const total = memoryHitCount + diskHitCount + networkFetchCount
  876. if (!total) {
  877. return '--'
  878. }
  879. const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
  880. return `${Math.round(hitRate)}%`
  881. }
  882. function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string {
  883. if (!point) {
  884. return '--'
  885. }
  886. const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}`
  887. if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) {
  888. return base
  889. }
  890. return `${base} / 卤${Math.round(accuracyMeters)}m`
  891. }
  892. function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
  893. const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  894. const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  895. const dy = (b.lat - a.lat) * 110540
  896. return Math.sqrt(dx * dx + dy * dy)
  897. }
  898. function resolveSmartHeadingSource(speedKmh: number | null, movementReliable: boolean): SmartHeadingSource {
  899. if (!movementReliable || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
  900. return 'sensor'
  901. }
  902. if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
  903. return 'movement'
  904. }
  905. return 'blended'
  906. }
  907. function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
  908. const fromLatRad = from.lat * Math.PI / 180
  909. const toLatRad = to.lat * Math.PI / 180
  910. const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
  911. const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
  912. const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
  913. const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
  914. return normalizeRotationDeg(bearingDeg)
  915. }
  916. export class MapEngine {
  917. buildVersion: string
  918. animationLevel: AnimationLevel
  919. renderer: WebGLMapRenderer
  920. accelerometerController: AccelerometerController
  921. compassController: CompassHeadingController
  922. gyroscopeController: GyroscopeController
  923. deviceMotionController: DeviceMotionController
  924. locationController: LocationController
  925. heartRateController: HeartRateInputController
  926. feedbackDirector: FeedbackDirector
  927. mockSimulatorDebugLogger: MockSimulatorDebugLogger
  928. onData: (patch: Partial<MapEngineViewState>) => void
  929. state: MapEngineViewState
  930. accelerometerErrorText: string | null
  931. previewScale: number
  932. previewOriginX: number
  933. previewOriginY: number
  934. panLastX: number
  935. panLastY: number
  936. panLastTimestamp: number
  937. tapStartX: number
  938. tapStartY: number
  939. tapStartAt: number
  940. panVelocityX: number
  941. panVelocityY: number
  942. pinchStartDistance: number
  943. pinchStartScale: number
  944. pinchStartAngle: number
  945. pinchStartRotationDeg: number
  946. pinchAnchorWorldX: number
  947. pinchAnchorWorldY: number
  948. gestureMode: GestureMode
  949. inertiaTimer: number
  950. previewResetTimer: number
  951. viewSyncTimer: number
  952. autoRotateTimer: number
  953. compassNeedleTimer: number
  954. compassBootstrapRetryTimer: number
  955. pendingViewPatch: Partial<MapEngineViewState>
  956. mounted: boolean
  957. diagnosticUiEnabled: boolean
  958. northReferenceMode: NorthReferenceMode
  959. sensorHeadingDeg: number | null
  960. smoothedSensorHeadingDeg: number | null
  961. compassDisplayHeadingDeg: number | null
  962. targetCompassDisplayHeadingDeg: number | null
  963. lastCompassSampleAt: number
  964. compassSource: 'compass' | 'motion' | null
  965. compassTuningProfile: CompassTuningProfile
  966. smoothedMovementHeadingDeg: number | null
  967. autoRotateHeadingDeg: number | null
  968. courseHeadingDeg: number | null
  969. targetAutoRotationDeg: number | null
  970. autoRotateSourceMode: AutoRotateSourceMode
  971. autoRotateCalibrationOffsetDeg: number | null
  972. autoRotateCalibrationPending: boolean
  973. lastStatsUiSyncAt: number
  974. minZoom: number
  975. maxZoom: number
  976. defaultZoom: number
  977. defaultCenterTileX: number
  978. defaultCenterTileY: number
  979. tileBoundsByZoom: Record<number, TileZoomBounds> | null
  980. currentGpsPoint: LonLatPoint | null
  981. currentGpsTrack: LonLatPoint[]
  982. currentGpsTrackSamples: GpsTrackSample[]
  983. currentGpsAccuracyMeters: number | null
  984. currentGpsInsideMap: boolean
  985. lastTrackMotionAt: number
  986. courseData: OrienteeringCourseData | null
  987. courseOverlayVisible: boolean
  988. cpRadiusMeters: number
  989. configAppId: string
  990. configSchemaVersion: string
  991. configVersion: string
  992. controlScoreOverrides: Record<string, number>
  993. controlContentOverrides: Record<string, GameControlDisplayContentOverride>
  994. defaultControlContentOverride: GameControlDisplayContentOverride | null
  995. defaultControlPointStyleOverride: ControlPointStyleEntry | null
  996. controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
  997. defaultLegStyleOverride: CourseLegStyleEntry | null
  998. legStyleOverrides: Record<number, CourseLegStyleEntry>
  999. defaultControlScore: number | null
  1000. courseStyleConfig: CourseStyleConfig
  1001. trackStyleConfig: TrackVisualizationConfig
  1002. gpsMarkerStyleConfig: GpsMarkerStyleConfig
  1003. gameRuntime: GameRuntime
  1004. telemetryRuntime: TelemetryRuntime
  1005. telemetryPlayerProfile: PlayerTelemetryProfile | null
  1006. gamePresentation: GamePresentationState
  1007. gameMode: 'classic-sequential' | 'score-o'
  1008. sessionCloseAfterMs: number
  1009. sessionCloseWarningMs: number
  1010. minCompletedControlsBeforeFinish: number
  1011. punchPolicy: 'enter' | 'enter-confirm'
  1012. punchRadiusMeters: number
  1013. requiresFocusSelection: boolean
  1014. skipEnabled: boolean
  1015. skipRadiusMeters: number
  1016. skipRequiresConfirm: boolean
  1017. autoFinishOnLastControl: boolean
  1018. punchFeedbackTimer: number
  1019. contentCardTimer: number
  1020. contentQuizTimer: number
  1021. contentQuizFeedbackTimer: number
  1022. currentContentCardPriority: number
  1023. shownContentCardKeys: Record<string, true>
  1024. consumedContentQuizKeys: Record<string, true>
  1025. rewardedContentQuizKeys: Record<string, true>
  1026. sessionBonusScore: number
  1027. currentContentCard: ContentCardEntry | null
  1028. pendingContentCards: ContentCardEntry[]
  1029. currentContentCardH5Request: H5ExperienceRequest | null
  1030. currentH5ExperienceOpen: boolean
  1031. currentContentQuizKey: string
  1032. currentContentQuizAnswer: number
  1033. currentContentQuizBonusScore: number
  1034. sessionQuizCorrectCount: number
  1035. sessionQuizWrongCount: number
  1036. sessionQuizTimeoutCount: number
  1037. mapPulseTimer: number
  1038. stageFxTimer: number
  1039. sessionTimerInterval: number
  1040. hasGpsCenteredOnce: boolean
  1041. gpsLockEnabled: boolean
  1042. onOpenH5Experience?: (request: H5ExperienceRequest) => void
  1043. constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
  1044. this.buildVersion = buildVersion
  1045. this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
  1046. this.compassTuningProfile = 'balanced'
  1047. this.onData = callbacks.onData
  1048. this.onOpenH5Experience = callbacks.onOpenH5Experience
  1049. this.accelerometerErrorText = null
  1050. this.mockSimulatorDebugLogger = new MockSimulatorDebugLogger((debugState) => {
  1051. this.setState({
  1052. mockDebugLogBridgeConnected: debugState.connected,
  1053. mockDebugLogBridgeStatusText: debugState.statusText,
  1054. mockDebugLogBridgeUrlText: debugState.url,
  1055. })
  1056. })
  1057. this.renderer = new WebGLMapRenderer(
  1058. (stats) => {
  1059. this.applyStats(stats)
  1060. },
  1061. (message) => {
  1062. this.setState({
  1063. statusText: `${message} (${this.buildVersion})`,
  1064. })
  1065. },
  1066. (info) => {
  1067. const statusText = !info.url
  1068. ? '未配置'
  1069. : info.status === 'ready'
  1070. ? '已就绪'
  1071. : info.status === 'loading'
  1072. ? '加载中'
  1073. : info.status === 'error'
  1074. ? '加载失败'
  1075. : '空闲'
  1076. this.setState({
  1077. gpsLogoStatusText: statusText,
  1078. gpsLogoSourceText: info.resolvedSrc || info.url || '--',
  1079. })
  1080. },
  1081. (scope, level, message, payload) => {
  1082. this.mockSimulatorDebugLogger.log(scope, level, message, payload)
  1083. },
  1084. )
  1085. this.accelerometerController = new AccelerometerController({
  1086. onSample: (x, y, z) => {
  1087. this.accelerometerErrorText = null
  1088. this.telemetryRuntime.dispatch({
  1089. type: 'accelerometer_updated',
  1090. at: Date.now(),
  1091. x,
  1092. y,
  1093. z,
  1094. })
  1095. if (this.diagnosticUiEnabled) {
  1096. this.setState(this.getTelemetrySensorViewPatch())
  1097. }
  1098. },
  1099. onError: (message) => {
  1100. this.accelerometerErrorText = `不可用: ${message}`
  1101. if (this.diagnosticUiEnabled) {
  1102. this.setState({
  1103. ...this.getTelemetrySensorViewPatch(),
  1104. statusText: `加速度计启动失败 (${this.buildVersion})`,
  1105. })
  1106. }
  1107. },
  1108. })
  1109. this.compassController = new CompassHeadingController({
  1110. onHeading: (headingDeg) => {
  1111. this.handleCompassHeading(headingDeg)
  1112. },
  1113. onError: (message) => {
  1114. this.handleCompassError(message)
  1115. },
  1116. })
  1117. this.compassController.setTuningProfile(this.compassTuningProfile)
  1118. this.gyroscopeController = new GyroscopeController({
  1119. onSample: (x, y, z) => {
  1120. this.telemetryRuntime.dispatch({
  1121. type: 'gyroscope_updated',
  1122. at: Date.now(),
  1123. x,
  1124. y,
  1125. z,
  1126. })
  1127. if (this.diagnosticUiEnabled) {
  1128. this.setState(this.getTelemetrySensorViewPatch())
  1129. }
  1130. },
  1131. onError: () => {
  1132. if (this.diagnosticUiEnabled) {
  1133. this.setState(this.getTelemetrySensorViewPatch())
  1134. }
  1135. },
  1136. })
  1137. this.deviceMotionController = new DeviceMotionController({
  1138. onSample: (alpha, beta, gamma) => {
  1139. this.telemetryRuntime.dispatch({
  1140. type: 'device_motion_updated',
  1141. at: Date.now(),
  1142. alpha,
  1143. beta,
  1144. gamma,
  1145. })
  1146. if (this.diagnosticUiEnabled) {
  1147. this.setState({
  1148. ...this.getTelemetrySensorViewPatch(),
  1149. autoRotateSourceText: this.getAutoRotateSourceText(),
  1150. })
  1151. }
  1152. },
  1153. onError: () => {
  1154. if (this.diagnosticUiEnabled) {
  1155. this.setState(this.getTelemetrySensorViewPatch())
  1156. }
  1157. },
  1158. })
  1159. this.locationController = new LocationController({
  1160. onLocation: (update) => {
  1161. this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
  1162. },
  1163. onStatus: (message) => {
  1164. this.setState({
  1165. gpsTracking: this.locationController.listening,
  1166. gpsTrackingText: message,
  1167. ...this.getLocationControllerViewPatch(),
  1168. })
  1169. },
  1170. onError: (message) => {
  1171. this.setState({
  1172. gpsTracking: this.locationController.listening,
  1173. gpsTrackingText: message,
  1174. ...this.getLocationControllerViewPatch(),
  1175. statusText: `${message} (${this.buildVersion})`,
  1176. })
  1177. },
  1178. onDebugStateChange: () => {
  1179. if (this.diagnosticUiEnabled) {
  1180. this.setState(this.getLocationControllerViewPatch())
  1181. }
  1182. },
  1183. })
  1184. this.heartRateController = new HeartRateInputController({
  1185. onHeartRate: (bpm) => {
  1186. this.telemetryRuntime.dispatch({
  1187. type: 'heart_rate_updated',
  1188. at: Date.now(),
  1189. bpm,
  1190. })
  1191. this.syncSessionTimerText()
  1192. },
  1193. onStatus: (message) => {
  1194. const deviceName = this.heartRateController.currentDeviceName
  1195. || (this.heartRateController.reconnecting ? this.heartRateController.lastDeviceName : null)
  1196. || '--'
  1197. this.setState({
  1198. heartRateStatusText: message,
  1199. heartRateDeviceText: deviceName,
  1200. heartRateScanText: this.getHeartRateScanText(),
  1201. ...this.getHeartRateControllerViewPatch(),
  1202. })
  1203. },
  1204. onError: (message) => {
  1205. this.clearHeartRateSignal()
  1206. const deviceName = this.heartRateController.reconnecting
  1207. ? (this.heartRateController.lastDeviceName || '--')
  1208. : '--'
  1209. this.setState({
  1210. heartRateConnected: false,
  1211. heartRateStatusText: message,
  1212. heartRateDeviceText: deviceName,
  1213. heartRateScanText: this.getHeartRateScanText(),
  1214. ...this.getHeartRateControllerViewPatch(),
  1215. statusText: `${message} (${this.buildVersion})`,
  1216. })
  1217. },
  1218. onConnectionChange: (connected, deviceName) => {
  1219. if (!connected) {
  1220. this.clearHeartRateSignal()
  1221. }
  1222. const resolvedDeviceName = connected
  1223. ? (deviceName || '--')
  1224. : (this.heartRateController.reconnecting
  1225. ? (this.heartRateController.lastDeviceName || '--')
  1226. : '--')
  1227. this.setState({
  1228. heartRateConnected: connected,
  1229. heartRateDeviceText: resolvedDeviceName,
  1230. heartRateStatusText: connected
  1231. ? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
  1232. : (this.heartRateController.reconnecting ? '心率带自动重连中' : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接')),
  1233. heartRateScanText: this.getHeartRateScanText(),
  1234. heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
  1235. ...this.getHeartRateControllerViewPatch(),
  1236. })
  1237. },
  1238. onDeviceListChange: (devices) => {
  1239. if (this.diagnosticUiEnabled) {
  1240. this.setState({
  1241. heartRateDiscoveredDevices: this.formatHeartRateDevices(devices),
  1242. heartRateScanText: this.getHeartRateScanText(),
  1243. ...this.getHeartRateControllerViewPatch(),
  1244. })
  1245. }
  1246. },
  1247. onDebugStateChange: () => {
  1248. if (this.diagnosticUiEnabled) {
  1249. this.setState(this.getHeartRateControllerViewPatch())
  1250. }
  1251. },
  1252. })
  1253. this.feedbackDirector = new FeedbackDirector({
  1254. showPunchFeedback: (text, tone, motionClass) => {
  1255. this.showPunchFeedback(text, tone, motionClass)
  1256. },
  1257. showContentCard: (title, body, motionClass, options) => {
  1258. this.showContentCard(title, body, motionClass, options)
  1259. },
  1260. setPunchButtonFxClass: (className) => {
  1261. this.setPunchButtonFxClass(className)
  1262. },
  1263. setHudProgressFxClass: (className) => {
  1264. this.setHudProgressFxClass(className)
  1265. },
  1266. setHudDistanceFxClass: (className) => {
  1267. this.setHudDistanceFxClass(className)
  1268. },
  1269. showMapPulse: (controlId, motionClass) => {
  1270. this.showMapPulse(controlId, motionClass)
  1271. },
  1272. showStageFx: (className) => {
  1273. this.showStageFx(className)
  1274. },
  1275. stopLocationTracking: () => {
  1276. if (this.locationController.listening) {
  1277. this.locationController.stop()
  1278. }
  1279. },
  1280. })
  1281. this.feedbackDirector.setAnimationLevel(this.animationLevel)
  1282. this.minZoom = MIN_ZOOM
  1283. this.maxZoom = MAX_ZOOM
  1284. this.defaultZoom = DEFAULT_ZOOM
  1285. this.defaultCenterTileX = DEFAULT_CENTER_TILE_X
  1286. this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y
  1287. this.tileBoundsByZoom = null
  1288. this.currentGpsPoint = null
  1289. this.currentGpsTrack = []
  1290. this.currentGpsTrackSamples = []
  1291. this.currentGpsAccuracyMeters = null
  1292. this.currentGpsInsideMap = false
  1293. this.lastTrackMotionAt = 0
  1294. this.courseData = null
  1295. this.courseOverlayVisible = false
  1296. this.cpRadiusMeters = 5
  1297. this.configAppId = ''
  1298. this.configSchemaVersion = '1'
  1299. this.configVersion = ''
  1300. this.controlScoreOverrides = {}
  1301. this.controlContentOverrides = {}
  1302. this.defaultControlContentOverride = null
  1303. this.defaultControlPointStyleOverride = null
  1304. this.controlPointStyleOverrides = {}
  1305. this.defaultLegStyleOverride = null
  1306. this.legStyleOverrides = {}
  1307. this.defaultControlScore = null
  1308. this.courseStyleConfig = DEFAULT_COURSE_STYLE_CONFIG
  1309. this.trackStyleConfig = DEFAULT_TRACK_VISUALIZATION_CONFIG
  1310. this.gpsMarkerStyleConfig = DEFAULT_GPS_MARKER_STYLE_CONFIG
  1311. this.gameRuntime = new GameRuntime()
  1312. this.telemetryRuntime = new TelemetryRuntime()
  1313. this.telemetryPlayerProfile = null
  1314. this.telemetryRuntime.configure()
  1315. this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
  1316. this.gameMode = 'classic-sequential'
  1317. const modeDefaults = getGameModeDefaults(this.gameMode)
  1318. this.sessionCloseAfterMs = modeDefaults.sessionCloseAfterMs
  1319. this.sessionCloseWarningMs = modeDefaults.sessionCloseWarningMs
  1320. this.minCompletedControlsBeforeFinish = modeDefaults.minCompletedControlsBeforeFinish
  1321. this.punchPolicy = 'enter-confirm'
  1322. this.punchRadiusMeters = 5
  1323. this.requiresFocusSelection = modeDefaults.requiresFocusSelection
  1324. this.skipEnabled = modeDefaults.skipEnabled
  1325. this.skipRadiusMeters = getDefaultSkipRadiusMeters(this.gameMode, this.punchRadiusMeters)
  1326. this.skipRequiresConfirm = modeDefaults.skipRequiresConfirm
  1327. this.autoFinishOnLastControl = modeDefaults.autoFinishOnLastControl
  1328. this.defaultControlScore = modeDefaults.defaultControlScore
  1329. this.gpsLockEnabled = false
  1330. this.punchFeedbackTimer = 0
  1331. this.contentCardTimer = 0
  1332. this.contentQuizTimer = 0
  1333. this.contentQuizFeedbackTimer = 0
  1334. this.currentContentCardPriority = 0
  1335. this.shownContentCardKeys = {}
  1336. this.consumedContentQuizKeys = {}
  1337. this.rewardedContentQuizKeys = {}
  1338. this.sessionBonusScore = 0
  1339. this.currentContentCard = null
  1340. this.pendingContentCards = []
  1341. this.currentContentCardH5Request = null
  1342. this.currentH5ExperienceOpen = false
  1343. this.currentContentQuizKey = ''
  1344. this.currentContentQuizAnswer = 0
  1345. this.currentContentQuizBonusScore = 0
  1346. this.sessionQuizCorrectCount = 0
  1347. this.sessionQuizWrongCount = 0
  1348. this.sessionQuizTimeoutCount = 0
  1349. this.mapPulseTimer = 0
  1350. this.stageFxTimer = 0
  1351. this.sessionTimerInterval = 0
  1352. this.hasGpsCenteredOnce = false
  1353. this.state = {
  1354. animationLevel: this.animationLevel,
  1355. buildVersion: this.buildVersion,
  1356. renderMode: RENDER_MODE,
  1357. projectionMode: PROJECTION_MODE,
  1358. mapReady: false,
  1359. mapReadyText: 'BOOTING',
  1360. mapName: '未命名配置',
  1361. configStatusText: '远程配置待加载',
  1362. zoom: DEFAULT_ZOOM,
  1363. rotationDeg: 0,
  1364. rotationText: formatRotationText(0),
  1365. rotationMode: 'manual',
  1366. rotationModeText: formatRotationModeText('manual'),
  1367. rotationToggleText: formatRotationToggleText('manual'),
  1368. orientationMode: 'manual',
  1369. orientationModeText: formatOrientationModeText('manual'),
  1370. sensorHeadingText: '--',
  1371. deviceHeadingText: '--',
  1372. devicePoseText: '竖持',
  1373. headingConfidenceText: '低',
  1374. accelerometerText: '未启用',
  1375. gyroscopeText: '--',
  1376. deviceMotionText: '--',
  1377. compassSourceText: '无数据',
  1378. compassTuningProfile: this.compassTuningProfile,
  1379. compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
  1380. compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
  1381. northReferenceMode: DEFAULT_NORTH_REFERENCE_MODE,
  1382. northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
  1383. autoRotateSourceText: formatAutoRotateSourceText('smart', false),
  1384. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
  1385. northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
  1386. compassNeedleDeg: 0,
  1387. centerTileX: DEFAULT_CENTER_TILE_X,
  1388. centerTileY: DEFAULT_CENTER_TILE_Y,
  1389. centerText: buildCenterText(DEFAULT_ZOOM, DEFAULT_CENTER_TILE_X, DEFAULT_CENTER_TILE_Y),
  1390. tileSource: TILE_SOURCE,
  1391. visibleColumnCount: DESIRED_VISIBLE_COLUMNS,
  1392. visibleTileCount: 0,
  1393. readyTileCount: 0,
  1394. memoryTileCount: 0,
  1395. diskTileCount: 0,
  1396. memoryHitCount: 0,
  1397. diskHitCount: 0,
  1398. networkFetchCount: 0,
  1399. cacheHitRateText: '--',
  1400. tileTranslateX: 0,
  1401. tileTranslateY: 0,
  1402. tileSizePx: 0,
  1403. previewScale: 1,
  1404. stageWidth: 0,
  1405. stageHeight: 0,
  1406. stageLeft: 0,
  1407. stageTop: 0,
  1408. statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
  1409. gpsTracking: false,
  1410. gpsTrackingText: '持续定位待启动',
  1411. gpsLockEnabled: false,
  1412. gpsLockAvailable: false,
  1413. locationSourceMode: 'real',
  1414. locationSourceText: '真实定位',
  1415. mockBridgeConnected: false,
  1416. mockBridgeStatusText: '未连接',
  1417. mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
  1418. mockChannelIdText: 'default',
  1419. mockCoordText: '--',
  1420. mockSpeedText: '--',
  1421. gpsCoordText: '--',
  1422. heartRateSourceMode: 'real',
  1423. heartRateSourceText: '真实心率',
  1424. heartRateConnected: false,
  1425. heartRateStatusText: '心率带未连接',
  1426. heartRateDeviceText: '--',
  1427. heartRateScanText: '未扫描',
  1428. heartRateDiscoveredDevices: [],
  1429. mockHeartRateBridgeConnected: false,
  1430. mockHeartRateBridgeStatusText: '未连接',
  1431. mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr',
  1432. mockHeartRateText: '--',
  1433. mockDebugLogBridgeConnected: false,
  1434. mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)',
  1435. mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log',
  1436. panelTimerText: '00:00:00',
  1437. panelTimerMode: 'elapsed',
  1438. panelMileageText: '0m',
  1439. panelActionTagText: '目标',
  1440. panelDistanceTagText: '点距',
  1441. panelTargetSummaryText: '等待选择目标',
  1442. panelDistanceValueText: '--',
  1443. panelDistanceUnitText: '',
  1444. panelProgressText: '0/0',
  1445. panelSpeedValueText: '0',
  1446. panelTelemetryTone: 'blue',
  1447. trackDisplayMode: DEFAULT_TRACK_VISUALIZATION_CONFIG.mode,
  1448. trackTailLength: DEFAULT_TRACK_VISUALIZATION_CONFIG.tailLength,
  1449. trackColorPreset: DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset,
  1450. trackStyleProfile: DEFAULT_TRACK_VISUALIZATION_CONFIG.style,
  1451. gpsMarkerVisible: DEFAULT_GPS_MARKER_STYLE_CONFIG.visible,
  1452. gpsMarkerStyle: DEFAULT_GPS_MARKER_STYLE_CONFIG.style,
  1453. gpsMarkerSize: DEFAULT_GPS_MARKER_STYLE_CONFIG.size,
  1454. gpsMarkerColorPreset: DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset,
  1455. gpsLogoStatusText: '未配置',
  1456. gpsLogoSourceText: '--',
  1457. panelHeartRateZoneNameText: '激活放松',
  1458. panelHeartRateZoneRangeText: '<=39%',
  1459. panelHeartRateValueText: '--',
  1460. panelHeartRateUnitText: '',
  1461. panelCaloriesValueText: '0',
  1462. panelCaloriesUnitText: 'kcal',
  1463. panelAverageSpeedValueText: '0',
  1464. panelAverageSpeedUnitText: 'km/h',
  1465. panelAccuracyValueText: '--',
  1466. panelAccuracyUnitText: '',
  1467. punchButtonText: '打点',
  1468. gameSessionStatus: 'idle',
  1469. gameModeText: '顺序赛',
  1470. punchButtonEnabled: false,
  1471. skipButtonEnabled: false,
  1472. punchHintText: '等待进入检查点范围',
  1473. punchFeedbackVisible: false,
  1474. punchFeedbackText: '',
  1475. punchFeedbackTone: 'neutral',
  1476. contentCardVisible: false,
  1477. contentCardTemplate: 'story',
  1478. contentCardTitle: '',
  1479. contentCardBody: '',
  1480. contentCardActions: [],
  1481. contentQuizVisible: false,
  1482. contentQuizQuestionText: '',
  1483. contentQuizCountdownText: '',
  1484. contentQuizOptions: [],
  1485. contentQuizFeedbackVisible: false,
  1486. contentQuizFeedbackText: '',
  1487. contentQuizFeedbackTone: 'neutral',
  1488. pendingContentEntryVisible: false,
  1489. pendingContentEntryText: '',
  1490. punchButtonFxClass: '',
  1491. panelProgressFxClass: '',
  1492. panelDistanceFxClass: '',
  1493. punchFeedbackFxClass: '',
  1494. contentCardFxClass: '',
  1495. mapPulseVisible: false,
  1496. mapPulseLeftPx: 0,
  1497. mapPulseTopPx: 0,
  1498. mapPulseFxClass: '',
  1499. stageFxVisible: false,
  1500. stageFxClass: '',
  1501. osmReferenceEnabled: false,
  1502. osmReferenceText: 'OSM参考:关',
  1503. }
  1504. this.previewScale = 1
  1505. this.previewOriginX = 0
  1506. this.previewOriginY = 0
  1507. this.panLastX = 0
  1508. this.panLastY = 0
  1509. this.panLastTimestamp = 0
  1510. this.tapStartX = 0
  1511. this.tapStartY = 0
  1512. this.tapStartAt = 0
  1513. this.panVelocityX = 0
  1514. this.panVelocityY = 0
  1515. this.pinchStartDistance = 0
  1516. this.pinchStartScale = 1
  1517. this.pinchStartAngle = 0
  1518. this.pinchStartRotationDeg = 0
  1519. this.pinchAnchorWorldX = 0
  1520. this.pinchAnchorWorldY = 0
  1521. this.gestureMode = 'idle'
  1522. this.inertiaTimer = 0
  1523. this.previewResetTimer = 0
  1524. this.viewSyncTimer = 0
  1525. this.autoRotateTimer = 0
  1526. this.compassNeedleTimer = 0
  1527. this.compassBootstrapRetryTimer = 0
  1528. this.pendingViewPatch = {}
  1529. this.mounted = false
  1530. this.diagnosticUiEnabled = false
  1531. this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
  1532. this.sensorHeadingDeg = null
  1533. this.smoothedSensorHeadingDeg = null
  1534. this.compassDisplayHeadingDeg = null
  1535. this.targetCompassDisplayHeadingDeg = null
  1536. this.lastCompassSampleAt = 0
  1537. this.compassSource = null
  1538. this.compassTuningProfile = 'balanced'
  1539. this.smoothedMovementHeadingDeg = null
  1540. this.autoRotateHeadingDeg = null
  1541. this.courseHeadingDeg = null
  1542. this.targetAutoRotationDeg = null
  1543. this.autoRotateSourceMode = 'smart'
  1544. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
  1545. this.autoRotateCalibrationPending = false
  1546. this.lastStatsUiSyncAt = 0
  1547. }
  1548. getInitialData(): MapEngineViewState {
  1549. return { ...this.state }
  1550. }
  1551. setDiagnosticUiEnabled(enabled: boolean): void {
  1552. if (this.diagnosticUiEnabled === enabled) {
  1553. return
  1554. }
  1555. this.diagnosticUiEnabled = enabled
  1556. this.mockSimulatorDebugLogger.setEnabled(enabled)
  1557. if (!enabled) {
  1558. return
  1559. }
  1560. this.setState({
  1561. ...this.getTelemetrySensorViewPatch(),
  1562. ...this.getLocationControllerViewPatch(),
  1563. ...this.getHeartRateControllerViewPatch(),
  1564. ...this.getMockDebugLogViewPatch(),
  1565. heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices),
  1566. autoRotateSourceText: this.getAutoRotateSourceText(),
  1567. visibleTileCount: this.state.visibleTileCount,
  1568. readyTileCount: this.state.readyTileCount,
  1569. memoryTileCount: this.state.memoryTileCount,
  1570. diskTileCount: this.state.diskTileCount,
  1571. memoryHitCount: this.state.memoryHitCount,
  1572. diskHitCount: this.state.diskHitCount,
  1573. networkFetchCount: this.state.networkFetchCount,
  1574. cacheHitRateText: this.state.cacheHitRateText,
  1575. }, true)
  1576. }
  1577. getGameInfoSnapshot(): MapEngineGameInfoSnapshot {
  1578. const definition = this.gameRuntime.definition
  1579. const sessionState = this.gameRuntime.state
  1580. const telemetryState = this.telemetryRuntime.state
  1581. const telemetryPresentation = this.telemetryRuntime.getPresentation()
  1582. const currentTarget = definition && sessionState
  1583. ? definition.controls.find((control) => control.id === sessionState.currentTargetControlId) || null
  1584. : null
  1585. const currentTargetText = currentTarget
  1586. ? `${currentTarget.label} / ${currentTarget.kind === 'start'
  1587. ? '开始点'
  1588. : currentTarget.kind === 'finish'
  1589. ? '结束点'
  1590. : '检查点'}`
  1591. : '--'
  1592. const title = this.state.mapName || (definition ? definition.title : '当前游戏')
  1593. const subtitle = `${this.getGameModeText()} / ${formatGameSessionStatusText(this.state.gameSessionStatus)}`
  1594. const localRows: MapEngineGameInfoRow[] = [
  1595. { label: '比赛名称', value: title || '--' },
  1596. { label: '配置版本', value: this.configVersion || '--' },
  1597. { label: 'Schema版本', value: this.configSchemaVersion || '--' },
  1598. { label: '活动ID', value: this.configAppId || '--' },
  1599. { label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) },
  1600. { label: '地图', value: this.state.mapName || '--' },
  1601. { label: '模式', value: this.getGameModeText() },
  1602. { label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) },
  1603. { label: '当前目标', value: currentTargetText },
  1604. { label: '进度', value: this.gamePresentation.hud.progressText || '--' },
  1605. { label: '当前积分', value: sessionState ? String(this.getTotalSessionScore()) : '0' },
  1606. { label: '已完成点', value: sessionState ? String(sessionState.completedControlIds.length) : '0' },
  1607. { label: '已跳过点', value: sessionState ? String(sessionState.skippedControlIds.length) : '0' },
  1608. { label: '打点规则', value: `${this.punchPolicy} / ${this.punchRadiusMeters}m` },
  1609. { label: '跳点规则', value: this.skipEnabled ? `${this.skipRadiusMeters}m / ${this.skipRequiresConfirm ? '确认跳过' : '直接跳过'}` : '关闭' },
  1610. { label: '定位源', value: this.state.locationSourceText || '--' },
  1611. { label: '当前位置', value: this.state.gpsCoordText || '--' },
  1612. { label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` },
  1613. { label: '设备朝向', value: this.state.deviceHeadingText || '--' },
  1614. { label: '设备姿态', value: this.state.devicePoseText || '--' },
  1615. { label: '朝向可信度', value: this.state.headingConfidenceText || '--' },
  1616. { label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' },
  1617. { label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
  1618. { label: '心率源', value: this.state.heartRateSourceText || '--' },
  1619. { label: '当前心率', value: this.state.panelHeartRateValueText === '--' ? '--' : `${this.state.panelHeartRateValueText}${this.state.panelHeartRateUnitText}` },
  1620. { label: '心率设备', value: this.state.heartRateDeviceText || '--' },
  1621. { label: '心率分区', value: this.state.panelHeartRateZoneNameText === '--' ? '--' : `${this.state.panelHeartRateZoneNameText} ${this.state.panelHeartRateZoneRangeText}` },
  1622. { label: '本局用时', value: telemetryPresentation.timerText },
  1623. { label: '累计里程', value: telemetryPresentation.mileageText },
  1624. { label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
  1625. { label: '提示状态', value: this.state.punchHintText || '--' },
  1626. ]
  1627. const globalRows: MapEngineGameInfoRow[] = [
  1628. { label: '全球积分', value: '未接入' },
  1629. { label: '全球排名', value: '未接入' },
  1630. { label: '在线人数', value: '未接入' },
  1631. { label: '队伍状态', value: '未接入' },
  1632. { label: '实时广播', value: '未接入' },
  1633. ]
  1634. return {
  1635. title,
  1636. subtitle,
  1637. localRows,
  1638. globalRows,
  1639. }
  1640. }
  1641. getResultSceneSnapshot(): MapEngineResultSnapshot {
  1642. const sessionState = this.gameRuntime.state || null
  1643. return buildResultSummarySnapshot(
  1644. this.gameRuntime.definition,
  1645. sessionState,
  1646. this.telemetryRuntime.getPresentation(),
  1647. this.state.mapName || (this.gameRuntime.definition ? this.gameRuntime.definition.title : '本局结果'),
  1648. {
  1649. totalScore: this.getTotalSessionScore(),
  1650. baseScore: this.getBaseSessionScore(),
  1651. bonusScore: this.sessionBonusScore,
  1652. quizCorrectCount: this.sessionQuizCorrectCount,
  1653. quizWrongCount: this.sessionQuizWrongCount,
  1654. quizTimeoutCount: this.sessionQuizTimeoutCount,
  1655. },
  1656. )
  1657. }
  1658. buildSessionRecoveryRuntimeSnapshot(): RecoveryRuntimeSnapshot | null {
  1659. const definition = this.gameRuntime.definition
  1660. const state = this.gameRuntime.state
  1661. if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
  1662. return null
  1663. }
  1664. return {
  1665. gameState: {
  1666. status: state.status,
  1667. endReason: state.endReason,
  1668. startedAt: state.startedAt,
  1669. endedAt: state.endedAt,
  1670. completedControlIds: state.completedControlIds.slice(),
  1671. skippedControlIds: state.skippedControlIds.slice(),
  1672. currentTargetControlId: state.currentTargetControlId,
  1673. inRangeControlId: state.inRangeControlId,
  1674. score: state.score,
  1675. guidanceState: state.guidanceState,
  1676. modeState: state.modeState
  1677. ? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
  1678. : null,
  1679. },
  1680. telemetry: this.telemetryRuntime.exportRecoveryState(),
  1681. viewport: {
  1682. zoom: this.state.zoom,
  1683. centerTileX: this.state.centerTileX,
  1684. centerTileY: this.state.centerTileY,
  1685. rotationDeg: this.state.rotationDeg,
  1686. gpsLockEnabled: this.gpsLockEnabled,
  1687. hasGpsCenteredOnce: this.hasGpsCenteredOnce,
  1688. },
  1689. currentGpsPoint: this.currentGpsPoint
  1690. ? {
  1691. lon: this.currentGpsPoint.lon,
  1692. lat: this.currentGpsPoint.lat,
  1693. }
  1694. : null,
  1695. currentGpsAccuracyMeters: this.currentGpsAccuracyMeters,
  1696. currentGpsInsideMap: this.currentGpsInsideMap,
  1697. bonusScore: this.sessionBonusScore,
  1698. quizCorrectCount: this.sessionQuizCorrectCount,
  1699. quizWrongCount: this.sessionQuizWrongCount,
  1700. quizTimeoutCount: this.sessionQuizTimeoutCount,
  1701. }
  1702. }
  1703. restoreSessionRecoveryRuntimeSnapshot(snapshot: RecoveryRuntimeSnapshot): boolean {
  1704. const definition = this.buildCurrentGameDefinition()
  1705. if (!definition) {
  1706. return false
  1707. }
  1708. this.feedbackDirector.reset()
  1709. this.resetTransientGameUiState()
  1710. const result = this.gameRuntime.restoreDefinition(definition, snapshot.gameState)
  1711. this.telemetryRuntime.restoreRecoveryState(
  1712. definition,
  1713. snapshot.gameState,
  1714. snapshot.telemetry,
  1715. result.presentation.hud.hudTargetControlId,
  1716. )
  1717. this.syncGameResultState(result)
  1718. this.currentGpsPoint = snapshot.currentGpsPoint
  1719. ? {
  1720. lon: snapshot.currentGpsPoint.lon,
  1721. lat: snapshot.currentGpsPoint.lat,
  1722. }
  1723. : null
  1724. this.currentGpsAccuracyMeters = snapshot.currentGpsAccuracyMeters
  1725. this.currentGpsInsideMap = snapshot.currentGpsInsideMap
  1726. this.gpsLockEnabled = snapshot.viewport.gpsLockEnabled && !!this.currentGpsPoint && snapshot.currentGpsInsideMap
  1727. this.hasGpsCenteredOnce = snapshot.viewport.hasGpsCenteredOnce || !!this.currentGpsPoint
  1728. this.sessionBonusScore = snapshot.bonusScore
  1729. this.sessionQuizCorrectCount = snapshot.quizCorrectCount
  1730. this.sessionQuizWrongCount = snapshot.quizWrongCount
  1731. this.sessionQuizTimeoutCount = snapshot.quizTimeoutCount
  1732. this.courseOverlayVisible = true
  1733. if (!this.locationController.listening) {
  1734. this.locationController.start()
  1735. }
  1736. this.updateSessionTimerLoop()
  1737. this.commitViewport({
  1738. zoom: snapshot.viewport.zoom,
  1739. centerTileX: snapshot.viewport.centerTileX,
  1740. centerTileY: snapshot.viewport.centerTileY,
  1741. rotationDeg: snapshot.viewport.rotationDeg,
  1742. rotationText: formatRotationText(snapshot.viewport.rotationDeg),
  1743. gpsTracking: !!this.currentGpsPoint,
  1744. gpsTrackingText: this.currentGpsPoint ? '已恢复上一局定位状态' : '已恢复上一局',
  1745. gpsCoordText: formatGpsCoordText(this.currentGpsPoint, this.currentGpsAccuracyMeters),
  1746. gpsLockEnabled: this.gpsLockEnabled,
  1747. gpsLockAvailable: !!this.currentGpsPoint && snapshot.currentGpsInsideMap,
  1748. autoRotateSourceText: this.getAutoRotateSourceText(),
  1749. ...this.getGameViewPatch(`已恢复上一局 (${this.buildVersion})`),
  1750. }, `已恢复上一局 (${this.buildVersion})`, true, () => {
  1751. this.syncRenderer()
  1752. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  1753. this.scheduleAutoRotate()
  1754. }
  1755. })
  1756. return true
  1757. }
  1758. destroy(): void {
  1759. this.clearInertiaTimer()
  1760. this.clearPreviewResetTimer()
  1761. this.clearViewSyncTimer()
  1762. this.clearAutoRotateTimer()
  1763. this.clearCompassNeedleTimer()
  1764. this.clearCompassBootstrapRetryTimer()
  1765. this.clearPunchFeedbackTimer()
  1766. this.clearContentCardTimer()
  1767. this.clearMapPulseTimer()
  1768. this.clearStageFxTimer()
  1769. this.clearSessionTimerInterval()
  1770. this.accelerometerController.destroy()
  1771. this.compassController.destroy()
  1772. this.gyroscopeController.destroy()
  1773. this.deviceMotionController.destroy()
  1774. this.locationController.destroy()
  1775. this.heartRateController.destroy()
  1776. this.feedbackDirector.destroy()
  1777. this.mockSimulatorDebugLogger.destroy()
  1778. this.renderer.destroy()
  1779. this.mounted = false
  1780. }
  1781. handleAppShow(): void {
  1782. this.feedbackDirector.setAppAudioMode('foreground')
  1783. if (this.mounted) {
  1784. this.lastCompassSampleAt = 0
  1785. this.compassController.start()
  1786. this.scheduleCompassBootstrapRetry()
  1787. }
  1788. }
  1789. handleAppHide(): void {
  1790. this.feedbackDirector.setAppAudioMode('foreground')
  1791. }
  1792. clearGameRuntime(): void {
  1793. this.gameRuntime.clear()
  1794. this.telemetryRuntime.reset()
  1795. this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
  1796. this.courseOverlayVisible = !!this.courseData
  1797. this.clearSessionTimerInterval()
  1798. this.setCourseHeading(null)
  1799. }
  1800. clearHeartRateSignal(): void {
  1801. this.telemetryRuntime.dispatch({
  1802. type: 'heart_rate_updated',
  1803. at: Date.now(),
  1804. bpm: null,
  1805. })
  1806. this.syncSessionTimerText()
  1807. }
  1808. clearFinishedTestOverlay(): void {
  1809. this.currentGpsPoint = null
  1810. this.currentGpsTrack = []
  1811. this.currentGpsTrackSamples = []
  1812. this.currentGpsAccuracyMeters = null
  1813. this.currentGpsInsideMap = false
  1814. this.smoothedMovementHeadingDeg = null
  1815. this.lastTrackMotionAt = 0
  1816. this.courseOverlayVisible = false
  1817. this.setCourseHeading(null)
  1818. }
  1819. clearStartSessionResidue(): void {
  1820. this.currentGpsTrack = []
  1821. this.currentGpsTrackSamples = []
  1822. this.smoothedMovementHeadingDeg = null
  1823. this.lastTrackMotionAt = 0
  1824. this.courseOverlayVisible = false
  1825. this.setCourseHeading(null)
  1826. }
  1827. handleClearMapTestArtifacts(): void {
  1828. this.clearFinishedTestOverlay()
  1829. this.setState({
  1830. gpsTracking: false,
  1831. gpsTrackingText: '测试痕迹已清空',
  1832. gpsCoordText: '--',
  1833. statusText: `已清空地图点位与轨迹 (${this.buildVersion})`,
  1834. }, true)
  1835. this.syncRenderer()
  1836. }
  1837. getHudTargetControlId(): string | null {
  1838. return this.gamePresentation.hud.hudTargetControlId
  1839. }
  1840. isSkipAvailable(): boolean {
  1841. const definition = this.gameRuntime.definition
  1842. const state = this.gameRuntime.state
  1843. if (!definition || !state || state.status !== 'running' || !definition.skipEnabled) {
  1844. return false
  1845. }
  1846. const currentTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
  1847. if (!currentTarget || currentTarget.kind !== 'control' || !this.currentGpsPoint) {
  1848. return false
  1849. }
  1850. const avgLatRad = ((currentTarget.point.lat + this.currentGpsPoint.lat) / 2) * Math.PI / 180
  1851. const dx = (this.currentGpsPoint.lon - currentTarget.point.lon) * 111320 * Math.cos(avgLatRad)
  1852. const dy = (this.currentGpsPoint.lat - currentTarget.point.lat) * 110540
  1853. const distanceMeters = Math.sqrt(dx * dx + dy * dy)
  1854. return distanceMeters <= definition.skipRadiusMeters
  1855. }
  1856. shouldConfirmSkipAction(): boolean {
  1857. return !!(this.gameRuntime.definition && this.gameRuntime.definition.skipRequiresConfirm)
  1858. }
  1859. getLocationControllerViewPatch(): Partial<MapEngineViewState> {
  1860. const debugState = this.locationController.getDebugState()
  1861. return {
  1862. gpsTracking: debugState.listening,
  1863. gpsLockEnabled: this.gpsLockEnabled,
  1864. gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
  1865. locationSourceMode: debugState.sourceMode,
  1866. locationSourceText: debugState.sourceModeText,
  1867. mockBridgeConnected: debugState.mockBridgeConnected,
  1868. mockBridgeStatusText: debugState.mockBridgeStatusText,
  1869. mockBridgeUrlText: debugState.mockBridgeUrlText,
  1870. mockChannelIdText: debugState.mockChannelIdText,
  1871. mockCoordText: debugState.mockCoordText,
  1872. mockSpeedText: debugState.mockSpeedText,
  1873. }
  1874. }
  1875. getHeartRateControllerViewPatch(): Partial<MapEngineViewState> {
  1876. const debugState = this.heartRateController.getDebugState()
  1877. return {
  1878. heartRateSourceMode: debugState.sourceMode,
  1879. heartRateSourceText: debugState.sourceModeText,
  1880. mockHeartRateBridgeConnected: debugState.mockBridgeConnected,
  1881. mockHeartRateBridgeStatusText: debugState.mockBridgeStatusText,
  1882. mockHeartRateBridgeUrlText: debugState.mockBridgeUrlText,
  1883. mockHeartRateText: debugState.mockHeartRateText,
  1884. }
  1885. }
  1886. getMockDebugLogViewPatch(): Partial<MapEngineViewState> {
  1887. const debugState = this.mockSimulatorDebugLogger.getState()
  1888. return {
  1889. mockDebugLogBridgeConnected: debugState.connected,
  1890. mockDebugLogBridgeStatusText: debugState.statusText,
  1891. mockDebugLogBridgeUrlText: debugState.url,
  1892. }
  1893. }
  1894. getTelemetrySensorViewPatch(): Partial<MapEngineViewState> {
  1895. const telemetryState = this.telemetryRuntime.state
  1896. return {
  1897. deviceHeadingText: formatHeadingText(
  1898. telemetryState.deviceHeadingDeg === null
  1899. ? null
  1900. : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg),
  1901. ),
  1902. devicePoseText: formatDevicePoseText(telemetryState.devicePose),
  1903. headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence),
  1904. accelerometerText: telemetryState.accelerometer
  1905. ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}`
  1906. : '未启用',
  1907. gyroscopeText: formatGyroscopeText(telemetryState.gyroscope),
  1908. deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion),
  1909. compassSourceText: formatCompassSourceText(this.compassSource),
  1910. compassTuningProfile: this.compassTuningProfile,
  1911. compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile),
  1912. }
  1913. }
  1914. getGameModeText(): string {
  1915. return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
  1916. }
  1917. buildCurrentGameDefinition(): ReturnType<typeof buildGameDefinitionFromCourse> | null {
  1918. if (!this.courseData) {
  1919. return null
  1920. }
  1921. return buildGameDefinitionFromCourse(
  1922. this.courseData,
  1923. this.cpRadiusMeters,
  1924. this.gameMode,
  1925. this.sessionCloseAfterMs,
  1926. this.sessionCloseWarningMs,
  1927. this.minCompletedControlsBeforeFinish,
  1928. this.autoFinishOnLastControl,
  1929. this.punchPolicy,
  1930. this.punchRadiusMeters,
  1931. this.requiresFocusSelection,
  1932. this.skipEnabled,
  1933. this.skipRadiusMeters,
  1934. this.skipRequiresConfirm,
  1935. this.controlScoreOverrides,
  1936. this.defaultControlContentOverride,
  1937. this.controlContentOverrides,
  1938. this.defaultControlScore,
  1939. )
  1940. }
  1941. loadGameDefinitionFromCourse(): GameResult | null {
  1942. const definition = this.buildCurrentGameDefinition()
  1943. if (!definition) {
  1944. this.clearGameRuntime()
  1945. return null
  1946. }
  1947. const result = this.gameRuntime.loadDefinition(definition)
  1948. this.telemetryRuntime.loadDefinition(definition)
  1949. this.courseOverlayVisible = true
  1950. this.syncGameResultState(result)
  1951. this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, result.presentation.hud.hudTargetControlId)
  1952. this.updateSessionTimerLoop()
  1953. return result
  1954. }
  1955. refreshCourseHeadingFromPresentation(): void {
  1956. if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) {
  1957. this.setCourseHeading(null)
  1958. return
  1959. }
  1960. const activeLegIndex = this.gamePresentation.map.activeLegIndices[0]
  1961. const activeLeg = this.courseData.layers.legs[activeLegIndex]
  1962. if (!activeLeg) {
  1963. this.setCourseHeading(null)
  1964. return
  1965. }
  1966. this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint))
  1967. }
  1968. resolveGameStatusText(effects: GameEffect[]): string | null {
  1969. const lastEffect = effects.length ? effects[effects.length - 1] : null
  1970. if (!lastEffect) {
  1971. return null
  1972. }
  1973. if (lastEffect.type === 'control_completed') {
  1974. const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId
  1975. return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})`
  1976. }
  1977. if (lastEffect.type === 'session_finished') {
  1978. return `璺嚎宸插畬鎴?(${this.buildVersion})`
  1979. }
  1980. if (lastEffect.type === 'session_timed_out') {
  1981. return `已到关门时间,超时结束 (${this.buildVersion})`
  1982. }
  1983. if (lastEffect.type === 'session_started') {
  1984. return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})`
  1985. }
  1986. return null
  1987. }
  1988. getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
  1989. const telemetryPresentation = this.telemetryRuntime.getPresentation()
  1990. const patch: Partial<MapEngineViewState> = {
  1991. gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
  1992. gameModeText: this.getGameModeText(),
  1993. panelTimerText: telemetryPresentation.timerText,
  1994. panelTimerMode: telemetryPresentation.timerMode,
  1995. panelMileageText: telemetryPresentation.mileageText,
  1996. panelActionTagText: this.gamePresentation.hud.actionTagText,
  1997. panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
  1998. panelTargetSummaryText: this.gamePresentation.hud.targetSummaryText,
  1999. panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
  2000. panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
  2001. panelSpeedValueText: telemetryPresentation.speedText,
  2002. panelTelemetryTone: telemetryPresentation.heartRateTone,
  2003. panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
  2004. panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
  2005. panelHeartRateValueText: telemetryPresentation.heartRateValueText,
  2006. panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
  2007. panelCaloriesValueText: telemetryPresentation.caloriesValueText,
  2008. panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
  2009. panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
  2010. panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
  2011. panelAccuracyValueText: telemetryPresentation.accuracyValueText,
  2012. panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
  2013. panelProgressText: this.resolveHudProgressText(),
  2014. punchButtonText: this.gamePresentation.hud.punchButtonText,
  2015. punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
  2016. skipButtonEnabled: this.isSkipAvailable(),
  2017. punchHintText: this.gamePresentation.hud.punchHintText,
  2018. gpsLockEnabled: this.gpsLockEnabled,
  2019. gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
  2020. }
  2021. if (statusText) {
  2022. patch.statusText = statusText
  2023. }
  2024. return patch
  2025. }
  2026. clearPunchFeedbackTimer(): void {
  2027. if (this.punchFeedbackTimer) {
  2028. clearTimeout(this.punchFeedbackTimer)
  2029. this.punchFeedbackTimer = 0
  2030. }
  2031. }
  2032. clearContentCardTimer(): void {
  2033. if (this.contentCardTimer) {
  2034. clearTimeout(this.contentCardTimer)
  2035. this.contentCardTimer = 0
  2036. }
  2037. }
  2038. getPendingManualContentCount(): number {
  2039. return this.pendingContentCards.filter((item) => !item.autoPopup).length
  2040. }
  2041. buildPendingContentEntryText(): string {
  2042. const count = this.getPendingManualContentCount()
  2043. if (count <= 1) {
  2044. return count === 1 ? '查看内容' : ''
  2045. }
  2046. return `查看内容(${count})`
  2047. }
  2048. syncPendingContentEntryState(immediate = true): void {
  2049. const count = this.getPendingManualContentCount()
  2050. this.setState({
  2051. pendingContentEntryVisible: count > 0,
  2052. pendingContentEntryText: this.buildPendingContentEntryText(),
  2053. }, immediate)
  2054. }
  2055. getBaseSessionScore(): number {
  2056. return this.gameRuntime.state && typeof this.gameRuntime.state.score === 'number'
  2057. ? this.gameRuntime.state.score
  2058. : 0
  2059. }
  2060. getTotalSessionScore(): number {
  2061. return this.getBaseSessionScore() + this.sessionBonusScore
  2062. }
  2063. resolveHudProgressText(): string {
  2064. const definition = this.gameRuntime.definition
  2065. const sessionState = this.gameRuntime.state
  2066. if (!definition || !sessionState) {
  2067. return this.gamePresentation.hud.progressText
  2068. }
  2069. const scoringControls = definition.controls.filter((control) => control.kind === 'control')
  2070. const scoringControlIdSet = new Set(scoringControls.map((control) => control.id))
  2071. const completedCount = sessionState.completedControlIds.filter((controlId) => scoringControlIdSet.has(controlId)).length
  2072. const skippedCount = sessionState.skippedControlIds.filter((controlId) => scoringControlIdSet.has(controlId)).length
  2073. const totalCount = scoringControls.length
  2074. if (definition.mode === 'score-o') {
  2075. return `${this.getTotalSessionScore()}分 ${completedCount}/${totalCount}`
  2076. }
  2077. return skippedCount > 0
  2078. ? `${completedCount}/${totalCount} 跳${skippedCount}`
  2079. : `${completedCount}/${totalCount}`
  2080. }
  2081. buildContentCardActions(
  2082. ctas: ContentCardCtaConfig[],
  2083. h5Request: H5ExperienceRequest | null,
  2084. contentKey = '',
  2085. ): ContentCardActionViewModel[] {
  2086. const resolved = this.resolveContentControlByKey(contentKey)
  2087. if (resolved && resolved.displayMode === 'click') {
  2088. return []
  2089. }
  2090. const actions = ctas
  2091. .filter((item) => item.type !== 'detail' || !!h5Request)
  2092. .map((item, index) => ({
  2093. key: `cta-${index + 1}`,
  2094. type: item.type,
  2095. label: item.label || buildDefaultContentCardCtaLabel(item.type),
  2096. })) as ContentCardActionViewModel[]
  2097. if (h5Request && !actions.some((item) => item.type === 'detail')) {
  2098. actions.unshift({
  2099. key: 'cta-detail',
  2100. type: 'detail',
  2101. label: '查看详情',
  2102. })
  2103. }
  2104. return actions.slice(0, 3)
  2105. }
  2106. isClickContentCardEntry(item: ContentCardEntry | null): boolean {
  2107. if (!item) {
  2108. return false
  2109. }
  2110. const resolved = this.resolveContentControlByKey(item.contentKey)
  2111. return !!resolved && resolved.displayMode === 'click'
  2112. }
  2113. resolveContentCardAutoDismissMs(
  2114. item: ContentCardEntry,
  2115. actions: ContentCardActionViewModel[],
  2116. ): number {
  2117. if (this.isClickContentCardEntry(item)) {
  2118. return 4000
  2119. }
  2120. return actions.length ? 0 : 2600
  2121. }
  2122. clearContentQuizTimer(): void {
  2123. if (this.contentQuizTimer) {
  2124. clearInterval(this.contentQuizTimer)
  2125. this.contentQuizTimer = 0
  2126. }
  2127. }
  2128. clearContentQuizFeedbackTimer(): void {
  2129. if (this.contentQuizFeedbackTimer) {
  2130. clearTimeout(this.contentQuizFeedbackTimer)
  2131. this.contentQuizFeedbackTimer = 0
  2132. }
  2133. }
  2134. closeContentQuiz(immediate = true): void {
  2135. this.clearContentQuizTimer()
  2136. this.clearContentQuizFeedbackTimer()
  2137. this.currentContentQuizKey = ''
  2138. this.currentContentQuizAnswer = 0
  2139. this.currentContentQuizBonusScore = 0
  2140. this.setState({
  2141. contentQuizVisible: false,
  2142. contentQuizQuestionText: '',
  2143. contentQuizCountdownText: '',
  2144. contentQuizOptions: [],
  2145. contentQuizFeedbackVisible: false,
  2146. contentQuizFeedbackText: '',
  2147. contentQuizFeedbackTone: 'neutral',
  2148. }, immediate)
  2149. }
  2150. buildContentQuizSession(quizConfig: ContentCardQuizConfig): {
  2151. questionText: string
  2152. correctAnswer: number
  2153. options: ContentCardQuizOptionViewModel[]
  2154. } {
  2155. const minValue = Math.max(10, Math.round(quizConfig.minValue))
  2156. const maxValue = Math.max(minValue + 10, Math.round(quizConfig.maxValue))
  2157. const leftValue = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
  2158. const rightValue = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
  2159. const allowSubtraction = quizConfig.allowSubtraction !== false
  2160. const useSubtraction = allowSubtraction && Math.random() < 0.45
  2161. const safeLeft = useSubtraction && leftValue < rightValue ? rightValue : leftValue
  2162. const safeRight = useSubtraction && leftValue < rightValue ? leftValue : rightValue
  2163. const correctAnswer = useSubtraction ? safeLeft - safeRight : leftValue + rightValue
  2164. const questionText = useSubtraction
  2165. ? `${safeLeft} - ${safeRight} = ?`
  2166. : `${leftValue} + ${rightValue} = ?`
  2167. const distractorA = correctAnswer + (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 8) + 2)
  2168. const distractorB = correctAnswer + (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 15) + 9)
  2169. const values = [correctAnswer, distractorA, distractorB]
  2170. .map((item) => Math.max(0, Math.round(item)))
  2171. while (new Set(values).size < 3) {
  2172. values[2] += 7
  2173. }
  2174. const shuffled = values
  2175. .map((value) => ({ sort: Math.random(), value }))
  2176. .sort((a, b) => a.sort - b.sort)
  2177. .map((item) => item.value)
  2178. return {
  2179. questionText,
  2180. correctAnswer,
  2181. options: shuffled.map((value, index) => ({
  2182. key: `quiz-${index + 1}`,
  2183. label: `${value}`,
  2184. })),
  2185. }
  2186. }
  2187. openContentQuizFromEntry(entry: ContentCardEntry): void {
  2188. if (!entry.contentKey) {
  2189. return
  2190. }
  2191. if (this.consumedContentQuizKeys[entry.contentKey]) {
  2192. return
  2193. }
  2194. const quizCta = entry.ctas.find((item) => item.type === 'quiz')
  2195. if (!quizCta) {
  2196. return
  2197. }
  2198. const quizConfig = buildDefaultContentCardQuizConfig(quizCta.quiz)
  2199. const session = this.buildContentQuizSession(quizConfig)
  2200. this.closeContentQuiz(false)
  2201. this.currentContentQuizKey = entry.contentKey
  2202. this.consumedContentQuizKeys[entry.contentKey] = true
  2203. this.currentContentQuizAnswer = session.correctAnswer
  2204. this.currentContentQuizBonusScore = Math.max(0, Math.round(quizConfig.bonusScore))
  2205. const expiresAt = Date.now() + (Math.max(3, quizConfig.countdownSeconds) * 1000)
  2206. const syncCountdown = () => {
  2207. const remainingMs = Math.max(0, expiresAt - Date.now())
  2208. const remainingSeconds = Math.ceil(remainingMs / 1000)
  2209. this.setState({
  2210. contentQuizCountdownText: `${remainingSeconds}s`,
  2211. })
  2212. if (remainingMs <= 0) {
  2213. this.handleContentCardQuizTimeout()
  2214. }
  2215. }
  2216. this.setState({
  2217. contentQuizVisible: true,
  2218. contentQuizQuestionText: session.questionText,
  2219. contentQuizCountdownText: `${Math.max(3, quizConfig.countdownSeconds)}s`,
  2220. contentQuizOptions: session.options,
  2221. contentQuizFeedbackVisible: false,
  2222. contentQuizFeedbackText: '',
  2223. contentQuizFeedbackTone: 'neutral',
  2224. }, true)
  2225. this.contentQuizTimer = setInterval(syncCountdown, 250) as unknown as number
  2226. }
  2227. openCurrentContentCardQuiz(): void {
  2228. if (!this.currentContentCard || !this.currentContentCard.contentKey) {
  2229. return
  2230. }
  2231. this.openContentQuizFromEntry(this.currentContentCard)
  2232. }
  2233. finishContentQuizFeedback(text: string, tone: 'success' | 'error'): void {
  2234. this.clearContentQuizTimer()
  2235. this.clearContentQuizFeedbackTimer()
  2236. this.setState({
  2237. contentQuizFeedbackVisible: true,
  2238. contentQuizFeedbackText: text,
  2239. contentQuizFeedbackTone: tone,
  2240. }, true)
  2241. this.contentQuizFeedbackTimer = setTimeout(() => {
  2242. this.contentQuizFeedbackTimer = 0
  2243. this.closeContentQuiz(true)
  2244. }, 1200) as unknown as number
  2245. }
  2246. handleContentCardQuizAnswer(optionKey: string): void {
  2247. if (!this.state.contentQuizVisible) {
  2248. return
  2249. }
  2250. const option = this.state.contentQuizOptions.find((item) => item.key === optionKey)
  2251. if (!option) {
  2252. return
  2253. }
  2254. const selectedValue = Number(option.label)
  2255. const quizKey = this.currentContentQuizKey
  2256. const isCorrect = selectedValue === this.currentContentQuizAnswer
  2257. if (isCorrect && quizKey && !this.rewardedContentQuizKeys[quizKey]) {
  2258. this.rewardedContentQuizKeys[quizKey] = true
  2259. this.sessionBonusScore += this.currentContentQuizBonusScore
  2260. }
  2261. if (isCorrect) {
  2262. this.sessionQuizCorrectCount += 1
  2263. } else {
  2264. this.sessionQuizWrongCount += 1
  2265. }
  2266. this.feedbackDirector.playAudioCue(isCorrect ? 'control_completed:control' : 'punch_feedback:warning')
  2267. this.finishContentQuizFeedback(isCorrect ? `回答正确 +${this.currentContentQuizBonusScore}分` : '回答错误 未获得加分', isCorrect ? 'success' : 'error')
  2268. }
  2269. handleContentCardQuizTimeout(): void {
  2270. if (!this.state.contentQuizVisible) {
  2271. return
  2272. }
  2273. this.sessionQuizTimeoutCount += 1
  2274. this.feedbackDirector.playAudioCue('punch_feedback:warning')
  2275. this.finishContentQuizFeedback('答题超时 未获得加分', 'error')
  2276. }
  2277. resolveContentControlByKey(contentKey: string): { control: GameControl; displayMode: 'auto' | 'click' } | null {
  2278. if (!contentKey || !this.gameRuntime.definition) {
  2279. return null
  2280. }
  2281. const isClickContent = contentKey.indexOf(':click') >= 0
  2282. const controlId = isClickContent ? contentKey.replace(/:click$/, '') : contentKey
  2283. const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
  2284. if (!control || !control.displayContent) {
  2285. return null
  2286. }
  2287. return {
  2288. control,
  2289. displayMode: isClickContent ? 'click' : 'auto',
  2290. }
  2291. }
  2292. buildContentH5Request(
  2293. contentKey: string,
  2294. title: string,
  2295. body: string,
  2296. motionClass: string,
  2297. once: boolean,
  2298. priority: number,
  2299. autoPopup: boolean,
  2300. ): H5ExperienceRequest | null {
  2301. const resolved = this.resolveContentControlByKey(contentKey)
  2302. if (!resolved) {
  2303. return null
  2304. }
  2305. const displayContent = resolved.control.displayContent
  2306. if (!displayContent) {
  2307. return null
  2308. }
  2309. const experienceConfig = resolved.displayMode === 'click'
  2310. ? displayContent.clickExperience
  2311. : displayContent.contentExperience
  2312. if (!experienceConfig || experienceConfig.type !== 'h5' || !experienceConfig.url) {
  2313. return null
  2314. }
  2315. return {
  2316. kind: 'content',
  2317. title: title || resolved.control.label || '内容体验',
  2318. subtitle: resolved.displayMode === 'click' ? '点击查看内容' : '打点内容体验',
  2319. url: experienceConfig.url,
  2320. bridgeVersion: experienceConfig.bridge || 'content-v1',
  2321. presentation: experienceConfig.presentation || 'sheet',
  2322. context: {
  2323. eventId: this.configAppId || '',
  2324. configTitle: this.state.mapName || '',
  2325. configVersion: this.configVersion || '',
  2326. mode: this.gameMode,
  2327. sessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
  2328. controlId: resolved.control.id,
  2329. controlKind: resolved.control.kind,
  2330. controlCode: resolved.control.code,
  2331. controlLabel: resolved.control.label,
  2332. controlSequence: resolved.control.sequence,
  2333. displayMode: resolved.displayMode,
  2334. title,
  2335. body,
  2336. },
  2337. fallback: {
  2338. title,
  2339. body,
  2340. motionClass,
  2341. contentKey,
  2342. once,
  2343. priority,
  2344. autoPopup,
  2345. },
  2346. }
  2347. }
  2348. buildControlContentCardEntry(
  2349. contentKey: string,
  2350. options: {
  2351. title?: string
  2352. body?: string
  2353. motionClass?: string
  2354. autoPopup?: boolean
  2355. once?: boolean
  2356. priority?: number
  2357. } = {},
  2358. ): ContentCardEntry | null {
  2359. const resolved = this.resolveContentControlByKey(contentKey)
  2360. if (!resolved || !resolved.control.displayContent) {
  2361. return null
  2362. }
  2363. const displayContent = resolved.control.displayContent
  2364. const motionClass = options.motionClass || ''
  2365. const autoPopup = options.autoPopup !== false
  2366. const once = options.once !== undefined ? options.once : displayContent.once
  2367. const priority = typeof options.priority === 'number' ? options.priority : displayContent.priority
  2368. const title = options.title !== undefined ? options.title : displayContent.title
  2369. const body = options.body !== undefined ? options.body : displayContent.body
  2370. return {
  2371. template: displayContent.template,
  2372. title,
  2373. body,
  2374. motionClass,
  2375. contentKey,
  2376. once,
  2377. priority,
  2378. autoPopup,
  2379. ctas: displayContent.ctas,
  2380. h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup),
  2381. }
  2382. }
  2383. removePendingContentCardsByKey(contentKey: string): void {
  2384. if (!contentKey || !this.pendingContentCards.length) {
  2385. return
  2386. }
  2387. const nextPendingCards = this.pendingContentCards.filter((item) => item.contentKey !== contentKey)
  2388. if (nextPendingCards.length === this.pendingContentCards.length) {
  2389. return
  2390. }
  2391. this.pendingContentCards = nextPendingCards
  2392. this.syncPendingContentEntryState()
  2393. }
  2394. removePendingClickContentCards(): void {
  2395. if (!this.pendingContentCards.length) {
  2396. return
  2397. }
  2398. const nextPendingCards = this.pendingContentCards.filter((item) => item.contentKey.indexOf(':click') < 0)
  2399. if (nextPendingCards.length === this.pendingContentCards.length) {
  2400. return
  2401. }
  2402. this.pendingContentCards = nextPendingCards
  2403. this.syncPendingContentEntryState()
  2404. }
  2405. replaceVisibleContentCard(item: ContentCardEntry): void {
  2406. this.clearContentCardTimer()
  2407. this.closeContentQuiz(false)
  2408. this.removePendingClickContentCards()
  2409. this.currentContentCardPriority = 0
  2410. this.currentContentCard = null
  2411. this.currentContentCardH5Request = null
  2412. this.currentH5ExperienceOpen = false
  2413. this.setState({
  2414. contentCardVisible: false,
  2415. contentCardTemplate: 'story',
  2416. contentCardTitle: '',
  2417. contentCardBody: '',
  2418. contentCardFxClass: '',
  2419. contentCardActions: [],
  2420. }, true)
  2421. this.openContentCardEntry(item)
  2422. }
  2423. applyAutoContentQuizEffects(effects: GameEffect[]): void {
  2424. for (let index = effects.length - 1; index >= 0; index -= 1) {
  2425. const effect = effects[index]
  2426. if (effect.type !== 'control_completed' || !effect.autoOpenQuiz) {
  2427. continue
  2428. }
  2429. let readyForQuiz = !!this.currentContentCard && this.currentContentCard.contentKey === effect.controlId
  2430. if (!readyForQuiz) {
  2431. const entry = this.buildControlContentCardEntry(effect.controlId, {
  2432. title: effect.displayTitle,
  2433. body: effect.displayBody,
  2434. autoPopup: effect.displayAutoPopup,
  2435. once: effect.displayOnce,
  2436. priority: effect.displayPriority + 100,
  2437. })
  2438. if (!entry) {
  2439. continue
  2440. }
  2441. this.removePendingContentCardsByKey(effect.controlId)
  2442. if (effect.displayAutoPopup) {
  2443. this.openContentCardEntry(entry)
  2444. readyForQuiz = true
  2445. } else {
  2446. this.currentContentCardPriority = entry.priority
  2447. this.currentContentCard = entry
  2448. this.currentContentCardH5Request = entry.h5Request
  2449. readyForQuiz = true
  2450. }
  2451. }
  2452. if (readyForQuiz && this.currentContentCard && this.currentContentCard.ctas.some((item) => item.type === 'quiz')) {
  2453. this.openContentQuizFromEntry(this.currentContentCard)
  2454. }
  2455. return
  2456. }
  2457. }
  2458. hasActiveContentExperience(): boolean {
  2459. return this.state.contentCardVisible || this.currentH5ExperienceOpen
  2460. }
  2461. enqueueContentCard(item: ContentCardEntry): void {
  2462. if (item.once && item.contentKey && this.shownContentCardKeys[item.contentKey]) {
  2463. return
  2464. }
  2465. if (item.contentKey && this.pendingContentCards.some((pending) => pending.contentKey === item.contentKey && pending.autoPopup === item.autoPopup)) {
  2466. return
  2467. }
  2468. this.pendingContentCards.push(item)
  2469. this.syncPendingContentEntryState()
  2470. }
  2471. openContentCardEntry(item: ContentCardEntry): void {
  2472. this.clearContentCardTimer()
  2473. this.closeContentQuiz(false)
  2474. const actions = this.buildContentCardActions(item.ctas, item.h5Request, item.contentKey)
  2475. const autoDismissMs = this.resolveContentCardAutoDismissMs(item, actions)
  2476. this.setState({
  2477. contentCardVisible: true,
  2478. contentCardTemplate: item.template,
  2479. contentCardTitle: item.title,
  2480. contentCardBody: item.body,
  2481. contentCardActions: actions,
  2482. contentCardFxClass: item.motionClass,
  2483. pendingContentEntryVisible: false,
  2484. pendingContentEntryText: '',
  2485. }, true)
  2486. this.currentContentCardPriority = item.priority
  2487. this.currentContentCard = item
  2488. this.currentContentCardH5Request = item.h5Request
  2489. if (item.once && item.contentKey) {
  2490. this.shownContentCardKeys[item.contentKey] = true
  2491. }
  2492. if (autoDismissMs <= 0) {
  2493. return
  2494. }
  2495. this.contentCardTimer = setTimeout(() => {
  2496. this.contentCardTimer = 0
  2497. this.currentContentCardPriority = 0
  2498. this.currentContentCard = null
  2499. this.currentContentCardH5Request = null
  2500. this.setState({
  2501. contentCardVisible: false,
  2502. contentCardTemplate: 'story',
  2503. contentCardFxClass: '',
  2504. contentCardActions: [],
  2505. }, true)
  2506. this.flushQueuedContentCards()
  2507. }, autoDismissMs) as unknown as number
  2508. }
  2509. openCurrentContentCardDetail(): void {
  2510. if (!this.currentContentCard) {
  2511. this.setState({
  2512. statusText: `当前没有可打开的内容详情 (${this.buildVersion})`,
  2513. }, true)
  2514. return
  2515. }
  2516. if (!this.currentContentCardH5Request) {
  2517. this.setState({
  2518. statusText: `当前内容未配置 H5 详情 (${this.buildVersion})`,
  2519. }, true)
  2520. return
  2521. }
  2522. if (!this.onOpenH5Experience) {
  2523. this.setState({
  2524. statusText: `H5 详情入口未就绪 (${this.buildVersion})`,
  2525. }, true)
  2526. return
  2527. }
  2528. if (this.currentH5ExperienceOpen) {
  2529. this.setState({
  2530. statusText: `H5 详情页已在打开中 (${this.buildVersion})`,
  2531. }, true)
  2532. return
  2533. }
  2534. const request = this.currentContentCardH5Request
  2535. this.clearContentCardTimer()
  2536. this.closeContentQuiz(false)
  2537. this.setState({
  2538. contentCardVisible: false,
  2539. contentCardTemplate: 'story',
  2540. contentCardTitle: '',
  2541. contentCardBody: '',
  2542. contentCardFxClass: '',
  2543. contentCardActions: [],
  2544. }, true)
  2545. this.currentH5ExperienceOpen = true
  2546. try {
  2547. this.onOpenH5Experience(request)
  2548. } catch {
  2549. this.currentH5ExperienceOpen = false
  2550. this.openContentCardEntry({
  2551. ...this.currentContentCard,
  2552. h5Request: null,
  2553. })
  2554. }
  2555. }
  2556. openCurrentContentCardAction(actionType: string): 'detail' | 'photo' | 'audio' | 'quiz' | null {
  2557. if (!this.currentContentCard) {
  2558. return null
  2559. }
  2560. if (actionType === 'detail') {
  2561. this.openCurrentContentCardDetail()
  2562. return 'detail'
  2563. }
  2564. if (actionType === 'quiz') {
  2565. this.openCurrentContentCardQuiz()
  2566. return 'quiz'
  2567. }
  2568. if (actionType === 'photo') {
  2569. return 'photo'
  2570. }
  2571. if (actionType === 'audio') {
  2572. return 'audio'
  2573. }
  2574. return null
  2575. }
  2576. handleContentCardPhotoCaptured(): void {
  2577. this.setState({
  2578. statusText: `已完成拍照,照片待接入上传 (${this.buildVersion})`,
  2579. }, true)
  2580. }
  2581. handleContentCardAudioRecorded(): void {
  2582. this.setState({
  2583. statusText: `已完成录音,音频待接入上传 (${this.buildVersion})`,
  2584. }, true)
  2585. }
  2586. flushQueuedContentCards(): void {
  2587. if (this.state.contentCardVisible || !this.pendingContentCards.length) {
  2588. this.syncPendingContentEntryState()
  2589. return
  2590. }
  2591. let candidateIndex = -1
  2592. let candidatePriority = Number.NEGATIVE_INFINITY
  2593. for (let index = 0; index < this.pendingContentCards.length; index += 1) {
  2594. const item = this.pendingContentCards[index]
  2595. if (!item.autoPopup) {
  2596. continue
  2597. }
  2598. if (item.priority > candidatePriority) {
  2599. candidatePriority = item.priority
  2600. candidateIndex = index
  2601. }
  2602. }
  2603. if (candidateIndex < 0) {
  2604. this.syncPendingContentEntryState()
  2605. return
  2606. }
  2607. const nextItem = this.pendingContentCards.splice(candidateIndex, 1)[0]
  2608. this.openContentCardEntry(nextItem)
  2609. }
  2610. clearMapPulseTimer(): void {
  2611. if (this.mapPulseTimer) {
  2612. clearTimeout(this.mapPulseTimer)
  2613. this.mapPulseTimer = 0
  2614. }
  2615. }
  2616. clearStageFxTimer(): void {
  2617. if (this.stageFxTimer) {
  2618. clearTimeout(this.stageFxTimer)
  2619. this.stageFxTimer = 0
  2620. }
  2621. }
  2622. resetTransientGameUiState(): void {
  2623. this.clearPunchFeedbackTimer()
  2624. this.clearContentCardTimer()
  2625. this.closeContentQuiz(false)
  2626. this.clearMapPulseTimer()
  2627. this.clearStageFxTimer()
  2628. this.setState({
  2629. punchFeedbackVisible: false,
  2630. punchFeedbackText: '',
  2631. punchFeedbackTone: 'neutral',
  2632. punchFeedbackFxClass: '',
  2633. contentCardVisible: false,
  2634. contentCardTemplate: 'story',
  2635. contentCardTitle: '',
  2636. contentCardBody: '',
  2637. contentCardActions: [],
  2638. pendingContentEntryVisible: this.getPendingManualContentCount() > 0,
  2639. pendingContentEntryText: this.buildPendingContentEntryText(),
  2640. contentCardFxClass: '',
  2641. mapPulseVisible: false,
  2642. mapPulseFxClass: '',
  2643. stageFxVisible: false,
  2644. stageFxClass: '',
  2645. punchButtonFxClass: '',
  2646. panelProgressFxClass: '',
  2647. panelDistanceFxClass: '',
  2648. }, true)
  2649. this.currentContentCardPriority = 0
  2650. this.currentContentCard = null
  2651. this.currentContentCardH5Request = null
  2652. this.currentH5ExperienceOpen = false
  2653. }
  2654. resetSessionContentExperienceState(): void {
  2655. this.shownContentCardKeys = {}
  2656. this.consumedContentQuizKeys = {}
  2657. this.rewardedContentQuizKeys = {}
  2658. this.sessionBonusScore = 0
  2659. this.sessionQuizCorrectCount = 0
  2660. this.sessionQuizWrongCount = 0
  2661. this.sessionQuizTimeoutCount = 0
  2662. this.currentContentCardPriority = 0
  2663. this.currentContentCard = null
  2664. this.currentContentCardH5Request = null
  2665. this.pendingContentCards = []
  2666. this.currentH5ExperienceOpen = false
  2667. this.closeContentQuiz(false)
  2668. this.setState({
  2669. pendingContentEntryVisible: false,
  2670. pendingContentEntryText: '',
  2671. })
  2672. }
  2673. clearSessionTimerInterval(): void {
  2674. if (this.sessionTimerInterval) {
  2675. clearInterval(this.sessionTimerInterval)
  2676. this.sessionTimerInterval = 0
  2677. }
  2678. }
  2679. syncSessionTimerText(): void {
  2680. const telemetryPresentation = this.telemetryRuntime.getPresentation()
  2681. this.setState({
  2682. panelTimerText: telemetryPresentation.timerText,
  2683. panelTimerMode: telemetryPresentation.timerMode,
  2684. panelMileageText: telemetryPresentation.mileageText,
  2685. panelActionTagText: this.gamePresentation.hud.actionTagText,
  2686. panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
  2687. panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
  2688. panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
  2689. panelSpeedValueText: telemetryPresentation.speedText,
  2690. panelTelemetryTone: telemetryPresentation.heartRateTone,
  2691. panelHeartRateZoneNameText: telemetryPresentation.heartRateZoneNameText,
  2692. panelHeartRateZoneRangeText: telemetryPresentation.heartRateZoneRangeText,
  2693. panelHeartRateValueText: telemetryPresentation.heartRateValueText,
  2694. panelHeartRateUnitText: telemetryPresentation.heartRateUnitText,
  2695. panelCaloriesValueText: telemetryPresentation.caloriesValueText,
  2696. panelCaloriesUnitText: telemetryPresentation.caloriesUnitText,
  2697. panelAverageSpeedValueText: telemetryPresentation.averageSpeedValueText,
  2698. panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
  2699. panelAccuracyValueText: telemetryPresentation.accuracyValueText,
  2700. panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
  2701. })
  2702. }
  2703. shouldAutoCloseSession(now = Date.now()): boolean {
  2704. const definition = this.gameRuntime.definition
  2705. const state = this.gameRuntime.state
  2706. if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
  2707. return false
  2708. }
  2709. return now - state.startedAt >= definition.sessionCloseAfterMs
  2710. }
  2711. handleSessionCloseTimeout(now = Date.now()): void {
  2712. if (!this.shouldAutoCloseSession(now)) {
  2713. return
  2714. }
  2715. this.clearSessionTimerInterval()
  2716. const result = this.gameRuntime.dispatch({
  2717. type: 'session_timed_out',
  2718. at: now,
  2719. })
  2720. this.commitGameResult(result, `已到关门时间,超时结束 (${this.buildVersion})`)
  2721. }
  2722. applyDebugSessionElapsedMs(elapsedMs: number, labelText: string): void {
  2723. const definition = this.gameRuntime.definition
  2724. const state = this.gameRuntime.state
  2725. if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
  2726. this.setState({
  2727. statusText: `当前对局未在进行中,无法调整${labelText} (${this.buildVersion})`,
  2728. }, true)
  2729. return
  2730. }
  2731. const boundedElapsedMs = Math.max(0, Math.min(elapsedMs, Math.max(0, definition.sessionCloseAfterMs - 1000)))
  2732. const now = Date.now()
  2733. const nextState = {
  2734. ...state,
  2735. startedAt: now - boundedElapsedMs,
  2736. endedAt: null,
  2737. status: 'running' as const,
  2738. }
  2739. this.gameRuntime.state = nextState
  2740. this.telemetryRuntime.syncGameState(this.gameRuntime.definition, nextState, this.getHudTargetControlId())
  2741. this.updateSessionTimerLoop()
  2742. this.setState({
  2743. ...this.getGameViewPatch(`调试已设置${labelText} (${this.buildVersion})`),
  2744. }, true)
  2745. }
  2746. handleDebugSetSessionRemainingWarning(): void {
  2747. const definition = this.gameRuntime.definition
  2748. if (!definition) {
  2749. return
  2750. }
  2751. this.applyDebugSessionElapsedMs(
  2752. Math.max(0, definition.sessionCloseAfterMs - definition.sessionCloseWarningMs),
  2753. '剩余10分钟',
  2754. )
  2755. }
  2756. handleDebugSetSessionRemainingOneMinute(): void {
  2757. const definition = this.gameRuntime.definition
  2758. if (!definition) {
  2759. return
  2760. }
  2761. this.applyDebugSessionElapsedMs(
  2762. Math.max(0, definition.sessionCloseAfterMs - 60 * 1000),
  2763. '剩余1分钟',
  2764. )
  2765. }
  2766. handleDebugTimeoutSession(): void {
  2767. const definition = this.gameRuntime.definition
  2768. const state = this.gameRuntime.state
  2769. if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) {
  2770. this.setState({
  2771. statusText: `当前对局未在进行中,无法触发超时 (${this.buildVersion})`,
  2772. }, true)
  2773. return
  2774. }
  2775. this.gameRuntime.state = {
  2776. ...state,
  2777. startedAt: Date.now() - definition.sessionCloseAfterMs,
  2778. }
  2779. this.handleSessionCloseTimeout(Date.now())
  2780. }
  2781. updateSessionTimerLoop(): void {
  2782. const gameState = this.gameRuntime.state
  2783. const shouldRun = !!gameState && gameState.status === 'running' && gameState.endedAt === null
  2784. this.syncSessionTimerText()
  2785. if (this.shouldAutoCloseSession()) {
  2786. this.handleSessionCloseTimeout()
  2787. return
  2788. }
  2789. if (!shouldRun) {
  2790. this.clearSessionTimerInterval()
  2791. return
  2792. }
  2793. if (this.sessionTimerInterval) {
  2794. return
  2795. }
  2796. this.sessionTimerInterval = setInterval(() => {
  2797. this.syncSessionTimerText()
  2798. if (this.shouldAutoCloseSession()) {
  2799. this.handleSessionCloseTimeout()
  2800. }
  2801. }, 1000) as unknown as number
  2802. }
  2803. getControlScreenPoint(controlId: string): { x: number; y: number } | null {
  2804. if (!this.gameRuntime.definition || !this.state.stageWidth || !this.state.stageHeight) {
  2805. return null
  2806. }
  2807. const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
  2808. if (!control) {
  2809. return null
  2810. }
  2811. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  2812. const screenPoint = worldToScreen({
  2813. centerWorldX: exactCenter.x,
  2814. centerWorldY: exactCenter.y,
  2815. viewportWidth: this.state.stageWidth,
  2816. viewportHeight: this.state.stageHeight,
  2817. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  2818. rotationRad: this.getRotationRad(this.state.rotationDeg),
  2819. }, lonLatToWorldTile(control.point, this.state.zoom), false)
  2820. if (screenPoint.x < -80 || screenPoint.x > this.state.stageWidth + 80 || screenPoint.y < -80 || screenPoint.y > this.state.stageHeight + 80) {
  2821. return null
  2822. }
  2823. return screenPoint
  2824. }
  2825. setPunchButtonFxClass(className: string): void {
  2826. this.setState({
  2827. punchButtonFxClass: className,
  2828. }, true)
  2829. }
  2830. setHudProgressFxClass(className: string): void {
  2831. this.setState({
  2832. panelProgressFxClass: className,
  2833. }, true)
  2834. }
  2835. setHudDistanceFxClass(className: string): void {
  2836. this.setState({
  2837. panelDistanceFxClass: className,
  2838. }, true)
  2839. }
  2840. showMapPulse(controlId: string, motionClass = ''): void {
  2841. const screenPoint = this.getControlScreenPoint(controlId)
  2842. if (!screenPoint) {
  2843. return
  2844. }
  2845. this.clearMapPulseTimer()
  2846. this.setState({
  2847. mapPulseVisible: true,
  2848. mapPulseLeftPx: screenPoint.x,
  2849. mapPulseTopPx: screenPoint.y,
  2850. mapPulseFxClass: motionClass,
  2851. }, true)
  2852. this.mapPulseTimer = setTimeout(() => {
  2853. this.mapPulseTimer = 0
  2854. this.setState({
  2855. mapPulseVisible: false,
  2856. mapPulseFxClass: '',
  2857. }, true)
  2858. }, 820) as unknown as number
  2859. }
  2860. showStageFx(className: string): void {
  2861. if (!className) {
  2862. return
  2863. }
  2864. this.clearStageFxTimer()
  2865. this.setState({
  2866. stageFxVisible: true,
  2867. stageFxClass: className,
  2868. }, true)
  2869. this.stageFxTimer = setTimeout(() => {
  2870. this.stageFxTimer = 0
  2871. this.setState({
  2872. stageFxVisible: false,
  2873. stageFxClass: '',
  2874. }, true)
  2875. }, 760) as unknown as number
  2876. }
  2877. showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning', motionClass = ''): void {
  2878. this.clearPunchFeedbackTimer()
  2879. this.setState({
  2880. punchFeedbackVisible: true,
  2881. punchFeedbackText: text,
  2882. punchFeedbackTone: tone,
  2883. punchFeedbackFxClass: motionClass,
  2884. }, true)
  2885. this.punchFeedbackTimer = setTimeout(() => {
  2886. this.punchFeedbackTimer = 0
  2887. this.setState({
  2888. punchFeedbackVisible: false,
  2889. punchFeedbackFxClass: '',
  2890. }, true)
  2891. }, 1400) as unknown as number
  2892. }
  2893. showContentCard(title: string, body: string, motionClass = '', options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }): void {
  2894. const autoPopup = !options || options.autoPopup !== false
  2895. const once = !!(options && options.once)
  2896. const priority = options && typeof options.priority === 'number' ? options.priority : 0
  2897. const contentKey = options && options.contentKey ? options.contentKey : ''
  2898. const resolved = this.resolveContentControlByKey(contentKey)
  2899. const resolvedCtas = resolved && resolved.control.displayContent ? resolved.control.displayContent.ctas : []
  2900. const h5Request = this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup)
  2901. const entry = {
  2902. template: resolved && resolved.control.displayContent ? resolved.control.displayContent.template : 'story',
  2903. title,
  2904. body,
  2905. motionClass,
  2906. contentKey,
  2907. once,
  2908. priority,
  2909. autoPopup,
  2910. ctas: resolvedCtas,
  2911. h5Request,
  2912. }
  2913. if (once && contentKey && this.shownContentCardKeys[contentKey]) {
  2914. return
  2915. }
  2916. if (!autoPopup) {
  2917. this.enqueueContentCard(entry)
  2918. return
  2919. }
  2920. if (this.currentH5ExperienceOpen) {
  2921. this.enqueueContentCard(entry)
  2922. return
  2923. }
  2924. if (this.state.contentCardVisible) {
  2925. if (priority > this.currentContentCardPriority) {
  2926. this.openContentCardEntry(entry)
  2927. return
  2928. }
  2929. this.enqueueContentCard(entry)
  2930. return
  2931. }
  2932. this.openContentCardEntry(entry)
  2933. }
  2934. closeContentCard(): void {
  2935. this.clearContentCardTimer()
  2936. this.closeContentQuiz(false)
  2937. this.currentContentCardPriority = 0
  2938. this.currentContentCard = null
  2939. this.currentContentCardH5Request = null
  2940. this.currentH5ExperienceOpen = false
  2941. this.setState({
  2942. contentCardVisible: false,
  2943. contentCardTemplate: 'story',
  2944. contentCardTitle: '',
  2945. contentCardBody: '',
  2946. contentCardFxClass: '',
  2947. contentCardActions: [],
  2948. }, true)
  2949. this.flushQueuedContentCards()
  2950. }
  2951. openPendingContentCard(): void {
  2952. if (!this.pendingContentCards.length) {
  2953. return
  2954. }
  2955. let candidateIndex = -1
  2956. let candidatePriority = Number.NEGATIVE_INFINITY
  2957. for (let index = 0; index < this.pendingContentCards.length; index += 1) {
  2958. const item = this.pendingContentCards[index]
  2959. if (item.autoPopup) {
  2960. continue
  2961. }
  2962. if (item.priority > candidatePriority) {
  2963. candidatePriority = item.priority
  2964. candidateIndex = index
  2965. }
  2966. }
  2967. if (candidateIndex < 0) {
  2968. return
  2969. }
  2970. const pending = this.pendingContentCards.splice(candidateIndex, 1)[0]
  2971. this.openContentCardEntry({
  2972. ...pending,
  2973. autoPopup: true,
  2974. })
  2975. }
  2976. handleH5ExperienceClosed(): void {
  2977. this.currentH5ExperienceOpen = false
  2978. this.currentContentCardPriority = 0
  2979. this.currentContentCard = null
  2980. this.currentContentCardH5Request = null
  2981. this.flushQueuedContentCards()
  2982. }
  2983. handleH5ExperienceFallback(fallback: H5ExperienceFallbackPayload): void {
  2984. this.currentH5ExperienceOpen = false
  2985. this.currentContentCardPriority = 0
  2986. this.currentContentCard = null
  2987. this.currentContentCardH5Request = null
  2988. this.openContentCardEntry({
  2989. template: 'story',
  2990. ...fallback,
  2991. ctas: [],
  2992. h5Request: null,
  2993. })
  2994. }
  2995. clearContentExperienceForResultScene(): void {
  2996. this.clearContentCardTimer()
  2997. this.closeContentQuiz(false)
  2998. this.currentContentCardPriority = 0
  2999. this.currentContentCard = null
  3000. this.currentContentCardH5Request = null
  3001. this.currentH5ExperienceOpen = false
  3002. this.pendingContentCards = []
  3003. this.setState({
  3004. contentCardVisible: false,
  3005. contentCardTemplate: 'story',
  3006. contentCardTitle: '',
  3007. contentCardBody: '',
  3008. contentCardFxClass: '',
  3009. contentCardActions: [],
  3010. pendingContentEntryVisible: false,
  3011. pendingContentEntryText: '',
  3012. }, true)
  3013. }
  3014. applyGameEffects(effects: GameEffect[]): string | null {
  3015. this.feedbackDirector.handleEffects(effects)
  3016. this.applyAutoContentQuizEffects(effects)
  3017. if (effects.some((effect) => effect.type === 'session_finished' || effect.type === 'session_timed_out')) {
  3018. this.clearContentExperienceForResultScene()
  3019. if (this.locationController.listening) {
  3020. this.locationController.stop()
  3021. }
  3022. this.setState({
  3023. gpsTracking: false,
  3024. gpsTrackingText: '测试结束,定位已停止',
  3025. }, true)
  3026. }
  3027. this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
  3028. this.updateSessionTimerLoop()
  3029. return this.resolveGameStatusText(effects)
  3030. }
  3031. syncGameResultState(result: GameResult): void {
  3032. this.gamePresentation = result.presentation
  3033. this.refreshCourseHeadingFromPresentation()
  3034. }
  3035. resolveAppliedGameStatusText(result: GameResult, fallbackStatusText?: string | null): string | null {
  3036. return this.applyGameEffects(result.effects) || fallbackStatusText || this.resolveGameStatusText(result.effects)
  3037. }
  3038. commitGameResult(
  3039. result: GameResult,
  3040. fallbackStatusText?: string | null,
  3041. extraPatch: Partial<MapEngineViewState> = {},
  3042. syncRenderer = true,
  3043. ): string | null {
  3044. this.syncGameResultState(result)
  3045. const gameStatusText = this.resolveAppliedGameStatusText(result, fallbackStatusText)
  3046. this.setState({
  3047. ...this.getGameViewPatch(gameStatusText),
  3048. ...extraPatch,
  3049. }, true)
  3050. if (syncRenderer) {
  3051. this.syncRenderer()
  3052. }
  3053. return gameStatusText
  3054. }
  3055. handleStartGame(): void {
  3056. if (!this.gameRuntime.definition || !this.gameRuntime.state) {
  3057. this.setState({
  3058. statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
  3059. }, true)
  3060. return
  3061. }
  3062. if (this.gameRuntime.state.status !== 'idle') {
  3063. if (this.gameRuntime.state.status === 'finished' || this.gameRuntime.state.status === 'failed') {
  3064. const reloadedResult = this.loadGameDefinitionFromCourse()
  3065. if (!reloadedResult || !this.gameRuntime.state) {
  3066. return
  3067. }
  3068. } else {
  3069. return
  3070. }
  3071. }
  3072. this.feedbackDirector.reset()
  3073. this.resetTransientGameUiState()
  3074. this.resetSessionContentExperienceState()
  3075. this.clearStartSessionResidue()
  3076. if (!this.locationController.listening) {
  3077. this.locationController.start()
  3078. }
  3079. const startedAt = Date.now()
  3080. const startResult = this.gameRuntime.startSession(startedAt)
  3081. let gameResult = startResult
  3082. if (this.currentGpsPoint) {
  3083. const gpsResult = this.gameRuntime.dispatch({
  3084. type: 'gps_updated',
  3085. at: Date.now(),
  3086. lon: this.currentGpsPoint.lon,
  3087. lat: this.currentGpsPoint.lat,
  3088. accuracyMeters: this.currentGpsAccuracyMeters,
  3089. })
  3090. gameResult = {
  3091. nextState: gpsResult.nextState,
  3092. presentation: gpsResult.presentation,
  3093. effects: [...startResult.effects, ...gpsResult.effects],
  3094. }
  3095. }
  3096. this.courseOverlayVisible = true
  3097. const gameModeText = this.gameMode === 'score-o' ? '积分赛' : '顺序打点'
  3098. const defaultStatusText = this.currentGpsPoint
  3099. ? `${gameModeText}已开始 (${this.buildVersion})`
  3100. : `${gameModeText}已开始,GPS定位启动中 (${this.buildVersion})`
  3101. this.commitGameResult(gameResult, defaultStatusText)
  3102. }
  3103. handleForceExitGame(): void {
  3104. this.feedbackDirector.reset()
  3105. if (this.locationController.listening) {
  3106. this.locationController.stop()
  3107. }
  3108. if (!this.courseData) {
  3109. this.clearGameRuntime()
  3110. this.resetTransientGameUiState()
  3111. this.resetSessionContentExperienceState()
  3112. this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
  3113. this.setState({
  3114. gpsTracking: false,
  3115. gpsTrackingText: '已退出对局,定位已停止',
  3116. ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
  3117. }, true)
  3118. this.syncRenderer()
  3119. return
  3120. }
  3121. this.loadGameDefinitionFromCourse()
  3122. this.resetTransientGameUiState()
  3123. this.resetSessionContentExperienceState()
  3124. this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
  3125. this.setState({
  3126. gpsTracking: false,
  3127. gpsTrackingText: '已退出对局,定位已停止',
  3128. ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
  3129. }, true)
  3130. this.syncRenderer()
  3131. }
  3132. handlePunchAction(): void {
  3133. const currentPoint = this.currentGpsPoint
  3134. const gameResult = this.gameRuntime.dispatch({
  3135. type: 'punch_requested',
  3136. at: Date.now(),
  3137. lon: currentPoint ? currentPoint.lon : null,
  3138. lat: currentPoint ? currentPoint.lat : null,
  3139. })
  3140. this.commitGameResult(gameResult)
  3141. }
  3142. handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
  3143. const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
  3144. const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
  3145. if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
  3146. const sampleAt = Date.now()
  3147. this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
  3148. this.currentGpsTrackSamples = [...this.currentGpsTrackSamples, { point: nextPoint, at: sampleAt }].slice(-GPS_TRACK_MAX_POINTS)
  3149. this.lastTrackMotionAt = sampleAt
  3150. }
  3151. this.currentGpsPoint = nextPoint
  3152. this.currentGpsAccuracyMeters = accuracyMeters
  3153. this.updateMovementHeadingDeg()
  3154. const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
  3155. const gpsTileX = Math.floor(gpsWorldPoint.x)
  3156. const gpsTileY = Math.floor(gpsWorldPoint.y)
  3157. const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
  3158. this.currentGpsInsideMap = gpsInsideMap
  3159. let gameStatusText: string | null = null
  3160. if (!gpsInsideMap && this.gpsLockEnabled) {
  3161. this.gpsLockEnabled = false
  3162. gameStatusText = `GPS已超出地图范围,锁定已关闭 (${this.buildVersion})`
  3163. }
  3164. if (this.courseData) {
  3165. const eventAt = Date.now()
  3166. const gameResult = this.gameRuntime.dispatch({
  3167. type: 'gps_updated',
  3168. at: eventAt,
  3169. lon: longitude,
  3170. lat: latitude,
  3171. accuracyMeters,
  3172. })
  3173. this.telemetryRuntime.dispatch({
  3174. type: 'gps_updated',
  3175. at: eventAt,
  3176. lon: longitude,
  3177. lat: latitude,
  3178. accuracyMeters,
  3179. })
  3180. this.syncGameResultState(gameResult)
  3181. gameStatusText = this.resolveAppliedGameStatusText(gameResult)
  3182. }
  3183. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  3184. this.scheduleAutoRotate()
  3185. }
  3186. if (gpsInsideMap && (this.gpsLockEnabled || !this.hasGpsCenteredOnce)) {
  3187. this.hasGpsCenteredOnce = true
  3188. const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
  3189. this.commitViewport({
  3190. ...lockedViewport,
  3191. gpsTracking: true,
  3192. gpsTrackingText: '持续定位进行中',
  3193. gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
  3194. autoRotateSourceText: this.getAutoRotateSourceText(),
  3195. gpsLockEnabled: this.gpsLockEnabled,
  3196. gpsLockAvailable: true,
  3197. ...this.getGameViewPatch(),
  3198. }, gameStatusText || (this.gpsLockEnabled ? `GPS锁定跟随中 (${this.buildVersion})` : `GPS定位成功,已定位到当前位置 (${this.buildVersion})`), true)
  3199. return
  3200. }
  3201. this.setState({
  3202. gpsTracking: true,
  3203. gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
  3204. gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
  3205. autoRotateSourceText: this.getAutoRotateSourceText(),
  3206. gpsLockEnabled: this.gpsLockEnabled,
  3207. gpsLockAvailable: gpsInsideMap,
  3208. ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
  3209. })
  3210. this.syncRenderer()
  3211. }
  3212. handleToggleGpsLock(): void {
  3213. if (!this.currentGpsPoint || !this.currentGpsInsideMap) {
  3214. this.setState({
  3215. gpsLockEnabled: false,
  3216. gpsLockAvailable: false,
  3217. statusText: this.currentGpsPoint
  3218. ? `当前位置不在地图范围内,无法锁定 (${this.buildVersion})`
  3219. : `当前还没有可锁定的GPS位置 (${this.buildVersion})`,
  3220. }, true)
  3221. return
  3222. }
  3223. const nextEnabled = !this.gpsLockEnabled
  3224. this.gpsLockEnabled = nextEnabled
  3225. if (nextEnabled) {
  3226. const gpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
  3227. const gpsTileX = Math.floor(gpsWorldPoint.x)
  3228. const gpsTileY = Math.floor(gpsWorldPoint.y)
  3229. const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
  3230. if (gpsInsideMap) {
  3231. this.hasGpsCenteredOnce = true
  3232. const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
  3233. this.commitViewport({
  3234. ...lockedViewport,
  3235. gpsLockEnabled: true,
  3236. gpsLockAvailable: true,
  3237. }, `GPS已锁定在屏幕中央 (${this.buildVersion})`, true)
  3238. return
  3239. }
  3240. this.setState({
  3241. gpsLockEnabled: true,
  3242. gpsLockAvailable: true,
  3243. statusText: `GPS锁定已开启,等待进入地图范围 (${this.buildVersion})`,
  3244. }, true)
  3245. this.syncRenderer()
  3246. return
  3247. }
  3248. this.setState({
  3249. gpsLockEnabled: false,
  3250. gpsLockAvailable: true,
  3251. statusText: `GPS锁定已关闭 (${this.buildVersion})`,
  3252. }, true)
  3253. this.syncRenderer()
  3254. }
  3255. handleToggleOsmReference(): void {
  3256. const nextEnabled = !this.state.osmReferenceEnabled
  3257. this.setState({
  3258. osmReferenceEnabled: nextEnabled,
  3259. osmReferenceText: nextEnabled ? 'OSM参考:开' : 'OSM参考:关',
  3260. statusText: nextEnabled ? `OSM参考底图已开启 (${this.buildVersion})` : `OSM参考底图已关闭 (${this.buildVersion})`,
  3261. }, true)
  3262. this.syncRenderer()
  3263. }
  3264. handleToggleGpsTracking(): void {
  3265. if (this.locationController.listening) {
  3266. this.locationController.stop()
  3267. return
  3268. }
  3269. this.locationController.start()
  3270. }
  3271. handleSetRealLocationMode(): void {
  3272. this.locationController.setSourceMode('real')
  3273. }
  3274. handleSetMockLocationMode(): void {
  3275. this.locationController.setSourceMode('mock')
  3276. }
  3277. handleConnectMockLocationBridge(): void {
  3278. this.locationController.connectMockBridge()
  3279. }
  3280. handleDisconnectMockLocationBridge(): void {
  3281. this.locationController.disconnectMockBridge()
  3282. }
  3283. handleSetMockLocationBridgeUrl(url: string): void {
  3284. this.locationController.setMockBridgeUrl(url)
  3285. }
  3286. handleSetMockChannelId(channelId: string): void {
  3287. const normalized = String(channelId || '').trim() || 'default'
  3288. this.locationController.setMockChannelId(normalized)
  3289. this.heartRateController.setMockChannelId(normalized)
  3290. this.mockSimulatorDebugLogger.setChannelId(normalized)
  3291. this.setState({
  3292. mockChannelIdText: normalized,
  3293. })
  3294. }
  3295. handleSetMockDebugLogBridgeUrl(url: string): void {
  3296. this.mockSimulatorDebugLogger.setUrl(url)
  3297. }
  3298. handleConnectMockDebugLogBridge(): void {
  3299. this.mockSimulatorDebugLogger.connect()
  3300. }
  3301. handleDisconnectMockDebugLogBridge(): void {
  3302. this.mockSimulatorDebugLogger.disconnect()
  3303. }
  3304. handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
  3305. if (this.gameMode === nextMode) {
  3306. return
  3307. }
  3308. this.gameMode = nextMode
  3309. const modeDefaults = getGameModeDefaults(nextMode)
  3310. this.sessionCloseAfterMs = modeDefaults.sessionCloseAfterMs
  3311. this.sessionCloseWarningMs = modeDefaults.sessionCloseWarningMs
  3312. this.minCompletedControlsBeforeFinish = modeDefaults.minCompletedControlsBeforeFinish
  3313. this.requiresFocusSelection = modeDefaults.requiresFocusSelection
  3314. this.skipEnabled = modeDefaults.skipEnabled
  3315. this.skipRadiusMeters = getDefaultSkipRadiusMeters(nextMode, this.punchRadiusMeters)
  3316. this.skipRequiresConfirm = modeDefaults.skipRequiresConfirm
  3317. this.autoFinishOnLastControl = modeDefaults.autoFinishOnLastControl
  3318. this.defaultControlScore = modeDefaults.defaultControlScore
  3319. const result = this.loadGameDefinitionFromCourse()
  3320. const modeText = this.getGameModeText()
  3321. if (!result) {
  3322. return
  3323. }
  3324. this.commitGameResult(result, `已切换到${modeText} (${this.buildVersion})`, {
  3325. gameModeText: modeText,
  3326. })
  3327. }
  3328. handleSkipAction(): void {
  3329. const gameResult = this.gameRuntime.dispatch({
  3330. type: 'skip_requested',
  3331. at: Date.now(),
  3332. lon: this.currentGpsPoint ? this.currentGpsPoint.lon : null,
  3333. lat: this.currentGpsPoint ? this.currentGpsPoint.lat : null,
  3334. })
  3335. this.commitGameResult(gameResult)
  3336. }
  3337. handleConnectHeartRate(): void {
  3338. this.heartRateController.startScanAndConnect()
  3339. }
  3340. handleDisconnectHeartRate(): void {
  3341. this.heartRateController.disconnect()
  3342. }
  3343. handleSetRealHeartRateMode(): void {
  3344. this.heartRateController.setSourceMode('real')
  3345. }
  3346. handleSetMockHeartRateMode(): void {
  3347. this.heartRateController.setSourceMode('mock')
  3348. }
  3349. handleConnectMockHeartRateBridge(): void {
  3350. this.heartRateController.connectMockBridge()
  3351. }
  3352. handleDisconnectMockHeartRateBridge(): void {
  3353. this.heartRateController.disconnectMockBridge()
  3354. }
  3355. handleSetMockHeartRateBridgeUrl(url: string): void {
  3356. this.heartRateController.setMockBridgeUrl(url)
  3357. }
  3358. handleConnectHeartRateDevice(deviceId: string): void {
  3359. this.heartRateController.connectToDiscoveredDevice(deviceId)
  3360. }
  3361. handleClearPreferredHeartRateDevice(): void {
  3362. this.heartRateController.clearPreferredDevice()
  3363. this.setState({
  3364. heartRateDeviceText: this.heartRateController.currentDeviceName || '--',
  3365. heartRateScanText: this.getHeartRateScanText(),
  3366. })
  3367. }
  3368. handleDebugHeartRateTone(tone: HeartRateTone): void {
  3369. const sampleBpm = getHeartRateToneSampleBpm(tone, this.telemetryRuntime.config)
  3370. this.telemetryRuntime.dispatch({
  3371. type: 'heart_rate_updated',
  3372. at: Date.now(),
  3373. bpm: sampleBpm,
  3374. })
  3375. this.setState({
  3376. heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`,
  3377. })
  3378. this.syncSessionTimerText()
  3379. }
  3380. handleClearDebugHeartRate(): void {
  3381. this.telemetryRuntime.dispatch({
  3382. type: 'heart_rate_updated',
  3383. at: Date.now(),
  3384. bpm: null,
  3385. })
  3386. this.setState({
  3387. heartRateStatusText: this.heartRateController.connected
  3388. ? (this.heartRateController.sourceMode === 'mock' ? '模拟心率源已连接' : '心率带已连接')
  3389. : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'),
  3390. heartRateScanText: this.getHeartRateScanText(),
  3391. ...this.getHeartRateControllerViewPatch(),
  3392. })
  3393. this.syncSessionTimerText()
  3394. }
  3395. formatHeartRateDevices(devices: HeartRateDiscoveredDevice[]): Array<{ deviceId: string; name: string; rssiText: string; preferred: boolean; connected: boolean }> {
  3396. return devices.map((device) => ({
  3397. deviceId: device.deviceId,
  3398. name: device.name,
  3399. rssiText: device.rssi === null ? '--' : `${device.rssi} dBm`,
  3400. preferred: device.isPreferred,
  3401. connected: !!this.heartRateController.currentDeviceId && this.heartRateController.currentDeviceId === device.deviceId && this.heartRateController.connected,
  3402. }))
  3403. }
  3404. getHeartRateScanText(): string {
  3405. if (this.heartRateController.sourceMode === 'mock') {
  3406. if (this.heartRateController.connected) {
  3407. return '模拟源已连接'
  3408. }
  3409. if (this.heartRateController.connecting) {
  3410. return '模拟源连接中'
  3411. }
  3412. return '模拟模式'
  3413. }
  3414. if (this.heartRateController.connected) {
  3415. return '已连接'
  3416. }
  3417. if (this.heartRateController.connecting) {
  3418. return '连接中'
  3419. }
  3420. if (this.heartRateController.disconnecting) {
  3421. return '断开中'
  3422. }
  3423. if (this.heartRateController.scanning) {
  3424. return this.heartRateController.lastDeviceId ? '扫描中(优先首选)' : '扫描中(等待选择)'
  3425. }
  3426. return this.heartRateController.discoveredDevices.length
  3427. ? `已发现 ${this.heartRateController.discoveredDevices.length} 个设备`
  3428. : '未扫描'
  3429. }
  3430. setStage(rect: MapEngineStageRect): void {
  3431. this.previewScale = 1
  3432. this.previewOriginX = rect.width / 2
  3433. this.previewOriginY = rect.height / 2
  3434. this.commitViewport(
  3435. {
  3436. stageWidth: rect.width,
  3437. stageHeight: rect.height,
  3438. stageLeft: rect.left,
  3439. stageTop: rect.top,
  3440. },
  3441. `地图视口已与 WebGL 引擎对齐 (${this.buildVersion})`,
  3442. true,
  3443. )
  3444. }
  3445. attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
  3446. if (this.mounted) {
  3447. return
  3448. }
  3449. this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
  3450. this.mounted = true
  3451. this.state.mapReady = true
  3452. this.state.mapReadyText = 'READY'
  3453. this.onData({
  3454. mapReady: true,
  3455. mapReadyText: 'READY',
  3456. statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
  3457. })
  3458. this.syncRenderer()
  3459. this.accelerometerErrorText = null
  3460. this.lastCompassSampleAt = 0
  3461. this.compassController.start()
  3462. this.scheduleCompassBootstrapRetry()
  3463. this.gyroscopeController.start()
  3464. this.deviceMotionController.start()
  3465. }
  3466. applyRemoteMapConfig(config: RemoteMapConfig): void {
  3467. this.courseData = config.course
  3468. this.configAppId = config.configAppId
  3469. this.configSchemaVersion = config.configSchemaVersion
  3470. this.configVersion = config.configVersion
  3471. this.controlScoreOverrides = config.controlScoreOverrides
  3472. this.controlContentOverrides = config.controlContentOverrides
  3473. this.defaultControlContentOverride = config.defaultControlContentOverride
  3474. this.defaultControlPointStyleOverride = config.defaultControlPointStyleOverride
  3475. this.controlPointStyleOverrides = config.controlPointStyleOverrides
  3476. this.defaultLegStyleOverride = config.defaultLegStyleOverride
  3477. this.legStyleOverrides = config.legStyleOverrides
  3478. const statePatch: Partial<MapEngineViewState> = {
  3479. mapName: config.configTitle,
  3480. configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`,
  3481. projectionMode: config.projectionModeText,
  3482. tileSource: config.tileSource,
  3483. sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
  3484. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  3485. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  3486. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  3487. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
  3488. }
  3489. if (!this.state.stageWidth || !this.state.stageHeight) {
  3490. this.setState({
  3491. ...statePatch,
  3492. zoom: this.defaultZoom,
  3493. centerTileX: this.defaultCenterTileX,
  3494. centerTileY: this.defaultCenterTileY,
  3495. centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
  3496. statusText: `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
  3497. }, true)
  3498. return
  3499. }
  3500. this.commitViewport({
  3501. ...statePatch,
  3502. zoom: this.defaultZoom,
  3503. centerTileX: this.defaultCenterTileX,
  3504. centerTileY: this.defaultCenterTileY,
  3505. tileTranslateX: 0,
  3506. tileTranslateY: 0,
  3507. }, `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => {
  3508. this.resetPreviewState()
  3509. this.syncRenderer()
  3510. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  3511. this.scheduleAutoRotate()
  3512. }
  3513. })
  3514. }
  3515. handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
  3516. this.clearInertiaTimer()
  3517. this.clearPreviewResetTimer()
  3518. this.panVelocityX = 0
  3519. this.panVelocityY = 0
  3520. if (event.touches.length >= 2) {
  3521. const origin = this.gpsLockEnabled
  3522. ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
  3523. : this.getStagePoint(event.touches)
  3524. this.gestureMode = 'pinch'
  3525. this.pinchStartDistance = this.getTouchDistance(event.touches)
  3526. this.pinchStartScale = this.previewScale || 1
  3527. this.pinchStartAngle = this.getTouchAngle(event.touches)
  3528. this.pinchStartRotationDeg = this.state.rotationDeg
  3529. const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
  3530. ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
  3531. : screenToWorld(this.getCameraState(), origin, true)
  3532. this.pinchAnchorWorldX = anchorWorld.x
  3533. this.pinchAnchorWorldY = anchorWorld.y
  3534. this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
  3535. this.syncRenderer()
  3536. this.compassController.start()
  3537. return
  3538. }
  3539. if (event.touches.length === 1) {
  3540. this.gestureMode = 'pan'
  3541. this.panLastX = event.touches[0].pageX
  3542. this.panLastY = event.touches[0].pageY
  3543. this.panLastTimestamp = event.timeStamp || Date.now()
  3544. this.tapStartX = event.touches[0].pageX
  3545. this.tapStartY = event.touches[0].pageY
  3546. this.tapStartAt = event.timeStamp || Date.now()
  3547. }
  3548. }
  3549. handleTouchMove(event: WechatMiniprogram.TouchEvent): void {
  3550. if (event.touches.length >= 2) {
  3551. const distance = this.getTouchDistance(event.touches)
  3552. const angle = this.getTouchAngle(event.touches)
  3553. const origin = this.gpsLockEnabled
  3554. ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
  3555. : this.getStagePoint(event.touches)
  3556. if (!this.pinchStartDistance) {
  3557. this.pinchStartDistance = distance
  3558. this.pinchStartScale = this.previewScale || 1
  3559. this.pinchStartAngle = angle
  3560. this.pinchStartRotationDeg = this.state.rotationDeg
  3561. const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
  3562. ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
  3563. : screenToWorld(this.getCameraState(), origin, true)
  3564. this.pinchAnchorWorldX = anchorWorld.x
  3565. this.pinchAnchorWorldY = anchorWorld.y
  3566. }
  3567. this.gestureMode = 'pinch'
  3568. const nextRotationDeg = this.state.orientationMode === 'heading-up'
  3569. ? this.state.rotationDeg
  3570. : normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI)
  3571. const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg)
  3572. const resolvedViewport = this.resolveViewportForExactCenter(
  3573. this.pinchAnchorWorldX - anchorOffset.x,
  3574. this.pinchAnchorWorldY - anchorOffset.y,
  3575. nextRotationDeg,
  3576. )
  3577. this.setPreviewState(
  3578. clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE),
  3579. origin.x,
  3580. origin.y,
  3581. )
  3582. this.commitViewport(
  3583. {
  3584. ...resolvedViewport,
  3585. rotationDeg: nextRotationDeg,
  3586. rotationText: formatRotationText(nextRotationDeg),
  3587. },
  3588. this.state.orientationMode === 'heading-up'
  3589. ? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
  3590. : `双指缩放与旋转中 (${this.buildVersion})`,
  3591. )
  3592. return
  3593. }
  3594. if (this.gestureMode !== 'pan' || event.touches.length !== 1) {
  3595. return
  3596. }
  3597. const touch = event.touches[0]
  3598. const deltaX = touch.pageX - this.panLastX
  3599. const deltaY = touch.pageY - this.panLastY
  3600. const nextTimestamp = event.timeStamp || Date.now()
  3601. const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16)
  3602. const instantVelocityX = deltaX / elapsed
  3603. const instantVelocityY = deltaY / elapsed
  3604. this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28
  3605. this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28
  3606. this.panLastX = touch.pageX
  3607. this.panLastY = touch.pageY
  3608. this.panLastTimestamp = nextTimestamp
  3609. if (this.gpsLockEnabled) {
  3610. this.panVelocityX = 0
  3611. this.panVelocityY = 0
  3612. return
  3613. }
  3614. this.normalizeTranslate(
  3615. this.state.tileTranslateX + deltaX,
  3616. this.state.tileTranslateY + deltaY,
  3617. `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
  3618. )
  3619. }
  3620. handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
  3621. const changedTouch = event.changedTouches && event.changedTouches.length ? event.changedTouches[0] : null
  3622. const endedAsTap = changedTouch
  3623. && this.gestureMode === 'pan'
  3624. && event.touches.length === 0
  3625. && Math.abs(changedTouch.pageX - this.tapStartX) <= MAP_TAP_MOVE_THRESHOLD_PX
  3626. && Math.abs(changedTouch.pageY - this.tapStartY) <= MAP_TAP_MOVE_THRESHOLD_PX
  3627. && ((event.timeStamp || Date.now()) - this.tapStartAt) <= MAP_TAP_DURATION_MS
  3628. if (this.gestureMode === 'pinch' && event.touches.length < 2) {
  3629. const gestureScale = this.previewScale || 1
  3630. const zoomDelta = Math.round(Math.log2(gestureScale))
  3631. const originX = this.gpsLockEnabled ? this.state.stageWidth / 2 : (this.previewOriginX || this.state.stageWidth / 2)
  3632. const originY = this.gpsLockEnabled ? this.state.stageHeight / 2 : (this.previewOriginY || this.state.stageHeight / 2)
  3633. if (zoomDelta) {
  3634. const residualScale = gestureScale / Math.pow(2, zoomDelta)
  3635. this.zoomAroundPoint(zoomDelta, originX, originY, residualScale)
  3636. } else {
  3637. this.animatePreviewToRest()
  3638. }
  3639. this.resetPinchState()
  3640. this.panVelocityX = 0
  3641. this.panVelocityY = 0
  3642. if (event.touches.length === 1) {
  3643. this.gestureMode = 'pan'
  3644. this.panLastX = event.touches[0].pageX
  3645. this.panLastY = event.touches[0].pageY
  3646. this.panLastTimestamp = event.timeStamp || Date.now()
  3647. return
  3648. }
  3649. this.gestureMode = 'idle'
  3650. this.renderer.setAnimationPaused(false)
  3651. this.scheduleAutoRotate()
  3652. return
  3653. }
  3654. if (event.touches.length === 1) {
  3655. this.gestureMode = 'pan'
  3656. this.panLastX = event.touches[0].pageX
  3657. this.panLastY = event.touches[0].pageY
  3658. this.panLastTimestamp = event.timeStamp || Date.now()
  3659. return
  3660. }
  3661. if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) {
  3662. this.startInertia()
  3663. this.gestureMode = 'idle'
  3664. this.resetPinchState()
  3665. return
  3666. }
  3667. if (endedAsTap && changedTouch) {
  3668. this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop)
  3669. }
  3670. this.gestureMode = 'idle'
  3671. this.resetPinchState()
  3672. this.renderer.setAnimationPaused(false)
  3673. this.scheduleAutoRotate()
  3674. }
  3675. handleTouchCancel(): void {
  3676. this.gestureMode = 'idle'
  3677. this.resetPinchState()
  3678. this.panVelocityX = 0
  3679. this.panVelocityY = 0
  3680. this.clearInertiaTimer()
  3681. this.animatePreviewToRest()
  3682. this.renderer.setAnimationPaused(false)
  3683. this.scheduleAutoRotate()
  3684. }
  3685. handleMapTap(stageX: number, stageY: number): void {
  3686. if (!this.gameRuntime.definition || !this.gameRuntime.state) {
  3687. return
  3688. }
  3689. if (this.gameRuntime.definition.mode === 'score-o') {
  3690. const focusedControlId = this.findFocusableControlAt(stageX, stageY)
  3691. if (focusedControlId !== undefined) {
  3692. const gameResult = this.gameRuntime.dispatch({
  3693. type: 'control_focused',
  3694. at: Date.now(),
  3695. controlId: focusedControlId,
  3696. })
  3697. this.commitGameResult(
  3698. gameResult,
  3699. this.buildFocusSelectionStatusText(focusedControlId),
  3700. )
  3701. }
  3702. }
  3703. const contentControlId = this.findContentControlAt(stageX, stageY)
  3704. if (contentControlId) {
  3705. this.openControlClickContent(contentControlId)
  3706. }
  3707. }
  3708. findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
  3709. if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
  3710. return undefined
  3711. }
  3712. const focusableControls = this.gameRuntime.definition.controls.filter((control) => (
  3713. this.gamePresentation.map.focusableControlIds.includes(control.id)
  3714. ))
  3715. let matchedControlId: string | null | undefined
  3716. let matchedDistance = Number.POSITIVE_INFINITY
  3717. const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
  3718. for (const control of focusableControls) {
  3719. const screenPoint = this.getControlScreenPoint(control.id)
  3720. if (!screenPoint) {
  3721. continue
  3722. }
  3723. const distancePx = Math.sqrt(
  3724. Math.pow(screenPoint.x - stageX, 2)
  3725. + Math.pow(screenPoint.y - stageY, 2),
  3726. )
  3727. if (distancePx <= hitRadiusPx && distancePx < matchedDistance) {
  3728. matchedDistance = distancePx
  3729. matchedControlId = control.id
  3730. }
  3731. }
  3732. if (matchedControlId === undefined) {
  3733. return undefined
  3734. }
  3735. return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
  3736. }
  3737. buildFocusSelectionStatusText(controlId: string | null): string {
  3738. if (!controlId || !this.gameRuntime.definition) {
  3739. return `已取消目标点选择 (${this.buildVersion})`
  3740. }
  3741. const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
  3742. if (!control) {
  3743. return `已更新目标点选择 (${this.buildVersion})`
  3744. }
  3745. if (control.kind === 'finish') {
  3746. return `已选择终点 ${control.label} (${this.buildVersion})`
  3747. }
  3748. if (control.kind === 'start') {
  3749. return `已选择开始点 ${control.label} (${this.buildVersion})`
  3750. }
  3751. const scoreText = typeof control.score === 'number' ? ` / ${control.score}分` : ''
  3752. return `已选择目标点 ${control.label}${scoreText} (${this.buildVersion})`
  3753. }
  3754. findContentControlAt(stageX: number, stageY: number): string | undefined {
  3755. if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
  3756. return undefined
  3757. }
  3758. let matchedControlId: string | undefined
  3759. let matchedDistance = Number.POSITIVE_INFINITY
  3760. let matchedPriority = Number.NEGATIVE_INFINITY
  3761. const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
  3762. for (const control of this.gameRuntime.definition.controls) {
  3763. if (
  3764. !control.displayContent
  3765. || (
  3766. !control.displayContent.clickTitle
  3767. && !control.displayContent.clickBody
  3768. && !(control.displayContent.clickExperience && control.displayContent.clickExperience.type === 'h5')
  3769. && !(control.displayContent.contentExperience && control.displayContent.contentExperience.type === 'h5')
  3770. )
  3771. ) {
  3772. continue
  3773. }
  3774. if (!this.isControlTapContentVisible(control)) {
  3775. continue
  3776. }
  3777. const screenPoint = this.getControlScreenPoint(control.id)
  3778. if (!screenPoint) {
  3779. continue
  3780. }
  3781. const distancePx = Math.sqrt(
  3782. Math.pow(screenPoint.x - stageX, 2)
  3783. + Math.pow(screenPoint.y - stageY, 2),
  3784. )
  3785. if (distancePx > hitRadiusPx) {
  3786. continue
  3787. }
  3788. const controlPriority = this.getControlTapContentPriority(control)
  3789. const sameDistance = Math.abs(distancePx - matchedDistance) <= 2
  3790. if (
  3791. distancePx < matchedDistance
  3792. || (sameDistance && controlPriority > matchedPriority)
  3793. ) {
  3794. matchedDistance = distancePx
  3795. matchedPriority = controlPriority
  3796. matchedControlId = control.id
  3797. }
  3798. }
  3799. return matchedControlId
  3800. }
  3801. getControlTapContentPriority(control: { kind: 'start' | 'control' | 'finish'; id: string }): number {
  3802. if (!this.gameRuntime.state || !this.gamePresentation.map) {
  3803. return 0
  3804. }
  3805. const currentTargetControlId = this.gameRuntime.state.currentTargetControlId
  3806. const completedControlIds = this.gameRuntime.state.completedControlIds
  3807. if (currentTargetControlId === control.id) {
  3808. return 100
  3809. }
  3810. if (control.kind === 'start') {
  3811. return completedControlIds.includes(control.id) ? 10 : 90
  3812. }
  3813. if (control.kind === 'finish') {
  3814. return completedControlIds.includes(control.id)
  3815. ? 80
  3816. : (this.gamePresentation.map.completedStart ? 85 : 5)
  3817. }
  3818. return completedControlIds.includes(control.id) ? 40 : 60
  3819. }
  3820. isControlTapContentVisible(control: { kind: 'start' | 'control' | 'finish'; sequence: number | null; id: string }): boolean {
  3821. if (this.gamePresentation.map.revealFullCourse) {
  3822. return true
  3823. }
  3824. if (control.kind === 'start') {
  3825. return this.gamePresentation.map.activeStart || this.gamePresentation.map.completedStart
  3826. }
  3827. if (control.kind === 'finish') {
  3828. return this.gamePresentation.map.activeFinish || this.gamePresentation.map.focusedFinish || this.gamePresentation.map.completedFinish
  3829. }
  3830. if (control.sequence === null) {
  3831. return false
  3832. }
  3833. const readyControlSequences = this.resolveReadyControlSequences()
  3834. return this.gamePresentation.map.activeControlSequences.includes(control.sequence)
  3835. || this.gamePresentation.map.completedControlSequences.includes(control.sequence)
  3836. || this.gamePresentation.map.skippedControlSequences.includes(control.sequence)
  3837. || this.gamePresentation.map.focusedControlSequences.includes(control.sequence)
  3838. || readyControlSequences.includes(control.sequence)
  3839. }
  3840. openControlClickContent(controlId: string): void {
  3841. if (!this.gameRuntime.definition) {
  3842. return
  3843. }
  3844. const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
  3845. if (!control || !control.displayContent) {
  3846. return
  3847. }
  3848. const title = control.displayContent.clickTitle || control.displayContent.title || control.label || '内容体验'
  3849. const body = control.displayContent.clickBody || control.displayContent.body || ''
  3850. if (!title && !body) {
  3851. return
  3852. }
  3853. const entry = this.buildControlContentCardEntry(`${control.id}:click`, {
  3854. title,
  3855. body,
  3856. motionClass: 'game-content-card--fx-pop',
  3857. autoPopup: true,
  3858. once: false,
  3859. priority: control.displayContent.priority,
  3860. })
  3861. if (!entry) {
  3862. return
  3863. }
  3864. this.replaceVisibleContentCard(entry)
  3865. }
  3866. getControlHitRadiusPx(): number {
  3867. if (!this.state.tileSizePx) {
  3868. return 28
  3869. }
  3870. const centerLonLat = worldTileToLonLat({ x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5 }, this.state.zoom)
  3871. const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * 40075016.686 / Math.pow(2, this.state.zoom)
  3872. if (!metersPerTile) {
  3873. return 28
  3874. }
  3875. const pixelsPerMeter = this.state.tileSizePx / metersPerTile
  3876. return Math.max(28, this.cpRadiusMeters * pixelsPerMeter * 1.6)
  3877. }
  3878. handleRecenter(): void {
  3879. this.clearInertiaTimer()
  3880. this.clearPreviewResetTimer()
  3881. this.panVelocityX = 0
  3882. this.panVelocityY = 0
  3883. this.renderer.setAnimationPaused(false)
  3884. this.commitViewport(
  3885. {
  3886. zoom: this.defaultZoom,
  3887. centerTileX: this.defaultCenterTileX,
  3888. centerTileY: this.defaultCenterTileY,
  3889. tileTranslateX: 0,
  3890. tileTranslateY: 0,
  3891. },
  3892. `已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
  3893. true,
  3894. () => {
  3895. this.resetPreviewState()
  3896. this.syncRenderer()
  3897. this.compassController.start()
  3898. this.scheduleAutoRotate()
  3899. },
  3900. )
  3901. }
  3902. handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
  3903. if (this.state.rotationMode === 'auto') {
  3904. this.setState({
  3905. statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
  3906. }, true)
  3907. return
  3908. }
  3909. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  3910. const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg)
  3911. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  3912. this.clearInertiaTimer()
  3913. this.clearPreviewResetTimer()
  3914. this.panVelocityX = 0
  3915. this.panVelocityY = 0
  3916. this.renderer.setAnimationPaused(false)
  3917. this.commitViewport(
  3918. {
  3919. ...resolvedViewport,
  3920. rotationDeg: nextRotationDeg,
  3921. rotationText: formatRotationText(nextRotationDeg),
  3922. },
  3923. `旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
  3924. true,
  3925. () => {
  3926. this.resetPreviewState()
  3927. this.syncRenderer()
  3928. this.compassController.start()
  3929. },
  3930. )
  3931. }
  3932. handleRotationReset(): void {
  3933. if (this.state.rotationMode === 'auto') {
  3934. this.setState({
  3935. statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
  3936. }, true)
  3937. return
  3938. }
  3939. const targetRotationDeg = MAP_NORTH_OFFSET_DEG
  3940. if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) {
  3941. return
  3942. }
  3943. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  3944. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg)
  3945. this.clearInertiaTimer()
  3946. this.clearPreviewResetTimer()
  3947. this.panVelocityX = 0
  3948. this.panVelocityY = 0
  3949. this.renderer.setAnimationPaused(false)
  3950. this.commitViewport(
  3951. {
  3952. ...resolvedViewport,
  3953. rotationDeg: targetRotationDeg,
  3954. rotationText: formatRotationText(targetRotationDeg),
  3955. },
  3956. `旋转角度已回到真北参考 (${this.buildVersion})`,
  3957. true,
  3958. () => {
  3959. this.resetPreviewState()
  3960. this.syncRenderer()
  3961. this.compassController.start()
  3962. },
  3963. )
  3964. }
  3965. handleToggleRotationMode(): void {
  3966. if (this.state.orientationMode === 'manual') {
  3967. this.setNorthUpMode()
  3968. return
  3969. }
  3970. if (this.state.orientationMode === 'north-up') {
  3971. this.setHeadingUpMode()
  3972. return
  3973. }
  3974. this.setManualMode()
  3975. }
  3976. handleSetManualMode(): void {
  3977. this.setManualMode()
  3978. }
  3979. handleSetNorthUpMode(): void {
  3980. this.setNorthUpMode()
  3981. }
  3982. handleSetHeadingUpMode(): void {
  3983. this.setHeadingUpMode()
  3984. }
  3985. handleCycleNorthReferenceMode(): void {
  3986. this.cycleNorthReferenceMode()
  3987. }
  3988. handleSetNorthReferenceMode(mode: NorthReferenceMode): void {
  3989. this.setNorthReferenceMode(mode)
  3990. }
  3991. handleSetAnimationLevel(level: AnimationLevel): void {
  3992. if (this.animationLevel === level) {
  3993. return
  3994. }
  3995. this.animationLevel = level
  3996. this.feedbackDirector.setAnimationLevel(level)
  3997. this.setState({
  3998. animationLevel: level,
  3999. statusText: `动画性能已切换为${formatAnimationLevelText(level)} (${this.buildVersion})`,
  4000. })
  4001. this.syncRenderer()
  4002. }
  4003. handleSetTrackMode(mode: TrackDisplayMode): void {
  4004. if (this.trackStyleConfig.mode === mode) {
  4005. return
  4006. }
  4007. this.trackStyleConfig = {
  4008. ...this.trackStyleConfig,
  4009. mode,
  4010. }
  4011. this.setState({
  4012. trackDisplayMode: mode,
  4013. statusText: `轨迹模式已切换为${formatTrackDisplayModeText(mode)} (${this.buildVersion})`,
  4014. })
  4015. this.syncRenderer()
  4016. }
  4017. playPunchHintHaptic(): void {
  4018. this.feedbackDirector.playHapticCue('hint:changed')
  4019. }
  4020. handleSetTrackTailLength(length: TrackTailLengthPreset): void {
  4021. if (this.trackStyleConfig.tailLength === length) {
  4022. return
  4023. }
  4024. this.trackStyleConfig = {
  4025. ...this.trackStyleConfig,
  4026. tailLength: length,
  4027. tailMeters: TRACK_TAIL_LENGTH_METERS[length],
  4028. }
  4029. this.setState({
  4030. trackTailLength: length,
  4031. statusText: `拖尾长度已切换为${formatTrackTailLengthText(length)} (${this.buildVersion})`,
  4032. })
  4033. this.syncRenderer()
  4034. }
  4035. handleSetTrackColorPreset(colorPreset: TrackColorPreset): void {
  4036. if (this.trackStyleConfig.colorPreset === colorPreset) {
  4037. return
  4038. }
  4039. const palette = TRACK_COLOR_PRESET_MAP[colorPreset]
  4040. this.trackStyleConfig = {
  4041. ...this.trackStyleConfig,
  4042. colorPreset,
  4043. colorHex: palette.colorHex,
  4044. headColorHex: palette.headColorHex,
  4045. }
  4046. this.setState({
  4047. trackColorPreset: colorPreset,
  4048. statusText: `轨迹颜色已切换为${formatTrackColorPresetText(colorPreset)} (${this.buildVersion})`,
  4049. })
  4050. this.syncRenderer()
  4051. }
  4052. handleSetTrackStyleProfile(style: TrackStyleProfile): void {
  4053. if (this.trackStyleConfig.style === style) {
  4054. return
  4055. }
  4056. const nextGlowStrength = style === 'neon'
  4057. ? Math.max(this.trackStyleConfig.glowStrength, 0.18)
  4058. : Math.min(this.trackStyleConfig.glowStrength, 0.08)
  4059. this.trackStyleConfig = {
  4060. ...this.trackStyleConfig,
  4061. style,
  4062. glowStrength: nextGlowStrength,
  4063. }
  4064. this.setState({
  4065. trackStyleProfile: style,
  4066. statusText: `轨迹风格已切换为${style === 'neon' ? '流光' : '经典'} (${this.buildVersion})`,
  4067. })
  4068. this.syncRenderer()
  4069. }
  4070. handleSetGpsMarkerVisible(visible: boolean): void {
  4071. if (this.gpsMarkerStyleConfig.visible === visible) {
  4072. return
  4073. }
  4074. this.gpsMarkerStyleConfig = {
  4075. ...this.gpsMarkerStyleConfig,
  4076. visible,
  4077. }
  4078. this.setState({
  4079. gpsMarkerVisible: visible,
  4080. statusText: `GPS点显示已切换为${visible ? '显示' : '隐藏'} (${this.buildVersion})`,
  4081. })
  4082. this.syncRenderer()
  4083. }
  4084. handleSetGpsMarkerStyle(style: GpsMarkerStyleId): void {
  4085. if (this.gpsMarkerStyleConfig.style === style) {
  4086. return
  4087. }
  4088. this.gpsMarkerStyleConfig = {
  4089. ...this.gpsMarkerStyleConfig,
  4090. style,
  4091. }
  4092. this.setState({
  4093. gpsMarkerStyle: style,
  4094. statusText: `GPS点风格已切换为${formatGpsMarkerStyleText(style)} (${this.buildVersion})`,
  4095. })
  4096. this.syncRenderer()
  4097. }
  4098. handleSetGpsMarkerSize(size: GpsMarkerSizePreset): void {
  4099. if (this.gpsMarkerStyleConfig.size === size) {
  4100. return
  4101. }
  4102. this.gpsMarkerStyleConfig = {
  4103. ...this.gpsMarkerStyleConfig,
  4104. size,
  4105. }
  4106. this.setState({
  4107. gpsMarkerSize: size,
  4108. statusText: `GPS点大小已切换为${formatGpsMarkerSizeText(size)} (${this.buildVersion})`,
  4109. })
  4110. this.syncRenderer()
  4111. }
  4112. handleSetGpsMarkerColorPreset(colorPreset: GpsMarkerColorPreset): void {
  4113. if (this.gpsMarkerStyleConfig.colorPreset === colorPreset) {
  4114. return
  4115. }
  4116. const palette = GPS_MARKER_COLOR_PRESET_MAP[colorPreset]
  4117. this.gpsMarkerStyleConfig = {
  4118. ...this.gpsMarkerStyleConfig,
  4119. colorPreset,
  4120. colorHex: palette.colorHex,
  4121. ringColorHex: palette.ringColorHex,
  4122. indicatorColorHex: palette.indicatorColorHex,
  4123. }
  4124. this.setState({
  4125. gpsMarkerColorPreset: colorPreset,
  4126. statusText: `GPS点颜色已切换为${formatGpsMarkerColorPresetText(colorPreset)} (${this.buildVersion})`,
  4127. })
  4128. this.syncRenderer()
  4129. }
  4130. handleSetCompassTuningProfile(profile: CompassTuningProfile): void {
  4131. if (this.compassTuningProfile === profile) {
  4132. return
  4133. }
  4134. this.compassTuningProfile = profile
  4135. this.compassController.setTuningProfile(profile)
  4136. this.setState({
  4137. compassTuningProfile: profile,
  4138. compassTuningProfileText: formatCompassTuningProfileText(profile),
  4139. statusText: `指北针响应已切换为${formatCompassTuningProfileText(profile)} (${this.buildVersion})`,
  4140. }, true)
  4141. }
  4142. handleAutoRotateCalibrate(): void {
  4143. if (this.state.orientationMode !== 'heading-up') {
  4144. this.setState({
  4145. statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
  4146. }, true)
  4147. return
  4148. }
  4149. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  4150. this.setState({
  4151. statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
  4152. }, true)
  4153. return
  4154. }
  4155. this.setState({
  4156. statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
  4157. }, true)
  4158. this.scheduleAutoRotate()
  4159. }
  4160. setManualMode(): void {
  4161. this.clearAutoRotateTimer()
  4162. this.targetAutoRotationDeg = null
  4163. this.autoRotateCalibrationPending = false
  4164. this.setState({
  4165. rotationMode: 'manual',
  4166. rotationModeText: formatRotationModeText('manual'),
  4167. rotationToggleText: formatRotationToggleText('manual'),
  4168. orientationMode: 'manual',
  4169. orientationModeText: formatOrientationModeText('manual'),
  4170. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  4171. statusText: `已切回手动地图旋转 (${this.buildVersion})`,
  4172. }, true)
  4173. }
  4174. setNorthUpMode(): void {
  4175. this.clearAutoRotateTimer()
  4176. this.targetAutoRotationDeg = null
  4177. this.autoRotateCalibrationPending = false
  4178. const mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG
  4179. this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg
  4180. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  4181. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg)
  4182. this.commitViewport(
  4183. {
  4184. ...resolvedViewport,
  4185. rotationDeg: mapNorthOffsetDeg,
  4186. rotationText: formatRotationText(mapNorthOffsetDeg),
  4187. rotationMode: 'manual',
  4188. rotationModeText: formatRotationModeText('north-up'),
  4189. rotationToggleText: formatRotationToggleText('north-up'),
  4190. orientationMode: 'north-up',
  4191. orientationModeText: formatOrientationModeText('north-up'),
  4192. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
  4193. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  4194. },
  4195. `地图已固定为真北朝上 (${this.buildVersion})`,
  4196. true,
  4197. () => {
  4198. this.resetPreviewState()
  4199. this.syncRenderer()
  4200. },
  4201. )
  4202. }
  4203. setHeadingUpMode(): void {
  4204. this.autoRotateCalibrationPending = false
  4205. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode)
  4206. this.targetAutoRotationDeg = null
  4207. this.setState({
  4208. rotationMode: 'auto',
  4209. rotationModeText: formatRotationModeText('heading-up'),
  4210. rotationToggleText: formatRotationToggleText('heading-up'),
  4211. orientationMode: 'heading-up',
  4212. orientationModeText: formatOrientationModeText('heading-up'),
  4213. autoRotateSourceText: this.getAutoRotateSourceText(),
  4214. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  4215. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  4216. statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
  4217. }, true)
  4218. if (this.refreshAutoRotateTarget()) {
  4219. this.scheduleAutoRotate()
  4220. }
  4221. }
  4222. applyHeadingSample(headingDeg: number, source: 'compass' | 'motion'): void {
  4223. this.compassSource = source
  4224. this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
  4225. this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
  4226. ? this.sensorHeadingDeg
  4227. : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
  4228. const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  4229. if (this.compassDisplayHeadingDeg === null) {
  4230. this.compassDisplayHeadingDeg = compassHeadingDeg
  4231. this.targetCompassDisplayHeadingDeg = compassHeadingDeg
  4232. this.syncCompassDisplayState()
  4233. } else {
  4234. this.targetCompassDisplayHeadingDeg = compassHeadingDeg
  4235. const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg))
  4236. if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) {
  4237. this.scheduleCompassNeedleFollow()
  4238. }
  4239. }
  4240. this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  4241. this.setState({
  4242. compassSourceText: formatCompassSourceText(this.compassSource),
  4243. ...(this.diagnosticUiEnabled
  4244. ? {
  4245. ...this.getTelemetrySensorViewPatch(),
  4246. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  4247. autoRotateSourceText: this.getAutoRotateSourceText(),
  4248. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  4249. }
  4250. : {}),
  4251. })
  4252. if (!this.refreshAutoRotateTarget()) {
  4253. return
  4254. }
  4255. if (this.state.orientationMode === 'heading-up') {
  4256. this.scheduleAutoRotate()
  4257. }
  4258. }
  4259. handleCompassHeading(headingDeg: number): void {
  4260. this.lastCompassSampleAt = Date.now()
  4261. this.clearCompassBootstrapRetryTimer()
  4262. this.applyHeadingSample(headingDeg, 'compass')
  4263. }
  4264. handleCompassError(message: string): void {
  4265. this.clearAutoRotateTimer()
  4266. this.clearCompassNeedleTimer()
  4267. this.targetAutoRotationDeg = null
  4268. this.autoRotateCalibrationPending = false
  4269. this.compassSource = null
  4270. this.targetCompassDisplayHeadingDeg = null
  4271. this.setState({
  4272. compassSourceText: formatCompassSourceText(null),
  4273. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  4274. statusText: `${message} (${this.buildVersion})`,
  4275. }, true)
  4276. }
  4277. cycleNorthReferenceMode(): void {
  4278. this.setNorthReferenceMode(getNextNorthReferenceMode(this.northReferenceMode))
  4279. }
  4280. setNorthReferenceMode(nextMode: NorthReferenceMode): void {
  4281. if (nextMode === this.northReferenceMode) {
  4282. return
  4283. }
  4284. const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
  4285. const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
  4286. ? null
  4287. : getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
  4288. this.northReferenceMode = nextMode
  4289. this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
  4290. this.compassDisplayHeadingDeg = compassHeadingDeg
  4291. this.targetCompassDisplayHeadingDeg = compassHeadingDeg
  4292. if (this.state.orientationMode === 'north-up') {
  4293. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  4294. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG)
  4295. this.commitViewport(
  4296. {
  4297. ...resolvedViewport,
  4298. rotationDeg: MAP_NORTH_OFFSET_DEG,
  4299. rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
  4300. northReferenceText: formatNorthReferenceText(nextMode),
  4301. sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
  4302. ...this.getTelemetrySensorViewPatch(),
  4303. compassDeclinationText: formatCompassDeclinationText(nextMode),
  4304. northReferenceMode: nextMode,
  4305. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  4306. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
  4307. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  4308. },
  4309. `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  4310. true,
  4311. () => {
  4312. this.resetPreviewState()
  4313. this.syncRenderer()
  4314. },
  4315. )
  4316. return
  4317. }
  4318. this.setState({
  4319. northReferenceText: formatNorthReferenceText(nextMode),
  4320. sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
  4321. ...this.getTelemetrySensorViewPatch(),
  4322. compassDeclinationText: formatCompassDeclinationText(nextMode),
  4323. northReferenceMode: nextMode,
  4324. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  4325. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg),
  4326. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  4327. statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  4328. }, true)
  4329. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  4330. this.scheduleAutoRotate()
  4331. }
  4332. if (this.compassDisplayHeadingDeg !== null) {
  4333. this.syncCompassDisplayState()
  4334. }
  4335. }
  4336. setCourseHeading(headingDeg: number | null): void {
  4337. this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
  4338. this.setState({
  4339. autoRotateSourceText: this.getAutoRotateSourceText(),
  4340. })
  4341. if (this.refreshAutoRotateTarget()) {
  4342. this.scheduleAutoRotate()
  4343. }
  4344. }
  4345. getRawMovementHeadingDeg(): number | null {
  4346. if (!this.currentGpsInsideMap) {
  4347. return null
  4348. }
  4349. if (this.currentGpsAccuracyMeters !== null && this.currentGpsAccuracyMeters > SMART_HEADING_MAX_ACCURACY_METERS) {
  4350. return null
  4351. }
  4352. if (this.currentGpsTrack.length < 2) {
  4353. return null
  4354. }
  4355. const lastPoint = this.currentGpsTrack[this.currentGpsTrack.length - 1]
  4356. let accumulatedDistanceMeters = 0
  4357. for (let index = this.currentGpsTrack.length - 2; index >= 0; index -= 1) {
  4358. const nextPoint = this.currentGpsTrack[index + 1]
  4359. const point = this.currentGpsTrack[index]
  4360. accumulatedDistanceMeters += getApproxDistanceMeters(point, nextPoint)
  4361. if (accumulatedDistanceMeters >= SMART_HEADING_MIN_DISTANCE_METERS) {
  4362. return getInitialBearingDeg(point, lastPoint)
  4363. }
  4364. }
  4365. return null
  4366. }
  4367. updateMovementHeadingDeg(): void {
  4368. const rawMovementHeadingDeg = this.getRawMovementHeadingDeg()
  4369. if (rawMovementHeadingDeg === null) {
  4370. this.smoothedMovementHeadingDeg = null
  4371. return
  4372. }
  4373. const smoothingFactor = getMovementHeadingSmoothingFactor(this.telemetryRuntime.state.currentSpeedKmh)
  4374. this.smoothedMovementHeadingDeg = this.smoothedMovementHeadingDeg === null
  4375. ? rawMovementHeadingDeg
  4376. : interpolateAngleDeg(this.smoothedMovementHeadingDeg, rawMovementHeadingDeg, smoothingFactor)
  4377. }
  4378. getTrackFadeFactor(now: number): number {
  4379. if (this.trackStyleConfig.mode !== 'tail' || !this.trackStyleConfig.fadeOutWhenStill) {
  4380. return 1
  4381. }
  4382. const currentSpeedKmh = this.telemetryRuntime.state.currentSpeedKmh || 0
  4383. if (currentSpeedKmh > this.trackStyleConfig.stillSpeedKmh) {
  4384. return 1
  4385. }
  4386. if (!this.lastTrackMotionAt) {
  4387. return 1
  4388. }
  4389. const elapsedMs = Math.max(0, now - this.lastTrackMotionAt)
  4390. const fadeDurationMs = Math.max(1, this.trackStyleConfig.fadeOutDurationMs)
  4391. return Math.max(0, 1 - elapsedMs / fadeDurationMs)
  4392. }
  4393. getDynamicTailMeters(): number {
  4394. const speedKmh = Math.max(0, this.telemetryRuntime.state.currentSpeedKmh || 0)
  4395. const speedFactor = Math.max(0.35, Math.min(1.8, 0.4 + speedKmh / 6))
  4396. return this.trackStyleConfig.tailMeters * speedFactor
  4397. }
  4398. buildTrackStyleConfigForScene(): TrackVisualizationConfig {
  4399. const base = this.trackStyleConfig
  4400. const speedKmh = Math.max(0, this.telemetryRuntime.state.currentSpeedKmh || 0)
  4401. const speedIntensity = clampNumber(speedKmh / 14, 0, 1)
  4402. const toneBoost = this.state.panelTelemetryTone === 'red'
  4403. ? 0.24
  4404. : this.state.panelTelemetryTone === 'orange'
  4405. ? 0.16
  4406. : this.state.panelTelemetryTone === 'yellow'
  4407. ? 0.08
  4408. : 0
  4409. const brighten = clampNumber(speedIntensity * 0.34 + toneBoost, 0, 0.42)
  4410. const liteGlowFactor = this.state.animationLevel === 'lite' ? 0.58 : 1
  4411. const liteWidthFactor = this.state.animationLevel === 'lite' ? 0.88 : 1
  4412. return {
  4413. ...base,
  4414. colorHex: mixHexColor(base.colorHex, '#ffffff', brighten * 0.62),
  4415. headColorHex: mixHexColor(base.headColorHex, '#ffffff', brighten),
  4416. widthPx: Math.max(2.6, base.widthPx * liteWidthFactor),
  4417. headWidthPx: Math.max(4.8, base.headWidthPx * liteWidthFactor),
  4418. glowStrength: clampNumber((base.glowStrength + speedIntensity * 0.18 + toneBoost * 0.9) * liteGlowFactor, 0, 1.2),
  4419. }
  4420. }
  4421. buildGpsMarkerStyleConfigForScene(): GpsMarkerStyleConfig {
  4422. const headingConfidence = this.telemetryRuntime.state.headingConfidence
  4423. const headingAlpha = headingConfidence === 'high'
  4424. ? 1
  4425. : headingConfidence === 'medium'
  4426. ? 0.72
  4427. : 0.42
  4428. const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
  4429. const safeSpeedKmh = speedKmh !== null && Number.isFinite(speedKmh)
  4430. ? Math.max(0, speedKmh)
  4431. : 0
  4432. const tone = this.state.panelTelemetryTone
  4433. const toneScale = tone === 'red'
  4434. ? 1.3
  4435. : tone === 'orange'
  4436. ? 1.2
  4437. : tone === 'yellow'
  4438. ? 1.1
  4439. : 1
  4440. const tonePulseBoost = tone === 'red'
  4441. ? 0.68
  4442. : tone === 'orange'
  4443. ? 0.4
  4444. : tone === 'yellow'
  4445. ? 0.18
  4446. : 0
  4447. const toneMixTarget = tone === 'red'
  4448. ? '#ff3c6a'
  4449. : tone === 'orange'
  4450. ? '#ff8a2d'
  4451. : tone === 'yellow'
  4452. ? '#ffe15a'
  4453. : '#ffffff'
  4454. const toneMix = tone === 'red'
  4455. ? 0.48
  4456. : tone === 'orange'
  4457. ? 0.32
  4458. : tone === 'yellow'
  4459. ? 0.18
  4460. : 0
  4461. const litePulseFactor = this.animationLevel === 'lite' ? 0.65 : 1
  4462. const movingBlend = Math.max(0, Math.min(1, (safeSpeedKmh - 1.0) / 3.2))
  4463. const fastBlend = Math.max(0, Math.min(1, (safeSpeedKmh - 6.8) / 3.4))
  4464. const warningBlend = tone === 'red'
  4465. ? 1
  4466. : tone === 'orange'
  4467. ? 0.72
  4468. : tone === 'yellow'
  4469. ? 0.28
  4470. : 0
  4471. const motionState = warningBlend >= 0.68
  4472. ? 'warning'
  4473. : safeSpeedKmh >= 6.8
  4474. ? 'fast-moving'
  4475. : safeSpeedKmh >= 1.0
  4476. ? 'moving'
  4477. : 'idle'
  4478. const motionIntensityBase = motionState === 'idle'
  4479. ? Math.max(0, Math.min(0.2, safeSpeedKmh / 5))
  4480. : motionState === 'moving'
  4481. ? 0.38 + movingBlend * 0.34
  4482. : motionState === 'fast-moving'
  4483. ? 0.76 + fastBlend * 0.24
  4484. : 0.58 + Math.max(warningBlend * 0.3, fastBlend * 0.16)
  4485. const profile = this.gpsMarkerStyleConfig.animationProfile
  4486. const profileGain = profile === 'minimal'
  4487. ? 0.72
  4488. : profile === 'warning-reactive'
  4489. ? 1.08
  4490. : 1
  4491. const motionIntensity = Math.max(0, Math.min(1.2, motionIntensityBase * profileGain))
  4492. const statePulseBoost = motionState === 'idle'
  4493. ? 0.06
  4494. : motionState === 'moving'
  4495. ? 0.24 + movingBlend * 0.12
  4496. : motionState === 'fast-moving'
  4497. ? 0.48 + fastBlend * 0.18
  4498. : 0.42 + warningBlend * 0.24
  4499. const wakeStrength = profile === 'minimal'
  4500. ? (motionState === 'idle' ? 0 : motionState === 'moving' ? 0.14 + movingBlend * 0.08 : motionState === 'fast-moving' ? 0.28 + fastBlend * 0.16 : 0.18 + warningBlend * 0.16)
  4501. : motionState === 'idle'
  4502. ? 0
  4503. : motionState === 'moving'
  4504. ? 0.24 + movingBlend * 0.16
  4505. : motionState === 'fast-moving'
  4506. ? 0.52 + fastBlend * 0.24
  4507. : 0.3 + warningBlend * 0.24
  4508. const warningGlowStrength = Math.max(
  4509. 0,
  4510. Math.min(
  4511. 1,
  4512. (warningBlend * (profile === 'warning-reactive' ? 1.12 : 0.9))
  4513. * (this.animationLevel === 'lite' ? 0.72 : 1),
  4514. ),
  4515. )
  4516. const dynamicEffectScale = motionState === 'idle'
  4517. ? 0.98 + motionIntensity * 0.04
  4518. : motionState === 'moving'
  4519. ? 1.03 + movingBlend * 0.06
  4520. : motionState === 'fast-moving'
  4521. ? 1.1 + fastBlend * 0.12
  4522. : 1.08 + warningBlend * 0.08
  4523. const indicatorScale = motionState === 'idle'
  4524. ? 0.96
  4525. : motionState === 'moving'
  4526. ? 1.08
  4527. : motionState === 'fast-moving'
  4528. ? 1.18
  4529. : 1.1
  4530. const logoScale = motionState === 'idle'
  4531. ? 0.96
  4532. : motionState === 'moving'
  4533. ? 1
  4534. : motionState === 'fast-moving'
  4535. ? 1.06
  4536. : 1
  4537. return {
  4538. ...this.gpsMarkerStyleConfig,
  4539. colorHex: mixHexColor(this.gpsMarkerStyleConfig.colorHex, toneMixTarget, toneMix),
  4540. indicatorColorHex: mixHexColor(this.gpsMarkerStyleConfig.indicatorColorHex, '#ffffff', Math.min(0.22, toneMix + 0.06)),
  4541. motionState,
  4542. motionIntensity,
  4543. pulseStrength: (this.gpsMarkerStyleConfig.pulseStrength + tonePulseBoost + statePulseBoost) * litePulseFactor,
  4544. headingAlpha: Math.max(
  4545. headingAlpha,
  4546. motionState === 'fast-moving' ? 0.72 : motionState === 'moving' ? 0.56 : motionState === 'warning' ? 0.66 : 0.42,
  4547. ),
  4548. effectScale: toneScale * dynamicEffectScale,
  4549. wakeStrength,
  4550. warningGlowStrength,
  4551. indicatorScale,
  4552. logoScale,
  4553. showHeadingIndicator: this.gpsMarkerStyleConfig.showHeadingIndicator && this.compassDisplayHeadingDeg !== null,
  4554. }
  4555. }
  4556. buildTrackPointsForScene(): LonLatPoint[] {
  4557. if (this.trackStyleConfig.mode === 'none') {
  4558. return []
  4559. }
  4560. if (this.trackStyleConfig.mode === 'full') {
  4561. return this.currentGpsTrack
  4562. }
  4563. if (this.currentGpsTrackSamples.length < 2) {
  4564. return this.currentGpsTrack
  4565. }
  4566. const now = Date.now()
  4567. const fadeFactor = this.getTrackFadeFactor(now)
  4568. if (fadeFactor <= 0.02) {
  4569. return []
  4570. }
  4571. const effectiveTailMeters = Math.max(4, this.getDynamicTailMeters() * fadeFactor)
  4572. const cutoffAt = this.trackStyleConfig.tailMaxSeconds > 0
  4573. ? now - this.trackStyleConfig.tailMaxSeconds * 1000
  4574. : 0
  4575. const samples = this.currentGpsTrackSamples
  4576. const collected: GpsTrackSample[] = [samples[samples.length - 1]]
  4577. let accumulatedDistanceMeters = 0
  4578. for (let index = samples.length - 2; index >= 0; index -= 1) {
  4579. const nextSample = samples[index + 1]
  4580. const sample = samples[index]
  4581. if (cutoffAt && sample.at < cutoffAt) {
  4582. break
  4583. }
  4584. accumulatedDistanceMeters += getApproxDistanceMeters(sample.point, nextSample.point)
  4585. collected.unshift(sample)
  4586. if (accumulatedDistanceMeters >= effectiveTailMeters) {
  4587. break
  4588. }
  4589. }
  4590. return collected.map((sample) => sample.point)
  4591. }
  4592. getMovementHeadingDeg(): number | null {
  4593. return this.smoothedMovementHeadingDeg
  4594. }
  4595. getPreferredSensorHeadingDeg(): number | null {
  4596. return this.smoothedSensorHeadingDeg === null
  4597. ? null
  4598. : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  4599. }
  4600. getSmartAutoRotateHeadingDeg(): number | null {
  4601. const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
  4602. const movementHeadingDeg = this.getMovementHeadingDeg()
  4603. const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
  4604. const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
  4605. if (smartSource === 'movement') {
  4606. return movementHeadingDeg === null ? sensorHeadingDeg : movementHeadingDeg
  4607. }
  4608. if (smartSource === 'blended' && sensorHeadingDeg !== null && movementHeadingDeg !== null && speedKmh !== null) {
  4609. 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)))
  4610. return interpolateAngleDeg(sensorHeadingDeg, movementHeadingDeg, blend)
  4611. }
  4612. return sensorHeadingDeg === null ? movementHeadingDeg : sensorHeadingDeg
  4613. }
  4614. getAutoRotateSourceText(): string {
  4615. if (this.autoRotateSourceMode !== 'smart') {
  4616. return formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null)
  4617. }
  4618. const smartSource = resolveSmartHeadingSource(
  4619. this.telemetryRuntime.state.currentSpeedKmh,
  4620. this.getMovementHeadingDeg() !== null,
  4621. )
  4622. return formatSmartHeadingSourceText(smartSource)
  4623. }
  4624. resolveAutoRotateInputHeadingDeg(): number | null {
  4625. if (this.autoRotateSourceMode === 'smart') {
  4626. return this.getSmartAutoRotateHeadingDeg()
  4627. }
  4628. const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
  4629. const courseHeadingDeg = this.courseHeadingDeg === null
  4630. ? null
  4631. : getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
  4632. if (this.autoRotateSourceMode === 'sensor') {
  4633. return sensorHeadingDeg
  4634. }
  4635. if (this.autoRotateSourceMode === 'course') {
  4636. return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg
  4637. }
  4638. if (sensorHeadingDeg !== null && courseHeadingDeg !== null) {
  4639. return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35)
  4640. }
  4641. return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg
  4642. }
  4643. calibrateAutoRotateToCurrentOrientation(): boolean {
  4644. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  4645. if (inputHeadingDeg === null) {
  4646. return false
  4647. }
  4648. this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg)
  4649. this.autoRotateCalibrationPending = false
  4650. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  4651. this.setState({
  4652. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  4653. })
  4654. return true
  4655. }
  4656. refreshAutoRotateTarget(): boolean {
  4657. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  4658. if (inputHeadingDeg === null) {
  4659. return false
  4660. }
  4661. if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) {
  4662. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  4663. return false
  4664. }
  4665. return true
  4666. }
  4667. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  4668. this.setState({
  4669. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  4670. })
  4671. return true
  4672. }
  4673. scheduleAutoRotate(): void {
  4674. if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  4675. return
  4676. }
  4677. const step = () => {
  4678. this.autoRotateTimer = 0
  4679. if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  4680. return
  4681. }
  4682. if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) {
  4683. this.scheduleAutoRotate()
  4684. return
  4685. }
  4686. const currentRotationDeg = this.state.rotationDeg
  4687. const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg)
  4688. if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) {
  4689. if (Math.abs(deltaDeg) > 0.01) {
  4690. this.applyAutoRotation(this.targetAutoRotationDeg)
  4691. }
  4692. this.scheduleAutoRotate()
  4693. return
  4694. }
  4695. if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) {
  4696. this.scheduleAutoRotate()
  4697. return
  4698. }
  4699. const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG)
  4700. this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg))
  4701. this.scheduleAutoRotate()
  4702. }
  4703. this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number
  4704. }
  4705. applyAutoRotation(nextRotationDeg: number): void {
  4706. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  4707. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  4708. this.setState({
  4709. ...resolvedViewport,
  4710. rotationDeg: nextRotationDeg,
  4711. rotationText: formatRotationText(nextRotationDeg),
  4712. centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY),
  4713. })
  4714. this.syncRenderer()
  4715. }
  4716. applyStats(stats: MapRendererStats): void {
  4717. const statsPatch = {
  4718. visibleTileCount: stats.visibleTileCount,
  4719. readyTileCount: stats.readyTileCount,
  4720. memoryTileCount: stats.memoryTileCount,
  4721. diskTileCount: stats.diskTileCount,
  4722. memoryHitCount: stats.memoryHitCount,
  4723. diskHitCount: stats.diskHitCount,
  4724. networkFetchCount: stats.networkFetchCount,
  4725. cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount),
  4726. }
  4727. if (!this.diagnosticUiEnabled) {
  4728. this.state = {
  4729. ...this.state,
  4730. ...statsPatch,
  4731. }
  4732. return
  4733. }
  4734. const now = Date.now()
  4735. if (now - this.lastStatsUiSyncAt < 500) {
  4736. this.state = {
  4737. ...this.state,
  4738. ...statsPatch,
  4739. }
  4740. return
  4741. }
  4742. this.lastStatsUiSyncAt = now
  4743. this.setState(statsPatch)
  4744. }
  4745. setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {
  4746. this.state = {
  4747. ...this.state,
  4748. ...patch,
  4749. }
  4750. const viewPatch = this.pickViewPatch(patch)
  4751. if (!Object.keys(viewPatch).length) {
  4752. return
  4753. }
  4754. this.pendingViewPatch = {
  4755. ...this.pendingViewPatch,
  4756. ...viewPatch,
  4757. }
  4758. if (immediateUi) {
  4759. this.flushViewPatch()
  4760. return
  4761. }
  4762. if (this.viewSyncTimer) {
  4763. return
  4764. }
  4765. this.viewSyncTimer = setTimeout(() => {
  4766. this.viewSyncTimer = 0
  4767. this.flushViewPatch()
  4768. }, UI_SYNC_INTERVAL_MS) as unknown as number
  4769. }
  4770. commitViewport(
  4771. patch: Partial<MapEngineViewState>,
  4772. statusText: string,
  4773. immediateUi = false,
  4774. afterUpdate?: () => void,
  4775. ): void {
  4776. const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom
  4777. const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX
  4778. const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY
  4779. const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth
  4780. const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight
  4781. const tileSizePx = getTileSizePx({
  4782. centerWorldX: nextCenterTileX,
  4783. centerWorldY: nextCenterTileY,
  4784. viewportWidth: nextStageWidth,
  4785. viewportHeight: nextStageHeight,
  4786. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  4787. })
  4788. this.setState({
  4789. ...patch,
  4790. tileSizePx,
  4791. centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY),
  4792. statusText,
  4793. }, immediateUi)
  4794. this.syncRenderer()
  4795. this.compassController.start()
  4796. if (afterUpdate) {
  4797. afterUpdate()
  4798. }
  4799. }
  4800. buildScene() {
  4801. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  4802. const readyControlSequences = this.resolveReadyControlSequences()
  4803. const controlScoresBySequence: Record<number, number> = {}
  4804. const controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry> = {}
  4805. const startStyleOverrides: ControlPointStyleEntry[] = []
  4806. const finishStyleOverrides: ControlPointStyleEntry[] = []
  4807. const gpsMarkerStyleConfig = this.buildGpsMarkerStyleConfigForScene()
  4808. if (this.gameRuntime.definition) {
  4809. for (let index = 0; index < this.gameRuntime.definition.controls.length; index += 1) {
  4810. const control = this.gameRuntime.definition.controls[index]
  4811. if (control.sequence !== null && control.score !== null) {
  4812. controlScoresBySequence[control.sequence] = control.score
  4813. }
  4814. const styleOverride = this.controlPointStyleOverrides[control.id]
  4815. if (!styleOverride) {
  4816. continue
  4817. }
  4818. if (control.kind === 'control' && control.sequence !== null) {
  4819. controlStyleOverridesBySequence[control.sequence] = styleOverride
  4820. continue
  4821. }
  4822. if (control.kind === 'start') {
  4823. const startIndexMatch = control.id.match(/^start-(\d+)$/)
  4824. if (startIndexMatch) {
  4825. startStyleOverrides[Math.max(0, Number(startIndexMatch[1]) - 1)] = styleOverride
  4826. }
  4827. continue
  4828. }
  4829. if (control.kind === 'finish') {
  4830. const finishIndexMatch = control.id.match(/^finish-(\d+)$/)
  4831. if (finishIndexMatch) {
  4832. finishStyleOverrides[Math.max(0, Number(finishIndexMatch[1]) - 1)] = styleOverride
  4833. }
  4834. }
  4835. }
  4836. }
  4837. return {
  4838. tileSource: this.state.tileSource,
  4839. osmTileSource: OSM_TILE_SOURCE,
  4840. zoom: this.state.zoom,
  4841. centerTileX: this.state.centerTileX,
  4842. centerTileY: this.state.centerTileY,
  4843. exactCenterWorldX: exactCenter.x,
  4844. exactCenterWorldY: exactCenter.y,
  4845. tileBoundsByZoom: this.tileBoundsByZoom,
  4846. viewportWidth: this.state.stageWidth,
  4847. viewportHeight: this.state.stageHeight,
  4848. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  4849. overdraw: OVERDRAW,
  4850. translateX: this.state.tileTranslateX,
  4851. translateY: this.state.tileTranslateY,
  4852. rotationRad: this.getRotationRad(this.state.rotationDeg),
  4853. animationLevel: this.state.animationLevel,
  4854. previewScale: this.previewScale || 1,
  4855. previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
  4856. previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
  4857. trackMode: this.trackStyleConfig.mode,
  4858. trackStyleConfig: this.buildTrackStyleConfigForScene(),
  4859. track: this.buildTrackPointsForScene(),
  4860. gpsPoint: this.currentGpsPoint,
  4861. gpsMarkerStyleConfig,
  4862. gpsHeadingDeg: this.compassDisplayHeadingDeg,
  4863. gpsHeadingAlpha: gpsMarkerStyleConfig.headingAlpha,
  4864. gpsCalibration: GPS_MAP_CALIBRATION,
  4865. gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
  4866. course: this.courseOverlayVisible ? this.courseData : null,
  4867. cpRadiusMeters: this.cpRadiusMeters,
  4868. gameMode: this.gameMode,
  4869. courseStyleConfig: this.courseStyleConfig,
  4870. controlScoresBySequence,
  4871. defaultControlStyleOverride: this.defaultControlPointStyleOverride,
  4872. controlStyleOverridesBySequence,
  4873. startStyleOverrides,
  4874. finishStyleOverrides,
  4875. defaultLegStyleOverride: this.defaultLegStyleOverride,
  4876. legStyleOverridesByIndex: this.legStyleOverrides,
  4877. controlVisualMode: this.gamePresentation.map.controlVisualMode,
  4878. showCourseLegs: this.gamePresentation.map.showCourseLegs,
  4879. guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled,
  4880. focusableControlIds: this.gamePresentation.map.focusableControlIds,
  4881. focusedControlId: this.gamePresentation.map.focusedControlId,
  4882. focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
  4883. activeControlSequences: this.gamePresentation.map.activeControlSequences,
  4884. readyControlSequences,
  4885. activeStart: this.gamePresentation.map.activeStart,
  4886. completedStart: this.gamePresentation.map.completedStart,
  4887. activeFinish: this.gamePresentation.map.activeFinish,
  4888. focusedFinish: this.gamePresentation.map.focusedFinish,
  4889. completedFinish: this.gamePresentation.map.completedFinish,
  4890. revealFullCourse: this.gamePresentation.map.revealFullCourse,
  4891. activeLegIndices: this.gamePresentation.map.activeLegIndices,
  4892. completedLegIndices: this.gamePresentation.map.completedLegIndices,
  4893. completedControlSequences: this.gamePresentation.map.completedControlSequences,
  4894. skippedControlIds: this.gamePresentation.map.skippedControlIds,
  4895. skippedControlSequences: this.gamePresentation.map.skippedControlSequences,
  4896. osmReferenceEnabled: this.state.osmReferenceEnabled,
  4897. overlayOpacity: MAP_OVERLAY_OPACITY,
  4898. }
  4899. }
  4900. resolveReadyControlSequences(): number[] {
  4901. const punchableControlId = this.gamePresentation.hud.punchableControlId
  4902. const definition = this.gameRuntime.definition
  4903. if (!punchableControlId || !definition) {
  4904. return []
  4905. }
  4906. const control = definition.controls.find((item) => item.id === punchableControlId)
  4907. if (!control || control.sequence === null) {
  4908. return []
  4909. }
  4910. return [control.sequence]
  4911. }
  4912. syncRenderer(): void {
  4913. if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
  4914. return
  4915. }
  4916. this.renderer.updateScene(this.buildScene())
  4917. }
  4918. getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
  4919. return {
  4920. centerWorldX: this.state.centerTileX + 0.5,
  4921. centerWorldY: this.state.centerTileY + 0.5,
  4922. viewportWidth: this.state.stageWidth,
  4923. viewportHeight: this.state.stageHeight,
  4924. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  4925. translateX: this.state.tileTranslateX,
  4926. translateY: this.state.tileTranslateY,
  4927. rotationRad: this.getRotationRad(rotationDeg),
  4928. }
  4929. }
  4930. getRotationRad(rotationDeg = this.state.rotationDeg): number {
  4931. return normalizeRotationDeg(rotationDeg) * Math.PI / 180
  4932. }
  4933. getBaseCamera(centerTileX = this.state.centerTileX, centerTileY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
  4934. return {
  4935. centerWorldX: centerTileX + 0.5,
  4936. centerWorldY: centerTileY + 0.5,
  4937. viewportWidth: this.state.stageWidth,
  4938. viewportHeight: this.state.stageHeight,
  4939. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  4940. rotationRad: this.getRotationRad(rotationDeg),
  4941. }
  4942. }
  4943. getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } {
  4944. const baseCamera = {
  4945. centerWorldX: 0,
  4946. centerWorldY: 0,
  4947. viewportWidth: this.state.stageWidth,
  4948. viewportHeight: this.state.stageHeight,
  4949. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  4950. rotationRad: this.getRotationRad(rotationDeg),
  4951. }
  4952. return screenToWorld(baseCamera, { x: stageX, y: stageY }, false)
  4953. }
  4954. getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
  4955. if (!this.state.stageWidth || !this.state.stageHeight) {
  4956. return {
  4957. x: this.state.centerTileX + 0.5,
  4958. y: this.state.centerTileY + 0.5,
  4959. }
  4960. }
  4961. const screenCenterX = this.state.stageWidth / 2
  4962. const screenCenterY = this.state.stageHeight / 2
  4963. return screenToWorld(this.getBaseCamera(), {
  4964. x: screenCenterX - translateX,
  4965. y: screenCenterY - translateY,
  4966. }, false)
  4967. }
  4968. resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): {
  4969. centerTileX: number
  4970. centerTileY: number
  4971. tileTranslateX: number
  4972. tileTranslateY: number
  4973. } {
  4974. const nextCenterTileX = Math.floor(centerWorldX)
  4975. const nextCenterTileY = Math.floor(centerWorldY)
  4976. if (!this.state.stageWidth || !this.state.stageHeight) {
  4977. return {
  4978. centerTileX: nextCenterTileX,
  4979. centerTileY: nextCenterTileY,
  4980. tileTranslateX: 0,
  4981. tileTranslateY: 0,
  4982. }
  4983. }
  4984. const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg)
  4985. const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false)
  4986. return {
  4987. centerTileX: nextCenterTileX,
  4988. centerTileY: nextCenterTileY,
  4989. tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x,
  4990. tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y,
  4991. }
  4992. }
  4993. setPreviewState(scale: number, originX: number, originY: number): void {
  4994. this.previewScale = scale
  4995. this.previewOriginX = originX
  4996. this.previewOriginY = originY
  4997. this.setState({
  4998. previewScale: scale,
  4999. }, true)
  5000. }
  5001. resetPreviewState(): void {
  5002. this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2)
  5003. }
  5004. resetPinchState(): void {
  5005. this.pinchStartDistance = 0
  5006. this.pinchStartScale = 1
  5007. this.pinchStartAngle = 0
  5008. this.pinchStartRotationDeg = this.state.rotationDeg
  5009. this.pinchAnchorWorldX = 0
  5010. this.pinchAnchorWorldY = 0
  5011. }
  5012. clearPreviewResetTimer(): void {
  5013. if (this.previewResetTimer) {
  5014. clearTimeout(this.previewResetTimer)
  5015. this.previewResetTimer = 0
  5016. }
  5017. }
  5018. clearInertiaTimer(): void {
  5019. if (this.inertiaTimer) {
  5020. clearTimeout(this.inertiaTimer)
  5021. this.inertiaTimer = 0
  5022. }
  5023. }
  5024. clearViewSyncTimer(): void {
  5025. if (this.viewSyncTimer) {
  5026. clearTimeout(this.viewSyncTimer)
  5027. this.viewSyncTimer = 0
  5028. }
  5029. }
  5030. clearAutoRotateTimer(): void {
  5031. if (this.autoRotateTimer) {
  5032. clearTimeout(this.autoRotateTimer)
  5033. this.autoRotateTimer = 0
  5034. }
  5035. }
  5036. clearCompassNeedleTimer(): void {
  5037. if (this.compassNeedleTimer) {
  5038. clearTimeout(this.compassNeedleTimer)
  5039. this.compassNeedleTimer = 0
  5040. }
  5041. }
  5042. clearCompassBootstrapRetryTimer(): void {
  5043. if (this.compassBootstrapRetryTimer) {
  5044. clearTimeout(this.compassBootstrapRetryTimer)
  5045. this.compassBootstrapRetryTimer = 0
  5046. }
  5047. }
  5048. scheduleCompassBootstrapRetry(): void {
  5049. this.clearCompassBootstrapRetryTimer()
  5050. if (!this.mounted) {
  5051. return
  5052. }
  5053. this.compassBootstrapRetryTimer = setTimeout(() => {
  5054. this.compassBootstrapRetryTimer = 0
  5055. if (!this.mounted || this.lastCompassSampleAt > 0) {
  5056. return
  5057. }
  5058. this.compassController.stop()
  5059. this.compassController.start()
  5060. }, COMPASS_BOOTSTRAP_RETRY_DELAY_MS) as unknown as number
  5061. }
  5062. syncCompassDisplayState(): void {
  5063. this.setState({
  5064. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
  5065. sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
  5066. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  5067. ...(this.diagnosticUiEnabled
  5068. ? {
  5069. ...this.getTelemetrySensorViewPatch(),
  5070. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  5071. autoRotateSourceText: this.getAutoRotateSourceText(),
  5072. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  5073. }
  5074. : {}),
  5075. })
  5076. }
  5077. applyTelemetryPlayerProfile(profile?: PlayerTelemetryProfile | null): void {
  5078. this.telemetryPlayerProfile = profile ? { ...profile } : null
  5079. this.telemetryRuntime.setPlayerProfile(this.telemetryPlayerProfile)
  5080. this.setState(this.getGameViewPatch(), true)
  5081. }
  5082. applyCompiledTelemetryProfile(profile: RuntimeTelemetryProfile): void {
  5083. this.telemetryPlayerProfile = profile.playerProfile ? { ...profile.playerProfile } : null
  5084. this.telemetryRuntime.applyCompiledProfile(profile.config, this.telemetryPlayerProfile)
  5085. this.setState(this.getGameViewPatch(), true)
  5086. }
  5087. applyCompiledSettingsProfile(profile: RuntimeSettingsProfile): void {
  5088. const values = profile.values
  5089. this.handleSetAnimationLevel(values.animationLevel)
  5090. this.handleSetTrackMode(values.trackDisplayMode)
  5091. this.handleSetTrackTailLength(values.trackTailLength)
  5092. this.handleSetTrackColorPreset(values.trackColorPreset)
  5093. this.handleSetTrackStyleProfile(values.trackStyleProfile)
  5094. this.handleSetGpsMarkerVisible(values.gpsMarkerVisible)
  5095. this.handleSetGpsMarkerStyle(values.gpsMarkerStyle)
  5096. this.handleSetGpsMarkerSize(values.gpsMarkerSize)
  5097. this.handleSetGpsMarkerColorPreset(values.gpsMarkerColorPreset)
  5098. if (values.autoRotateEnabled) {
  5099. this.handleSetHeadingUpMode()
  5100. } else {
  5101. this.handleSetManualMode()
  5102. }
  5103. this.handleSetCompassTuningProfile(values.compassTuningProfile)
  5104. this.handleSetNorthReferenceMode(values.northReferenceMode)
  5105. }
  5106. applyCompiledMapProfile(profile: RuntimeMapProfile): void {
  5107. MAGNETIC_DECLINATION_DEG = profile.magneticDeclinationDeg
  5108. MAGNETIC_DECLINATION_TEXT = normalizeDegreeDisplayText(profile.magneticDeclinationText)
  5109. this.minZoom = profile.minZoom
  5110. this.maxZoom = profile.maxZoom
  5111. this.defaultZoom = profile.initialZoom
  5112. this.defaultCenterTileX = profile.initialCenterTileX
  5113. this.defaultCenterTileY = profile.initialCenterTileY
  5114. this.tileBoundsByZoom = profile.tileBoundsByZoom
  5115. this.cpRadiusMeters = profile.cpRadiusMeters
  5116. this.setState({
  5117. mapName: profile.title,
  5118. configStatusText: `配置已载入 / ${profile.title} / ${profile.courseStatusText}`,
  5119. projectionMode: profile.projectionModeText,
  5120. tileSource: profile.tileSource,
  5121. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  5122. sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg),
  5123. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  5124. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  5125. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg),
  5126. }, true)
  5127. }
  5128. applyCompiledGameProfile(profile: RuntimeGameProfile): void {
  5129. this.gameMode = profile.mode
  5130. this.sessionCloseAfterMs = profile.sessionCloseAfterMs
  5131. this.sessionCloseWarningMs = profile.sessionCloseWarningMs
  5132. this.minCompletedControlsBeforeFinish = profile.minCompletedControlsBeforeFinish
  5133. this.punchPolicy = profile.punchPolicy
  5134. this.punchRadiusMeters = profile.punchRadiusMeters
  5135. this.requiresFocusSelection = profile.requiresFocusSelection
  5136. this.skipEnabled = profile.skipEnabled
  5137. this.skipRadiusMeters = profile.skipRadiusMeters
  5138. this.skipRequiresConfirm = profile.skipRequiresConfirm
  5139. this.autoFinishOnLastControl = profile.autoFinishOnLastControl
  5140. this.defaultControlScore = profile.defaultControlScore
  5141. const gameResult = this.loadGameDefinitionFromCourse()
  5142. const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null
  5143. this.setState(this.getGameViewPatch(gameStatusText), true)
  5144. }
  5145. applyCompiledFeedbackProfile(profile: RuntimeFeedbackProfile): void {
  5146. this.feedbackDirector.configure({
  5147. audioConfig: profile.audio,
  5148. hapticsConfig: profile.haptics,
  5149. uiEffectsConfig: profile.uiEffects,
  5150. })
  5151. }
  5152. applyCompiledPresentationProfile(profile: RuntimePresentationProfile): void {
  5153. this.courseStyleConfig = profile.course
  5154. this.trackStyleConfig = profile.track
  5155. this.gpsMarkerStyleConfig = profile.gpsMarker
  5156. this.setState({
  5157. trackDisplayMode: this.trackStyleConfig.mode,
  5158. trackTailLength: this.trackStyleConfig.tailLength,
  5159. trackColorPreset: this.trackStyleConfig.colorPreset,
  5160. trackStyleProfile: this.trackStyleConfig.style,
  5161. gpsMarkerVisible: this.gpsMarkerStyleConfig.visible,
  5162. gpsMarkerStyle: this.gpsMarkerStyleConfig.style,
  5163. gpsMarkerSize: this.gpsMarkerStyleConfig.size,
  5164. gpsMarkerColorPreset: this.gpsMarkerStyleConfig.colorPreset,
  5165. gpsLogoStatusText: this.gpsMarkerStyleConfig.logoUrl ? '等待渲染' : '未配置',
  5166. gpsLogoSourceText: this.gpsMarkerStyleConfig.logoUrl || '--',
  5167. }, true)
  5168. }
  5169. scheduleCompassNeedleFollow(): void {
  5170. if (
  5171. this.compassNeedleTimer
  5172. || this.targetCompassDisplayHeadingDeg === null
  5173. || this.compassDisplayHeadingDeg === null
  5174. ) {
  5175. return
  5176. }
  5177. const step = () => {
  5178. this.compassNeedleTimer = 0
  5179. if (
  5180. this.targetCompassDisplayHeadingDeg === null
  5181. || this.compassDisplayHeadingDeg === null
  5182. ) {
  5183. return
  5184. }
  5185. const deltaDeg = normalizeAngleDeltaDeg(
  5186. this.targetCompassDisplayHeadingDeg - this.compassDisplayHeadingDeg,
  5187. )
  5188. const absDeltaDeg = Math.abs(deltaDeg)
  5189. if (absDeltaDeg <= COMPASS_NEEDLE_SNAP_DEG) {
  5190. if (absDeltaDeg > 0.001) {
  5191. this.compassDisplayHeadingDeg = this.targetCompassDisplayHeadingDeg
  5192. this.syncCompassDisplayState()
  5193. }
  5194. return
  5195. }
  5196. this.compassDisplayHeadingDeg = interpolateAngleDeg(
  5197. this.compassDisplayHeadingDeg,
  5198. this.targetCompassDisplayHeadingDeg,
  5199. getCompassNeedleSmoothingFactor(
  5200. this.compassDisplayHeadingDeg,
  5201. this.targetCompassDisplayHeadingDeg,
  5202. this.compassTuningProfile,
  5203. ),
  5204. )
  5205. this.syncCompassDisplayState()
  5206. this.scheduleCompassNeedleFollow()
  5207. }
  5208. this.compassNeedleTimer = setTimeout(step, COMPASS_NEEDLE_FRAME_MS) as unknown as number
  5209. }
  5210. pickViewPatch(patch: Partial<MapEngineViewState>): Partial<MapEngineViewState> {
  5211. const viewPatch = {} as Partial<MapEngineViewState>
  5212. for (const key of VIEW_SYNC_KEYS) {
  5213. if (Object.prototype.hasOwnProperty.call(patch, key)) {
  5214. ;(viewPatch as any)[key] = patch[key]
  5215. }
  5216. }
  5217. return viewPatch
  5218. }
  5219. flushViewPatch(): void {
  5220. if (!Object.keys(this.pendingViewPatch).length) {
  5221. return
  5222. }
  5223. const patch = this.pendingViewPatch
  5224. const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer
  5225. const nextPendingPatch = {} as Partial<MapEngineViewState>
  5226. const outputPatch = {} as Partial<MapEngineViewState>
  5227. for (const [key, value] of Object.entries(patch) as Array<[keyof MapEngineViewState, MapEngineViewState[keyof MapEngineViewState]]>) {
  5228. if (shouldDeferForInteraction && INTERACTION_DEFERRED_VIEW_KEYS.has(key)) {
  5229. ;(nextPendingPatch as Record<string, unknown>)[key] = value
  5230. continue
  5231. }
  5232. ;(outputPatch as Record<string, unknown>)[key] = value
  5233. }
  5234. this.pendingViewPatch = nextPendingPatch
  5235. if (Object.keys(this.pendingViewPatch).length && !this.viewSyncTimer) {
  5236. this.viewSyncTimer = setTimeout(() => {
  5237. this.viewSyncTimer = 0
  5238. this.flushViewPatch()
  5239. }, UI_SYNC_INTERVAL_MS) as unknown as number
  5240. }
  5241. if (!Object.keys(outputPatch).length) {
  5242. return
  5243. }
  5244. this.onData(outputPatch)
  5245. }
  5246. getTouchDistance(touches: TouchPoint[]): number {
  5247. if (touches.length < 2) {
  5248. return 0
  5249. }
  5250. const first = touches[0]
  5251. const second = touches[1]
  5252. const deltaX = first.pageX - second.pageX
  5253. const deltaY = first.pageY - second.pageY
  5254. return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
  5255. }
  5256. getTouchAngle(touches: TouchPoint[]): number {
  5257. if (touches.length < 2) {
  5258. return 0
  5259. }
  5260. const first = touches[0]
  5261. const second = touches[1]
  5262. return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX)
  5263. }
  5264. getStagePoint(touches: TouchPoint[]): { x: number; y: number } {
  5265. if (!touches.length) {
  5266. return {
  5267. x: this.state.stageWidth / 2,
  5268. y: this.state.stageHeight / 2,
  5269. }
  5270. }
  5271. let pageX = 0
  5272. let pageY = 0
  5273. for (const touch of touches) {
  5274. pageX += touch.pageX
  5275. pageY += touch.pageY
  5276. }
  5277. return {
  5278. x: pageX / touches.length - this.state.stageLeft,
  5279. y: pageY / touches.length - this.state.stageTop,
  5280. }
  5281. }
  5282. animatePreviewToRest(): void {
  5283. this.clearPreviewResetTimer()
  5284. const startScale = this.previewScale || 1
  5285. const originX = this.previewOriginX || this.state.stageWidth / 2
  5286. const originY = this.previewOriginY || this.state.stageHeight / 2
  5287. if (Math.abs(startScale - 1) < 0.01) {
  5288. this.resetPreviewState()
  5289. this.syncRenderer()
  5290. this.compassController.start()
  5291. this.scheduleAutoRotate()
  5292. return
  5293. }
  5294. const startAt = Date.now()
  5295. const step = () => {
  5296. const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS)
  5297. const eased = 1 - Math.pow(1 - progress, 3)
  5298. const nextScale = startScale + (1 - startScale) * eased
  5299. this.setPreviewState(nextScale, originX, originY)
  5300. this.syncRenderer()
  5301. this.compassController.start()
  5302. if (progress >= 1) {
  5303. this.resetPreviewState()
  5304. this.syncRenderer()
  5305. this.compassController.start()
  5306. this.previewResetTimer = 0
  5307. this.scheduleAutoRotate()
  5308. return
  5309. }
  5310. this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  5311. }
  5312. step()
  5313. }
  5314. normalizeTranslate(translateX: number, translateY: number, statusText: string): void {
  5315. if (!this.state.stageWidth) {
  5316. this.setState({
  5317. tileTranslateX: translateX,
  5318. tileTranslateY: translateY,
  5319. })
  5320. this.syncRenderer()
  5321. this.compassController.start()
  5322. return
  5323. }
  5324. const exactCenter = this.getExactCenterFromTranslate(translateX, translateY)
  5325. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y)
  5326. const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY
  5327. if (centerChanged) {
  5328. this.commitViewport(resolvedViewport, statusText)
  5329. return
  5330. }
  5331. this.setState({
  5332. tileTranslateX: resolvedViewport.tileTranslateX,
  5333. tileTranslateY: resolvedViewport.tileTranslateY,
  5334. })
  5335. this.syncRenderer()
  5336. this.compassController.start()
  5337. }
  5338. zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
  5339. const nextZoom = clamp(this.state.zoom + zoomDelta, this.minZoom, this.maxZoom)
  5340. const appliedDelta = nextZoom - this.state.zoom
  5341. if (!appliedDelta) {
  5342. this.animatePreviewToRest()
  5343. return
  5344. }
  5345. if (this.gpsLockEnabled && this.currentGpsPoint) {
  5346. const nextGpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, nextZoom)
  5347. const resolvedViewport = this.resolveViewportForExactCenter(nextGpsWorldPoint.x, nextGpsWorldPoint.y)
  5348. this.commitViewport(
  5349. {
  5350. zoom: nextZoom,
  5351. ...resolvedViewport,
  5352. },
  5353. `缩放级别调整到 ${nextZoom}`,
  5354. true,
  5355. () => {
  5356. this.setPreviewState(residualScale, this.state.stageWidth / 2, this.state.stageHeight / 2)
  5357. this.syncRenderer()
  5358. this.compassController.start()
  5359. this.animatePreviewToRest()
  5360. },
  5361. )
  5362. return
  5363. }
  5364. if (!this.state.stageWidth || !this.state.stageHeight) {
  5365. this.commitViewport(
  5366. {
  5367. zoom: nextZoom,
  5368. centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2),
  5369. centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2),
  5370. tileTranslateX: 0,
  5371. tileTranslateY: 0,
  5372. },
  5373. `缩放级别调整到 ${nextZoom}`,
  5374. true,
  5375. () => {
  5376. this.setPreviewState(residualScale, stageX, stageY)
  5377. this.syncRenderer()
  5378. this.compassController.start()
  5379. this.animatePreviewToRest()
  5380. },
  5381. )
  5382. return
  5383. }
  5384. const camera = this.getCameraState()
  5385. const world = screenToWorld(camera, { x: stageX, y: stageY }, true)
  5386. const zoomFactor = Math.pow(2, appliedDelta)
  5387. const nextWorldX = world.x * zoomFactor
  5388. const nextWorldY = world.y * zoomFactor
  5389. const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY)
  5390. const exactCenterX = nextWorldX - anchorOffset.x
  5391. const exactCenterY = nextWorldY - anchorOffset.y
  5392. const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY)
  5393. this.commitViewport(
  5394. {
  5395. zoom: nextZoom,
  5396. ...resolvedViewport,
  5397. },
  5398. `缩放级别调整到 ${nextZoom}`,
  5399. true,
  5400. () => {
  5401. this.setPreviewState(residualScale, stageX, stageY)
  5402. this.syncRenderer()
  5403. this.compassController.start()
  5404. this.animatePreviewToRest()
  5405. },
  5406. )
  5407. }
  5408. startInertia(): void {
  5409. this.clearInertiaTimer()
  5410. const step = () => {
  5411. this.panVelocityX *= INERTIA_DECAY
  5412. this.panVelocityY *= INERTIA_DECAY
  5413. if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
  5414. this.setState({
  5415. statusText: `惯性滑动结束 (${this.buildVersion})`,
  5416. })
  5417. this.renderer.setAnimationPaused(false)
  5418. this.inertiaTimer = 0
  5419. this.scheduleAutoRotate()
  5420. return
  5421. }
  5422. this.normalizeTranslate(
  5423. this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
  5424. this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
  5425. `惯性滑动中 (${this.buildVersion})`,
  5426. )
  5427. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  5428. }
  5429. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  5430. }
  5431. }