From 3cd0f2b2e7c292724c6237c55468e8dfb0f1cca5 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 12 Jun 2026 13:52:27 -0600 Subject: [PATCH] feat: add Bejeweled Blitz and Mini Motorways games - Implement Phaser scenes and pure JS logic modules for both games - Register games in server registry and scene routing - Add headless verification scripts for Bejeweled and Mini Motorways logic - Update game-icons sprite sheet --- public/assets/images/game-icons.png | Bin 230671 -> 237742 bytes public/assets/images/game-icons.psd | Bin 613017 -> 631988 bytes public/src/games/bejeweled/BejeweledGame.js | 1167 +++++++++++++++ public/src/games/bejeweled/BejeweledLogic.js | 424 ++++++ .../games/minimotorways/MiniMotorwaysGame.js | 1251 +++++++++++++++++ .../games/minimotorways/MiniMotorwaysLogic.js | 1196 ++++++++++++++++ public/src/main.js | 4 + public/src/scenes/GameRoomScene.js | 2 +- server/games/registry.js | 2 + server/scripts/verifyBejeweled.js | 281 ++++ server/scripts/verifyMiniMotorways.js | 380 +++++ 11 files changed, 4706 insertions(+), 1 deletion(-) create mode 100644 public/src/games/bejeweled/BejeweledGame.js create mode 100644 public/src/games/bejeweled/BejeweledLogic.js create mode 100644 public/src/games/minimotorways/MiniMotorwaysGame.js create mode 100644 public/src/games/minimotorways/MiniMotorwaysLogic.js create mode 100644 server/scripts/verifyBejeweled.js create mode 100644 server/scripts/verifyMiniMotorways.js diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 5bfb0d4fad6c9b132f306cba9a13b6c39904ef71..4dee5d4bbf48878ed9e8d5637e2b555228668dd6 100644 GIT binary patch delta 22663 zcmXt8V|b>^wj9K^`Ng&-n%K5&+j%FpZDV5Fwrx(FOzhIx0 z3kNANabP@5p>AL2#?N1|v4JzF=@!K}0kF0$m>Pqpb7d7x%d1%rSsJU4D4NImT^>*H zG*VhqiCG>~X}4W=B@iFvOx(($LrvVY_FU3IaX30{mZw(8cnE%@qCII5L%^8!9LToy z9a-wF(7-5TdGUp!wA+uhdd-@tAVDmP{n6h!N8V8nzN|&GmAq zVbuceF>8~Go)r>S1- zR+H1U|L~}uOy#R|+zV_=E93(7)vJ6+(b|tY5~kmp=YR6qUUqztU%W|mfAnqK%C+?v zALYa-0lx6j8l!pw7!S%rL27bPLRsqQ2N4QZ%j*ye{Y#2b^J6ss)h+v# zl4L4W0r;MNxK3^ap8oK%A3HMHq8PdugdemoNPuj{p9&OJFyn26z0TGZp9!lu62u2x z8TeM2++h*?MrRN}E%z&)<|~JEv=hw8-01>tx6?_HJ>6=gX(}unT&;+25o^OGJv$Gw!IbGuM442(UT|< z7m>dfKLV3&?|@;lzkp^LgSr9j#_Lq!`T#Fkso-Ek4D%b7HGMH-d^#5KYp`>mnc*^jf2ICe_w@$^S z{S^nK{*G;+EI%ihu2PA}#D5^bige?WLNngt_=n+f{b30T*9x!t>XGDZnp*2Za-^<` zh(cmmvR$OW#`1yy=f}de>k3nIR`j;jx1>^jzyCrA7>;bBTZ(J%rfYgfcTP;MPVh|l zhw{~;HlN$|8sR5#HoD?W*`|V-DNL?>k?{QDL9Cv-UoL_7BP74&VsTNNTRemzJ|rHd zktnfCniuJP=BOk_Qco;HT~p<@bK*=;ebRn8DMy%I0Ua6+fvV-@r2V>cu%uULAfvfV ze3&d0c!twm+xBC7btB4zkJ_Z0xh2jJH`^kO-HQ0O?yKQY_pt6gxTV!>tIIueI@<>gRPwnAupd!a86}iHSu9Mg=FY6dS$1?wR8h|-8xOo<| z7ioCZrvT;2ItuFFJ$sB4&WVRpzlxq+a z^b=ge6y^k-y}LGgt-*rj&jdlWL~8>eI_p1liy)&qpB^@1?-P}yQTv8fp;4c#*UV+@1veswcg8`Grl2#f`YSi=r!*MEx(NkW7LO}$%Gi-&&$5s zolz63PW)cX$Xx!&N9eVuAzWag{hW0R)7&2>pVv^4&zXU!{WuMI^%CR6(DBwut_e^= zy!?3l9qwGFUq{QDHd%6G>SeRvkU(QejyEjYUjP*&JhM6n-3#i&2d(#s4HQ-*Y67K< zC5%!I9wMqszIZks$GgD5+8VIFrv`k&j2RjNNOHwZKKFa$+itu5F&QDBjya;qDYja# z103!l7rG+Jbe72#jOO z641&JrZeYZ#KWB3I^7Q(wYi_7dkh&XDLAE_SLeSAx#cJmr;4&tVy7X`1f(vciKhHU5` zUBxrVi>01F<+$;qQEFKOkZE%(Jn1EHOH=tUStNdJV29FEfDvlg>5?8r`U$ebQJ+HU z;)(d?f8HmSyky?Mb^(vs$d4xt;)w8nkR!lho1aa_qS+X({5O}v^CiLesmf8F{6^pP*JRpMgHVK3OkfH9_`&FdhL_!(gVTvT8bD`Mum%JXL68Q zZzTq84)!Xkg=8C`3pl=|H_~?K^7gU?jf6slgb~tge}G$Dg#f!dmo_-AcJn3MJ~E54 z-B&x8KFfoBtp)dBY=zukHL9V=PJ06_9HEkkiR0%L*!?O%=(a*f`woTHT%EB_2F|>M zGN7Uq_(txp+(GUwKBNh!4t&WOMcv^j;_=s?@{a_h4Q&N=xx}Csq50tK*g#fKgQzhS_k@$u-c&WoAi=|XS)aY&B?obf8h~l9|`G? zgzs@1Yd^`ag}0B7%Ylvyw@s zP%`t>HKrG`Gp0Bk8}2l+x=e_sB_{p+IUWxH$zyoJ=!pFMfJ8MSL>brIdwL1w>_xfW zR!2jZZ>(g=&B(a4LE^N*BN4?AjBj#eoNn4?$of?quG{7Z_oD6L=-@`)Q8YQZ%~D9H zeq;JEi<}O9wN`grvt}jasijq6T;7MBjraSa8(OUk1Mk`njl;)?K{IS51_dSz0*xAA zBcN}NHcZ%@8v3=>9K(_F1Z4yh?615-S1oRO$RTowRk^v*Gd1k&hS4Gkj1x6%+~(=4h5ly7E$OWepV)>1@8v*q|@*FebB# z2g{uhRmCvJSAOgcbKHK}w>N()9gkIZ=U2A_A%cBx>*u>4qK^V8a(t-eFnC9Dk<4e_pKwyJu+%q}S6Rf& zJKUL4@Un=#8@I@cf^$&>4DRDTetoGV0h`s`QfU`}+Lp;zPJwfKwz_hT&xar_RG=p%Ecz*19w7dQF zBa6NFupwV8XJ6ZZ#u=+pmIDF;!r}a;V77@HjzwxK$GUVi0#m|qXCA7&Qxew~|#hivcvdLigCoDO)RaoiJ%X zUF@H(b9!(yR!&g;TqnOYiE+Ya0}e0r9j=cFIy^ZbjRp34WCXk-o&R-Za5(Gb?>=G` ze`4N&%`GM3Cy~1KSRgdI=LcOBImLcvYO!&$px)z8gKT`c0e>jOicDp!UGYNUwvGwU z!J-szx5rD~ZSOaG0PVS(BpJgN`+RJwDe16m?s!%>4v|bOlVPy`&Qd|T3OF28dm->K zU0nC9edK>K-}?}9B8~oGxuiprJY|Qar{dkVEJ=95s!=>*8{)PxKwahF2#>WI2p6Z! zMVR>VI!M7k*2E{R2LN3S4QFcTNhb64X~IV2X_lePiR?%Kl%j2OxxAueN-GL)7PMYa zR~JU=CYrGZh=RWT;k11Hph#>o6b-$V=jC9LRsgNOj1qbmB}dB=i(|^Erl24VRhohK zdZzyh=jXuRzDX{LEIwvt&5^Ug8y@H4 z6`f&Lr-@NF2=D*HhR4zCF#$pPn?-NkL(vi8`7|np)ngZx5BQXH3(ixYQ`fLh2xAP*mD}R`qDD8I#I$W5#U!wyJ$iMc_e!3 zT14Hk)VJ!11_uM5_48XA$)Jdlk|XlXgtzvM5!;Nt#5 zo6#q*6SE|ON9E7oSpesc2-@IK`@F%tvRa5VxI5RU9y{BhyhSHYBHqTl$tux+MBW!G#%?zo zQ_@{R!+rIr8e$r@JCjxQV!;x$25o=5dadmm87@(Xa-e=Ra!pGS7x6bX)Ph<98lxM4 zp|6)Zs2>B&@=xvd`>nRrdi|^cdCVRI_M}`l=--HJqLSmss;tC{39!DXlaVN{e?)FcUSDbPV9-bNR++0Uv7t4p%i>W$eqP@${ zNfGt*;=pNcR%9Mpi`9PDod&_H%aZ`YwLgmB${nUx@Gf%@5ic&JyZRIT7-00E^6-vv zJ@fjKpHYhxG1EB98*{WrZkdt!+QaT7Cn+B@8r*-B2cahu9=W`E_ij8N*{!u{$c^9p zQ5fUM5pHy;uEc)33DVO-pLEWVIrN~eOkrGdOdP%t2BrUeZo%HMp_eI?wpReSqR0i~ zr6?@w7d!GxOOHqO8K1fWZ)j59jBk0}wtcm!lw2b-ca=u%9WS1@c|O03xZAAM2oXo{ zy|^TFY5!e>JujI^=EGps~!6E{vZUE_(?)X48UEt0bN*H3As19eqeRH8&lBHbYS z+FctyA-$~d#opXP?Xd?ge0LQ{FH@A6pem)kRq_Z68fDd~U!z?{2+>Lw$)7E#%b#m? zJe*I-^t$9iR7f1`G8&$u5N1NXI6GT?KP|UHWM!BAL6aa+x!&$J&5e=AFD*L3&x``s z*;B^V!Xx5&{UWHG)g^XM1yfc`L_q~b5s!3N2G$0J@y%(W&m46;a?lPaPtP`sCy@Ha zaCB#ZqR$0R?svDOQp|WHOK3B3)F^n5=A*1M%~kxV&FOa>kjttPtC*aeC+QK1AVPd7 zVMgjjiDX{!bG&3soV*3S%u5#tF~D8%lC9c-;~(#>U77!HR0_+hZ_= zg5^`zVUS#4#3cj%$0TN7Wheu@yqVsNtKpiXd-f^%Dh~3B91r$n_++Y~1{klX&BZjm z_+^7%LXz@6N}4A%!x;|i;;IqBxdHMeX?jOasoy9m(2+YNir93{p{$*x4%xx_1-pj9 zZ6n7a`9Z(wt?W$jPPrp~E;n2h}=ir}{>Aj$wdL_E4h)pIn5<=Z#djJUn&O`XhmIw02P`JjO=;<=Tl)^ephMulnhLQ8U(LSjYKl{3T+8b`4$8GPP!jnn+9CF6< z<}4g7qQkgabW{^xN5aw@EzyKt2r9q{N>ui-*tuqwyC?1Pk9XF|?sZz9>S*rPqX^gO zhC>M4DJ7CG4u&rXm%X}BM~(BhckQXpmMc5U3;=ET=C{CqWRi>(_xHwTsMsv`DiSyLlC^X7$Ba!!rml0&79%{g87>maU^DXTUU+Qf7B?dBCfM$9mo z0vhZbdd%Rb*X9fS%SQX-MleMM=h1d@0P`2sO!q3jx*0EXkB8k50#+Hzvd0=)E7?Gy z93UWrA%~#c_Icfh;`47t`k>`A-MC{>S$1eg?1KadI1G8!=coKG=nu|4o9G3NL-^63~Ml?QH=>!tgP#9CmXdmpVIenry5jjXc18bP2k)= zF^UL_U@-8;kQW^7@gL?(@vF&bbN(gt&_*hSwp%U|{*zT_%{{KjFnqmtR{YGO-jO7c z?{Lg^c0scGzFH|p)AcaG&G%6#Nx+9CuCmh8`N5P!$4!B{AUjQpoPkBn)au-q<|KqA zC=$v;BgRAe>^h#iq4bWzh4S9dAJFEv(RIIEJxBC)pVUFO*e-s^n%q_<@*d2gq#i>E zgFK16@o!x5jOiZh{=8pgFb~Ml;G8WP#}uS=5fv3(Ad^aA!YEgow~`)LL{rUx&KK^t zXv9Ftx+RcQ9a$t{IJtkgvjUg-ZA5|?B2|W$oZ=7n@dTOT_WpOxis6xS5 z7<+-U+d+esYt*M!{EeTcdU$F8(ImtKBiIu1u25yS>938llm&Q+Xdv*`c>o9XtDqd7 z%F4>xe*UY~^>~VLBZB7FYhh{;osUYp&# z!(Gpwg+8*9?kCx+cVFWeC+*^;m4J6YUKhxTE7PD zA&LuSWR#&*h^-4IO1_fOJlU_l(B^FTTzQ)?94S=<-eqF}9x4^ITL2Fq>TQpr#E0(I zqH=o9ieZ+agBy`f-R;asxz?@ciuNSkE}PLL&b!7g3?o$9^uZ+qknZf%m#g@iMCyol zu{kDN$MQ-1iiWy{_ z7U=hFD8bB&qZuNtT35;2h${}mFddf|iYCCKH<@oKikBpZi;Q*;_L3YC0uH1is5B;Qwi{5=)f;6T6Y=o>)@Zo0~LP zT|G;rEK_Q}A$^t>LjYO2124K5wwLadIAh>*^k5zA&&e<$aN(~My*dT60Jl|mgG#Yn zk2g3wNVX-|syoFjn@8)afp$z$u(va2{bP2P8zJ_r`~KXz9fG)hjX8PFw%Pr6z(mUe)@l)4fniu3 zN#b2S@$gnA4po zV%*`V5Ug(7O*hz7cJ`62HE-|Df$Eg%pA8PU&oMw_n(sYtP;JSYzj^(_O%ZJp1$qBo zP&l0cPr4p&awaqT*?{)6k}>62-1HLr+Vwr@*i&aqj?V$`&i;xT)a1^ zx0^)x(`ntUt>l>doV(?_J=(s%y6xu`^_1|3q`!W+ z&Zo~%yr)6rcty^NfBz2x7?21xDCN43X~WC6It3HwDQf+L6qKe?c-QVqu3CZTtFaS3 z?qxizjfOQ2CMJ`_RnFw11Lc=@dln-U0&YID$Yca$8o}WMyP{HQg z?e^%YE3FYtMdz5+&^6o;`zJO324^h3UfwlF3Mt$@83C?*#fJzy2B{~1v_Kw@4Qn)G z{NVv8!f@ZXM91|0Y_rkp-h)IOGP<^*DP9IjopJsX+JErLf1MY2ldhK|NP)jA<=U}y zTi+Ip!g}pP`KWXpuE6M(g~dONSXBs5;D%v$9u6}0f_9?FjJn?M3U6u}Mlw#otTP?I zv)kf%F6`E6|1|n7$N#<&PYwm$DPOqr!7%^gQ2!D|7zx zi1KLZNII5f(|@Ih53@#4^~)FXI!FhX(VnELx1mr|Q}-g=U~^P+XT4i_luLCMjyRO7 z>PY7HdWKF(o6k>aN)^bE&NT*?P*D(dd=^W|X{&gzw=MAdFM8v~Au+~)`f8ebm#|lY zOMutGkU24Tm6~FWm7#6#r$hpwrV`-;geD_S42gIelf}}vg|^~?v92$pvvlHGIy>iR zPc2@K4r9JBHJ>qrE@Zyz3s-@;gz z{Apf@ZJX!j{HOQdP#E%;KO)eB|9e7+9Dcr7`D}+#1!w9t7b*SaozZ6j41{)kfLscq;DPfT9c>35-FUE& zxs1?9c}GteVcNTV4)TY;r5crH~~YYZXNMPiXRe&KU_dgIYgX~cV< z!mUPP`9_K3vZRV|Wu8Sv+E@UZ&$!()l-84Y9HHT2f_Nd$Um}jlrVKY41VQe^h1G_J zHrY3(XwL`(XcOdW9uiY|+G@ghhHVv=5;M$KS?hK}RqVIaS9coiFITreql-*fuXnn< z`)zA`Tp#gWOomb5c?AQvf2fslzCw$XV01Yy2R-Q#gXx^B`n;pSfT{K_o8-xki z>o<8(Z1aBVd=eB0r2{5p;eho8i3#u5GUMqn*y*<+yW;AO$L~?`quFGb z@MVL|_nos=I6gl78!q?iuA*ep@bo;bb%>}(tusyB5^M7j<-Y2W!0%tinqVSKBW2oo zz*Z3723CU1Px7zfWEW~wH{$4J#*7>4qmAyt`MN7&DpDH7o{~|w+BuWRd*jJ(hhyA& zo%>5yc-%LBO;05wB#-=S%Ghw5^q&{DJk&7RViCUtAc2lyf5Q>~RJ)^s`4JuU$FDXvP)bu&wF8pKpr}4E@P>pC8or zo=&rgP}5$78GkS7{21`sQtf(Mf}BWf-CgT6xRU!0nXch#kJW`k_$N#GJ8-sO2sRH> zoFgA0?)Amc4P{yT&%gvZdO-uWLbwR^W-zY-Ax_xB4-Xt!)&A1NkU|H2Rt%nJI2}@v z-}2(HhXmjyQ}VU7QG?=oiF~$c%O$;cN^WtgE{of`Eq6dy>mCbT0}HAfdBI!6V06@cfLO~w=P}~PvRhoJm=!cz`~m6 z%IR62$643}l8O|WfXg|Bf4`uVut3PSTr8H{J7~PF2%lp7UadqGS}2J@v&u}bD=p3u4pvQnHXhs^wdr&uVIhn<);kdE=g?6E} zx$%8BXFKO_YwLTZ1+fqf1c)0BzOK;P zPGrY|>u82FeT6+$#+=}Zpw(zgJP|Jpf1Fm|(1+Z6#7t4F@$sqosnDlxZSZWL#x5&|h!keSyKQ<@|1g zqJ4aeaMW1QNL_WD=bhBL8+e1aAI>2*nUiO2W89-Ak7rDI>h<9H3Gj=iXRsv~A56Dx z{yW0N2(G9T-}@o$Sd+|v*w7NHh^&zAOtG@Xu(Svd=Ozf`u@TPeby$1*c}7py=3UbC zep;MTeJUi5??osayAw0khq=PwWdm_;WS*LQfd8=lJ1w!5KyQ2uq&qK9Okh#RHg^Bjy&OC7```2q9e0w!DLrdN`wc@XJ&2Un0 zstYFfK{-6;w!76R#k!CRyIz9ij;DD4yK@cQe&pOO}XSQkk|5Go< z4Q|HrJ8eIzHiOj&e&i-!g@`O8m!KBCmw<{eq!i-U{;N@lp)kBF7~YUN!?9 zuqglLcNj|?{PY4Fpx&?&4&n5rZZbHH57i?YBK+tlG@7)h(lsDI_6NQ)Urq?+ zp^~FheS!HPRVdF3W8xfhU6jfIM`cJW;DF+2Qz_4*D?ac}cSs-wN(l8Cy7+h7;QZzx zmS=qhm09LsXyMTMrM@SxZS7mI6H!I2`l}(XDXp{qbs>1{cWk_kIX`eTC2;|xIW&1% zL>!b_7Tug*P?LLKP#o?qeZF73Htjx*_HtP=KB^!nca}0;j-hplEw$N-6v@=!}s#nS7?v@b}}-=6V#A-M^WK zIKd4=b|ou2PNuTH4d>m&UZt(p%?@ea3U|ku)8YIMD41=mM$N8(6HpTmCMHjY z5E&OXEfuU1^EtNKo{CCU=0#?~x5Wz$W@@m5#7JJu|Ll>S@dwA4^51*zdp@H$6_{?c#;6wPhGTsZIjwl z>{k^Z{4O7I#2<`;+EITvxdO6EV%vv&wMFw!|p_F?bl9sZQ+isl_g$^r)$jA~}o+4h5Db_EWCK7;lzo!R} zo@dWrFn~-@AQB1^1S1h^n-x{)eKV9SA=Z%# zXfRVMB|}*yna5JStzn_9V2%3vk6nP2IAby?i|bb1^>9k}9Okw^8Z;r* zznaIy!}prk+@gW7bGZxib4vAJk%HW{0TEE9m_jZ5t(gWAnN1YM7e7keah94^I(x5* zP+_vj^L>T3LNK1JwB&?*7I7!N546X_bx=c&_scWmu)JR9UiuYZ-=_2W?C%f0@B}o; zB{asiJ6Q4qSx!&Dto7=2n6T~22{q65->GYS&7LO>Uod8!BIPV_r=}I#9U&6sD8;Ip1hQf4ZUq{=W3bEkMMN4xfn=$xe#-HMXqYsv z%x?xqQu~e4j2Q2Yj4`vvAEl#XBY-$+rC~JoL{vOcY&C>c!JEB@@(n?WLLuvF3RY_2 zG^8C5c#yAPEdE|0Fa;6+$3(Rmo$>+--ZLTpFR;<&N}B~!AS`k zXpjb5@%ycUv$fKj@9E9-vUDzZKQLI@Y%l1UsOK-IM!791z(@$_JZn3j^tEEjK0mY3 z$S_prNJ+r3g3efn6&&MuamsE!U$(qHJ9y*7eJ{twaj^aiZ$6I<`SkNKs)Bx)0<{P# z%Q?pCu^3+mG;|lISc0{X9b4pK+M9fjbywCaC?Qp4MzoNcbOAZ~5I9ySY;-~TFpdh< zl?J4@1EVJ3p`pj7$-qH&$VdYOw8C`=8`QBz1eX1rD2yusV-%J}CyUW#ELAqHbZXG^ zc6y~nFl}Kwrpsv%7Gz(5kxE0S$Yz6YLivj8D*X5Cs`+Qq1SP*Oa(Fn_1f`k&)KTrJ z!q?rVFRV)V(a&ROcVNbNaOUc&uoWoi4TvM=xP~ymEybiM{7dXH8UuJvK+yIC2n}>$ zwH0Jd9$Ki|Fw2h1yZN;+-y(5gjnS+YQHajL+VhwQr~6!p(&C5b;G|ZBUW@BiiO>40 zec{6=%${3S7qR=TogGgU!I91uOYFz!)}OAIIzwBPqM>c3Hly*O&e!$rdgi$Qab~|> z!0Vbp&|^rAg0-mdDF!R*>$nBhr3s@iL`>7x7t2A{1!%mRS-7%vYj=}LqId&nU)b+7 z$h|d``C>}3!7#ehScQ~OWzfogee9rgCpy;#^8M03?gJ*+x=WD=RPcN$f`R#WlqhH{ zIQ|0x>YxN_kpQFPjpBe;MLSy~XVUH8fb-)7ZL{P8IwtGRBM@UaFjin0{(XF$j&b^a z=dzs}`6_|&iUxz$7A$k1Rx;w${%TSJYz_5k4z4-7RXYZ|@00+3u_7{MPIu@f+^{Qa0R75@}l;fhY?Ju@Ad9XA=tm7AYq|`>NH1= zMq&qL8UmAVIb+dx6^+kBL-hA2-02g3W*Rb|1%h$&sd8Q`8Uo=k0>klsVz^viUd;g}sY9IfPvf5$9FOTLnc!mr z4e_K7ZOA1Hux`*ul3M@WEyV(Dc-?pS%sRceV7(&iW}S1Rh7t>?^NK`g7Bl?j%X;=7 ze>r#|MYq)yHDbTEiUgLFg-4w%e00f>rFeJcIXpJu(u4JDPba+|D&aroo5{P}@A=yS zT)BzP_eWrDK-XRxZR>@+;%%PtkDdp+>M~~yxw(Hlp(v>}2xUY+Q%krpV)*)KQGeNr ziBwcG4+_7u(b!vi2$3uC1q#eohIaVvu}DT7ifL@Vbot~Iqm z!5;mZ*96CG=I@3PlxZlWYe;NLCS5&hxaQ!pQriyJju>pYwf50|)oRmKU3aS6UT^-^ zyTmS?!4KW&a_?@`FS?>5Qd(9n>bP&pbJN35IlBGphM7W}9o!#H@CS6AEZoGPNdvIs z0;9;IE+Mv;bQzz1NTyVgVeHc1cwvLzt~A;}zuP7~_r(8u0nBe+F-iond7mw}sUTK{3vFYLbSkMjGZjFAT(E(M}x;6mqA0KeKH2CGFjX z7tAfDYL8?sl(Val)E@)(v>D*c#c)F&jVeEoN<~0>+$fY&f(cpVJ^ECDu~dQdoLunt zxG^lUbX?06BeG;f_C}^*EXlN+IdhpgO9y-QNV_M zVFomCLfwZ(oea~zVLt1y*@$F?iRjh{vafSEHKKdf>|gxj;INQ6(c@e4d~SeJYR%y? zFty+IU64r!!f3jA(W{$8;&5dPd^@mxOCrtfuxd3S`W3#?(UFV{v4M$MbUg9Ei$R0S zrC~=a;?|8Sx|6TecpM3wuUz&#i(#Vw@&Du0^Wvh=KsX}k^xUf>Fp`uX}scz!sm z;zc{Mb;{4*aOvxwqYtpeSr5GvicoL}#At`R2Po#Cvsl8@AW$2!>g0$UT<%6(um+Ye z=NE+b!)w-EW5vKWn->UCdq$9Gp3`I;PdMr)mX)kHA&Oj_$W{ z_ssS6f&UQGzj6BQYm1n)x)T<<>j*0NBKwgiP%d`E4kZjB7&iTXhQ)|lQ8|%xAl>Sq z`P`rqaA|sLQ5+?(9laC9F=F0}2xxH-`m4nMDl`dZ_aoCmt0shso_;FqP+~~j*tLs- zB^c(4t(&Jy3MU5Tpvc?(`I+VpM&&f6eAd1T^bKDnY*p?q=gf0@&pQQl-!JdSAyD+= zt&i-KKHIWV%$r+W2(+R8RbA*Qn@(Fz%?74 z{{!}o4`=;_=K26uz}q4n9|JOE!kl2b8pd#F&az@XJ|y`{j2gK&d71L=3U^@SCq()l z5L`0krw_oyjiQ|%Rzete2)$@O2V{H$|L-6%XGex{A;j~xYDIhS0^05 z-;2>SgDzD8F zYGb9Hs!XNNN=Wj z=X*p%c&-u_c#Xyyt+3yaHb!5$yB9_F_-Q&-l=5-c{dp7J-09-$thhR+rAyFKS{>DX z7BuN#m4X){U0>5Gx$~Dvy>aoI)AzV)7a@66w5K|l33VoLQ-wa3>9E)yAtOIWev!av z@Px%0sl zV`2K%pRSGwS=@+pe5gpi>Cj%wX>I`j!Ksl;ePW?AkF=$E=(&7@q`R}2^*f1o&m`hc<80>fC0j(=iR&_%aVf9;SG@xt^# zqYSJ=Aq^L52Bxd;Lkdo7pYaY2j5X&DL&OUxtwzi}@0wv?6Z;C1-nIn}riDK)53tQm ziFxzWm`T_W@(gu57fA=+Wa)e-uK)+j9Y_wI`lC%%B3vn+#g${OuAs*3W&@{0D{~}9 zR((-*v>lM5#gg)b<{kw1nBnkOmRPk~_crKRqyQT~fX6smh`iE3n<~gzkk~&hWH7z= zXM8Wd++gl19p}FJ*K)Ptof-x4uz7 z&mWLE8a0d$Z}|+B?!LLzJ|X&sCfU|k-aP-f%TT)#cyO34{Mg>WVUgO0hRg=y26<$MwS@okUtFNrvN(ytG*&NNin(!?w|eP- z>*?rv)|pY~WSWFAskx)6$&$VGGP8;KCOTDU`}&e3N3qrRjsNv#U?GN(?@7zGtR3Kt z-BXJUg&q;1y=nJiG!Vs(&Q9{H7w+q703ThV7nT&JI=IA>dU|+ zbKih@0)-HO0(t2Qp5|tKGr4mbm{k0uvTUWPml-R!{5vN-8mwhOsG|XgiX+CfSgTuM z;e-3RD-k~<=FHj#y9J4TaMR`Zh zC|d%wNr4G@`3rxqG1hVLrOUQk%hTb`WJxm_Pi(uWe1s6U?sdwd|59tf&#(8@|GgjI zjZz8|e=!3K`jHB6afHlW{r<;_287Nl{>nOri0VW^Id4R~BDY~2YhMRE8Ow!vCbCE_ zM~Hc9z@Vj=iwQRM8#`H?;^I&C9Gwhwq3pcgTrvz81)M{SydUN=G=EqZ)4t+M$lqav z6to0YQETAhUNtGJ9?^FJ?s=;MObGxdD@sOayI=qX&9#qiksQO1yRwZu?Oc7tWc{{isbU)+H?k zy-Xo7WdU8)+8V#!TS$O{$=e^>nyNJp|&zPIfziP;EDwNQfb263TVowXboHD+ZiDVW**h#wJq>id!-eGRwkKiX$3(+WKv?l zs|6~95d^~=#UaQbAYvdP`6?8d$#q~72m(bE2#5?}N+=*n1pyfn=0w0SLkNKkWZ<#Z zd+WK@yYKb-{;>D_z0dxAXOCyCvp=@Z_ShxggFw)mqWB*CSXt)ys(27Jwv74>(ZD)Y zdUzULrQFC}FMh?0ekv(Fd(L|&QVK9WXXTb?$4@m_C2 z*LOR4c~U^Q#1hwZ#Z95Z0%|rcA`Aa8}Bz>_olJC+IEO7ahmn*{~es z0wOL)o{&A9=F0iDB4+LI53V;qM4XH|<@eb9sQq#E(@!I{q;ND+`-eGCohb3}@I*rT z1G9(mM?5pHd0S-S!hZZU?AITn&UrXpS4=m5`Sq_LjWwftMVZ&n=?}j;`0F>*nyjPk zz{9ZDzi^E9oC!Xb-I0*W@_x0m-R$8FvnQg(g&_MqnrNhF@OGHIAf^n3dRAI0S}2{v zg_79gedCvcDfAU+N)~y!vwChdCy?Y~T#wA$Lq^7aZ}K(hx5oPGav8H~Z+hWEW3 z4&~mP2M~=Qy0fT4fP!U77-e#Tq&GaEgJDn3>mku zMNXZL=y?HF(X-Qg7F5@MXHz=U5rWCUtdz?D{Mpcn!M377 zSO{tRVK+NQgb|^tqUdVfmietmr|)AV9HB8oQ+2!R{sDCYbt=i&!=j>=oI!DNOuO+R~(VP!pZQJin>YqDYE4y`HD0y zmiM>tJ`yu=>cORr4WkqmDYwW?Ep@d-=u5gAVx}$|zR_k(y(a^G&2!EzK4to?&X$D# znjQj8!w=%sh>TW9Ivby?t2k`SUU%;&E45uUWt~{rAaH>9QivXNK;O(Rld{3g^1FzT zlRJW=#c52($`YwsM5Y#Z@FK0Taba<+jSOeRaJpMJa-cwr;Eh;jHwY`v;IB@?0FgH{ z+<%;Y)D#h-(Lg2=xes^CPN!;piTt=q4HWe$%{UuwK3Q!LKqd2-~|r7IW$)m zK>*UPb2~X0nH1HQ%a|+t70dGqlQmKE70#@!XPc8YzJaO1r1dh0PKENgMjFK*Q|J<; zRP0_>>qWynn(1jKXPFQ>*!tq}W~Nx;QGsO{GuPg&o9hUE*D+^ofw##GLyvl>+}I}~ zYovs(rP}(6-n`Y+aW+1EL6|r;s`?rbwI0)4B8MNjn@3YCRVsIx&&ORwQ~a+58{KNw zoxaG_Ol)`kPCht<5{FjxcWzV@Ih1w|H8p1EKJVOwpV$w^F}_KoUo#U~QqB~rXhAvy zobnsZ>{;1_iu8NeQ)sGECr&=IWqLq?f$E4*FXpX>=oRNYRGD<^jWqRM2TvTvxD|^2^Lhz`UZqiL31B)l6M|sn7+E2 zYHC@TIIzBHg5DV&nm{9lRB{!x9=;q|PC9el@v*F|OJJJa!9W)e)LZ}RsC81%Nh4{eVwI%(Z~$$X9oPum(42YwyF|ptXj4ALJMJORYkEvZob#so z%i*$$UAz<9ltbd%+VGdk{Q@TM&Q*6t)K^IZNem1txDMcwWzu_lobV_37o;K8e(3-m&XiE-;@{&q zvsvWEJ5t$#QQi|KN+{;~@ev9MBV%WP915p;qLIY+WavsKF-Wv zv^4`%H;ky*+co=dI^s1#(q2TZRaT!6^z-d%#!@SlTa_@Sc&*&}o(c5gtZ*1J|1hFe z2&6BJfp5KlM{Q-oC#!`gkr^pHArV}z$({GS=L&Lm%nOFkN)bh)G;QDAOVc0L+3{yg z&wCON^)|n+H~_ZZpOM}hV6oKi{Un_`(*}6JM5wpCO^W-9YNuOt^jjkc&$})4n?o$PD}kFM0!0aR7f3)} z*9APXbD6ELszwaVEiA1ZWH%ou7PF3OoX5LduMB(tGlqFMuA4_#@B%i|YkDQXe6UHf zr1j;qmXYel4VGb*H83~KgTK1%&Q-38>KQb$vA)gLVAjpFPFg|g-Zinxa)UxeA6Nvb z6n~?dP}->UBCVAX;cGhGlz``&!bPv&1O&fEY+sX#W(0A3QmAm8A?0yKM}>MVUlXok z1s(a4jD0rEjho|;m68!}po-EDW+zQ&YV}JVr+b)3elI`iofddKq?SOSzMGGVdEbHd z@1QfDYigwEmY+|lLqv84UpdBu+o*9LJ*u9K2K%FK{!CS19w;x6md$I7d}G}3ekg!# z>}zUeE-hFUcCnD+r<~=TL9?01rUhFBnbl^5cR3@qH~~i)`Dp>Dx>Tv12PdNj)1MpX zfg}AJ49gt*+=Dus_Cb>vFmM)8gbsfhgz2`bp$U647r%yxEui~5UakIUYDR>BLUCp#<7O&$l;?UKYIorO&SnT{74er&< zQwzoLI=%1DJaQ2UFqZjp)P&tP=9F7qjzpSGx4;1GhbiB&R{kn73%XjA|0Ew15dD-d zmZA&RWbBMEoRbkc;%PizMKqkZIi{s+!_QzD`ft?YPnb59*AQwluF;8?EuGVY`w{ByYC?{?8mOuiTiIyv8R5u@nkBjiomipF>P>VlscRU~cXJ?| z;SWTOJJe<4)k1sTzBNPIgl+r70DCl{lvv(mcCUu!g(;{Z;hfT znfz^zJzHOSZ@*x5p-^XQeITpl`JMOgE-ZOM0ZLDg>ww}r2^w{*cT*+*Mz zS4OHxQfFhsmPKFiFw!^=#cPaj7nbHFPCg2$UK%+aGyN<*dub`LZV7$W-P_O(XMbnI z&0nNOs>Yo4PIWT7dIz{`bf`SdWBaW$VPCmyO{v`LetcJZ92F^*j9c?%vL+*$cnB`K z1v!CPB7zMhx!)&~O7wf^8Z}IXWjaJ*SX!oT>*ujH#)D(Pq zc`+vsk3ZpRa4FqfeMt{i5emLgc_&iIu~X5eEP4+J`)bD%YzC$wdI4+`PSXt?j~+x& zpq~6>IC~y0-6V|PU~H=bThCNU(lj0HP~|`$c2!ovmeviw?NXZ@&pC5*^t$DQ*_QpJ z1S1Ov8mG#(?!}!YR3|0bhwKvxnAj$59PhVftmFecCTh7Q1-r7zcO!~vrk2#pkvD)V zcPqeq#&sEjD)sPdSDcq8n2D8MrgWHB^=;2`^Or3n**QuA#3Z( zUWSkQq~^`zAPGyb$w4_n}xT@;3_k zQPrF8_$>c9m|+IPp~NFl#sA7w5S&!c$wW$are~}&;+7DL*t<5Z${Ec;vEt-L1 zZgwW8V997&S)pFUY3Ti#ogZ`7D-ESFQ$uh#KSMDt^d!1FY^ENF>%q))wpaGV6%!RI z7bojH)EoH3f-&TW0#pUARwP9uvMPBo?DdXXGFq6huWfTShUAxD0DNf6mvm>s%Ihj$ z*bQOlO$tCDT<2B!u15^%YS69JhS@jj-H#Tp>IijGs)QZe0&dp{?>V}`k7uqdzwlW_Bl&yMq}kb;%;6;>^i&&Cr&8E`ye@bsFluD zygy-rrt(MuyQX)jXxA@#!%(*i`#JYvWRIV}0NJ?4?g#y%^!Kkpr<0$43Hr|V!WW=_ ze6PO?^z*lmL7?BhasYu`zx)vdI{cqv!kNM~(h@*LspPfKG#G<^-_Cj}=5V6EaBP6!>2}^ZBVCp*#Hliif|F`Vk6z zTCje$j|2WJMLrw#BNX`O#QY1WAECg%*Zm;ilWNuHGYRk~74q{@KSF_js>^=`^&=Gc zw`M=+H1H{{>NA=I_`e(EXQY0F0-v|N{>wAm`hVRS{-wJA*4DMX2V&B_hTxy-2JNhK Nw&#DUJaakrKLEpiF_ZuR delta 15537 zcmc(l_g9nK*6(%eJ|00)DI#5@8;bM}BE1IbEucUsB2q#R@N64hkls51=_T|Q6_8G( zg&wwa2oP!l1d_{s-+$oVbM_tkr(}$bvA*B+nRCs#GM}gF8%m{V?6{pI>oYx3`Ba|%pezMxr0lI(JsNgq29T#|l14MSJC`|j+g4ppIgGxR;n)E_eJ(<`;dRd*%JD73GJgK z&zbd3M~cmY@2}rEf`j)9REvjl#V-tTyXQl{e0Cnh#6r%$jiUo62cHs}aGm?t4&TKA zE8n_=WpX42`aO<4Me*7Q5ssam(_`sH zU_Y9@4PJFIxmH%08Ql>3JbXr8ICe{H6!d*5OA+Ezom@nC-t$c&> z9GI);`gkD5Qk*9jJP=lCjc9U9<)$S({o>uS8r$G~^c=Q&L}V9k!U8R2_0M!&L&0#9 z_G8u*NqY<|*OvL~T|rn~k&t3?(!%`c6jFG5gZE2F3*T|7JdjbYQR(}BtwDeet| zg&j(GlKjloGwJ-jaUZ^(L<)KxO7J2*mww_lS_kSXl?Ar2N=Gf*wNOSu3)-%9*Tmtw~-QIn~7EK7fS~k zMG8ytpF5Hk7X!&;&7Iz34zbaL*o}(F!2y~o^3yN*^9)BVJ?s5i=EacEc4*stqgzy# z?9N?T56o|oCiQyk548zeBjP5lKc!$P^*iaSiAMpYmdFw(XlgSV&}#c$WVK6Q{@h)&P}wJ%&%O;`@>Uokq^ zOM%pR56UrVYx1G*Pakr^E3*TuduQPSI-a`u54^&vN=vKGHV@I2TQlU=_3ME<3gu=y z)&?Y&$ZEjW&(Lr34EE`$yJgpM^$;3tQ-0?Z8qoVC37r;NO1b@`Xorm@iL~O4_{LDH zC_e|4n0=$jDYK0`&||N!Dp`GF{LSHl&((zo^ zS#tkh+p;|xy4M(}e%Evj`nXO$`#{)6qWkL*$l+iq*H^8*K5bo2-?TDpL1E7I6S4-1 ztBQ5rtB-d!RyFDI9r&w?MrNT?#^?&5bNH7h{f@xLjk%v3N^3{;*MgH_<{+AeVG>L${=>G7m^N3oG98 zsUi+2V|^}AS(JS6MIm+N;^&+y!mx%YP~3~N+dV!V@gY4MUt{+UG9JvL%c_lk$;8B( zOxW33+IKEec;z0=4AN_D?cR<}sS?n0tFoyzv}sV@9UN{te_7$KId{US1W#Wh%878q z@nM2b62Kj6AE^mV)}gTXfa?N#r_&@Ghi~|Ml*oOmP`Em%kUn{L%*Tm9Z=u0y&TLYc z94q%~$xq+b&_kJq3W4Jq?l|n88P8kQ0ka!G7UBILo@ba$cPdTgoXpdJ;p#d4(}K)1 zCTH@syJS0Hc{|$9+hzUCW~(Vzo;a3ogU?A+M4qLbMWw$N*LVg{OAg;(Y%EcK#p1Xz z3ZdO%rl*XWDg3Ll7i&>?{3J$eb0EgsEnLhqc4HZ&D_>A7rP`h27sT!K5dmgpN4Z>u z3-{-_I1n1ryxhX&dD8150$x>A&t=!i{(2w$3?~ZPh>$qzDEkJY zrg>H0m<$f`kB*6jBEIJ)drqI(Ua!cljxknv`$r6c58+xh7liE_grSRhOx6+xHpdaL zub(+_`#AA&BU32Yv$B8Vg~oSV;>y7eK7mBi*q*QX0VLga+aAOViwz+?rdwVfCOdG= zEN|a@Rk3hWU>fucy8Z2gHjZ=RAsrKLlV6c@)4tP(Sx#C>IPwL?wy(v z1pU36u3}1c4o{Nxf{DpWK&v#NopTjZ_%R-1B5?|yJiJB?QGxtsOOn$|?T+C*Rh<$a zOJt8v1gvS2p~E*U;}k(oiiHWIw{en>9~0kL^W8*awNH#|RkWR}Q$!CD45a%w{MHbd zh$a;yCI8$2tyHH4g`roghjm}o+X=T(l8{Elx8jL$UFWQA`^b{#|5vSP6&( z$MpYvQEh8$%vYNAJaJ^D^NH^(q#&ejo5gLaGEl!nO5}7NynKi9tE^Y7e;<~0&&@-c zF~P<=`T-XvXF1QhAYH57S)#2>TGC5%YH!a0)w~u?X~UkXbDWjzXF zF_kRb?>l(W-C1XI3)j>2`OA=ekv3pfWhK%IPA%^|HI}`Y=;~qIS3d5A+*UhfMj$uPrv8u^gI2~3AxXKBn&*0JUGMw zLQ|CUa1H)H8*PWW2=k+6+N5n&HQIj%9K%Kw5PRK;=;mv0zcy};TOGW=02XqC*EV}? zrMhzhPB4~|4e=u3s+@6MtWCE3Hs0$O<%lN0!a(7d3+Pq_gvv^LgAsE}=3&K_Ahpemfw8_f4hKQn!?LobpF@sZ#e{Uq?>FG5d4a&-PZE-n zeaqmEha49#DvyI4t>k510;Lh%XgzM~N(>8N?K?;5VBib+YCsJaJCH=R?kbbvn`1rE z<>lpIzn1oJNeO+~S30n0=U)Zt2+MzSduZ){VMu^4d3iNRMEm$h$pYVA2xbKLKP-`c zwKIFXcFNHipq$k_11Q1gTbk@hM+?P#56(MwJ+E)$?Jzls7zY3_XG83X%npiK0UmK* z8qs=5VP39h*i~7+sg*{K3IyceA>N@{BxW6wR>@pwZg8W-`f8`~s(m$8t{xyVJ5K4S z&JGzJRN|ZWU0jgGIo%QCzb!ExLK>*k^FWV6cCrT*U?Q@!3>>!Gr+YDV!3B1=K!#a_ zc(QF=T3dlV2Jp3d?yAVcUHS89L}X_#ZKSloQr^T!%KpOI=17!u=7#g~09&MG-z^k- zsBUgipYh7;wsqVMT+0|V?yGjHrN5NW$F9_cgsntf7Ujcq^XwZEv^RqW9$nq6ul9OF zmE!l{TD=4RqDSk_=xDkcK$8WQZLDx`R(W$9L}V*>PP*;^!5rhBEgJbBog3ybiX zJ0yIvMpt6(H>+N~Y~oG`EG?7NeP1l+hgkgy6-Sf_b6<3X-NL_09++izt#Z8^*T%m& zDk>myN4HiYJ5>fNL^O||A^cnb(nDA7xgFQwFQQXN6!PIaUC#Ql8y}ux`*eTq)a^zy zL@<$_u>&~Vld+{k{Z9JXYcHruKOFm7$1(X=HphsK9(wg3`&5qfw4wfd(62y@Q}Sw* z##G}Z7jrivC{k0or&ET}xU8Q(L>rv1)}7XK1lKwEht+E7FDXkb5^`Uo*fL*^w@prd zHJ&YmHtqqU#EpwiSNjpH^p;!P14!+KH@CT|CV(S+PhuKJVgF3OzugKtjr?KUQDxY| znZKQ;(D3HHo==?aNYI7&_SL|vNS|9>odheE(Vpp~ICx#o+`IS#HiYXX^Pb=~FL<;7IzoBN_$W!X$c0B~DNo=(qY2ld6y~>z6No zkGpPjI(9EMQcc6-^}lTh`??hOuCPxWZ6S;N2j=1_N9WDeTpbeApWO{~O49qA3-`bE zbuDw96{hQ*_oEb!Qmd@-9TXmTtu)pLz;%CuSI5h&rSVMVwk}0jA~Va;Ra6_j>h0wB zRI9(r(hoBsFviA+!atLf7)n3R8+NwI!%_ztu_fhg^5%An``N_;x1+~OBU?%X?9GPq z(-k{ZtpWorU9|<|*Iw;x>dg<2O*TM+S2O6T0&$TbMre1lv0;Bv@GY^FzG&s8;=ruD7YSPKam8Jmo$!A-wG9uB3! zAiWiC`uf}rB;q*wkPz***?i=;>cJpP2l^3NwlqtZ>{w_d+M!Q`blQvwKx1%#ne&b z62ghnoct6FD)=m#Fdzor?&xQ0x)75Au;0~HfImZoQ*oXiUs3axih~ZUV%Bw;jTZ7x z=n{@j%oWeqGvyYCNXLFP$F-CGBLMndAi#eD!oeYKXzKLgqKe+qIUwp;?~3d0*lQ{$ z*IgY&yJ>mTiIwduBwoQL4r-c(T7TxpaG!e7mT`Llh^8wV3CuTP~56?<+VxlZvPomyjkrQBLO zW{Vct>T>w>Eg9wn!ADk{jXaM_UG(a#DvuB8>4n$^ndtiGK218@X-}V9|FrQ2&#(2Z z+1_vO^HPrW4doe6D+FA_*eZ*u>2*!#dU1!2=~vrn;H$uFUb4l`Y0|jQ0?VybAS4ek zhMz-)nF5*u%&$Yi=9KsP-zG#PRokXf{Taa3&s_Z zkI#>}?#M5_bIt`ua#B`3;*7{UiC<2;mq8@*rYhqChwhFNc(A3RsN+Lz`sCw5F0p%= zpMRtS(LHQ};^GD@qF)t!;CzUGtQ;&cF@pHS!xX zZ`Ipi1#Iz;#P1#ft}h%({e`vDqMLfP7bEicmZS4SWlv=BsVWXriQ-Ds?rCk6lU5*1 zm$}2%g=M6Zbrfn{$A6?kK7{6)@DfYD!OB}q<8(+>I>TM+^er2p9KxvnJ7xi-WLp ziW#^z?$siTxX$i-K_ok4}1 z&^PbcR4S52d?VTNt)+ld;L}?`2Ysv^?=lK*vK6b) zD6t7Ab)a=+wzW>cIsRT7b(ywP^qd%imwe^J!vOu#in`FyAFu?0=E~e}R7}h)$<5q| zQXc+Le8lywM6qeR&ym1^T))NW3yV=hHn0AnFdECYQt%i}iHX?>wAt6@5d#g=&L?g4 zJgbCNChfKGqQRj1PZVz(DYaQ`Rp1An;_^%|mfviKBGdbr1hYN*wHvkzRyR_^KMtU1h}$AANZ?a{bxf=o#?-xuBV zLR8n;AwrSuPDKz`uCyerxT9JQwaJhov>t@y#%8t3JrQaXlo8_&9PFU%5q41w2Rn!U`u?6A(%R;_zNcEKye&Vy9Cu@^AbCcf zA2YIU;1=!!1~?f#gFFZy$^iPRTTP8sz?KkGOu)kq(7OY$^yL&l&Ub5dJky!A78x=( zX5#%lsFtW@s^22(V3zp2&l-JDB_^KDJjLrx?m;?(mA_F9Sg-b|oIc8wM!m58liV#0 za(f(`{eY=jfSM&}EXb98&=Jut`0$e?hOR;U#VZ;&V6kg6bwKN@k8IX{PSviYudO~u zkZVzAfv%EQdPu7mD%-pL6nGPB-PC~bnA)w5JM~`LkKFZat~gntE3ZnEME3X&XoE)N z9Le(@uczx^kU(zl0891B?EaH!#uTCSM6K0p8rIgi4#e2xk}RLC1Ujp^*(_!9uO@Qq z>-`E~@GQK}H<#Rm#!4wE<)9tK5F5-KER<0?uoBb`PQ26fOS>fWPt(-*@A^vd@{3A! zHO}~*PU|F*+sVo0$(HJ8v6ww)^f<_JIY@vvp;svuk9$e0Eh(S58E`#EvnR zs=uTBArrhheZhYs2roCE==On8hhma(7z*I$W}jPS{F;dE4<@&|m@hV&IvgZtopgb= zuSODgI{N9%DVT8<7h6NfA=!ah87kc17Zp1O|2`bF_~DHL1Hq&MJ8(I^Z+GagJ!hBP5D4wlQIC3Y*==`Ozl%HN-4uLa$Y$?^S!93`U%1Ez`+bzWR zUYzi?5C>X4+3wkX;gHwHEOkmH0tQXG&&*=fG{X^{t$GKW&zYE+Qe;0(66g)9{0A>W z=+baG&Am$Yfx}S_;1*7Csl7?Z3t&F_LjOi`IHZ+(#}!%kJ|m>xw9kXtxa>ES;z?Sf zvj4a*g%Fmaa$#MFZkjDb=7Oe5?6S0&)*YAk!6(H<*3W#ex+%{;&$!2FUel|l&|NciU$iVO9+P!gGht|(=+7CC9^=wnM9vWtB%tJ~U zqA=x}!XDL4&M32u4AH=qmx~FHNlOU&_mt4n5;-LbLL{V2Y^iMq>U8ljCZsmx`8(5w zrJE;uDCvi$nDx@C3Q^E+K&*{mD{NlWt@kC-B6*NY=RoOjLiI2zac}27NRY7c)Qmjy;ZFUB)LoL>`m@;wrtgO*169Y zl}ym@3(m9Sw4a=FM8!@;9?1Xj!=uq8r_=s8N*7H8W5pb5r=z2NlFE>yH>TI5Q`Fxu9nkIR(8q;h63O#5bWI*o-6!W1`5- zH253T#XdDU0Bu#GX}wKg-aVyumVspGR##O%7Mj8xpaox%4LO%b@lnvTT|GWNza6I7 zAG0J)-gaAb8(Cs$ktMQ6XKvqFnF19sV-Ez5_EFh;l#C8k=Y0IjLwsnDJh~IVK1YTJ z3^t%dLrk$OsVMx)x-Uxn#f^dvRtEv@hK{M;UC6N${K>;yfq+}jy3&iq%}|`*6gE@gyoIvY{H(ugaJ?~S1%(SV2WmL$ z0-tW}+ITN2ddLJ*JMH1}4L9WU$Ft2MT6R80=)pZ}c6~N!7NF?!C$d|WKHO6^)$$CL zJxWn-v#H@Oi1%SXI=k8lW>b9CMh)w<;+0{@=;8Wmm74Web#jZI7L=`e?38Oq=Hl5E zPTc#`O8?7;PIWfolFpPryFu)(KuF1D2Fy|8a)qp#AiZo}-M!3ruJk+r zqg*(8g1zzjMfI7)C}X`&-jXb@Pys4>{H*m_wjZ$gxZJE-cwlRiFgxwBbv7;Hj1Id^ zKrc!Yu{ziT>fzeO_Dz+*F+PJ*QOCg+n$+G!`d2N#P@y$qXHI}Y`;M0c?Ug_L@2%!3 zkd|PQQxmsKoT&Q8fh=}ZmzElPsRZM~VOu$+Er6iDP6{IBgw#8cyH#NK_2>$vgZ5qd zggUD#%_);rl{hjwo916vt^`OM(F_E?Bu+WU7NJCuWzRXpI%t)rPfjq$af<-9X`Ld7 zJsj#~`$m3$j-@Is6-ZU&l3&Abj!=RNLoe`r z^3Cvxaa}5p>f46;E|s|_4jj~Hj>1t#Et|y%-J~A8d z*CBr8=$XPNge`EEvJ$j40i*jH{0;#v>L<@NoHH8dCfxU zvuM)Y`mQ7@kxI{6T+jM?p#7s#>CIL(4uQVz-F7WIm9W+zgv|Ec%LfqCFKo|fG1YXc zV&yIa;mpjAGY5fo{mw>|Aocdc5#Ubt;V53H^^k~>8Y4CgMEUFsG~w#7lkU+cm4f!gWVg=vj5i?z9_{^h z(wAE#BmxhTsP9X_w~D8LECsRIz>D3T!kkUKuTGsZS2SZ_(el0A4|N%Obh-5&!+mAF zVTgQ}KGzK4)Ds1<%2q4Z!?kF$Wd<0w-(G6u?&z)2R(bVymH5t3(Bp9RK_~Uh2%WM8 zs^K-J4Cy?;eGm?g77jjoo7R;i3LN{_@rEZuGx9lS+}pm0>y_Ff+;*dYD>K{OZb{ox&k^j5tg*TbIIU+x+E zM^&}(NVBIpnx_^SJ1jk>N6d5FcYdw)!yiwNgce^Ps>N48yv8yRV1lV?!Vtm$N zZ|HYt`@Fab5Dhu)4lp_lpEsJ?NKtcBqfwj{+No5f)n`}{AiB&`a-MD!{*LpGoq~V zp+hB<7C+`lK8U#G&X#a?RudC<3ROt|OnhKqx#6jmoPNK_X|iaX-PmElCUn#vPb*&1 zkC7}#yc~UQv^tLXL6>AwW2BIw>s?xOi+<_g-MEM>NH~>`uc*Ak+Q~r3dI|5U*mL%j z*xmqSUcYow-8=ZN)mTV}b4Hb3h;N~0Fng+A_7-C}NRX@8*i3PPH##+Zb>nwUoqTB? zd2@UMmt^>ZVgXwuf5x~_>q=#sPc;2)(TkVykzO;%&FVUbLuT3i>BQBKm{0b_#tG54 zb%nE21eB^75(95q;z@c+0^>_wOxq$l>JI_G&bvvM4m{jMSdw-RPB>?p^KXD4OZzTj zO;sub#Yuz3`l@v#V-E3IZ3|L>wjF6xfof2Ep;iKvM41u5~oSKRUTcdk}gXjGTzN}^k5dwj9b1{Gva z4VD7QkKIgnQg?J#NxjO&#~E(?TdS}la9Uxqc9>w`%rYW-6tJF+;{dTne7bgN$DEju zr*}&WDPr=a;acsRC|?{;731E3!UO!LJP|+BA zXVTIk_9FK?M$+pOjV{V@rGATda|jC6?vj{kZqql?uw`v2C^A}35T;*Y!np$>J?}m! z+A`_#a0_Kf;AjNs#S)~teFv5b%PRA>mG83L_$xhjc~mxUGoT-^^UKX+@SwGH?*Le~ zk8>Ep^-N5g;E|Gln<)_e+_cVj*lMQrjT)7@Dr<(WMof~A6EcC;SV2HlVW#<9^6uw6 zrerzXxWZx!WFSOER?hnK956;FmZ7hmERcZ77JEt0lc=L(a2U}QHEu7;2k+)Ij`|%L z(ku_V(nx*j!?uv|_f?Xzg0t--Wim8;^KQvt(pyEE^p0sEcX$xg$|+or*v!qSt9{s> zcMgT-c55COt@~?zrTP}Hry)07z}<7VNzuWumS6S92Z?B2w~C(%zz|0Q=A>EAw*}g6 z`1x@O*l#s)X@u9&WL!>fKBb=m93AXR95p0HsYN3{SaCakT)Ryq42+#7*PTr3 zY+9pqZ8G_~m-hx-fNwS4WA)xYle~>9i2ePIJ{kj2zgtSPC+7|F-6`UAw1M^2#HZvP zO9_B@Gb;kNMXASZhja>$JqRxQR3(}cC9pZ;%-lhajZc^7_z&~7*9$E>VSOy2b;$vmVnmr5alXnh}eCmF>7bZI&cjrN5okpBoF?#h3i`=7k|p1v_yp+yR-9}Q@nhv-F&$A8WlDL8UB(6a2~ ze6HX9Kmh2QG0y=(GVv)~Dbm_cVLBe9H=@h3`+*&GC66D8lN=Ii5}v%Iht3aaqZpzl z&G<7kRQ#e;t)c>FUi^+=OPs6xI%KggbNPKSXWj;;g$W|nSbla|;4@WeqPGbh_iUpC z=SknaOdhi6`g!`>O8H*RRCVUEqO#zel_`&8ptiQ4UXZBU2m*L=CE!_^-lT2=rMZ z{t_OM|89rU; zt~XxuY(l^{L(}QRrXX-Ztmw23>ndpuglrI4nnP(XyOH~d<^8G?T@RmgY6JcApy9j; zAwAvi=_zkFB8F~*Q}_3kMv+RJEIR!f4o_)vo^wqKh15Ag(*@I(;(uA#CXj2?aI6Qx zDydyJ!nrFl?GZ&LIxQdCdG(FZZrp)3rSYEEzj~oTNX>a@*!D(E=&=i}{yI-}i=)4O_KYH}cq|&) zprfgRmRDG+DG)V&!ULTxL;<(=b!(5@zMcUV{!vqB#E3c0Vss*_L`Uhjl$w&wJ*KPG z&K1J;vA{mxq8yTVP`+_+L;MM@m`hqm4=VF+-z#jVCVoTJ0-ICU9n$(r7*bR0sMK*|)(qZYyYyLfw^Y~!O zgt?$>-D~y1*B7UmK*%p5RcW^*$LYKx-Ho94mxb!jo-E}y!t7F1VeE{!sLYtDk1Gs9 z_vYpY1zJ7%A_rZ-LaL#MJG)=IH@1EP6Ylt0LV#;GZ-SjUp(Wa3tc?#;doT6m8fUs3 zLM3DCT~FkPmq*30P18-i`Z|Tm9go7cvL|o zrP}6|p|1!Dx+$dZ84kbJNjG}e6b>hL6Pc~X7)LV9NW(7x&jAS>+15Kx`oUjJhXTLv z4<6rv9D%v~7lY>RjMW_zGo=|FC)gDdVcK>;_0cC@HM;PlBgAT+rR36++g_xDv7dD& z2CYTXJb;*SwJAJkBgM0CU@gZs@%(0Bv-9LC6XSfwN^W&Qqll< zLbm4O{XM+P@LR0KrQZMnXLDj+&Xi65CM@-JY)@47vZ}jKAe^k_j}erk9y8n-9e8K3 ze_i!p=Ei@y%8UVDNH?z_pC zZf@h%QelVU>>RLPK=)Q=2L92+@E6rE*!l?JdvYoe=s2=%8F>mTGyC|gGtS44Neu9K zYkgmcMkl!2;fSEGC1v_kxMlB&`*`CU*iME$XR>}ewyvJt4L_EeHC@#3#)b{Qkb=um z9=1%w(8~-wXUzzigSaaX<8th4(?E_y(XM)1n&|8#s;uXKn&zirzD_sj@VR zn_9%4-1dhd=X_O^MXvbc;|A8`%bpsL2_x@th?V+iLM^1(dYII!xr`q$N6I-%!n00o zba30CdTC5@!_-;kuB8O!GfP72Mtq`Mwk-ZuFIUq7Y-5&BPRYp9-6r5yPxys>fD5Sv z)w!|dyB`_TvQ~v!15hWuc4_fiKYuQ$bVW@p>AQn&$0sAufxUD!tiJ3;-cvsUlbqx8p(5}zI*Q`tMuw&Owp`{85MNe^Y-`*5u~tMM5M z)wG@yzJ-fxK2L)=-yY!L0S05&cRbG*lRYqGg3-b+p@F447eq?V7@1&dF6#?PjdZM& zQ2g1{c?gL$yu3%Z4`2G4Y9#n_cu&VrKT3|gu^VtAB8#h@O<@n+9dIe$B`1#LH27e) zTackX!0_!%)(l>Cr; zi}UI+CwopUk{4#z@ez_zHYX|`Md$r9Fj{5cZ1Qrz-wB)^HHcU_kVe`lHc?-xN0jvS zAknU|b$I3D;3K}bS~7>z#{LX)Z9&7MwY7|F?(M6XIqC!Zin-gDfuLClK9;`MvR;+h zwkAb6nAWPBSRJ**OQLfn<8_tFX4Ck_!eUaJecnfT8 zVWkgg{;Jg3tCBYmI|JqH7D5MKGkxv;iZRVm>$}U|eB}gwo#*l+>3{w!89w`q^V6Fu zm-B-wJpbJfz~8vmf3g(tx9#LVQUC37{`dEA{Lj>{JO%!iDE_DFH&uYYA&35R^_zdq zU;oPt{%_PT=WqY0I?r|B|Ie}hzlL(j|L-p<|JUl5{Qpt)q`Sah!>7Nqp#DGX{jaTG z^8W|bFZutY>W0sNzps`3Ka&4n@R$7m(FOqgqv}s@0)NLP;y)t)zu+(V|Dz26_(#>R v@Bsha_3ghP|G(h>;{Q*U;0ITd&M9Wec`mYvZ~ZR4T+voHP^)_O;@$rNRWRhc diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index 5fc754e494ebe5fcceb10bcbb0dda8391c9469ff..23fe1c35d338c8bbec80a26f0b2a69b9a6af1e10 100644 GIT binary patch delta 30819 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 q02 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 Z%|t}Q|L~l{a-OFX&@}w$=@O}--d|T00=obJ delta 11911 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 16) & 255, ag = (a >> 8) & 255, ab = a & 255; + const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255; + const r = Math.round(ar + (br - ar) * t); + const g = Math.round(ag + (bg - ag) * t); + const bl = Math.round(ab + (bb - ab) * t); + return (r << 16) | (g << 8) | bl; +} + +// Each colour gets its own silhouette so gems read at a glance. +function unitShape(color) { + const poly = (n, rotDeg) => { + const pts = []; + for (let i = 0; i < n; i++) { + const a = (Math.PI / 180) * (rotDeg + (360 / n) * i); + pts.push({ x: Math.cos(a), y: Math.sin(a) }); + } + return pts; + }; + switch (color) { + case 'red': return poly(4, 45); + case 'orange': return poly(5, -90); + case 'yellow': return [{ x: 0, y: -1.06 }, { x: 0.72, y: 0 }, { x: 0, y: 1.06 }, { x: -0.72, y: 0 }]; + case 'green': return poly(6, 0); + case 'purple': return poly(3, -90); + case 'white': return poly(8, 22.5); + default: return null; // blue → circle + } +} + +export default class BejeweledGame extends Phaser.Scene { + constructor() { super('BejeweledGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'bejeweled', name: 'Bejeweled Blitz' }; + this.view = 'menu'; + this.state = null; + this.grid = []; // sprite containers, [r][c] + this.busy = false; + this.timeUp = false; + this.hurrahStarted = false; + this.score = 0; + this.displayScore = 0; + this.selected = null; + this.dragFrom = null; + this.lastAction = 0; + this.maxCascade = 0; + this.peakMultiplier = 1; + } + + create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.createTextures(); + this.buildBackground(); + this.layer = this.add.container(0, 0); + + this.input.on('pointerdown', this.onPointerDown, this); + this.input.on('pointermove', this.onPointerMove, this); + this.input.on('pointerup', () => { this.dragFrom = null; }); + + this.showMenu(); + } + + // ── Procedural textures ─────────────────────────────────────────────────── + + createTextures() { + if (!this.textures.exists('bj-bg')) { + const tex = this.textures.createCanvas('bj-bg', 16, 540); + const ctx = tex.getContext(); + const grad = ctx.createLinearGradient(0, 0, 0, 540); + grad.addColorStop(0, '#1c1038'); + grad.addColorStop(0.38, '#241349'); + grad.addColorStop(0.72, '#101437'); + grad.addColorStop(1, '#06070f'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 16, 540); + tex.refresh(); + } + + if (!this.textures.exists('bj-beam')) { + const tex = this.textures.createCanvas('bj-beam', 256, 64); + const ctx = tex.getContext(); + const gx = ctx.createLinearGradient(0, 0, 256, 0); + gx.addColorStop(0, 'rgba(255,255,255,0)'); + gx.addColorStop(0.18, 'rgba(255,255,255,0.85)'); + gx.addColorStop(0.5, 'rgba(255,255,255,1)'); + gx.addColorStop(0.82, 'rgba(255,255,255,0.85)'); + gx.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = gx; + ctx.fillRect(0, 0, 256, 64); + ctx.globalCompositeOperation = 'destination-in'; + const gy = ctx.createLinearGradient(0, 0, 0, 64); + gy.addColorStop(0, 'rgba(255,255,255,0)'); + gy.addColorStop(0.5, 'rgba(255,255,255,1)'); + gy.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = gy; + ctx.fillRect(0, 0, 256, 64); + tex.refresh(); + } + + if (this.textures.exists('bj-gem-red')) return; + + // Soft radial glow (additive-blended everywhere for halos and flashes). + let g = this.add.graphics(); + for (let i = 16; i >= 1; i--) { + const t = i / 16; + g.fillStyle(0xffffff, 0.022 + 0.085 * (1 - t)); + g.fillCircle(64, 64, 64 * t); + } + g.generateTexture('bj-glow', 128, 128); + g.destroy(); + + g = this.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillCircle(4, 4, 4); + g.generateTexture('bj-dot', 8, 8); + g.destroy(); + + g = this.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillPoints([ + { x: 16, y: 0 }, { x: 19, y: 13 }, { x: 32, y: 16 }, { x: 19, y: 19 }, + { x: 16, y: 32 }, { x: 13, y: 19 }, { x: 0, y: 16 }, { x: 13, y: 13 }, + ], true); + g.generateTexture('bj-spark', 32, 32); + g.destroy(); + + g = this.add.graphics(); + g.lineStyle(5, 0xffffff, 1); + g.strokeCircle(32, 32, 27); + g.generateTexture('bj-ring', 64, 64); + g.destroy(); + + // 8-spike starburst for Star gems. + g = this.add.graphics(); + const burst = []; + for (let i = 0; i < 16; i++) { + const a = (Math.PI / 8) * i - Math.PI / 2; + const r = i % 2 === 0 ? 30 : 11; + burst.push({ x: 32 + Math.cos(a) * r, y: 32 + Math.sin(a) * r }); + } + g.fillStyle(0xffffff, 1); + g.fillPoints(burst, true); + g.generateTexture('bj-burst', 64, 64); + g.destroy(); + + g = this.add.graphics(); + g.fillStyle(0xffffff, 0.55); + g.fillRoundedRect(0, 0, 64, 16, 8); + g.generateTexture('bj-sheen', 64, 16); + g.destroy(); + + // Faceted gems, one silhouette per colour. + const S = 108, CC = S / 2, R = 46; + const at = (pts, r, ox, oy) => pts.map((p) => ({ x: CC + ox + p.x * r, y: CC + oy + p.y * r })); + for (const color of GEM_COLORS) { + const def = GEMS[color]; + const midC = mixColor(def.base, def.hi, 0.35); + const coreC = mixColor(def.base, def.hi, 0.72); + const rimC = mixColor(def.lo, 0x000000, 0.35); + const pts = unitShape(color); + g = this.add.graphics(); + if (pts) { + g.fillStyle(def.lo, 1); g.fillPoints(at(pts, R, 0, 1), true); + g.fillStyle(def.base, 1); g.fillPoints(at(pts, R - 5, 0, -1), true); + g.fillStyle(midC, 1); g.fillPoints(at(pts, (R - 5) * 0.66, -3, -5), true); + g.fillStyle(coreC, 1); g.fillPoints(at(pts, (R - 5) * 0.36, -5, -8), true); + g.lineStyle(2.5, rimC, 0.9); g.strokePoints(at(pts, R, 0, 1), true, true); + } else { + g.fillStyle(def.lo, 1); g.fillCircle(CC, CC + 1, R); + g.fillStyle(def.base, 1); g.fillCircle(CC, CC - 1, R - 5); + g.fillStyle(midC, 1); g.fillCircle(CC - 3, CC - 5, (R - 5) * 0.66); + g.fillStyle(coreC, 1); g.fillCircle(CC - 5, CC - 8, (R - 5) * 0.36); + g.lineStyle(2.5, rimC, 0.9); g.strokeCircle(CC, CC + 1, R); + } + g.fillStyle(0xffffff, 0.35); + g.fillEllipse(CC - 12, CC - 19, 26, 13); + g.fillStyle(0xffffff, 0.9); + g.fillCircle(CC - 17, CC - 21, 3.5); + g.generateTexture(`bj-gem-${color}`, S, S); + g.destroy(); + } + + // Hypercube: an iridescent orb. + g = this.add.graphics(); + const wheel = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'white']; + wheel.forEach((c, i) => { + const a0 = (Math.PI * 2 / wheel.length) * i - Math.PI / 2; + const a1 = a0 + Math.PI * 2 / wheel.length; + g.fillStyle(GEMS[c].base, 0.95); + g.slice(CC, CC, R, a0, a1, false); + g.fillPath(); + }); + g.fillStyle(0x16102e, 0.92); + g.fillCircle(CC, CC, R * 0.62); + g.fillStyle(0xffffff, 0.95); + g.fillCircle(CC, CC, R * 0.24); + g.lineStyle(3, 0xffffff, 0.8); + g.strokeCircle(CC, CC, R); + g.fillStyle(0xffffff, 0.3); + g.fillEllipse(CC - 12, CC - 19, 26, 13); + g.generateTexture('bj-hyper', S, S); + g.destroy(); + } + + // ── Cosmic backdrop ─────────────────────────────────────────────────────── + + buildBackground() { + this.add.image(0, 0, 'bj-bg').setOrigin(0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg); + + const nebulas = [ + { x: 420, y: 280, tint: 0x6633ff, alpha: 0.17, scale: 9 }, + { x: 1520, y: 760, tint: 0x2255ff, alpha: 0.15, scale: 10 }, + { x: 1080, y: 170, tint: 0xff3aa0, alpha: 0.10, scale: 7 }, + { x: 250, y: 900, tint: 0x00c2a8, alpha: 0.08, scale: 6 }, + ]; + for (const n of nebulas) { + const img = this.add.image(n.x, n.y, 'bj-glow') + .setScale(n.scale).setTint(n.tint).setAlpha(n.alpha) + .setBlendMode(Phaser.BlendModes.ADD).setDepth(D.bg); + this.tweens.add({ + targets: img, + x: n.x + Phaser.Math.Between(-70, 70), + y: n.y + Phaser.Math.Between(-50, 50), + scale: n.scale * 1.12, + duration: Phaser.Math.Between(11000, 17000), + yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + } + + for (let i = 0; i < 90; i++) { + const star = this.add.image( + Phaser.Math.Between(0, GAME_WIDTH), Phaser.Math.Between(0, GAME_HEIGHT), + i % 9 === 0 ? 'bj-spark' : 'bj-dot', + ).setScale(Phaser.Math.FloatBetween(0.15, 0.55)) + .setAlpha(Phaser.Math.FloatBetween(0.15, 0.8)) + .setDepth(D.bg).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ + targets: star, alpha: 0.05, + duration: Phaser.Math.Between(700, 2600), + delay: Phaser.Math.Between(0, 2000), + yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + } + } + + clearLayer() { + this.stopTimer(); + for (const row of this.grid) { + for (const sprite of row ?? []) { + if (!sprite) continue; + this.tweens.killTweensOf(sprite); + sprite.each((child) => this.tweens.killTweensOf(child)); + } + } + this.tweens.killTweensOf(this.layer.list); + this.layer.removeAll(true); + if (this.boardMask) { this.boardMask.destroy(); this.boardMask = null; } + this.grid = []; + this.selected = null; + this.selRing = null; + this.scoreText = null; + this.multText = null; + this.timerFill = null; + this.timerText = null; + } + + // ── Menu ────────────────────────────────────────────────────────────────── + + showMenu() { + this.view = 'menu'; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const halo = this.add.image(cx, 200, 'bj-glow').setScale(8, 3.2) + .setTint(0xd4a017).setAlpha(0.35).setBlendMode(Phaser.BlendModes.ADD); + const title = this.add.text(cx, 168, 'BEJEWELED', { + fontFamily: 'Righteous', fontSize: '116px', color: '#ffffff', + }).setOrigin(0.5); + title.setTint(0xfff3c0, 0xffe14d, 0xd4a017, 0xb8741a); + const blitz = this.add.text(cx, 282, 'B L I T Z', { + fontFamily: 'Righteous', fontSize: '64px', color: '#6cc1ff', + }).setOrigin(0.5); + blitz.setTint(0xaadcff, 0xaadcff, 0x2e9bf0, 0x6633ff); + this.layer.add([halo, title, blitz]); + this.tweens.add({ targets: halo, alpha: 0.2, scaleX: 8.6, duration: 2400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + + // A row of slowly bobbing gems under the title. + GEM_COLORS.forEach((color, i) => { + const x = cx - 330 + i * 110; + const gem = this.add.image(x, 420, `bj-gem-${color}`).setScale(0.95); + const glow = this.add.image(x, 420, 'bj-glow').setScale(1.1) + .setTint(GEMS[color].base).setAlpha(0.5).setBlendMode(Phaser.BlendModes.ADD); + this.layer.add([glow, gem]); + this.tweens.add({ + targets: [gem, glow], y: 404, duration: 1500, delay: i * 160, + yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + }); + + const sub = this.add.text(cx, 532, '60 seconds. Match gems. Chase the cascade.', { + fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex, + }).setOrigin(0.5); + const rules = this.add.text(cx, 588, + 'Match 4 → Flame Gem • L or T shape → Star Gem • Match 5 → Hypercube', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([sub, rules]); + + const best = Number(localStorage.getItem(BEST_KEY) ?? 0); + if (best > 0) { + const bestText = this.add.text(cx, 648, `Best score: ${best.toLocaleString()}`, { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex, + }).setOrigin(0.5); + this.layer.add(bestText); + } + + const play = new Button(this, cx, 770, 'Play', () => this.startGame(), + { width: 340, height: 76, fontSize: 32 }); + const back = new Button(this, cx, 880, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 240, height: 60, fontSize: 24 }); + this.layer.add([play, back]); + } + + // ── Game setup ──────────────────────────────────────────────────────────── + + startGame() { + this.view = 'play'; + this.clearLayer(); + this.state = newGame(); + this.score = 0; + this.displayScore = 0; + this.timeLeft = BLITZ_SECONDS; + this.timeUp = false; + this.hurrahStarted = false; + this.busy = true; // until the intro drop settles + this.maxCascade = 0; + this.peakMultiplier = 1; + this.lastAction = this.time.now; + + this.drawBoardPanel(); + this.buildHud(); + + this.gemLayer = this.add.container(0, 0).setDepth(D.gems); + this.layer.add(this.gemLayer); + this.boardMask = this.make.graphics({ add: false }); + this.boardMask.fillRect(BOARD_X - 4, BOARD_Y - 4, BOARD_W + 8, BOARD_W + 8); + this.gemLayer.setMask(this.boardMask.createGeometryMask()); + + this.buildGems(true); + this.time.delayedCall(950, () => { + this.busy = false; + this.lastAction = this.time.now; + this.startTimer(); + }); + } + + cellXY(c, r) { + return { x: BOARD_X + c * CELL + CELL / 2, y: BOARD_Y + r * CELL + CELL / 2 }; + } + + drawBoardPanel() { + const p = this.add.graphics().setDepth(D.panel); + // Outer aura. + for (let i = 4; i >= 1; i--) { + p.lineStyle(i * 5, 0x7b5cff, 0.05 * (5 - i)); + p.strokeRoundedRect(BOARD_X - 16, BOARD_Y - 16, BOARD_W + 32, BOARD_W + 32, 26); + } + p.fillStyle(0x0a0c22, 0.78); + p.fillRoundedRect(BOARD_X - 14, BOARD_Y - 14, BOARD_W + 28, BOARD_W + 28, 24); + p.lineStyle(2, 0x9d8bff, 0.85); + p.strokeRoundedRect(BOARD_X - 14, BOARD_Y - 14, BOARD_W + 28, BOARD_W + 28, 24); + // Checkered cells. + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + p.fillStyle(0xffffff, (c + r) % 2 === 0 ? 0.045 : 0.085); + p.fillRoundedRect(BOARD_X + c * CELL + 2, BOARD_Y + r * CELL + 2, CELL - 4, CELL - 4, 10); + } + } + this.layer.add(p); + } + + buildHud() { + const leftX = 250; + + const panel = this.add.graphics().setDepth(D.hud); + panel.fillStyle(0x0a0c22, 0.72); + panel.fillRoundedRect(leftX - 190, 196, 380, 470, 22); + panel.lineStyle(2, 0x9d8bff, 0.6); + panel.strokeRoundedRect(leftX - 190, 196, 380, 470, 22); + this.layer.add(panel); + + const mk = (y, txt, opts) => { + const t = this.add.text(leftX, y, txt, opts).setOrigin(0.5).setDepth(D.hud); + this.layer.add(t); + return t; + }; + + mk(150, 'BEJEWELED BLITZ', { fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex }); + + mk(250, 'SCORE', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex }); + this.scoreText = mk(310, '0', { fontFamily: 'Righteous', fontSize: '62px', color: COLORS.goldHex }); + + mk(400, 'MULTIPLIER', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex }); + this.multText = mk(452, '×1', { fontFamily: 'Righteous', fontSize: '46px', color: '#6cc1ff' }).setAlpha(0.45); + + mk(540, 'BEST', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex }); + const best = Number(localStorage.getItem(BEST_KEY) ?? 0); + mk(588, best.toLocaleString(), { fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex }); + + const hint = new Button(this, leftX, 730, 'Hint', () => this.showHint(), + { width: 240, height: 56, fontSize: 22, variant: 'ghost' }); + const restart = new Button(this, leftX, 806, 'New Game', () => { if (!this.busy) this.startGame(); }, + { width: 240, height: 56, fontSize: 22 }); + const menu = new Button(this, leftX, 882, 'Menu', () => { if (!this.busy) this.showMenu(); }, + { width: 240, height: 56, fontSize: 22, variant: 'ghost' }); + [hint, restart, menu].forEach((b) => { b.setDepth(D.hud); this.layer.add(b); }); + + this.buildLegend(); + this.buildTimerBar(); + } + + buildLegend() { + const x = GAME_WIDTH - 250; + const panel = this.add.graphics().setDepth(D.hud); + panel.fillStyle(0x0a0c22, 0.72); + panel.fillRoundedRect(x - 190, 196, 380, 470, 22); + panel.lineStyle(2, 0x9d8bff, 0.6); + panel.strokeRoundedRect(x - 190, 196, 380, 470, 22); + this.layer.add(panel); + + const title = this.add.text(x, 236, 'SPECIAL GEMS', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.hud); + this.layer.add(title); + + const rows = [ + { tex: 'bj-gem-red', glow: 0xff7a1a, name: 'Flame Gem', desc: 'Match 4 — blasts a 3×3 area', burst: false }, + { tex: 'bj-gem-blue', glow: 0xffffff, name: 'Star Gem', desc: 'L or T match — clears row + column', burst: true }, + { tex: 'bj-hyper', glow: 0xc98aff, name: 'Hypercube', desc: 'Match 5 — swap to zap a whole colour', burst: false }, + { tex: 'bj-gem-green', glow: 0xffd24a, name: 'Multiplier', desc: 'Drops in big cascades — boosts scoring', mult: true }, + ]; + rows.forEach((rowDef, i) => { + const y = 312 + i * 92; + const glow = this.add.image(x - 130, y, 'bj-glow').setScale(0.85) + .setTint(rowDef.glow).setAlpha(0.7).setBlendMode(Phaser.BlendModes.ADD).setDepth(D.hud); + const icon = this.add.image(x - 130, y, rowDef.tex).setScale(0.62).setDepth(D.hud); + const name = this.add.text(x - 80, y - 18, rowDef.name, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0, 0.5).setDepth(D.hud); + const desc = this.add.text(x - 80, y + 12, rowDef.desc, { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, + wordWrap: { width: 250 }, + }).setOrigin(0, 0.5).setDepth(D.hud); + this.layer.add([glow, icon, name, desc]); + if (rowDef.burst) { + const b = this.add.image(x - 130, y, 'bj-burst').setScale(0.8).setAlpha(0.95) + .setBlendMode(Phaser.BlendModes.ADD).setDepth(D.hud); + this.layer.add(b); + } + if (rowDef.mult) { + const badge = this.add.text(x - 130, y, '×', { + fontFamily: 'Righteous', fontSize: '30px', color: '#ffd24a', stroke: '#101226', strokeThickness: 5, + }).setOrigin(0.5).setDepth(D.hud); + this.layer.add(badge); + } + }); + } + + buildTimerBar() { + const bg = this.add.graphics().setDepth(D.hud); + bg.fillStyle(0x0a0c22, 0.85); + bg.fillRoundedRect(BOARD_X, TIMER_Y - 16, BOARD_W, 32, 16); + bg.lineStyle(2, 0x9d8bff, 0.6); + bg.strokeRoundedRect(BOARD_X, TIMER_Y - 16, BOARD_W, 32, 16); + this.layer.add(bg); + + this.timerFill = this.add.graphics().setDepth(D.hud); + this.layer.add(this.timerFill); + this.timerText = this.add.text(BOARD_X + BOARD_W + 24, TIMER_Y, '60', { + fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex, + }).setOrigin(0, 0.5).setDepth(D.hud); + this.layer.add(this.timerText); + this.redrawTimer(); + } + + redrawTimer() { + if (!this.timerFill) return; + const t = Math.max(0, this.timeLeft) / BLITZ_SECONDS; + const color = t > 0.5 + ? mixColor(0xffd24a, 0x3ddc84, (t - 0.5) * 2) + : mixColor(0xff4d5e, 0xffd24a, t * 2); + this.timerFill.clear(); + const w = Math.max(0, (BOARD_W - 8) * t); + if (w > 16) { + this.timerFill.fillStyle(color, 1); + this.timerFill.fillRoundedRect(BOARD_X + 4, TIMER_Y - 12, w, 24, 12); + } + this.timerText.setText(String(Math.ceil(Math.max(0, this.timeLeft)))); + this.timerText.setColor(this.timeLeft <= 10 ? '#ff4d5e' : COLORS.textHex); + } + + startTimer() { + this.stopTimer(); + this.timerEvent = this.time.addEvent({ + delay: 100, loop: true, + callback: () => { + if (this.timeUp) return; + this.timeLeft -= 0.1; + if (this.timeLeft <= 0) { + this.timeLeft = 0; + this.timeUp = true; + this.stopTimer(); + this.redrawTimer(); + if (!this.busy) this.beginLastHurrah(); + return; + } + this.redrawTimer(); + }, + }); + } + + stopTimer() { + if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; } + } + + // ── Gem sprites ─────────────────────────────────────────────────────────── + + makeGem(cell, c, r) { + const { x, y } = this.cellXY(c, r); + const cont = this.add.container(x, y); + cont._color = cell.color; + cont._special = cell.special; + + if (cell.special === SPECIAL.HYPER) { + const glow = this.add.image(0, 0, 'bj-glow').setScale(1.3) + .setTint(0xc98aff).setAlpha(0.85).setBlendMode(Phaser.BlendModes.ADD); + const orb = this.add.image(0, 0, 'bj-hyper').setScale(CELL / 108 * 0.94); + const sheen = this.add.image(0, -10, 'bj-sheen').setAlpha(0.8).setAngle(-30); + cont.add([glow, orb, sheen]); + this.tweens.add({ targets: glow, scale: 1.6, alpha: 0.45, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + this.tweens.add({ targets: sheen, angle: 330, duration: 2600, repeat: -1 }); + } else { + if (cell.special === SPECIAL.FLAME) { + const glow = this.add.image(0, 0, 'bj-glow').setScale(1.15) + .setTint(0xff7a1a).setAlpha(0.9).setBlendMode(Phaser.BlendModes.ADD); + const core = this.add.image(0, 0, 'bj-glow').setScale(0.55) + .setTint(0xffd24a).setAlpha(0.9).setBlendMode(Phaser.BlendModes.ADD); + cont.add([glow, core]); + this.tweens.add({ targets: glow, scale: 1.45, alpha: 0.55, duration: 420, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + this.tweens.add({ targets: core, scale: 0.8, duration: 300, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } else if (cell.special === SPECIAL.STAR) { + const glow = this.add.image(0, 0, 'bj-glow').setScale(1.2) + .setAlpha(0.9).setBlendMode(Phaser.BlendModes.ADD); + cont.add(glow); + this.tweens.add({ targets: glow, scale: 1.55, alpha: 0.5, duration: 520, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } + const img = this.add.image(0, 0, `bj-gem-${cell.color}`).setScale(CELL / 108 * 0.94); + cont.add(img); + if (cell.special === SPECIAL.STAR) { + const star = this.add.image(0, 0, 'bj-burst').setScale(1.05) + .setAlpha(0.95).setBlendMode(Phaser.BlendModes.ADD); + cont.add(star); + this.tweens.add({ targets: star, scale: 1.25, duration: 520, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } + if (cell.special === SPECIAL.MULT) { + const badge = this.add.circle(26, 26, 17, 0x101226, 0.94).setStrokeStyle(2.5, 0xffd24a, 1); + const sym = this.add.text(26, 26, '×', { + fontFamily: 'Righteous', fontSize: '27px', color: '#ffd24a', + }).setOrigin(0.5, 0.56); + cont.add([badge, sym]); + this.tweens.add({ targets: [badge, sym], scale: 1.18, duration: 480, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + } + } + this.gemLayer.add(cont); + return cont; + } + + destroyGem(cont) { + if (!cont) return; + this.tweens.killTweensOf(cont); + cont.each((child) => this.tweens.killTweensOf(child)); + cont.destroy(); + } + + buildGems(intro = false) { + this.grid = []; + for (let r = 0; r < ROWS; r++) { + this.grid[r] = []; + for (let c = 0; c < COLS; c++) { + const sprite = this.makeGem(this.state.board[r][c], c, r); + this.grid[r][c] = sprite; + if (intro) { + const finalY = sprite.y; + sprite.y = finalY - (ROWS + 2) * CELL; + this.tweens.add({ + targets: sprite, y: finalY, + delay: c * 45 + r * 22, + duration: 430, ease: 'Bounce.easeOut', + }); + } + } + } + } + + // Safety net: after every move make sprites agree with the board exactly. + resyncSprites() { + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + const cell = this.state.board[r][c]; + const sprite = this.grid[r][c]; + const { x, y } = this.cellXY(c, r); + if (sprite && sprite._color === cell.color && sprite._special === cell.special) { + sprite.setPosition(x, y); + continue; + } + this.destroyGem(sprite); + this.grid[r][c] = this.makeGem(cell, c, r); + } + } + } + + // ── Input ───────────────────────────────────────────────────────────────── + + cellAt(x, y) { + const c = Math.floor((x - BOARD_X) / CELL); + const r = Math.floor((y - BOARD_Y) / CELL); + if (c < 0 || c >= COLS || r < 0 || r >= ROWS) return null; + return { c, r }; + } + + onPointerDown(pointer) { + if (this.view !== 'play' || this.busy || this.timeUp) return; + this.lastAction = this.time.now; + const cell = this.cellAt(pointer.x, pointer.y); + if (!cell) { this.clearSelection(); return; } + + if (this.selected) { + const d = Math.abs(this.selected.c - cell.c) + Math.abs(this.selected.r - cell.r); + if (d === 1) { + const from = this.selected; + this.clearSelection(); + this.attemptSwap(from, cell); + return; + } + if (d === 0) { this.clearSelection(); return; } + } + this.select(cell); + this.dragFrom = cell; + this.dragStart = { x: pointer.x, y: pointer.y }; + } + + onPointerMove(pointer) { + if (!this.dragFrom || !pointer.isDown || this.busy || this.timeUp || this.view !== 'play') return; + const dx = pointer.x - this.dragStart.x; + const dy = pointer.y - this.dragStart.y; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 32) return; + const from = this.dragFrom; + this.dragFrom = null; + const to = Math.abs(dx) > Math.abs(dy) + ? { c: from.c + Math.sign(dx), r: from.r } + : { c: from.c, r: from.r + Math.sign(dy) }; + if (to.c < 0 || to.c >= COLS || to.r < 0 || to.r >= ROWS) return; + this.clearSelection(); + this.attemptSwap(from, to); + } + + select(cell) { + this.clearSelection(); + this.selected = cell; + const { x, y } = this.cellXY(cell.c, cell.r); + this.selRing = this.add.image(x, y, 'bj-ring').setScale(CELL / 64 * 0.92) + .setTint(0xffffff).setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ + targets: this.selRing, scale: CELL / 64 * 1.02, alpha: 0.6, + duration: 420, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + playSound(this, SFX.PIECE_CLICK); + } + + clearSelection() { + this.selected = null; + if (this.selRing) { this.tweens.killTweensOf(this.selRing); this.selRing.destroy(); this.selRing = null; } + } + + // ── Moves ───────────────────────────────────────────────────────────────── + + attemptSwap(a, b) { + if (this.busy || this.timeUp || this.view !== 'play') return; + const board = this.state.board; + const isHyperSwap = board[a.r][a.c]?.special === SPECIAL.HYPER + || board[b.r][b.c]?.special === SPECIAL.HYPER; + + const phases = applyMove(this.state, a, b); + this.lastAction = this.time.now; + if (!phases) { this.invalidSwap(a, b); return; } + + this.busy = true; + playSound(this, SFX.SCIFI_WOOSH); + const sa = this.grid[a.r][a.c]; + const sb = this.grid[b.r][b.c]; + const pa = this.cellXY(a.c, a.r); + const pb = this.cellXY(b.c, b.r); + + if (isHyperSwap) { + // The hypercube fires in place: pull the gems together, then detonate. + this.tweens.add({ targets: sa, x: pa.x + (pb.x - pa.x) * 0.3, y: pa.y + (pb.y - pa.y) * 0.3, duration: 110, yoyo: true }); + this.tweens.add({ + targets: sb, x: pb.x + (pa.x - pb.x) * 0.3, y: pb.y + (pa.y - pb.y) * 0.3, duration: 110, yoyo: true, + onComplete: () => this.runPhases(phases, () => this.afterMove()), + }); + } else { + this.grid[a.r][a.c] = sb; + this.grid[b.r][b.c] = sa; + this.tweens.add({ targets: sa, x: pb.x, y: pb.y, duration: 170, ease: 'Quad.easeInOut' }); + this.tweens.add({ + targets: sb, x: pa.x, y: pa.y, duration: 170, ease: 'Quad.easeInOut', + onComplete: () => this.runPhases(phases, () => this.afterMove()), + }); + } + } + + invalidSwap(a, b) { + this.busy = true; + playSound(this, SFX.MASTERMIND_DENIED); + const sa = this.grid[a.r][a.c]; + const sb = this.grid[b.r][b.c]; + const pa = this.cellXY(a.c, a.r); + const pb = this.cellXY(b.c, b.r); + this.tweens.add({ targets: sa, x: pb.x, y: pb.y, duration: 130, yoyo: true, ease: 'Quad.easeInOut' }); + this.tweens.add({ + targets: sb, x: pa.x, y: pa.y, duration: 130, yoyo: true, ease: 'Quad.easeInOut', + onComplete: () => { this.busy = false; }, + }); + } + + afterMove() { + this.resyncSprites(); + this.busy = false; + this.lastAction = this.time.now; + if (this.timeUp) { this.beginLastHurrah(); return; } + if (this.state.noMoves) this.doReshuffle(); + } + + doReshuffle() { + this.busy = true; + this.showBanner('NO MORE MOVES', '#ff6d7e', 'reshuffling the gems…'); + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + const s = this.grid[r][c]; + this.tweens.add({ + targets: s, alpha: 0, scale: 0.3, delay: (c + r) * 18, duration: 240, ease: 'Quad.easeIn', + onComplete: () => this.destroyGem(s), + }); + } + this.time.delayedCall(620, () => { + shuffleBoard(this.state); + this.buildGems(true); + this.time.delayedCall(900, () => { + this.busy = false; + if (this.timeUp) this.beginLastHurrah(); + }); + }); + } + + // ── Phase animation ─────────────────────────────────────────────────────── + + runPhases(phases, done) { + const step = (i) => { + if (i >= phases.length) { done(); return; } + this.animatePhase(phases[i], () => step(i + 1)); + }; + step(0); + } + + animatePhase(phase, done) { + this.maxCascade = Math.max(this.maxCascade, phase.cascade); + this.peakMultiplier = Math.max(this.peakMultiplier, phase.multiplier); + + // Special-gem fireworks, staggered so chains read as chains. + phase.events.forEach((e, i) => { + this.time.delayedCall(i * 90, () => this.playEvent(e)); + }); + + // Clear matched gems with a burst. + if (phase.cleared.length) playSound(this, SFX.MASTERMIND_MATCH); + const sparse = phase.cleared.length > 14; + phase.cleared.forEach((cell, i) => { + const sprite = this.grid[cell.r][cell.c]; + this.grid[cell.r][cell.c] = null; + if (!sprite) return; + this.tweens.add({ + targets: sprite, scale: 0, alpha: 0, duration: 180, ease: 'Back.easeIn', + onComplete: () => this.destroyGem(sprite), + }); + if (!sparse || i % 2 === 0) this.gemBurst(sprite.x, sprite.y, cell.color); + }); + + // Score & combo callout at the centroid of the clear. + if (phase.points > 0 && phase.cleared.length) { + let mx = 0, my = 0; + for (const cell of phase.cleared) { const p = this.cellXY(cell.c, cell.r); mx += p.x; my += p.y; } + mx /= phase.cleared.length; my /= phase.cleared.length; + this.addScore(phase.points); + this.time.delayedCall(70, () => this.scorePopup(mx, my, phase.points, phase.cascade)); + if (phase.cascade >= 2) this.time.delayedCall(120, () => this.comboCallout(phase.cascade)); + } + + // Newly earned specials flash into existence. + phase.spawns.forEach((s) => { + this.time.delayedCall(190, () => { + const old = this.grid[s.r][s.c]; + this.destroyGem(old); + const sprite = this.makeGem({ color: s.color, special: s.special }, s.c, s.r); + this.grid[s.r][s.c] = sprite; + sprite.setScale(1.7).setAlpha(0); + this.tweens.add({ targets: sprite, scale: 1, alpha: 1, duration: 260, ease: 'Back.easeOut' }); + const flash = this.add.image(sprite.x, sprite.y, 'bj-glow').setScale(0.6) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ + targets: flash, scale: 2.2, alpha: 0, duration: 380, + onComplete: () => flash.destroy(), + }); + }); + }); + + // Gravity: surviving gems slide down, fresh ones rain in from above. + const FALL_AT = 250; + let maxFall = 0; + this.time.delayedCall(FALL_AT, () => { + for (const f of phase.falls) { + const sprite = this.grid[f.fromR][f.c]; + this.grid[f.toR][f.c] = sprite; + this.grid[f.fromR][f.c] = null; + if (!sprite) continue; + const { y } = this.cellXY(f.c, f.toR); + this.tweens.add({ + targets: sprite, y, + duration: 110 + 58 * (f.toR - f.fromR), ease: 'Bounce.easeOut', + }); + } + for (const f of phase.refills) { + const sprite = this.makeGem({ color: f.color, special: f.special }, f.c, f.fromR); + this.grid[f.r][f.c] = sprite; + const { y } = this.cellXY(f.c, f.r); + this.tweens.add({ + targets: sprite, y, + duration: 110 + 58 * (f.r - f.fromR), ease: 'Bounce.easeOut', + }); + } + }); + for (const f of phase.falls) maxFall = Math.max(maxFall, 110 + 58 * (f.toR - f.fromR)); + for (const f of phase.refills) maxFall = Math.max(maxFall, 110 + 58 * (f.r - f.fromR)); + + this.time.delayedCall(FALL_AT + maxFall + 70, done); + } + + playEvent(e) { + if (e.type === 'mult') { + playSound(this, SFX.COINS); + this.multText.setText(`×${e.multiplier}`).setAlpha(1); + this.tweens.add({ targets: this.multText, scale: 1.5, duration: 160, yoyo: true, ease: 'Quad.easeOut' }); + this.showBanner(`MULTIPLIER ×${e.multiplier}!`, '#6cc1ff'); + return; + } + const { x, y } = this.cellXY(e.c, e.r); + if (e.type === 'flame') { + playSound(this, SFX.SCIFI_EXPLODE); + this.cameras.main.shake(110, 0.0045); + const flash = this.add.image(x, y, 'bj-glow').setScale(1).setTint(0xff7a1a) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ targets: flash, scale: 4.5, alpha: 0, duration: 420, onComplete: () => flash.destroy() }); + const ring = this.add.image(x, y, 'bj-ring').setScale(0.6).setTint(0xffd24a) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ targets: ring, scale: 4, alpha: 0, duration: 380, onComplete: () => ring.destroy() }); + const em = this.add.particles(x, y, 'bj-dot', { + speed: { min: 130, max: 420 }, lifespan: 600, quantity: 26, + scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 }, + tint: [0xff7a1a, 0xffd24a, 0xff4d5e, 0xffffff], + blendMode: 'ADD', + }).setDepth(D.fx); + this.time.delayedCall(60, () => em.stop()); + this.time.delayedCall(800, () => em.destroy()); + } else if (e.type === 'star') { + playSound(this, SFX.SCIFI_LAUNCH); + this.cameras.main.shake(90, 0.003); + const h = this.add.image(BOARD_X + BOARD_W / 2, y, 'bj-beam') + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD).setDisplaySize(BOARD_W + 60, 110); + const v = this.add.image(x, BOARD_Y + BOARD_W / 2, 'bj-beam').setAngle(90) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD).setDisplaySize(BOARD_W + 60, 110); + this.tweens.add({ targets: [h, v], alpha: 0, duration: 360, ease: 'Quad.easeIn', + onComplete: () => { h.destroy(); v.destroy(); } }); + const em = this.add.particles(x, y, 'bj-spark', { + speed: { min: 80, max: 260 }, lifespan: 500, quantity: 14, + scale: { start: 0.8, end: 0 }, alpha: { start: 1, end: 0 }, + tint: 0xffffff, blendMode: 'ADD', + }).setDepth(D.fx); + this.time.delayedCall(60, () => em.stop()); + this.time.delayedCall(700, () => em.destroy()); + } else if (e.type === 'hyper') { + playSound(this, SFX.SCIFI_REVEAL); + this.cameras.main.shake(170, 0.006); + const tint = e.color ? GEMS[e.color].base : 0xffffff; + const flash = this.add.image(x, y, 'bj-glow').setScale(1.4).setTint(tint) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ targets: flash, scale: 7, alpha: 0, duration: 520, onComplete: () => flash.destroy() }); + // Lightning to each zapped gem. + const bolts = this.add.graphics().setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + const targets = (e.cells ?? []).slice(0, 20); + for (const [c, r] of targets) { + const p = this.cellXY(c, r); + bolts.lineStyle(3, tint, 0.9); + bolts.beginPath(); + bolts.moveTo(x, y); + const segs = 3; + for (let i = 1; i <= segs; i++) { + const t = i / segs; + const jx = (i < segs) ? Phaser.Math.Between(-22, 22) : 0; + const jy = (i < segs) ? Phaser.Math.Between(-22, 22) : 0; + bolts.lineTo(x + (p.x - x) * t + jx, y + (p.y - y) * t + jy); + } + bolts.strokePath(); + } + this.tweens.add({ targets: bolts, alpha: 0, duration: 300, onComplete: () => bolts.destroy() }); + const em = this.add.particles(x, y, 'bj-spark', { + speed: { min: 140, max: 480 }, lifespan: 700, quantity: 30, + scale: { start: 1, end: 0 }, alpha: { start: 1, end: 0 }, + tint: [tint, 0xffffff], blendMode: 'ADD', + }).setDepth(D.fx); + this.time.delayedCall(80, () => em.stop()); + this.time.delayedCall(900, () => em.destroy()); + } + } + + gemBurst(x, y, color) { + const tint = GEMS[color]?.base ?? 0xffffff; + const em = this.add.particles(x, y, 'bj-dot', { + speed: { min: 60, max: 200 }, lifespan: 420, quantity: 8, + scale: { start: 0.9, end: 0 }, alpha: { start: 1, end: 0 }, + tint: [tint, mixColor(tint, 0xffffff, 0.6)], blendMode: 'ADD', + }).setDepth(D.fx); + this.time.delayedCall(40, () => em.stop()); + this.time.delayedCall(520, () => em.destroy()); + } + + addScore(points) { + this.score += points; + if (this.scoreTween) this.scoreTween.stop(); + const from = this.displayScore; + const counter = { v: from }; + this.scoreTween = this.tweens.add({ + targets: counter, v: this.score, duration: 320, ease: 'Quad.easeOut', + onUpdate: () => { + this.displayScore = Math.round(counter.v); + if (this.scoreText) this.scoreText.setText(this.displayScore.toLocaleString()); + }, + }); + } + + scorePopup(x, y, points, cascade) { + const size = Math.min(30 + cascade * 7, 64); + const t = this.add.text(x, y, `+${points.toLocaleString()}`, { + fontFamily: 'Righteous', fontSize: `${size}px`, color: COLORS.goldHex, + stroke: '#100c04', strokeThickness: 6, + }).setOrigin(0.5).setDepth(D.banner); + this.tweens.add({ + targets: t, y: y - 70, alpha: 0, duration: 800, ease: 'Quad.easeOut', + onComplete: () => t.destroy(), + }); + } + + comboCallout(cascade) { + const idx = Math.min(cascade, COMBO_WORDS.length - 1); + const word = cascade >= 7 ? 'UNBELIEVABLE!' : COMBO_WORDS[idx]; + const color = cascade >= 7 ? '#ffe75e' : COMBO_COLORS[idx]; + if (!word) return; + const t = this.add.text(GAME_WIDTH / 2, BOARD_Y + 240, word, { + fontFamily: 'Righteous', fontSize: `${46 + cascade * 6}px`, color, + stroke: '#0a0c22', strokeThickness: 8, + }).setOrigin(0.5).setDepth(D.banner).setScale(0.3).setAlpha(0); + this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 200, ease: 'Back.easeOut' }); + this.tweens.add({ + targets: t, alpha: 0, y: t.y - 40, delay: 600, duration: 320, + onComplete: () => t.destroy(), + }); + } + + showBanner(text, color, subText) { + const cx = GAME_WIDTH / 2; + const cy = BOARD_Y + BOARD_W / 2 - 40; + const t = this.add.text(cx, cy, text, { + fontFamily: 'Righteous', fontSize: '64px', color, + stroke: '#0a0c22', strokeThickness: 10, + }).setOrigin(0.5).setDepth(D.banner).setScale(0.3).setAlpha(0); + this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' }); + this.tweens.add({ targets: t, alpha: 0, delay: 950, duration: 300, onComplete: () => t.destroy() }); + if (subText) { + const s = this.add.text(cx, cy + 56, subText, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, + stroke: '#0a0c22', strokeThickness: 6, + }).setOrigin(0.5).setDepth(D.banner).setAlpha(0); + this.tweens.add({ targets: s, alpha: 1, duration: 220 }); + this.tweens.add({ targets: s, alpha: 0, delay: 950, duration: 300, onComplete: () => s.destroy() }); + } + } + + // ── Hint ────────────────────────────────────────────────────────────────── + + showHint() { + if (this.view !== 'play' || this.busy || this.timeUp) return; + const mv = findMove(this.state.board); + if (!mv) return; + this.lastAction = this.time.now; + for (const cell of [mv.a, mv.b]) { + const sprite = this.grid[cell.r][cell.c]; + if (!sprite) continue; + this.tweens.add({ targets: sprite, scale: 1.18, duration: 180, yoyo: true, repeat: 2, ease: 'Sine.easeInOut' }); + const { x, y } = this.cellXY(cell.c, cell.r); + const glow = this.add.image(x, y, 'bj-glow').setScale(1.4).setAlpha(0.8) + .setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ targets: glow, alpha: 0, scale: 1.9, duration: 900, onComplete: () => glow.destroy() }); + } + } + + update(time) { + if (this.view !== 'play') return; + // Low-clock pulse. + if (this.timerFill && this.timeLeft <= 10 && this.timeLeft > 0) { + this.timerFill.setAlpha(0.65 + 0.35 * Math.sin(time / 90)); + } + // Gentle automatic hint when the player stalls. + if (!this.busy && !this.timeUp && time - this.lastAction > 7000) { + this.lastAction = time; + this.showHint(); + } + } + + // ── Endgame ─────────────────────────────────────────────────────────────── + + beginLastHurrah() { + if (this.hurrahStarted) return; + this.hurrahStarted = true; + this.busy = true; + this.clearSelection(); + + const phases = lastHurrah(this.state); + if (!phases.length) { this.time.delayedCall(700, () => this.gameOver()); this.showBanner("TIME'S UP!", '#ff6d7e'); return; } + + this.showBanner('LAST HURRAH!', '#ffe75e', 'every special gem detonates'); + this.time.delayedCall(1000, () => { + this.runPhases(phases, () => this.time.delayedCall(300, () => this.gameOver())); + }); + } + + gameOver() { + this.view = 'over'; + playSound(this, SFX.VICTORY_SHORT); + + const prevBest = Number(localStorage.getItem(BEST_KEY) ?? 0); + const newBest = this.score > prevBest; + if (newBest) localStorage.setItem(BEST_KEY, String(this.score)); + + api.post('/history/single-player', { + slug: 'bejeweled', score: this.score, opponentScores: [], result: 'win', + }).catch(() => { /* best effort */ }); + + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x05060f, 0.7) + .setDepth(D.overlay).setInteractive(); + this.layer.add(dim); + + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(0x0a0c22, 0.97); + panel.fillRoundedRect(cx - 390, cy - 270, 780, 540, 26); + panel.lineStyle(3, newBest ? 0xffd24a : 0x9d8bff, 1); + panel.strokeRoundedRect(cx - 390, cy - 270, 780, 540, 26); + this.layer.add(panel); + + const title = this.add.text(cx, cy - 198, "TIME'S UP!", { + fontFamily: 'Righteous', fontSize: '64px', color: '#ff6d7e', + }).setOrigin(0.5).setDepth(D.overlayUI); + const scoreLabel = this.add.text(cx, cy - 116, 'FINAL SCORE', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const scoreText = this.add.text(cx, cy - 48, '0', { + fontFamily: 'Righteous', fontSize: '84px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([title, scoreLabel, scoreText]); + + const counter = { v: 0 }; + this.tweens.add({ + targets: counter, v: this.score, duration: 1100, ease: 'Cubic.easeOut', + onUpdate: () => scoreText.setText(Math.round(counter.v).toLocaleString()), + }); + + const stats = this.add.text(cx, cy + 38, + `Biggest cascade ×${this.maxCascade} • Top multiplier ×${this.peakMultiplier}`, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(stats); + + if (newBest) { + const nb = this.add.text(cx, cy + 92, '★ NEW BEST SCORE ★', { + fontFamily: 'Righteous', fontSize: '36px', color: '#ffd24a', + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(nb); + this.tweens.add({ targets: nb, scale: 1.1, duration: 480, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + const em = this.add.particles(cx, cy - 270, 'bj-dot', { + x: { min: -360, max: 360 }, speedY: { min: 120, max: 260 }, speedX: { min: -40, max: 40 }, + lifespan: 2400, quantity: 2, frequency: 70, scale: { start: 0.8, end: 0.2 }, + alpha: { start: 1, end: 0 }, + tint: Object.values(GEMS).map((gem) => gem.base), blendMode: 'ADD', + }).setDepth(D.overlayUI); + this.time.delayedCall(3600, () => em.destroy()); + } else if (prevBest > 0) { + const bb = this.add.text(cx, cy + 92, `Best: ${prevBest.toLocaleString()}`, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(bb); + } + + const again = new Button(this, cx - 170, cy + 188, 'Play Again', () => this.startGame(), + { width: 290, height: 64, fontSize: 26 }).setDepth(D.overlayUI); + const menu = new Button(this, cx + 170, cy + 188, 'Menu', () => this.showMenu(), + { width: 290, height: 64, fontSize: 26, variant: 'ghost' }).setDepth(D.overlayUI); + this.layer.add([again, menu]); + } +} diff --git a/public/src/games/bejeweled/BejeweledLogic.js b/public/src/games/bejeweled/BejeweledLogic.js new file mode 100644 index 0000000..2aed562 --- /dev/null +++ b/public/src/games/bejeweled/BejeweledLogic.js @@ -0,0 +1,424 @@ +// Bejeweled Blitz — pure game logic (no Phaser). +// +// Board is ROWS×COLS of gems { color, special }. Swapping two adjacent gems is +// legal when it creates a run of 3+ (or involves a Hypercube). Matches resolve +// in cascading phases; each phase reports exactly what happened so the scene +// can animate it: cleared gems, special-gem spawns, detonation events, falls +// and refills, plus the points earned. +// +// Specials: +// FLAME — from a 4-in-a-row; detonates a 3×3 blast when cleared. +// STAR — from an L/T intersection; clears its full row and column. +// HYPER — from 5+ in a row; swap with any gem to clear that colour +// (detonated by a blast, it zaps a random colour instead). +// MULT — multiplier gem dropped during deep cascades; clearing it raises +// the global score multiplier (×2 … ×8). + +export const COLS = 8; +export const ROWS = 8; +export const BLITZ_SECONDS = 60; +export const MAX_MULTIPLIER = 8; + +export const GEM_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'white']; + +export const SPECIAL = { + NONE: 'none', + FLAME: 'flame', + STAR: 'star', + HYPER: 'hyper', + MULT: 'mult', +}; + +const key = (c, r) => r * COLS + c; +const unkey = (k) => ({ c: k % COLS, r: (k / COLS) | 0 }); +const inBounds = (c, r) => c >= 0 && c < COLS && r >= 0 && r < ROWS; + +function randomGem(rng) { + return { color: GEM_COLORS[(rng() * GEM_COLORS.length) | 0], special: SPECIAL.NONE }; +} + +// ── Board construction ────────────────────────────────────────────────────── + +export function randomBoard(rng = Math.random) { + for (let attempt = 0; attempt < 100; attempt++) { + const board = []; + for (let r = 0; r < ROWS; r++) { + board[r] = []; + for (let c = 0; c < COLS; c++) { + let gem; + do { gem = randomGem(rng); } while ( + (c >= 2 && board[r][c - 1].color === gem.color && board[r][c - 2].color === gem.color) || + (r >= 2 && board[r - 1][c].color === gem.color && board[r - 2][c].color === gem.color) + ); + board[r][c] = gem; + } + } + if (findMove(board)) return board; + } + throw new Error('Could not generate a board with a legal move.'); +} + +export function newGame(rng = Math.random) { + return { board: randomBoard(rng), multiplier: 1, noMoves: false }; +} + +// ── Runs & match groups ───────────────────────────────────────────────────── + +export function findRuns(board) { + const runs = []; + for (let r = 0; r < ROWS; r++) { + let c = 0; + while (c < COLS) { + const color = board[r][c]?.color; + if (!color) { c++; continue; } + let end = c + 1; + while (end < COLS && board[r][end]?.color === color) end++; + if (end - c >= 3) { + const cells = []; + for (let i = c; i < end; i++) cells.push([i, r]); + runs.push({ color, horizontal: true, cells }); + } + c = end; + } + } + for (let c = 0; c < COLS; c++) { + let r = 0; + while (r < ROWS) { + const color = board[r][c]?.color; + if (!color) { r++; continue; } + let end = r + 1; + while (end < ROWS && board[end][c]?.color === color) end++; + if (end - r >= 3) { + const cells = []; + for (let i = r; i < end; i++) cells.push([c, i]); + runs.push({ color, horizontal: false, cells }); + } + r = end; + } + } + return runs; +} + +// Union runs that share a cell into match groups (an L/T counts as one group). +function groupRuns(runs) { + const parent = runs.map((_, i) => i); + const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i]))); + const union = (a, b) => { parent[find(a)] = find(b); }; + + const byCell = new Map(); + runs.forEach((run, i) => run.cells.forEach(([c, r]) => { + const k = key(c, r); + if (byCell.has(k)) union(i, byCell.get(k)); + else byCell.set(k, i); + })); + + const groups = new Map(); + runs.forEach((run, i) => { + const root = find(i); + if (!groups.has(root)) groups.set(root, { color: run.color, runs: [], cells: new Set() }); + const g = groups.get(root); + g.runs.push(run); + run.cells.forEach(([c, r]) => g.cells.add(key(c, r))); + }); + return [...groups.values()]; +} + +// Where a freshly-earned special gem materialises: the swapped cell if it is +// part of the group, else the runs' shared cell, else the longest run's middle. +function pickSpawnKey(group, swapKeys) { + for (const k of swapKeys) if (group.cells.has(k)) return k; + if (group.runs.length >= 2) { + const seen = new Set(); + for (const run of group.runs) { + for (const [c, r] of run.cells) { + const k = key(c, r); + if (seen.has(k)) return k; + seen.add(k); + } + } + } + const longest = group.runs.reduce((a, b) => (b.cells.length > a.cells.length ? b : a)); + const [c, r] = longest.cells[(longest.cells.length / 2) | 0]; + return key(c, r); +} + +// ── Detonation chains ─────────────────────────────────────────────────────── + +// Expand a seed set of cleared cells through special-gem chain reactions. +// hyperOverrides maps a hyper gem's key to the colour it must zap (set by a +// hyper swap); hypers consumed by blasts zap a random colour on the board. +function expandClears(board, seedKeys, rng, hyperOverrides = new Map()) { + const keys = new Set(); + const events = []; + const queue = []; + + const add = (c, r) => { + if (!inBounds(c, r)) return; + const k = key(c, r); + if (keys.has(k) || !board[r][c]) return; + keys.add(k); + const sp = board[r][c].special; + if (sp === SPECIAL.FLAME || sp === SPECIAL.STAR || sp === SPECIAL.HYPER) queue.push(k); + }; + + for (const k of seedKeys) { const { c, r } = unkey(k); add(c, r); } + + while (queue.length) { + const k = queue.shift(); + const { c, r } = unkey(k); + const sp = board[r][c].special; + if (sp === SPECIAL.FLAME) { + events.push({ type: 'flame', c, r }); + for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) add(c + dc, r + dr); + } else if (sp === SPECIAL.STAR) { + events.push({ type: 'star', c, r }); + for (let i = 0; i < COLS; i++) add(i, r); + for (let i = 0; i < ROWS; i++) add(c, i); + } else if (sp === SPECIAL.HYPER) { + let color = hyperOverrides.get(k) ?? null; + if (!color) { + const present = new Set(); + for (let rr = 0; rr < ROWS; rr++) for (let cc = 0; cc < COLS; cc++) { + if (board[rr][cc]?.color && !keys.has(key(cc, rr))) present.add(board[rr][cc].color); + } + const pool = [...present]; + color = pool.length ? pool[(rng() * pool.length) | 0] : null; + } + const cells = []; + if (color) { + for (let rr = 0; rr < ROWS; rr++) for (let cc = 0; cc < COLS; cc++) { + if (board[rr][cc]?.color === color) { cells.push([cc, rr]); add(cc, rr); } + } + } + events.push({ type: 'hyper', c, r, color, cells }); + } + } + return { keys, events }; +} + +// ── Gravity & refill ──────────────────────────────────────────────────────── + +function collapse(board, rng) { + const falls = []; + const refills = []; + for (let c = 0; c < COLS; c++) { + let write = ROWS - 1; + for (let r = ROWS - 1; r >= 0; r--) { + if (!board[r][c]) continue; + if (write !== r) { + board[write][c] = board[r][c]; + board[r][c] = null; + falls.push({ c, fromR: r, toR: write }); + } + write--; + } + const newCount = write + 1; + for (let r = write; r >= 0; r--) { + const gem = randomGem(rng); + board[r][c] = gem; + refills.push({ c, r, color: gem.color, special: gem.special, fromR: r - newCount }); + } + } + return { falls, refills }; +} + +// ── Cascade driver ────────────────────────────────────────────────────────── + +const GEM_POINTS = 30; +const EVENT_BONUS = { flame: 100, star: 200, hyper: 400 }; + +// Resolve one or more cascade phases. opts.preClear seeds phase 1 directly +// (hyper swaps, Last Hurrah); afterwards phases come from runs on the board. +function runCascades(state, rng, opts = {}) { + const board = state.board; + const phases = []; + let swapKeys = opts.swapKeys ?? []; + let preClear = opts.preClear ?? null; + let multDropped = false; + let cascade = 0; + + while (cascade < 30) { + cascade++; + let seedKeys; + let spawns = []; + let runBonus = 0; + let hyperOverrides = new Map(); + + if (preClear) { + seedKeys = preClear.keys; + runBonus = preClear.bonus ?? 0; + hyperOverrides = preClear.hyperOverrides ?? new Map(); + preClear = null; + } else { + const runs = findRuns(board); + if (!runs.length) break; + seedKeys = new Set(); + for (const group of groupRuns(runs)) { + group.cells.forEach((k) => seedKeys.add(k)); + const maxLen = Math.max(...group.runs.map((r) => r.cells.length)); + let special = null; + if (maxLen >= 5) special = SPECIAL.HYPER; + else if (group.runs.length >= 2) special = SPECIAL.STAR; + else if (maxLen === 4) special = SPECIAL.FLAME; + if (special) { + const k = pickSpawnKey(group, swapKeys); + spawns.push({ k, color: special === SPECIAL.HYPER ? null : group.color, special }); + } + runBonus += (maxLen - 3) * 100 + (group.runs.length >= 2 ? 150 : 0); + } + } + + const { keys, events } = expandClears(board, seedKeys, rng, hyperOverrides); + const spawnKeys = new Set(spawns.map((s) => s.k)); + + // Multiplier gems consumed this phase raise the global multiplier. + let eventBonus = 0; + const cleared = []; + for (const k of keys) { + const { c, r } = unkey(k); + const gem = board[r][c]; + if (gem.special === SPECIAL.MULT && state.multiplier < MAX_MULTIPLIER) { + state.multiplier++; + events.push({ type: 'mult', c, r, multiplier: state.multiplier }); + } + if (!spawnKeys.has(k)) cleared.push({ c, r, color: gem.color, special: gem.special }); + } + for (const e of events) eventBonus += EVENT_BONUS[e.type] ?? 0; + + const points = Math.round((keys.size * GEM_POINTS + runBonus + eventBonus) * cascade * state.multiplier); + + for (const k of keys) { const { c, r } = unkey(k); board[r][c] = null; } + const placedSpawns = []; + for (const s of spawns) { + const { c, r } = unkey(s.k); + board[r][c] = { color: s.color, special: s.special }; + placedSpawns.push({ c, r, color: s.color, special: s.special }); + } + + const { falls, refills } = collapse(board, rng); + + // Deep cascades can drop a multiplier gem into the refill. + if (opts.allowMult !== false && !multDropped && cascade >= 2 + && state.multiplier < MAX_MULTIPLIER && refills.length && rng() < 0.6) { + const pick = refills[(rng() * refills.length) | 0]; + pick.special = SPECIAL.MULT; + board[pick.r][pick.c].special = SPECIAL.MULT; + multDropped = true; + } + + phases.push({ + cascade, points, multiplier: state.multiplier, + cleared, spawns: placedSpawns, events, falls, refills, + }); + swapKeys = []; + } + + state.noMoves = !findMove(board); + return phases; +} + +// ── Moves ─────────────────────────────────────────────────────────────────── + +// Attempt a swap of adjacent cells a/b ({c, r}). Returns an array of phases, +// or null if the swap is illegal (board left untouched). +export function applyMove(state, a, b, rng = Math.random) { + const board = state.board; + if (!inBounds(a.c, a.r) || !inBounds(b.c, b.r)) return null; + if (Math.abs(a.c - b.c) + Math.abs(a.r - b.r) !== 1) return null; + const A = board[a.r][a.c]; + const B = board[b.r][b.c]; + if (!A || !B) return null; + + if (A.special === SPECIAL.HYPER || B.special === SPECIAL.HYPER) { + const keys = new Set(); + const hyperOverrides = new Map(); + let bonus; + if (A.special === SPECIAL.HYPER && B.special === SPECIAL.HYPER) { + // Double hypercube: the whole board goes up. + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) keys.add(key(c, r)); + hyperOverrides.set(key(a.c, a.r), null); + hyperOverrides.set(key(b.c, b.r), null); + bonus = 2000; + } else { + const hyper = A.special === SPECIAL.HYPER ? a : b; + const other = A.special === SPECIAL.HYPER ? B : A; + keys.add(key(hyper.c, hyper.r)); + hyperOverrides.set(key(hyper.c, hyper.r), other.color); + bonus = 500; + } + return runCascades(state, rng, { preClear: { keys, bonus, hyperOverrides } }); + } + + board[a.r][a.c] = B; + board[b.r][b.c] = A; + if (!findRuns(board).length) { + board[a.r][a.c] = A; + board[b.r][b.c] = B; + return null; + } + return runCascades(state, rng, { swapKeys: [key(b.c, b.r), key(a.c, a.r)] }); +} + +// When the clock runs out every special left on the board detonates, +// repeatedly, until none remain. +export function lastHurrah(state, rng = Math.random) { + const board = state.board; + const phases = []; + for (let round = 0; round < 12; round++) { + const keys = new Set(); + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + if (board[r][c] && board[r][c].special !== SPECIAL.NONE) keys.add(key(c, r)); + } + if (!keys.size) break; + phases.push(...runCascades(state, rng, { preClear: { keys }, allowMult: false })); + } + return phases; +} + +// ── Move search / shuffle ─────────────────────────────────────────────────── + +export function findMove(board) { + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + if (board[r][c]?.special === SPECIAL.HYPER) { + const b = c + 1 < COLS ? { c: c + 1, r } : { c: c - 1, r }; + return { a: { c, r }, b }; + } + } + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + for (const [dc, dr] of [[1, 0], [0, 1]]) { + const c2 = c + dc, r2 = r + dr; + if (!inBounds(c2, r2)) continue; + const A = board[r][c], B = board[r2][c2]; + if (!A || !B || A.color === B.color) continue; + board[r][c] = B; board[r2][c2] = A; + const hit = findRuns(board).length > 0; + board[r][c] = A; board[r2][c2] = B; + if (hit) return { a: { c, r }, b: { c: c2, r: r2 } }; + } + } + return null; +} + +// Rearrange the existing gems into a fresh layout with no instant matches and +// at least one legal move. Falls back to a brand-new board if that fails. +export function shuffleBoard(state, rng = Math.random) { + const gems = []; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) gems.push(state.board[r][c]); + + for (let attempt = 0; attempt < 300; attempt++) { + for (let i = gems.length - 1; i > 0; i--) { + const j = (rng() * (i + 1)) | 0; + [gems[i], gems[j]] = [gems[j], gems[i]]; + } + const board = []; + for (let r = 0; r < ROWS; r++) board[r] = gems.slice(r * COLS, (r + 1) * COLS); + if (!findRuns(board).length && findMove(board)) { + state.board = board; + state.noMoves = false; + return board; + } + } + state.board = randomBoard(rng); + state.noMoves = false; + return state.board; +} diff --git a/public/src/games/minimotorways/MiniMotorwaysGame.js b/public/src/games/minimotorways/MiniMotorwaysGame.js new file mode 100644 index 0000000..a701ed7 --- /dev/null +++ b/public/src/games/minimotorways/MiniMotorwaysGame.js @@ -0,0 +1,1251 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { + WORLD_W, WORLD_H, TERRAIN, TUNE, CITIES, COLOR_HEX, + Sim, generateCity, keyOf, xOf, yOf, +} from './MiniMotorwaysLogic.js'; + +const CELL = 64; +const WORLD_PX_W = WORLD_W * CELL; +const WORLD_PX_H = WORLD_H * CELL; + +// World-space depths. +const D = { + land: 0, waterFx: 1, roads: 2, items: 3, ghost: 3.5, + structures: 4, cars: 5, motorway: 6, pins: 7, fx: 8, night: 9, +}; + +const BEST_KEY = (i) => `minimotorways-best-${i}`; + +const UPGRADE_DEFS = { + roads: { name: 'More Roads', desc: `+${TUNE.UPGRADE_ROADS} extra road tiles\non top of your weekly batch.` }, + bridge: { name: 'Bridge', desc: 'Span up to 3 tiles of water\nand join two shores.' }, + motorway: { name: 'Motorway', desc: 'An express link between two\nramps — traffic flies over town.' }, + light: { name: 'Traffic Light', desc: 'Meters a busy intersection,\nalternating the right of way.' }, + roundabout: { name: 'Roundabout', desc: 'Keeps a junction flowing —\nno more all-stop pile-ups.' }, +}; + +function mixColor(a, b, t) { + const ar = (a >> 16) & 255; const ag = (a >> 8) & 255; const ab = a & 255; + const br = (b >> 16) & 255; const bg = (b >> 8) & 255; const bb = b & 255; + return ((ar + (br - ar) * t) << 16) | (((ag + (bg - ag) * t) | 0) << 8) | ((ab + (bb - ab) * t) | 0); +} +function darken(c, f) { + const r = Math.round(((c >> 16) & 255) * f); + const g = Math.round(((c >> 8) & 255) * f); + const b = Math.round((c & 255) * f); + return (r << 16) | (g << 8) | b; +} +const hexStr = (c) => `#${c.toString(16).padStart(6, '0')}`; +const cellCx = (k) => (xOf(k) + 0.5) * CELL; +const cellCy = (k) => (yOf(k) + 0.5) * CELL; + +export default class MiniMotorwaysGame extends Phaser.Scene { + constructor() { super('MiniMotorwaysGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'minimotorways', name: 'Mini Motorways' }; + this.autoCity = data.autoCity ?? null; + this.view = 'select'; + this.sim = null; + this.tool = null; + this.overlayUp = false; + this.drawnNetVersion = -1; + this.lastTickAt = 0; + this.lastPaintSound = 0; + this.houseSprites = new Map(); + this.buildingSprites = new Map(); + this.carSprites = new Map(); + this.carRender = new Map(); + this.shimmerCells = []; + this.dragMode = null; // 'draw' | 'erase' | null + this.dragCell = null; + } + + create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) this.music = new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + if (this.input.mouse) this.input.mouse.disableContextMenu(); + + if (this.autoCity !== null) this.startGame(this.autoCity); + else this.showCitySelect(); + } + + // ── City select ─────────────────────────────────────────────────────────────── + + showCitySelect() { + this.view = 'select'; + const cx = GAME_WIDTH / 2; + this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg); + + this.add.text(cx, 96, 'MINI MOTORWAYS', { + fontFamily: 'Righteous', fontSize: '72px', color: COLORS.goldHex, + }).setOrigin(0.5); + this.add.text(cx, 162, 'Draw the roads. Keep the city moving. One overwhelmed destination ends it all.', { + fontFamily: '"Julius Sans One"', fontSize: '25px', color: COLORS.mutedHex, + }).setOrigin(0.5); + + const CARD_W = 440; const CARD_H = 332; const GAP_X = 48; const GAP_Y = 40; + const left = cx - (3 * CARD_W + 2 * GAP_X) / 2 + CARD_W / 2; + const top = 318; + + CITIES.forEach((city, i) => { + const x = left + (i % 3) * (CARD_W + GAP_X); + const y = top + Math.floor(i / 3) * (CARD_H + GAP_Y); + + const card = this.add.rectangle(x, y, CARD_W, CARD_H, 0x171411) + .setStrokeStyle(3, city.palette.accent, 0.85); + + // Miniature of the real map this city generates. + this.drawCityPreview(i, x, y - 50, 360, 216); + + this.add.text(x - 170, y + 92, city.name, { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex, + }).setOrigin(0, 0.5); + + const best = Number(localStorage.getItem(BEST_KEY(i)) ?? 0); + this.add.text(x + 170, y + 92, best > 0 ? `BEST ${best}` : 'NEW', { + fontFamily: 'Righteous', fontSize: '26px', color: hexStr(city.palette.accent), + }).setOrigin(1, 0.5); + + this.add.text(x - 170, y + 130, city.blurb, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5); + + card.setInteractive({ useHandCursor: true }); + card.on('pointerover', () => card.setStrokeStyle(5, city.palette.accent, 1)); + card.on('pointerout', () => card.setStrokeStyle(3, city.palette.accent, 0.85)); + card.on('pointerup', () => this.startGame(i)); + }); + + const back = new Button(this, cx, GAME_HEIGHT - 56, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 220, height: 56, fontSize: 24 }); + this.add.existing(back); + } + + drawCityPreview(cityIndex, cx, cy, w, h) { + const city = CITIES[cityIndex]; + const { terrain } = generateCity(cityIndex, 7000 + cityIndex * 131); + const g = this.add.graphics(); + const sx = w / WORLD_W; const sy = h / WORLD_H; + const ox = cx - w / 2; const oy = cy - h / 2; + g.fillStyle(city.palette.land, 1); + g.fillRoundedRect(ox, oy, w, h, 10); + for (let y = 0; y < WORLD_H; y++) { + for (let x = 0; x < WORLD_W; x++) { + const t = terrain[keyOf(x, y)]; + if (t === TERRAIN.WATER) { + g.fillStyle(city.palette.water, 1); + g.fillRect(ox + x * sx, oy + y * sy, sx + 0.8, sy + 0.8); + } else if (t === TERRAIN.TREE) { + g.fillStyle(darken(city.palette.landAlt, 0.78), 1); + g.fillRect(ox + x * sx + sx * 0.25, oy + y * sy + sy * 0.25, sx * 0.5, sy * 0.5); + } + } + } + // A few hint houses in the city's first colours. + const rng = (n) => ((Math.sin(cityIndex * 999 + n * 71.7) + 1) / 2); + for (let i = 0; i < 6; i++) { + const color = COLOR_HEX[city.colorOrder[i % 2]]; + let px; let py; let guard = 0; + do { + px = Math.floor(rng(i * 3 + guard) * WORLD_W); + py = Math.floor(rng(i * 3 + 1 + guard) * WORLD_H); + guard++; + } while (terrain[keyOf(px, py)] !== TERRAIN.LAND && guard < 20); + g.fillStyle(color, 1); + g.fillRect(ox + px * sx, oy + py * sy, sx * 1.4, sy * 1.4); + } + } + + // ── Boot a city ─────────────────────────────────────────────────────────────── + + startGame(cityIndex) { + // Destroy (not just remove) the select screen — removed-but-alive objects + // keep their input hit areas and would still swallow clicks during play. + // The music player's buttons live outside the screen flow and must survive. + const keep = new Set(this.music?._objs ?? []); + for (const child of [...this.children.list]) { + if (!keep.has(child)) child.destroy(); + } + this.view = 'play'; + this.cityIndex = cityIndex; + this.city = CITIES[cityIndex]; + this.sim = new Sim(cityIndex, (Math.random() * 1e9) | 0); + this.overlayUp = false; + this.tool = null; + this.drawnNetVersion = -1; + + this.makeTextures(); + this.buildWorld(); + this.buildHud(); + this.wireInput(); + + // Seed sprites for the structures spawned in the Sim constructor. + for (const h of this.sim.houses) this.ensureHouseSprite(h, false); + for (const b of this.sim.buildings) this.ensureBuildingSprite(b, false); + + this.lastTickAt = this.time.now; + this.simTimer = this.time.addEvent({ + delay: 100, loop: true, callback: () => this.simTick(), + }); + } + + fitZoom(rect) { + return Math.min(GAME_WIDTH / (rect.w * CELL), GAME_HEIGHT / (rect.h * CELL)) * 0.92; + } + + buildWorld() { + this.worldRoot = this.add.container(0, 0); + this.uiRoot = this.add.container(0, 0); + + const cam = this.cameras.main; + cam.setBackgroundColor(darken(this.city.palette.landAlt, 0.9)); + cam.setBounds(-CELL * 2, -CELL * 2, WORLD_PX_W + CELL * 4, WORLD_PX_H + CELL * 4); + cam.centerOn(WORLD_PX_W / 2, WORLD_PX_H / 2); + cam.setZoom(this.fitZoom(this.sim.activeRect)); + + this.uiCam = this.cameras.add(0, 0, GAME_WIDTH, GAME_HEIGHT); + this.uiCam.ignore(this.worldRoot); + cam.ignore(this.uiRoot); + // Music controls are screen-space UI: only the UI camera should draw them. + if (this.music?._objs?.length) cam.ignore(this.music._objs); + + this.terrainRT = this.add.renderTexture(0, 0, WORLD_PX_W, WORLD_PX_H) + .setOrigin(0, 0).setDepth(D.land); + this.worldRoot.add(this.terrainRT); + this.drawTerrainRT(); + + this.shimmerG = this.add.graphics().setDepth(D.waterFx); + this.roadsG = this.add.graphics().setDepth(D.roads); + this.itemsG = this.add.graphics().setDepth(D.items); + this.motorwayG = this.add.graphics().setDepth(D.motorway); + this.ghostG = this.add.graphics().setDepth(D.ghost); + this.worldRoot.add([this.shimmerG, this.roadsG, this.itemsG, this.motorwayG, this.ghostG]); + + this.nightRect = this.add.rectangle(WORLD_PX_W / 2, WORLD_PX_H / 2, WORLD_PX_W + CELL * 8, WORLD_PX_H + CELL * 8, 0xffffff) + .setDepth(D.night).setBlendMode(Phaser.BlendModes.MULTIPLY); + this.worldRoot.add(this.nightRect); + } + + // ── Procedural textures ─────────────────────────────────────────────────────── + + makeTextures() { + for (const [name, color] of Object.entries(COLOR_HEX)) { + const houseKey = `mm-house-${name}`; + if (!this.textures.exists(houseKey)) { + const g = this.add.graphics(); + g.fillStyle(darken(color, 0.72), 1); + g.fillRoundedRect(2, 2, 44, 44, 10); + g.fillStyle(color, 1); + g.fillRoundedRect(2, 2, 44, 40, 10); + g.fillStyle(darken(color, 0.82), 1); + g.fillRoundedRect(2, 2, 44, 14, { tl: 10, tr: 10, bl: 0, br: 0 }); + g.fillStyle(0xffffff, 0.92); + g.fillRoundedRect(19, 28, 10, 14, 3); + g.generateTexture(houseKey, 48, 48); + g.destroy(); + } + const bldgKey = `mm-bldg-${name}`; + if (!this.textures.exists(bldgKey)) { + const g = this.add.graphics(); + g.fillStyle(darken(color, 0.68), 1); + g.fillRoundedRect(4, 8, 104, 100, 14); + g.fillStyle(color, 1); + g.fillRoundedRect(4, 4, 104, 100, 14); + g.fillStyle(darken(color, 0.85), 1); + g.fillRoundedRect(12, 12, 88, 30, 8); + g.fillStyle(0xffffff, 0.85); + for (let wx = 0; wx < 3; wx++) { + g.fillRoundedRect(22 + wx * 26, 56, 16, 16, 4); + } + g.fillStyle(0xffffff, 0.95); + g.fillRoundedRect(46, 80, 20, 24, 4); + g.generateTexture(bldgKey, 112, 112); + g.destroy(); + } + const carKey = `mm-car-${name}`; + if (!this.textures.exists(carKey)) { + const g = this.add.graphics(); + g.fillStyle(darken(color, 0.6), 1); + g.fillRoundedRect(1, 3, 32, 18, 7); + g.fillStyle(color, 1); + g.fillRoundedRect(1, 1, 32, 18, 7); + g.fillStyle(0xffffff, 0.55); + g.fillRoundedRect(19, 4, 8, 12, 3); + g.generateTexture(carKey, 34, 22); + g.destroy(); + } + } + + if (!this.textures.exists('mm-pin')) { + const g = this.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillCircle(7, 7, 7); + g.generateTexture('mm-pin', 14, 14); + g.destroy(); + } + if (!this.textures.exists('mm-dot')) { + const g = this.add.graphics(); + g.fillStyle(0xffffff, 1); + g.fillRect(0, 0, 8, 8); + g.generateTexture('mm-dot', 8, 8); + g.destroy(); + } + if (!this.textures.exists('mm-glow')) { + const g = this.add.graphics(); + for (let i = 14; i >= 1; i--) { + const t = i / 14; + g.fillStyle(0xffffff, 0.02 + 0.07 * (1 - t)); + g.fillCircle(56, 56, 56 * t); + } + g.generateTexture('mm-glow', 112, 112); + g.destroy(); + } + } + + // ── Terrain / roads / items rendering ───────────────────────────────────────── + + drawTerrainRT() { + const pal = this.city.palette; + const sim = this.sim; + const g = this.add.graphics(); + + g.fillStyle(darken(pal.land, 0.94), 1); + g.fillRect(0, 0, WORLD_PX_W, WORLD_PX_H); + const r = sim.activeRect; + g.fillStyle(pal.land, 1); + g.fillRoundedRect(r.x0 * CELL, r.y0 * CELL, r.w * CELL, r.h * CELL, 26); + + // Soft alternating "field" blocks for texture, barely visible. + g.fillStyle(pal.landAlt, 0.35); + for (let y = 0; y < WORLD_H; y += 2) { + for (let x = (y / 2) % 2 === 0 ? 0 : 2; x < WORLD_W; x += 4) { + g.fillRoundedRect(x * CELL + 6, y * CELL + 6, CELL * 2 - 12, CELL * 2 - 12, 14); + } + } + + // Water as merged rounded blobs. + this.shimmerCells = []; + g.fillStyle(darken(pal.water, 0.86), 1); + for (let k = 0; k < sim.terrain.length; k++) { + if (sim.terrain[k] !== TERRAIN.WATER) continue; + g.fillRoundedRect(xOf(k) * CELL - 7, yOf(k) * CELL - 5, CELL + 14, CELL + 14, 20); + } + g.fillStyle(pal.water, 1); + let wi = 0; + for (let k = 0; k < sim.terrain.length; k++) { + if (sim.terrain[k] !== TERRAIN.WATER) continue; + g.fillRoundedRect(xOf(k) * CELL - 6, yOf(k) * CELL - 6, CELL + 12, CELL + 12, 20); + if (wi++ % 5 === 0 && this.shimmerCells.length < 70) this.shimmerCells.push(k); + } + + // Trees. + for (let k = 0; k < sim.terrain.length; k++) { + if (sim.terrain[k] !== TERRAIN.TREE) continue; + const cx = cellCx(k); const cy = cellCy(k); + g.fillStyle(0x9a7b58, 1); + g.fillRoundedRect(cx - 3, cy + 6, 6, 12, 2); + g.fillStyle(darken(0x7fae6e, 0.92), 1); + g.fillCircle(cx - 8, cy + 2, 10); + g.fillCircle(cx + 8, cy + 2, 10); + g.fillStyle(0x8cba7b, 1); + g.fillCircle(cx, cy - 6, 12); + } + + this.terrainRT.clear(); + this.terrainRT.draw(g, 0, 0); + g.destroy(); + } + + redrawRoads() { + const sim = this.sim; + const pal = this.city.palette; + const g = this.roadsG; + g.clear(); + this.drawnNetVersion = sim.netVersion; + + const edges = []; + for (const k of sim.roads) { + for (const nb of sim.roadNeighbors(k)) { + if (nb.motorway || nb.k < k) continue; + edges.push([k, nb.k]); + } + } + + // Bridge plinths sit under everything. + for (const k of sim.bridgeCells) { + g.fillStyle(darken(pal.roadEdge, 0.62), 1); + g.fillRoundedRect(xOf(k) * CELL + 2, yOf(k) * CELL + 2, CELL - 4, CELL - 4, 14); + } + + // Pass 1: outline (wide), pass 2: fill (narrow). Round cap circles at each + // cell centre make every 8-way junction read as one smooth ribbon. + g.lineStyle(CELL * 0.62, pal.roadEdge, 1); + for (const [a, b] of edges) g.lineBetween(cellCx(a), cellCy(a), cellCx(b), cellCy(b)); + g.fillStyle(pal.roadEdge, 1); + for (const k of sim.roads) g.fillCircle(cellCx(k), cellCy(k), CELL * 0.31); + + g.lineStyle(CELL * 0.5, pal.road, 1); + for (const [a, b] of edges) g.lineBetween(cellCx(a), cellCy(a), cellCx(b), cellCy(b)); + g.fillStyle(pal.road, 1); + for (const k of sim.roads) g.fillCircle(cellCx(k), cellCy(k), CELL * 0.25); + + // Bridge side rails over the deck. + for (const k of sim.bridgeCells) { + const horiz = sim.roads.has(k + 1) || sim.roads.has(k - 1); + g.lineStyle(4, darken(pal.roadEdge, 0.55), 1); + const x = xOf(k) * CELL; const y = yOf(k) * CELL; + if (horiz) { + g.lineBetween(x, y + 8, x + CELL, y + 8); + g.lineBetween(x, y + CELL - 8, x + CELL, y + CELL - 8); + } else { + g.lineBetween(x + 8, y, x + 8, y + CELL); + g.lineBetween(x + CELL - 8, y, x + CELL - 8, y + CELL); + } + } + + this.redrawMotorways(); + } + + redrawMotorways() { + const g = this.motorwayG; + const sim = this.sim; + g.clear(); + const ASPHALT = 0x4d4d59; + for (const m of sim.motorways) { + const ax = cellCx(m.a); const ay = cellCy(m.a); + const bx = cellCx(m.b); const by = cellCy(m.b); + // Drop shadow gives the elevated feel. + g.lineStyle(CELL * 0.55, 0x000000, 0.14); + g.lineBetween(ax + 7, ay + 10, bx + 7, by + 10); + g.lineStyle(CELL * 0.55, ASPHALT, 1); + g.lineBetween(ax, ay, bx, by); + // Dashed centre line. + const dx = bx - ax; const dy = by - ay; + const len = Math.hypot(dx, dy); + const steps = Math.floor(len / 34); + g.lineStyle(4, 0xf4e26b, 0.9); + for (let i = 0; i < steps; i++) { + const t0 = (i + 0.18) / steps; const t1 = (i + 0.55) / steps; + g.lineBetween(ax + dx * t0, ay + dy * t0, ax + dx * t1, ay + dy * t1); + } + // Ramps. + for (const k of [m.a, m.b]) { + g.fillStyle(darken(ASPHALT, 0.8), 1); + g.fillRoundedRect(xOf(k) * CELL + 4, yOf(k) * CELL + 4, CELL - 8, CELL - 8, 12); + g.fillStyle(0xf4e26b, 0.9); + g.fillTriangle(cellCx(k) - 8, cellCy(k) + 7, cellCx(k) + 8, cellCy(k) + 7, cellCx(k), cellCy(k) - 9); + } + } + } + + redrawItems() { + const g = this.itemsG; + const sim = this.sim; + g.clear(); + if (sim.items.size === 0) return; + const phase = sim.lightPhase(); + for (const [k, item] of sim.items) { + const cx = cellCx(k); const cy = cellCy(k); + if (item.type === 'roundabout') { + g.fillStyle(this.city.palette.land, 1); + g.fillCircle(cx, cy, CELL * 0.16); + g.lineStyle(5, darken(this.city.palette.roadEdge, 0.7), 1); + g.strokeCircle(cx, cy, CELL * 0.16); + } else { + g.fillStyle(0x2f2f38, 1); + g.fillRoundedRect(cx - 9, cy - 14, 18, 28, 5); + const hOn = phase === 0; + g.fillStyle(hOn ? 0x57d977 : 0xe35050, 1); + g.fillCircle(cx, cy - 6, 4.5); + g.fillStyle(hOn ? 0xe35050 : 0x57d977, 1); + g.fillCircle(cx, cy + 6, 4.5); + } + } + } + + // ── Entity sprites ──────────────────────────────────────────────────────────── + + ensureHouseSprite(house, pop = true) { + if (this.houseSprites.has(house.id)) return; + const c = this.add.container(cellCx(house.k), cellCy(house.k)).setDepth(D.structures); + const img = this.add.image(0, 0, `mm-house-${house.color}`); + c.add(img); + this.worldRoot.add(c); + this.houseSprites.set(house.id, c); + if (pop) { + c.setScale(0); + this.tweens.add({ targets: c, scale: 1, duration: 420, ease: 'Back.easeOut' }); + } + } + + ensureBuildingSprite(building, pop = true) { + if (this.buildingSprites.has(building.id)) return; + const cx = xOf(building.k) * CELL + CELL; + const cy = yOf(building.k) * CELL + CELL; + const c = this.add.container(cx, cy).setDepth(D.structures); + const img = this.add.image(0, 0, `mm-bldg-${building.color}`); + c.add(img); + this.worldRoot.add(c); + + const pinsC = this.add.container(cx, cy).setDepth(D.pins); + this.worldRoot.add(pinsC); + const ringG = this.add.graphics().setDepth(D.pins); + this.worldRoot.add(ringG); + + this.buildingSprites.set(building.id, { + root: c, img, pinsC, ringG, lastPins: -1, pulse: null, + }); + if (pop) { + c.setScale(0); + this.tweens.add({ targets: c, scale: 1, duration: 480, ease: 'Back.easeOut' }); + } + } + + syncBuilding(building) { + const rec = this.buildingSprites.get(building.id); + if (!rec) return; + + if (rec.lastPins !== building.pins) { + rec.lastPins = building.pins; + rec.pinsC.removeAll(true); + for (let i = 0; i < building.pins; i++) { + const row = Math.floor(i / 6); const col = i % 6; + const dot = this.add.image(-45 + col * 18, -86 - row * 18, 'mm-pin') + .setTint(COLOR_HEX[building.color]).setScale(i === building.pins - 1 ? 0 : 1); + rec.pinsC.add(dot); + if (i === building.pins - 1) { + this.tweens.add({ targets: dot, scale: 1, duration: 240, ease: 'Back.easeOut' }); + } + } + } + + rec.ringG.clear(); + if (building.ring > 0.001) { + const cx = xOf(building.k) * CELL + CELL; + const cy = yOf(building.k) * CELL + CELL; + rec.ringG.lineStyle(7, 0xe3504f, 0.28); + rec.ringG.strokeCircle(cx, cy, 84); + rec.ringG.lineStyle(7, 0xe3504f, 0.95); + rec.ringG.beginPath(); + rec.ringG.arc(cx, cy, 84, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, building.ring)); + rec.ringG.strokePath(); + } + + if (building.overflowing && !rec.pulse) { + rec.pulse = this.tweens.add({ + targets: rec.root, scale: 1.07, duration: 320, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + } else if (!building.overflowing && rec.pulse) { + rec.pulse.stop(); + rec.pulse = null; + rec.root.setScale(1); + } + } + + ensureCarSprite(car) { + let rec = this.carSprites.get(car.id); + if (!rec) { + const c = this.add.container(0, 0).setDepth(D.cars); + const img = this.add.image(0, 0, `mm-car-${car.color}`); + const pin = this.add.image(0, -12, 'mm-pin').setTint(COLOR_HEX[car.color]).setScale(0.8).setVisible(false); + c.add([img, pin]); + this.worldRoot.add(c); + rec = { c, img, pin, rot: 0 }; + this.carSprites.set(car.id, rec); + } + return rec; + } + + // ── HUD ─────────────────────────────────────────────────────────────────────── + + buildHud() { + const pal = this.city.palette; + + this.scoreText = this.add.text(44, 36, '0', { + fontFamily: 'Righteous', fontSize: '54px', color: COLORS.textDarkHex, + }).setOrigin(0, 0.5).setShadow(0, 2, '#ffffff', 6); + this.bestText = this.add.text(46, 82, '', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textDarkHex, + }).setOrigin(0, 0.5).setAlpha(0.65); + this.uiRoot.add([this.scoreText, this.bestText]); + + this.weekArcG = this.add.graphics(); + this.weekText = this.add.text(GAME_WIDTH / 2, 62, '1', { + fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textDarkHex, + }).setOrigin(0.5); + this.uiRoot.add([this.weekArcG, this.weekText]); + + // Below the music player controls in the top-right corner. + const cityLabel = this.add.text(GAME_WIDTH - 44, 122, this.city.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '34px', color: hexStr(darken(pal.accent, 0.8)), + }).setOrigin(1, 0.5); + this.uiRoot.add(cityLabel); + + // Inventory chips. + this.chips = {}; + const defs = [ + ['roads', 'Roads'], ['bridge', 'Bridge'], ['motorway', 'Motorway'], + ['light', 'Light'], ['roundabout', 'Rndabout'], ['eraser', 'Erase'], + ]; + const CHIP = 92; const GAPC = 18; + const total = defs.length * CHIP + (defs.length - 1) * GAPC; + let x = GAME_WIDTH / 2 - total / 2 + CHIP / 2; + const y = GAME_HEIGHT - 78; + for (const [key, label] of defs) { + this.chips[key] = this.makeChip(x, y, key, label); + x += CHIP + GAPC; + } + + this.hintText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 16, + 'Drag to draw roads · Right-drag to erase · Esc cancels a tool', { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textDarkHex, + }).setOrigin(0.5).setAlpha(0.55); + this.uiRoot.add(this.hintText); + + const quit = new Button(this, GAME_WIDTH - 110, GAME_HEIGHT - 56, 'Cities', + () => this.scene.restart({ game: this.gameDef }), + { variant: 'ghost', width: 160, height: 50, fontSize: 20 }); + this.uiRoot.add(quit); + + this.syncHud(); + } + + makeChip(x, y, key, label) { + const c = this.add.container(x, y); + const bg = this.add.graphics(); + const icon = this.add.graphics(); + this.drawChipIcon(icon, key); + const count = this.add.text(30, 28, '', { + fontFamily: 'Righteous', fontSize: '22px', color: '#ffffff', + }).setOrigin(0.5); + const lbl = this.add.text(0, 56, label, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textDarkHex, + }).setOrigin(0.5).setAlpha(0.8); + c.add([bg, icon, count, lbl]); + this.uiRoot.add(c); + + const rec = { c, bg, icon, count, key, active: false, enabled: true }; + this.paintChip(rec); + + c.setSize(92, 92); + c.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(0, 0, 92, 92), hitAreaCallback: Phaser.Geom.Rectangle.Contains }); + c.on('pointerover', () => { rec.hover = true; this.paintChip(rec); }); + c.on('pointerout', () => { rec.hover = false; this.paintChip(rec); }); + c.on('pointerdown', () => this.onChip(key)); + return rec; + } + + drawChipIcon(g, key) { + g.clear(); + const PAVE = 0xf5f2ea; + if (key === 'roads') { + g.lineStyle(10, PAVE, 1); + g.lineBetween(-26, 10, -2, 10); + g.lineBetween(-2, 10, 18, -10); + g.fillStyle(PAVE, 1); + g.fillCircle(-26, 10, 5); g.fillCircle(-2, 10, 5); g.fillCircle(18, -10, 5); + } else if (key === 'bridge') { + g.lineStyle(5, 0x9fd2e8, 1); + g.lineBetween(-28, 14, -14, 14); g.lineBetween(-6, 14, 8, 14); g.lineBetween(16, 14, 28, 14); + g.lineStyle(9, PAVE, 1); + g.beginPath(); g.arc(0, 26, 34, Math.PI * 1.22, Math.PI * 1.78); g.strokePath(); + } else if (key === 'motorway') { + g.lineStyle(12, 0x4d4d59, 1); + g.lineBetween(-26, 12, 26, -12); + g.lineStyle(3, 0xf4e26b, 1); + g.lineBetween(-18, 8, -6, 2); g.lineBetween(2, -2, 14, -8); + } else if (key === 'light') { + g.fillStyle(0x2f2f38, 1); + g.fillRoundedRect(-10, -22, 20, 38, 6); + g.fillStyle(0x57d977, 1); g.fillCircle(0, -12, 6); + g.fillStyle(0xe35050, 1); g.fillCircle(0, 6, 6); + } else if (key === 'roundabout') { + g.lineStyle(10, PAVE, 1); + g.strokeCircle(0, -2, 16); + } else if (key === 'eraser') { + g.fillStyle(0xe8a0a0, 1); + g.fillRoundedRect(-16, -16, 30, 22, 6); + g.fillStyle(0xc97e7e, 1); + g.fillRoundedRect(-16, -2, 30, 8, { tl: 0, tr: 0, bl: 6, br: 6 }); + } + } + + paintChip(rec) { + const { bg } = rec; + bg.clear(); + const active = rec.active; + const base = active ? this.city.palette.accent : 0xffffff; + bg.fillStyle(base, active ? 0.95 : 0.82); + bg.fillRoundedRect(-46, -46, 92, 92, 18); + bg.lineStyle(3, rec.hover || active ? darken(this.city.palette.accent, 0.85) : 0x999188, active ? 1 : 0.6); + bg.strokeRoundedRect(-46, -46, 92, 92, 18); + rec.c.setAlpha(rec.enabled ? 1 : 0.38); + // Count badge backdrop. + bg.fillStyle(0x33302c, 0.92); + bg.fillCircle(30, 28, 15); + } + + onChip(key) { + if (this.overlayUp || !this.sim || this.sim.gameOver) return; + if (key === 'roads') return; + if (key === 'eraser') { + this.setTool(this.tool === 'eraser' ? null : 'eraser'); + return; + } + const stock = { + bridge: this.sim.stock.bridges, motorway: this.sim.stock.motorways, + light: this.sim.stock.lights, roundabout: this.sim.stock.roundabouts, + }[key]; + if (stock < 1) return; + if (this.tool && this.tool.type === key) { this.setTool(null); return; } + this.setTool(key === 'motorway' ? { type: 'motorway', stage: 0, a: null } : { type: key }); + playSound(this, SFX.PIECE_CLICK); + } + + setTool(tool) { + this.tool = tool; + this.ghostG.clear(); + for (const rec of Object.values(this.chips)) { + rec.active = (tool === 'eraser' && rec.key === 'eraser') + || (tool && tool.type === rec.key); + this.paintChip(rec); + } + } + + syncHud() { + const sim = this.sim; + if (!sim) return; + this.scoreText.setText(String(sim.score)); + const best = Number(localStorage.getItem(BEST_KEY(this.cityIndex)) ?? 0); + this.bestText.setText(best > 0 ? `BEST ${Math.max(best, sim.score)}` : ''); + this.weekText.setText(String(sim.week + 1)); + + const g = this.weekArcG; + g.clear(); + const cx = GAME_WIDTH / 2; const cy = 62; + g.lineStyle(7, 0x000000, 0.12); + g.strokeCircle(cx, cy, 34); + g.lineStyle(7, darken(this.city.palette.accent, 0.85), 0.95); + g.beginPath(); + g.arc(cx, cy, 34, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * (sim.weekT / TUNE.WEEK_MS)); + g.strokePath(); + + const counts = { + roads: sim.stock.roads, bridge: sim.stock.bridges, motorway: sim.stock.motorways, + light: sim.stock.lights, roundabout: sim.stock.roundabouts, eraser: null, + }; + for (const [key, rec] of Object.entries(this.chips)) { + const n = counts[key]; + rec.count.setText(n === null ? '' : String(n)); + const enabled = key === 'eraser' || key === 'roads' ? true : n > 0; + if (enabled !== rec.enabled) { rec.enabled = enabled; this.paintChip(rec); } + } + } + + // ── Input ───────────────────────────────────────────────────────────────────── + + wireInput() { + this.input.keyboard.on('keydown-ESC', () => this.setTool(null)); + + this.input.on('pointerdown', (pointer, over) => { + if (this.view !== 'play' || this.overlayUp || !this.sim || this.sim.paused || this.sim.gameOver) return; + if (over && over.length) return; // a HUD element took it + const cell = this.worldCell(pointer); + if (cell === null) return; + + if (pointer.rightButtonDown()) { + if (this.tool) { this.setTool(null); return; } + this.dragMode = 'erase'; + this.dragCell = cell; + this.eraseAt(cell); + return; + } + + if (this.tool === 'eraser') { + this.dragMode = 'erase'; + this.dragCell = cell; + this.eraseAt(cell); + return; + } + if (this.tool) { this.useToolAt(cell); return; } + + this.dragMode = 'draw'; + this.dragCell = cell; + this.tryPaint(cell); + }); + + this.input.on('pointermove', (pointer) => { + if (this.view !== 'play' || !this.sim) return; + const cell = this.worldCell(pointer); + if (this.dragMode && pointer.isDown && cell !== null) { + this.stepDragTo(cell); + } + this.updateGhost(cell); + }); + + const endDrag = () => { this.dragMode = null; this.dragCell = null; }; + this.input.on('pointerup', endDrag); + this.input.on('pointerupoutside', endDrag); + + this.input.keyboard.on('keydown-ONE', () => this.pickUpgradeKey(0)); + this.input.keyboard.on('keydown-TWO', () => this.pickUpgradeKey(1)); + } + + worldCell(pointer) { + const p = pointer.positionToCamera(this.cameras.main); + const x = Math.floor(p.x / CELL); const y = Math.floor(p.y / CELL); + if (x < 0 || x >= WORLD_W || y < 0 || y >= WORLD_H) return null; + return keyOf(x, y); + } + + stepDragTo(target) { + let guard = 0; + while (this.dragCell !== target && guard++ < 80) { + const cx = xOf(this.dragCell); const cy = yOf(this.dragCell); + const dx = Math.sign(xOf(target) - cx); + const dy = Math.sign(yOf(target) - cy); + const next = keyOf(cx + dx, cy + dy); + this.dragCell = next; + if (this.dragMode === 'draw') this.tryPaint(next); + else this.eraseAt(next); + } + } + + tryPaint(cell) { + if (this.sim.canPlaceRoad(cell)) { + this.sim.placeRoad(cell); + if (this.time.now - this.lastPaintSound > 60) { + this.lastPaintSound = this.time.now; + playSound(this, SFX.PIECE_CLICK); + } + } + } + + eraseAt(cell) { + if (this.sim.eraseRoad(cell)) { + if (this.time.now - this.lastPaintSound > 60) { + this.lastPaintSound = this.time.now; + playSound(this, SFX.CARD_PLACE); + } + } + } + + useToolAt(cell) { + const sim = this.sim; + const t = this.tool; + if (t.type === 'bridge') { + const span = this.bridgeSpanAt(cell); + if (span && sim.placeBridge(span)) { + playSound(this, SFX.CARD_PLACE); + this.setTool(null); + } + } else if (t.type === 'motorway') { + if (t.stage === 0) { + if (sim.canPlacePortal(cell)) { + t.a = cell; t.stage = 1; + playSound(this, SFX.PIECE_CLICK); + } + } else if (sim.canPlaceMotorway(t.a, cell)) { + sim.placeMotorway(t.a, cell); + playSound(this, SFX.CARD_PLACE); + this.setTool(null); + } + } else if (t.type === 'light' || t.type === 'roundabout') { + if (sim.placeItem(cell, t.type)) { + playSound(this, SFX.CARD_PLACE); + this.setTool(null); + } + } + } + + bridgeSpanAt(cell) { + const sim = this.sim; + if (sim.terrain[cell] !== TERRAIN.WATER) return null; + const tryAxis = (step) => { + const run = [cell]; + for (let k = cell - step; sim.terrain[k] === TERRAIN.WATER && run.length < 5; k -= step) run.unshift(k); + for (let k = cell + step; sim.terrain[k] === TERRAIN.WATER && run.length < 5; k += step) run.push(k); + return sim.canPlaceBridge(run) ? run : null; + }; + const h = tryAxis(1); + const v = tryAxis(WORLD_W); + if (h && v) return h.length <= v.length ? h : v; + return h ?? v; + } + + updateGhost(cell) { + const g = this.ghostG; + g.clear(); + if (this.view !== 'play' || this.overlayUp || cell === null || !this.sim + || this.sim.paused || this.sim.gameOver) return; + const sim = this.sim; + const x = xOf(cell) * CELL; const y = yOf(cell) * CELL; + const t = this.tool; + + if (!t) { + const ok = sim.canPlaceRoad(cell) || sim.roads.has(cell); + g.fillStyle(ok ? 0xffffff : 0xe35050, ok ? 0.22 : 0.2); + g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12); + return; + } + if (t === 'eraser') { + g.fillStyle(0xe35050, sim.roads.has(cell) ? 0.34 : 0.15); + g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12); + return; + } + if (t.type === 'bridge') { + const span = this.bridgeSpanAt(cell); + if (span) { + g.fillStyle(0x57d977, 0.4); + for (const k of span) g.fillRoundedRect(xOf(k) * CELL + 4, yOf(k) * CELL + 4, CELL - 8, CELL - 8, 12); + } else { + g.fillStyle(0xe35050, 0.3); + g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12); + } + return; + } + if (t.type === 'motorway') { + const valid = t.stage === 0 ? sim.canPlacePortal(cell) : sim.canPlaceMotorway(t.a, cell); + if (t.stage === 1) { + g.lineStyle(CELL * 0.4, valid ? 0x4d4d59 : 0xe35050, 0.55); + g.lineBetween(cellCx(t.a), cellCy(t.a), cellCx(cell), cellCy(cell)); + g.fillStyle(0x4d4d59, 0.85); + g.fillRoundedRect(xOf(t.a) * CELL + 6, yOf(t.a) * CELL + 6, CELL - 12, CELL - 12, 10); + } + g.fillStyle(valid ? 0x57d977 : 0xe35050, 0.4); + g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12); + return; + } + // light / roundabout: highlight all eligible junctions, plus the hover cell. + g.fillStyle(0xffffff, 0.18); + for (const k of sim.roads) { + if (!sim.items.has(k) && !sim.portals.has(k) && sim.connCount(k) >= 3) { + g.fillCircle(cellCx(k), cellCy(k), CELL * 0.2); + } + } + const ok = sim.canPlaceItem(cell, t.type); + g.fillStyle(ok ? 0x57d977 : 0xe35050, 0.4); + g.fillRoundedRect(x + 4, y + 4, CELL - 8, CELL - 8, 12); + } + + // ── Sim tick / events ───────────────────────────────────────────────────────── + + simTick() { + if (this.view !== 'play' || !this.sim || this.sim.gameOver) return; + + for (const [id, rec] of this.carRender) { + rec.px = rec.cx; rec.py = rec.cy; + } + const events = this.sim.step(100); + this.lastTickAt = this.time.now; + + for (const car of this.sim.cars) { + let rec = this.carRender.get(car.id); + const wx = (car.x + 0.5) * CELL; const wy = (car.y + 0.5) * CELL; + if (!rec) { + rec = { px: wx, py: wy, cx: wx, cy: wy }; + this.carRender.set(car.id, rec); + } + rec.cx = wx; rec.cy = wy; + } + + for (const e of events) this.handleEvent(e); + + for (const b of this.sim.buildings) this.syncBuilding(b); + this.redrawItems(); + this.syncHud(); + } + + handleEvent(e) { + switch (e.type) { + case 'houseSpawn': + this.ensureHouseSprite(this.sim.houseById(e.id)); + break; + case 'buildingSpawn': + this.ensureBuildingSprite(this.sim.buildingById(e.id)); + break; + case 'delivered': { + playSound(this, SFX.COINS); + this.confetti((e.x + 0.5) * CELL, (e.y + 0.5) * CELL, COLOR_HEX[e.color]); + this.tweens.add({ targets: this.scoreText, scale: 1.18, duration: 110, yoyo: true }); + break; + } + case 'overflowStart': + playSound(this, SFX.SCIFI_RISER); + break; + case 'growth': { + this.drawTerrainRT(); + this.tweens.add({ + targets: this.cameras.main, zoom: this.fitZoom(e.rect), + duration: 1800, ease: 'Sine.easeInOut', + }); + break; + } + case 'weekEnd': + this.showWeekModal(e.week, e.choices); + break; + case 'gameOver': + this.onGameOver(e); + break; + default: + break; + } + } + + confetti(x, y, tint) { + for (let i = 0; i < 8; i++) { + const p = this.add.image(x, y, 'mm-dot') + .setTint(i % 3 === 0 ? 0xffffff : tint) + .setDepth(D.fx) + .setScale(0.6 + Math.random() * 0.8) + .setAngle(Math.random() * 90); + this.worldRoot.add(p); + this.tweens.add({ + targets: p, + x: x + (Math.random() - 0.5) * 110, + y: y - 30 - Math.random() * 30 + Math.random() * 110, + angle: p.angle + (Math.random() - 0.5) * 260, + alpha: 0, + duration: 520 + Math.random() * 240, + ease: 'Quad.easeIn', + onComplete: () => p.destroy(), + }); + } + } + + // ── Frame update ────────────────────────────────────────────────────────────── + + update(time) { + if (this.view !== 'play' || !this.sim) return; + const sim = this.sim; + + if (sim.netVersion !== this.drawnNetVersion) this.redrawRoads(); + + // Cars: lerp between the last two sim positions. + const alpha = Math.max(0, Math.min(1, (time - this.lastTickAt) / 100)); + const active = new Set(); + for (const car of sim.cars) { + const moving = car.state === 'toPickup' || car.state === 'toHome' || car.state === 'dwell'; + if (!moving) continue; + active.add(car.id); + const rec = this.ensureCarSprite(car); + const rr = this.carRender.get(car.id); + if (!rr) continue; + const x = rr.px + (rr.cx - rr.px) * alpha; + const y = rr.py + (rr.cy - rr.py) * alpha + Math.sin(time * 0.011 + car.id) * 0.8; + rec.c.setPosition(x, y); + rec.c.setVisible(true); + rec.c.setDepth(car.mw ? D.motorway + 0.5 : D.cars); + let target = car.heading; + let delta = target - rec.rot; + while (delta > Math.PI) delta -= Math.PI * 2; + while (delta < -Math.PI) delta += Math.PI * 2; + rec.rot += delta * 0.22; + rec.c.setRotation(rec.rot); + rec.pin.setVisible(car.state === 'toHome'); + rec.pin.setRotation(-rec.rot); + } + for (const [id, rec] of this.carSprites) { + if (!active.has(id)) rec.c.setVisible(false); + } + + this.updateNight(); + this.frameN = (this.frameN ?? 0) + 1; + if (this.frameN % 3 === 0) this.updateShimmer(time); + } + + updateNight() { + const sim = this.sim; + const p = sim.weekT / TUNE.WEEK_MS; + const keys = [ + [0.0, 0xffffff], [0.45, 0xfff6e6], [0.62, 0xffd9b0], [0.72, 0xc9b4d8], + [0.80, this.city.palette.night], [0.90, this.city.palette.night], + [0.96, 0xffe9d4], [1.0, 0xffffff], + ]; + let c = 0xffffff; + for (let i = 0; i < keys.length - 1; i++) { + if (p >= keys[i][0] && p <= keys[i + 1][0]) { + const t = (p - keys[i][0]) / (keys[i + 1][0] - keys[i][0]); + c = mixColor(keys[i][1], keys[i + 1][1], t); + break; + } + } + this.nightRect.fillColor = c; + } + + updateShimmer(time) { + const g = this.shimmerG; + g.clear(); + const pal = this.city.palette; + for (let i = 0; i < this.shimmerCells.length; i++) { + const k = this.shimmerCells[i]; + const ph = time * 0.0014 + i * 1.71; + const a = 0.05 + 0.05 * Math.sin(ph); + g.lineStyle(4, mixColor(pal.water, 0xffffff, 0.55), a); + const cx = cellCx(k) + Math.sin(ph * 0.7) * 8; + const cy = cellCy(k) + Math.cos(ph * 0.4) * 6; + g.lineBetween(cx - 12, cy, cx + 12, cy); + } + } + + // ── Weekly upgrade modal ────────────────────────────────────────────────────── + + showWeekModal(week, choices) { + this.overlayUp = true; + this.weekChoices = choices; + this.setTool(null); + playSound(this, SFX.CARD_SHOW); + + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; + const root = this.add.container(0, 0); + this.uiRoot.add(root); + this.weekModal = root; + + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setInteractive(); + root.add(dim); + + const panel = this.add.graphics(); + panel.fillStyle(COLORS.panel, 0.97); + panel.fillRoundedRect(cx - 470, cy - 270, 940, 540, 24); + panel.lineStyle(3, this.city.palette.accent, 1); + panel.strokeRoundedRect(cx - 470, cy - 270, 940, 540, 24); + root.add(panel); + + root.add(this.add.text(cx, cy - 212, `WEEK ${week} COMPLETE`, { + fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 158, `+${TUNE.WEEK_ROADS} road tiles delivered. Choose one bonus:`, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + + choices.forEach((key, i) => { + const x = cx + (i === 0 ? -200 : 200); + const y = cy + 40; + const card = this.add.rectangle(x, y, 340, 320, 0x262017) + .setStrokeStyle(3, this.city.palette.accent, 0.7); + const icon = this.add.graphics({ x, y: y - 80 }); + this.drawChipIcon(icon, key); + icon.setScale(1.7); + const name = this.add.text(x, y + 10, UPGRADE_DEFS[key].name, { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex, + }).setOrigin(0.5); + const desc = this.add.text(x, y + 78, UPGRADE_DEFS[key].desc, { + fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.mutedHex, align: 'center', + }).setOrigin(0.5); + const num = this.add.text(x - 150, y - 140, `${i + 1}`, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.mutedHex, + }).setOrigin(0.5); + root.add([card, icon, name, desc, num]); + + card.setInteractive({ useHandCursor: true }); + card.on('pointerover', () => card.setStrokeStyle(5, this.city.palette.accent, 1)); + card.on('pointerout', () => card.setStrokeStyle(3, this.city.palette.accent, 0.7)); + card.on('pointerup', () => this.pickUpgrade(i)); + }); + } + + pickUpgradeKey(i) { + if (this.weekModal && this.weekChoices && i < this.weekChoices.length) this.pickUpgrade(i); + } + + pickUpgrade(i) { + if (!this.weekModal) return; + playSound(this, SFX.PURCHASE); + this.sim.chooseUpgrade(i); + this.weekModal.destroy(); + this.weekModal = null; + this.weekChoices = null; + this.overlayUp = false; + this.lastTickAt = this.time.now; + this.syncHud(); + } + + // ── Game over ───────────────────────────────────────────────────────────────── + + onGameOver(e) { + this.setTool(null); + this.ghostG.clear(); + playSound(this, SFX.SCIFI_EXPLODE); + + // Linger on the culprit, then show the report card. + const rec = this.buildingSprites.get(e.buildingId); + if (rec) { + this.tweens.add({ targets: rec.root, scale: 1.25, duration: 300, yoyo: true, repeat: 2 }); + } + this.time.delayedCall(1100, () => this.showGameOver(e)); + } + + showGameOver(e) { + this.overlayUp = true; + const sim = this.sim; + const building = sim.buildingById(e.buildingId); + + const prevBest = Number(localStorage.getItem(BEST_KEY(this.cityIndex)) ?? 0); + const newBest = sim.score > prevBest; + if (newBest) localStorage.setItem(BEST_KEY(this.cityIndex), String(sim.score)); + + api.post('/history/single-player', { + slug: 'minimotorways', score: sim.score, opponentScores: [], result: 'win', + }).catch(() => { /* best effort */ }); + + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; + const root = this.add.container(0, 0); + this.uiRoot.add(root); + + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive(); + root.add(dim); + + const panel = this.add.graphics(); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 380, cy - 250, 760, 500, 22); + panel.lineStyle(3, COLORS.danger, 1); + panel.strokeRoundedRect(cx - 380, cy - 250, 760, 500, 22); + root.add(panel); + + root.add(this.add.text(cx, cy - 182, 'GRIDLOCK!', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex, + }).setOrigin(0.5)); + + const colorName = building ? building.color : 'a'; + root.add(this.add.text(cx, cy - 112, + `Your ${colorName} destination was overwhelmed in week ${sim.week + 1}.`, { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + + const scoreText = this.add.text(cx, cy - 18, '0', { + fontFamily: 'Righteous', fontSize: '96px', color: COLORS.goldHex, + }).setOrigin(0.5); + root.add(scoreText); + const counter = { v: 0 }; + this.tweens.add({ + targets: counter, v: sim.score, duration: 1000, ease: 'Cubic.easeOut', + onUpdate: () => scoreText.setText(String(Math.round(counter.v))), + }); + root.add(this.add.text(cx, cy + 52, 'TRIPS COMPLETED', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + + root.add(this.add.text(cx, cy + 102, + newBest ? '★ NEW BEST FOR ' + this.city.name.toUpperCase() + ' ★' + : (prevBest > 0 ? `Best for ${this.city.name}: ${prevBest}` : ''), { + fontFamily: 'Righteous', fontSize: '26px', + color: newBest ? hexStr(this.city.palette.accent) : COLORS.mutedHex, + }).setOrigin(0.5)); + + const again = new Button(this, cx - 170, cy + 180, 'Play Again', + () => this.scene.restart({ game: this.gameDef, autoCity: this.cityIndex }), + { width: 280, height: 62, fontSize: 26 }); + const cities = new Button(this, cx + 170, cy + 180, 'Cities', + () => this.scene.restart({ game: this.gameDef }), + { width: 280, height: 62, fontSize: 26, variant: 'ghost' }); + root.add([again, cities]); + } +} diff --git a/public/src/games/minimotorways/MiniMotorwaysLogic.js b/public/src/games/minimotorways/MiniMotorwaysLogic.js new file mode 100644 index 0000000..a8fdeb0 --- /dev/null +++ b/public/src/games/minimotorways/MiniMotorwaysLogic.js @@ -0,0 +1,1196 @@ +// Mini Motorways — pure simulation logic. No Phaser imports; runs in Node for +// headless verification (server/scripts/verifyMiniMotorways.js) and in the +// browser scene. All distances are in grid cells, all times in milliseconds. + +export const WORLD_W = 40; +export const WORLD_H = 24; + +export const TERRAIN = { LAND: 0, WATER: 1, TREE: 2 }; + +export const COLOR_NAMES = ['red', 'blue', 'yellow', 'green', 'purple', 'orange']; +export const COLOR_HEX = { + red: 0xe4574c, blue: 0x4a90d9, yellow: 0xf0b429, + green: 0x55b86a, purple: 0x9b6dd6, orange: 0xee8a3c, +}; + +export const TUNE = { + WEEK_MS: 55000, + SUBSTEP_MS: 50, + CAR_CAP: 60, + HOUSE_CAP: 30, + BUILDING_CAP: 12, + CAR_SPEED: 3.0, // cells / second + HEADWAY: 0.65, // minimum gap behind the car ahead, in cells + DWELL_MS: 1000, + COOLDOWN_MS: 2000, + PIN_MS_BASE: 12500, // pin interval = max(MIN, BASE * DECAY^week) + PIN_MS_DECAY: 0.96, + PIN_MS_MIN: 3500, + PIN_GRACE_MS: 15000, // new buildings wait this long before pin #1 + PIN_CAP: 12, + OVERFLOW_PINS: 8, + OVERFLOW_MS: 35000, // full ring → game over + OVERFLOW_DRAIN: 2, // ring drains at 2x fill rate while pins < 8 + DELIVERY_RELIEF: 0.06, // each delivery knocks the ring down a touch + HOUSE_MS_BASE: 9000, + HOUSE_MS_DECAY: 0.95, + HOUSE_MS_MIN: 5000, + BUILDING_MS_BASE: 85000, + BUILDING_MS_DECAY: 0.93, + BUILDING_MS_MIN: 50000, + START_ROADS: 30, + WEEK_ROADS: 12, + UPGRADE_ROADS: 10, + MOTORWAY_COST: 2.5, // A* cost of the portal edge + MOTORWAY_MS: 1200, // real traversal time, ignores all traffic + MOTORWAY_MIN_DIST: 4, // portals must be at least this far apart + CONGESTION_COST: 0.4, // A* edge penalty per car on the target cell + LIGHT_PHASE_MS: 4000, + ROUNDABOUT_CAP: 2, + DISPATCH_MS: 500, + SECOND_CAR_WEEK: 2, // houses gain a second car from this week on + COLOR_UNLOCK_WEEKS: [0, 0, 2, 4, 7, 10], + GROWTH: [ + { week: 0, w: 20, h: 12 }, + { week: 3, w: 26, h: 16 }, + { week: 6, w: 32, h: 20 }, + { week: 9, w: 40, h: 24 }, + ], +}; + +// Six cities: palette + terrain generator parameters + personality. +export const CITIES = [ + { + name: 'Marlow', + blurb: 'A gentle market town split by one slow river.', + palette: { land: 0xf4efe6, landAlt: 0xeae3d4, water: 0x8fcde4, accent: 0xf0b429, night: 0x8e9cc8, road: 0xffffff, roadEdge: 0xd8d0c0 }, + gen: { rivers: 1, riverWidth: 1, lakes: 0, lakeSize: 0, treeDensity: 0.02 }, + colorOrder: ['red', 'blue', 'yellow', 'green', 'purple', 'orange'], + upgradeWeights: { bridge: 1, motorway: 1, light: 1, roundabout: 1, roads: 1.5 }, + }, + { + name: 'Sandpoint', + blurb: 'Lake country — build around the water, not through it.', + palette: { land: 0xf2e8d5, landAlt: 0xe8dcc2, water: 0x5fb8b0, accent: 0xee8a3c, night: 0x96a0c4, road: 0xfffdf7, roadEdge: 0xd9cdb2 }, + gen: { rivers: 0, riverWidth: 1, lakes: 3, lakeSize: 13, treeDensity: 0.015 }, + colorOrder: ['orange', 'blue', 'green', 'red', 'yellow', 'purple'], + upgradeWeights: { bridge: 1.6, motorway: 1.2, light: 1, roundabout: 1, roads: 1.4 }, + }, + { + name: 'Twin Forks', + blurb: 'Two rivers braid through town. Bring bridges.', + palette: { land: 0xe9eef2, landAlt: 0xdde4ea, water: 0x7fb5d9, accent: 0x5a8fd6, night: 0x8893be, road: 0xffffff, roadEdge: 0xc9d2dc }, + gen: { rivers: 2, riverWidth: 1, lakes: 0, lakeSize: 0, treeDensity: 0.02 }, + colorOrder: ['blue', 'yellow', 'red', 'purple', 'orange', 'green'], + upgradeWeights: { bridge: 3, motorway: 1, light: 1, roundabout: 1, roads: 1.3 }, + }, + { + name: 'Cedar Falls', + blurb: 'Forest roads wind between the pines.', + palette: { land: 0xe8f0dd, landAlt: 0xdbe7cb, water: 0x74c4cf, accent: 0x4f9e58, night: 0x84a08e, road: 0xfdfff8, roadEdge: 0xc6d4b4 }, + gen: { rivers: 1, riverWidth: 1, lakes: 1, lakeSize: 8, treeDensity: 0.06 }, + colorOrder: ['green', 'red', 'purple', 'blue', 'orange', 'yellow'], + upgradeWeights: { bridge: 1.4, motorway: 1, light: 1, roundabout: 1.4, roads: 1.5 }, + }, + { + name: 'Saltmere', + blurb: 'A wide cold estuary cuts the city in half.', + palette: { land: 0xe8e8ec, landAlt: 0xdcdce2, water: 0x6f9fc8, accent: 0x8a77c9, night: 0x7d88b4, road: 0xffffff, roadEdge: 0xc8c8d2 }, + gen: { rivers: 1, riverWidth: 3, lakes: 0, lakeSize: 0, treeDensity: 0.01 }, + colorOrder: ['purple', 'yellow', 'blue', 'orange', 'green', 'red'], + upgradeWeights: { bridge: 2.5, motorway: 1.2, light: 1, roundabout: 1, roads: 1.3 }, + }, + { + name: 'Solano', + blurb: 'Dry, sprawling, and fast. Motorway territory.', + palette: { land: 0xf6e7d3, landAlt: 0xeedbc0, water: 0x66b2c4, accent: 0xd95f43, night: 0xa78f9e, road: 0xfffaf2, roadEdge: 0xe0cdaf }, + gen: { rivers: 0, riverWidth: 1, lakes: 1, lakeSize: 6, treeDensity: 0.025 }, + colorOrder: ['red', 'orange', 'purple', 'green', 'blue', 'yellow'], + upgradeWeights: { bridge: 0.6, motorway: 2.5, light: 1.2, roundabout: 1, roads: 1.4 }, + }, +]; + +export function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export const keyOf = (x, y) => y * WORLD_W + x; +export const xOf = (k) => k % WORLD_W; +export const yOf = (k) => Math.floor(k / WORLD_W); +const inBounds = (x, y) => x >= 0 && x < WORLD_W && y >= 0 && y < WORLD_H; + +const SQRT2 = Math.SQRT2; +const DIRS = [ + [1, 0], [-1, 0], [0, 1], [0, -1], + [1, 1], [1, -1], [-1, 1], [-1, -1], +]; + +export function octile(ax, ay, bx, by) { + const dx = Math.abs(ax - bx); + const dy = Math.abs(ay - by); + return Math.max(dx, dy) + (SQRT2 - 1) * Math.min(dx, dy); +} + +function centeredRect(w, h) { + const x0 = Math.floor((WORLD_W - w) / 2); + const y0 = Math.floor((WORLD_H - h) / 2); + return { x0, y0, x1: x0 + w - 1, y1: y0 + h - 1, w, h }; +} + +const START_RECT = centeredRect(TUNE.GROWTH[0].w, TUNE.GROWTH[0].h); + +// ── Terrain generation ───────────────────────────────────────────────────────── + +function carveRiver(terrain, rng, width) { + // Biased random walk from one edge to the opposite one. + const vertical = rng() < 0.5; + let x; let y; + if (vertical) { x = 6 + Math.floor(rng() * (WORLD_W - 12)); y = 0; } + else { x = 0; y = 4 + Math.floor(rng() * (WORLD_H - 8)); } + + while (inBounds(x, y)) { + for (let o = 0; o < width; o++) { + const wx = vertical ? x + o : x; + const wy = vertical ? y : y + o; + if (inBounds(wx, wy)) terrain[keyOf(wx, wy)] = TERRAIN.WATER; + } + if (vertical) { + y += 1; + if (rng() < 0.42) x += rng() < 0.5 ? -1 : 1; + x = Math.max(1, Math.min(WORLD_W - 1 - width, x)); + } else { + x += 1; + if (rng() < 0.42) y += rng() < 0.5 ? -1 : 1; + y = Math.max(1, Math.min(WORLD_H - 1 - width, y)); + } + } +} + +function carveLake(terrain, rng, size) { + const cx = 4 + Math.floor(rng() * (WORLD_W - 8)); + const cy = 3 + Math.floor(rng() * (WORLD_H - 6)); + const blob = [keyOf(cx, cy)]; + const inBlob = new Set(blob); + terrain[blob[0]] = TERRAIN.WATER; + while (blob.length < size) { + const from = blob[Math.floor(rng() * blob.length)]; + const [dx, dy] = DIRS[Math.floor(rng() * 4)]; + const nx = xOf(from) + dx; const ny = yOf(from) + dy; + if (!inBounds(nx, ny)) continue; + const k = keyOf(nx, ny); + if (inBlob.has(k)) continue; + inBlob.add(k); blob.push(k); + terrain[k] = TERRAIN.WATER; + } +} + +function waterRunLengths(terrain, x, y) { + let h = 1; let v = 1; + for (let i = x - 1; i >= 0 && terrain[keyOf(i, y)] === TERRAIN.WATER; i--) h++; + for (let i = x + 1; i < WORLD_W && terrain[keyOf(i, y)] === TERRAIN.WATER; i++) h++; + for (let j = y - 1; j >= 0 && terrain[keyOf(x, j)] === TERRAIN.WATER; j--) v++; + for (let j = y + 1; j < WORLD_H && terrain[keyOf(x, j)] === TERRAIN.WATER; j++) v++; + return { h, v }; +} + +function startRectOk(terrain) { + let water = 0; + for (let y = START_RECT.y0; y <= START_RECT.y1; y++) { + for (let x = START_RECT.x0; x <= START_RECT.x1; x++) { + if (terrain[keyOf(x, y)] !== TERRAIN.WATER) continue; + water++; + // Every water cell crossing the start zone must be bridgeable (≤3 wide) + // along at least one axis. + const { h, v } = waterRunLengths(terrain, x, y); + if (h > 3 && v > 3) return false; + } + } + return water <= START_RECT.w * START_RECT.h * 0.2; +} + +function forceFixStartRect(terrain) { + const cells = []; + for (let y = START_RECT.y0; y <= START_RECT.y1; y++) { + for (let x = START_RECT.x0; x <= START_RECT.x1; x++) { + if (terrain[keyOf(x, y)] === TERRAIN.WATER) cells.push([x, y]); + } + } + for (const [x, y] of cells) { + const { h, v } = waterRunLengths(terrain, x, y); + if (h > 3 && v > 3) terrain[keyOf(x, y)] = TERRAIN.LAND; + } + const cap = Math.floor(START_RECT.w * START_RECT.h * 0.2); + const cx = WORLD_W / 2; const cy = WORLD_H / 2; + let water = cells.filter(([x, y]) => terrain[keyOf(x, y)] === TERRAIN.WATER); + water.sort((a, b) => octile(a[0], a[1], cx, cy) - octile(b[0], b[1], cx, cy)); + while (water.length > cap) { + const [x, y] = water.shift(); + terrain[keyOf(x, y)] = TERRAIN.LAND; + } +} + +function genTerrainOnce(city, rng) { + const terrain = new Uint8Array(WORLD_W * WORLD_H); + for (let i = 0; i < city.gen.rivers; i++) carveRiver(terrain, rng, city.gen.riverWidth); + for (let i = 0; i < city.gen.lakes; i++) carveLake(terrain, rng, city.gen.lakeSize); + for (let k = 0; k < terrain.length; k++) { + if (terrain[k] === TERRAIN.LAND && rng() < city.gen.treeDensity) terrain[k] = TERRAIN.TREE; + } + // Keep a small clearing at the very centre so the first structures always fit. + for (let y = 10; y <= 13; y++) { + for (let x = 17; x <= 22; x++) { + if (terrain[keyOf(x, y)] === TERRAIN.TREE) terrain[keyOf(x, y)] = TERRAIN.LAND; + } + } + return terrain; +} + +export function generateCity(cityIndex, seed) { + const city = CITIES[cityIndex]; + for (let attempt = 0; attempt < 10; attempt++) { + const rng = mulberry32((seed + attempt * 1000003) >>> 0); + const terrain = genTerrainOnce(city, rng); + if (startRectOk(terrain)) return { terrain }; + } + const terrain = genTerrainOnce(city, mulberry32(seed >>> 0)); + forceFixStartRect(terrain); + return { terrain }; +} + +// ── Simulation ───────────────────────────────────────────────────────────────── + +export class Sim { + constructor(cityIndex, seed) { + this.cityIndex = cityIndex; + this.city = CITIES[cityIndex]; + this.rng = mulberry32(seed >>> 0); + this.terrain = generateCity(cityIndex, seed).terrain; + + this.roads = new Set(); + this.bridgeCells = new Set(); + this.bridges = []; // { id, cells: [k...] } + this.items = new Map(); // k -> { type: 'light' | 'roundabout' } + this.motorways = []; // { id, a, b } + this.portals = new Map(); // k -> twin portal k + + this.houses = []; // { id, color, k, carIds: [] } + this.buildings = []; // { id, color, k, cells, pins, reserved, ring, pinT, graceT, overflowing } + this.cars = []; + this.nextId = 1; + + this.stock = { roads: TUNE.START_ROADS, bridges: 0, motorways: 0, lights: 0, roundabouts: 0 }; + this.score = 0; + this.week = 0; + this.weekT = 0; + this.time = 0; + this.paused = false; + this.gameOver = false; + this.gameOverInfo = null; + this.upgradeChoices = null; + + this.netVersion = 0; + this.connCache = new Map(); + this.cellOcc = new Map(); // k -> count of active cars on the cell + this.dispatchT = 0; + this.events = []; + + this.growthIdx = 0; + this.activeRect = centeredRect(TUNE.GROWTH[0].w, TUNE.GROWTH[0].h); + this.colorsUnlocked = 0; + + // Cities with water start with one bridge in hand so an early river spawn + // can't be an unwinnable death sentence. + if (this.terrain.includes(TERRAIN.WATER)) this.stock.bridges = 1; + + this.startComponent = this.computeStartComponent(); + this.houseT = this.houseInterval(); + this.buildingT = this.buildingInterval(); + + // Week 0 opens with two colours, each seeded with a destination and homes. + this.unlockColors(2); + } + + // ── Small helpers ──────────────────────────────────────────────────────────── + + emit(type, data = {}) { this.events.push({ type, ...data }); } + + houseInterval() { return Math.max(TUNE.HOUSE_MS_MIN, TUNE.HOUSE_MS_BASE * TUNE.HOUSE_MS_DECAY ** this.week); } + buildingInterval() { return Math.max(TUNE.BUILDING_MS_MIN, TUNE.BUILDING_MS_BASE * TUNE.BUILDING_MS_DECAY ** this.week); } + pinInterval() { return Math.max(TUNE.PIN_MS_MIN, TUNE.PIN_MS_BASE * TUNE.PIN_MS_DECAY ** this.week); } + carsPerHouse() { return this.week >= TUNE.SECOND_CAR_WEEK ? 2 : 1; } + lightPhase() { return Math.floor(this.time / TUNE.LIGHT_PHASE_MS) % 2; } + + computeStartComponent() { + // Flood fill over non-water from the centre: early spawns stay on the + // starting landmass so the player is never forced to bridge in week 0. + const seen = new Set(); + let seed = null; + for (let r = 0; r < 10 && seed === null; r++) { + for (let dy = -r; dy <= r && seed === null; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = 20 + dx; const y = 12 + dy; + if (inBounds(x, y) && this.terrain[keyOf(x, y)] !== TERRAIN.WATER) { seed = keyOf(x, y); break; } + } + } + } + if (seed === null) return seen; + const stack = [seed]; + seen.add(seed); + while (stack.length) { + const k = stack.pop(); + const x = xOf(k); const y = yOf(k); + for (let d = 0; d < 4; d++) { + const nx = x + DIRS[d][0]; const ny = y + DIRS[d][1]; + if (!inBounds(nx, ny)) continue; + const nk = keyOf(nx, ny); + if (seen.has(nk) || this.terrain[nk] === TERRAIN.WATER) continue; + seen.add(nk); + stack.push(nk); + } + } + return seen; + } + + occupiedAt(k) { + for (const h of this.houses) if (h.k === k) return { type: 'house', ref: h }; + for (const b of this.buildings) if (b.cells.includes(k)) return { type: 'building', ref: b }; + return null; + } + + buildOccupiedSet() { + const s = new Set(); + for (const h of this.houses) s.add(h.k); + for (const b of this.buildings) for (const c of b.cells) s.add(c); + return s; + } + + // ── Road network ───────────────────────────────────────────────────────────── + + touchNetwork() { this.netVersion++; this.connCache.clear(); } + + roadNeighbors(k) { + const x = xOf(k); const y = yOf(k); + const out = []; + for (const [dx, dy] of DIRS) { + const nx = x + dx; const ny = y + dy; + if (!inBounds(nx, ny)) continue; + const nk = keyOf(nx, ny); + if (!this.roads.has(nk)) continue; + if (dx !== 0 && dy !== 0) { + // Crossing rule: if both shared corners carry road, the two diagonals + // of this 2x2 block would cross — and the corners already connect the + // cells orthogonally — so the diagonal edge is suppressed. + const cornerA = keyOf(x + dx, y); + const cornerB = keyOf(x, y + dy); + if (this.roads.has(cornerA) && this.roads.has(cornerB)) continue; + } + out.push({ k: nk, cost: dx !== 0 && dy !== 0 ? SQRT2 : 1 }); + } + const twin = this.portals.get(k); + if (twin !== undefined) out.push({ k: twin, cost: TUNE.MOTORWAY_COST, motorway: true }); + return out; + } + + connCount(k) { + let n = this.connCache.get(k); + if (n === undefined) { + n = this.roadNeighbors(k).length; + this.connCache.set(k, n); + } + return n; + } + + canPlaceRoad(k) { + return this.stock.roads > 0 + && this.terrain[k] === TERRAIN.LAND + && !this.roads.has(k) + && !this.occupiedAt(k); + } + + placeRoad(k) { + if (!this.canPlaceRoad(k)) return false; + this.stock.roads--; + this.roads.add(k); + this.touchNetwork(); + return true; + } + + eraseRoad(k) { + if (!this.roads.has(k)) return false; + if (this.portals.has(k)) return this.eraseMotorwayAt(k); + if (this.bridgeCells.has(k)) return this.eraseBridgeAt(k); + this.roads.delete(k); + const item = this.items.get(k); + if (item) { + this.items.delete(k); + if (item.type === 'light') this.stock.lights++; + else this.stock.roundabouts++; + } + this.stock.roads++; + this.touchNetwork(); + this.flagReroutes([k]); + return true; + } + + flagReroutes(removed) { + const gone = new Set(removed); + for (const car of this.cars) { + if (car.state !== 'toPickup' && car.state !== 'toHome') continue; + const from = Math.floor(car.pos); + for (let i = from; i < car.path.length; i++) { + if (gone.has(car.path[i])) { car.needsReroute = true; break; } + } + } + } + + // ── Bridges / motorways / intersection items ───────────────────────────────── + + // cells must be a straight orthogonal run of 1-3 water cells whose two + // extension cells (just beyond each end) are dry land. + canPlaceBridge(cells) { + if (this.stock.bridges < 1) return false; + if (!cells || cells.length < 1 || cells.length > 3) return false; + const xs = cells.map(xOf); const ys = cells.map(yOf); + const horiz = ys.every((y) => y === ys[0]); + const vert = xs.every((x) => x === xs[0]); + if (!horiz && !vert) return false; + const sorted = [...cells].sort((a, b) => a - b); + for (let i = 1; i < sorted.length; i++) { + const stepOk = horiz ? sorted[i] === sorted[i - 1] + 1 : sorted[i] === sorted[i - 1] + WORLD_W; + if (!stepOk) return false; + } + for (const k of cells) { + if (this.terrain[k] !== TERRAIN.WATER || this.roads.has(k)) return false; + } + const step = horiz ? 1 : WORLD_W; + const before = sorted[0] - step; + const after = sorted[sorted.length - 1] + step; + const dry = (k) => { + const x = xOf(k); const y = yOf(k); + return inBounds(x, y) && this.terrain[k] !== TERRAIN.WATER; + }; + // Guard against wrap-around on horizontal runs at the map edge. + if (horiz && (xOf(sorted[0]) === 0 || xOf(sorted[sorted.length - 1]) === WORLD_W - 1)) return false; + if (!horiz && (yOf(sorted[0]) === 0 || yOf(sorted[sorted.length - 1]) === WORLD_H - 1)) return false; + return dry(before) && dry(after); + } + + placeBridge(cells) { + if (!this.canPlaceBridge(cells)) return false; + this.stock.bridges--; + const bridge = { id: this.nextId++, cells: [...cells] }; + this.bridges.push(bridge); + for (const k of cells) { + this.roads.add(k); + this.bridgeCells.add(k); + } + this.touchNetwork(); + return true; + } + + eraseBridgeAt(k) { + const idx = this.bridges.findIndex((b) => b.cells.includes(k)); + if (idx < 0) return false; + const bridge = this.bridges[idx]; + this.bridges.splice(idx, 1); + for (const c of bridge.cells) { + this.roads.delete(c); + this.bridgeCells.delete(c); + } + this.stock.bridges++; + this.touchNetwork(); + this.flagReroutes(bridge.cells); + return true; + } + + // Motorway portals sit on previously-empty land next to existing road. + canPlacePortal(k) { + if (this.terrain[k] !== TERRAIN.LAND || this.roads.has(k) || this.occupiedAt(k)) return false; + const x = xOf(k); const y = yOf(k); + for (const [dx, dy] of DIRS) { + const nx = x + dx; const ny = y + dy; + if (inBounds(nx, ny) && this.roads.has(keyOf(nx, ny))) return true; + } + return false; + } + + canPlaceMotorway(a, b) { + return this.stock.motorways > 0 + && a !== b + && this.canPlacePortal(a) && this.canPlacePortal(b) + && octile(xOf(a), yOf(a), xOf(b), yOf(b)) >= TUNE.MOTORWAY_MIN_DIST; + } + + placeMotorway(a, b) { + if (!this.canPlaceMotorway(a, b)) return false; + this.stock.motorways--; + this.motorways.push({ id: this.nextId++, a, b }); + this.roads.add(a); this.roads.add(b); + this.portals.set(a, b); this.portals.set(b, a); + this.touchNetwork(); + return true; + } + + eraseMotorwayAt(k) { + const idx = this.motorways.findIndex((m) => m.a === k || m.b === k); + if (idx < 0) return false; + const m = this.motorways[idx]; + this.motorways.splice(idx, 1); + this.portals.delete(m.a); this.portals.delete(m.b); + this.roads.delete(m.a); this.roads.delete(m.b); + this.stock.motorways++; + this.touchNetwork(); + this.flagReroutes([m.a, m.b]); + return true; + } + + canPlaceItem(k, type) { + const stockOk = type === 'light' ? this.stock.lights > 0 : this.stock.roundabouts > 0; + return stockOk && this.roads.has(k) && !this.items.has(k) && !this.portals.has(k) + && this.connCount(k) >= 3; + } + + placeItem(k, type) { + if (!this.canPlaceItem(k, type)) return false; + if (type === 'light') this.stock.lights--; else this.stock.roundabouts--; + this.items.set(k, { type }); + return true; + } + + eraseItem(k) { + const item = this.items.get(k); + if (!item) return false; + this.items.delete(k); + if (item.type === 'light') this.stock.lights++; else this.stock.roundabouts++; + return true; + } + + // ── Pathfinding ────────────────────────────────────────────────────────────── + + roadCellsAround(cells) { + const out = []; + const seen = new Set(); + for (const k of Array.isArray(cells) ? cells : [cells]) { + const x = xOf(k); const y = yOf(k); + for (const [dx, dy] of DIRS) { + const nx = x + dx; const ny = y + dy; + if (!inBounds(nx, ny)) continue; + const nk = keyOf(nx, ny); + if (this.roads.has(nk) && !seen.has(nk)) { seen.add(nk); out.push(nk); } + } + } + return out; + } + + // Multi-source / multi-goal A* over road cells. Returns [k...] or null. + findPath(starts, goals) { + if (!starts.length || !goals.length) return null; + const goalSet = new Set(goals); + const goalPts = goals.map((g) => [xOf(g), yOf(g)]); + // Octile distance is inadmissible once motorway teleports exist (a portal + // can cover 30 cells for cost 2.5), so fall back to Dijkstra then. + const h = this.portals.size > 0 ? () => 0 : (k) => { + const x = xOf(k); const y = yOf(k); + let best = Infinity; + for (const [gx, gy] of goalPts) { + const d = octile(x, y, gx, gy); + if (d < best) best = d; + } + return best; + }; + + const gScore = new Map(); + const cameFrom = new Map(); + const open = []; // binary min-heap of [f, k] + const push = (f, k) => { + open.push([f, k]); + let i = open.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (open[p][0] <= open[i][0]) break; + [open[p], open[i]] = [open[i], open[p]]; i = p; + } + }; + const pop = () => { + const top = open[0]; + const last = open.pop(); + if (open.length) { + open[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1; const r = l + 1; + let m = i; + if (l < open.length && open[l][0] < open[m][0]) m = l; + if (r < open.length && open[r][0] < open[m][0]) m = r; + if (m === i) break; + [open[m], open[i]] = [open[i], open[m]]; i = m; + } + } + return top; + }; + + for (const s of starts) { + if (!this.roads.has(s)) continue; + gScore.set(s, 0); + push(h(s), s); + } + + while (open.length) { + const [, k] = pop(); + if (goalSet.has(k)) { + const path = [k]; + let cur = k; + while (cameFrom.has(cur)) { cur = cameFrom.get(cur); path.push(cur); } + return path.reverse(); + } + const gk = gScore.get(k); + for (const nb of this.roadNeighbors(k)) { + const occ = this.cellOcc.get(nb.k) || 0; + const tentative = gk + nb.cost + TUNE.CONGESTION_COST * occ; + if (tentative < (gScore.get(nb.k) ?? Infinity)) { + gScore.set(nb.k, tentative); + cameFrom.set(nb.k, k); + push(tentative + h(nb.k), nb.k); + } + } + } + return null; + } + + // Full trip path for a car leaving home: [houseCell, road..., roadByBuilding]. + findTripPath(house, building) { + const starts = this.roadCellsAround(house.k); + const goals = this.roadCellsAround(building.cells); + const path = this.findPath(starts, goals); + return path ? [house.k, ...path] : null; + } + + findHomePath(fromRoadCell, house) { + if (!this.roads.has(fromRoadCell)) return null; + const goals = this.roadCellsAround(house.k); + const path = this.findPath([fromRoadCell], goals); + return path ? [...path, house.k] : null; + } + + // ── Spawning ───────────────────────────────────────────────────────────────── + + randomFreeCell(needs2x2) { + const r = this.activeRect; + const occupied = this.buildOccupiedSet(); + const fits = (x, y) => { + if (!inBounds(x, y)) return false; + const k = keyOf(x, y); + if (this.terrain[k] !== TERRAIN.LAND || this.roads.has(k) || occupied.has(k)) return false; + if (this.week < 2 && !this.startComponent.has(k)) return false; + // One cell of clearance from other structures so driveways stay open. + for (const [dx, dy] of DIRS) { + const nx = x + dx; const ny = y + dy; + if (inBounds(nx, ny) && occupied.has(keyOf(nx, ny))) return false; + } + return true; + }; + for (let attempt = 0; attempt < 90; attempt++) { + const x = r.x0 + Math.floor(this.rng() * (r.w - (needs2x2 ? 1 : 0))); + const y = r.y0 + Math.floor(this.rng() * (r.h - (needs2x2 ? 1 : 0))); + if (needs2x2) { + if (fits(x, y) && fits(x + 1, y) && fits(x, y + 1) && fits(x + 1, y + 1)) return keyOf(x, y); + } else if (fits(x, y)) { + return keyOf(x, y); + } + } + return null; + } + + spawnHouse(color, force = false) { + if (!force && this.houses.length >= TUNE.HOUSE_CAP) return null; + const k = this.randomFreeCell(false); + if (k === null) return null; + const house = { id: this.nextId++, color, k, carIds: [] }; + this.houses.push(house); + this.emit('houseSpawn', { id: house.id, k, color }); + return house; + } + + spawnBuilding(color) { + if (this.buildings.length >= TUNE.BUILDING_CAP) return null; + const k = this.randomFreeCell(true); + if (k === null) return null; + const cells = [k, k + 1, k + WORLD_W, k + WORLD_W + 1]; + const building = { + id: this.nextId++, color, k, cells, + pins: 0, reserved: 0, ring: 0, overflowing: false, + pinT: this.pinInterval(), graceT: TUNE.PIN_GRACE_MS, + }; + this.buildings.push(building); + this.emit('buildingSpawn', { id: building.id, k, color }); + return building; + } + + unlockColors(target) { + while (this.colorsUnlocked < Math.min(target, this.city.colorOrder.length)) { + const color = this.city.colorOrder[this.colorsUnlocked]; + this.colorsUnlocked++; + this.spawnBuilding(color); + // Bypass the house cap: a fresh colour must never start supply-starved. + this.spawnHouse(color, true); + this.spawnHouse(color, true); + this.emit('colorUnlock', { color }); + } + } + + pickSpawnColor(forBuilding) { + const unlocked = this.city.colorOrder.slice(0, this.colorsUnlocked); + const stats = unlocked.map((color) => ({ + color, + houses: this.houses.filter((h) => h.color === color).length, + buildings: this.buildings.filter((b) => b.color === color).length, + })); + if (forBuilding) { + // Destinations follow supply: favour colours with spare houses per stop. + stats.sort((a, b) => (b.houses / (b.buildings + 1)) - (a.houses / (a.buildings + 1))); + } else { + // Houses go where demand is under-served. + stats.sort((a, b) => (a.houses / Math.max(1, a.buildings)) - (b.houses / Math.max(1, b.buildings))); + } + const top = stats.slice(0, 2); + return top[Math.floor(this.rng() * top.length)].color; + } + + // ── Weekly cycle ───────────────────────────────────────────────────────────── + + activeRectHasWater() { + const r = this.activeRect; + for (let y = r.y0; y <= r.y1; y++) { + for (let x = r.x0; x <= r.x1; x++) { + if (this.terrain[keyOf(x, y)] === TERRAIN.WATER) return true; + } + } + return false; + } + + pickUpgradeChoices() { + const weights = { ...this.city.upgradeWeights }; + if (!this.activeRectHasWater()) delete weights.bridge; + const choices = []; + for (let pick = 0; pick < 2; pick++) { + const entries = Object.entries(weights).filter(([key]) => !choices.includes(key)); + let total = 0; + for (const [, w] of entries) total += w; + let roll = this.rng() * total; + for (const [key, w] of entries) { + roll -= w; + if (roll <= 0) { choices.push(key); break; } + } + if (choices.length < pick + 1) choices.push(entries[entries.length - 1][0]); + } + return choices; + } + + chooseUpgrade(index) { + if (!this.upgradeChoices) return; + const pick = this.upgradeChoices[index] ?? this.upgradeChoices[0]; + if (pick === 'bridge') this.stock.bridges++; + else if (pick === 'motorway') this.stock.motorways++; + else if (pick === 'light') this.stock.lights++; + else if (pick === 'roundabout') this.stock.roundabouts++; + else this.stock.roads += TUNE.UPGRADE_ROADS; + this.upgradeChoices = null; + this.paused = false; + } + + rollWeek() { + this.week++; + this.stock.roads += TUNE.WEEK_ROADS; + + const growth = TUNE.GROWTH.findIndex((g) => g.week === this.week); + if (growth > this.growthIdx) { + this.growthIdx = growth; + this.activeRect = centeredRect(TUNE.GROWTH[growth].w, TUNE.GROWTH[growth].h); + this.emit('growth', { rect: { ...this.activeRect }, idx: growth }); + } + + const unlockTarget = TUNE.COLOR_UNLOCK_WEEKS.filter((w) => w <= this.week).length; + this.unlockColors(unlockTarget); + + this.upgradeChoices = this.pickUpgradeChoices(); + this.paused = true; + this.emit('weekEnd', { week: this.week, choices: [...this.upgradeChoices] }); + } + + // ── Cars ───────────────────────────────────────────────────────────────────── + + setCarOcc(car, k) { + if (car.occK === k) return; + if (car.occK !== null && car.occK !== undefined) { + const n = (this.cellOcc.get(car.occK) || 1) - 1; + if (n <= 0) this.cellOcc.delete(car.occK); else this.cellOcc.set(car.occK, n); + } + car.occK = k; + if (k !== null) this.cellOcc.set(k, (this.cellOcc.get(k) || 0) + 1); + } + + syncCarXY(car) { + if (car.mw) { + const a = car.path[car.mw.fromIdx]; const b = car.path[car.mw.fromIdx + 1]; + car.x = xOf(a) + (xOf(b) - xOf(a)) * car.mw.t; + car.y = yOf(a) + (yOf(b) - yOf(a)) * car.mw.t; + return; + } + const i = Math.min(Math.floor(car.pos), car.path.length - 1); + const j = Math.min(i + 1, car.path.length - 1); + const f = car.pos - i; + const a = car.path[i]; const b = car.path[j]; + car.x = xOf(a) + (xOf(b) - xOf(a)) * f; + car.y = yOf(a) + (yOf(b) - yOf(a)) * f; + if (a !== b) car.heading = Math.atan2(yOf(b) - yOf(a), xOf(b) - xOf(a)); + } + + createCar(house) { + const car = { + id: this.nextId++, color: house.color, houseId: house.id, + state: 'idle', path: null, pos: 0, x: xOf(house.k), y: yOf(house.k), + heading: 0, dwellT: 0, cooldownT: 0, targetId: null, + needsReroute: false, mw: null, occK: null, + }; + this.cars.push(car); + house.carIds.push(car.id); + return car; + } + + houseById(id) { return this.houses.find((h) => h.id === id); } + buildingById(id) { return this.buildings.find((b) => b.id === id); } + carById(id) { return this.cars.find((c) => c.id === id); } + + dispatch() { + const wanting = this.buildings + .filter((b) => b.pins - b.reserved > 0) + .sort((a, b) => (b.ring - a.ring) || (b.pins - a.pins)); + for (const building of wanting) { + const bx = xOf(building.k); const by = yOf(building.k); + const homes = this.houses + .filter((h) => h.color === building.color) + .sort((a, b) => octile(xOf(a.k), yOf(a.k), bx, by) - octile(xOf(b.k), yOf(b.k), bx, by)); + let assignments = 0; + let failures = 0; + for (const house of homes) { + if (assignments >= 3 || failures >= 8) break; + if (building.pins - building.reserved <= 0) break; + let car = house.carIds.map((id) => this.carById(id)).find((c) => c && c.state === 'idle'); + if (!car && house.carIds.length < this.carsPerHouse() && this.cars.length < TUNE.CAR_CAP) { + car = this.createCar(house); + } + if (!car) continue; + const path = this.findTripPath(house, building); + if (!path) { failures++; continue; } + car.state = 'toPickup'; + car.path = path; + car.pos = 0; + car.targetId = building.id; + car.needsReroute = false; + car.mw = null; + building.reserved++; + assignments++; + this.setCarOcc(car, path[0]); + this.syncCarXY(car); + } + } + } + + releaseReservation(car) { + if (car.targetId === null) return; + const b = this.buildingById(car.targetId); + if (b && b.reserved > 0) b.reserved--; + car.targetId = null; + } + + parkAtHome(car) { + const house = this.houseById(car.houseId); + car.state = 'cooldown'; + car.cooldownT = TUNE.COOLDOWN_MS; + car.path = null; + car.pos = 0; + car.mw = null; + car.needsReroute = false; + if (house) { car.x = xOf(house.k); car.y = yOf(house.k); } + this.setCarOcc(car, null); + } + + goHome(car) { + const house = this.houseById(car.houseId); + const here = car.path[car.path.length - 1]; + const path = house && this.roads.has(here) ? this.findHomePath(here, house) : null; + if (!path) { + this.emit('stranded', { carId: car.id, x: car.x, y: car.y }); + this.parkAtHome(car); + return; + } + car.state = 'toHome'; + car.path = path; + car.pos = 0; + car.mw = null; + car.needsReroute = false; + this.setCarOcc(car, path[0]); + this.syncCarXY(car); + } + + reroute(car) { + car.needsReroute = false; + const idx = Math.min(Math.round(car.pos), car.path.length - 1); + let anchor = car.path[idx]; + if (!this.roads.has(anchor)) { + const back = car.path.slice(0, idx).reverse().find((k) => this.roads.has(k)); + if (back === undefined) { + if (car.state === 'toPickup') this.releaseReservation(car); + this.emit('stranded', { carId: car.id, x: car.x, y: car.y }); + this.parkAtHome(car); + return; + } + anchor = back; + } + let path = null; + if (car.state === 'toPickup') { + const building = this.buildingById(car.targetId); + if (building) { + const goals = this.roadCellsAround(building.cells); + const tail = this.findPath([anchor], goals); + if (tail) path = tail; + } + if (!path) { + this.releaseReservation(car); + const house = this.houseById(car.houseId); + const home = house ? this.findHomePath(anchor, house) : null; + if (home) { car.state = 'toHome'; path = home; } + } + } else { + const house = this.houseById(car.houseId); + if (house) path = this.findHomePath(anchor, house); + } + if (!path) { + this.emit('stranded', { carId: car.id, x: car.x, y: car.y }); + this.parkAtHome(car); + return; + } + car.path = path; + car.pos = 0; + car.mw = null; + this.setCarOcc(car, path[0]); + this.syncCarXY(car); + } + + canEnterCell(car, k, fromK) { + if (this.connCount(k) < 3) return true; + const item = this.items.get(k); + const occ = (this.cellOcc.get(k) || 0); + if (item?.type === 'roundabout') return occ < TUNE.ROUNDABOUT_CAP; + if (item?.type === 'light') { + const dx = xOf(k) - xOf(fromK); + const dy = yOf(k) - yOf(fromK); + if (dx !== 0 && dy !== 0) return true; // diagonal approaches filter in + const axis = dy === 0 ? 0 : 1; + return axis === this.lightPhase(); + } + return occ === 0; + } + + headwayLimit(car, want) { + let allowed = want; + const hx = Math.cos(car.heading); const hy = Math.sin(car.heading); + for (const other of this.cars) { + if (other === car || other.mw) continue; + if (other.state !== 'toPickup' && other.state !== 'toHome' && other.state !== 'dwell') continue; + const ddx = other.x - car.x; const ddy = other.y - car.y; + const d2 = ddx * ddx + ddy * ddy; + if (d2 > 4) continue; + const front = ddx * hx + ddy * hy; + if (front <= 0.05) continue; + const lat = Math.abs(-ddx * hy + ddy * hx); + if (lat > 0.45) continue; + if (other.state !== 'dwell') { + // Opposite-direction cars pass through each other — this is what keeps + // a single road usable both ways without lane simulation. + const dot = hx * Math.cos(other.heading) + hy * Math.sin(other.heading); + if (dot < 0.3) continue; + } + allowed = Math.min(allowed, front - TUNE.HEADWAY); + } + return Math.max(0, allowed); + } + + moveCar(car, dtMs) { + if (car.mw) { + car.mw.t += dtMs / TUNE.MOTORWAY_MS; + if (car.mw.t >= 1) { + car.pos = car.mw.fromIdx + 1; + car.mw = null; + this.setCarOcc(car, car.path[Math.round(car.pos)]); + } + this.syncCarXY(car); + return; + } + + const want = TUNE.CAR_SPEED * (dtMs / 1000); + let allowed = this.headwayLimit(car, want); + if (allowed <= 0.0001) return; + + const curIdx = Math.round(car.pos); + const boundary = curIdx + 0.5; + let target = car.pos + allowed; + + if (car.pos < boundary && target >= boundary && curIdx + 1 < car.path.length) { + const fromK = car.path[curIdx]; + const nextK = car.path[curIdx + 1]; + const isPortalJump = this.portals.get(fromK) === nextK + && octile(xOf(fromK), yOf(fromK), xOf(nextK), yOf(nextK)) > SQRT2 + 0.01; + if (isPortalJump) { + // Snap to the portal mouth, then fly. + car.pos = curIdx; + car.mw = { fromIdx: curIdx, t: 0 }; + this.setCarOcc(car, null); + this.syncCarXY(car); + return; + } + if (!this.canEnterCell(car, nextK, fromK)) target = boundary - 0.01; + } + + car.pos = Math.min(target, car.path.length - 1); + const occIdx = Math.round(car.pos); + this.setCarOcc(car, car.path[Math.min(occIdx, car.path.length - 1)]); + this.syncCarXY(car); + + if (car.pos >= car.path.length - 1 - 0.0001) { + if (car.state === 'toPickup') { + car.state = 'dwell'; + car.dwellT = TUNE.DWELL_MS; + } else if (car.state === 'toHome') { + this.parkAtHome(car); + car.state = 'cooldown'; + } + } + } + + updateCar(car, dtMs) { + switch (car.state) { + case 'idle': + return; + case 'cooldown': + car.cooldownT -= dtMs; + if (car.cooldownT <= 0) car.state = 'idle'; + return; + case 'dwell': { + car.dwellT -= dtMs; + if (car.dwellT > 0) return; + const building = this.buildingById(car.targetId); + if (building) { + if (building.pins > 0) building.pins--; + if (building.reserved > 0) building.reserved--; + building.ring = Math.max(0, building.ring - TUNE.DELIVERY_RELIEF); + this.score++; + this.emit('delivered', { + carId: car.id, buildingId: building.id, color: car.color, + x: car.x, y: car.y, score: this.score, + }); + } + car.targetId = null; + this.goHome(car); + return; + } + case 'toPickup': + case 'toHome': + if (car.needsReroute) this.reroute(car); + if (car.state === 'toPickup' || car.state === 'toHome') this.moveCar(car, dtMs); + } + } + + // ── Pins / overflow ────────────────────────────────────────────────────────── + + updateBuilding(building, dtMs) { + if (building.graceT > 0) { + building.graceT -= dtMs; + } else { + building.pinT -= dtMs; + if (building.pinT <= 0) { + building.pinT += this.pinInterval(); + if (building.pins < TUNE.PIN_CAP) { + building.pins++; + this.emit('pinAdded', { buildingId: building.id, pins: building.pins }); + } + } + } + + if (building.pins >= TUNE.OVERFLOW_PINS) { + if (!building.overflowing) { + building.overflowing = true; + this.emit('overflowStart', { buildingId: building.id }); + } + building.ring += dtMs / TUNE.OVERFLOW_MS; + if (building.ring >= 1) { + this.gameOver = true; + this.gameOverInfo = { buildingId: building.id, k: building.k, week: this.week, score: this.score }; + this.emit('gameOver', { ...this.gameOverInfo }); + } + } else if (building.overflowing) { + building.ring -= (dtMs / TUNE.OVERFLOW_MS) * TUNE.OVERFLOW_DRAIN; + if (building.ring <= 0) { + building.ring = 0; + building.overflowing = false; + this.emit('overflowEnd', { buildingId: building.id }); + } + } + } + + // ── Main step ──────────────────────────────────────────────────────────────── + + step(dtMs) { + this.events = []; + if (this.paused || this.gameOver) return this.events; + + let remaining = dtMs; + while (remaining > 0 && !this.paused && !this.gameOver) { + const dt = Math.min(TUNE.SUBSTEP_MS, remaining); + remaining -= dt; + this.time += dt; + + // Weekly clock. + this.weekT += dt; + if (this.weekT >= TUNE.WEEK_MS) { + this.weekT -= TUNE.WEEK_MS; + this.rollWeek(); // pauses the sim for the upgrade choice + } + + // Spawning. + this.houseT -= dt; + if (this.houseT <= 0) { + this.houseT = this.houseInterval(); + this.spawnHouse(this.pickSpawnColor(false)); + } + this.buildingT -= dt; + if (this.buildingT <= 0) { + this.buildingT = this.buildingInterval(); + this.spawnBuilding(this.pickSpawnColor(true)); + } + + // Demand. + for (const building of this.buildings) { + this.updateBuilding(building, dt); + if (this.gameOver) return this.events; + } + + // Dispatch. + this.dispatchT -= dt; + if (this.dispatchT <= 0) { + this.dispatchT = TUNE.DISPATCH_MS; + this.dispatch(); + } + + // Movement. + for (const car of this.cars) this.updateCar(car, dt); + } + return this.events; + } +} diff --git a/public/src/main.js b/public/src/main.js index e623f67..42e4264 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -70,6 +70,8 @@ import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js'; import MahjongGame from './games/mahjong/MahjongGame.js'; import JewelQuestGame from './games/jewelquest/JewelQuestGame.js'; import ZumaGame from './games/zuma/ZumaGame.js'; +import BejeweledGame from './games/bejeweled/BejeweledGame.js'; +import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js'; const config = { type: Phaser.AUTO, @@ -153,6 +155,8 @@ const config = { MahjongGame, JewelQuestGame, ZumaGame, + BejeweledGame, + MiniMotorwaysGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 5276e38..a313425 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/server/games/registry.js b/server/games/registry.js index 7de12d0..7693290 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -85,3 +85,5 @@ registerGame({ slug: 'mahjongmatch', name: 'Mahjong Match', category: ' registerGame({ slug: 'mahjong', name: 'Mahjong', category: 'tabletop', minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 58 }); registerGame({ slug: 'jewelquest', name: 'Jewel Quest', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 59 }); registerGame({ slug: 'zuma', name: 'Zuma', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 60 }); +registerGame({ slug: 'bejeweled', name: 'Bejeweled Blitz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 61 }); +registerGame({ slug: 'minimotorways', name: 'Mini Motorways', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 62 }); diff --git a/server/scripts/verifyBejeweled.js b/server/scripts/verifyBejeweled.js new file mode 100644 index 0000000..bf583c6 --- /dev/null +++ b/server/scripts/verifyBejeweled.js @@ -0,0 +1,281 @@ +// Headless verification for Bejeweled Blitz. +// node server/scripts/verifyBejeweled.js +// Exits non-zero on any failure. +// +// 1. Fixture tests: special-gem creation and detonation on hand-built boards. +// 2. Monte-carlo self-play: thousands of random moves with invariants checked +// after every resolution (board full, no resting matches, phases coherent). +// 3. Last Hurrah and shuffle sanity. + +import { + COLS, ROWS, SPECIAL, GEM_COLORS, + newGame, applyMove, lastHurrah, findMove, findRuns, shuffleBoard, randomBoard, +} from '../../public/src/games/bejeweled/BejeweledLogic.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); return; } + failures++; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Build a board from 8 strings of 8 colour initials (r/o/y/g/b/p/w). +// Uppercase suffix markers are handled by the caller via overrides. +const INITIAL = { r: 'red', o: 'orange', y: 'yellow', g: 'green', b: 'blue', p: 'purple', w: 'white' }; +function boardFromStrings(rows, overrides = {}) { + const board = []; + for (let r = 0; r < ROWS; r++) { + board[r] = []; + for (let c = 0; c < COLS; c++) { + const color = INITIAL[rows[r][c]]; + board[r][c] = { color, special: SPECIAL.NONE }; + } + } + for (const [k, v] of Object.entries(overrides)) { + const [c, r] = k.split(',').map(Number); + board[r][c] = { ...board[r][c], ...v }; + } + return board; +} + +function boardFull(board) { + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + const cell = board[r][c]; + if (!cell) return false; + if (cell.special !== SPECIAL.HYPER && !GEM_COLORS.includes(cell.color)) return false; + } + return true; +} + +console.log('Fixture: 3-match clears, board refills'); +{ + // Swapping (0,1)↔(0,0)? Build a guaranteed vertical 3-match: column 0 has + // red at rows 1,2 and a red arrives at row 0 via swap from (1,0). + const rows = [ + 'rgybgypg', + 'grybyopw', + 'gboprwyb', + 'ywgwobry', + 'obrygwpo', + 'wpogrbwy', + 'rygbpoyr', + 'bowyrgbw', + ]; + const board = boardFromStrings(rows); + const state = { board, multiplier: 1, noMoves: false }; + // (1,0) is 'g'; swap with (0,0)='r'? col0 rows1,2 are g,g → moving g to (0,0) makes col0 g,g,g. + const phases = applyMove(state, { c: 1, r: 0 }, { c: 0, r: 0 }, mulberry32(7)); + check('legal swap returns phases', Array.isArray(phases) && phases.length >= 1); + check('phase 1 cleared 3+ gems', phases && phases[0].cleared.length >= 3); + check('phase points positive', phases && phases[0].points > 0); + check('board still full after resolution', boardFull(state.board)); + check('no resting matches', findRuns(state.board).length === 0); +} + +console.log('Fixture: illegal swap rejected, board untouched'); +{ + const state = newGame(mulberry32(3)); + const snapshot = JSON.stringify(state.board); + // Find a swap that yields no match. + let rejected = false; + outer: + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS - 1; c++) { + const res = applyMove(state, { c, r }, { c: c + 1, r }, mulberry32(4)); + if (res === null) { rejected = true; break outer; } + // applyMove mutated the board — restore for the next probe. + state.board = JSON.parse(snapshot); + } + check('some non-matching swap was rejected', rejected); + check('rejected swap left board unchanged', JSON.stringify(state.board) === snapshot); + check('non-adjacent swap rejected', applyMove(state, { c: 0, r: 0 }, { c: 2, r: 0 }) === null); +} + +console.log('Fixture: match 4 spawns a Flame gem'); +{ + const rows = [ + 'gybgypgo', + 'rrwryopw', + 'gboprwyb', + 'ywgwobry', + 'obrygwpo', + 'wpogrbwy', + 'rygbpoyr', + 'bowyrgbw', + ]; + // Row 1: r r w r — swapping (2,1)'w' with (2,0)'b'? need the 'w' replaced by r. + // Instead swap (2,1)↔(2,2): (2,2)='o'… simpler: put r at (2,0) and swap down. + const board = boardFromStrings(rows, { '2,0': { color: 'red' } }); + const state = { board, multiplier: 1, noMoves: false }; + const phases = applyMove(state, { c: 2, r: 0 }, { c: 2, r: 1 }, mulberry32(9)); + check('4-match resolved', phases !== null); + const spawns = phases ? phases.flatMap((p) => p.spawns) : []; + check('flame gem spawned', spawns.some((s) => s.special === SPECIAL.FLAME && s.color === 'red'), + JSON.stringify(spawns)); +} + +console.log('Fixture: match 5 spawns a Hypercube; hyper swap zaps a colour'); +{ + const rows = [ + 'gybgypgo', + 'rrwrropw', + 'gboprwyb', + 'ywgwobry', + 'obrygwpo', + 'wpogrbwy', + 'rygbpoyr', + 'bowyrgbw', + ]; + // Row 1 becomes r r r r r after dropping a red into (2,1) from (2,0). + const board = boardFromStrings(rows, { '2,0': { color: 'red' } }); + const state = { board, multiplier: 1, noMoves: false }; + const phases = applyMove(state, { c: 2, r: 0 }, { c: 2, r: 1 }, mulberry32(11)); + const spawns = phases ? phases.flatMap((p) => p.spawns) : []; + const hyperSpawn = spawns.find((s) => s.special === SPECIAL.HYPER); + check('hypercube spawned from 5-match', !!hyperSpawn, JSON.stringify(spawns)); + + // Now swap the hypercube with a neighbour and confirm a colour sweep. + if (hyperSpawn) { + // The hyper may have fallen; find it. + let pos = null; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + if (state.board[r][c]?.special === SPECIAL.HYPER) pos = { c, r }; + } + check('hypercube present on board', !!pos); + if (pos) { + const nb = pos.c > 0 ? { c: pos.c - 1, r: pos.r } : { c: pos.c + 1, r: pos.r }; + const target = state.board[nb.r][nb.c].color; + const before = JSON.stringify(state.board); + const hp = applyMove(state, pos, nb, mulberry32(13)); + check('hyper swap always legal', hp !== null); + if (hp) { + const ev = hp.flatMap((p) => p.events).find((e) => e.type === 'hyper'); + check('hyper event fired with swapped colour', !!ev && ev.color === target, + `event=${JSON.stringify(ev)} target=${target}`); + check('board full after hyper sweep', boardFull(state.board)); + } else { + state.board = JSON.parse(before); + } + } + } +} + +console.log('Fixture: L-match spawns a Star; star detonation clears row+col'); +{ + const rows = [ + 'rwbgypgo', + 'rgwbropw', + 'gboprwyb', + 'ywgwobry', + 'obrygwpo', + 'wpogrbwy', + 'rygbpoyr', + 'bowyrgbw', + ]; + // Column 0: r r g … and row 0: r w b — swap (1,0)'g' row0? Build L: + // put red at (1,1) wait — simpler: col0 rows0,1 red; row2 'g b o' — make + // row 2 start r ? Use overrides: row2 col1,col2 red → swapping (0,2)'g' + // with (1,2)? Instead: cells (0,0),(0,1) red vertical; (1,2),(2,2) red + // horizontal… Build explicitly: + const board = boardFromStrings(rows, { + '0,0': { color: 'red' }, '0,1': { color: 'red' }, // col 0, rows 0,1 + '1,2': { color: 'red' }, '2,2': { color: 'red' }, // row 2, cols 1,2 + '0,3': { color: 'green' }, // below the L corner + '0,2': { color: 'blue' }, '1,3': { color: 'yellow' }, + }); + // Swap (1,3)? The corner (0,2) needs red: swap (0,2)'blue' with… place red at (1,2)? taken. + // Give (0,3) red and swap it up into (0,2): col0 r,r,[r] + row2 [r],r,r → L of 5. + board[3][0] = { color: 'red', special: SPECIAL.NONE }; + board[2][0] = { color: 'blue', special: SPECIAL.NONE }; + const state = { board, multiplier: 1, noMoves: false }; + const phases = applyMove(state, { c: 0, r: 3 }, { c: 0, r: 2 }, mulberry32(17)); + check('L-swap resolved', phases !== null); + const spawns = phases ? phases.flatMap((p) => p.spawns) : []; + check('star gem spawned from L', spawns.some((s) => s.special === SPECIAL.STAR), + JSON.stringify(spawns)); +} + +console.log('Monte-carlo self-play'); +{ + const rng = mulberry32(42); + let totalMoves = 0; + let totalPhases = 0; + let specialsSeen = 0; + let multsSeen = 0; + let maxCascade = 0; + let invariantsOk = true; + let pointsOk = true; + + for (let game = 0; game < 60; game++) { + const state = newGame(rng); + for (let move = 0; move < 80; move++) { + const mv = findMove(state.board); + if (!mv) { shuffleBoard(state, rng); continue; } + const phases = applyMove(state, mv.a, mv.b, rng); + if (!phases) { invariantsOk = false; console.error(' findMove suggested an illegal move', mv); break; } + totalMoves++; + totalPhases += phases.length; + for (const p of phases) { + maxCascade = Math.max(maxCascade, p.cascade); + if (p.points <= 0) pointsOk = false; + specialsSeen += p.spawns.length; + multsSeen += p.events.filter((e) => e.type === 'mult').length; + // falls/refills coherence: every refill lands on a distinct cell. + const seen = new Set(); + for (const f of p.refills) { + const k = `${f.c},${f.r}`; + if (seen.has(k)) { invariantsOk = false; console.error(' duplicate refill cell', k); } + seen.add(k); + } + } + if (!boardFull(state.board)) { invariantsOk = false; console.error(' board has holes after move'); break; } + if (findRuns(state.board).length) { invariantsOk = false; console.error(' resting matches after move'); break; } + if (state.multiplier < 1 || state.multiplier > 8) { invariantsOk = false; console.error(' multiplier out of range'); break; } + } + if (!invariantsOk) break; + } + check('played 4000+ moves', totalMoves >= 4000, `moves=${totalMoves}`); + check('all invariants held', invariantsOk); + check('all phases scored points', pointsOk); + check('cascades occurred', totalPhases > totalMoves, `phases=${totalPhases}`); + check('special gems spawned', specialsSeen > 0, `specials=${specialsSeen}`); + check('multiplier gems appeared', multsSeen > 0, `mults=${multsSeen}`); + console.log(` info moves=${totalMoves} phases=${totalPhases} specials=${specialsSeen} mults=${multsSeen} maxCascade=${maxCascade}`); +} + +console.log('Last Hurrah & shuffle'); +{ + const rng = mulberry32(99); + const state = newGame(rng); + // Seed some specials by hand. + state.board[7][0].special = SPECIAL.FLAME; + state.board[7][3].special = SPECIAL.STAR; + state.board[7][6] = { color: null, special: SPECIAL.HYPER }; + state.board[6][2].special = SPECIAL.MULT; + const phases = lastHurrah(state, rng); + check('last hurrah produced phases', phases.length >= 1); + check('last hurrah detonated events', phases.flatMap((p) => p.events).length >= 3); + let specialsLeft = 0; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { + if (state.board[r][c].special !== SPECIAL.NONE) specialsLeft++; + } + check('no specials remain after last hurrah', specialsLeft === 0, `left=${specialsLeft}`); + check('board full after last hurrah', boardFull(state.board)); + + const s2 = { board: randomBoard(rng), multiplier: 1, noMoves: false }; + shuffleBoard(s2, rng); + check('shuffle leaves no resting matches', findRuns(s2.board).length === 0); + check('shuffle leaves a legal move', !!findMove(s2.board)); +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); +process.exit(failures ? 1 : 0); diff --git a/server/scripts/verifyMiniMotorways.js b/server/scripts/verifyMiniMotorways.js new file mode 100644 index 0000000..356cf63 --- /dev/null +++ b/server/scripts/verifyMiniMotorways.js @@ -0,0 +1,380 @@ +// Headless verification for Mini Motorways. +// node server/scripts/verifyMiniMotorways.js +// Exits non-zero on any failure. +// +// 1. City generation invariants for all six cities. +// 2. Road adjacency fixtures (diagonal crossing rule, costs). +// 3. Pathfinder fixtures (straight runs, bridges, motorway shortcuts). +// 4. Overflow with no roads ends the game. +// 5. Monte-carlo bot: 15 simulated weeks per city with invariant checks. + +import { + WORLD_W, WORLD_H, TERRAIN, TUNE, CITIES, COLOR_NAMES, + Sim, generateCity, keyOf, xOf, yOf, octile, +} from '../../public/src/games/minimotorways/MiniMotorwaysLogic.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); return; } + failures++; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +// A sim stripped to bare terrain for hand-built network fixtures. +function fixtureSim() { + const sim = new Sim(0, 12345); + sim.terrain.fill(TERRAIN.LAND); + sim.houses = []; + sim.buildings = []; + sim.cars = []; + sim.roads.clear(); + sim.bridgeCells.clear(); + sim.bridges = []; + sim.items.clear(); + sim.motorways = []; + sim.portals.clear(); + sim.cellOcc.clear(); + sim.touchNetwork(); + return sim; +} + +// ── 1. City generation ───────────────────────────────────────────────────────── + +console.log('City generation'); +{ + const r = { x0: (WORLD_W - 20) / 2, y0: (WORLD_H - 12) / 2, w: 20, h: 12 }; + for (let ci = 0; ci < CITIES.length; ci++) { + for (const seed of [1, 777, 424242]) { + const { terrain } = generateCity(ci, seed); + let bad = terrain.length !== WORLD_W * WORLD_H; + let water = 0; let startWater = 0; + for (let i = 0; i < terrain.length; i++) { + if (terrain[i] > 2) bad = true; + if (terrain[i] === TERRAIN.WATER) { + water++; + const x = xOf(i); const y = yOf(i); + if (x >= r.x0 && x < r.x0 + r.w && y >= r.y0 && y < r.y0 + r.h) startWater++; + } + } + check(`${CITIES[ci].name} seed ${seed}: terrain valid`, !bad); + check(`${CITIES[ci].name} seed ${seed}: start rect ≥80% land`, + startWater <= r.w * r.h * 0.2, `water=${startWater}`); + const expectsWater = CITIES[ci].gen.rivers > 0 || CITIES[ci].gen.lakes > 0; + if (expectsWater) check(`${CITIES[ci].name} seed ${seed}: has water`, water > 0); + } + } +} + +// ── 2. Adjacency ─────────────────────────────────────────────────────────────── + +console.log('Adjacency / crossing rule'); +{ + const sim = fixtureSim(); + // 2x2 all-road square: diagonals must be suppressed, orthogonals intact. + const square = [keyOf(5, 5), keyOf(6, 5), keyOf(5, 6), keyOf(6, 6)]; + for (const k of square) sim.roads.add(k); + sim.touchNetwork(); + let diagonals = 0; let orthogonals = 0; + for (const k of square) { + for (const nb of sim.roadNeighbors(k)) { + if (Math.abs(nb.cost - Math.SQRT2) < 0.001) diagonals++; + else orthogonals++; + } + } + check('2x2 square has zero diagonal edges', diagonals === 0, `diag=${diagonals}`); + check('2x2 square fully orthogonally connected', orthogonals === 8, `orth=${orthogonals}`); + + // Diagonal staircase keeps its diagonal edges at cost √2. + const sim2 = fixtureSim(); + const stairs = [keyOf(10, 10), keyOf(11, 11), keyOf(12, 12)]; + for (const k of stairs) sim2.roads.add(k); + sim2.touchNetwork(); + const mid = sim2.roadNeighbors(keyOf(11, 11)); + check('staircase middle has two diagonal edges', mid.length === 2); + check('diagonal edge costs √2', mid.every((nb) => Math.abs(nb.cost - Math.SQRT2) < 0.001)); + + // Symmetry: every edge runs both ways. + const sim3 = fixtureSim(); + for (let i = 0; i < 60; i++) { + sim3.roads.add(keyOf(2 + Math.floor(Math.random() * 20), 2 + Math.floor(Math.random() * 15))); + } + sim3.touchNetwork(); + let asym = 0; + for (const k of sim3.roads) { + for (const nb of sim3.roadNeighbors(k)) { + if (!sim3.roadNeighbors(nb.k).some((b) => b.k === k)) asym++; + } + } + check('random network adjacency is symmetric', asym === 0, `asym=${asym}`); +} + +// ── 3. Pathfinding ───────────────────────────────────────────────────────────── + +console.log('Pathfinding'); +{ + const sim = fixtureSim(); + for (let x = 1; x <= 10; x++) sim.roads.add(keyOf(x, 1)); + sim.touchNetwork(); + const path = sim.findPath([keyOf(1, 1)], [keyOf(10, 1)]); + check('straight 10-cell road: path found', !!path); + check('straight path has 10 cells', path && path.length === 10, `len=${path?.length}`); + + // Bridge across a 2-wide channel. + const sim2 = fixtureSim(); + for (let y = 0; y < WORLD_H; y++) { + sim2.terrain[keyOf(12, y)] = TERRAIN.WATER; + sim2.terrain[keyOf(13, y)] = TERRAIN.WATER; + } + for (let x = 8; x <= 11; x++) sim2.roads.add(keyOf(x, 5)); + for (let x = 14; x <= 17; x++) sim2.roads.add(keyOf(x, 5)); + sim2.touchNetwork(); + check('channel blocks path', sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]) === null); + sim2.stock.bridges = 1; + const span = [keyOf(12, 5), keyOf(13, 5)]; + check('bridge placement allowed', sim2.canPlaceBridge(span)); + sim2.placeBridge(span); + const over = sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]); + check('bridge connects the banks', !!over && over.length === 10, `len=${over?.length}`); + check('bridge cells marked', sim2.bridgeCells.has(span[0]) && sim2.bridgeCells.has(span[1])); + sim2.eraseBridgeAt(span[0]); + check('erasing bridge disconnects again', sim2.findPath([keyOf(8, 5)], [keyOf(17, 5)]) === null); + check('bridge stock refunded', sim2.stock.bridges === 1); + + // Motorway shortcut. + const sim3 = fixtureSim(); + for (let x = 1; x <= 30; x++) sim3.roads.add(keyOf(x, 1)); + sim3.touchNetwork(); + sim3.stock.motorways = 1; + const a = keyOf(1, 2); const b = keyOf(30, 2); + check('motorway placement allowed', sim3.canPlaceMotorway(a, b)); + sim3.placeMotorway(a, b); + const quick = sim3.findPath([keyOf(1, 1)], [keyOf(30, 1)]); + check('motorway path found', !!quick); + check('path takes the portal', quick && quick.includes(a) && quick.includes(b)); + check('portal route is short', quick && quick.length <= 6, `len=${quick?.length}`); + sim3.eraseMotorwayAt(a); + const slow = sim3.findPath([keyOf(1, 1)], [keyOf(30, 1)]); + check('after erase, path is the long way', !!slow && slow.length === 30, `len=${slow?.length}`); + check('motorway stock refunded', sim3.stock.motorways === 1); + + // Intersection items need ≥3 connections. + const sim4 = fixtureSim(); + sim4.roads.add(keyOf(5, 5)); sim4.roads.add(keyOf(4, 5)); sim4.roads.add(keyOf(6, 5)); + sim4.touchNetwork(); + sim4.stock.lights = 1; + check('light rejected on straight road', !sim4.canPlaceItem(keyOf(5, 5), 'light')); + sim4.roads.add(keyOf(5, 4)); + sim4.touchNetwork(); + check('light allowed on T-junction', sim4.canPlaceItem(keyOf(5, 5), 'light')); +} + +// ── 4. Overflow ends the game ────────────────────────────────────────────────── + +console.log('Overflow → game over'); +{ + const sim = new Sim(0, 99); + let gameOverEvent = null; + let guard = 0; + while (!sim.gameOver && guard++ < 10000) { + const events = sim.step(100); + for (const e of events) { + if (e.type === 'weekEnd') sim.chooseUpgrade(0); + if (e.type === 'gameOver') gameOverEvent = e; + } + } + check('roadless city overflows to game over', sim.gameOver); + check('game over event emitted with culprit', !!gameOverEvent && gameOverEvent.buildingId > 0); + check('overflow timing plausible', sim.time > 60000 && sim.time < 400000, `t=${sim.time}`); + check('score stayed at zero', sim.score === 0); +} + +// ── 5. Monte-carlo bot ───────────────────────────────────────────────────────── + +console.log('Monte-carlo bot, 15 weeks per city'); + +// Weighted search over buildable cells (4-connected) between two structures, +// then pave the path. Water is allowed at a steep cost; runs of water ≤3 cells +// become bridges, longer runs abort the connection. +function botConnect(sim, fromCells, toCells) { + const from = Array.isArray(fromCells) ? fromCells : [fromCells]; + const to = new Set(Array.isArray(toCells) ? toCells : [toCells]); + const structs = new Set([...from, ...to]); + const occ = sim.buildOccupiedSet(); + const cellCost = (k) => { + if (structs.has(k)) return 1; + if (sim.terrain[k] === TERRAIN.WATER) return sim.roads.has(k) ? 1 : 6; + if (sim.terrain[k] === TERRAIN.LAND && !occ.has(k)) return 1; + return Infinity; + }; + + const dist = new Map(); + const prev = new Map(); + const open = [...from.map((k) => [0, k])]; + for (const k of from) dist.set(k, 0); + let hit = null; + while (open.length && hit === null) { + open.sort((a, b) => a[0] - b[0]); + const [d, k] = open.shift(); + if (d > (dist.get(k) ?? Infinity)) continue; + if (to.has(k)) { hit = k; break; } + const x = xOf(k); const y = yOf(k); + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; const ny = y + dy; + if (nx < 0 || nx >= WORLD_W || ny < 0 || ny >= WORLD_H) continue; + const nk = keyOf(nx, ny); + const c = cellCost(nk); + if (c === Infinity) continue; + const nd = d + c; + if (nd < (dist.get(nk) ?? Infinity)) { + dist.set(nk, nd); + prev.set(nk, k); + open.push([nd, nk]); + } + } + } + if (hit === null) return false; + + const path = []; + for (let k = hit; k !== undefined; k = prev.get(k)) path.push(k); + path.reverse(); + + // Validate water runs first: each must be ≤3 cells and straight. + const runs = []; + let run = []; + for (const k of path) { + if (sim.terrain[k] === TERRAIN.WATER && !sim.roads.has(k)) { run.push(k); continue; } + if (run.length) { runs.push(run); run = []; } + } + if (run.length) runs.push(run); + for (const r of runs) { + if (r.length > 3) return false; + const straight = r.every((k) => xOf(k) === xOf(r[0])) || r.every((k) => yOf(k) === yOf(r[0])); + if (!straight) return false; + } + + for (const r of runs) { + if (sim.stock.bridges < 1 || !sim.placeBridge(r)) return false; + } + for (const k of path) { + if (!structs.has(k) && !sim.roads.has(k) && sim.terrain[k] !== TERRAIN.WATER) sim.placeRoad(k); + } + return true; +} + +function botHandleSpawn(sim, e) { + if (e.type === 'houseSpawn') { + const targets = sim.buildings.filter((b) => b.color === e.color); + targets.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(e.k), yOf(e.k)) + - octile(xOf(q.k), yOf(q.k), xOf(e.k), yOf(e.k))); + for (const t of targets.slice(0, 4)) { + if (botConnect(sim, e.k, t.cells)) return; + } + } else if (e.type === 'buildingSpawn') { + const building = sim.buildings.find((b) => b.id === e.id); + const homes = sim.houses.filter((h) => h.color === e.color && h.k !== e.k); + homes.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(e.k), yOf(e.k)) + - octile(xOf(q.k), yOf(q.k), xOf(e.k), yOf(e.k))); + for (const h of homes.slice(0, 4)) { + if (botConnect(sim, h.k, building.cells)) return; + } + } +} + +// Late repairs: any building accumulating pins with nothing en route gets a +// fresh connection attempt to its nearest same-colour houses. +function botRescue(sim) { + for (const b of sim.buildings) { + if (b.pins < 3 || (b.reserved > 0 && b.pins < 5)) continue; + const homes = sim.houses.filter((h) => h.color === b.color); + homes.sort((p, q) => octile(xOf(p.k), yOf(p.k), xOf(b.k), yOf(b.k)) + - octile(xOf(q.k), yOf(q.k), xOf(b.k), yOf(b.k))); + for (const h of homes.slice(0, 3)) { + if (botConnect(sim, h.k, b.cells)) break; + } + } +} + +function checkInvariants(sim, label) { + for (const car of sim.cars) { + if (Number.isNaN(car.x) || Number.isNaN(car.y) || Number.isNaN(car.pos)) { + return `${label}: NaN in car ${car.id} (${car.state})`; + } + } + if (sim.cars.length > TUNE.CAR_CAP) return `${label}: car cap exceeded (${sim.cars.length})`; + for (const b of sim.buildings) { + if (b.reserved > b.pins) return `${label}: reserved ${b.reserved} > pins ${b.pins} at building ${b.id}`; + if (b.ring < 0 || b.ring > 1.2) return `${label}: ring out of range ${b.ring}`; + } + for (const k of sim.roads) { + for (const nb of sim.roadNeighbors(k)) { + if (!sim.roadNeighbors(nb.k).some((x) => x.k === k)) { + return `${label}: asymmetric edge ${k}→${nb.k}`; + } + } + } + return null; +} + +{ + const targetWeeks = 15; + for (let ci = 0; ci < CITIES.length; ci++) { + const sim = new Sim(ci, 1000 + ci); + sim.stock.roads = 99999; + sim.stock.bridges += 50; + + // Wire up the structures spawned during construction. + for (const h of sim.houses) { + const targets = sim.buildings.filter((b) => b.color === h.color); + if (targets.length) botConnect(sim, h.k, targets[0].cells); + } + + let lastScore = 0; + let scoreRegressed = false; + let invariantError = null; + let steps = 0; + let stranded = 0; + + while (sim.week < targetWeeks && !sim.gameOver && steps < 30000) { + const events = sim.step(100); + steps++; + for (const e of events) { + if (e.type === 'weekEnd') sim.chooseUpgrade(0); + else if (e.type === 'houseSpawn' || e.type === 'buildingSpawn' || e.type === 'colorUnlock') { + if (e.type !== 'colorUnlock') botHandleSpawn(sim, e); + } else if (e.type === 'stranded') stranded++; + } + if (sim.score < lastScore) scoreRegressed = true; + lastScore = sim.score; + + // Exercise erase + reroute: pull a road cell out from under traffic, + // then put it back two ticks later. + if (steps % 600 === 300 && sim.roads.size > 10) { + const plain = [...sim.roads].filter((k) => !sim.bridgeCells.has(k) && !sim.portals.has(k)); + if (plain.length) { + const victim = plain[Math.floor(Math.random() * plain.length)]; + sim.eraseRoad(victim); + sim.step(100); steps++; + sim.placeRoad(victim); + } + } + + if (steps % 100 === 0) botRescue(sim); + if (steps % 50 === 0 && !invariantError) { + invariantError = checkInvariants(sim, `${CITIES[ci].name} step ${steps}`); + } + } + + const name = CITIES[ci].name; + check(`${name}: no invariant violations`, !invariantError, invariantError ?? ''); + check(`${name}: score is positive`, sim.score > 0, `score=${sim.score}`); + check(`${name}: score never regressed`, !scoreRegressed); + check(`${name}: survived or died legitimately`, + sim.week >= targetWeeks || sim.gameOver, `week=${sim.week} steps=${steps}`); + console.log(` ${name}: weeks=${sim.week} score=${sim.score} cars=${sim.cars.length} ` + + `houses=${sim.houses.length} buildings=${sim.buildings.length} roads=${sim.roads.size} ` + + `stranded=${stranded} gameOver=${sim.gameOver}`); + } +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.'); +process.exit(failures ? 1 : 0);