mapEngine.ts 204 KB

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