From 7316588d9e59bd287b37e31b32af0f0612cd2bd4 Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Tue, 24 Mar 2026 22:09:03 +0800 Subject: [PATCH] =?UTF-8?q?refactor(game):=20=E7=A7=BB=E9=99=A4=E5=BA=9F?= =?UTF-8?q?=E5=BC=83=E7=9A=84=E6=88=BF=E9=97=B4=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=96=87=E4=BB=B6=E5=B9=B6=E4=BC=98=E5=8C=96=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 src/state/active-room.ts 文件及其相关导入引用 - 更新 ChengduGamePage.vue 中的导入路径从 features/chengdu-game/useChengduGameRoom 到 game/chengdu - 移除 ChengduGamePage.vue 中不再需要的状态变量如 roomId、startGamePending 等 - 简化 roomStatusText 计算属性逻辑,移除 "等待中" 默认值 - 调整 phaseLabelMap 映射,移除 "摸牌" 阶段显示 - 删除多个废弃的计算属性如 centerTimer、selectedTileText、pendingClaimText 等 - 移除 actionTheme 函数及相关的按钮样式绑定 - 清理游戏场景中的装饰元素如 diamond outline、scene watermark、center desk 等 - 更新 HallPage.vue 中的导入路径到 store/active-room-store - 添加缺失的玩家数据字段如 hand、melds、outTiles、hasHu - 调整 CSS 样式包括工具栏位置、动画角度和时钟位置等视觉优化 --- src/assets/images/actions/hu.png | Bin 0 -> 13802 bytes src/assets/images/actions/pass.png | Bin 0 -> 15013 bytes src/assets/images/actions/peng.png | Bin 0 -> 13924 bytes src/assets/styles/room.css | 11 +- src/constants/index.ts | 2 + src/constants/suits.ts | 3 + src/constants/ws-events.ts | 6 + src/game/chengdu/index.ts | 2 + src/game/chengdu/parser-utils.ts | 71 +++ src/game/chengdu/room-normalizers.ts | 421 ++++++++++++++ src/game/chengdu/types.ts | 60 ++ .../chengdu}/useChengduGameRoom.ts | 542 +----------------- src/game/index.ts | 1 + src/models/index.ts | 2 + src/models/room-state/constants.ts | 1 + src/models/room-state/engine-state.ts | 19 + src/models/room-state/game-player-state.ts | 5 + src/models/room-state/game-state.ts | 7 + src/models/room-state/index.ts | 9 + src/models/room-state/player-state.ts | 7 + src/models/room-state/room-player-state.ts | 9 + src/models/room-state/room-state.ts | 19 + src/models/room-state/room-status.ts | 1 + src/models/room-state/rule-state.ts | 5 + src/models/tile.ts | 50 ++ .../active-room-store.ts} | 81 +-- src/views/ChengduGamePage.vue | 85 +-- src/views/HallPage.vue | 8 +- 28 files changed, 757 insertions(+), 670 deletions(-) create mode 100644 src/assets/images/actions/hu.png create mode 100644 src/assets/images/actions/pass.png create mode 100644 src/assets/images/actions/peng.png create mode 100644 src/constants/index.ts create mode 100644 src/constants/suits.ts create mode 100644 src/constants/ws-events.ts create mode 100644 src/game/chengdu/index.ts create mode 100644 src/game/chengdu/parser-utils.ts create mode 100644 src/game/chengdu/room-normalizers.ts create mode 100644 src/game/chengdu/types.ts rename src/{features/chengdu-game => game/chengdu}/useChengduGameRoom.ts (53%) create mode 100644 src/game/index.ts create mode 100644 src/models/index.ts create mode 100644 src/models/room-state/constants.ts create mode 100644 src/models/room-state/engine-state.ts create mode 100644 src/models/room-state/game-player-state.ts create mode 100644 src/models/room-state/game-state.ts create mode 100644 src/models/room-state/index.ts create mode 100644 src/models/room-state/player-state.ts create mode 100644 src/models/room-state/room-player-state.ts create mode 100644 src/models/room-state/room-state.ts create mode 100644 src/models/room-state/room-status.ts create mode 100644 src/models/room-state/rule-state.ts create mode 100644 src/models/tile.ts rename src/{state/active-room.ts => store/active-room-store.ts} (66%) diff --git a/src/assets/images/actions/hu.png b/src/assets/images/actions/hu.png new file mode 100644 index 0000000000000000000000000000000000000000..b92f301d69019b39fa305ab93e4b45a9915796d3 GIT binary patch literal 13802 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 literal 0 HcmV?d00001 diff --git a/src/assets/images/actions/pass.png b/src/assets/images/actions/pass.png new file mode 100644 index 0000000000000000000000000000000000000000..0bba8464df498edd69c567fdfd2f525b12f8c90e GIT binary patch literal 15013 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 literal 0 HcmV?d00001 diff --git a/src/assets/images/actions/peng.png b/src/assets/images/actions/peng.png new file mode 100644 index 0000000000000000000000000000000000000000..1473a3f136429738435e3947d780b47920f7b4ba GIT binary patch literal 13924 zcmW+-2RzjO8$S_8#hIOKPWCu^MPzSsLPkGZgb+Ggc7!8)Waos8A~WG6D|?e{viJUf z{Cl0m@%?_jpU?9=@9{iE>T0XpA-P8afk5u4sVeG&&k^9m2TBP3E^qj`2!Sv|)D-0n zyfZfZUZ)rgr(XK>nmwPbtH;*Yjo(|LDR7Wa5sbLc%<}T#!wmBIUBwtWMWUmAIES)u zLgaiz8i#XBs$I9Do-$stGDSE+3tWUmMB|N!cb6H&?NbC?2LFDe><(+tI7?l ziyaa+m(E_`Ih?SS;DAc#2E(PZY4r9Hb7VmY@?BJ@BQxQ1(%YuF3kjaKvoV4R9)c&l z9)498d2YAY+Dh~`!r-B>tLsBpC=-m;AuE2 z?ifyP8&bvk*MVIlfH=2V@&QtUCBNJ2q9}{=9B+qih*G{YF>$m0hI?+xtB7%YVeD6l*9O%SJf$sbv9VAE9xS+bK zKCoRwtG7>f`{z~M@xgwkOCnmC*#4iY|D73QZnsGnC(1XH$mr~IuM0nT=aY!&(9LEG zI8=V$B{Lo_207HMSS8_zq?NB7QoW&SO>)(HRi4awE+edF>8oo?(Wkk)8d;X?Y49XD ziymEUr%W*~%wwIfx-O12n-?AGeyyzVTq2b7Ju{Bz1%ikp3_no#F+GGn8cBpMAP$fY z+P}jU5{Ez&_a|@>Pjg&lQmS2jjvBsFes7NmH6Nf=%e}jZ)~9>h$Ku_+pXcdE`I{J!K#qxzC5YE%sJJ&Et>cQqkcqC%YSz#9!x z4v08}%&NP{Ax&qnIuMJMMWGt8*g6rdDq)c-sfcX^`z&Q#gyJR2<3 zYEnh8Ml(o@&5Vt~$(4{SYWk*B?J1m5W{Cq(mi$`}Ltqq2{Cc%ia|}Ob#Y)HtTxwGH z1t+w`a4DHc+hs-D=tx=&jDH8pQmmtI9T%Z;;kj(E(bIj zaU1g0XeRtaLs`wty zh1+mwXZ(Ka__H#)OQ5;IZth2DW+iWjwecp6QiTRABq4EtUqQ7d2!T#-vwv$8^Ze70 z<%p+cv?Sb$RBMnvtxyt$dWFS49~9}q!SjB8R8%ik;L;)qj)M{8gu^I`5)WYt=(v(g z@2B2GBoM_i%U4R>LuB~{327|mhSO`Ca#)@WUfgmH$4A0IQBhGF=@XA}<`ZRp^ki!* z{FWe&+W=JxmOLD(n7i9_wn;ZUHRU8;B9W^3ciLBH9Q>U<(tHsfV_3pcSaE?K&P`_Y#H9#7x?PjYS!Te!Xh7ATs)|_Xle?N4oSET| z1*aqCd%T^(3{_LZpcVC^Zw15fVT17@UYIQAMC5%)oQPI}s8*F@n(bp0YD7e9Kt$^& zeF?b&mAV62fk>9#H}dJX1EJ<;5p;cMqmz)+(&tg{nKR(%if&XCkwYgrL^sF5q#&15 z6gZ1=0g64z#gb| zE0Y;gw5h_%3~Ps6i}bm?gi<{OESee`iiC5#6HUg)R4Hu2+pa$?8$z=W_0fb0v{~dytADi0g*O z_||r(1RX>p8~1-wgus3jgqsbDJPBj&<;eCO7#$iyqFH&)TQvyh-u zD59kyi2IvXcmNHC*+mx!z4%2Dg1m1HW#x&o)G5&anv_J-vAfvCFj?br_h@T2`#r%M zBC?TQxvTS`{nf$xj##DuO-w$&9#vy5XWdQt&$oh^@nFO(rhK$TJkg9#W&$oStnKPB z;mOvR>iuneP9oTlNU6GBB1@#ArM(Oz)9#p6?46V2`Pj3?-sXRmU9KmK$yQ)>aR}mE z&gY2(pPGsi?+X#AftXNGWfEv1YMexW0}jW)XvUqEHN-)&o16^;>^CIkI`N zF$V_+-rFriNXs_p&&u0+RF4w}5RhS27%t@l7MrMEkY6+FPbYVE(sye_?!`p)5bBB9 zE4TfXeiek8)O|JmaAs)uO?e3gt_KQGNy0w_feES_2}Xr01&$*TF3+D^7O2NrLUZr- zO-*>QKKVj`7h-^7$k4nWexD-pzsavJ=%fNJeY1^2-Iuz9I_6u-11{Es?>O1pe=kTj zkHX=UZ!^i#2Qvrilc*OfL)G-usq>{3l}y4NQ++I>k=ClYHS(M+ius9AiTaWH>X3&h z6oP`7*XT3E!vDmk#q*^qCgeI0K>2&a88v>02Vrq&`3`?m=%7n=lgIGW_q{$j3y%xP z=VvX!$HPx7run;9Zq(vQsGZ%b>SLr=8Y`4z% zk5OyTH!AnI>G!+pEzt04-Oy>ejvAt?rx&El8PcDp z@`y@l;`i@olmsn?#>SCYEY>L;vZ}qhvhvW!M;dN5*61T{Sz&bORWlg_9zG-B@&p3W z8iYkEsUJv)^3pR zZv~oGz#q8Ba;BF)L@~)GhU`mx(g#ELCsPdNM+C_smQelZH{E#O+u?g3v1=%*_PrJ& zrj)hq@hZF44bL~ouu+pC=f!^l?cZ{<4d+P{u`GJ(8-jdE^pW+ z>6PAU@SpkdJx;w~d#8hGzDVwZ%m1i;HkDEMzaIoLJ8hJ;m#6DQ9;Md-TX=QkEt4Yn z-wm0p^TYE>o38xj?0_bzcvS<=D1A@}9}bELSaru#Pt>q^rG1A7JQh-CiFltE>BE#s zi;<{tp5{(P3B6h~2x#}YAmLmn4tKPbVpa~`a%uUA-i zQk)-m$SJ>i^{RQjw5$xr;5GFhw_d$@ZCJKo!%M5i*=SapyFb&P#@(wIW7 zDEHKKSGQ(JFZ$9ai8mUV_LAe4;iJZ#`Hs5=0(N;*{%0HB|0+6mXU-q4Syb7^IAqpL zdp4w>h_u{qmSV|)Gw(FC-g)419R@!U{aUtOOFS!qTnvKDgC z>y+H&Hwk*#14T~4niU78zLq$VaND!(R?&*Ng) z67`8`F(0EZ5wj6HA=BjFmn8S#7nx zzK#SnK6kxpw?4yb=5?Hnac<%-TDKJyVN|i z=&R%AGU)z^2LUn5Tbxx^#?Na~{j<6ZJXOsKI*HfVlIua!L8*&8l~Src_bT+q1vBpF zxo;QmZZ1`@5#O3#{JU0FJ@w&<36saRCMU|h)JDblkd@6%T-Y7DsIv8OyKGVR$!jfX z>d~*HG`!O--1jHdE(>`PqaSrkpaB-ltgBwkM_KtSk-4bNh%brRwhOOn?nEXoSwHQ$ zy}jTa%%bI~DeqSEm26sCnxOlVrp8l;oxkl$qc+N7u^n%oI)5wpb7JAUbF-{rSG{2e zN0*d|1o;bqEu+tTGL9Inu&E$g!s4Qad(QPv=)=|o@IK$d$FyyL%{ zko~7$h3U(u`Y&IGcS{5I`&IU&NB}ke?6&wT81wx6cqc{HPfA1!AmX!CSGBtZL&V>; zp~&9Ol=ysmX#|se_b2mt3VFgn0e^i0dDY>Ok-{56fg2!^{kVPSW5iv%_-d%04?47f z-Tdsas{hHX{%fDhdCwAD(hafMbmlzecQkEO@5wh(`ZOFw0#r*&3v7O`=U2wk($ZQ$ z`u-E^zB(Okey1VOSWPAgVhk)z{gdgpfq_e;FL-Ytnz`@dX^Sct) zD|}bD!MhnooAu)Pi7T>DndNqdK}3{8qd7cE7V$BN4Qhh_1Pb<*4$Pg8b;35WQfdp0VAr$HLT)iJ80MbomYwFeSY(L_+674UJpQ%@>~bX`=G$ zYiq8Z?8(ojYG1n6Pk$^a>AD=5n1}*<{^@BA>H0|NBLqcDkN9l#mfx1oO(qi%_B8ya zMnmZrfWc(wJJ3WbettQx>@-nLOegNF=;*l4RWeBeo(W*{%O}Rhd6vGL+dXhbq?gh)pYwIS>{o82B7F_6&t00%FI7HU2w@lF;QWpUp%W z0>IA-G?Holb*mzRU+*nZ^Bwj?k35b9UyY0yUf+0QW5eFXrAFo}7R$}U((#TV{c%SJ zQ$RGC?D6;AsrB+^^Tv&@lb234lSe-*8&s&;LA*SIykmaS(-v1^YtW@0FCEpdLDu*( zjZ!kqoVxYxdS=zj!&@0=eWqJb0)^ZTN&&j4`BpAm7nY_&##q~Z+5V)wgUis2d3VjM0$=I;de_ zkN7L;ofjMY%2qD`JQ^_f&9)$fT8g*}<7VwV>G8!SMv6@J46)g;*BeY$USBUQoxyw4 zu6|I1@2Q-K&y}PVGO>R#yXrgA*4#M^)}EK5fhv)fH~rw%he5po~=H zj90z>WUQkT%flGU#u%GdR7BKiO@YNKU=Y_u|GEkxy*5pw=_M;a_PWIq3m8xE0wTOq zmDKMtHZGSlr!|}`{HycyJ$q;8-ZBIosD>HZ98^jzg<9#Kfh+)Zk|vytiGPg+FcW}i zP&VagPJ51j6JNRp#;Hlekv6}moW?%mDQ`I=D7YL}e9NA-MUWR>&DOXu31M1$(fASF z2-W6g%TI<|?yGadBBh#oiBSgJ)XyCqON^`S`+py-kERoHAKi7Xp}UsFdO#HiT#N>M zIe!5N^)|W@q*be3w%^F|5#XnDI64=4U7tSWPl$JP1g-q1+-^;#>V8$xrQbh|eY)U# zC0LU}PWA2IIgf^c96XD7to80190B()uUge|)e0duib{bxzz|I5?d`{ULT5`7>72Vy1)6&!TvX~+j|Ifo_FE?g;k2Uu;r|Zch z6)UqJnxIXJVa(GrSEuDyLKhpsxA-64w+gb35UPq29Mqr~B;x-eV=}E7Tq~lr#~t<8 z(Z!|w_+M2YeY+fqqp9~?=rt5xa|t|7K9QZ8<_kV4DJjCKu<3fQ`5+?3?~ipJyqZuQ zn3{?P-?5Vp*jwru?^8hV)7-gp=XZQ7TxO^|K01{EdHS%nf8IL!HMk4V%f(L2;%>m@ z%B!d#ziZ)(O0A<~e<^qr#>1=n?)^LeYrOE!&Y;>*%kl+1j6X^A9`mexUE#)z`e3 zToQ6z04zPoUL>j~UEB(s$9Vb+Cnpd=Y=XAka1k-FrG|nFmat27jxH3)UITFN2LFo5c=(Z(xwGt{pl(1`f5TT23%1>`9(!`iX!(Inq238Oe7^G z$sTqj=*YfSpJ}@M0B#&`KF?IOz^j-t*BTDAr=5}?^3RdytlyChyM?UNOam?1gMHoJ ztKFU=*K4mZNxtvoS^`~zw04njR_f{OBd%#3s3hdWoyI*Oj^F9$y>KQBzVjQnM(@Pa zd|^_%gzhzpkBfm%$L;XJBLgDZk|F61$tSLQy=VJxy8f=u;Xk~%th>Oxx_w_OE#%j) zU%WX{H|0F9pS1J^__n4Yx2uS__+8(~rjs6*T(Ic!#zT|-7x;n6=4j$aVYgd=VevdK zQ3}dJst{o&j;$b(uQm`iJ-jy>M_`G%bE| z*Ugf%iXR}1-!}CDxF==uKmPCV9s>h|Ql>PBAPn&h2w;Eo;Ak9}VXhPkriuD0O9dn4 z=K>QBwqkc%<$7qIYXEo6uw)see_vhfhs3`gQdJa128nJlNGqIm-#h`AfkKY%yL)u9 zz7mXn`+BBc)34yNzO^>>AjY?U@su;aSC=$Ey#gZcVHqFBWw(q>uiGHh7)zAJm6DA2 zpxV9I)Mw*MlDp`M?k~}O?K;c8NJUm->0gU(bJrUDzIH~0V|gRZ^*T4dEi=}d7Fyl> zWK?kj0I&yV)ZcEW56?uap8j?)++XweBW!uR^Sig;2isUF>>SW*<~wNr$7RLVxU)jcLl zM8xJrtW~zPXn%+Aj3}pT^or_Jr@sLfFExQy(JU!R(z2G)eHBB1 zOklP3gyMbQ&oanE_q$9aLw+ZWzhe^vAnDBvi;H3L1#pT3Dnnz_=m8M2;eejm-l?(# zc8B{#7wfPaApfqIwh)`(8$B z{so zl&t@XoS}kS{seBer_2dj_$j+xTXMk4;PZ}Y^f^ik)4%ckqh6kO8nlU>uP?&hCVXX7 ziCi0|O==$P^YuR7HbYi!FtZ}Kfw||nO_XuowXFeu@o+sz_Ue3Zye4Pp-4}InNm%gh ze0q?)ed(Dl`&R!JuJ`I@Ilw^Pn!Ci|tz&>A=oeENpuX72U{4dL4O1iBoM7{uIH$p9 zO`>+ZReVM@oKe%jDTyVxfj5Y2-2QQkUG6t9tTMFu7pDM+1ri|T>!MR!>!(({h4)aj z(6sk_%*T%(IlcR07{t#X(1a(};!nm^)x@8(+m+_vZT>`k-m z!0F@cuSVpGI@>P3^K{4Q%_r#%ueLyizJ`FAv!&y{t8*P*izd_GIx`jBpJYCT*F6kF z80Ntnh71(XUw{;_z7iTXQev%s)7#J~9zHQeOe;@@mU2F1(^eNr!G2v2Qc6Lpp zOXC5}46ti*Cw}ehWna+)=r6m)U;AB+>y@th!RzTg{;q0$q;0)i2b6_e;b-S<`FEg` z;4$-R)Qqbn%n#`AYqoyY@bc_%qZL@R5!vSp97$L_POS$h|iI%CZNa=`7aJR3FsZy1ZRcW~%jlKOIEHb#MrXBFI= zyM7J3@GT4nfzU8r&jOTa2FSTBKtWs7&>)S3|7K6t7SrW6(aX)LZ(>@5(e(;OO38RD z^_ALkn7J^ldHWbKi&+RPnHdWDA(_$0j^LZOoa#L{c3s9R+beg@ji)}deR$g8-5L_!SV6>}z;x9sALvUZ9#?vAmkZ|=96R!?j8 zfLqX>tMvAdhm`xf*8p!TJ9Y|g8VU+)hu@|R-raQ@+yhIz?o5F4*dnyyDGPw+8cih+ z)+ftV5EMu(MU`k>5(R-^b}TrF2y={T2#l+0Fc1-y3od%Ic~)n+C;e{axYOb6#ip0i ztIyNUDmpqk*;mOzQ7=)b+eg!07M|~CE37(B z5&0Es_V4ryIgb{R8u9~RDGcNFl!i1%b zZb2fZojGK)V}Y$;1srPm^3nC{Hpj%|WdHa$F=|6A)TU-q&)odof30CTxw)(y4g5tc zN)!aDF3ZT*yVh-%$lJ6+h^>4$(A?6e=K?fu7dJjDP_%vOT^%fXnq}qbi-e0lrviq- z*s^RanPAUannnzEb+%-Uj@^-mOWdf4|O87>X>T#@Jv+i>#jS@d6okdapZwr_P*#!N` z>+TP5peT|}-PF_$fQ1&UkJVGJnjB*jr!K63d`@CMe=MN%Q3;QQAW>fngBV^vz|OfE zX%*;W3mxQfDJ6}KncPt~v<4q?g@R*XhCQ^gu`$2ZNJXsA{b{D!7cOM2+Cm;}&I*Xr zzg~Squp*@|dAYea0MvC-gmDm> z5F#QXh@0{&ov;v&0j(S_vofTYH2&_^;RgCULAC`F-E zO(9$`=*(Ih6S5G1TmUVv!n<@Qa1@Pm|DB zhEF5G?H&f?ljYa86s#-qCVtCfk^sZCX!}}?ynJh5Uq&)Z!Vvo zAQf8IhD&z9b|pHc+*lKnHTc=`fWo-e72(+!kCpD zFYM|oc%O9c>}}?B?Sz8ICdS%Ri*)yB-U>9IQ zFJ{uC|2#KzEY&zlU4#mO0#;{Nk8g+*~s>hXBv>=<#Dp!*cT%ue(75Zmgpz zRm6b;s6%V?`8Vx%M!dH7VuMb8?Frx(HnxBBR;G7?N>cqNnU&$;P3Hlp zV6Grq4dIiUTCeT zlLgT8RakigM!;tg0VKB- zP@x>`YEt~rA3L|@Sp_*FiZAf?-5ovg)uIO;F_CABaB<;`)CWZl3_M0o57zhY4=x_B zH(xq}8clYLE_eoJEF`?Qng8y1doCB_6Ol4A#0?bd_=xYV`S;yh76AJYXu61UU&X%S2}Mf#o96DwBMmh#KTG-_x(I zD69R@1>SRIjm#NB$XHN1NJ4k1ym&)Bnp>h9} z)Ba}wYU9SZ(tn?e3ZN|U#Njn!vSp5c9=#NKPD-w{Yx_C+Ipws zh1eu;{$q93V{P+0)b=S9w>X6-SzrC3Xd|$FjKdXVNY0F0nt%NAQPA(fH4i|oV2-H@InG(Q6{sYzM_DneyWqwukE?k@uF=1iH^H&+kfPZ_((0jx!I^? zf}&4YKRx3T>nGt&t5_PWJeohgsR?sHR;UuZ6tK(hYM>lStU2LEDegy9Aix;J0 zmTeKLf^eCCdncshPG28^6a)i9w)sS}r0+Gz=C{SR2)l}+nd@@O-(SS{7$`#3tSR~t zah@&9AHZ4k(J{q#?AbDAiM=opvp0?fy)#U{iVWlR0MD5>1T2S* znwmPCo>V2BGl~K7jzYm(+w)?jJ(@mg?WV*91tOeYb(wZ9(*7@yi7`Ys@QRS+T6T6E zCQqNbi^-k;c|*oKy9BU!i9l&xFA5C|)m!EP7YDh+k$?#Z!WEfepheY5aYI$Wi%N+c zgOI0ccOoZX3FnQ<4cwwi)4bpENH}O;_wK`_XCW{!-cvqW5~h&T2249`QaZ7JsuJ|h z!0_IvOOScE@uk?g2y@D@@4s?d;=6IA-NrYx3W7jzVD zh$|gO%l=uqvfT)REcIqO(%#a${dY$KEbi69Q`(Oz67V8x#&H$IO$ftPVGB0&wz?10yb*DWmsg=&Gmait(mL?ObqW)hX$^`2Ki zAQtQ;C8(JypsIxTElcyN>D%FE{YF!Zzdo;lm^kk;^%kzV2u zAadiPxQ7#)b9$%z$+s9|!|yJ6NKu1z05TlK+r?#^M6HGq(W64C7Z*C*>qL0vL@EXs z>T_XK5%#R{%&9-vN$@`aU!-z!gE+a1`ryRZEa>4Ma8N5A_0t_71=Y9~*Fs{Aj>Z1j zV%t{?<>m?cW6e)_Z;Z0%uBHk8r|`tareM0eq|UOPM1r1FV^|2Z0yI;FZkm^iuYVQ$ zu{LE^?>Q&le4?f2vs?#SJoiLNf*wM^X9f*ai%o*ZWUPe2h~0R)9Oy=R22 z%MDc7L@-#WM@20UGXO6Is8zH)3Wd=V9@146N(Ibc5QP%OrEo`C>SOY2du-oIW%dFx zzLkV1)_@Uche6TogaU>ynb5cqYfa|*E z=DjkEI-GbcoZ>7#)y&^J{4cpA8a#436He@F0d z)Gj7H)qVUpAt4M6#;FR|pIUg&-2{CgiXCtI>-LY{!P$ksscXwk5Eh_k!YLr20@(lS z*ROvq?##aoZd^)4p>#q-;bRP96m(1f0XucHpP?B>7E(S`6kP^-!qL!^1;=Q7QQC+@>Z* zFfI$Mp@(2C67-wJA3sX09-Zj`NSLqW&88vkntAmXk9xx zyHNeK9j*05164-pTbi8YsZlHa3f$ zySuyd&NcOewp9#8iMu&gV!jk33!F;T>CWvzO@wk@blY}mly8r{dRO(!8A-cE}01jq)R8PS4?ByI;fGfXAbp+MK z2?mBSTcCmxfbM{x{qGM3U^7Vx-*jB~Um*bmb49Tp@ukGKO6dP4nxi@+q+=%zAMUO- z&rRvUq*LS-7f0^!4z&MG)QFSuRHgW~$`;~b8CZ=>8s}x+^sKisFR$qUHK7EH{e|hw zhum=wtX2atREhd~|& zgA#z`%wkspg%r#~y$5hRBJFh8aqYzlWZ^@q`Ec#-JJ6`D;^9+j1>F z7RXyUUOt2U4t!cSs(wE|*FuhU3AM^w--%f?VFaPbqz{QWkV{_1#>UkR4KgFs)6=D# zQ3fc~uo?KEg%Z?dYTJ<&7~HI^x6p%76b$vk-6L~XSVO!o{F}W%nq0a{{F`p_tA7D_ zC=hQzj)U1m?IKDb>~4iB6oYko16B>{>f+1h*;$9G@qPW)XnORUp<0EWDEdM?Nm{B$ zuzB~MqX#}t0g){)O`w)>tTG)DvY{a}%=7c-2o{7b80x<84>$ses;#dsZGfVFl%x9z z%x`wh@9aZ^KQNE}%nN}*>V8C z;B2p5LB$_)+ntq_m6VLJ%e9S$T1>xhUZO&qgbjnY)X!&OgIw|u_tE+tuLtso z{0-uM+bUWwE0KGzPea6}w%^AeR*i8Lc!symfl|!@Mw9i;%r>4y6O)EN0uZcLG?=Q|me-Wadw^~qoL1^zQiCA~v$ z8Gxh7pE#os(BL?=PZbdtEatF8RsdDe2OayjCfF~mFFQK?8r zL`0LUFy?377H*p1;A`yrv$OyK+16{| z)8GwWg9!NS-0rcTIUQh|jF; z#sRu^_{%rsKRr`UQK0CE7BPIab4v29tZ$siB*9Wjp1Yd8aYv oB77Vvn3gl#yLT0S`;uv8 = { + W: '万', + B: '筒', + T: '条', + } + + return suitMap[value] ?? value +} + +export function toRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null +} + +export function toStringOrEmpty(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + return '' +} + +export function toFiniteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +export function toBoolean(value: unknown): boolean { + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'number') { + return value !== 0 + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + return normalized === '1' || normalized === 'true' || normalized === 'yes' + } + return false +} + +export function normalizeActionName(value: unknown): string { + const raw = toStringOrEmpty(value).trim().toLowerCase() + if (!raw) { + return '' + } + const actionMap: Record = { + candraw: 'draw', + draw: 'draw', + candiscard: 'discard', + discard: 'discard', + canpeng: 'peng', + peng: 'peng', + cangang: 'gang', + gang: 'gang', + canhu: 'hu', + hu: 'hu', + canpass: 'pass', + pass: 'pass', + } + + return actionMap[raw.replace(/[_\-\s]/g, '')] ?? raw +} diff --git a/src/game/chengdu/room-normalizers.ts b/src/game/chengdu/room-normalizers.ts new file mode 100644 index 0000000..9b9bf20 --- /dev/null +++ b/src/game/chengdu/room-normalizers.ts @@ -0,0 +1,421 @@ +import { + DEFAULT_MAX_PLAYERS, + type GameState, + type RoomPlayerState, + type RoomState, +} from '../../store/active-room-store' +import { type PendingClaimOption } from './types' +import { + humanizeSuit, + normalizeActionName, + toBoolean, + toFiniteNumber, + toRecord, + toStringOrEmpty, +} from './parser-utils' + +function normalizeScores(value: unknown): Record { + const record = toRecord(value) + if (!record) { + return {} + } + + const scores: Record = {} + for (const [key, score] of Object.entries(record)) { + const parsed = toFiniteNumber(score) + if (parsed !== null) { + scores[key] = parsed + } + } + return scores +} + +export function normalizeTileList(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value + .flatMap((item) => { + if (Array.isArray(item)) { + return item.map((nested) => toStringOrEmpty(nested)).filter(Boolean) + } + const record = toRecord(item) + if (record) { + const explicit = + toStringOrEmpty( + record.tile ?? record.Tile ?? record.code ?? record.Code ?? record.name ?? record.Name, + ) || '' + if (explicit) { + return [explicit] + } + + const suit = toStringOrEmpty(record.suit ?? record.Suit) + const tileValue = toStringOrEmpty(record.value ?? record.Value) + if (suit && tileValue) { + return [`${humanizeSuit(suit)}${tileValue}`] + } + + return [] + } + return [toStringOrEmpty(item)].filter(Boolean) + }) + .filter(Boolean) +} + +function normalizeMeldGroups(value: unknown): string[][] { + if (!Array.isArray(value)) { + return [] + } + + return value + .map((item) => { + if (Array.isArray(item)) { + return normalizeTileList(item) + } + const record = toRecord(item) + if (record) { + const nested = record.tiles ?? record.Tiles ?? record.meld ?? record.Meld + if (nested) { + return normalizeTileList(nested) + } + } + return normalizeTileList([item]) + }) + .filter((group) => group.length > 0) +} + +function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null { + const player = toRecord(input) + if (!player) { + return null + } + + const playerId = toStringOrEmpty( + player.playerId ?? + player.player_id ?? + player.PlayerID ?? + player.UserID ?? + player.user_id ?? + player.id, + ) + if (!playerId) { + return null + } + + const seatIndex = toFiniteNumber( + player.index ?? + player.Index ?? + player.seat ?? + player.Seat ?? + player.position ?? + player.Position ?? + player.player_index, + ) + const hand = normalizeTileList(player.hand ?? player.Hand) + + return { + index: seatIndex ?? fallbackIndex, + playerId, + displayName: + toStringOrEmpty( + player.playerName ?? + player.player_name ?? + player.PlayerName ?? + player.username ?? + player.nickname, + ) || undefined, + ready: Boolean(player.ready), + handCount: + toFiniteNumber(player.handCount ?? player.hand_count ?? player.HandCount) ?? + (hand.length > 0 ? hand.length : undefined), + hand, + melds: normalizeMeldGroups(player.melds ?? player.Melds), + outTiles: normalizeTileList(player.outTiles ?? player.out_tiles ?? player.OutTiles), + hasHu: toBoolean(player.hasHu ?? player.has_hu ?? player.HasHu), + missingSuit: + toStringOrEmpty(player.missingSuit ?? player.missing_suit ?? player.MissingSuit) || null, + } +} + +function normalizePublicGameState(source: Record): GameState | null { + const publicPlayers = (Array.isArray(source.players) ? source.players : []) + .map((item, index) => normalizePlayer(item, index)) + .filter((item): item is RoomPlayerState => Boolean(item)) + .sort((a, b) => a.index - b.index) + + const currentTurnPlayerId = toStringOrEmpty( + source.current_turn_player ?? source.currentTurnPlayer ?? source.current_turn_player_id, + ) + const currentTurnIndex = + publicPlayers.find((player) => player.playerId === currentTurnPlayerId)?.index ?? null + + return { + rule: null, + state: { + phase: toStringOrEmpty(source.phase), + dealerIndex: 0, + currentTurn: currentTurnIndex ?? 0, + needDraw: toBoolean(source.need_draw ?? source.needDraw), + players: publicPlayers.map((player) => ({ + playerId: player.playerId, + index: player.index, + ready: player.ready, + })), + wall: Array.from({ + length: toFiniteNumber(source.wall_count ?? source.wallCount) ?? 0, + }).map((_, index) => `wall-${index}`), + lastDiscardTile: + toStringOrEmpty(source.last_discard_tile ?? source.lastDiscardTile) || null, + lastDiscardBy: toStringOrEmpty(source.last_discard_by ?? source.lastDiscardBy), + pendingClaim: toRecord(source.pending_claim ?? source.pendingClaim), + winners: Array.isArray(source.winners) + ? source.winners.map((item) => toStringOrEmpty(item)).filter(Boolean) + : [], + scores: normalizeScores(source.scores), + lastDrawPlayerId: '', + lastDrawFromGang: false, + lastDrawIsLastTile: false, + huWay: '', + }, + } +} + +export function normalizePendingClaimOptions(value: unknown): PendingClaimOption[] { + const pendingClaim = toRecord(value) + if (!pendingClaim) { + return [] + } + + const rawOptions = + (Array.isArray(pendingClaim.options) ? pendingClaim.options : null) ?? + (Array.isArray(pendingClaim.Options) ? pendingClaim.Options : null) ?? + [] + + const optionsFromArray = rawOptions + .map((option) => { + const record = toRecord(option) + if (!record) { + return null + } + const playerId = toStringOrEmpty( + record.playerId ?? record.player_id ?? record.PlayerID ?? record.user_id ?? record.UserID, + ) + if (!playerId) { + return null + } + const actions = new Set() + for (const [key, enabled] of Object.entries(record)) { + if (typeof enabled === 'boolean' && enabled) { + const normalized = normalizeActionName(key) + if (normalized && normalized !== 'playerid' && normalized !== 'userid') { + actions.add(normalized) + } + } + } + if (Array.isArray(record.actions)) { + for (const action of record.actions) { + const normalized = normalizeActionName(action) + if (normalized) { + actions.add(normalized) + } + } + } + + return { playerId, actions: [...actions] } + }) + .filter((item): item is PendingClaimOption => Boolean(item)) + + if (optionsFromArray.length > 0) { + return optionsFromArray + } + + const claimPlayerId = toStringOrEmpty( + pendingClaim.playerId ?? + pendingClaim.player_id ?? + pendingClaim.PlayerID ?? + pendingClaim.user_id ?? + pendingClaim.UserID, + ) + if (!claimPlayerId) { + return [] + } + + const actions = Object.entries(pendingClaim) + .filter(([, enabled]) => typeof enabled === 'boolean' && enabled) + .map(([key]) => normalizeActionName(key)) + .filter(Boolean) + + return actions.length > 0 ? [{ playerId: claimPlayerId, actions }] : [] +} + +function extractCurrentTurnIndex(value: Record): number | null { + const game = toRecord(value.game) + const gameState = toRecord(game?.state) + const keys = [ + gameState?.currentTurn, + gameState?.current_turn, + gameState?.currentTurnIndex, + gameState?.current_turn_index, + value.currentTurnIndex, + value.current_turn_index, + value.currentPlayerIndex, + value.current_player_index, + value.turnIndex, + value.turn_index, + value.activePlayerIndex, + value.active_player_index, + ] + for (const key of keys) { + const parsed = toFiniteNumber(key) + if (parsed !== null) { + return parsed + } + } + return null +} + +function normalizeGame(input: unknown): GameState | null { + const game = toRecord(input) + if (!game) { + return null + } + + const rule = toRecord(game.rule) + const rawState = toRecord(game.state) + const playersRaw = + (Array.isArray(rawState?.players) ? rawState?.players : null) ?? + (Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ?? + [] + + const normalizedPlayers = playersRaw + .map((item, index) => normalizePlayer(item, index)) + .filter((item): item is RoomPlayerState => Boolean(item)) + + return { + rule: rule + ? { + name: toStringOrEmpty(rule.name), + isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow), + hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong), + } + : null, + state: rawState + ? { + phase: toStringOrEmpty(rawState.phase), + dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0, + currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0, + needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw), + players: normalizedPlayers, + wall: Array.isArray(rawState.wall) + ? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean) + : [], + lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null, + lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by), + pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim), + winners: Array.isArray(rawState.winners) + ? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean) + : [], + scores: normalizeScores(rawState.scores), + lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id), + lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang), + lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile), + huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way), + } + : null, + } +} + +export function normalizeRoom(input: unknown, currentRoomState: RoomState): RoomState | null { + const source = toRecord(input) + if (!source) { + return null + } + + let room = source + let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id) + if (!id) { + const nestedRoom = toRecord(room.data) + if (nestedRoom) { + room = nestedRoom + id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id) + } + } + if (!id) { + return null + } + + const maxPlayers = + toFiniteNumber(room.maxPlayers ?? room.max_players) ?? currentRoomState.maxPlayers ?? DEFAULT_MAX_PLAYERS + const playersRaw = + (Array.isArray(room.players) ? room.players : null) ?? + (Array.isArray(room.playerList) ? room.playerList : null) ?? + (Array.isArray(room.player_list) ? room.player_list : null) ?? + [] + const playerIdsRaw = + (Array.isArray(room.player_ids) ? room.player_ids : null) ?? + (Array.isArray(room.playerIds) ? room.playerIds : null) ?? + [] + + const players = playersRaw + .map((item, index) => normalizePlayer(item, index)) + .filter((item): item is RoomPlayerState => Boolean(item)) + .sort((a, b) => a.index - b.index) + const playersFromIds = playerIdsRaw + .map((item, index) => ({ + index, + playerId: toStringOrEmpty(item), + ready: false, + hand: [], + melds: [], + outTiles: [], + hasHu: false, + })) + .filter((item) => Boolean(item.playerId)) + const resolvedPlayers = players.length > 0 ? players : playersFromIds + const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount) + const game = normalizeGame(room.game) ?? normalizePublicGameState(room) + const playersFromGame = game?.state?.players + .map((player, index) => + normalizePlayer( + { + player_id: player.playerId, + index: player.index ?? index, + }, + index, + ), + ) + .filter((item): item is RoomPlayerState => Boolean(item)) + const finalPlayers = + resolvedPlayers.length > 0 + ? resolvedPlayers + : playersFromGame && playersFromGame.length > 0 + ? playersFromGame + : [] + const derivedTurnIndex = + extractCurrentTurnIndex(room) ?? + (game?.state + ? finalPlayers.find( + (player) => player.playerId === toStringOrEmpty(room.current_turn_player ?? room.currentTurnPlayer), + )?.index ?? null + : null) + + return { + id, + name: toStringOrEmpty(room.name) || currentRoomState.name, + gameType: toStringOrEmpty(room.gameType ?? room.game_type) || currentRoomState.gameType || 'chengdu', + ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id ?? room.OwnerID ?? room.ownerID), + maxPlayers, + playerCount: + parsedPlayerCount ?? + toFiniteNumber(room.player_count ?? room.playerCount ?? room.playerCount) ?? + finalPlayers.length, + status: toStringOrEmpty(room.status) || currentRoomState.status || 'waiting', + createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || currentRoomState.createdAt, + updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || currentRoomState.updatedAt, + game: game ?? currentRoomState.game, + players: finalPlayers, + currentTurnIndex: derivedTurnIndex, + myHand: [], + } +} diff --git a/src/game/chengdu/types.ts b/src/game/chengdu/types.ts new file mode 100644 index 0000000..a4e9acb --- /dev/null +++ b/src/game/chengdu/types.ts @@ -0,0 +1,60 @@ +import type { ComputedRef, Ref } from 'vue' +import type { StoredAuth } from '../../types/session' +import type { RoomPlayerState } from '../../store/active-room-store' +import { activeRoomState } from '../../store/active-room-store' + +export type SeatKey = 'top' | 'right' | 'bottom' | 'left' + +export interface ActionEventLike { + type?: unknown + status?: unknown + requestId?: unknown + request_id?: unknown + roomId?: unknown + room_id?: unknown + payload?: unknown + data?: unknown +} + +export interface PendingClaimOption { + playerId: string + actions: string[] +} + +export interface ActionButtonState { + type: 'draw' | 'discard' | 'peng' | 'gang' | 'hu' | 'pass' + label: string + disabled: boolean +} + +export interface SeatView { + key: SeatKey + player: RoomPlayerState | null + isSelf: boolean + isTurn: boolean + label: string + subLabel: string +} + +export interface ChengduGameRoomModel { + auth: Ref + roomState: typeof activeRoomState + roomId: ComputedRef + roomName: ComputedRef + currentUserId: ComputedRef + loggedInUserName: ComputedRef + wsStatus: Ref<'disconnected' | 'connecting' | 'connected'> + wsError: Ref + wsMessages: Ref + startGamePending: Ref + leaveRoomPending: Ref + canStartGame: ComputedRef + seatViews: ComputedRef + selectedTile: Ref + actionButtons: ComputedRef + connectWs: () => Promise + sendStartGame: () => void + selectTile: (tile: string) => void + sendGameAction: (type: ActionButtonState['type']) => void + backHall: () => void +} diff --git a/src/features/chengdu-game/useChengduGameRoom.ts b/src/game/chengdu/useChengduGameRoom.ts similarity index 53% rename from src/features/chengdu-game/useChengduGameRoom.ts rename to src/game/chengdu/useChengduGameRoom.ts index fd66f34..e5d90e3 100644 --- a/src/features/chengdu-game/useChengduGameRoom.ts +++ b/src/game/chengdu/useChengduGameRoom.ts @@ -1,87 +1,34 @@ -import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef, type Ref } from 'vue' +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import type { RouteLocationNormalizedLoaded, Router } from 'vue-router' import type { AuthSession } from '../../api/authed-request' import { refreshAccessToken } from '../../api/auth' import { getUserInfo } from '../../api/user' import { - DEFAULT_MAX_PLAYERS, activeRoomState, destroyActiveRoomState, mergeActiveRoomState, resetActiveRoomState, - type GameState, type RoomPlayerState, type RoomState, -} from '../../state/active-room' +} from '../../store/active-room-store' import { readStoredAuth, writeStoredAuth } from '../../utils/auth-storage' -import type { StoredAuth } from '../../types/session' - -export type SeatKey = 'top' | 'right' | 'bottom' | 'left' - -interface ActionEventLike { - type?: unknown - status?: unknown - requestId?: unknown - request_id?: unknown - roomId?: unknown - room_id?: unknown - payload?: unknown - data?: unknown -} - -interface PendingClaimOption { - playerId: string - actions: string[] -} - -export interface ActionButtonState { - type: 'draw' | 'discard' | 'peng' | 'gang' | 'hu' | 'pass' - label: string - disabled: boolean -} - -export interface SeatView { - key: SeatKey - player: RoomPlayerState | null - isSelf: boolean - isTurn: boolean - label: string - subLabel: string -} - -function humanizeSuit(value: string): string { - const suitMap: Record = { - W: '万', - B: '筒', - T: '条', - } - - return suitMap[value] ?? value -} - -export interface ChengduGameRoomModel { - auth: Ref - roomState: typeof activeRoomState - roomId: ComputedRef - roomName: ComputedRef - currentUserId: ComputedRef - loggedInUserName: ComputedRef - wsStatus: Ref<'disconnected' | 'connecting' | 'connected'> - wsError: Ref - wsMessages: Ref - startGamePending: Ref - leaveRoomPending: Ref - canStartGame: ComputedRef - seatViews: ComputedRef - selectedTile: Ref - actionButtons: ComputedRef - connectWs: () => Promise - sendStartGame: () => void - selectTile: (tile: string) => void - sendGameAction: (type: ActionButtonState['type']) => void - backHall: () => void -} +import type { + ActionButtonState, + ActionEventLike, + ChengduGameRoomModel, + PendingClaimOption, + SeatKey, + SeatView, +} from './types' +import { toRecord, toStringOrEmpty } from './parser-utils' +import { normalizePendingClaimOptions, normalizeRoom, normalizeTileList } from './room-normalizers' +export type { + ActionButtonState, + ChengduGameRoomModel, + SeatKey, + SeatView, +} from './types' const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws' export function useChengduGameRoom( @@ -221,6 +168,10 @@ export function useChengduGameRoom( index: 0, playerId: currentUserId.value, ready: false, + hand: [], + melds: [], + outTiles: [], + hasHu: false, }) } @@ -281,16 +232,16 @@ export function useChengduGameRoom( } function logWsSend(message: unknown): void { - console.log('[WS][client] 发送:', message) + console.log('[WS][client] 鍙戦€?', message) } function logWsReceive(kind: string, payload?: unknown): void { const now = new Date().toLocaleTimeString() if (payload === undefined) { - console.log(`[WS][${now}] 收到${kind}`) + console.log(`[WS][${now}] 鏀跺埌${kind}`) return } - console.log(`[WS][${now}] 收到${kind}:`, payload) + console.log(`[WS][${now}] 鏀跺埌${kind}:`, payload) } function disconnectWs(): void { @@ -301,43 +252,6 @@ export function useChengduGameRoom( wsStatus.value = 'disconnected' } - function toRecord(value: unknown): Record | null { - return typeof value === 'object' && value !== null ? (value as Record) : null - } - - function toStringOrEmpty(value: unknown): string { - if (typeof value === 'string') { - return value - } - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value) - } - return '' - } - - function normalizeActionName(value: unknown): string { - const raw = toStringOrEmpty(value).trim().toLowerCase() - if (!raw) { - return '' - } - const actionMap: Record = { - candraw: 'draw', - draw: 'draw', - candiscard: 'discard', - discard: 'discard', - canpeng: 'peng', - peng: 'peng', - cangang: 'gang', - gang: 'gang', - canhu: 'hu', - hu: 'hu', - canpass: 'pass', - pass: 'pass', - } - - return actionMap[raw.replace(/[_\-\s]/g, '')] ?? raw - } - function toSession(source: NonNullable): AuthSession { return { token: source.token, @@ -384,7 +298,7 @@ export function useChengduGameRoom( } writeStoredAuth(auth.value) } catch { - wsError.value = '获取当前用户 ID 失败,部分操作可能不可用' + wsError.value = '鑾峰彇褰撳墠鐢ㄦ埛 ID 澶辫触锛岄儴鍒嗘搷浣滃彲鑳戒笉鍙敤' } } @@ -422,407 +336,6 @@ export function useChengduGameRoom( } } - function toFiniteNumber(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) { - return value - } - if (typeof value === 'string' && value.trim()) { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null - } - return null - } - - function toBoolean(value: unknown): boolean { - if (typeof value === 'boolean') { - return value - } - if (typeof value === 'number') { - return value !== 0 - } - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase() - return normalized === '1' || normalized === 'true' || normalized === 'yes' - } - return false - } - - function normalizeScores(value: unknown): Record { - const record = toRecord(value) - if (!record) { - return {} - } - - const scores: Record = {} - for (const [key, score] of Object.entries(record)) { - const parsed = toFiniteNumber(score) - if (parsed !== null) { - scores[key] = parsed - } - } - return scores - } - - function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null { - const player = toRecord(input) - if (!player) { - return null - } - - const playerId = toStringOrEmpty( - player.playerId ?? - player.player_id ?? - player.PlayerID ?? - player.UserID ?? - player.user_id ?? - player.id, - ) - if (!playerId) { - return null - } - - const seatIndex = toFiniteNumber( - player.index ?? - player.Index ?? - player.seat ?? - player.Seat ?? - player.position ?? - player.Position ?? - player.player_index, - ) - return { - index: seatIndex ?? fallbackIndex, - playerId, - displayName: - toStringOrEmpty( - player.playerName ?? - player.player_name ?? - player.PlayerName ?? - player.username ?? - player.nickname, - ) || undefined, - ready: Boolean(player.ready), - handCount: - toFiniteNumber(player.handCount ?? player.hand_count ?? player.HandCount) ?? undefined, - melds: normalizeTileList(player.melds ?? player.Melds), - outTiles: normalizeTileList(player.outTiles ?? player.out_tiles ?? player.OutTiles), - hasHu: toBoolean(player.hasHu ?? player.has_hu ?? player.HasHu), - missingSuit: - toStringOrEmpty(player.missingSuit ?? player.missing_suit ?? player.MissingSuit) || null, - } - } - - function normalizeTileList(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - - return value - .flatMap((item) => { - if (Array.isArray(item)) { - return item.map((nested) => toStringOrEmpty(nested)).filter(Boolean) - } - const record = toRecord(item) - if (record) { - const explicit = - toStringOrEmpty( - record.tile ?? record.Tile ?? record.code ?? record.Code ?? record.name ?? record.Name, - ) || '' - if (explicit) { - return [explicit] - } - - const suit = toStringOrEmpty(record.suit ?? record.Suit) - const tileValue = toStringOrEmpty(record.value ?? record.Value) - if (suit && tileValue) { - return [`${humanizeSuit(suit)}${tileValue}`] - } - - return [] - } - return [toStringOrEmpty(item)].filter(Boolean) - }) - .filter(Boolean) - } - - function normalizePublicGameState(source: Record): GameState | null { - const publicPlayers = (Array.isArray(source.players) ? source.players : []) - .map((item, index) => normalizePlayer(item, index)) - .filter((item): item is RoomPlayerState => Boolean(item)) - .sort((a, b) => a.index - b.index) - - const currentTurnPlayerId = toStringOrEmpty( - source.current_turn_player ?? source.currentTurnPlayer ?? source.current_turn_player_id, - ) - const currentTurnIndex = - publicPlayers.find((player) => player.playerId === currentTurnPlayerId)?.index ?? null - - return { - rule: null, - state: { - phase: toStringOrEmpty(source.phase), - dealerIndex: 0, - currentTurn: currentTurnIndex ?? 0, - needDraw: toBoolean(source.need_draw ?? source.needDraw), - players: publicPlayers.map((player) => ({ - playerId: player.playerId, - index: player.index, - ready: player.ready, - })), - wall: Array.from({ - length: toFiniteNumber(source.wall_count ?? source.wallCount) ?? 0, - }).map((_, index) => `wall-${index}`), - lastDiscardTile: - toStringOrEmpty(source.last_discard_tile ?? source.lastDiscardTile) || null, - lastDiscardBy: toStringOrEmpty(source.last_discard_by ?? source.lastDiscardBy), - pendingClaim: toRecord(source.pending_claim ?? source.pendingClaim), - winners: Array.isArray(source.winners) - ? source.winners.map((item) => toStringOrEmpty(item)).filter(Boolean) - : [], - scores: normalizeScores(source.scores), - lastDrawPlayerId: '', - lastDrawFromGang: false, - lastDrawIsLastTile: false, - huWay: '', - }, - } - } - - function normalizePendingClaimOptions(value: unknown): PendingClaimOption[] { - const pendingClaim = toRecord(value) - if (!pendingClaim) { - return [] - } - - const rawOptions = - (Array.isArray(pendingClaim.options) ? pendingClaim.options : null) ?? - (Array.isArray(pendingClaim.Options) ? pendingClaim.Options : null) ?? - [] - - const optionsFromArray = rawOptions - .map((option) => { - const record = toRecord(option) - if (!record) { - return null - } - const playerId = toStringOrEmpty( - record.playerId ?? record.player_id ?? record.PlayerID ?? record.user_id ?? record.UserID, - ) - if (!playerId) { - return null - } - const actions = new Set() - for (const value of Object.values(record)) { - if (typeof value === 'boolean' && value) { - continue - } - } - for (const [key, enabled] of Object.entries(record)) { - if (typeof enabled === 'boolean' && enabled) { - const normalized = normalizeActionName(key) - if (normalized && normalized !== 'playerid' && normalized !== 'userid') { - actions.add(normalized) - } - } - } - if (Array.isArray(record.actions)) { - for (const action of record.actions) { - const normalized = normalizeActionName(action) - if (normalized) { - actions.add(normalized) - } - } - } - - return { playerId, actions: [...actions] } - }) - .filter((item): item is PendingClaimOption => Boolean(item)) - - if (optionsFromArray.length > 0) { - return optionsFromArray - } - - const claimPlayerId = toStringOrEmpty( - pendingClaim.playerId ?? - pendingClaim.player_id ?? - pendingClaim.PlayerID ?? - pendingClaim.user_id ?? - pendingClaim.UserID, - ) - if (!claimPlayerId) { - return [] - } - - const actions = Object.entries(pendingClaim) - .filter(([, enabled]) => typeof enabled === 'boolean' && enabled) - .map(([key]) => normalizeActionName(key)) - .filter(Boolean) - - return actions.length > 0 ? [{ playerId: claimPlayerId, actions }] : [] - } - - function extractCurrentTurnIndex(value: Record): number | null { - const game = toRecord(value.game) - const gameState = toRecord(game?.state) - const keys = [ - gameState?.currentTurn, - gameState?.current_turn, - gameState?.currentTurnIndex, - gameState?.current_turn_index, - value.currentTurnIndex, - value.current_turn_index, - value.currentPlayerIndex, - value.current_player_index, - value.turnIndex, - value.turn_index, - value.activePlayerIndex, - value.active_player_index, - ] - for (const key of keys) { - const parsed = toFiniteNumber(key) - if (parsed !== null) { - return parsed - } - } - return null - } - - function normalizeGame(input: unknown): GameState | null { - const game = toRecord(input) - if (!game) { - return null - } - - const rule = toRecord(game.rule) - const rawState = toRecord(game.state) - const playersRaw = - (Array.isArray(rawState?.players) ? rawState?.players : null) ?? - (Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ?? - [] - - const normalizedPlayers = playersRaw - .map((item, index) => normalizePlayer(item, index)) - .filter((item): item is RoomPlayerState => Boolean(item)) - - return { - rule: rule - ? { - name: toStringOrEmpty(rule.name), - isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow), - hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong), - } - : null, - state: rawState - ? { - phase: toStringOrEmpty(rawState.phase), - dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0, - currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0, - needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw), - players: normalizedPlayers, - wall: Array.isArray(rawState.wall) - ? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean) - : [], - lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null, - lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by), - pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim), - winners: Array.isArray(rawState.winners) - ? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean) - : [], - scores: normalizeScores(rawState.scores), - lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id), - lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang), - lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile), - huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way), - } - : null, - } - } - - function normalizeRoom(input: unknown): RoomState | null { - const source = toRecord(input) - if (!source) { - return null - } - - let room = source - let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id) - if (!id) { - const nestedRoom = toRecord(room.data) - if (nestedRoom) { - room = nestedRoom - id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id) - } - } - if (!id) { - return null - } - - const maxPlayers = - toFiniteNumber(room.maxPlayers ?? room.max_players) ?? roomState.value.maxPlayers ?? DEFAULT_MAX_PLAYERS - const playersRaw = - (Array.isArray(room.players) ? room.players : null) ?? - (Array.isArray(room.playerList) ? room.playerList : null) ?? - (Array.isArray(room.player_list) ? room.player_list : null) ?? - [] - const playerIdsRaw = - (Array.isArray(room.player_ids) ? room.player_ids : null) ?? - (Array.isArray(room.playerIds) ? room.playerIds : null) ?? - [] - - const players = playersRaw - .map((item, index) => normalizePlayer(item, index)) - .filter((item): item is RoomPlayerState => Boolean(item)) - .sort((a, b) => a.index - b.index) - const playersFromIds = playerIdsRaw - .map((item, index) => ({ - index, - playerId: toStringOrEmpty(item), - ready: false, - })) - .filter((item) => Boolean(item.playerId)) - const resolvedPlayers = players.length > 0 ? players : playersFromIds - const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount) - const game = normalizeGame(room.game) ?? normalizePublicGameState(room) - const playersFromGame = game?.state?.players - .map((player, index) => - normalizePlayer( - { - player_id: player.playerId, - index: player.index ?? index, - }, - index, - ), - ) - .filter((item): item is RoomPlayerState => Boolean(item)) - const finalPlayers = - resolvedPlayers.length > 0 ? resolvedPlayers : playersFromGame && playersFromGame.length > 0 ? playersFromGame : [] - const derivedTurnIndex = - extractCurrentTurnIndex(room) ?? - (game?.state - ? finalPlayers.find((player) => player.playerId === toStringOrEmpty(room.current_turn_player ?? room.currentTurnPlayer)) - ?.index ?? null - : null) - - return { - id, - name: toStringOrEmpty(room.name) || roomState.value.name, - gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu', - ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id ?? room.OwnerID ?? room.ownerID), - maxPlayers, - playerCount: - parsedPlayerCount ?? - toFiniteNumber(room.player_count ?? room.playerCount ?? room.playerCount) ?? - finalPlayers.length, - status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting', - createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt, - updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt, - game: game ?? roomState.value.game, - players: finalPlayers, - currentTurnIndex: derivedTurnIndex, - myHand: [], - } - } - function mergeRoomState(next: RoomState): void { if (roomId.value && next.id !== roomId.value) { return @@ -903,7 +416,7 @@ export function useChengduGameRoom( candidates.push(event) for (const candidate of candidates) { - const normalized = normalizeRoom(candidate) + const normalized = normalizeRoom(candidate, roomState.value) if (normalized) { mergeRoomState(normalized) break @@ -1210,3 +723,4 @@ export function useChengduGameRoom( backHall, } } + diff --git a/src/game/index.ts b/src/game/index.ts new file mode 100644 index 0000000..532305b --- /dev/null +++ b/src/game/index.ts @@ -0,0 +1 @@ +export * from './chengdu' diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..ebd9765 --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,2 @@ +export * from './tile' +export * from './room-state' diff --git a/src/models/room-state/constants.ts b/src/models/room-state/constants.ts new file mode 100644 index 0000000..a6abd72 --- /dev/null +++ b/src/models/room-state/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_MAX_PLAYERS = 4 diff --git a/src/models/room-state/engine-state.ts b/src/models/room-state/engine-state.ts new file mode 100644 index 0000000..783a955 --- /dev/null +++ b/src/models/room-state/engine-state.ts @@ -0,0 +1,19 @@ +import type { GamePlayerState } from './game-player-state' + +export interface EngineState { + phase: string + dealerIndex: number + currentTurn: number + needDraw: boolean + players: GamePlayerState[] + wall: string[] + lastDiscardTile: string | null + lastDiscardBy: string + pendingClaim: Record | null + winners: string[] + scores: Record + lastDrawPlayerId: string + lastDrawFromGang: boolean + lastDrawIsLastTile: boolean + huWay: string +} diff --git a/src/models/room-state/game-player-state.ts b/src/models/room-state/game-player-state.ts new file mode 100644 index 0000000..35f4ada --- /dev/null +++ b/src/models/room-state/game-player-state.ts @@ -0,0 +1,5 @@ +export interface GamePlayerState { + playerId: string + index: number + ready: boolean +} diff --git a/src/models/room-state/game-state.ts b/src/models/room-state/game-state.ts new file mode 100644 index 0000000..c25c764 --- /dev/null +++ b/src/models/room-state/game-state.ts @@ -0,0 +1,7 @@ +import type { EngineState } from './engine-state' +import type { RuleState } from './rule-state' + +export interface GameState { + rule: RuleState | null + state: EngineState | null +} diff --git a/src/models/room-state/index.ts b/src/models/room-state/index.ts new file mode 100644 index 0000000..20475c5 --- /dev/null +++ b/src/models/room-state/index.ts @@ -0,0 +1,9 @@ +export { DEFAULT_MAX_PLAYERS } from './constants' +export type { RoomStatus } from './room-status' +export type { PlayerState } from './player-state' +export type { RoomPlayerState } from './room-player-state' +export type { RuleState } from './rule-state' +export type { GamePlayerState } from './game-player-state' +export type { EngineState } from './engine-state' +export type { GameState } from './game-state' +export type { RoomState } from './room-state' diff --git a/src/models/room-state/player-state.ts b/src/models/room-state/player-state.ts new file mode 100644 index 0000000..e1fc6ca --- /dev/null +++ b/src/models/room-state/player-state.ts @@ -0,0 +1,7 @@ +export interface PlayerState { + playerId: string + hand: string[] + melds: string[][] + outTiles: string[] + hasHu: boolean +} diff --git a/src/models/room-state/room-player-state.ts b/src/models/room-state/room-player-state.ts new file mode 100644 index 0000000..6b6e544 --- /dev/null +++ b/src/models/room-state/room-player-state.ts @@ -0,0 +1,9 @@ +import type { PlayerState } from './player-state' + +export interface RoomPlayerState extends PlayerState { + index: number + displayName?: string + ready: boolean + handCount?: number + missingSuit?: string | null +} diff --git a/src/models/room-state/room-state.ts b/src/models/room-state/room-state.ts new file mode 100644 index 0000000..ded2b57 --- /dev/null +++ b/src/models/room-state/room-state.ts @@ -0,0 +1,19 @@ +import type { GameState } from './game-state' +import type { RoomPlayerState } from './room-player-state' +import type { RoomStatus } from './room-status' + +export interface RoomState { + id: string + name: string + gameType: string + ownerId: string + maxPlayers: number + playerCount: number + status: RoomStatus | string + createdAt: string + updatedAt: string + game: GameState | null + players: RoomPlayerState[] + currentTurnIndex: number | null + myHand: string[] +} diff --git a/src/models/room-state/room-status.ts b/src/models/room-state/room-status.ts new file mode 100644 index 0000000..bec836d --- /dev/null +++ b/src/models/room-state/room-status.ts @@ -0,0 +1 @@ +export type RoomStatus = 'waiting' | 'playing' | 'finished' diff --git a/src/models/room-state/rule-state.ts b/src/models/room-state/rule-state.ts new file mode 100644 index 0000000..615df9c --- /dev/null +++ b/src/models/room-state/rule-state.ts @@ -0,0 +1,5 @@ +export interface RuleState { + name: string + isBloodFlow: boolean + hasHongZhong: boolean +} diff --git a/src/models/tile.ts b/src/models/tile.ts new file mode 100644 index 0000000..5b16263 --- /dev/null +++ b/src/models/tile.ts @@ -0,0 +1,50 @@ +export type Suit = 'W' | 'T' | 'B' + +export interface Tile { + id: number + suit: Suit + value: number +} + + +export class TileModel { + id: number + suit: Suit + value: number + + constructor(tile: Tile) { + this.id = tile.id + this.suit = tile.suit + this.value = tile.value + } + + /** 花色中文 */ + get suitName(): string { + const map: Record = { + W: '万', + T: '筒', + B: '条', + } + return map[this.suit] + } + + /** 显示文本 */ + toString(): string { + return `${this.suitName}${this.value}[#${this.id}]` + } + + /** 是否同一张牌(和后端一致:按ID) */ + equals(other: TileModel): boolean { + return this.id === other.id + } + + /** 排序权重(用于手牌排序) */ + get sortValue(): number { + const suitOrder: Record = { + W: 0, + T: 1, + B: 2, + } + return suitOrder[this.suit] * 10 + this.value + } +} \ No newline at end of file diff --git a/src/state/active-room.ts b/src/store/active-room-store.ts similarity index 66% rename from src/state/active-room.ts rename to src/store/active-room-store.ts index a12be75..be9c7a6 100644 --- a/src/state/active-room.ts +++ b/src/store/active-room-store.ts @@ -1,70 +1,21 @@ import { ref } from 'vue' +import { + DEFAULT_MAX_PLAYERS, + type RoomPlayerState, + type RoomState, +} from '../models/room-state' -export const DEFAULT_MAX_PLAYERS = 4 -export type RoomStatus = 'waiting' | 'playing' | 'finished' - -export interface RoomPlayerState { - index: number - playerId: string - displayName?: string - ready: boolean - handCount?: number - melds?: string[] - outTiles?: string[] - hasHu?: boolean - missingSuit?: string | null -} - -export interface RuleState { - name: string - isBloodFlow: boolean - hasHongZhong: boolean -} - -export interface GamePlayerState { - playerId: string - index: number - ready: boolean -} - -export interface EngineState { - phase: string - dealerIndex: number - currentTurn: number - needDraw: boolean - players: GamePlayerState[] - wall: string[] - lastDiscardTile: string | null - lastDiscardBy: string - pendingClaim: Record | null - winners: string[] - scores: Record - lastDrawPlayerId: string - lastDrawFromGang: boolean - lastDrawIsLastTile: boolean - huWay: string -} - -export interface GameState { - rule: RuleState | null - state: EngineState | null -} - -export interface RoomState { - id: string - name: string - gameType: string - ownerId: string - maxPlayers: number - playerCount: number - status: RoomStatus | string - createdAt: string - updatedAt: string - game: GameState | null - players: RoomPlayerState[] - currentTurnIndex: number | null - myHand: string[] -} +export { + DEFAULT_MAX_PLAYERS, + type EngineState, + type GamePlayerState, + type GameState, + type PlayerState, + type RoomPlayerState, + type RoomState, + type RoomStatus, + type RuleState, +} from '../models/room-state' function createInitialRoomState(): RoomState { return { diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index cd11f5c..6b10914 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -17,28 +17,22 @@ import RightPlayerCard from '../components/game/RightPlayerCard.vue' import BottomPlayerCard from '../components/game/BottomPlayerCard.vue' import LeftPlayerCard from '../components/game/LeftPlayerCard.vue' import type {SeatPlayerCardModel} from '../components/game/seat-player-card' -import {type SeatKey, useChengduGameRoom} from '../features/chengdu-game/useChengduGameRoom' +import {type SeatKey, useChengduGameRoom} from '../game/chengdu' const route = useRoute() const router = useRouter() const { roomState, - roomId, roomName, loggedInUserName, wsStatus, wsError, wsMessages, - startGamePending, leaveRoomPending, - canStartGame, seatViews, selectedTile, - actionButtons, connectWs, - sendStartGame, selectTile, - sendGameAction, backHall, } = useChengduGameRoom(route, router) @@ -57,7 +51,6 @@ const roomStatusText = computed(() => { if (roomState.value.status === 'finished') { return '已结束' } - return '等待中' }) const currentPhaseText = computed(() => { @@ -68,7 +61,6 @@ const currentPhaseText = computed(() => { const phaseLabelMap: Record = { dealing: '发牌', - draw: '摸牌', discard: '出牌', action: '响应', settle: '结算', @@ -152,30 +144,6 @@ const seatDecor = computed>(() => { return result }) -const centerTimer = computed(() => { - const wallLeft = roomState.value.game?.state?.wall.length - if (typeof wallLeft === 'number' && Number.isFinite(wallLeft)) { - return String(wallLeft).padStart(2, '0') - } - - return String(roomState.value.playerCount).padStart(2, '0') -}) - -const selectedTileText = computed(() => selectedTile.value ?? '未选择') - -const pendingClaimText = computed(() => { - const claim = roomState.value.game?.state?.pendingClaim - if (!claim) { - return '无' - } - - try { - return JSON.stringify(claim) - } catch { - return '有待响应动作' - } -}) - const rightMessages = computed(() => wsMessages.value.slice(-16).reverse()) const floatingMissingSuit = computed(() => { @@ -193,10 +161,6 @@ const floatingMissingSuit = computed(() => { }) function missingSuitLabel(value: string | null | undefined): string { - if (!value) { - return '未定' - } - const suitMap: Record = { wan: '万', tong: '筒', @@ -217,16 +181,6 @@ function getBackImage(seat: SeatKey): string { return imageMap[seat] } -function actionTheme(type: string): 'gold' | 'jade' | 'blue' { - if (type === 'hu' || type === 'gang') { - return 'gold' - } - if (type === 'pass') { - return 'jade' - } - return 'blue' -} - function toggleMenu(): void { menuTriggerActive.value = true if (menuTriggerTimer !== null) { @@ -316,7 +270,6 @@ onBeforeUnmount(() => {
-
-
- 四川麻将 - {{ roomState.name || roomName || '成都麻将房' }} - 底注 6 亿 · 封顶 32 倍 -
@@ -392,14 +340,6 @@ onBeforeUnmount(() => {
-
- - 西 - {{ centerTimer }} - - -
-
{{ seatDecor.top.missingSuitLabel }} @@ -413,30 +353,8 @@ onBeforeUnmount(() => { {{ seatDecor.right.missingSuitLabel }}
-
- {{ roomStatusText }} - {{ currentPhaseText }} -
-
-

房间 {{ roomId || '--' }}

- 当前选择:{{ selectedTileText }} · 待响应:{{ pendingClaimText }} -
- -
- -
-

等待服务端下发 `my_hand`。

diff --git a/src/views/HallPage.vue b/src/views/HallPage.vue index fcf005c..e0a7fcd 100644 --- a/src/views/HallPage.vue +++ b/src/views/HallPage.vue @@ -4,8 +4,8 @@ import { useRouter } from 'vue-router' import { AuthExpiredError, type AuthSession } from '../api/authed-request' import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong' import { getUserInfo, type UserInfo } from '../api/user' -import { hydrateActiveRoomFromSelection } from '../state/active-room' -import type { RoomPlayerState } from '../state/active-room' +import { hydrateActiveRoomFromSelection } from '../store/active-room-store' +import type { RoomPlayerState } from '../store/active-room-store' import type { StoredAuth } from '../types/session' import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage' @@ -129,6 +129,10 @@ function mapRoomPlayers(room: RoomItem): RoomPlayerState[] { index: Number.isFinite(item.index) ? item.index : fallbackIndex, playerId: item.player_id, ready: Boolean(item.ready), + hand: [], + melds: [], + outTiles: [], + hasHu: false, })) .filter((item) => Boolean(item.playerId)) }