mapEngine.ts 205 KB

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