From 01c3511137261383d65273e0f377690d2ec3bdd1 Mon Sep 17 00:00:00 2001 From: "crper, ZTools port contributors" <8589561@qq.com> Date: Mon, 7 Sep 2026 15:17:47 +0800 Subject: [PATCH 1/2] feat: first commit --- plugins/gomoku-3d-ztools/.gitignore | 6 + plugins/gomoku-3d-ztools/LICENSE | 21 + plugins/gomoku-3d-ztools/NOTICE | 15 + plugins/gomoku-3d-ztools/README.md | 32 + plugins/gomoku-3d-ztools/README.upstream.md | 143 + .../gomoku-3d-ztools/README.upstream.zh.md | 143 + plugins/gomoku-3d-ztools/index.html | 12 + plugins/gomoku-3d-ztools/package-lock.json | 3680 +++++++++++++++++ plugins/gomoku-3d-ztools/package.json | 68 + .../gomoku-3d-ztools/playwright.e2e.config.js | 17 + plugins/gomoku-3d-ztools/postcss.config.js | 5 + .../gomoku-3d-ztools/scripts/engine.test.ts | 131 + .../gomoku-3d-ztools/scripts/replay.test.ts | 171 + plugins/gomoku-3d-ztools/scripts/sim.ts | 55 + .../gomoku-3d-ztools/scripts/strength.test.ts | 149 + plugins/gomoku-3d-ztools/src-ztools/LICENSE | 21 + plugins/gomoku-3d-ztools/src-ztools/NOTICE | 6 + plugins/gomoku-3d-ztools/src-ztools/logo.png | Bin 0 -> 36603 bytes plugins/gomoku-3d-ztools/src-ztools/logo.svg | 61 + .../gomoku-3d-ztools/src-ztools/plugin.json | 26 + .../src-ztools/preload/package.json | 3 + .../src-ztools/preload/services.js | 61 + plugins/gomoku-3d-ztools/src/App.tsx | 1044 +++++ .../src/components/ExportDialog.tsx | 206 + .../src/components/GameBoard.tsx | 1058 +++++ .../src/components/LanguageSwitcher.tsx | 35 + .../src/components/ReplayPanel.tsx | 270 ++ .../src/components/ui/badge.tsx | 36 + .../src/components/ui/button.tsx | 117 + .../src/components/ui/card.tsx | 85 + .../src/components/ui/label.tsx | 21 + .../src/components/ui/separator.tsx | 29 + .../src/components/ui/slider.tsx | 26 + .../src/components/ui/switch.tsx | 27 + .../src/components/ui/toast.tsx | 66 + .../gomoku-3d-ztools/src/hooks/useGomoku.ts | 461 +++ .../src/hooks/usePluginActivity.ts | 20 + .../gomoku-3d-ztools/src/hooks/useReplay.ts | 183 + .../gomoku-3d-ztools/src/hooks/useViewMode.ts | 35 + plugins/gomoku-3d-ztools/src/i18n/index.ts | 28 + .../gomoku-3d-ztools/src/i18n/locales/en.ts | 143 + .../gomoku-3d-ztools/src/i18n/locales/zh.ts | 143 + plugins/gomoku-3d-ztools/src/index.css | 282 ++ .../src/lib/achievementIcons.ts | 20 + plugins/gomoku-3d-ztools/src/lib/audio/sfx.ts | 350 ++ plugins/gomoku-3d-ztools/src/lib/gomoku/ai.ts | 391 ++ .../gomoku-3d-ztools/src/lib/gomoku/engine.ts | 123 + .../gomoku-3d-ztools/src/lib/gomoku/export.ts | 194 + .../src/lib/gomoku/persistence.ts | 171 + .../gomoku-3d-ztools/src/lib/gomoku/replay.ts | 84 + .../gomoku-3d-ztools/src/lib/gomoku/stats.ts | 258 ++ .../gomoku-3d-ztools/src/lib/gomoku/types.ts | 161 + .../gomoku-3d-ztools/src/lib/pluginHost.ts | 106 + plugins/gomoku-3d-ztools/src/lib/utils.ts | 6 + plugins/gomoku-3d-ztools/src/main.tsx | 14 + plugins/gomoku-3d-ztools/src/vite-env.d.ts | 1 + plugins/gomoku-3d-ztools/tailwind.config.js | 51 + .../gomoku-3d-ztools/tests/e2e/plugin.spec.js | 328 ++ plugins/gomoku-3d-ztools/tsconfig.json | 24 + plugins/gomoku-3d-ztools/vite.config.ts | 32 + 60 files changed, 11455 insertions(+) create mode 100644 plugins/gomoku-3d-ztools/.gitignore create mode 100644 plugins/gomoku-3d-ztools/LICENSE create mode 100644 plugins/gomoku-3d-ztools/NOTICE create mode 100644 plugins/gomoku-3d-ztools/README.md create mode 100644 plugins/gomoku-3d-ztools/README.upstream.md create mode 100644 plugins/gomoku-3d-ztools/README.upstream.zh.md create mode 100644 plugins/gomoku-3d-ztools/index.html create mode 100644 plugins/gomoku-3d-ztools/package-lock.json create mode 100644 plugins/gomoku-3d-ztools/package.json create mode 100644 plugins/gomoku-3d-ztools/playwright.e2e.config.js create mode 100644 plugins/gomoku-3d-ztools/postcss.config.js create mode 100644 plugins/gomoku-3d-ztools/scripts/engine.test.ts create mode 100644 plugins/gomoku-3d-ztools/scripts/replay.test.ts create mode 100644 plugins/gomoku-3d-ztools/scripts/sim.ts create mode 100644 plugins/gomoku-3d-ztools/scripts/strength.test.ts create mode 100644 plugins/gomoku-3d-ztools/src-ztools/LICENSE create mode 100644 plugins/gomoku-3d-ztools/src-ztools/NOTICE create mode 100644 plugins/gomoku-3d-ztools/src-ztools/logo.png create mode 100644 plugins/gomoku-3d-ztools/src-ztools/logo.svg create mode 100644 plugins/gomoku-3d-ztools/src-ztools/plugin.json create mode 100644 plugins/gomoku-3d-ztools/src-ztools/preload/package.json create mode 100644 plugins/gomoku-3d-ztools/src-ztools/preload/services.js create mode 100644 plugins/gomoku-3d-ztools/src/App.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ExportDialog.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/GameBoard.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/LanguageSwitcher.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ReplayPanel.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ui/badge.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ui/button.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ui/card.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ui/label.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ui/separator.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ui/slider.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ui/switch.tsx create mode 100644 plugins/gomoku-3d-ztools/src/components/ui/toast.tsx create mode 100644 plugins/gomoku-3d-ztools/src/hooks/useGomoku.ts create mode 100644 plugins/gomoku-3d-ztools/src/hooks/usePluginActivity.ts create mode 100644 plugins/gomoku-3d-ztools/src/hooks/useReplay.ts create mode 100644 plugins/gomoku-3d-ztools/src/hooks/useViewMode.ts create mode 100644 plugins/gomoku-3d-ztools/src/i18n/index.ts create mode 100644 plugins/gomoku-3d-ztools/src/i18n/locales/en.ts create mode 100644 plugins/gomoku-3d-ztools/src/i18n/locales/zh.ts create mode 100644 plugins/gomoku-3d-ztools/src/index.css create mode 100644 plugins/gomoku-3d-ztools/src/lib/achievementIcons.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/audio/sfx.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/gomoku/ai.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/gomoku/engine.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/gomoku/export.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/gomoku/persistence.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/gomoku/replay.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/gomoku/stats.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/gomoku/types.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/pluginHost.ts create mode 100644 plugins/gomoku-3d-ztools/src/lib/utils.ts create mode 100644 plugins/gomoku-3d-ztools/src/main.tsx create mode 100644 plugins/gomoku-3d-ztools/src/vite-env.d.ts create mode 100644 plugins/gomoku-3d-ztools/tailwind.config.js create mode 100644 plugins/gomoku-3d-ztools/tests/e2e/plugin.spec.js create mode 100644 plugins/gomoku-3d-ztools/tsconfig.json create mode 100644 plugins/gomoku-3d-ztools/vite.config.ts diff --git a/plugins/gomoku-3d-ztools/.gitignore b/plugins/gomoku-3d-ztools/.gitignore new file mode 100644 index 000000000..deaa2e176 --- /dev/null +++ b/plugins/gomoku-3d-ztools/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +src-ztools/dist/ +test-results/ +.DS_Store +*.log +*.tsbuildinfo diff --git a/plugins/gomoku-3d-ztools/LICENSE b/plugins/gomoku-3d-ztools/LICENSE new file mode 100644 index 000000000..88fd429ea --- /dev/null +++ b/plugins/gomoku-3d-ztools/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 crper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/gomoku-3d-ztools/NOTICE b/plugins/gomoku-3d-ztools/NOTICE new file mode 100644 index 000000000..cd391bba2 --- /dev/null +++ b/plugins/gomoku-3d-ztools/NOTICE @@ -0,0 +1,15 @@ +Gomoku 3D for ZTools + +This plugin is a ZTools port and adaptation of: + crper/gomoku-3d + https://github.com/crper/gomoku-3d + +Upstream baseline commit: + fa6e266e4e9135aebb80e1487de88a6438380cf7 + +The upstream project is Copyright (c) 2026 crper and licensed under the MIT License. +The original LICENSE text is included in this distribution. + +ZTools-specific adaptations include plugin manifest and lifecycle integration, +host-backed persistence, native save-dialog export, packaging, validation and +Electron end-to-end tests. diff --git a/plugins/gomoku-3d-ztools/README.md b/plugins/gomoku-3d-ztools/README.md new file mode 100644 index 000000000..b7771f820 --- /dev/null +++ b/plugins/gomoku-3d-ztools/README.md @@ -0,0 +1,32 @@ +# 五子棋 3D · ZTools 插件 + +这是 [`crper/gomoku-3d`](https://github.com/crper/gomoku-3d) 的 ZTools 插件移植版,基于上游提交 `fa6e266e4e9135aebb80e1487de88a6438380cf7`。 + +## 功能 + +- 15×15 五子棋,2D/3D 双视图 +- 简单、中等、困难、大师四档本地 AI +- 落子、悬停、获胜与 AI 思考动效 +- 一整回合悔棋 +- 棋局回放与速度调节 +- SGF、JSON、TXT 棋谱导出 +- 战绩、连胜和成就 +- 中英文界面与 Web Audio 合成音效 +- ZTools 宿主存储、进入/退出生命周期和原生保存对话框适配 + +## 开发 + +```bash +npm install +npm run dev +npm test +npm run build +npm run validate:plugin +npm run test:e2e +``` + +可安装插件目录为 `src-ztools/`,生产页面位于 `src-ztools/dist/`。 + +## 许可与来源 + +本项目保留上游 MIT 许可证,详见 `LICENSE` 与 `NOTICE`。上游原始说明保存在 `README.upstream.md` 和 `README.upstream.zh.md`。 diff --git a/plugins/gomoku-3d-ztools/README.upstream.md b/plugins/gomoku-3d-ztools/README.upstream.md new file mode 100644 index 000000000..54462f64c --- /dev/null +++ b/plugins/gomoku-3d-ztools/README.upstream.md @@ -0,0 +1,143 @@ +
YI%Nu;CI5gpxwC8fejvCkP$6wVN zW4#r2W48T!z72IWPK-IL(U|lFV1Wzi0>MUX~St5OxHgJ>kGQy4raqs78J{+-UJ+7 zMsHcfvbs02qArgwGc3)hhyTnE=sdxm=_ykw`*tu-*SblPI gZ<)wS$ZmUfiVuxf@nw7XrCl@$UoQ7Y}YU2)XfDw>dSD{N^rKCOe!D&*3h~#&F zFOC)sMjeDpZ#O?yvwc`!M-tl!L=YoNLhQ2o#ICEla#>0+Tu?N*bsW@NUQ0g=8|QI- zzV`1(MumCU(CIwZdh>6b|J;ySh{@GT;pyawA9j!=2gB~C7$14U?nOb~=*l{U&8LuF zUUFsi82RyX;uFF7pf$5y9GWI54+$Ry1qMKK9B!Nj 3-3`0vUdR{MyGaNt7dS=i1#s#<}*~ z6*?lnc}|%zoE4P=?~(nEUlLapVUTIA3OCnd!+dVCudW^MI%z!t0Bk755N1W4hR^l+ z`wvnT9sT2WPvQqjV!6N=uey2~54)$>zp-4KtJ>Ql%&J9HA@{+3vNe?0{{iPf7{3`; zkaLWErX~<-%5$ENGZYf7j>ir7-h)a84m*r zZB^%7LP!QtFszxL!Rj-narX9;A%%pTn*%_&_8AuhN#P7?HG->yJ&ZAcl xuDAGd z5NAsA_mt&-BF~Uz3jE3#s0^5D`oO9KKh<{USX&UPsT-kwF2P>)n|>z(NV=;;M*EjV zF{ASg`-7=P_@|S7wD?0LEIujV=hf5o3q@^kRh2Zo(T{=}LM2p=5B_$9bYlSHQu)UK z{-AOZ%qs@wF5qtz7sh}nwE<|>UEZ2Y)gJmR%OC`hWqM6F0|*Qp41#fh7sv!d!5AbF zgpeQ*7zt%aWDFC%+*&3p?;${WUcUe`Fujvco#@Lm29{;YILR2Yi5xuR=w;jje|ZK# zpZ~SMPtcoKyr!_1X{GKDBEmrSurQdw0;bWIlNiVhg^;-7<*x?-{BWKj&p3LlK#whB ziY;I!TSi990F(7WtFkD4HFn+H_BnsW^a9`y4;t@%oZxSPm^xbr>{U=o>WAJtaG?&% zfMWu`^>XCEpY#u;x4_>ta6IrcHRrGF0R+iQqo5k>1OjA^jD(WZJqD3+a8ZB@Kn$e1 zFkVVXuC^1)oP!Yu2*_Dpw}d(HwFF_4r8dE %Q9J%e2(z;389}|4hlRnU#40 zu$D+pTNx6(1pYwg*xz4;xxpF;WZ1BABQ|Y32{Wr#VS0KM@`(v$qX3~00)zfC78mBR zFn<`c`=7wW2loRISeMOWEkBGNEdz`}fb?ogsT4@7M;O5{#@2{xp6@%aBO3eaAr)TP zAvnAGOk @sz52b zs(S*6z%qs$970QLAq04jfeQh_)g~g&B{C@>2NFdl!6bo6snmA>J*@$l#g_LIFg=wZ zeW^#rnL6mhxz+%*5wMp7f+H`j{Tb6Y0F}%m1b(Ob8vLbwZ%O3Xv%C=tVhWp2J{1>l zI}>Zxt%unewX{|mLXUIw@*LAMtFUp?iP(DD=>QQ9%^t+#J0HfwyLVwaTf~NJ52k2Q zUz86BUiYt*q;4c&wYE?);pWXf&JZ5a*w+dC0kFrW09C~t5{Cspa8y1GcI<-RV*@P| zlo0sK!qp`iS}QQl(G%y&I0ps+3C0LgsBCzTfwNNTGe}OzgoFe^3Q|^l2IRe-*COTa zJYE4 ?En}saG9y~evZoDmHcHpy5KKMF57q@IUZlygu`+Qr=NWxww`t- zvMjG-Ysm&^DXspg0m{mP>goR4b?dNZ?Zr6#ob#~rk%#cuLpw0T4&wy=IG9`pCk94J z2dEfPxkW+S2};8wbM`G5Xm^HLxG(Kq9v=Kwo<^gF=KDUab^iYTORGw)-H;5RCHOtf zAO-#u)hl<5H~2IP@BwQOl><+WZK^oDWE_-9 28 %Pd0#WGzF`rG8i+M05lUQFB_}lD*_04mgCgZPRB_nZ^e!s_u`?)C$Wj` z!K&f_NRs+YmXP|CqE5#vIjhT+^_0LEKWXrX#?t8Ew+xnL{)S{XwSrn*sG7r}#Q3+1 z=+|!eZv(agzx5K3z7z>FB87!`D1J>>u*1C9F5tK8)fW7fE9cEjNvv`XOT<9n$n>R~ zk`n|JXoXPAc6pB=NP?sU5rJnJhzQw44`$oddPaGLUwxBL#yCg-Try-mwHm<~Q$|5; zR4gz1V0x{;l*B>s>tb1!VdvrrSfI7I{OW74dhL1`LswDcsH_U?1`{wFwgP>v1=LJ| zfoD0+IP*z3;e;)?_ny13NUMRo2RZ0N2({A^1jPt6ij+2>^O0m}0%^RA3;d0xr04rC z&tg9HCM3FQ0Y~7|U@58Jszk>l4*v1a_ha$fgTI;rlt?f((~#aRNP4v(lM*cFka`(U zZIDrij3hXCkC1T&0>i{aZqZD7T$gqL&N$>iUrqw3O+c)^)B}tGIY+66mlqf_LliLw z{pNf>%X>Kg*)IfJb1JU5^lIe!M709pwHK+C1W>J2RIJ^gyA+Hi;7ygFI%EZgRjb$F z!b>j013&maE_&`uaQByPR4XL}z1 !TfF+ccEp`MvM?ctvm- zqufzPq>pHp;bd5_iJ(vK<0Bus$u7Z=uw_}5L97A~6w}e~AGqPh%Bo)h*Z$+zM1%k0 zYhR9SXP=LYFTE0san0CtN@KK^hB+nOO0ix}0pMr>Mk59LnpN57S&lQ$y+CbU?)C6@ zpZFw-rG?4>%9*;Isk*HhiUm-lepXFG;O`{$(FlHTzHcJjkox4jmmgqv``u*(ZP7Kt zr@ zoIH4u9Tow9jOLIVeHFkfEYeyo)n1%rD~HQ>8!)P$DfgCl{EXeXEtp#QR!aUizwMQ2 zngO`$^X1F`e)q>!9&h<6<)gR2R-C?m(>vtM^=kr=#0TH^DS&|2zv0F4-quqKg_9E1 zRDtM*_k9`+z#HE9qFNm`rIJ|_Su QqF(XHGQ9;`?}{v^UzNVXfWP>we+{(au;6!-#l}10 z^L?@v0(JhuEjywG*km-iAwLb)5&W^RDkuT+Soy8NAL@!O;Eyn^$b7%?q?+MXFIUyV zvXsaSu%x=0r*owla0Ye2fa_mo{M4qOa-Pq)I*U-=;aBEe3S}z&0^qNzqQQTtKZC=x z1{Xf%N=uS6#=&?7&NKB~X&6k;$#XECt0l~oW8qg8ieQw_8ONz-oP|E$fJMFqk_m`` z+B_wMRC_P9W _`~GHM?e|kU8zOT8;QzrWiTY@aZd`= z7HTWQ{!s4hlQP4urHy#%mCr<;_sRklxI=2a;}nC-K#W6*0pj;O4CgrK*mn9^c<{ag zCt?=J7J)+bN(KPLL0qlCD0&zOvf5OnTzq`jLpeR)w|cgzil)@p*;p%;Is!TI38 zQ(8b9*YVqcvCM}7e?+o2l<&=(Mh3q_$4Z0WFVTw2jCr!USfsvkoIwG$l;qX?pRwu? zkTI)Qex*-o>@(-mp*}E;k5%mmJC`=#lx=5X&04*yk1=rG^HlP%tIJ?u;8`y;--v@Q z;bXd~>1nLnxDj*NR`dq~B9jn>fG7l{6kz2|&7|sQNzW!a2;&8RqD<@1R8_V^inQYW(OS3P=FKN#Ia>qS zTaUp&Kypz{1xOnOrIa9{v>UJgqC={Uu`eWsQ^L{a!32jMxMATUh8}k4$^e?ATN@TM z4EV`SmSQ??jD$8|7Lfvf8)ILp?i9;ICya0^w_}+l(QBr_OeSGy3oda{GMol>&+zYA z+JNn6o{ucc0L>H_~M06fV~QH!9Rp+#{il|X)`79(U(X^b|pjc0_9pyu>tQz ziE--|;E&N(d4)FZhSJv_cC|vZ)u3%N2+97($;U69BtS$ckYip<;*@Qtr^lGr4&i>X zqXj%;!=MDK9!K?Xs`Dt5uHUo)%UBJ{H=qy#LI@y}5QT)03ixU&K+&0KXYgD3cn#|1 z9Rt4waPv6;_9)~iG~U8)_^rU6SE9(|d_R@04fta*x`ICxH3ZvO=f;`G=-?00=4LSj z_{nWMef6}ggAXjP!j_Y_YUz*KXeA{W&rGj6`xbUJlA ~ zi<3@0-H4@-BIO`NflXYjxj+y?r;l{2OKr7uSJg)YztSQIbueMgy7gGjH$W5uR7eb@ z1Z8UeFN9D_o1$haX4xwWej}y{S2MZ`t04H@MjrzFKx+mN;bO6{Drk7{C*wMeOYU1! z)=BVFgzoO(PnGY!Sapa>4i6UfpBVUCF o%g?npUk-6H`Zdr9?qX;5-NC8IUsUkpiF; z16>?JAB94x1eYY&KrUge-%A={&}uRdD-4*KnMToH3%0Z$oCy@=DM;cFLV~Hg*tF_T zAPAI)quloLMqH$?+JT=?s{BN@nqRH&mwPpgut0Gmfgc!#0W?)`XsO>o`l#Tq-eOFv z1^6TR)?VW<_66dlvw+toEx;e9&4?g@MLC0w8#k9@5CC~hfDs_Y08$ouHG*4v0~C0r zUUyeu&8Pq;VeAD^$Djx0k-*ZoB(DOyG=Q$ROKY%~?=Tp`#Pkdn53L205|TKWkRZ+> zsr*O_ftjjJq`g{!zl!eU6CGDJ@ZI+k{N%jXHVS{NS&X11Cs@Hxg9S2(2CqE$ljV$T z?DJCCagR3mWBn4TS9wSOfF>}rY87hWGvgW600p4$;v?uIH<989J+TA^3HgnWLf-ck z002Mx8e~s<6*w4{7}pmIk}gYH(!P@V2GITKfi5fWVS!Jn%R&YcoN)*#!Fya?wkioZ z6Up|ndMeglvHoiTe%sRlzwzwmOHR@j#Rgr6PlJVuYFlax@W)4u7nlaW+nEWnfR5k~ zr;m5D`cnvrn{WOmcI`d@VhlIkbUU7V?WM>v-U0k+hSp{frt+zPqb02*!m`LPHNC1D zu|5;1B{l)xUVfL<90eF8iVwd5^8T+^LGtc@0l)hT$bbHQ=z%aWrF*r9-~36R83dR_ z-pe6a53G=2Tq2VaQgU@#P!c#7Aj&l3Dz{!s0QC*X;|70~+_mgCOaaV%Ek(e|w*&S8 z47(NB^^R7y g4& z^%da5nm|e`fWD_dYKA6-lKMhOu%4_KOZAC{u}FRj 9oVIG$gU&`3iD%k}eg2mO2A_4ld5nuq~_>PT-Fw`=zS&+n^Wy9*E8_h{$5 zlq0
-26ba_+3$wEj#dAdX}p{|lu6aa9_pv=L@XtF o|K_*8B9J=9knB84 z$^WKbet97Gh%To+Zo27q+;YpER>(7+aUq`fyvtp}eVxY{Nu}g}!y8{5D3ILd@caP6 z```Z=0Kn^i@h2>Gn+RG}7J!iWVP9GJIiJ)=jdfNG-i|f1jL=5FvK)T)HIR4z3qp>c z^DMQrXaU}o_*D6a!EZAHDJ3{3Joh;lVKUE=O=Osy$dUJQOyoH-WXN-lkAD2$Y>7^x z8bqXi9pI;kK%r_nJ?xY7ed7R^73_5DFyIG Jr`v{v;)2=R?8%a0hBB6{&ugvYNI}FreS> zqg=+T+KZ?z! U3@_}R|@zx)*#NPvU}dC3T5JsqgPP2?~F z<5%wY7XszdVHsCSgIP$2VG^uu&?^CcH)1UQu=9QLNlLo$ej2PPtONM1I8Eu2;18G4 z3H(VTXFPrRlyQ#huDh(7^Ivz}WftLa;5+$6$#8Q>=M!~K0U0MOEX-qSdKye$MoOrc z0IOUL{nVY>m^t=|Fa;og?G2E(|2^crw*X+^=UfAR`70oaurwG{;Fp!O*Uz$M3MDhB z8G$rb3{==)FaQ(FV3}e@yyQpvsx?00RM{1=R HAReD-nC~0S z7d5 HE{-ZR-E_8pvslv0B} zzy$OXKqWE5)vfIe+8$8t+%x9n)tbM>1jqrtZ5t@#n9QJDEm$55Am{g5ZDX}=a`=$X z%!UAea1=S X3t%KLcW4%?*Q_;|0JEk86%%mR18V|wRW#@cH4|_x_b~=Q zwFY3?22B5QIzW=pUtR{21^S0}BF{79x->;o>@Gv4X1n4U;{(5wCm}K4ho>Y&rvTm% zZ^;3=-wHe~@V6~5SbPLvL1x fVU3ltsa *xcYX_WEG$wd7rU+4Y8R)_4iRc7(BGz2UXP z8|&EEwGC^54F(fl aLjoDFkPxE-BqYtDIW*IA_uc*8tNiwlJSsCQt6sg= z-8yimqBXB7udlMc-*;tJW)GCE75r5+0eykL_^3Tt6l(y#SEr|TisQtEd-;1I21b)P zoH~CFohz }EHu z@7?ML_+@RD!M2Nb^lq&+*jG(k`OpMURui;U?UV}2ieR5QgFssW+RO)_n6$NkX1av4 zr%qw#^ku&JZ?%8%*sfULZXCd@At=t_yTLqP0GOa)H X$c~mMQi>9QvJb|`AAtE zvXbwb?9xllC9N0uqvIYo^9|VD>q4+EvPLOnn_jGd4LBMsa>G;hWg0ksuUzY+oT &VT{@`}$NOPo#64C87Qw{#OEb9;a6%AUG9;5 e;fL}mrp1-=s$Twa_*cK$E~)Cs`}=Ld#rs`ZzQiU#*A20LDXUtS!q zI>Ekb28@B =73rq@xV{j5xQ%QvbUGcLRP%RcU?| zsp|p$_{*shIc4?hf J>wn1m?q2~11Z?3`ICXvm6O&spG&BNGt~LF0 z0z*K#{km}gln4NSZNIy8*9#81E`b&VolYAoD~nh?{Y?e>guqM?sQ?RgP9X$=Sf8Y1 zrZGzdSMingXY!XO%_|fEyR`yBf~ZJWR1nlmy#)ayKchE1yjQ8xUdiQXl{omzg-5U8 zFFsB=*b3k;6DDf{zu#o>iHhS?4E7b @LsSk?vn92w@Yoj{|<+NlWkMU%NthUo}QfFQPJvLFHzq0u^pMmmZU&m6|?EB5OX zh*f1j1Q2kp3m7A?bR3{G7-&K7#{+V}=N16~%w4(&xpWS5Cm%s7@<62^LaL7nC*uYX zIj1GFwFJC)#+t!ztewb^v!nIR|4Li{FAwN`C}_OP($t#^uvYME$^X27AHW~I@8i97 z8)@>7-~Wl8+KAEf!4Is*W*&E~g#-Y=hd=xoFVirinWiHRKsJ&0RD#4qIPl#+1O~AC zimR;lk52;TEwj@>7zZey5pZGw?gAl4>jS{-#Y?y_a~fZ{^Dog}okv0fA|W7C8-9=$ z01#RbbO<8RYZ^oz`nOoC0DnDyza0EwwVwTb^68AIk)BfhJpt>0yNZOaBT}xlizUk} zk~%M^Uh4KZJ+|`vrAiX2>61`yfI4(oo6DVLJo<&ZaMN3E1t2iJdyn>`%g+f`ub#c% z)*3LuK*R)q`}uD!0Nl+!<}O~s{Drf4;M1Q#XLVkK8sIEHIT sJ17hus zKEYq(wTD)Kd+pL;pyz4+nBIfWvvoOd?MH*v(Z1cl&m|PEbFlC2@3_@3TT zI zmFEAcpZcC4+h}CuzIR6*tl8;gV3DES?qIdu!JT(~9Itru4}xWesmm^h5PE9~Gr#Uq z;*3f8YyeO&|AT8olX_&v+@*^+cls2*_UTXHmIGUnimebrppgm)N{}Wgl7>LDnIcUS zG}07}Mgox$(lk*nOd{}?fAdu}73iG_&;|Uxx>`Y13eEb^-zO)}y&ZYA;cMTedHa2aFFZbWc@;IGqf94d&5M-P7GAc;0nO;i8i z=ScB>n6M}?>rGo}l|*VG4OmCfS%6!NM6mJ=vSlO}A4M{{7pIO(jEs+C>*NmQS4Wz( z^8oDpYekFJq5uFO07*naRA&PeEoD}KDZj8dkJY6mWEY;s(y2$#Y0o1OYLm}2O#l{1 zl0*gmQh_8*AQFLu2xzM*!9 n hy;R!UJ6W*dWz6l` z^ _-g{c z&|NrgHCu8DStca4Xa?F9ND=|rVUQpQ2_&fiI6=EL3z~TlgJaW}Z|}zZrExSzhA}!a zj>bUKK5H(h!f8Nw!9W1$wA)x-T0&=a1+q1d_QgY3Id@38B_!089+XgGDbP%nnJ*g3 z ;wSM_2NnSY@?2guV!5;|``0NuBQxju-ehjn*@RwAp zWYMnR@8a*5+*^0>m$lPNkj{3yKJ64*iVjo&ej@8{TjN-3eZc}r!r%@;BtV)b`3}Kg z$oW&?OD7 ZOxt%^rh SiUm zLM%FzX2iOKza;8U!F8cZfdvD;sIOz!>1eQSz}Hefo)cr@Sl=au`V98@x5~j^hX3%Q zF8I5((-ZhBfnSfAi<&IIFbjhq0Fpqar`QtKE m*iA0<3L?B5>5
0g$e?i84ZHWzgb`dvh@Ie1o#zJZ+Nys z;j|!cJk$?=H{nom^q$tXDQGwFSK1<7z#o>2^c#CcRc-pXE7$sdZdfkJiMHiGcf{^M zSRh(vL%|>34%^ZD1F#C@nSh>&oC_jIp)@gPj7F+lhmKwcAdsO$1kNM`Nn}!jm;h#o zR06CtF-zo!q?&Oc)4#AwkySjBkU*$!`%{55AyA^O8)*tkNLc`h@W7vHfh7dlCC&g8 zJ?3 L5slq5_$$Cx2mE~p`(ly)LW)19RZvXI2Qa$QtfvRm6dQmnBcv%nl%S)1Y>}u1 zM3e|nCxes%31`Tp5(JznfH%^_Av0HtDhxqLp!-YJvcxn|3wX@*9}%f3KNIlFr~rO; zz)z%}iP3%IPoR%rUsuJ5sEX%Uk;1D9_@buHC`%i2G+3$Ps?yL5WEqeo`7IK#8`S~m z&EL;&tULJ2+Nrg;C+xPm9aXFk@RtauB7<}RziyfGuY~}p?X?7y_BI=ZW|;sbYWJOv zAdpnxl+0?`fRvC-Nc8swDXBC15;A83=d=(Jkm{X$1nDmmRpdVvLe1wBsYN|P#Q=oX z{6ZfBn&`FurQi?I-!;w>8~zadegzVVcg^j#UmdwFbTz*l<@YqdLo)}B^kW6|F%8%+ z`12oX2Nv@cR{r9-y$VW(Z5 nHB#2TC9K`rvWQkuIRO^=GQ4(T}C?S2mlfmaHZg{qxDN)WKg!7rj1?$-NDb1 z;@#ic#zp#LO~%1*5)r^sSprPCU=V^JGreLE!Y;DOq+Tn~0Hg%6L p?&%Mk2|CTfF{E4f}e8mBLe>QiuA`^Ee0s@Er4ug*`hqu z`a#u7f8W+y1NecSrU5HeSv<#tIc03+Q>4<$JTS4%UMA@PWP3GIvP_^vz+XySH}FTS zh!`COOjd(GdaqaTyJfnxQv`oVaGf#Bxz1Pu7EF3tO_}+6YE9YukQswG0kKelKcU m?9nua#}jbuy5v#n%7Olt0=xIi@C84JQK!Amg&Cw%SK8~OF@V-` zN!`A|&ygm&fWPb+V&Lzhv5U ktAMTc3#y!yyznso4 z>EGP;%J?7w=okC|Fg!8km0CD{)Nk5_5_1&%W!;u){v-tvtpMmm2=!7O3!p $@*Qy`22G=B1;LVhc+({Rbc&7=}n1kXb9zHkaIr5WtML|A!x{F!}w9wMF{7 zwNn(H&&9G{m*cuJbFJWLeoJY1>9x+xD%i(H*r;vA5&$fHYr)L$xZ_1uy5w{9M1&6V zcz`JZ%6)s%cJy->?9(kpz@MZ;7#Phh0G1MGAOD7?Q;&<${HzyVIo-G}L0x~Jp=Qq@ z#0u2ju9c~t1t7$dM_rK0z%PTF#wR12?^js4a0czOz7^0M9*?N2h}Q1{+Hz+0t%!2a zb^||q_evxEFBI_S59`-4U@Q_5=~4ZUNP4EX(Yhp5P|nQbCu(DUAQ6CG%)^4fi2w`r zC0~*VeP1X52vc5{5qc${(flGm<;Q^E6$tV1;>fs%4<^AbhgOYO1pHuNbjRgzRo>h) zhp~9>v^Si_Mt8nS_S%y^>lL(q2!1ewk_05uWz?_$>Ii@iQ?d+-8w 1#LC>gJ!yDY E-t@g3u(=s;w&^y%;cVQQka7>qoW5D`W7<&d%G~5 {H3vNeIBiu6 Qu7?Q@IQ#S_wNdE=W`T#%s?O5Gd@m50pY1{E? z#57P0{6Th%nG4;;pL4yypUV_y>nxk8!EfRM#s_B&Q!5N$cjF~ Sq!uSV>DFMIA^Lbf_ z21Jsqn+32;&EXkp -F3f{2@6-@RzoeqpOw%Dr)`Wb9zr!!puJN#o7tT;7D+}WW2n ^`9W8h7lrQ>vH6C=P|(@5xOyU@t(7Pf8TEhnB02 |H)1M010*CBO4v;3&$%R9Km>C{vW&d?mPxY zt^#y+B#5w3peKE@!*SOIlED!qqgzh^Sd2V`mud+BjO{#u#&9k0+r<|p4C(}Tp_sx4 zKLNP-wa=n`=}cZn5JtD}DL$#!g~xjNuLvD%i&ulTikaPR;~FSzIlNZ{?Q k&&z$n{I&;tJEfz3 zbyilec)x%0&EVK}G=|3_1k`21V8Ej7gpa|& f_-i~ezgkl*Bq}p z;I}SlMLTv(<1*9%e|W#nc$I^Hc*`z~JF8g%;P74l7wyG`T=P5iVrvvw-{)ZBO_3Lj zc7)tya0H|KuRDr$vH+H__m P9)HE_ zp2d2^0Emc@4$O0N=okh@Lz41xB}{I&kudUr+__ID_`^oJsmL&|uHyWsJ_0x3lr#o0 zKD9sZ!{a$;^h%hpPOz_jq(4Z#1pDmI9wPk+3&ML&AA2$G55XTBuYimmf_-JrStrio zmFNZh>l*CqHtz9YpS@&Y*EQdvtyGgC@r^(KFwk1m-%yY$O9iYWivVA`*A*te%d*Mv zD3XD}lW96IPZXOP@l)NCfi8&QZ4cA%mVm?vG0uf&@`J7B_kuzC;oN|bA^Xzf4`AWp zFL;Cuj7?&Aayr7yAuYWzVI5}f3H$|Gd>Z=?_Qk 7@I_MeEXvSE=C@#kZKD6-m&jt3{ChHlrbmZ1Uo1!=q&AYr+wV1BB&*j1f06_ zLuj2j=G7nFz88ZNW{$NV4OC>pI?P;G162tSTPAF$kk)ZoZyERtJ(~Yj2m8v$D Bf zhOfBh>i{mGMyf3U(i^`2VH%pq%sTy2CP(Pca2@~*G(TyR-<|FO0o2l)IWW+fpT*3_ zZ--o6@%k8_+K-{}9YOa#dzOPgq`ZQexul)Gf_-(ExzAu<88drS^b7v5+S FZtx7t-zJMLhVvKfvnj9PA#XtLm#o!|0|Bqxr26Fp>V^XvfHP z*#RJd#@H6Q<27$Ri1iczMAXKX%g$nO>?sV6Rf6BYrGA(=K>nBS%wi=Y%+^n8V+p|o z)(b-Bk9-qn?syNl({_o%`1I8nncQ8VgZ=+Gbd`YLLrK8Q0fcNj-s9E{_SInKLU(bn z`ImK=c|E|-`Mt<^z2Jg hu*VxaK0U&F)@!)&^C(eKCoAzjR zvyg|NKY(VyHS}#vO#h#l)v{ny)}_ID1uj7hARV1RV|eW8q09H4CL#wc<*Ap`fiKYL zuCLS3WI#=x@?f8$()DZdDkc`7AJ ITMB57jx%in|@ zSKka*^RtvV_<`GT?Ed?dC95kKUd|O=ZV@vaD y`QPkruAy`oX?jsyTikQB6qV6LB8 z?QH-+I>IBY6oNm$Noa1{g)KL}?j8U$k%x_@+M9wB(M4u{f)9S|P&PQRAIq1DPvnf6 zl4yAm$?OF`Y_y*MFhhVK!Fu~=4gMB^2P8Pt_ZXP@(p|`w7ch0(yC4Sg?-P>dASU;| z49l};v2f-nvd*d|sT(r~%oSti4fFSPsV?mlS}F_nd9`Dpb#6t$&yi|tAFp`TjXa9G z-iDq2-D;D+F))H1`)|hht}6@OwwD+2;2-`zj(z#wd>OOZ)?$@xY)cq-myEFwZ2_>S z*(E!gUt`1yjS@l77~6`*_?Dx)-hA65MC5?8I`w)kz_Xa#`#Cy$ct0BfP{J5+Zd38q zIzc}r7&3j6ptOBPQV9VNI#6X;#|X|n^hLC0&tv-5|A^+)6<)hT6FV?Cz75NlX0Ukv z1hUqOLtO CW$HeLmxLt2c+nFnPrd zn3xV1p{X=`>@XgD-+OWHTaWp`Ca42V^lxFBw>%CVA8 (CD~ z$ADD?ZGlJE#ov$6w_)I~e$EZ`_ap68g5Ta-Kkz4wL5%LW9OJw8V|dFB1fIC1@tJ!+ zgRlMB2hh572_Ui-u8n=IQm*;+zaTUBub#<*AEcFBPHO1yBe*udi3FNkcgpGSy6v+7 zPDMtkH1#9^h-itKk9LN)KSU!tUd3}2eHqE-46N~qNK>z-NYrB*pNTmitPa%1F=zoi z03;c!1|*@wjP~p-j(qfgVB2F~#*Xj%2@C|*001;cCeR$2zyyHK$|6>mX3<$)KxcIs zS$hTC=|IX1g;j6s73_;J;l_YJq$P6sf?wGeYrEfIU$L-zn*>25DMZ>tqdAJ^$QYU< zTQIz3N^j#80liMM$DY9>fBs>dc;HKUU=MlF8U)aa0lZ6r*(QapB>?2EFhMCd&&=0S zfK5{cR$(-^O<`cm)`R2M-g1bDx_$uUrhd)_JTCUV`i^YH%oWIzHIJJ>vsGpD{;_ zJOIe~2{7(w!2&=c)BzJhC|1dckT5IriU=9hjKcYczk~} (2PY z4vrul9Es*_rp?qXEuB4sr|$YBp7_)!AeWbO0PA1I%ol6|+rGJP0^vGDpcEYgtgQz| z5d5pq`s!L)%0tt8G5PY>-2uS=c&0`w7jq?v)p4 R)Q*roRdNDUdrs zZuWzMf`N=otBw_5?gijCUj+_KPGQT-zT?n=-~Q-N1Gulw`hPd|Yyl7vGxHH#am&Xr zcly7;2!Ikh&CG8)9=ab3(6a*|<^Str=OG0_NU5hu^oXqjNt2K 9JFxX7FT>UYZ^Y34mtk;fFYGGD&9s@S5=&fq<|xiSb`VFud>_s{{-`z6 z69Z_F K@| zlj*B}@{8@G4?K;L$*Zw&zEB_!U=*S*UM#@60up*2)a)at%p0MBp0PUaiCX|QfsQ1k z#K=gy5lkS-cHK1?n0@LoTzu*=NC}Kg?ZK9pz5)X~E=P0wE;J^mkPM9?85{;R1`B7n zZKiMUBs&?h)(YB7OK2_5WAV&sES^4%*%Qa{?7@dHKXcNH#v7heu`06KTfo=+cI^ss z6WPT+9h6V;*{T8f8(VjvIXQLgiraqS9`qRfH>ocHKtz(6k0e*z_}AIo z>Hoxw7fJ&!X7HH(Sb)^if0AJ%p6XL!w$V@lGuUjS{|PZ<3rL$ ;#~r zm00Mk7W{qpk_f}Q_hI7NSN;Xk^av4kJq0M1`n3Q6;JA40&wo~)eDtlDJ#{0`x&la% z>iRADjDXJ5KixsIQvk?k1&nWGEdimVrf4zHRIWlI)wa`;Sz80jq7X1EuzdE-3>}U~ zkmyWF`R^F>vR =>gu;UUhXqEmF;*r->OB&OWWEOElyj)iO!Rz#b|Wb z y zU*KbFCOOPYamH{vDuCZ;3jv=lM+g- eOv|I5-^EC3ME3^RWXkKgmRv~cc6`Sjz3w{L_c;H(SC z6}=Jykjm^f1Led61k{&N3A8&Pl$OsVYvDjZi$P5-8zhjLwb!h{E{s{9yBBKBK_H?z zU&C##3xGD<5PLZ;^HzY&a|fLN$3W#~yUguvxC|fsrfwqCtcq`~1Z|^=E-Tt{jT)&N z(nW3qHqY1kVREeztLgKq9+1kO!L@`!!5KVi{yglTA1EDg+G#xp_#~8tNG-C;!0u(C zC0GpnAXw`TKq2ZM3hB~y6#z!AxCX4Z}_46iRdiWkk%vsbb37f=fCuk&j0z< zH}LGKDJ+G{kOQvbTqu}RWT2-=jPEV %Y};3>QQ1&7w2`)oblARwu`3fcCQrgSMqF1+bfyt@9-j~(R&!t9HTlVC zwV39IZ}x{#|ERD;{8cr^wqj`K<>#;b@n87}fF~**u~u5M03f1eW`06k{rcOnI`>=h z=mUhz97v)_*UDIzH!nHYph^ZPcfgz{Xr}|=xC2nJ0A@f)b4Y|*6F{sM4pCc!eLxEW z2_#yQWZJhTeJ#VuRYR-rnc2q?2j{rMV<1nHapWaBb?f;19;1g^&C~3GU(G<0XbZp@ zd$%pOeb@9h6uAP)R`qt8KkU^TsC#Ucsj^q${)nVkma$@W!h|>lZrK*Gc$Mj@IP) zMwS4Cp0;bL0tWn6%e#X>`4pcAerH&ei(3?6_E|W`OqeawCdT&Nz>^2waQpCOdmbaA zuA6>#OY30)0Kj4Kx}UzA&pve{tt{NeCm%(~IRuy4*fZ-`=0bp+wb@1pFcC-s1Zg)) zkOE*m^_Mu-0D+JQfLl=JosJPm0)z$w3H7f4+0QYJYohwg>GsX~P!zy_QzhCq1|%~8 zA)wv1JZ&2=Z=T0~U5Bnjk4eFuD5Z=2VgVg77a*joK%*1v3c+6p=27r7f`Tf#O2H5K zE|Sx>tAXwAbvkw;Rsp+wmlO|qTAx|x=SP%<%YSX0Sx@;{@N-Bj7hWa%*%-a@I*jgp z$tSP;v3K4D;7G}%_LQouq@EHp4`Oxk4V}OF cC{k_%4%a(6hJSy#N3X zfJsC_R4D)|_7nPFw)a(@WbK2o8Yl@sX8B;+)q<>;o;epX_Jmbt$br0VczFRs`43lk z8SH0Qj7#wR-Aw*STVb9H{wlgwIzTJ)^GoT97c1QKgi>h(MXg_!3PPxYn4ZTg{2htg z*y%b0J`H*RHeRGM9DpOR8j$} vtTmuT zy#RxBa8G?dO%U3dHw%I!=Wc+e5!8mYIE=sn)G}k;K`?d3o~i9`-(<7xZv<66li&Hj z)ns-I5CxTY?U~v}`}lo(bS20i)X6LY#3wwuASF;s-nFMj;-7xocHEL7U13{}W{+OS z68mGB_pN4z?4{jeGB+KprgyR|xXsv8PN%u=S|HX1z&e0mXQU(J7`@_}#Vdd0=YA(0 znK-y!;0M-G0000Hon__+ bIi-Or|%#&u&?N9z6_RxmwMZb2Yu& zmjOQId0xFhgC+u2>lNs-rO3;( #}? zf(p#2CB~%ZzD)p3Nb@4dzC=ktswIiqgv8L0gA6eFd6G_q*xq;P59*qS3&D<(>w>m* zK<2I<^8|s@mfKgF0HWj5W%PYPD7iwY pm{xcMUj^TI(q +k1nR)F6vD+I=0awA5sy8d%7|MmC(7J&PSsD%wk8zuk%fSHHTS$R$8j(7hi z&pi1?KKZD!1l%(I|8za|Zvm*B&k`8$7s@$h&1NGA%9`$^(^o8_Zc_MhP*;DSxgsxU zz6SV9$f_ZPfQ6Rx%vc*;eqU>&t2A|^E394^ul#568+wzGUx(I%r{%Tg_xyLq{UVrU z>v?`g5kMnu@>^0o@Fy61$;&ai_nLpa{@s808)-WDm0oxG=_PHb0001H9z$p4Rh_^6 zjbG-|kN+T_cq9@NC=QTxzHQ1ujNIgtYQt?ijc3YoUe+|htk-Yn-Q`-*OglQ97BWqh zh7F!Id@MNs)Bt twMmcfa&3|&u+kg8b0KT?SK7W{=GXVeq%$xu?(D~vA z-ioKb@GE@w&={XTs+h|e6leVp%uiSItG0Am&Trb2SnO6|xiE13cnX-=bvHuheJ=fd z4mzvAer>uk3Cy?RWvl4&`wBo-deI~JO8{9{(1e|p2_Y&Lwf9w7{SM7{84lQ8|K2t8 z%K=^A-v@x9sXZ9od+oyHO>g{ztKRmmPXhQl5&40#4NT9e0002Jsp$Uf@B`n;|L}od z=krHzkf$EEae #Ho_nm8qydFc_p`v9+ P{%&+>NIEQqW0=h?kzcN +k91y=(fezu)w{2><|K=0N~A%76Ia_he7r_fC1?#7>@h5}dV*FQ)=jUf@rc^_hk} z7a(lQGZ(u8kPCDm_+3j7=(b9M6l%3T>0$sYZNf^%m2`!Tmx8~7u7cpK=qoA+!u}%T zRdyMh;)~I5SE9K7euMx|vq$pT=_@u~@hJZQM)q8d!CiaKZ#(dY+x1s{53lFi{$A7b zDgXc&mtY^}&RyI7{Cj>}o_^xT_{^aOUpkqa*#XnI(mG}p6u|G44Vkwz@aHy)BGdG& z)e3$44U__|2fD(_rffR{7W^$5N|}3I=l3IE53g$pnr yhIjA7(DvykrvBN# z{9n7?bn90DJn?*LeK);e1b|7*yaT{>oqOK*mh9*QZ{xW$`{jk>;Mp0UNxAr<%0L$K zkPHHeWz5_Q$ja#Q^ZH-XyC%9Am~W}`6#YUabQQB}(G^xMqpP$^L2&vdEBh|WiGYsy zc)2$VpFG#4E4&~=b7C8YckjdC GJZ}M7`h9AIl z>55h-LSu9S1KXxCFu6-Mw`_fQ^5w6+W6zKL{CxnP0dTzELpz>}^nw-uCh0)oG=M$X z*{An(9{8I#@XVp_&gRbEz;iR;`SXy=vv8*a`Ucqx#osRheqVI4ng*OVlPmv3{rwo- zW#bjn F5NS6WcH_xeJZaiIYQ9SKNEq_x|huH-7CchoDaBJ@tGC`0At=g#d69 zGY a<-?J~2;9lMIq7wjaV&*LXrT|Rh(z82T2k(4&HuKa0Id|?A z+*-PlSC<;xUItc|AXk^bttBAK0M5Xj4p?SdNXSyA>CXyye*OJ2x>h@kWoMD8Pjh{` z%K9p$E9|cd{1tRW=?dHL!QUqcwRd`wLL>=5>UT0Qgk)$0Vqgf#;4sp`p^g|BI? (I0JsPR`U^zVj`#55O5ZjDAWS-LFa%)4`5T1V1})M5n%| |(`MRC xn`tv`rp>gOHq&O>Oq*#lZKln%nf_^~{||xK2NX_Sup$5e002ovPDHLkV1kGi + + + ++ ++ + + + ++ + + + ++ + + + ++ + + + ++ + ++ + + ++ + + + + + ++ + + ++ + + + ++ + + + ++ + + + + + diff --git a/plugins/gomoku-3d-ztools/src-ztools/plugin.json b/plugins/gomoku-3d-ztools/src-ztools/plugin.json new file mode 100644 index 000000000..6c49c1592 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src-ztools/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "gomoku-3d-ztools", + "title": "五子棋 3D", + "description": "支持四档 AI、2D/3D 视图、悔棋、回放和棋谱导出的五子棋游戏。", + "author": "crper, ZTools port contributors", + "version": "1.0.1", + "main": "dist/index.html", + "preload": "preload/services.js", + "logo": "logo.png", + "development": { + "main": "http://127.0.0.1:5173/" + }, + "features": [ + { + "code": "gomoku-3d", + "explain": "打开五子棋 3D,与四档本地 AI 对弈", + "icon": "logo.png", + "cmds": [ + "五子棋", + "五子棋 3D", + "Gomoku", + "连珠" + ] + } + ] +} diff --git a/plugins/gomoku-3d-ztools/src-ztools/preload/package.json b/plugins/gomoku-3d-ztools/src-ztools/preload/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/plugins/gomoku-3d-ztools/src-ztools/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/gomoku-3d-ztools/src-ztools/preload/services.js b/plugins/gomoku-3d-ztools/src-ztools/preload/services.js new file mode 100644 index 000000000..38ced92a9 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src-ztools/preload/services.js @@ -0,0 +1,61 @@ +const fs = require('node:fs') +const path = require('node:path') + +const FORMAT_CONFIG = Object.freeze({ + sgf: { extension: '.sgf', name: 'SGF 棋谱' }, + json: { extension: '.json', name: 'JSON 棋谱' }, + txt: { extension: '.txt', name: '文本棋谱' }, +}) + +/** + * 校验并规范化页面传入的棋谱保存参数。 + * @param {unknown} input 页面提交的参数。 + * @returns {{format: 'sgf' | 'json' | 'txt', suggestedName: string, text: string}} 安全的保存参数。 + * @throws 参数类型、文件名或内容不合法时抛出错误。 + */ +function normalizeSaveInput(input) { + if (!input || typeof input !== 'object') throw new Error('棋谱保存参数无效') + const { format, suggestedName, text } = input + if (!Object.prototype.hasOwnProperty.call(FORMAT_CONFIG, format)) { + throw new Error('不支持的棋谱格式') + } + if (typeof suggestedName !== 'string' || !suggestedName.trim()) { + throw new Error('请输入有效的文件名') + } + if (typeof text !== 'string' || text.length === 0 || text.length > 2 * 1024 * 1024) { + throw new Error('棋谱内容为空或超过 2 MB') + } + + const cleanName = path.basename(suggestedName.trim()).replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-') + if (!cleanName) throw new Error('请输入有效的文件名') + return { format, suggestedName: cleanName.slice(0, 160), text } +} + +/** + * 使用宿主保存对话框选择目标文件,并写入 UTF-8 棋谱。 + * @param {unknown} input 页面提交的保存参数。 + * @returns {{cancelled: boolean, path?: string}} 保存结果。 + * @throws 宿主能力不可用或写入失败时抛出错误。 + */ +function saveTextFile(input) { + const normalized = normalizeSaveInput(input) + const config = FORMAT_CONFIG[normalized.format] + const ztools = globalThis.ztools || globalThis.window?.ztools + if (!ztools || typeof ztools.showSaveDialog !== 'function') { + throw new Error('当前环境不支持系统保存对话框') + } + + let selectedPath = ztools.showSaveDialog({ + title: '导出五子棋棋谱', + defaultPath: normalized.suggestedName, + filters: [{ name: config.name, extensions: [config.extension.slice(1)] }], + }) + if (!selectedPath) return { cancelled: true } + if (path.extname(selectedPath).toLowerCase() !== config.extension) { + selectedPath += config.extension + } + fs.writeFileSync(selectedPath, normalized.text, 'utf8') + return { cancelled: false, path: selectedPath } +} + +window.gomokuBridge = Object.freeze({ saveTextFile }) diff --git a/plugins/gomoku-3d-ztools/src/App.tsx b/plugins/gomoku-3d-ztools/src/App.tsx new file mode 100644 index 000000000..20dad6fe5 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/App.tsx @@ -0,0 +1,1044 @@ +import * as React from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + RotateCcw, + Circle, + Bot, + User, + Trophy, + Volume2, + VolumeX, + Undo2, + Dices, + Shield, + Sword, + Crown, + Sparkles, + Box, + Grid3x3, + Film, + Download, + Home, + type LucideIcon, +} from "lucide-react"; + +import GameBoard from "@/components/GameBoard"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { Toaster } from "@/components/ui/toast"; +import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { useGomoku } from "@/hooks/useGomoku"; +import { useViewMode, type ViewMode } from "@/hooks/useViewMode"; +import { useReplay } from "@/hooks/useReplay"; +import ReplayPanel from "@/components/ReplayPanel"; +import ExportDialog from "@/components/ExportDialog"; +import { play } from "@/lib/audio/sfx"; +import { + ACHIEVEMENTS, + isEarned, + TONE_CLASSES, +} from "@/lib/gomoku/stats"; +import { ACH_ICON } from "@/lib/achievementIcons"; +import { type Difficulty } from "@/lib/gomoku/ai"; +import { BLACK, WHITE } from "@/lib/gomoku/types"; + +/** Tween an integer from its previous value to the new one. */ +function AnimatedNumber({ value }: { value: number }) { + const [disp, setDisp] = useState(value); + const fromRef = useRef(value); + useEffect(() => { + const from = fromRef.current; + if (from === value) return; + const start = performance.now(); + const dur = 500; + let raf = 0; + const tick = (t: number) => { + const p = Math.min(1, (t - start) / dur); + const e = 1 - Math.pow(1 - p, 3); + setDisp(Math.round(from + (value - from) * e)); + if (p < 1) raf = requestAnimationFrame(tick); + else fromRef.current = value; + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [value]); + return {disp}; +} + +/** One-shot celebratory burst, rendered when the title is clicked or the player wins. */ +function Confetti() { + const pieces = useMemo( + () => + Array.from({ length: 26 }, () => { + const angle = Math.random() * Math.PI * 2; + const dist = 90 + Math.random() * 180; + const palette = [ + "#f97316", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#10b981", + "#0ea5e9", + ]; + return { + left: 50 + (Math.random() * 22 - 11), + cx: Math.cos(angle) * dist, + cy: Math.sin(angle) * dist - 30, + color: palette[Math.floor(Math.random() * palette.length)], + delay: Math.random() * 0.18, + w: 6 + Math.random() * 6, + h: 4 + Math.random() * 5, + }; + }), + [] + ); + return ( + + {pieces.map((p, i) => ( + + ))} ++ ); +} + +type GameResult = "win" | "lose" | "draw"; + +/** + * Semi-transparent game-over overlay with play-again / replay / dismiss actions. + * Mounts on game end and animates in; on dismiss it animates out before + * unmounting. All motion collapses under `prefers-reduced-motion`. + */ +function GameOverOverlay({ + open, + result, + canReplay, + onPlayAgain, + onReplay, + onDismiss, +}: { + open: boolean; + result: GameResult; + canReplay: boolean; + onPlayAgain: () => void; + onReplay: () => void; + onDismiss: () => void; +}) { + const { t } = useTranslation(); + const [render, setRender] = useState(open); + const [show, setShow] = useState(false); + + useEffect(() => { + if (open) { + setRender(true); + const r = requestAnimationFrame(() => setShow(true)); + return () => cancelAnimationFrame(r); + } + setShow(false); + const tm = window.setTimeout(() => setRender(false), 260); + return () => window.clearTimeout(tm); + }, [open]); + + if (!render) return null; + + const theme = { + win: { + Icon: Trophy, + chip: "bg-amber-100 text-amber-600", + title: t("overlay.win"), + desc: t("overlay.winDesc"), + }, + lose: { + Icon: Sword, + chip: "bg-rose-100 text-rose-600", + title: t("overlay.lose"), + desc: t("overlay.loseDesc"), + }, + draw: { + Icon: Circle, + chip: "bg-sky-100 text-sky-600", + title: t("overlay.draw"), + desc: t("overlay.drawDesc"), + }, + }[result]; + const Icon = theme.Icon; + + return ( ++ ++ ); +} + +const DIFFICULTIES: { id: Difficulty; icon: LucideIcon }[] = [ + { id: "easy", icon: Dices }, + { id: "medium", icon: Shield }, + { id: "hard", icon: Sword }, + { id: "master", icon: Crown }, +]; + +function diffSelectedClass(id: Difficulty): string { + switch (id) { + case "easy": + return "border-emerald-400 bg-emerald-50 text-emerald-700 shadow-[var(--shadow-glow)]"; + case "medium": + return "border-sky-400 bg-sky-50 text-sky-700 shadow-[var(--shadow-glow)]"; + case "hard": + return "border-orange-400 bg-orange-50 text-orange-700 shadow-[var(--shadow-glow)]"; + case "master": + return "border-rose-400 bg-rose-50 text-rose-700 shadow-[var(--shadow-glow)] ring-2 ring-rose-200"; + } +} + +export default function App() { + const { t, i18n } = useTranslation(); + const { + game, + board, + currentPlayer, + status, + lastMove, + winLine, + winner, + moveCount, + difficulty, + humanColor, + thinking, + stats, + muted, + mmss, + toasts, + interactive, + canUndo, + setDifficulty, + setHumanColor, + setHover, + handlePlace, + restart, + undo, + toggleMute, + pushToast, + dismissToast, + } = useGomoku(); + + const [par, setPar] = useState({ x: 0, y: 0 }); + const [egg, setEgg] = useState(false); + const [boardMounted, setBoardMounted] = useState(false); + const [viewMode, setViewMode] = useViewMode(); + const [exportOpen, setExportOpen] = useState(false); + const [overlayClosed, setOverlayClosed] = useState(false); + + // ---- replay (read-only view over a frozen snapshot; live game untouched) ---- + const replay = useReplay(game, thinking); + const isReplay = replay.mode !== "idle"; + const displayBoard = isReplay ? replay.display.board : board; + const displayLast = isReplay ? replay.display.lastMove : lastMove; + const displayWin = isReplay ? replay.display.winLine : winLine; + const displayInteractive = isReplay ? false : interactive; + + const switchView = (m: ViewMode) => { + if (m === viewMode) return; + setViewMode(m); + play("ui_click"); + }; + + // ---- keep+ +++ + +++ {theme.title} +
+{theme.desc}
++ +++ + ++/ in sync with the active language ---- + useEffect(() => { + document.title = t("header.documentTitle"); + document.documentElement.lang = i18n.resolvedLanguage ?? i18n.language ?? "zh"; + }, [t, i18n.resolvedLanguage, i18n.language]); + + // ---- board mount animation ---- + useEffect(() => { + const r = requestAnimationFrame(() => setBoardMounted(true)); + return () => cancelAnimationFrame(r); + }, []); + + // ---- reset the game-over overlay whenever a fresh game starts ---- + useEffect(() => { + if (status === "playing") setOverlayClosed(false); + }, [status]); + + // ---- parallax (no-op on touch / reduced motion) ---- + useEffect(() => { + const onMove = (e: MouseEvent) => { + const x = e.clientX / window.innerWidth - 0.5; + const y = e.clientY / window.innerHeight - 0.5; + setPar({ x, y }); + }; + window.addEventListener("mousemove", onMove); + return () => window.removeEventListener("mousemove", onMove); + }, []); + + // ---- replay keyboard shortcuts (capture phase so we can also shield the + // global Ctrl/Cmd+Z undo while the read-only replay is open) ---- + const { + stepBack: rStepBack, + stepForward: rStepForward, + togglePlay: rTogglePlay, + exitReplay: rExit, + goTo: rGoTo, + N: rN, + } = replay; + useEffect(() => { + if (!isReplay) return; + const onKey = (e: KeyboardEvent) => { + const t = e.target as HTMLElement | null; + if ( + t && + (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable) + ) + return; // never hijack typing (e.g. the export filename input) + if (exportOpen) return; // dialog owns the keyboard while open + if ((e.ctrlKey || e.metaKey) && (e.key === "z" || e.key === "Z")) { + // replay is read-only: block the global undo shortcut + e.preventDefault(); + e.stopPropagation(); + return; + } + switch (e.key) { + case "ArrowLeft": + e.preventDefault(); + rStepBack(); + break; + case "ArrowRight": + e.preventDefault(); + rStepForward(); + break; + case " ": + e.preventDefault(); // keep the page from scrolling + rTogglePlay(); + break; + case "Escape": + e.preventDefault(); + rExit(); + break; + case "Home": + e.preventDefault(); + rGoTo(0); + break; + case "End": + e.preventDefault(); + rGoTo(rN); + break; + } + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [isReplay, exportOpen, rStepBack, rStepForward, rTogglePlay, rExit, rGoTo, rN]); + + const celebrate = + (status === "black_win" || status === "white_win") && winner === humanColor; + + const triggerEgg = () => { + setEgg(true); + window.setTimeout(() => setEgg(false), 1300); + pushToast({ + title: t("toast.eggTitle"), + desc: t("toast.eggDesc"), + icon: , + toneClass: "bg-amber-100 text-amber-600", + }); + }; + + const statusText = useMemo(() => { + if (status === "draw") + return { label: t("status.draw"), tone: "secondary" as const }; + if (status === "black_win" || status === "white_win") + return { + label: winner === humanColor ? t("status.youWin") : t("status.aiWin"), + tone: "default" as const, + }; + if (thinking) return { label: t("status.thinking"), tone: "secondary" as const }; + return { + label: currentPlayer === humanColor ? t("status.yourTurn") : t("status.aiTurn"), + tone: "secondary" as const, + }; + }, [status, winner, thinking, currentPlayer, humanColor, t]); + + const gameResult: GameResult | null = + status === "draw" + ? "draw" + : status === "black_win" || status === "white_win" + ? winner === humanColor + ? "win" + : "lose" + : null; + const showOverlay = !isReplay && gameResult !== null && !overlayClosed; + + const streakPct = Math.min(stats.streak, 5) / 5; + + return ( + + {/* ---- aurora background with mouse parallax ---- */} ++ ); +} diff --git a/plugins/gomoku-3d-ztools/src/components/ExportDialog.tsx b/plugins/gomoku-3d-ztools/src/components/ExportDialog.tsx new file mode 100644 index 000000000..db5318c3f --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ExportDialog.tsx @@ -0,0 +1,206 @@ +import { useEffect, useRef, useState } from "react"; +import { Download, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { GameState } from "@/lib/gomoku/engine"; +import type { Player } from "@/lib/gomoku/types"; +import { deriveMoves } from "@/lib/gomoku/replay"; +import { + buildMeta, + defaultFileName, + exportGame, + sanitizeFileName, + type ExportFormat, +} from "@/lib/gomoku/export"; +import type { ToastItem } from "@/components/ui/toast"; + +const FORMATS: { id: ExportFormat; name: string }[] = [ + { id: "sgf", name: "SGF" }, + { id: "json", name: "JSON" }, + { id: "txt", name: "TXT" }, +]; + +const EXT: Record+ + + ++ + {/* bottom padding = mobile action bar height (~61px: py-2.5×2 + 40px + buttons + border) + breathing room, plus the iOS safe-area inset */} ++ {/* ---- header ---- */} ++ + {/* ---- mobile bottom action bar ---- */} ++ + +++ ++ 五 +++++ {t("header.title")}{" "} + {t("header.brand")} +
+{t("header.subtitle")}
++++ {thinking ? ( + + ) : status === "playing" ? ( + + ++ ) : null} + {statusText.label} + + + + {muted ? ( +++ ) : ( + + )} + + toggleMute()} + aria-label={t("header.soundToggle")} + /> + + {/* ---- board ---- */} + {/* Keep the board prominent in the embedded ZTools view while leaving + enough room for the header and fixed mobile action bar. */} +++ + + {/* ---- side panel ---- */} + +++ + {/* ---- board status bar ---- */} ++ + {/* AI thinking overlay: shimmer sweep + scanning + dots */} + {thinking && ( + + + ++ )} + + {/* replay-mode badge + current move info (aria-live) */} + {isReplay && ( ++ + {t("board.thinking")} + + {[0, 1, 2].map((i) => ( + + ))} + ++++ )} + + {/* game-over overlay (hidden while replaying mid-game states) */} + {gameResult && ( ++++ {t("board.replayMode")} + + {replay.currentStep} / {replay.N} + + 0 && !thinking} + onPlayAgain={restart} + onReplay={() => { + setOverlayClosed(true); + replay.enterReplay(); + }} + onDismiss={() => setOverlayClosed(true)} + /> + )} + +++++ + {isReplay + ? t("board.replayReadonly") + : interactive + ? t("board.yourTurn") + : thinking + ? t("board.thinking") + : status === "playing" + ? t("board.aiTurn") + : ""} + + + + {t("board.moves")}{" "} + {moveCount} + + + {t("board.time")}{" "} + {mmss} + + + ? + +++ + + + + + + ++ + {/* ---- mobile replay bottom sheet (covers the action bar while open) ---- */} + {isReplay && ( +++ )} + +setExportOpen(true)} + exportDisabled={moveCount === 0} + /> + setExportOpen(false)} + game={game} + humanColor={humanColor} + pushToast={pushToast} + /> + + + {egg && } + {celebrate && } + = { + sgf: ".sgf", + json: ".json", + txt: ".txt", +}; + +export interface ExportDialogProps { + open: boolean; + onClose: () => void; + game: GameState; + humanColor: Player; + pushToast: (t: Omit ) => void; +} + +export default function ExportDialog({ + open, + onClose, + game, + humanColor, + pushToast, +}: ExportDialogProps) { + const [format, setFormat] = useState ("sgf"); + const [name, setName] = useState(""); + const inputRef = useRef (null); + const { t } = useTranslation(); + + // reset + focus on every open + useEffect(() => { + if (!open) return; + setFormat("sgf"); + setName(defaultFileName()); + const t = window.setTimeout(() => inputRef.current?.focus(), 0); + return () => window.clearTimeout(t); + }, [open]); + + // Esc closes the dialog only (capture + stopPropagation so the replay + // Esc handler does not also exit replay underneath) + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [open, onClose]); + + if (!open) return null; + + const canExport = game.moveCount > 0 && name.trim().length > 0; + + const doExport = async () => { + if (!canExport) return; + try { + const moves = deriveMoves(game); + const meta = buildMeta(game, humanColor); + const { cancelled, filename, path } = await exportGame(moves, meta, format, name); + if (cancelled) return; + pushToast({ + title: t("export.exported", { filename: path ?? filename }), + desc: t("export.exportedDesc", { + count: game.moveCount, + format: FORMATS.find((f) => f.id === format)!.name, + }), + icon: , + toneClass: "bg-emerald-50 text-emerald-700", + }); + onClose(); + } catch { + pushToast({ + title: t("export.exportFailed"), + toneClass: "bg-rose-50 text-rose-600", + }); + } + }; + + return ( + + ++ ); +} diff --git a/plugins/gomoku-3d-ztools/src/components/GameBoard.tsx b/plugins/gomoku-3d-ztools/src/components/GameBoard.tsx new file mode 100644 index 000000000..b24748aa1 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/GameBoard.tsx @@ -0,0 +1,1058 @@ +import * as THREE from "three"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Canvas, ThreeEvent, useFrame, useThree } from "@react-three/fiber"; +import { OrbitControls, RoundedBox, ContactShadows } from "@react-three/drei"; +import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; +import { Board, Move, Player, BLACK, idx } from "@/lib/gomoku/types"; +import type { ViewMode } from "@/hooks/useViewMode"; + +const SPACING = 1; +const HALF = (15 - 1) / 2; // 7 +const PLANE = 16; +const STONE_RADIUS = 0.42; +const STONE_FLAT = 0.45; +/** Resting centre height of a placed stone (fx-spec §0.4). */ +const REST_Y = STONE_RADIUS * STONE_FLAT; // 0.189 + +const BLACK_COLOR = "#16161a"; +const WHITE_COLOR = "#f4f4f5"; +const BOARD_COLOR = "#e9cfa3"; +const GUIDE_COLOR = "#f97316"; +/** fx-spec §0.3: wood-toned dust ripple, darker than the board. */ +const DUST_COLOR = "#b78a54"; +const STAR_POINTS: ReadonlyArray<[number, number]> = [ + [3, 3], + [3, 11], + [11, 3], + [11, 11], + [7, 7], +]; + +/** fx-spec §5.3: victory / draw dim targets, pre-parsed once. */ +const C_BASE_BLACK = new THREE.Color(BLACK_COLOR); +const C_BASE_WHITE = new THREE.Color(WHITE_COLOR); +const C_DIM_BLACK = new THREE.Color("#0b0b0d"); +const C_DIM_WHITE = new THREE.Color("#a8a29e"); + +const TAU = Math.PI * 2; + +// --------------------------------------------------------------------------- +// fx-spec §0.1 easing library (module-level pure functions) +// --------------------------------------------------------------------------- + +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); +const easeOutQuad = (t: number) => 1 - (1 - t) * (1 - t); +/** Overshoot rebound; larger c1 = higher overshoot. */ +const easeOutBack = (t: number, c1: number) => { + const c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); +}; + +function easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + +// --------------------------------------------------------------------------- +// fx-spec §0.2: single reduced-motion entry point for the whole scene. +// Module-level constant — every effect below reads this one flag. +// --------------------------------------------------------------------------- + +const REDUCED = + typeof window !== "undefined" && + !!window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +function gridToWorld(x: number, y: number): [number, number, number] { + return [(x - HALF) * SPACING, 0, (y - HALF) * SPACING]; +} + +function worldToGrid(p: THREE.Vector3): Move | null { + const gx = Math.round(p.x / SPACING + HALF); + const gy = Math.round(p.z / SPACING + HALF); + if (gx < 0 || gx > 14 || gy < 0 || gy > 14) return null; + const [wx, , wz] = gridToWorld(gx, gy); + if (Math.hypot(p.x - wx, p.z - wz) > 0.5) return null; + return { x: gx, y: gy }; +} + +function stoneColor(player: Player): string { + return player === BLACK ? BLACK_COLOR : WHITE_COLOR; +} + +/** + * fx-spec §1.3 touch-down squash, applied to the Y scale only (3D view). + * 120–170ms: 1.0 → 0.84; 170ms → D: 0.84 → 1.0; flat 1.0 elsewhere. + */ +function squashFactor(tSec: number, dSec: number): number { + const tms = tSec * 1000; + if (tms < 120) return 1; + if (tms < 170) return 1 - 0.16 * easeOutQuad((tms - 120) / 50); + const dms = dSec * 1000; + if (tms < dms) return 0.84 + 0.16 * easeOutQuad((tms - 170) / (dms - 170)); + return 1; +} + +/** + * A single stone. + * + * fx-spec §1 (drop / bounce entrance), §5.1 (win-line travelling shimmer), + * §5.2 (win-line hop, or XZ pulse in 2D) and §5.3 (dimming) all share this one + * `useFrame` callback. When nothing is animating the callback early-returns + * (§1.1 perf guard) so a full 225-stone board costs nothing per frame. + */ +function Stone({ + x, + y, + player, + highlight, + entrance, + viewMode, + winIndex, + dimK, + dimMs, +}: { + x: number; + y: number; + player: Player; + highlight?: boolean; + /** "bounce" = the freshly played stone; "pop" = bulk (re)mount. */ + entrance: "bounce" | "pop"; + viewMode: ViewMode; + /** 0–4 position along the winning line; undefined = not a winning stone. */ + winIndex?: number; + /** Target dim amount: 0 none, 0.5 draw, 1 victory (non-winning stones). */ + dimK: number; + dimMs: number; +}) { + const ref = useRef++++ + {/* format */} + + + {/* filename */} ++ {t("export.title")} +
+ ++ ++ ++ setName(sanitizeFileName(e.target.value))} + className="min-w-0 flex-1 bg-transparent text-sm text-stone-800 outline-none" + aria-label={t("export.filenameLabel")} + /> + + {EXT[format]} + +++ + ++(null); + const matRef = useRef (null); + const t = useRef(0); + const settled = useRef(false); + const dimP = useRef(0); + const igniteAt = useRef (null); + // Frozen at mount: a later lastMove change must not restart / swap the + // entrance animation of an already-placed stone. + const mode = useRef<"bounce" | "pop">( + entrance === "bounce" && !REDUCED ? "bounce" : "pop" + ).current; + + const [wx, , wz] = gridToWorld(x, y); + const color = stoneColor(player); + const baseColor = player === BLACK ? C_BASE_BLACK : C_BASE_WHITE; + const dimColor = player === BLACK ? C_DIM_BLACK : C_DIM_WHITE; + // §1.2 black is heavier / slower, white lighter / snappier. + const dur = player === BLACK ? 0.28 : 0.23; + const c1 = player === BLACK ? 1.4 : 1.95; + + useFrame(({ clock }, delta) => { + const mesh = ref.current; + if (!mesh) return; + + const wi = winIndex; + const waveOn = wi !== undefined && !REDUCED; + const dimOn = dimK > 0 && dimP.current < 1; + const entranceOn = !settled.current; + // §1.1 performance guard: fully settled, non-winning, non-dimming stones + // do zero work per frame. + if (!waveOn && !dimOn && !entranceOn) return; + + if (entranceOn) { + t.current += delta; + if (mode === "pop") { + // §1.5 / §8.3-1 degraded + bulk-mount path: 160ms easeOutCubic. + const p = Math.min(1, t.current / 0.16); + const e = easeOutCubic(p); + mesh.scale.set(e, e * STONE_FLAT, e); + mesh.position.y = REST_Y; + if (p >= 1) { + mesh.scale.set(1, STONE_FLAT, 1); + settled.current = true; + } + } else { + const p = Math.min(1, t.current / dur); + const s = easeOutBack(p, c1); + // §8.4-1: the Y drop and the touch-down squash are invisible from the + // locked top-down camera, so 2D runs the XZ overshoot only. + const is3d = viewMode === "3d"; + const sq = is3d ? squashFactor(t.current, dur) : 1; + mesh.scale.set(s, s * STONE_FLAT * sq, s); + mesh.position.y = is3d + ? REST_Y + 0.55 * (1 - easeOutCubic(Math.min(1, (t.current * 1000) / 120))) + : REST_Y; + if (p >= 1) { + mesh.scale.set(1, STONE_FLAT, 1); + mesh.position.y = REST_Y; + settled.current = true; + } + } + } + + if (dimOn) { + // §5.3 stock-based interpolation (never accumulate frame-rate dependent + // lerps). §8.3-5c: reduced motion applies the same end state instantly. + dimP.current = REDUCED + ? 1 + : Math.min(1, dimP.current + (delta * 1000) / dimMs); + const m = matRef.current; + if (m) m.color.lerpColors(baseColor, dimColor, dimK * easeOutQuad(dimP.current)); + } + + if (waveOn && wi !== undefined) { + const tms = clock.elapsedTime * 1000; + if (igniteAt.current === null) igniteAt.current = tms; + // §5.1 one-shot ignition order: stone i only lights up after i*120ms. + const w = + tms - igniteAt.current >= wi * 120 + ? Math.max(0, Math.sin((TAU * (tms - wi * 216)) / 1800)) + : 0; + const m = matRef.current; + if (m) m.emissiveIntensity = 0.25 + 0.85 * w * w * w; + // Transform is only taken over once the entrance finished, so the two + // animations never fight over scale/position. + if (settled.current) { + if (viewMode === "3d") { + // §5.2 3D: hop. + mesh.position.y = REST_Y + 0.35 * w * w; + mesh.scale.set(1, STONE_FLAT, 1); + } else { + // §5.2 / §8.4-5b 2D substitute: in-phase XZ scale pulse to 112%. + const s = 1 + 0.12 * w * w; + mesh.position.y = REST_Y; + mesh.scale.set(s, STONE_FLAT, s); + } + } + } + }); + + const isWinStone = winIndex !== undefined; + + return ( + + + ); +} + +function GridLines() { + const lines = useMemo(() => { + const arr: { pos: [number, number, number]; size: [number, number, number] }[] = + []; + for (let g = 0; g < 15; g++) { + const w = (g - HALF) * SPACING; + // vertical + arr.push({ pos: [w, 0.012, 0], size: [0.025, 0.02, PLANE - 2] }); + // horizontal + arr.push({ pos: [0, 0.012, w], size: [PLANE - 2, 0.02, 0.025] }); + } + return arr; + }, []); + + return ( ++ + + {lines.map((l, i) => ( + + ); +} + +/** + * fx-spec §2: dust ripple kicked up at the landing point. + * Mounted keyed by move, self-unmounts once both rings finished so R3F + * disposes the JSX-declared materials automatically (no manual pooling). + */ +function ImpactRipple({ move }: { move: Move }) { + const r1 = useRef+ + ))} + {STAR_POINTS.map(([x, y]) => { + const [wx, , wz] = gridToWorld(x, y); + return ( ++ + + + ); + })} ++ + (null); + const r2 = useRef (null); + const t = useRef(0); + const doneRef = useRef(false); + const [done, setDone] = useState(false); + const [wx, , wz] = gridToWorld(move.x, move.y); + // Both rings share one geometry; only the materials animate independently. + const geo = useMemo(() => new THREE.RingGeometry(0.3, 0.4, 40), []); + useEffect(() => () => geo.dispose(), [geo]); + + useFrame((_, delta) => { + if (doneRef.current) return; + t.current += delta; + const tms = t.current * 1000; + + if (r1.current) { + const p = Math.min(1, tms / 450); + const s = 1 + easeOutCubic(p) * 1.2; // 1.0 → 2.2 + r1.current.scale.set(s, s, 1); + (r1.current.material as THREE.MeshBasicMaterial).opacity = + 0.5 * Math.pow(1 - p, 1.5); + } + if (r2.current) { + const p = Math.min(1, Math.max(0, (tms - 90) / 380)); + const s = 0.8 + easeOutCubic(p) * 0.9; // 0.8 → 1.7 + r2.current.scale.set(s, s, 1); + (r2.current.material as THREE.MeshBasicMaterial).opacity = + tms < 90 ? 0 : 0.32 * Math.pow(1 - p, 1.5); + } + if (tms >= 470) { + // Fires exactly once — guarded by a ref, never a setState loop. + doneRef.current = true; + setDone(true); + } + }); + + if (done) return null; + + return ( + + + ); +} + +/** + * Outer shell: keeps the `!hover` early-return so nothing renders (and no + * per-frame work happens) when the pointer is off the board. All hooks live in + * `HoverPreviewInner`, which is only mounted when `hover` is non-null — + * see fx-spec §3.1. + */ +function HoverPreview({ + hover, + player, + valid, + interactive, +}: { + hover: Move | null; + player: Player; + valid: boolean; + interactive: boolean; +}) { + if (!hover) return null; + return ( ++ ++ + ++ + ); +} + +function HoverPreviewInner({ + hover, + player, + valid, + interactive, +}: { + hover: Move; + player: Player; + valid: boolean; + interactive: boolean; +}) { + const ghost = useRef (null); + const matRef = useRef (null); + const [wx, , wz] = gridToWorld(hover.x, hover.y); + const color = stoneColor(player); + + useFrame(({ clock }) => { + const mat = matRef.current; + const g = ghost.current; + if (!mat || !g) return; + let opacity: number; + let s = 1; + if (REDUCED) { + // §8.3-3: static, current behaviour. + opacity = valid ? 0.45 : 0.18; + } else if (!interactive) { + // §3.4: visible position, unmistakably "not your turn". + opacity = 0.12; + } else if (!valid) { + opacity = 0.18; + } else { + // §3.2: 0.9 Hz opacity breathing on an absolute clock phase (no jump + // when the preview hops between intersections). + const ph = Math.sin(TAU * 0.9 * clock.elapsedTime); + opacity = 0.38 + 0.1 * ph; + s = 1 + 0.015 * ph; + } + mat.opacity = opacity; + g.scale.set(s, STONE_FLAT, s); + }); + + // §3.4: the full-width crosshair is hidden while it is not the player's turn. + const showCross = REDUCED || interactive; + + return ( + + {/* ghost stone */} + + ); +} + +/** + * fx-spec §5.1: the bar is demoted to a backing layer (slow 0.5 Hz, narrower + * band) now that the per-stone travelling shimmer carries the victory read. + * Callers must guarantee `line.length >= 2`. + * + * `animated` is deliberately NOT "did the human win" — highlighting the five + * decisive stones is information, not celebration, so it runs for either side. + */ +function WinLine({ line, animated }: { line: Move[]; animated?: boolean }) { + const matRef = useRef+ + {/* guide crosshair through the hovered intersection (§3.3: static) */} + {showCross && ( + <> ++ + + ++ + + + > + )} ++ + (null); + const a = gridToWorld(line[0].x, line[0].y); + const b = gridToWorld(line[line.length - 1].x, line[line.length - 1].y); + const mid: [number, number, number] = [ + (a[0] + b[0]) / 2, + 0.5, + (a[2] + b[2]) / 2, + ]; + const len = Math.hypot(b[0] - a[0], b[2] - a[2]) + STONE_RADIUS; + const angle = Math.atan2(b[2] - a[2], b[0] - a[0]); + + useFrame(({ clock }) => { + if (!matRef.current) return; + if (animated && !REDUCED) { + const t = (Math.sin(TAU * 0.5 * clock.elapsedTime) + 1) / 2; // 0..1 + matRef.current.emissiveIntensity = 0.55 + t * 0.35; // 0.55..0.90 + } else { + matRef.current.emissiveIntensity = 0.9; + } + }); + + return ( + + + ); +} + +/** A soft, pulsing radial glow used during the victory celebration. */ +function CelebrationGlow() { + const ref = useRef+ + (null); + useFrame(({ clock }) => { + if (!ref.current) return; + const t = REDUCED ? 0 : (Math.sin(clock.elapsedTime * 2) + 1) / 2; // 0..1 + const s = 1 + t * 0.08; + ref.current.scale.set(s, s, s); + const mat = ref.current.material as THREE.MeshBasicMaterial; + mat.opacity = (REDUCED ? 0.12 : 0.1) + t * 0.06; + }); + return ( + + + ); +} + +/** + * fx-spec §4: last-move marker — an expanding outer ring plus a new static + * breathing inner ring. `fading` (§7.2 draw) runs a 400ms linear fade and then + * unmounts the whole group. + */ +function LastMovePulse({ move, fading }: { move: Move; fading?: boolean }) { + const outer = useRef+ + (null); + const inner = useRef (null); + const fade = useRef(1); + const goneRef = useRef(false); + const [gone, setGone] = useState(false); + const [wx, , wz] = gridToWorld(move.x, move.y); + + useFrame(({ clock }, delta) => { + if (goneRef.current) return; + if (fading) { + // §8.3-7b: reduced motion drops it instantly. + fade.current = REDUCED ? 0 : Math.max(0, fade.current - delta / 0.4); + if (fade.current <= 0) { + goneRef.current = true; + setGone(true); + return; + } + } + const f = fade.current; + if (inner.current) { + (inner.current.material as THREE.MeshBasicMaterial).opacity = REDUCED + ? // §8.3-4: static inner ring only. + 0.4 * f + : (0.35 + 0.15 * Math.sin(TAU * 0.7 * clock.elapsedTime)) * f; + } + if (!REDUCED && outer.current) { + const t = (clock.elapsedTime % 1.6) / 1.6; + const s = 0.75 + easeOutQuad(t) * 0.95; // 0.75 → 1.70 + outer.current.scale.set(s, s, 1); + (outer.current.material as THREE.MeshBasicMaterial).opacity = + 0.5 * (1 - t) * f; + } + }); + + if (gone) return null; + + return ( + + {!REDUCED && ( + + ); +} + +/** + * fx-spec §6: a very quiet breathing frame around the board rim while the AI + * thinks. Four thin bars share a single material (their opacity is always in + * sync). Calls `onFadedOut` when the 450ms exit envelope reaches zero so the + * parent can unmount it and release the four draw calls. + */ +function ThinkingFrame({ + thinking, + onFadedOut, +}: { + thinking: boolean; + onFadedOut: () => void; +}) { + const mat = useMemo( + () => + new THREE.MeshBasicMaterial({ + color: GUIDE_COLOR, + transparent: true, + opacity: 0, + depthWrite: false, + }), + [] + ); + useEffect(() => () => mat.dispose(), [mat]); + const lin = useRef(REDUCED && thinking ? 1 : 0); + const releasedRef = useRef(false); + + useFrame(({ clock }, delta) => { + if (thinking) { + // A new AI turn starting before the exit envelope finished re-arms the + // one-shot release guard instead of leaving the frame permanently dead. + releasedRef.current = false; + // §8.3-6: reduced motion enters / leaves instantly. + lin.current = REDUCED ? 1 : Math.min(1, lin.current + delta / 0.3); + } else if (releasedRef.current) { + return; + } else { + lin.current = REDUCED ? 0 : Math.max(0, lin.current - delta / 0.45); + if (lin.current <= 0) { + mat.opacity = 0; + releasedRef.current = true; + onFadedOut(); + return; + } + } + const env = thinking + ? easeOutQuad(lin.current) + : 1 - easeOutQuad(1 - lin.current); + mat.opacity = REDUCED + ? env * 0.14 + : env * (0.12 + 0.12 * (Math.sin((TAU * clock.elapsedTime) / 2.4) + 1) / 2); + }); + + return ( ++ + )} ++ + + ++ + + {[-7.6, 7.6].map((z) => ( + + ); +} + +function InteractionPlane({ + interactive, + onHover, + onPlace, +}: { + interactive: boolean; + onHover: (m: Move | null) => void; + onPlace: (m: Move) => void; +}) { + const handle = (e: ThreeEvent+ + ))} + {[-7.6, 7.6].map((x) => ( ++ + + ))} ++ ) => { + const g = worldToGrid(e.point); + onHover(g); + return g; + }; + return ( + { + e.stopPropagation(); + handle(e); + }} + onPointerOut={() => onHover(null)} + onClick={(e) => { + e.stopPropagation(); + const g = worldToGrid(e.point); + if (g && interactive) onPlace(g); + }} + > + + ); +} + +// --------------------------------------------------------------------------- +// Camera rig: tweens between the 3D orbit view and the locked 2D top-down view. +// --------------------------------------------------------------------------- + +const CAM_3D = new THREE.Vector3(0, 16, 15); +/** Tiny z offset avoids a degenerate lookAt (camera exactly on the up axis). */ +const CAM_2D = new THREE.Vector3(0, 22, 0.0001); +const TWEEN_DURATION = 0.6; // seconds +const POLAR_MIN_3D = 0.15; +const POLAR_MAX_3D = Math.PI / 2.6; + +/** + * Apply per-mode OrbitControls constraints imperatively (the JSX props keep + * the 3D defaults; R3F only re-applies props when they change, so these + * imperative overrides are never clobbered by re-renders). + * - 2D: rotation hard-locked (enableRotate=false AND polar clamped to ~0, + * Spherical.makeSafe keeps phi at EPS so there is no gimbal lock). Zoom kept. + * - 3D: full original freedom restored. + */ +function applyModeConstraints(controls: OrbitControlsImpl, mode: ViewMode): void { + controls.enableRotate = mode === "3d"; + controls.minPolarAngle = mode === "2d" ? 0 : POLAR_MIN_3D; + controls.maxPolarAngle = mode === "2d" ? 0 : POLAR_MAX_3D; +} + +/** + * Drives the camera when `viewMode` changes: a 600ms easeInOutCubic position + * tween (instant when prefers-reduced-motion). During the tween the controls + * are disabled so drei's per-frame `controls.update()` cannot fight the tween + * (drei gates update() on `controls.enabled`). The Canvas is never remounted, + * so all scene/game state survives mode switches. + */ +function CameraRig({ + viewMode, + onTweeningChange, +}: { + viewMode: ViewMode; + onTweeningChange: (tweening: boolean) => void; +}) { + const camera = useThree((s) => s.camera); + const controls = useThree((s) => s.controls) as OrbitControlsImpl | null; + const tween = useRef<{ from: THREE.Vector3; to: THREE.Vector3; t: number } | null>( + null + ); + const appliedMode = useRef+ + (null); + + useEffect(() => { + const to = viewMode === "2d" ? CAM_2D : CAM_3D; + + const snap = () => { + tween.current = null; + camera.position.copy(to); + camera.lookAt(0, 0, 0); + if (controls) { + applyModeConstraints(controls, viewMode); + controls.target.set(0, 0, 0); + controls.enabled = true; + controls.update(); + } + onTweeningChange(false); + }; + + if (appliedMode.current === viewMode) { + // Same mode re-run (e.g. controls instance arrived after mount): + // just (re)apply constraints, never tween. + if (controls) { + applyModeConstraints(controls, viewMode); + controls.target.set(0, 0, 0); + controls.update(); + } + return; + } + + const isFirst = appliedMode.current === null; + appliedMode.current = viewMode; + + if (isFirst || REDUCED) { + // Initial placement (persisted mode) or reduced motion: no animation. + snap(); + return; + } + + // Animated transition: freeze controls, tween from wherever the camera + // currently is (covers "user rotated in 3D, must return to exact top-down"). + if (controls) controls.enabled = false; + tween.current = { from: camera.position.clone(), to: to.clone(), t: 0 }; + onTweeningChange(true); + }, [viewMode, camera, controls, onTweeningChange]); + + useFrame((_, delta) => { + const tw = tween.current; + if (!tw) return; + tw.t = Math.min(1, tw.t + delta / TWEEN_DURATION); + camera.position.lerpVectors(tw.from, tw.to, easeInOutCubic(tw.t)); + camera.lookAt(0, 0, 0); + if (tw.t >= 1) { + tween.current = null; + if (controls) { + applyModeConstraints(controls, viewMode); + controls.target.set(0, 0, 0); + controls.enabled = true; + controls.update(); + } + onTweeningChange(false); + } + }); + + return null; +} + +export interface GameBoardProps { + board: Board; + currentPlayer: Player; + interactive: boolean; + lastMove: Move | null; + winLine: Move[] | null; + onHover: (m: Move | null) => void; + onPlace: (m: Move) => void; + /** + * The *human* won — purely celebratory extras (the ground glow) only. + * Win-line highlighting is informational and keys off `winLine` instead, so + * it still plays when the AI wins. + */ + celebrate?: boolean; + /** "3d" = free orbit (default), "2d" = locked top-down, zoom only. */ + viewMode?: ViewMode; + /** True while the AI is computing its move (drives the §6 thinking frame). */ + thinking?: boolean; + /** Explicit draw signal from the App layer (`status === "draw"`). */ + drawn?: boolean; +} + +function Scene({ + board, + currentPlayer, + interactive, + lastMove, + winLine, + onHover, + onPlace, + celebrate, + viewMode = "3d", + thinking, + drawn, +}: GameBoardProps) { + const [hover, setHover] = useState (null); + // Explicit interaction policy while the camera tween runs: hover preview + // stays live (harmless), but placement is blocked to prevent mis-clicks on + // a moving camera. setCamTweening is a stable setState fn (safe effect dep). + const [camTweening, setCamTweening] = useState(false); + // The thinking frame outlives `thinking` by its 450ms exit envelope. + const [frameAlive, setFrameAlive] = useState(false); + // Frozen at first render: OrbitControls JSX props match the persisted mode + // so there is no 1-frame polar-clamp flicker at mount. All later mode + // changes are applied imperatively by CameraRig (R3F never re-applies + // unchanged JSX props, so the two never fight). + const initialMode = useRef(viewMode).current; + const hoverValid = + hover !== null && board[idx(hover.x, hover.y)] === 0; + + const stones = useMemo(() => { + const list: { x: number; y: number; p: Player }[] = []; + for (let y = 0; y < 15; y++) { + for (let x = 0; x < 15; x++) { + const s = board[idx(x, y)]; + if (s !== 0) list.push({ x, y, p: s as Player }); + } + } + return list; + }, [board]); + + // §5.1: winning-line order, resolved once per win instead of per stone. + // Gated on `winLine`, NOT on `celebrate`: the shimmer / hop / dimming answer + // "which five stones decided this game", which the loser needs to see just + // as much as the winner. Only confetti and the ground glow are celebratory. + const winIndexMap = useMemo(() => { + if (!winLine) return null; + const m = new Map (); + winLine.forEach((mv, i) => m.set(`${mv.x}-${mv.y}`, i)); + return m; + }, [winLine]); + + useEffect(() => { + if (thinking) setFrameAlive(true); + }, [thinking]); + const releaseFrame = useCallback(() => setFrameAlive(false), []); + + const hoverOnLastMove = + !!hover && !!lastMove && hover.x === lastMove.x && hover.y === lastMove.y; + + return ( + <> + + + + + + + + { + setHover(m); + onHover(m); + }} + onPlace={onPlace} + /> + + {/* board base */} + + + + {/* soft grounding shadow under the floating board */} ++ + + + + {frameAlive && ( + + )} + + {stones.map((s) => { + const key = `${s.x}-${s.y}`; + const isLast = !!lastMove && lastMove.x === s.x && lastMove.y === s.y; + const wi = winIndexMap?.get(key); + return ( + + ); + })} + + {/* §2: replays on every new lastMove (keyed), self-unmounts after 470ms */} + {lastMove && !REDUCED && ( + + )} + + {/* §4.2 avoidance: never shown under a win, never fought with a same-cell hover */} + {lastMove && !winLine && !hoverOnLastMove && ( + + )} + + + + {winLine && winLine.length >= 2 && ( + + )} + + {celebrate && !REDUCED && } + + + > + ); +} + +export default function GameBoard(props: GameBoardProps) { + // Canvas `camera` is only read at creation; freeze the initial position so + // a persisted "2d" mode starts top-down without a first-frame 3D flash. + // Mode switches afterwards are handled by CameraRig — the Canvas itself is + // NEVER remounted, so the ongoing game / scene state is fully preserved. + const initialPos = useRef<[number, number, number]>( + (props.viewMode ?? "3d") === "2d" + ? [CAM_2D.x, CAM_2D.y, CAM_2D.z] + : [CAM_3D.x, CAM_3D.y, CAM_3D.z] + ).current; + return ( + + ++ ); +} diff --git a/plugins/gomoku-3d-ztools/src/components/LanguageSwitcher.tsx b/plugins/gomoku-3d-ztools/src/components/LanguageSwitcher.tsx new file mode 100644 index 000000000..6d5b972fe --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/LanguageSwitcher.tsx @@ -0,0 +1,35 @@ +import { Languages } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { play } from "@/lib/audio/sfx"; +import { writePersistent } from "@/lib/pluginHost"; + +/** + * 切换中英文界面并将选择保存到 ZTools 插件存储。 + * @returns 语言切换按钮。 + */ +export function LanguageSwitcher() { + const { i18n, t } = useTranslation(); + const isZh = (i18n.resolvedLanguage ?? i18n.language ?? "zh").startsWith("zh"); + + const toggle = () => { + const next = isZh ? "en" : "zh"; + void i18n.changeLanguage(next); + writePersistent("language", next); + play("ui_click"); + }; + + return ( + + ); +} diff --git a/plugins/gomoku-3d-ztools/src/components/ReplayPanel.tsx b/plugins/gomoku-3d-ztools/src/components/ReplayPanel.tsx new file mode 100644 index 000000000..2a1bfa389 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ReplayPanel.tsx @@ -0,0 +1,270 @@ +import { + Box, + ChevronLeft, + ChevronRight, + Download, + Film, + Grid3x3, + Pause, + Play, + RotateCw, + SkipBack, + SkipForward, + Volume2, + VolumeX, + X, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Slider } from "@/components/ui/slider"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { BLACK, type Player } from "@/lib/gomoku/types"; +import type { UseReplayReturn } from "@/hooks/useReplay"; +import type { ViewMode } from "@/hooks/useViewMode"; + +const SPEEDS: { label: string; ms: number }[] = [ + { label: "0.5×", ms: 1600 }, + { label: "1×", ms: 800 }, + { label: "2×", ms: 400 }, + { label: "4×", ms: 200 }, +]; + +export interface ReplayPanelProps { + replay: UseReplayReturn; + humanColor: Player; + muted: boolean; + onToggleMute: () => void; + viewMode: ViewMode; + onSwitchView: (m: ViewMode) => void; + onExport: () => void; + exportDisabled: boolean; + /** Mobile bottom-sheet variant: tighter paddings, same controls. */ + compact?: boolean; +} + +export default function ReplayPanel({ + replay, + humanColor, + muted, + onToggleMute, + viewMode, + onSwitchView, + onExport, + exportDisabled, + compact = false, +}: ReplayPanelProps) { + const { t } = useTranslation(); + const { currentStep, N, moves } = replay; + const playing = replay.mode === "replaying-playing"; + const atStart = currentStep === 0; + const atEnd = currentStep === N; + const currentMove = currentStep === 0 ? null : moves[currentStep - 1]; + + const info = currentMove + ? t("replay.moveInfo", { + step: currentStep, + label: currentMove.player === BLACK ? t("replay.black") : t("replay.white"), + player: + currentMove.player === humanColor + ? t("replay.player") + : t("replay.computer"), + }) + ` · (${currentMove.x}, ${currentMove.y})` + : t("replay.opening"); + + return ( ++ + ); +} diff --git a/plugins/gomoku-3d-ztools/src/components/ui/badge.tsx b/plugins/gomoku-3d-ztools/src/components/ui/badge.tsx new file mode 100644 index 000000000..4c40ee11c --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow", + secondary: + "border-transparent bg-secondary text-secondary-foreground", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes+ {/* header: badge + exit */} + +++ + {/* current move info */} ++ + +{t("replay.mode")} + + {currentMove && ( + + )} + {info} ++ + {/* progress */} +++ + {/* transport controls */} +replay.goTo(v[0])} + className="flex-1" + /> + + {currentStep} / {N} + + + + + + + ++ + {/* speed */} ++ {SPEEDS.map((s) => ( + + ))} ++ ++ + {/* companions that remain useful during replay */} + + + + ++, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/plugins/gomoku-3d-ztools/src/components/ui/button.tsx b/plugins/gomoku-3d-ztools/src/components/ui/button.tsx new file mode 100644 index 000000000..fd042bf0f --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ui/button.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-11 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes , + VariantProps { + asChild?: boolean; + /** Disable the click ripple effect (e.g. for asChild wrappers). */ + noRipple?: boolean; +} + +interface Ripple { + id: number; + x: number; + y: number; + size: number; +} + +const Button = React.forwardRef ( + ( + { className, variant, size, asChild = false, noRipple = false, onClick, children, ...props }, + ref + ) => { + const [ripples, setRipples] = React.useState ([]); + + const handleClick = (e: React.MouseEvent ) => { + if (!noRipple && !asChild) { + const el = e.currentTarget; + const rect = el.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height); + const x = e.clientX - rect.left - size / 2; + const y = e.clientY - rect.top - size / 2; + const id = performance.now() + Math.random(); + setRipples((r) => [...r, { id, x, y, size }]); + window.setTimeout( + () => setRipples((r) => r.filter((rp) => rp.id !== id)), + 600 + ); + } + onClick?.(e); + }; + + const Comp = asChild ? Slot : "button"; + return ( + + {asChild ? ( + children + ) : ( + <> + + {children} + + + {ripples.map((r) => ( + + ))} + + > + )} + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/plugins/gomoku-3d-ztools/src/components/ui/card.tsx b/plugins/gomoku-3d-ztools/src/components/ui/card.tsx new file mode 100644 index 000000000..231a1350a --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ui/card.tsx @@ -0,0 +1,85 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes& { interactive?: boolean } +>(({ className, interactive, ...props }, ref) => ( + +)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/plugins/gomoku-3d-ztools/src/components/ui/label.tsx b/plugins/gomoku-3d-ztools/src/components/ui/label.tsx new file mode 100644 index 000000000..dcb69245f --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ui/label.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; + +import { cn } from "@/lib/utils"; + +const Label = React.forwardRef< + React.ElementRef , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/plugins/gomoku-3d-ztools/src/components/ui/separator.tsx b/plugins/gomoku-3d-ztools/src/components/ui/separator.tsx new file mode 100644 index 000000000..11b874188 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef , + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/plugins/gomoku-3d-ztools/src/components/ui/slider.tsx b/plugins/gomoku-3d-ztools/src/components/ui/slider.tsx new file mode 100644 index 000000000..e0b780ecc --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ui/slider.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +import { cn } from "@/lib/utils"; + +const Slider = React.forwardRef< + React.ElementRef , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/plugins/gomoku-3d-ztools/src/components/ui/switch.tsx b/plugins/gomoku-3d-ztools/src/components/ui/switch.tsx new file mode 100644 index 000000000..f15014bd4 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +import { cn } from "@/lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef+ ++ + , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/plugins/gomoku-3d-ztools/src/components/ui/toast.tsx b/plugins/gomoku-3d-ztools/src/components/ui/toast.tsx new file mode 100644 index 000000000..05106f5c9 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/components/ui/toast.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; + +export interface ToastItem { + id: number; + title: string; + desc?: string; + icon?: React.ReactNode; + toneClass?: string; +} + +interface ToasterProps { + items: ToastItem[]; + onDismiss: (id: number) => void; +} + +export function Toaster({ items, onDismiss }: ToasterProps) { + const { t: tr } = useTranslation(); + return ( ++ + {items.map((t) => ( ++ ); +} diff --git a/plugins/gomoku-3d-ztools/src/hooks/useGomoku.ts b/plugins/gomoku-3d-ztools/src/hooks/useGomoku.ts new file mode 100644 index 000000000..5b28b1ac5 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/hooks/useGomoku.ts @@ -0,0 +1,461 @@ +import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Player, + Move, + GameStatus, + opponent, + validateMove, +} from "@/lib/gomoku/types"; +import { getAIMove, type Difficulty } from "@/lib/gomoku/ai"; +import * as engine from "@/lib/gomoku/engine"; +import { + GameStats, + loadStats, + saveStats, + recordResult, + TONE_CLASSES, + type AchievementDef, +} from "@/lib/gomoku/stats"; +import { play, unlockAudio, setMuted, isMuted } from "@/lib/audio/sfx"; +import { ACH_ICON } from "@/lib/achievementIcons"; +import { Sparkles, type LucideIcon } from "lucide-react"; +import { type ToastItem } from "@/components/ui/toast"; +import { usePluginActivity } from "@/hooks/usePluginActivity"; +import { + loadDifficulty, + loadElapsedMs, + loadGame, + loadHumanColor, + saveDifficulty, + saveElapsedMs, + saveGame, + saveHumanColor, +} from "@/lib/gomoku/persistence"; +import { setAudioActive } from "@/lib/audio/sfx"; + +export interface UseGomoku { + game: engine.GameState; + board: engine.GameState["board"]; + currentPlayer: Player; + status: GameStatus; + lastMove: Move | null; + winLine: Move[] | null; + winner: Player | null; + moveCount: number; + difficulty: Difficulty; + humanColor: Player; + aiColor: Player; + thinking: boolean; + hover: Move | null; + stats: GameStats; + muted: boolean; + elapsedMs: number; + mmss: string; + toasts: ToastItem[]; + interactive: boolean; + canUndo: boolean; + setDifficulty: (d: Difficulty) => void; + setHumanColor: (p: Player) => void; + setHover: (m: Move | null) => void; + handlePlace: (m: Move) => void; + restart: () => void; + undo: () => void; + toggleMute: () => void; + pushToast: (t: Omit+ {t.icon && ( + + {t.icon} + + )} ++ ))} +++ ++ {t.title} ++ {t.desc && ( +{t.desc}+ )} +) => void; + dismissToast: (id: number) => void; +} + +export function useGomoku(): UseGomoku { + const { t } = useTranslation(); + const pluginActive = usePluginActivity(); + const [game, setGame] = useState (loadGame); + const [difficulty, setDifficultyState] = useState (loadDifficulty); + const [humanColor, setHumanColorState] = useState (loadHumanColor); + const [thinking, setThinking] = useState(false); + const [hover, setHoverState] = useState (null); + const [stats, setStats] = useState (() => loadStats()); + const [muted, setMutedState] = useState (() => isMuted()); + const [elapsedMs, setElapsedMs] = useState(loadElapsedMs); + const [toasts, setToasts] = useState ([]); + + const aiColor = opponent(humanColor); + + // ---- ref mirrors to avoid stale closures ---- + const gameRef = useRef(game); + gameRef.current = game; + const statusRef = useRef(game.status); + statusRef.current = game.status; + const aiColorRef = useRef(aiColor); + aiColorRef.current = aiColor; + const difficultyRef = useRef(difficulty); + difficultyRef.current = difficulty; + const humanColorRef = useRef(humanColor); + humanColorRef.current = humanColor; + const statsRef = useRef(stats); + statsRef.current = stats; + const thinkingRef = useRef(thinking); + thinkingRef.current = thinking; + const aiPendingRef = useRef(false); + const recordedRef = useRef(game.status !== "playing"); + const timerRef = useRef (null); + const toastId = useRef(0); + const prevHoverRef = useRef (null); + const lastHoverPlayRef = useRef(0); + const thinkingWasTrueRef = useRef(false); + + // ---- toast helpers ---- + const pushToast = useCallback((t: Omit ) => { + const id = ++toastId.current; + setToasts((prev) => [...prev, { ...t, id }]); + window.setTimeout( + () => setToasts((prev) => prev.filter((x) => x.id !== id)), + 4200 + ); + }, []); + + const dismissToast = useCallback((id: number) => { + setToasts((prev) => prev.filter((x) => x.id !== id)); + }, []); + + const pushAchievementToast = useCallback( + (a: AchievementDef) => { + const Icon: LucideIcon = ACH_ICON[a.icon] ?? Sparkles; + const tone = TONE_CLASSES[a.tone]; + pushToast({ + title: `${t("toast.achievementUnlocked")} · ${t(`achievements.${a.id}.title`)}`, + desc: t(`achievements.${a.id}.desc`), + icon: createElement(Icon, { className: "h-5 w-5" }), + toneClass: `${tone.bg} ${tone.text}`, + }); + }, + [pushToast, t] + ); + + // ---- apply a move (shared by human + AI) ---- + const applyMove = useCallback((m: Move, player: Player) => { + setGame((prev) => engine.placeStone(prev, m, player)); + }, []); + + const handlePlace = useCallback( + (m: Move) => { + if (statusRef.current !== "playing") return; + if (gameRef.current.currentPlayer !== humanColorRef.current) return; + if (thinkingRef.current) return; + if (!validateMove(gameRef.current.board, m)) return; + try { + unlockAudio(); + play("place_player"); + } catch { + /* ignore */ + } + applyMove(m, humanColorRef.current); + }, + [applyMove] + ); + + // ---- persist the resumable match and elapsed time in the host store ---- + useEffect(() => saveGame(game), [game]); + useEffect(() => saveElapsedMs(elapsedMs), [elapsedMs]); + + // ---- suspend sound when the plugin leaves the foreground ---- + useEffect(() => { + setAudioActive(pluginActive); + if (!pluginActive) setHoverState(null); + return () => setAudioActive(false); + }, [pluginActive]); + + // ---- AI turn: schedule exactly once per turn (no self-cancel bug) ---- + // NOTE: `thinking` is intentionally NOT in the dependency array — including it + // would cancel the in-flight timeout and deadlock the AI. + useEffect(() => { + const isAITurn = + pluginActive && game.status === "playing" && game.currentPlayer === aiColor; + if (!isAITurn) { + aiPendingRef.current = false; + // BUG-QA-01 fix: switching 先手 mid-thinking cancels the AI timer (cleanup) + // and re-enters this branch; without resetting thinking the UI soft-locks. + setThinking(false); + return; + } + if (aiPendingRef.current) return; + aiPendingRef.current = true; + setThinking(true); + const delay = 320 + (difficulty === "master" ? 400 : 0); + const t = window.setTimeout(() => { + const m = getAIMove(gameRef.current.board, aiColor, difficultyRef.current); + aiPendingRef.current = false; + setThinking(false); + if (m) { + try { + play("place_ai"); + } catch { + /* ignore */ + } + applyMove(m, aiColor); + } else { + setGame((prev) => ({ ...prev, status: "draw", winner: null })); + } + }, delay); + return () => window.clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pluginActive, game.status, game.currentPlayer, aiColor, applyMove]); + + // ---- record finished game into stats + fire result / achievement sounds ---- + useEffect(() => { + if (game.status === "playing") { + recordedRef.current = false; + return; + } + if (recordedRef.current) return; + recordedRef.current = true; + + const outcome: "win" | "loss" | "draw" = + game.status === "draw" + ? "draw" + : game.winner === humanColorRef.current + ? "win" + : "loss"; + const res = recordResult(statsRef.current, { + outcome, + moves: game.moveCount, + difficulty: difficultyRef.current, + }); + setStats(res.stats); + saveStats(res.stats); + + window.setTimeout(() => { + try { + if (outcome === "win") play("victory"); + else if (outcome === "loss") play("defeat"); + else play("draw"); + } catch { + /* ignore */ + } + }, 150); + + res.unlocked.forEach((a, i) => + window.setTimeout( + () => { + try { + play("achievement"); + } catch { + /* ignore */ + } + pushAchievementToast(a); + }, + 250 + i * 350 + ) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [game.status, pushAchievementToast]); + + // ---- per-game timer ---- + useEffect(() => { + if (!pluginActive || game.status !== "playing" || game.moveCount === 0) { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + return; + } + if (timerRef.current !== null) return; // already running + timerRef.current = window.setInterval(() => { + setElapsedMs((e) => e + 1000); + }, 1000); + }, [pluginActive, game.status, game.moveCount]); + + useEffect( + () => () => { + if (timerRef.current !== null) window.clearInterval(timerRef.current); + }, + [] + ); + + // ---- thinking sound ---- + useEffect(() => { + try { + if (thinking) { + play("thinking_start"); + thinkingWasTrueRef.current = true; + } else if (thinkingWasTrueRef.current) { + play("thinking_end"); + thinkingWasTrueRef.current = false; + } + } catch { + /* ignore */ + } + }, [thinking]); + + // ---- hover sound (throttled) ---- + useEffect(() => { + const prev = prevHoverRef.current; + prevHoverRef.current = hover; + if (!hover) return; + if (prev && prev.x === hover.x && prev.y === hover.y) return; + const t = typeof performance !== "undefined" ? performance.now() : Date.now(); + if (t - lastHoverPlayRef.current < 40) return; + lastHoverPlayRef.current = t; + try { + play("hover"); + } catch { + /* ignore */ + } + }, [hover]); + + // ---- actions ---- + const setDifficulty = useCallback((d: Difficulty) => { + try { + unlockAudio(); + } catch { + /* ignore */ + } + setDifficultyState(d); + saveDifficulty(d); + }, []); + + const setHumanColor = useCallback((p: Player) => { + try { + unlockAudio(); + play("ui_click"); + } catch { + /* ignore */ + } + setHumanColorState(p); + saveHumanColor(p); + }, []); + + const setHover = useCallback((m: Move | null) => setHoverState(m), []); + + const restart = useCallback(() => { + try { + unlockAudio(); + play("ui_click"); + } catch { + /* ignore */ + } + setGame(engine.createGame()); + setThinking(false); + aiPendingRef.current = false; + setHoverState(null); + recordedRef.current = false; + setElapsedMs(0); + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const undo = useCallback(() => { + if (thinkingRef.current) return; + if (gameRef.current.status !== "playing") return; + if (gameRef.current.history.length < 2) return; // need a full round + try { + unlockAudio(); + play("undo"); + } catch { + /* ignore */ + } + setGame((prev) => engine.undo(prev)); + recordedRef.current = false; + }, []); + + const toggleMute = useCallback(() => { + try { + unlockAudio(); + } catch { + /* ignore */ + } + setMutedState((prev) => { + const next = !prev; + if (prev === false && next === true) { + // muting: click is still audible, so play it before silencing. + try { + play("ui_click"); + } catch { + /* ignore */ + } + try { + setMuted(next); + } catch { + /* ignore */ + } + } else { + // unmuting (muted→unmuted): re-enable sound first, then confirm tick. + try { + setMuted(next); + } catch { + /* ignore */ + } + try { + play("unmute_tick"); + } catch { + /* ignore */ + } + } + return next; + }); + }, []); + + // ---- keyboard: Ctrl/Cmd+Z to undo ---- + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && (e.key === "z" || e.key === "Z")) { + e.preventDefault(); + undo(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [undo]); + + // ---- derived ---- + const interactive = + pluginActive && + game.status === "playing" && + game.currentPlayer === humanColor && + !thinking; + const canUndo = + pluginActive && + game.history.length >= 2 && + game.status === "playing" && + !thinking; + const mmss = useMemo(() => { + const total = Math.floor(elapsedMs / 1000); + const m = Math.floor(total / 60); + const s = total % 60; + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + }, [elapsedMs]); + + return { + game, + board: game.board, + currentPlayer: game.currentPlayer, + status: game.status, + lastMove: game.lastMove, + winLine: game.winLine, + winner: game.winner, + moveCount: game.moveCount, + difficulty, + humanColor, + aiColor, + thinking, + hover, + stats, + muted, + elapsedMs, + mmss, + toasts, + interactive, + canUndo, + setDifficulty, + setHumanColor, + setHover, + handlePlace, + restart, + undo, + toggleMute, + pushToast, + dismissToast, + }; +} diff --git a/plugins/gomoku-3d-ztools/src/hooks/usePluginActivity.ts b/plugins/gomoku-3d-ztools/src/hooks/usePluginActivity.ts new file mode 100644 index 000000000..c9c8d5477 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/hooks/usePluginActivity.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { PLUGIN_ACTIVITY_EVENT } from "@/lib/pluginHost"; + +/** + * 订阅插件前后台状态,供 AI、计时器和音频暂停使用。 + * @returns 插件当前是否处于激活状态。 + */ +export function usePluginActivity(): boolean { + const [active, setActive] = useState(() => window.__gomokuPluginActive !== false); + + useEffect(() => { + const handleActivity = (event: Event) => { + setActive((event as CustomEvent ).detail !== false); + }; + window.addEventListener(PLUGIN_ACTIVITY_EVENT, handleActivity); + return () => window.removeEventListener(PLUGIN_ACTIVITY_EVENT, handleActivity); + }, []); + + return active; +} diff --git a/plugins/gomoku-3d-ztools/src/hooks/useReplay.ts b/plugins/gomoku-3d-ztools/src/hooks/useReplay.ts new file mode 100644 index 000000000..e0414fa21 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/hooks/useReplay.ts @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Board, Move, createBoard } from "@/lib/gomoku/types"; +import type { GameState } from "@/lib/gomoku/engine"; +import { + boardAtStep, + deriveMoves, + snapshotGame, + type ReplayMove, + type ReplaySnapshot, +} from "@/lib/gomoku/replay"; + +export type ReplayMode = "idle" | "replaying-paused" | "replaying-playing"; + +/** Everything GameBoard needs while replaying (mode !== "idle"). */ +export interface ReplayDisplay { + board: Board; + lastMove: Move | null; + winLine: Move[] | null; + interactive: boolean; + drawn: boolean; + celebrate: boolean; + thinking: boolean; + currentMove: ReplayMove | null; +} + +export interface UseReplayReturn { + mode: ReplayMode; + currentStep: number; + speedMs: number; + N: number; + moves: ReplayMove[]; + snapshot: ReplaySnapshot | null; + canEnterReplay: boolean; + display: ReplayDisplay; + enterReplay: () => void; + exitReplay: () => void; + stepForward: () => void; + stepBack: () => void; + goTo: (k: number) => void; + play: () => void; + pause: () => void; + togglePlay: () => void; + setSpeed: (ms: number) => void; +} + +/** + * Read-only replay state machine over a frozen snapshot of the live game. + * The live `game` object is never touched; exiting replay simply renders the + * real state again. + */ +export function useReplay(game: GameState, thinking: boolean): UseReplayReturn { + const [mode, setMode] = useState ("idle"); + const [currentStep, setCurrentStep] = useState(0); + const [speedMs, setSpeedMs] = useState(800); // 1× = 800ms per move + const [snapshot, setSnapshot] = useState (null); + const [moves, setMoves] = useState ([]); + + const N = snapshot ? snapshot.moveCount : 0; + + // ref mirrors so all actions stay referentially stable + const gameRef = useRef(game); + gameRef.current = game; + const thinkingRef = useRef(thinking); + thinkingRef.current = thinking; + const modeRef = useRef(mode); + modeRef.current = mode; + const nRef = useRef(N); + nRef.current = N; + + const canEnterReplay = game.moveCount > 0 && !thinking; + + const enterReplay = useCallback(() => { + if (gameRef.current.moveCount === 0 || thinkingRef.current) return; + const snap = snapshotGame(gameRef.current); + setSnapshot(snap); + setMoves(deriveMoves(snap)); + setCurrentStep(0); + setMode("replaying-paused"); + }, []); + + const exitReplay = useCallback(() => { + setMode("idle"); + setSnapshot(null); + setMoves([]); + setCurrentStep(0); + }, []); + + const pause = useCallback(() => { + setMode((m) => (m === "replaying-playing" ? "replaying-paused" : m)); + }, []); + + const stepForward = useCallback(() => { + setCurrentStep((s) => Math.min(nRef.current, s + 1)); + }, []); + + const stepBack = useCallback(() => { + pause(); + setCurrentStep((s) => Math.max(0, s - 1)); + }, [pause]); + + const goTo = useCallback( + (k: number) => { + pause(); + setCurrentStep(Math.max(0, Math.min(nRef.current, Math.round(k)))); + }, + [pause] + ); + + const play = useCallback(() => { + if (modeRef.current === "idle") return; + // replay-from-start semantics when already at the last move + setCurrentStep((s) => (s >= nRef.current ? 0 : s)); + setMode("replaying-playing"); + }, []); + + const togglePlay = useCallback(() => { + if (modeRef.current === "replaying-playing") pause(); + else play(); + }, [pause, play]); + + const setSpeed = useCallback((ms: number) => setSpeedMs(ms), []); + + // auto-play: rebuilt whenever mode or speed changes + useEffect(() => { + if (mode !== "replaying-playing") return; + const t = window.setInterval(() => stepForward(), speedMs); + return () => window.clearInterval(t); + }, [mode, speedMs, stepForward]); + + // reaching the last move while playing → auto pause (clears the interval) + useEffect(() => { + if (mode === "replaying-playing" && currentStep >= N) pause(); + }, [mode, currentStep, N, pause]); + + const display: ReplayDisplay = useMemo(() => { + if (!snapshot) { + return { + board: createBoard(), + lastMove: null, + winLine: null, + interactive: false, + drawn: false, + celebrate: false, + thinking: false, + currentMove: null, + }; + } + const currentMove = currentStep === 0 ? null : moves[currentStep - 1] ?? null; + const atEnd = currentStep === snapshot.moveCount; + return { + board: boardAtStep(snapshot, currentStep), + lastMove: currentMove ? { x: currentMove.x, y: currentMove.y } : null, + // the win line only exists at the very last step (mid-replay the game + // "had not been won yet") + winLine: atEnd && snapshot.winner ? snapshot.winLine : null, + interactive: false, + drawn: atEnd && snapshot.status === "draw", + celebrate: false, + thinking: false, + currentMove, + }; + }, [snapshot, moves, currentStep]); + + return { + mode, + currentStep, + speedMs, + N, + moves, + snapshot, + canEnterReplay, + display, + enterReplay, + exitReplay, + stepForward, + stepBack, + goTo, + play, + pause, + togglePlay, + setSpeed, + }; +} diff --git a/plugins/gomoku-3d-ztools/src/hooks/useViewMode.ts b/plugins/gomoku-3d-ztools/src/hooks/useViewMode.ts new file mode 100644 index 000000000..15b1379db --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/hooks/useViewMode.ts @@ -0,0 +1,35 @@ +import { useCallback, useState } from "react"; +import { readPersistent, writePersistent } from "@/lib/pluginHost"; + +/** + * Board view mode: + * - "3d": free orbit camera + * - "2d": locked top-down camera, rotation disabled, zoom kept + */ +export type ViewMode = "2d" | "3d"; + +const STORAGE_KEY = "view-mode"; + +/** + * 读取并校验持久化的棋盘视图。 + * @returns 有效的 2D 或 3D 视图模式。 + */ +function loadViewMode(): ViewMode { + const value = readPersistent (STORAGE_KEY, "3d"); + return value === "2d" || value === "3d" ? value : "3d"; +} + +/** + * 提供持久化的棋盘视图状态。 + * @returns 当前视图和更新函数。 + */ +export function useViewMode(): [ViewMode, (mode: ViewMode) => void] { + const [viewMode, setViewModeState] = useState (loadViewMode); + + const setViewMode = useCallback((mode: ViewMode) => { + setViewModeState(mode); + writePersistent(STORAGE_KEY, mode); + }, []); + + return [viewMode, setViewMode]; +} diff --git a/plugins/gomoku-3d-ztools/src/i18n/index.ts b/plugins/gomoku-3d-ztools/src/i18n/index.ts new file mode 100644 index 000000000..a33ea5621 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/i18n/index.ts @@ -0,0 +1,28 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import { readPersistent } from "@/lib/pluginHost"; +import { zh } from "./locales/zh"; +import { en } from "./locales/en"; + +export const resources = { + zh: { translation: zh }, + en: { translation: en }, +} as const; + +export const SUPPORTED_LANGS = ["zh", "en"] as const; +export type Lang = (typeof SUPPORTED_LANGS)[number]; + +const savedLanguage = readPersistent ("language", "zh"); +const initialLanguage: Lang = savedLanguage === "en" ? "en" : "zh"; + +i18n.use(initReactI18next).init({ + resources, + lng: initialLanguage, + fallbackLng: "zh", + supportedLngs: SUPPORTED_LANGS as unknown as string[], + load: "languageOnly", + nonExplicitSupportedLngs: true, + interpolation: { escapeValue: false }, +}); + +export default i18n; diff --git a/plugins/gomoku-3d-ztools/src/i18n/locales/en.ts b/plugins/gomoku-3d-ztools/src/i18n/locales/en.ts new file mode 100644 index 000000000..3708b2a52 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/i18n/locales/en.ts @@ -0,0 +1,143 @@ +import type { Resources } from "./zh"; + +// English text resources. Mirrors the shape of `zh` for full type-safety. +export const en: Resources = { + header: { + title: "Gomoku", + brand: "3D", + subtitle: "Play vs AI · Hover preview · Guide lines", + sound: "Sound", + soundToggle: "Toggle sound", + documentTitle: "Gomoku 3D", + }, + status: { + draw: "Draw", + youWin: "You win", + aiWin: "AI wins", + thinking: "AI thinking…", + yourTurn: "Your turn", + aiTurn: "AI's turn", + }, + banner: { + draw: "Draw", + win: "You win · Five in a row!", + lose: "So close · Try again", + winLine: " · Winning line highlighted", + }, + board: { + thinking: "AI thinking", + replayMode: "Replay", + replayReadonly: "Replaying · read-only", + yourTurn: "Your turn", + aiTurn: "AI's turn", + moves: "Moves", + time: "Time", + hint: "Hover over a board intersection to preview a translucent stone and orange guide lines", + }, + difficulty: { + title: "Difficulty", + desc: "Higher is stronger — the AI attacks and defends", + current: "Current: {{name}}", + easy: { label: "Easy", hint: "Occasional slips" }, + medium: { label: "Medium", hint: "Solid play" }, + hard: { label: "Hard", hint: "Attack & defend" }, + master: { label: "Master", hint: "Flawless calc" }, + }, + match: { + title: "Match", + humanFirst: "You first", + aiFirst: "AI first", + view: "Switch view", + view2d: "2D", + view3d: "3D", + view2dTitle: "2D top-down · rotation locked", + view3dTitle: "3D · free camera", + switchToggleFirst: "Toggle who goes first", + restart: "Restart", + undo: "Undo", + replay: "Replay", + replayThis: "Replay this game", + replayNone: "No moves to replay", + export: "Export", + exportNone: "No moves to export", + switchTo2d: "Switch to 2D view", + switchTo3d: "Switch to 3D view", + thinking: "AI thinking", + }, + overlay: { + win: "Victory!", + lose: "Defeat", + draw: "Draw", + winDesc: "Five in a row — a beautiful finish!", + loseDesc: "So close. Rematch and turn it around.", + drawDesc: "Evenly matched — a worthy opponent.", + playAgain: "Play again", + replay: "Replay this game", + backToMenu: "Back to menu", + }, + stats: { + title: "Record & Achievements", + win: "W", + loss: "L", + draw: "D", + streak: "Win streak", + }, + replay: { + mode: "Replay", + exit: "Exit", + exitReplay: "Exit replay", + black: "Black", + white: "White", + progress: "Replay progress", + first: "First move", + prev: "Previous move", + play: "Play", + pause: "Pause", + replayAgain: "Replay", + next: "Next move", + last: "Last move", + speed: "Playback speed", + soundToggle: "Toggle sound", + view: "Switch view", + moveInfo: "Move {{step}} · {{label}} ({{player}})", + opening: "Start · empty board", + player: "You", + computer: "AI", + export: "Export", + }, + export: { + title: "Export game", + format: "Format", + filename: "File name", + filenameLabel: "Export file name", + cancel: "Cancel", + export: "Export", + close: "Close", + emptyTip: "Nothing to export", + exported: "Exported {{filename}}", + exportedDesc: "{{count}} moves · {{format}} format", + exportFailed: "Export failed, please retry", + sgf: { desc: "Universal game format, imports into most Go/Gomoku/Renju apps" }, + json: { desc: "Structured data with metadata, easy for programs to read and extend" }, + txt: { desc: "Plain-text coordinate list, human-readable, easy to share" }, + }, + toast: { + close: "Close", + achievementUnlocked: "Achievement unlocked", + eggTitle: "Easter egg ✦", + eggDesc: "May your stones flow like the wind — five in a row!", + }, + achievements: { + first_win: { title: "First Blood", desc: "Win your very first game" }, + streak3: { title: "On a Roll", desc: "Win 3 games in a row" }, + streak5: { title: "Penta Streak", desc: "Win 5 games in a row" }, + speed: { title: "Blitz", desc: "Win in 24 moves or fewer" }, + beat_hard: { title: "Dragon Slayer", desc: "Beat the AI on Hard" }, + draw_master: { title: "Even Match", desc: "Draw a game" }, + master_win: { title: "Crownless King", desc: "Beat the AI on Master" }, + }, + lang: { + label: "Language", + switch: "切换语言 / Switch language", + }, +}; diff --git a/plugins/gomoku-3d-ztools/src/i18n/locales/zh.ts b/plugins/gomoku-3d-ztools/src/i18n/locales/zh.ts new file mode 100644 index 000000000..b981ac22b --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/i18n/locales/zh.ts @@ -0,0 +1,143 @@ +// 简体中文文案资源。所有界面文本集中于此,禁止在组件内硬编码。 +export const zh = { + header: { + title: "五子棋", + brand: "Gomoku", + subtitle: "人机对战 · 悬浮预览 · 落子引导线", + sound: "音效", + soundToggle: "音效开关", + documentTitle: "五子棋 · Gomoku 3D", + }, + status: { + draw: "平局", + youWin: "你 获胜", + aiWin: "AI 获胜", + thinking: "AI 思考中…", + yourTurn: "轮到你落子", + aiTurn: "AI 回合", + }, + banner: { + draw: "平局", + win: "你赢了 · 五子连珠!", + lose: "惜败 · 再来一局", + winLine: " · 已高亮胜利连线", + }, + board: { + thinking: "AI 思考中", + replayMode: "回放模式", + replayReadonly: "回放中 · 只读", + yourTurn: "轮到你", + aiTurn: "AI 回合", + moves: "手数", + time: "用时", + hint: "将鼠标移到棋盘交叉点,会显示半透明落子预览与橙色引导线", + }, + difficulty: { + title: "难度", + desc: "越高越强,AI 会尝试进攻与防守", + current: "当前:{{name}}", + easy: { label: "简单", hint: "偶有失误" }, + medium: { label: "中等", hint: "稳健应对" }, + hard: { label: "困难", hint: "攻防兼备" }, + master: { label: "大师", hint: "深算无懈" }, + }, + match: { + title: "对局", + humanFirst: "玩家先手", + aiFirst: "AI 先手", + view: "切换视图", + view2d: "2D 平面", + view3d: "3D 立体", + view2dTitle: "2D 俯视 · 锁定旋转", + view3dTitle: "3D 立体 · 自由视角", + switchToggleFirst: "切换先手", + restart: "重新开始", + undo: "悔棋", + replay: "回放", + replayThis: "回放本局", + replayNone: "暂无棋谱可回放", + export: "导出棋谱", + exportNone: "暂无棋谱可导出", + switchTo2d: "切换到 2D 视图", + switchTo3d: "切换到 3D 视图", + thinking: "AI 思考中", + }, + overlay: { + win: "胜利!", + lose: "惜败", + draw: "平局", + winDesc: "五子连珠,漂亮的一手收官!", + loseDesc: "差之毫厘,再来一局扳回来。", + drawDesc: "势均力敌,棋逢对手。", + playAgain: "再来一局", + replay: "回放本局", + backToMenu: "返回菜单", + }, + stats: { + title: "战绩 & 成就", + win: "胜", + loss: "负", + draw: "和", + streak: "当前连胜", + }, + replay: { + mode: "回放模式", + exit: "退出", + exitReplay: "退出回放", + black: "黑", + white: "白", + progress: "回放进度", + first: "首手", + prev: "上一手", + play: "播放", + pause: "暂停", + replayAgain: "重播", + next: "下一手", + last: "末手", + speed: "播放速度", + soundToggle: "音效开关", + view: "切换视图", + moveInfo: "第 {{step}} 手 · {{label}}({{player}})", + opening: "开局 · 空盘", + player: "玩家", + computer: "电脑", + export: "导出棋谱", + }, + export: { + title: "导出棋谱", + format: "格式", + filename: "文件名", + filenameLabel: "导出文件名", + cancel: "取消", + export: "导出", + close: "关闭", + emptyTip: "空对局无法导出", + exported: "已导出 {{filename}}", + exportedDesc: "共 {{count}} 手 · {{format}} 格式", + exportFailed: "导出失败,请重试", + sgf: { desc: "通用棋谱格式,可导入绝大多数围棋/五子棋/连珠软件" }, + json: { desc: "结构化数据,含元信息,便于程序读取与二次开发" }, + txt: { desc: "纯文本坐标列表,人类可读,便于粘贴分享" }, + }, + toast: { + close: "关闭", + achievementUnlocked: "成就解锁", + eggTitle: "彩蛋 ✦", + eggDesc: "愿你落子如风,五子连珠!", + }, + achievements: { + first_win: { title: "初露锋芒", desc: "赢下你的第一盘对局" }, + streak3: { title: "小有斩获", desc: "连续获胜 3 局" }, + streak5: { title: "五连胜王", desc: "连续获胜 5 局" }, + speed: { title: "雷霆一击", desc: "在 24 手之内取胜" }, + beat_hard: { title: "屠龙者", desc: "在困难难度下击败 AI" }, + draw_master: { title: "棋逢对手", desc: "下成一盘和棋" }, + master_win: { title: "无冕之王", desc: "在大师难度下击败 AI" }, + }, + lang: { + label: "语言", + switch: "切换语言 / Switch language", + }, +}; + +export type Resources = typeof zh; diff --git a/plugins/gomoku-3d-ztools/src/index.css b/plugins/gomoku-3d-ztools/src/index.css new file mode 100644 index 000000000..6e5991ea6 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/index.css @@ -0,0 +1,282 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@config "../tailwind.config.js"; + +@layer base { + :root { + --background: 30 33% 98%; + --foreground: 20 14% 12%; + --card: 0 0% 100%; + --card-foreground: 20 14% 12%; + --primary: 24 95% 53%; + --primary-foreground: 0 0% 100%; + --secondary: 30 20% 92%; + --secondary-foreground: 20 14% 20%; + --muted: 30 16% 94%; + --muted-foreground: 25 10% 45%; + --accent: 24 90% 95%; + --accent-foreground: 24 80% 35%; + --destructive: 0 72% 51%; + --destructive-foreground: 0 0% 100%; + --border: 30 15% 86%; + --input: 30 15% 86%; + --ring: 24 95% 53%; + --radius: 0.75rem; + } + + .dark { + --background: 20 14% 10%; + --foreground: 30 10% 96%; + --card: 20 14% 13%; + --card-foreground: 30 10% 96%; + --primary: 24 95% 55%; + --primary-foreground: 20 14% 8%; + --secondary: 20 10% 20%; + --secondary-foreground: 30 10% 92%; + --muted: 20 10% 18%; + --muted-foreground: 25 8% 60%; + --accent: 24 50% 22%; + --accent-foreground: 24 90% 80%; + --destructive: 0 62% 50%; + --destructive-foreground: 0 0% 100%; + --border: 20 10% 24%; + --input: 20 10% 24%; + --ring: 24 95% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + html, + body, + #root { + min-height: 100%; + background: transparent; + } + body { + @apply text-foreground antialiased; + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif; + } +} + +/* ============================================================ + Design tokens: layered shadows + glow for depth & hierarchy + ============================================================ */ +:root { + --shadow-soft: 0 1px 2px rgba(60, 40, 20, 0.06), + 0 4px 12px -2px rgba(60, 40, 20, 0.08); + --shadow-card: 0 2px 8px rgba(60, 40, 20, 0.07), + 0 18px 40px -16px rgba(60, 40, 20, 0.22); + --shadow-float: 0 12px 30px -12px rgba(234, 88, 12, 0.32), + 0 6px 14px -6px rgba(60, 40, 20, 0.12); + --shadow-glow: 0 0 0 1px rgba(234, 88, 12, 0.25), + 0 10px 28px -8px rgba(234, 88, 12, 0.45); +} + +/* ============================================================ + Keyframes + ============================================================ */ +@keyframes shimmer { + 100% { + transform: translateX(100%); + } +} +@keyframes float-bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-8px); + } +} +@keyframes pop-in { + 0% { + transform: scale(0.7); + opacity: 0; + } + 60% { + transform: scale(1.06); + opacity: 1; + } + 100% { + transform: scale(1); + } +} +@keyframes fade-up { + 0% { + transform: translateY(10px); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} +@keyframes pulse-ring { + 0% { + transform: scale(0.85); + opacity: 0.85; + } + 70% { + transform: scale(1.8); + opacity: 0; + } + 100% { + transform: scale(1.8); + opacity: 0; + } +} +@keyframes wiggle { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(-7deg); + } + 75% { + transform: rotate(7deg); + } +} +@keyframes twinkle { + 0%, + 100% { + opacity: 0.25; + } + 50% { + opacity: 0.9; + } +} +@keyframes aurora-shift { + 0% { + transform: translate3d(-6%, -4%, 0) scale(1.1); + } + 50% { + transform: translate3d(6%, 4%, 0) scale(1.25); + } + 100% { + transform: translate3d(-6%, -4%, 0) scale(1.1); + } +} +@keyframes bounce-dot { + 0%, + 80%, + 100% { + transform: translateY(0); + opacity: 0.5; + } + 40% { + transform: translateY(-5px); + opacity: 1; + } +} +@keyframes confetti-fall { + 0% { + transform: translate(0, 0) rotate(0deg); + opacity: 1; + } + 100% { + transform: translate(var(--cx, 0), var(--cy, -120px)) rotate(540deg); + opacity: 0; + } +} + +/* ============================================================ + Utility classes + ============================================================ */ +@layer components { + .animate-shimmer { + animation: shimmer 1.6s infinite; + } + .animate-float { + animation: float-bob 5s ease-in-out infinite; + } + .animate-pop-in { + animation: pop-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both; + } + .animate-fade-up { + animation: fade-up 0.5s ease-out both; + } + .animate-wiggle { + animation: wiggle 0.6s ease-in-out; + } + .animate-twinkle { + animation: twinkle 3.5s ease-in-out infinite; + } + .animate-aurora { + animation: aurora-shift 18s ease-in-out infinite; + } + + /* Frosted-glass panel */ + .glass { + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.82), + rgba(255, 252, 247, 0.72) + ); + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); + border: 1px solid rgba(255, 255, 255, 0.6); + box-shadow: var(--shadow-card); + } + + /* Animated multi-stop gradient text for the brand */ + .text-gradient { + background: linear-gradient( + 100deg, + #ea580c, + #f59e0b 35%, + #f97316 60%, + #d946ef 100% + ); + background-size: 200% auto; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: bg-pan 12s linear infinite; + } + @keyframes bg-pan { + to { + background-position: 200% center; + } + } + + /* Shimmering skeleton block */ + .skeleton { + position: relative; + overflow: hidden; + background: rgba(120, 90, 60, 0.08); + } + .skeleton::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.55), + transparent + ); + animation: shimmer 1.6s infinite; + } +} + +/* ============================================================ + Respect users who prefer reduced motion (a11y + perf) + ============================================================ */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} diff --git a/plugins/gomoku-3d-ztools/src/lib/achievementIcons.ts b/plugins/gomoku-3d-ztools/src/lib/achievementIcons.ts new file mode 100644 index 000000000..ccfb52f78 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/lib/achievementIcons.ts @@ -0,0 +1,20 @@ +import { + Sparkles, + Flame, + Crown, + Zap, + Sword, + Handshake, + type LucideIcon, +} from "lucide-react"; + +/** Maps an achievement's `icon` string to a lucide component. Kept in one place + * so the toasts (hook) and the achievement grid (App) stay in sync. */ +export const ACH_ICON: Record = { + Sparkles, + Flame, + Crown, + Zap, + Sword, + Handshake, +}; diff --git a/plugins/gomoku-3d-ztools/src/lib/audio/sfx.ts b/plugins/gomoku-3d-ztools/src/lib/audio/sfx.ts new file mode 100644 index 000000000..56696f260 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/lib/audio/sfx.ts @@ -0,0 +1,350 @@ +/** + * Zero-dependency Web Audio sound engine. Synthesizes all UI / game sounds from + * oscillators + filtered noise. The AudioContext is created lazily on first use + * and resumed inside a user gesture via `unlockAudio`. Everything degrades + * gracefully (no-ops) when audio is unavailable (SSR, privacy mode, failures). + */ + +import { readPersistent, writePersistent } from "@/lib/pluginHost"; + +export type SfxEvent = + | "place_player" + | "place_ai" + | "victory" + | "defeat" + | "draw" + | "ui_click" + | "achievement" + | "hover" + | "thinking_start" + | "thinking_end" + | "undo" + | "unmute_tick"; + +const STORAGE_KEY = "audio-muted"; + +let ctx: AudioContext | null = null; +let masterGain: GainNode | null = null; +let muted = false; +let active = true; + +function getCtx(): AudioContext | null { + if (ctx) return ctx; + try { + const AC: typeof AudioContext | undefined = + typeof window !== "undefined" + ? window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext + : undefined; + if (!AC) return null; + ctx = new AC(); + masterGain = ctx.createGain(); + masterGain.gain.value = muted || !active ? 0 : 0.7; + + const lp = ctx.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = 6000; + + const comp = ctx.createDynamicsCompressor(); + comp.threshold.value = -18; + comp.ratio.value = 4; + comp.knee.value = 12; + + masterGain.connect(lp); + lp.connect(comp); + comp.connect(ctx.destination); + } catch { + ctx = null; + masterGain = null; + } + return ctx; +} + +/** Resume the context inside a user gesture (required by browser autoplay). */ +export function unlockAudio(): void { + const c = getCtx(); + if (!c) return; + try { + if (c.state === "suspended") void c.resume(); + } catch { + /* ignore */ + } +} + +/** Toggle mute. Persisted in the ZTools plugin store; smoothing avoids a click. */ +export function setMuted(value: boolean): void { + muted = value; + writePersistent(STORAGE_KEY, value); + if (masterGain && ctx) { + try { + masterGain.gain.setTargetAtTime(value || !active ? 0 : 0.7, ctx.currentTime, 0.02); + } catch { + /* ignore */ + } + } +} + +/** + * Pause or resume all generated audio with the plugin lifecycle. + * @param value Whether the plugin is currently active. + * @returns No value. + */ +export function setAudioActive(value: boolean): void { + active = value; + if (masterGain && ctx) { + try { + masterGain.gain.setTargetAtTime(muted || !active ? 0 : 0.7, ctx.currentTime, 0.02); + if (!active && ctx.state === "running") void ctx.suspend(); + if (active && ctx.state === "suspended") void ctx.resume(); + } catch { + /* ignore */ + } + } +} + +export function isMuted(): boolean { + return muted; +} + +function initMutedFromStorage(): void { + muted = readPersistent (STORAGE_KEY, false) === true; +} +initMutedFromStorage(); + +function makeNoise(c: AudioContext, dur: number): AudioBufferSourceNode { + const len = Math.max(1, Math.floor(c.sampleRate * dur)); + const buf = c.createBuffer(1, len, c.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1; + const src = c.createBufferSource(); + src.buffer = buf; + return src; +} + +interface BlipOpts { + type?: OscillatorType; + f0: number; + f1?: number; // glide target (exponential) + t0: number; // absolute ctx time + dur: number; // seconds + a: number; // attack (s) + d: number; // decay to ~0 (s) + peak: number; + lp?: number; // lowpass cutoff + noise?: { hp: number; gain: number; dur: number; t: number }; + sub?: { f: number; gain: number; type?: OscillatorType }; + detune?: number; // cents slide (e.g. +4% up) + partials?: { mult: number; gain: number; type?: OscillatorType }[]; +} + +function blip(c: AudioContext, o: BlipOpts): void { + const now = o.t0; + const g = c.createGain(); + g.gain.setValueAtTime(0.0001, now); + g.gain.linearRampToValueAtTime(o.peak, now + o.a); + g.gain.linearRampToValueAtTime(0.0001, now + o.a + o.d); + + let node: AudioNode = g; + let lp: BiquadFilterNode | null = null; + if (o.lp) { + lp = c.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = o.lp; + g.connect(lp); + node = lp; + } + node.connect(masterGain!); + + const spawnOsc = ( + freq: number, + type: OscillatorType, + gain: number, + glideTo?: number + ): void => { + const osc = c.createOscillator(); + osc.type = type; + osc.frequency.setValueAtTime(freq, now); + if (glideTo !== undefined) { + osc.frequency.exponentialRampToValueAtTime( + Math.max(0.0001, glideTo), + now + o.a + o.d + ); + } + if (o.detune) { + osc.detune.setValueAtTime(0, now); + osc.detune.linearRampToValueAtTime(o.detune, now + o.dur); + } + const og = c.createGain(); + og.gain.value = gain; + osc.connect(og); + og.connect(g); + osc.start(now); + osc.stop(now + o.dur + 0.02); + osc.onended = () => { + try { + osc.disconnect(); + og.disconnect(); + } catch { + /* ignore */ + } + }; + }; + + spawnOsc(o.f0, o.type ?? "triangle", 1, o.f1); + if (o.partials) { + for (const p of o.partials) { + spawnOsc( + o.f0 * p.mult, + p.type ?? "sine", + p.gain, + o.f1 !== undefined ? o.f1 * p.mult : undefined + ); + } + } + + if (o.sub) { + const so = c.createOscillator(); + so.type = o.sub.type ?? "sine"; + so.frequency.setValueAtTime(o.sub.f, now); + const sg = c.createGain(); + sg.gain.setValueAtTime(0.0001, now); + sg.gain.linearRampToValueAtTime(o.sub.gain, now + o.a); + sg.gain.linearRampToValueAtTime(0.0001, now + o.dur); + so.connect(sg); + sg.connect(masterGain!); + so.start(now); + so.stop(now + o.dur + 0.02); + so.onended = () => { + try { + so.disconnect(); + sg.disconnect(); + } catch { + /* ignore */ + } + }; + } + + if (o.noise) { + const nd = o.noise; + const n = makeNoise(c, nd.dur); + const ng = c.createGain(); + ng.gain.setValueAtTime(nd.gain, now + nd.t); + ng.gain.linearRampToValueAtTime(0.0001, now + nd.t + nd.dur); + let nn: AudioNode = n; + let hp: BiquadFilterNode | null = null; + if (nd.hp) { + hp = c.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = nd.hp; + n.connect(hp); + nn = hp; + } + nn.connect(ng); + ng.connect(masterGain!); + n.start(now + nd.t); + n.stop(now + nd.t + nd.dur + 0.02); + n.onended = () => { + try { + n.disconnect(); + ng.disconnect(); + if (hp) hp.disconnect(); + } catch { + /* ignore */ + } + }; + } + + const stopMs = (o.dur + 0.1) * 1000; + window.setTimeout( + () => { + try { + g.disconnect(); + if (lp) lp.disconnect(); + } catch { + /* ignore */ + } + }, + stopMs + ); +} + +/** Play a synthesized sound event. No-op when muted or audio is unavailable. */ +export function play(event: SfxEvent): void { + if (!active || muted) return; + const c = getCtx(); + if (!c) return; + try { + const t = c.currentTime; + switch (event) { + case "place_player": + blip(c, { type: "triangle", f0: 480, f1: 230, t0: t, dur: 0.13, a: 0.002, d: 0.07, peak: 0.5, lp: 4200, noise: { hp: 2000, gain: 0.15, dur: 0.005, t: 0 } }); + break; + case "place_ai": + blip(c, { type: "triangle", f0: 360, f1: 170, t0: t, dur: 0.15, a: 0.002, d: 0.08, peak: 0.42, lp: 3200 }); + break; + case "victory": { + const notes = [523, 659, 784, 1046]; + const offs = [0, 0.09, 0.18, 0.28]; + notes.forEach((f, i) => + blip(c, { type: "triangle", f0: f, t0: t + offs[i], dur: 0.358, a: 0.008, d: 0.1, peak: 0.4, lp: 6000 }) + ); + blip(c, { type: "sine", f0: 261, t0: t, dur: 0.7, a: 0.01, d: 0.4, peak: 0.3, lp: 2000 }); + blip(c, { type: "triangle", f0: 1568, t0: t + 0.28, dur: 0.4, a: 0.008, d: 0.2, peak: 0.12, lp: 7000 }); + break; + } + case "defeat": { + const notes = [294, 233, 196]; + const offs = [0, 0.14, 0.28]; + notes.forEach((f, i) => + blip(c, { type: "triangle", f0: f, t0: t + offs[i], dur: 0.59, a: 0.02, d: 0.18, peak: 0.35, lp: 1800 }) + ); + blip(c, { type: "sine", f0: 110, t0: t, dur: 0.8, a: 0.02, d: 0.5, peak: 0.1, lp: 600 }); + break; + } + case "draw": { + blip(c, { type: "triangle", f0: 587, f1: 440, t0: t, dur: 0.36, a: 0.015, d: 0.14, peak: 0.32, lp: 2600 }); + blip(c, { type: "triangle", f0: 440, t0: t + 0.16, dur: 0.36, a: 0.015, d: 0.14, peak: 0.32, lp: 2600 }); + break; + } + case "ui_click": + blip(c, { type: "triangle", f0: 880, f1: 660, t0: t, dur: 0.06, a: 0.001, d: 0.03, peak: 0.25, lp: 5000, noise: { hp: 2000, gain: 0.08, dur: 0.003, t: 0 } }); + break; + case "achievement": + blip(c, { + type: "triangle", f0: 988, f1: 988 * 1.04, t0: t, dur: 0.6, a: 0.005, d: 0.09, peak: 0.35, lp: 7000, + partials: [ + { mult: 2.0, gain: 0.12 }, + { mult: 2.76, gain: 0.07 }, + ], + }); + blip(c, { + type: "sine", f0: 1319, f1: 1319 * 1.04, t0: t + 0.11, dur: 0.6, a: 0.005, d: 0.09, peak: 0.3, lp: 7000, + partials: [ + { mult: 2.0, gain: 0.1 }, + { mult: 2.76, gain: 0.06 }, + ], + }); + break; + case "hover": + blip(c, { type: "triangle", f0: 300, t0: t, dur: 0.08, a: 0.005, d: 0.04, peak: 0.05, lp: 1500 }); + break; + case "thinking_start": + blip(c, { type: "triangle", f0: 440, f1: 587, t0: t, dur: 0.12, a: 0.005, d: 0.06, peak: 0.11, lp: 3000 }); + break; + case "thinking_end": + blip(c, { type: "triangle", f0: 587, f1: 440, t0: t, dur: 0.13, a: 0.005, d: 0.07, peak: 0.11, lp: 3000 }); + break; + case "undo": + // triangle 240→180Hz glide; soft lowpassed "step back" thunk. + blip(c, { type: "triangle", f0: 240, f1: 180, t0: t, dur: 0.14, a: 0.003, d: 0.09, peak: 0.4, lp: 2200 }); + break; + case "unmute_tick": + // short confirmation tick when sound returns from muted. + blip(c, { type: "triangle", f0: 660, f1: 550, t0: t, dur: 0.06, a: 0.001, d: 0.03, peak: 0.12, lp: 5000 }); + break; + } + } catch { + /* audio failures must never break gameplay */ + } +} diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/ai.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/ai.ts new file mode 100644 index 000000000..83526de3d --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/ai.ts @@ -0,0 +1,391 @@ +import { + Board, + Move, + Player, + opponent, + idx, + inBounds, + getCandidateMoves, + checkWinFrom, +} from "./types"; + +export type Difficulty = "easy" | "medium" | "hard" | "master"; + +const FIVE = 10_000_000; +const OPEN_FOUR = 1_000_000; +const FOUR = 100_000; +const OPEN_THREE = 10_000; +const THREE = 1_000; +const OPEN_TWO = 100; +const TWO = 10; + +/** + * Ordered by descending score. Score = first pattern (highest) found inside + * the 9-cell line window. Line chars: '1' = our stone, '2' = opponent/edge + * (blocks), '0' = empty. + */ +const PATTERNS: ReadonlyArray<{ p: string; s: number }> = [ + { p: "11111", s: FIVE }, + { p: "011110", s: OPEN_FOUR }, + { p: "11011", s: FOUR }, + { p: "10111", s: FOUR }, + { p: "11101", s: FOUR }, + { p: "011112", s: FOUR }, + { p: "211110", s: FOUR }, + { p: "11110", s: FOUR }, + { p: "01111", s: FOUR }, + { p: "010110", s: OPEN_THREE }, + { p: "011010", s: OPEN_THREE }, + { p: "011100", s: OPEN_THREE }, + { p: "001110", s: OPEN_THREE }, + { p: "11100", s: THREE }, + { p: "00111", s: THREE }, + { p: "11010", s: THREE }, + { p: "01011", s: THREE }, + { p: "10110", s: THREE }, + { p: "01101", s: THREE }, + { p: "11001", s: THREE }, + { p: "10011", s: THREE }, + { p: "10101", s: THREE }, + { p: "011000", s: OPEN_TWO }, + { p: "000110", s: OPEN_TWO }, + { p: "001100", s: OPEN_TWO }, + { p: "010100", s: OPEN_TWO }, + { p: "001010", s: OPEN_TWO }, + { p: "11000", s: TWO }, + { p: "00011", s: TWO }, + { p: "10100", s: TWO }, + { p: "00101", s: TWO }, + { p: "10010", s: TWO }, + { p: "01010", s: TWO }, +]; + +function bestPatternInLine(line: string): number { + for (const { p, s } of PATTERNS) { + if (line.includes(p)) return s; + } + return 0; +} + +const DIRS: ReadonlyArray<[number, number]> = [ + [1, 0], + [0, 1], + [1, 1], + [1, -1], +]; + +/** Score of the shape formed by placing `player` at (x, y). */ +export function evaluatePoint( + board: Board, + x: number, + y: number, + player: Player +): number { + let total = 0; + for (const [dx, dy] of DIRS) { + let line = ""; + for (let i = -4; i <= 4; i++) { + if (i === 0) { + line += "1"; + continue; + } + const nx = x + dx * i; + const ny = y + dy * i; + if (!inBounds(nx, ny)) { + line += "2"; + } else { + const s = board[idx(nx, ny)]; + if (s === player) line += "1"; + else if (s === 0) line += "0"; + else line += "2"; + } + } + total += bestPatternInLine(line); + } + return total; +} + +function wouldWin(board: Board, m: Move, player: Player): boolean { + board[idx(m.x, m.y)] = player; + const win = checkWinFrom(board, m.x, m.y, player); + board[idx(m.x, m.y)] = 0; + return win; +} + +function moveScore( + board: Board, + m: Move, + player: Player, + defWeight: number +): number { + const offense = evaluatePoint(board, m.x, m.y, player); + const defense = evaluatePoint(board, m.x, m.y, opponent(player)); + return offense + defWeight * defense; +} + +function greedy( + board: Board, + candidates: Move[], + player: Player, + defWeight: number +): Move { + let best = candidates[0]; + let bestScore = -Infinity; + for (const m of candidates) { + const s = moveScore(board, m, player, defWeight); + if (s > bestScore) { + bestScore = s; + best = m; + } + } + return best; +} + +function search2Ply( + board: Board, + candidates: Move[], + player: Player, + defWeight: number, + K: number +): Move { + const opp = opponent(player); + const scored = candidates + .map((m) => ({ m, s: moveScore(board, m, player, defWeight) })) + .sort((a, b) => b.s - a.s); + const top = scored.slice(0, K).map((e) => e.m); + + let best = top[0]; + let bestScore = -Infinity; + for (const m of top) { + board[idx(m.x, m.y)] = player; + // opponent's best reply + let oppBest = -Infinity; + const oppMoves = getCandidateMoves(board); + for (const om of oppMoves) { + const s = moveScore(board, om, opp, defWeight); + if (s > oppBest) oppBest = s; + } + const score = moveScore(board, m, player, defWeight) - 0.8 * oppBest; + board[idx(m.x, m.y)] = 0; + if (score > bestScore) { + bestScore = score; + best = m; + } + } + return best; +} + +interface DifficultyConfig { + defWeight: number; + searchDepth: number; + candidateK: number; + randomness: number; + threatForcing: boolean; + forkAware: boolean; +} + +/** Difficulty-driven tuning table. The "must win / must block" rules in + * `getAIMove` are always applied regardless of `threatForcing`. */ +const DIFFICULTY_CONFIG: Record = { + easy: { defWeight: 0.5, searchDepth: 0, candidateK: 4, randomness: 0.7, threatForcing: false, forkAware: false }, + medium: { defWeight: 1.0, searchDepth: 1, candidateK: 8, randomness: 0.1, threatForcing: false, forkAware: false }, + hard: { defWeight: 1.0, searchDepth: 2, candidateK: 14, randomness: 0.02, threatForcing: true, forkAware: false }, + master: { defWeight: 1.05, searchDepth: 4, candidateK: 20, randomness: 0, threatForcing: true, forkAware: true }, +}; + +/* ---------------------------------------------------------------------------- + * Fork awareness (master only): localized, dimension-aligned double-threat + * recognition. We count the *immediate winning threats* a posted move grants + * (empty points that would complete five), on the same scale as `moveScore`, + * instead of adding flat hundred-thousand-level global constants. This keeps the + * evaluation on one coherent scale and makes the look-ahead strictly dominate. + * ------------------------------------------------------------------------- */ + +function now(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); +} + +const WIN = 1_000_000_000; + +// Leaf-evaluation weights, on the `moveScore` scale. Steep gaps make building a +// four / open-four dominate small shape noise, so the search prefers forcing +// lines — a dimension-aligned alternative to the old global fork constants. +const T_WIN = 10_000_000; // immediate winning threat +const T_OPEN_FOUR = 1_000_000; +const T_FOUR = 100_000; +const T_OPEN_THREE = 10_000; +const T_THREE = 1_000; +const T_TWO = 100; + +/** + * Threat potential of `player` on the current board: weighted shape value summed + * over every candidate point (what `player` could build there). Counts fours, + * open-threes and forks, giving the search a real positional sense instead of a + * single best-move heuristic. + */ +function evalSide(board: Board, player: Player): number { + let win = 0; + let openFour = 0; + let four = 0; + let openThree = 0; + let three = 0; + let two = 0; + for (const m of getCandidateMoves(board)) { + const e = evaluatePoint(board, m.x, m.y, player); + if (e >= FIVE) win++; + else if (e >= OPEN_FOUR) openFour++; + else if (e >= FOUR) four++; + else if (e >= OPEN_THREE) openThree++; + else if (e >= THREE) three++; + else if (e >= OPEN_TWO) two++; + } + return ( + win * T_WIN + + openFour * T_OPEN_FOUR + + four * T_FOUR + + openThree * T_OPEN_THREE + + three * T_THREE + + two * T_TWO + ); +} + +/** Leaf heuristic from the perspective of the side to move. */ +function evaluateBoard(board: Board, player: Player, defWeight: number): number { + return evalSide(board, player) - defWeight * evalSide(board, opponent(player)); +} + +/** + * Alpha-beta negamax with beam pruning. Returns the value of the position from + * the perspective of `player` (the side to move at this node). A win is returned + * as `WIN`; a full board is a draw (0). `K` caps how many candidate moves are + * expanded at each node so the search stays within the time budget. + */ +function negamax( + board: Board, + player: Player, + defWeight: number, + depth: number, + K: number, + alpha: number, + beta: number +): number { + if (depth === 0) return evaluateBoard(board, player, defWeight); + const cands = getCandidateMoves(board); + if (cands.length === 0) return 0; // full board -> draw + const ranked = cands + .map((m) => ({ m, s: moveScore(board, m, player, defWeight) })) + .sort((a, b) => b.s - a.s) + .slice(0, K); + let best = -Infinity; + for (const { m } of ranked) { + board[idx(m.x, m.y)] = player; + let val: number; + if (checkWinFrom(board, m.x, m.y, player)) { + val = WIN; + } else { + val = -negamax(board, opponent(player), defWeight, depth - 1, K, -beta, -alpha); + } + board[idx(m.x, m.y)] = 0; + if (val > best) best = val; + if (best > alpha) alpha = best; + if (alpha >= beta) break; // prune + } + return best; +} + +/** + * Master search: top-K root candidates (by pattern score), bounded by an ~800ms + * time budget, evaluated with an alpha-beta minimax that is strictly deeper than + * the hard tier's 2-ply look-ahead, using a threat-counting leaf evaluation. + * + * Always returns a legal move; never null (callers guarantee non-empty input). + */ +function searchMaster( + board: Board, + candidates: Move[], + player: Player, + defWeight: number, + K: number +): Move { + const opp = opponent(player); + const t0 = now(); + const TIME_BUDGET = 800; + const DEPTH = 4; // plies after the root move -> a 5-ply search (hard is 2-ply) + const DEEP_K = 3; // beam width inside the tree + const ROOT_K = Math.min(K, 8); + + const scored = candidates + .map((m) => ({ m, s: moveScore(board, m, player, defWeight) })) + .sort((a, b) => b.s - a.s); + const top = scored.slice(0, ROOT_K); + + let best = top[0].m; + let bestScore = -Infinity; + for (const { m } of top) { + if (now() - t0 > TIME_BUDGET) break; + board[idx(m.x, m.y)] = player; + let val: number; + if (checkWinFrom(board, m.x, m.y, player)) { + val = WIN; + } else { + val = -negamax(board, opp, defWeight, DEPTH, DEEP_K, -Infinity, Infinity); + } + board[idx(m.x, m.y)] = 0; + if (val > bestScore) { + bestScore = val; + best = m; + } + } + return best; +} + +/** + * Choose the AI's move for `player`. Guarantees: take an immediate win, and + * block the opponent's immediate win (the "must" rules, kept for every tier). + * Then search by difficulty. Returns `null` only when no candidate exists + * (a full board → caller should declare a draw). + */ +export function getAIMove( + board: Board, + player: Player, + difficulty: Difficulty +): Move | null { + const candidates = getCandidateMoves(board); + if (candidates.length === 0) return null; + + const opp = opponent(player); + + // 1. Take an immediate win. + for (const m of candidates) { + if (wouldWin(board, m, player)) return m; + } + // 2. Block the opponent's immediate win. + for (const m of candidates) { + if (wouldWin(board, m, opp)) return m; + } + + const cfg = DIFFICULTY_CONFIG[difficulty]; + + if (difficulty === "easy") { + const ranked = candidates + .map((m) => ({ m, s: moveScore(board, m, player, cfg.defWeight) })) + .sort((a, b) => b.s - a.s); + const top = ranked.slice(0, cfg.candidateK); + // Often a careless blunder among all cells; otherwise among the top few. + if (Math.random() < cfg.randomness) { + return candidates[Math.floor(Math.random() * candidates.length)]; + } + return top[Math.floor(Math.random() * top.length)].m; + } + + if (difficulty === "medium") { + return greedy(board, candidates, player, cfg.defWeight); + } + + if (difficulty === "hard") { + return search2Ply(board, candidates, player, cfg.defWeight, cfg.candidateK); + } + + // master: fork-aware, time-bounded 3-ply minimax (strictly stronger than hard). + return searchMaster(board, candidates, player, cfg.defWeight, cfg.candidateK); +} diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/engine.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/engine.ts new file mode 100644 index 000000000..1f52bd3a6 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/engine.ts @@ -0,0 +1,123 @@ +import { + Board, + Player, + Move, + GameStatus, + Stone, + BLACK, + opponent, + createBoard, + idx, + checkWinFrom, + isFull, + getWinningLine, + validateMove, +} from "./types"; + +/** Immutable snapshot taken *before* a move is applied. Used to support undo. */ +export interface HistoryEntry { + board: Board; + currentPlayer: Player; + lastMove: Move | null; + winLine: Move[] | null; + status: GameStatus; + winner: Player | null; + moveCount: number; +} + +/** The complete, immutable game state. All transitions go through pure helpers + * in this module so the React layer can stay a thin view over the engine. */ +export interface GameState { + board: Board; + currentPlayer: Player; + status: GameStatus; + lastMove: Move | null; + winLine: Move[] | null; + winner: Player | null; + moveCount: number; + history: HistoryEntry[]; +} + +/** A fresh game: empty board, BLACK to move, no history. */ +export function createGame(): GameState { + return { + board: createBoard(), + currentPlayer: BLACK, + status: "playing", + lastMove: null, + winLine: null, + winner: null, + moveCount: 0, + history: [], + }; +} + +/** + * Apply `player`'s move. Pure + immutable: returns a brand-new state and never + * mutates the input. Boundary defense — any of these makes us return the input + * state unchanged (no throw): not your turn, game already over, out of bounds, + * or the cell is occupied. + */ +export function placeStone( + state: GameState, + move: Move, + player: Player +): GameState { + if (state.status !== "playing") return state; + if (player !== state.currentPlayer) return state; + if (!validateMove(state.board, move)) return state; + + const snapshot: HistoryEntry = { + board: state.board.slice(), + currentPlayer: state.currentPlayer, + lastMove: state.lastMove, + winLine: state.winLine, + status: state.status, + winner: state.winner, + moveCount: state.moveCount, + }; + + const board = state.board.slice(); + board[idx(move.x, move.y)] = player as Stone; + + const next: GameState = { + ...state, + board, + lastMove: move, + moveCount: state.moveCount + 1, + history: [...state.history, snapshot], + }; + + if (checkWinFrom(board, move.x, move.y, player)) { + next.status = player === BLACK ? "black_win" : "white_win"; + next.winner = player; + next.winLine = getWinningLine(board, move.x, move.y, player); + return next; + } + if (isFull(board)) { + next.status = "draw"; + return next; + } + next.currentPlayer = opponent(player); + return next; +} + +/** Whether an undo is currently possible. */ +export function canUndo(state: GameState): boolean { + return state.history.length > 0 && state.status === "playing"; +} + +/** + * Revert a full round (the AI's reply + the human's move), returning to the + * state just before the human last moved — i.e. it's the human's turn again. + * If only one move is on the board we revert that single move. When the game + * is over or there is nothing to revert we return the state unchanged. + */ +export function undo(state: GameState): GameState { + if (state.history.length === 0 || state.status !== "playing") return state; + const h = state.history.slice(); + const restoreIdx = h.length >= 2 ? h.length - 2 : 0; + const restored = h[restoreIdx]; + const newHistory = h.slice(0, restoreIdx); + return { ...restored, history: newHistory }; +} diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/export.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/export.ts new file mode 100644 index 000000000..af3adcbce --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/export.ts @@ -0,0 +1,194 @@ +import { Player, BLACK, BOARD_SIZE } from "./types"; +import type { GameState } from "./engine"; +import type { ReplayMove } from "./replay"; + +export type ExportFormat = "sgf" | "json" | "txt"; + +export interface ExportMeta { + blackName: string; + whiteName: string; + /** SGF-style result: "B+W" | "W+W" | "0" (draw) | "?" (unfinished). */ + result: string; + /** Local date, YYYY-MM-DD. */ + date: string; + boardSize: number; + moveCount: number; +} + +/** + * SGF game type. GM[4] = Gomoku+Renju per the SGF FF[4] spec. GM[1] (Go) is + * also recognised by many Go tools if broader compatibility is ever needed. + */ +const SGF_GM = 4; + +const EXT: Record = { + sgf: ".sgf", + json: ".json", + txt: ".txt", +}; + +const MIME: Record = { + sgf: "application/x-go-sgf", + json: "application/json", + txt: "text/plain", +}; + +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +export function buildMeta(game: GameState, humanColor: Player): ExportMeta { + const result = + game.status === "black_win" + ? "B+W" + : game.status === "white_win" + ? "W+W" + : game.status === "draw" + ? "0" + : "?"; + const d = new Date(); + const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + const humanIsBlack = humanColor === BLACK; + return { + blackName: humanIsBlack ? "玩家" : "电脑", + whiteName: humanIsBlack ? "电脑" : "玩家", + result, + date, + boardSize: BOARD_SIZE, + moveCount: game.moveCount, + }; +} + +export function resultToChinese(result: string): string { + switch (result) { + case "B+W": + return "黑胜"; + case "W+W": + return "白胜"; + case "0": + return "平局"; + default: + return "未结束"; + } +} + +/** SGF coordinate: letters a–o, x first then y. (7,7) → "hh". */ +function sgfCoord(x: number, y: number): string { + return String.fromCharCode(97 + x) + String.fromCharCode(97 + y); +} + +export function toSGF(moves: ReplayMove[], meta: ExportMeta): string { + const head = + `(;GM[${SGF_GM}]FF[4]CA[UTF-8]SZ[${meta.boardSize}]AP[Gomoku3D:1.0]` + + `DT[${meta.date}]PB[${meta.blackName}]PW[${meta.whiteName}]RE[${meta.result}]`; + const body = moves + .map((m) => `;${m.player === BLACK ? "B" : "W"}[${sgfCoord(m.x, m.y)}]`) + .join(""); + return head + body + ")"; +} + +export function toJSON(moves: ReplayMove[], meta: ExportMeta): string { + return JSON.stringify( + { + meta: { + app: "Gomoku3D", + boardSize: meta.boardSize, + blackName: meta.blackName, + whiteName: meta.whiteName, + result: meta.result, + date: meta.date, + moveCount: meta.moveCount, + }, + moves: moves.map((m) => ({ + index: m.index, + x: m.x, + y: m.y, + player: m.player, + label: m.label, + })), + }, + null, + 2 + ); +} + +export function toTXT(moves: ReplayMove[], meta: ExportMeta): string { + const lines = [ + `五子棋棋谱 ${meta.date} 黑:${meta.blackName} 白:${meta.whiteName} 结果:${resultToChinese(meta.result)}`, + ]; + for (const m of moves) lines.push(`${m.index}. ${m.label} (${m.x},${m.y})`); + return lines.join("\n") + "\n"; +} + +export function buildExportText( + moves: ReplayMove[], + meta: ExportMeta, + format: ExportFormat +): string { + switch (format) { + case "sgf": + return toSGF(moves, meta); + case "json": + return toJSON(moves, meta); + case "txt": + return toTXT(moves, meta); + } +} + +/** Replace characters illegal in file names with "-". Empty stays empty. */ +export function sanitizeFileName(name: string): string { + return name.replace(/[/\\:*?"<>|]/g, "-"); +} + +/** `gomoku-YYYYMMDD-HHMMSS` in local time. */ +export function defaultFileName(): string { + const d = new Date(); + return ( + `gomoku-${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}` + + `-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}` + ); +} + +/** Native download: Blob + a[download]. No runtime dependency. */ +export function downloadTextFile( + filename: string, + text: string, + mime: string +): void { + const blob = new Blob([text], { type: mime + ";charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +/** + * Build text + filename for `format`, trigger the browser download and + * return what was written (useful for the success toast). + */ +export async function exportGame( + moves: ReplayMove[], + meta: ExportMeta, + format: ExportFormat, + baseName?: string +): Promise<{ cancelled: boolean; filename: string; text: string; path?: string }> { + const base = sanitizeFileName(baseName ?? "") || defaultFileName(); + const text = buildExportText(moves, meta, format); + const filename = base + EXT[format]; + + if (window.gomokuBridge?.saveTextFile) { + const result = await window.gomokuBridge.saveTextFile({ + format, + suggestedName: filename, + text, + }); + return { cancelled: result.cancelled, filename, text, path: result.path }; + } + + downloadTextFile(filename, text, MIME[format]); + return { cancelled: false, filename, text }; +} diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/persistence.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/persistence.ts new file mode 100644 index 000000000..6fedd30f2 --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/persistence.ts @@ -0,0 +1,171 @@ +import type { Difficulty } from "./ai"; +import type { GameState, HistoryEntry } from "./engine"; +import { + BLACK, + BOARD_SIZE, + WHITE, + type Board, + type GameStatus, + type Move, + type Player, +} from "./types"; +import { createGame } from "./engine"; +import { readPersistent, writePersistent } from "@/lib/pluginHost"; + +const GAME_KEY = "current-game"; +const DIFFICULTY_KEY = "difficulty"; +const HUMAN_COLOR_KEY = "human-color"; +const ELAPSED_KEY = "elapsed-ms"; +const STATUS_VALUES = new Set (["playing", "black_win", "white_win", "draw"]); +const DIFFICULTY_VALUES = new Set (["easy", "medium", "hard", "master"]); + +/** + * 校验棋盘数组。 + * @param value 待校验数据。 + * @returns 数据是否为合法的 15×15 棋盘。 + */ +function isBoard(value: unknown): value is Board { + return ( + Array.isArray(value) && + value.length === BOARD_SIZE * BOARD_SIZE && + value.every((stone) => stone === 0 || stone === BLACK || stone === WHITE) + ); +} + +/** + * 校验棋盘坐标。 + * @param value 待校验数据。 + * @returns 数据是否为合法坐标或空值。 + */ +function isMoveOrNull(value: unknown): value is Move | null { + if (value === null) return true; + if (!value || typeof value !== "object") return false; + const move = value as Move; + return ( + Number.isInteger(move.x) && + Number.isInteger(move.y) && + move.x >= 0 && + move.x < BOARD_SIZE && + move.y >= 0 && + move.y < BOARD_SIZE + ); +} + +/** + * 校验历史快照。 + * @param value 待校验数据。 + * @returns 数据是否为合法历史快照。 + */ +function isHistoryEntry(value: unknown): value is HistoryEntry { + if (!value || typeof value !== "object") return false; + const entry = value as HistoryEntry; + return ( + isBoard(entry.board) && + (entry.currentPlayer === BLACK || entry.currentPlayer === WHITE) && + isMoveOrNull(entry.lastMove) && + (entry.winLine === null || (Array.isArray(entry.winLine) && entry.winLine.every(isMoveOrNull))) && + STATUS_VALUES.has(entry.status) && + (entry.winner === null || entry.winner === BLACK || entry.winner === WHITE) && + Number.isInteger(entry.moveCount) && + entry.moveCount >= 0 && + entry.moveCount <= BOARD_SIZE * BOARD_SIZE + ); +} + +/** + * 校验完整对局快照。 + * @param value 待校验数据。 + * @returns 数据是否为合法对局。 + */ +function isGameState(value: unknown): value is GameState { + if (!value || typeof value !== "object") return false; + const game = value as GameState; + const occupied = isBoard(game.board) ? game.board.filter((stone) => stone !== 0).length : -1; + return ( + isBoard(game.board) && + (game.currentPlayer === BLACK || game.currentPlayer === WHITE) && + STATUS_VALUES.has(game.status) && + isMoveOrNull(game.lastMove) && + (game.winLine === null || (Array.isArray(game.winLine) && game.winLine.every(isMoveOrNull))) && + (game.winner === null || game.winner === BLACK || game.winner === WHITE) && + Number.isInteger(game.moveCount) && + game.moveCount === occupied && + Array.isArray(game.history) && + game.history.length === game.moveCount && + game.history.every(isHistoryEntry) + ); +} + +/** + * 读取已保存对局,损坏数据自动回退到新棋局。 + * @returns 可安全恢复的对局状态。 + */ +export function loadGame(): GameState { + const value = readPersistent (GAME_KEY, null); + return isGameState(value) ? value : createGame(); +} + +/** + * 保存当前对局。 + * @param game 当前完整对局。 + * @returns 无返回值。 + */ +export function saveGame(game: GameState): void { + writePersistent(GAME_KEY, game); +} + +/** + * 读取 AI 难度。 + * @returns 有效的 AI 难度。 + */ +export function loadDifficulty(): Difficulty { + const value = readPersistent (DIFFICULTY_KEY, "medium"); + return DIFFICULTY_VALUES.has(value as Difficulty) ? (value as Difficulty) : "medium"; +} + +/** + * 保存 AI 难度。 + * @param difficulty AI 难度。 + * @returns 无返回值。 + */ +export function saveDifficulty(difficulty: Difficulty): void { + writePersistent(DIFFICULTY_KEY, difficulty); +} + +/** + * 读取玩家执子颜色。 + * @returns 黑棋或白棋。 + */ +export function loadHumanColor(): Player { + const value = readPersistent (HUMAN_COLOR_KEY, BLACK); + return value === WHITE ? WHITE : BLACK; +} + +/** + * 保存玩家执子颜色。 + * @param player 玩家颜色。 + * @returns 无返回值。 + */ +export function saveHumanColor(player: Player): void { + writePersistent(HUMAN_COLOR_KEY, player); +} + +/** + * 读取当前对局计时。 + * @returns 限制在合理范围内的毫秒数。 + */ +export function loadElapsedMs(): number { + const value = readPersistent (ELAPSED_KEY, 0); + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.min(value, 7 * 24 * 60 * 60 * 1000) + : 0; +} + +/** + * 保存当前对局计时。 + * @param elapsedMs 已用毫秒数。 + * @returns 无返回值。 + */ +export function saveElapsedMs(elapsedMs: number): void { + writePersistent(ELAPSED_KEY, Math.max(0, Math.floor(elapsedMs))); +} diff --git a/plugins/gomoku-3d-ztools/src/lib/gomoku/replay.ts b/plugins/gomoku-3d-ztools/src/lib/gomoku/replay.ts new file mode 100644 index 000000000..f4fd558af --- /dev/null +++ b/plugins/gomoku-3d-ztools/src/lib/gomoku/replay.ts @@ -0,0 +1,84 @@ +import { + Board, + Move, + Player, + GameStatus, + BLACK, + BOARD_SIZE, + createBoard, +} from "./types"; +import type { GameState, HistoryEntry } from "./engine"; + +/** One derived move of the finished/ongoing game, 1-based `index`. */ +export interface ReplayMove { + index: number; + x: number; + y: number; + player: Player; + /** "黑" | "白" — colour label, side (玩家/电脑) is mapped by the UI. */ + label: string; +} + +/** + * Frozen copy of the fields replay needs. Taken once on enterReplay so the + * replay view is immune to any live-game changes while it is open. + */ +export interface ReplaySnapshot { + history: HistoryEntry[]; + board: Board; + moveCount: number; + status: GameStatus; + winner: Player | null; + winLine: Move[] | null; +} + +/** Structural subset shared by GameState and ReplaySnapshot. */ +export type ReplaySource = Pick