From 5fc996dea173e30ba157de79ec23b8be16f93156 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Thu, 26 Mar 2026 16:58:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=9C=B0=E5=9B=BE=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E3=80=81=E5=8A=A8=E7=94=BB=E4=B8=8E=E7=BD=97=E7=9B=98?= =?UTF-8?q?=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compass-debugging-notes.md | 212 +++++++++ miniprogram/assets/btn_locked.png | Bin 0 -> 5056 bytes miniprogram/assets/btn_settings.png | Bin 0 -> 5370 bytes miniprogram/assets/btn_unlock.png | Bin 0 -> 5339 bytes miniprogram/engine/map/mapEngine.ts | 378 ++++++++++++--- .../engine/renderer/courseLabelRenderer.ts | 15 +- miniprogram/engine/renderer/mapRenderer.ts | 3 + .../engine/renderer/webglMapRenderer.ts | 6 +- .../engine/renderer/webglVectorRenderer.ts | 133 +++++- .../engine/sensor/compassHeadingController.ts | 26 +- miniprogram/game/feedback/feedbackConfig.ts | 34 +- miniprogram/game/feedback/feedbackDirector.ts | 8 + miniprogram/game/feedback/uiEffectDirector.ts | 109 ++++- .../game/telemetry/telemetryRuntime.ts | 46 +- miniprogram/pages/map/map.ts | 440 ++++++++++++++++-- miniprogram/pages/map/map.wxml | 149 +++++- miniprogram/pages/map/map.wxss | 148 +++++- miniprogram/utils/animationLevel.ts | 24 + 18 files changed, 1566 insertions(+), 165 deletions(-) create mode 100644 compass-debugging-notes.md create mode 100644 miniprogram/assets/btn_locked.png create mode 100644 miniprogram/assets/btn_settings.png create mode 100644 miniprogram/assets/btn_unlock.png create mode 100644 miniprogram/utils/animationLevel.ts diff --git a/compass-debugging-notes.md b/compass-debugging-notes.md new file mode 100644 index 0000000..536d676 --- /dev/null +++ b/compass-debugging-notes.md @@ -0,0 +1,212 @@ +# 罗盘问题排查记录 + +## 背景 + +本项目在微信小程序中使用罗盘驱动: + +- 指北针针头 +- 指北针顶部角度数字 +- `heading-up` 自动转图 + +在一次围绕顶部提示窗、传感器显示链和性能优化的修改后,出现了以下问题: + +- iOS 端偶发正常,偶发异常 +- Android 端罗盘长期无样本 +- 指北针不转 +- `heading-up` 自动转图一起失效 + +## 最终结论 + +这次问题的主因不是算法本身,而是: + +**Android 微信环境下,罗盘监听需要被持续保活;之前将多处看似冗余的 `compassController.start()` 清理掉后,Android 的罗盘样本链被破坏了。** + +也就是说: + +- iOS 对罗盘监听更宽容 +- Android 对罗盘监听更脆弱 +- 之前稳定,不是因为链路更“干净”,而是因为老代码里存在一条实际有效的“罗盘保活链” + +## 现象总结 + +### 失效期 + +- Android 调试面板里 `Compass Source` 为 `无数据` +- iOS 仍可能有 `罗盘` 样本 +- 若强行用 `DeviceMotion` 兜底,会出现: + - 指针会转 + - 但方向不准 + - 自动转图方向错误 + +### 恢复后 + +- Android `Compass Source` 恢复为 `罗盘` +- 指北针针头恢复 +- 顶部角度数字恢复 +- `heading-up` 恢复 + +## 误判过的方向 + +以下方向在本次排查中都被考虑过,但最终不是根因或不是主要根因: + +### 1. `DeviceMotion` 兜底方案 + +问题: + +- `DeviceMotion` 可以给出设备姿态角 +- 但不能稳定代替“指向北”的绝对罗盘 +- 用它兜底会导致: + - 能转 + - 但方向明显不准 + +结论: + +**`DeviceMotion` 不能作为正式指北针来源。** + +### 2. 加速度计 / 其他传感器互斥 + +曾排查: + +- `Accelerometer` +- `Gyroscope` +- `DeviceMotion` +- `Compass` + +结论: + +- 加速度计在当前微信 Android 环境下不稳定,已放弃 +- 但这不是这次罗盘彻底失效的主因 + +### 3. 算法问题 + +曾尝试调整: + +- 角度平滑 +- 设备方向单位解释 +- motion fallback 算法 + +结论: + +这些会影响“顺不顺”、“准不准”,但**不能解释 Android 完全无罗盘样本**。 + +## 真正修复的方法 + +将之前被清理掉的多处 `this.compassController.start()` 恢复回去。 + +这些调用点主要分布在: + +- `commitViewport(...)` +- `handleTouchStart(...)` +- `animatePreviewToRest(...)` +- `normalizeTranslate(...)` +- `zoomAroundPoint(...)` +- `handleRecenter(...)` +- `handleRotateStep(...)` +- `handleRotationReset(...)` + +这些调用在代码审美上看起来像“重复启动”,但在 Android 微信环境里,它们实际上承担了: + +**重新拉起 / 保活罗盘监听** + +的作用。 + +## 当前工程判断 + +本项目当前应当采用以下原则: + +### 1. 罗盘主来源只使用 `Compass` + +不要再让: + +- `DeviceMotion` +- 其它姿态角 + +参与正式指北针和自动转图的主方向链。 + +### 2. `DeviceMotion` 只保留为辅助或调试输入 + +可用于: + +- 调试面板显示 +- 设备姿态观察 +- 未来原生端姿态融合参考 + +但不要直接驱动指北针。 + +### 3. Android 端罗盘需要保活 + +后续不要再把这些 `compassController.start()` 当成纯冗余逻辑随意清掉。 + +如果要优化代码,应该: + +- 保留现有行为 +- 将其收口为有明确语义的方法 + +例如: + +- `ensureCompassAlive()` +- `refreshCompassBinding()` + +而不是直接删掉。 + +## 与生命周期相关的硬约束 + +以下约束必须保持: + +### 单实例 + +页面层必须保证任意时刻只有一个 `MapEngine` 活跃实例。 + +### 完整销毁 + +`MapEngine.destroy()` 中必须完整执行: + +- `compassController.destroy()` +- 其它传感器 `destroy()` + +防止旧监听残留。 + +### 调试状态不应影响罗盘主链 + +调试面板开关不应再控制: + +- 罗盘是否启动 +- 罗盘是否停止 + +否则容易再次引入平台差异问题。 + +## 推荐保留的调试字段 + +以下字段建议长期保留,便于后续定位: + +- `Compass Source` +- `sensorHeadingText` +- 顶部角度数字 +- `heading-up` 开关状态 + +其中 `Compass Source` 至少应显示: + +- `罗盘` +- `无数据` + +避免再次将问题误判为算法问题。 + +## 后续优化建议 + +如果后面要继续优化这段代码,推荐方向是: + +### 可做 + +- 将分散的 `compassController.start()` 收口成命名明确的方法 +- 为 Android 罗盘链补一层更可读的“保活机制”注释 +- 保留当前稳定行为前提下做重构 + +### 不建议 + +- 再次移除这些重复 `start()` 调用 +- 用 `DeviceMotion` 正式兜底指北针 +- 让调试开关影响罗盘主链启动 + +## 一句话经验 + +**在微信小程序里,Android 罗盘监听的稳定性比 iOS 更脆;某些看似冗余的 `start()` 调用,实际是平台兼容补丁,不应该在没有真机回归的情况下清理。** diff --git a/miniprogram/assets/btn_locked.png b/miniprogram/assets/btn_locked.png new file mode 100644 index 0000000000000000000000000000000000000000..6dff728fd2d8ae45519734ad1625b5df52ba086a GIT binary patch literal 5056 zcmb`Li8s{$_s7Q=jD3wU$c!yyUrLCzw!NanAeR*FCTEKIgvgeclvnOJjBxC<_1pU^hV_(KM3( zJzz%KF8DG*j|R{keG@w{ZAF1G$p8Rvgb7m5?yl>vqWb~%hR`0)=u~Z%DgBR^&|JO~ z7O=hnq(!PS1;a@uAGjYTm}Z&|3Ue^y<#a*pvJ#sw)e3?ssQ@{<$?57if@OS)!{#^xmB9iEkDjY zUGJ%|mC9-N@ozk(+NA64MN7vC4y0GR?;g9_&pHQCoC8`!yRD{uC=$);;r{TIXLL92 z9^Q^A37cor{@%cEKc#S5WTw#%#ku2+$Ym@~U|gwlc6*+384dh)AQ4Us7vhLHQ9Z^} z&!{$zg-=8t^?op>FUS5A^6>5VxM6ce)?yY@-CrgbR&I|B0N?kssQ7+btXYcxecBVs zSXuLSdOF#93}l`6Y!edp{yW^(*9;)eWf9{nS0F(?<;gd) zSZ1~G(mkV!)Cd;EuCbtUXX1ae66Z4X2mg`_2uyZQ;kVuewTv9uI$sF;R!LW7gu!u0 zjJB|NU)&W5)$=N_A#Wp<)_aEfq=kGwY_D)S_Zn622zgm=!T?oGiURCvO7kls_gN`kix6FkCDaq_V$)&YHBXbb%bra2aWKu-mPJ(K7(f9*!>x5)`*ZKQ|57(fAkv12^~0Qr z?pydAtP!9o_-*RB?^e2}e}p8i<$J2#mlHN&CM>P=7;s*l+L|y^43jD)Bi4OQSD@&p zBD`D^jusXcYo!KB3*t7aeGrq*IQCS;5%$K7|DGFXp8w@T0>L7sy^bsvGwh-0A6h#$ zVbC;yWXcruU8ZQJx8|n~;|>Q$$5|H%gC++6S$gSaFL{^vLoy7{$DpjXl1KL~OWYV* zVOd0dhcY|Id25xx&W7U);&v4mj;pJJvAobg_kK^t@xR@-RrP<$+SY~JzHCsDp27x}a z;TyDOcS7EiS>N1@>8`E)Ig>ohnRG4=lm}pnC0jQkhB-XJ#@{O8r~B2a=IVgR&Cl)p zx0O7c!Mg)e;<9wN5#PX9GOfpZP_~FNHLe8PYDq6ds^xA2l*s9wx)8NHD)>-9aY4wY z{Y!&>PEUr2)(?57rez^f=~Iq_iu4gaRJac#31nMzNST@0N7&m>_ru483NxO8Xs#`d z9UW9dM$}B`Lk?Heq^=$wqmJpr{rvUobYt~nIqsZI{=cXQE35H99<9%hK}7bzlklJi z-Yx>Dh3l0JssN0e+hSmo&sD8Ak)D&>xd5kQE2DI17%M$ly6wwoxI64)YiHwHvMC@i zNm@>>miLfwQp)OJrb$P%NUVGz4lp5-7me*)HEQKhvC`%5uZW_(TAs%)k6*3m%>(Vc zk{c0vmHZ3@CfI^>BoHzkq8F@lMvJ$;OkTyxh=Nqgaxfv+gbD7|rzfb7SufuBo|XlO zz<6a!BnZaM8n~ab9y~C_OfXbM4VL@QKPfac;=Zfd!UPO-Ik!s8)0kcahKLW zuwBw)0Lrh$ONXoq!_FmTHgThpSmkc9sENfMp6rHqUEO}L^8EUmx#w*J^@vc$+&ecl$B)0Vdf7Y&HV+NV6<@U^Ngynz|G>FX&)=rpgU52YgQRb` zF4+Qe0F`eO5YAx5-_^he>JrIe?BeG@h{&9~*ZJuH=F{{zL?4K`vjilx9!62^>@9&{(T$h<*m)dsB*pn+5Rq{gN zl{*!ut346Ix(nS!F!|6hK_(3_hD%Rqit-wV1NL>@Ke(rRvK6m43P;h6%?G`{s_~@Z z+}+(#qf^a`Cx4HNnOxEt1%OzXTy`1hbns3KuTn=_VCS|`rgp9RQ^S|@BMI#l@vQDG z+^Ao<(~51_NvZnwcBj{IdM2?vu5+`Ukp@Xpw?lg2Y#xH<3ZMz3b86FahDp$9f%*cT zo=Lc+fHI{G6pTbF7oMU{co$xjP_kp6u^djn7j&q(W5 zA4y6F$^*N5{HxWzlN_xZP5pxmu-| z}|m*4x?uRDtO#%-Hm*k z2!)aG*c(vL1Q(n0bpb8`r3oFq{&nBs1Xckl;x1O;yslY_=e3Gx7|cc_2T;P@!uc}X zR_(7+PgFmgHP?!nNu}p(q^&2X|3)IEZ7VkL5?OnSNupOk-kJQ9t7Siw9^&OW1Oz~T zGFH_UX+?M!a{%3V&T2I+(g~qIXEG z3l@^|HJ!BvrjZ1~jU z%JktuNm3(z+9GG0%!}d^|kO(w@u`_sI6UM9Y zL4a6krT(w~Ok5}fydb|Z&6J3MvWzrn{4;?jtlbmea??z3+BsFZ41_F*@qS+e?n%x3 zb@o}+a*fYYtSgt(&%gGVuQH4VO&H$|esIxEZ@H2oLbK1p)8;i7YKW!xYZrCB#>P#z zeA^@_SKUwYb03(D?Cv+wnvn4;0){q3 zAOC!S$WhuC93@=xXOTY%_L^i-miQ;@^ZIm6*P};|NKNT>T~zA5pvel0ENHf`5}53f zQczI96Z3c-hcOO$+UrBk4CVi8Z~*3j;?tJO-bA8o=v2O&@rdsDJ`s@EymJ9D!zFm~ zBffYGh6~=IE&+2Z;0&<4K`qa4#K+8u;tX|G9w%-%CP?2XK0NX8j6S zlXoGNfVJ7D{XIDN`S{XOPKV!E1L*1LyAl!-Uc1MVp~TCS0Bd@p$__j%)?AOWxcIu2 zOvd=J&5G~a>O^c__s3$_v@H$L5GTQD8SYz!Y} zXAg#+*^~#G0ILf5IBVfYwz{=LmQQ18wW`_c=cmS9&$jc8CAVo!&}LIp(=i0*^lQ)` z6`mwT7~&T(_*N?C=kI@?R9(IP<$v*lX-Cqhr>7N7V=)nahayma7{ymhzAf@}Z`McQ zMn|aC|1P*d>$$@s{tKJEDGKw_QX4=F1BGTX&5ww0+Hc7yC=~M_pG(aGjFsr(9)Ozn zFvFd{rA?D3ZyCtKZ^5t(f0x_bsFhn&Rkj-!Wo2V{%lDE`?W~??GvL#+}f3scYqGR1>10wlpqgxdO9Q$Nz-L2Vs)P7v&_@|7)ouX2~c;Jx! zQ1OXL{;&Q0{Y6Iy2mLn*vr3O+GsDTRRK-T6wVAL$i9K<{SC={!olu)pp;nrVwn}~^ ztTi$#J~Jsk*Lct}A9(y->v!iJDQul#O7OaBlkpfL&;d$hKtBSS((f+y#=owsshJnv zI4ATY`)s|YezQc~e}+jo?Ps^T#X<6ZT@hf&-}LXyCpBrSV(p_>DZCdJbVp_-A5Dt) zZoJQyvX~>$m7A+-GNE4-)_oKFuZ-H=Z>DLpd?8EdNa#+kJO*I73imPI_D$CX%g^#Dx zwMNUorT$zMRLFkzvpc{((pH*oBquT`AfPjV(&Y8Jh^9mJKToId{{+41&dA7+x_!-i ze(t6JnVHc$g9{yNi=0MzB(!QlK|ptBiPaD_x9LCzp-YoS?aLu|c87KYyt9`o*<6<$ zk{iK(ZKS>y(e^!35=eGyeUqjNLeKu5;$D486w*V>5iXjE1RzTygI9gRBN7&|H`Pm19N5@VxfPh39-}W- zr?@sE#0Y^mTj+?K=E!+ml%U_EBVT8sHzX++yG-@P&hTu*<<0@Vc0euvqjUjvG6W+6 zb4tEOKxQ>r_K2DO!my)sEr#@EfH{7aSq^;p{<^DNsH-pvT02VY`2aOR3}O%c2tm5A zSJlYa2RFrt)k_*ei2}MnEQdtG5T~pS$pd(EELM%dmaOcwb$7R({%MV_LaXcbeV56E zSosT+87ggfRU8npN86n@4!mGJ4p&Yi_sAcnz4sz;y)sf z^XlkW)4`~7cf~B$E;Bv|MQK(zqwQSzWwe-`?O+Kn31#7@u>W@c8Dpu5=QMWOi3PoeLztdirb>-ruZ!&Ruzc@DY00Qm}wbtxB5Hn}(2HP7OP zf8V#i;cv5iuQ7}|E8{7j3{fZ1=UV1lBl#2FX7oYyxdV$Y6{te`)0%ce<%2)9KLYqw zUatr2mq^jkN+NBP#WjeMXX0SQBN@dc@n>|Q@3eihn~1iJiJAa0=|9F-qp_v} zTB1RzQ4ywf~HHsC*8e2hB{!k-o+W1$2NYWZ@KtqBpg@rEd0@>xU8^HxxI^UZ+ z+u7NfGc)JR%$a-6oaE;2-FwcQncsZQ?=`=3?yySSq67kxz_5V&61x&5P>DcB2!fJ8 z1Tv`Uh@FW*Mh1eCK;A&+eg2>(BcRTl%d0PW1EF(%oqh6>Up<~z2R5C3oQ&PqWG9_R z)d$u0P6SinMpdvSeXlw;v)Fu1HhUiaoT|^VLHg=@W+CU)fG#}zeo3Eo+e|%VSCcAg zm;TQtoUZzxTA+a(r~)SqgrYW8=P?$Cs|giB#{-!LP=0`}`fn`Azz$G>qN4@rKC*WV zS#=&`vDlnYndxQ0Q$d-eJ?Y?V_Sslqb$Eab8utGx`0#xanAYI5HMzPP5&M|{ih=6( zQFp&OWJ;^cy4p$?L>9m#AVuwIby&1h)=4x4k%v(i&=}a5O|c42k+u^7sQMqP8Lfb6 z3rLmL!}kp}ZAT$w->D|#0aa z@kB7{z{$cW>u1{j*AH6hdaCjpWl-@TvJm1s{xgM7WYSZCnRY@&!HG!ou`sd_CIM-p zCDZml-89$FQ6@l&v?2W;3n2ZQhmt>DX2!LoXF8!O&B#JYp~E}-H?J{ea7GQOHHE^R zvH+O`A->ZAlh=+apLSpnX@QglCnAkVf#kugf_VS^_qQxwy!eVKQ>M%vIdbIm=H}+9 z!-o%_*3i)Ko%Hh&k5oyZzrVlt%$YMCeSLi$r%s*vcV}nk&UNe7ee&w7uO6-WiadPu zGY_OpW2VxSS%74rlxagA#_H#%pMHAMop;{3Wc>K?SC1GmqE#il_`N>~5I}bwJ$m#n zuf6u#`o|xC{BTVpvM`FkiSvt|3+0u?iu5 z&73*2dH3$!?~$lE!Wbl8={iNA{H(35?dJCO_C5wm0Leos%Wo#0SfwFlz9S!14}uX; zl1Od!1s7Zp&2+kX5BjKM$Bw;8G9=3dK#Bq$7EC%WXPL zTLfidaHFdsMQhOmsht{>O>5V#ok=3=PimiSjXSmzs027I`7Hw`G$QpUc_=djN&QDr zAVW1|6FE!M@#DwWj2%1n=0N2ayX8OvOn?I*DU>XbvSJKjZ;~`*(pvQpQw<ngSA~fhe3(Ehz(1RL#R`NIsZpdgF~Z<}6&eaBHA) zi`{Z4fsGqC&R?`>(LWQQOe*dj)H72c*_9bU2~%Bt@95~bck0xs4?7%pY&uX096We% z)wF5TUSxn|O}D7v!3g4+_C9EB|zr>(@~>F-NXPX3T}9_of=3!x@H5K z0h3Lf+rKq6HC+^_%wo43N`M5j`$)|K8`$tEAknQ4)_~;Ab=8L?CGr0vwT>ekjypCT zs0463L}O!Po4|+Ui!N9#X$43&89tyL0VGM19tc!ksk^m){rbuS4?IvI`PI~Ium_GC zH?H#h^Uqgqzx{SA?S<3`QAX1_A5vwuGXcrhJr07g2IT&b1eCQe`}gm!kP^DA%?OX* zv13PN?%ZOz+XToN0v{4kitfir6=ky(K76`oP|Bx>K(1M{rm7uXY|xVAZc7(U=hh|% zBusUZA1NwHtM)x9%GLq0n)0au*`Av;9Gv*cC!ef5_0&@iTCP+`l}rWg7il9v!kp0M zLv|TaRk%Ay3_3t6Q$9rma{m1Jm2KO$Ibmkrym^(aTemuCJFi9)fRq(lu#aCE=(aA9 zIzD8fG~}h1URv3?bElK}h5raYvPhc%NNToaKBP4uY1)U6vI_+y5wKHUNLIpW`@S3J zfK2isMGvgXq#;=#Ss!wrZy~rkpUViF_L#x7Qf&zvtn~#8o_RmIOgN(ojAAC^R zym@nVeH^YpRvDy`Uj&j!?Zz8#tWE^oFcAQgJdxKpf(3|JD)haAg47RuR`l6q1B9qX zV#kl;;cftQ-&^T{#6$o{*@lTm0RtvFNn>C&21OLULFD_5{=WYD>xmZ%4U zZ`t<#`|npTOrKlnfkbIo(xkAHf%-#g(&_(E;|Pt26i-MSE+XFSQV4Y~8o-UENuQ>> z)Ig?9%V44aCwwhTye`nbfu&{~+8f$1O@XlQv{Gq-1Vo5}(@c_Yu<|~i&*jO|0?7w5 zc~5%C=l2=lloCjvDVdN#GwGYJ0H>5dnglW?lUC$?eFZqB12SzOQ)Jk5$L1rzDHV`0 zkkLg?ydTm(NCUa7zbqAyF_6gyB#uTN7XGBGtu#Q!K*k0&DdkUlE6T})2MZMq6@3J)Y!VvyzueMJU^w(R=&7hil4GLO~uZ5IJf;eo`O zGl~jA+fZ|f00FBYvZxhV0aPQw|3#TtXd5b^utsikD?E^{?~68_3``<&p{A)5z=`8R zySQxM7hZTEQIQjZ`MczjORCz=6ht0)RPI3QhcG4=_wy7UNK7D1fT2kd(t+l5KvO}KBD z1h+@Hd~$E8fW&0dI}1;oIAI!(80|lO`t<5k&up1#*~y{(;g|Xt54|JNNDziQmkvm3 z!r@oCdTt}m;aZch^9*0^jyvu!oO=D;k)pymTwh%1Q7M6RZNlO3yLu+13?pbs7lE7a zX9txUNK7uw5PTw=CeNpKq=4GilH?b)s_u>LC|aeLOAjOjgrqZ8Ol)Zw@>9EB%o{to z@FmF)-mB(Y>w91Ruc$K-$eL7aJs1l97XK6oEeb5`WNQpQvIDZ=Mj$N%>8c7whgVx^ ztq5f8CqanN@k?I0Jkr_LjbB9oGS4TfMGaX~J+_K4Tvk)7w4~}eMXKZ3?`;=N=$<&jHi#^l%l;Stl@r3yjLaMK+=EK-#L}$pRyLEKE4kD5gng1Y!7rraVk^(%5YmVH6(7 zyl$pf0Sq&a7nbRg9yW!gKxlZEDJoT8W#7Aa2twh3gl2(hv3V0$+6hcL4t=CS0(`_g zeO{gM;U+N1)^=c1cpxEMTc!Ov6IGUdT-_d93LpWAt$N3@0GBjQm-UjR0Wv=H-rl9; z@-~gq0Ey|aRWE37YU{5h7l8~bg;D`oF9A-dm?Drtuw|OEG8s>)fQ$i-2^vH9^>M#Y z>3~E^1t}I=k4?!o#r1PE9@NMmNmU+kD{oLyu`AlDfx;{)Y5d5FM z8pz-L3lI*#SVSOQ{+@NwQa)ga0}^0TD0vw1IWv%e6Ho#|`d-zHMb(flQ$IyZ`AC4I zPy#@zB>`e)ASsMgO9IM4+OdclGHpeXj_E8~%0~etZ?akKh`)_Zb`p@R54m4wGU{a? zm#LqkrF?2Y&LHnPKBRRZp(#Z^WTAk>`Zyjc>S8Nege?Fv$%nK8q|Aq8HRJ&olb2_k zsHQ<7udU|`p+Fd#m#xdpb9^?>O@f-~5)H}wkg0*Jf?o?_ShG!i$U%PO*|TSNH8eDQ z$L1hoTlFD<{{H^n#>U3)GeA-e3ICDxA=#h?gER6Wb$}dzvahdi>xdB}X4QuXV=rw= z;PmO!`#gJ7 zdh4xEiIyBw@cAh9@Q8Noz2%l$e)P^e@0_TaYf&Pc_Z_Kb%%mY%AZ00_L16CMwd*Hs zZEb5yJ!1tQ{Fh&Tc@HTx-ps+su3OV|k7(`M3Xr^}WUI(|^WAv(@Zn!hoH+5Gg3n7S zhev|fmnTh{v{C~`wip+(vZ^6@O)0vyVIa^MIdWuEcX#)@NJZCw7cN|Q^`=dmjx*X( zW~Pm`BMX^I$n<6~J`E%)ycl%vO>1lGdE2*dUom0AgxgdniQk7Sfn&#xZMyQxD<9dt zdv{+AMiGcIzfozbb)#(2eo47WnP1tkXV0ER7hila5~wiiajPE*oF#Mj@SHhw-ey3Q z!6-}f(9ueR8B-wH{Y6?*X2Mx**+AAjzkA(v*DWQF%)5!Cg@)J9oRqJpB(3u;dDP!~ zAAb1Z%cMqO4+o>@K5PDN!ox_?5IIV z&94YZ_B&n^is~JeraFts=8x6UmTW>@1S#7F4>~J&{sJ<+pfXKj9UaUAAo|RMLqD^t zEBs0es|iiCqB9^xFtPx#f3s#F^vn|q!B6FQ&mZQlQ z!}US(R8q4!iLwY-=3|OLl>INsk4!c2 z5*;K1HOc4W&AJLiD=_k!$mSfeaEgFbfyW=C>YL3AOLeZ0zcY*!0JKNr3P>4XBGW7DgKpn+_tO?io$sg%iHRxa zL3So0Uz3?7b>pj#&V(FT!zy5PQbx{(l=o!ooL^@jX96lRN6fNb7pmj?G065c)^>t8TcMkHxQ&0xbwq)WXDjoBug4kSoHjYGw_Uk&J zDm#Uy0^GoReFG!li&x171#BN2SsX&RQLO*?uk{NwVOhCBd&XUvrVre7I%H+&8D-=z zB7LVV9IelG!;X-xhnIK8cMf+*4{e-&Z(i(P?1mwE97P_1jb9n#c6Dt8>12TEFa!Zi z`$<29#wH#sLd`Dv2n`}0T!@q8*-^wM^rYxKd}d);4B5DzmHhq9qaiV*l&^oqFmuzB zGUnoCS2i<#ZXeu@?NIBb(YyC5hPjS}mnTjgr$6| z$*ey&OjOoLt#9ER>0{tDmROP;@_QhckpmwIXHGp%WAXk`;NV`m1u8IWim2=)R0FHpIFu(;-PzP zN##BM@El`#dp?l1JG6?<9gc*`p?@znsfJq3$NJ$EVAF}K7~eT`jx)1+OQbrg1Y!gI z_sVOgT%nYvhd-yZLr2NwW!?*=F(%ZOv8>S6Wo8>yamgX^j%&IUN0OhN zR<=4RZ<$MT!))g8e~@5&^s{`z$==|*%u@&T&xH_!YpTKL(oxS8iK(=1k^It@=7sdnMz|Y)buBy?7jo8!8vfwO*qj#LkuaYefVzea;DHYV>4CU zqbZ{tyt}*0e7NvFs6A*z^Sc!Gy(6IB@O+Su4`P)^<6jYf15ci@eBkT`+Y^k1a%vy9 zf!wF>V0^<9nE4Dj5_`0aQ9y9ebd_WEAW?qn<`WI_C-c%=+QRc&3ST9SdQGgGfMw)D z8xJDr)*jU4s@ymkF_Uo=M4S#lU>H{0t7vZdb9oU1BuK)1dkAxwgez7h5VlXN zWHOnVMf=yW=}N_~;j%M=%2Rg0*4Fad#Rv26JYR%+*g;L03sZ|z9Q3jqTmVnhnXETvIQJKJNcHfVd|2!VbQMO~S5W>q5n$A5|Qjt(A`V)KXLP~Rob!tura;CAFl zuIfq3t$GjN3AhBX%>FqWJ>s8~^S6xG+ELp2#cH!M1~%%6)|Zv8y=kG|J?UO8HWt)s z%8gJfMQ%rdIb*$BJS35PKEQU1$(JVrEK%* zHJ5#gH34vWp<~Zz^{()Q(<1(%UJdYhQ^<^%^x?PE>mN4j8@SmTG5%cE^_eKj4^_z{ zdfb1#I#=_-{)_pN_uDfB-QfS&v!~*dW7yx z9Y|0i#)8Ya?s#=-x4<7mba@)$Nmli~dvxK&m$O|pN@|T|q_<|q~1NvT~#1?!3eBL9RBJiB2;3n%4Xoke3n5}4@%J!}6umw;wc zVPT=EuDjvnV@u%5pdU_=p}-!lTU;|lHBC2x{!g%JRPWvDl!x7>U_mN2d9(e|90+-vBiyeO2< z4uD~<_26lXe6-WDvTjrzKkd|n2nqn@@(Bue--y7*P&BE+Hm2iNZ-eW`Az#;?6O1h3~;^QDeF%Ej731~ zd}C@+9Tj|+>vm#q_2=n6Lp_pGU|A2I04Tv$CtwgBbq5lB0I5%fMta0k(ev!iv$f|-LmV& z0qg|}hIFu$t=!HTJ10oSZBZUtj(etAhCork?7)3SWBBb5b9e#MRizqAMUt8E?rrp6 z9$w+i{j8p=-PPaUZ*@~8C$XKMLn-XodFrEj{JKyJt8Oe?ehRrmR#Z1BApRZgC)SSo zz9yeZ$!|-Tqat{$L8;QM-HA3!*?mf~duBOBsj6=ftaHhd9Kx}h?MbOn!zlS!_RHB0 z6%DZ4xu5N`8utONZ`-e*)QxCeNTsXihAxHg_gwWi^R}qI?jv7Wu8%lD!XKXb1r#Un zKTQ1iwdblVVUQ>HTTfNC^5(ApUrKy>h zfpOnOVmjXre(!w##q=F_D_eEo+JbA2g(grAr=`o&t5?$L*GFqoe-rqT{DXDWaLWke zk~jCJ{-#1dVY^Fpc$*b3sJ=pp-K9n%wf)X3yyy@2qxrWtbCmu0WB4$;zyQhPx`AKx zEIE65p*)>sh6%~oVAf~3oLmGsmAY{GcNiMqxSuE+WA^nBa;E?xD|4*K=dFpf7gk|LAAZ# z`?yr7BFQDbe&o`3OkYXbA+{DxoXDd?hzIf^Q|hI%aH^!T_8r@kxRoy|94Bor^4k~# z9tdH6pH0u=t}s;e7E$W2r$E%XV2mS%=>EEdL3XUdfhXe^*|fx-18=^nmK`?N;Ju&m zuU2ipCVEDOn&xai!F#-@=9e z9OUz6Bn2WrlP+qS66O%leuZ&ZQ=wR16r1WVfqXxBS0kSP52pijPc=Gwp9+*~$N4l~ zVeHj8AM%(|?Lk2=!d8>eV+98MMdkHvF^N{mQUM_rqmw273VfTpSIvjiHAhC>L)ZtU zT4QMa2-B1_~Z5&8c`4Y=9Gw&sV%%8Jz+hl$eJT*@S0KcXmMQb*drT+b*v^crorE zm~x#yQMUf(HU|c7X*ZQ3?%p#>wXMtXZ&9t;&S%B$sC;f497v*({3OCoZpYe&z+e9c6nn`2)iRBSfxCMZNyvnU8{I zV~T1~j5t@#$%wYL42#AXHu7}Li0B>AE_En}PD$q4R*ENlF=Kfiq;=3R0rZcbz`c7S zHq5}iO+6Ii>ij_m#@Cz91AH9*Ri<-BxW`oU1~^rauMeW&g_lh@K)tMmA%PQIbPJf6 zlT9Y+Sa#4xJP*nLqqETOw;VS$Tnc3i6V@{TBDNQl0GxTUVrk4K-vYS!{>x=+${M(FymmfIUFR?2+8kqMRlmD zZMUtY^{{$mV}r34X4!Uda{QO)ESdq+NM|0;t_Xg0hM2B@q2pA;AHeBv1r){7sD1=S zoabTIz0xf~LJ=yWNv@-b3I$S8z;tXd9{$Ui4nYIidJ`Xv*USC;zQc}Z?jW0)z}0Be z{w`5ZE8w_J0Jt!wMk9lw!U@CpVmF8cii($N2PQRANKviOB4}K~#YABsaDwTF<9D13 zby)G-jn%jd%<=xqz3Sdx3-LcfFwjrhk^Wn)QITZKSV26<<)1X~plk!#T5&;`qEO?k zBM;_CKLWIn>^gVJ`t?R$vM}XyXV85m-~MW&&2`)}5tt%;PO2;9^83hrY7NNHH*5vk zCKHQ7EcN6J+a|=lKa%a`?F_i;n|_lLoEhg!un3hg90z8PzYCbfaK?Qh7&~&Y+-w{E{RrOc}hVJu{R@ zG2CJLmlr~Z0Ln2}eHi#VxPRE{Cj^`w9~iLVC`K`S*vVmJNI_1Cxqc)^F&w}8LPd^( z?8fU5o@GoiTsrQ0`V{*ak(p+d1X2znf(s1|gJ%ek7b;e)cxc&pU_{`G2W4zVJe$^L zXlFc=hr}lQv4hHEmTm-`x~=O;@SWPbSLT~S?lC)0*F0Wq3UTdBVDa48*if~M2Fi&y zl?6x=IbmN%vFs6(las#EykD(N-RS^$%`g5&u+;ODSh>7*UJSx7DnhOJ^&WzV(DLFu$Qb%W)-68E(b~Gcv9xr126sdRa>9Lm zeQg2*ze)h{4tlHSmb`k8&6!O4DP#!brb(d0IFL=@0Of#-*DE;@+oSo&qM{;CQ)qf8 zk1D>-T39*Y@|UQLa{6-)`>N=@{i%MtrVw5kwOp|8b)yj+^kVWZ_T~V#xr%m;u#8sGCQ1mvv-O+$^&u& zG%_NTq^QG9K!qyS!Xu-x)K;gB^0_(TC!1${+EKs8f%{Kz{^VajPDY3_E*;T8?B}6T z6;N3ax+_p56#bY&89#VW6n|uBNuWmn5?BP5XovSl86x1qzv%R?c{pIz)qqqSzY*Vj9{;w|VbE%~-x3t5#s#o7s_>#btMUBxk8#~94$(8P8?jvN* zJsX$Cyi4<9zK&tKi5*5iEyhc&|QBE#zSv&@fO|x?j?YN#dT#kNjK+ zywPi{z>Pxj{X5i=;+t%ZG6o+wg(O^AcvVA&=i5;X;T(XKrykiQ@%I_kTF4fPK=1?B08{?r+ra8evM1mpknY;QVZqd&7= zEqGgIE|}w|=6m*;de4yp`4NqiGz#IeDRDtPD!r-k6+(X&9{bR{<@feKDlCw^-*(c{0jy=t_EV?vEh4b|R|g%vnH1 z@|;9zlx~0Y7|Le3*|c47bAt8$NjcE!7ji0=be3jl&DY~L5boPh#A9KnBl%1)MwT**>$YK`<0lJB^xHZ z;MCqGc|C-oMw9EL!4Ui{v5d-iMOrXKE7O*vH%^iV2_1uU)j-E>t+Q9wEgsKVoGIs} z!C;G&Dt1Mu=swB;HVDO6Ig`wq7+<4FzWMdYEk8hzaKa7nUA4RipNT;bkFU+YW=(x8 z9~2COrcz~XV7r%G3TdZ4VN~wpkDv;h8ADBUT?l7_q9q|xq@xTt4t|l_i+X$QPp#Q^ z6Y^2jZL7>XTM|aA4bNk_?TkqMOgL1PIF;QfaN4yxDef?JiFap2N0=?!O$oB^ufPz;piZ=^Pr^ z--$!Q4L4t%f0Cxl%ZpW`NT-t-z*YNsy7(D=(<>JUEcWy%bR!7~1iPUNig1x4aQII| nD!EIH3Mi*EX4S66ey%VFxRirb;kV = { + smooth: { + needleMinSmoothing: 0.16, + needleMaxSmoothing: 0.4, + displayDeadzoneDeg: 0.75, + }, + balanced: { + needleMinSmoothing: 0.22, + needleMaxSmoothing: 0.52, + displayDeadzoneDeg: 0.45, + }, + responsive: { + needleMinSmoothing: 0.3, + needleMaxSmoothing: 0.68, + displayDeadzoneDeg: 0.2, + }, +} const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2 const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0 const SMART_HEADING_MIN_DISTANCE_METERS = 12 @@ -88,6 +108,7 @@ export interface MapEngineStageRect { } export interface MapEngineViewState { + animationLevel: AnimationLevel buildVersion: string renderMode: string projectionMode: string @@ -110,7 +131,11 @@ export interface MapEngineViewState { accelerometerText: string gyroscopeText: string deviceMotionText: string + compassSourceText: string + compassTuningProfile: CompassTuningProfile + compassTuningProfileText: string compassDeclinationText: string + northReferenceMode: NorthReferenceMode northReferenceButtonText: string autoRotateSourceText: string autoRotateCalibrationText: string @@ -199,6 +224,8 @@ export interface MapEngineViewState { contentCardTitle: string contentCardBody: string punchButtonFxClass: string + panelProgressFxClass: string + panelDistanceFxClass: string punchFeedbackFxClass: string contentCardFxClass: string mapPulseVisible: boolean @@ -228,6 +255,7 @@ export interface MapEngineGameInfoSnapshot { } const VIEW_SYNC_KEYS: Array = [ + 'animationLevel', 'buildVersion', 'renderMode', 'projectionMode', @@ -252,7 +280,11 @@ const VIEW_SYNC_KEYS: Array = [ 'accelerometerText', 'gyroscopeText', 'deviceMotionText', + 'compassSourceText', + 'compassTuningProfile', + 'compassTuningProfileText', 'compassDeclinationText', + 'northReferenceMode', 'northReferenceButtonText', 'autoRotateSourceText', 'autoRotateCalibrationText', @@ -330,6 +362,8 @@ const VIEW_SYNC_KEYS: Array = [ 'contentCardTitle', 'contentCardBody', 'punchButtonFxClass', + 'panelProgressFxClass', + 'panelDistanceFxClass', 'punchFeedbackFxClass', 'contentCardFxClass', 'mapPulseVisible', @@ -342,6 +376,38 @@ const VIEW_SYNC_KEYS: Array = [ 'osmReferenceText', ] +const INTERACTION_DEFERRED_VIEW_KEYS = new Set([ + 'rotationText', + 'sensorHeadingText', + 'deviceHeadingText', + 'devicePoseText', + 'headingConfidenceText', + 'accelerometerText', + 'gyroscopeText', + 'deviceMotionText', + 'compassSourceText', + 'compassTuningProfile', + 'compassTuningProfileText', + 'compassDeclinationText', + 'autoRotateSourceText', + 'autoRotateCalibrationText', + 'northReferenceText', + 'centerText', + 'gpsCoordText', + 'visibleTileCount', + 'readyTileCount', + 'memoryTileCount', + 'diskTileCount', + 'memoryHitCount', + 'diskHitCount', + 'networkFetchCount', + 'cacheHitRateText', + 'heartRateDiscoveredDevices', + 'mockCoordText', + 'mockSpeedText', + 'mockHeartRateText', +]) + function buildCenterText(zoom: number, x: number, y: number): string { return `z${zoom} / x${x} / y${y}` } @@ -387,18 +453,23 @@ function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: numb return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor) } -function getCompassNeedleSmoothingFactor(currentDeg: number, targetDeg: number): number { +function getCompassNeedleSmoothingFactor( + currentDeg: number, + targetDeg: number, + profile: CompassTuningProfile, +): number { + const preset = COMPASS_TUNING_PRESETS[profile] const deltaDeg = Math.abs(normalizeAngleDeltaDeg(targetDeg - currentDeg)) if (deltaDeg <= 4) { - return COMPASS_NEEDLE_MIN_SMOOTHING + return preset.needleMinSmoothing } if (deltaDeg >= 36) { - return COMPASS_NEEDLE_MAX_SMOOTHING + return preset.needleMaxSmoothing } const progress = (deltaDeg - 4) / (36 - 4) - return COMPASS_NEEDLE_MIN_SMOOTHING - + (COMPASS_NEEDLE_MAX_SMOOTHING - COMPASS_NEEDLE_MIN_SMOOTHING) * progress + return preset.needleMinSmoothing + + (preset.needleMaxSmoothing - preset.needleMinSmoothing) * progress } function getMovementHeadingSmoothingFactor(speedKmh: number | null): number { @@ -434,7 +505,7 @@ function formatRotationText(rotationDeg: number): string { } function normalizeDegreeDisplayText(text: string): string { - return text.replace(/[°掳•]/g, '˚') + return text.replace(/[掳•˚]/g, '°') } function formatHeadingText(headingDeg: number | null): string { @@ -442,7 +513,7 @@ function formatHeadingText(headingDeg: number | null): string { return '--' } - return `${Math.round(normalizeRotationDeg(headingDeg))}˚` + return `${Math.round(normalizeRotationDeg(headingDeg))}°` } function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string { @@ -494,9 +565,9 @@ function formatDeviceMotionText(motion: { alpha: number | null; beta: number | n return '--' } - const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha * 180 / Math.PI)) - const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta * 180 / Math.PI) - const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma * 180 / Math.PI) + const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha)) + const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta) + const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma) return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}` } @@ -620,6 +691,26 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string { return '' } +function formatCompassSourceText(source: 'compass' | 'motion' | null): string { + if (source === 'compass') { + return '罗盘' + } + if (source === 'motion') { + return '设备方向兜底' + } + return '无数据' +} + +function formatCompassTuningProfileText(profile: CompassTuningProfile): string { + if (profile === 'smooth') { + return '顺滑' + } + if (profile === 'responsive') { + return '跟手' + } + return '平衡' +} + function formatNorthReferenceButtonText(mode: NorthReferenceMode): string { return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北' } @@ -702,6 +793,7 @@ function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number { export class MapEngine { buildVersion: string + animationLevel: AnimationLevel renderer: WebGLMapRenderer accelerometerController: AccelerometerController compassController: CompassHeadingController @@ -742,6 +834,8 @@ export class MapEngine { sensorHeadingDeg: number | null smoothedSensorHeadingDeg: number | null compassDisplayHeadingDeg: number | null + compassSource: 'compass' | 'motion' | null + compassTuningProfile: CompassTuningProfile smoothedMovementHeadingDeg: number | null autoRotateHeadingDeg: number | null courseHeadingDeg: number | null @@ -789,6 +883,8 @@ export class MapEngine { constructor(buildVersion: string, callbacks: MapEngineCallbacks) { this.buildVersion = buildVersion + this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync()) + this.compassTuningProfile = 'balanced' this.onData = callbacks.onData this.accelerometerErrorText = null this.renderer = new WebGLMapRenderer( @@ -812,7 +908,7 @@ export class MapEngine { z, }) if (this.diagnosticUiEnabled) { - this.setState(this.getTelemetrySensorViewPatch(), true) + this.setState(this.getTelemetrySensorViewPatch()) } }, onError: (message) => { @@ -821,7 +917,7 @@ export class MapEngine { this.setState({ ...this.getTelemetrySensorViewPatch(), statusText: `加速度计启动失败 (${this.buildVersion})`, - }, true) + }) } }, }) @@ -833,6 +929,7 @@ export class MapEngine { this.handleCompassError(message) }, }) + this.compassController.setTuningProfile(this.compassTuningProfile) this.gyroscopeController = new GyroscopeController({ onSample: (x, y, z) => { this.telemetryRuntime.dispatch({ @@ -843,12 +940,12 @@ export class MapEngine { z, }) if (this.diagnosticUiEnabled) { - this.setState(this.getTelemetrySensorViewPatch(), true) + this.setState(this.getTelemetrySensorViewPatch()) } }, onError: () => { if (this.diagnosticUiEnabled) { - this.setState(this.getTelemetrySensorViewPatch(), true) + this.setState(this.getTelemetrySensorViewPatch()) } }, }) @@ -865,16 +962,12 @@ export class MapEngine { this.setState({ ...this.getTelemetrySensorViewPatch(), autoRotateSourceText: this.getAutoRotateSourceText(), - }, true) - } - - if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { - this.scheduleAutoRotate() + }) } }, onError: () => { if (this.diagnosticUiEnabled) { - this.setState(this.getTelemetrySensorViewPatch(), true) + this.setState(this.getTelemetrySensorViewPatch()) } }, }) @@ -899,7 +992,7 @@ export class MapEngine { }, onDebugStateChange: () => { if (this.diagnosticUiEnabled) { - this.setState(this.getLocationControllerViewPatch(), true) + this.setState(this.getLocationControllerViewPatch()) } }, }) @@ -963,12 +1056,12 @@ export class MapEngine { heartRateDiscoveredDevices: this.formatHeartRateDevices(devices), heartRateScanText: this.getHeartRateScanText(), ...this.getHeartRateControllerViewPatch(), - }, true) + }) } }, onDebugStateChange: () => { if (this.diagnosticUiEnabled) { - this.setState(this.getHeartRateControllerViewPatch(), true) + this.setState(this.getHeartRateControllerViewPatch()) } }, }) @@ -982,6 +1075,12 @@ export class MapEngine { setPunchButtonFxClass: (className) => { this.setPunchButtonFxClass(className) }, + setHudProgressFxClass: (className) => { + this.setHudProgressFxClass(className) + }, + setHudDistanceFxClass: (className) => { + this.setHudDistanceFxClass(className) + }, showMapPulse: (controlId, motionClass) => { this.showMapPulse(controlId, motionClass) }, @@ -994,6 +1093,7 @@ export class MapEngine { } }, }) + this.feedbackDirector.setAnimationLevel(this.animationLevel) this.minZoom = MIN_ZOOM this.maxZoom = MAX_ZOOM this.defaultZoom = DEFAULT_ZOOM @@ -1032,6 +1132,7 @@ export class MapEngine { this.sessionTimerInterval = 0 this.hasGpsCenteredOnce = false this.state = { + animationLevel: this.animationLevel, buildVersion: this.buildVersion, renderMode: RENDER_MODE, projectionMode: PROJECTION_MODE, @@ -1051,10 +1152,14 @@ export class MapEngine { deviceHeadingText: '--', devicePoseText: '竖持', headingConfidenceText: '低', - accelerometerText: '未启用', + accelerometerText: '未启用', gyroscopeText: '--', deviceMotionText: '--', + compassSourceText: '无数据', + compassTuningProfile: this.compassTuningProfile, + compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile), compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE), + northReferenceMode: DEFAULT_NORTH_REFERENCE_MODE, northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE), autoRotateSourceText: formatAutoRotateSourceText('smart', false), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)), @@ -1137,6 +1242,8 @@ export class MapEngine { contentCardTitle: '', contentCardBody: '', punchButtonFxClass: '', + panelProgressFxClass: '', + panelDistanceFxClass: '', punchFeedbackFxClass: '', contentCardFxClass: '', mapPulseVisible: false, @@ -1177,6 +1284,8 @@ export class MapEngine { this.sensorHeadingDeg = null this.smoothedSensorHeadingDeg = null this.compassDisplayHeadingDeg = null + this.compassSource = null + this.compassTuningProfile = 'balanced' this.smoothedMovementHeadingDeg = null this.autoRotateHeadingDeg = null this.courseHeadingDeg = null @@ -1241,6 +1350,7 @@ export class MapEngine { { label: '配置版本', value: this.configVersion || '--' }, { label: 'Schema版本', value: this.configSchemaVersion || '--' }, { label: '活动ID', value: this.configAppId || '--' }, + { label: '动画等级', value: formatAnimationLevelText(this.state.animationLevel) }, { label: '地图', value: this.state.mapName || '--' }, { label: '模式', value: this.getGameModeText() }, { label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) }, @@ -1417,20 +1527,23 @@ export class MapEngine { getTelemetrySensorViewPatch(): Partial { const telemetryState = this.telemetryRuntime.state - return { - deviceHeadingText: formatHeadingText( - telemetryState.deviceHeadingDeg === null - ? null - : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg), - ), - devicePoseText: formatDevicePoseText(telemetryState.devicePose), - headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence), - accelerometerText: telemetryState.accelerometer - ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}` - : '未启用', - gyroscopeText: formatGyroscopeText(telemetryState.gyroscope), - deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion), - } + return { + deviceHeadingText: formatHeadingText( + telemetryState.deviceHeadingDeg === null + ? null + : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg), + ), + devicePoseText: formatDevicePoseText(telemetryState.devicePose), + headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence), + accelerometerText: telemetryState.accelerometer + ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}` + : '未启用', + gyroscopeText: formatGyroscopeText(telemetryState.gyroscope), + deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion), + compassSourceText: formatCompassSourceText(this.compassSource), + compassTuningProfile: this.compassTuningProfile, + compassTuningProfileText: formatCompassTuningProfileText(this.compassTuningProfile), + } } getGameModeText(): string { @@ -1589,6 +1702,8 @@ export class MapEngine { stageFxVisible: false, stageFxClass: '', punchButtonFxClass: '', + panelProgressFxClass: '', + panelDistanceFxClass: '', }, true) } @@ -1675,6 +1790,18 @@ export class MapEngine { }, true) } + setHudProgressFxClass(className: string): void { + this.setState({ + panelProgressFxClass: className, + }, true) + } + + setHudDistanceFxClass(className: string): void { + this.setState({ + panelDistanceFxClass: className, + }, true) + } + showMapPulse(controlId: string, motionClass = ''): void { const screenPoint = this.getControlScreenPoint(controlId) if (!screenPoint) { @@ -1761,6 +1888,9 @@ export class MapEngine { applyGameEffects(effects: GameEffect[]): string | null { this.feedbackDirector.handleEffects(effects) if (effects.some((effect) => effect.type === 'session_finished')) { + if (this.locationController.listening) { + this.locationController.stop() + } this.setState({ gpsTracking: false, gpsTrackingText: '测试结束,定位已停止', @@ -1845,12 +1975,17 @@ export class MapEngine { handleForceExitGame(): void { this.feedbackDirector.reset() + if (this.locationController.listening) { + this.locationController.stop() + } if (!this.courseData) { this.clearGameRuntime() this.resetTransientGameUiState() this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.setState({ + gpsTracking: false, + gpsTrackingText: '已退出对局,定位已停止', ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), }, true) this.syncRenderer() @@ -1861,6 +1996,8 @@ export class MapEngine { this.resetTransientGameUiState() this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.setState({ + gpsTracking: false, + gpsTrackingText: '已退出对局,定位已停止', ...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`), }, true) this.syncRenderer() @@ -1946,7 +2083,7 @@ export class MapEngine { gpsLockEnabled: this.gpsLockEnabled, gpsLockAvailable: gpsInsideMap, ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)), - }, true) + }) this.syncRenderer() } @@ -2100,7 +2237,7 @@ export class MapEngine { this.setState({ heartRateDeviceText: this.heartRateController.currentDeviceName || '--', heartRateScanText: this.getHeartRateScanText(), - }, true) + }) } handleDebugHeartRateTone(tone: HeartRateTone): void { @@ -2112,7 +2249,7 @@ export class MapEngine { }) this.setState({ heartRateStatusText: `调试心率: ${sampleBpm} bpm / ${tone.toUpperCase()}`, - }, true) + }) this.syncSessionTimerText() } @@ -2128,7 +2265,7 @@ export class MapEngine { : (this.heartRateController.sourceMode === 'mock' ? '模拟心率源未连接' : '心率带未连接'), heartRateScanText: this.getHeartRateScanText(), ...this.getHeartRateControllerViewPatch(), - }, true) + }) this.syncSessionTimerText() } @@ -2250,7 +2387,7 @@ export class MapEngine { configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`, projectionMode: config.projectionModeText, tileSource: config.tileSource, - sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)), + sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode), @@ -2308,7 +2445,7 @@ export class MapEngine { this.pinchAnchorWorldY = anchorWorld.y this.setPreviewState(this.pinchStartScale, origin.x, origin.y) this.syncRenderer() - this.compassController.start() + this.compassController.start() return } @@ -2567,7 +2704,7 @@ export class MapEngine { () => { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() this.scheduleAutoRotate() }, ) @@ -2601,7 +2738,7 @@ export class MapEngine { () => { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() }, ) } @@ -2638,7 +2775,7 @@ export class MapEngine { () => { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() }, ) } @@ -2673,6 +2810,38 @@ export class MapEngine { this.cycleNorthReferenceMode() } + handleSetNorthReferenceMode(mode: NorthReferenceMode): void { + this.setNorthReferenceMode(mode) + } + + handleSetAnimationLevel(level: AnimationLevel): void { + if (this.animationLevel === level) { + return + } + + this.animationLevel = level + this.feedbackDirector.setAnimationLevel(level) + this.setState({ + animationLevel: level, + statusText: `动画性能已切换为${formatAnimationLevelText(level)} (${this.buildVersion})`, + }) + this.syncRenderer() + } + + handleSetCompassTuningProfile(profile: CompassTuningProfile): void { + if (this.compassTuningProfile === profile) { + return + } + + this.compassTuningProfile = profile + this.compassController.setTuningProfile(profile) + this.setState({ + compassTuningProfile: profile, + compassTuningProfileText: formatCompassTuningProfileText(profile), + statusText: `指北针响应已切换为${formatCompassTuningProfileText(profile)} (${this.buildVersion})`, + }, true) + } + handleAutoRotateCalibrate(): void { if (this.state.orientationMode !== 'heading-up') { this.setState({ @@ -2761,30 +2930,40 @@ export class MapEngine { } } - handleCompassHeading(headingDeg: number): void { + applyHeadingSample(headingDeg: number, source: 'compass' | 'motion'): void { + this.compassSource = source this.sensorHeadingDeg = normalizeRotationDeg(headingDeg) this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null ? this.sensorHeadingDeg : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING) const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg) - this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null - ? compassHeadingDeg - : interpolateAngleDeg( - this.compassDisplayHeadingDeg, - compassHeadingDeg, - getCompassNeedleSmoothingFactor(this.compassDisplayHeadingDeg, compassHeadingDeg), - ) + if (this.compassDisplayHeadingDeg === null) { + this.compassDisplayHeadingDeg = compassHeadingDeg + } else { + const displayDeltaDeg = Math.abs(normalizeAngleDeltaDeg(compassHeadingDeg - this.compassDisplayHeadingDeg)) + if (displayDeltaDeg >= COMPASS_TUNING_PRESETS[this.compassTuningProfile].displayDeadzoneDeg) { + this.compassDisplayHeadingDeg = interpolateAngleDeg( + this.compassDisplayHeadingDeg, + compassHeadingDeg, + getCompassNeedleSmoothingFactor( + this.compassDisplayHeadingDeg, + compassHeadingDeg, + this.compassTuningProfile, + ), + ) + } + } this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg() this.setState({ compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg), + sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), + compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), ...(this.diagnosticUiEnabled ? { - sensorHeadingText: formatHeadingText(compassHeadingDeg), ...this.getTelemetrySensorViewPatch(), - compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), autoRotateSourceText: this.getAutoRotateSourceText(), northReferenceText: formatNorthReferenceText(this.northReferenceMode), @@ -2801,18 +2980,31 @@ export class MapEngine { } } + handleCompassHeading(headingDeg: number): void { + this.applyHeadingSample(headingDeg, 'compass') + } + handleCompassError(message: string): void { this.clearAutoRotateTimer() this.targetAutoRotationDeg = null this.autoRotateCalibrationPending = false + this.compassSource = null this.setState({ + compassSourceText: formatCompassSourceText(null), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), statusText: `${message} (${this.buildVersion})`, }, true) } cycleNorthReferenceMode(): void { - const nextMode = getNextNorthReferenceMode(this.northReferenceMode) + this.setNorthReferenceMode(getNextNorthReferenceMode(this.northReferenceMode)) + } + + setNorthReferenceMode(nextMode: NorthReferenceMode): void { + if (nextMode === this.northReferenceMode) { + return + } + const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode) const compassHeadingDeg = this.smoothedSensorHeadingDeg === null ? null @@ -2831,9 +3023,10 @@ export class MapEngine { rotationDeg: MAP_NORTH_OFFSET_DEG, rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG), northReferenceText: formatNorthReferenceText(nextMode), - sensorHeadingText: formatHeadingText(compassHeadingDeg), + sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), ...this.getTelemetrySensorViewPatch(), compassDeclinationText: formatCompassDeclinationText(nextMode), + northReferenceMode: nextMode, northReferenceButtonText: formatNorthReferenceButtonText(nextMode), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), @@ -2850,9 +3043,10 @@ export class MapEngine { this.setState({ northReferenceText: formatNorthReferenceText(nextMode), - sensorHeadingText: formatHeadingText(compassHeadingDeg), + sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), ...this.getTelemetrySensorViewPatch(), compassDeclinationText: formatCompassDeclinationText(nextMode), + northReferenceMode: nextMode, northReferenceButtonText: formatNorthReferenceButtonText(nextMode), compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.compassDisplayHeadingDeg), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg), @@ -3167,6 +3361,7 @@ export class MapEngine { buildScene() { const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) + const readyControlSequences = this.resolveReadyControlSequences() return { tileSource: this.state.tileSource, osmTileSource: OSM_TILE_SOURCE, @@ -3183,6 +3378,7 @@ export class MapEngine { translateX: this.state.tileTranslateX, translateY: this.state.tileTranslateY, rotationRad: this.getRotationRad(this.state.rotationDeg), + animationLevel: this.state.animationLevel, previewScale: this.previewScale || 1, previewOriginX: this.previewOriginX || this.state.stageWidth / 2, previewOriginY: this.previewOriginY || this.state.stageHeight / 2, @@ -3199,6 +3395,7 @@ export class MapEngine { focusedControlId: this.gamePresentation.map.focusedControlId, focusedControlSequences: this.gamePresentation.map.focusedControlSequences, activeControlSequences: this.gamePresentation.map.activeControlSequences, + readyControlSequences, activeStart: this.gamePresentation.map.activeStart, completedStart: this.gamePresentation.map.completedStart, activeFinish: this.gamePresentation.map.activeFinish, @@ -3215,6 +3412,21 @@ export class MapEngine { } } + resolveReadyControlSequences(): number[] { + const punchableControlId = this.gamePresentation.hud.punchableControlId + const definition = this.gameRuntime.definition + if (!punchableControlId || !definition) { + return [] + } + + const control = definition.controls.find((item) => item.id === punchableControlId) + if (!control || control.sequence === null) { + return [] + } + + return [control.sequence] + } + syncRenderer(): void { if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) { return @@ -3374,8 +3586,32 @@ export class MapEngine { } const patch = this.pendingViewPatch - this.pendingViewPatch = {} - this.onData(patch) + const shouldDeferForInteraction = this.gestureMode !== 'idle' || !!this.inertiaTimer || !!this.previewResetTimer + const nextPendingPatch = {} as Partial + const outputPatch = {} as Partial + + for (const [key, value] of Object.entries(patch) as Array<[keyof MapEngineViewState, MapEngineViewState[keyof MapEngineViewState]]>) { + if (shouldDeferForInteraction && INTERACTION_DEFERRED_VIEW_KEYS.has(key)) { + ;(nextPendingPatch as Record)[key] = value + continue + } + ;(outputPatch as Record)[key] = value + } + + this.pendingViewPatch = nextPendingPatch + + if (Object.keys(this.pendingViewPatch).length && !this.viewSyncTimer) { + this.viewSyncTimer = setTimeout(() => { + this.viewSyncTimer = 0 + this.flushViewPatch() + }, UI_SYNC_INTERVAL_MS) as unknown as number + } + + if (!Object.keys(outputPatch).length) { + return + } + + this.onData(outputPatch) } getTouchDistance(touches: TouchPoint[]): number { @@ -3431,7 +3667,7 @@ export class MapEngine { if (Math.abs(startScale - 1) < 0.01) { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() this.scheduleAutoRotate() return } @@ -3443,12 +3679,12 @@ export class MapEngine { const nextScale = startScale + (1 - startScale) * eased this.setPreviewState(nextScale, originX, originY) this.syncRenderer() - this.compassController.start() + this.compassController.start() if (progress >= 1) { this.resetPreviewState() this.syncRenderer() - this.compassController.start() + this.compassController.start() this.previewResetTimer = 0 this.scheduleAutoRotate() return @@ -3467,7 +3703,7 @@ export class MapEngine { tileTranslateY: translateY, }) this.syncRenderer() - this.compassController.start() + this.compassController.start() return } @@ -3530,7 +3766,7 @@ export class MapEngine { () => { this.setPreviewState(residualScale, stageX, stageY) this.syncRenderer() - this.compassController.start() + this.compassController.start() this.animatePreviewToRest() }, ) @@ -3557,7 +3793,7 @@ export class MapEngine { () => { this.setPreviewState(residualScale, stageX, stageY) this.syncRenderer() - this.compassController.start() + this.compassController.start() this.animatePreviewToRest() }, ) diff --git a/miniprogram/engine/renderer/courseLabelRenderer.ts b/miniprogram/engine/renderer/courseLabelRenderer.ts index e883949..14635e2 100644 --- a/miniprogram/engine/renderer/courseLabelRenderer.ts +++ b/miniprogram/engine/renderer/courseLabelRenderer.ts @@ -9,11 +9,14 @@ const SCORE_LABEL_FONT_SIZE_RATIO = 0.7 const SCORE_LABEL_OFFSET_Y_RATIO = 0.06 const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)' const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)' +const READY_LABEL_COLOR = 'rgba(98, 255, 214, 0.98)' const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)' const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)' const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)' +const SKIPPED_LABEL_COLOR = 'rgba(152, 156, 162, 0.88)' const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)' const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)' +const SCORE_SKIPPED_LABEL_COLOR = 'rgba(176, 182, 188, 0.9)' export class CourseLabelRenderer { courseLayer: CourseLayer @@ -107,6 +110,10 @@ export class CourseLabelRenderer { return FOCUSED_LABEL_COLOR } + if (scene.readyControlSequences.includes(sequence)) { + return READY_LABEL_COLOR + } + if (scene.activeControlSequences.includes(sequence)) { return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_LABEL_COLOR : ACTIVE_LABEL_COLOR } @@ -116,7 +123,7 @@ export class CourseLabelRenderer { } if (scene.skippedControlSequences.includes(sequence)) { - return COMPLETED_LABEL_COLOR + return SKIPPED_LABEL_COLOR } return DEFAULT_LABEL_COLOR @@ -127,12 +134,16 @@ export class CourseLabelRenderer { return FOCUSED_LABEL_COLOR } + if (scene.readyControlSequences.includes(sequence)) { + return READY_LABEL_COLOR + } + if (scene.completedControlSequences.includes(sequence)) { return SCORE_COMPLETED_LABEL_COLOR } if (scene.skippedControlSequences.includes(sequence)) { - return SCORE_COMPLETED_LABEL_COLOR + return SCORE_SKIPPED_LABEL_COLOR } return SCORE_LABEL_COLOR diff --git a/miniprogram/engine/renderer/mapRenderer.ts b/miniprogram/engine/renderer/mapRenderer.ts index 3bb4a89..1eaad93 100644 --- a/miniprogram/engine/renderer/mapRenderer.ts +++ b/miniprogram/engine/renderer/mapRenderer.ts @@ -3,6 +3,7 @@ import { type TileStoreStats } from '../tile/tileStore' import { type LonLatPoint, type MapCalibration } from '../../utils/projection' import { type TileZoomBounds } from '../../utils/remoteMapConfig' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' +import { type AnimationLevel } from '../../utils/animationLevel' export interface MapScene { tileSource: string @@ -20,6 +21,7 @@ export interface MapScene { translateX: number translateY: number rotationRad: number + animationLevel: AnimationLevel previewScale: number previewOriginX: number previewOriginY: number @@ -36,6 +38,7 @@ export interface MapScene { focusedControlId: string | null focusedControlSequences: number[] activeControlSequences: number[] + readyControlSequences: number[] activeStart: boolean completedStart: boolean activeFinish: boolean diff --git a/miniprogram/engine/renderer/webglMapRenderer.ts b/miniprogram/engine/renderer/webglMapRenderer.ts index 51d28c0..d37b67f 100644 --- a/miniprogram/engine/renderer/webglMapRenderer.ts +++ b/miniprogram/engine/renderer/webglMapRenderer.ts @@ -135,12 +135,16 @@ export class WebGLMapRenderer implements MapRenderer { this.scheduleRender() } - this.animationTimer = setTimeout(tick, ANIMATION_FRAME_MS) as unknown as number + this.animationTimer = setTimeout(tick, this.getAnimationFrameMs()) as unknown as number } tick() } + getAnimationFrameMs(): number { + return this.scene && this.scene.animationLevel === 'lite' ? 48 : ANIMATION_FRAME_MS + } + scheduleRender(): void { if (this.renderTimer || !this.scene || this.destroyed) { return diff --git a/miniprogram/engine/renderer/webglVectorRenderer.ts b/miniprogram/engine/renderer/webglVectorRenderer.ts index b3ceabb..c5bf361 100644 --- a/miniprogram/engine/renderer/webglVectorRenderer.ts +++ b/miniprogram/engine/renderer/webglVectorRenderer.ts @@ -7,11 +7,17 @@ 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 SKIPPED_ROUTE_COLOR: [number, number, number, number] = [0.38, 0.4, 0.44, 0.72] const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1] +const READY_CONTROL_COLOR: [number, number, number, number] = [0.38, 1, 0.92, 1] const MULTI_ACTIVE_CONTROL_COLOR: [number, number, number, number] = [1, 0.8, 0.2, 0.98] const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1] const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86] const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88] +const READY_PULSE_COLOR: [number, number, number, number] = [0.44, 1, 0.92, 0.98] +const COMPLETED_SETTLE_COLOR: [number, number, number, number] = [0.86, 0.9, 0.94, 0.24] +const SKIPPED_SETTLE_COLOR: [number, number, number, number] = [0.72, 0.76, 0.82, 0.18] +const SKIPPED_SLASH_COLOR: [number, number, number, number] = [0.78, 0.82, 0.88, 0.9] 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 @@ -196,6 +202,18 @@ export class WebGLVectorRenderer { gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2) } + isLite(scene: MapScene): boolean { + return scene.animationLevel === 'lite' + } + + getRingSegments(scene: MapScene): number { + return this.isLite(scene) ? 24 : 36 + } + + getCircleSegments(scene: MapScene): number { + return this.isLite(scene) ? 14 : 20 + } + getPixelsPerMeter(scene: MapScene): number { const camera: CameraState = { centerWorldX: scene.exactCenterWorldX, @@ -249,6 +267,18 @@ export class WebGLVectorRenderer { if (scene.activeStart) { this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame) } + if (scene.completedStart) { + this.pushRing( + positions, + colors, + start.point.x, + start.point.y, + this.getMetric(scene, controlRadiusMeters * 1.16), + this.getMetric(scene, controlRadiusMeters * 1.02), + COMPLETED_SETTLE_COLOR, + scene, + ) + } this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene) } if (!scene.revealFullCourse) { @@ -261,10 +291,29 @@ export class WebGLVectorRenderer { this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame) } else { this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR) - this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52]) + if (!this.isLite(scene)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52]) + } } } + if (scene.readyControlSequences.includes(control.sequence)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, READY_PULSE_COLOR) + if (!this.isLite(scene)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.22, scene, pulseFrame + 11, [0.92, 1, 1, 0.42]) + } + this.pushRing( + positions, + colors, + control.point.x, + control.point.y, + this.getMetric(scene, controlRadiusMeters * 1.16), + this.getMetric(scene, controlRadiusMeters * 1.02), + READY_CONTROL_COLOR, + scene, + ) + } + this.pushRing( positions, colors, @@ -278,7 +327,9 @@ export class WebGLVectorRenderer { if (scene.focusedControlSequences.includes(control.sequence)) { this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.02, scene, pulseFrame, FOCUSED_PULSE_COLOR) - this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5]) + if (!this.isLite(scene)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5]) + } this.pushRing( positions, colors, @@ -290,6 +341,33 @@ export class WebGLVectorRenderer { scene, ) } + + if (scene.completedControlSequences.includes(control.sequence)) { + this.pushRing( + positions, + colors, + control.point.x, + control.point.y, + this.getMetric(scene, controlRadiusMeters * 1.14), + this.getMetric(scene, controlRadiusMeters * 1.02), + COMPLETED_SETTLE_COLOR, + scene, + ) + } + + if (this.isSkippedControl(scene, control.sequence)) { + this.pushRing( + positions, + colors, + control.point.x, + control.point.y, + this.getMetric(scene, controlRadiusMeters * 1.1), + this.getMetric(scene, controlRadiusMeters * 1.01), + SKIPPED_SETTLE_COLOR, + scene, + ) + this.pushSkippedControlSlash(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene) + } } for (const finish of course.finishes) { @@ -298,10 +376,24 @@ export class WebGLVectorRenderer { } if (scene.focusedFinish) { this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, FOCUSED_PULSE_COLOR) - this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46]) + if (!this.isLite(scene)) { + this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46]) + } } const finishColor = this.getFinishColor(scene) + if (scene.completedFinish) { + this.pushRing( + positions, + colors, + finish.point.x, + finish.point.y, + this.getMetric(scene, controlRadiusMeters * 1.18), + this.getMetric(scene, controlRadiusMeters * 1.02), + COMPLETED_SETTLE_COLOR, + scene, + ) + } this.pushRing( positions, colors, @@ -418,6 +510,27 @@ export class WebGLVectorRenderer { ) } + pushSkippedControlSlash( + positions: number[], + colors: number[], + centerX: number, + centerY: number, + controlRadiusMeters: number, + scene: MapScene, + ): void { + const slashRadius = this.getMetric(scene, controlRadiusMeters * 0.72) + const slashWidth = this.getMetric(scene, controlRadiusMeters * 0.08) + this.pushSegment( + positions, + colors, + { x: centerX - slashRadius, y: centerY + slashRadius }, + { x: centerX + slashRadius, y: centerY - slashRadius }, + slashWidth, + SKIPPED_SLASH_COLOR, + scene, + ) + } + pushActiveStartPulse( positions: number[], colors: number[], @@ -462,14 +575,22 @@ export class WebGLVectorRenderer { } getControlColor(scene: MapScene, sequence: number): RgbaColor { + if (scene.readyControlSequences.includes(sequence)) { + return READY_CONTROL_COLOR + } + if (scene.activeControlSequences.includes(sequence)) { return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR } - if (scene.completedControlSequences.includes(sequence) || this.isSkippedControl(scene, sequence)) { + if (scene.completedControlSequences.includes(sequence)) { return COMPLETED_ROUTE_COLOR } + if (this.isSkippedControl(scene, sequence)) { + return SKIPPED_ROUTE_COLOR + } + return COURSE_COLOR } @@ -633,7 +754,7 @@ export class WebGLVectorRenderer { color: RgbaColor, scene: MapScene, ): void { - const segments = 36 + const segments = this.getRingSegments(scene) for (let index = 0; index < segments; index += 1) { const startAngle = index / segments * Math.PI * 2 const endAngle = (index + 1) / segments * Math.PI * 2 @@ -682,7 +803,7 @@ export class WebGLVectorRenderer { color: RgbaColor, scene: MapScene, ): void { - const segments = 20 + const segments = this.getCircleSegments(scene) const center = this.toClip(centerX, centerY, scene) for (let index = 0; index < segments; index += 1) { const startAngle = index / segments * Math.PI * 2 diff --git a/miniprogram/engine/sensor/compassHeadingController.ts b/miniprogram/engine/sensor/compassHeadingController.ts index 0eb0bb5..30d8c27 100644 --- a/miniprogram/engine/sensor/compassHeadingController.ts +++ b/miniprogram/engine/sensor/compassHeadingController.ts @@ -5,7 +5,13 @@ export interface CompassHeadingControllerCallbacks { type SensorSource = 'compass' | 'motion' | null -const ABSOLUTE_HEADING_CORRECTION = 0.44 +export type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive' + +const HEADING_CORRECTION_BY_PROFILE: Record = { + smooth: 0.3, + balanced: 0.4, + responsive: 0.54, +} function normalizeHeadingDeg(headingDeg: number): number { const normalized = headingDeg % 360 @@ -41,6 +47,7 @@ export class CompassHeadingController { rollDeg: number | null motionReady: boolean compassReady: boolean + tuningProfile: CompassTuningProfile constructor(callbacks: CompassHeadingControllerCallbacks) { this.callbacks = callbacks @@ -53,6 +60,7 @@ export class CompassHeadingController { this.rollDeg = null this.motionReady = false this.compassReady = false + this.tuningProfile = 'balanced' } start(): void { @@ -99,6 +107,10 @@ export class CompassHeadingController { this.stop() } + setTuningProfile(profile: CompassTuningProfile): void { + this.tuningProfile = profile + } + startMotionSource(previousMessage: string): void { if (typeof wx.startDeviceMotionListening !== 'function' || typeof wx.onDeviceMotionChange !== 'function') { this.callbacks.onError(previousMessage) @@ -111,14 +123,13 @@ export class CompassHeadingController { } this.pitchDeg = typeof result.beta === 'number' && !Number.isNaN(result.beta) - ? result.beta * 180 / Math.PI + ? result.beta : null this.rollDeg = typeof result.gamma === 'number' && !Number.isNaN(result.gamma) - ? result.gamma * 180 / Math.PI + ? result.gamma : null - const alphaDeg = result.alpha * 180 / Math.PI - this.applyAbsoluteHeading(normalizeHeadingDeg(360 - alphaDeg), 'motion') + this.applyAbsoluteHeading(normalizeHeadingDeg(360 - result.alpha), 'motion') } this.motionCallback = callback @@ -163,10 +174,11 @@ export class CompassHeadingController { } applyAbsoluteHeading(headingDeg: number, source: 'compass' | 'motion'): void { + const headingCorrection = HEADING_CORRECTION_BY_PROFILE[this.tuningProfile] if (this.absoluteHeadingDeg === null) { this.absoluteHeadingDeg = headingDeg } else { - this.absoluteHeadingDeg = interpolateHeadingDeg(this.absoluteHeadingDeg, headingDeg, ABSOLUTE_HEADING_CORRECTION) + this.absoluteHeadingDeg = interpolateHeadingDeg(this.absoluteHeadingDeg, headingDeg, headingCorrection) } this.source = source @@ -200,5 +212,3 @@ export class CompassHeadingController { this.compassCallback = null } } - - diff --git a/miniprogram/game/feedback/feedbackConfig.ts b/miniprogram/game/feedback/feedbackConfig.ts index 1372970..4228425 100644 --- a/miniprogram/game/feedback/feedbackConfig.ts +++ b/miniprogram/game/feedback/feedbackConfig.ts @@ -1,3 +1,5 @@ +import { type AnimationLevel } from '../../utils/animationLevel' + export type FeedbackCueKey = | 'session_started' | 'session_finished' @@ -14,7 +16,9 @@ export type UiPunchFeedbackMotion = 'none' | 'pop' | 'success' | 'warning' export type UiContentCardMotion = 'none' | 'pop' | 'finish' export type UiPunchButtonMotion = 'none' | 'ready' | 'warning' export type UiMapPulseMotion = 'none' | 'ready' | 'control' | 'finish' -export type UiStageMotion = 'none' | 'finish' +export type UiStageMotion = 'none' | 'control' | 'finish' +export type UiHudProgressMotion = 'none' | 'success' | 'finish' +export type UiHudDistanceMotion = 'none' | 'success' export interface HapticCueConfig { enabled: boolean @@ -28,6 +32,8 @@ export interface UiCueConfig { punchButtonMotion: UiPunchButtonMotion mapPulseMotion: UiMapPulseMotion stageMotion: UiStageMotion + hudProgressMotion: UiHudProgressMotion + hudDistanceMotion: UiHudDistanceMotion durationMs: number } @@ -41,6 +47,10 @@ export interface GameUiEffectsConfig { cues: Record } +export interface ResolvedGameUiEffectsConfig extends GameUiEffectsConfig { + animationLevel: AnimationLevel +} + export interface PartialHapticCueConfig { enabled?: boolean pattern?: HapticPattern @@ -53,6 +63,8 @@ export interface PartialUiCueConfig { punchButtonMotion?: UiPunchButtonMotion mapPulseMotion?: UiMapPulseMotion stageMotion?: UiStageMotion + hudProgressMotion?: UiHudProgressMotion + hudDistanceMotion?: UiHudDistanceMotion durationMs?: number } @@ -84,15 +96,15 @@ export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = { export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = { enabled: true, cues: { - session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 }, - session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 }, - 'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 }, - 'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'none', durationMs: 0 }, - 'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', durationMs: 0 }, - 'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 560 }, - 'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 }, - 'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', durationMs: 0 }, - 'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', durationMs: 900 }, + session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + 'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 }, + 'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 }, + 'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', hudProgressMotion: 'finish', hudDistanceMotion: 'success', durationMs: 680 }, + 'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 560 }, + 'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + 'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + 'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 900 }, }, } @@ -115,6 +127,8 @@ function mergeUiCue(baseCue: UiCueConfig, override?: PartialUiCueConfig): UiCueC punchButtonMotion: override && override.punchButtonMotion ? override.punchButtonMotion : baseCue.punchButtonMotion, mapPulseMotion: override && override.mapPulseMotion ? override.mapPulseMotion : baseCue.mapPulseMotion, stageMotion: override && override.stageMotion ? override.stageMotion : baseCue.stageMotion, + hudProgressMotion: override && override.hudProgressMotion ? override.hudProgressMotion : baseCue.hudProgressMotion, + hudDistanceMotion: override && override.hudDistanceMotion ? override.hudDistanceMotion : baseCue.hudDistanceMotion, durationMs: clampDuration(Number(override && override.durationMs), baseCue.durationMs), } } diff --git a/miniprogram/game/feedback/feedbackDirector.ts b/miniprogram/game/feedback/feedbackDirector.ts index c3d4b31..e741f4d 100644 --- a/miniprogram/game/feedback/feedbackDirector.ts +++ b/miniprogram/game/feedback/feedbackDirector.ts @@ -1,6 +1,7 @@ import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig' import { SoundDirector } from '../audio/soundDirector' import { type GameEffect } from '../core/gameResult' +import { type AnimationLevel } from '../../utils/animationLevel' import { DEFAULT_GAME_HAPTICS_CONFIG, DEFAULT_GAME_UI_EFFECTS_CONFIG, @@ -41,6 +42,9 @@ export class FeedbackDirector { reset(): void { this.soundDirector.resetContexts() + this.uiEffectDirector.clearPunchButtonMotion() + this.uiEffectDirector.clearHudProgressMotion() + this.uiEffectDirector.clearHudDistanceMotion() } destroy(): void { @@ -49,6 +53,10 @@ export class FeedbackDirector { this.uiEffectDirector.destroy() } + setAnimationLevel(level: AnimationLevel): void { + this.uiEffectDirector.setAnimationLevel(level) + } + setAppAudioMode(mode: 'foreground' | 'background'): void { this.soundDirector.setAppAudioMode(mode) } diff --git a/miniprogram/game/feedback/uiEffectDirector.ts b/miniprogram/game/feedback/uiEffectDirector.ts index 00f5e19..3b45535 100644 --- a/miniprogram/game/feedback/uiEffectDirector.ts +++ b/miniprogram/game/feedback/uiEffectDirector.ts @@ -1,12 +1,16 @@ import { type GameEffect } from '../core/gameResult' +import { type AnimationLevel } from '../../utils/animationLevel' import { DEFAULT_GAME_UI_EFFECTS_CONFIG, type FeedbackCueKey, type GameUiEffectsConfig, type UiContentCardMotion, + type UiHudDistanceMotion, + type UiHudProgressMotion, type UiMapPulseMotion, type UiPunchButtonMotion, type UiPunchFeedbackMotion, + type UiCueConfig, type UiStageMotion, } from './feedbackConfig' @@ -14,6 +18,8 @@ export interface UiEffectHost { showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void showContentCard: (title: string, body: string, motionClass?: string) => void setPunchButtonFxClass: (className: string) => void + setHudProgressFxClass: (className: string) => void + setHudDistanceFxClass: (className: string) => void showMapPulse: (controlId: string, motionClass?: string) => void showStageFx: (className: string) => void } @@ -23,30 +29,46 @@ export class UiEffectDirector { config: GameUiEffectsConfig host: UiEffectHost punchButtonMotionTimer: number + hudProgressMotionTimer: number + hudDistanceMotionTimer: number punchButtonMotionToggle: boolean + animationLevel: AnimationLevel constructor(host: UiEffectHost, config: GameUiEffectsConfig = DEFAULT_GAME_UI_EFFECTS_CONFIG) { this.enabled = true this.host = host this.config = config this.punchButtonMotionTimer = 0 + this.hudProgressMotionTimer = 0 + this.hudDistanceMotionTimer = 0 this.punchButtonMotionToggle = false + this.animationLevel = 'standard' } configure(config: GameUiEffectsConfig): void { this.config = config this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } setEnabled(enabled: boolean): void { this.enabled = enabled if (!enabled) { this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } } + setAnimationLevel(level: AnimationLevel): void { + this.animationLevel = level + } + destroy(): void { this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } clearPunchButtonMotion(): void { @@ -57,6 +79,22 @@ export class UiEffectDirector { this.host.setPunchButtonFxClass('') } + clearHudProgressMotion(): void { + if (this.hudProgressMotionTimer) { + clearTimeout(this.hudProgressMotionTimer) + this.hudProgressMotionTimer = 0 + } + this.host.setHudProgressFxClass('') + } + + clearHudDistanceMotion(): void { + if (this.hudDistanceMotionTimer) { + clearTimeout(this.hudDistanceMotionTimer) + this.hudDistanceMotionTimer = 0 + } + this.host.setHudDistanceFxClass('') + } + getPunchFeedbackMotionClass(motion: UiPunchFeedbackMotion): string { if (motion === 'warning') { return 'game-punch-feedback--fx-warning' @@ -94,12 +132,32 @@ export class UiEffectDirector { } getStageMotionClass(motion: UiStageMotion): string { + if (motion === 'control') { + return 'map-stage__stage-fx--control' + } if (motion === 'finish') { return 'map-stage__stage-fx--finish' } return '' } + getHudProgressMotionClass(motion: UiHudProgressMotion): string { + if (motion === 'finish') { + return 'race-panel__progress--fx-finish' + } + if (motion === 'success') { + return 'race-panel__progress--fx-success' + } + return '' + } + + getHudDistanceMotionClass(motion: UiHudDistanceMotion): string { + if (motion === 'success') { + return 'race-panel__metric-group--fx-distance-success' + } + return '' + } + triggerPunchButtonMotion(motion: UiPunchButtonMotion, durationMs: number): void { if (motion === 'none') { return @@ -121,7 +179,37 @@ export class UiEffectDirector { }, durationMs) as unknown as number } - getCue(key: FeedbackCueKey) { + triggerHudProgressMotion(motion: UiHudProgressMotion, durationMs: number): void { + const className = this.getHudProgressMotionClass(motion) + if (!className) { + return + } + this.host.setHudProgressFxClass(className) + if (this.hudProgressMotionTimer) { + clearTimeout(this.hudProgressMotionTimer) + } + this.hudProgressMotionTimer = setTimeout(() => { + this.hudProgressMotionTimer = 0 + this.host.setHudProgressFxClass('') + }, durationMs) as unknown as number + } + + triggerHudDistanceMotion(motion: UiHudDistanceMotion, durationMs: number): void { + const className = this.getHudDistanceMotionClass(motion) + if (!className) { + return + } + this.host.setHudDistanceFxClass(className) + if (this.hudDistanceMotionTimer) { + clearTimeout(this.hudDistanceMotionTimer) + } + this.hudDistanceMotionTimer = setTimeout(() => { + this.hudDistanceMotionTimer = 0 + this.host.setHudDistanceFxClass('') + }, durationMs) as unknown as number + } + + getCue(key: FeedbackCueKey): UiCueConfig | null { if (!this.enabled || !this.config.enabled) { return null } @@ -131,7 +219,16 @@ export class UiEffectDirector { return null } - return cue + if (this.animationLevel === 'standard') { + return cue + } + + return { + ...cue, + stageMotion: 'none' as const, + hudDistanceMotion: 'none' as const, + durationMs: cue.durationMs > 0 ? Math.max(260, Math.round(cue.durationMs * 0.6)) : 0, + } } handleEffects(effects: GameEffect[]): void { @@ -172,6 +269,10 @@ export class UiEffectDirector { if (cue && cue.stageMotion !== 'none') { this.host.showStageFx(this.getStageMotionClass(cue.stageMotion)) } + if (cue) { + this.triggerHudProgressMotion(cue.hudProgressMotion, cue.durationMs) + this.triggerHudDistanceMotion(cue.hudDistanceMotion, cue.durationMs) + } continue } @@ -188,10 +289,14 @@ export class UiEffectDirector { if (effect.type === 'session_finished') { this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } if (effect.type === 'session_cancelled') { this.clearPunchButtonMotion() + this.clearHudProgressMotion() + this.clearHudDistanceMotion() } } } diff --git a/miniprogram/game/telemetry/telemetryRuntime.ts b/miniprogram/game/telemetry/telemetryRuntime.ts index 3f78e21..be712ca 100644 --- a/miniprogram/game/telemetry/telemetryRuntime.ts +++ b/miniprogram/game/telemetry/telemetryRuntime.ts @@ -52,6 +52,44 @@ function interpolateHeadingDeg(currentDeg: number, targetDeg: number, factor: nu return normalizeHeadingDeg(currentDeg + normalizeHeadingDeltaDeg(targetDeg - currentDeg) * factor) } +function resolveMotionCompassHeadingDeg( + alpha: number | null, + beta: number | null, + gamma: number | null, +): number | null { + if (alpha === null) { + return null + } + + if (beta === null || gamma === null) { + return normalizeHeadingDeg(360 - alpha) + } + + const alphaRad = alpha * Math.PI / 180 + const betaRad = beta * Math.PI / 180 + const gammaRad = gamma * Math.PI / 180 + + const cA = Math.cos(alphaRad) + const sA = Math.sin(alphaRad) + const sB = Math.sin(betaRad) + const cG = Math.cos(gammaRad) + const sG = Math.sin(gammaRad) + + const headingX = -cA * sG - sA * sB * cG + const headingY = -sA * sG + cA * sB * cG + + if (Math.abs(headingX) < 1e-6 && Math.abs(headingY) < 1e-6) { + return normalizeHeadingDeg(360 - alpha) + } + + let headingRad = Math.atan2(headingX, headingY) + if (headingRad < 0) { + headingRad += Math.PI * 2 + } + + return normalizeHeadingDeg(headingRad * 180 / Math.PI) +} + function getApproxDistanceMeters( a: { lon: number; lat: number }, b: { lon: number; lat: number }, @@ -530,13 +568,13 @@ export class TelemetryRuntime { } if (event.type === 'device_motion_updated') { - const nextDeviceHeadingDeg = event.alpha === null + const motionHeadingDeg = resolveMotionCompassHeadingDeg(event.alpha, event.beta, event.gamma) + const nextDeviceHeadingDeg = motionHeadingDeg === null ? this.state.deviceHeadingDeg : (() => { - const nextHeadingDeg = normalizeHeadingDeg(360 - event.alpha * 180 / Math.PI) return this.state.deviceHeadingDeg === null - ? nextHeadingDeg - : interpolateHeadingDeg(this.state.deviceHeadingDeg, nextHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA) + ? motionHeadingDeg + : interpolateHeadingDeg(this.state.deviceHeadingDeg, motionHeadingDeg, DEVICE_HEADING_SMOOTHING_ALPHA) })() this.state = { diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 8d86656..52a3a00 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -6,6 +6,7 @@ import { type MapEngineViewState, } from '../../engine/map/mapEngine' import { loadRemoteMapConfig } from '../../utils/remoteMapConfig' +import { type AnimationLevel } from '../../utils/animationLevel' type CompassTickData = { angle: number long: boolean @@ -31,9 +32,17 @@ type ScaleRulerMajorMarkData = { type SideButtonMode = 'all' | 'left' | 'right' | 'hidden' type SideActionButtonState = 'muted' | 'default' | 'active' type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center' +type UserNorthReferenceMode = 'magnetic' | 'true' +type StoredUserSettings = { + animationLevel?: AnimationLevel + northReferenceMode?: UserNorthReferenceMode + showCenterScaleRuler?: boolean + centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode +} type MapPageData = MapEngineViewState & { showDebugPanel: boolean showGameInfoPanel: boolean + showSystemSettingsPanel: boolean showCenterScaleRuler: boolean showPunchHintBanner: boolean centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode @@ -52,6 +61,10 @@ type MapPageData = MapEngineViewState & { panelDistanceValueText: string panelProgressText: string panelSpeedValueText: string + panelTimerFxClass: string + panelMileageFxClass: string + panelSpeedFxClass: string + panelHeartRateFxClass: string compassTicks: CompassTickData[] compassLabels: CompassLabelData[] sideButtonMode: SideButtonMode @@ -59,6 +72,7 @@ type MapPageData = MapEngineViewState & { sideButton2Class: string sideButton4Class: string sideButton11Class: string + sideButton12Class: string sideButton13Class: string sideButton14Class: string sideButton16Class: string @@ -75,7 +89,8 @@ type MapPageData = MapEngineViewState & { showRightButtonGroups: boolean showBottomDebugButton: boolean } -const INTERNAL_BUILD_VERSION = 'map-build-261' +const INTERNAL_BUILD_VERSION = 'map-build-282' +const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1' const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json' const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json' const PUNCH_HINT_AUTO_HIDE_MS = 30000 @@ -83,7 +98,43 @@ let mapEngine: MapEngine | null = null let stageCanvasAttached = false let gameInfoPanelSyncTimer = 0 let centerScaleRulerSyncTimer = 0 +let centerScaleRulerUpdateTimer = 0 let punchHintDismissTimer = 0 +let panelTimerFxTimer = 0 +let panelMileageFxTimer = 0 +let panelSpeedFxTimer = 0 +let panelHeartRateFxTimer = 0 +let lastCenterScaleRulerStablePatch: Pick< + MapPageData, + | 'centerScaleRulerVisible' + | 'centerScaleRulerCenterXPx' + | 'centerScaleRulerZeroYPx' + | 'centerScaleRulerHeightPx' + | 'centerScaleRulerAxisBottomPx' + | 'centerScaleRulerZeroVisible' + | 'centerScaleRulerZeroLabel' + | 'centerScaleRulerMinorTicks' + | 'centerScaleRulerMajorMarks' +> = { + centerScaleRulerVisible: false, + centerScaleRulerCenterXPx: 0, + centerScaleRulerZeroYPx: 0, + centerScaleRulerHeightPx: 0, + centerScaleRulerAxisBottomPx: 0, + centerScaleRulerZeroVisible: false, + centerScaleRulerZeroLabel: '0 m', + centerScaleRulerMinorTicks: [], + centerScaleRulerMajorMarks: [], +} +let centerScaleRulerInputCache: Partial> = {} const DEBUG_ONLY_VIEW_KEYS = new Set([ 'buildVersion', @@ -93,14 +144,15 @@ const DEBUG_ONLY_VIEW_KEYS = new Set([ 'mapReadyText', 'mapName', 'configStatusText', - 'sensorHeadingText', 'deviceHeadingText', 'devicePoseText', 'headingConfidenceText', 'accelerometerText', 'gyroscopeText', 'deviceMotionText', - 'compassDeclinationText', + 'compassSourceText', + 'compassTuningProfile', + 'compassTuningProfileText', 'northReferenceButtonText', 'autoRotateSourceText', 'autoRotateCalibrationText', @@ -148,6 +200,15 @@ const CENTER_SCALE_RULER_DEP_KEYS = new Set([ 'previewScale', ]) +const CENTER_SCALE_RULER_CACHE_KEYS: Array = [ + 'stageWidth', + 'stageHeight', + 'zoom', + 'centerTileY', + 'tileSizePx', + 'previewScale', +] + const RULER_ONLY_VIEW_KEYS = new Set([ 'zoom', 'centerTileX', @@ -213,12 +274,83 @@ function clearCenterScaleRulerSyncTimer() { } } +function clearCenterScaleRulerUpdateTimer() { + if (centerScaleRulerUpdateTimer) { + clearTimeout(centerScaleRulerUpdateTimer) + centerScaleRulerUpdateTimer = 0 + } +} + function clearPunchHintDismissTimer() { if (punchHintDismissTimer) { clearTimeout(punchHintDismissTimer) punchHintDismissTimer = 0 } } + +function clearHudFxTimer(key: 'timer' | 'mileage' | 'speed' | 'heartRate') { + const timerMap = { + timer: panelTimerFxTimer, + mileage: panelMileageFxTimer, + speed: panelSpeedFxTimer, + heartRate: panelHeartRateFxTimer, + } + const timer = timerMap[key] + if (timer) { + clearTimeout(timer) + } + if (key === 'timer') { + panelTimerFxTimer = 0 + } else if (key === 'mileage') { + panelMileageFxTimer = 0 + } else if (key === 'speed') { + panelSpeedFxTimer = 0 + } else { + panelHeartRateFxTimer = 0 + } +} + +function updateCenterScaleRulerInputCache(patch: Partial) { + for (const key of CENTER_SCALE_RULER_CACHE_KEYS) { + if (Object.prototype.hasOwnProperty.call(patch, key)) { + ;(centerScaleRulerInputCache as Record)[key] = + (patch as Record)[key] + } + } +} + +function loadStoredUserSettings(): StoredUserSettings { + try { + const stored = wx.getStorageSync(USER_SETTINGS_STORAGE_KEY) + if (!stored || typeof stored !== 'object') { + return {} + } + + const normalized = stored as Record + const settings: StoredUserSettings = {} + if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') { + settings.animationLevel = normalized.animationLevel + } + if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') { + settings.northReferenceMode = normalized.northReferenceMode + } + if (typeof normalized.showCenterScaleRuler === 'boolean') { + settings.showCenterScaleRuler = normalized.showCenterScaleRuler + } + if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') { + settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode + } + return settings + } catch { + return {} + } +} + +function persistStoredUserSettings(settings: StoredUserSettings) { + try { + wx.setStorageSync(USER_SETTINGS_STORAGE_KEY, settings) + } catch {} +} function buildSideButtonVisibility(mode: SideButtonMode) { return { sideButtonMode: mode, @@ -296,7 +428,7 @@ function getSideActionButtonClass(state: SideActionButtonState): string { return 'map-side-button map-side-button--default' } -function buildSideButtonState(data: Pick) { +function buildSideButtonState(data: Pick) { const sideButton2State: SideActionButtonState = !data.gpsLockAvailable ? 'muted' : data.gpsLockEnabled @@ -304,6 +436,7 @@ function buildSideButtonState(data: Pick) { if (!data.showCenterScaleRuler) { - return { + lastCenterScaleRulerStablePatch = { centerScaleRulerVisible: false, centerScaleRulerCenterXPx: 0, centerScaleRulerZeroYPx: 0, @@ -378,20 +512,11 @@ function buildCenterScaleRulerPatch(data: Pick, CENTER_SCALE_RULER_DEP_KEYS) ) { + clearCenterScaleRulerUpdateTimer() Object.assign(derivedPatch, buildCenterScaleRulerPatch(mergedData)) } @@ -685,6 +818,57 @@ Page({ } } + const nextAnimationLevel = typeof nextPatch.animationLevel === 'string' + ? nextPatch.animationLevel + : this.data.animationLevel + + if (nextAnimationLevel === 'lite') { + clearHudFxTimer('timer') + clearHudFxTimer('mileage') + clearHudFxTimer('speed') + clearHudFxTimer('heartRate') + nextData.panelTimerFxClass = '' + nextData.panelMileageFxClass = '' + nextData.panelSpeedFxClass = '' + nextData.panelHeartRateFxClass = '' + } else { + if (typeof nextPatch.panelTimerText === 'string' && nextPatch.panelTimerText !== this.data.panelTimerText && this.data.panelTimerText !== '00:00:00') { + clearHudFxTimer('timer') + nextData.panelTimerFxClass = 'race-panel__timer--fx-tick' + panelTimerFxTimer = setTimeout(() => { + panelTimerFxTimer = 0 + this.setData({ panelTimerFxClass: '' }) + }, 320) as unknown as number + } + + if (typeof nextPatch.panelMileageText === 'string' && nextPatch.panelMileageText !== this.data.panelMileageText && this.data.panelMileageText !== '0m') { + clearHudFxTimer('mileage') + nextData.panelMileageFxClass = 'race-panel__mileage-wrap--fx-update' + panelMileageFxTimer = setTimeout(() => { + panelMileageFxTimer = 0 + this.setData({ panelMileageFxClass: '' }) + }, 360) as unknown as number + } + + if (typeof nextPatch.panelSpeedValueText === 'string' && nextPatch.panelSpeedValueText !== this.data.panelSpeedValueText && this.data.panelSpeedValueText !== '0') { + clearHudFxTimer('speed') + nextData.panelSpeedFxClass = 'race-panel__metric-group--fx-speed-update' + panelSpeedFxTimer = setTimeout(() => { + panelSpeedFxTimer = 0 + this.setData({ panelSpeedFxClass: '' }) + }, 360) as unknown as number + } + + if (typeof nextPatch.panelHeartRateValueText === 'string' && nextPatch.panelHeartRateValueText !== this.data.panelHeartRateValueText && this.data.panelHeartRateValueText !== '--') { + clearHudFxTimer('heartRate') + nextData.panelHeartRateFxClass = 'race-panel__metric-group--fx-heart-rate-update' + panelHeartRateFxTimer = setTimeout(() => { + panelHeartRateFxTimer = 0 + this.setData({ panelHeartRateFxClass: '' }) + }, 400) as unknown as number + } + } + if (Object.keys(nextData).length || Object.keys(derivedPatch).length) { this.setData({ ...nextData, @@ -698,22 +882,46 @@ Page({ }, }) + const storedUserSettings = loadStoredUserSettings() + if (storedUserSettings.animationLevel) { + mapEngine.handleSetAnimationLevel(storedUserSettings.animationLevel) + } + if (storedUserSettings.northReferenceMode) { + mapEngine.handleSetNorthReferenceMode(storedUserSettings.northReferenceMode) + } + mapEngine.setDiagnosticUiEnabled(false) + centerScaleRulerInputCache = { + stageWidth: 0, + stageHeight: 0, + zoom: 0, + centerTileY: 0, + tileSizePx: 0, + previewScale: 1, + } + + const initialShowCenterScaleRuler = !!storedUserSettings.showCenterScaleRuler + const initialCenterScaleRulerAnchorMode = storedUserSettings.centerScaleRulerAnchorMode || 'screen-center' this.setData({ ...mapEngine.getInitialData(), showDebugPanel: false, showGameInfoPanel: false, + showSystemSettingsPanel: false, + showCenterScaleRuler: initialShowCenterScaleRuler, statusBarHeight, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), hudPanelIndex: 0, configSourceText: '顺序赛配置', + centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, gameInfoTitle: '当前游戏', gameInfoSubtitle: '未开始', gameInfoLocalRows: [], gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, panelTimerText: '00:00:00', + panelTimerFxClass: '', panelMileageText: '0m', + panelMileageFxClass: '', panelActionTagText: '目标', panelDistanceTagText: '点距', panelDistanceValueText: '--', @@ -740,6 +948,7 @@ Page({ mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', mockHeartRateText: '--', panelSpeedValueText: '0', + panelSpeedFxClass: '', panelTelemetryTone: 'blue', panelHeartRateZoneNameText: '--', panelHeartRateZoneRangeText: '', @@ -747,6 +956,7 @@ Page({ heartRateStatusText: '心率带未连接', heartRateDeviceText: '--', panelHeartRateValueText: '--', + panelHeartRateFxClass: '', panelHeartRateUnitText: '', panelCaloriesValueText: '0', panelCaloriesUnitText: 'kcal', @@ -760,6 +970,9 @@ Page({ accelerometerText: '--', gyroscopeText: '--', deviceMotionText: '--', + compassSourceText: '无数据', + compassTuningProfile: 'balanced', + compassTuningProfileText: '平衡', punchButtonText: '打点', punchButtonEnabled: false, skipButtonEnabled: false, @@ -771,6 +984,8 @@ Page({ contentCardTitle: '', contentCardBody: '', punchButtonFxClass: '', + panelProgressFxClass: '', + panelDistanceFxClass: '', punchFeedbackFxClass: '', contentCardFxClass: '', mapPulseVisible: false, @@ -785,8 +1000,9 @@ Page({ ...buildSideButtonState({ sideButtonMode: 'left', showGameInfoPanel: false, - showCenterScaleRuler: false, - centerScaleRulerAnchorMode: 'screen-center', + showSystemSettingsPanel: false, + showCenterScaleRuler: initialShowCenterScaleRuler, + centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, skipButtonEnabled: false, gameSessionStatus: 'idle', gpsLockEnabled: false, @@ -794,8 +1010,8 @@ Page({ }), ...buildCenterScaleRulerPatch({ ...(mapEngine.getInitialData() as MapPageData), - showCenterScaleRuler: false, - centerScaleRulerAnchorMode: 'screen-center', + showCenterScaleRuler: initialShowCenterScaleRuler, + centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, stageWidth: 0, stageHeight: 0, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), @@ -827,7 +1043,12 @@ Page({ onUnload() { clearGameInfoPanelSyncTimer() clearCenterScaleRulerSyncTimer() + clearCenterScaleRulerUpdateTimer() clearPunchHintDismissTimer() + clearHudFxTimer('timer') + clearHudFxTimer('mileage') + clearHudFxTimer('speed') + clearHudFxTimer('heartRate') if (mapEngine) { mapEngine.destroy() mapEngine = null @@ -997,6 +1218,24 @@ Page({ } }, + handleSetCompassTuningSmooth() { + if (mapEngine) { + mapEngine.handleSetCompassTuningProfile('smooth') + } + }, + + handleSetCompassTuningBalanced() { + if (mapEngine) { + mapEngine.handleSetCompassTuningProfile('balanced') + } + }, + + handleSetCompassTuningResponsive() { + if (mapEngine) { + mapEngine.handleSetCompassTuningProfile('responsive') + } + }, + handleAutoRotateCalibrate() { if (mapEngine) { mapEngine.handleAutoRotateCalibrate() @@ -1260,10 +1499,12 @@ Page({ this.syncGameInfoPanelSnapshot() this.setData({ showDebugPanel: false, + showSystemSettingsPanel: false, showGameInfoPanel: true, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: true, + showSystemSettingsPanel: false, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1281,6 +1522,7 @@ Page({ ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: false, + showSystemSettingsPanel: this.data.showSystemSettingsPanel, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1293,6 +1535,89 @@ Page({ handleGameInfoPanelTap() {}, + handleOpenSystemSettingsPanel() { + clearGameInfoPanelSyncTimer() + this.setData({ + showDebugPanel: false, + showGameInfoPanel: false, + showSystemSettingsPanel: true, + ...buildSideButtonState({ + sideButtonMode: this.data.sideButtonMode, + showGameInfoPanel: false, + showSystemSettingsPanel: true, + showCenterScaleRuler: this.data.showCenterScaleRuler, + centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + skipButtonEnabled: this.data.skipButtonEnabled, + gameSessionStatus: this.data.gameSessionStatus, + gpsLockEnabled: this.data.gpsLockEnabled, + gpsLockAvailable: this.data.gpsLockAvailable, + }), + }) + }, + + handleCloseSystemSettingsPanel() { + this.setData({ + showSystemSettingsPanel: false, + ...buildSideButtonState({ + sideButtonMode: this.data.sideButtonMode, + showGameInfoPanel: this.data.showGameInfoPanel, + showSystemSettingsPanel: false, + showCenterScaleRuler: this.data.showCenterScaleRuler, + centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + skipButtonEnabled: this.data.skipButtonEnabled, + gameSessionStatus: this.data.gameSessionStatus, + gpsLockEnabled: this.data.gpsLockEnabled, + gpsLockAvailable: this.data.gpsLockAvailable, + }), + }) + }, + + handleSystemSettingsPanelTap() {}, + + handleSetAnimationLevelStandard() { + if (!mapEngine) { + return + } + mapEngine.handleSetAnimationLevel('standard') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + animationLevel: 'standard', + }) + }, + + handleSetAnimationLevelLite() { + if (!mapEngine) { + return + } + mapEngine.handleSetAnimationLevel('lite') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + animationLevel: 'lite', + }) + }, + + handleSetNorthReferenceMagnetic() { + if (!mapEngine) { + return + } + mapEngine.handleSetNorthReferenceMode('magnetic') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + northReferenceMode: 'magnetic', + }) + }, + + handleSetNorthReferenceTrue() { + if (!mapEngine) { + return + } + mapEngine.handleSetNorthReferenceMode('true') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + northReferenceMode: 'true', + }) + }, + handleOverlayTouch() {}, handlePunchAction() { @@ -1318,6 +1643,8 @@ Page({ }) }, + handlePunchHintTap() {}, + handleHudPanelChange(event: WechatMiniprogram.CustomEvent<{ current: number }>) { this.setData({ hudPanelIndex: event.detail.current || 0, @@ -1331,6 +1658,7 @@ Page({ ...buildSideButtonState({ sideButtonMode: nextMode, showGameInfoPanel: this.data.showGameInfoPanel, + showSystemSettingsPanel: this.data.showSystemSettingsPanel, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1368,9 +1696,11 @@ Page({ this.setData({ showDebugPanel: nextShowDebugPanel, showGameInfoPanel: false, + showSystemSettingsPanel: false, ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: false, + showSystemSettingsPanel: false, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1390,6 +1720,7 @@ Page({ ...buildSideButtonState({ sideButtonMode: this.data.sideButtonMode, showGameInfoPanel: this.data.showGameInfoPanel, + showSystemSettingsPanel: this.data.showSystemSettingsPanel, showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, skipButtonEnabled: this.data.skipButtonEnabled, @@ -1400,25 +1731,29 @@ Page({ }) }, - handleToggleCenterScaleRuler() { - const nextEnabled = !this.data.showCenterScaleRuler + applyCenterScaleRulerSettings(nextEnabled: boolean, nextAnchorMode: CenterScaleRulerAnchorMode) { this.data.showCenterScaleRuler = nextEnabled + this.data.centerScaleRulerAnchorMode = nextAnchorMode clearCenterScaleRulerSyncTimer() + clearCenterScaleRulerUpdateTimer() const syncRulerFromEngine = () => { if (!mapEngine) { return } const engineSnapshot = mapEngine.getInitialData() as Partial + updateCenterScaleRulerInputCache(engineSnapshot) const mergedData = { - ...engineSnapshot, + ...centerScaleRulerInputCache, ...this.data, showCenterScaleRuler: nextEnabled, + centerScaleRulerAnchorMode: nextAnchorMode, } as MapPageData this.setData({ ...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, nextEnabled), showCenterScaleRuler: nextEnabled, + centerScaleRulerAnchorMode: nextAnchorMode, ...buildCenterScaleRulerPatch(mergedData), ...buildSideButtonState(mergedData), }) @@ -1431,9 +1766,11 @@ Page({ this.setData({ showCenterScaleRuler: true, + centerScaleRulerAnchorMode: nextAnchorMode, ...buildSideButtonState({ ...this.data, showCenterScaleRuler: true, + centerScaleRulerAnchorMode: nextAnchorMode, } as MapPageData), }) @@ -1450,6 +1787,42 @@ Page({ }, 96) as unknown as number }, + handleSetCenterScaleRulerVisibleOn() { + this.applyCenterScaleRulerSettings(true, this.data.centerScaleRulerAnchorMode) + persistStoredUserSettings({ + ...loadStoredUserSettings(), + showCenterScaleRuler: true, + centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + }) + }, + + handleSetCenterScaleRulerVisibleOff() { + this.applyCenterScaleRulerSettings(false, this.data.centerScaleRulerAnchorMode) + persistStoredUserSettings({ + ...loadStoredUserSettings(), + showCenterScaleRuler: false, + centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + }) + }, + + handleSetCenterScaleRulerAnchorScreenCenter() { + this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'screen-center') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + showCenterScaleRuler: this.data.showCenterScaleRuler, + centerScaleRulerAnchorMode: 'screen-center', + }) + }, + + handleSetCenterScaleRulerAnchorCompassCenter() { + this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'compass-center') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + showCenterScaleRuler: this.data.showCenterScaleRuler, + centerScaleRulerAnchorMode: 'compass-center', + }) + }, + handleToggleCenterScaleRulerAnchor() { if (!this.data.showCenterScaleRuler) { return @@ -1459,9 +1832,10 @@ Page({ ? 'compass-center' : 'screen-center' const engineSnapshot = mapEngine ? (mapEngine.getInitialData() as Partial) : {} + updateCenterScaleRulerInputCache(engineSnapshot) this.data.centerScaleRulerAnchorMode = nextAnchorMode const mergedData = { - ...engineSnapshot, + ...centerScaleRulerInputCache, ...this.data, centerScaleRulerAnchorMode: nextAnchorMode, } as MapPageData diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index b80bc67..752e29b 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -28,10 +28,6 @@ - - {{punchHintText}} - × - {{punchFeedbackText}} {{contentCardTitle}} @@ -40,7 +36,7 @@ - + @@ -84,13 +80,18 @@ - + + {{punchHintText}} + × + + + - + 1 2 @@ -98,7 +99,7 @@ - + 5 6 7 @@ -107,24 +108,24 @@ 10 - + - 12 - 13 - 14 + 12 + 13 + 14 15 - + {{punchButtonText}} - + 开始 - + @@ -132,7 +133,7 @@ 调试 - + {{panelActionTagText}} @@ -155,10 +156,10 @@ - {{panelTimerText}} + {{panelTimerText}} - + {{panelMileageText}} @@ -167,16 +168,16 @@ - + {{panelDistanceValueText}} {{panelDistanceUnitText}} - {{panelProgressText}} + {{panelProgressText}} - + {{panelSpeedValueText}} km/h @@ -201,13 +202,13 @@ - + {{panelHeartRateValueText}} {{panelHeartRateUnitText}} - {{panelTimerText}} + {{panelTimerText}} @@ -237,7 +238,7 @@ - + @@ -281,6 +282,93 @@ + + + + + SYSTEM SETTINGS + 系统设置 + 用户端偏好与设备级选项 + + + 关闭 + + + + + + + + + + + + + + + + + @@ -464,6 +552,19 @@ Heading Confidence {{headingConfidenceText}} + + Compass Source + {{compassSourceText}} + + + Compass Tune + {{compassTuningProfileText}} + + + 顺滑 + 平衡 + 跟手 + Accel {{accelerometerText}} diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 89350a3..3864139 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -85,6 +85,10 @@ animation: stage-fx-finish 0.76s ease-out 1; } +.map-stage__stage-fx--control { + animation: stage-fx-control 0.52s ease-out 1; +} + .map-stage__overlay { position: absolute; inset: 0; @@ -834,6 +838,10 @@ text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.2); } +.race-panel__timer--fx-tick { + animation: race-panel-timer-tick 0.32s cubic-bezier(0.24, 0.86, 0.3, 1) 1; +} + .race-panel__mileage { max-width: 100%; box-sizing: border-box; @@ -851,6 +859,10 @@ transform: translateX(-16rpx); } +.race-panel__mileage-wrap--fx-update { + animation: race-panel-mileage-update 0.36s cubic-bezier(0.22, 0.88, 0.34, 1) 1; +} + .race-panel__metric-group { max-width: 100%; box-sizing: border-box; @@ -864,11 +876,23 @@ transform: translateX(16rpx); } +.race-panel__metric-group--fx-distance-success { + animation: race-panel-distance-success 0.56s cubic-bezier(0.22, 0.88, 0.34, 1) 1; +} + .race-panel__metric-group--right { justify-content: center; transform: translateX(-16rpx); } +.race-panel__metric-group--fx-speed-update { + animation: race-panel-speed-update 0.36s cubic-bezier(0.22, 0.88, 0.34, 1) 1; +} + +.race-panel__metric-group--fx-heart-rate-update { + animation: race-panel-heart-rate-update 0.4s cubic-bezier(0.2, 0.9, 0.3, 1) 1; +} + .race-panel__metric-value { line-height: 1; text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.16); @@ -924,6 +948,38 @@ text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.16); } +.race-panel__progress--fx-success { + animation: race-panel-progress-success 0.56s cubic-bezier(0.2, 0.88, 0.32, 1) 1; +} + +.race-panel__progress--fx-finish { + animation: race-panel-progress-finish 0.68s cubic-bezier(0.18, 0.92, 0.28, 1) 1; +} + +@keyframes race-panel-timer-tick { + 0% { transform: translateY(0) scale(1); opacity: 0.94; } + 35% { transform: translateY(-2rpx) scale(1.04); opacity: 1; } + 100% { transform: translateY(0) scale(1); opacity: 1; } +} + +@keyframes race-panel-mileage-update { + 0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; } + 40% { transform: translateX(-16rpx) scale(1.05); opacity: 1; } + 100% { transform: translateX(-16rpx) scale(1); opacity: 1; } +} + +@keyframes race-panel-speed-update { + 0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; } + 40% { transform: translateX(-16rpx) scale(1.06); opacity: 1; } + 100% { transform: translateX(-16rpx) scale(1); opacity: 1; } +} + +@keyframes race-panel-heart-rate-update { + 0% { transform: translateX(16rpx) scale(1); opacity: 0.94; } + 38% { transform: translateX(16rpx) scale(1.05); opacity: 1; } + 100% { transform: translateX(16rpx) scale(1); opacity: 1; } +} + .race-panel__zone { display: flex; flex-direction: column; @@ -982,6 +1038,72 @@ right: 0; bottom: 0; } + +@keyframes race-panel-distance-success { + 0% { + transform: translateX(16rpx) scale(1); + opacity: 1; + } + + 28% { + transform: translateX(16rpx) scale(1.09); + opacity: 1; + } + + 62% { + transform: translateX(16rpx) scale(0.98); + opacity: 0.96; + } + + 100% { + transform: translateX(16rpx) scale(1); + opacity: 1; + } +} + +@keyframes race-panel-progress-success { + 0% { + transform: scale(1) translateY(0); + opacity: 1; + } + + 24% { + transform: scale(1.16) translateY(-4rpx); + opacity: 1; + } + + 60% { + transform: scale(0.98) translateY(0); + opacity: 0.96; + } + + 100% { + transform: scale(1) translateY(0); + opacity: 1; + } +} + +@keyframes race-panel-progress-finish { + 0% { + transform: scale(1) translateY(0); + opacity: 1; + } + + 20% { + transform: scale(1.2) translateY(-6rpx); + opacity: 1; + } + + 46% { + transform: scale(1.08) translateY(-2rpx); + opacity: 1; + } + + 100% { + transform: scale(1) translateY(0); + opacity: 1; + } +} .map-punch-button { position: absolute; right: 24rpx; @@ -1593,7 +1715,7 @@ font-size: 24rpx; line-height: 1.2; text-align: left; - z-index: 16; + z-index: 40; pointer-events: auto; } @@ -1603,9 +1725,9 @@ } .game-punch-hint__close { - width: 40rpx; - height: 40rpx; - flex: 0 0 40rpx; + width: 56rpx; + height: 56rpx; + flex: 0 0 56rpx; border-radius: 999rpx; display: flex; align-items: center; @@ -1939,3 +2061,21 @@ backdrop-filter: brightness(1); } } + +@keyframes stage-fx-control { + 0% { + opacity: 0; + background: radial-gradient(circle at 50% 50%, rgba(138, 255, 235, 0.16) 0%, rgba(138, 255, 235, 0.06) 26%, rgba(255, 255, 255, 0) 60%); + backdrop-filter: brightness(1); + } + 36% { + opacity: 1; + background: radial-gradient(circle at 50% 50%, rgba(138, 255, 235, 0.24) 0%, rgba(138, 255, 235, 0.1) 32%, rgba(255, 255, 255, 0.03) 72%); + backdrop-filter: brightness(1.03); + } + 100% { + opacity: 0; + background: radial-gradient(circle at 50% 50%, rgba(138, 255, 235, 0) 0%, rgba(138, 255, 235, 0) 100%); + backdrop-filter: brightness(1); + } +} diff --git a/miniprogram/utils/animationLevel.ts b/miniprogram/utils/animationLevel.ts new file mode 100644 index 0000000..aa7ca62 --- /dev/null +++ b/miniprogram/utils/animationLevel.ts @@ -0,0 +1,24 @@ +export type AnimationLevel = 'standard' | 'lite' + +const LITE_BENCHMARK_THRESHOLD = 18 +const LITE_DEVICE_MEMORY_GB = 3 + +export function resolveAnimationLevel(systemInfo?: WechatMiniprogram.SystemInfo): AnimationLevel { + const info = systemInfo || wx.getSystemInfoSync() + const benchmarkLevel = Number((info as WechatMiniprogram.SystemInfo & { benchmarkLevel?: number }).benchmarkLevel) + const deviceMemory = Number((info as WechatMiniprogram.SystemInfo & { deviceMemory?: number }).deviceMemory) + + if (Number.isFinite(benchmarkLevel) && benchmarkLevel > 0 && benchmarkLevel <= LITE_BENCHMARK_THRESHOLD) { + return 'lite' + } + + if (Number.isFinite(deviceMemory) && deviceMemory > 0 && deviceMemory <= LITE_DEVICE_MEMORY_GB) { + return 'lite' + } + + return 'standard' +} + +export function formatAnimationLevelText(level: AnimationLevel): string { + return level === 'lite' ? '精简' : '标准' +}