From c35c18bbbcaefef7cf1328ef2b20b1cb3ad5e881 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 30 Aug 2024 03:56:03 +0100 Subject: [PATCH] =?UTF-8?q?first=20commit!=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 4 + .gitignore | 1 + Dockerfile | 10 + client/css/main.css | 69 +++++++ client/img/ball.png | Bin 0 -> 29410 bytes client/img/player.png | Bin 0 -> 2127 bytes client/index.html | 39 ++++ client/js/main.js | 432 ++++++++++++++++++++++++++++++++++++++++ client/js/silver.min.js | 1 + common/log.js | 13 ++ common/player.js | 20 ++ common/prop.js | 48 +++++ common/world.js | 1 + docker-compose.yml | 6 + nodemon.json | 6 + package-lock.json | 37 ++++ package.json | 16 ++ server/main.js | 112 +++++++++++ server/ws.js | 231 +++++++++++++++++++++ 19 files changed, 1046 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 client/css/main.css create mode 100644 client/img/ball.png create mode 100644 client/img/player.png create mode 100644 client/index.html create mode 100644 client/js/main.js create mode 100644 client/js/silver.min.js create mode 100644 common/log.js create mode 100644 common/player.js create mode 100644 common/prop.js create mode 100644 common/world.js create mode 100644 docker-compose.yml create mode 100644 nodemon.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/main.js create mode 100644 server/ws.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..89c3529 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +.git/ +.gitignore +nodemon.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2165a5b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:alpine + +WORKDIR /app + +COPY . . + +RUN npm ci + +ENTRYPOINT [ "npm", "run", "start" ] + diff --git a/client/css/main.css b/client/css/main.css new file mode 100644 index 0000000..ce1a63d --- /dev/null +++ b/client/css/main.css @@ -0,0 +1,69 @@ +body { + margin: 0; + padding: 1em; + + font-family: "Inter", "sans-serif"; + + text-align: center; +} + +#game { + border: 2px solid #ddd; +} + +#game:focus { + outline: none; +} + +#game.offline { + opacity: .5; + cursor: not-allowed; +} + +#chatbox { + width: min(calc(100% - 4px), calc(796px - 1em)); + height: 8em; + margin: 1em auto 0 auto; + padding: .5em; + display: flex; + flex-direction: column; + text-align: left; + background: #f8f8f8; + border: 2px solid #e0e0e0; + border-bottom: none; + overflow-y: scroll; +} + +#chatbox .chat-message { + margin: 0; +} + +.chat-message.error { + color: #e42020; +} + +#compose { + width: min(100%, 800px); + margin: 0 auto; + display: flex; +} + +#compose input[type="text"] { + padding: .2em .5em; + background: #fff; + border: 2px solid #e0e0e0; + overflow-y: scroll; + flex-grow: 1; + border-right: none; +} + +#compose button { + padding: .2em 1em; + border: 2px solid #2b8adf; + background: #51acfd; +} + +footer { + color: #808080; + font-style: italic; +} diff --git a/client/img/ball.png b/client/img/ball.png new file mode 100644 index 0000000000000000000000000000000000000000..b97c47e3d555b3238a5db27e10e47e410bb401cc GIT binary patch literal 29410 zcmV(-K-|BHP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>Da(78YK~#8N-MtBz zZP``dx6e6uta*G>PnwYg2mvyf!9l?o$2cT*_<|EV&X*WW9Ajex#*C4K5C{ke5JGVP zWAnimgGBi3$Pg?~5d2{?YG@6uR?q#WdhgXd+`9LkA-~@`Rjuw;&+3+1+OMmt>ed1FAo_A8}WU1+P zQ!$^X!3$PXcYWqFdTdVHXUs$H{QJ9`d{U)mRHsgvrEFTK{J2h4IZ0{CWA-`daGL6( zNyXtLaa1b0CWpJS zf|*{b9E}=;$nw+_S*lb5hN$XgsWTY1zYlt;9CCg?PicOTmcIJaTOmHJI=}yp4_#mamhm= z{P7q;f!`CXzR6NO=%#eUb-MU|5Fj5<5Gua^Xq>W{ep0h?&aFX$n#*U{ek!?^7LT?S z^NXAZl9UL1c_uX=OF7^gxQ-4AQT9==d4_Wq7qZq*3xDpVpB9SnyzS)SuYI`5fcWf~ z0pA`2+eNDPM_4^%F2njJ(^PC?{zE{TV=_PEUp<}R)3Ibk+B8!*0g4c?k7Xmu48NZC zI0nJi{Jl7;5jZZO&)-p^qToItK;57iB}jqq$g&LB@1`!GFli=SuFsuNB8lr|A;M65@R$RR z`3wt}@RgIJ)Ql?xgQbrd>>@H?sjt^hAmOc=5*BoKx0UPckwh~F1P3(mv^pm(?f z`T;sLea?kEHWzRk{9c|Nq?JGW;^)n{f66;D`qkST2Qc3~COiX*_1XCPv0_Za z93Ttm$NW23m{5&UG)Mb6icIW3(1NsR0nc<=L;iMdbsO3vG%MQHZ7=dH$6~4Zc5bXXALI-7rpu+fo zpFt0<*cmRMjC5oMg~+FAI>-G*o_UT%$(kkH)ygn+zxtLmJO9bgK7LYfc{>d)nN6(}Z3IIuLa0}B_{KUCITK@B&-0<;}cC+LE>qAXXa<_{Bb`grijMzTg9iaeb z^l$?VX5N9MVOg^DOmarpm9&12t@^!Gfau_TF!D4UrV1eyTJ5Ec)G)X?#H>*v&!(QIz`VAQkIN zL?q)>E#iWz<{H*l=`yHf_(|AV%q_!5bPW*B zS(FYAQl5o3oS922U-ybnnp~dq8y)=4eNFEUMhYQzM`%9rdvkRcHcwawqYjd%HTorM zA`}xDgi_2KW#MO_y$1FZ{6GuJ_|8ZpV}y+p&+z>YHf%1HQ1M(Y0D@!<$C$F+1ODXz z403${xr4FSC`aPn8Kkq;;`c|v>Lq#k0e!4dAYA|)MHkS_aXfuAHJlDW82h$@t675G zql6vwrPiuJtg44=8p1&;?q^~US5-|=9+eZ z`qL4gtuMd@Pz(tp_i{c!et<@$at-o%!-xTwpnJhZ;Dfjp-@^^z?-e-8S1*Db+qgAc zR#otvL>?9XKP%`f|78PypNt4hmeNtP2bF;|qNLTCS&oPMKrl@3xkMJ`rt=YrXEHmKG%{|a|9ApNL^8*2r2_Es$RpzbnxpaZX}6r9Hq(# zgzI;llJ4;QbKDc+=XA7QD4Rja;C^1`c$GfP4N}pECoFON1tN_LGimw1e%W&-Cwl(u zx6!YBu<2ctSOKZmb_2%qO$J}mWkwEotXT9YKtgl^B|D*(gMQa9`Jx#t7o9YZJ-Fnm?*-m zA^p&Lo26kYOy~ghwMn3>dl8D(V~}7D9}lBXo4cvlS1$C!$U~$#pbsccj=M5ian2zd z(|`CyWn{oTBP-~9#S{hh*-)(9L=pzlJ3s+;;Sbjj2wO3p`*u=s?{2#GOMiIs@XvmG z)-|j7)jJz~d`5Ve7?5TQtAs(v-}I{vh$IfEbxPLoDgo&VMZP3VI|rcEWcbcN2+z}P zC{M0sc1^!;$QYzfR)55RLZ$)*%nNK>jqt8sC4Rr5@N!_j zTE-Vo^ydl%7htra@=ZA(BnqWCf>WG~RwH`w943Z>S1c~kXTsN2`Zwkv6}-pw;06hT zC*P=NuznLuCzDoAPp2WAVAvXW)8m?S$Q(u80Ttj4(`Ark0gi*hPtS9`v-4@;Z@lWH z7tc<6)-G1Q)bHN=b>QJO4f`R;s&m<8rkzj1ipzC5(x;!=K8Z`EJ?)? z>N!peO=bY`^NqxcSUITx*FhJ_7Yg7kU^C$RH6VHx_|{jOtvk1f!t62NDBRT4F4r_e zh$UADqPRg+f`Dt&zysoNk>fH7#`i{_PNdPmTXLs~IaiAprA*#L#9=Oa0pV`(nSTE| z->naT+c>c#tk)|3GOj{?;fy4)f34CMgsNIQJR&EGiKqi*n&Nsyj0VN8h=DCrYm9Q> z3o7*FJY@7{LeRxLEq=r2KWlP(RxULDmG?E>2eB|iCey0;xG1Q&z8@*l%$hqap}BjD z8!9;BXn*oJ6~bP?uob-a;75_%aYm0&t1?T7)n~GBV{;n_5`x%4iIy4kiGKg&dMEP- z#_)~)7+;Tk1U&R<5DzTAdBIXQ4?N(_gyQIE4*qEZ5)~rG_CYjJX!d zI6aC!RVTzS85zuRZ3|V-4}(KYZ|S7vZ~UTXMRuR?MULL_zGiqYgQak;?7)Drax`8f z&w%6H!51nOkjWpQQ5cL6szyLqt;w5o;~>@Z44}uMARN#h>pAo?A_85=*eN^)#sL-H zwN8KbJ|c&qVZz#lUaZnN{~R5uP=o^hP|GpqXTygVX8Jk^m}_WpCE=vG{2-G?7AS8{ zRKQ6HfMY-?qf=)i7i6+%)!abQosCDH63NtX1LrcP%k*h&kS4eE)5>3e%_l5Bdil)T z@jt(#8Qu@%O5h|~2qr`88-!6HbaV05Rp3_5N05eHmT5&ug9R~VqSvu&V&nZvY`;s4`$w-aQ1s72 zNtY*$Vwzy z#eMMIspWcwFxC4w6j-}Fpq_`X)I5MSiMl(+(40Yq>u82t^Hw697p|m*uY2Zsf@gGM zb8){(@B9${m)>g8&YmG;{aVsWu>N}Euq~$CMw?~CT7j82dEN}46Gx}@r8eEbW(m? zRHcQjagBY+p}v_2p%#r04zRDag}cFuoqo>FuBJ-YV3CQ+EK$Q1WVvZ)Z^P$1+sTQc zWb`mT3VxGIBt;8|u1X(s6v(i_Et=KH2E(=-T>^@Nwu;WRA+@!$gm9MR4CCA)&xn%D zaKj7}qpuPKkU>?C}{XUSEt=6N#>0U^n&<6=n z=uW^iSV1l9D@;Zq82rK zAtzq1cZni6HJe9?tmuW91sa$$u#YMe&4N!SW8@0eF>ngx);pld0bB%uP4{@B^4=_-0*~^AByoj#jM#5L4#mB?NySP?~ZoT_NJBd(Xz z2kQoF(5eLLjg%}ptPhS-`OzCGzx^^}g)4&7iTrE zY7U4#G1o0HFd6|&Nfd$GOka+`(k}Nw>J@0CGC06i`53Q!nLrt$!xS`=CW%SH=k?QlP(eGWDX zgk$=};(U~f)p8IRynh=VgL-lhly>TjCTO9O)^H4K7PtZ5v48qykx0HPit#ZDTZ%gN ziAbW*708e7+fLctxKbjJZ~<_jKnNoec!&9x{xw|M^}{r{#DhMA_Ouk>_zx~Ooec&T zE6k?A6`hEwR|WFB_o4v1F%ag$GveK-g2DP~N!z8ZHusKSb{=2Lb)G-w-yMk4Od10n zg&z_?;&lCcI>ed~s6MiB9suPqL#G2P@FCeDe^ZGV&3b9EtsbTywXR7ObRB?s{XGOU zK-f2MKeI!^Lhs4*0MlI{zd|VsV7NROF)*MMeKKD&4PL@MK{2nZ|DNvdMVKd|S+Jsj zx}9NLhK98&#K;(8-7(BY7q}np7~_#~?-~WpOk+!QxsO~$6tvDUxQmKd6{)yJzcGNA z<=i|<{nALm2&+!xLs5v&uO6rEUwQf{@U#@5xP1cx#=t8W;E`@4C}8~oSiA+0i)bwa z9z)nuugt<;A!^()pj0R~1{P9{yCsR%Ot?-EMKC zOdG#-$J0`Rr>p>zcYUjJ@@lRS(WBQILpS=t(S4bxb6Fy2?-#`dee!xEDj| zm>C=~Mkbn!J`4vTnodht7*n}Mf#G-EM0c}Hoy&WvcL|sIG@5sP(l+_C5X+5$a{xId z%Bw<47|1J|3<63-H;tRK5s=O$!aX<#KZ}UWK8pUk6LhbI@F|MYU7p86u`J0a$2W*G zx7cEb5R86UYGze&1+03GAW$iEkVo_ogZ1wABPM4cp31JKZzhWh;QkoK-@|H~66DAb z4A0R+fh(S^C^%a#BZQ-9l|&C^P#ySz=jA!%J#{|~CjWqQxkm;eOh1vD9`}Xqb7ef@ zIQ0UZP(~9^l48cWIXjmscvc0n)?JPlL2p`0^-cXWdD%)@{`yxwCD}UCPu|`OBQ;6m z;Xq9Wd`A5HzAGV6Nt=SNuzEWuWHs{$-z*ruS3;KwZ)WTkiQiqQVGaI1?qnVz%C>o6 z70GNH7X-S|WZ>U+45+ZxyI2k7EW>i=0%zGs5;X306cWwC)hzK%3$UM2||rA&92ZH{)XXTZJ3LZqYGU z4E6Agl15zx3hZ-OvBZHT{p+YKPI==78jh=QU&Z*_(wN^TK_nf%Z{z$05Q66i!FpX# z3U1^imF4P{8BY|vz8B+g3Y;W#Swf{UVDZAK5rgH~v5xQB z4xjTGzAr=gnGnx)o4q|C8pWG4se(1^;9ekDouvf^lEDn}gmugC71aX1e+ebiHK2SA zgfxK&;a(W*4s5(AW*BgUUl_tU{toJX0(H@!6vByxd1bC_0|c z-$X26M3QK%p0MVaLe{eBSBFqh!tA|sKIdc30u3>%M>Mn`pMF!yZe2oo)K)%{4;c>X zj?8UXlYqMt`psTCHnG+bL$J)Qw}CR>g>gNFMmL_g4G1#=&iz93a#*gjZ@CbTfTHAD zi#HSJ$Y=zhPYySMk_Ar?6-2QZ z0FL74DV80RKAi9Dq{{PgO&hxVoAIRd8&`sY_co1LJ@e)W+ANv#_|+ZP5flS0InyeflErs7 zSX@}0ZsBG#V+hr3;1VQn8Pavz8t#FID6x!4|MDNMpq9WPd#zN#XU6>2@#FJMx5o6U zZbtuX(I^CJ3Ll7xBiqhyOeyDBeuCUpxR2cVhdm(P!NJAovdkmcI_qcBi?W|Vng2fG zJq$tJc?@%}-;n;+`G1wV&L225SbYa2?Pc%~p3_27xq}fv@D`NQJV2Kyr-XmxTDim! z;TJ>30&+6to?REQaIQqOYpU9$64y40c)UjM)1Y|+q>@O;P_hFdbfO6klM{*5S9ep1 z0e0>>h|Fb$i!td{9Owd2UdVX?F?_Q2gbL8TvL8O#csM3doYq9^3Yo6eGk!tDV#;b6 zzqNtTocYsYt;@S@omjsYP@X%jiZ9EDoC^Zft7x)DOCNLoE(eH|r&%{AS56eESjD=5 z?nt#_;coCCV{^j>XZ!rUS>)bp7Is3!u4!R?1j9WbqgqX)JArVuWV4%ob^J%^a&zP4 zDIeK>{_^jpZuPyIagaTsJ>q>7Kk5cKrW9_uR^CO?`7Z0X#nB*>2oiG+z=2`$G|-Og zK-RQ;%gUYUQz<>?@tO#C{*b=#92ICF+Q!fPO?TUk`H&%Se*>jJHl8yAfyX>6uAp;$ zoNoNud(@@JZI7z}qyP8LChF6iG*JPBWF6NHIx@f_<{5a&n}iS;*Se}R_+*%=uFqhq zHq0URH+LQIZiFC7;=%F*l!0Hv@)uABizlpzQEAt?j@djjdoKAx^Xop(VH|IzkZIvL z6^73&1hKBbWQkUXpvCC?Bs*9vEYeT3^#cZgRSclwejBArafdE!Ob+0}#3Oud?bT@!+nLqFn z7GT-7@%J8s5A=Dev8nUhrdt(2aShfFp=&nwAud?HK3OCy>VPGnWq<&J= z*+wyd;KDTpAj!snaP09PJDMW7c(8%1Smk>9eNm0)3L}7s(O1jQi>5`Isf$n0dKtR{ z_5x8ynB$j098*QE5Q>>aBsZ-gZAL+y`*2~OHW#Z#L}TMxdG!zDJ`sJ{ImWFWCx;$$Bu4+{uBO=K0r0*0(8yWSQ)6)xLE-0s z8ZB3NwsYk=T6%sl{84shsZCod>@_TVX}(8>7%1tDB3lxA@z&?%;FjUs{ssssETS7X@bCQ$|su?q;UkM@Dda@>9ZSrx2>)i1bW)%wA7 z79BuB8OyU`m5pp&|83GpaY!`vZ?b(9z}Z19B<2ecdQ{eW9bAtybdkbU5mXXW&z6tb zSL*|;G0;tDwYm(^hkME3x6-xf?_-(7IH3LEtxn&*@;j-A0vaAhB8Mo#h&Gmit4K(+ z*xw#&(cpXDFlo5cykwx%19DAM24nUIC_iqu7(j>XfL9Qwd;}rB7t& zhyQnmOkt(vWx@`KqN`#diCS|uw}92t(F>3_1&#K(DML)pst|o(94??|R4CX)khKI% zGcu{LCeOJ7&;uG1^?Hrx>Vr)!k2wRv7inY5zbw?0U%1)_x8ls3c>-q!Edc2#r~&Ozryjy63Dq_$}Ps$4IzQq2m)wf zsmKKDjy(PX>>orDOlta4Z}Gr0D2HWOgoG{#jpbn~kR_1z!(xTpU(1t?s; z<;M(alvfFVb^xo7DqfY|@I#mfUK@!a59s`U3p*T3_?T`m*eH?9tHM_bOU|g_3aq&^ zt!sUKjxZZ5gq&Y+P65=g+NH6m6Ef&DXv`iW#5RRYe|C2f{#95e6a`I9t9$6ow0&Z0)4(<&WnAk5GW=`!<>y(ifI(S(WhS;!Vv)KWajzM(_x` zhU96DhB&d-JuI1X^}!N8G(U3c!uw==V%SfEH;*--x#*vausb$#t~SXV9idz?s5u zL$u8NKA(*n#^{8toBRo-V+<6$$)TS00rB88tC@CDXoMCy3C71tV3(f%diuupm`64K z9Pl64MFu`+Z=X@SX^HEDFu;DYfW?_ni%KQW7TN4qxBwm(gm8DX&K^++Vl%9NbbBNM z+`SzqIp`us5G$FC89iAmgX&&-!OS_+v}D@G8r_Q3rtrOo`h-*dgK@EJ4*f3UC?MIl8OwhfY52+GhhfDRN;--BeHfVm{k89`IIF-Oqh zFCZL%fyIMVM+nJ?V|^}l;VoagAQ60|C?4Ltzlz{KmOg2v?2z7h;KZD z_WHs916UnK0;j>}mlIG6A^N3@GbyA+JET#gz}ToLfdJ^aSwsbCX4Hg~Jiyh+p`Z(# z1AlnZ1_D%{zsS%)ZrxA|cb8TcQoc?&Xr-3phVd2ZK4D;<7&{CyWXU*KND}4c4{Hx8 zK)HX^#8M@NC#$xg(Fq_YbzG}wxz_caRIecnq5`*Ki)G$hqg06k&RBV8EW!16mVhUt z0|jh~){sS}FUuFQyI8pDD&h#Q>bTHoQAf_Tt(Fl%%m{M6poBcEV)3(rg~znm8xjEvmp$T@jPcp{3>vR#6^9LoL{ve3U7j zX^TZg6vMh$Sjt0atuFX9Vu-0^t;fF1a4*q=kSMly;pch*79Pv33;aJ`_y*An%H%R^ zOT{PSL%MSuvw+>ul4sI#qEOsdShv4P@K8+2;mIuE6zC1XnSc_lbWMDDtUlkL@*~LZ z7GdhvcIy_Gu;$&v^c5q=R7Bx3$CaZS z4u;$)oR}O^Nc!@51T3r%Q70`?F5%yuVg;sSWlyA$(NYx4?N$vpYv&l;fQduxaI6M) zI@lzV*Dg6vqGL9Z-`zH*aWyY}b(>e+PX`n$EENatV&Z6JF7=2sx=s)=sIM$;Z6EmQ zHhk)2d=RcAvV06q_;&}1VT@V;tUZJdDiAowaiW5C27wy-WnCk~VWFxnY63}}USJGy zfoJQp?R61+Ac5|XuSYa)C+_4|CB(SuYX^+^Z0c+g8R!<*aV?O&>iTX333*9BTn~v0 z%8$7o1iu^m8no@9t9K5xJbvIBx`5n$c($x{=ip$M#L+&$lnbpahM=`}r-8L(c|iRF zw3zQ1q>rI!!$ti*$YMAaWDdqJ^vC|Z=s=Wzqg=7%TKLAwGfTp>_RvCpHfq2f)aK^( z!A=n~NUrROW;nUKk4r#NL@zUzT-PPwQOeD=Ok-O-@!s^ma&CLt(`pQ01a^K$2~LNu z=LVEKtH2@}MD2ou=%@5wlq&q3{#r&J&v?sHno8ibN?cvfXh88RB;u$hep@)3^c$q%=r^w#4X*m<;nmZj=$buEU$^eFl#f3UK)DI~t+d5^rJ6S+{r) zqXC5pOvPI>Bz-s0LHU4W2r?)`x3VE>{)_;Gb)yHh(TZWGdWClD1hC>L1A{0!IU`vV zmWA_hHBLHNI&K2Uq2|FFg&3t~{D+mmXMz|;fuvjj-(NB~`6|R}fiE+ZUQ6V$N{MGk z7dOKdiAIz0D6J+2=V`VWD1}eIY!mk#K=2A#BB+^_^sfuqY+|m~nK=wf3iPDP>97JWu$Pu@W1T_&c_bk$Ex`+6%FbVM**|Bxm0mkC*-Ogy?`^t;!;cHok_*mEJkg&<>hzUi2`U%2$nBl-+X<{ z0U&Vq<&7Eu1+)%XDX{@B=Eet3^C}!V6NQl0<dA@R2-}#dQB~3eSzDz>6lnPCJJ$E z_*JNOHEq=LaUTyg^dVrj36usGWBmGSu{V6s3J}E($`0aE?PmSd_2F2Y*_&>*)4V;E65EXQ7`s|1V zuvWK!i}`%V1_1=TV|1N@u?r2R?~H%{6}P5e{I1ufpZUhOwD)2>I~dmXPyB~8guzSd zbTztA9tL#dDBU2(7!K$6?FQ-?Ub->mWNh*WL}9!+1LHu3&Kkz2G?y^vLZ}rdSWd*^ z3f2zsoQ;+!dzFnxhKHq3L^%X(Uy>L?cB!=ejk0hQz&y3{13VzT4t|mundBv1RP(vFd`0)-U=w|~ z(#O@tDgj!a`u&CUN1ykj?Y*C*edqbFMX&pG9VLyV4?>!8J%2%9)55z1(R_dcYkfbc zrQk$V;hRTkh#oW`6}&D`M%*sq4?pcjktFGO>4E?AKcuaH`kwT_ zTi=~J^f`APqQjV=C>D!Gp>9MBjK5G2Q5N5~_R{nW;c$7(a6$y>pPNpUjB8;{hVr9@ z0>qLoeW?Menq|J-v2*?%SZ}j_{LRC!L#)is>1!3PU-&gQKj3-8C)%NS`$h->%K)xJ zZlH_{D}zfYL?O)CvAecXx*2N+F`_>xMSU|N9}nR@t-Ougz7d6?l>HNJohn9WA~WX8 zqT~#&Fh+sg4@lN+$W{_=^{|vsC`l@o&;aMJzWoSqY#8_WjUJ{qUim`)-==%Dd^e+< z;A&250|VV?S-hJLp@M=YiQG%5CPJFjs5t*o=@44&oJh z?lfXF75#;vx_PHnREKxmX!7#?R~th(SDl#giy1$EN09n$JE+A#(<9z)+zJhWSHd|z zd#E>PsEw*y~Tpo)8q^6yS7 z>6YlI)Mk#oNAM}}l z4VK}yEsU)zfOQoy;Q*w@-npw-*B&>$vfD!TJ_6W+gt=;1sCKi18;}tO#!a7UNhO_k zVkn{wC!DxfHsoQo(3tb5qasZdJirr_r|!paN#PS(R+o2zL`Hg<17bH0uN(CQ4CwkI z@j8%C*GtKj{?(zAFm1gA|O+==9irRkF5=?dq*Yz5s%A>V&34Sw%3(2gM) z9d$YH)rP2yKVYXUe21aeHG)WM7$1t^JWDe^jAv*-Qxi=w>xY5G9!+$rJlb#b9-ObM zi4{*+ee68O@W*t#mDz4sn7j3<4BAHrhJwxOrwt3y%)na&9}n$udIQ5gpvp?uAd1ey zp~+zZ5=d7MA?&Cl&`PydLOXdx?w&#$!2Sh<0yMh4dMFZF8~$4XTMZOGg)iqBgf#uP z3ZEntMa!(6$eO`#hftMAEob3 zht2meK5t0BeEizDe-Jw%JbbnL_2^RnyF3iT{jOo^h zR7MOugXAq8U`dc?1{kU}S;d|RtCzwclQfiFB437`TPa*cj-8AW&i=r>9b0hVB z_nwxgn8{OVA*yEcVoDp7>)mbjQQn92XHk$ z?`MS9aujzcuNXs@)wwx`D5>Bg1E&>803~d}BpGxqj0Xy204`#yHR11;<%ln1mha+L zFm%TzNGbk5+=k%}zS&CF@bhS6Aj=EZ;SBr^<9Gf)&gJ8H8>nxM(wY?mE+wXerTK=T zSd=w*0skmszP=ntp}P_(RI(cG;7goTP}9N{gXCO;*3?r;=wQRoZ4Dm^V+|uiaN30Q zHuS6sdO$HgQ6c=iJy95ahHxW6lf z^>DGaQZfgEPG7nhE+&7^)l|Iu62b-bj&K>EXrZ#9)W!R^Q+|o4=wvF`F)HY5m+)E_ z6|=bXFOp{bJ4!#mSRrr+7uWnd!}rI=(;a+Go;hZaM2sr8NFbWR(O(XLHGQi2d#wUL zrl8_ytysTw74o%;CE5|JJQjU;8B3NG6yzc-M*&-%OC%8a=rv+>d4Uhlbs}|)-w$*# zWO6;eV=WRn$d-R&Em0;0Tt$lA>uw&V^2!Z?m*YA2>|z^gf(Q|UN=}7H(8p*%Z)8bW z$c+DWv_J+hNL1WVx1j&#KGydmD3QSh8Mb&2G4~DLK#Mn$>KYi#ss8 zySnV`q`Ci?wBos{HCz*+VLk<&Ob26N1*H4n2mf@+h}*LdT>()crj1i9v zqYF#9Sc^nhpJ~_Qoi-#L2P|`a2&S`mm5VnKe+%vmiilv`9)eYw`5YyPkd$k-i54xN z@t`qE5#sWf5JK0NL~y-0oKHt4oKWcCM>=DkPpkKRhK1!^viM~zP(lm3`dr;TApLKA zZrs0k=Lg%DS8!E%RjhHa0vcV76<3U*yPW4|m(p~MVxeHZ(pD%ftP8a7AVL>Z8c(hv z#!>pbi?>D!zc|2UswklJM7L$4z*s)ko~u!OkKzrpdX{P1bCFV*vyj6DkrrK|AX=Kf zx;F2x-<#(=$&gS@$m8Y_z64T&7%yos$+V5a2;KU02bOzAc%veyR6Kx%0y1K)slk_r zGI|ZpF^Y*jfXpud@27JY@me{4pK57V1ThH13ujV!Y`Dku7(HL|I7TthP$dW(piv|4jWY0e1I8%rMk!jfFnfmu)88C~&=pMZ!)blp(YJ_#84dfXJ zKdc&!4#dGSVvr9HfIWmMX|W3F!b-a!#euiOB9t5jl!#?yS9ck51TleC^Lb%d1wZi3 z9us%?H0h+}^Bj^z0V$F>3Xk(tLY|tdw^oiC^Zyex;qNkVo1gc42!K!h*}@nRqQd%| z=rQN-`LVYEh~v&=0%o;iSv}{t!zrO_d3=a~xsG!g&gy|y@hZFu4uKMv11$WnenF}* z!0yj}AT|4=)c?0v86Wy{l_)2ahkjWVU2qPlQsVCN8CCBo#dR;Kq z5YrNQ6wW#^jLsBBWl~@ZpX3q=G%H@(0P%>9cNi3XyzkyITX#~u+AM$wCS_dwiEoNj zt^VCvr~@p*En*RDq-G6AP>M*}fDSPOD3@?X(C?B-TEMpq*f{xg(?|)>9+2&pb8->U ziSz58arSKbmNUPd@|}N}I`!+*u>O8XAqr^N#W6j2fw6zd&m&8;34|56Ns-t(&U=Cs z+$_Yc^=k17zy0#>s)q`wp2DD23ZsmM>)}NG-3=xpbILh^914r)jClvz*pfOdP8TT| z%(l(fb3D&oed$d>wtcx|j1m6c?QNznSWe?Etp1lj!q}RqW2jK9sj=MhkdP6Sk$-@W zPBQs6Q*S3%Dl!a*9{vdlbm}Zba!R5FS>w=aqvAw z(4y_!gAG(TtHNV?MJCGep^59#oX!3^liL+S>LW83GcG|V6f}Jp&Lt_ z(+}cz9Y6-#RXlCUGS`nC z|J|_D7Pe_9mdX`y&b^$KRKqP48Cg<>Ahd{664&nQbD}i%BX~gT+xkQ{@ZRt83jm2=4|cVP0Q>hy=g)vSq&6x5G0948KyLKC&DjG*P@hqWhQ-o z{WIwrJ}u^__?wpRmaGb6l-`&o^FNTv{0HKBT*5x(+5`GG;dh15;dwl7N|*t^GC<9Q z9k&8HfqX;2)88Yb7sA&l+An|C(or+MT8mfVv>OXW;x8v?VYN}~5@?K9F+amq;oiPt z24r-IA~A#l#j9!FyMYUnqfAn_XA%hh{;%&(#YI9{Ig)jbR`$x%V(d^nn7!{ic3pr< zA1QkJlIx-&yZf`#G=e`Yqx{tYt^{NgSxu2o`ydTUH1XX;W49sKw{nrpc>tjpP4UG_ zn0%Ymi6n|w$GRkXX4OytVadTaPUq>{dc4b!GPi#BJdSnu>?kI2wXm3}!Y}}Vyl8FA zk}YygA4=>#z(iGiosu<(a1guM4knaf}&3`t{d8_)k&?0{m~k(xLYQRgS1rO?LQ|~D(HHCA%o7C3L$T{rj6 zU}_q({N2^VhH;#lsT3%f7GwcxEM4X|@5xBC%7CT@teNJGNh(l;Yjt6fpbGulc#}L=m#hFo;0^?E*VEC0|Qt!bG`URnPl~vUvq5^50NuBGag|B}#3h-GgSqzRwp@P!_sv!&E?|~R!_iETB zfrxlkg$P#5cVNll1SdDL-+v&b3-g@Ag^UicR8%FdEvpl9-S8vTt$RJ#2Y=G zHB>n8gLu`ueehK>*A7zi;`KC(#z*1@>uR|Em!$Iu0$45fxDA8NgX^}c8+&OuKOb(w zojVn?BGrOr{%+Ku&=gn;|30Lt5eJv+(-f?NM|~I;2c~U)E)x&b!15h`b6aF!)cz`@eZ#a=Bao%5ieTX~|mQ zcDe_+hpGJ{`qKU47IQ2)kVXaB+KU%nPhF;WW|gs;PhDhG%rEfF^vbZe91;8T-7G1e z4PUwLh|+vU-GH-x!tcrSYb050JPdb^@G|o)UA~r0g)nS{G@8QCTq$h!?&tvj#xZwo zqRo3Tr|+s?-vknq8u)!J!+72YugoJjZW1H+2yjJ<_fu}}js?P)ZQOq4The!2_)Dq$ zoS)}j-Sk^af4hZzkYvoiiG9`r!)p8jhH*C;Fvuf^@D!Xsi97Z}VYT*-0EF*Ag!Xfl zAxhYzoqpHv+cC~Zc)1xs(SiQ9n?-oG|4g^4WDJ;4>*pXqy|G2Sj&g3oK@7YIOM6=! z6GrCLI!b6=rGyZ;ZiXlT$HZ&rthRRBT>cjM5h9}G<{N}~)%8786-y^fwJ@e;Y!Bu^ z4CG;alSkaoHL?B*1IW4{03X@n$ztDL9}EGx*-$8hN&!(ZJ*<$`{NM!oZ4twm7XPXu zM95}(*96ib4{W;D4WEGic(?EU5X2iO#)mf;Ebd{Jk$YGHE2Qcw+(6pJ5y=-zIhhl= z0?uXNT>oy(;+qFwnPT$?<7X^(hCa`L43<}2deI_QhOiA^ov0#e86O>Yv58utvPu%QIapLDj6dnXHNC!E|v23JVV7eY4z@z?B2++HQelp^_zA#@&Z*1H~Xs# z-VK>FF|+vR?tW`*c=es1{aT+mO@}HA3doqqje;=tkTWe5U}}LPem0bQ!i5#8f_Ehl z{@sqmOdQvd-PW@7A*VXfu-Hib9&-FLinL4IDMA=UxPgKHNe)roNavp91)`J;fqA_O z^l-_v3r*`eTm<)(tT`5uZB+5JY(pgZ3Fy>C(>s+M0ca((`ik3sh<>B-^vy!a>O_HJ zAASU;7Y|_zJ3MwM!->G#pWDwIP@6}`XD2ShFO@gk2m-FOJi@*TtLrVyq<-x5A@LLX z;oKd@&Szy@V;MN`JC)edIyK!t;cPFnZSkx`-oQxqb!$XE?;eM*4Y%5Xc22+{ej|Y?VPggEHasbrm5HYYvE3 zEcm?g^4AkSVbL6aoUnFav~y;TV4{U!r)9$oX*`~w0{%kn>5)wNY#SEZwJ*RS6Sg_x$H#b1lioMQuOJqETs~rpUy5Yu8h_B zg;c)!mXzOq0|B;0Bd&IiMAaCzg~WE;B2QT?qYRel0p;D;H<6p6Al6!Dx2~je4W%H8 zI_jc8h_p+DVUKhn(|#*U=VvOShDi`^pOQBGpT00N*$_TlOY^`;$nY@wZfBsll3#%g zx(GktL$h@WPH>o)huB1s0i;DQ8eyWzoszHHuVN7#`v>czdt^_K`OhX-_Cb|#gO#y+UvM8`zVz`;$8yd+X zLGU4pX7&%6Sytu?CBE{;&tiw2^d-HQq^d7DrNz4_{z8pGH4pPzHf)<0EBQm zSX~W+6?uY7E?_`QT{qxMp!EH|O{7CVAXSlc;(GA{W!DHgDTK?B?8US)1C++yG@MyW zoqj*v{Gm4ladL+;qTLgr>F-gTlSkFsC84cwW%`fw-42PgGTg<748=<|}3lzl-1t zTnvKEjDrIo;a zWQmeybFG`RwNFT^<`@_|>yF#Tc?W5u!E^K*&~G4{*yeFoHn! z=_pFaWnHF-^}|4WzO;m}vFwYx#Oql5J=-aJ|CN+(#hTAT-t<$h8+4_yq%Q^>C72_6 zumjc^$dtu3r=0C0ZX@SG?_2pE{4ISOBe+H@pidx#+@gyB6jYU=h=faC*Do#DrZT7` z?ftk7{6OJ~09togHp=!f>sJ|ocKu`^0e`daav^w5ygm#n6S5(K_x*oB zuiT*^x@mUyqb{QJFFugU>)X+T0v_QEtQ&B-n6~0`-9{`c`_Pq?GalI%N&uu6hlgqE zOS9v>2pA1U*;!g+sOTNn=oA>6*Ca&j?7Ga~T10RUS6vO;=e0eg0ObqY!b=%qY)UMF z;b9sDW0p>0$j?y=$dDCI=_2slcm6oLWvItvN66#e0xE!nN<(tjF$e0->x4mc7A5kv z@9B(j2#h^8P1p*z7MW-c`G>23pZX53h$>oU;88QLfnP98pdb>pK`b(L51$t zPFbI{hZJD&H80EJy}I-#$R7_8IcAN1=^e9ck`syy^6Vew8 zO?6GChgAma?e-DO{!#kym;NB|r$89!Y>&ZiVkM9lirj+_bJ|1{`fkVl*Z%C62_A*p z4vybd8n3FmI6Wua0SZy%5Lz%_gt98f0j`K*qM8_A@5EE2X&JM;H;5TMUtC=bahZ%y z#W=)b7NqOD1bVs@Z)2aFf7wEL>4nJ`jmpILWO9&t_Z}uo#iAP6IRgH9KQ5Y%Gx!BO zqIxgH+tT~7RoTa>ODG;(2^Z@gjO#!AXSs0O9D$ED;MLVR!j8{uk)t7-&mLjoi9bu$ zlw12}^2hyWEKfRvCHn4wvxIPvKr4UTwk6V`Ac!?_7H|ZMcdvjuuA=SS%Lx+e3eF(Z zC<)^;U5`pL1Y~ur2;PY!H-1cKKq@n1t~=b?jg)HVk2eXY4_d3or?2Ec9zVC~ScMO= z38VbO_V{X1;MC8zvHr(~;uP{dC%6JaI-M>az)8Z@VR06N1mdY^M;s673xNaW5-~TG z91LLYfAiI`{-Na9DLknpYKT4kVEiVlTqI)8T%fCq&;y6%(PCdl&I@!>dCkIZ?WgG; zzWx{lS)EVynT2#y^+WWupZ@)qeJ6IqGo4vQK680Ddkfyu90AYGK@RxgfBVujc?oWBRHpvfb;#<;mhxO9@(4UbC^`MseV9Wa z9Kg_ceq4p~463BcszN)PSKd4WiG)`y6B+Ch3GI%saFh)mblbh_se8=xS^)%+i9MfP z$pkkjQKHd0*L_&~*hrkT=1srbG*)VG3Y3q{($1q5?@U-+a2O43}L?{V&$HZfL537XG6lQNinD8uxKdqegh zh~=3R6F;mza8Z(IDBKYo?GgoM`*{s;T`(+5q`2>7@EzF|?nfnX#5N_Av3YubhF02Xo zNU{2exHp&B7!&WcSvN;6RvPtq@S7QA6%4Dd=KCiDX>Yn|oc|aP&KEyukyDgQ;YW(~!QGTz z>|P2YMiJ|6ESQx*iaJIH?qy^$O=xxx0iREW>Gt7AB}XxSuJYlAoS`CceDuXD&}ZCC z$IT)@#_8E!`tkJPbhwD}(VuFA2yD23Vi=Jyou*l}a-t*%5TRLI;lz)EQ7QVa zF+gZjbNc6(Q-%=aP7x7B#3A!&qm8&8O5@^DAK#{X(p@-_qkFQKZ83?8$2|$7MJ_kG z_LcP!ZcA*JL6qJ?`pMouk8-jHFZ^ia4pgAD=j-=S8~QuVF(^36WH?P@-=OlS#?g;# z*@eZ->E4*#Lp95{QPqkId?L}b+;q;s$T(iR*a);>-FY|Tx;h;P>9{xR#MMoIz^Y6x1Z^94^Lgb=2G@AA-2 z1fhY|%kb$ClH6C8KoE_Ev-4bYjgBGYSoz9;$7@_nV><34&1gUJ0NK?oq6mcjpB_l{ zM>b>j&-w#tm3s>{mw{T)B;$dLtGlV=Yw3h$gk)KE)qR7*d(x(G<2MFO+f;+L3DZ8H z!qGw%R_pg=+q#n0|F$jMLqrv-Bkte`f+{={$EzT|GRFD?)7+ElCH%dw0gUnZ(R zFG91*1u`gDWa%&-C%#lQS$yFPUuKaDl+C;VF2h}4tO0!<*=vArBd>yog^dH(kp?J*S~|7)L}$;5?_n3Xcsd-_0Fa1?3s zr3LA$D-%8kE;wXpc%Z_`p|kol9vC87>k>eV?4|4BGMq&6?U(K{lEDEg!@uAU-BfZN ziBPx)D_}N2gg-_RuA&^yPe4vxS1{h&OXJ(`kFxK~>0y+wP(WzD>jMTEuIFK`-+Vtp za>mZ*(5LF(TZlgW?6kGM7bfp9D8(rXoVGxM_(318i%^)rxVZ@p0;N(}+OU)J5Vne^ zrB}L3eGuW8_m`7&H6E_-T)plzH)YEW`ZGj87PlhBL> z+O)UULffYQZCk4xMKYC2=i~Qjq+?ALOXxx=0=78MIQ`@tvyAO`Kk@J41$_uEU?jnB zx<8XU44KDwuz07gbpgf+BAG9|Hrx*4xdL)XptlM4E>-EJn?I7q*#RMKC*8C7rXayR zcLJG5xq$i_9>#wR3{Pytq3bo}{y6RN72w;yIEz)iuE(SQK`wgmSeSWZLdURBXvW%e zmBo#vEaOrMBL3tFvDD^8D|Cdv8LlDlSh0IsOVsMoF%Q6T5JNYIc7Q6_Yk&>cH!)(G zxPD+41)NQ}rRZ`91dXq?@KU@2!KwG=#M6^;Os84p!f>t>k zD*Br7W>Sbh9#7X;s)pUXSt!fgCqPX9Pt4WI8TQ= zKrrqzG===BFmeS@!dJcIriOv3c>lem=f$hBGf9`w#L{k8w0IpYyI5IjEuPDk5Xb4( zPHH}KIZZ*45X$;u6hBDTG(@>cTWrB#1q3_v+>1XRrq{=3wm>ghxA{(fa+fc`zdiQ! zyDgTbog{wr+hqc!;#j$h&#Zz7;Q})X$tRxck~Rgc)wkDF3B2#=bwva|QE|B56rL4I z4-wA0Gx73>cp-($%O^bd8Rr3b#_i*D%h9*eixE(t(#6i}_^h2Ma&Esqt>su|$?2p%;_^-73C*RW~=b9bhm>9y^U8KS9VQpBhOy0>J2g{|-;X332<^Uj% zE+@+%b#5N*>)^9XkUV(=9j)%(2thlYJ9U#s#>R;a)2dz5m{@(ey&naV(!i)=Krm7J z1|f_RHaCu<4-zuh^_%Pwbz`4?nnLQL2#;>n@W;}4w9Q|+M!fZ+@!a4d=$u6My&fco%TVb%~T>t_6gRYcJ!{@DV27 z?mIJ=zGCwY7|)1aE~h(Jzl$-47%tPBf-&P78Kkf1;aYLKMppi{);D+ zF;Da)Edz%2Ck%Kj30Ir-D=rr^Tr*U|0)1BBEgY|i$3K}?j{JaQVSZm|a=zO%=(#13 zg7b#J>#ZxP0}*uLX#t`r_#7V41!bZxPL4ox9$TJn1}<2?3oDoD(}-)LJh@pq+(5?? zafp>l72=>Tra;~B>Cvqi{e$!c-CyCT4)LCba}nuHlO%3s($m75ZQ^4T=$5nT|fyQmaFMee`6g>ik7_M{4s|Gh8DoF2}B zyU=tkn;Yw^BctLxfC{0Y6~0j9c_xeW_hC64hkuLBAuF!oG_HI>*Wn1S;R`3gAuhoV z37nPl0!A37Ss9qN-~tH7B~^R-sl5Lh*GEXsCjbkjh{RW%k21-~qpFwlC3G&r7!wXY zy4^9o8?gT#o=@g4R4e$8p@AV9{|{ld#PLLkiaLCM`@A0`ks66#ww99^@&(DmX^Q~P z)yMPlX%vg={q<7bvi$MOPY2FcSi>?)_30gI*bEk!u4j?=-ey$dGjAMu@Qrb7!x|aUXB~arV_65HI%b* zNP+7AIj(jy=pJxv9^b(*=9f5b5Cy<&+ri;*Kj)ezl=265BBD4Z929VZ^l@BgW+s)~ z!`Rs=S1qgp71Y5lO3}~JKnMx=bY9U%5tCiZP*yNFO&*pEAWA`dt&S~R!1v*VX z!f`yOzlXqS^g|JPy%q=j?NrEqjuC`7YDLe--8@x`J6)*1;Te-4($ zZ6uNU1O2=+UDmY|O<22bEt}JtvF6Zq^Y?Ax<|Y7!bKndTklI<8k6FQE9=5+Q8*kx? zH*rcxMJ2;2SLGr^YwxTFa8-~Qyy(AimzGRl^jQ7i3E$I7>0!+QLEXY-oX#Rj24k;3 z1nu8E>SGE(18H335rk04N678Dr>JmpU(d!mEf{qT;N%Eyv#^xDWbc>Lox{5jE+mo; zNUmq$2KTPKInvMBWR#|m@4S!I%lLOVu7?5659tx%Eg)E}@cgT|L=0~FqQ$iQmdB3! zAKjj^0@#|j{!x>zSOAKURC@OUP_9M*RtRa1CWFxCsAAR0MbaDV8=$FJqaj~Bzu5y$g+FZ%(0tI{%p*xm(w9@YM1`i}H@*?j6F)apdIHv1(D*WY3aT+e|J zWker`RmW!%NwhiTYt(I>)#Qm^+oed}{}SSiozD z@2&B#iT5i|&>dV2yLWHO)6GBr7wz&>YESvV(^mh?A2-7IrHd-9iXum(K|)!#$hwg}_x#2j=DtfYHX$4)V$0fMb`7^$3wB2_DPphLeZG5}4&UI^M$uXEU!x7&3A z3|uILTI?Gx5LgC$%WQp#bnLNbK`11#;bCK|dc%P+T?IX?k;`|du?E3VuY)g?R4L3J z3|K2KbsZps+`!f0q=>-YCK?WAU@8pT2A6UFk0WnDIel?M9eW}21F?K; z`asufm)Kn*++AG^dgZ2=We=ry|0MZ_JGV2;#|uRoe8Zng!`GgF+VXtbC!DtCw;ybp z-@Bg>kYC) z?gC6)hbbUG^Lhe??*(uZXlv-L!LNLi8HnJ^BHAh5ww(&7_5Xyc;R0G7af$@N@I%=2 zGteOL40%GV;1ccixsc!ieXvt%6 z(?CaaEAh6r8IgiNxat=!q;HQ+AkLl%!!i#e_jF0U$)4OK65Vo=&N+kWO9yH7^&hMB zeLC$KD}b$e>w6k2fOB{LG&FRLzZ({T3{kVD@MY*AL^X@=(+DdbI3|2}A_l-Qh+0FG z!g8l{xX)lSz&^3P!|%m$SaqC(8*xFTD~zkX16%-BaB-`3H(2*{i^u^}QrN8zXTabQ zQG-Z3bqDfJLq=ITH{cH^3V~ov+?eSzgV)yOsepc+Di3a8Zh>FNX>xcJd-X+kd!P=m z+&ShvUtwc2L4$GRxQ$quBRZnMjjzY)weHDM?Re)u&tI)L>&SVaU2GOfNLX9Y;WJ2b z3H@T3+x+S{Kcooz9Qw^IQ$Q&m7!G-9B~&SP+i>i8(8 zbmPri5?>L`>sSvizmdFppwEo(qI{h`Wp0Cu`*M0aW=CgcQ|BTmHw0#T@GD-XC|q020uzr{!V!X~ zteq8?TDJ>7VqP6?uX6fIT#vV~QzeVcC+;rN;?BS!{1QdBKQ0!Dwt!&yQ=kle6eYUH zJgMs;7#D>vtIr6LA~JETO2W&$Oq00={UVaWEmR9FqBV06&=tnv{NNK7zU>oM09)tp zq6EtfsEap+cwkRexk&`gUI^A=L7G&S2>pV)BEBiw%{X)L`(n zl-zxT+@!)91bwyn0)(9L-E^;EygQG?dPCn^2=jLpappo5BL{aaJ*e8M+3Q#t_sbxe zC4F%Hy-H{rIW}Cpu$Z!oJ1Ji$rrx9vAd^d}3pcq`34oEji_djIr_l@wB}k|k5;u!Dg>WoBwxl(a zXl$cJApC~--bz+Wnqqrd_ZHw!v45a71@yGt9}mt@APB9|MX3Ctk`%(UvrVpPMYGo8 z8b&@t!&0q+p)B#r8Vgon^Om^h<{Mo36n?xVUHWS?d_nwFE`5*xgixD#CGmSOEezixw*PO5ssCCNR^*m*yo;%j z--s6Cl`|%nfaJ&vxJXi>#m_;^j4DDCycXvXHpSL-hMroeJi|fL0#TrF2USbb5+Pljofd4Z9H zIS-FroTG1E4?#PPjdHtm-8ZYJ&3#-Qiq7>->qgBb_iVbl9Qq-b;P3Si#K2v7-1wG$ zTKnN=SNOJP{eq{h{HM1!8Gbt7#Sd=nN9N9rqJ;f$0lESdAxg_Z23O0*Vo{p)guW<2 zYu1(IRugGW2-lUh4rYYl?gb~p;O|{bVuA=wK>_f|MjtAd_iD>kF`WhL1_|^`793cba)(0y1#+M-|C@mPpF#Vh9E)4q=w8afr zL?n_JC>K=bKxXW<3vyV7mJj6u5JVtef*D)U5UQD+DY^*29Dw{lSmsZzLdpiX1T3@} z@b|+5tPo|O&w(adE>0lo6GR@Ap|My~7Ehl=qj-4*{f!mNgtYGB1;yuih#1y|ej*JN z$6Z5XqpOI5;2$o0{6yPx zssN|0e(Rq!?jY_wwiXm54h7K1=iB<*^idgEq?5l#4_3FBui-Fbk>81Z?`JaUWz zv-p%yEM9pFDYUk(LP*5{?#J1&t)r9&8|QdFZ-Cky(kEw#wF_8h6=V~X0X?RWtil>c zU#{dvPTV7^l3%crGG{PV0Im_jL|~?(W7{^P2caAIk2M2)l+QZ#L%--_nuTm)-73y> zf-91nz;moGNG|k{II2_x$^ z90cs@4vNKLl#=OkVkHxiN{w}?jHZ0!1q{G%iS-sc++PK=O36KkKq2@1wVx)3BZY*K zt0?w6TV768pOB77qg(*7L^QPkKB2W6;TZJ@^%j`8UqZYw6fRzzpk@gxkMTG%n0 zJ?mue@!BU%0c_PrZ#3C&-ka*HghxkM3gpgAU}0FLLO4Qud#S|F`-)fh`!kIzxpH=l zp@bx&1agBK1Tg_)WiU!pyV3}^Q6O~Z4xB5n_{6eleQgEYx`~5(?lp_V@g-ja zCz3_NH-5f?NLeAK6K4mlq9jFraDM=w=CvKxO z-MkcU0?UjROlBC?nlHeG1<5o6N~Gbuz8S}I)(NO4mH2t{7$%u2NZ;a{zv7cFk57pL zoVMm?-q)1wm2=48pbRFBOt}i5odc}SX}nv(u#Y~CGZZJA#p+Sc z7c8gc@Ay-n5?Opo6~I=0@M=^4&P75*82c{Pf^YZTKBh~9Fd7LM74Xf8kODoajz_5j zdoK0AUIb;(rAWFY{Kn+Y;+n7T0Lzxt3AF`i-4GeL1BtNj&SX{{yP0I>JiVA~l4ce} zknrhrtX|dyfB??Zt6RIp%fEDV|^Kx zpYyYG@0NwRpE0-)p<=w7&)hlTq0_T&&)P*e!5fI-8w5~*DigxS+{r1kjaX_FnG!X` z>|76P)#W$=R8M1Ve|3BSf(%hc&J$(u-PQnDMKt9z`G&Rm@`ec-=hC|NEva%Do(t|U znTO=P>FJejJy-2{r2waG`j7skN!NgF;JYA}A+^jO$SXT|(!OW6iOnG_FO|@_nbS;iUT$xY*VC`6-qvp||wh zd4h5TNi+iCzJ=4Zb8{#nLdIpefpaYOEIGy;ggcYy`z=(dA(SigX$)^%d)BJZzHN0v1t(&^xc0!%%3Ve9zv3X&(=*BwMc2>0T`4tMZSS*q$Vl`6!5 z@C`zm8@8fs*%C}6%fs4T`>^tTPrgd$Q_-H+3UJzvfAIrN>^{1O^#l2`clRNdEb6}{ zZn5h|0pT<4Yx$NHw}7=7Mt9q;eBWMAZh^Hz%-pZn2`baZzVIUAeXdmyX3EavmA58n zd^HVRBVNpi4*1qBcif0+?!gR%5oMf5!Qc|{7I5nfK$!{dqx0&sY5r?p{=6RJPYVS& zZPmfJ8NcI$gl@nx1MgcxddFE?{Hain5iZv4Tt4RAx(laS4KHygCS}YvHgXE*1&$zb zd=_s^#o8NTUDj?sUpE7Ux1YmBR5o2h6sfk#R7Nzwu`$U60zep5g0;W*MV~g~`e~&A zk8TIQ^e0W%&cj=7f}Gz<*>9opnU$V#1`)i9me3PuRi@h@9_BFyUl9 zpQT+EntFWc8b17gOE~fAU65hMun%iDoP$(mtFx&%GoNPv%JbXOAFq8{E5M`M{;%BL zI6LPSs-}7eX$JTnBfv4j#&8iFhK(X%(B|-6j}J;jJ|7E)N)UE}3b+M}S*E^O!`#2T z0yNOdYs<7=eLl^8@y(wRWA_=Q0FT!WfA_v-de>D#Lxd@m8!`g@B^F&c#jN`WJS_jJ t3*3lv66bnp_~o~y{+Y$k;4w<+{|9X<<@9GE`ZWLm002ovPDHLkV1g$!d@uk2 literal 0 HcmV?d00001 diff --git a/client/img/player.png b/client/img/player.png new file mode 100644 index 0000000000000000000000000000000000000000..2026744cc655cdc846f3cac91638d51c3ed8c252 GIT binary patch literal 2127 zcmeAS@N?(olHy`uVBq!ia0vp^3qY8I8A$FCoGuTf7>k44ofy`glX(f`u%tWsIx;Y9 z?C1WI$jZRL%n;xc;tCWO5D*CL$_nYt4eiSd>(39LP!KV(@ZF_|4_Bf-U5nYiefy3b zJ9h2bwP(+sy?giGzJ2@7ojZ5$-o1D4-u?UcA3S*Q@ZrNpj~+dK{P@qGKY#!J{rB(R z|NsAi#*Tu~5E%R+F!4?}FfTANlmz(&1JmtD1`NU9*;Rot##!JISx;TbZ#J#8uRDUjeifWM%R9@m#k-3`>=|0PZrbq zSqE0j8DF?sQ}pA{hl>v{H-C&MG#9S8$`WVV81DNZv|r@KA7T^d+9Sm&$1 z{55a84~Mtw^Vi+|bki|*O~>ByzlpO+2W;vP^6vo{%*wX72UfaC_=FM z{jpoFu~#7IXxEKu^J_4@i}b>d+}pje45qhqqtdr~y<1^=S9FVR{GPW4rq>ml)wBQW z{Q0o<%F+FtCk!v51ii5Z?#zS*!@OW7yxCb3;!s?5PAIYdh`9A zy&r|!%>3rgI52l%Z?L*rL3+mHE1jHoByU-iCNY(Rq$Vf$hG_Wk^FX2(7s zf4IJR;R~C^nTz#aJqml%yQ|`cL_g5?3pp%@f>@c&%kPKJv-|%1{NeAx_K*Gwlay41 zcK{>rhXF8#os1emvB3}Y8&2VV$qcQsoI0zFuJ#|Q-5+aeKL|W7)H;{1RuCx8Zz#9? z(VsLXabU8-EhuMuAwTp_-P7%dx3|CNllyem(esb^9t#0?f1d}yRQAY0#?}z*4(7ry zM_53qhwsQmVX!MfifqyzWS8aq37h-jf5jqAyE_&imT<<@>?nNTDZJxH!Q%sy)e1g7 zJl3f0`{2>xUS|KQF9*yEA4=9#Oa`jLAg0T{c-XSAV#i4qJ-Ixf@7rhlJb3g_O?U@T z=rm_Z_G5o_p4>k#CDjU^`=2*xw63pe + + + + + multiplayer test + + + +

multiplayer test

+ + +
+ + + + +
+
+
+ + +
+ +
+

+ This is a test game I'm using to learn client/server interactions. + Expect a very minimal experience with little polish! +

+

+ Mobile devices are sadly not supported, but not off the table! +

+

+ If you enjoy this little project, or have any neat ideas, + feel free to send them my way at + ari@arimelody.me. +

+
+ diff --git a/client/js/main.js b/client/js/main.js new file mode 100644 index 0000000..de9d254 --- /dev/null +++ b/client/js/main.js @@ -0,0 +1,432 @@ +import Player from "/common/player.js"; +import { WORLD_SIZE } from "/common/world.js"; +import Stateful from "./silver.min.js"; +import Prop from "/common/prop.js"; + +const canvas = document.getElementById("game"); +const ctx = canvas.getContext("2d"); + +const chatbox = document.getElementById("chatbox"); +const composeBox = document.getElementById("compose-msg"); +const composeBtn = document.getElementById("compose-btn"); + +const playerSprite = new Image(); +playerSprite.src = "/img/player.png"; + +const TICK_RATE = 30; + +canvas.height = WORLD_SIZE; +canvas.width = WORLD_SIZE; + +var players = {}; +var props = {}; +var client_id; +var delta = 0.0; +var last_update = 0.0; +var frames = 0; +var ticks = 0; +var server_tick = 0; +var server_ping = 0; +var ws; +var predictions = {}; + +const interpolationToggle = document.getElementById("interpolation"); +var enable_interpolation = new Stateful(localStorage.getItem("interpolation") || true); +interpolationToggle.checked = enable_interpolation.get(); +enable_interpolation.onUpdate(val => { + localStorage.setItem("interpolation", val); +}); +interpolationToggle.addEventListener("change", () => { + enable_interpolation.set(interpolationToggle.checked); +}); + +const fakePingInput = document.getElementById("fakeping"); +var fake_ping = new Stateful(localStorage.getItem("fakeping") || 0); +fakePingInput.value = fake_ping.get(); +fake_ping.onUpdate(val => { + localStorage.setItem("fakeping", val); +}); +fakePingInput.addEventListener("change", () => { + fake_ping.set(fakePingInput.value); +}); + +var input = { + move_up: 0.0, + move_down: 0.0, + move_left: 0.0, + move_right: 0.0, +}; + +function start() { + const secure = location.protocol === "https:"; + ws = new WebSocket((secure ? "wss://" : "ws://") + location.host); + ws.addEventListener("open", () => { + canvas.classList.remove("offline"); + console.log("Websocket connection established!"); + const name = prompt("What's your name?"); + canvas.focus(); + ws.send(JSON.stringify({ + type: "join", + name: name, + })); + }); + + ws.addEventListener("message", packet => { + setTimeout(() => { + var data = JSON.parse(packet.data); + + switch (data.type) { + case "welcome": + client_id = data.id; + Object.keys(data.players).forEach(id => { + players[id] = new Player( + data.players[id].name, + data.players[id].x, + data.players[id].y, + data.players[id].col); + }); + Object.keys(data.props).forEach(id => { + const prop = new Prop( + data.props[id].name, + data.props[id].x, + data.props[id].y, + data.props[id].col, + data.props[id].sprite); + prop.spriteImage = new Image(); + prop.spriteImage.src = prop.sprite; + props[id] = prop; + }); + console.log("client ID is " + client_id); + break; + case "join": + console.log(data.name + " joined the game."); + const p = document.createElement("p"); + p.className = "chat-message"; + p.innerText = data.name + " joined the game."; + chatbox.appendChild(p); + chatbox.scrollTop = chatbox.scrollHeight; + players[data.id] = new Player(data.name, data.x, data.y, data.col); + break; + case "update": { + server_tick = data.tick; + Object.keys(data.players).forEach(id => { + const player = players[id]; + const update = data.players[id]; + // this should never be true, but just in case + if (!player) return; + if (id == client_id) { + // clear all predictions prior to this tick + Object.keys(predictions).forEach(tick => { + if (tick < data.tick) delete predictions[tick]; + }); + var prediction = predictions[data.tick]; + if (!prediction) return; + server_ping = new Date() - prediction.time; + if (Math.abs(prediction.x - update.x) > 1) + players[client_id].x = update.x; + if (Math.abs(prediction.y - update.y) > 1) + players[client_id].y = update.y; + delete predictions[data.tick]; + } else { + player.x = update.x; + player.y = update.y; + } + }); + Object.keys(data.props).forEach(id => { + const prop = props[id]; + const update = data.props[id]; + + prop.x = update.x; + prop.y = update.y; + }); + break; + } + case "chat": { + const player = players[data.player]; + + const _name = document.createElement("span"); + _name.innerText = player.name; + const _msg = document.createElement("span"); + _msg.innerText = data.msg; + + const p = document.createElement("p"); + p.className = "chat-message"; + p.innerHTML = `<${_name.innerText}> ${_msg.innerText}`; + + chatbox.appendChild(p); + chatbox.scrollTop = chatbox.scrollHeight; + } + case "leave": { + const player = players[data.id]; + if (!player) break; + console.log(player.name + " left the game."); + const p = document.createElement("p"); + p.className = "chat-message"; + p.innerText = player.name + " left the game."; + chatbox.appendChild(p); + chatbox.scrollTop = chatbox.scrollHeight; + delete players[data.id]; + break; + } + case "kick": { + console.log("Kicked from the server: " + data.reason); + const p = document.createElement("p"); + p.className = "chat-message"; + p.innerText = "Kicked from the server: " + data.reason; + chatbox.appendChild(p); + chatbox.scrollTop = chatbox.scrollHeight; + break; + } + default: + console.warn("Unknown message received from the server."); + console.warn(msg); + } + }, fake_ping.get() / 2); + }); + + ws.addEventListener("error", error => { + canvas.classList.add("offline"); + console.error(error); + const p = document.createElement("p"); + p.classList.add("chat-message"); + p.classList.add("error"); + p.innerText = "Connection error. Please refresh!"; + chatbox.appendChild(p); + chatbox.scrollTop = chatbox.scrollHeight; + ws = undefined; + }); + + ws.addEventListener("close", () => { + canvas.classList.add("offline"); + console.log("Websocket connection closed."); + const p = document.createElement("p"); + p.classList.add("chat-message"); + p.classList.add("error"); + p.innerText = "Connection error. Please refresh!"; + chatbox.appendChild(p); + chatbox.scrollTop = chatbox.scrollHeight; + ws = undefined; + }); +} + +canvas.addEventListener("keypress", event => { + switch (event.key.toLowerCase()) { + case 'p': + console.log(predictions); + break; + case 'enter': + composeBox.focus(); + break; + } +}); + +canvas.addEventListener("keydown", event => { + switch (event.key.toLowerCase()) { + case 'w': + input.move_up = 1.0; + break; + case 'a': + input.move_left = 1.0; + break; + case 's': + input.move_down = 1.0; + break; + case 'd': + input.move_right = 1.0; + break; + default: + break; + } +}); + +canvas.addEventListener("keyup", event => { + switch (event.key.toLowerCase()) { + case 'w': + input.move_up = 0.0; + break; + case 'a': + input.move_left = 0.0; + break; + case 's': + input.move_down = 0.0; + break; + case 'd': + input.move_right = 0.0; + break; + default: + break; + } +}); + +canvas.addEventListener("focusout", () => { + input.move_up = 0.0; + input.move_left = 0.0; + input.move_down = 0.0; + input.move_right = 0.0; +}); + +composeBtn.addEventListener("click", () => { + sendChat(composeBox.value); + composeBox.value = ""; +}); + +composeBox.addEventListener("keypress", event => { + if (event.key != "Enter") return; + sendChat(composeBox.value); + composeBox.value = ""; + canvas.focus(); +}); + +function sendChat(msg) { + setTimeout(() => { + if (!ws) return; + ws.send(JSON.stringify({ + type: "chat", + msg: msg, + })); + }, fake_ping.get() / 2); +} + +function update(delta) { + const clientPlayer = players[client_id]; + if (clientPlayer) { + clientPlayer.in_x = input.move_right - input.move_left; + clientPlayer.in_y = input.move_down - input.move_up; + + clientPlayer.update(delta); + + // insert prediction for the next server tick + predictions[ticks] = { + time: new Date(), + x: clientPlayer.x, + y: clientPlayer.y, + }; + + var t = ticks; + setTimeout(() => { + if (!ws) return; + ws.send(JSON.stringify({ + type: "update", + tick: t, + x: input.move_right - input.move_left, + y: input.move_down - input.move_up, + })); + }, fake_ping.get() / 2); + } + + ticks++; +} + +function draw() { + delta = performance.now() - last_update; + if (performance.now() - last_update >= 1000 / TICK_RATE) { + last_update = performance.now(); + update(delta / 1000); + } + + ctx.clearRect(0, 0, WORLD_SIZE, WORLD_SIZE); + + ctx.fillStyle = "#f0f0f0"; + ctx.fillRect(0, 0, WORLD_SIZE, WORLD_SIZE); + + drawPlayers(); + drawProps(); + + // DEBUG: draw last known authoritative state + if (Object.values(predictions).length > 10) { + const server_state = Object.values(predictions)[0]; + ctx.fillStyle = "#208020"; + ctx.beginPath(); + ctx.rect(server_state.x - Player.SIZE / 2, + server_state.y - Player.SIZE / 2, + Player.SIZE, Player.SIZE); + ctx.stroke(); + } + + var debug = "ping: " + server_ping + "ms\n" + + "fake ping: " + fake_ping.get() + "ms\n" + + "buffer length: " + Object.keys(predictions).length + "\n" + + "delta: " + delta + "\n" + + "ticks behind: " + (ticks - server_tick); + ctx.fillStyle = "#101010"; + ctx.font = "16px monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + var debug_lines = debug.split('\n'); + var debug_y = WORLD_SIZE - 8 - (debug_lines.length - 1) * 16; + for (var i = 0; i < debug_lines.length; i++) { + ctx.fillText(debug_lines[i], 8, debug_y + 16 * i); + } + + frames++; + requestAnimationFrame(draw); +} + +function drawPlayers() { + Object.keys(players).forEach((id, index) => { + const player = players[id]; + + if (enable_interpolation.get()) { + player.draw_x = player.draw_x + 0.1 * (player.x - player.draw_x); + player.draw_y = player.draw_y + 0.1 * (player.y - player.draw_y); + } else { + player.draw_x = player.x; + player.draw_y = player.y; + } + + ctx.drawImage( + playerSprite, + player.draw_x - Player.SIZE / 2, + player.draw_y - Player.SIZE / 2, + Player.SIZE, Player.SIZE + ); + + ctx.fillStyle = player.colour; + ctx.font = "16px monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(player.name, player.draw_x, player.draw_y - Player.SIZE / 2 - 16); + + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText(`${player.name} (${id})`, 8, 28 + index * 16); + }); + + ctx.fillStyle = "#101010"; + ctx.font = "20px monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText("Players:", 8, 8); +} + +function drawProps() { + Object.keys(props).forEach(id => { + const prop = props[id]; + + if (enable_interpolation.get()) { + prop.draw_x = prop.draw_x + 0.1 * (prop.x - prop.draw_x); + prop.draw_y = prop.draw_y + 0.1 * (prop.y - prop.draw_y); + } else { + prop.draw_x = prop.x; + prop.draw_y = prop.y; + } + + ctx.drawImage( + prop.spriteImage, + prop.draw_x - Prop.SIZE / 2, + prop.draw_y - Prop.SIZE / 2, + Prop.SIZE, Prop.SIZE + ); + + ctx.fillStyle = prop.colour; + ctx.font = "16px monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillText(prop.name, prop.draw_x, prop.draw_y - Prop.SIZE / 2 - 16); + }); +} + +start(); + +requestAnimationFrame(draw); + diff --git a/client/js/silver.min.js b/client/js/silver.min.js new file mode 100644 index 0000000..47d4cbb --- /dev/null +++ b/client/js/silver.min.js @@ -0,0 +1 @@ +export default class Stateful{#e;#t=[];constructor(e){this.#e=e}get(){return this.#e}set(e){let t=this.#e;this.#e=e;for(let s in this.#t)this.#t[s](e,t)}update(e){this.set(e(this.#e))}onUpdate(e){return this.#t.push(e),e}removeListener(e){this.#t=this.#t.filter((t=>t!==e))}} diff --git a/common/log.js b/common/log.js new file mode 100644 index 0000000..32f9d18 --- /dev/null +++ b/common/log.js @@ -0,0 +1,13 @@ +export default class Log { + static info(message) { + console.log('[' + new Date().toISOString() + '] ' + message); + } + + static warn(message) { + console.warn('[' + new Date().toISOString() + '] WARN: ' + message); + } + + static error(message) { + console.error('[' + new Date().toISOString() + '] FATAL: ' + message); + } +} diff --git a/common/player.js b/common/player.js new file mode 100644 index 0000000..d624fb0 --- /dev/null +++ b/common/player.js @@ -0,0 +1,20 @@ +export default class Player { + static SPEED = 400.0; + static SIZE = 50.0; + + constructor(name, x, y, colour) { + this.x = x; + this.y = y; + this.in_x = 0.0; + this.in_y = 0.0; + this.draw_x = x; + this.draw_y = y; + this.name = name; + this.colour = colour; + } + + update(delta) { + if (this.in_x != 0) this.x += this.in_x * Player.SPEED * delta; + if (this.in_y != 0) this.y += this.in_y * Player.SPEED * delta; + } +} diff --git a/common/prop.js b/common/prop.js new file mode 100644 index 0000000..4045d2c --- /dev/null +++ b/common/prop.js @@ -0,0 +1,48 @@ +import Player from "./player.js"; +import { WORLD_SIZE } from "./world.js"; + +export default class Prop { + static SIZE = 50.0; + + constructor(name, x, y, colour, sprite) { + this.x = x; + this.y = y; + this.xv = 0.0; + this.yv = 0.0; + this.draw_x = x; + this.draw_y = y; + this.name = name; + this.colour = colour; + this.sprite = sprite; + } + + update(delta, players) { + players.forEach(player => { + if (this.x - Prop.SIZE / 2 < player.x + Player.SIZE / 2 && + this.x + Prop.SIZE / 2 > player.x - Player.SIZE / 2 && + this.y - Prop.SIZE / 2 < player.y + Player.SIZE / 2 && + this.y + Prop.SIZE / 2 > player.y - Player.SIZE / 2) { + this.xv += player.in_x * Player.SPEED * delta * 20.0; + this.yv += player.in_y * Player.SPEED * delta * 20.0; + } + }); + + if (this.xv != 0) this.x += this.xv * delta; + if (this.yv != 0) this.y += this.yv * delta; + + this.xv *= 0.95; + this.yv *= 0.95; + + // bounce off walls + if (this.x + Prop.SIZE / 2 > WORLD_SIZE || + this.x - Prop.SIZE / 2 < 0.0) { + this.x = Math.min(Math.max(this.x, 0.0), WORLD_SIZE); + this.xv *= -1; + } + if (this.y + Prop.SIZE / 2 > WORLD_SIZE || + this.y - Prop.SIZE / 2 < 0.0) { + this.y = Math.min(Math.max(this.y, 0.0), WORLD_SIZE); + this.yv *= -1; + } + } +} diff --git a/common/world.js b/common/world.js new file mode 100644 index 0000000..0293fb2 --- /dev/null +++ b/common/world.js @@ -0,0 +1 @@ +export const WORLD_SIZE = 500; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b389d78 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + web: + build: . + image: docker.arimelody.me/multiplayer-test:latest + ports: + - "3000:3000" diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..3376d1e --- /dev/null +++ b/nodemon.json @@ -0,0 +1,6 @@ +{ + "ext": "js", + "ignore": [ + "client" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..52ee977 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "client-server-test", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "client-server-test", + "version": "1.0.0", + "license": "GPLv3", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ea910c2 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "client-server-test", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node ./server/main.js", + "dev": "nodemon ./server/main.js" + }, + "author": "ari melody ", + "license": "GPLv3", + "description": "", + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/server/main.js b/server/main.js new file mode 100644 index 0000000..3f98b47 --- /dev/null +++ b/server/main.js @@ -0,0 +1,112 @@ +import http from "http"; +import path from "path"; +import fs, { openSync } from "fs"; +import Log from "../common/log.js"; +import { init as initWS } from "./ws.js"; + +var PORT = 3000; +var mime_types = { + 'html': 'text/html', + 'css': 'text/css', + 'js': 'application/javascript', + 'png': 'image/png', + 'jpg': 'image/jpg', + 'gif': 'image/gif', +}; + +process.argv.forEach((arg, index) => { + if (index < 2) return; + if (!arg.startsWith('-')) return; + switch (arg.substring(1)) { + case "port": + if (process.argv.length < index + 2) { + Log.error("FATAL: -port was supplied with no arguments."); + exit(1); + } + var val = process.argv[index + 1]; + if (val.startsWith('-')) { + Log.error("FATAL: -port was supplied with no arguments."); + exit(1); + } + var port = Number(val); + if (isNaN(port)) { + Log.error("FATAL: -port was supplied with invalid arguments."); + exit(1); + } + PORT = port; + break; + } +}); + +const server = http.createServer(async (req, res) => { + var code = await new Promise((resolve) => { + if (req.method !== "GET") { + res.writeHead(501, { + "Content-Type": "text/plain", + "Server": "ari's awesome server" + }); + res.end("Not Implemented"); + return resolve(501); + } + + // find the file + var filepath = path.join("client", req.url); + if (req.url.startsWith("/common/")) { + filepath = path.join("common", req.url.slice(8)); + } + if (filepath.endsWith('/') || filepath.endsWith('\\')) { + filepath = path.join(filepath, "index.html"); + } + if (!fs.existsSync(filepath)) { + res.writeHead(404, { + "Content-Type": "text/plain", + "Server": "ari's awesome server" + }); + res.end("Not Found"); + return resolve(404); + } + + try { + var ext = path.extname(filepath).slice(1); + var mime_type = mime_types[ext] || "application/octet-stream"; + + const stream = fs.createReadStream(filepath); + res.writeHead(200, { + "Content-Type": mime_type, + "Server": "ari's awesome server" + }); + stream.pipe(res); + return resolve(200); + stream.on("open", () => { + res.writeHead(200, { + "Content-Type": mime_type, + "Server": "ari's awesome server" + }); + stream.pipe(res); + res.end(); + return resolve(200); + }); + stream.on("error", error => { + throw error; + }); + } catch (error) { + Log.error(error); + res.writeHead(500, { + "Content-Type": "text/plain", + "Server": "ari's awesome server" + }); + res.end("Internal Server Error"); + return resolve(500); + } + }); + + Log.info(`${code} - ${req.method} ${req.url}`); +}); + +initWS(server); + +server.on("listening", () => { + Log.info("server listening on port " + PORT); +}); + +server.listen(PORT, "0.0.0.0") diff --git a/server/ws.js b/server/ws.js new file mode 100644 index 0000000..a387592 --- /dev/null +++ b/server/ws.js @@ -0,0 +1,231 @@ +import { WebSocketServer } from "ws"; +import Log from "../common/log.js"; +import { WORLD_SIZE } from "../common/world.js"; +import Player from "../common/player.js"; +import Prop from "../common/prop.js"; + +const TICK_RATE = 30; + +var clients = []; +var props = { + 1: new Prop("the silly", + WORLD_SIZE / 2, WORLD_SIZE / 2, + "#ff00ff", "/img/ball.png") +}; +var last_update = 0.0; +var ticks = 0; +var wss; + +export function init(http_server) { + wss = new WebSocketServer({ server: http_server }); + + wss.on("connection", (socket) => { + clients.push(socket); + + socket.on("error", error => { + Log.warn("Websocket connection closed due to error: " + error); + if (socket.player) { + broadcast({ + type: "leave", + id: socket.id, + }); + } + clients = clients.filter(s => s != socket); + }); + + socket.on("close", () => { + if (socket.player) { + Log.info(socket.player.name + " left the game."); + broadcast({ + type: "leave", + id: socket.id, + }); + } + clients = clients.filter(s => s != socket); + }); + + socket.on("message", msg => { + try { + const data = JSON.parse(msg); + if (!data.type) + throw new Error("Type not specified"); + + switch (data.type) { + case "join": + if (!data.name) + throw new Error("Name cannot be null"); + + var player_name = data.name.slice(0, 32); + player_name = player_name.replaceAll('<', '<'); + player_name = player_name.replaceAll('>', '>'); + player_name = player_name.trim(); + + socket.id = generateID(); + socket.player = new Player( + data.name.slice(0, 32), + WORLD_SIZE / 2, + WORLD_SIZE / 2, + randomColour() + ); + + Log.info(socket.player.name + " joined the game."); + + var lobby_players = {}; + clients.forEach(client => { + if (!client.player) return; + lobby_players[client.id] = { + name: client.player.name, + x: client.player.x, + y: client.player.y, + col: client.player.colour, + }; + }); + + var lobby_props = {}; + Object.keys(props).forEach(id => { + const prop = props[id]; + lobby_props[id] = { + name: prop.name, + x: prop.x, + y: prop.y, + col: prop.colour, + sprite: prop.sprite, + } + }); + + socket.send(JSON.stringify({ + type: "welcome", + id: socket.id, + tick: ticks, + players: lobby_players, + props: lobby_props, + })); + + clients.forEach(s => { + if (s.id == socket.id) return; + // send player joined event + s.send(JSON.stringify({ + type: "join", + id: socket.id, + name: socket.player.name, + x: socket.player.x, + y: socket.player.y, + col: socket.player.colour, + })); + }); + break; + case "update": + if (!socket.player) + throw new Error("Player does not exist"); + if (data.x === undefined || data.y === undefined) + throw new Error("Movement vector not provided"); + if (data.tick === undefined) + throw new Error("User tick not provided"); + + socket.player.tick = data.tick; + socket.player.in_x = Math.min(1.0, data.x); + socket.player.in_y = Math.min(1.0, data.y); + break; + case "chat": + if (data.msg === undefined) + throw new Error("Attempted chat with no message"); + Log.info('<' + socket.player.name + '> ' + data.msg) + data.msg = data.msg.replaceAll("<", "<") + data.msg = data.msg.replaceAll(">", ">") + data.msg = data.msg.replaceAll("\n", ""); + data.msg = data.msg.trim(); + if (data.msg == "") return; + clients.forEach(client => { + client.send(JSON.stringify({ + type: "chat", + player: socket.id, + msg: data.msg, + })); + }); + break; + default: + throw new Error("Invalid message type"); + } + } catch (error) { + if (socket.player) { + Log.warn("Received invalid packet from " + socket.player.id + ": " + error); + socket.send(JSON.stringify({ + type: "kick", + reason: "Received invalid packet", + })); + } else { + Log.warn("Received invalid packet: " + error); + } + socket.close(); + } + }); + }); + + update(); +} + +function update() { + var delta = (performance.now() - last_update) / 1000; + + // update players + var frame_players = {}; + clients.forEach(client => { + if (!client.player) return; + client.player.update(delta); + client.player.x = Math.max(Math.min(client.player.x, WORLD_SIZE), 0); + client.player.y = Math.max(Math.min(client.player.y, WORLD_SIZE), 0); + frame_players[client.id] = { + x: client.player.x, + y: client.player.y, + }; + }); + + // god help me this code is awful + // really leaning on this just being a tech demo here + var prop_players = []; + clients.forEach(client => { + if (client.player) prop_players.push(client.player); + }); + var frame_props = {}; + Object.keys(props).forEach(id => { + const prop = props[id]; + prop.update(delta, prop_players); + frame_props[id] = { + x: prop.x, + y: prop.y, + } + }); + + // send update to players + clients.forEach(client => { + if (!client.player) return; + client.send(JSON.stringify({ + type: "update", + tick: client.player.tick, + players: frame_players, + props: frame_props, + })); + }) + + last_update = performance.now(); + ticks++; + setTimeout(update, 1000 / TICK_RATE); +} + +function broadcast(data) { + clients.forEach(socket => { + socket.send(JSON.stringify(data)); + }); +} + +function generateID() { + // five random digits followed by five digits from the end of unix timestamp + return (10000 + Math.floor(Math.random() * 90000)).toString() + (new Date() % 100000).toString(); +} + +function randomColour() { + var res = "#"; + for (var i = 0; i < 6; i++) + res += "0123456789abcdef"[Math.floor(Math.random() * 16)]; + return res; +}