From 48159be9008812160e5b30b6bd50a7da16087c15 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Mon, 23 Mar 2026 19:35:17 +0800 Subject: [PATCH] Add configurable game flow, finish punching, and audio cues --- .../assets/sounds/control-complete.wav | Bin 0 -> 10186 bytes miniprogram/assets/sounds/finish-complete.wav | Bin 0 -> 17242 bytes miniprogram/assets/sounds/session-start.wav | Bin 0 -> 16360 bytes miniprogram/assets/sounds/start-complete.wav | Bin 0 -> 11510 bytes miniprogram/assets/sounds/warning.wav | Bin 0 -> 7982 bytes miniprogram/engine/map/mapEngine.ts | 387 ++++++++++++++++-- .../engine/renderer/courseLabelRenderer.ts | 20 +- miniprogram/engine/renderer/mapRenderer.ts | 11 + .../engine/renderer/webglVectorRenderer.ts | 228 +++++++++-- miniprogram/game/audio/soundDirector.ts | 100 +++++ .../game/content/courseToGameDefinition.ts | 76 ++++ miniprogram/game/core/gameDefinition.ts | 31 ++ miniprogram/game/core/gameEvent.ts | 5 + miniprogram/game/core/gameResult.ts | 14 + miniprogram/game/core/gameRuntime.ts | 89 ++++ miniprogram/game/core/gameSessionState.ts | 11 + .../game/presentation/presentationState.ts | 39 ++ .../game/rules/classicSequentialRule.ts | 330 +++++++++++++++ miniprogram/game/rules/rulePlugin.ts | 11 + miniprogram/pages/map/map.ts | 51 ++- miniprogram/pages/map/map.wxml | 23 +- miniprogram/pages/map/map.wxss | 180 ++++++++ miniprogram/utils/remoteMapConfig.ts | 82 ++++ 23 files changed, 1620 insertions(+), 68 deletions(-) create mode 100644 miniprogram/assets/sounds/control-complete.wav create mode 100644 miniprogram/assets/sounds/finish-complete.wav create mode 100644 miniprogram/assets/sounds/session-start.wav create mode 100644 miniprogram/assets/sounds/start-complete.wav create mode 100644 miniprogram/assets/sounds/warning.wav create mode 100644 miniprogram/game/audio/soundDirector.ts create mode 100644 miniprogram/game/content/courseToGameDefinition.ts create mode 100644 miniprogram/game/core/gameDefinition.ts create mode 100644 miniprogram/game/core/gameEvent.ts create mode 100644 miniprogram/game/core/gameResult.ts create mode 100644 miniprogram/game/core/gameRuntime.ts create mode 100644 miniprogram/game/core/gameSessionState.ts create mode 100644 miniprogram/game/presentation/presentationState.ts create mode 100644 miniprogram/game/rules/classicSequentialRule.ts create mode 100644 miniprogram/game/rules/rulePlugin.ts diff --git a/miniprogram/assets/sounds/control-complete.wav b/miniprogram/assets/sounds/control-complete.wav new file mode 100644 index 0000000000000000000000000000000000000000..e945812d46635bdfbee691c7db2fba02786501d4 GIT binary patch literal 10186 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%0DJ<(Q5!F;TslEOdTLwtbG_H$`8` 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 Q`886-|Hw+&W)=Yc4;RNc;s5{u literal 0 HcmV?d00001 diff --git a/miniprogram/assets/sounds/finish-complete.wav b/miniprogram/assets/sounds/finish-complete.wav new file mode 100644 index 0000000000000000000000000000000000000000..40a8ebf95ea7d71484e72e01210fb2c0bbb2d2bf GIT binary patch literal 17242 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 literal 0 HcmV?d00001 diff --git a/miniprogram/assets/sounds/start-complete.wav b/miniprogram/assets/sounds/start-complete.wav new file mode 100644 index 0000000000000000000000000000000000000000..579206ddd09e251e2e779affadd5ee33a1335c30 GIT binary patch literal 11510 zcmWmJcT`%*+XrxZ5kx^mP*FieL_yk3Hoa$)P4DHGx~ZFZ(|e_f z6cJEB0R=%okltS2bI<*6=FGYGGxyFs^Bp~S;6PU;2sEM3_yKbluPn+0fk5EYMIR3W z^}h!KK|q`2 z+3n;p`K7s2ayAnW;CJE{V!ERIuybH`;!Xq~ob9{f7CTHWCbP)+qF!kFV*aD0*x_}l zy|REl5>N1ykgh75TAo|_v1n|*BBy)SVVoQth2cTOC@4_pIp-MM zY&Ol)Yt($XP2Qm<>Ia*STH9M^dz}6~(XQYQ!~^UfgkrLRdb)UYIkxJ%5W*W&aj;Zb zNXeZ}ypC%{aiOc@Z-Nl-Am@Q*rHP`SuD(`%Of*c6to@|l(AcTP~OO ziW_w^47nDy{g8XG9~ylNT8-dgo%kC$GpM=6YUUx%VBz1quN4@^sG?K3^+bBceDr;2 zN4#t3Z|_$pre##)NqxPVE}t)wSMOE#*C$Lbn^!p5UR&@={D07Fv>dmW*grQ}__B0G z1)FzX*vKg>UtIi{8p`QO*pB^)$N-Ozp7S@l%UTy(9veb+J(b&}PbEtf%sO+!1@iy3 zWR1l7$+ef{R?+Bctopldi>X_)-|?`0L9irlfSg8+#-$U#Q??X#D-Ez7aw)*{s%zz~ z#oU5bu&UlBdb0X3k2x1#>5W@H*dzHiT_x(~){jEw$!% zv08RWHCP8Vy|b=y@Y~x0H)As(xhQq|p{&6aSmC>pH7o)5Bv4;PXU#9UU*IEm$=a0u z1(^mJ6+0QI_s|{ltoMyRZ5P!h*-Pp}ptucuw$nYwOcBiqo zHQM^jvoug1GlMT6C!`ayB;=h1y-T9y&#IOJlew2z){=_CWt8VxvGl&ET@YD}7@X98 z$ziotn3n0Dt75Wl;(+{N?SlG}CS%K)wlO|j_(x(3tXo1p(@u5%-h9jC(h1aS ztKW&%$oaKx`kRe2Tk_j#y@x};C19|3=rtLF?6%ySMKc-sm9;#dpbKXc^9wzVIx^=3 zUXP(6=7R1=d>y~IH`%|K(+nf(PAK$}DbgHeP2E1j01K$~m3yVXDry5=K}^Bs5NdMv zQ3n=-%3pC-3dixzR+vf|MGJEu5reoM=&jJ7@r=+|?^&m*xwLVi{*gLZ-CGo`ex_ck zXEj=yFFGfBvqIANPG}!=6!(m{G?!ImDZN-RiMLGnoD*aAE#5_y=425jVlN^rpo-`+ z|8sZD-p8`bAgv=PCrXWyGm0^FIKvO~R(lV3u;Wo=0jLyV#GJv8&B0NB(6=&saDoB_ zk6byGaka>vTa~>s;}seN8<^N1Qh3R2Q(LY!+Vz~;mGW1jjn%)Xy}Em*xy>}EzWqdS zWE>6sjM|9%h3KW+E1XwKtI%^@LSfa~^7qB?g5Su83ANaK&-Na}p^lUrO6u)|VaQOZi#siDegQmb`M((#&URQCOek zuCUZca80ybY_imsYnIkL6K|FEPzAM*j0>#{hskp`FfN9J{6KC^?~xTGKPp&M!eE)I z&H)_mO4h3qP~iZ|zO0&b5^4(MYRn$sw6Ao$vL=lKbo*2_vbkbfjlT9o{m7;?+n2UY zzAj;3;vQ^X8ZA?weIjopEv@WJ!lqKCmlknr^Pgmq+Lfvfmd`K&4`aF6R{buQgl z_?dzt4#yow=^#b%xxstw?T*gP8%>{eDE07ayXdNXS}nO=(YU|m*EX>4b!b(h3T8)N z&6t)=&QlZ}VEkGM=Km1jIAfS+=*Ik#oCWxY7(culXlvw0N4k5A{fybrP*S%*@ldi$ z+E*E?du~{6sc5yjFZm}&v%pfsZfsvdEay3Od2vO#m2+8G&RbgXtTbBKJ9j5hg2SUH zKrhD4!E*0X=dRgnPRw7*P>uc)1+S%o9nt5eCGT1 z`EI(uK5`N?5|M`eg5R9eh3cdK%bd@l3vcq;D)@{wMelN<*@H6mAc0WRL=W z0P`yzobx7s6`jj$WnUMR@E24*WcZ7^`~`CStS9v8TOXFdq94vK&=`vu0hUOfDprRX{#+ zJt}>$r zKG<={sxs#4W~i>qj*5rYAT%H9*Eaz+ciSD`>~KM{4t6wcXeNU6F>gH$DDza_;TH<# zuY+l&H!NRt*BSaKEvzrK1Z^QyQ|0aetuxh4^FLf?L|ZIk3&6O{a7X zs^V&*SSH_7+pj*}_@d>{wn|@f=%2)77%{CnV^4O!ym-+I#-Ejy{0o96Hj}x8{vV@Bd5y3M-6C&q3np{l#67!RWu%?${yX1rPf^wO*n=xj2()zmx8Mqn+!0!;fu+Ql*nD9d-S+-y3>2J#5Z#{N?`XBZkJu4uIam%qUQL7QP_6kdi}1&>SV- z=;sU8=guaLCyd68Pn(Tc4>_Os76}D}zIE;&jw0I@i^DXn0oACr{9~K$BzivhW(8xB z{Rsl(FdT+niml0DWvwH9pv3c)1*7wHISYvh{O$DdY3WD_^aN;rykDd&nB9SG$GWl| zOj~~|)LLm9;+W}L(!RW7VQ@;Mf1CyiKt92Dpn754=_fOVSwFJJkcX#gfEV~E?pN#u zbPO>IrUs8ps$w%D!Qe@MFQ3PA*R{;qqxBc(16O|g-#)#c9aHekN}u z4#YRbR!7%Gc81S|UIiNiSA!oy%5X!(5LL&%#BV2dB*%k_z$Wl1NPnmux*bM>UxH^L z2Ew<&en4}eOCfS_H}C^cCFn`AZ&IJwm|!Lv;%DPCqek-1pSecL}@{_+OFD4bq zSTY|Z0QCj^3hD>y1SiEyIybb5LZ=YxH+uGU5cW3&p1RurX;C{$n*aw-DiP)UWF(t389f1ZB0cG2v*bC>g=^kv=NP_N_B*Heza_??_Tbwog|w%PIV@1+ zqKbP=Lvar(CA$vO2CfU{xXP@X8~)a;SKg|jE1Fdr?F$oNw|Ym!+mKxekMcCdeOVVc z_qe0D``H_r(`oz2OK>}(GeW!Cc9|P>2-WCnsIc*uD@yJdX7i?B5x4{)X9t+ z>=*psfX=`?K7>89)SL&!4}p&i-)VC;{iE%vph@?ME{Y~dPbx^dwdR2?f7k>62fsXD zQ#yrxnLiy^1K9X-_MZ$EbtyrFsE&}_0?QK}R(VDGQnX4`FOk%swLnw7b9HDrOpsZY z_o-x0C52xJoCT%}PH@(jEiBMv4ML5G?eP3j?62XBw8TB)yJvqv%j2PTMYFU1tLlpk5DyeVCFA69%?Bf~b+dmd zczF6Y@)r7cmWz8`_#R*jq1;vFx}x)>9+*+d#lCVI(eST&cJ(%KFVPM0t?JC$gNEle zM29=6!LV}1({$y7xub<2f$hS3+<7b%J(%+hs{mO$cG&wGFW08a>&1OVW5uxQV)g8X zsV#@SOA^!4B-tfJ7UsFC4#7*HM)(_VWCf>q7=?u!1DPJMwyrjv)qIfml8h8##S3J; zRDJ5v&H3%lm>Km4(OF#R`R0;qGdO!oJ&IUVHKH0d9O>hSCaS@z&T*1;4EiL*`EcbtdXdRv4fu7R)yZK zFdes=$}}v+^Z?UDj|})p}eu^8fhSAa&ncAXUj7@ zRnM#5krKQnzE+K?-EH{Smhd$vt1p3HfN)q`9e2ke85 zH)`>6LrQRTs@5q`Pi+|2vdcRsF)r;(HnqsWJXPftya1HK;k{0lndH1@C7Ua=_~pQPV7A~QXII(U0$bK3 z)XdmP&mpT;?@|IXzi5JJjs#j0)YwgP9rl0`@(A~Xa=Q3R#TK4J_ymv(CROcbJ}n%Y ztwuM+(cZ@9NWEMomUT%9Cd9+!k=jQ_SnCr1G;ojfmE;xlx2$IFm6RY)h~aK2w-wzZ zjlj%Grs^&m&G1sauzHucr|63Kay7hmi{XaN?`ueY!{p`+qE(mo;SNs;?iN1b{>jRw zBgx;eW>BExgngv(UTwDAloA{%j>{rW=|c@_v$0BD8px3{VN{BUVhiKBhoTA{q*GWjf9-!8ITUXy$dT z{9nnxx&LNZp>4s*&X0|^>vq&Uk}MEOL@McIC0T#ha?U+DIu7Y2T%sx&6b@0a0~ii0 z<7cv0mPYcZ_&M-};ZJQ4^Aqh*MY;5#=$vSTbhiSk``yfT>BF_~-T43Ie<>Z#KEU417%wZJXyb^7Q`-kPFan5pYj)3ASF0Ul9ab=8ckCjra&#^Htr+kNb!Y= zO+0%_P$8IFb&&bGaBQ{#?TF*O_GVCnT2(FUDjpz;i3iI&YVR0h_BsA>U}5?q@?!db zEHn3VN>Cuo;O;7S7d<6Sz${Ac^7Xbc4e!-UtM{Y?FN@DtgKF0qF523Bs^nV?A*T=R zXL%3q(3Id_;eXsUtbBR~xf<&PB|9$ICm0{qQshl3!QtYtEK5DIVPMND@0djYw5Qn_ zMGEG@sVZN{N4GZ@j>0*nx~S5BC)7OI!=k#U$Y!_kB$yS zwh~TLr3@kmpAsAatl(#}*Ofx@OYuwKE5hQoH1jL%NJXXekm#&vsC1hmuKl06)TIbZ z;hXVe^52yXW*_JO4_FWQ_-6KA#vtlpLL0&x>Fge5k?4w)52Wu>f;Ey?HJ-Yhrq|A~ zq2VxmCOYp{$zPRNei?89_(O1$bF6H4K`3hhYDMg(=aRKqA69mgg+${;GbB;DL!&c| zb7%rm$YtD1%HHDB73+95;Zr~*_`T{l^HbrJ>}Iqtp6hLIMmFeF3R(A*U_?AX?ykLR z?66Pu4+U4I&nC~MKV})Z7lj`HK$yinP#!3HLz;p4D|yH_$W~?eqFzzGFC}j$Cy*X`4c|J73E7I8n(FtJ~T zQx9(F*|Nm@TcUf~y=+vGl)1OcA@~od6HesKsTf?mm@*W%3bHi-cN{l8*3`%cNk)qh z;;FI<6{Eh@8u9#$eLxK+Ru{<16r8OoK?>mH^{<>?@;3KfMi80^E^t;iKC3%e^E@Re z63L_^l^Feb%U<{3Xg_2V;V|_(Ba@RM*ba;YR`GM$f0v^3+4$A)4Pj*)-uyv3PEjR2 zEIK0^EZw9CYR8zVuAkwr@YVRC`7cWQvybp60~>$63SFu4 zvGl#@PmxUWyr#7-v+0R*NN4~Ioe9pnQnIWP%`Z&}E)?A5oGCk003xnHt&ctM+^{h>cAJsY1|{q_Tpm|t9i{SL5*N;)mi4x!r9qwbSzHyhMRE> zMpdn>XG$<6?jyI?o;Nz}WBvWW#pzSX)9LqFdhYp@U}s?t_h@;%=ridL%%nas;$@<(;^LQi2DC@3>o8EP4UigiU}l{rBy2 zjBjgc^5&G_5V2Q=R`+e_)H280Cm~3?mJKfY&fHmLPYLRTQ+Nw1Mi#H6jKgh)>R9*WB+JAQ&?A zr&H7Ty6#xbOUWY9PmxGEL85j;$uniamtmPN7cb4MwJK_I^ z?+EMLDCTe4$qK&oi0HIvpmd$WqaA7{x;}>Az?b6t=RYp(%|6JV1Z)Jtd?))9V?6aA zLJ|Rs4sy@5Xmu>*GwBD>a*HRKTPHf3Y+fr^zpOG;2HSWtDD zDKA`<9YVts6<%-)vB9d+%6g>){o?L&bL|PE#XiK}6`YqomOPPulU2t(n-c6Q%;TOe zhtb5O<(Qqxe|!^dJq*?Ab=8Mbf@j5ttKI53hFvzL?^W^|CPJ#Dy(s5%2c`rM3qNsp zv$%8y*^WhmDgI~nMaECHrE+^paIn}VgQ~kXa9XB%yChg?=dvS(ADLULT2g{~;q=r= z-}vGUl&QGgkYfRozL0(tEff8e+^;dzft#*4yM_d?P=+`6M9JJr z2)`J(1S}UkkrEsvcE}Pcpn=gc*2_)M(~f2P z3STofRapejfd=6$-pY!p#oH-!aYrEM0|k!Trq3FKd_+nRA|5TvQ)SgFt&N@su^T8Z z@m0Z_vag(Vf`0%i5a5le+)$#*t;@i}$f0dcYvcF2^EK~Mg5N}6qfK&*~T<0w6heyNRNq5ih4_zDJ~95+KIiJKLOYbB=~;zRmL3ZV?rj96rJQ=X0hr7%Gc7*DZy`&TQzlck;b!5 zUWf(rWVGfUESXuE2UmCu2W7Z>^yEbk$&4Tr^fRUeYR8Y2KLt z$Fsm4$R^xb%Ch2Z6^nU|DM5qa&#JpjL*a&ObXrcLyEn6i-r!c5Wc^Zt?P8(4u6CDE zW$)@Q2cy&blZVjHv*g@UDZ!q?V(!&)0!>faiaDKp?3-^JV$i9#Rv$|Vo)qt?wx}l< zR@*-LZY7UmT1f@8`{gWd-&DV2!k^q@te*5vm}AY&yCmx6qES4;Ca~x&KkjGpb!Z2CRT1Osn0cKov-L7JuW&S z>LFdIFlYgDwCzUtANY7YC;w6@kG+FG9{3vo3nJ{>jK$Pf1PZbsI@7({;?(`3d?)=P zS}OW1xmKg7^EDoIGDCD&ONKdjcgd8>7_SJp46G8o2rM#g- zWs&tyy-~NAD_7M1Z4}#i{vt3qy$88J{S-^WJ&}^=EiB{SE~n7Uq&pI2AG5kc$+I06d$9k#$AEj4{#j+ znW{C-^0AV!qNHeOD%VBSf3eCv7h)$+MZ~)W56j+g{z?fJ0dd~c$~`5PTzf__j1@ZO z>}XWhU90&bSt|M>dME9ybm&%DX1J-*9OO&FLh3C>m<>(|jt6$|`RvQ3RrwR}SK+t9 z@wU!pqjr&^r}TvAxTvdio}y06G5gvshR?u<;Tid-ODox1`D1}C08EfzKVYn+ej?D3 zWzj#}n=M{l59LScSJ4vDN6DocNu9HCud^sbff+OOxm!vmREBwlz(2qm!AH)2Wlsvq ziI-3}V>*x2nqHry8Yu&b$B0Hr8s$GU4@{+wtAW#y#kjqcS;cEB=JE_F!6w1Fs{fd6 zg$J^8)0l~2-lCSOhKR~08<_g5wTUa`(%QAg&-QYE4mgAb$UW#sS*dR*CDB*N;{ID+ zM01diW9}qB`_|Yd8m#I))hAPeN5xyJwd&st^K1`&r;=MRI#MR>N;#d|Bh~Meu!ehq zHIzP(j7evKyZP1j-9~LKAa73z_7$6CE)}gIqotcSJAq1DpWRS+hxun!LrSnoxR|%S zVtMg7%4XbM$n!ua$9t1n(<+~k5{!!m$*`)V{*Cp!=Xh*CicGv#aHs4AXNBMrKnFnl z-zyK5IC9%FDq(`qd1tgyTX(zWn`D{jv*@){ptR_gS|+%O(G286!ffglMh82=-waFu zcJn*4Z6lF^lZ z9yKMnUhtLkw(NBQpLi4XFxKQzS&8-esT>xP5*#6EkblzLGUYqY1r9;x;I>hw6tArK zo!5{Ov%#?qx{nPl`UgXDtJym|TNVkkV)u z$_u#NQi5lMD(+R*X!>w+R=NP(-``+AWHi=xk$0p7`-tmhc2#ZzvW4%(CX(nC*|mk& zn2W3QDM7PvId4zJn&PXJ-MFWa_ko^{ZzjFQEuSPACyI#%$dIa#{;Bo7XMb!PDwBAj z;9A)e&a#wXF#zSyt2|la%ME7$ux_DiPH2;{?tabBl;B6v3n@ow(9N@qaATr~)Vb(X z>UoBT9p!HVCIb8T-P!j_d*;u_KZU;xr@IE29okije$rE-qavYnszR(SG&i;F5ATBa zz~|)eFD+(o;Ex8j0%!q}^O~`Zsw4=IU83vU2Q5k6ugV|NA1T4tl9M$b>KYo?I&mQ+ zEEVE-1ESAdNI5$AK+r-H7;hp3k^ho|0}TTfGsmqEp&MZ+Xo`CH9DCZgkL zU>9TxZarmO@sf(^JiYKgphd8)>Ju|scp^XioxCL8EuA=W^edHcW2@VwUxi8Dvv?%F1=5~YB$r`CBq;4vc`mn0O7NVpj(dwWi9U{;pWYoj%x|@yG+JtV$OEapZ!fV{W>IA| zB%8~7tO6|UqRtk_h1mvR{Q7V=N7YJBo3DZz-SuMDE{ z>hD{hd$z^ap^(HA1sBU6aFz%z0wn-~zo_zjNi-Ld*#p);bkCXAWUYH#Bati@eGomB zmMiOY(=ETcq0u?frI@|WS?!as#cu3_eO?K;H}=>yRl z(IV+ZMY?XH8SOe1UIh1NUd(GO8C1EN#}N({*6@s#&r6@?48I#l{X}+LUahLok2kkCX9fE~l2}}hyHHrRrSf>yAnr-dN!EsvQ~ATQNa&Rbmk-_A zuMwkrqe{&cc|S#3twq1hywkZhP!FoaG!qX}UzDI(z1aZASUIBndGVC|OIht`Y4WL` z;XH5NTF=uslywTIYKrE+da1d;^I!k5>=5HYsA!_t)pokYI^GK7+zYa&Q%?gWbN!kcPxr|Vf^&Z#D00Ri_FERN*9&RE&iu) zSni`NIu-CweQD8%(#&+Llgj8+?-@e}Pw{;29F)c8WK@ ztzb%FkAlm2H*$6owqPxAM6z3`zP*d{R14q2Yx=kGUK7>YX#3i>*Vi201)?Aar~e>~ z%DI>OD1RVzem*VtD9MhWh5Z#_P3{Zt_X%8|?JJuXTfUojTdp^QTbH_?`;LZRBsK8s z7)@qn_7d`{TvpzW+{5H~*?fFCc0b}e=zXNvPw_N5{sav*$up;Asm{$l1SRR9iZur{V@HV2Os&z)|HHWP&y}h+# zSU4Vk2N{dpj>*e#;#FC1i5H1$v!>%0<3wrK5d2h9K0G|cuWc`K_iLNroa3Bql>F=Iq}e%dm`TF9crhKM%s*vDyK=-%x5)79T? z_YC$82s{rjjrRhFV1JuR4cW*~tkQ5mgzX$4of{^#n8qD1EUvbND=JXp_Y1%T>1Nc42(d72n-7qUyKGgoQqW!%=PM(U@ZF1gsm=p0*IZ0$B_{0|_Rl#$AzbA#m`i z-_`NF1LybnhX=F6oalylQ?d_aJ!~a{h#HKBrgcyAqASrI$SH_*uz8Rkpxgv0HZ-CL zy$E80%0OoDO|UxjOT-d=8^4o03Z4!P!-gS-AZ^G3R2y<4@(%LH#EIk~&^quCND#6b`WlT^~wo4OY-J{-n?0Ym@DV7R*$hoKufbm(w*m!@s%?y076 zcX_Vo$ich5D1DLZ{C<8vj~F&AT8to522LKjVCkAL7J?uc_=HbJkRf*w1VvcLyg6&; zpzt#YG8cJ^&@odmXEEg%ESi9hK^LIw(CzS9k1j;Vp-CtKt-@TuOvi9AACZL!9=YI` z`XBm|eE)jc-gTY|cdUD@>y^{ykUA#YH`)HS-nKk5zcf8D-Y^{3uhEUwN;JP|5t}N%W<)U zy+jRZI;D&{o?gRP!1A*H;SS+h_;&?ML>a;O&?fPF$<2r}QO9HWF?CTVBZoy8BqzfX z#UDaO1Q&>g2%iOp^0x=na^l#ln2+fys)#a(G?%amcK|(x9P{n-taHt9^s#X)t;QSr zd0L_RhjK|5rQ?z!q2*!Yvijs&L)F8Im1U_VrlKbWtMjF@#f7)!%97afg_Sp}yK18v z<~RM@(xHgxnA>$#sZdF@v-OvZE#^?$497pNMo+MR8s;pv4lf}6N;yrdX7JgQxPS2~ z1G%D!!6!n?!`KnyqmIRt#4!@aCLKwUr_nM;Wd~;0W}Hcznj%W7k3SzbJtjD+Dg2+X znW3SY4R);s1uj0t*;=ALS)GO0`7 z{y?#!C8fz&|G0Klb$X?x>}knbd1j$4|7L!7L9~29$@Q|1ipc8!)n2P_Ym~IiQCx0s z?G!6#sxE4pbRovy%;&8Qc9CnU=d7;=3B*pu|3$1K2he_DoMe@AIJ^mg$AzUq%+PUR ze})%F(ql%)9gZ(dq@;{YJCGsEI+r~?Gbp_=k0iwV7L&Ci$N@GNe;R}wl}<}+fb8JYG%zpb&Ue(oq#(=C?PSaW9dhjayBhsH2;vGKtu@{AwD3%7J4=$GNWGdoH!&io^Capo*LHbGBviI=iTc&bJ`uPubMYD_O5r=ys6q!(YMT7{Jv;g!GQc( zvWtaH@{rQs$}dzlREz4SHJoj#Z4tCh={Vh0t>mdEX;0}ZjaXK{7OAPt?~KtaNa~uB6W?nDpGt9og+!*D~g% zMWu8m-iTii7Zan5ycNDEOd6^R`cL>rU;NM$q;% zez1t#VZ1$o--Ni}A)&j&K8K@G17mi^eT?@f4oKOS_CCX#wIDk>vpel(%I`^W2|aOl zVirfmM`*(ChAs_G66pdT@RoB^*ha=9+A2yK$&7!BU4zN;+dMB^8yq<{r}>p}lfIAE zt$M56($%lSr+DA8y(zZ=sryvDvvN>5w)BfUuW*QLYW|sm+M>XcDP@0GR8{e6f2lv! zSkcT;{M>%Lv#gt?8mBp`D>l&0W2}emMNX<`r0)QdkCO1iiF?W4sd&avRvza|05))t za97Z$5F{)&e0${kXkTo<_^pX=lijI(GB#x?vu|fEPM4;tlm1IsB2A3dM%|BCCP@|> zf**=j3R3wd?i2QEW(M6#c}7}C$imq%ulyUmz1=Ry8`~C3Uz1n=PPSRhZPfxe#okS@Czmtohq&@d@jHnp zqHpx(*w<25La*eFsV~#*nb|oBS(@~Fsmqd+67|vtu`8leBaM>B;#DE(K^DPN{@Q>{ zj*a<(zMh&xb`oCUHle){kN2H>tFyn|XZc{-VaU~CG@p8Qbr0^uwtsESYaZH2sQ+HG zuj;1?QkksyK+(toYW_)Cc_F8GLg}&c(n@B{xVk?ZV3F*C#wr zvZZ9Eug!d#{UB?3MoO9?`BCDk__R24^pnUn;hAC9kmo_`gxP_PfR~(&tX>Qk^$mG5 zu`k|>zC*VA26zzHC;Lw8ATw(GqRZ0^QQ?)}I`_5@R}fqB8~4|bsHIdFR2(WBT|$$~ z3y$QoWaA2t%8N@F_F*9Y1yLSN>2Dw8Qj!jNi;S+hE6T*JlsL zpNrX!{ebro`%$*i-ZDI_KHN>b*MUx9uiy=#FT(8MSyAg^p2b<>Gm=)PJV`TUq-LjP zn=+oHtxm~Evcx}&TNjfRWeESN_yR?vN=Cwv1|`HWbw!t{zj#C@(HKDj!$Kk{!vH7trLR zOAeJ4R8Xo%)b6j(ZzQ%1SL|*7)`?dRQRQjA=uqPz^G@q0JK`GP+3tIXc+tN2&BQlk z7qu5-BkLu{5s)3YPWU{?8j=~dCj3dHIXW$FRs5qwLvl*m@{9*rPqWu%W~SRxo+qtO z$dNi?UPWz+=q+)Fz6stU>L>8>-gCFH2QU%(N6JppKmv;Ug30p__2S*%9s6vuzVQciJs;Yk@ae`LXdB3Uu1?5B!-Ro`m} z^+Ox;n!mPU+Xr{<>i*P&(d6oO7(SSMmj3pw&UbE)w>Pp0eT8!pa>(ncFX%RACTDHH zQ@%xz9<(atvDhd{ja(7^AXYC;N?ewFFIAJCkd>Wd&wQD_F|}8+E8(?tb8O#ePsBUP zHgW$DzvzQt2Y+AyhVz-Zi$0i&BY!3A!3{+TNWOQ!dxVp0FR&akjWW=5a`lm(vE7W$ zlD1>5yEaM z9saHXJ=@gpv|fE*(-zAco6FJLz0v#1Z^vZe))AhOtdtD;YUUHRiJQt_DR?L{1SgA^ zN$y8zqY`76NdHSvCrMKmr{B(0W^c;sli^N%o4hr#U%W5&ef0Lo+;Al9Q^>BMK|*Zc zmw-IZP!^u?ow}DioJhjwqX&?YKB}k4dDuS2N;el9j_Sr~SgNw_WTsf)wVr_eUWYgT13ySh~d{>5YmTI5oKV6Zb z$Anr1cBwPRJ=i-eiJ75ogh9FIZ2A77M70;4LB3dKQ zM^B6;NT0|5FHw=8kaonXqivD62)-miJTzo(&~D+)z+#?@8^@W>I>9KUk*OoeM~OA~ z2;6G)I}+;O;H`1zxGp+~_SM!7^AuBwVWeKB9i=H#P47{5Z|q`p-e@1#rfAJ;iEplI z+}&`oVMtR~b6%^s?Ropi&ibweN`227wNcD#kUXyq%q{}lzY?~nvX7F4rKktUduVaJr{6`_n7}O@U`HT@Tur-P;t<1 zQD31W@CAPpZ(sn8ThG4B+R2>8$fgI<+>|!5ob;CXfN&ju5qAN50lk8`jXd{%^VNFw z9UT@Zo)7{sywe^}p?MvMi{XWBD z<4>jtv)f#3xn%vrmSK0=-#9ip(_JdpKkhLer{{`yn6KNH=MO=iAj2?~nAvDI`Uh5n zU4(1HO~K3X*@UYEGI0s9h!{)SNvbBrkk^pklkt?{ltq*ZaxVE1DVlVVC?cLE1QV{| z6Y+0wV{mQQ&DcQf3v@b)qW7St7?>}}Rs=4H|D%7uf1*FaZ}k=VZus(i^L-~v0WMY|DCtLK~dq3??S zZ{#@U1bPPhFYY*0?UJQGF+oeAcI9vAZ@ zm&21IzebIS8XY+@VsLntBqEF|)`b)XUkh3*8Ym@kenKg;*tBXIxWd~L_thFS7VZw-1~h<2!YVNYK7h0go!uN9wKzBGMk zcwTq2=0w$oim7E8B@{WMP%0DTyJanfU*tF8S}cbhl2s$B^Fcd(Z@$y|XWPn-NnO2^ zAu5cf6K?ojSchw%JNi1q+*oh7uK>A^o`7$uX{3G>2@TKafzAF1YH&l~Z^GQ5$PkiP z6IKeh<20<2S+I9wk|-&<)bjM_ndh=2vvV_N!1wN7aM9)QJ#l!bkp7X=!qqv&mGryN1M%O34--I5xVM-`ex6U?$%CE zyPz!tzJ-^-eZOA&sk*7sS*b^%aMs_7EVO)5!+x#-ho0{3GLg97ZQjO zc8c#*)b;7pGy7-n$i4tqrZUx#OoL@S6q;*m^x4Q~ z;pJhvP)bNNd_&KK+kYBXWeHPFCqfAiApJ(zfIEdgMC86658fRPi)6+598g94xush1y8R+t(FL&2lC*5S6`Jy$<%(^OW07mLC&kA_?3f1FHrHS^FNPjVWAfQ9 zPE)`q{&m3t(bC`vp_yTVa8E=lw9GB2TS!bje zqIH8bGgX_Fr@NkXl(eZ^i7gRO1;0UWo&w1zDpQu=izP+<3Z~}w!0veji+TgJT5e@z zHK|rpSJLpL>2%9x#VptiG2N6N9dyofDCljbIhLWeI0xNjbXUQ>Jr7Inf6xf=WG2-N zuJM|6k+Umc0e_?*NyHAehSb5nz5?a;JD5O9d_bZjsWIha+O>>*uplPFHa!H@^EIh8 z!6OyKWyFk+S`u+Ua$Wo>q$$WL_XT?mtk$!Ijwd! z2*C)`e8Vo?Ma^qfwbBGPOA1T%f6zeZp{^@x3{|ws*zzHzvx~PBohf*hef0h#VB5Q4dq04xTMu5)-wu7C!jc+8M9b}IneuM3cTl0^k1a*g+Cg9?o0Cr`K8Y`hQ^ydaBVk$n2Alm9)L@aIhmQxr>c^hSTuVPry-zM6 zcHvQ6D3sI`X!K*CKl!!}3t|p2_R>$%u7s_4yZc*bTf0vo0y+5^OzvRq&FU|eE#>ag zz>;)W@JpZ#UxPaAf~_^OEU|)JWv#BOeOG@4*6;7oRVf_-T@J9I59({$efmF)?;htqbW0uji zR5B$B*8NQA#=k)%N<3;8(HUvawf<(_U_7ONsFkaGdhkjK)cjO%q7%*c8w={YYSEg| zs@@e-%2q?K{8#ux*8XFbY*1meoKm7IEiZpwd9HeE?Ht(pan1BrBlyHikgpv*^VC0S z6ZA}@*;E5N`4SY^LTLL$gpFFkBi|9Pkn^a&(?>H?*a2KeKm&B~H4v9S!1B_=`H`-u z=9tfMH{uV%@}H2JnJ&!qW_`+T%5Pe|2~*p%Hp!;B2tltz1hoRL?@_>B}HBwOy7D zR(pbCM9ci9oelrgy{f6IGF33jrJx*h!OzZt%pNLOUNo^dyELfWU(p5~cL#)E71&d6 zMMyiQv$IR4yr(*@S*!ci(AN}Z!P%7HHV;4q*Ml|p$A;qx#2%8I@`&~ql<5pGk4QdA zpaG|S8gfRwMKUX5a8yhTHBK+B053QP5;Yf$Q<`%s>rsY0ttSPaEP*1L3axzt6sJJa zB}PG>`-r9pR`ZVr{KxshYG)wyU=WaBz}60f+J5)9d3|n?E6ee-Z8&Ac;kIB^HN?gGP_9 zV%C_!72kkZ?`mDpHnJnBi=(vl)I;B10XbO&CYNgGIUVqh@X>b-*@ymt8%IbZ@hL89 zGkDewkkX}K@R>qkkT+Nn`ZerU_+cpaUt)8l!3qAvcJS-}(vN1Y&K{NhduCqxWvJcS zL`ysigl+`b?@oACcokY5Y!Weq(!gQ7xnL0ILANTXdNLJ6elTt}x&=Awd*&%~X&q#H zG|13QFvq_^vrBr^-Ndeljsb1cTh~MTKCCaQRaWDw!a#zj!W+Z!!h15=kBzb!g}L&` z5>lB4tm`Q#>Sply!ObzP)HZ#4dFS))b5Qeh!HJ|sy4eV}{R&=9c6#Rfh9mJP6KBTP zfU#Txy;}e-oW$kuZ2UUGJJFTkJ)w)j#)PLv21Gkz8o^Zm1+8C_IxamuGcfyR_Lt0- zba$!%)Mz|-7+$TvJFch{_L< zVG29JU+#hItpPLcTN_r7Yg9HBwmeXrY+v6wy?a1UgqomLfww*e$=?KTL<3z>9}=$lXx=;a9m5;?i7Ul>bhdns2*p9pO@ zJT@LP$-l#U&)wu?I)>ObTb`N>hGb}iCu&E}K;@yX+K$-vO^V`{sOD{r_4PS*XKLJ4 zb1G%!>1CHon8izr2bBiEl6q9NrDjMSrJ=a-C{zKZ?Q8qy&Xn#p|KQXdcx7i}@{s0SaM&MULoakVX zDtLJ4Rk15?-Rk%OUeDQ5{=D^|nFt?0h0Ebl?o z37TKDn{}7;9}TTWk15cSWF2CgVqfT3>)h!&=sxcG%X`}QxBmok1hWU-gk6T4i62Ag zMGPmA$vSc=?p_Cvn8wZti~p%XmqAHUCE7ECElrUa&QA5C0hN zpMZPZ51dN2nuTM9G5a%S(Rb5sQA;Uqax8fY=>YK^!2-zL{!M5X8o%u9IV9q+&7 zGkAx1&wDKHaqfFAx@(2A%F)+x!_KwuvYD+5txc9mmQwRrbFpc>sml19vCFW^fH9oV zPuDXH9}G*40@Ekc?`E3izGaM6WzDk%+aKG9I;tG=oO@68$?!k>7oCfSqn(&Lm{pj* z7!sxixs7Z=CL#$45$W`Q^WX9x@vrgE^pEuSf*0x#Ki5z56a6^&5d2g>#~ = [ 'gpsTracking', 'gpsTrackingText', 'gpsCoordText', + 'gameSessionStatus', + 'panelProgressText', + 'punchButtonText', + 'punchButtonEnabled', + 'punchHintText', + 'punchFeedbackVisible', + 'punchFeedbackText', + 'punchFeedbackTone', + 'contentCardVisible', + 'contentCardTitle', + 'contentCardBody', 'osmReferenceEnabled', 'osmReferenceText', ] @@ -216,7 +243,7 @@ function formatHeadingText(headingDeg: number | null): string { return '--' } - return `${Math.round(normalizeRotationDeg(headingDeg))}°` + return `${Math.round(normalizeRotationDeg(headingDeg))}掳` } function formatOrientationModeText(mode: OrientationMode): string { @@ -244,7 +271,7 @@ function formatRotationToggleText(mode: OrientationMode): string { return '切到朝向朝上' } - return '切到手动旋转' + return '鍒囧埌鎵嬪姩鏃嬭浆' } function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string { @@ -324,7 +351,7 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string { } function formatNorthReferenceButtonText(mode: NorthReferenceMode): string { - return mode === 'magnetic' ? '北参考:磁北' : '北参考:真北' + return mode === 'magnetic' ? '鍖楀弬鑰冿細纾佸寳' : '鍖楀弬鑰冿細鐪熷寳' } function formatNorthReferenceStatusText(mode: NorthReferenceMode): string { @@ -371,7 +398,7 @@ function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | return base } - return `${base} / ±${Math.round(accuracyMeters)}m` + return `${base} / 卤${Math.round(accuracyMeters)}m` } function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number { @@ -381,11 +408,22 @@ function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number { return Math.sqrt(dx * dx + dy * dy) } +function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number { + const fromLatRad = from.lat * Math.PI / 180 + const toLatRad = to.lat * Math.PI / 180 + const deltaLonRad = (to.lon - from.lon) * Math.PI / 180 + const y = Math.sin(deltaLonRad) * Math.cos(toLatRad) + const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad) + const bearingDeg = Math.atan2(y, x) * 180 / Math.PI + return normalizeRotationDeg(bearingDeg) +} + export class MapEngine { buildVersion: string renderer: WebGLMapRenderer compassController: CompassHeadingController locationController: LocationController + soundDirector: SoundDirector onData: (patch: Partial) => void state: MapEngineViewState previewScale: number @@ -430,6 +468,14 @@ export class MapEngine { currentGpsAccuracyMeters: number | null courseData: OrienteeringCourseData | null cpRadiusMeters: number + gameRuntime: GameRuntime + gamePresentation: GamePresentationState + gameMode: 'classic-sequential' + punchPolicy: 'enter' | 'enter-confirm' + punchRadiusMeters: number + autoFinishOnLastControl: boolean + punchFeedbackTimer: number + contentCardTimer: number hasGpsCenteredOnce: boolean constructor(buildVersion: string, callbacks: MapEngineCallbacks) { @@ -471,6 +517,7 @@ export class MapEngine { }, true) }, }) + this.soundDirector = new SoundDirector() this.minZoom = MIN_ZOOM this.maxZoom = MAX_ZOOM this.defaultZoom = DEFAULT_ZOOM @@ -482,6 +529,14 @@ export class MapEngine { this.currentGpsAccuracyMeters = null this.courseData = null this.cpRadiusMeters = 5 + this.gameRuntime = new GameRuntime() + this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE + this.gameMode = 'classic-sequential' + this.punchPolicy = 'enter-confirm' + this.punchRadiusMeters = 5 + this.autoFinishOnLastControl = true + this.punchFeedbackTimer = 0 + this.contentCardTimer = 0 this.hasGpsCenteredOnce = false this.state = { buildVersion: this.buildVersion, @@ -489,7 +544,7 @@ export class MapEngine { projectionMode: PROJECTION_MODE, mapReady: false, mapReadyText: 'BOOTING', - mapName: 'LCX 测试地图', + mapName: 'LCX 娴嬭瘯鍦板浘', configStatusText: '远程配置待加载', zoom: DEFAULT_ZOOM, rotationDeg: 0, @@ -502,7 +557,7 @@ export class MapEngine { sensorHeadingText: '--', compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE), northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE), - autoRotateSourceText: formatAutoRotateSourceText('fusion', false), + autoRotateSourceText: formatAutoRotateSourceText('sensor', false), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)), northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE), compassNeedleDeg: 0, @@ -526,10 +581,21 @@ export class MapEngine { stageHeight: 0, stageLeft: 0, stageTop: 0, - statusText: `单 WebGL 管线已准备接入方向传感器 (${this.buildVersion})`, + statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`, gpsTracking: false, gpsTrackingText: '持续定位待启动', gpsCoordText: '--', + panelProgressText: '0/0', + punchButtonText: '鎵撶偣', + gameSessionStatus: 'idle', + punchButtonEnabled: false, + punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿', + punchFeedbackVisible: false, + punchFeedbackText: '', + punchFeedbackTone: 'neutral', + contentCardVisible: false, + contentCardTitle: '', + contentCardBody: '', osmReferenceEnabled: false, osmReferenceText: 'OSM参考:关', } @@ -561,7 +627,7 @@ export class MapEngine { this.autoRotateHeadingDeg = null this.courseHeadingDeg = null this.targetAutoRotationDeg = null - this.autoRotateSourceMode = 'fusion' + this.autoRotateSourceMode = 'sensor' this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE) this.autoRotateCalibrationPending = false } @@ -575,13 +641,222 @@ export class MapEngine { this.clearPreviewResetTimer() this.clearViewSyncTimer() this.clearAutoRotateTimer() + this.clearPunchFeedbackTimer() + this.clearContentCardTimer() this.compassController.destroy() this.locationController.destroy() + this.soundDirector.destroy() this.renderer.destroy() this.mounted = false } + clearGameRuntime(): void { + this.gameRuntime.clear() + this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE + this.setCourseHeading(null) + } + + loadGameDefinitionFromCourse(): GameEffect[] { + if (!this.courseData) { + this.clearGameRuntime() + return [] + } + + const definition = buildGameDefinitionFromCourse( + this.courseData, + this.cpRadiusMeters, + this.gameMode, + this.autoFinishOnLastControl, + this.punchPolicy, + this.punchRadiusMeters, + ) + const result = this.gameRuntime.loadDefinition(definition) + this.gamePresentation = result.presentation + this.refreshCourseHeadingFromPresentation() + return result.effects + } + + refreshCourseHeadingFromPresentation(): void { + if (!this.courseData || !this.gamePresentation.activeLegIndices.length) { + this.setCourseHeading(null) + return + } + + const activeLegIndex = this.gamePresentation.activeLegIndices[0] + const activeLeg = this.courseData.layers.legs[activeLegIndex] + if (!activeLeg) { + this.setCourseHeading(null) + return + } + + this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint)) + } + + resolveGameStatusText(effects: GameEffect[]): string | null { + const lastEffect = effects.length ? effects[effects.length - 1] : null + if (!lastEffect) { + return null + } + + if (lastEffect.type === 'control_completed') { + const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId + return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})` + } + + if (lastEffect.type === 'session_finished') { + return `璺嚎宸插畬鎴?(${this.buildVersion})` + } + + if (lastEffect.type === 'session_started') { + return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})` + } + + return null + } + getGameViewPatch(statusText?: string | null): Partial { + const patch: Partial = { + gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle', + panelProgressText: this.gamePresentation.progressText, + punchButtonText: this.gamePresentation.punchButtonText, + punchButtonEnabled: this.gamePresentation.punchButtonEnabled, + punchHintText: this.gamePresentation.punchHintText, + } + + if (statusText) { + patch.statusText = statusText + } + + return patch + } + + clearPunchFeedbackTimer(): void { + if (this.punchFeedbackTimer) { + clearTimeout(this.punchFeedbackTimer) + this.punchFeedbackTimer = 0 + } + } + + clearContentCardTimer(): void { + if (this.contentCardTimer) { + clearTimeout(this.contentCardTimer) + this.contentCardTimer = 0 + } + } + + showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning'): void { + this.clearPunchFeedbackTimer() + this.setState({ + punchFeedbackVisible: true, + punchFeedbackText: text, + punchFeedbackTone: tone, + }, true) + this.punchFeedbackTimer = setTimeout(() => { + this.punchFeedbackTimer = 0 + this.setState({ + punchFeedbackVisible: false, + }, true) + }, 1400) as unknown as number + } + + showContentCard(title: string, body: string): void { + this.clearContentCardTimer() + this.setState({ + contentCardVisible: true, + contentCardTitle: title, + contentCardBody: body, + }, true) + this.contentCardTimer = setTimeout(() => { + this.contentCardTimer = 0 + this.setState({ + contentCardVisible: false, + }, true) + }, 2600) as unknown as number + } + + closeContentCard(): void { + this.clearContentCardTimer() + this.setState({ + contentCardVisible: false, + }, true) + } + + applyGameEffects(effects: GameEffect[]): string | null { + this.soundDirector.handleEffects(effects) + const statusText = this.resolveGameStatusText(effects) + for (const effect of effects) { + if (effect.type === 'punch_feedback') { + this.showPunchFeedback(effect.text, effect.tone) + } + + if (effect.type === 'control_completed') { + this.showPunchFeedback(`完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`, 'success') + this.showContentCard(effect.displayTitle, effect.displayBody) + } + + if (effect.type === 'session_finished' && this.locationController.listening) { + this.locationController.stop() + } + } + + return statusText + } + + handleStartGame(): void { + if (!this.gameRuntime.definition || !this.gameRuntime.state) { + this.setState({ + statusText: `当前还没有可开始的路线 (${this.buildVersion})`, + }, true) + return + } + + if (this.gameRuntime.state.status !== 'idle') { + return + } + + if (!this.locationController.listening) { + this.locationController.start() + } + + const startedAt = Date.now() + let gameResult = this.gameRuntime.startSession(startedAt) + if (this.currentGpsPoint) { + gameResult = this.gameRuntime.dispatch({ + type: 'gps_updated', + at: Date.now(), + lon: this.currentGpsPoint.lon, + lat: this.currentGpsPoint.lat, + accuracyMeters: this.currentGpsAccuracyMeters, + }) + } + + this.gamePresentation = this.gameRuntime.getPresentation() + this.refreshCourseHeadingFromPresentation() + const defaultStatusText = this.currentGpsPoint + ? `顺序打点已开始 (${this.buildVersion})` + : `顺序打点已开始,GPS定位启动中 (${this.buildVersion})` + const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText + this.setState({ + ...this.getGameViewPatch(gameStatusText), + }, true) + this.syncRenderer() + } + + + handlePunchAction(): void { + const gameResult = this.gameRuntime.dispatch({ + type: 'punch_requested', + at: Date.now(), + }) + this.gamePresentation = gameResult.presentation + this.refreshCourseHeadingFromPresentation() + const gameStatusText = this.applyGameEffects(gameResult.effects) + this.setState({ + ...this.getGameViewPatch(gameStatusText), + }, true) + this.syncRenderer() + } + handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void { const nextPoint: LonLatPoint = { lon: longitude, lat: latitude } const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null @@ -596,6 +871,20 @@ export class MapEngine { const gpsTileX = Math.floor(gpsWorldPoint.x) const gpsTileY = Math.floor(gpsWorldPoint.y) const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY) + let gameStatusText: string | null = null + + if (this.courseData) { + const gameResult = this.gameRuntime.dispatch({ + type: 'gps_updated', + at: Date.now(), + lon: longitude, + lat: latitude, + accuracyMeters, + }) + this.gamePresentation = gameResult.presentation + this.refreshCourseHeadingFromPresentation() + gameStatusText = this.applyGameEffects(gameResult.effects) + } if (gpsInsideMap && !this.hasGpsCenteredOnce) { this.hasGpsCenteredOnce = true @@ -607,7 +896,8 @@ export class MapEngine { gpsTracking: true, gpsTrackingText: '持续定位进行中', gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters), - }, `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true) + ...this.getGameViewPatch(), + }, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true) return } @@ -615,7 +905,7 @@ export class MapEngine { gpsTracking: true, gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内', gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters), - statusText: gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`, + ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)), }, true) this.syncRenderer() } @@ -649,7 +939,7 @@ export class MapEngine { stageLeft: rect.left, stageTop: rect.top, }, - `地图视口与 WebGL 引擎已对齐 (${this.buildVersion})`, + `鍦板浘瑙嗗彛涓?WebGL 寮曟搸宸插榻?(${this.buildVersion})`, true, ) } @@ -662,7 +952,7 @@ export class MapEngine { this.onData({ mapReady: true, mapReadyText: 'READY', - statusText: `单 WebGL 管线已就绪,可切换手动或自动朝向 (${this.buildVersion})`, + statusText: `鍗?WebGL 绠$嚎宸插氨缁紝鍙垏鎹㈡墜鍔ㄦ垨鑷姩鏈濆悜 (${this.buildVersion})`, }) this.syncRenderer() this.compassController.start() @@ -679,9 +969,15 @@ export class MapEngine { this.tileBoundsByZoom = config.tileBoundsByZoom this.courseData = config.course this.cpRadiusMeters = config.cpRadiusMeters + this.gameMode = config.gameMode + this.punchPolicy = config.punchPolicy + this.punchRadiusMeters = config.punchRadiusMeters + this.autoFinishOnLastControl = config.autoFinishOnLastControl + const gameEffects = this.loadGameDefinitionFromCourse() + const gameStatusText = this.applyGameEffects(gameEffects) const statePatch: Partial = { - configStatusText: `远程配置已载入 / ${config.courseStatusText}`, + configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`, projectionMode: config.projectionModeText, tileSource: config.tileSource, sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)), @@ -689,6 +985,7 @@ export class MapEngine { northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode), compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg), + ...this.getGameViewPatch(), } if (!this.state.stageWidth || !this.state.stageHeight) { @@ -698,7 +995,7 @@ export class MapEngine { centerTileX: this.defaultCenterTileX, centerTileY: this.defaultCenterTileY, centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY), - statusText: `远程地图配置已载入 (${this.buildVersion})`, + statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, }, true) return } @@ -710,7 +1007,7 @@ export class MapEngine { centerTileY: this.defaultCenterTileY, tileTranslateX: 0, tileTranslateY: 0, - }, `远程地图配置已载入 (${this.buildVersion})`, true, () => { + }, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { @@ -722,7 +1019,6 @@ export class MapEngine { handleTouchStart(event: WechatMiniprogram.TouchEvent): void { this.clearInertiaTimer() this.clearPreviewResetTimer() - this.renderer.setAnimationPaused(true) this.panVelocityX = 0 this.panVelocityY = 0 @@ -787,8 +1083,8 @@ export class MapEngine { rotationText: formatRotationText(nextRotationDeg), }, this.state.orientationMode === 'heading-up' - ? `双指缩放中,自动朝向保持开启 (${this.buildVersion})` - : `双指缩放与旋转中 (${this.buildVersion})`, + ? `鍙屾寚缂╂斁涓紝鑷姩鏈濆悜淇濇寔寮€鍚?(${this.buildVersion})` + : `鍙屾寚缂╂斁涓庢棆杞腑 (${this.buildVersion})`, ) return } @@ -813,7 +1109,7 @@ export class MapEngine { this.normalizeTranslate( this.state.tileTranslateX + deltaX, this.state.tileTranslateY + deltaY, - `已拖拽单 WebGL 地图引擎 (${this.buildVersion})`, + `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`, ) } @@ -895,7 +1191,7 @@ export class MapEngine { tileTranslateX: 0, tileTranslateY: 0, }, - `已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`, + `宸插洖鍒板崟 WebGL 寮曟搸榛樿棣栧睆 (${this.buildVersion})`, true, () => { this.resetPreviewState() @@ -909,7 +1205,7 @@ export class MapEngine { handleRotateStep(stepDeg = ROTATE_STEP_DEG): void { if (this.state.rotationMode === 'auto') { this.setState({ - statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`, + statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`, }, true) return } @@ -929,7 +1225,7 @@ export class MapEngine { rotationDeg: nextRotationDeg, rotationText: formatRotationText(nextRotationDeg), }, - `旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`, + `鏃嬭浆瑙掑害璋冩暣鍒?${formatRotationText(nextRotationDeg)} (${this.buildVersion})`, true, () => { this.resetPreviewState() @@ -942,7 +1238,7 @@ export class MapEngine { handleRotationReset(): void { if (this.state.rotationMode === 'auto') { this.setState({ - statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`, + statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`, }, true) return } @@ -966,7 +1262,7 @@ export class MapEngine { rotationDeg: targetRotationDeg, rotationText: formatRotationText(targetRotationDeg), }, - `旋转角度已回到真北参考 (${this.buildVersion})`, + `鏃嬭浆瑙掑害宸插洖鍒扮湡鍖楀弬鑰?(${this.buildVersion})`, true, () => { this.resetPreviewState() @@ -1009,20 +1305,20 @@ export class MapEngine { handleAutoRotateCalibrate(): void { if (this.state.orientationMode !== 'heading-up') { this.setState({ - statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`, + statusText: `璇峰厛鍒囧埌鏈濆悜鏈濅笂妯″紡鍐嶆牎鍑?(${this.buildVersion})`, }, true) return } if (!this.calibrateAutoRotateToCurrentOrientation()) { this.setState({ - statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`, + statusText: `褰撳墠杩樻病鏈変紶鎰熷櫒鏂瑰悜鏁版嵁锛屾殏鏃舵棤娉曟牎鍑?(${this.buildVersion})`, }, true) return } this.setState({ - statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`, + statusText: `宸叉寜褰撳墠鎸佹満鏂瑰悜瀹屾垚鏈濆悜鏍″噯 (${this.buildVersion})`, }, true) this.scheduleAutoRotate() } @@ -1038,7 +1334,7 @@ export class MapEngine { orientationMode: 'manual', orientationModeText: formatOrientationModeText('manual'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), - statusText: `已切回手动地图旋转 (${this.buildVersion})`, + statusText: `宸插垏鍥炴墜鍔ㄥ湴鍥炬棆杞?(${this.buildVersion})`, }, true) } @@ -1065,7 +1361,7 @@ export class MapEngine { autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg), northReferenceText: formatNorthReferenceText(this.northReferenceMode), }, - `地图已固定为真北朝上 (${this.buildVersion})`, + `鍦板浘宸插浐瀹氫负鐪熷寳鏈濅笂 (${this.buildVersion})`, true, () => { this.resetPreviewState() @@ -1086,7 +1382,7 @@ export class MapEngine { orientationModeText: formatOrientationModeText('heading-up'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), northReferenceText: formatNorthReferenceText(this.northReferenceMode), - statusText: `正在启用朝向朝上模式 (${this.buildVersion})`, + statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`, }, true) if (this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() @@ -1409,6 +1705,15 @@ export class MapEngine { gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom), course: this.courseData, cpRadiusMeters: this.cpRadiusMeters, + activeControlSequences: this.gamePresentation.activeControlSequences, + activeStart: this.gamePresentation.activeStart, + completedStart: this.gamePresentation.completedStart, + activeFinish: this.gamePresentation.activeFinish, + completedFinish: this.gamePresentation.completedFinish, + revealFullCourse: this.gamePresentation.revealFullCourse, + activeLegIndices: this.gamePresentation.activeLegIndices, + completedLegIndices: this.gamePresentation.completedLegIndices, + completedControlSequences: this.gamePresentation.completedControlSequences, osmReferenceEnabled: this.state.osmReferenceEnabled, overlayOpacity: MAP_OVERLAY_OPACITY, } @@ -1701,7 +2006,7 @@ export class MapEngine { tileTranslateX: 0, tileTranslateY: 0, }, - `缩放级别调整到 ${nextZoom}`, + `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`, true, () => { this.setPreviewState(residualScale, stageX, stageY) @@ -1728,7 +2033,7 @@ export class MapEngine { zoom: nextZoom, ...resolvedViewport, }, - `缩放级别调整到 ${nextZoom}`, + `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`, true, () => { this.setPreviewState(residualScale, stageX, stageY) @@ -1748,7 +2053,7 @@ export class MapEngine { if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) { this.setState({ - statusText: `惯性滑动结束 (${this.buildVersion})`, + statusText: `鎯€ф粦鍔ㄧ粨鏉?(${this.buildVersion})`, }) this.renderer.setAnimationPaused(false) this.inertiaTimer = 0 @@ -1759,7 +2064,7 @@ export class MapEngine { this.normalizeTranslate( this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS, this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS, - `惯性滑动中 (${this.buildVersion})`, + `鎯€ф粦鍔ㄤ腑 (${this.buildVersion})`, ) this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number @@ -1805,6 +2110,18 @@ export class MapEngine { + + + + + + + + + + + + diff --git a/miniprogram/engine/renderer/courseLabelRenderer.ts b/miniprogram/engine/renderer/courseLabelRenderer.ts index 0ad2f8e..1b44066 100644 --- a/miniprogram/engine/renderer/courseLabelRenderer.ts +++ b/miniprogram/engine/renderer/courseLabelRenderer.ts @@ -5,6 +5,9 @@ const EARTH_CIRCUMFERENCE_METERS = 40075016.686 const LABEL_FONT_SIZE_RATIO = 1.08 const LABEL_OFFSET_X_RATIO = 1.18 const LABEL_OFFSET_Y_RATIO = -0.68 +const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)' +const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)' +const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)' export class CourseLabelRenderer { courseLayer: CourseLayer @@ -49,7 +52,7 @@ export class CourseLabelRenderer { const ctx = this.ctx this.clearCanvas(ctx) - if (!course || !course.controls.length) { + if (!course || !course.controls.length || !scene.revealFullCourse) { return } @@ -60,13 +63,13 @@ export class CourseLabelRenderer { this.applyPreviewTransform(ctx, scene) ctx.save() - ctx.fillStyle = 'rgba(204, 0, 107, 0.98)' ctx.textAlign = 'left' ctx.textBaseline = 'middle' ctx.font = `700 ${fontSizePx}px sans-serif` for (const control of course.controls) { ctx.save() + ctx.fillStyle = this.getLabelColor(scene, control.sequence) ctx.translate(control.point.x, control.point.y) ctx.rotate(scene.rotationRad) ctx.fillText(String(control.sequence), offsetX, offsetY) @@ -76,6 +79,18 @@ export class CourseLabelRenderer { ctx.restore() } + getLabelColor(scene: MapScene, sequence: number): string { + if (scene.activeControlSequences.includes(sequence)) { + return ACTIVE_LABEL_COLOR + } + + if (scene.completedControlSequences.includes(sequence)) { + return COMPLETED_LABEL_COLOR + } + + return DEFAULT_LABEL_COLOR + } + clearCanvas(ctx: any): void { ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) @@ -118,3 +133,4 @@ export class CourseLabelRenderer { return latRad * 180 / Math.PI } } + diff --git a/miniprogram/engine/renderer/mapRenderer.ts b/miniprogram/engine/renderer/mapRenderer.ts index 2151c9c..0d21d62 100644 --- a/miniprogram/engine/renderer/mapRenderer.ts +++ b/miniprogram/engine/renderer/mapRenderer.ts @@ -29,6 +29,15 @@ export interface MapScene { gpsCalibrationOrigin: LonLatPoint course: OrienteeringCourseData | null cpRadiusMeters: number + activeControlSequences: number[] + activeStart: boolean + completedStart: boolean + activeFinish: boolean + completedFinish: boolean + revealFullCourse: boolean + activeLegIndices: number[] + completedLegIndices: number[] + completedControlSequences: number[] osmReferenceEnabled: boolean overlayOpacity: number } @@ -54,3 +63,5 @@ export function buildCamera(scene: MapScene): CameraState { rotationRad: scene.rotationRad, } } + + diff --git a/miniprogram/engine/renderer/webglVectorRenderer.ts b/miniprogram/engine/renderer/webglVectorRenderer.ts index 83d46ec..08e2766 100644 --- a/miniprogram/engine/renderer/webglVectorRenderer.ts +++ b/miniprogram/engine/renderer/webglVectorRenderer.ts @@ -6,6 +6,9 @@ import { TrackLayer } from '../layer/trackLayer' import { GpsLayer } from '../layer/gpsLayer' const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96] +const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82] +const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1] +const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5] const EARTH_CIRCUMFERENCE_METERS = 40075016.686 const CONTROL_RING_WIDTH_RATIO = 0.2 const FINISH_INNER_RADIUS_RATIO = 0.6 @@ -13,16 +16,19 @@ const FINISH_RING_WIDTH_RATIO = 0.2 const START_RING_WIDTH_RATIO = 0.2 const LEG_WIDTH_RATIO = 0.2 const LEG_TRIM_TO_RING_CENTER_RATIO = 1 - CONTROL_RING_WIDTH_RATIO / 2 +const ACTIVE_CONTROL_PULSE_SPEED = 0.18 +const ACTIVE_CONTROL_PULSE_MIN_SCALE = 1.12 +const ACTIVE_CONTROL_PULSE_MAX_SCALE = 1.46 +const ACTIVE_CONTROL_PULSE_WIDTH_RATIO = 0.12 +const GUIDE_FLOW_COUNT = 5 +const GUIDE_FLOW_SPEED = 0.02 +const GUIDE_FLOW_TRAIL = 0.16 +const GUIDE_FLOW_MIN_WIDTH_RATIO = 0.12 +const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22 +const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18 type RgbaColor = [number, number, number, number] -const GUIDE_FLOW_COUNT = 6 -const GUIDE_FLOW_SPEED = 0.022 -const GUIDE_FLOW_MIN_RADIUS_RATIO = 0.14 -const GUIDE_FLOW_MAX_RADIUS_RATIO = 0.34 -const GUIDE_FLOW_OUTER_SCALE = 1.45 -const GUIDE_FLOW_INNER_SCALE = 0.56 - function createShader(gl: any, type: number, source: string): any { const shader = gl.createShader(type) if (!shader) { @@ -225,20 +231,36 @@ export class WebGLVectorRenderer { ): void { const controlRadiusMeters = this.getControlRadiusMeters(scene) - for (const leg of course.legs) { - this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, scene) + if (scene.revealFullCourse) { + for (let index = 0; index < course.legs.length; index += 1) { + const leg = course.legs[index] + this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, this.getLegColor(scene, index), scene) + if (scene.activeLegIndices.includes(index)) { + this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene) + } } - const guideLeg = this.getGuideLeg(course) + const guideLeg = this.getGuideLeg(course, scene) if (guideLeg) { this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame) } + } for (const start of course.starts) { - this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene) + if (scene.activeStart) { + this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame) + } + this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene) + } + if (!scene.revealFullCourse) { + return } for (const control of course.controls) { + if (scene.activeControlSequences.includes(control.sequence)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame) + } + this.pushRing( positions, colors, @@ -246,12 +268,17 @@ export class WebGLVectorRenderer { control.point.y, this.getMetric(scene, controlRadiusMeters), this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)), - COURSE_COLOR, + this.getControlColor(scene, control.sequence), scene, ) } for (const finish of course.finishes) { + if (scene.activeFinish) { + this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame) + } + + const finishColor = this.getFinishColor(scene) this.pushRing( positions, colors, @@ -259,7 +286,7 @@ export class WebGLVectorRenderer { finish.point.y, this.getMetric(scene, controlRadiusMeters), this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)), - COURSE_COLOR, + finishColor, scene, ) this.pushRing( @@ -269,17 +296,46 @@ export class WebGLVectorRenderer { finish.point.y, this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO), this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)), - COURSE_COLOR, + finishColor, scene, ) } } - getGuideLeg(course: ProjectedCourseLayers): ProjectedCourseLeg | null { - return course.legs.length ? course.legs[0] : null + getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null { + const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1 + if (activeIndex >= 0 && activeIndex < course.legs.length) { + return course.legs[activeIndex] + } + + return null + } + + getLegColor(scene: MapScene, index: number): RgbaColor { + return this.isCompletedLeg(scene, index) ? COMPLETED_ROUTE_COLOR : COURSE_COLOR + } + + isCompletedLeg(scene: MapScene, index: number): boolean { + return scene.completedLegIndices.includes(index) } pushCourseLeg( + positions: number[], + colors: number[], + leg: ProjectedCourseLeg, + controlRadiusMeters: number, + color: RgbaColor, + scene: MapScene, + ): void { + const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene) + if (!trimmed) { + return + } + + this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), color, scene) + } + + pushCourseLegHighlight( positions: number[], colors: number[], leg: ProjectedCourseLeg, @@ -291,7 +347,110 @@ export class WebGLVectorRenderer { return } - this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), COURSE_COLOR, scene) + this.pushSegment( + positions, + colors, + trimmed.from, + trimmed.to, + this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * 1.5), + ACTIVE_LEG_COLOR, + scene, + ) + } + + pushActiveControlPulse( + positions: number[], + colors: number[], + centerX: number, + centerY: number, + controlRadiusMeters: number, + scene: MapScene, + pulseFrame: number, + ): void { + const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2 + const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse + const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO + const glowAlpha = 0.24 + pulse * 0.34 + const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha] + + this.pushRing( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, controlRadiusMeters * pulseScale), + this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)), + glowColor, + scene, + ) + } + + pushActiveStartPulse( + positions: number[], + colors: number[], + centerX: number, + centerY: number, + headingDeg: number | null, + controlRadiusMeters: number, + scene: MapScene, + pulseFrame: number, + ): void { + const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2 + const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse + const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO + const glowAlpha = 0.24 + pulse * 0.34 + const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha] + const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180 + const ringCenterX = centerX + Math.cos(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04) + const ringCenterY = centerY + Math.sin(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04) + + this.pushRing( + positions, + colors, + ringCenterX, + ringCenterY, + this.getMetric(scene, controlRadiusMeters * pulseScale), + this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)), + glowColor, + scene, + ) + } + + getStartColor(scene: MapScene): RgbaColor { + if (scene.activeStart) { + return ACTIVE_CONTROL_COLOR + } + + if (scene.completedStart) { + return COMPLETED_ROUTE_COLOR + } + + return COURSE_COLOR + } + + getControlColor(scene: MapScene, sequence: number): RgbaColor { + if (scene.activeControlSequences.includes(sequence)) { + return ACTIVE_CONTROL_COLOR + } + + if (scene.completedControlSequences.includes(sequence)) { + return COMPLETED_ROUTE_COLOR + } + + return COURSE_COLOR + } + + + getFinishColor(scene: MapScene): RgbaColor { + if (scene.activeFinish) { + return ACTIVE_CONTROL_COLOR + } + + if (scene.completedFinish) { + return COMPLETED_ROUTE_COLOR + } + + return COURSE_COLOR } pushGuidanceFlow( @@ -316,18 +475,28 @@ export class WebGLVectorRenderer { for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) { const progress = (pulseFrame * GUIDE_FLOW_SPEED + index / GUIDE_FLOW_COUNT) % 1 + const tailProgress = Math.max(0, progress - GUIDE_FLOW_TRAIL) + const head = { + x: trimmed.from.x + dx * progress, + y: trimmed.from.y + dy * progress, + } + const tail = { + x: trimmed.from.x + dx * tailProgress, + y: trimmed.from.y + dy * tailProgress, + } const eased = progress * progress - const x = trimmed.from.x + dx * progress - const y = trimmed.from.y + dy * progress - const radius = this.getMetric( + const width = this.getMetric( scene, - controlRadiusMeters * (GUIDE_FLOW_MIN_RADIUS_RATIO + (GUIDE_FLOW_MAX_RADIUS_RATIO - GUIDE_FLOW_MIN_RADIUS_RATIO) * eased), + controlRadiusMeters * (GUIDE_FLOW_MIN_WIDTH_RATIO + (GUIDE_FLOW_MAX_WIDTH_RATIO - GUIDE_FLOW_MIN_WIDTH_RATIO) * eased), ) const outerColor = this.getGuideFlowOuterColor(eased) const innerColor = this.getGuideFlowInnerColor(eased) + const headRadius = this.getMetric(scene, controlRadiusMeters * GUIDE_FLOW_HEAD_RADIUS_RATIO * (0.72 + eased * 0.42)) - this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_OUTER_SCALE, outerColor, scene) - this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_INNER_SCALE, innerColor, scene) + this.pushSegment(positions, colors, tail, head, width * 1.9, outerColor, scene) + this.pushSegment(positions, colors, tail, head, width, innerColor, scene) + this.pushCircle(positions, colors, head.x, head.y, headRadius * 1.35, outerColor, scene) + this.pushCircle(positions, colors, head.x, head.y, headRadius, innerColor, scene) } } @@ -345,11 +514,11 @@ export class WebGLVectorRenderer { } getGuideFlowOuterColor(progress: number): RgbaColor { - return [1, 0.18, 0.6, 0.16 + progress * 0.34] + return [0.28, 0.92, 1, 0.14 + progress * 0.22] } getGuideFlowInnerColor(progress: number): RgbaColor { - return [1, 0.95, 0.98, 0.3 + progress * 0.54] + return [0.94, 0.99, 1, 0.38 + progress * 0.42] } getLegTrim(kind: ProjectedCourseLeg['fromKind'], controlRadiusMeters: number, scene: MapScene): number { @@ -398,6 +567,7 @@ export class WebGLVectorRenderer { centerY: number, headingDeg: number | null, controlRadiusMeters: number, + color: RgbaColor, scene: MapScene, ): void { const startRadius = this.getMetric(scene, controlRadiusMeters) @@ -411,9 +581,9 @@ export class WebGLVectorRenderer { } }) - this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, COURSE_COLOR, scene) - this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, COURSE_COLOR, scene) - this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, COURSE_COLOR, scene) + this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, color, scene) + this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, color, scene) + this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene) } pushRing( @@ -515,3 +685,5 @@ export class WebGLVectorRenderer { } + + diff --git a/miniprogram/game/audio/soundDirector.ts b/miniprogram/game/audio/soundDirector.ts new file mode 100644 index 0000000..0079498 --- /dev/null +++ b/miniprogram/game/audio/soundDirector.ts @@ -0,0 +1,100 @@ +import { type GameEffect } from '../core/gameResult' + +type SoundKey = 'session-start' | 'start-complete' | 'control-complete' | 'finish-complete' | 'warning' + +const SOUND_SRC: Record = { + 'session-start': '/assets/sounds/session-start.wav', + 'start-complete': '/assets/sounds/start-complete.wav', + 'control-complete': '/assets/sounds/control-complete.wav', + 'finish-complete': '/assets/sounds/finish-complete.wav', + warning: '/assets/sounds/warning.wav', +} + +export class SoundDirector { + enabled: boolean + contexts: Partial> + + constructor() { + this.enabled = true + this.contexts = {} + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled + } + + destroy(): void { + const keys = Object.keys(this.contexts) as SoundKey[] + for (const key of keys) { + const context = this.contexts[key] + if (!context) { + continue + } + context.stop() + context.destroy() + } + this.contexts = {} + } + + handleEffects(effects: GameEffect[]): void { + if (!this.enabled || !effects.length) { + return + } + + const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish') + + for (const effect of effects) { + if (effect.type === 'session_started') { + this.play('session-start') + continue + } + + if (effect.type === 'punch_feedback' && effect.tone === 'warning') { + this.play('warning') + continue + } + + if (effect.type === 'control_completed') { + if (effect.controlKind === 'start') { + this.play('start-complete') + continue + } + + if (effect.controlKind === 'finish') { + this.play('finish-complete') + continue + } + + this.play('control-complete') + continue + } + + if (effect.type === 'session_finished' && !hasFinishCompletion) { + this.play('finish-complete') + } + } + } + + play(key: SoundKey): void { + const context = this.getContext(key) + context.stop() + context.seek(0) + context.play() + } + + getContext(key: SoundKey): WechatMiniprogram.InnerAudioContext { + const existing = this.contexts[key] + if (existing) { + return existing + } + + const context = wx.createInnerAudioContext() + context.src = SOUND_SRC[key] + context.autoplay = false + context.loop = false + context.obeyMuteSwitch = true + context.volume = 1 + this.contexts[key] = context + return context + } +} diff --git a/miniprogram/game/content/courseToGameDefinition.ts b/miniprogram/game/content/courseToGameDefinition.ts new file mode 100644 index 0000000..50ea732 --- /dev/null +++ b/miniprogram/game/content/courseToGameDefinition.ts @@ -0,0 +1,76 @@ +import { type GameDefinition, type GameControl, type PunchPolicyType } from '../core/gameDefinition' +import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' + +function sortBySequence(items: T[]): T[] { + return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0)) +} + +function buildDisplayBody(label: string, sequence: number | null): string { + if (typeof sequence === 'number') { + return `检查点 ${sequence} · ${label || String(sequence)}` + } + + return label +} + +export function buildGameDefinitionFromCourse( + course: OrienteeringCourseData, + controlRadiusMeters: number, + mode: GameDefinition['mode'] = 'classic-sequential', + autoFinishOnLastControl = true, + punchPolicy: PunchPolicyType = 'enter-confirm', + punchRadiusMeters = 5, +): GameDefinition { + const controls: GameControl[] = [] + + for (const start of course.layers.starts) { + controls.push({ + id: `start-${controls.length + 1}`, + code: start.label || 'S', + label: start.label || 'Start', + kind: 'start', + point: start.point, + sequence: null, + displayContent: null, + }) + } + + for (const control of sortBySequence(course.layers.controls)) { + const label = control.label || String(control.sequence) + controls.push({ + id: `control-${control.sequence}`, + code: label, + label, + kind: 'control', + point: control.point, + sequence: control.sequence, + displayContent: { + title: `收集 ${label}`, + body: buildDisplayBody(label, control.sequence), + }, + }) + } + + for (const finish of course.layers.finishes) { + controls.push({ + id: `finish-${controls.length + 1}`, + code: finish.label || 'F', + label: finish.label || 'Finish', + kind: 'finish', + point: finish.point, + sequence: null, + displayContent: null, + }) + } + + return { + id: `course-${course.title || 'default'}`, + mode, + title: course.title || 'Classic Sequential', + controlRadiusMeters, + punchRadiusMeters, + punchPolicy, + controls, + autoFinishOnLastControl, + } +} diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts new file mode 100644 index 0000000..280b8ab --- /dev/null +++ b/miniprogram/game/core/gameDefinition.ts @@ -0,0 +1,31 @@ +import { type LonLatPoint } from '../../utils/projection' + +export type GameMode = 'classic-sequential' +export type GameControlKind = 'start' | 'control' | 'finish' +export type PunchPolicyType = 'enter' | 'enter-confirm' + +export interface GameControlDisplayContent { + title: string + body: string +} + +export interface GameControl { + id: string + code: string + label: string + kind: GameControlKind + point: LonLatPoint + sequence: number | null + displayContent: GameControlDisplayContent | null +} + +export interface GameDefinition { + id: string + mode: GameMode + title: string + controlRadiusMeters: number + punchRadiusMeters: number + punchPolicy: PunchPolicyType + controls: GameControl[] + autoFinishOnLastControl: boolean +} diff --git a/miniprogram/game/core/gameEvent.ts b/miniprogram/game/core/gameEvent.ts new file mode 100644 index 0000000..1acdbd9 --- /dev/null +++ b/miniprogram/game/core/gameEvent.ts @@ -0,0 +1,5 @@ +export type GameEvent = + | { type: 'session_started'; at: number } + | { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null } + | { type: 'punch_requested'; at: number } + | { type: 'session_ended'; at: number } diff --git a/miniprogram/game/core/gameResult.ts b/miniprogram/game/core/gameResult.ts new file mode 100644 index 0000000..ed5a132 --- /dev/null +++ b/miniprogram/game/core/gameResult.ts @@ -0,0 +1,14 @@ +import { type GameSessionState } from './gameSessionState' +import { type GamePresentationState } from '../presentation/presentationState' + +export type GameEffect = + | { type: 'session_started' } + | { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' } + | { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string } + | { type: 'session_finished' } + +export interface GameResult { + nextState: GameSessionState + presentation: GamePresentationState + effects: GameEffect[] +} diff --git a/miniprogram/game/core/gameRuntime.ts b/miniprogram/game/core/gameRuntime.ts new file mode 100644 index 0000000..b75da50 --- /dev/null +++ b/miniprogram/game/core/gameRuntime.ts @@ -0,0 +1,89 @@ +import { type GameDefinition } from './gameDefinition' +import { type GameEvent } from './gameEvent' +import { type GameResult } from './gameResult' +import { type GameSessionState } from './gameSessionState' +import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState' +import { ClassicSequentialRule } from '../rules/classicSequentialRule' +import { type RulePlugin } from '../rules/rulePlugin' + +export class GameRuntime { + definition: GameDefinition | null + plugin: RulePlugin | null + state: GameSessionState | null + presentation: GamePresentationState + lastResult: GameResult | null + + constructor() { + this.definition = null + this.plugin = null + this.state = null + this.presentation = EMPTY_GAME_PRESENTATION_STATE + this.lastResult = null + } + + clear(): void { + this.definition = null + this.plugin = null + this.state = null + this.presentation = EMPTY_GAME_PRESENTATION_STATE + this.lastResult = null + } + + loadDefinition(definition: GameDefinition): GameResult { + this.definition = definition + this.plugin = this.resolvePlugin(definition) + this.state = this.plugin.initialize(definition) + const result: GameResult = { + nextState: this.state, + presentation: this.plugin.buildPresentation(definition, this.state), + effects: [], + } + this.presentation = result.presentation + this.lastResult = result + return result + } + + startSession(startAt = Date.now()): GameResult { + return this.dispatch({ type: 'session_started', at: startAt }) + } + + dispatch(event: GameEvent): GameResult { + if (!this.definition || !this.plugin || !this.state) { + const emptyState: GameSessionState = { + status: 'idle', + startedAt: null, + endedAt: null, + completedControlIds: [], + currentTargetControlId: null, + inRangeControlId: null, + score: 0, + } + const result: GameResult = { + nextState: emptyState, + presentation: EMPTY_GAME_PRESENTATION_STATE, + effects: [], + } + this.lastResult = result + this.presentation = result.presentation + return result + } + + const result = this.plugin.reduce(this.definition, this.state, event) + this.state = result.nextState + this.presentation = result.presentation + this.lastResult = result + return result + } + + getPresentation(): GamePresentationState { + return this.presentation + } + + resolvePlugin(definition: GameDefinition): RulePlugin { + if (definition.mode === 'classic-sequential') { + return new ClassicSequentialRule() + } + + throw new Error(`未支持的玩法模式: ${definition.mode}`) + } +} diff --git a/miniprogram/game/core/gameSessionState.ts b/miniprogram/game/core/gameSessionState.ts new file mode 100644 index 0000000..f007b74 --- /dev/null +++ b/miniprogram/game/core/gameSessionState.ts @@ -0,0 +1,11 @@ +export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed' + +export interface GameSessionState { + status: GameSessionStatus + startedAt: number | null + endedAt: number | null + completedControlIds: string[] + currentTargetControlId: string | null + inRangeControlId: string | null + score: number +} diff --git a/miniprogram/game/presentation/presentationState.ts b/miniprogram/game/presentation/presentationState.ts new file mode 100644 index 0000000..70237b4 --- /dev/null +++ b/miniprogram/game/presentation/presentationState.ts @@ -0,0 +1,39 @@ +export interface GamePresentationState { + activeControlIds: string[] + activeControlSequences: number[] + activeStart: boolean + completedStart: boolean + activeFinish: boolean + completedFinish: boolean + revealFullCourse: boolean + activeLegIndices: number[] + completedLegIndices: number[] + completedControlIds: string[] + completedControlSequences: number[] + progressText: string + punchableControlId: string | null + punchButtonEnabled: boolean + punchButtonText: string + punchHintText: string +} + +export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = { + activeControlIds: [], + activeControlSequences: [], + activeStart: false, + completedStart: false, + activeFinish: false, + completedFinish: false, + revealFullCourse: false, + activeLegIndices: [], + completedLegIndices: [], + completedControlIds: [], + completedControlSequences: [], + progressText: '0/0', + punchableControlId: null, + punchButtonEnabled: false, + punchButtonText: '打点', + punchHintText: '等待进入检查点范围', +} + + diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts new file mode 100644 index 0000000..2d59e3c --- /dev/null +++ b/miniprogram/game/rules/classicSequentialRule.ts @@ -0,0 +1,330 @@ +import { type LonLatPoint } from '../../utils/projection' +import { type GameControl, type GameDefinition } from '../core/gameDefinition' +import { type GameEvent } from '../core/gameEvent' +import { type GameEffect, type GameResult } from '../core/gameResult' +import { type GameSessionState } from '../core/gameSessionState' +import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState' +import { type RulePlugin } from './rulePlugin' + +function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number { + const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180 + const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad) + const dy = (b.lat - a.lat) * 110540 + return Math.sqrt(dx * dx + dy * dy) +} + +function getScoringControls(definition: GameDefinition): GameControl[] { + return definition.controls.filter((control) => control.kind === 'control') +} + +function getSequentialTargets(definition: GameDefinition): GameControl[] { + return definition.controls +} + +function getCompletedControlSequences(definition: GameDefinition, state: GameSessionState): number[] { + return getScoringControls(definition) + .filter((control) => state.completedControlIds.includes(control.id) && typeof control.sequence === 'number') + .map((control) => control.sequence as number) +} + +function getCurrentTarget(definition: GameDefinition, state: GameSessionState): GameControl | null { + return getSequentialTargets(definition).find((control) => control.id === state.currentTargetControlId) || null +} + +function getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] { + const targets = getSequentialTargets(definition) + const completedLegIndices: number[] = [] + + for (let index = 1; index < targets.length; index += 1) { + if (state.completedControlIds.includes(targets[index].id)) { + completedLegIndices.push(index - 1) + } + } + + return completedLegIndices +} + +function getTargetText(control: GameControl): string { + if (control.kind === 'start') { + return '开始点' + } + + if (control.kind === 'finish') { + return '终点' + } + + return '目标圈' +} + + +function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string { + if (state.status === 'idle') { + return '点击开始后先打开始点' + } + + if (state.status === 'finished') { + return '本局已完成' + } + + if (!currentTarget) { + return '本局已完成' + } + + const targetText = getTargetText(currentTarget) + if (state.inRangeControlId !== currentTarget.id) { + return definition.punchPolicy === 'enter' + ? `进入${targetText}自动打点` + : `进入${targetText}后点击打点` + } + + return definition.punchPolicy === 'enter' + ? `${targetText}内,自动打点中` + : `${targetText}内,可点击打点` +} + +function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState { + const scoringControls = getScoringControls(definition) + const sequentialTargets = getSequentialTargets(definition) + const currentTarget = getCurrentTarget(definition, state) + const currentTargetIndex = currentTarget ? sequentialTargets.findIndex((control) => control.id === currentTarget.id) : -1 + const completedControls = scoringControls.filter((control) => state.completedControlIds.includes(control.id)) + const running = state.status === 'running' + const activeLegIndices = running && currentTargetIndex > 0 + ? [currentTargetIndex - 1] + : [] + const completedLegIndices = getCompletedLegIndices(definition, state) + const punchButtonEnabled = running && !!currentTarget && state.inRangeControlId === currentTarget.id && definition.punchPolicy === 'enter-confirm' + const activeStart = running && !!currentTarget && currentTarget.kind === 'start' + const completedStart = definition.controls.some((control) => control.kind === 'start' && state.completedControlIds.includes(control.id)) + const activeFinish = running && !!currentTarget && currentTarget.kind === 'finish' + const completedFinish = definition.controls.some((control) => control.kind === 'finish' && state.completedControlIds.includes(control.id)) + const punchButtonText = currentTarget + ? currentTarget.kind === 'start' + ? '开始打卡' + : currentTarget.kind === 'finish' + ? '结束打卡' + : '打点' + : '打点' + const revealFullCourse = completedStart + + if (!scoringControls.length) { + return { + ...EMPTY_GAME_PRESENTATION_STATE, + activeStart, + completedStart, + activeFinish, + completedFinish, + revealFullCourse, + activeLegIndices, + completedLegIndices, + progressText: '0/0', + punchButtonText, + punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null, + punchButtonEnabled, + punchHintText: buildPunchHintText(definition, state, currentTarget), + } + } + + return { + activeControlIds: running && currentTarget ? [currentTarget.id] : [], + activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [], + activeStart, + completedStart, + activeFinish, + completedFinish, + revealFullCourse, + activeLegIndices, + completedLegIndices, + completedControlIds: completedControls.map((control) => control.id), + completedControlSequences: getCompletedControlSequences(definition, state), + progressText: `${completedControls.length}/${scoringControls.length}`, + punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null, + punchButtonEnabled, + punchButtonText, + punchHintText: buildPunchHintText(definition, state, currentTarget), + } +} + +function getInitialTargetId(definition: GameDefinition): string | null { + const firstTarget = getSequentialTargets(definition)[0] + return firstTarget ? firstTarget.id : null +} + +function buildCompletedEffect(control: GameControl): GameEffect { + if (control.kind === 'start') { + return { + type: 'control_completed', + controlId: control.id, + controlKind: 'start', + sequence: null, + label: control.label, + displayTitle: '比赛开始', + displayBody: '已完成开始点打卡,前往 1 号点。', + } + } + + if (control.kind === 'finish') { + return { + type: 'control_completed', + controlId: control.id, + controlKind: 'finish', + sequence: null, + label: control.label, + displayTitle: '比赛结束', + displayBody: '已完成终点打卡,本局结束。', + } + } + + const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label + const displayTitle = control.displayContent ? control.displayContent.title : `完成 ${sequenceText}` + const displayBody = control.displayContent ? control.displayContent.body : control.label + + return { + type: 'control_completed', + controlId: control.id, + controlKind: 'control', + sequence: control.sequence, + label: control.label, + displayTitle, + displayBody, + } +} + +function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult { + const targets = getSequentialTargets(definition) + const currentIndex = targets.findIndex((control) => control.id === currentTarget.id) + const completedControlIds = state.completedControlIds.includes(currentTarget.id) + ? state.completedControlIds + : [...state.completedControlIds, currentTarget.id] + const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1 + ? targets[currentIndex + 1] + : null + const nextState: GameSessionState = { + ...state, + completedControlIds, + currentTargetControlId: nextTarget ? nextTarget.id : null, + inRangeControlId: null, + score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length, + status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished', + endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at, + } + const effects: GameEffect[] = [buildCompletedEffect(currentTarget)] + + if (!nextTarget && definition.autoFinishOnLastControl) { + effects.push({ type: 'session_finished' }) + } + + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects, + } +} + +export class ClassicSequentialRule implements RulePlugin { + get mode(): 'classic-sequential' { + return 'classic-sequential' + } + + initialize(definition: GameDefinition): GameSessionState { + return { + status: 'idle', + startedAt: null, + endedAt: null, + completedControlIds: [], + currentTargetControlId: getInitialTargetId(definition), + inRangeControlId: null, + score: 0, + } + } + + buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState { + return buildPresentation(definition, state) + } + + reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult { + if (event.type === 'session_started') { + const nextState: GameSessionState = { + ...state, + status: 'running', + startedAt: event.at, + endedAt: null, + inRangeControlId: null, + } + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: [{ type: 'session_started' }], + } + } + + if (event.type === 'session_ended') { + const nextState: GameSessionState = { + ...state, + status: 'finished', + endedAt: event.at, + } + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: [{ type: 'session_finished' }], + } + } + + if (state.status !== 'running' || !state.currentTargetControlId) { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [], + } + } + + const currentTarget = getCurrentTarget(definition, state) + if (!currentTarget) { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [], + } + } + + if (event.type === 'gps_updated') { + const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat }) + const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null + const nextState: GameSessionState = { + ...state, + inRangeControlId, + } + + if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) { + return applyCompletion(definition, nextState, currentTarget, event.at) + } + + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: [], + } + } + + if (event.type === 'punch_requested') { + if (state.inRangeControlId !== currentTarget.id) { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '未进入开始点打卡范围' : currentTarget.kind === 'finish' ? '未进入终点打卡范围' : '未进入目标打点范围', tone: 'warning' }], + } + } + + return applyCompletion(definition, state, currentTarget, event.at) + } + + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [], + } + } +} + + diff --git a/miniprogram/game/rules/rulePlugin.ts b/miniprogram/game/rules/rulePlugin.ts new file mode 100644 index 0000000..1578ff0 --- /dev/null +++ b/miniprogram/game/rules/rulePlugin.ts @@ -0,0 +1,11 @@ +import { type GameDefinition } from '../core/gameDefinition' +import { type GameEvent } from '../core/gameEvent' +import { type GameResult } from '../core/gameResult' +import { type GameSessionState } from '../core/gameSessionState' + +export interface RulePlugin { + readonly mode: GameDefinition['mode'] + initialize(definition: GameDefinition): GameSessionState + buildPresentation(definition: GameDefinition, state: GameSessionState): GameResult['presentation'] + reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult +} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index b2adc98..97d11cd 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -97,8 +97,18 @@ Page({ panelTimerText: '00:00:00', panelMileageText: '0m', panelDistanceValueText: '108', - panelProgressText: '0/14', + panelProgressText: '0/0', + gameSessionStatus: 'idle', panelSpeedValueText: '0', + punchButtonText: '打点', + punchButtonEnabled: false, + punchHintText: '等待进入检查点范围', + punchFeedbackVisible: false, + punchFeedbackText: '', + punchFeedbackTone: 'neutral', + contentCardVisible: false, + contentCardTitle: '', + contentCardBody: '', compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), ...buildSideButtonVisibility('left'), @@ -124,8 +134,18 @@ Page({ panelTimerText: '00:00:00', panelMileageText: '0m', panelDistanceValueText: '108', - panelProgressText: '0/14', + panelProgressText: '0/0', + gameSessionStatus: 'idle', panelSpeedValueText: '0', + punchButtonText: '打点', + punchButtonEnabled: false, + punchHintText: '等待进入检查点范围', + punchFeedbackVisible: false, + punchFeedbackText: '', + punchFeedbackTone: 'neutral', + contentCardVisible: false, + contentCardTitle: '', + contentCardBody: '', compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), ...buildSideButtonVisibility('left'), @@ -311,6 +331,30 @@ Page({ } }, + handleStartGame() { + if (mapEngine) { + mapEngine.handleStartGame() + } + }, + + handleOverlayTouch() {}, + + handlePunchAction() { + if (!this.data.punchButtonEnabled) { + return + } + + if (mapEngine) { + mapEngine.handlePunchAction() + } + }, + + handleCloseContentCard() { + if (mapEngine) { + mapEngine.closeContentCard() + } + }, + handleCycleSideButtons() { this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode))) }, @@ -378,6 +422,9 @@ Page({ + + + diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 8498a19..ebc1e1c 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -23,6 +23,15 @@ + {{punchHintText}} + {{punchFeedbackText}} + + {{contentCardTitle}} + {{contentCardBody}} + 点击关闭 + + + @@ -87,6 +96,14 @@ USER + + {{punchButtonText}} + + + + 开始 + + @@ -111,7 +128,9 @@ - + + {{punchButtonText}} + {{panelTimerText}} @@ -291,3 +310,5 @@ + + diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 8beab05..840007f 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -189,6 +189,23 @@ bottom: 244rpx; } +.screen-button-layer--start-left { + left: 24rpx; + bottom: 378rpx; + min-height: 96rpx; + padding: 0 18rpx; + background: rgba(255, 226, 88, 0.96); + box-shadow: 0 14rpx 36rpx rgba(120, 89, 0, 0.2), 0 0 0 3rpx rgba(255, 246, 186, 0.38); +} + +.screen-button-layer__text--start { + margin-top: 0; + font-size: 30rpx; + font-weight: 800; + color: #6d4b00; + letter-spacing: 2rpx; +} + .map-side-toggle { position: absolute; left: 24rpx; @@ -685,6 +702,36 @@ right: 0; bottom: 0; } +.map-punch-button { + position: absolute; + right: 24rpx; + bottom: 244rpx; + width: 92rpx; + height: 92rpx; + border-radius: 50%; + background: rgba(78, 92, 106, 0.82); + box-shadow: 0 12rpx 28rpx rgba(22, 34, 46, 0.22), inset 0 0 0 2rpx rgba(255, 255, 255, 0.08); + z-index: 18; +} + +.map-punch-button__text { + font-size: 20rpx; + line-height: 92rpx; + font-weight: 800; + text-align: center; + color: rgba(236, 241, 246, 0.88); +} + +.map-punch-button--active { + background: rgba(92, 255, 237, 0.96); + box-shadow: 0 0 0 5rpx rgba(149, 255, 244, 0.18), 0 0 30rpx rgba(92, 255, 237, 0.5); + animation: punch-button-ready 1s ease-in-out infinite; +} + +.map-punch-button--active .map-punch-button__text { + color: #064d46; +} + .race-panel__line { position: absolute; @@ -979,6 +1026,139 @@ + + + + +.game-punch-hint { + position: absolute; + left: 50%; + bottom: 280rpx; + transform: translateX(-50%); + max-width: 72vw; + padding: 14rpx 24rpx; + border-radius: 999rpx; + background: rgba(18, 33, 24, 0.78); + color: #f7fbf2; + font-size: 24rpx; + line-height: 1.2; + text-align: center; + z-index: 16; + pointer-events: none; +} + +.game-punch-feedback { + position: absolute; + left: 50%; + top: 18%; + transform: translateX(-50%); + min-width: 240rpx; + padding: 20rpx 28rpx; + border-radius: 24rpx; + color: #ffffff; + font-size: 24rpx; + font-weight: 700; + text-align: center; + box-shadow: 0 16rpx 36rpx rgba(0, 0, 0, 0.18); + z-index: 17; + pointer-events: none; +} + +.game-punch-feedback--neutral { + background: rgba(27, 109, 189, 0.92); +} + +.game-punch-feedback--success { + background: rgba(37, 134, 88, 0.94); +} + +.game-punch-feedback--warning { + background: rgba(196, 117, 18, 0.94); +} + +.game-content-card { + position: absolute; + left: 50%; + top: 26%; + width: 440rpx; + max-width: calc(100vw - 72rpx); + transform: translateX(-50%); + padding: 28rpx 28rpx 24rpx; + border-radius: 28rpx; + background: rgba(248, 251, 244, 0.96); + box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18); + box-sizing: border-box; + z-index: 17; +} + +.game-content-card__title { + font-size: 34rpx; + line-height: 1.2; + font-weight: 700; + color: #163020; +} + +.game-content-card__body { + margin-top: 12rpx; + font-size: 24rpx; + line-height: 1.5; + color: #45624b; +} + +.game-content-card__hint { + margin-top: 16rpx; + font-size: 20rpx; + color: #809284; +} + +.race-panel__action-button { + display: flex; + align-items: center; + justify-content: center; + min-width: 116rpx; + min-height: 72rpx; + padding: 0 20rpx; + border-radius: 999rpx; + background: rgba(78, 92, 106, 0.54); + border: 2rpx solid rgba(210, 220, 228, 0.18); + box-sizing: border-box; + box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.06); +} + +.race-panel__action-button--active { + background: rgba(255, 226, 88, 0.98); + border-color: rgba(255, 247, 194, 0.98); + box-shadow: 0 0 0 4rpx rgba(255, 241, 158, 0.18), 0 0 28rpx rgba(255, 239, 122, 0.42); + animation: punch-button-ready 1s ease-in-out infinite; +} + +.race-panel__action-button-text { + font-size: 24rpx; + line-height: 1; + font-weight: 700; + color: rgba(236, 241, 246, 0.86); +} + +.race-panel__action-button--active .race-panel__action-button-text { + color: #775000; +} + +@keyframes punch-button-ready { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 241, 158, 0.22), 0 0 18rpx rgba(255, 239, 122, 0.28); + } + + 50% { + transform: scale(1.06); + box-shadow: 0 0 0 8rpx rgba(255, 241, 158, 0.08), 0 0 34rpx rgba(255, 239, 122, 0.52); + } + + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 241, 158, 0.22), 0 0 18rpx rgba(255, 239, 122, 0.28); + } +} diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index 4f0d75c..b424dc6 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/miniprogram/utils/remoteMapConfig.ts @@ -29,6 +29,10 @@ export interface RemoteMapConfig { course: OrienteeringCourseData | null courseStatusText: string cpRadiusMeters: number + gameMode: 'classic-sequential' + punchPolicy: 'enter' | 'enter-confirm' + punchRadiusMeters: number + autoFinishOnLastControl: boolean } interface ParsedGameConfig { @@ -36,6 +40,10 @@ interface ParsedGameConfig { mapMeta: string course: string | null cpRadiusMeters: number + gameMode: 'classic-sequential' + punchPolicy: 'enter' | 'enter-confirm' + punchRadiusMeters: number + autoFinishOnLastControl: boolean declinationDeg: number } @@ -158,6 +166,28 @@ function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number { return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue } +function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean { + if (typeof rawValue === 'boolean') { + return rawValue + } + + if (typeof rawValue === 'string') { + const normalized = rawValue.trim().toLowerCase() + if (normalized === 'true') { + return true + } + if (normalized === 'false') { + return false + } + } + + return fallbackValue +} + +function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' { + return rawValue === 'enter' ? 'enter' : 'enter-confirm' +} + function parseLooseJsonObject(text: string): Record { const parsed: Record = {} const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g @@ -198,17 +228,50 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig { normalized[key.toLowerCase()] = parsed[key] } + const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game) + ? parsed.game as Record + : null + const normalizedGame: Record = {} + if (rawGame) { + const gameKeys = Object.keys(rawGame) + for (const key of gameKeys) { + normalizedGame[key.toLowerCase()] = rawGame[key] + } + } + const mapRoot = typeof normalized.map === 'string' ? normalized.map : '' const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : '' if (!mapRoot || !mapMeta) { throw new Error('game.json 缺少 map 或 mapmeta 字段') } + const gameMode = 'classic-sequential' as const + const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode + if (typeof modeValue === 'string' && modeValue !== gameMode) { + throw new Error(`暂不支持的 game.mode: ${modeValue}`) + } + return { mapRoot, mapMeta, course: typeof normalized.course === 'string' ? normalized.course : null, cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5), + gameMode, + punchPolicy: parsePunchPolicy(normalizedGame.punchpolicy !== undefined ? normalizedGame.punchpolicy : normalized.punchpolicy), + punchRadiusMeters: parsePositiveNumber( + normalizedGame.punchradiusmeters !== undefined + ? normalizedGame.punchradiusmeters + : normalizedGame.punchradius !== undefined + ? normalizedGame.punchradius + : normalized.punchradiusmeters !== undefined + ? normalized.punchradiusmeters + : normalized.punchradius, + 5, + ), + autoFinishOnLastControl: parseBoolean( + normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol, + true, + ), declinationDeg: parseDeclinationValue(normalized.declination), } } @@ -237,11 +300,23 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig { throw new Error('game.yaml 缺少 map 或 mapmeta 字段') } + const gameMode = 'classic-sequential' as const + if (config.gamemode && config.gamemode !== gameMode) { + throw new Error(`暂不支持的 game.mode: ${config.gamemode}`) + } + return { mapRoot, mapMeta, course: typeof config.course === 'string' ? config.course : null, cpRadiusMeters: parsePositiveNumber(config.cpradius, 5), + gameMode, + punchPolicy: parsePunchPolicy(config.punchpolicy), + punchRadiusMeters: parsePositiveNumber( + config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius, + 5, + ), + autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true), declinationDeg: parseDeclinationValue(config.declination), } } @@ -459,5 +534,12 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise