From 07e56517b098d050bf2829e8a07798c1a2e02786 Mon Sep 17 00:00:00 2001 From: Frank Force Date: Sat, 8 Jul 2023 14:43:16 -0500 Subject: [PATCH] update littlejs small changes to work with latest version and improved build setup --- LittleJS/README.md | 20 - LittleJS/build.bat | 13 +- LittleJS/buildSetup.bat | 10 - LittleJS/game.js | 4 +- LittleJS/gameObjects.js | 7 +- LittleJS/index.html | 8 +- LittleJS/js13kBreakout.zip | Bin 26561 -> 26682 bytes .../{engine/engine.all.js => littlejs.js} | 8932 +++++++++-------- ...ine.all.release.js => littlejs.release.js} | 8210 +++++++-------- LittleJS/package.json | 33 + 10 files changed, 8813 insertions(+), 8424 deletions(-) delete mode 100644 LittleJS/README.md delete mode 100644 LittleJS/buildSetup.bat rename LittleJS/{engine/engine.all.js => littlejs.js} (79%) rename LittleJS/{engine/engine.all.release.js => littlejs.release.js} (78%) create mode 100644 LittleJS/package.json diff --git a/LittleJS/README.md b/LittleJS/README.md deleted file mode 100644 index 4763de9..0000000 --- a/LittleJS/README.md +++ /dev/null @@ -1,20 +0,0 @@ -## js13kBreakout with LittleJS 🚂 - -## [Live Demo](https://breakouts.js13kgames.com/LittleJS/) - LittleJS js13kBreakout demo - -## [LittleJS on GitHub](https://github.com/KilledByAPixel/LittleJS) - LittleJS Engine code and examples - -## Helpful links... - -### [LittleJS Documentation](https://killedbyapixel.github.io/LittleJS/docs) - Learn how to use LittleJS -### [LittleJS Trailer](https://youtu.be/chuBzGjv7Ms) - Watch this trailer to see what it can do -### [LittleJS Discord](https://discord.gg/zb7hcGkyZe) - You can ask questions here and show off your work -### [Space Huggers with LittleJS](https://www.newgrounds.com/portal/view/819609) - A game I developed to show off the engine - -## Building for js13k with LittleJS... - -There is a build file included which uses most engine features and builds the default example into around a 7kb zip. This includes all the overhead of the engine leaving plenty of space for your game. You can use the buildSetup.bat file to install build dependencies with npm and run build.bat to build the package. - -The build system works by combining all the js files together, then running several minifiers on the code and zipping the result. You may want to modify the build script to include additional files for your project. These minifiers like Closure and Terser are very good at simplifying code and removing unused code. - -It is possible to save even more space by converting the png to a data uri and using that as the image source in the code rather then including a separate file. diff --git a/LittleJS/build.bat b/LittleJS/build.bat index bf7911f..fe67999 100644 --- a/LittleJS/build.bat +++ b/LittleJS/build.bat @@ -12,8 +12,8 @@ rmdir /s /q %BUILD_FOLDER% rem copy engine release build mkdir %BUILD_FOLDER% -cd %BUILD_FOLDER% -type ..\engine\engine.all.release.js >> %BUILD_FILENAME% +pushd %BUILD_FOLDER% +type ..\littlejs.release.js >> %BUILD_FILENAME% echo. >> %BUILD_FILENAME% rem add your game's files to include here @@ -33,9 +33,8 @@ if %ERRORLEVEL% NEQ 0 ( ) del %BUILD_FILENAME%.temp -rem more minification with uglify or terser (they both do about the same) +rem more minification with uglify call npx uglifyjs -o %BUILD_FILENAME% --compress --mangle -- %BUILD_FILENAME% -rem call terser -o %BUILD_FILENAME% --compress --mangle -- %BUILD_FILENAME% if %ERRORLEVEL% NEQ 0 ( pause exit /b %ERRORLEVEL% @@ -51,7 +50,7 @@ if %ERRORLEVEL% NEQ 0 ( rem build the html, you can add html header and footers here rem type ..\header.html >> index.html -echo ^^^ >> index.html +echo ^^ >> index.html type roadroller_%BUILD_FILENAME% >> index.html echo ^ >> index.html @@ -62,6 +61,4 @@ if %ERRORLEVEL% NEQ 0 ( exit /b %ERRORLEVEL% ) -cd .. - -rem pause to see result \ No newline at end of file +popd rem BUILD_FOLDER \ No newline at end of file diff --git a/LittleJS/buildSetup.bat b/LittleJS/buildSetup.bat deleted file mode 100644 index e842bc4..0000000 --- a/LittleJS/buildSetup.bat +++ /dev/null @@ -1,10 +0,0 @@ -rem Run this file to install the necessary npm depencies. - -call npm install google-closure-compiler -call npm install uglify-js -call npm install roadroller -call npm install ect-bin -call npm install typescript - -echo Finishd installing LittleJS build dependencies. -pause \ No newline at end of file diff --git a/LittleJS/game.js b/LittleJS/game.js index 5826cf9..a8f7583 100644 --- a/LittleJS/game.js +++ b/LittleJS/game.js @@ -94,8 +94,8 @@ function gameUpdatePost() // called after LittleJS objects are updated /////////////////////////////////////////////////////////////////////////////// function gameRender() // called before LittleJS objects are rendered { - // draw the background - drawRectScreenSpace(canvasFixedSize.scale(.5), canvasFixedSize, new Color(.13, .15, .2)); + // draw the background without using webgl + drawRectScreenSpace(canvasFixedSize.scale(.5), canvasFixedSize, new Color(.13, .15, .2), 0, 0); } /////////////////////////////////////////////////////////////////////////////// diff --git a/LittleJS/gameObjects.js b/LittleJS/gameObjects.js index d85d828..3c4d5e2 100644 --- a/LittleJS/gameObjects.js +++ b/LittleJS/gameObjects.js @@ -16,7 +16,7 @@ class Paddle extends EngineObject super(pos, size, tileIndex, tileSize); // set up collision - this.setCollision(1, 1); + this.setCollision(); this.mass = 0; } @@ -63,7 +63,7 @@ class Block extends EngineObject super(pos, size, tileIndex, tileSize, 0, color); // set up collision - this.setCollision(1, 1); + this.setCollision(); this.mass = 0; // draw smaller then physical size @@ -110,9 +110,8 @@ class Ball extends EngineObject super(pos, size, tileIndex, tileSize); // set up collision - this.setCollision(1, 1); + this.setCollision(); this.elasticity = 1; - this.damping = 1; // set start speed this.velocity = vec2(0, -.2); diff --git a/LittleJS/index.html b/LittleJS/index.html index f1d7d8b..33d76da 100644 --- a/LittleJS/index.html +++ b/LittleJS/index.html @@ -1,7 +1,7 @@ - + LittleJS js13k Breakout Demo - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/LittleJS/js13kBreakout.zip b/LittleJS/js13kBreakout.zip index 8a078d29b5729ffe9e648fb2bb8b300ff4f2bb2c..0df3acb6e5b1ff0cb1fb3a323d7e211934ac535d 100644 GIT binary patch delta 6735 zcmV-V8nETT&jGs70e?_S0|XQR000O8M0My^9y{0{#~AMd?crIvk zZEO@&LmKvgdL36gNXtUVlpznhKg_dN|5wZAN_i zL-OL-SXFW7&ra;>m>mf(ybvEm%vIk$A{1%r%NvN@XvX~7e!s)&?=zSZo6;$|b}Hm) zn9Xx|VQ9lo!VSrSW%IcS^s_P7ge6Tj348W%&5@ix=C0{-+ltS=?nRo3!MZj(wZ9dB zxFWf{JQ0)Je1DOCRxvn6@A79EFf#HRwZLab=IViNJd;v03nZ5kf%mzN5ORkg$PlCM z?V?ac*j08O0j|1WCnO#`1eQC|K2XqczE>)YYfhTCj}Mj!IOJGLcq0ydPtc=Mo_`4HTX0P6TWyxPW?(Vzj9>4b ze_6uUUq8305cf;WB@sR{j!F!O{VfJghoR_Qes}clWYiClIjd?%{gR@A>63^W;`k9V zLmea@wiZuj(A~=t5g8o{WSZ?@m$DW{luWg_$+dHMsFAl#{=ks#=MA|?>%LKpAVq1c zuXFNdDu3$A@M~HThpa5z#0Ah7@Apf}dmCOZrJx~-8w+E+)G@9k6JA_;oi3w_2$_ah zJW~YH2g_5ZA_y#=(=oe5qxpuU6}Dhj?0@VPvx)YM)o0wxIJfrfc><8b zi0_uK#Y+(;&J3r6Ep{yTZ|y1_cBhRpSFdiRULA&v)ZAnQm6~snj=BiJ^5_|rabqal zL4Uwy?eQQG=mM zBOYt95DkGUzrSHQxgI7dV*`ir*98!qlN@vJe(i`J4}hcfX)nMzKrMs6LlMzh@2+MpmFr%I)YM$3OaF@wS#<>3@?#GV>~zRFiy15}L`uW{cFowo`x?MNSGYsTyjP zdiiZ?62@dBvkSup)D6csN?eufTu0{<#+gJT!uz*&Bw2FtFaVhlp7D)C^xb9aR7$&~ zyue2;){A_i*;;q*3@|^_J@?A39w!Rz*6-qyvdYNqYqsuvJ8wl|GSC=qO zpo*IM%jTd$3|`*NxG(<@Ib_pL4uG;Ew=p_eT{RU^zNd%t=P)(ZWC3OOvufIwm+~4) zU^wvS)aiAXh9L+w<77KJ4TVopH*Axy%Ft>Jch=7M0+)k^!YkzDf<-G~C83#`J0axl zXt7+dW1Ws=J?S7I%WPXVfPX%!lKjfXW42ET8mGEw;|X9DRs0l8N$D*WN=A-!LzcE| zznLB;W!M;6V>IK@o@<%v`)F%|eYAbr8N;c#`42>q9@SFq=-$!0G2i_<6=7=z;$tE& zF7i8rMgns+sx_hp2JdtkK8|{JHJ?m>{d#L6&bXJIp?|7^jSdsQ5vLuC z_s)5h9FB2T!T4`64i_<JMx)OZKw&G{AQw`mIm#~p#VMSQRntvt@* zL({ThP6JCA#z6`@v2Lsa1=4sLZ*X%gfAe6;Mmt@)96Yvte&@X(AEH3Z)hoaR;0krfkuz#j1KX~mHt^x!Qo1qo< zDu~VBsR^8Q)OH~4p)X@3x7d(zS_P$7o3`G{oMd3>TYzJ?qR?A`GMtA{EPQasnUj52CV~)9>dG<+J8X0KbbfQh#fS`cRpHfD1V`%A0c1n zGk+aULn$H5pnn@k89@QZ!AY6ft5Kb72U+%uO?I8D#=(dfQ8hm^34R1Gx;kIa^RY7( z2RF15M9)rn-3*{Oesq&X(vsM;zE zQort@B)fd%M*sp=cIX*|&0|Fl7dO}S<9+xCoz4{s-j-D-KP3b?8SiBXjQ& z23W?@+>(h(SfIjXqRj%(n-T(8WSiKsGr69=Ul*T;`tn>o-Ft(1I=>fg@wG~SuutS* zD2PCS_B3NHnDNG_mp++e&erW5C9&Cs@o5%)iqn0OwTpME5+Z26<)i}FnhB%~9RkqBwU~WH z$9Yxd+VVHFV?yKK(!-NeMqWqaYpxsZJ~^?inOqn4Mi~gWXG@QZ_WS;)x+ZR_s>?O= z%ble@2iN#LRxde|#_6i5TTg&n&Rvw&Fn=~6`G=3)vX}P)`h|6MCb-jUdOZl*9UuKd zK&X77!lXFQ_HY5R4RLGB{J`QIg0mlx)|KW}q7M6r9}<)*i}oebg1VHAvN88IK& z;VXh7)&cx;t_u1&_&4*E)Z&ztZSII)LvvoVhKKL(QEByLFh!Tc7C`zTpPT;3=lGoL z$zt3&!_ttq4vPt^?ePxr;RzQ|27lU@JRWs4>?jiZ5F~Oa!ZRw z>^ggS&trh@b~(7+4?vPhXGkntxlM)c{xvlgkl`xMv{lzMLhbOFZ${(mrvV01jQ z9M3bCOw93U5TV3ZGtsBbN@qx(UB;gD$>yWglMMKIU2CcC@q{L!gb;r9bd=k{WVZIB z1ci*OI`z$A!Z@@Xth5%uA(7us_UjcvA2x$BP^_%uQv-RyF?%?NxU(GwniW%JABP=3 zQYoDm1(Jut<+PI>Y{8Jakbh^tSFP|Pxtv&)430ulzLKMV@;LLFn4}KCnRL2=Hj}5B zFCKrN;}pK$Xg`&U*Ti`5Yq=r&G<#vx4Mrm{jOsHKNhR^VSzD5c3R7g42>L|(#OtGp z*qV+d%jVaU^)>$$g?<3JwA@}q1qu1=0TdWc`49_Mmu#4D?=$a|Ie+$CXq;Kq?B=On z_m}diAG2+q$GC16C;Fbe%UTfq)c94(^8L;|O?Pg=d5VE`i<2f02&t%!QJ!%5A`1Z~ z7#OD`ZS6HsW`hDS+u5kw-?I#SsWi|rt}Igqbw9vpM^0KgD4Ddm1_NaLLcy2oA54Il z)Ek)&Y1tOeEcVSK;eW}Q@nzh7dR?S2FIC#cBM=>bVq>Kem{xE!+WE19*sPpYfHn(_ z@9~6sz%mW`Q;Tnls*C5x;#bh)on5O{T?Htz46X)4@Q@FxSIkw&ARu-`1EPn>NOfw+(Soh9NVmtDd z$F2ZmV6Wzw`6|WymD|MVSwl3_Q!M|$Z}tkF0+ZT>f^iHB1uUUY29WU=l~g`s64Zl! z?e^4M8?e!OT2TK~09gzz@9j5*9w7mc=V-mPL5l~Ptx8~?s_eWCZ_OP`LA{a}c&<|R za~ff&qOwFn_M^i<+&Sya;!tF8P$qYao)mqD<}|-EC*ma;tE=a*g@HE0_ci z#Sqd}7XixYg^A`bZg7_Q%cZinLx7aI30|ZI`DI@V#M5uVFy9+sCdo&quecEXq5|KUoEmSS zKdG&bH6MT`>6%DsA_7YYU!42?#Rl((8rZMduaKUuWK1&})bOE4^DzizvyaCXFoS@^ z7iHr(4z7Q?th^Wr)Q$#%uvt~yy3<6leX2EVsdLSF8y7Sa4;M+RJ-b6e$cFUYNBXkX#8QA zwEP{rc<}yMne51P`k?f#UxWqcbGB0TDIzka$2zq{eHdwZ!#%2Y|DtXJGsn1}zXXK# zpX#|`g)!vJR3~d+ui&`Jl-UBm#K55k6T<1LCx1D7b~LU%PDh@%)eOy7QaLjcl!R0i zX&Y3pPqa=GnJOhhQjE2QAMnqf@q&JeJros1oWOZfrj;+%K+3$SLn@rE0ZdzSU4{Z< z?|aTzhwzvM?^kYN>@`k%5=?Mdc@v4nqh>uUxY6{J>L~yiROHKDZ0UR|vbFU3Fz#$= z=6@LmF^D5{-j>M6pXSqI*hK5SxEBg8v7=32gWUGmZSQr6i5z6KlJ5Q`Ib{Lw}r>cB`*CY^g-HE3TIJbARt6I!l$)5YYT#-4DN0*o&2DI@Cp>tw5U- zi#J&{4Fv0$IZUXB6l(lZ{z|W8l8^wCop5n{EB2{=e|?OAjLg7VevmTMRjksf;pcG# zJqwCocvUV|BYIvdyva{_$u1Qf&%IxDJF~4jx>z58pQFF2Y2>f7@B636NbpM|Du0(> z+r!NhQR&@e)f&UuEPoq+eSJ9PE5Qpk&82m~P|LWeu+GjKNUVZ(sBlpOE90s`FMfW# z@VqT=V+pOmk$lKAXHR@jrElW~jpto+IupegsuOq->XDzZKI5JGwn69;v`y)8MqM!J zEHhJ0k!&GgO^BZ~3UtFS)5#Vdl7ER61Egp9YHmq#XomA1*+U(lk4!Wjwji+Mwmq~@ zD(|eHvnkyXYI(~k z^RN0nKkBEr+_M(Yt0adUqz7G7cOxRP`TWr=Q6AwEto;5=x*YLAFrp_YAyJxjXFRP} z+7u=R4QP$FNp{BF^08$-tbZ{oRo~mcRqHMbh5N;LAAqfmV1#l};gTi0vT5d~uaNJ# z4^7}_<-skU3_W4KL>kifc*4!=iv?%)*CSOD7_Py|*Xt#h{p|Qo)(+eq(U^7-tE7x{ zRKtDyy6{tMFqjm*w4!TLov5_<%Ep&sZxwTT}4=B ztu^UVHA|;Pay!~wF^P(AS!8U?kGlw{S&Ug0T0;F+rb>}YFG46EWt|ln(5DH_3l0-r zX+lLFV>L*OYvWv)AwQ7{zC#li($0=3{7TqKH%I3I!!6w(n-?cE>($Soq8C?OWV5_w zQFtipG3CdgVH@k{Vt*m9a2&31>~=Wmfl025S~tS24HwR!gs1^n*4l{r3-+05W3m(* zB#iE`$7R=Mi{n=W1;xaeo2}esi`2@_`b1uIs%>Zr?~No8pQ;AU?A}TRD&5d|uU=%o zWuq*4oVxOtlR?&ots5>#zjkN~1efY^$B>sKwSSK!5|5s8#C|V0sk4&> zhC~#Y>eQ}noQjTM+b=N_(5{x;d#lA`OMLz<8O9erg`QtaY`fhlN9zU!E>2Hec#I^W z$}PV9yC{m3B3s&morH(lZ`4-yzH9d2?&PyVLOt#_OM!_Y<;?DL21J zje0okKIKE!!hf#j51Ogl3uws=vx1j{jmIP!@VC;X&tj7F33dYpniJu{rBS~m0;mwm zn{7+oY-UKXu3;dw!HJCf{H+N8Wey`dw40LX8)d{~J|FLtgS{s*hxu_+qho>^{FD?o znhV7Sorn+&kfp7@O>qA$0;Neba6e;BUntwYcZAtX@&;bubzm=n=Ptk!DN`!Y zrm-T0u}QoJUK;lg3x<)tvmhsrCi`u;M3g#J8oGEAqORq}L~q1T>Q|p;ZCW~zY1`c} zb0XaOT5 zUq^TW4TN%Jb!A6lLOx~Eqp|TtC;H4BR@eYe$?1$dr~*KYp}qF2CP*m!c>gUDe+z=B zvLBFlFUEQx^>o6ZWZ%bav=BLb825-N8Uh8de}9J$m_FP2!XcsnjyyW5ud4HCG$grvWnFqOu}RKU_w7$dn=`PGkEFGY+UHj{nKxx#vVy}iq`b^ zrYl3(>U89&ehW;qLRPbz4)L^KeBFFqL?cVuEC{S#uO6uxbR+O^Q#M ziA~$fIU6}kt*8h4Dzn6&t6gXWV+f3>x_<~0TXqM-24zg;a3j?5fC!pnulr=jt$)3| z(L$Q3604kwSXGgb5hK#$ZZf45QBl^f!6Sp;mVq_l|#a(C?rl)rey8Xo9!28*(p?_uK&o}dy+ClY2Df-s^hvIuTVUXrJ)^&%Gqa^^` z-g%(YzeKc6ikmS&J%7+J`9Jcd%#e`g5a;}Fsjl}V^!NP57HLp}nx!fBwt=w`mkQc7FoFPpR3= z`tVF(uOY~ZF5lj_AYf>AJ_N_rPQnZw!ZF@_O6Is+MLFrdnKNhZ4Y4) z(QLk1g|}J3d2RC zla$5cf3~@Wl+`+r6A(qTLJg37B}`{DN3ICHl9iZT;r28+1gVU5jfi5fgLgmu8fRx; z88U$h$jk!2$joPC7So=SSmRnu}XVFvN$()sTGmy+=&!u5C_mUpJ)#^fHBe*Pk9IPZ6?Q z_(hTL36UA`-Md?crIvk zZEO@zLl(t=Vx1Q|NNvsQ*w%|qY};6Sf2UXY@2F4C|NZYaUBbVNi*TCL<$u;C`?r7p z-~XQDsT`L7{mVc7qjU?Ke*?|M>@EJaE#)*?%#NpCUW;4^xsgnhs$0 zyt@VzPm_IzM|VK)n=6_-0TpvI&O52P0&+DDt9Xih354&SFRxxK;2KW8iXBm<{`^Tk@r>=gj_6Rdan@)II` z?x0xNeGyu!!f5;Kk}fxwyJGC0=8J9##SvJ>%Qt9$6S-GEfZ~f1=!))vI?)UjgUuZY z40uHnqJI+9B_Jyjs4nQ**2t||)gI`l1Z#PR!pk2Cp7nuhlfN;A!Z*?x^TFl8>XC~W zA%eX)I-*&-Gv#8#)6xWDE-I^1W09gK;ShpXqy@o~P9wWm8E?}|ub*CFYRqcE} zAhiq~;b@#z5)p<2f>|x}9d`}OtN>V=AAIzs(0_{E(rWAo#O+9jtobGLx!mF&I+>1Y zv&G2C7lFIEyZiHU(~GxLBsp5nSNsz8beRW^@B5UtbZ)WBYKVNrfO&DK@iW(v4v&?V z1l@u^$r*R{09IH-z---eH*WWs8h5ctUI+vD3E1RM=Y(+I;&~`V9A4f9bJTe}B=*_; zK!2FG9CM1O%fln4oJ9%4Z~#HJZ>ISn!pCqFGnczjxdhyH)89M*6JJ&W4i8}&F8FIy z6lGKX6+wF!c@3`X{7PYEp-^J~S$cgZbY*(Dx@ZM@T{?WNkPu{(xNI)cx`|-aFx_5F zg@wWZw&K&WcZp4+$@N@-lZc;WzT!#SK7WgaPv^fanQE@BGqFL~$Wfl+i;Lw;_)SA~ zAmY;bt`Y2lS0X4UntfrCP{WIQIWi#7ApfeZxft|Im;3XZ%(<*eq-=b$;MUD93As1g z7=mW9Dmm^9G!W}CM?d$s)|IIc1vMRR4(=n|PiyYS5O}s1#nuP_@RzH*j+AxSZGYly zcv^?ZKb;GS{B<49g1x-?Y$I~2G8FCR;@61#Gl$4T@N7-_h#75eyyBCC^7SQ0!>%Sb zmPZxWWc_zbA>aUYx#rxfF_PbLe_*2LPVYG# zyw6uta4L66p1NpacUkGz-1g#AX@AE^4k_B0&ObB-y{aHXX<`+UpDA92t9}LM(xBbL zrJ=c!aI}xRBjWIiix*?OS;9KHMEEaUAP?%5y9MMLQa|2i$m?5+I!(y$I@Vy1#k&Ew zYh_T;7St;}j5~j05^}fZ=S9x2H4GrgYm2czuYy`)geb5Ugo^4t7IfnAet*h+)mobz z0f=PIR#iYtTIj9M&5&4=_O@0D^>rNVfHd7*!RskWq)yaUdw0#ToVv63#yW(bj8D>k zJKu8V;5No|V^fH(Kep=>mbI1V*QrCoKqC7}d#EX5XR%`SuxPqIF&Io$Vje&S7m`)h z6O`Z0F>o}0z>fwTe({=Fi+|q6S4KXQk#K&)#FCQhW}DGCXiTQ{sHq+^?k_%obiuCr zzNA~tZ~MfE55YJLODI~ZCenc~SY#JFLP)+yvSn`U=lww3QMggbIO~@9y74541zEOo zHyF}qcYhxgDA4871(#n2IsWXRi8u*@bob697ca;ADCD+JZDiV)1b?v4*WIF(-5ISp zjGroq4m4#U3rcI!|LQ0yUUayV++BkePJ=LBYcR@yJ3C}Rb_)r%U1|R;C4NjX#oCfU zk^LkCi_B)`>MZQL7;p@-GeT4odOXW}Q2mPe0SA<@;dXHt}c~O7eVUYN5 z9jUvIPWNlFslND`4u3qNn^nO4h=z`d)R3vva2bgiy#;zAci4#OekGeHT~i6Aw8Su2 zhu0fIub+;+I}Fa^J$weUjgO)-?*J{S$~EuA2u?#s;Vw8Wdc>&g6&l4XXd!e7#Ci19 zbI`aB7@)M2Jlvrm-)!nTq>WFXf`3XftKx!R?+BPD67b;N(SO7o>JT=-OqRqXzWWg| zRhfog12F2yAJP0bn({sliJ z@*@aeQk}2WYp%w89J==NAJgf1rlIXT=DM~xQm3QjvH77)&lPT_8ONA!UM%X3lXX7r zbcM>_Kl$F{sDBGQ;H85Wgx)-|){txZ2&v>nS5Q30A|4d=GMY8X8S@apQAb+L^2g*j z=4V@n)QxWrMZf$3)fda<=O2<#j;GTfuD+3W-LoBdS zpKUNe>Q-|YU8#KojziU4%OJ#_DwEuRwCBXfqjC)pCN~kCDOCzx1Q+kPd+0hxIiZ*s zC9CaFbnhsbQr2HUhDYLho5^HJqZ|oF*n`2gxqmDH&1g_oB=${_mOVlOK9z+tn}5UwEAgbUA`2m-mItl40kW2$fn?N#<^6vfD6_D zs(*+3!jZi-#p)hCNHAG}m={=bBzlwhOG&6i4vI+!Ei!Ru^P3~?lKOJD5W|KuvzOX| zg?bX+5>OQeGg?&;zVVeQhfqRAyElY7rs+!PNfCln07M*$L8@84dkE3YDUIRh`3O?* zLa%zwcW5FPkqKIA`J|8*!#=pszT(GwjDLVaYKq>I$)?EQuvG$_Hn zS$3X&Wf`UAg(H|OWX)C5P9)CDB zFZp!593cLKhVvEFic+PUBWGK;z-Lmrf= zZbP_6kgmtrte{LL5=O!K1Cji1UVqFh&Z9)%4pH{5^a;tX-}rWp9$_8@qSLb`)X&C< zni2xb`KyO@=d)>S5LB*71ULY{wrBJGStlX1w2E~?1O+#+O&@+t4Y zG^k}#%)}adAb`}3PJ|C0JL4c8-+)ji_`2!4o|H(bNu&@RQCM{NBnGu&M8z=OE7{(uuq*?!}A~+c>7({$d1ogC(9%Us+=~{(JmF z&eUo>`Q-juoehFGW=0xiD1W||62l%?qUAx-TI2e4Cm&Vrr2W|jw*ENDez07-h2tnA zhx?kfFV6eY#(BNTcumW=;!u>tuB(YydxH@|bS}E1hSfl}`kF^6%I=yz-2CoF`zOR} z;3^VQE;4p$CDO0`nUEq+M!j*fd4j-JW0{YwYouf(y@wqcCtSbW1Am29F8bVZ=^G$n z%+_#amAjc~aH^~I5D99`=x2^6#GlyRamZ@IP__A6dBOBOxkOnhi zPo*>~SUr7(;1kZ;;~L*@9H|d^qDqvcf=W!9AU+?wJ>fzw%trCbhZnZLzSIw0K$`CT z_EwWXg;vOz!dnOec7LU38#_CaLEJu<4%>tmBIVs2Y*e9bAT~l{-54z<-^AKg$%&v2 zV(0az-IUItHh$Q*#?3d*yJvUOddA^;feOqOLxz$(()$Q39)wfHJPvJj*8Jb0%A+1&(5;B0_;}OxdtRun0vP z?f_LM?}sYv28xcxTK93aSvD7VDjG+hV)=Y(Q7EK-Mle%E1bpkvH2(gkOt~-gXeJdc zuoUB4MJs+or9IQK8pd%PsDF=4I|1lPm&7$1&^Z{8H*zhr zqI!rPz$e+q+r~R>Qq>N)6v>JrR(xY?7L|J_t`{P|_%v6=JlNbWmZ+<&U*MF7$E{3- zdz^(I75pL(Wbpgezx=PuT?@KtDsTCC8dd0rjCKl=0e*G1sac!#Dp3{H9E)XzDg;{? zwbd5VI)4}gdwVaLa;F-?yv(KRma*++mQJ2p^Qx`=Zo|oJA#E9T-5;PaBJey9wcv9# zX%GD^S+*fEFCBku6uG8vm4=_lLB@c6i z+Prh3NdFw2jK9byS)rXYVzN)SVsm!Zih=flX@7;W2Kl;PwhXWRc2`L(U^Oz&>V_Xy z#BfhD&Fjko;&vgOUnd88ds0$;2r3Oi7>nNZe&BF>kaw&Ka_t!ey9c`}zbWtiox&ko zvE{QM#P?v}mgst;c&s^Oqb_!U;UI|GrlyjBK-vUT3s?B*65lu11Q^yLtdc# z<$p0&h*Tf1=uf_$1~;_*_j^BvuWcGKYvW59zBqx7mYhCo{DR7kon=78O@|->?FmK6 zRJ~PkCMf!9T8r9lj{-rYwLok!D5?9ion3E&zUy#_6Tz1D0%`c!n; z`HY4d^7qlZ4%_&|qJmjifAGibbM386&wm*RqfTV?{lr6IvJJW1&xYEjxG@CL*qFmML_Ky*rKixP|MXHe7~uHpsN{vq4%tGC<1)(!Z=(H5d=-@MEC3LF6)BS>$Wp^_mUmkElO; zHk%#$dcu=FOz^xOw5TBvPKwQ`4oy40P$)Ut%VfVOPPW2PmRMh^S55=0K2h|mj(Lg< zBL{L}fB=Bxu0m)QB-%?>WW~*vyniIe9uq3P*tZrJ%I!gLR2*W!bi`VVyMVIMBkr+t zZ`{#k1P5*@piuwPCUz=N>(z%L4w;!1+omUWq9$FLke?-U++=4oyqEKtq z>cm?pNT*#P9OTdf`^J{(!fBbdpVEz(dRJA;#%%MlY!=D~fO|Dr_|3Dd`h}F7 zX3Nn^g6R(nAv3yjhuLvkOBowj4t|sXKXnz-g&Fq_JnyTD7rlboI%j}*?>5F?vPDN6 z#7*&)PiUuJGHdGSrGKT8-|rnwOhG!qv4#7$f4rkxmPAqeWj{2mMt%*ewp0PGsAt-x z6kN`NicQ6}?Ub$Tilf>?aKb)fehD!+a}6;oUt>+YP@A*n$JhS6m#&i@Olqs$_q>Pl zxD7HxgiFt%5PBZPTkj*giHyRc7Lr$;=|kJ9%uvv*D&G#8(SOpd4;A+*yUh3j+(06` zGMS@?;M_BXfIDp%-UoV#Lq2yfI<{SL=R=q*hGtsm9v^~OrN@If1_iH>5mKer@J^Sx z3w+|52ERC4W4qcz@*Gk9V>i0QaL_{gGk<{rg5vmhCaM>7}v&_CsHe{=bf z8i6M-)}gt7VH{hBN0KPC>l55niu~v2DtuAbO+k|Pw2LGmo*iHpJslfn=pxc}Q{8Fl zVcpk8Qvg!)-i5;L{Re6)+WROK*M}fyBTn8d=%XYts%2~>Pbf+r3d|7qsVZG*+49b- z(jK#41%DrJU6EVX!(in+vK}7}2go1lY>~pa)RVBG_e*k^hsKHtl`Qwf4T8@GlZyWK zyCy*p@N&Njy|u=PuJ-Pp>HXq}FU+U(LevRLkS|2FPoUjjPZnw~SI z3?6fAT>z2K_}OjW{H#p%p;B#v*z9@eJMI)=Su=YFN^qeh zb}KVk5(@0-gT?nn`};%;hzTnBZOUp)6$`#>K0t_%oe0E8wgzO$9enIm_>N?Zo>>yC zY=7tzyP-W_)GNp!_ElljLv7^z5YRi^z?KwG4<#3S^=d$GYW~6PLc;Z?cf@`05YPY_ z>q5<~&5GnErk1r&+~-Lj{j#TTj`g7KU!lk#=&WbUR=64mCn5rqX1)n7(1sDXwu`D9 z51IZulukc!6hkJ!!Eq0>avY(Bt7GSJ&wo}2(Js!t=EPRD)P-P2;AH98x6>6bJWN^^ z8ql85oK_7q!$9BMG&UE-y}oLWvmTnn>Ik#T^p%_bE{uG7ofBYU2^rPeJIuqDxzjDP ztA;pbZ@Hl7nqo4KO-3RK=sE^;vf}h5EDu%SlTe~XsQ-6(}X-kSc&Xo+M zR*0HloUr+m5eWa#Bc;cX`M)-eLVw3jAowX~HnTp@4E8mIm|d(PMyVrv0)*SYCk|-$;O&sRg3UMyZ?9kZM%{Hacnyv8h$4~pRemU#7hbQICVz13XW3{V z{#Cggdq@yJlE`d#(Zq@90_2cANp_7)kU_WK(;VeV#uD+r*xW+K8a^~>E8|Qae9i77h*aT$21qY5h?%Og=ovi?5DI>3_36{^bJ-9a!V{Hd||P2rn0Vyu7ty72|G?-|lf4&7i3Rht|re@+ - Press ~ to show debug overlay with mouse pick - *
- Number keys toggle debug functions - *
- +/- apply time scale - *
- Debug primitive rendering - *
- Save a 2d canvas as an image - * @namespace Debug - */ - -'use strict'; - -/** True if debug is enabled - * @default - * @memberof Debug */ -const debug = 1; - -/** True if asserts are enaled - * @default - * @memberof Debug */ -const enableAsserts = 1; - -/** Size to render debug points by default - * @default - * @memberof Debug */ -const debugPointSize = .5; - -/** True if watermark with FPS should be down, false in release builds - * @default - * @memberof Debug */ -let showWatermark = 1; - -/** True if god mode is enabled, handle this however you want - * @default - * @memberof Debug */ -let godMode = 0; - -// Engine internal variables not exposed to documentation -let debugPrimitives = [], debugOverlay = 0, debugPhysics = 0, debugRaycast = 0, -debugParticles = 0, debugGamepads = 0, debugMedals = 0, debugTakeScreenshot, downloadLink; - -/////////////////////////////////////////////////////////////////////////////// -// Debug helper functions - -/** Asserts if the experssion is false, does not do anything in release builds - * @param {Boolean} assertion - * @param {Object} output - * @memberof Debug */ -const ASSERT = enableAsserts ? (...assert)=> console.assert(...assert) : ()=>{}; - -/** Draw a debug rectangle in world space - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2()] - * @param {String} [color='#fff'] - * @param {Number} [time=0] - * @param {Number} [angle=0] - * @param {Boolean} [fill=0] - * @memberof Debug */ -const debugRect = (pos, size=vec2(), color='#fff', time=0, angle=0, fill=0)=> -{ - ASSERT(typeof color == 'string'); // pass in regular html strings as colors - debugPrimitives.push({pos, size:vec2(size), color, time:new Timer(time), angle, fill}); -} - -/** Draw a debug circle in world space - * @param {Vector2} pos - * @param {Number} [radius=0] - * @param {String} [color='#fff'] - * @param {Number} [time=0] - * @param {Boolean} [fill=0] - * @memberof Debug */ -const debugCircle = (pos, radius=0, color='#fff', time=0, fill=0)=> -{ - ASSERT(typeof color == 'string'); // pass in regular html strings as colors - debugPrimitives.push({pos, size:radius, color, time:new Timer(time), angle:0, fill}); -} - -/** Draw a debug point in world space - * @param {Vector2} pos - * @param {String} [color='#fff'] - * @param {Number} [time=0] - * @param {Number} [angle=0] - * @memberof Debug */ -const debugPoint = (pos, color, time, angle)=> debugRect(pos, 0, color, time, angle); - -/** Draw a debug line in world space - * @param {Vector2} posA - * @param {Vector2} posB - * @param {String} [color='#fff'] - * @param {Number} [thickness=.1] - * @param {Number} [time=0] - * @memberof Debug */ -const debugLine = (posA, posB, color, thickness=.1, time)=> -{ - const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2); - const size = vec2(thickness, halfDelta.length()*2); - debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), 1); -} - -/** Draw a debug axis aligned bounding box in world space - * @param {Vector2} posA - * @param {Vector2} sizeA - * @param {Vector2} posB - * @param {Vector2} sizeB - * @param {String} [color='#fff'] - * @memberof Debug */ -const debugAABB = (pA, sA, pB, sB, color)=> -{ - const minPos = vec2(min(pA.x - sA.x/2, pB.x - sB.x/2), min(pA.y - sA.y/2, pB.y - sB.y/2)); - const maxPos = vec2(max(pA.x + sA.x/2, pB.x + sB.x/2), max(pA.y + sA.y/2, pB.y + sB.y/2)); - debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color); -} - -/** Draw a debug axis aligned bounding box in world space - * @param {String} text - * @param {Vector2} pos - * @param {Number} [size=1] - * @param {String} [color='#fff'] - * @param {Number} [time=0] - * @param {Number} [angle=0] - * @param {String} [font='monospace'] - * @memberof Debug */ -const debugText = (text, pos, size=1, color='#fff', time=0, angle=0, font='monospace')=> -{ - ASSERT(typeof color == 'string'); // pass in regular html strings as colors - debugPrimitives.push({text, pos, size, color, time:new Timer(time), angle, font}); -} - -/** Clear all debug primitives in the list - * @memberof Debug */ -const debugClear = ()=> debugPrimitives = []; - -/** Save a canvas to disk - * @param {HTMLCanvasElement} canvas - * @param {String} [filename] - * @memberof Debug */ -const debugSaveCanvas = (canvas, filename = engineName + '.png') => -{ - downloadLink.download = 'screenshot.png'; - downloadLink.href = canvas.toDataURL('image/png').replace('image/png','image/octet-stream'); - downloadLink.click(); -} - -/////////////////////////////////////////////////////////////////////////////// -// Engine debug function (called automatically) - -const debugInit = ()=> -{ - // create link for saving screenshots - document.body.appendChild(downloadLink = document.createElement('a')); - downloadLink.style.display = 'none'; -} - -const debugUpdate = ()=> -{ - if (!debug) - return; - - if (keyWasPressed(192)) // ~ - debugOverlay = !debugOverlay; - if (debugOverlay) - { - if (keyWasPressed(48)) // 0 - showWatermark = !showWatermark; - if (keyWasPressed(49)) // 1 - debugPhysics = !debugPhysics, debugParticles = 0; - if (keyWasPressed(50)) // 2 - debugParticles = !debugParticles, debugPhysics = 0; - if (keyWasPressed(51)) // 3 - debugGamepads = !debugGamepads; - if (keyWasPressed(52)) // 4 - godMode = !godMode; - if (keyWasPressed(53)) // 5 - debugTakeScreenshot = 1; - //if (keyWasPressed(54)) // 6 - //if (keyWasPressed(55)) // 7 - //if (keyWasPressed(56)) // 8 - //if (keyWasPressed(57)) // 9 - } -} - -const debugRender = ()=> -{ - glCopyToContext(mainContext); - - if (debugTakeScreenshot) - { - // composite canvas - glCopyToContext(mainContext, 1); - mainContext.drawImage(overlayCanvas, 0, 0); - overlayCanvas.width |= 0; - - debugSaveCanvas(mainCanvas); - debugTakeScreenshot = 0; - } - - if (debugGamepads && gamepadsEnable && navigator.getGamepads) - { - // gamepad debug display - const gamepads = navigator.getGamepads(); - for (let i = gamepads.length; i--;) - { - const gamepad = gamepads[i]; - if (gamepad) - { - const stickScale = 1; - const buttonScale = .2; - const centerPos = cameraPos; - const sticks = stickData[i]; - for (let j = sticks.length; j--;) - { - const drawPos = centerPos.add(vec2(j*stickScale*2, i*stickScale*3)); - const stickPos = drawPos.add(sticks[j].scale(stickScale)); - debugCircle(drawPos, stickScale, '#fff7',0,1); - debugLine(drawPos, stickPos, '#f00'); - debugPoint(stickPos, '#f00'); - } - for (let j = gamepad.buttons.length; j--;) - { - const drawPos = centerPos.add(vec2(j*buttonScale*2, i*stickScale*3-stickScale-buttonScale)); - const pressed = gamepad.buttons[j].pressed; - debugCircle(drawPos, buttonScale, pressed ? '#f00' : '#fff7', 0, 1); - debugText(j, drawPos, .2); - } - } - } - } - - if (debugOverlay) - { - // mouse pick - let bestDistance = Infinity, bestObject; - for (const o of engineObjects) - { - if (o.canvas || o.destroyed) - continue; // skip tile layers - - const size = o.size.copy(); - if (!size.x || !size.y) - continue; - - const distance = mousePos.distanceSquared(o.pos); - if (distance < bestDistance) - { - bestDistance = distance; - bestObject = o - } - - // show object info - const color = new Color( - o.collideTiles?1:0, - o.collideSolidObjects?1:0, - o.isSolid?1:0, - o.parent ? .2 : .5); - size.x = max(size.x, .2); - size.y = max(size.y, .2); - drawRect(o.pos, size, color); - drawRect(o.pos, size.scale(.8), o.parent ? new Color(1,1,1,.5) : new Color(0,0,0,.8)); - o.parent && drawLine(o.pos, o.parent.pos, .1, new Color(0,0,1,.5)); - } - - if (bestObject) - { - const saveContext = mainContext; - mainContext = overlayContext - const raycastHitPos = tileCollisionRaycast(bestObject.pos, mousePos); - raycastHitPos && drawRect(raycastHitPos.floor().add(vec2(.5)), vec2(1), new Color(0,1,1,.3)); - drawRect(mousePos.floor().add(vec2(.5)), vec2(1), new Color(0,0,1,.5)); - drawLine(mousePos, bestObject.pos, .1, !raycastHitPos ? new Color(0,1,0,.5) : new Color(1,0,0,.5)); - - const debugText = 'mouse pos = ' + mousePos + - '\nmouse collision = ' + getTileCollisionData(mousePos) + - '\n\n--- object info ---\n' + - bestObject.toString(); - drawTextScreen(debugText, mousePosScreen, 24, new Color, .05, undefined, undefined, 'monospace'); - mainContext = saveContext; - } - - glCopyToContext(mainContext); - } - - { - // draw debug primitives - overlayContext.lineWidth = 2; - const pointSize = debugPointSize * cameraScale; - debugPrimitives.forEach(p=> - { - overlayContext.save(); - - // create canvas transform from world space to screen space - const pos = worldToScreen(p.pos); - - overlayContext.translate(pos.x|0, pos.y|0); - overlayContext.rotate(p.angle); - overlayContext.fillStyle = overlayContext.strokeStyle = p.color; - - if (p.text != undefined) - { - overlayContext.font = p.size*cameraScale + 'px '+ p.font; - overlayContext.textAlign = 'center'; - overlayContext.textBaseline = 'middle'; - overlayContext.fillText(p.text, 0, 0); - } - else if (p.size == 0 || p.size.x === 0 && p.size.y === 0 ) - { - // point - overlayContext.fillRect(-pointSize/2, -1, pointSize, 3); - overlayContext.fillRect(-1, -pointSize/2, 3, pointSize); - } - else if (p.size.x != undefined) - { - // rect - const w = p.size.x*cameraScale|0, h = p.size.y*cameraScale|0; - p.fill && overlayContext.fillRect(-w/2|0, -h/2|0, w, h); - overlayContext.strokeRect(-w/2|0, -h/2|0, w, h); - } - else - { - // circle - overlayContext.beginPath(); - overlayContext.arc(0, 0, p.size*cameraScale, 0, 9); - p.fill && overlayContext.fill(); - overlayContext.stroke(); - } - - overlayContext.restore(); - }); - - // remove expired pritives - debugPrimitives = debugPrimitives.filter(r=>r.time.get()<0); - } - - { - // draw debug overlay - overlayContext.save(); - overlayContext.fillStyle = '#fff'; - overlayContext.textAlign = 'left'; - overlayContext.textBaseline = 'top'; - overlayContext.font = '28px monospace'; - overlayContext.shadowColor = '#000'; - overlayContext.shadowBlur = 9; - - let x = 9, y = -20, h = 30; - if (debugOverlay) - { - overlayContext.fillText(engineName, x, y += h); - overlayContext.fillText('Objects: ' + engineObjects.length, x, y += h); - overlayContext.fillText('Time: ' + formatTime(time), x, y += h); - overlayContext.fillText('---------', x, y += h); - overlayContext.fillStyle = '#f00'; - overlayContext.fillText('~: Debug Overlay', x, y += h); - overlayContext.fillStyle = debugPhysics ? '#f00' : '#fff'; - overlayContext.fillText('1: Debug Physics', x, y += h); - overlayContext.fillStyle = debugParticles ? '#f00' : '#fff'; - overlayContext.fillText('2: Debug Particles', x, y += h); - overlayContext.fillStyle = debugGamepads ? '#f00' : '#fff'; - overlayContext.fillText('3: Debug Gamepads', x, y += h); - overlayContext.fillStyle = godMode ? '#f00' : '#fff'; - overlayContext.fillText('4: God Mode', x, y += h); - overlayContext.fillStyle = '#fff'; - overlayContext.fillText('5: Save Screenshot', x, y += h); - - let keysPressed = ''; - for(const i in inputData[0]) - { - if (i && keyIsDown(i, 0)) - keysPressed += i + ' ' ; - } - keysPressed && overlayContext.fillText('Keys Down: ' + keysPressed, x, y += h); - - let buttonsPressed = ''; - if (inputData[1]) - for(const i in inputData[1]) - { - if (i && keyIsDown(i, 1)) - buttonsPressed += i + ' ' ; - } - buttonsPressed && overlayContext.fillText('Gamepad: ' + buttonsPressed, x, y += h); - } - else - { - overlayContext.fillText(debugPhysics ? 'Debug Physics' : '', x, y += h); - overlayContext.fillText(debugParticles ? 'Debug Particles' : '', x, y += h); - overlayContext.fillText(godMode ? 'God Mode' : '', x, y += h); - overlayContext.fillText(debugGamepads ? 'Debug Gamepads' : '', x, y += h); - } - - overlayContext.restore(); - } -} -/** - * LittleJS Utility Classes and Functions - *
- General purpose math library - *
- Vector2 - fast, simple, easy 2D vector class - *
- Color - holds a rgba color with some math functions - *
- Timer - tracks time automatically - * @namespace Utilities - */ - -'use strict'; - -/** A shortcut to get Math.PI - * @const - * @memberof Utilities */ -const PI = Math.PI; - -/** Returns absoulte value of value passed in - * @param {Number} value - * @return {Number} - * @memberof Utilities */ -const abs = (a)=> a < 0 ? -a : a; - -/** Returns lowest of two values passed in - * @param {Number} valueA - * @param {Number} valueB - * @return {Number} - * @memberof Utilities */ -const min = (a, b)=> a < b ? a : b; - -/** Returns highest of two values passed in - * @param {Number} valueA - * @param {Number} valueB - * @return {Number} - * @memberof Utilities */ -const max = (a, b)=> a > b ? a : b; - -/** Returns the sign of value passed in (also returns 1 if 0) - * @param {Number} value - * @return {Number} - * @memberof Utilities */ -const sign = (a)=> a < 0 ? -1 : 1; - -/** Returns first parm modulo the second param, but adjusted so negative numbers work as expected - * @param {Number} dividend - * @param {Number} [divisor=1] - * @return {Number} - * @memberof Utilities */ -const mod = (a, b=1)=> ((a % b) + b) % b; - -/** Clamps the value beween max and min - * @param {Number} value - * @param {Number} [min=0] - * @param {Number} [max=1] - * @return {Number} - * @memberof Utilities */ -const clamp = (v, min=0, max=1)=> v < min ? min : v > max ? max : v; - -/** Returns what percentage the value is between max and min - * @param {Number} value - * @param {Number} [min=0] - * @param {Number} [max=1] - * @return {Number} - * @memberof Utilities */ -const percent = (v, min=0, max=1)=> max-min ? clamp((v-min) / (max-min)) : 0; - -/** Linearly interpolates the percent value between max and min - * @param {Number} percent - * @param {Number} [min=0] - * @param {Number} [max=1] - * @return {Number} - * @memberof Utilities */ -const lerp = (p, min=0, max=1)=> min + clamp(p) * (max-min); - -/** Applies smoothstep function to the percentage value - * @param {Number} value - * @return {Number} - * @memberof Utilities */ -const smoothStep = (p)=> p * p * (3 - 2 * p); - -/** Returns the nearest power of two not less then the value - * @param {Number} value - * @return {Number} - * @memberof Utilities */ -const nearestPowerOfTwo = (v)=> 2**Math.ceil(Math.log2(v)); - -/** Returns true if two axis aligned bounding boxes are overlapping - * @param {Vector2} pointA - Center of box A - * @param {Vector2} sizeA - Size of box A - * @param {Vector2} pointB - Center of box B - * @param {Vector2} [sizeB] - Size of box B - * @return {Boolean} - True if overlapping - * @memberof Utilities */ -const isOverlapping = (pA, sA, pB, sB)=> abs(pA.x - pB.x)*2 < sA.x + sB.x & abs(pA.y - pB.y)*2 < sA.y + sB.y; - -/** Returns an oscillating wave between 0 and amplitude with frequency of 1 Hz by default - * @param {Number} [frequency=1] - Frequency of the wave in Hz - * @param {Number} [amplitude=1] - Amplitude (max height) of the wave - * @param {Number} [t=time] - Value to use for time of the wave - * @return {Number} - Value waving between 0 and amplitude - * @memberof Utilities */ -const wave = (frequency=1, amplitude=1, t=time)=> amplitude/2 * (1 - Math.cos(t*frequency*2*PI)); - -/** Formats seconds to mm:ss style for display purposes - * @param {Number} t - time in seconds - * @return {String} - * @memberof Utilities */ -const formatTime = (t)=> (t/60|0)+':'+(t%60<10?'0':'')+(t%60|0); - -/////////////////////////////////////////////////////////////////////////////// - -/** Random global functions - * @namespace Random */ - -/** Returns a random value between the two values passed in - * @param {Number} [valueA=1] - * @param {Number} [valueB=0] - * @return {Number} - * @memberof Random */ -const rand = (a=1, b=0)=> b + (a-b)*Math.random(); - -/** Returns a floored random value the two values passed in - * @param {Number} [valueA=1] - * @param {Number} [valueB=0] - * @return {Number} - * @memberof Random */ -const randInt = (a=1, b=0)=> rand(a,b)|0; - -/** Randomly returns either -1 or 1 - * @return {Number} - * @memberof Random */ -const randSign = ()=> (rand(2)|0) * 2 - 1; - -/** Returns a random Vector2 within a circular shape - * @param {Number} [radius=1] - * @param {Number} [minRadius=0] - * @return {Vector2} - * @memberof Random */ -const randInCircle = (radius=1, minRadius=0)=> radius > 0 ? randVector(radius * rand(minRadius / radius, 1)**.5) : new Vector2; - -/** Returns a random Vector2 with the passed in length - * @param {Number} [length=1] - * @return {Vector2} - * @memberof Random */ -const randVector = (length=1)=> new Vector2().setAngle(rand(2*PI), length); - -/** Returns a random color between the two passed in colors, combine components if linear - * @param {Color} [colorA=new Color(1,1,1,1)] - * @param {Color} [colorB=new Color(0,0,0,1)] - * @param {Boolean} [linear] - * @return {Color} - * @memberof Random */ -const randColor = (cA = new Color, cB = new Color(0,0,0,1), linear)=> - linear ? cA.lerp(cB, rand()) : new Color(rand(cA.r,cB.r),rand(cA.g,cB.g),rand(cA.b,cB.b),rand(cA.a,cB.a)); - -/** The seed used by the randSeeded function, should not be 0 - * @memberof Random */ -let randSeed = 1; - -/** Returns a seeded random value between the two values passed in using randSeed - * @param {Number} [valueA=1] - * @param {Number} [valueB=0] - * @return {Number} - * @memberof Random */ -const randSeeded = (a=1, b=0)=> -{ - randSeed ^= randSeed << 13; randSeed ^= randSeed >>> 17; randSeed ^= randSeed << 5; // xorshift - return b + (a-b) * abs(randSeed % 1e9) / 1e9; -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Create a 2d vector, can take another Vector2 to copy, 2 scalars, or 1 scalar - * @param {Number} [x=0] - * @param {Number} [y=0] - * @return {Vector2} - * @example - * let a = vec2(0, 1); // vector with coordinates (0, 1) - * let b = vec2(a); // copy a into b - * a = vec2(5); // set a to (5, 5) - * b = vec2(); // set b to (0, 0) - * @memberof Utilities - */ -const vec2 = (x=0, y)=> x.x == undefined ? new Vector2(x, y == undefined? x : y) : new Vector2(x.x, x.y); - -/** - * 2D Vector object with vector math library - *
- Functions do not change this so they can be chained together - * @example - * let a = new Vector2(2, 3); // vector with coordinates (2, 3) - * let b = new Vector2; // vector with coordinates (0, 0) - * let c = vec2(4, 2); // use the vec2 function to make a Vector2 - * let d = a.add(b).scale(5); // operators can be chained - */ -class Vector2 -{ - /** Create a 2D vector with the x and y passed in, can also be created with vec2() - * @param {Number} [x=0] - X axis location - * @param {Number} [y=0] - Y axis location */ - constructor(x=0, y=0) - { - /** @property {Number} - X axis location */ - this.x = x; - /** @property {Number} - Y axis location */ - this.y = y; - } - - /** Returns a new vector that is a copy of this - * @return {Vector2} */ - copy() { return new Vector2(this.x, this.y); } - - /** Returns a copy of this vector plus the vector passed in - * @param {Vector2} vector - * @return {Vector2} */ - add(v) { ASSERT(v.x!=undefined); return new Vector2(this.x + v.x, this.y + v.y); } - - /** Returns a copy of this vector minus the vector passed in - * @param {Vector2} vector - * @return {Vector2} */ - subtract(v) { ASSERT(v.x!=undefined); return new Vector2(this.x - v.x, this.y - v.y); } - - /** Returns a copy of this vector times the vector passed in - * @param {Vector2} vector - * @return {Vector2} */ - multiply(v) { ASSERT(v.x!=undefined); return new Vector2(this.x * v.x, this.y * v.y); } - - /** Returns a copy of this vector divided by the vector passed in - * @param {Vector2} vector - * @return {Vector2} */ - divide(v) { ASSERT(v.x!=undefined); return new Vector2(this.x / v.x, this.y / v.y); } - - /** Returns a copy of this vector scaled by the vector passed in - * @param {Number} scale - * @return {Vector2} */ - scale(s) { ASSERT(s.x==undefined); return new Vector2(this.x * s, this.y * s); } - - /** Returns the length of this vector - * @return {Number} */ - length() { return this.lengthSquared()**.5; } - - /** Returns the length of this vector squared - * @return {Number} */ - lengthSquared() { return this.x**2 + this.y**2; } - - /** Returns the distance from this vector to vector passed in - * @param {Vector2} vector - * @return {Number} */ - distance(v) { return this.distanceSquared(v)**.5; } - - /** Returns the distance squared from this vector to vector passed in - * @param {Vector2} vector - * @return {Number} */ - distanceSquared(v) { return (this.x - v.x)**2 + (this.y - v.y)**2; } - - /** Returns a new vector in same direction as this one with the length passed in - * @param {Number} [length=1] - * @return {Vector2} */ - normalize(length=1) { const l = this.length(); return l ? this.scale(length/l) : new Vector2(0, length); } - - /** Returns a new vector clamped to length passed in - * @param {Number} [length=1] - * @return {Vector2} */ - clampLength(length=1) { const l = this.length(); return l > length ? this.scale(length/l) : this; } - - /** Returns the dot product of this and the vector passed in - * @param {Vector2} vector - * @return {Number} */ - dot(v) { ASSERT(v.x!=undefined); return this.x*v.x + this.y*v.y; } - - /** Returns the cross product of this and the vector passed in - * @param {Vector2} vector - * @return {Number} */ - cross(v) { ASSERT(v.x!=undefined); return this.x*v.y - this.y*v.x; } - - /** Returns the angle of this vector, up is angle 0 - * @return {Number} */ - angle() { return Math.atan2(this.x, this.y); } - - /** Sets this vector with angle and length passed in - * @param {Number} [angle=0] - * @param {Number} [length=1] */ - setAngle(a=0, length=1) { this.x = length*Math.sin(a); this.y = length*Math.cos(a); return this; } - - /** Returns copy of this vector rotated by the angle passed in - * @param {Number} angle - * @return {Vector2} */ - rotate(a) { const c = Math.cos(a), s = Math.sin(a); return new Vector2(this.x*c-this.y*s, this.x*s+this.y*c); } - - /** Returns the integer direction of this vector, corrosponding to multiples of 90 degree rotation (0-3) - * @return {Number} */ - direction() { return abs(this.x) > abs(this.y) ? this.x < 0 ? 3 : 1 : this.y < 0 ? 2 : 0; } - - /** Returns a copy of this vector that has been inverted - * @return {Vector2} */ - invert() { return new Vector2(this.y, -this.x); } - - /** Returns a copy of this vector with each axis floored - * @return {Vector2} */ - floor() { return new Vector2(Math.floor(this.x), Math.floor(this.y)); } - - /** Returns the area this vector covers as a rectangle - * @return {Number} */ - area() { return abs(this.x * this.y); } - - /** Returns a new vector that is p percent between this and the vector passed in - * @param {Vector2} vector - * @param {Number} percent - * @return {Vector2} */ - lerp(v, p) { ASSERT(v.x!=undefined); return this.add(v.subtract(this).scale(clamp(p))); } - - /** Returns true if this vector is within the bounds of an array size passed in - * @param {Vector2} arraySize - * @return {Boolean} */ - arrayCheck(arraySize) { return this.x >= 0 && this.y >= 0 && this.x < arraySize.x && this.y < arraySize.y; } - - /** Returns this vector expressed as a string - * @param {float} digits - precision to display - * @return {String} */ - toString(digits=3) - { return `(${(this.x<0?'':' ') + this.x.toFixed(digits)},${(this.y<0?'':' ') + this.y.toFixed(digits)} )`; } -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Color object (red, green, blue, alpha) with some helpful functions - * @example - * let a = new Color; // white - * let b = new Color(1, 0, 0); // red - * let c = new Color(0, 0, 0, 0); // transparent black - */ -class Color -{ - /** Create a color with the components passed in, white by default - * @param {Number} [red=1] - * @param {Number} [green=1] - * @param {Number} [blue=1] - * @param {Number} [alpha=1] */ - constructor(r=1, g=1, b=1, a=1) - { - /** @property {Number} - Red */ - this.r = r; - /** @property {Number} - Green */ - this.g = g; - /** @property {Number} - Blue */ - this.b = b; - /** @property {Number} - Alpha */ - this.a = a; - } - - /** Returns a new color that is a copy of this - * @return {Color} */ - copy() { return new Color(this.r, this.g, this.b, this.a); } - - /** Returns a copy of this color plus the color passed in - * @param {Color} color - * @return {Color} */ - add(c) { return new Color(this.r+c.r, this.g+c.g, this.b+c.b, this.a+c.a); } - - /** Returns a copy of this color minus the color passed in - * @param {Color} color - * @return {Color} */ - subtract(c) { return new Color(this.r-c.r, this.g-c.g, this.b-c.b, this.a-c.a); } - - /** Returns a copy of this color times the color passed in - * @param {Color} color - * @return {Color} */ - multiply(c) { return new Color(this.r*c.r, this.g*c.g, this.b*c.b, this.a*c.a); } - - /** Returns a copy of this color divided by the color passed in - * @param {Color} color - * @return {Color} */ - divide(c) { return new Color(this.r/c.r, this.g/c.g, this.b/c.b, this.a/c.a); } - - /** Returns a copy of this color scaled by the value passed in, alpha can be scaled separately - * @param {Number} scale - * @param {Number} [alphaScale=scale] - * @return {Color} */ - scale(s, a=s) { return new Color(this.r*s, this.g*s, this.b*s, this.a*a); } - - /** Returns a copy of this color clamped to the valid range between 0 and 1 - * @return {Color} */ - clamp() { return new Color(clamp(this.r), clamp(this.g), clamp(this.b), clamp(this.a)); } - - /** Returns a new color that is p percent between this and the color passed in - * @param {Color} color - * @param {Number} percent - * @return {Color} */ - lerp(c, p) { return this.add(c.subtract(this).scale(clamp(p))); } - - /** Sets this color given a hue, saturation, lightness, and alpha - * @param {Number} [hue=0] - * @param {Number} [saturation=0] - * @param {Number} [lightness=1] - * @param {Number} [alpha=1] - * @return {Color} */ - setHSLA(h=0, s=0, l=1, a=1) - { - const q = l < .5 ? l*(1+s) : l+s-l*s, p = 2*l-q, - f = (p, q, t)=> - (t = ((t%1)+1)%1) < 1/6 ? p+(q-p)*6*t : - t < 1/2 ? q : - t < 2/3 ? p+(q-p)*(2/3-t)*6 : p; - - this.r = f(p, q, h + 1/3); - this.g = f(p, q, h); - this.b = f(p, q, h - 1/3); - this.a = a; - return this; - } - - /** Returns this color expressed in hsla format - * @return {Array} */ - getHSLA() - { - const r = this.r; - const g = this.g; - const b = this.b; - const a = this.a; - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const l = (max + min) / 2; - - let h = 0, s = 0; - if (max != min) - { - let d = max - min; - s = l > .5 ? d / (2 - max - min) : d / (max + min); - if (r == max) - h = (g - b) / d + (g < b ? 6 : 0); - else if (g == max) - h = (b - r) / d + 2; - else if (b == max) - h = (r - g) / d + 4; - } - - return [h / 6, s, l, a]; - } - - /** Returns a new color that has each component randomly adjusted - * @param {Number} [amount=.05] - * @param {Number} [alphaAmount=0] - * @return {Color} */ - mutate(amount=.05, alphaAmount=0) - { - return new Color - ( - this.r + rand(amount, -amount), - this.g + rand(amount, -amount), - this.b + rand(amount, -amount), - this.a + rand(alphaAmount, -alphaAmount) - ).clamp(); - } - - /** Returns this color expressed as an CSS color value - * @return {String} */ - toString() - { - ASSERT(this.r>=0 && this.r<=1 && this.g>=0 && this.g<=1 && this.b>=0 && this.b<=1 && this.a>=0 && this.a<=1); - return `rgb(${this.r*255|0},${this.g*255|0},${this.b*255|0},${this.a})`; - } - - /** Returns this color expressed as 32 bit integer RGBA value - * @return {Number} */ - rgbaInt() - { - ASSERT(this.r>=0 && this.r<=1 && this.g>=0 && this.g<=1 && this.b>=0 && this.b<=1 && this.a>=0 && this.a<=1); - return (this.r*255|0) + (this.g*255<<8) + (this.b*255<<16) + (this.a*255<<24); - } - - /** Set this color from a hex code - * @param {String} hex - html hex code - * @return {Color} */ - setHex(hex) - { - const fromHex = (a)=> parseInt(hex.slice(a,a+2), 16)/255; - this.r = fromHex(1); - this.g = fromHex(3), - this.b = fromHex(5); - this.a = 1; - ASSERT(this.r>=0 && this.r<=1 && this.g>=0 && this.g<=1 && this.b>=0 && this.b<=1); - return this; - } - - /** Returns this color expressed as a hex code - * @return {String} */ - getHex() - { - const toHex = (c)=> ((c=c*255|0)<16 ? '0' : '') + c.toString(16); - return '#' + toHex(this.r) + toHex(this.g) + toHex(this.b); - } -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Timer object tracks how long has passed since it was set - * @example - * let a = new Timer; // creates a timer that is not set - * a.set(3); // sets the timer to 3 seconds - * - * let b = new Timer(1); // creates a timer with 1 second left - * b.unset(); // unsets the timer - */ -class Timer -{ - /** Create a timer object set time passed in - * @param {Number} [timeLeft] - How much time left before the timer elapses in seconds */ - constructor(timeLeft) { this.time = timeLeft == undefined ? undefined : time + timeLeft; this.setTime = timeLeft; } - - /** Set the timer with seconds passed in - * @param {Number} [timeLeft=0] - How much time left before the timer is elapsed in seconds */ - set(timeLeft=0) { this.time = time + timeLeft; this.setTime = timeLeft; } - - /** Unset the timer */ - unset() { this.time = undefined; } - - /** Returns true if set - * @return {Boolean} */ - isSet() { return this.time != undefined; } - - /** Returns true if set and has not elapsed - * @return {Boolean} */ - active() { return time <= this.time; } - - /** Returns true if set and elapsed - * @return {Boolean} */ - elapsed() { return time > this.time; } - - /** Get how long since elapsed, returns 0 if not set (returns negative if currently active) - * @return {Number} */ - get() { return this.isSet()? time - this.time : 0; } - - /** Get percentage elapsed based on time it was set to, returns 0 if not set - * @return {Number} */ - getPercent() { return this.isSet()? percent(this.time - time, this.setTime, 0) : 0; } - - /** Returns this timer expressed as a string - * @return {String} */ - toString() { if (debug) { return this.unset() ? 'unset' : Math.abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ); } } -} -/** - * LittleJS Engine Settings - * @namespace Settings - */ - -'use strict'; - -/////////////////////////////////////////////////////////////////////////////// -// Display settings - -/** The max size of the canvas, centered if window is larger - * @type {Vector2} - * @default - * @memberof Settings */ -let canvasMaxSize = vec2(1920, 1200); - -/** Fixed size of the canvas, if enabled canvas size never changes - * - you may also need to set mainCanvasSize if using screen space coords in startup - * @type {Vector2} - * @default - * @memberof Settings */ -let canvasFixedSize = vec2(); - -/** Disables anti aliasing for pixel art if true - * @default - * @memberof Settings */ -let cavasPixelated = 1; - -/** Default font used for text rendering - * @default - * @memberof Settings */ -let fontDefault = 'arial'; - -/////////////////////////////////////////////////////////////////////////////// -// Tile sheet settings - -/** Default size of tiles in pixels - * @type {Vector2} - * @default - * @memberof Settings */ -let tileSizeDefault = vec2(16); - -/** Prevent tile bleeding from neighbors in pixels - * @default - * @memberof Settings */ -let tileFixBleedScale = .3; - -/////////////////////////////////////////////////////////////////////////////// -// Object settings - -/** Default size of objects - * @type {Vector2} - * @default - * @memberof Settings */ -let objectDefaultSize = vec2(1); - -/** Enable physics solver for collisions between objects - * @default - * @memberof Settings */ -let enablePhysicsSolver = 1; - -/** Default object mass for collison calcuations (how heavy objects are) - * @default - * @memberof Settings */ -let objectDefaultMass = 1; - -/** How much to slow velocity by each frame (0-1) - * @default - * @memberof Settings */ -let objectDefaultDamping = .99; - -/** How much to slow angular velocity each frame (0-1) - * @default - * @memberof Settings */ -let objectDefaultAngleDamping = .99; - -/** How much to bounce when a collision occurs (0-1) - * @default - * @memberof Settings */ -let objectDefaultElasticity = 0; - -/** How much to slow when touching (0-1) - * @default - * @memberof Settings */ -let objectDefaultFriction = .8; - -/** Clamp max speed to avoid fast objects missing collisions - * @default - * @memberof Settings */ -let objectMaxSpeed = 1; - -/** How much gravity to apply to objects along the Y axis, negative is down - * @default - * @memberof Settings */ -let gravity = 0; - -/** Scales emit rate of particles, useful for low graphics mode (0 disables particle emitters) - * @default - * @memberof Settings */ -let particleEmitRateScale = 1; - -/////////////////////////////////////////////////////////////////////////////// -// Camera settings - -/** Position of camera in world space - * @type {Vector2} - * @default - * @memberof Settings */ -let cameraPos = vec2(); - -/** Scale of camera in world space - * @default - * @memberof Settings */ -let cameraScale = max(tileSizeDefault.x, tileSizeDefault.y); - -/////////////////////////////////////////////////////////////////////////////// -// WebGL settings - -/** Enable webgl rendering, webgl can be disabled and removed from build (with some features disabled) - * @default - * @memberof Settings */ -let glEnable = 1; - -/** Fixes slow rendering in some browsers by not compositing the WebGL canvas - * @default - * @memberof Settings */ -let glOverlay = 1; - -/////////////////////////////////////////////////////////////////////////////// -// Input settings - -/** Should gamepads be allowed - * @default - * @memberof Settings */ -let gamepadsEnable = 1; - -/** If true, the dpad input is also routed to the left analog stick (for better accessability) - * @default - * @memberof Settings */ -let gamepadDirectionEmulateStick = 1; - -/** If true the WASD keys are also routed to the direction keys (for better accessability) - * @default - * @memberof Settings */ -let inputWASDEmulateDirection = 1; - -/** True if touch gamepad should appear on mobile devices - *
- Supports left analog stick, 4 face buttons and start button (button 9) - *
- Must be set by end of gameInit to be activated - * @default - * @memberof Settings */ -let touchGamepadEnable = 0; - -/** True if touch gamepad should be analog stick or false to use if 8 way dpad - * @default - * @memberof Settings */ -let touchGamepadAnalog = 1; - -/** Size of virutal gamepad for touch devices in pixels - * @default - * @memberof Settings */ -let touchGamepadSize = 80; - -/** Transparency of touch gamepad overlay - * @default - * @memberof Settings */ -let touchGamepadAlpha = .3; - -/** Allow vibration hardware if it exists - * @default - * @memberof Settings */ -let vibrateEnable = 1; - -/////////////////////////////////////////////////////////////////////////////// -// Audio settings - -/** Volume scale to apply to all sound, music and speech - * @default - * @memberof Settings */ -let soundVolume = .5; - -/** All audio code can be disabled and removed from build - * @default - * @memberof Settings */ -let soundEnable = 1; - -/** Default range where sound no longer plays - * @default - * @memberof Settings */ -let soundDefaultRange = 30; - -/** Default range percent to start tapering off sound (0-1) - * @default - * @memberof Settings */ -let soundDefaultTaper = .7; - -/////////////////////////////////////////////////////////////////////////////// -// Medals settings - -/** How long to show medals for in seconds - * @default - * @memberof Settings */ -let medalDisplayTime = 5; - -/** How quickly to slide on/off medals in seconds - * @default - * @memberof Settings */ -let medalDisplaySlideTime = .5; - -/** Width of medal display - * @default - * @memberof Settings */ -let medalDisplayWidth = 640; - -/** Height of medal display - * @default - * @memberof Settings */ -let medalDisplayHeight = 80; - -/** Size of icon in medal display - * @default - * @memberof Settings */ -let medalDisplayIconSize = 50; -/* - LittleJS Object System -*/ - -'use strict'; - -/** - * LittleJS Object Base Object Class - *
- Base object class used by the engine - *
- Automatically adds self to object list - *
- Will be updated and rendered each frame - *
- Renders as a sprite from a tilesheet by default - *
- Can have color and addtive color applied - *
- 2d Physics and collision system - *
- Sorted by renderOrder - *
- Objects can have children attached - *
- Parents are updated before children, and set child transform - *
- Call destroy() to get rid of objects - *
- *
The physics system used by objects is simple and fast with some caveats... - *
- Collision uses the axis aligned size, the object's rotation angle is only for rendering - *
- Objects are guaranteed to not intersect tile collision from physics - *
- If an object starts or is moved inside tile collision, it will not collide with that tile - *
- Collision for objects can be set to be solid to block other objects - *
- Objects may get pushed into overlapping other solid objects, if so they will push away - *
- Solid objects are more performance intensive and should be used sparingly - * @example - * // create an engine object, normally you would first extend the class with your own - * const pos = vec2(2,3); - * const object = new EngineObject(pos); - */ -class EngineObject -{ - /** Create an engine object and adds it to the list of objects - * @param {Vector2} [position=new Vector2()] - World space position of the object - * @param {Vector2} [size=objectDefaultSize] - World space size of the object - * @param {Number} [tileIndex=-1] - Tile to use to render object (-1 is untextured) - * @param {Vector2} [tileSize=tileSizeDefault] - Size of tile in source pixels - * @param {Number} [angle=0] - Angle the object is rotated by - * @param {Color} [color] - Color to apply to tile when rendered - * @param {Number} [renderOrder=0] - Objects sorted by renderOrder before being rendered - */ - constructor(pos=vec2(), size=objectDefaultSize, tileIndex=-1, tileSize=tileSizeDefault, angle=0, color, renderOrder=0) - { - // set passed in params - ASSERT(pos && pos.x != undefined && size.x != undefined); // ensure pos and size are vec2s - - /** @property {Vector2} - World space position of the object */ - this.pos = pos.copy(); - /** @property {Vector2} - World space width and height of the object */ - this.size = size; - /** @property {Vector2} - Size of object used for drawing, uses size if not set */ - this.drawSize; - /** @property {Number} - Tile to use to render object (-1 is untextured) */ - this.tileIndex = tileIndex; - /** @property {Vector2} - Size of tile in source pixels */ - this.tileSize = tileSize; - /** @property {Number} - Angle to rotate the object */ - this.angle = angle; - /** @property {Color} - Color to apply when rendered */ - this.color = color; - /** @property {Color} - Additive color to apply when rendered */ - this.additiveColor; - - // set object defaults - /** @property {Number} [mass=objectDefaultMass] - How heavy the object is, static if 0 */ - this.mass = objectDefaultMass; - /** @property {Number} [damping=objectDefaultDamping] - How much to slow down velocity each frame (0-1) */ - this.damping = objectDefaultDamping; - /** @property {Number} [angleDamping=objectDefaultAngleDamping] - How much to slow down rotation each frame (0-1) */ - this.angleDamping = objectDefaultAngleDamping; - /** @property {Number} [elasticity=objectDefaultElasticity] - How bouncy the object is when colliding (0-1) */ - this.elasticity = objectDefaultElasticity; - /** @property {Number} [friction=objectDefaultFriction] - How much friction to apply when sliding (0-1) */ - this.friction = objectDefaultFriction; - /** @property {Number} [gravityScale=1] - How much to scale gravity by for this object */ - this.gravityScale = 1; - /** @property {Number} [renderOrder=0] - Objects are sorted by render order */ - this.renderOrder = renderOrder; - /** @property {Vector2} [velocity=new Vector2()] - Velocity of the object */ - this.velocity = new Vector2(); - /** @property {Number} [angleVelocity=0] - Angular velocity of the object */ - this.angleVelocity = 0; - - // init other internal object stuff - this.spawnTime = time; - this.children = []; - this.collideTiles = 1; - - // add to list of objects - engineObjects.push(this); - } - - /** Update the object transform and physics, called automatically by engine once each frame */ - update() - { - const parent = this.parent; - if (parent) - { - // copy parent pos/angle - this.pos = this.localPos.multiply(vec2(parent.getMirrorSign(),1)).rotate(-parent.angle).add(parent.pos); - this.angle = parent.getMirrorSign()*this.localAngle + parent.angle; - return; - } - - // limit max speed to prevent missing collisions - this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed); - this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed); - - // apply physics - const oldPos = this.pos.copy(); - this.pos.x += this.velocity.x = this.damping * this.velocity.x; - this.pos.y += this.velocity.y = this.damping * this.velocity.y + gravity * this.gravityScale; - this.angle += this.angleVelocity *= this.angleDamping; - - // physics sanity checks - ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1); - ASSERT(this.damping >= 0 && this.damping <= 1); - - if (!enablePhysicsSolver || !this.mass) // do not update collision for fixed objects - return; - - const wasMovingDown = this.velocity.y < 0; - if (this.groundObject) - { - // apply friction in local space of ground object - const groundSpeed = this.groundObject.velocity ? this.groundObject.velocity.x : 0; - this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * this.friction; - this.groundObject = 0; - //debugOverlay && debugPhysics && debugPoint(this.pos.subtract(vec2(0,this.size.y/2)), '#0f0'); - } - - if (this.collideSolidObjects) - { - // check collisions against solid objects - const epsilon = 1e-3; // necessary to push slightly outside of the collision - for (const o of engineObjectsCollide) - { - // non solid objects don't collide with eachother - if (!this.isSolid & !o.isSolid || o.destroyed || o.parent || o == this) - continue; - - // check collision - if (!isOverlapping(this.pos, this.size, o.pos, o.size)) - continue; - - // pass collision to objects - if (!this.collideWithObject(o) | !o.collideWithObject(this)) - continue; - - if (isOverlapping(oldPos, this.size, o.pos, o.size)) - { - // if already was touching, try to push away - const deltaPos = oldPos.subtract(o.pos); - const length = deltaPos.length(); - const pushAwayAccel = .001; // push away if already overlapping - const velocity = length < .01 ? randVector(pushAwayAccel) : deltaPos.scale(pushAwayAccel/length); - this.velocity = this.velocity.add(velocity); - if (o.mass) // push away if not fixed - o.velocity = o.velocity.subtract(velocity); - - debugOverlay && debugPhysics && debugAABB(this.pos, this.size, o.pos, o.size, '#f00'); - continue; - } - - // check for collision - const sizeBoth = this.size.add(o.size); - const smallStepUp = (oldPos.y - o.pos.y)*2 > sizeBoth.y + gravity; // prefer to push up if small delta - const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y; - const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x; - - if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision - { - // push outside object collision - this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y); - if (o.groundObject && wasMovingDown || !o.mass) - { - // set ground object if landed on something - if (wasMovingDown) - this.groundObject = o; - - // bounce if other object is fixed or grounded - this.velocity.y *= -this.elasticity; - } - else if (o.mass) - { - // inelastic collision - const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass); - - // elastic collision - const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass) - + o.velocity.y * 2 * o.mass / (this.mass + o.mass); - const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass) - + this.velocity.y * 2 * this.mass / (this.mass + o.mass); - - // lerp betwen elastic or inelastic based on elasticity - const elasticity = max(this.elasticity, o.elasticity); - this.velocity.y = lerp(elasticity, inelastic, elastic0); - o.velocity.y = lerp(elasticity, inelastic, elastic1); - } - } - if (!smallStepUp && (isBlockedX || !isBlockedY)) // resolve x collision - { - // push outside collision - this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x); - if (o.mass) - { - // inelastic collision - const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass); - - // elastic collision - const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass) - + o.velocity.x * 2 * o.mass / (this.mass + o.mass); - const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass) - + this.velocity.x * 2 * this.mass / (this.mass + o.mass); - - // lerp betwen elastic or inelastic based on elasticity - const elasticity = max(this.elasticity, o.elasticity); - this.velocity.x = lerp(elasticity, inelastic, elastic0); - o.velocity.x = lerp(elasticity, inelastic, elastic1); - } - else // bounce if other object is fixed - this.velocity.x *= -this.elasticity; - } - debugOverlay && debugPhysics && debugAABB(this.pos, this.size, o.pos, o.size, '#f0f'); - } - } - if (this.collideTiles) - { - // check collision against tiles - if (tileCollisionTest(this.pos, this.size, this)) - { - // if already was stuck in collision, don't do anything - // this should not happen unless something starts in collision - if (!tileCollisionTest(oldPos, this.size, this)) - { - // test which side we bounced off (or both if a corner) - const isBlockedY = tileCollisionTest(new Vector2(oldPos.x, this.pos.y), this.size, this); - const isBlockedX = tileCollisionTest(new Vector2(this.pos.x, oldPos.y), this.size, this); - if (isBlockedY || !isBlockedX) - { - // set if landed on ground - this.groundObject = wasMovingDown; - - // bounce velocity - this.velocity.y *= -this.elasticity; - - // adjust next velocity to settle on ground - const o = (oldPos.y - this.size.y/2|0) - (oldPos.y - this.size.y/2); - if (o < 0 && o > this.damping * this.velocity.y + gravity * this.gravityScale) - this.velocity.y = this.damping ? (o - gravity * this.gravityScale) / this.damping : 0; - - // move to previous position - this.pos.y = oldPos.y; - } - if (isBlockedX) - { - // move to previous position and bounce - this.pos.x = oldPos.x; - this.velocity.x *= -this.elasticity; - } - } - } - } - } - - /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */ - render() - { - // default object render - drawTile(this.pos, this.drawSize || this.size, this.tileIndex, this.tileSize, this.color, this.angle, this.mirror, this.additiveColor); - } - - /** Destroy this object, destroy it's children, detach it's parent, and mark it for removal */ - destroy() - { - if (this.destroyed) - return; - - // disconnect from parent and destroy chidren - this.destroyed = 1; - this.parent && this.parent.removeChild(this); - for (const child of this.children) - child.destroy(child.parent = 0); - } - - /** Called to check if a tile collision should be resolved - * @param {Number} tileData - the value of the tile at the position - * @param {Vector2} pos - tile where the collision occured - * @return {Boolean} - true if the collision should be resolved */ - collideWithTile(tileData, pos) { return tileData > 0; } - - /** Called to check if a tile raycast hit - * @param {Number} tileData - the value of the tile at the position - * @param {Vector2} pos - tile where the raycast is - * @return {Boolean} - true if the raycast should hit */ - collideWithTileRaycast(tileData, pos) { return tileData > 0; } - - /** Called to check if a tile raycast hit - * @param {EngineObject} object - the object to test against - * @return {Boolean} - true if the collision should be resolved - */ - collideWithObject(o) { return 1; } - - /** How long since the object was created - * @return {Number} */ - getAliveTime() { return time - this.spawnTime; } - - /** Apply acceleration to this object (adjust velocity, not affected by mass) - * @param {Vector2} acceleration */ - applyAcceleration(a) { if (this.mass) this.velocity = this.velocity.add(a); } - - /** Apply force to this object (adjust velocity, affected by mass) - * @param {Vector2} force */ - applyForce(force) { this.applyAcceleration(force.scale(1/this.mass)); } - - /** Get the direction of the mirror - * @return {Number} -1 if this.mirror is true, or 1 if not mirrored */ - getMirrorSign() { return this.mirror ? -1 : 1; } - - /** Attaches a child to this with a given local transform - * @param {EngineObject} child - * @param {Vector2} [localPos=new Vector2] - * @param {Number} [localAngle=0] */ - addChild(child, localPos=vec2(), localAngle=0) - { - ASSERT(!child.parent && !this.children.includes(child)); - this.children.push(child); - child.parent = this; - child.localPos = localPos.copy(); - child.localAngle = localAngle; - } - - /** Removes a child from this one - * @param {EngineObject} child */ - removeChild(child) - { - ASSERT(child.parent == this && this.children.includes(child)); - this.children.splice(this.children.indexOf(child), 1); - child.parent = 0; - } - - /** Set how this object collides - * @param {boolean} [collideSolidObjects=0] - Does it collide with solid objects - * @param {boolean} [isSolid=0] - Does it collide with and block other objects (expensive in large numbers) - * @param {boolean} [collideTiles=1] - Does it collide with the tile collision */ - setCollision(collideSolidObjects=0, isSolid=0, collideTiles=1) - { - ASSERT(collideSolidObjects || !isSolid); // solid objects must be set to collide - - this.collideSolidObjects = collideSolidObjects; - this.isSolid = isSolid; - this.collideTiles = collideTiles; - } - - toString() - { - if (debug) - { - let text = 'type = ' + this.constructor.name; - if (this.pos.x || this.pos.y) - text += '\npos = ' + this.pos; - if (this.velocity.x || this.velocity.y) - text += '\nvelocity = ' + this.velocity; - if (this.size.x || this.size.y) - text += '\nsize = ' + this.size; - if (this.angle) - text += '\nangle = ' + this.angle.toFixed(3); - if (this.color) - text += '\ncolor = ' + this.color; - return text; - } - } -} -/** - * LittleJS Drawing System - *
- Hybrid with both Canvas2D and WebGL available - *
- Super fast tile sheet rendering with WebGL - *
- Can apply rotation, mirror, color and additive color - *
- Many useful utility functions - *
- *
LittleJS uses a hybrid rendering solution with the best of both Canvas2D and WebGL. - *
There are 3 canvas/contexts available to draw to... - *
- mainCanvas - 2D background canvas, non WebGL stuff like tile layers are drawn here. - *
- glCanvas - Used by the accelerated WebGL batch rendering system. - *
- overlayCanvas - Another 2D canvas that appears on top of the other 2 canvases. - *
- *
The WebGL rendering system is very fast with some caveats... - *
- The default setup supports only 1 tile sheet, to support more call glCreateTexture and glSetTexture - *
- Switching blend modes (additive) or textures causes another draw call which is expensive in excess - *
- Group additive rendering together using renderOrder to mitigate this issue - *
- *
The LittleJS rendering solution is intentionally simple, feel free to adjust it for your needs! - * @namespace Draw - */ - -'use strict'; - -/** Tile sheet for batch rendering system - * @type {Image} - * @memberof Draw */ -const tileImage = new Image(); - -/** The primary 2D canvas visible to the user - * @type {HTMLCanvasElement} - * @memberof Draw */ -let mainCanvas; - -/** 2d context for mainCanvas - * @type {CanvasRenderingContext2D} - * @memberof Draw */ -let mainContext; - -/** A canvas that appears on top of everything the same size as mainCanvas - * @type {HTMLCanvasElement} - * @memberof Draw */ -let overlayCanvas; - -/** 2d context for overlayCanvas - * @type {CanvasRenderingContext2D} - * @memberof Draw */ -let overlayContext; - -/** The size of the main canvas (and other secondary canvases) - * @type {Vector2} - * @memberof Draw */ -let mainCanvasSize = vec2(); - -/** Convert from screen to world space coordinates - * - if calling outside of render, you may need to manually set mainCanvasSize - * @param {Vector2} screenPos - * @return {Vector2} - * @memberof Draw */ -const screenToWorld = (screenPos)=> -{ - ASSERT(mainCanvasSize.x && mainCanvasSize.y, 'mainCanvasSize is invalid'); - return screenPos.add(vec2(.5)).subtract(mainCanvasSize.scale(.5)).multiply(vec2(1/cameraScale,-1/cameraScale)).add(cameraPos); -} - -/** Convert from world to screen space coordinates - * - if calling outside of render, you may need to manually set mainCanvasSize - * @param {Vector2} worldPos - * @return {Vector2} - * @memberof Draw */ -const worldToScreen = (worldPos)=> -{ - ASSERT(mainCanvasSize.x && mainCanvasSize.y, 'mainCanvasSize is invalid'); - return worldPos.subtract(cameraPos).multiply(vec2(cameraScale,-cameraScale)).add(mainCanvasSize.scale(.5)).subtract(vec2(.5)); -} - -/** Draw textured tile centered in world space, with color applied if using WebGL - * @param {Vector2} pos - Center of the tile in world space - * @param {Vector2} [size=new Vector2(1,1)] - Size of the tile in world space, width and height - * @param {Number} [tileIndex=-1] - Tile index to use, negative is untextured - * @param {Vector2} [tileSize=tileSizeDefault] - Tile size in source pixels - * @param {Color} [color=new Color(1,1,1)] - Color to modulate with - * @param {Number} [angle=0] - Angle to rotate by - * @param {Boolean} [mirror=0] - If true image is flipped along the Y axis - * @param {Color} [additiveColor=new Color(0,0,0,0)] - Additive color to be applied - * @param {Boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering - * @memberof Draw */ -function drawTile(pos, size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, color=new Color, angle=0, mirror, - additiveColor=new Color(0,0,0,0), useWebGL=glEnable) -{ - showWatermark && ++drawCount; - if (glEnable && useWebGL) - { - if (tileIndex < 0 || !tileImage.width) - { - // if negative tile index or image not found, force untextured - glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, color.rgbaInt()); - } - else - { - // calculate uvs and render - const cols = tileImageSize.x / tileSize.x |0; - const uvSizeX = tileSize.x / tileImageSize.x; - const uvSizeY = tileSize.y / tileImageSize.y; - const uvX = (tileIndex%cols)*uvSizeX, uvY = (tileIndex/cols|0)*uvSizeY; - - glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle, - uvX + tileImageFixBleed.x, uvY + tileImageFixBleed.y, - uvX - tileImageFixBleed.x + uvSizeX, uvY - tileImageFixBleed.y + uvSizeY, - color.rgbaInt(), additiveColor.rgbaInt()); - } - } - else - { - // normal canvas 2D rendering method (slower) - drawCanvas2D(pos, size, angle, mirror, (context)=> - { - if (tileIndex < 0) - { - // if negative tile index, force untextured - context.fillStyle = color; - context.fillRect(-.5, -.5, 1, 1); - } - else - { - // calculate uvs and render - const cols = tileImageSize.x / tileSize.x |0; - const sX = (tileIndex%cols)*tileSize.x + tileFixBleedScale; - const sY = (tileIndex/cols|0)*tileSize.y + tileFixBleedScale; - const sWidth = tileSize.x - 2*tileFixBleedScale; - const sHeight = tileSize.y - 2*tileFixBleedScale; - context.globalAlpha = color.a; // only alpha is supported - context.drawImage(tileImage, sX, sY, sWidth, sHeight, -.5, -.5, 1, 1); - } - }); - } -} - -/** Draw colored rect centered on pos - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [angle=0] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function drawRect(pos, size, color, angle, useWebGL) -{ - drawTile(pos, size, -1, tileSizeDefault, color, angle, 0, 0, useWebGL); -} - -/** Draw textured tile centered on pos in screen space - * @param {Vector2} pos - Center of the tile - * @param {Vector2} [size=new Vector2(1,1)] - Size of the tile - * @param {Number} [tileIndex=-1] - Tile index to use, negative is untextured - * @param {Vector2} [tileSize=tileSizeDefault] - Tile size in source pixels - * @param {Color} [color=new Color] - * @param {Number} [angle=0] - * @param {Boolean} [mirror=0] - * @param {Color} [additiveColor=new Color(0,0,0,0)] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function drawTileScreenSpace(pos, size=vec2(1), tileIndex, tileSize, color, angle, mirror, additiveColor, useWebGL) -{ - drawTile(screenToWorld(pos), size.scale(1/cameraScale), tileIndex, tileSize, color, angle, mirror, additiveColor, useWebGL); -} - -/** Draw colored rectangle in screen space - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [angle=0] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function drawRectScreenSpace(pos, size, color, angle, useWebGL) -{ - drawTileScreenSpace(pos, size, -1, tileSizeDefault, color, angle, 0, 0, useWebGL); -} - -/** Draw colored line between two points - * @param {Vector2} posA - * @param {Vector2} posB - * @param {Number} [thickness=.1] - * @param {Color} [color=new Color(1,1,1)] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function drawLine(posA, posB, thickness=.1, color, useWebGL) -{ - const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2); - const size = vec2(thickness, halfDelta.length()*2); - drawRect(posA.add(halfDelta), size, color, halfDelta.angle(), 0, 0, useWebGL); -} - -/** Draw directly to a 2d canvas context in world space - * @param {Vector2} pos - * @param {Vector2} size - * @param {Number} angle - * @param {Boolean} mirror - * @param {Function} drawFunction - * @param {CanvasRenderingContext2D} [context=mainContext] - * @memberof Draw */ -function drawCanvas2D(pos, size, angle, mirror, drawFunction, context = mainContext) -{ - // create canvas transform from world space to screen space - pos = worldToScreen(pos); - size = size.scale(cameraScale); - context.save(); - context.translate(pos.x+.5|0, pos.y-.5|0); - context.rotate(angle); - context.scale(mirror ? -size.x : size.x, size.y); - drawFunction(context); - context.restore(); -} - -/** Enable normal or additive blend mode - * @param {Boolean} [additive=0] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function setBlendMode(additive, useWebGL=glEnable) -{ - if (glEnable && useWebGL) - glSetBlendMode(additive); - else - mainContext.globalCompositeOperation = additive ? 'lighter' : 'source-over'; -} - -/** Draw text on overlay canvas in screen space - * Automatically splits new lines into rows - * @param {String} text - * @param {Vector2} pos - * @param {Number} [size=1] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [lineWidth=0] - * @param {Color} [lineColor=new Color(0,0,0)] - * @param {String} [textAlign='center'] - * @memberof Draw */ -function drawTextScreen(text, pos, size=1, color=new Color, lineWidth=0, lineColor=new Color(0,0,0), textAlign='center', font=fontDefault) -{ - overlayContext.fillStyle = color; - overlayContext.lineWidth = lineWidth; - overlayContext.strokeStyle = lineColor; - overlayContext.textAlign = textAlign; - overlayContext.font = size + 'px '+ font; - overlayContext.textBaseline = 'middle'; - overlayContext.lineJoin = 'round'; - - pos = pos.copy(); - (text+'').split('\n').forEach(line=> - { - lineWidth && overlayContext.strokeText(line, pos.x, pos.y); - overlayContext.fillText(line, pos.x, pos.y); - pos.y += size; - }); -} - -/** Draw text on overlay canvas in world space - * Automatically splits new lines into rows - * @param {String} text - * @param {Vector2} pos - * @param {Number} [size=1] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [lineWidth=0] - * @param {Color} [lineColor=new Color(0,0,0)] - * @param {String} [textAlign='center'] - * @memberof Draw */ -function drawText(text, pos, size=1, color, lineWidth, lineColor, textAlign, font) -{ - drawTextScreen(text, worldToScreen(pos), size*cameraScale, color, lineWidth*cameraScale, lineColor, textAlign, font); -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Font Image Object - Draw text on a 2D canvas by using characters in an image - *
- 96 characters (from space to tilde) are stored in an image - *
- Uses a default 8x8 font if none is supplied - *
- You can also use fonts from the main tile sheet - * @example - * // use built in font - * const font = new ImageFont; - * - * // draw text - * font.drawTextScreen("LittleJS\nHello World!", vec2(200, 50)); - */ - -let engineFontImage; - -class FontImage -{ - /** Create an image font - * @param {Image} [image] - The image the font is stored in, if undefined the default font is used - * @param {Vector2} [tileSize=vec2(8)] - The size of the font source tiles - * @param {Vector2} [paddingSize=vec2(0,1)] - How much extra space to add between characters - * @param {Number} [startTileIndex=0] - Tile index in image where font starts - * @param {CanvasRenderingContext2D} [context=overlayContext] - context to draw to - */ - constructor(image, tileSize=vec2(8), paddingSize=vec2(0,1), startTileIndex=0, context=overlayContext) - { - if (!image && !engineFontImage) - { - // load default font image - engineFontImage = new Image(); - engineFontImage.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAAYAQAAAAA9+x6JAAAAAnRSTlMAAHaTzTgAAAGiSURBVHjaZZABhxxBEIUf6ECLBdFY+Q0PMNgf0yCgsSAGZcT9sgIPtBWwIA5wgAPEoHUyJeeSlW+gjK+fegWwtROWpVQEyWh2npdpBmTUFVhb29RINgLIukoXr5LIAvYQ5ve+1FqWEMqNKTX3FAJHyQDRZvmKWubAACcv5z5Gtg2oyCWE+Yk/8JZQX1jTTCpKAFGIgza+dJCNBF2UskRlsgwitHbSV0QLgt9sTPtsRlvJjEr8C/FARWA2bJ/TtJ7lko34dNDn6usJUMzuErP89UUBJbWeozrwLLncXczd508deAjLWipLO4Q5XGPcJvPu92cNDaN0P5G1FL0nSOzddZOrJ6rNhbXGmeDvO3TF7DeJWl4bvaYQTNHCTeuqKZmbjHaSOFes+IX/+IhHrnAkXOAsfn24EM68XieIECoccD4KZLk/odiwzeo2rovYdhvb2HYFgyznJyDpYJdYOmfXgVdJTaUi4xA2uWYNYec9BLeqdl9EsoTw582mSFDX2DxVLbNt9U3YYoeatBad1c2Tj8t2akrjaIGJNywKB/7h75/gN3vCMSaadIUTAAAAAElFTkSuQmCC'; - } - - this.image = image || engineFontImage; - this.tileSize = tileSize; - this.paddingSize = paddingSize; - this.startTileIndex = startTileIndex; - this.context = context; - } - - /** Draw text in screen space using the image font - * @param {String} text - * @param {Vector2} pos - * @param {Number} [scale=4] - * @param {Boolean} [center] - */ - drawTextScreen(text, pos, scale=4, center) - { - const context = this.context; - context.save(); - context.imageSmoothingEnabled = !cavasPixelated; - - const size = this.tileSize; - const drawSize = size.add(this.paddingSize).scale(scale); - const cols = this.image.width / this.tileSize.x |0; - text.split('\n').forEach((line, i)=> - { - const centerOffset = center ? line.length * size.x * scale / 2 |0 : 0; - for(let j=line.length; j--;) - { - // draw each character - let charCode = line[j].charCodeAt(); - if (charCode < 32 || charCode > 127) - charCode = 127; // unknown character - - // get the character source location and draw it - const tile = this.startTileIndex + charCode - 32; - const x = tile % cols; - const y = tile / cols |0; - const drawPos = pos.add(vec2(j,i).multiply(drawSize)); - context.drawImage(this.image, x * size.x, y * size.y, size.x, size.y, - drawPos.x - centerOffset, drawPos.y, size.x * scale, size.y * scale); - } - }); - - context.restore(); - } - - /** Draw text in world space using the image font - * @param {String} text - * @param {Vector2} pos - * @param {Number} [scale=.25] - * @param {Boolean} [center] - */ - drawText(text, pos, scale=1, center) - { - this.drawTextScreen(text, worldToScreen(pos).floor(), scale*cameraScale|0, center); - } -} - -/////////////////////////////////////////////////////////////////////////////// -// Fullscreen mode - -/** Returns true if fullscreen mode is active - * @return {Boolean} - * @memberof Draw */ -const isFullscreen =()=> document.fullscreenElement; - -/** Toggle fullsceen mode - * @memberof Draw */ -function toggleFullscreen() -{ - if (isFullscreen()) - { - if (document.exitFullscreen) - document.exitFullscreen(); - else if (document.mozCancelFullScreen) - document.mozCancelFullScreen(); - } - else - { - if (document.body.webkitRequestFullScreen) - document.body.webkitRequestFullScreen(); - else if (document.body.mozRequestFullScreen) - document.body.mozRequestFullScreen(); - } -} -/** - * LittleJS Input System - *
- Tracks key down, pressed, and released - *
- Also tracks mouse buttons, position, and wheel - *
- Supports multiple gamepads - *
- Virtual gamepad for touch devices with touchGamepadSize - * @namespace Input - */ - -'use strict'; - -/** Returns true if device key is down - * @param {Number} key - * @param {Number} [device=0] - * @return {Boolean} - * @memberof Input */ -const keyIsDown = (key, device=0)=> inputData[device] && inputData[device][key] & 1 ? 1 : 0; - -/** Returns true if device key was pressed this frame - * @param {Number} key - * @param {Number} [device=0] - * @return {Boolean} - * @memberof Input */ -const keyWasPressed = (key, device=0)=> inputData[device] && inputData[device][key] & 2 ? 1 : 0; - -/** Returns true if device key was released this frame - * @param {Number} key - * @param {Number} [device=0] - * @return {Boolean} - * @memberof Input */ -const keyWasReleased = (key, device=0)=> inputData[device] && inputData[device][key] & 4 ? 1 : 0; - -/** Clears all input - * @memberof Input */ -const clearInput = ()=> inputData = [[]]; - -/** Returns true if mouse button is down - * @param {Number} button - * @return {Boolean} - * @memberof Input */ -const mouseIsDown = keyIsDown; - -/** Returns true if mouse button was pressed - * @param {Number} button - * @return {Boolean} - * @memberof Input */ -const mouseWasPressed = keyWasPressed; - -/** Returns true if mouse button was released - * @param {Number} button - * @return {Boolean} - * @memberof Input */ -const mouseWasReleased = keyWasReleased; - -/** Mouse pos in world space - * @type {Vector2} - * @memberof Input */ -let mousePos = vec2(); - -/** Mouse pos in screen space - * @type {Vector2} - * @memberof Input */ -let mousePosScreen = vec2(); - -/** Mouse wheel delta this frame - * @memberof Input */ -let mouseWheel = 0; - -/** Returns true if user is using gamepad (has more recently pressed a gamepad button) - * @memberof Input */ -let isUsingGamepad = 0; - -/** Prevents input continuing to the default browser handling (false by default) - * @memberof Input */ -let preventDefaultInput = 0; - -/** Returns true if gamepad button is down - * @param {Number} button - * @param {Number} [gamepad=0] - * @return {Boolean} - * @memberof Input */ -const gamepadIsDown = (button, gamepad=0)=> keyIsDown(button, gamepad+1); - -/** Returns true if gamepad button was pressed - * @param {Number} button - * @param {Number} [gamepad=0] - * @return {Boolean} - * @memberof Input */ -const gamepadWasPressed = (button, gamepad=0)=> keyWasPressed(button, gamepad+1); - -/** Returns true if gamepad button was released - * @param {Number} button - * @param {Number} [gamepad=0] - * @return {Boolean} - * @memberof Input */ -const gamepadWasReleased = (button, gamepad=0)=> keyWasReleased(button, gamepad+1); - -/** Returns gamepad stick value - * @param {Number} stick - * @param {Number} [gamepad=0] - * @return {Vector2} - * @memberof Input */ -const gamepadStick = (stick, gamepad=0)=> stickData[gamepad] ? stickData[gamepad][stick] || vec2() : vec2(); - -/////////////////////////////////////////////////////////////////////////////// -// Input update called by engine - -// store input as a bit field for each key: 1 = isDown, 2 = wasPressed, 4 = wasReleased -// mouse and keyboard are stored together in device 0, gamepads are in devices > 0 -let inputData = [[]]; - -function inputUpdate() -{ - // clear input when lost focus (prevent stuck keys) - document.hasFocus() || clearInput(); - - // update mouse world space position - mousePos = screenToWorld(mousePosScreen); - - // update gamepads if enabled - gamepadsUpdate(); -} - -function inputUpdatePost() -{ - // clear input to prepare for next frame - for (const deviceInputData of inputData) - for (const i in deviceInputData) - deviceInputData[i] &= 1; - mouseWheel = 0; -} - -/////////////////////////////////////////////////////////////////////////////// -// Keyboard event handlers - -onkeydown = (e)=> -{ - if (debug && e.target != document.body) return; - e.repeat || (inputData[isUsingGamepad = 0][remapKeyCode(e.keyCode)] = 3); - preventDefaultInput && e.preventDefault(); -} -onkeyup = (e)=> -{ - if (debug && e.target != document.body) return; - inputData[0][remapKeyCode(e.keyCode)] = 4; -} -const remapKeyCode = (c)=> inputWASDEmulateDirection ? c==87?38 : c==83?40 : c==65?37 : c==68?39 : c : c; - -/////////////////////////////////////////////////////////////////////////////// -// Mouse event handlers - -onmousedown = (e)=> {inputData[isUsingGamepad = 0][e.button] = 3; onmousemove(e); e.button && e.preventDefault();} -onmouseup = (e)=> inputData[0][e.button] = inputData[0][e.button] & 2 | 4; -onmousemove = (e)=> mousePosScreen = mouseToScreen(e); -onwheel = (e)=> e.ctrlKey || (mouseWheel = sign(e.deltaY)); -oncontextmenu = (e)=> !1; // prevent right click menu - -// convert a mouse or touch event position to screen space -const mouseToScreen = (mousePos)=> -{ - if (!mainCanvas) - return vec2(); // fix bug that can occur if user clicks before page loads - - const rect = mainCanvas.getBoundingClientRect(); - return vec2(mainCanvas.width, mainCanvas.height).multiply( - vec2(percent(mousePos.x, rect.left, rect.right), percent(mousePos.y, rect.top, rect.bottom))); -} - -/////////////////////////////////////////////////////////////////////////////// -// Gamepad input - -const stickData = []; -function gamepadsUpdate() -{ - if (touchGamepadEnable && touchGamepadTimer.isSet()) - { - // read virtual analog stick - const sticks = stickData[0] || (stickData[0] = []); - sticks[0] = vec2(touchGamepadStick.x, -touchGamepadStick.y); // flip vertical - - // read virtual gamepad buttons - const data = inputData[1] || (inputData[1] = []); - for (let i=10; i--;) - { - const j = i == 3 ? 2 : i == 2 ? 3 : i; // fix button locations - data[j] = touchGamepadButtons[i] ? 1 + 2*!gamepadIsDown(j,0) : 4*gamepadIsDown(j,0); - } - } - - if (!gamepadsEnable || !navigator.getGamepads || !document.hasFocus() && !debug) - return; - - // poll gamepads - const gamepads = navigator.getGamepads(); - for (let i = gamepads.length; i--;) - { - // get or create gamepad data - const gamepad = gamepads[i]; - const data = inputData[i+1] || (inputData[i+1] = []); - const sticks = stickData[i] || (stickData[i] = []); - - if (gamepad) - { - // read clamp dead zone of analog sticks - const deadZone = .3, deadZoneMax = .8; - const applyDeadZone = (v)=> - v > deadZone ? percent( v, deadZone, deadZoneMax) : - v < -deadZone ? -percent(-v, deadZone, deadZoneMax) : 0; - - // read analog sticks - for (let j = 0; j < gamepad.axes.length-1; j+=2) - sticks[j>>1] = vec2(applyDeadZone(gamepad.axes[j]), applyDeadZone(-gamepad.axes[j+1])).clampLength(); - - // read buttons - for (let j = gamepad.buttons.length; j--;) - { - const button = gamepad.buttons[j]; - data[j] = button.pressed ? 1 + 2*!gamepadIsDown(j,i) : 4*gamepadIsDown(j,i); - isUsingGamepad |= !i && button.pressed; - touchGamepadEnable && touchGamepadTimer.unset(); // disable touch gamepad if using real gamepad - } - - if (gamepadDirectionEmulateStick) - { - // copy dpad to left analog stick when pressed - const dpad = vec2(gamepadIsDown(15,i) - gamepadIsDown(14,i), gamepadIsDown(12,i) - gamepadIsDown(13,i)); - if (dpad.lengthSquared()) - sticks[0] = dpad.clampLength(); - } - } - } -} - -/////////////////////////////////////////////////////////////////////////////// - -/** Pulse the vibration hardware if it exists - * @param {Number} [pattern=100] - a single value in miliseconds or vibration interval array - * @memberof Input */ -const vibrate = (pattern)=> vibrateEnable && Navigator.vibrate && Navigator.vibrate(pattern); - -/** Cancel any ongoing vibration - * @memberof Input */ -const vibrateStop = ()=> vibrate(0); - -/////////////////////////////////////////////////////////////////////////////// -// Touch input - -/** True if a touch device has been detected - * @const {boolean} - * @memberof Input */ -const isTouchDevice = window.ontouchstart !== undefined; - -// try to enable touch mouse -if (isTouchDevice) -{ - // handle all touch events the same way - let wasTouching, hadTouchInput; - ontouchstart = ontouchmove = ontouchend = (e)=> - { - e.button = 0; // all touches are left click - - // check if touching and pass to mouse events - const touching = e.touches.length; - if (touching) - { - hadTouchInput || zzfx(0, hadTouchInput=1) ; // fix mobile audio, force it to play a sound the first time - - // set event pos and pass it along - e.x = e.touches[0].clientX; - e.y = e.touches[0].clientY; - wasTouching ? onmousemove(e) : onmousedown(e); - } - else if (wasTouching) - onmouseup(e); - - // set was touching - wasTouching = touching; - - // prevent normal mouse events from being called - return !e.cancelable; - } -} - -/////////////////////////////////////////////////////////////////////////////// -// touch gamepad, virtual on screen gamepad emulator for touch devices - -// touch input internal variables -let touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadStick = vec2(); - -// create the touch gamepad, called automatically by the engine -function touchGamepadCreate() -{ - if (!touchGamepadEnable || !isTouchDevice) - return; - - ontouchstart = ontouchmove = ontouchend = (e)=> - { - if (!touchGamepadEnable) - return; - - // clear touch gamepad input - touchGamepadStick = vec2(); - touchGamepadButtons = []; - - const touching = e.touches.length; - if (touching) - { - touchGamepadTimer.isSet() || zzfx(0) ; // fix mobile audio, force it to play a sound the first time - - // set that gamepad is active - isUsingGamepad = 1; - touchGamepadTimer.set(); - - if (paused) - { - // touch anywhere to press start when paused - touchGamepadButtons[9] = 1; - return; - } - } - - // get center of left and right sides - const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize); - const buttonCenter = mainCanvasSize.subtract(vec2(touchGamepadSize, touchGamepadSize)); - const startCenter = mainCanvasSize.scale(.5); - - // check each touch point - for (const touch of e.touches) - { - const touchPos = mouseToScreen(vec2(touch.clientX, touch.clientY)); - if (touchPos.distance(stickCenter) < touchGamepadSize) - { - // virtual analog stick - if (touchGamepadAnalog) - touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize).clampLength(); - else - { - // 8 way dpad - const angle = touchPos.subtract(stickCenter).angle(); - touchGamepadStick.setAngle((angle * 4 / PI + 8.5 | 0) * PI / 4); - } - } - else if (touchPos.distance(buttonCenter) < touchGamepadSize) - { - // virtual face buttons - const button = touchPos.subtract(buttonCenter).direction(); - touchGamepadButtons[button] = 1; - } - else if (touchPos.distance(startCenter) < touchGamepadSize) - { - // virtual start button in center - touchGamepadButtons[9] = 1; - } - } - } -} - -// render the touch gamepad, called automatically by the engine -function touchGamepadRender() -{ - if (!touchGamepadEnable || !touchGamepadTimer.isSet()) - return; - - // fade off when not touching or paused - const alpha = percent(touchGamepadTimer.get(), 4, 3); - if (!alpha || paused) - return; - - // setup the canvas - overlayContext.save(); - overlayContext.globalAlpha = alpha*touchGamepadAlpha; - overlayContext.strokeStyle = '#fff'; - overlayContext.lineWidth = 3; - - // draw left analog stick - overlayContext.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000'; - overlayContext.beginPath(); - - const leftCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize); - if (touchGamepadAnalog) - { - overlayContext.arc(leftCenter.x, leftCenter.y, touchGamepadSize/2, 0, 9); - overlayContext.fill(); - overlayContext.stroke(); - } - else // draw cross shaped gamepad - { - for(let i=10; i--;) - { - const angle = i*PI/4; - overlayContext.arc(leftCenter.x, leftCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8); - i%2 && overlayContext.arc(leftCenter.x, leftCenter.y, touchGamepadSize*.33, angle, angle); - i==1 && overlayContext.fill(); - } - overlayContext.stroke(); - } - - // draw right face buttons - const rightCenter = vec2(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize); - for (let i=4; i--;) - { - const pos = rightCenter.add((new Vector2).setAngle(i*PI/2, touchGamepadSize/2)); - overlayContext.fillStyle = touchGamepadButtons[i] ? '#fff' : '#000'; - overlayContext.beginPath(); - overlayContext.arc(pos.x, pos.y, touchGamepadSize/4, 0,9); - overlayContext.fill(); - overlayContext.stroke(); - } - - // set canvas back to normal - overlayContext.restore(); -} -/** - * LittleJS Audio System - *
- ZzFX Sound Effects - *
- ZzFXM Music - *
- Caches sounds and music for fast playback - *
- Can attenuate and apply stereo panning to sounds - *
- Ability to play mp3, ogg, and wave files - *
- Speech synthesis wrapper functions - */ - -'use strict'; - -/** - * Sound Object - Stores a zzfx sound for later use and can be played positionally - *
- *
Create sounds using the ZzFX Sound Designer. - * @example - * // create a sound - * const sound_example = new Sound([.5,.5]); - * - * // play the sound - * sound_example.play(); - */ -class Sound -{ - /** Create a sound object and cache the zzfx samples for later use - * @param {Array} zzfxSound - Array of zzfx parameters, ex. [.5,.5] - * @param {Number} [range=soundDefaultRange] - World space max range of sound, will not play if camera is farther away - * @param {Number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering off - */ - constructor(zzfxSound, range=soundDefaultRange, taper=soundDefaultTaper) - { - if (!soundEnable) return; - - /** @property {Number} - World space max range of sound, will not play if camera is farther away */ - this.range = range; - - /** @property {Number} - At what percentage of range should it start tapering off */ - this.taper = taper; - - // get randomness from sound parameters - this.randomness = zzfxSound[1] || 0; - zzfxSound[1] = 0; - - // generate sound now for fast playback - this.cachedSamples = zzfxG(...zzfxSound); - } - - /** Play the sound - * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null - * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) - * @param {Number} [pitch=1] - How much to scale pitch by (also adjusted by this.randomness) - * @param {Number} [randomnessScale=1] - How much to scale randomness - * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later - */ - play(pos, volume=1, pitch=1, randomnessScale=1) - { - if (!soundEnable) return; - - let pan = 0; - if (pos) - { - const range = this.range; - if (range) - { - // apply range based fade - const lengthSquared = cameraPos.distanceSquared(pos); - if (lengthSquared > range*range) - return; // out of range - - // attenuate volume by distance - volume *= percent(lengthSquared**.5, range, range*this.taper); - } - - // get pan from screen space coords - pan = worldToScreen(pos).x * 2/mainCanvas.width - 1; - } - - // play the sound - const playbackRate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1); - return playSamples([this.cachedSamples], volume, playbackRate, pan); - } - - /** Play the sound as a note with a semitone offset - * @param {Number} semitoneOffset - How many semitones to offset pitch - * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null - * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) - * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later - */ - playNote(semitoneOffset, pos, volume=1) - { - if (!soundEnable) return; - - return this.play(pos, volume, 2**(semitoneOffset/12), 0); - } -} - -/** - * Music Object - Stores a zzfx music track for later use - *
- *
Create music with the ZzFXM tracker. - * @example - * // create some music - * const music_example = new Music( - * [ - * [ // instruments - * [,0,400] // simple note - * ], - * [ // patterns - * [ // pattern 1 - * [ // channel 0 - * 0, -1, // instrument 0, left speaker - * 1, 0, 9, 1 // channel notes - * ], - * [ // channel 1 - * 0, 1, // instrument 1, right speaker - * 0, 12, 17, -1 // channel notes - * ] - * ], - * ], - * [0, 0, 0, 0], // sequence, play pattern 0 four times - * 90 // BPM - * ]); - * - * // play the music - * music_example.play(); - */ -class Music -{ - /** Create a music object and cache the zzfx music samples for later use - * @param {Array} zzfxMusic - Array of zzfx music parameters - */ - constructor(zzfxMusic) - { - if (!soundEnable) return; - - this.cachedSamples = zzfxM(...zzfxMusic); - } - - /** Play the music - * @param {Number} [volume=1] - How much to scale volume by - * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end - * @return {AudioBufferSourceNode} - The audio node, can be used to stop sound later - */ - play(volume = 1, loop = 1) - { - if (!soundEnable) return; - - return playSamples(this.cachedSamples, volume, 1, 0, loop); - } -} - -/** Play an mp3 or wav audio from a local file or url - * @param {String} url - Location of sound file to play - * @param {Number} [volume=1] - How much to scale volume by - * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end - * @return {HTMLAudioElement} - The audio element for this sound - * @memberof Audio */ -function playAudioFile(url, volume=1, loop=1) -{ - if (!soundEnable) return; - - const audio = new Audio(url); - audio.volume = soundVolume * volume; - audio.loop = loop; - audio.play(); - return audio; -} - -/** Speak text with passed in settings - * @param {String} text - The text to speak - * @param {String} [language] - The language/accent to use (examples: en, it, ru, ja, zh) - * @param {Number} [volume=1] - How much to scale volume by - * @param {Number} [rate=1] - How quickly to speak - * @param {Number} [pitch=1] - How much to change the pitch by - * @return {SpeechSynthesisUtterance} - The utterance that was spoken - * @memberof Audio */ -function speak(text, language='', volume=1, rate=1, pitch=1) -{ - if (!soundEnable || !speechSynthesis) return; - - // common languages (not supported by all browsers) - // en - english, it - italian, fr - french, de - german, es - spanish - // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean - - // build utterance and speak - const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = language; - utterance.volume = 2*volume*soundVolume; - utterance.rate = rate; - utterance.pitch = pitch; - speechSynthesis.speak(utterance); - return utterance; -} - -/** Stop all queued speech - * @memberof Audio */ -const speakStop = ()=> speechSynthesis && speechSynthesis.cancel(); - -/** Get frequency of a note on a musical scale - * @param {Number} semitoneOffset - How many semitones away from the root note - * @param {Number} [rootNoteFrequency=220] - Frequency at semitone offset 0 - * @return {Number} - The frequency of the note - * @memberof Audio */ -const getNoteFrequency = (semitoneOffset, rootFrequency=220)=> rootFrequency * 2**(semitoneOffset/12); - -/////////////////////////////////////////////////////////////////////////////// - -/** Audio context used by the engine - * @memberof Audio */ -let audioContext; - -/** Play cached audio samples with given settings - * @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback) - * @param {Number} [volume=1] - How much to scale volume by - * @param {Number} [rate=1] - The playback rate to use - * @param {Number} [pan=0] - How much to apply stereo panning - * @param {Boolean} [loop=0] - True if the sound should loop when it reaches the end - * @return {AudioBufferSourceNode} - The audio node of the sound played - * @memberof Audio */ -function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=0) -{ - if (!soundEnable) return; - - // create audio context - if (!audioContext) - audioContext = new (window.AudioContext||webkitAudioContext); - - // fix stalled audio - audioContext.resume(); - - // prevent sounds from building up if they can't be played - if (audioContext.state != 'running') - return; - - // create buffer and source - const buffer = audioContext.createBuffer(sampleChannels.length, sampleChannels[0].length, zzfxR), - source = audioContext.createBufferSource(); - - // copy samples to buffer and setup source - sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c)); - source.buffer = buffer; - source.playbackRate.value = rate; - source.loop = loop; - - // create and connect gain node (createGain is more widley spported then GainNode construtor) - const gainNode = audioContext.createGain(); - gainNode.gain.value = soundVolume*volume; - gainNode.connect(audioContext.destination); - - // connect source to gain - ( - window.StereoPannerNode ? // create pan node if possible - source.connect(new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)})) - : source - ) - .connect(gainNode); - - // play and return sound - source.start(); - return source; -} - -/////////////////////////////////////////////////////////////////////////////// -// ZzFXMicro - Zuper Zmall Zound Zynth - v1.1.8 by Frank Force - -/** Generate and play a ZzFX sound - *
- *
Create sounds using the ZzFX Sound Designer. - * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5] - * @return {Array} - Array of audio samples - * @memberof Audio */ -const zzfx = (...zzfxSound) => playSamples([zzfxG(...zzfxSound)]); - -/** Sample rate used for all ZzFX sounds - * @default 44100 - * @memberof Audio */ -const zzfxR = 44100; - -/** Generate samples for a ZzFX sound - * @memberof Audio */ -function zzfxG -( - // parameters - volume = 1, randomness = .05, frequency = 220, attack = 0, sustain = 0, - release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0, - pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0, - bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0 -) -{ - // init parameters - let PI2 = PI*2, startSlide = slide *= 500 * PI2 / zzfxR / zzfxR, b=[], - startFrequency = frequency *= (1 + randomness*rand(-1,1)) * PI2 / zzfxR, - t=0, tm=0, i=0, j=1, r=0, c=0, s=0, f, length; - - // scale by sample rate - attack = attack * zzfxR + 9; // minimum attack to prevent pop - decay *= zzfxR; - sustain *= zzfxR; - release *= zzfxR; - delay *= zzfxR; - deltaSlide *= 500 * PI2 / zzfxR**3; - modulation *= PI2 / zzfxR; - pitchJump *= PI2 / zzfxR; - pitchJumpTime *= zzfxR; - repeatTime = repeatTime * zzfxR | 0; - - // generate waveform - for (length = attack + decay + sustain + release + delay | 0; - i < length; b[i++] = s) - { - if (!(++c%(bitCrush*100|0))) // bit crush - { - s = shape? shape>1? shape>2? shape>3? // wave shape - Math.sin((t%PI2)**3) : // 4 noise - max(min(Math.tan(t),1),-1): // 3 tan - 1-(2*t/PI2%2+2)%2: // 2 saw - 1-4*abs(Math.round(t/PI2)-t/PI2): // 1 triangle - Math.sin(t); // 0 sin - - s = (repeatTime ? - 1 - tremolo + tremolo*Math.sin(PI2*i/repeatTime) // tremolo - : 1) * - sign(s)*(abs(s)**shapeCurve) * // curve 0=square, 2=pointy - volume * soundVolume * ( // envelope - i < attack ? i/attack : // attack - i < attack + decay ? // decay - 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff - i < attack + decay + sustain ? // sustain - sustainVolume : // sustain volume - i < length - delay ? // release - (length - i - delay)/release * // release falloff - sustainVolume : // release volume - 0); // post release - - s = delay ? s/2 + (delay > i ? 0 : // delay - (i pitchJumpTime) // pitch jump - { - frequency += pitchJump; // apply pitch jump - startFrequency += pitchJump; // also apply to start - j = 0; // reset pitch jump time - } - - if (repeatTime && !(++r % repeatTime)) // repeat - { - frequency = startFrequency; // reset frequency - slide = startSlide; // reset slide - j = j || 1; // reset pitch jump time - } - } - - return b; -} - -/////////////////////////////////////////////////////////////////////////////// -// ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force - -/** Generate samples for a ZzFM song with given parameters - * @param {Array} instruments - Array of ZzFX sound paramaters - * @param {Array} patterns - Array of pattern data - * @param {Array} sequence - Array of pattern indexes - * @param {Number} [BPM=125] - Playback speed of the song in BPM - * @returns {Array} - Left and right channel sample data - * @memberof Audio */ -function zzfxM(instruments, patterns, sequence, BPM = 125) -{ - let instrumentParameters; - let i; - let j; - let k; - let note; - let sample; - let patternChannel; - let notFirstBeat; - let stop; - let instrument; - let attenuation; - let outSampleOffset; - let isSequenceEnd; - let sampleOffset = 0; - let nextSampleOffset; - let sampleBuffer = []; - let leftChannelBuffer = []; - let rightChannelBuffer = []; - let channelIndex = 0; - let panning = 0; - let hasMore = 1; - let sampleCache = {}; - let beatLength = zzfxR / BPM * 60 >> 2; - - // for each channel in order until there are no more - for (; hasMore; channelIndex++) { - - // reset current values - sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0]; - - // for each pattern in sequence - sequence.forEach((patternIndex, sequenceIndex) => { - // get pattern for current channel, use empty 1 note pattern if none found - patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0]; - - // check if there are more channels - hasMore |= !!patterns[patternIndex][channelIndex]; - - // get next offset, use the length of first channel - nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - !notFirstBeat) * beatLength; - // for each beat in pattern, plus one extra if end of sequence - isSequenceEnd = sequenceIndex == sequence.length - 1; - for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) { - - // - note = patternChannel[i]; - - // stop if end, different instrument or new note - stop = i == patternChannel.length + isSequenceEnd - 1 && isSequenceEnd || - instrument != (patternChannel[0] || 0) | note | 0; - - // fill buffer with samples for previous beat, most cpu intensive part - for (j = 0; j < beatLength && notFirstBeat; - - // fade off attenuation at end of beat if stopping note, prevents clicking - j++ > beatLength - 99 && stop ? attenuation += (attenuation < 1) / 99 : 0 - ) { - // copy sample to stereo buffers with panning - sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0; - leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample; - rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample; - } - - // set up for next note - if (note) { - // set attenuation - attenuation = note % 1; - panning = patternChannel[1] || 0; - if (note |= 0) { - // get cached sample - sampleBuffer = sampleCache[ - [ - instrument = patternChannel[sampleOffset = 0] || 0, - note - ] - ] = sampleCache[[instrument, note]] || ( - // add sample to cache - instrumentParameters = [...instruments[instrument]], - instrumentParameters[2] *= 2 ** ((note - 12) / 12), - - // allow negative values to stop notes - note > 0 ? zzfxG(...instrumentParameters) : [] - ); - } - } - } - - // update the sample offset - outSampleOffset = nextSampleOffset; - }); - } - - return [leftChannelBuffer, rightChannelBuffer]; -} -/** - * LittleJS Tile Layer System - *
- Caches arrays of tiles to off screen canvas for fast rendering - *
- Unlimted numbers of layers, allocates canvases as needed - *
- Interfaces with EngineObject for collision - *
- Collision layer is separate from visible layers - *
- It is recommended to have a visible layer that matches the collision - *
- Tile layers can be drawn to using their context with canvas2d - *
- Drawn directly to the main canvas without using WebGL - * @namespace TileCollision - */ - -'use strict'; - -/** The tile collision layer array, use setTileCollisionData and getTileCollisionData to access - * @memberof TileCollision */ -let tileCollision = []; - -/** Size of the tile collision layer - * @type {Vector2} - * @memberof TileCollision */ -let tileCollisionSize = vec2(); - -/** Clear and initialize tile collision - * @param {Vector2} size - * @memberof TileCollision */ -function initTileCollision(size) -{ - tileCollisionSize = size; - tileCollision = []; - for (let i=tileCollision.length = tileCollisionSize.area(); i--;) - tileCollision[i] = 0; -} - -/** Set tile collision data - * @param {Vector2} pos - * @param {Number} [data=0] - * @memberof TileCollision */ -const setTileCollisionData = (pos, data=0)=> - pos.arrayCheck(tileCollisionSize) && (tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] = data); - -/** Get tile collision data - * @param {Vector2} pos - * @return {Number} - * @memberof TileCollision */ -const getTileCollisionData = (pos)=> - pos.arrayCheck(tileCollisionSize) ? tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] : 0; - -/** Check if collision with another object should occur - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {EngineObject} [object] - * @return {Boolean} - * @memberof TileCollision */ -function tileCollisionTest(pos, size=vec2(), object) -{ - const minX = max(pos.x - size.x/2|0, 0); - const minY = max(pos.y - size.y/2|0, 0); - const maxX = min(pos.x + size.x/2, tileCollisionSize.x); - const maxY = min(pos.y + size.y/2, tileCollisionSize.y); - for (let y = minY; y < maxY; ++y) - for (let x = minX; x < maxX; ++x) - { - const tileData = tileCollision[y*tileCollisionSize.x+x]; - if (tileData && (!object || object.collideWithTile(tileData, new Vector2(x, y)))) - return 1; - } -} - -/** Return the center of tile if any that is hit (this does not return the exact hit point) - * @param {Vector2} posStart - * @param {Vector2} posEnd - * @param {EngineObject} [object] - * @return {Vector2} - * @memberof TileCollision */ -function tileCollisionRaycast(posStart, posEnd, object) -{ - // test if a ray collides with tiles from start to end - // todo: a way to get the exact hit point, it must still register as inside the hit tile - posStart = posStart.floor(); - posEnd = posEnd.floor(); - const posDelta = posEnd.subtract(posStart); - const dx = abs(posDelta.x), dy = -abs(posDelta.y); - const sx = sign(posDelta.x), sy = sign(posDelta.y); - let e = dx + dy; - - for (let x = posStart.x, y = posStart.y;;) - { - const tileData = getTileCollisionData(vec2(x,y)); - if (tileData && (object ? object.collideWithTileRaycast(tileData, new Vector2(x, y)) : tileData > 0)) - { - debugRaycast && debugLine(posStart, posEnd, '#f00',.02, 1); - debugRaycast && debugPoint(new Vector2(x+.5, y+.5), '#ff0', 1); - return new Vector2(x+.5, y+.5); - } - - // update Bresenham line drawing algorithm - if (x == posEnd.x & y == posEnd.y) break; - const e2 = 2*e; - if (e2 >= dy) e += dy, x += sx; - if (e2 <= dx) e += dx, y += sy; - } - debugRaycast && debugLine(posStart, posEnd, '#00f',.02, 1); -} - -/////////////////////////////////////////////////////////////////////////////// -// Tile Layer Rendering System - -/** - * Tile layer data object stores info about how to render a tile - * @example - * // create tile layer data with tile index 0 and random orientation and color - * const tileIndex = 0; - * const direction = randInt(4) - * const mirror = randInt(2); - * const color = randColor(); - * const data = new TileLayerData(tileIndex, direction, mirror, color); - */ -class TileLayerData -{ - /** Create a tile layer data object, one for each tile in a TileLayer - * @param {Number} [tile] - The tile to use, untextured if undefined - * @param {Number} [direction=0] - Integer direction of tile, in 90 degree increments - * @param {Boolean} [mirror=0] - If the tile should be mirrored along the x axis - * @param {Color} [color=new Color(1,1,1)] - Color of the tile */ - constructor(tile, direction=0, mirror=0, color=new Color) - { - /** @property {Number} - The tile to use, untextured if undefined */ - this.tile = tile; - /** @property {Number} - Integer direction of tile, in 90 degree increments */ - this.direction = direction; - /** @property {Boolean} - If the tile should be mirrored along the x axis */ - this.mirror = mirror; - /** @property {Color} - Color of the tile */ - this.color = color; - } - - /** Set this tile to clear, it will not be rendered */ - clear() { this.tile = this.direction = this.mirror = 0; color = new Color; } -} - -/** - * Tile layer object - cached rendering system for tile layers - *
- Each Tile layer is rendered to an off screen canvas - *
- To allow dynamic modifications, layers are rendered using canvas 2d - *
- Some devices like mobile phones are limited to 4k texture resolution - *
- So with 16x16 tiles this limits layers to 256x256 on mobile devices - * @extends EngineObject - * @example - * // create tile collision and visible tile layer - * initTileCollision(vec2(200,100)); - * const tileLayer = new TileLayer(); - */ -class TileLayer extends EngineObject -{ -/** Create a tile layer object - * @param {Vector2} [position=new Vector2()] - World space position - * @param {Vector2} [size=tileCollisionSize] - World space size - * @param {Vector2} [tileSize=tileSizeDefault] - Size of tiles in source pixels - * @param {Vector2} [scale=new Vector2(1,1)] - How much to scale this layer when rendered - * @param {Number} [renderOrder=0] - Objects sorted by renderOrder before being rendered - */ -constructor(pos, size=tileCollisionSize, tileSize=tileSizeDefault, scale=vec2(1), renderOrder=0) - { - super(pos, size, -1, tileSize, 0, undefined, renderOrder); - - /** @property {HTMLCanvasElement} - The canvas used by this tile layer */ - this.canvas = document.createElement('canvas'); - /** @property {CanvasRenderingContext2D} - The 2D canvas context used by this tile layer */ - this.context = this.canvas.getContext('2d'); - /** @property {Vector2} - How much to scale this layer when rendered */ - this.scale = scale; - /** @property {Boolean} [isOverlay=0] - If true this layer will render to overlay canvas and appear above all objects */ - this.isOverlay; - - // init tile data - this.data = []; - for (let j = this.size.area(); j--;) - this.data.push(new TileLayerData()); - } - - /** Set data at a given position in the array - * @param {Vector2} position - Local position in array - * @param {TileLayerData} data - Data to set - * @param {Boolean} [redraw=0] - Force the tile to redraw if true */ - setData(layerPos, data, redraw) - { - if (layerPos.arrayCheck(this.size)) - { - this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data; - redraw && this.drawTileData(layerPos); - } - } - - /** Get data at a given position in the array - * @param {Vector2} layerPos - Local position in array - * @return {TileLayerData} */ - getData(layerPos) - { return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0]; } - - // Tile layers are not updated - update() {} - - // Render the tile layer, called automatically by the engine - render() - { - ASSERT(mainContext != this.context); // must call redrawEnd() after drawing tiles - - // flush and copy gl canvas because tile canvas does not use webgl - glEnable && !glOverlay && !this.isOverlay && glCopyToContext(mainContext); - - // draw the entire cached level onto the canvas - const pos = worldToScreen(this.pos.add(vec2(0,this.size.y*this.scale.y))); - (this.isOverlay ? overlayContext : mainContext).drawImage - ( - this.canvas, pos.x, pos.y, - cameraScale*this.size.x*this.scale.x, cameraScale*this.size.y*this.scale.y - ); - } - - /** Draw all the tile data to an offscreen canvas - * - This may be slow in some browsers - */ - redraw() - { - this.redrawStart(1); - this.drawAllTileData(); - this.redrawEnd(); - } - - /** Call to start the redraw process - * @param {Boolean} [clear=0] - Should it clear the canvas before drawing */ - redrawStart(clear = 0) - { - if (clear) - { - // clear and set size - this.canvas.width = this.size.x * this.tileSize.x; - this.canvas.height = this.size.y * this.tileSize.y; - } - - // save current render settings - this.savedRenderSettings = [mainCanvas, mainContext, cameraPos, cameraScale]; - - // use normal rendering system to render the tiles - mainCanvas = this.canvas; - mainContext = this.context; - cameraPos = this.size.scale(.5); - cameraScale = this.tileSize.x; - enginePreRender(); - } - - /** Call to end the redraw process */ - redrawEnd() - { - ASSERT(mainContext == this.context); // must call redrawStart() before drawing tiles - glCopyToContext(mainContext, 1); - //debugSaveCanvas(this.canvas); - - // set stuff back to normal - [mainCanvas, mainContext, cameraPos, cameraScale] = this.savedRenderSettings; - } - - /** Draw the tile at a given position - * @param {Vector2} layerPos */ - drawTileData(layerPos) - { - // first clear out where the tile was - const pos = layerPos.floor().add(this.pos).add(vec2(.5)); - this.drawCanvas2D(pos, vec2(1), 0, 0, (context)=>context.clearRect(-.5, -.5, 1, 1)); - - // draw the tile if not undefined - const d = this.getData(layerPos); - if (d.tile != undefined) - { - ASSERT(mainContext == this.context); // must call redrawStart() before drawing tiles - drawTile(pos, vec2(1), d.tile, this.tileSize, d.color, d.direction*PI/2, d.mirror); - } - } - - /** Draw all the tiles in this layer */ - drawAllTileData() - { - for (let x = this.size.x; x--;) - for (let y = this.size.y; y--;) - this.drawTileData(vec2(x,y)); - } - - /** Draw directly to the 2D canvas in world space (bipass webgl) - * @param {Vector2} pos - * @param {Vector2} size - * @param {Number} [angle=0] - * @param {Boolean} [mirror=0] - * @param {Function} drawFunction */ - drawCanvas2D(pos, size, angle=0, mirror, drawFunction) - { - const context = this.context; - context.save(); - pos = pos.subtract(this.pos).multiply(this.tileSize); - size = size.multiply(this.tileSize); - context.translate(pos.x, this.canvas.height - pos.y); - context.rotate(angle); - context.scale(mirror ? -size.x : size.x, size.y); - drawFunction(context); - context.restore(); - } - - /** Draw a tile directly onto the layer canvas - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {Number} [tileIndex=-1] - * @param {Vector2} [tileSize=tileSizeDefault] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [angle=0] - * @param {Boolean} [mirror=0] */ - drawTile(pos, size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, color=new Color, angle, mirror) - { - this.drawCanvas2D(pos, size, angle, mirror, (context)=> - { - if (tileIndex < 0) - { - // untextured - context.fillStyle = color; - context.fillRect(-.5, -.5, 1, 1); - } - else - { - const cols = tileImage.width/tileSize.x; - context.globalAlpha = color.a; // only alpha, no color, is supported in this mode - context.drawImage(tileImage, - (tileIndex%cols)*tileSize.x, (tileIndex/cols|0)*tileSize.x, - tileSize.x, tileSize.y, -.5, -.5, 1, 1); - } - }); - } - - /** Draw a rectangle directly onto the layer canvas - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [angle=0] */ - drawRect(pos, size, color, angle) - { this.drawTile(pos, size, -1, 0, color, angle); } -} -/* - LittleJS Particle System - - Spawns particles with randomness from parameters - - Updates particle physics - - Fast particle rendering -*/ - -'use strict'; - -/** - * Particle Emitter - Spawns particles with the given settings - * @extends EngineObject - * @example - * // create a particle emitter - * let pos = vec2(2,3); - * let particleEmiter = new ParticleEmitter - * ( - * pos, 0, 1, 0, 500, PI, // pos, angle, emitSize, emitTime, emitRate, emiteCone - * 0, vec2(16), // tileIndex, tileSize - * new Color(1,1,1), new Color(0,0,0), // colorStartA, colorStartB - * new Color(1,1,1,0), new Color(0,0,0,0), // colorEndA, colorEndB - * 2, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed - * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate, - * .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder - * ); - */ -class ParticleEmitter extends EngineObject -{ - /** Create a particle system with the given settings - * @param {Vector2} position - World space position of the emitter - * @param {Number} [angle=0] - Angle to emit the particles - * @param {Number} [emitSize=0] - World space size of the emitter (float for circle diameter, vec2 for rect) - * @param {Number} [emitTime=0] - How long to stay alive (0 is forever) - * @param {Number} [emitRate=100] - How many particles per second to spawn, does not emit if 0 - * @param {Number} [emitConeAngle=PI] - Local angle to apply velocity to particles from emitter - * @param {Number} [tileIndex=-1] - Index into tile sheet, if <0 no texture is applied - * @param {Number} [tileSize=tileSizeDefault] - Tile size for particles - * @param {Color} [colorStartA=new Color(1,1,1)] - Color at start of life 1, randomized between start colors - * @param {Color} [colorStartB=new Color(1,1,1)] - Color at start of life 2, randomized between start colors - * @param {Color} [colorEndA=new Color(1,1,1,0)] - Color at end of life 1, randomized between end colors - * @param {Color} [colorEndB=new Color(1,1,1,0)] - Color at end of life 2, randomized between end colors - * @param {Number} [particleTime=.5] - How long particles live - * @param {Number} [sizeStart=.1] - How big are particles at start - * @param {Number} [sizeEnd=1] - How big are particles at end - * @param {Number} [speed=.1] - How fast are particles when spawned - * @param {Number} [angleSpeed=.05] - How fast are particles rotating - * @param {Number} [damping=1] - How much to dampen particle speed - * @param {Number} [angleDamping=1] - How much to dampen particle angular speed - * @param {Number} [gravityScale=0] - How much does gravity effect particles - * @param {Number} [particleConeAngle=PI] - Cone for start particle angle - * @param {Number} [fadeRate=.1] - How quick to fade in particles at start/end in percent of life - * @param {Number} [randomness=.2] - Apply extra randomness percent - * @param {Boolean} [collideTiles=0] - Do particles collide against tiles - * @param {Boolean} [additive=0] - Should particles use addtive blend - * @param {Boolean} [randomColorLinear=1] - Should color be randomized linearly or across each component - * @param {Number} [renderOrder=0] - Render order for particles (additive is above other stuff by default) - */ - constructor - ( - pos, - angle, - emitSize = 0, - emitTime = 0, - emitRate = 100, - emitConeAngle = PI, - tileIndex = -1, - tileSize = tileSizeDefault, - colorStartA = new Color, - colorStartB = new Color, - colorEndA = new Color(1,1,1,0), - colorEndB = new Color(1,1,1,0), - particleTime = .5, - sizeStart = .1, - sizeEnd = 1, - speed = .1, - angleSpeed = .05, - damping = 1, - angleDamping = 1, - gravityScale = 0, - particleConeAngle = PI, - fadeRate = .1, - randomness = .2, - collideTiles, - additive, - randomColorLinear = 1, - renderOrder = additive ? 1e9 : 0 - ) - { - super(pos, new Vector2, tileIndex, tileSize, angle, undefined, renderOrder); - - // emitter settings - /** @property {Number} - World space size of the emitter (float for circle diameter, vec2 for rect) */ - this.emitSize = emitSize - /** @property {Number} - How long to stay alive (0 is forever) */ - this.emitTime = emitTime; - /** @property {Number} - How many particles per second to spawn, does not emit if 0 */ - this.emitRate = emitRate; - /** @property {Number} - Local angle to apply velocity to particles from emitter */ - this.emitConeAngle = emitConeAngle; - - // color settings - /** @property {Color} - Color at start of life 1, randomized between start colors */ - this.colorStartA = colorStartA; - /** @property {Color} - Color at start of life 2, randomized between start colors */ - this.colorStartB = colorStartB; - /** @property {Color} - Color at end of life 1, randomized between end colors */ - this.colorEndA = colorEndA; - /** @property {Color} - Color at end of life 2, randomized between end colors */ - this.colorEndB = colorEndB; - /** @property {Boolean} - Should color be randomized linearly or across each component */ - this.randomColorLinear = randomColorLinear; - - // particle settings - /** @property {Number} - How long particles live */ - this.particleTime = particleTime; - /** @property {Number} - How big are particles at start */ - this.sizeStart = sizeStart; - /** @property {Number} - How big are particles at end */ - this.sizeEnd = sizeEnd; - /** @property {Number} - How fast are particles when spawned */ - this.speed = speed; - /** @property {Number} - How fast are particles rotating */ - this.angleSpeed = angleSpeed; - /** @property {Number} - How much to dampen particle speed */ - this.damping = damping; - /** @property {Number} - How much to dampen particle angular speed */ - this.angleDamping = angleDamping; - /** @property {Number} - How much does gravity effect particles */ - this.gravityScale = gravityScale; - /** @property {Number} - Cone for start particle angle */ - this.particleConeAngle = particleConeAngle; - /** @property {Number} - How quick to fade in particles at start/end in percent of life */ - this.fadeRate = fadeRate; - /** @property {Number} - Apply extra randomness percent */ - this.randomness = randomness; - /** @property {Number} - Do particles collide against tiles */ - this.collideTiles = collideTiles; - /** @property {Number} - Should particles use addtive blend */ - this.additive = additive; - /** @property {Number} - If set the partile is drawn as a trail, stretched in the drection of velocity */ - this.trailScale = 0; - - // internal variables - this.emitTimeBuffer = 0; - } - - /** Update the emitter to spawn particles, called automatically by engine once each frame */ - update() - { - // only do default update to apply parent transforms - this.parent && super.update(); - - // update emitter - if (!this.emitTime || this.getAliveTime() <= this.emitTime) - { - // emit particles - if (this.emitRate * particleEmitRateScale) - { - const rate = 1/this.emitRate/particleEmitRateScale; - for (this.emitTimeBuffer += timeDelta; this.emitTimeBuffer > 0; this.emitTimeBuffer -= rate) - this.emitParticle(); - } - } - else - this.destroy(); - - debugParticles && debugRect(this.pos, vec2(this.emitSize), '#0f0', 0, this.angle); - } - - /** Spawn one particle - * @return {Particle} */ - emitParticle() - { - // spawn a particle - const pos = this.emitSize.x != undefined ? // check if vec2 was used for size - (new Vector2(rand(-.5,.5), rand(-.5,.5))).multiply(this.emitSize).rotate(this.angle) // box emitter - : randInCircle(this.emitSize * .5); // circle emitter - const particle = new Particle(this.pos.add(pos), this.tileIndex, this.tileSize, - this.angle + rand(this.particleConeAngle, -this.particleConeAngle)); - - // randomness scales each paremeter by a percentage - const randomness = this.randomness; - const randomizeScale = (v)=> v + v*rand(randomness, -randomness); - - // randomize particle settings - const particleTime = randomizeScale(this.particleTime); - const sizeStart = randomizeScale(this.sizeStart); - const sizeEnd = randomizeScale(this.sizeEnd); - const speed = randomizeScale(this.speed); - const angleSpeed = randomizeScale(this.angleSpeed) * randSign(); - const coneAngle = rand(this.emitConeAngle, -this.emitConeAngle); - const colorStart = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear); - const colorEnd = randColor(this.colorEndA, this.colorEndB, this.randomColorLinear); - - // build particle settings - particle.colorStart = colorStart; - particle.colorEndDelta = colorEnd.subtract(colorStart); - particle.velocity = (new Vector2).setAngle(this.angle + coneAngle, speed); - particle.angleVelocity = angleSpeed; - particle.lifeTime = particleTime; - particle.sizeStart = sizeStart; - particle.sizeEndDelta = sizeEnd - sizeStart; - particle.fadeRate = this.fadeRate; - particle.damping = this.damping; - particle.angleDamping = this.angleDamping; - particle.elasticity = this.elasticity; - particle.friction = this.friction; - particle.gravityScale = this.gravityScale; - particle.collideTiles = this.collideTiles; - particle.additive = this.additive; - particle.renderOrder = this.renderOrder; - particle.trailScale = this.trailScale; - particle.mirror = rand()<.5; - - // setup callbacks for particles - particle.destroyCallback = this.particleDestroyCallback; - this.particleCreateCallback && this.particleCreateCallback(particle); - - // return the newly created particle - return particle; - } - - // Particle emitters are not rendered, only the particles are - render() {} -} - -/////////////////////////////////////////////////////////////////////////////// -/** - * Particle Object - Created automatically by Particle Emitters - * @extends EngineObject - */ -class Particle extends EngineObject -{ - /** - * Create a particle with the given settings - * @param {Vector2} position - World space position of the particle - * @param {Number} [tileIndex=-1] - Tile to use to render, untextured if -1 - * @param {Vector2} [tileSize=tileSizeDefault] - Size of tile in source pixels - * @param {Number} [angle=0] - Angle to rotate the particle - */ - constructor(pos, tileIndex, tileSize, angle) { super(pos, new Vector2, tileIndex, tileSize, angle); } - - /** Render the particle, automatically called each frame, sorted by renderOrder */ - render() - { - // modulate size and color - const p = min((time - this.spawnTime) / this.lifeTime, 1); - const radius = this.sizeStart + p * this.sizeEndDelta; - const size = new Vector2(radius, radius); - const fadeRate = this.fadeRate/2; - const color = new Color( - this.colorStart.r + p * this.colorEndDelta.r, - this.colorStart.g + p * this.colorEndDelta.g, - this.colorStart.b + p * this.colorEndDelta.b, - (this.colorStart.a + p * this.colorEndDelta.a) * - (p < fadeRate ? p/fadeRate : p > 1-fadeRate ? (1-p)/fadeRate : 1)); // fade alpha - - // draw the particle - this.additive && setBlendMode(1); - if (this.trailScale) - { - // trail style particles - const speed = this.velocity.length(); - const direction = this.velocity.scale(1/speed); - const trailLength = speed * this.trailScale; - size.y = max(size.x, trailLength); - this.angle = direction.angle(); - drawTile(this.pos.add(direction.multiply(vec2(0,-trailLength/2))), size, this.tileIndex, this.tileSize, color, this.angle, this.mirror); - } - else - drawTile(this.pos, size, this.tileIndex, this.tileSize, color, this.angle, this.mirror); - this.additive && setBlendMode(); - debugParticles && debugRect(this.pos, size, '#f005', 0, this.angle); - - if (p == 1) - { - // destroy particle when it's time runs out - this.color = color; - this.size = size; - this.destroyCallback && this.destroyCallback(this); - this.destroyed = 1; - } - } -} -/** - * LittleJS Medal System - *
- Tracks and displays medals - *
- Saves medals to local storage - *
- Newgrounds and OS13k integration - * @namespace Medals - */ - -'use strict'; - -/** List of all medals - * @memberof Medals */ -const medals = []; - -/** Set to stop medals from being unlockable (like if cheats are enabled) - * @memberof Medals */ -let medalsPreventUnlock; - -/** This can used to enable Newgrounds functionality - * @type {Newgrounds} - * @memberof Medals */ -let newgrounds; - -// Engine internal variables not exposed to documentation -let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast; - -/////////////////////////////////////////////////////////////////////////////// - -/** Initialize medals with a save name used for storage - *
- Call this after creating all medals - *
- Checks if medals are unlocked - * @param {String} saveName - * @memberof Medals */ -function medalsInit(saveName) -{ - // check if medals are unlocked - medalsSaveName = saveName; - debugMedals || medals.forEach(medal=> localStorage[medal.storageKey()]); -} - -/** - * Medal Object - Tracks an unlockable medal - * @example - * // create a medal - * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️'); - * - * // initialize medals - * medalsInit('Example Game'); - * - * // unlock the medal - * medal_example.unlock(); - */ -class Medal -{ - /** Create an medal object and adds it to the list of medals - * @param {Number} id - The unique identifier of the medal - * @param {String} name - Name of the medal - * @param {String} [description] - Description of the medal - * @param {String} [icon='🏆'] - Icon for the medal - * @param {String} [src] - Image location for the medal - */ - constructor(id, name, description='', icon='🏆', src) - { - ASSERT(id >= 0 && !medals[id]); - - // save attributes and add to list of medals - medals[this.id = id] = this; - this.name = name; - this.description = description; - this.icon = icon; - this.image = new Image(); - if (src) - this.image.src = src; - } - - /** Unlocks a medal if not already unlocked */ - unlock() - { - if (medalsPreventUnlock || this.unlocked) - return; - - // save the medal - ASSERT(medalsSaveName); // save name must be set - localStorage[this.storageKey()] = this.unlocked = 1; - medalsDisplayQueue.push(this); - - // save for newgrounds and OS13K - newgrounds && newgrounds.unlockMedal(this.id); - localStorage['OS13kTrophy,' + this.icon + ',' + medalsSaveName + ',' + this.name] = this.description; - } - - /** Render a medal - * @param {Number} [hidePercent=0] - How much to slide the medal off screen - */ - render(hidePercent=0) - { - const context = overlayContext; - const width = min(medalDisplayWidth, mainCanvas.width); - const x = overlayCanvas.width - width; - const y = -medalDisplayHeight*hidePercent; - - // draw containing rect and clip to that region - context.save(); - context.beginPath(); - context.fillStyle = '#ddd' - context.fill(context.rect(x, y, width, medalDisplayHeight)); - context.strokeStyle = '#000'; - context.lineWidth = 3; - context.stroke(); - context.clip(); - - // draw the icon and text - this.renderIcon(x+15+medalDisplayIconSize/2, y+medalDisplayHeight/2); - context.textAlign = 'left'; - context.font = '38px '+ fontDefault; - context.fillText(this.name, x+medalDisplayIconSize+30, y+28); - context.font = '24px '+ fontDefault; - context.fillText(this.description, x+medalDisplayIconSize+30, y+60); - context.restore(); - } - - /** Render the icon for a medal - * @param {Number} x - Screen space X position - * @param {Number} y - Screen space Y position - * @param {Number} [size=medalDisplayIconSize] - Screen space size - */ - renderIcon(x, y, size=medalDisplayIconSize) - { - // draw the image or icon - const context = overlayContext; - context.fillStyle = '#000'; - context.textAlign = 'center'; - context.textBaseline = 'middle'; - context.font = size*.7 + 'px '+ fontDefault; - if (this.image.src) - context.drawImage(this.image, x-size/2, y-size/2, size, size); - else - context.fillText(this.icon, x, y); // show icon if there is no image - } - - // Get local storage key used by the medal - storageKey() { return medalsSaveName + '_' + this.id; } -} - -// engine automatically renders medals -function medalsRender() -{ - if (!medalsDisplayQueue.length) - return; - - // update first medal in queue - const medal = medalsDisplayQueue[0]; - const time = timeReal - medalsDisplayTimeLast; - if (!medalsDisplayTimeLast) - medalsDisplayTimeLast = timeReal; - else if (time > medalDisplayTime) - medalsDisplayQueue.shift(medalsDisplayTimeLast = 0); - else - { - // slide on/off medals - const slideOffTime = medalDisplayTime - medalDisplaySlideTime; - const hidePercent = - time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime : - time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0; - medal.render(hidePercent); - } -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Newgrounds API wrapper object - * @example - * // create a newgrounds object, replace the app id and cipher with your own - * const app_id = '53123:1ZuSTQ9l'; - * const cipher = 'enF0vGH@Mj/FRASKL23Q=='; - * newgrounds = new Newgrounds(app_id, cipher); - */ -class Newgrounds -{ - /** Create a newgrounds object - * @param {Number} app_id - The newgrounds App ID - * @param {String} [cipher] - The encryption Key (AES-128/Base64) */ - constructor(app_id, cipher) - { - ASSERT(!newgrounds && app_id); - this.app_id = app_id; - this.cipher = cipher; - this.host = location ? location.hostname : ''; - - // create an instance of CryptoJS for encrypted calls - cipher && (this.cryptoJS = CryptoJS()); - - // get session id from url search params - const url = new URL(location.href); - this.session_id = url.searchParams.get('ngio_session_id') || 0; - - if (this.session_id == 0) - return; // only use newgrounds when logged in - - // get medals - const medalsResult = this.call('Medal.getList'); - this.medals = medalsResult ? medalsResult.result.data['medals'] : []; - debugMedals && console.log(this.medals); - for (const newgroundsMedal of this.medals) - { - const medal = medals[newgroundsMedal['id']]; - if (medal) - { - // copy newgrounds medal data - medal.image.src = newgroundsMedal['icon']; - medal.name = newgroundsMedal['name']; - medal.description = newgroundsMedal['description']; - medal.unlocked = newgroundsMedal['unlocked']; - medal.difficulty = newgroundsMedal['difficulty']; - medal.value = newgroundsMedal['value']; - - if (medal.value) - medal.description = medal.description + ' (' + medal.value + ')'; - } - } - - // get scoreboards - const scoreboardResult = this.call('ScoreBoard.getBoards'); - this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : []; - debugMedals && console.log(this.scoreboards); - - const keepAliveMS = 5 * 60 * 1e3; - setInterval(()=>this.call('Gateway.ping', 0, 1), keepAliveMS); - } - - /** Send message to unlock a medal by id - * @param {Number} id - The medal id */ - unlockMedal(id) { return this.call('Medal.unlock', {'id':id}, 1); } - - /** Send message to post score - * @param {Number} id - The scoreboard id - * @param {Number} value - The score value */ - postScore(id, value) { return this.call('ScoreBoard.postScore', {'id':id, 'value':value}, 1); } - - /** Get scores from a scoreboard - * @param {Number} id - The scoreboard id - * @param {String} [user=0] - A user's id or name - * @param {Number} [social=0] - If true, only social scores will be loaded - * @param {Number} [skip=0] - Number of scores to skip before start - * @param {Number} [limit=10] - Number of scores to include in the list - * @return {Object} - The response JSON object - */ - getScores(id, user=0, social=0, skip=0, limit=10) - { return this.call('ScoreBoard.getScores', {'id':id, 'user':user, 'social':social, 'skip':skip, 'limit':limit}); } - - /** Send message to log a view */ - logView() { return this.call('App.logView', {'host':this.host}, 1); } - - /** Send a message to call a component of the Newgrounds API - * @param {String} component - Name of the component - * @param {Object} [parameters=0] - Parameters to use for call - * @param {Boolean} [async=0] - If true, don't wait for response before continuing (avoid stall) - * @return {Object} - The response JSON object - */ - call(component, parameters=0, async=0) - { - const call = {'component':component, 'parameters':parameters}; - if (this.cipher) - { - // encrypt using AES-128 Base64 with cryptoJS - const cryptoJS = this.cryptoJS; - const aesKey = cryptoJS['enc']['Base64']['parse'](this.cipher); - const iv = cryptoJS['lib']['WordArray']['random'](16); - const encrypted = cryptoJS['AES']['encrypt'](JSON.stringify(call), aesKey, {'iv':iv}); - call['secure'] = cryptoJS['enc']['Base64']['stringify'](iv.concat(encrypted['ciphertext'])); - call['parameters'] = 0; - } - - // build the input object - const input = - { - 'app_id': this.app_id, - 'session_id': this.session_id, - 'call': call - }; - - // build post data - const formData = new FormData(); - formData.append('input', JSON.stringify(input)); - - // send post data - const xmlHttp = new XMLHttpRequest(); - const url = 'https://newgrounds.io/gateway_v3.php'; - xmlHttp.open('POST', url, !debugMedals && async); - xmlHttp.send(formData); - debugMedals && console.log(xmlHttp.responseText); - return xmlHttp.responseText && JSON.parse(xmlHttp.responseText); - } -} - -/////////////////////////////////////////////////////////////////////////////// -// Crypto-JS - https://github.com/brix/crypto-js [The MIT License (MIT)] -// Copyright (c) 2009-2013 Jeff Mott Copyright (c) 2013-2016 Evan Vosberg - -const CryptoJS=()=>eval(Function("[M='GBMGXz^oVYPPKKbB`agTXU|LxPc_ZBcMrZvCr~wyGfWrwk@ATqlqeTp^N?p{we}jIpEnB_sEr`l?YDkDhWhprc|Er|XETG?pTl`e}dIc[_N~}fzRycIfpW{HTolvoPB_FMe_eH~BTMx]yyOhv?biWPCGc]kABencBhgERHGf{OL`Dj`c^sh@canhy[secghiyotcdOWgO{tJIE^JtdGQRNSCrwKYciZOa]Y@tcRATYKzv|sXpboHcbCBf`}SKeXPFM|RiJsSNaIb]QPc[D]Jy_O^XkOVTZep`ONmntLL`Qz~UupHBX_Ia~WX]yTRJIxG`ioZ{fefLJFhdyYoyLPvqgH?b`[TMnTwwfzDXhfM?rKs^aFr|nyBdPmVHTtAjXoYUloEziWDCw_suyYT~lSMksI~ZNCS[Bex~j]Vz?kx`gdYSEMCsHpjbyxQvw|XxX_^nQYue{sBzVWQKYndtYQMWRef{bOHSfQhiNdtR{o?cUAHQAABThwHPT}F{VvFmgN`E@FiFYS`UJmpQNM`X|tPKHlccT}z}k{sACHL?Rt@MkWplxO`ASgh?hBsuuP|xD~LSH~KBlRs]t|l|_tQAroDRqWS^SEr[sYdPB}TAROtW{mIkE|dWOuLgLmJrucGLpebrAFKWjikTUzS|j}M}szasKOmrjy[?hpwnEfX[jGpLt@^v_eNwSQHNwtOtDgWD{rk|UgASs@mziIXrsHN_|hZuxXlPJOsA^^?QY^yGoCBx{ekLuZzRqQZdsNSx@ezDAn{XNj@fRXIwrDX?{ZQHwTEfu@GhxDOykqts|n{jOeZ@c`dvTY?e^]ATvWpb?SVyg]GC?SlzteilZJAL]mlhLjYZazY__qcVFYvt@|bIQnSno@OXyt]OulzkWqH`rYFWrwGs`v|~XeTsIssLrbmHZCYHiJrX}eEzSssH}]l]IhPQhPoQ}rCXLyhFIT[clhzYOvyHqigxmjz`phKUU^TPf[GRAIhNqSOdayFP@FmKmuIzMOeoqdpxyCOwCthcLq?n`L`tLIBboNn~uXeFcPE{C~mC`h]jUUUQe^`UqvzCutYCgct|SBrAeiYQW?X~KzCz}guXbsUw?pLsg@hDArw?KeJD[BN?GD@wgFWCiHq@Ypp_QKFixEKWqRp]oJFuVIEvjDcTFu~Zz]a{IcXhWuIdMQjJ]lwmGQ|]g~c]Hl]pl`Pd^?loIcsoNir_kikBYyg?NarXZEGYspt_vLBIoj}LI[uBFvm}tbqvC|xyR~a{kob|HlctZslTGtPDhBKsNsoZPuH`U`Fqg{gKnGSHVLJ^O`zmNgMn~{rsQuoymw^JY?iUBvw_~mMr|GrPHTERS[MiNpY[Mm{ggHpzRaJaoFomtdaQ_?xuTRm}@KjU~RtPsAdxa|uHmy}n^i||FVL[eQAPrWfLm^ndczgF~Nk~aplQvTUpHvnTya]kOenZlLAQIm{lPl@CCTchvCF[fI{^zPkeYZTiamoEcKmBMfZhk_j_~Fjp|wPVZlkh_nHu]@tP|hS@^G^PdsQ~f[RqgTDqezxNFcaO}HZhb|MMiNSYSAnQWCDJukT~e|OTgc}sf[cnr?fyzTa|EwEtRG|I~|IO}O]S|rp]CQ}}DWhSjC_|z|oY|FYl@WkCOoPuWuqr{fJu?Brs^_EBI[@_OCKs}?]O`jnDiXBvaIWhhMAQDNb{U`bqVR}oqVAvR@AZHEBY@depD]OLh`kf^UsHhzKT}CS}HQKy}Q~AeMydXPQztWSSzDnghULQgMAmbWIZ|lWWeEXrE^EeNoZApooEmrXe{NAnoDf`m}UNlRdqQ@jOc~HLOMWs]IDqJHYoMziEedGBPOxOb?[X`KxkFRg@`mgFYnP{hSaxwZfBQqTm}_?RSEaQga]w[vxc]hMne}VfSlqUeMo_iqmd`ilnJXnhdj^EEFifvZyxYFRf^VaqBhLyrGlk~qowqzHOBlOwtx?i{m~`n^G?Yxzxux}b{LSlx]dS~thO^lYE}bzKmUEzwW^{rPGhbEov[Plv??xtyKJshbG`KuO?hjBdS@Ru}iGpvFXJRrvOlrKN?`I_n_tplk}kgwSXuKylXbRQ]]?a|{xiT[li?k]CJpwy^o@ebyGQrPfF`aszGKp]baIx~H?ElETtFh]dz[OjGl@C?]VDhr}OE@V]wLTc[WErXacM{We`F|utKKjgllAxvsVYBZ@HcuMgLboFHVZmi}eIXAIFhS@A@FGRbjeoJWZ_NKd^oEH`qgy`q[Tq{x?LRP|GfBFFJV|fgZs`MLbpPYUdIV^]mD@FG]pYAT^A^RNCcXVrPsgk{jTrAIQPs_`mD}rOqAZA[}RETFz]WkXFTz_m{N@{W@_fPKZLT`@aIqf|L^Mb|crNqZ{BVsijzpGPEKQQZGlApDn`ruH}cvF|iXcNqK}cxe_U~HRnKV}sCYb`D~oGvwG[Ca|UaybXea~DdD~LiIbGRxJ_VGheI{ika}KC[OZJLn^IBkPrQj_EuoFwZ}DpoBRcK]Q}?EmTv~i_Tul{bky?Iit~tgS|o}JL_VYcCQdjeJ_MfaA`FgCgc[Ii|CBHwq~nbJeYTK{e`CNstKfTKPzw{jdhp|qsZyP_FcugxCFNpKitlR~vUrx^NrSVsSTaEgnxZTmKc`R|lGJeX}ccKLsQZQhsFkeFd|ckHIVTlGMg`~uPwuHRJS_CPuN_ogXe{Ba}dO_UBhuNXby|h?JlgBIqMKx^_u{molgL[W_iavNQuOq?ap]PGB`clAicnl@k~pA?MWHEZ{HuTLsCpOxxrKlBh]FyMjLdFl|nMIvTHyGAlPogqfZ?PlvlFJvYnDQd}R@uAhtJmDfe|iJqdkYr}r@mEjjIetDl_I`TELfoR|qTBu@Tic[BaXjP?dCS~MUK[HPRI}OUOwAaf|_}HZzrwXvbnNgltjTwkBE~MztTQhtRSWoQHajMoVyBBA`kdgK~h`o[J`dm~pm]tk@i`[F~F]DBlJKklrkR]SNw@{aG~Vhl`KINsQkOy?WhcqUMTGDOM_]bUjVd|Yh_KUCCgIJ|LDIGZCPls{RzbVWVLEhHvWBzKq|^N?DyJB|__aCUjoEgsARki}j@DQXS`RNU|DJ^a~d{sh_Iu{ONcUtSrGWW@cvUjefHHi}eSSGrNtO?cTPBShLqzwMVjWQQCCFB^culBjZHEK_{dO~Q`YhJYFn]jq~XSnG@[lQr]eKrjXpG~L^h~tDgEma^AUFThlaR{xyuP@[^VFwXSeUbVetufa@dX]CLyAnDV@Bs[DnpeghJw^?UIana}r_CKGDySoRudklbgio}kIDpA@McDoPK?iYcG?_zOmnWfJp}a[JLR[stXMo?_^Ng[whQlrDbrawZeSZ~SJstIObdDSfAA{MV}?gNunLOnbMv_~KFQUAjIMj^GkoGxuYtYbGDImEYiwEMyTpMxN_LSnSMdl{bg@dtAnAMvhDTBR_FxoQgANniRqxd`pWv@rFJ|mWNWmh[GMJz_Nq`BIN@KsjMPASXORcdHjf~rJfgZYe_uulzqM_KdPlMsuvU^YJuLtofPhGonVOQxCMuXliNvJIaoC?hSxcxKVVxWlNs^ENDvCtSmO~WxI[itnjs^RDvI@KqG}YekaSbTaB]ki]XM@[ZnDAP~@|BzLRgOzmjmPkRE@_sobkT|SszXK[rZN?F]Z_u}Yue^[BZgLtR}FHzWyxWEX^wXC]MJmiVbQuBzkgRcKGUhOvUc_bga|Tx`KEM`JWEgTpFYVeXLCm|mctZR@uKTDeUONPozBeIkrY`cz]]~WPGMUf`MNUGHDbxZuO{gmsKYkAGRPqjc|_FtblEOwy}dnwCHo]PJhN~JoteaJ?dmYZeB^Xd?X^pOKDbOMF@Ugg^hETLdhwlA}PL@_ur|o{VZosP?ntJ_kG][g{Zq`Tu]dzQlSWiKfnxDnk}KOzp~tdFstMobmy[oPYjyOtUzMWdjcNSUAjRuqhLS@AwB^{BFnqjCmmlk?jpn}TksS{KcKkDboXiwK]qMVjm~V`LgWhjS^nLGwfhAYrjDSBL_{cRus~{?xar_xqPlArrYFd?pHKdMEZzzjJpfC?Hv}mAuIDkyBxFpxhstTx`IO{rp}XGuQ]VtbHerlRc_LFGWK[XluFcNGUtDYMZny[M^nVKVeMllQI[xtvwQnXFlWYqxZZFp_|]^oWX[{pOMpxXxvkbyJA[DrPzwD|LW|QcV{Nw~U^dgguSpG]ClmO@j_TENIGjPWwgdVbHganhM?ema|dBaqla|WBd`poj~klxaasKxGG^xbWquAl~_lKWxUkDFagMnE{zHug{b`A~IYcQYBF_E}wiA}K@yxWHrZ{[d~|ARsYsjeNWzkMs~IOqqp[yzDE|WFrivsidTcnbHFRoW@XpAV`lv_zj?B~tPCppRjgbbDTALeFaOf?VcjnKTQMLyp{NwdylHCqmo?oelhjWuXj~}{fpuX`fra?GNkDiChYgVSh{R[BgF~eQa^WVz}ATI_CpY?g_diae]|ijH`TyNIF}|D_xpmBq_JpKih{Ba|sWzhnAoyraiDvk`h{qbBfsylBGmRH}DRPdryEsSaKS~tIaeF[s]I~xxHVrcNe@Jjxa@jlhZueLQqHh_]twVMqG_EGuwyab{nxOF?`HCle}nBZzlTQjkLmoXbXhOtBglFoMz?eqre`HiE@vNwBulglmQjj]DB@pPkPUgA^sjOAUNdSu_`oAzar?n?eMnw{{hYmslYi[TnlJD'",...']charCodeAtUinyxpf',"for(;e<10359;c[e++]=p-=128,A=A?p-A&&A:p==34&&p)for(p=1;p<128;y=f.map((n,x)=>(U=r[n]*2+1,U=Math.log(U/(h-U)),t-=a[x]*U,U/500)),t=~-h/(1+Math.exp(t))|1,i=o%h>17)-!i*t,f.map((n,x)=>(U=r[n]+=(i*h/2-r[n]<<13)/((C[n]+=C[n]<5)+1/20)>>13,a[x]+=y[x]*(i-t/h))),p=p*2+i)for(f='010202103203210431053105410642065206541'.split(t=0).map((n,x)=>(U=0,[...n].map((n,x)=>(U=U*997+(c[e-n]|0)|0)),h*32-1&U*997+p+!!A*129)*12+x);o - All webgl used by the engine is wrapped up here - *
- For normal stuff you won't need to see or call anything in this file - *
- For advanced stuff there are helper functions to create shaders, textures, etc - *
- Can be disabled with glEnable to revert to 2D canvas rendering - *
- Batches sprite rendering on GPU for incredibly fast performance - *
- Sprite transform math is done in the shader where possible - * @namespace WebGL - */ - -'use strict'; - -/** The WebGL canvas which appears above the main canvas and below the overlay canvas - * @type {HTMLCanvasElement} - * @memberof WebGL */ -let glCanvas; - -/** 2d context for glCanvas - * @type {WebGLRenderingContext} - * @memberof WebGL */ -let glContext; - -/** Main tile sheet texture automatically loaded by engine - * @type {WebGLTexture} - * @memberof WebGL */ -let glTileTexture; - -// WebGL internal variables not exposed to documentation -let glActiveTexture, glShader, glPositionData, glColorData, glBatchCount, glBatchAdditive, glAdditive; - -/////////////////////////////////////////////////////////////////////////////// - -// Init WebGL, called automatically by the engine -function glInit() -{ - if (!glEnable) return; - - // create the canvas and tile texture - glCanvas = document.createElement('canvas'); - glContext = glCanvas.getContext('webgl', {antialias: false}); - glCanvas.style = styleCanvas; - glTileTexture = glCreateTexture(tileImage); - - // some browsers are much faster without copying the gl buffer so we just overlay it instead - glOverlay && document.body.appendChild(glCanvas); - - // setup vertex and fragment shaders - glShader = glCreateProgram( - 'precision highp float;'+ // use highp for better accuracy - 'uniform mat4 m;'+ // transform matrix - 'attribute vec2 p,t;'+ // position, uv - 'attribute vec4 c,a;'+ // color, additiveColor - 'varying vec2 v;'+ // return uv - 'varying vec4 d,e;'+ // return color, additiveColor - 'void main(){'+ // shader entry point - 'gl_Position=m*vec4(p,1,1);'+ // transform position - 'v=t;d=c;e=a;'+ // pass stuff to fragment shader - '}' // end of shader - , - 'precision highp float;'+ // use highp for better accuracy - 'varying vec2 v;'+ // uv - 'varying vec4 d,e;'+ // color, additiveColor - 'uniform sampler2D s;'+ // texture - 'void main(){'+ // shader entry point - 'gl_FragColor=texture2D(s,v)*d+e;'+ // modulate texture by color plus additive - '}' // end of shader - ); - - // init buffers - const glVertexData = new ArrayBuffer(gl_MAX_BATCH * gl_VERTICES_PER_QUAD * gl_VERTEX_BYTE_STRIDE); - glCreateBuffer(gl_ARRAY_BUFFER, glVertexData.byteLength, gl_DYNAMIC_DRAW); - glPositionData = new Float32Array(glVertexData); - glColorData = new Uint32Array(glVertexData); - - // setup the vertex data array - let offset = glBatchCount = 0; - const initVertexAttribArray = (name, type, typeSize, size, normalize=0)=> - { - const location = glContext.getAttribLocation(glShader, name); - glContext.enableVertexAttribArray(location); - glContext.vertexAttribPointer(location, size, type, normalize, gl_VERTEX_BYTE_STRIDE, offset); - offset += size*typeSize; - } - initVertexAttribArray('p', gl_FLOAT, 4, 2); // position - initVertexAttribArray('t', gl_FLOAT, 4, 2); // texture coords - initVertexAttribArray('c', gl_UNSIGNED_BYTE, 1, 4, 1); // color - initVertexAttribArray('a', gl_UNSIGNED_BYTE, 1, 4, 1); // additiveColor -} - -/** Set the WebGl blend mode, normally you should call setBlendMode instead - * @param {Boolean} [additive=0] - * @memberof WebGL */ -function glSetBlendMode(additive) -{ - if (!glEnable) return; - - // setup blending - glAdditive = additive; -} - -/** Set the WebGl texture, not normally necessary unless multiple tile sheets are used - *
- This may also flush the gl buffer resulting in more draw calls and worse performance - * @param {WebGLTexture} [texture=glTileTexture] - * @memberof WebGL */ -function glSetTexture(texture=glTileTexture) -{ - if (!glEnable) return; - - // must flush cache with the old texture to set a new one - if (texture != glActiveTexture) - glFlush(); - - glContext.bindTexture(gl_TEXTURE_2D, glActiveTexture = texture); -} - -/** Compile WebGL shader of the given type, will throw errors if in debug mode - * @param {String} source - * @param type - * @return {WebGLShader} - * @memberof WebGL */ -function glCompileShader(source, type) -{ - if (!glEnable) return; - - // build the shader - const shader = glContext.createShader(type); - glContext.shaderSource(shader, source); - glContext.compileShader(shader); - - // check for errors - if (debug && !glContext.getShaderParameter(shader, gl_COMPILE_STATUS)) - throw glContext.getShaderInfoLog(shader); - return shader; -} - -/** Create WebGL program with given shaders - * @param {WebGLShader} vsSource - * @param {WebGLShader} fsSource - * @return {WebGLProgram} - * @memberof WebGL */ -function glCreateProgram(vsSource, fsSource) -{ - if (!glEnable) return; - - // build the program - const program = glContext.createProgram(); - glContext.attachShader(program, glCompileShader(vsSource, gl_VERTEX_SHADER)); - glContext.attachShader(program, glCompileShader(fsSource, gl_FRAGMENT_SHADER)); - glContext.linkProgram(program); - - // check for errors - if (debug && !glContext.getProgramParameter(program, gl_LINK_STATUS)) - throw glContext.getProgramInfoLog(program); - return program; -} - -/** Create WebGL buffer - * @param bufferType - * @param size - * @param usage - * @return {WebGLBuffer} - * @memberof WebGL */ -function glCreateBuffer(bufferType, size, usage) -{ - if (!glEnable) return; - - // build the buffer - const buffer = glContext.createBuffer(); - glContext.bindBuffer(bufferType, buffer); - glContext.bufferData(bufferType, size, usage); - return buffer; -} - -/** Create WebGL texture from an image and set the texture settings - * @param {Image} image - * @return {WebGLTexture} - * @memberof WebGL */ -function glCreateTexture(image) -{ - if (!glEnable || !image || !image.width) return; - - // build the texture - const texture = glContext.createTexture(); - glContext.bindTexture(gl_TEXTURE_2D, texture); - glContext.texImage2D(gl_TEXTURE_2D, 0, gl_RGBA, gl_RGBA, gl_UNSIGNED_BYTE, image); - - // use point filtering for pixelated rendering - glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MIN_FILTER, cavasPixelated ? gl_NEAREST : gl_LINEAR); - glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MAG_FILTER, cavasPixelated ? gl_NEAREST : gl_LINEAR); - glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_WRAP_S, gl_CLAMP_TO_EDGE); - glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_WRAP_T, gl_CLAMP_TO_EDGE); - return texture; -} - -// called automatically by engine before render -function glPreRender(width, height, cameraX, cameraY, cameraScale) -{ - if (!glEnable) return; - - // clear and set to same size as main canvas - glContext.viewport(0, 0, glCanvas.width = width, glCanvas.height = height); - glContext.clear(gl_COLOR_BUFFER_BIT); - - // set up the shader - glContext.bindTexture(gl_TEXTURE_2D, glActiveTexture = glTileTexture); - glContext.useProgram(glShader); - glSetBlendMode(); - - // build the transform matrix - const sx = 2 * cameraScale / width; - const sy = 2 * cameraScale / height; - glContext.uniformMatrix4fv(glContext.getUniformLocation(glShader, 'm'), 0, - new Float32Array([ - sx, 0, 0, 0, - 0, sy, 0, 0, - 1, 1, -1, 1, - -1-sx*cameraX, -1-sy*cameraY, 0, 0 - ]) - ); -} - -/** Draw all sprites and clear out the buffer, called automatically by the system whenever necessary - * @memberof WebGL */ -function glFlush() -{ - if (!glEnable || !glBatchCount) return; - - const destBlend = glBatchAdditive ? gl_ONE : gl_ONE_MINUS_SRC_ALPHA; - glContext.blendFuncSeparate(gl_SRC_ALPHA, destBlend, gl_ONE, destBlend); - glContext.enable(gl_BLEND); - - // draw all the sprites in the batch and reset the buffer - glContext.bufferSubData(gl_ARRAY_BUFFER, 0, - glPositionData.subarray(0, glBatchCount * gl_VERTICES_PER_QUAD * gl_INDICIES_PER_VERT)); - glContext.drawArrays(gl_TRIANGLES, 0, glBatchCount * gl_VERTICES_PER_QUAD); - glBatchCount = 0; - glBatchAdditive = glAdditive; -} - -/** Draw any sprites still in the buffer, copy to main canvas and clear - * @param {CanvasRenderingContext2D} context - * @param {Boolean} [forceDraw=0] - * @memberof WebGL */ -function glCopyToContext(context, forceDraw) -{ - if ((!glEnable || !glBatchCount) && !forceDraw) return; - - glFlush(); - - // do not draw in overlay mode because the canvas is visible - if (!glOverlay || forceDraw) - context.drawImage(glCanvas, 0, 0); -} - -/** Add a sprite to the gl draw list, used by all gl draw functions - * @param x - * @param y - * @param sizeX - * @param sizeY - * @param angle - * @param uv0X - * @param uv0Y - * @param uv1X - * @param uv1Y - * @param [rgba=0xffffffff] - * @param [rgbaAdditive=0] - * @memberof WebGL */ -function glDraw(x, y, sizeX, sizeY, angle, uv0X, uv0Y, uv1X, uv1Y, rgba=0xffffffff, rgbaAdditive=0) -{ - if (!glEnable) return; - - // flush if there is no room for more verts or if different blend mode - if (glBatchCount == gl_MAX_BATCH || glBatchAdditive != glAdditive) - glFlush(); - - // prepare to create the verts from size and angle - const c = Math.cos(angle)/2, s = Math.sin(angle)/2; - const cx = c*sizeX, cy = c*sizeY, sx = s*sizeX, sy = s*sizeY; - - // setup 2 triangles to form a quad - let offset = glBatchCount++ * gl_VERTICES_PER_QUAD * gl_INDICIES_PER_VERT; - - // vertex 0 - glPositionData[offset++] = x - cx - sy; - glPositionData[offset++] = y - cy + sx; - glPositionData[offset++] = uv0X; glPositionData[offset++] = uv1Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 1 - glPositionData[offset++] = x + cx + sy; - glPositionData[offset++] = y + cy - sx; - glPositionData[offset++] = uv1X; glPositionData[offset++] = uv0Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 2 - glPositionData[offset++] = x - cx + sy; - glPositionData[offset++] = y + cy + sx; - glPositionData[offset++] = uv0X; glPositionData[offset++] = uv0Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 0 - glPositionData[offset++] = x - cx - sy; - glPositionData[offset++] = y - cy + sx; - glPositionData[offset++] = uv0X; glPositionData[offset++] = uv1Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 3 - glPositionData[offset++] = x + cx - sy; - glPositionData[offset++] = y - cy - sx; - glPositionData[offset++] = uv1X; glPositionData[offset++] = uv1Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 1 - glPositionData[offset++] = x + cx + sy; - glPositionData[offset++] = y + cy - sx; - glPositionData[offset++] = uv1X; glPositionData[offset++] = uv0Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; -} - -/////////////////////////////////////////////////////////////////////////////// -// store gl constants as integers so their name doesn't use space in minifed -const -gl_ONE = 1, -gl_TRIANGLES = 4, -gl_SRC_ALPHA = 770, -gl_ONE_MINUS_SRC_ALPHA = 771, -gl_BLEND = 3042, -gl_TEXTURE_2D = 3553, -gl_UNSIGNED_BYTE = 5121, -gl_FLOAT = 5126, -gl_RGBA = 6408, -gl_NEAREST = 9728, -gl_LINEAR = 9729, -gl_TEXTURE_MAG_FILTER = 10240, -gl_TEXTURE_MIN_FILTER = 10241, -gl_TEXTURE_WRAP_S = 10242, -gl_TEXTURE_WRAP_T = 10243, -gl_COLOR_BUFFER_BIT = 16384, -gl_CLAMP_TO_EDGE = 33071, -gl_ARRAY_BUFFER = 34962, -gl_DYNAMIC_DRAW = 35048, -gl_FRAGMENT_SHADER = 35632, -gl_VERTEX_SHADER = 35633, -gl_COMPILE_STATUS = 35713, -gl_LINK_STATUS = 35714, - -// constants for batch rendering -gl_VERTICES_PER_QUAD = 6, -gl_INDICIES_PER_VERT = 6, -gl_MAX_BATCH = 1<<16, -gl_VERTEX_BYTE_STRIDE = (4 * 2) * 2 + (4) * 2; // vec2 * 2 + (char * 4) * 2 -/* - LittleJS - The Tiny JavaScript Game Engine That Can! - MIT License - Copyright 2021 Frank Force - - Engine Features - - Object oriented system with base class engine object - - Base class object handles update, physics, collision, rendering, etc - - Engine helper classes and functions like Vector2, Color, and Timer - - Super fast rendering system for tile sheets - - Sound effects audio with zzfx and music with zzfxm - - Input processing system with gamepad and touchscreen support - - Tile layer rendering and collision system - - Particle effect system - - Medal system tracks and displays achievements - - Debug tools and debug rendering system - - Call engineInit() to start it up! -*/ - -'use strict'; - -/** Name of engine */ -const engineName = 'LittleJS'; - -/** Version of engine */ -const engineVersion = '1.3.7'; - -/** Frames per second to update objects - * @default */ -const frameRate = 60; - -/** How many seconds each frame lasts, engine uses a fixed time step - * @default 1/60 */ -const timeDelta = 1/frameRate; - -/** Array containing all engine objects */ -let engineObjects = []; - -/** Array containing only objects that are set to collide with other objects this frame (for optimization) */ -let engineObjectsCollide = []; - -/** Current update frame, used to calculate time */ -let frame = 0; - -/** Current engine time since start in seconds, derived from frame */ -let time = 0; - -/** Actual clock time since start in seconds (not affected by pause or frame rate clamping) */ -let timeReal = 0; - -/** Is the game paused? Causes time and objects to not be updated. */ -let paused = 0; - -// Engine internal variables not exposed to documentation -let frameTimeLastMS = 0, frameTimeBufferMS = 0, tileImageSize, tileImageFixBleed; - -// Engine stat tracking, if showWatermark is true -let averageFPS, drawCount; - -// css text used for elements created by engine -const styleBody = 'margin:0;overflow:hidden;background:#000' + - ';touch-action:none;user-select:none;-webkit-user-select:none;-moz-user-select:none'; -const styleCanvas = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)'; - -/////////////////////////////////////////////////////////////////////////////// - -/** Start up LittleJS engine with your callback functions - * @param {Function} gameInit - Called once after the engine starts up, setup the game - * @param {Function} gameUpdate - Called every frame at 60 frames per second, handle input and update the game state - * @param {Function} gameUpdatePost - Called after physics and objects are updated, setup camera and prepare for render - * @param {Function} gameRender - Called before objects are rendered, draw any background effects that appear behind objects - * @param {Function} gameRenderPost - Called after objects are rendered, draw effects or hud that appear above all objects - * @param {String} [tileImageSource] - Tile image to use, everything starts when the image is finished loading - */ -function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, tileImageSource) -{ - // init engine when tiles load or fail to load - tileImage.onerror = tileImage.onload = ()=> - { - // save tile image info - tileImageFixBleed = vec2(tileFixBleedScale).divide(tileImageSize = vec2(tileImage.width, tileImage.height)); - debug && (tileImage.onload=()=>ASSERT(1)); // tile sheet can not reloaded - - // setup html - document.body.style = styleBody; - document.body.appendChild(mainCanvas = document.createElement('canvas')); - mainContext = mainCanvas.getContext('2d'); - mainCanvas.style = styleCanvas; - - // init stuff and start engine - debugInit(); - glInit(); - - // create overlay canvas for hud to appear above gl canvas - document.body.appendChild(overlayCanvas = document.createElement('canvas')); - overlayContext = overlayCanvas.getContext('2d'); - overlayCanvas.style = styleCanvas; - - gameInit(); - touchGamepadCreate(); - engineUpdate(); - }; - - // main update loop - const engineUpdate = (frameTimeMS=0)=> - { - // update time keeping - let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS; - frameTimeLastMS = frameTimeMS; - if (debug || showWatermark) - averageFPS = lerp(.05, averageFPS || 0, 1e3/(frameTimeDeltaMS||1)); - if (debug) - frameTimeDeltaMS *= keyIsDown(107) ? 5 : keyIsDown(109) ? .2 : 1; // +/- to speed/slow time - timeReal += frameTimeDeltaMS / 1e3; - frameTimeBufferMS = min(frameTimeBufferMS + !paused * frameTimeDeltaMS, 50); // clamp incase of slow framerate - - if (canvasFixedSize.x) - { - // clear set fixed size - overlayCanvas.width = mainCanvas.width = canvasFixedSize.x; - overlayCanvas.height = mainCanvas.height = canvasFixedSize.y; - - // fit to window by adding space on top or bottom if necessary - const aspect = innerWidth / innerHeight; - const fixedAspect = mainCanvas.width / mainCanvas.height; - mainCanvas.style.width = overlayCanvas.style.width = aspect < fixedAspect ? '100%' : ''; - mainCanvas.style.height = overlayCanvas.style.height = aspect < fixedAspect ? '' : '100%'; - if (glCanvas) - { - glCanvas.style.width = mainCanvas.style.width; - glCanvas.style.height = mainCanvas.style.height; - } - } - else - { - // clear and set size to same as window - overlayCanvas.width = mainCanvas.width = min(innerWidth, canvasMaxSize.x); - overlayCanvas.height = mainCanvas.height = min(innerHeight, canvasMaxSize.y); - } - - // save canvas size - mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); - - if (paused) - { - // do post update even when paused - inputUpdate(); - debugUpdate(); - gameUpdatePost(); - inputUpdatePost(); - } - else - { - // apply time delta smoothing, improves smoothness of framerate in some browsers - let deltaSmooth = 0; - if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9) - { - // force an update each frame if time is close enough (not just a fast refresh rate) - deltaSmooth = frameTimeBufferMS; - frameTimeBufferMS = 0; - } - - // update multiple frames if necessary in case of slow framerate - for (;frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate) - { - // update game and objects - inputUpdate(); - gameUpdate(); - engineObjectsUpdate(); - - // do post update - debugUpdate(); - gameUpdatePost(); - inputUpdatePost(); - } - - // add the time smoothing back in - frameTimeBufferMS += deltaSmooth; - } - - // render sort then render while removing destroyed objects - enginePreRender(); - gameRender(); - engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder); - for (const o of engineObjects) - o.destroyed || o.render(); - gameRenderPost(); - medalsRender(); - touchGamepadRender(); - debugRender(); - glCopyToContext(mainContext); - - if (showWatermark) - { - // update fps - overlayContext.textAlign = 'right'; - overlayContext.textBaseline = 'top'; - overlayContext.font = '1em monospace'; - overlayContext.fillStyle = '#000'; - const text = engineName + ' ' + 'v' + engineVersion + ' / ' - + drawCount + ' / ' + engineObjects.length + ' / ' + averageFPS.toFixed(1) - + ' ' + (glEnable ? 'GL' : '2D') ; - overlayContext.fillText(text, mainCanvas.width-3, 3); - overlayContext.fillStyle = '#fff'; - overlayContext.fillText(text, mainCanvas.width-2, 2); - drawCount = 0; - } - - requestAnimationFrame(engineUpdate); - } - - // set tile image source to load the image and start the engine - tileImageSource ? tileImage.src = tileImageSource : tileImage.onload(); -} - -// called by engine to setup render system -function enginePreRender() -{ - // save canvas size - mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); - - // disable smoothing for pixel art - mainContext.imageSmoothingEnabled = !cavasPixelated; - - // setup gl rendering if enabled - glPreRender(mainCanvas.width, mainCanvas.height, cameraPos.x, cameraPos.y, cameraScale); -} - -/////////////////////////////////////////////////////////////////////////////// - -/** Calls update on each engine object (recursively if child), removes destroyed objects, and updated time */ -function engineObjectsUpdate() -{ - // get list of solid objects for physics optimzation - engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects); - - // recursive object update - const updateObject = (o)=> - { - if (!o.destroyed) - { - o.update(); - for (const child of o.children) - updateObject(child); - } - } - for (const o of engineObjects) - o.parent || updateObject(o); - - // remove destroyed objects - engineObjects = engineObjects.filter(o=>!o.destroyed); - - // increment frame and update time - time = ++frame / frameRate; -} - -/** Destroy and remove all objects */ -function engineObjectsDestroy() -{ - for (const o of engineObjects) - o.parent || o.destroy(); - engineObjects = engineObjects.filter(o=>!o.destroyed); -} - -/** Triggers a callback for each object within a given area - * @param {Vector2} [pos] - Center of test area - * @param {Number} [size] - Radius of circle if float, rectangle size if Vector2 - * @param {Function} [callbackFunction] - Calls this function on every object that passes the test - * @param {Array} [objects=engineObjects] - List of objects to check */ -function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects) -{ - if (!pos) // all objects - { - for (const o of objects) - callbackFunction(o); - } - else if (size.x != undefined) // bounding box test - { - for (const o of objects) - isOverlapping(pos, size, o.pos, o.size) && callbackFunction(o); - } - else // circle test - { - const sizeSquared = size*size; - for (const o of objects) - pos.distanceSquared(o.pos) < sizeSquared && callbackFunction(o); - } -} +/* + LittleJS - Debug Build + MIT License - Copyright 2021 Frank Force +*/ + +/** + * LittleJS Debug System + *
- Press ~ to show debug overlay with mouse pick + *
- Number keys toggle debug functions + *
- +/- apply time scale + *
- Debug primitive rendering + *
- Save a 2d canvas as an image + * @namespace Debug + */ + +'use strict'; + +/** True if debug is enabled + * @type {Boolean} + * @default + * @memberof Debug */ +const debug = 1; + +/** True if asserts are enaled + * @type {Boolean} + * @default + * @memberof Debug */ +const enableAsserts = 1; + +/** Size to render debug points by default + * @type {Number} + * @default + * @memberof Debug */ +const debugPointSize = .5; + +/** True if watermark with FPS should be shown, false in release builds + * @type {Boolean} + * @default + * @memberof Debug */ +let showWatermark = 1; + +/** True if god mode is enabled, handle this however you want + * @type {Boolean} + * @default + * @memberof Debug */ +let godMode = 0; + +// Engine internal variables not exposed to documentation +let debugPrimitives = [], debugOverlay = 0, debugPhysics = 0, debugRaycast = 0, +debugParticles = 0, debugGamepads = 0, debugMedals = 0, debugTakeScreenshot, downloadLink; + +/////////////////////////////////////////////////////////////////////////////// +// Debug helper functions + +/** Asserts if the experssion is false, does not do anything in release builds + * @param {Boolean} assertion + * @param {Object} output + * @memberof Debug */ +const ASSERT = enableAsserts ? (...assert)=> console.assert(...assert) : ()=>{}; + +/** Draw a debug rectangle in world space + * @param {Vector2} pos + * @param {Vector2} [size=Vector2()] + * @param {String} [color='#fff'] + * @param {Number} [time=0] + * @param {Number} [angle=0] + * @param {Boolean} [fill=false] + * @memberof Debug */ +const debugRect = (pos, size=vec2(), color='#fff', time=0, angle=0, fill=false)=> +{ + ASSERT(typeof color == 'string'); // pass in regular html strings as colors + debugPrimitives.push({pos, size:vec2(size), color, time:new Timer(time), angle, fill}); +} + +/** Draw a debug circle in world space + * @param {Vector2} pos + * @param {Number} [radius=0] + * @param {String} [color='#fff'] + * @param {Number} [time=0] + * @param {Boolean} [fill=false] + * @memberof Debug */ +const debugCircle = (pos, radius=0, color='#fff', time=0, fill=false)=> +{ + ASSERT(typeof color == 'string'); // pass in regular html strings as colors + debugPrimitives.push({pos, size:radius, color, time:new Timer(time), angle:0, fill}); +} + +/** Draw a debug point in world space + * @param {Vector2} pos + * @param {String} [color='#fff'] + * @param {Number} [time=0] + * @param {Number} [angle=0] + * @memberof Debug */ +const debugPoint = (pos, color, time, angle)=> debugRect(pos, 0, color, time, angle); + +/** Draw a debug line in world space + * @param {Vector2} posA + * @param {Vector2} posB + * @param {String} [color='#fff'] + * @param {Number} [thickness=.1] + * @param {Number} [time=0] + * @memberof Debug */ +const debugLine = (posA, posB, color, thickness=.1, time)=> +{ + const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2); + const size = vec2(thickness, halfDelta.length()*2); + debugRect(posA.add(halfDelta), size, color, time, halfDelta.angle(), 1); +} + +/** Draw a debug axis aligned bounding box in world space + * @param {Vector2} posA + * @param {Vector2} sizeA + * @param {Vector2} posB + * @param {Vector2} sizeB + * @param {String} [color='#fff'] + * @memberof Debug */ +const debugAABB = (pA, sA, pB, sB, color)=> +{ + const minPos = vec2(min(pA.x - sA.x/2, pB.x - sB.x/2), min(pA.y - sA.y/2, pB.y - sB.y/2)); + const maxPos = vec2(max(pA.x + sA.x/2, pB.x + sB.x/2), max(pA.y + sA.y/2, pB.y + sB.y/2)); + debugRect(minPos.lerp(maxPos,.5), maxPos.subtract(minPos), color); +} + +/** Draw a debug axis aligned bounding box in world space + * @param {String} text + * @param {Vector2} pos + * @param {Number} [size=1] + * @param {String} [color='#fff'] + * @param {Number} [time=0] + * @param {Number} [angle=0] + * @param {String} [font='monospace'] + * @memberof Debug */ +const debugText = (text, pos, size=1, color='#fff', time=0, angle=0, font='monospace')=> +{ + ASSERT(typeof color == 'string'); // pass in regular html strings as colors + debugPrimitives.push({text, pos, size, color, time:new Timer(time), angle, font}); +} + +/** Clear all debug primitives in the list + * @memberof Debug */ +const debugClear = ()=> debugPrimitives = []; + +/** Save a canvas to disk + * @param {HTMLCanvasElement} canvas + * @param {String} [filename] + * @memberof Debug */ +const debugSaveCanvas = (canvas, filename = engineName + '.png') => +{ + downloadLink.download = 'screenshot.png'; + downloadLink.href = canvas.toDataURL('image/png').replace('image/png','image/octet-stream'); + downloadLink.click(); +} + +/////////////////////////////////////////////////////////////////////////////// +// Engine debug function (called automatically) + +const debugInit = ()=> +{ + // create link for saving screenshots + document.body.appendChild(downloadLink = document.createElement('a')); + downloadLink.style.display = 'none'; +} + +const debugUpdate = ()=> +{ + if (!debug) + return; + + if (keyWasPressed(192)) // ~ + debugOverlay = !debugOverlay; + if (debugOverlay) + { + if (keyWasPressed(48)) // 0 + showWatermark = !showWatermark; + if (keyWasPressed(49)) // 1 + debugPhysics = !debugPhysics, debugParticles = 0; + if (keyWasPressed(50)) // 2 + debugParticles = !debugParticles, debugPhysics = 0; + if (keyWasPressed(51)) // 3 + debugGamepads = !debugGamepads; + if (keyWasPressed(52)) // 4 + godMode = !godMode; + if (keyWasPressed(53)) // 5 + debugTakeScreenshot = 1; + //if (keyWasPressed(54)) // 6 + //if (keyWasPressed(55)) // 7 + //if (keyWasPressed(56)) // 8 + //if (keyWasPressed(57)) // 9 + } +} + +const debugRender = ()=> +{ + glCopyToContext(mainContext); + + if (debugTakeScreenshot) + { + // composite canvas + glCopyToContext(mainContext, 1); + mainContext.drawImage(overlayCanvas, 0, 0); + overlayCanvas.width |= 0; + + debugSaveCanvas(mainCanvas); + debugTakeScreenshot = 0; + } + + if (debugGamepads && gamepadsEnable && navigator.getGamepads) + { + // gamepad debug display + const gamepads = navigator.getGamepads(); + for (let i = gamepads.length; i--;) + { + const gamepad = gamepads[i]; + if (gamepad) + { + const stickScale = 1; + const buttonScale = .2; + const centerPos = cameraPos; + const sticks = stickData[i]; + for (let j = sticks.length; j--;) + { + const drawPos = centerPos.add(vec2(j*stickScale*2, i*stickScale*3)); + const stickPos = drawPos.add(sticks[j].scale(stickScale)); + debugCircle(drawPos, stickScale, '#fff7',0,1); + debugLine(drawPos, stickPos, '#f00'); + debugPoint(stickPos, '#f00'); + } + for (let j = gamepad.buttons.length; j--;) + { + const drawPos = centerPos.add(vec2(j*buttonScale*2, i*stickScale*3-stickScale-buttonScale)); + const pressed = gamepad.buttons[j].pressed; + debugCircle(drawPos, buttonScale, pressed ? '#f00' : '#fff7', 0, 1); + debugText(j, drawPos, .2); + } + } + } + } + + if (debugOverlay) + { + const saveContext = mainContext; + mainContext = overlayContext; + + // mouse pick + let bestDistance = Infinity, bestObject; + for (const o of engineObjects) + { + if (o.canvas || o.destroyed) + continue; + if (!o.size.x || !o.size.y) + continue; + + const distance = mousePos.distanceSquared(o.pos); + if (distance < bestDistance) + { + bestDistance = distance; + bestObject = o; + } + + // show object info + const size = vec2(max(o.size.x, .2), max(o.size.y, .2)); + const color1 = new Color(!!o.collideTiles, !!o.collideSolidObjects, !!o.isSolid, o.parent?.2:.5); + const color2 = o.parent ? new Color(1,1,1,.5) : new Color(0,0,0,.8); + drawRect(o.pos, size, color1, o.angle, 0); + drawRect(o.pos, size.scale(.8), color2, o.angle, 0); + o.parent && drawLine(o.pos, o.parent.pos, .1, new Color(0,0,1,.5), 0); + } + + if (bestObject) + { + const raycastHitPos = tileCollisionRaycast(bestObject.pos, mousePos); + raycastHitPos && drawRect(raycastHitPos.floor().add(vec2(.5)), vec2(1), new Color(0,1,1,.3)); + drawRect(mousePos.floor().add(vec2(.5)), vec2(1), new Color(0,0,1,.5), 0, 0); + drawLine(mousePos, bestObject.pos, .1, raycastHitPos ? new Color(1,0,0,.5) : new Color(0,1,0,.5), 0); + + const debugText = 'mouse pos = ' + mousePos + + '\nmouse collision = ' + getTileCollisionData(mousePos) + + '\n\n--- object info ---\n' + + bestObject.toString(); + drawTextScreen(debugText, mousePosScreen, 24, new Color, .05, 0, 0, 'monospace'); + } + + glCopyToContext(mainContext = saveContext); + } + + { + // draw debug primitives + overlayContext.lineWidth = 2; + const pointSize = debugPointSize * cameraScale; + debugPrimitives.forEach(p=> + { + overlayContext.save(); + + // create canvas transform from world space to screen space + const pos = worldToScreen(p.pos); + overlayContext.translate(pos.x|0, pos.y|0); + overlayContext.rotate(p.angle); + overlayContext.fillStyle = overlayContext.strokeStyle = p.color; + + if (p.text != undefined) + { + overlayContext.font = p.size*cameraScale + 'px '+ p.font; + overlayContext.textAlign = 'center'; + overlayContext.textBaseline = 'middle'; + overlayContext.fillText(p.text, 0, 0); + } + else if (p.size == 0 || p.size.x === 0 && p.size.y === 0 ) + { + // point + overlayContext.fillRect(-pointSize/2, -1, pointSize, 3); + overlayContext.fillRect(-1, -pointSize/2, 3, pointSize); + } + else if (p.size.x != undefined) + { + // rect + const w = p.size.x*cameraScale|0, h = p.size.y*cameraScale|0; + p.fill && overlayContext.fillRect(-w/2|0, -h/2|0, w, h); + overlayContext.strokeRect(-w/2|0, -h/2|0, w, h); + } + else + { + // circle + overlayContext.beginPath(); + overlayContext.arc(0, 0, p.size*cameraScale, 0, 9); + p.fill && overlayContext.fill(); + overlayContext.stroke(); + } + + overlayContext.restore(); + }); + + // remove expired pritives + debugPrimitives = debugPrimitives.filter(r=>r.time<0); + } + + { + // draw debug overlay + overlayContext.save(); + overlayContext.fillStyle = '#fff'; + overlayContext.textAlign = 'left'; + overlayContext.textBaseline = 'top'; + overlayContext.font = '28px monospace'; + overlayContext.shadowColor = '#000'; + overlayContext.shadowBlur = 9; + + let x = 9, y = -20, h = 30; + if (debugOverlay) + { + overlayContext.fillText(engineName, x, y += h); + overlayContext.fillText('Objects: ' + engineObjects.length, x, y += h); + overlayContext.fillText('Time: ' + formatTime(time), x, y += h); + overlayContext.fillText('---------', x, y += h); + overlayContext.fillStyle = '#f00'; + overlayContext.fillText('~: Debug Overlay', x, y += h); + overlayContext.fillStyle = debugPhysics ? '#f00' : '#fff'; + overlayContext.fillText('1: Debug Physics', x, y += h); + overlayContext.fillStyle = debugParticles ? '#f00' : '#fff'; + overlayContext.fillText('2: Debug Particles', x, y += h); + overlayContext.fillStyle = debugGamepads ? '#f00' : '#fff'; + overlayContext.fillText('3: Debug Gamepads', x, y += h); + overlayContext.fillStyle = godMode ? '#f00' : '#fff'; + overlayContext.fillText('4: God Mode', x, y += h); + overlayContext.fillStyle = '#fff'; + overlayContext.fillText('5: Save Screenshot', x, y += h); + + let keysPressed = ''; + for(const i in inputData[0]) + { + if (i && keyIsDown(i, 0)) + keysPressed += i + ' ' ; + } + keysPressed && overlayContext.fillText('Keys Down: ' + keysPressed, x, y += h); + + let buttonsPressed = ''; + if (inputData[1]) + for(const i in inputData[1]) + { + if (i && keyIsDown(i, 1)) + buttonsPressed += i + ' ' ; + } + buttonsPressed && overlayContext.fillText('Gamepad: ' + buttonsPressed, x, y += h); + } + else + { + overlayContext.fillText(debugPhysics ? 'Debug Physics' : '', x, y += h); + overlayContext.fillText(debugParticles ? 'Debug Particles' : '', x, y += h); + overlayContext.fillText(godMode ? 'God Mode' : '', x, y += h); + overlayContext.fillText(debugGamepads ? 'Debug Gamepads' : '', x, y += h); + } + + overlayContext.restore(); + } +} +/** + * LittleJS Utility Classes and Functions + *
- General purpose math library + *
- Vector2 - fast, simple, easy 2D vector class + *
- Color - holds a rgba color with some math functions + *
- Timer - tracks time automatically + * @namespace Utilities + */ + +'use strict'; + +/** A shortcut to get Math.PI + * @type {Number} + * @default Math.PI + * @memberof Utilities */ +const PI = Math.PI; + +/** Returns absoulte value of value passed in + * @param {Number} value + * @return {Number} + * @memberof Utilities */ +const abs = (a)=> a < 0 ? -a : a; + +/** Returns lowest of two values passed in + * @param {Number} valueA + * @param {Number} valueB + * @return {Number} + * @memberof Utilities */ +const min = (a, b)=> a < b ? a : b; + +/** Returns highest of two values passed in + * @param {Number} valueA + * @param {Number} valueB + * @return {Number} + * @memberof Utilities */ +const max = (a, b)=> a > b ? a : b; + +/** Returns the sign of value passed in (also returns 1 if 0) + * @param {Number} value + * @return {Number} + * @memberof Utilities */ +const sign = (a)=> a < 0 ? -1 : 1; + +/** Returns first parm modulo the second param, but adjusted so negative numbers work as expected + * @param {Number} dividend + * @param {Number} [divisor=1] + * @return {Number} + * @memberof Utilities */ +const mod = (a, b=1)=> ((a % b) + b) % b; + +/** Clamps the value beween max and min + * @param {Number} value + * @param {Number} [min=0] + * @param {Number} [max=1] + * @return {Number} + * @memberof Utilities */ +const clamp = (v, min=0, max=1)=> v < min ? min : v > max ? max : v; + +/** Returns what percentage the value is between max and min + * @param {Number} value + * @param {Number} [min=0] + * @param {Number} [max=1] + * @return {Number} + * @memberof Utilities */ +const percent = (v, min=0, max=1)=> max-min ? clamp((v-min) / (max-min)) : 0; + +/** Linearly interpolates the percent value between max and min + * @param {Number} percent + * @param {Number} [min=0] + * @param {Number} [max=1] + * @return {Number} + * @memberof Utilities */ +const lerp = (p, min=0, max=1)=> min + clamp(p) * (max-min); + +/** Applies smoothstep function to the percentage value + * @param {Number} value + * @return {Number} + * @memberof Utilities */ +const smoothStep = (p)=> p * p * (3 - 2 * p); + +/** Returns the nearest power of two not less then the value + * @param {Number} value + * @return {Number} + * @memberof Utilities */ +const nearestPowerOfTwo = (v)=> 2**Math.ceil(Math.log2(v)); + +/** Returns true if two axis aligned bounding boxes are overlapping + * @param {Vector2} pointA - Center of box A + * @param {Vector2} sizeA - Size of box A + * @param {Vector2} pointB - Center of box B + * @param {Vector2} [sizeB] - Size of box B + * @return {Boolean} - True if overlapping + * @memberof Utilities */ +const isOverlapping = (pA, sA, pB, sB)=> abs(pA.x - pB.x)*2 < sA.x + sB.x && abs(pA.y - pB.y)*2 < sA.y + sB.y; + +/** Returns an oscillating wave between 0 and amplitude with frequency of 1 Hz by default + * @param {Number} [frequency=1] - Frequency of the wave in Hz + * @param {Number} [amplitude=1] - Amplitude (max height) of the wave + * @param {Number} [t=time] - Value to use for time of the wave + * @return {Number} - Value waving between 0 and amplitude + * @memberof Utilities */ +const wave = (frequency=1, amplitude=1, t=time)=> amplitude/2 * (1 - Math.cos(t*frequency*2*PI)); + +/** Formats seconds to mm:ss style for display purposes + * @param {Number} t - time in seconds + * @return {String} + * @memberof Utilities */ +const formatTime = (t)=> (t/60|0) + ':' + (t%60<10?'0':'') + (t%60|0); + +/////////////////////////////////////////////////////////////////////////////// + +/** Random global functions + * @namespace Random */ + +/** Returns a random value between the two values passed in + * @param {Number} [valueA=1] + * @param {Number} [valueB=0] + * @return {Number} + * @memberof Random */ +const rand = (a=1, b=0)=> b + (a-b)*Math.random(); + +/** Returns a floored random value the two values passed in + * @param {Number} [valueA=1] + * @param {Number} [valueB=0] + * @return {Number} + * @memberof Random */ +const randInt = (a=1, b=0)=> rand(a,b)|0; + +/** Randomly returns either -1 or 1 + * @return {Number} + * @memberof Random */ +const randSign = ()=> randInt(2) * 2 - 1; + +/** Returns a random Vector2 within a circular shape + * @param {Number} [radius=1] + * @param {Number} [minRadius=0] + * @return {Vector2} + * @memberof Random */ +const randInCircle = (radius=1, minRadius=0)=> radius > 0 ? randVector(radius * rand(minRadius / radius, 1)**.5) : new Vector2; + +/** Returns a random Vector2 with the passed in length + * @param {Number} [length=1] + * @return {Vector2} + * @memberof Random */ +const randVector = (length=1)=> new Vector2().setAngle(rand(2*PI), length); + +/** Returns a random color between the two passed in colors, combine components if linear + * @param {Color} [colorA=Color()] + * @param {Color} [colorB=Color(0,0,0,1)] + * @param {Boolean} [linear] + * @return {Color} + * @memberof Random */ +const randColor = (cA = new Color, cB = new Color(0,0,0,1), linear)=> + linear ? cA.lerp(cB, rand()) : new Color(rand(cA.r,cB.r),rand(cA.g,cB.g),rand(cA.b,cB.b),rand(cA.a,cB.a)); + +/** Seed used by the randSeeded function + * @type {Number} + * @default + * @memberof Random */ +let randSeed = 1; + +/** Set seed used by the randSeeded function, should not be 0 + * @param {Number} seed + * @memberof Random */ +const setRandSeed = (seed)=> randSeed = seed; + +/** Returns a seeded random value between the two values passed in using randSeed + * @param {Number} [valueA=1] + * @param {Number} [valueB=0] + * @return {Number} + * @memberof Random */ +const randSeeded = (a=1, b=0)=> +{ + randSeed ^= randSeed << 13; randSeed ^= randSeed >>> 17; randSeed ^= randSeed << 5; // xorshift + return b + (a-b) * abs(randSeed % 1e9) / 1e9; +} + +/////////////////////////////////////////////////////////////////////////////// + +/** + * Create a 2d vector, can take another Vector2 to copy, 2 scalars, or 1 scalar + * @param {Number} [x=0] + * @param {Number} [y=0] + * @return {Vector2} + * @example + * let a = vec2(0, 1); // vector with coordinates (0, 1) + * let b = vec2(a); // copy a into b + * a = vec2(5); // set a to (5, 5) + * b = vec2(); // set b to (0, 0) + * @memberof Utilities + */ +const vec2 = (x=0, y)=> x.x == undefined ? new Vector2(x, y == undefined? x : y) : new Vector2(x.x, x.y); + +/** + * Check if object is a valid Vector2 + * @param {Vector2} vector + * @return {Boolean} + * @memberof Utilities + */ +const isVector2 = (v)=> !isNaN(v.x) && !isNaN(v.y); + +/** + * 2D Vector object with vector math library + *
- Functions do not change this so they can be chained together + * @example + * let a = new Vector2(2, 3); // vector with coordinates (2, 3) + * let b = new Vector2; // vector with coordinates (0, 0) + * let c = vec2(4, 2); // use the vec2 function to make a Vector2 + * let d = a.add(b).scale(5); // operators can be chained + */ +class Vector2 +{ + /** Create a 2D vector with the x and y passed in, can also be created with vec2() + * @param {Number} [x=0] - X axis location + * @param {Number} [y=0] - Y axis location */ + constructor(x=0, y=0) + { + /** @property {Number} - X axis location */ + this.x = x; + /** @property {Number} - Y axis location */ + this.y = y; + } + + /** Returns a new vector that is a copy of this + * @return {Vector2} */ + copy() { return new Vector2(this.x, this.y); } + + /** Returns a copy of this vector plus the vector passed in + * @param {Vector2} vector + * @return {Vector2} */ + add(v) { ASSERT(isVector2(v)); return new Vector2(this.x + v.x, this.y + v.y); } + + /** Returns a copy of this vector minus the vector passed in + * @param {Vector2} vector + * @return {Vector2} */ + subtract(v) { ASSERT(isVector2(v)); return new Vector2(this.x - v.x, this.y - v.y); } + + /** Returns a copy of this vector times the vector passed in + * @param {Vector2} vector + * @return {Vector2} */ + multiply(v) { ASSERT(isVector2(v)); return new Vector2(this.x * v.x, this.y * v.y); } + + /** Returns a copy of this vector divided by the vector passed in + * @param {Vector2} vector + * @return {Vector2} */ + divide(v) { ASSERT(isVector2(v)); return new Vector2(this.x / v.x, this.y / v.y); } + + /** Returns a copy of this vector scaled by the vector passed in + * @param {Number} scale + * @return {Vector2} */ + scale(s) { ASSERT(!isVector2(s)); return new Vector2(this.x * s, this.y * s); } + + /** Returns the length of this vector + * @return {Number} */ + length() { return this.lengthSquared()**.5; } + + /** Returns the length of this vector squared + * @return {Number} */ + lengthSquared() { return this.x**2 + this.y**2; } + + /** Returns the distance from this vector to vector passed in + * @param {Vector2} vector + * @return {Number} */ + distance(v) { return this.distanceSquared(v)**.5; } + + /** Returns the distance squared from this vector to vector passed in + * @param {Vector2} vector + * @return {Number} */ + distanceSquared(v) { return (this.x - v.x)**2 + (this.y - v.y)**2; } + + /** Returns a new vector in same direction as this one with the length passed in + * @param {Number} [length=1] + * @return {Vector2} */ + normalize(length=1) { const l = this.length(); return l ? this.scale(length/l) : new Vector2(0, length); } + + /** Returns a new vector clamped to length passed in + * @param {Number} [length=1] + * @return {Vector2} */ + clampLength(length=1) { const l = this.length(); return l > length ? this.scale(length/l) : this; } + + /** Returns the dot product of this and the vector passed in + * @param {Vector2} vector + * @return {Number} */ + dot(v) { ASSERT(isVector2(v)); return this.x*v.x + this.y*v.y; } + + /** Returns the cross product of this and the vector passed in + * @param {Vector2} vector + * @return {Number} */ + cross(v) { ASSERT(isVector2(v)); return this.x*v.y - this.y*v.x; } + + /** Returns the angle of this vector, up is angle 0 + * @return {Number} */ + angle() { return Math.atan2(this.x, this.y); } + + /** Sets this vector with angle and length passed in + * @param {Number} [angle=0] + * @param {Number} [length=1] + * @return {Vector2} */ + setAngle(a=0, length=1) { this.x = length*Math.sin(a); this.y = length*Math.cos(a); return this; } + + /** Returns copy of this vector rotated by the angle passed in + * @param {Number} angle + * @return {Vector2} */ + rotate(a) { const c = Math.cos(a), s = Math.sin(a); return new Vector2(this.x*c-this.y*s, this.x*s+this.y*c); } + + /** Returns the integer direction of this vector, corrosponding to multiples of 90 degree rotation (0-3) + * @return {Number} */ + direction() { return abs(this.x) > abs(this.y) ? this.x < 0 ? 3 : 1 : this.y < 0 ? 2 : 0; } + + /** Returns a copy of this vector that has been inverted + * @return {Vector2} */ + invert() { return new Vector2(this.y, -this.x); } + + /** Returns a copy of this vector with each axis floored + * @return {Vector2} */ + floor() { return new Vector2(Math.floor(this.x), Math.floor(this.y)); } + + /** Returns the area this vector covers as a rectangle + * @return {Number} */ + area() { return abs(this.x * this.y); } + + /** Returns a new vector that is p percent between this and the vector passed in + * @param {Vector2} vector + * @param {Number} percent + * @return {Vector2} */ + lerp(v, p) { ASSERT(isVector2(v)); return this.add(v.subtract(this).scale(clamp(p))); } + + /** Returns true if this vector is within the bounds of an array size passed in + * @param {Vector2} arraySize + * @return {Boolean} */ + arrayCheck(arraySize) { return this.x >= 0 && this.y >= 0 && this.x < arraySize.x && this.y < arraySize.y; } + + /** Returns this vector expressed as a string + * @param {float} digits - precision to display + * @return {String} */ + toString(digits=3) + { if (debug) { return `(${(this.x<0?'':' ') + this.x.toFixed(digits)},${(this.y<0?'':' ') + this.y.toFixed(digits)} )`; }} +} + +/////////////////////////////////////////////////////////////////////////////// + +/** + * Create a color object with RGBA values + * @param {Number} [r=1] + * @param {Number} [g=1] + * @param {Number} [b=1] + * @param {Number} [a=1] + * @return {Color} + * @memberof Utilities + */ +const colorRGBA = (r, g, b, a)=> new Color(r, g, b, a); + +/** + * Create a color object with HSLA values + * @param {Number} [h=0] + * @param {Number} [s=0] + * @param {Number} [l=1] + * @param {Number} [a=1] + * @return {Color} + * @memberof Utilities + */ +const colorHSLA = (h, s, l, a)=> new Color().setHSLA(h, s, l, a); + +/** + * Color object (red, green, blue, alpha) with some helpful functions + * @example + * let a = new Color; // white + * let b = new Color(1, 0, 0); // red + * let c = new Color(0, 0, 0, 0); // transparent black + * let d = colorRGBA(0, 0, 1); // blue using rgb color + * let e = colorHSLA(.3, 1, .5); // green using hsl color + */ +class Color +{ + /** Create a color with the components passed in, white by default + * @param {Number} [red=1] + * @param {Number} [green=1] + * @param {Number} [blue=1] + * @param {Number} [alpha=1] */ + constructor(r=1, g=1, b=1, a=1) + { + /** @property {Number} - Red */ + this.r = r; + /** @property {Number} - Green */ + this.g = g; + /** @property {Number} - Blue */ + this.b = b; + /** @property {Number} - Alpha */ + this.a = a; + } + + /** Returns a new color that is a copy of this + * @return {Color} */ + copy() { return new Color(this.r, this.g, this.b, this.a); } + + /** Returns a copy of this color plus the color passed in + * @param {Color} color + * @return {Color} */ + add(c) { return new Color(this.r+c.r, this.g+c.g, this.b+c.b, this.a+c.a); } + + /** Returns a copy of this color minus the color passed in + * @param {Color} color + * @return {Color} */ + subtract(c) { return new Color(this.r-c.r, this.g-c.g, this.b-c.b, this.a-c.a); } + + /** Returns a copy of this color times the color passed in + * @param {Color} color + * @return {Color} */ + multiply(c) { return new Color(this.r*c.r, this.g*c.g, this.b*c.b, this.a*c.a); } + + /** Returns a copy of this color divided by the color passed in + * @param {Color} color + * @return {Color} */ + divide(c) { return new Color(this.r/c.r, this.g/c.g, this.b/c.b, this.a/c.a); } + + /** Returns a copy of this color scaled by the value passed in, alpha can be scaled separately + * @param {Number} scale + * @param {Number} [alphaScale=scale] + * @return {Color} */ + scale(s, a=s) { return new Color(this.r*s, this.g*s, this.b*s, this.a*a); } + + /** Returns a copy of this color clamped to the valid range between 0 and 1 + * @return {Color} */ + clamp() { return new Color(clamp(this.r), clamp(this.g), clamp(this.b), clamp(this.a)); } + + /** Returns a new color that is p percent between this and the color passed in + * @param {Color} color + * @param {Number} percent + * @return {Color} */ + lerp(c, p) { return this.add(c.subtract(this).scale(clamp(p))); } + + /** Sets this color given a hue, saturation, lightness, and alpha + * @param {Number} [hue=0] + * @param {Number} [saturation=0] + * @param {Number} [lightness=1] + * @param {Number} [alpha=1] + * @return {Color} */ + setHSLA(h=0, s=0, l=1, a=1) + { + const q = l < .5 ? l*(1+s) : l+s-l*s, p = 2*l-q, + f = (p, q, t)=> + (t = ((t%1)+1)%1) < 1/6 ? p+(q-p)*6*t : + t < 1/2 ? q : + t < 2/3 ? p+(q-p)*(2/3-t)*6 : p; + + this.r = f(p, q, h + 1/3); + this.g = f(p, q, h); + this.b = f(p, q, h - 1/3); + this.a = a; + return this; + } + + /** Returns this color expressed in hsla format + * @return {Array} */ + getHSLA() + { + const r = clamp(this.r); + const g = clamp(this.g); + const b = clamp(this.b); + const a = clamp(this.a); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + + let h = 0, s = 0; + if (max != min) + { + let d = max - min; + s = l > .5 ? d / (2 - max - min) : d / (max + min); + if (r == max) + h = (g - b) / d + (g < b ? 6 : 0); + else if (g == max) + h = (b - r) / d + 2; + else if (b == max) + h = (r - g) / d + 4; + } + + return [h / 6, s, l, a]; + } + + /** Returns a new color that has each component randomly adjusted + * @param {Number} [amount=.05] + * @param {Number} [alphaAmount=0] + * @return {Color} */ + mutate(amount=.05, alphaAmount=0) + { + return new Color + ( + this.r + rand(amount, -amount), + this.g + rand(amount, -amount), + this.b + rand(amount, -amount), + this.a + rand(alphaAmount, -alphaAmount) + ).clamp(); + } + + /** Returns this color expressed as a hex color code + * @param {Boolean} [useAlpha=1] - if alpha should be included in result + * @return {String} */ + toString(useAlpha = 1) + { + const toHex = (c)=> ((c=c*255|0)<16 ? '0' : '') + c.toString(16); + return '#' + toHex(this.r) + toHex(this.g) + toHex(this.b) + (useAlpha ? toHex(this.a) : ''); + } + + /** Set this color from a hex code + * @param {String} hex - html hex code + * @return {Color} */ + setHex(hex) + { + const fromHex = (c)=> clamp(parseInt(hex.slice(c,c+2),16)/255); + this.r = fromHex(1); + this.g = fromHex(3), + this.b = fromHex(5); + this.a = hex.length > 7 ? fromHex(7) : 1; + return this; + } + + /** Returns this color expressed as 32 bit RGBA value + * @return {Number} */ + rgbaInt() + { + const toByte = (c)=> clamp(c)*255|0; + const r = toByte(this.r); + const g = toByte(this.g)<<8; + const b = toByte(this.b)<<16; + const a = toByte(this.a)<<24; + return r + g + b + a; + } +} + +/////////////////////////////////////////////////////////////////////////////// + +/** + * Timer object tracks how long has passed since it was set + * @example + * let a = new Timer; // creates a timer that is not set + * a.set(3); // sets the timer to 3 seconds + * + * let b = new Timer(1); // creates a timer with 1 second left + * b.unset(); // unsets the timer + */ +class Timer +{ + /** Create a timer object set time passed in + * @param {Number} [timeLeft] - How much time left before the timer elapses in seconds */ + constructor(timeLeft) { this.time = timeLeft == undefined ? undefined : time + timeLeft; this.setTime = timeLeft; } + + /** Set the timer with seconds passed in + * @param {Number} [timeLeft=0] - How much time left before the timer is elapsed in seconds */ + set(timeLeft=0) { this.time = time + timeLeft; this.setTime = timeLeft; } + + /** Unset the timer */ + unset() { this.time = undefined; } + + /** Returns true if set + * @return {Boolean} */ + isSet() { return this.time != undefined; } + + /** Returns true if set and has not elapsed + * @return {Boolean} */ + active() { return time <= this.time; } + + /** Returns true if set and elapsed + * @return {Boolean} */ + elapsed() { return time > this.time; } + + /** Get how long since elapsed, returns 0 if not set (returns negative if currently active) + * @return {Number} */ + get() { return this.isSet()? time - this.time : 0; } + + /** Get percentage elapsed based on time it was set to, returns 0 if not set + * @return {Number} */ + getPercent() { return this.isSet()? percent(this.time - time, this.setTime, 0) : 0; } + + /** Returns this timer expressed as a string + * @return {String} */ + toString() { if (debug) { return this.unset() ? 'unset' : Math.abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ); }} + + /** Get how long since elapsed, returns 0 if not set (returns negative if currently active) + * @return {Number} */ + valueOf() { return this.get(); } +} +/** + * LittleJS Engine Settings + * @namespace Settings + */ + +'use strict'; + +/////////////////////////////////////////////////////////////////////////////// +// Camera settings + +/** Position of camera in world space + * @type {Vector2} + * @default Vector2() + * @memberof Settings */ +let cameraPos = vec2(); + +/** Scale of camera in world space + * @type {Number} + * @default + * @memberof Settings */ +let cameraScale = 32; + +/////////////////////////////////////////////////////////////////////////////// +// Display settings + +/** The max size of the canvas, centered if window is larger + * @type {Vector2} + * @default Vector2(1920,1200) + * @memberof Settings */ +let canvasMaxSize = vec2(1920, 1200); + +/** Fixed size of the canvas, if enabled canvas size never changes + * - you may also need to set mainCanvasSize if using screen space coords in startup + * @type {Vector2} + * @default Vector2() + * @memberof Settings */ +let canvasFixedSize = vec2(); + +/** Disables anti aliasing for pixel art if true + * @type {Boolean} + * @default + * @memberof Settings */ +let cavasPixelated = 1; + +/** Default font used for text rendering + * @type {String} + * @default + * @memberof Settings */ +let fontDefault = 'arial'; + +/////////////////////////////////////////////////////////////////////////////// +// WebGL settings + +/** Enable webgl rendering, webgl can be disabled and removed from build (with some features disabled) + * @type {Boolean} + * @default + * @memberof Settings */ +let glEnable = 1; + +/** Fixes slow rendering in some browsers by not compositing the WebGL canvas + * @type {Boolean} + * @default + * @memberof Settings */ +let glOverlay = 1; + +/////////////////////////////////////////////////////////////////////////////// +// Tile sheet settings + +/** Default size of tiles in pixels + * @type {Vector2} + * @default Vector2(16,16) + * @memberof Settings */ +let tileSizeDefault = vec2(16); + +/** Prevent tile bleeding from neighbors in pixels + * @type {Number} + * @default + * @memberof Settings */ +let tileFixBleedScale = .3; + +/////////////////////////////////////////////////////////////////////////////// +// Object settings + +/** Enable physics solver for collisions between objects + * @type {Boolean} + * @default + * @memberof Settings */ +let enablePhysicsSolver = 1; + +/** Default object mass for collison calcuations (how heavy objects are) + * @type {Number} + * @default + * @memberof Settings */ +let objectDefaultMass = 1; + +/** How much to slow velocity by each frame (0-1) + * @type {Number} + * @default + * @memberof Settings */ +let objectDefaultDamping = 1; + +/** How much to slow angular velocity each frame (0-1) + * @type {Number} + * @default + * @memberof Settings */ +let objectDefaultAngleDamping = 1; + +/** How much to bounce when a collision occurs (0-1) + * @type {Number} + * @default 0 + * @memberof Settings */ +let objectDefaultElasticity = 0; + +/** How much to slow when touching (0-1) + * @type {Number} + * @default + * @memberof Settings */ +let objectDefaultFriction = .8; + +/** Clamp max speed to avoid fast objects missing collisions + * @type {Number} + * @default + * @memberof Settings */ +let objectMaxSpeed = 1; + +/** How much gravity to apply to objects along the Y axis, negative is down + * @type {Number} + * @default 0 + * @memberof Settings */ +let gravity = 0; + +/** Scales emit rate of particles, useful for low graphics mode (0 disables particle emitters) + * @type {Number} + * @default + * @memberof Settings */ +let particleEmitRateScale = 1; + +/////////////////////////////////////////////////////////////////////////////// +// Input settings + +/** Should gamepads be allowed + * @type {Boolean} + * @default + * @memberof Settings */ +let gamepadsEnable = 1; + +/** If true, the dpad input is also routed to the left analog stick (for better accessability) + * @type {Boolean} + * @default + * @memberof Settings */ +let gamepadDirectionEmulateStick = 1; + +/** If true the WASD keys are also routed to the direction keys (for better accessability) + * @type {Boolean} + * @default + * @memberof Settings */ +let inputWASDEmulateDirection = 1; + +/** True if touch gamepad should appear on mobile devices + *
- Supports left analog stick, 4 face buttons and start button (button 9) + *
- Must be set by end of gameInit to be activated + * @type {Boolean} + * @default 0 + * @memberof Settings */ +let touchGamepadEnable = 0; + +/** True if touch gamepad should be analog stick or false to use if 8 way dpad + * @type {Boolean} + * @default + * @memberof Settings */ +let touchGamepadAnalog = 1; + +/** Size of virutal gamepad for touch devices in pixels + * @type {Number} + * @default + * @memberof Settings */ +let touchGamepadSize = 99; + +/** Transparency of touch gamepad overlay + * @type {Number} + * @default + * @memberof Settings */ +let touchGamepadAlpha = .3; + +/** Allow vibration hardware if it exists + * @type {Boolean} + * @default + * @memberof Settings */ +let vibrateEnable = 1; + +/////////////////////////////////////////////////////////////////////////////// +// Audio settings + +/** All audio code can be disabled and removed from build + * @type {Boolean} + * @default + * @memberof Settings */ +let soundEnable = 1; + +/** Volume scale to apply to all sound, music and speech + * @type {Number} + * @default + * @memberof Settings */ +let soundVolume = .5; + +/** Default range where sound no longer plays + * @type {Number} + * @default + * @memberof Settings */ +let soundDefaultRange = 40; + +/** Default range percent to start tapering off sound (0-1) + * @type {Number} + * @default + * @memberof Settings */ +let soundDefaultTaper = .7; + +/////////////////////////////////////////////////////////////////////////////// +// Medals settings + +/** How long to show medals for in seconds + * @type {Number} + * @default + * @memberof Settings */ +let medalDisplayTime = 5; + +/** How quickly to slide on/off medals in seconds + * @type {Number} + * @default + * @memberof Settings */ +let medalDisplaySlideTime = .5; + +/** Size of medal display + * @type {Vector2} + * @default Vector2(640,80) + * @memberof Settings */ +let medalDisplaySize = vec2(640, 80); + +/** Size of icon in medal display + * @type {Number} + * @default + * @memberof Settings */ +let medalDisplayIconSize = 50; + +/** Set to stop medals from being unlockable (like if cheats are enabled) + * @type {Boolean} + * @default 0 + * @memberof Settings */ +let medalsPreventUnlock; +/* + LittleJS Object System +*/ + +'use strict'; + +/** + * LittleJS Object Base Object Class + *
- Base object class used by the engine + *
- Automatically adds self to object list + *
- Will be updated and rendered each frame + *
- Renders as a sprite from a tilesheet by default + *
- Can have color and addtive color applied + *
- 2d Physics and collision system + *
- Sorted by renderOrder + *
- Objects can have children attached + *
- Parents are updated before children, and set child transform + *
- Call destroy() to get rid of objects + *
+ *
The physics system used by objects is simple and fast with some caveats... + *
- Collision uses the axis aligned size, the object's rotation angle is only for rendering + *
- Objects are guaranteed to not intersect tile collision from physics + *
- If an object starts or is moved inside tile collision, it will not collide with that tile + *
- Collision for objects can be set to be solid to block other objects + *
- Objects may get pushed into overlapping other solid objects, if so they will push away + *
- Solid objects are more performance intensive and should be used sparingly + * @example + * // create an engine object, normally you would first extend the class with your own + * const pos = vec2(2,3); + * const object = new EngineObject(pos); + */ +class EngineObject +{ + /** Create an engine object and adds it to the list of objects + * @param {Vector2} [position=Vector2()] - World space position of the object + * @param {Vector2} [size=Vector2(1,1)] - World space size of the object + * @param {Number} [tileIndex=-1] - Tile to use to render object (-1 is untextured) + * @param {Vector2} [tileSize=tileSizeDefault] - Size of tile in source pixels + * @param {Number} [angle=0] - Angle the object is rotated by + * @param {Color} [color=Color()] - Color to apply to tile when rendered + * @param {Number} [renderOrder=0] - Objects sorted by renderOrder before being rendered + */ + constructor(pos=vec2(), size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, angle=0, color, renderOrder=0) + { + // set passed in params + ASSERT(isVector2(pos) && isVector2(size)); // ensure pos and size are vec2s + + /** @property {Vector2} - World space position of the object */ + this.pos = pos.copy(); + /** @property {Vector2} - World space width and height of the object */ + this.size = size; + /** @property {Vector2} - Size of object used for drawing, uses size if not set */ + this.drawSize; + /** @property {Number} - Tile to use to render object (-1 is untextured) */ + this.tileIndex = tileIndex; + /** @property {Vector2} - Size of tile in source pixels */ + this.tileSize = tileSize; + /** @property {Number} - Angle to rotate the object */ + this.angle = angle; + /** @property {Color} - Color to apply when rendered */ + this.color = color; + /** @property {Color} - Additive color to apply when rendered */ + this.additiveColor; + + // set object defaults + /** @property {Number} [mass=objectDefaultMass] - How heavy the object is, static if 0 */ + this.mass = objectDefaultMass; + /** @property {Number} [damping=objectDefaultDamping] - How much to slow down velocity each frame (0-1) */ + this.damping = objectDefaultDamping; + /** @property {Number} [angleDamping=objectDefaultAngleDamping] - How much to slow down rotation each frame (0-1) */ + this.angleDamping = objectDefaultAngleDamping; + /** @property {Number} [elasticity=objectDefaultElasticity] - How bouncy the object is when colliding (0-1) */ + this.elasticity = objectDefaultElasticity; + /** @property {Number} [friction=objectDefaultFriction] - How much friction to apply when sliding (0-1) */ + this.friction = objectDefaultFriction; + /** @property {Number} [gravityScale=1] - How much to scale gravity by for this object */ + this.gravityScale = 1; + /** @property {Number} [renderOrder=0] - Objects are sorted by render order */ + this.renderOrder = renderOrder; + /** @property {Vector2} [velocity=Vector2()] - Velocity of the object */ + this.velocity = new Vector2(); + /** @property {Number} [angleVelocity=0] - Angular velocity of the object */ + this.angleVelocity = 0; + + // init other internal object stuff + this.spawnTime = time; + this.children = []; + this.collideTiles = 1; + + // add to list of objects + engineObjects.push(this); + } + + /** Update the object transform and physics, called automatically by engine once each frame */ + update() + { + const parent = this.parent; + if (parent) + { + // copy parent pos/angle + this.pos = this.localPos.multiply(vec2(parent.getMirrorSign(),1)).rotate(-parent.angle).add(parent.pos); + this.angle = parent.getMirrorSign()*this.localAngle + parent.angle; + return; + } + + // limit max speed to prevent missing collisions + this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed); + this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed); + + // apply physics + const oldPos = this.pos.copy(); + this.velocity.y += gravity * this.gravityScale; + this.pos.x += this.velocity.x *= this.damping; + this.pos.y += this.velocity.y *= this.damping; + + // physics sanity checks + ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1); + ASSERT(this.damping >= 0 && this.damping <= 1); + + if (!enablePhysicsSolver || !this.mass) // do not update collision for fixed objects + return; + + const wasMovingDown = this.velocity.y < 0; + if (this.groundObject) + { + // apply friction in local space of ground object + const groundSpeed = this.groundObject.velocity ? this.groundObject.velocity.x : 0; + this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * this.friction; + this.groundObject = 0; + //debugOverlay && debugPhysics && debugPoint(this.pos.subtract(vec2(0,this.size.y/2)), '#0f0'); + } + + if (this.collideSolidObjects) + { + // check collisions against solid objects + const epsilon = .001; // necessary to push slightly outside of the collision + for (const o of engineObjectsCollide) + { + // non solid objects don't collide with eachother + if (!this.isSolid & !o.isSolid || o.destroyed || o.parent || o == this) + continue; + + // check collision + if (!isOverlapping(this.pos, this.size, o.pos, o.size)) + continue; + + // pass collision to objects + if (!this.collideWithObject(o) | !o.collideWithObject(this)) + continue; + + if (isOverlapping(oldPos, this.size, o.pos, o.size)) + { + // if already was touching, try to push away + const deltaPos = oldPos.subtract(o.pos); + const length = deltaPos.length(); + const pushAwayAccel = .001; // push away if already overlapping + const velocity = length < .01 ? randVector(pushAwayAccel) : deltaPos.scale(pushAwayAccel/length); + this.velocity = this.velocity.add(velocity); + if (o.mass) // push away if not fixed + o.velocity = o.velocity.subtract(velocity); + + debugOverlay && debugPhysics && debugAABB(this.pos, this.size, o.pos, o.size, '#f00'); + continue; + } + + // check for collision + const sizeBoth = this.size.add(o.size); + const smallStepUp = (oldPos.y - o.pos.y)*2 > sizeBoth.y + gravity; // prefer to push up if small delta + const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y; + const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x; + + if (smallStepUp | isBlockedY | !isBlockedX) // resolve y collision + { + // push outside object collision + this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y); + if (o.groundObject && wasMovingDown || !o.mass) + { + // set ground object if landed on something + if (wasMovingDown) + this.groundObject = o; + + // bounce if other object is fixed or grounded + this.velocity.y *= -this.elasticity; + } + else if (o.mass) + { + // inelastic collision + const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass); + + // elastic collision + const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass) + + o.velocity.y * 2 * o.mass / (this.mass + o.mass); + const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass) + + this.velocity.y * 2 * this.mass / (this.mass + o.mass); + + // lerp betwen elastic or inelastic based on elasticity + const elasticity = max(this.elasticity, o.elasticity); + this.velocity.y = lerp(elasticity, inelastic, elastic0); + o.velocity.y = lerp(elasticity, inelastic, elastic1); + } + } + if (!smallStepUp & isBlockedX) // resolve x collision + { + // push outside collision + this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x); + if (o.mass) + { + // inelastic collision + const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass); + + // elastic collision + const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass) + + o.velocity.x * 2 * o.mass / (this.mass + o.mass); + const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass) + + this.velocity.x * 2 * this.mass / (this.mass + o.mass); + + // lerp betwen elastic or inelastic based on elasticity + const elasticity = max(this.elasticity, o.elasticity); + this.velocity.x = lerp(elasticity, inelastic, elastic0); + o.velocity.x = lerp(elasticity, inelastic, elastic1); + } + else // bounce if other object is fixed + this.velocity.x *= -this.elasticity; + } + debugOverlay && debugPhysics && debugAABB(this.pos, this.size, o.pos, o.size, '#f0f'); + } + } + if (this.collideTiles) + { + // check collision against tiles + if (tileCollisionTest(this.pos, this.size, this)) + { + // if already was stuck in collision, don't do anything + // this should not happen unless something starts in collision + if (!tileCollisionTest(oldPos, this.size, this)) + { + // test which side we bounced off (or both if a corner) + const isBlockedY = tileCollisionTest(new Vector2(oldPos.x, this.pos.y), this.size, this); + const isBlockedX = tileCollisionTest(new Vector2(this.pos.x, oldPos.y), this.size, this); + if (isBlockedY | !isBlockedX) + { + // set if landed on ground + this.groundObject = wasMovingDown; + + // bounce velocity + this.velocity.y *= -this.elasticity; + + // adjust next velocity to settle on ground + const o = (oldPos.y - this.size.y/2|0) - (oldPos.y - this.size.y/2); + if (o < 0 && o > this.damping * this.velocity.y + gravity * this.gravityScale) + this.velocity.y = this.damping ? (o - gravity * this.gravityScale) / this.damping : 0; + + // move to previous position + this.pos.y = oldPos.y; + } + if (isBlockedX) + { + // move to previous position and bounce + this.pos.x = oldPos.x; + this.velocity.x *= -this.elasticity; + } + } + } + } + } + + /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */ + render() + { + // default object render + drawTile(this.pos, this.drawSize || this.size, this.tileIndex, this.tileSize, this.color, this.angle, this.mirror, this.additiveColor); + } + + /** Destroy this object, destroy it's children, detach it's parent, and mark it for removal */ + destroy() + { + if (this.destroyed) + return; + + // disconnect from parent and destroy chidren + this.destroyed = 1; + this.parent && this.parent.removeChild(this); + for (const child of this.children) + child.destroy(child.parent = 0); + } + + /** Called to check if a tile collision should be resolved + * @param {Number} tileData - the value of the tile at the position + * @param {Vector2} pos - tile where the collision occured + * @return {Boolean} - true if the collision should be resolved */ + collideWithTile(tileData, pos) { return tileData > 0; } + + /** Called to check if a tile raycast hit + * @param {Number} tileData - the value of the tile at the position + * @param {Vector2} pos - tile where the raycast is + * @return {Boolean} - true if the raycast should hit */ + collideWithTileRaycast(tileData, pos) { return tileData > 0; } + + /** Called to check if a object collision should be resolved + * @param {EngineObject} object - the object to test against + * @return {Boolean} - true if the collision should be resolved + */ + collideWithObject(o) { return 1; } + + /** How long since the object was created + * @return {Number} */ + getAliveTime() { return time - this.spawnTime; } + + /** Apply acceleration to this object (adjust velocity, not affected by mass) + * @param {Vector2} acceleration */ + applyAcceleration(a) { if (this.mass) this.velocity = this.velocity.add(a); } + + /** Apply force to this object (adjust velocity, affected by mass) + * @param {Vector2} force */ + applyForce(force) { this.applyAcceleration(force.scale(1/this.mass)); } + + /** Get the direction of the mirror + * @return {Number} -1 if this.mirror is true, or 1 if not mirrored */ + getMirrorSign() { return this.mirror ? -1 : 1; } + + /** Attaches a child to this with a given local transform + * @param {EngineObject} child + * @param {Vector2} [localPos=Vector2()] + * @param {Number} [localAngle=0] */ + addChild(child, localPos=vec2(), localAngle=0) + { + ASSERT(!child.parent && !this.children.includes(child)); + this.children.push(child); + child.parent = this; + child.localPos = localPos.copy(); + child.localAngle = localAngle; + } + + /** Removes a child from this one + * @param {EngineObject} child */ + removeChild(child) + { + ASSERT(child.parent == this && this.children.includes(child)); + this.children.splice(this.children.indexOf(child), 1); + child.parent = 0; + } + + /** Set how this object collides + * @param {Boolean} [collideSolidObjects=1] - Does it collide with solid objects + * @param {Boolean} [isSolid=1] - Does it collide with and block other objects (expensive in large numbers) + * @param {Boolean} [collideTiles=1] - Does it collide with the tile collision */ + setCollision(collideSolidObjects=1, isSolid=1, collideTiles=1) + { + ASSERT(collideSolidObjects || !isSolid); // solid objects must be set to collide + + this.collideSolidObjects = collideSolidObjects; + this.isSolid = isSolid; + this.collideTiles = collideTiles; + } + + /** Returns string containg info about this object for debugging + * @return {String} */ + toString() + { + if (debug) + { + let text = 'type = ' + this.constructor.name; + if (this.pos.x || this.pos.y) + text += '\npos = ' + this.pos; + if (this.velocity.x || this.velocity.y) + text += '\nvelocity = ' + this.velocity; + if (this.size.x || this.size.y) + text += '\nsize = ' + this.size; + if (this.angle) + text += '\nangle = ' + this.angle.toFixed(3); + if (this.color) + text += '\ncolor = ' + this.color; + return text; + } + } +} +/** + * LittleJS Drawing System + *
- Hybrid with both Canvas2D and WebGL available + *
- Super fast tile sheet rendering with WebGL + *
- Can apply rotation, mirror, color and additive color + *
- Many useful utility functions + *
+ *
LittleJS uses a hybrid rendering solution with the best of both Canvas2D and WebGL. + *
There are 3 canvas/contexts available to draw to... + *
- mainCanvas - 2D background canvas, non WebGL stuff like tile layers are drawn here. + *
- glCanvas - Used by the accelerated WebGL batch rendering system. + *
- overlayCanvas - Another 2D canvas that appears on top of the other 2 canvases. + *
+ *
The WebGL rendering system is very fast with some caveats... + *
- The default setup supports only 1 tile sheet, to support more call glCreateTexture and glSetTexture + *
- Switching blend modes (additive) or textures causes another draw call which is expensive in excess + *
- Group additive rendering together using renderOrder to mitigate this issue + *
+ *
The LittleJS rendering solution is intentionally simple, feel free to adjust it for your needs! + * @namespace Draw + */ + +'use strict'; + +/** The primary 2D canvas visible to the user + * @type {HTMLCanvasElement} + * @memberof Draw */ +let mainCanvas; + +/** 2d context for mainCanvas + * @type {CanvasRenderingContext2D} + * @memberof Draw */ +let mainContext; + +/** A canvas that appears on top of everything the same size as mainCanvas + * @type {HTMLCanvasElement} + * @memberof Draw */ +let overlayCanvas; + +/** 2d context for overlayCanvas + * @type {CanvasRenderingContext2D} + * @memberof Draw */ +let overlayContext; + +/** The size of the main canvas (and other secondary canvases) + * @type {Vector2} + * @memberof Draw */ +let mainCanvasSize = vec2(); + +/** Tile sheet for batch rendering system + * @type {CanvasImageSource} + * @memberof Draw */ +const tileImage = new Image; + +// Engine internal variables not exposed to documentation +let tileImageSize, tileImageFixBleed, drawCount; + +/** Convert from screen to world space coordinates + * - if calling outside of render, you may need to manually set mainCanvasSize + * @param {Vector2} screenPos + * @return {Vector2} + * @memberof Draw */ +const screenToWorld = (screenPos)=> +{ + ASSERT(mainCanvasSize.x && mainCanvasSize.y, 'mainCanvasSize is invalid'); + return screenPos.add(vec2(.5)).subtract(mainCanvasSize.scale(.5)).multiply(vec2(1/cameraScale,-1/cameraScale)).add(cameraPos); +} + +/** Convert from world to screen space coordinates + * - if calling outside of render, you may need to manually set mainCanvasSize + * @param {Vector2} worldPos + * @return {Vector2} + * @memberof Draw */ +const worldToScreen = (worldPos)=> +{ + ASSERT(mainCanvasSize.x && mainCanvasSize.y, 'mainCanvasSize is invalid'); + return worldPos.subtract(cameraPos).multiply(vec2(cameraScale,-cameraScale)).add(mainCanvasSize.scale(.5)).subtract(vec2(.5)); +} + +/** Draw textured tile centered in world space, with color applied if using WebGL + * @param {Vector2} pos - Center of the tile in world space + * @param {Vector2} [size=Vector2(1,1)] - Size of the tile in world space + * @param {Number} [tileIndex=-1] - Tile index to use, negative is untextured + * @param {Vector2} [tileSize=tileSizeDefault] - Tile size in source pixels + * @param {Color} [color=Color()] - Color to modulate with + * @param {Number} [angle=0] - Angle to rotate by + * @param {Boolean} [mirror=0] - If true image is flipped along the Y axis + * @param {Color} [additiveColor=Color(0,0,0,0)] - Additive color to be applied + * @param {Boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering + * @memberof Draw */ +function drawTile(pos, size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, color=new Color, angle=0, mirror, + additiveColor=new Color(0,0,0,0), useWebGL=glEnable) +{ + showWatermark && ++drawCount; + if (glEnable && useWebGL) + { + if (tileIndex < 0 || !tileImage.width) + { + // if negative tile index or image not found, force untextured + glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, color.rgbaInt()); + } + else + { + // calculate uvs and render + const cols = tileImageSize.x / tileSize.x |0; + const uvSizeX = tileSize.x / tileImageSize.x; + const uvSizeY = tileSize.y / tileImageSize.y; + const uvX = (tileIndex%cols)*uvSizeX, uvY = (tileIndex/cols|0)*uvSizeY; + + glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle, + uvX + tileImageFixBleed.x, uvY + tileImageFixBleed.y, + uvX - tileImageFixBleed.x + uvSizeX, uvY - tileImageFixBleed.y + uvSizeY, + color.rgbaInt(), additiveColor.rgbaInt()); + } + } + else + { + // normal canvas 2D rendering method (slower) + drawCanvas2D(pos, size, angle, mirror, (context)=> + { + if (tileIndex < 0) + { + // if negative tile index, force untextured + context.fillStyle = color; + context.fillRect(-.5, -.5, 1, 1); + } + else + { + // calculate uvs and render + const cols = tileImageSize.x / tileSize.x |0; + const sX = (tileIndex%cols)*tileSize.x + tileFixBleedScale; + const sY = (tileIndex/cols|0)*tileSize.y + tileFixBleedScale; + const sWidth = tileSize.x - 2*tileFixBleedScale; + const sHeight = tileSize.y - 2*tileFixBleedScale; + context.globalAlpha = color.a; // only alpha is supported + context.drawImage(tileImage, sX, sY, sWidth, sHeight, -.5, -.5, 1, 1); + } + }); + } +} + +/** Draw colored rect centered on pos + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {Color} [color=Color()] + * @param {Number} [angle=0] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function drawRect(pos, size, color, angle, useWebGL) +{ + drawTile(pos, size, -1, tileSizeDefault, color, angle, 0, 0, useWebGL); +} + +/** Draw textured tile centered on pos in screen space + * @param {Vector2} pos - Center of the tile + * @param {Vector2} [size=Vector2(1,1)] - Size of the tile + * @param {Number} [tileIndex=-1] - Tile index to use, negative is untextured + * @param {Vector2} [tileSize=tileSizeDefault] - Tile size in source pixels + * @param {Color} [color=Color()] + * @param {Number} [angle=0] + * @param {Boolean} [mirror=0] + * @param {Color} [additiveColor=Color(0,0,0,0)] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function drawTileScreenSpace(pos, size=vec2(1), tileIndex, tileSize, color, angle, mirror, additiveColor, useWebGL) +{ + drawTile(screenToWorld(pos), size.scale(1/cameraScale), tileIndex, tileSize, color, angle, mirror, additiveColor, useWebGL); +} + +/** Draw colored rectangle in screen space + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {Color} [color=Color()] + * @param {Number} [angle=0] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function drawRectScreenSpace(pos, size, color, angle, useWebGL) +{ + drawTileScreenSpace(pos, size, -1, tileSizeDefault, color, angle, 0, 0, useWebGL); +} + +/** Draw colored line between two points + * @param {Vector2} posA + * @param {Vector2} posB + * @param {Number} [thickness=.1] + * @param {Color} [color=Color()] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function drawLine(posA, posB, thickness=.1, color, useWebGL) +{ + const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2); + const size = vec2(thickness, halfDelta.length()*2); + drawRect(posA.add(halfDelta), size, color, halfDelta.angle(), useWebGL); +} + +/** Draw directly to a 2d canvas context in world space + * @param {Vector2} pos + * @param {Vector2} size + * @param {Number} angle + * @param {Boolean} mirror + * @param {Function} drawFunction + * @param {CanvasRenderingContext2D} [context=mainContext] + * @memberof Draw */ +function drawCanvas2D(pos, size, angle, mirror, drawFunction, context = mainContext) +{ + // create canvas transform from world space to screen space + pos = worldToScreen(pos); + size = size.scale(cameraScale); + context.save(); + context.translate(pos.x+.5|0, pos.y+.5|0); + context.rotate(angle); + context.scale(mirror ? -size.x : size.x, size.y); + drawFunction(context); + context.restore(); +} + +/** Enable normal or additive blend mode + * @param {Boolean} [additive=0] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function setBlendMode(additive, useWebGL=glEnable) +{ + if (glEnable && useWebGL) + glSetBlendMode(additive); + else + mainContext.globalCompositeOperation = additive ? 'lighter' : 'source-over'; +} + +/** Draw text on overlay canvas in world space + * Automatically splits new lines into rows + * @param {String} text + * @param {Vector2} pos + * @param {Number} [size=1] + * @param {Color} [color=Color()] + * @param {Number} [lineWidth=0] + * @param {Color} [lineColor=Color(0,0,0)] + * @param {String} [textAlign='center'] + * @param {String} [font=fontDefault] + * @param {CanvasRenderingContext2D} [context=overlayContext] + * @memberof Draw */ +function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, context=overlayContext) +{ + drawTextScreen(text, worldToScreen(pos), size*cameraScale, color, lineWidth*cameraScale, lineColor, textAlign, font, context); +} + +/** Draw text on overlay canvas in screen space + * Automatically splits new lines into rows + * @param {String} text + * @param {Vector2} pos + * @param {Number} [size=1] + * @param {Color} [color=Color()] + * @param {Number} [lineWidth=0] + * @param {Color} [lineColor=Color(0,0,0)] + * @param {String} [textAlign='center'] + * @param {String} [font=fontDefault] + * @param {CanvasRenderingContext2D} [context=overlayContext] + * @memberof Draw */ +function drawTextScreen(text, pos, size=1, color=new Color, lineWidth=0, lineColor=new Color(0,0,0), textAlign='center', font=fontDefault, context=overlayContext) +{ + context.fillStyle = color; + context.lineWidth = lineWidth; + context.strokeStyle = lineColor; + context.textAlign = textAlign; + context.font = size + 'px '+ font; + context.textBaseline = 'middle'; + context.lineJoin = 'round'; + + pos = pos.copy(); + (text+'').split('\n').forEach(line=> + { + lineWidth && context.strokeText(line, pos.x, pos.y); + context.fillText(line, pos.x, pos.y); + pos.y += size; + }); +} + +/////////////////////////////////////////////////////////////////////////////// + +let engineFontImage; + +/** + * Font Image Object - Draw text on a 2D canvas by using characters in an image + *
- 96 characters (from space to tilde) are stored in an image + *
- Uses a default 8x8 font if none is supplied + *
- You can also use fonts from the main tile sheet + * @example + * // use built in font + * const font = new ImageFont; + * + * // draw text + * font.drawTextScreen("LittleJS\nHello World!", vec2(200, 50)); + */ +class FontImage +{ + /** Create an image font + * @param {HTMLImageElement} [image] - Image for the font, if undefined default font is used + * @param {Vector2} [tileSize=vec2(8)] - Size of the font source tiles + * @param {Vector2} [paddingSize=vec2(0,1)] - How much extra space to add between characters + * @param {Number} [startTileIndex=0] - Tile index in image where font starts + * @param {CanvasRenderingContext2D} [context=overlayContext] - context to draw to + */ + constructor(image, tileSize=vec2(8), paddingSize=vec2(0,1), startTileIndex=0, context=overlayContext) + { + // load default font image + if (!engineFontImage) + (engineFontImage = new Image).src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAAYAQAAAAA9+x6JAAAAAnRSTlMAAHaTzTgAAAGiSURBVHjaZZABhxxBEIUf6ECLBdFY+Q0PMNgf0yCgsSAGZcT9sgIPtBWwIA5wgAPEoHUyJeeSlW+gjK+fegWwtROWpVQEyWh2npdpBmTUFVhb29RINgLIukoXr5LIAvYQ5ve+1FqWEMqNKTX3FAJHyQDRZvmKWubAACcv5z5Gtg2oyCWE+Yk/8JZQX1jTTCpKAFGIgza+dJCNBF2UskRlsgwitHbSV0QLgt9sTPtsRlvJjEr8C/FARWA2bJ/TtJ7lko34dNDn6usJUMzuErP89UUBJbWeozrwLLncXczd508deAjLWipLO4Q5XGPcJvPu92cNDaN0P5G1FL0nSOzddZOrJ6rNhbXGmeDvO3TF7DeJWl4bvaYQTNHCTeuqKZmbjHaSOFes+IX/+IhHrnAkXOAsfn24EM68XieIECoccD4KZLk/odiwzeo2rovYdhvb2HYFgyznJyDpYJdYOmfXgVdJTaUi4xA2uWYNYec9BLeqdl9EsoTw582mSFDX2DxVLbNt9U3YYoeatBad1c2Tj8t2akrjaIGJNywKB/7h75/gN3vCMSaadIUTAAAAAElFTkSuQmCC'; + + this.image = image || engineFontImage; + this.tileSize = tileSize; + this.paddingSize = paddingSize; + this.startTileIndex = startTileIndex; + this.context = context; + } + + /** Draw text in screen space using the image font + * @param {String} text + * @param {Vector2} pos + * @param {Number} [scale=4] + * @param {Boolean} [center] + */ + drawTextScreen(text, pos, scale=4, center) + { + const context = this.context; + context.save(); + context.imageSmoothingEnabled = !cavasPixelated; + + const size = this.tileSize; + const drawSize = size.add(this.paddingSize).scale(scale); + const cols = this.image.width / this.tileSize.x |0; + (text+'').split('\n').forEach((line, i)=> + { + const centerOffset = center ? line.length * size.x * scale / 2 |0 : 0; + for(let j=line.length; j--;) + { + // draw each character + let charCode = line[j].charCodeAt(); + if (charCode < 32 || charCode > 127) + charCode = 127; // unknown character + + // get the character source location and draw it + const tile = this.startTileIndex + charCode - 32; + const x = tile % cols; + const y = tile / cols |0; + const drawPos = pos.add(vec2(j,i).multiply(drawSize)); + context.drawImage(this.image, x * size.x, y * size.y, size.x, size.y, + drawPos.x - centerOffset, drawPos.y, size.x * scale, size.y * scale); + } + }); + + context.restore(); + } + + /** Draw text in world space using the image font + * @param {String} text + * @param {Vector2} pos + * @param {Number} [scale=.25] + * @param {Boolean} [center] + */ + drawText(text, pos, scale=1, center) + { + this.drawTextScreen(text, worldToScreen(pos).floor(), scale*cameraScale|0, center); + } +} + +/////////////////////////////////////////////////////////////////////////////// +// Fullscreen mode + +/** Returns true if fullscreen mode is active + * @return {Boolean} + * @memberof Draw */ +const isFullscreen = ()=> document.fullscreenElement; + +/** Toggle fullsceen mode + * @memberof Draw */ +function toggleFullscreen() +{ + if (isFullscreen()) + { + if (document.exitFullscreen) + document.exitFullscreen(); + } + else if (document.body.requestFullscreen) + document.body.requestFullscreen(); +} + +/** + * LittleJS Input System + *
- Tracks key down, pressed, and released + *
- Also tracks mouse buttons, position, and wheel + *
- Supports multiple gamepads + *
- Virtual gamepad for touch devices with touchGamepadSize + * @namespace Input + */ + +'use strict'; + +/** Returns true if device key is down + * @param {Number} key + * @param {Number} [device=0] + * @return {Boolean} + * @memberof Input */ +const keyIsDown = (key, device=0)=> inputData[device] && inputData[device][key] & 1; + +/** Returns true if device key was pressed this frame + * @param {Number} key + * @param {Number} [device=0] + * @return {Boolean} + * @memberof Input */ +const keyWasPressed = (key, device=0)=> inputData[device] && inputData[device][key] & 2 ? 1 : 0; + +/** Returns true if device key was released this frame + * @param {Number} key + * @param {Number} [device=0] + * @return {Boolean} + * @memberof Input */ +const keyWasReleased = (key, device=0)=> inputData[device] && inputData[device][key] & 4 ? 1 : 0; + +/** Clears all input + * @memberof Input */ +const clearInput = ()=> inputData = [[]]; + +/** Returns true if mouse button is down + * @function + * @param {Number} button + * @return {Boolean} + * @memberof Input */ +const mouseIsDown = keyIsDown; + +/** Returns true if mouse button was pressed + * @function + * @param {Number} button + * @return {Boolean} + * @memberof Input */ +const mouseWasPressed = keyWasPressed; + +/** Returns true if mouse button was released + * @function + * @param {Number} button + * @return {Boolean} + * @memberof Input */ +const mouseWasReleased = keyWasReleased; + +/** Mouse pos in world space + * @type {Vector2} + * @memberof Input */ +let mousePos = vec2(); + +/** Mouse pos in screen space + * @type {Vector2} + * @memberof Input */ +let mousePosScreen = vec2(); + +/** Mouse wheel delta this frame + * @type {Number} + * @memberof Input */ +let mouseWheel = 0; + +/** Returns true if user is using gamepad (has more recently pressed a gamepad button) + * @type {Boolean} + * @memberof Input */ +let isUsingGamepad = 0; + +/** Prevents input continuing to the default browser handling (false by default) + * @type {Boolean} + * @memberof Input */ +let preventDefaultInput = 0; + +/** Returns true if gamepad button is down + * @param {Number} button + * @param {Number} [gamepad=0] + * @return {Boolean} + * @memberof Input */ +const gamepadIsDown = (button, gamepad=0)=> keyIsDown(button, gamepad+1); + +/** Returns true if gamepad button was pressed + * @param {Number} button + * @param {Number} [gamepad=0] + * @return {Boolean} + * @memberof Input */ +const gamepadWasPressed = (button, gamepad=0)=> keyWasPressed(button, gamepad+1); + +/** Returns true if gamepad button was released + * @param {Number} button + * @param {Number} [gamepad=0] + * @return {Boolean} + * @memberof Input */ +const gamepadWasReleased = (button, gamepad=0)=> keyWasReleased(button, gamepad+1); + +/** Returns gamepad stick value + * @param {Number} stick + * @param {Number} [gamepad=0] + * @return {Vector2} + * @memberof Input */ +const gamepadStick = (stick, gamepad=0)=> stickData[gamepad] ? stickData[gamepad][stick] || vec2() : vec2(); + +/////////////////////////////////////////////////////////////////////////////// +// Input update called by engine + +// store input as a bit field for each key: 1 = isDown, 2 = wasPressed, 4 = wasReleased +// mouse and keyboard are stored together in device 0, gamepads are in devices > 0 +let inputData = [[]]; + +function inputUpdate() +{ + // clear input when lost focus (prevent stuck keys) + isTouchDevice || document.hasFocus() || clearInput(); + + // update mouse world space position + mousePos = screenToWorld(mousePosScreen); + + // update gamepads if enabled + gamepadsUpdate(); +} + +function inputUpdatePost() +{ + // clear input to prepare for next frame + for (const deviceInputData of inputData) + for (const i in deviceInputData) + deviceInputData[i] &= 1; + mouseWheel = 0; +} + +/////////////////////////////////////////////////////////////////////////////// +// Keyboard event handlers + +onkeydown = (e)=> +{ + if (debug && e.target != document.body) return; + e.repeat || (inputData[isUsingGamepad = 0][remapKey(e.which)] = 3); + preventDefaultInput && e.preventDefault(); +} +onkeyup = (e)=> +{ + if (debug && e.target != document.body) return; + inputData[0][remapKey(e.which)] = 4; +} +const remapKey = (c)=> inputWASDEmulateDirection ? c==87?38 : c==83?40 : c==65?37 : c==68?39 : c : c; + +/////////////////////////////////////////////////////////////////////////////// +// Mouse event handlers + +onmousedown = (e)=> {inputData[isUsingGamepad = 0][e.button] = 3; onmousemove(e); e.button && e.preventDefault();} +onmouseup = (e)=> inputData[0][e.button] = inputData[0][e.button] & 2 | 4; +onmousemove = (e)=> mousePosScreen = mouseToScreen(e); +onwheel = (e)=> e.ctrlKey || (mouseWheel = sign(e.deltaY)); +oncontextmenu = (e)=> !1; // prevent right click menu + +// convert a mouse or touch event position to screen space +const mouseToScreen = (mousePos)=> +{ + if (!mainCanvas) + return vec2(); // fix bug that can occur if user clicks before page loads + + const rect = mainCanvas.getBoundingClientRect(); + return vec2(mainCanvas.width, mainCanvas.height).multiply( + vec2(percent(mousePos.x, rect.left, rect.right), percent(mousePos.y, rect.top, rect.bottom))); +} + +/////////////////////////////////////////////////////////////////////////////// +// Gamepad input + +const stickData = []; +function gamepadsUpdate() +{ + if (touchGamepadEnable && touchGamepadTimer.isSet()) + { + // read virtual analog stick + const sticks = stickData[0] || (stickData[0] = []); + sticks[0] = vec2(touchGamepadStick.x, -touchGamepadStick.y); // flip vertical + + // read virtual gamepad buttons + const data = inputData[1] || (inputData[1] = []); + for (let i=10; i--;) + { + const j = i == 3 ? 2 : i == 2 ? 3 : i; // fix button locations + data[j] = touchGamepadButtons[i] ? 1 + 2*!gamepadIsDown(j,0) : 4*gamepadIsDown(j,0); + } + } + + if (!gamepadsEnable || !navigator || !navigator.getGamepads || !document.hasFocus() && !debug) + return; + + // poll gamepads + const gamepads = navigator.getGamepads(); + for (let i = gamepads.length; i--;) + { + // get or create gamepad data + const gamepad = gamepads[i]; + const data = inputData[i+1] || (inputData[i+1] = []); + const sticks = stickData[i] || (stickData[i] = []); + + if (gamepad) + { + // read clamp dead zone of analog sticks + const deadZone = .3, deadZoneMax = .8; + const applyDeadZone = (v)=> + v > deadZone ? percent( v, deadZone, deadZoneMax) : + v < -deadZone ? -percent(-v, deadZone, deadZoneMax) : 0; + + // read analog sticks + for (let j = 0; j < gamepad.axes.length-1; j+=2) + sticks[j>>1] = vec2(applyDeadZone(gamepad.axes[j]), applyDeadZone(-gamepad.axes[j+1])).clampLength(); + + // read buttons + for (let j = gamepad.buttons.length; j--;) + { + const button = gamepad.buttons[j]; + data[j] = button.pressed ? 1 + 2*!gamepadIsDown(j,i) : 4*gamepadIsDown(j,i); + isUsingGamepad |= !i && button.pressed; + touchGamepadEnable && touchGamepadTimer.unset(); // disable touch gamepad if using real gamepad + } + + if (gamepadDirectionEmulateStick) + { + // copy dpad to left analog stick when pressed + const dpad = vec2(gamepadIsDown(15,i) - gamepadIsDown(14,i), gamepadIsDown(12,i) - gamepadIsDown(13,i)); + if (dpad.lengthSquared()) + sticks[0] = dpad.clampLength(); + } + } + } +} + +/////////////////////////////////////////////////////////////////////////////// + +/** Pulse the vibration hardware if it exists + * @param {Number} [pattern=100] - a single value in miliseconds or vibration interval array + * @memberof Input */ +const vibrate = (pattern)=> vibrateEnable && navigator && navigator.vibrate && navigator.vibrate(pattern); + +/** Cancel any ongoing vibration + * @memberof Input */ +const vibrateStop = ()=> vibrate(0); + +/////////////////////////////////////////////////////////////////////////////// +// Touch input + +/** True if a touch device has been detected + * @memberof Input */ +const isTouchDevice = window.ontouchstart !== undefined; + +// try to enable touch mouse +if (isTouchDevice) +{ + // override mouse events + let wasTouching, mouseDown = onmousedown, mouseUp = onmouseup; + onmousedown = onmouseup = ()=> 0; + + // setup touch input + ontouchstart = (e)=> + { + // fix mobile audio, force it to play a sound on first touch + zzfx(0); + + // handle all touch events the same way + ontouchstart = ontouchmove = ontouchend = (e)=> + { + e.button = 0; // all touches are left click + + // check if touching and pass to mouse events + const touching = e.touches.length; + if (touching) + { + // set event pos and pass it along + e.x = e.touches[0].clientX; + e.y = e.touches[0].clientY; + wasTouching ? onmousemove(e) : mouseDown(e); + } + else if (wasTouching) + mouseUp(e); + + // set was touching + wasTouching = touching; + + // must return true so the document will get focus + return true; + } + + return ontouchstart(e); + } +} + +/////////////////////////////////////////////////////////////////////////////// +// touch gamepad, virtual on screen gamepad emulator for touch devices + +// touch input internal variables +let touchGamepadTimer = new Timer, touchGamepadButtons, touchGamepadStick; + +// create the touch gamepad, called automatically by the engine +function touchGamepadCreate() +{ + if (!touchGamepadEnable || !isTouchDevice) + return; + + // touch input internal variables + touchGamepadButtons = []; + touchGamepadStick = vec2(); + + // setup touch input + ontouchstart = (e)=> + { + // fix mobile audio, force it to play a sound on first touch + zzfx(0); + + ontouchstart = ontouchmove = ontouchend = (e)=> + { + // clear touch gamepad input + touchGamepadStick = vec2(); + touchGamepadButtons = []; + + const touching = e.touches.length; + if (touching) + { + // set that gamepad is active + isUsingGamepad = 1; + touchGamepadTimer.set(); + + if (paused) + { + // touch anywhere to press start when paused + touchGamepadButtons[9] = 1; + return; + } + } + + // get center of left and right sides + const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize); + const buttonCenter = mainCanvasSize.subtract(vec2(touchGamepadSize, touchGamepadSize)); + const startCenter = mainCanvasSize.scale(.5); + + // check each touch point + for (const touch of e.touches) + { + const touchPos = mouseToScreen(vec2(touch.clientX, touch.clientY)); + if (touchPos.distance(stickCenter) < touchGamepadSize) + { + // virtual analog stick + if (touchGamepadAnalog) + touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize).clampLength(); + else + { + // 8 way dpad + const angle = touchPos.subtract(stickCenter).angle(); + touchGamepadStick.setAngle((angle * 4 / PI + 8.5 | 0) * PI / 4); + } + } + else if (touchPos.distance(buttonCenter) < touchGamepadSize) + { + // virtual face buttons + const button = touchPos.subtract(buttonCenter).direction(); + touchGamepadButtons[button] = 1; + } + else if (touchPos.distance(startCenter) < touchGamepadSize) + { + // virtual start button in center + touchGamepadButtons[9] = 1; + } + } + } + + return ontouchstart(e); + } +} + +// render the touch gamepad, called automatically by the engine +function touchGamepadRender() +{ + if (!touchGamepadEnable || !touchGamepadTimer.isSet()) + return; + + // fade off when not touching or paused + const alpha = percent(touchGamepadTimer, 4, 3); + if (!alpha || paused) + return; + + // setup the canvas + overlayContext.save(); + overlayContext.globalAlpha = alpha*touchGamepadAlpha; + overlayContext.strokeStyle = '#fff'; + overlayContext.lineWidth = 3; + + // draw left analog stick + overlayContext.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000'; + overlayContext.beginPath(); + + const leftCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize); + if (touchGamepadAnalog) + { + overlayContext.arc(leftCenter.x, leftCenter.y, touchGamepadSize/2, 0, 9); + overlayContext.fill(); + overlayContext.stroke(); + } + else // draw cross shaped gamepad + { + for(let i=10; i--;) + { + const angle = i*PI/4; + overlayContext.arc(leftCenter.x, leftCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8); + i%2 && overlayContext.arc(leftCenter.x, leftCenter.y, touchGamepadSize*.33, angle, angle); + i==1 && overlayContext.fill(); + } + overlayContext.stroke(); + } + + // draw right face buttons + const rightCenter = vec2(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize); + for (let i=4; i--;) + { + const pos = rightCenter.add((new Vector2).setAngle(i*PI/2, touchGamepadSize/2)); + overlayContext.fillStyle = touchGamepadButtons[i] ? '#fff' : '#000'; + overlayContext.beginPath(); + overlayContext.arc(pos.x, pos.y, touchGamepadSize/4, 0,9); + overlayContext.fill(); + overlayContext.stroke(); + } + + // set canvas back to normal + overlayContext.restore(); +} +/** + * LittleJS Audio System + *
- ZzFX Sound Effects + *
- ZzFXM Music + *
- Caches sounds and music for fast playback + *
- Can attenuate and apply stereo panning to sounds + *
- Ability to play mp3, ogg, and wave files + *
- Speech synthesis wrapper functions + */ + +'use strict'; + +/** + * Sound Object - Stores a zzfx sound for later use and can be played positionally + *
+ *
Create sounds using the ZzFX Sound Designer. + * @example + * // create a sound + * const sound_example = new Sound([.5,.5]); + * + * // play the sound + * sound_example.play(); + */ +class Sound +{ + /** Create a sound object and cache the zzfx samples for later use + * @param {Array} zzfxSound - Array of zzfx parameters, ex. [.5,.5] + * @param {Number} [range=soundDefaultRange] - World space max range of sound, will not play if camera is farther away + * @param {Number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering off + */ + constructor(zzfxSound, range=soundDefaultRange, taper=soundDefaultTaper) + { + if (!soundEnable) return; + + /** @property {Number} - World space max range of sound, will not play if camera is farther away */ + this.range = range; + + /** @property {Number} - At what percentage of range should it start tapering off */ + this.taper = taper; + + // get randomness from sound parameters + this.randomness = zzfxSound[1] || 0; + zzfxSound[1] = 0; + + // generate sound now for fast playback + this.cachedSamples = zzfxG(...zzfxSound); + } + + /** Play the sound + * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null + * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) + * @param {Number} [pitch=1] - How much to scale pitch by (also adjusted by this.randomness) + * @param {Number} [randomnessScale=1] - How much to scale randomness + * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later + */ + play(pos, volume=1, pitch=1, randomnessScale=1) + { + if (!soundEnable) return; + + let pan; + if (pos) + { + const range = this.range; + if (range) + { + // apply range based fade + const lengthSquared = cameraPos.distanceSquared(pos); + if (lengthSquared > range*range) + return; // out of range + + // attenuate volume by distance + volume *= percent(lengthSquared**.5, range, range*this.taper); + } + + // get pan from screen space coords + pan = worldToScreen(pos).x * 2/mainCanvas.width - 1; + } + + // play the sound + const playbackRate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1); + return playSamples([this.cachedSamples], volume, playbackRate, pan); + } + + /** Play the sound as a note with a semitone offset + * @param {Number} semitoneOffset - How many semitones to offset pitch + * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null + * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) + * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later + */ + playNote(semitoneOffset, pos, volume) + { + if (!soundEnable) return; + + return this.play(pos, volume, 2**(semitoneOffset/12), 0); + } +} + +/** + * Music Object - Stores a zzfx music track for later use + *
+ *
Create music with the ZzFXM tracker. + * @example + * // create some music + * const music_example = new Music( + * [ + * [ // instruments + * [,0,400] // simple note + * ], + * [ // patterns + * [ // pattern 1 + * [ // channel 0 + * 0, -1, // instrument 0, left speaker + * 1, 0, 9, 1 // channel notes + * ], + * [ // channel 1 + * 0, 1, // instrument 1, right speaker + * 0, 12, 17, -1 // channel notes + * ] + * ], + * ], + * [0, 0, 0, 0], // sequence, play pattern 0 four times + * 90 // BPM + * ]); + * + * // play the music + * music_example.play(); + */ +class Music +{ + /** Create a music object and cache the zzfx music samples for later use + * @param {Array} zzfxMusic - Array of zzfx music parameters + */ + constructor(zzfxMusic) + { + if (!soundEnable) return; + + this.cachedSamples = zzfxM(...zzfxMusic); + } + + /** Play the music + * @param {Number} [volume=1] - How much to scale volume by + * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end + * @return {AudioBufferSourceNode} - The audio node, can be used to stop sound later + */ + play(volume, loop = 1) + { + if (!soundEnable) return; + + return this.source = playSamples(this.cachedSamples, volume, 1, 0, loop); + } + + /** Stop the music */ + stop() + { + if (this.source) + this.source.stop(); + this.source = 0; + } + + /** Check if music is playing + * @return {Boolean} + */ + isPlaying() { return this.source; } +} + +/** Play an mp3 or wav audio from a local file or url + * @param {String} url - Location of sound file to play + * @param {Number} [volume=1] - How much to scale volume by + * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end + * @return {HTMLAudioElement} - The audio element for this sound + * @memberof Audio */ +function playAudioFile(url, volume=1, loop=1) +{ + if (!soundEnable) return; + + const audio = new Audio(url); + audio.volume = soundVolume * volume; + audio.loop = loop; + audio.play(); + return audio; +} + +/** Speak text with passed in settings + * @param {String} text - The text to speak + * @param {String} [language] - The language/accent to use (examples: en, it, ru, ja, zh) + * @param {Number} [volume=1] - How much to scale volume by + * @param {Number} [rate=1] - How quickly to speak + * @param {Number} [pitch=1] - How much to change the pitch by + * @return {SpeechSynthesisUtterance} - The utterance that was spoken + * @memberof Audio */ +function speak(text, language='', volume=1, rate=1, pitch=1) +{ + if (!soundEnable || !speechSynthesis) return; + + // common languages (not supported by all browsers) + // en - english, it - italian, fr - french, de - german, es - spanish + // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean + + // build utterance and speak + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = language; + utterance.volume = 2*volume*soundVolume; + utterance.rate = rate; + utterance.pitch = pitch; + speechSynthesis.speak(utterance); + return utterance; +} + +/** Stop all queued speech + * @memberof Audio */ +const speakStop = ()=> speechSynthesis && speechSynthesis.cancel(); + +/** Get frequency of a note on a musical scale + * @param {Number} semitoneOffset - How many semitones away from the root note + * @param {Number} [rootNoteFrequency=220] - Frequency at semitone offset 0 + * @return {Number} - The frequency of the note + * @memberof Audio */ +const getNoteFrequency = (semitoneOffset, rootFrequency=220)=> rootFrequency * 2**(semitoneOffset/12); + +/////////////////////////////////////////////////////////////////////////////// + +/** Audio context used by the engine + * @memberof Audio */ +let audioContext; + +/** Play cached audio samples with given settings + * @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback) + * @param {Number} [volume=1] - How much to scale volume by + * @param {Number} [rate=1] - The playback rate to use + * @param {Number} [pan=0] - How much to apply stereo panning + * @param {Boolean} [loop=0] - True if the sound should loop when it reaches the end + * @return {AudioBufferSourceNode} - The audio node of the sound played + * @memberof Audio */ +function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=0) +{ + if (!soundEnable) return; + + // create audio context + if (!audioContext) + audioContext = new AudioContext; + + // fix stalled audio + audioContext.resume(); + + // prevent sounds from building up if they can't be played + if (audioContext.state != 'running') + return; + + // create buffer and source + const buffer = audioContext.createBuffer(sampleChannels.length, sampleChannels[0].length, zzfxR), + source = audioContext.createBufferSource(); + + // copy samples to buffer and setup source + sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c)); + source.buffer = buffer; + source.playbackRate.value = rate; + source.loop = loop; + + // create and connect gain node (createGain is more widely spported then GainNode construtor) + const gainNode = audioContext.createGain(); + gainNode.gain.value = soundVolume*volume; + gainNode.connect(audioContext.destination); + + // connect source to stereo panner and gain + source.connect(new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)})).connect(gainNode); + + // play and return sound + source.start(); + return source; +} + +/////////////////////////////////////////////////////////////////////////////// +// ZzFXMicro - Zuper Zmall Zound Zynth - v1.1.8 by Frank Force + +/** Generate and play a ZzFX sound + *
+ *
Create sounds using the ZzFX Sound Designer. + * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5] + * @return {Array} - Array of audio samples + * @memberof Audio */ +const zzfx = (...zzfxSound) => playSamples([zzfxG(...zzfxSound)]); + +/** Sample rate used for all ZzFX sounds + * @default 44100 + * @memberof Audio */ +const zzfxR = 44100; + +/** Generate samples for a ZzFX sound + * @memberof Audio */ +function zzfxG +( + // parameters + volume = 1, randomness = .05, frequency = 220, attack = 0, sustain = 0, + release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0, + pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0, + bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0 +) +{ + // init parameters + let PI2 = PI*2, startSlide = slide *= 500 * PI2 / zzfxR / zzfxR, b=[], + startFrequency = frequency *= (1 + randomness*rand(-1,1)) * PI2 / zzfxR, + t=0, tm=0, i=0, j=1, r=0, c=0, s=0, f, length; + + // scale by sample rate + attack = attack * zzfxR + 9; // minimum attack to prevent pop + decay *= zzfxR; + sustain *= zzfxR; + release *= zzfxR; + delay *= zzfxR; + deltaSlide *= 500 * PI2 / zzfxR**3; + modulation *= PI2 / zzfxR; + pitchJump *= PI2 / zzfxR; + pitchJumpTime *= zzfxR; + repeatTime = repeatTime * zzfxR | 0; + + // generate waveform + for (length = attack + decay + sustain + release + delay | 0; + i < length; b[i++] = s) + { + if (!(++c%(bitCrush*100|0))) // bit crush + { + s = shape? shape>1? shape>2? shape>3? // wave shape + Math.sin((t%PI2)**3) : // 4 noise + max(min(Math.tan(t),1),-1): // 3 tan + 1-(2*t/PI2%2+2)%2: // 2 saw + 1-4*abs(Math.round(t/PI2)-t/PI2): // 1 triangle + Math.sin(t); // 0 sin + + s = (repeatTime ? + 1 - tremolo + tremolo*Math.sin(PI2*i/repeatTime) // tremolo + : 1) * + sign(s)*(abs(s)**shapeCurve) * // curve 0=square, 2=pointy + volume * soundVolume * ( // envelope + i < attack ? i/attack : // attack + i < attack + decay ? // decay + 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff + i < attack + decay + sustain ? // sustain + sustainVolume : // sustain volume + i < length - delay ? // release + (length - i - delay)/release * // release falloff + sustainVolume : // release volume + 0); // post release + + s = delay ? s/2 + (delay > i ? 0 : // delay + (i pitchJumpTime) // pitch jump + { + frequency += pitchJump; // apply pitch jump + startFrequency += pitchJump; // also apply to start + j = 0; // reset pitch jump time + } + + if (repeatTime && !(++r % repeatTime)) // repeat + { + frequency = startFrequency; // reset frequency + slide = startSlide; // reset slide + j = j || 1; // reset pitch jump time + } + } + + return b; +} + +/////////////////////////////////////////////////////////////////////////////// +// ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force + +/** Generate samples for a ZzFM song with given parameters + * @param {Array} instruments - Array of ZzFX sound paramaters + * @param {Array} patterns - Array of pattern data + * @param {Array} sequence - Array of pattern indexes + * @param {Number} [BPM=125] - Playback speed of the song in BPM + * @returns {Array} - Left and right channel sample data + * @memberof Audio */ +function zzfxM(instruments, patterns, sequence, BPM = 125) +{ + let i, j, k; + let instrumentParameters; + let note; + let sample; + let patternChannel; + let notFirstBeat; + let stop; + let instrument; + let attenuation; + let outSampleOffset; + let isSequenceEnd; + let sampleOffset = 0; + let nextSampleOffset; + let sampleBuffer = []; + let leftChannelBuffer = []; + let rightChannelBuffer = []; + let channelIndex = 0; + let panning = 0; + let hasMore = 1; + let sampleCache = {}; + let beatLength = zzfxR / BPM * 60 >> 2; + + // for each channel in order until there are no more + for (; hasMore; channelIndex++) { + + // reset current values + sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0]; + + // for each pattern in sequence + sequence.forEach((patternIndex, sequenceIndex) => { + // get pattern for current channel, use empty 1 note pattern if none found + patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0]; + + // check if there are more channels + hasMore |= !!patterns[patternIndex][channelIndex]; + + // get next offset, use the length of first channel + nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - !notFirstBeat) * beatLength; + // for each beat in pattern, plus one extra if end of sequence + isSequenceEnd = sequenceIndex == sequence.length - 1; + for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) { + + // + note = patternChannel[i]; + + // stop if end, different instrument or new note + stop = i == patternChannel.length + isSequenceEnd - 1 && isSequenceEnd || + instrument != (patternChannel[0] || 0) | note | 0; + + // fill buffer with samples for previous beat, most cpu intensive part + for (j = 0; j < beatLength && notFirstBeat; + + // fade off attenuation at end of beat if stopping note, prevents clicking + j++ > beatLength - 99 && stop ? attenuation += (attenuation < 1) / 99 : 0 + ) { + // copy sample to stereo buffers with panning + sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0; + leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample; + rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample; + } + + // set up for next note + if (note) { + // set attenuation + attenuation = note % 1; + panning = patternChannel[1] || 0; + if (note |= 0) { + // get cached sample + sampleBuffer = sampleCache[ + [ + instrument = patternChannel[sampleOffset = 0] || 0, + note + ] + ] = sampleCache[[instrument, note]] || ( + // add sample to cache + instrumentParameters = [...instruments[instrument]], + instrumentParameters[2] *= 2 ** ((note - 12) / 12), + + // allow negative values to stop notes + note > 0 ? zzfxG(...instrumentParameters) : [] + ); + } + } + } + + // update the sample offset + outSampleOffset = nextSampleOffset; + }); + } + + return [leftChannelBuffer, rightChannelBuffer]; +} +/** + * LittleJS Tile Layer System + *
- Caches arrays of tiles to off screen canvas for fast rendering + *
- Unlimted numbers of layers, allocates canvases as needed + *
- Interfaces with EngineObject for collision + *
- Collision layer is separate from visible layers + *
- It is recommended to have a visible layer that matches the collision + *
- Tile layers can be drawn to using their context with canvas2d + *
- Drawn directly to the main canvas without using WebGL + * @namespace TileCollision + */ + +'use strict'; + +/** The tile collision layer array, use setTileCollisionData and getTileCollisionData to access + * @type {Array} + * @memberof TileCollision */ +let tileCollision = []; + +/** Size of the tile collision layer + * @type {Vector2} + * @memberof TileCollision */ +let tileCollisionSize = vec2(); + +/** Clear and initialize tile collision + * @param {Vector2} size + * @memberof TileCollision */ +function initTileCollision(size) +{ + tileCollisionSize = size; + tileCollision = []; + for (let i=tileCollision.length = tileCollisionSize.area(); i--;) + tileCollision[i] = 0; +} + +/** Set tile collision data + * @param {Vector2} pos + * @param {Number} [data=0] + * @memberof TileCollision */ +const setTileCollisionData = (pos, data=0)=> + pos.arrayCheck(tileCollisionSize) && (tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] = data); + +/** Get tile collision data + * @param {Vector2} pos + * @return {Number} + * @memberof TileCollision */ +const getTileCollisionData = (pos)=> + pos.arrayCheck(tileCollisionSize) ? tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] : 0; + +/** Check if collision with another object should occur + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {EngineObject} [object] + * @return {Boolean} + * @memberof TileCollision */ +function tileCollisionTest(pos, size=vec2(), object) +{ + const minX = max(pos.x - size.x/2|0, 0); + const minY = max(pos.y - size.y/2|0, 0); + const maxX = min(pos.x + size.x/2, tileCollisionSize.x); + const maxY = min(pos.y + size.y/2, tileCollisionSize.y); + for (let y = minY; y < maxY; ++y) + for (let x = minX; x < maxX; ++x) + { + const tileData = tileCollision[y*tileCollisionSize.x+x]; + if (tileData && (!object || object.collideWithTile(tileData, new Vector2(x, y)))) + return 1; + } +} + +/** Return the center of tile if any that is hit (this does not return the exact hit point) + * @param {Vector2} posStart + * @param {Vector2} posEnd + * @param {EngineObject} [object] + * @return {Vector2} + * @memberof TileCollision */ +function tileCollisionRaycast(posStart, posEnd, object) +{ + // test if a ray collides with tiles from start to end + // todo: a way to get the exact hit point, it must still register as inside the hit tile + const posDelta = (posEnd = posEnd.floor()).subtract(posStart = posStart.floor()); + const dx = abs(posDelta.x), dy = -abs(posDelta.y); + const sx = sign(posDelta.x), sy = sign(posDelta.y); + + for (let x = posStart.x, y = posStart.y, e = dx + dy;;) + { + const tileData = getTileCollisionData(vec2(x,y)); + if (tileData && (object ? object.collideWithTileRaycast(tileData, new Vector2(x, y)) : tileData > 0)) + { + debugRaycast && debugLine(posStart, posEnd, '#f00',.02, 1); + debugRaycast && debugPoint(new Vector2(x+.5, y+.5), '#ff0', 1); + return new Vector2(x+.5, y+.5); + } + + // update Bresenham line drawing algorithm + if (x == posEnd.x & y == posEnd.y) break; + const e2 = 2*e; + if (e2 >= dy) e += dy, x += sx; + if (e2 <= dx) e += dx, y += sy; + } + debugRaycast && debugLine(posStart, posEnd, '#00f',.02, 1); +} + +/////////////////////////////////////////////////////////////////////////////// +// Tile Layer Rendering System + +/** + * Tile layer data object stores info about how to render a tile + * @example + * // create tile layer data with tile index 0 and random orientation and color + * const tileIndex = 0; + * const direction = randInt(4) + * const mirror = randInt(2); + * const color = randColor(); + * const data = new TileLayerData(tileIndex, direction, mirror, color); + */ +class TileLayerData +{ + /** Create a tile layer data object, one for each tile in a TileLayer + * @param {Number} [tile] - The tile to use, untextured if undefined + * @param {Number} [direction=0] - Integer direction of tile, in 90 degree increments + * @param {Boolean} [mirror=0] - If the tile should be mirrored along the x axis + * @param {Color} [color=Color()] - Color of the tile */ + constructor(tile, direction=0, mirror=0, color=new Color()) + { + /** @property {Number} - The tile to use, untextured if undefined */ + this.tile = tile; + /** @property {Number} - Integer direction of tile, in 90 degree increments */ + this.direction = direction; + /** @property {Boolean} - If the tile should be mirrored along the x axis */ + this.mirror = mirror; + /** @property {Color} - Color of the tile */ + this.color = color; + } + + /** Set this tile to clear, it will not be rendered */ + clear() { this.tile = this.direction = this.mirror = 0; color = new Color; } +} + +/** + * Tile layer object - cached rendering system for tile layers + *
- Each Tile layer is rendered to an off screen canvas + *
- To allow dynamic modifications, layers are rendered using canvas 2d + *
- Some devices like mobile phones are limited to 4k texture resolution + *
- So with 16x16 tiles this limits layers to 256x256 on mobile devices + * @extends EngineObject + * @example + * // create tile collision and visible tile layer + * initTileCollision(vec2(200,100)); + * const tileLayer = new TileLayer(); + */ +class TileLayer extends EngineObject +{ +/** Create a tile layer object + * @param {Vector2} [position=Vector2()] - World space position + * @param {Vector2} [size=tileCollisionSize] - World space size + * @param {Vector2} [tileSize=tileSizeDefault] - Size of tiles in source pixels + * @param {Vector2} [scale=Vector2(1,1)] - How much to scale this layer when rendered + * @param {Number} [renderOrder=0] - Objects sorted by renderOrder before being rendered + */ +constructor(pos, size=tileCollisionSize, tileSize=tileSizeDefault, scale=vec2(1), renderOrder=0) + { + super(pos, size, -1, tileSize, 0, undefined, renderOrder); + + /** @property {HTMLCanvasElement} - The canvas used by this tile layer */ + this.canvas = document.createElement('canvas'); + /** @property {CanvasRenderingContext2D} - The 2D canvas context used by this tile layer */ + this.context = this.canvas.getContext('2d'); + /** @property {Vector2} - How much to scale this layer when rendered */ + this.scale = scale; + /** @property {Boolean} [isOverlay=0] - If true this layer will render to overlay canvas and appear above all objects */ + this.isOverlay; + + // init tile data + this.data = []; + for (let j = this.size.area(); j--;) + this.data.push(new TileLayerData); + } + + /** Set data at a given position in the array + * @param {Vector2} position - Local position in array + * @param {TileLayerData} data - Data to set + * @param {Boolean} [redraw=0] - Force the tile to redraw if true */ + setData(layerPos, data, redraw) + { + if (layerPos.arrayCheck(this.size)) + { + this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data; + redraw && this.drawTileData(layerPos); + } + } + + /** Get data at a given position in the array + * @param {Vector2} layerPos - Local position in array + * @return {TileLayerData} */ + getData(layerPos) + { return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0]; } + + // Tile layers are not updated + update() {} + + // Render the tile layer, called automatically by the engine + render() + { + ASSERT(mainContext != this.context); // must call redrawEnd() after drawing tiles + + // flush and copy gl canvas because tile canvas does not use webgl + glEnable && !glOverlay && !this.isOverlay && glCopyToContext(mainContext); + + // draw the entire cached level onto the canvas + const pos = worldToScreen(this.pos.add(vec2(0,this.size.y*this.scale.y))); + (this.isOverlay ? overlayContext : mainContext).drawImage + ( + this.canvas, pos.x, pos.y, + cameraScale*this.size.x*this.scale.x, cameraScale*this.size.y*this.scale.y + ); + } + + /** Draw all the tile data to an offscreen canvas + * - This may be slow in some browsers + */ + redraw() + { + this.redrawStart(1); + this.drawAllTileData(); + this.redrawEnd(); + } + + /** Call to start the redraw process + * @param {Boolean} [clear=0] - Should it clear the canvas before drawing */ + redrawStart(clear = 0) + { + // save current render settings + this.savedRenderSettings = [mainCanvas, mainContext, mainCanvasSize, cameraPos, cameraScale]; + + // hack: use normal rendering system to render the tiles + mainCanvas = this.canvas; + mainContext = this.context; + cameraPos = this.size.scale(.5); + cameraScale = this.tileSize.x; + + if (clear) + { + // clear and set size + mainCanvas.width = this.size.x * this.tileSize.x; + mainCanvas.height = this.size.y * this.tileSize.y; + } + + // begin a new render for the tile canvas + enginePreRender(); + } + + /** Call to end the redraw process */ + redrawEnd() + { + ASSERT(mainContext == this.context); // must call redrawStart() before drawing tiles + glEnable && glCopyToContext(mainContext, 1); + //debugSaveCanvas(this.canvas); + + // set stuff back to normal + [mainCanvas, mainContext, mainCanvasSize, cameraPos, cameraScale] = this.savedRenderSettings; + } + + /** Draw the tile at a given position + * @param {Vector2} layerPos */ + drawTileData(layerPos) + { + // first clear out where the tile was + const pos = layerPos.floor().add(this.pos).add(vec2(.5)); + this.drawCanvas2D(pos, vec2(1), 0, 0, (context)=>context.clearRect(-.5, -.5, 1, 1)); + + // draw the tile if not undefined + const d = this.getData(layerPos); + if (d.tile != undefined) + { + ASSERT(mainContext == this.context); // must call redrawStart() before drawing tiles + drawTile(pos, vec2(1), d.tile, this.tileSize, d.color, d.direction*PI/2, d.mirror); + } + } + + /** Draw all the tiles in this layer */ + drawAllTileData() + { + for (let x = this.size.x; x--;) + for (let y = this.size.y; y--;) + this.drawTileData(vec2(x,y)); + } + + /** Draw directly to the 2D canvas in world space (bipass webgl) + * @param {Vector2} pos + * @param {Vector2} size + * @param {Number} [angle=0] + * @param {Boolean} [mirror=0] + * @param {Function} drawFunction */ + drawCanvas2D(pos, size, angle=0, mirror, drawFunction) + { + const context = this.context; + context.save(); + pos = pos.subtract(this.pos).multiply(this.tileSize); + size = size.multiply(this.tileSize); + context.translate(pos.x, this.canvas.height - pos.y); + context.rotate(angle); + context.scale(mirror ? -size.x : size.x, size.y); + drawFunction(context); + context.restore(); + } + + /** Draw a tile directly onto the layer canvas + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {Number} [tileIndex=-1] + * @param {Vector2} [tileSize=tileSizeDefault] + * @param {Color} [color=Color()] + * @param {Number} [angle=0] + * @param {Boolean} [mirror=0] */ + drawTile(pos, size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, color=new Color, angle, mirror) + { + this.drawCanvas2D(pos, size, angle, mirror, (context)=> + { + if (tileIndex < 0) + { + // untextured + context.fillStyle = color; + context.fillRect(-.5, -.5, 1, 1); + } + else + { + const cols = tileImage.width/tileSize.x; + context.globalAlpha = color.a; // only alpha, no color, is supported in this mode + context.drawImage(tileImage, + (tileIndex%cols)*tileSize.x, (tileIndex/cols|0)*tileSize.y, + tileSize.x, tileSize.y, -.5, -.5, 1, 1); + } + }); + } + + /** Draw a rectangle directly onto the layer canvas + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {Color} [color=Color()] + * @param {Number} [angle=0] */ + drawRect(pos, size, color, angle) + { this.drawTile(pos, size, -1, 0, color, angle); } +} +/* + LittleJS Particle System + - Spawns particles with randomness from parameters + - Updates particle physics + - Fast particle rendering +*/ + +'use strict'; + +/** + * Particle Emitter - Spawns particles with the given settings + * @extends EngineObject + * @example + * // create a particle emitter + * let pos = vec2(2,3); + * let particleEmiter = new ParticleEmitter + * ( + * pos, 0, 1, 0, 500, PI, // pos, angle, emitSize, emitTime, emitRate, emiteCone + * 0, vec2(16), // tileIndex, tileSize + * new Color(1,1,1), new Color(0,0,0), // colorStartA, colorStartB + * new Color(1,1,1,0), new Color(0,0,0,0), // colorEndA, colorEndB + * 2, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate, + * .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder + * ); + */ +class ParticleEmitter extends EngineObject +{ + /** Create a particle system with the given settings + * @param {Vector2} position - World space position of the emitter + * @param {Number} [angle=0] - Angle to emit the particles + * @param {Number} [emitSize=0] - World space size of the emitter (float for circle diameter, vec2 for rect) + * @param {Number} [emitTime=0] - How long to stay alive (0 is forever) + * @param {Number} [emitRate=100] - How many particles per second to spawn, does not emit if 0 + * @param {Number} [emitConeAngle=PI] - Local angle to apply velocity to particles from emitter + * @param {Number} [tileIndex=-1] - Index into tile sheet, if <0 no texture is applied + * @param {Vector2} [tileSize=tileSizeDefault] - Tile size for particles + * @param {Color} [colorStartA=Color()] - Color at start of life 1, randomized between start colors + * @param {Color} [colorStartB=Color()] - Color at start of life 2, randomized between start colors + * @param {Color} [colorEndA=Color(1,1,1,0)] - Color at end of life 1, randomized between end colors + * @param {Color} [colorEndB=Color(1,1,1,0)] - Color at end of life 2, randomized between end colors + * @param {Number} [particleTime=.5] - How long particles live + * @param {Number} [sizeStart=.1] - How big are particles at start + * @param {Number} [sizeEnd=1] - How big are particles at end + * @param {Number} [speed=.1] - How fast are particles when spawned + * @param {Number} [angleSpeed=.05] - How fast are particles rotating + * @param {Number} [damping=1] - How much to dampen particle speed + * @param {Number} [angleDamping=1] - How much to dampen particle angular speed + * @param {Number} [gravityScale=0] - How much does gravity effect particles + * @param {Number} [particleConeAngle=PI] - Cone for start particle angle + * @param {Number} [fadeRate=.1] - How quick to fade in particles at start/end in percent of life + * @param {Number} [randomness=.2] - Apply extra randomness percent + * @param {Boolean} [collideTiles=0] - Do particles collide against tiles + * @param {Boolean} [additive=0] - Should particles use addtive blend + * @param {Boolean} [randomColorLinear=1] - Should color be randomized linearly or across each component + * @param {Number} [renderOrder=0] - Render order for particles (additive is above other stuff by default) + * @param {Boolean} [localSpace=0] - Should it be in local space of emitter (world space is default) + */ + constructor + ( + pos, + angle, + emitSize = 0, + emitTime = 0, + emitRate = 100, + emitConeAngle = PI, + tileIndex = -1, + tileSize = tileSizeDefault, + colorStartA = new Color, + colorStartB = new Color, + colorEndA = new Color(1,1,1,0), + colorEndB = new Color(1,1,1,0), + particleTime = .5, + sizeStart = .1, + sizeEnd = 1, + speed = .1, + angleSpeed = .05, + damping = 1, + angleDamping = 1, + gravityScale = 0, + particleConeAngle = PI, + fadeRate = .1, + randomness = .2, + collideTiles, + additive, + randomColorLinear = 1, + renderOrder = additive ? 1e9 : 0, + localSpace + ) + { + super(pos, new Vector2, tileIndex, tileSize, angle, undefined, renderOrder); + + // emitter settings + /** @property {Number} - World space size of the emitter (float for circle diameter, vec2 for rect) */ + this.emitSize = emitSize + /** @property {Number} - How long to stay alive (0 is forever) */ + this.emitTime = emitTime; + /** @property {Number} - How many particles per second to spawn, does not emit if 0 */ + this.emitRate = emitRate; + /** @property {Number} - Local angle to apply velocity to particles from emitter */ + this.emitConeAngle = emitConeAngle; + + // color settings + /** @property {Color} - Color at start of life 1, randomized between start colors */ + this.colorStartA = colorStartA; + /** @property {Color} - Color at start of life 2, randomized between start colors */ + this.colorStartB = colorStartB; + /** @property {Color} - Color at end of life 1, randomized between end colors */ + this.colorEndA = colorEndA; + /** @property {Color} - Color at end of life 2, randomized between end colors */ + this.colorEndB = colorEndB; + /** @property {Boolean} - Should color be randomized linearly or across each component */ + this.randomColorLinear = randomColorLinear; + + // particle settings + /** @property {Number} - How long particles live */ + this.particleTime = particleTime; + /** @property {Number} - How big are particles at start */ + this.sizeStart = sizeStart; + /** @property {Number} - How big are particles at end */ + this.sizeEnd = sizeEnd; + /** @property {Number} - How fast are particles when spawned */ + this.speed = speed; + /** @property {Number} - How fast are particles rotating */ + this.angleSpeed = angleSpeed; + /** @property {Number} - How much to dampen particle speed */ + this.damping = damping; + /** @property {Number} - How much to dampen particle angular speed */ + this.angleDamping = angleDamping; + /** @property {Number} - How much does gravity effect particles */ + this.gravityScale = gravityScale; + /** @property {Number} - Cone for start particle angle */ + this.particleConeAngle = particleConeAngle; + /** @property {Number} - How quick to fade in particles at start/end in percent of life */ + this.fadeRate = fadeRate; + /** @property {Number} - Apply extra randomness percent */ + this.randomness = randomness; + /** @property {Number} - Do particles collide against tiles */ + this.collideTiles = collideTiles; + /** @property {Number} - Should particles use addtive blend */ + this.additive = additive; + /** @property {Boolean} - Should it be in local space of emitter */ + this.localSpace = localSpace; + /** @property {Number} - If set the partile is drawn as a trail, stretched in the drection of velocity */ + this.trailScale = 0; + + // internal variables + this.emitTimeBuffer = 0; + } + + /** Update the emitter to spawn particles, called automatically by engine once each frame */ + update() + { + // only do default update to apply parent transforms + this.parent && super.update(); + + // update emitter + if (!this.emitTime | this.getAliveTime() <= this.emitTime) + { + // emit particles + if (this.emitRate * particleEmitRateScale) + { + const rate = 1/this.emitRate/particleEmitRateScale; + for (this.emitTimeBuffer += timeDelta; this.emitTimeBuffer > 0; this.emitTimeBuffer -= rate) + this.emitParticle(); + } + } + else + this.destroy(); + + debugParticles && debugRect(this.pos, vec2(this.emitSize), '#0f0', 0, this.angle); + } + + /** Spawn one particle + * @return {Particle} */ + emitParticle() + { + // spawn a particle + let pos = this.emitSize.x != undefined ? // check if vec2 was used for size + (new Vector2(rand(-.5,.5), rand(-.5,.5))) + .multiply(this.emitSize).rotate(this.angle) // box emitter + : randInCircle(this.emitSize/2); // circle emitter + let angle = rand(this.particleConeAngle, -this.particleConeAngle); + if (!this.localSpace) + { + pos = this.pos.add(pos); + angle += this.angle; + } + + const particle = new Particle(pos, this.tileIndex, this.tileSize, angle); + + // randomness scales each paremeter by a percentage + const randomness = this.randomness; + const randomizeScale = (v)=> v + v*rand(randomness, -randomness); + + // randomize particle settings + const particleTime = randomizeScale(this.particleTime); + const sizeStart = randomizeScale(this.sizeStart); + const sizeEnd = randomizeScale(this.sizeEnd); + const speed = randomizeScale(this.speed); + const angleSpeed = randomizeScale(this.angleSpeed) * randSign(); + const coneAngle = rand(this.emitConeAngle, -this.emitConeAngle); + const colorStart = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear); + const colorEnd = randColor(this.colorEndA, this.colorEndB, this.randomColorLinear); + const velocityAngle = this.localSpace ? coneAngle : this.angle + coneAngle; + + // build particle settings + particle.colorStart = colorStart; + particle.colorEndDelta = colorEnd.subtract(colorStart); + particle.velocity = (new Vector2).setAngle(velocityAngle, speed); + particle.angleVelocity = angleSpeed; + particle.lifeTime = particleTime; + particle.sizeStart = sizeStart; + particle.sizeEndDelta = sizeEnd - sizeStart; + particle.fadeRate = this.fadeRate; + particle.damping = this.damping; + particle.angleDamping = this.angleDamping; + particle.elasticity = this.elasticity; + particle.friction = this.friction; + particle.gravityScale = this.gravityScale; + particle.collideTiles = this.collideTiles; + particle.additive = this.additive; + particle.renderOrder = this.renderOrder; + particle.trailScale = this.trailScale; + particle.mirror = randInt(2); + particle.localSpaceEmitter = this.localSpace && this; + + // setup callbacks for particles + particle.destroyCallback = this.particleDestroyCallback; + this.particleCreateCallback && this.particleCreateCallback(particle); + + // return the newly created particle + return particle; + } + + // Particle emitters are not rendered, only the particles are + render() {} +} + +/////////////////////////////////////////////////////////////////////////////// +/** + * Particle Object - Created automatically by Particle Emitters + * @extends EngineObject + */ +class Particle extends EngineObject +{ + /** + * Create a particle with the given settings + * @param {Vector2} position - World space position of the particle + * @param {Number} [tileIndex=-1] - Tile to use to render, untextured if -1 + * @param {Vector2} [tileSize=tileSizeDefault] - Size of tile in source pixels + * @param {Number} [angle=0] - Angle to rotate the particle + */ + constructor(pos, tileIndex, tileSize, angle) + { super(pos, new Vector2, tileIndex, tileSize, angle); } + + /** Render the particle, automatically called each frame, sorted by renderOrder */ + render() + { + // modulate size and color + const p = min((time - this.spawnTime) / this.lifeTime, 1); + const radius = this.sizeStart + p * this.sizeEndDelta; + const size = new Vector2(radius, radius); + const fadeRate = this.fadeRate/2; + const color = new Color( + this.colorStart.r + p * this.colorEndDelta.r, + this.colorStart.g + p * this.colorEndDelta.g, + this.colorStart.b + p * this.colorEndDelta.b, + (this.colorStart.a + p * this.colorEndDelta.a) * + (p < fadeRate ? p/fadeRate : p > 1-fadeRate ? (1-p)/fadeRate : 1)); // fade alpha + + // draw the particle + this.additive && setBlendMode(1); + + let pos = this.pos, angle = this.angle; + if (this.localSpaceEmitter) + { + // in local space of emitter + pos = this.localSpaceEmitter.pos.add(pos.rotate(-this.localSpaceEmitter.angle)); + angle += this.localSpaceEmitter.angle; + } + if (this.trailScale) + { + // trail style particles + let velocity = this.velocity; + if (this.localSpaceEmitter) + velocity = velocity.rotate(-this.localSpaceEmitter.angle); + const speed = velocity.length(); + const direction = velocity.scale(1/speed); + const trailLength = speed * this.trailScale; + size.y = max(size.x, trailLength); + angle = direction.angle(); + drawTile(pos.add(direction.multiply(vec2(0,-trailLength/2))), size, this.tileIndex, this.tileSize, color, angle, this.mirror); + } + else + drawTile(pos, size, this.tileIndex, this.tileSize, color, angle, this.mirror); + this.additive && setBlendMode(); + debugParticles && debugRect(pos, size, '#f005', 0, angle); + + if (p == 1) + { + // destroy particle when it's time runs out + this.color = color; + this.size = size; + this.destroyCallback && this.destroyCallback(this); + this.destroyed = 1; + } + } +} +/** + * LittleJS Medal System + *
- Tracks and displays medals + *
- Saves medals to local storage + *
- Newgrounds integration + * @namespace Medals + */ + +'use strict'; + +/** List of all medals + * @type {Array} + * @memberof Medals */ +const medals = []; + +// Engine internal variables not exposed to documentation +let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast; + +/////////////////////////////////////////////////////////////////////////////// + +/** Initialize medals with a save name used for storage + *
- Call this after creating all medals + *
- Checks if medals are unlocked + * @param {String} saveName + * @memberof Medals */ +function medalsInit(saveName) +{ + // check if medals are unlocked + medalsSaveName = saveName; + debugMedals || medals.forEach(medal=> medal.unlocked = (localStorage[medal.storageKey()] | 0)); +} + +/** + * Medal Object - Tracks an unlockable medal + * @example + * // create a medal + * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️'); + * + * // initialize medals + * medalsInit('Example Game'); + * + * // unlock the medal + * medal_example.unlock(); + */ +class Medal +{ + /** Create an medal object and adds it to the list of medals + * @param {Number} id - The unique identifier of the medal + * @param {String} name - Name of the medal + * @param {String} [description] - Description of the medal + * @param {String} [icon='🏆'] - Icon for the medal + * @param {String} [src] - Image location for the medal + */ + constructor(id, name, description='', icon='🏆', src) + { + ASSERT(id >= 0 && !medals[id]); + + // save attributes and add to list of medals + medals[this.id = id] = this; + this.name = name; + this.description = description; + this.icon = icon; + if (src) + (this.image = new Image).src = src; + } + + /** Unlocks a medal if not already unlocked */ + unlock() + { + if (medalsPreventUnlock || this.unlocked) + return; + + // save the medal + ASSERT(medalsSaveName); // save name must be set + localStorage[this.storageKey()] = this.unlocked = 1; + medalsDisplayQueue.push(this); + newgrounds && newgrounds.unlockMedal(this.id); + } + + /** Render a medal + * @param {Number} [hidePercent=0] - How much to slide the medal off screen + */ + render(hidePercent=0) + { + const context = overlayContext; + const width = min(medalDisplaySize.x, mainCanvas.width); + const x = overlayCanvas.width - width; + const y = -medalDisplaySize.y*hidePercent; + + // draw containing rect and clip to that region + context.save(); + context.beginPath(); + context.fillStyle = new Color(.9,.9,.9); + context.strokeStyle = new Color(0,0,0); + context.lineWidth = 3; + context.fill(context.rect(x, y, width, medalDisplaySize.y)); + context.stroke(); + context.clip(); + + // draw the icon and text + this.renderIcon(vec2(x+15+medalDisplayIconSize/2, y+medalDisplaySize.y/2)); + const pos = vec2(x+medalDisplayIconSize+30, y+28); + drawTextScreen(this.name, pos, 38, new Color(0,0,0), 0, 0, 'left'); + pos.y += 32; + drawTextScreen(this.description, pos, 24, new Color(0,0,0), 0, 0, 'left'); + context.restore(); + } + + /** Render the icon for a medal + * @param {Number} x - Screen space X position + * @param {Number} y - Screen space Y position + * @param {Number} [size=medalDisplayIconSize] - Screen space size + */ + renderIcon(pos, size=medalDisplayIconSize) + { + // draw the image or icon + if (this.image) + overlayContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size); + else + drawTextScreen(this.icon, pos, size*.7, new Color(0,0,0)); + } + + // Get local storage key used by the medal + storageKey() { return medalsSaveName + '_' + this.id; } +} + +// engine automatically renders medals +function medalsRender() +{ + if (!medalsDisplayQueue.length) + return; + + // update first medal in queue + const medal = medalsDisplayQueue[0]; + const time = timeReal - medalsDisplayTimeLast; + if (!medalsDisplayTimeLast) + medalsDisplayTimeLast = timeReal; + else if (time > medalDisplayTime) + medalsDisplayQueue.shift(medalsDisplayTimeLast = 0); + else + { + // slide on/off medals + const slideOffTime = medalDisplayTime - medalDisplaySlideTime; + const hidePercent = + time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime : + time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0; + medal.render(hidePercent); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +// global Newgrounds object +let newgrounds; + +/** This can used to enable Newgrounds functionality + * @param {Number} app_id - The newgrounds App ID + * @param {String} [cipher] - The encryption Key (AES-128/Base64) + * @memberof Medals */ +function newgroundsInit(app_id, cipher) { newgrounds = new Newgrounds(app_id, cipher); } + +/** + * Newgrounds API wrapper object + * @example + * // create a newgrounds object, replace the app id and cipher with your own + * const app_id = '53123:1ZuSTQ9l'; + * const cipher = 'enF0vGH@Mj/FRASKL23Q=='; + * newgrounds = new Newgrounds(app_id, cipher); + */ +class Newgrounds +{ + /** Create a newgrounds object + * @param {Number} app_id - The newgrounds App ID + * @param {String} [cipher] - The encryption Key (AES-128/Base64) */ + constructor(app_id, cipher) + { + ASSERT(!newgrounds && app_id); + + this.app_id = app_id; + this.cipher = cipher; + this.host = location ? location.hostname : ''; + + // create an instance of CryptoJS for encrypted calls + if (cipher) + this.cryptoJS = this.CryptoJS(); + + // get session id from url search params + const url = new URL(location.href); + this.session_id = url.searchParams.get('ngio_session_id'); + + if (!this.session_id) + return; // only use newgrounds when logged in + + // get medals + const medalsResult = this.call('Medal.getList'); + this.medals = medalsResult ? medalsResult.result.data['medals'] : []; + debugMedals && console.log(this.medals); + for (const newgroundsMedal of this.medals) + { + const medal = medals[newgroundsMedal['id']]; + if (medal) + { + // copy newgrounds medal data + medal.image = new Image; + medal.image.src = newgroundsMedal['icon']; + medal.name = newgroundsMedal['name']; + medal.description = newgroundsMedal['description']; + medal.unlocked = newgroundsMedal['unlocked']; + medal.difficulty = newgroundsMedal['difficulty']; + medal.value = newgroundsMedal['value']; + + if (medal.value) + medal.description = medal.description + ' (' + medal.value + ')'; + } + } + + // get scoreboards + const scoreboardResult = this.call('ScoreBoard.getBoards'); + this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : []; + debugMedals && console.log(this.scoreboards); + + const keepAliveMS = 5 * 60 * 1e3; + setInterval(()=>this.call('Gateway.ping', 0, 1), keepAliveMS); + } + + /** Send message to unlock a medal by id + * @param {Number} id - The medal id */ + unlockMedal(id) { return this.call('Medal.unlock', {'id':id}, 1); } + + /** Send message to post score + * @param {Number} id - The scoreboard id + * @param {Number} value - The score value */ + postScore(id, value) { return this.call('ScoreBoard.postScore', {'id':id, 'value':value}, 1); } + + /** Get scores from a scoreboard + * @param {Number} id - The scoreboard id + * @param {String} [user=0] - A user's id or name + * @param {Number} [social=0] - If true, only social scores will be loaded + * @param {Number} [skip=0] - Number of scores to skip before start + * @param {Number} [limit=10] - Number of scores to include in the list + * @return {Object} - The response JSON object + */ + getScores(id, user=0, social=0, skip=0, limit=10) + { return this.call('ScoreBoard.getScores', {'id':id, 'user':user, 'social':social, 'skip':skip, 'limit':limit}); } + + /** Send message to log a view */ + logView() { return this.call('App.logView', {'host':this.host}, 1); } + + /** Send a message to call a component of the Newgrounds API + * @param {String} component - Name of the component + * @param {Object} [parameters=0] - Parameters to use for call + * @param {Boolean} [async=0] - If true, don't wait for response before continuing (avoid stall) + * @return {Object} - The response JSON object + */ + call(component, parameters=0, async=0) + { + const call = {'component':component, 'parameters':parameters}; + if (this.cipher) + { + // encrypt using AES-128 Base64 with cryptoJS + const cryptoJS = this.cryptoJS; + const aesKey = cryptoJS['enc']['Base64']['parse'](this.cipher); + const iv = cryptoJS['lib']['WordArray']['random'](16); + const encrypted = cryptoJS['AES']['encrypt'](JSON.stringify(call), aesKey, {'iv':iv}); + call['secure'] = cryptoJS['enc']['Base64']['stringify'](iv.concat(encrypted['ciphertext'])); + call['parameters'] = 0; + } + + // build the input object + const input = + { + 'app_id': this.app_id, + 'session_id': this.session_id, + 'call': call + }; + + // build post data + const formData = new FormData(); + formData.append('input', JSON.stringify(input)); + + // send post data + const xmlHttp = new XMLHttpRequest(); + const url = 'https://newgrounds.io/gateway_v3.php'; + xmlHttp.open('POST', url, !debugMedals && async); + xmlHttp.send(formData); + debugMedals && console.log(xmlHttp.responseText); + return xmlHttp.responseText && JSON.parse(xmlHttp.responseText); + } + + CryptoJS() + { + /////////////////////////////////////////////////////////////////////////////// + // Crypto-JS - https://github.com/brix/crypto-js [The MIT License (MIT)] + // Copyright (c) 2009-2013 Jeff Mott Copyright (c) 2013-2016 Evan Vosberg + + return eval(Function("[M='GBMGXz^oVYPPKKbB`agTXU|LxPc_ZBcMrZvCr~wyGfWrwk@ATqlqeTp^N?p{we}jIpEnB_sEr`l?YDkDhWhprc|Er|XETG?pTl`e}dIc[_N~}fzRycIfpW{HTolvoPB_FMe_eH~BTMx]yyOhv?biWPCGc]kABencBhgERHGf{OL`Dj`c^sh@canhy[secghiyotcdOWgO{tJIE^JtdGQRNSCrwKYciZOa]Y@tcRATYKzv|sXpboHcbCBf`}SKeXPFM|RiJsSNaIb]QPc[D]Jy_O^XkOVTZep`ONmntLL`Qz~UupHBX_Ia~WX]yTRJIxG`ioZ{fefLJFhdyYoyLPvqgH?b`[TMnTwwfzDXhfM?rKs^aFr|nyBdPmVHTtAjXoYUloEziWDCw_suyYT~lSMksI~ZNCS[Bex~j]Vz?kx`gdYSEMCsHpjbyxQvw|XxX_^nQYue{sBzVWQKYndtYQMWRef{bOHSfQhiNdtR{o?cUAHQAABThwHPT}F{VvFmgN`E@FiFYS`UJmpQNM`X|tPKHlccT}z}k{sACHL?Rt@MkWplxO`ASgh?hBsuuP|xD~LSH~KBlRs]t|l|_tQAroDRqWS^SEr[sYdPB}TAROtW{mIkE|dWOuLgLmJrucGLpebrAFKWjikTUzS|j}M}szasKOmrjy[?hpwnEfX[jGpLt@^v_eNwSQHNwtOtDgWD{rk|UgASs@mziIXrsHN_|hZuxXlPJOsA^^?QY^yGoCBx{ekLuZzRqQZdsNSx@ezDAn{XNj@fRXIwrDX?{ZQHwTEfu@GhxDOykqts|n{jOeZ@c`dvTY?e^]ATvWpb?SVyg]GC?SlzteilZJAL]mlhLjYZazY__qcVFYvt@|bIQnSno@OXyt]OulzkWqH`rYFWrwGs`v|~XeTsIssLrbmHZCYHiJrX}eEzSssH}]l]IhPQhPoQ}rCXLyhFIT[clhzYOvyHqigxmjz`phKUU^TPf[GRAIhNqSOdayFP@FmKmuIzMOeoqdpxyCOwCthcLq?n`L`tLIBboNn~uXeFcPE{C~mC`h]jUUUQe^`UqvzCutYCgct|SBrAeiYQW?X~KzCz}guXbsUw?pLsg@hDArw?KeJD[BN?GD@wgFWCiHq@Ypp_QKFixEKWqRp]oJFuVIEvjDcTFu~Zz]a{IcXhWuIdMQjJ]lwmGQ|]g~c]Hl]pl`Pd^?loIcsoNir_kikBYyg?NarXZEGYspt_vLBIoj}LI[uBFvm}tbqvC|xyR~a{kob|HlctZslTGtPDhBKsNsoZPuH`U`Fqg{gKnGSHVLJ^O`zmNgMn~{rsQuoymw^JY?iUBvw_~mMr|GrPHTERS[MiNpY[Mm{ggHpzRaJaoFomtdaQ_?xuTRm}@KjU~RtPsAdxa|uHmy}n^i||FVL[eQAPrWfLm^ndczgF~Nk~aplQvTUpHvnTya]kOenZlLAQIm{lPl@CCTchvCF[fI{^zPkeYZTiamoEcKmBMfZhk_j_~Fjp|wPVZlkh_nHu]@tP|hS@^G^PdsQ~f[RqgTDqezxNFcaO}HZhb|MMiNSYSAnQWCDJukT~e|OTgc}sf[cnr?fyzTa|EwEtRG|I~|IO}O]S|rp]CQ}}DWhSjC_|z|oY|FYl@WkCOoPuWuqr{fJu?Brs^_EBI[@_OCKs}?]O`jnDiXBvaIWhhMAQDNb{U`bqVR}oqVAvR@AZHEBY@depD]OLh`kf^UsHhzKT}CS}HQKy}Q~AeMydXPQztWSSzDnghULQgMAmbWIZ|lWWeEXrE^EeNoZApooEmrXe{NAnoDf`m}UNlRdqQ@jOc~HLOMWs]IDqJHYoMziEedGBPOxOb?[X`KxkFRg@`mgFYnP{hSaxwZfBQqTm}_?RSEaQga]w[vxc]hMne}VfSlqUeMo_iqmd`ilnJXnhdj^EEFifvZyxYFRf^VaqBhLyrGlk~qowqzHOBlOwtx?i{m~`n^G?Yxzxux}b{LSlx]dS~thO^lYE}bzKmUEzwW^{rPGhbEov[Plv??xtyKJshbG`KuO?hjBdS@Ru}iGpvFXJRrvOlrKN?`I_n_tplk}kgwSXuKylXbRQ]]?a|{xiT[li?k]CJpwy^o@ebyGQrPfF`aszGKp]baIx~H?ElETtFh]dz[OjGl@C?]VDhr}OE@V]wLTc[WErXacM{We`F|utKKjgllAxvsVYBZ@HcuMgLboFHVZmi}eIXAIFhS@A@FGRbjeoJWZ_NKd^oEH`qgy`q[Tq{x?LRP|GfBFFJV|fgZs`MLbpPYUdIV^]mD@FG]pYAT^A^RNCcXVrPsgk{jTrAIQPs_`mD}rOqAZA[}RETFz]WkXFTz_m{N@{W@_fPKZLT`@aIqf|L^Mb|crNqZ{BVsijzpGPEKQQZGlApDn`ruH}cvF|iXcNqK}cxe_U~HRnKV}sCYb`D~oGvwG[Ca|UaybXea~DdD~LiIbGRxJ_VGheI{ika}KC[OZJLn^IBkPrQj_EuoFwZ}DpoBRcK]Q}?EmTv~i_Tul{bky?Iit~tgS|o}JL_VYcCQdjeJ_MfaA`FgCgc[Ii|CBHwq~nbJeYTK{e`CNstKfTKPzw{jdhp|qsZyP_FcugxCFNpKitlR~vUrx^NrSVsSTaEgnxZTmKc`R|lGJeX}ccKLsQZQhsFkeFd|ckHIVTlGMg`~uPwuHRJS_CPuN_ogXe{Ba}dO_UBhuNXby|h?JlgBIqMKx^_u{molgL[W_iavNQuOq?ap]PGB`clAicnl@k~pA?MWHEZ{HuTLsCpOxxrKlBh]FyMjLdFl|nMIvTHyGAlPogqfZ?PlvlFJvYnDQd}R@uAhtJmDfe|iJqdkYr}r@mEjjIetDl_I`TELfoR|qTBu@Tic[BaXjP?dCS~MUK[HPRI}OUOwAaf|_}HZzrwXvbnNgltjTwkBE~MztTQhtRSWoQHajMoVyBBA`kdgK~h`o[J`dm~pm]tk@i`[F~F]DBlJKklrkR]SNw@{aG~Vhl`KINsQkOy?WhcqUMTGDOM_]bUjVd|Yh_KUCCgIJ|LDIGZCPls{RzbVWVLEhHvWBzKq|^N?DyJB|__aCUjoEgsARki}j@DQXS`RNU|DJ^a~d{sh_Iu{ONcUtSrGWW@cvUjefHHi}eSSGrNtO?cTPBShLqzwMVjWQQCCFB^culBjZHEK_{dO~Q`YhJYFn]jq~XSnG@[lQr]eKrjXpG~L^h~tDgEma^AUFThlaR{xyuP@[^VFwXSeUbVetufa@dX]CLyAnDV@Bs[DnpeghJw^?UIana}r_CKGDySoRudklbgio}kIDpA@McDoPK?iYcG?_zOmnWfJp}a[JLR[stXMo?_^Ng[whQlrDbrawZeSZ~SJstIObdDSfAA{MV}?gNunLOnbMv_~KFQUAjIMj^GkoGxuYtYbGDImEYiwEMyTpMxN_LSnSMdl{bg@dtAnAMvhDTBR_FxoQgANniRqxd`pWv@rFJ|mWNWmh[GMJz_Nq`BIN@KsjMPASXORcdHjf~rJfgZYe_uulzqM_KdPlMsuvU^YJuLtofPhGonVOQxCMuXliNvJIaoC?hSxcxKVVxWlNs^ENDvCtSmO~WxI[itnjs^RDvI@KqG}YekaSbTaB]ki]XM@[ZnDAP~@|BzLRgOzmjmPkRE@_sobkT|SszXK[rZN?F]Z_u}Yue^[BZgLtR}FHzWyxWEX^wXC]MJmiVbQuBzkgRcKGUhOvUc_bga|Tx`KEM`JWEgTpFYVeXLCm|mctZR@uKTDeUONPozBeIkrY`cz]]~WPGMUf`MNUGHDbxZuO{gmsKYkAGRPqjc|_FtblEOwy}dnwCHo]PJhN~JoteaJ?dmYZeB^Xd?X^pOKDbOMF@Ugg^hETLdhwlA}PL@_ur|o{VZosP?ntJ_kG][g{Zq`Tu]dzQlSWiKfnxDnk}KOzp~tdFstMobmy[oPYjyOtUzMWdjcNSUAjRuqhLS@AwB^{BFnqjCmmlk?jpn}TksS{KcKkDboXiwK]qMVjm~V`LgWhjS^nLGwfhAYrjDSBL_{cRus~{?xar_xqPlArrYFd?pHKdMEZzzjJpfC?Hv}mAuIDkyBxFpxhstTx`IO{rp}XGuQ]VtbHerlRc_LFGWK[XluFcNGUtDYMZny[M^nVKVeMllQI[xtvwQnXFlWYqxZZFp_|]^oWX[{pOMpxXxvkbyJA[DrPzwD|LW|QcV{Nw~U^dgguSpG]ClmO@j_TENIGjPWwgdVbHganhM?ema|dBaqla|WBd`poj~klxaasKxGG^xbWquAl~_lKWxUkDFagMnE{zHug{b`A~IYcQYBF_E}wiA}K@yxWHrZ{[d~|ARsYsjeNWzkMs~IOqqp[yzDE|WFrivsidTcnbHFRoW@XpAV`lv_zj?B~tPCppRjgbbDTALeFaOf?VcjnKTQMLyp{NwdylHCqmo?oelhjWuXj~}{fpuX`fra?GNkDiChYgVSh{R[BgF~eQa^WVz}ATI_CpY?g_diae]|ijH`TyNIF}|D_xpmBq_JpKih{Ba|sWzhnAoyraiDvk`h{qbBfsylBGmRH}DRPdryEsSaKS~tIaeF[s]I~xxHVrcNe@Jjxa@jlhZueLQqHh_]twVMqG_EGuwyab{nxOF?`HCle}nBZzlTQjkLmoXbXhOtBglFoMz?eqre`HiE@vNwBulglmQjj]DB@pPkPUgA^sjOAUNdSu_`oAzar?n?eMnw{{hYmslYi[TnlJD'",...']charCodeAtUinyxpf',"for(;e<10359;c[e++]=p-=128,A=A?p-A&&A:p==34&&p)for(p=1;p<128;y=f.map((n,x)=>(U=r[n]*2+1,U=Math.log(U/(h-U)),t-=a[x]*U,U/500)),t=~-h/(1+Math.exp(t))|1,i=o%h>17)-!i*t,f.map((n,x)=>(U=r[n]+=(i*h/2-r[n]<<13)/((C[n]+=C[n]<5)+1/20)>>13,a[x]+=y[x]*(i-t/h))),p=p*2+i)for(f='010202103203210431053105410642065206541'.split(t=0).map((n,x)=>(U=0,[...n].map((n,x)=>(U=U*997+(c[e-n]|0)|0)),h*32-1&U*997+p+!!A*129)*12+x);o - All webgl used by the engine is wrapped up here + *
- For normal stuff you won't need to see or call anything in this file + *
- For advanced stuff there are helper functions to create shaders, textures, etc + *
- Can be disabled with glEnable to revert to 2D canvas rendering + *
- Batches sprite rendering on GPU for incredibly fast performance + *
- Sprite transform math is done in the shader where possible + * @namespace WebGL + */ + +'use strict'; + +/** The WebGL canvas which appears above the main canvas and below the overlay canvas + * @type {HTMLCanvasElement} + * @memberof WebGL */ +let glCanvas; + +/** 2d context for glCanvas + * @type {WebGLRenderingContext} + * @memberof WebGL */ +let glContext; + +/** Main tile sheet texture automatically loaded by engine + * @type {WebGLTexture} + * @memberof WebGL */ +let glTileTexture; + +// WebGL internal variables not exposed to documentation +let glActiveTexture, glShader, glArrayBuffer, glPositionData, glColorData, glBatchCount, glBatchAdditive, glAdditive; + +/////////////////////////////////////////////////////////////////////////////// + +// Init WebGL, called automatically by the engine +function glInit() +{ + // create the canvas and tile texture + glCanvas = document.createElement('canvas'); + glContext = glCanvas.getContext('webgl', {antialias: false}); + glTileTexture = glCreateTexture(tileImage); + + // some browsers are much faster without copying the gl buffer so we just overlay it instead + glOverlay && document.body.appendChild(glCanvas); + + // setup vertex and fragment shaders + glShader = glCreateProgram( + 'precision highp float;'+ // use highp for better accuracy + 'uniform mat4 m;'+ // transform matrix + 'attribute vec2 p,t;'+ // position, uv + 'attribute vec4 c,a;'+ // color, additiveColor + 'varying vec4 v,d,e;'+ // return uv, color, additiveColor + 'void main(){'+ // shader entry point + 'gl_Position=m*vec4(p,1,1);'+ // transform position + 'v=vec4(t,p);d=c;e=a;'+ // pass stuff to fragment shader + '}' // end of shader + , + 'precision highp float;'+ // use highp for better accuracy + 'varying vec4 v,d,e;'+ // uv, color, additiveColor + 'uniform sampler2D s;'+ // texture + 'void main(){'+ // shader entry point + 'gl_FragColor=texture2D(s,v.xy)*d+e;'+ // modulate texture by color plus additive + '}' // end of shader + ); + + // init buffers + const vertexData = new ArrayBuffer(gl_VERTEX_BUFFER_SIZE); + glArrayBuffer = glContext.createBuffer(); + glPositionData = new Float32Array(vertexData); + glColorData = new Uint32Array(vertexData); + glBatchCount = 0; +} + +/** Set the WebGl blend mode, normally you should call setBlendMode instead + * @param {Boolean} [additive=0] + * @memberof WebGL */ +function glSetBlendMode(additive) +{ + // setup blending + glAdditive = additive; +} + +/** Set the WebGl texture, not normally necessary unless multiple tile sheets are used + *
- This may also flush the gl buffer resulting in more draw calls and worse performance + * @param {WebGLTexture} [texture=glTileTexture] + * @memberof WebGL */ +function glSetTexture(texture=glTileTexture) +{ + // must flush cache with the old texture to set a new one + if (texture != glActiveTexture) + glFlush(); + + glContext.bindTexture(gl_TEXTURE_2D, glActiveTexture = texture); +} + +/** Compile WebGL shader of the given type, will throw errors if in debug mode + * @param {String} source + * @param type + * @return {WebGLShader} + * @memberof WebGL */ +function glCompileShader(source, type) +{ + // build the shader + const shader = glContext.createShader(type); + glContext.shaderSource(shader, source); + glContext.compileShader(shader); + + // check for errors + if (debug && !glContext.getShaderParameter(shader, gl_COMPILE_STATUS)) + throw glContext.getShaderInfoLog(shader); + return shader; +} + +/** Create WebGL program with given shaders + * @param {WebGLShader} vsSource + * @param {WebGLShader} fsSource + * @return {WebGLProgram} + * @memberof WebGL */ +function glCreateProgram(vsSource, fsSource) +{ + // build the program + const program = glContext.createProgram(); + glContext.attachShader(program, glCompileShader(vsSource, gl_VERTEX_SHADER)); + glContext.attachShader(program, glCompileShader(fsSource, gl_FRAGMENT_SHADER)); + glContext.linkProgram(program); + + // check for errors + if (debug && !glContext.getProgramParameter(program, gl_LINK_STATUS)) + throw glContext.getProgramInfoLog(program); + return program; +} + +/** Create WebGL texture from an image and set the texture settings + * @param {Image} image + * @return {WebGLTexture} + * @memberof WebGL */ +function glCreateTexture(image) +{ + // build the texture + const texture = glContext.createTexture(); + glContext.bindTexture(gl_TEXTURE_2D, texture); + image && image.width && glContext.texImage2D(gl_TEXTURE_2D, 0, gl_RGBA, gl_RGBA, gl_UNSIGNED_BYTE, image); + + // use point filtering for pixelated rendering + const filter = cavasPixelated ? gl_NEAREST : gl_LINEAR; + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MIN_FILTER, filter); + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MAG_FILTER, filter); + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_WRAP_S, gl_CLAMP_TO_EDGE); + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_WRAP_T, gl_CLAMP_TO_EDGE); + return texture; +} + +// called automatically by engine before render +function glPreRender() +{ + // clear and set to same size as main canvas + glContext.viewport(0, 0, glCanvas.width = mainCanvas.width, glCanvas.height = mainCanvas.height); + glContext.clear(gl_COLOR_BUFFER_BIT); + + // set up the shader + glContext.useProgram(glShader); + glContext.activeTexture(gl_TEXTURE0); + glContext.bindTexture(gl_TEXTURE_2D, glActiveTexture = glTileTexture); + glContext.bindBuffer(gl_ARRAY_BUFFER, glArrayBuffer); + glContext.bufferData(gl_ARRAY_BUFFER, gl_VERTEX_BUFFER_SIZE, gl_DYNAMIC_DRAW); + glSetBlendMode(); + + // set vertex attributes + let offset = 0; + const initVertexAttribArray = (name, type, typeSize, size, normalize=0)=> + { + const location = glContext.getAttribLocation(glShader, name); + glContext.enableVertexAttribArray(location); + glContext.vertexAttribPointer(location, size, type, normalize, gl_VERTEX_BYTE_STRIDE, offset); + offset += size*typeSize; + } + initVertexAttribArray('p', gl_FLOAT, 4, 2); // position + initVertexAttribArray('t', gl_FLOAT, 4, 2); // texture coords + initVertexAttribArray('c', gl_UNSIGNED_BYTE, 1, 4, 1); // color + initVertexAttribArray('a', gl_UNSIGNED_BYTE, 1, 4, 1); // additiveColor + + // build the transform matrix + const sx = 2 * cameraScale / mainCanvas.width; + const sy = 2 * cameraScale / mainCanvas.height; + glContext.uniformMatrix4fv(glContext.getUniformLocation(glShader, 'm'), 0, + new Float32Array([ + sx, 0, 0, 0, + 0, sy, 0, 0, + 1, 1, -1, 1, + -1-sx*cameraPos.x, -1-sy*cameraPos.y, 0, 0 + ]) + ); +} + +/** Draw all sprites and clear out the buffer, called automatically by the system whenever necessary + * @memberof WebGL */ +function glFlush() +{ + if (!glBatchCount) return; + + const destBlend = glBatchAdditive ? gl_ONE : gl_ONE_MINUS_SRC_ALPHA; + glContext.blendFuncSeparate(gl_SRC_ALPHA, destBlend, gl_ONE, destBlend); + glContext.enable(gl_BLEND); + + // draw all the sprites in the batch and reset the buffer + glContext.bufferSubData(gl_ARRAY_BUFFER, 0, + glPositionData.subarray(0, glBatchCount * gl_VERTICES_PER_QUAD * gl_INDICIES_PER_VERT)); + glContext.drawArrays(gl_TRIANGLES, 0, glBatchCount * gl_VERTICES_PER_QUAD); + glBatchCount = 0; + glBatchAdditive = glAdditive; +} + +/** Draw any sprites still in the buffer, copy to main canvas and clear + * @param {CanvasRenderingContext2D} context + * @param {Boolean} [forceDraw=0] + * @memberof WebGL */ +function glCopyToContext(context, forceDraw) +{ + if (!glBatchCount && !forceDraw) return; + + glFlush(); + + // do not draw in overlay mode because the canvas is visible + if (!glOverlay || forceDraw) + context.drawImage(glCanvas, 0, 0); +} + +/** Add a sprite to the gl draw list, used by all gl draw functions + * @param x + * @param y + * @param sizeX + * @param sizeY + * @param angle + * @param uv0X + * @param uv0Y + * @param uv1X + * @param uv1Y + * @param rgba + * @param [rgbaAdditive=0] + * @memberof WebGL */ +function glDraw(x, y, sizeX, sizeY, angle, uv0X, uv0Y, uv1X, uv1Y, rgba, rgbaAdditive=0) +{ + // flush if there is no room for more verts or if different blend mode + if (glBatchCount == gl_MAX_BATCH || glBatchAdditive != glAdditive) + glFlush(); + + // prepare to create the verts from size and angle + const c = Math.cos(angle)/2, s = Math.sin(angle)/2; + const cx = c*sizeX, cy = c*sizeY, sx = s*sizeX, sy = s*sizeY; + + // setup 2 triangles to form a quad + for(let i=6, offset = glBatchCount++ * gl_VERTICES_PER_QUAD * gl_INDICIES_PER_VERT; i--;) + { + const a = i-4&&i>1, b = i-5&&i-2&&i-1; + glPositionData[offset++] = x + (a?-cx:cx) + (b?sy:-sy); + glPositionData[offset++] = y + (b?cy:-cy) + (a?sx:-sx); + glPositionData[offset++] = a ? uv0X : uv1X; + glPositionData[offset++] = b ? uv0Y : uv1Y; + glColorData[offset++] = rgba; + glColorData[offset++] = rgbaAdditive; + } +} + +/////////////////////////////////////////////////////////////////////////////// +// post processing - can be enabled to pass other canvases through a final shader + +let glPostShader, glPostArrayBuffer, glPostTexture, glPostIncludeOverlay; + +/** Set up a post processing shader + * @param {String} shaderCode + * @param {Boolean} includeOverlay + * @memberof WebGL */ +function glInitPostProcess(shaderCode, includeOverlay) +{ + ASSERT(!glPostShader); // can only have 1 post effects shader + + if (!shaderCode) // default shader + shaderCode = 'void mainImage(out vec4 c,vec2 p){c=texture2D(iChannel0,p/iResolution.xy);}'; + + // create the shader + glPostShader = glCreateProgram( + 'precision highp float;'+ // use highp for better accuracy + 'attribute vec2 p;'+ // position + 'void main(){'+ // shader entry point + 'gl_Position=vec4(p,1,1);'+ // set position + '}' // end of shader + , + 'precision highp float;'+ // use highp for better accuracy + 'uniform sampler2D iChannel0;'+ // input texture + 'uniform vec3 iResolution;'+ // size of output texture + 'uniform float iTime;'+ // time passed + '\n' + shaderCode + '\n'+ // insert custom shader code + 'void main(){'+ // shader entry point + 'mainImage(gl_FragColor,gl_FragCoord.xy);'+ // call post process function + 'gl_FragColor.a=1.;'+ // always use full alpha + '}' // end of shader + ); + + // create buffer and texture + glPostArrayBuffer = glContext.createBuffer(); + glPostTexture = glCreateTexture(); + glPostIncludeOverlay = includeOverlay; + + // hide the original 2d canvas + mainCanvas.style.visibility = 'hidden'; +} + +// Render the post processing shader, called automatically by the engine +function glRenderPostProcess() +{ + if (!glPostShader) + return; + + // prepare to render post process shader + if (glEnable) + { + glFlush(); // clear out the buffer + mainContext.drawImage(glCanvas, 0, 0); // copy to the main canvas + } + else // set viewport + glContext.viewport(0, 0, glCanvas.width = mainCanvas.width, glCanvas.height = mainCanvas.height); + + if (glPostIncludeOverlay) + { + // copy overlay canvas so it will be included in post processing + mainContext.drawImage(overlayCanvas, 0, 0); + + // clear overlay canvas + overlayCanvas.width = mainCanvas.width; + } + + // setup shader program to draw one triangle + glContext.useProgram(glPostShader); + glContext.disable(gl_BLEND); + glContext.bindBuffer(gl_ARRAY_BUFFER, glPostArrayBuffer); + glContext.bufferData(gl_ARRAY_BUFFER, new Float32Array([-3,1,1,-3,1,1]), gl_STATIC_DRAW); + glContext.pixelStorei(gl_UNPACK_FLIP_Y_WEBGL, true); + + // set textures, pass in the 2d canvas and gl canvas in separate texture channels + glContext.activeTexture(gl_TEXTURE0); + glContext.bindTexture(gl_TEXTURE_2D, glPostTexture); + glContext.texImage2D(gl_TEXTURE_2D, 0, gl_RGBA, gl_RGBA, gl_UNSIGNED_BYTE, mainCanvas); + + // set vertex position attribute + const vertexByteStride = 8; + const pLocation = glContext.getAttribLocation(glPostShader, 'p'); + glContext.enableVertexAttribArray(pLocation); + glContext.vertexAttribPointer(pLocation, 2, gl_FLOAT, 0, vertexByteStride, 0); + + // set uniforms and draw + const uniformLocation = (name)=>glContext.getUniformLocation(glPostShader, name); + glContext.uniform1i(uniformLocation('iChannel0'), 0); + glContext.uniform1f(uniformLocation('iTime'), time); + glContext.uniform3f(uniformLocation('iResolution'), mainCanvas.width, mainCanvas.height, 1); + glContext.drawArrays(gl_TRIANGLES, 0, 3); +} + +/////////////////////////////////////////////////////////////////////////////// +// store gl constants as integers so their name doesn't use space in minifed +const +gl_ONE = 1, +gl_TRIANGLES = 4, +gl_SRC_ALPHA = 770, +gl_ONE_MINUS_SRC_ALPHA = 771, +gl_BLEND = 3042, +gl_TEXTURE_2D = 3553, +gl_UNSIGNED_BYTE = 5121, +gl_BYTE = 5120, +gl_FLOAT = 5126, +gl_RGBA = 6408, +gl_NEAREST = 9728, +gl_LINEAR = 9729, +gl_TEXTURE_MAG_FILTER = 10240, +gl_TEXTURE_MIN_FILTER = 10241, +gl_TEXTURE_WRAP_S = 10242, +gl_TEXTURE_WRAP_T = 10243, +gl_COLOR_BUFFER_BIT = 16384, +gl_CLAMP_TO_EDGE = 33071, +gl_TEXTURE0 = 33984, +gl_TEXTURE1 = 33985, +gl_ARRAY_BUFFER = 34962, +gl_STATIC_DRAW = 35044, +gl_DYNAMIC_DRAW = 35048, +gl_FRAGMENT_SHADER = 35632, +gl_VERTEX_SHADER = 35633, +gl_COMPILE_STATUS = 35713, +gl_LINK_STATUS = 35714, +gl_UNPACK_FLIP_Y_WEBGL = 37440, + +// constants for batch rendering +gl_VERTICES_PER_QUAD = 6, +gl_INDICIES_PER_VERT = 6, +gl_MAX_BATCH = 1<<16, +gl_VERTEX_BYTE_STRIDE = (4 * 2) * 2 + (4) * 2, // vec2 * 2 + (char * 4) * 2 +gl_VERTEX_BUFFER_SIZE = gl_MAX_BATCH * gl_VERTICES_PER_QUAD * gl_VERTEX_BYTE_STRIDE; +/* + LittleJS - The Tiny JavaScript Game Engine That Can! + MIT License - Copyright 2021 Frank Force + + Engine Features + - Object oriented system with base class engine object + - Base class object handles update, physics, collision, rendering, etc + - Engine helper classes and functions like Vector2, Color, and Timer + - Super fast rendering system for tile sheets + - Sound effects audio with zzfx and music with zzfxm + - Input processing system with gamepad and touchscreen support + - Tile layer rendering and collision system + - Particle effect system + - Medal system tracks and displays achievements + - Debug tools and debug rendering system + - Post processing effects + - Call engineInit() to start it up! +*/ + +/** + * LittleJS Engine Globals + * @namespace Engine + */ + +'use strict'; + +/** Name of engine + * @type {String} + * @default + * @memberof Engine */ +const engineName = 'LittleJS'; + +/** Version of engine + * @type {String} + * @default + * @memberof Engine */ +const engineVersion = '1.6.0'; + +/** Frames per second to update objects + * @type {Number} + * @default + * @memberof Engine */ +const frameRate = 60; + +/** How many seconds each frame lasts, engine uses a fixed time step + * @type {Number} + * @default 1/60 + * @memberof Engine */ +const timeDelta = 1/frameRate; + +/** Array containing all engine objects + * @type {Array} + * @memberof Engine */ +let engineObjects = []; + +/** Array containing only objects that are set to collide with other objects this frame (for optimization) + * @type {Array} + * @memberof Engine */ +let engineObjectsCollide = []; + +/** Current update frame, used to calculate time + * @type {Number} + * @memberof Engine */ +let frame = 0; + +/** Current engine time since start in seconds, derived from frame + * @type {Number} + * @memberof Engine */ +let time = 0; + +/** Actual clock time since start in seconds (not affected by pause or frame rate clamping) + * @type {Number} + * @memberof Engine */ +let timeReal = 0; + +/** Is the game paused? Causes time and objects to not be updated + * @type {Boolean} + * @default 0 + * @memberof Engine */ +let paused = 0; + +/** Set if game is paused + * @param {Boolean} paused + * @memberof Engine */ +function setPaused(_paused) { paused = _paused; } + +/////////////////////////////////////////////////////////////////////////////// + +/** Start up LittleJS engine with your callback functions + * @param {Function} gameInit - Called once after the engine starts up, setup the game + * @param {Function} gameUpdate - Called every frame at 60 frames per second, handle input and update the game state + * @param {Function} gameUpdatePost - Called after physics and objects are updated, setup camera and prepare for render + * @param {Function} gameRender - Called before objects are rendered, draw any background effects that appear behind objects + * @param {Function} gameRenderPost - Called after objects are rendered, draw effects or hud that appear above all objects + * @param {String} [tileImageSource] - Tile image to use, everything starts when the image is finished loading + * @memberof Engine */ +function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, tileImageSource) +{ + // init engine when tiles load or fail to load + tileImage.onerror = tileImage.onload = ()=> + { + // save tile image info + tileImageFixBleed = vec2(tileFixBleedScale).divide(tileImageSize = vec2(tileImage.width, tileImage.height)); + debug && (tileImage.onload=()=>ASSERT(1)); // tile sheet can not reloaded + + // setup html + const styleBody = 'margin:0;overflow:hidden;background:#000' + // fill the window + ';touch-action:none' + // prevent mobile pinch to resize + ';user-select:none' + // prevent mobile hold to select + ';-webkit-user-select:none'; // compatibility for ios + document.body.style = styleBody; + document.body.appendChild(mainCanvas = document.createElement('canvas')); + mainContext = mainCanvas.getContext('2d'); + + // init stuff and start engine + debugInit(); + glEnable && glInit(); + + // create overlay canvas for hud to appear above gl canvas + document.body.appendChild(overlayCanvas = document.createElement('canvas')); + overlayContext = overlayCanvas.getContext('2d'); + + // set canvas style to fill the window + const styleCanvas = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)'; + (glCanvas||mainCanvas).style = mainCanvas.style = overlayCanvas.style = styleCanvas; + + gameInit(); + touchGamepadCreate(); + engineUpdate(); + }; + + // frame time tracking + let frameTimeLastMS = 0, frameTimeBufferMS, averageFPS; + + // main update loop + function engineUpdate(frameTimeMS=0) + { + // update time keeping + let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS; + frameTimeLastMS = frameTimeMS; + if (debug || showWatermark) + averageFPS = lerp(.05, averageFPS, 1e3/(frameTimeDeltaMS||1)); + const debugSpeedUp = debug && keyIsDown(107); // + + const debugSpeedDown = debug && keyIsDown(109); // - + if (debug) // +/- to speed/slow time + frameTimeDeltaMS *= debugSpeedUp ? 5 : debugSpeedDown ? .2 : 1; + timeReal += frameTimeDeltaMS / 1e3; + frameTimeBufferMS += !paused * frameTimeDeltaMS; + if (!debugSpeedUp) + frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp incase of slow framerate + + if (canvasFixedSize.x) + { + // clear canvas and set fixed size + mainCanvas.width = canvasFixedSize.x; + mainCanvas.height = canvasFixedSize.y; + + // fit to window by adding space on top or bottom if necessary + const aspect = innerWidth / innerHeight; + const fixedAspect = mainCanvas.width / mainCanvas.height; + (glCanvas||mainCanvas).style.width = mainCanvas.style.width = overlayCanvas.style.width = aspect < fixedAspect ? '100%' : ''; + (glCanvas||mainCanvas).style.height = mainCanvas.style.height = overlayCanvas.style.height = aspect < fixedAspect ? '' : '100%'; + } + else + { + // clear canvas and set size to same as window + mainCanvas.width = min(innerWidth, canvasMaxSize.x); + mainCanvas.height = min(innerHeight, canvasMaxSize.y); + } + + // clear overlay canvas and set size + overlayCanvas.width = mainCanvas.width; + overlayCanvas.height = mainCanvas.height; + + // save canvas size + mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); + + if (paused) + { + // do post update even when paused + inputUpdate(); + debugUpdate(); + gameUpdatePost(); + inputUpdatePost(); + } + else + { + // apply time delta smoothing, improves smoothness of framerate in some browsers + let deltaSmooth = 0; + if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9) + { + // force an update each frame if time is close enough (not just a fast refresh rate) + deltaSmooth = frameTimeBufferMS; + frameTimeBufferMS = 0; + } + + // update multiple frames if necessary in case of slow framerate + for (;frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate) + { + // update game and objects + inputUpdate(); + gameUpdate(); + engineObjectsUpdate(); + + // do post update + debugUpdate(); + gameUpdatePost(); + inputUpdatePost(); + } + + // add the time smoothing back in + frameTimeBufferMS += deltaSmooth; + } + + // render sort then render while removing destroyed objects + enginePreRender(); + gameRender(); + engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder); + for (const o of engineObjects) + o.destroyed || o.render(); + gameRenderPost(); + glRenderPostProcess(); + medalsRender(); + touchGamepadRender(); + debugRender(); + glEnable && glCopyToContext(mainContext); + + if (showWatermark) + { + // update fps + overlayContext.textAlign = 'right'; + overlayContext.textBaseline = 'top'; + overlayContext.font = '1em monospace'; + overlayContext.fillStyle = '#000'; + const text = engineName + ' ' + 'v' + engineVersion + ' / ' + + drawCount + ' / ' + engineObjects.length + ' / ' + averageFPS.toFixed(1) + + (glEnable ? ' GL' : ' 2D') ; + overlayContext.fillText(text, mainCanvas.width-3, 3); + overlayContext.fillStyle = '#fff'; + overlayContext.fillText(text, mainCanvas.width-2, 2); + drawCount = 0; + } + + requestAnimationFrame(engineUpdate); + } + + // set tile image source to load the image and start the engine + tileImageSource ? tileImage.src = tileImageSource : tileImage.onload(); +} + +// Called automatically by engine to setup render system +function enginePreRender() +{ + // save canvas size + mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); + + // disable smoothing for pixel art + mainContext.imageSmoothingEnabled = !cavasPixelated; + + // setup gl rendering if enabled + glEnable && glPreRender(); +} + +/** Update each engine object, remove destroyed objects, and update time + * @memberof Engine */ +function engineObjectsUpdate() +{ + // get list of solid objects for physics optimzation + engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects); + + // recursive object update + const updateObject = (o)=> + { + if (!o.destroyed) + { + o.update(); + for (const child of o.children) + updateObject(child); + } + } + for (const o of engineObjects) + o.parent || updateObject(o); + + // remove destroyed objects + engineObjects = engineObjects.filter(o=>!o.destroyed); + + // increment frame and update time + time = ++frame / frameRate; +} + +/** Destroy and remove all objects + * @memberof Engine */ +function engineObjectsDestroy() +{ + for (const o of engineObjects) + o.parent || o.destroy(); + engineObjects = engineObjects.filter(o=>!o.destroyed); +} + +/** Triggers a callback for each object within a given area + * @param {Vector2} [pos] - Center of test area + * @param {Number} [size] - Radius of circle if float, rectangle size if Vector2 + * @param {Function} [callbackFunction] - Calls this function on every object that passes the test + * @param {Array} [objects=engineObjects] - List of objects to check + * @memberof Engine */ +function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects) +{ + if (!pos) // all objects + { + for (const o of objects) + callbackFunction(o); + } + else if (size.x != undefined) // bounding box test + { + for (const o of objects) + isOverlapping(pos, size, o.pos, o.size) && callbackFunction(o); + } + else // circle test + { + const sizeSquared = size*size; + for (const o of objects) + pos.distanceSquared(o.pos) < sizeSquared && callbackFunction(o); + } +} diff --git a/LittleJS/engine/engine.all.release.js b/LittleJS/littlejs.release.js similarity index 78% rename from LittleJS/engine/engine.all.release.js rename to LittleJS/littlejs.release.js index 5569c54..343c5e8 100644 --- a/LittleJS/engine/engine.all.release.js +++ b/LittleJS/littlejs.release.js @@ -1,4007 +1,4203 @@ -/* - LittleJS - Release Build - MIT License - Copyright 2021 Frank Force - - - This file is used for release builds in place of engineDebug.js - - Debug functionality will be disabled to lower size and increase performance -*/ - -'use strict'; - -let showWatermark = 0; -let godMode = 0; -const debug = 0; -const debugOverlay = 0; -const debugPhysics = 0; -const debugParticles = 0; -const debugRaycast = 0; -const debugGamepads = 0; -const debugMedals = 0; - -// debug commands are automatically removed from the final build -const ASSERT = ()=> {} -const debugInit = ()=> {} -const debugUpdate = ()=> {} -const debugRender = ()=> {} -const debugRect = ()=> {} -const debugCircle = ()=> {} -const debugPoint = ()=> {} -const debugLine = ()=> {} -const debugAABB = ()=> {} -const debugText = ()=> {} -const debugClear = ()=> {} -const debugSaveCanvas = ()=> {} -/** - * LittleJS Utility Classes and Functions - *
- General purpose math library - *
- Vector2 - fast, simple, easy 2D vector class - *
- Color - holds a rgba color with some math functions - *
- Timer - tracks time automatically - * @namespace Utilities - */ - -'use strict'; - -/** A shortcut to get Math.PI - * @const - * @memberof Utilities */ -const PI = Math.PI; - -/** Returns absoulte value of value passed in - * @param {Number} value - * @return {Number} - * @memberof Utilities */ -const abs = (a)=> a < 0 ? -a : a; - -/** Returns lowest of two values passed in - * @param {Number} valueA - * @param {Number} valueB - * @return {Number} - * @memberof Utilities */ -const min = (a, b)=> a < b ? a : b; - -/** Returns highest of two values passed in - * @param {Number} valueA - * @param {Number} valueB - * @return {Number} - * @memberof Utilities */ -const max = (a, b)=> a > b ? a : b; - -/** Returns the sign of value passed in (also returns 1 if 0) - * @param {Number} value - * @return {Number} - * @memberof Utilities */ -const sign = (a)=> a < 0 ? -1 : 1; - -/** Returns first parm modulo the second param, but adjusted so negative numbers work as expected - * @param {Number} dividend - * @param {Number} [divisor=1] - * @return {Number} - * @memberof Utilities */ -const mod = (a, b=1)=> ((a % b) + b) % b; - -/** Clamps the value beween max and min - * @param {Number} value - * @param {Number} [min=0] - * @param {Number} [max=1] - * @return {Number} - * @memberof Utilities */ -const clamp = (v, min=0, max=1)=> v < min ? min : v > max ? max : v; - -/** Returns what percentage the value is between max and min - * @param {Number} value - * @param {Number} [min=0] - * @param {Number} [max=1] - * @return {Number} - * @memberof Utilities */ -const percent = (v, min=0, max=1)=> max-min ? clamp((v-min) / (max-min)) : 0; - -/** Linearly interpolates the percent value between max and min - * @param {Number} percent - * @param {Number} [min=0] - * @param {Number} [max=1] - * @return {Number} - * @memberof Utilities */ -const lerp = (p, min=0, max=1)=> min + clamp(p) * (max-min); - -/** Applies smoothstep function to the percentage value - * @param {Number} value - * @return {Number} - * @memberof Utilities */ -const smoothStep = (p)=> p * p * (3 - 2 * p); - -/** Returns the nearest power of two not less then the value - * @param {Number} value - * @return {Number} - * @memberof Utilities */ -const nearestPowerOfTwo = (v)=> 2**Math.ceil(Math.log2(v)); - -/** Returns true if two axis aligned bounding boxes are overlapping - * @param {Vector2} pointA - Center of box A - * @param {Vector2} sizeA - Size of box A - * @param {Vector2} pointB - Center of box B - * @param {Vector2} [sizeB] - Size of box B - * @return {Boolean} - True if overlapping - * @memberof Utilities */ -const isOverlapping = (pA, sA, pB, sB)=> abs(pA.x - pB.x)*2 < sA.x + sB.x & abs(pA.y - pB.y)*2 < sA.y + sB.y; - -/** Returns an oscillating wave between 0 and amplitude with frequency of 1 Hz by default - * @param {Number} [frequency=1] - Frequency of the wave in Hz - * @param {Number} [amplitude=1] - Amplitude (max height) of the wave - * @param {Number} [t=time] - Value to use for time of the wave - * @return {Number} - Value waving between 0 and amplitude - * @memberof Utilities */ -const wave = (frequency=1, amplitude=1, t=time)=> amplitude/2 * (1 - Math.cos(t*frequency*2*PI)); - -/** Formats seconds to mm:ss style for display purposes - * @param {Number} t - time in seconds - * @return {String} - * @memberof Utilities */ -const formatTime = (t)=> (t/60|0)+':'+(t%60<10?'0':'')+(t%60|0); - -/////////////////////////////////////////////////////////////////////////////// - -/** Random global functions - * @namespace Random */ - -/** Returns a random value between the two values passed in - * @param {Number} [valueA=1] - * @param {Number} [valueB=0] - * @return {Number} - * @memberof Random */ -const rand = (a=1, b=0)=> b + (a-b)*Math.random(); - -/** Returns a floored random value the two values passed in - * @param {Number} [valueA=1] - * @param {Number} [valueB=0] - * @return {Number} - * @memberof Random */ -const randInt = (a=1, b=0)=> rand(a,b)|0; - -/** Randomly returns either -1 or 1 - * @return {Number} - * @memberof Random */ -const randSign = ()=> (rand(2)|0) * 2 - 1; - -/** Returns a random Vector2 within a circular shape - * @param {Number} [radius=1] - * @param {Number} [minRadius=0] - * @return {Vector2} - * @memberof Random */ -const randInCircle = (radius=1, minRadius=0)=> radius > 0 ? randVector(radius * rand(minRadius / radius, 1)**.5) : new Vector2; - -/** Returns a random Vector2 with the passed in length - * @param {Number} [length=1] - * @return {Vector2} - * @memberof Random */ -const randVector = (length=1)=> new Vector2().setAngle(rand(2*PI), length); - -/** Returns a random color between the two passed in colors, combine components if linear - * @param {Color} [colorA=new Color(1,1,1,1)] - * @param {Color} [colorB=new Color(0,0,0,1)] - * @param {Boolean} [linear] - * @return {Color} - * @memberof Random */ -const randColor = (cA = new Color, cB = new Color(0,0,0,1), linear)=> - linear ? cA.lerp(cB, rand()) : new Color(rand(cA.r,cB.r),rand(cA.g,cB.g),rand(cA.b,cB.b),rand(cA.a,cB.a)); - -/** The seed used by the randSeeded function, should not be 0 - * @memberof Random */ -let randSeed = 1; - -/** Returns a seeded random value between the two values passed in using randSeed - * @param {Number} [valueA=1] - * @param {Number} [valueB=0] - * @return {Number} - * @memberof Random */ -const randSeeded = (a=1, b=0)=> -{ - randSeed ^= randSeed << 13; randSeed ^= randSeed >>> 17; randSeed ^= randSeed << 5; // xorshift - return b + (a-b) * abs(randSeed % 1e9) / 1e9; -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Create a 2d vector, can take another Vector2 to copy, 2 scalars, or 1 scalar - * @param {Number} [x=0] - * @param {Number} [y=0] - * @return {Vector2} - * @example - * let a = vec2(0, 1); // vector with coordinates (0, 1) - * let b = vec2(a); // copy a into b - * a = vec2(5); // set a to (5, 5) - * b = vec2(); // set b to (0, 0) - * @memberof Utilities - */ -const vec2 = (x=0, y)=> x.x == undefined ? new Vector2(x, y == undefined? x : y) : new Vector2(x.x, x.y); - -/** - * 2D Vector object with vector math library - *
- Functions do not change this so they can be chained together - * @example - * let a = new Vector2(2, 3); // vector with coordinates (2, 3) - * let b = new Vector2; // vector with coordinates (0, 0) - * let c = vec2(4, 2); // use the vec2 function to make a Vector2 - * let d = a.add(b).scale(5); // operators can be chained - */ -class Vector2 -{ - /** Create a 2D vector with the x and y passed in, can also be created with vec2() - * @param {Number} [x=0] - X axis location - * @param {Number} [y=0] - Y axis location */ - constructor(x=0, y=0) - { - /** @property {Number} - X axis location */ - this.x = x; - /** @property {Number} - Y axis location */ - this.y = y; - } - - /** Returns a new vector that is a copy of this - * @return {Vector2} */ - copy() { return new Vector2(this.x, this.y); } - - /** Returns a copy of this vector plus the vector passed in - * @param {Vector2} vector - * @return {Vector2} */ - add(v) { ASSERT(v.x!=undefined); return new Vector2(this.x + v.x, this.y + v.y); } - - /** Returns a copy of this vector minus the vector passed in - * @param {Vector2} vector - * @return {Vector2} */ - subtract(v) { ASSERT(v.x!=undefined); return new Vector2(this.x - v.x, this.y - v.y); } - - /** Returns a copy of this vector times the vector passed in - * @param {Vector2} vector - * @return {Vector2} */ - multiply(v) { ASSERT(v.x!=undefined); return new Vector2(this.x * v.x, this.y * v.y); } - - /** Returns a copy of this vector divided by the vector passed in - * @param {Vector2} vector - * @return {Vector2} */ - divide(v) { ASSERT(v.x!=undefined); return new Vector2(this.x / v.x, this.y / v.y); } - - /** Returns a copy of this vector scaled by the vector passed in - * @param {Number} scale - * @return {Vector2} */ - scale(s) { ASSERT(s.x==undefined); return new Vector2(this.x * s, this.y * s); } - - /** Returns the length of this vector - * @return {Number} */ - length() { return this.lengthSquared()**.5; } - - /** Returns the length of this vector squared - * @return {Number} */ - lengthSquared() { return this.x**2 + this.y**2; } - - /** Returns the distance from this vector to vector passed in - * @param {Vector2} vector - * @return {Number} */ - distance(v) { return this.distanceSquared(v)**.5; } - - /** Returns the distance squared from this vector to vector passed in - * @param {Vector2} vector - * @return {Number} */ - distanceSquared(v) { return (this.x - v.x)**2 + (this.y - v.y)**2; } - - /** Returns a new vector in same direction as this one with the length passed in - * @param {Number} [length=1] - * @return {Vector2} */ - normalize(length=1) { const l = this.length(); return l ? this.scale(length/l) : new Vector2(0, length); } - - /** Returns a new vector clamped to length passed in - * @param {Number} [length=1] - * @return {Vector2} */ - clampLength(length=1) { const l = this.length(); return l > length ? this.scale(length/l) : this; } - - /** Returns the dot product of this and the vector passed in - * @param {Vector2} vector - * @return {Number} */ - dot(v) { ASSERT(v.x!=undefined); return this.x*v.x + this.y*v.y; } - - /** Returns the cross product of this and the vector passed in - * @param {Vector2} vector - * @return {Number} */ - cross(v) { ASSERT(v.x!=undefined); return this.x*v.y - this.y*v.x; } - - /** Returns the angle of this vector, up is angle 0 - * @return {Number} */ - angle() { return Math.atan2(this.x, this.y); } - - /** Sets this vector with angle and length passed in - * @param {Number} [angle=0] - * @param {Number} [length=1] */ - setAngle(a=0, length=1) { this.x = length*Math.sin(a); this.y = length*Math.cos(a); return this; } - - /** Returns copy of this vector rotated by the angle passed in - * @param {Number} angle - * @return {Vector2} */ - rotate(a) { const c = Math.cos(a), s = Math.sin(a); return new Vector2(this.x*c-this.y*s, this.x*s+this.y*c); } - - /** Returns the integer direction of this vector, corrosponding to multiples of 90 degree rotation (0-3) - * @return {Number} */ - direction() { return abs(this.x) > abs(this.y) ? this.x < 0 ? 3 : 1 : this.y < 0 ? 2 : 0; } - - /** Returns a copy of this vector that has been inverted - * @return {Vector2} */ - invert() { return new Vector2(this.y, -this.x); } - - /** Returns a copy of this vector with each axis floored - * @return {Vector2} */ - floor() { return new Vector2(Math.floor(this.x), Math.floor(this.y)); } - - /** Returns the area this vector covers as a rectangle - * @return {Number} */ - area() { return abs(this.x * this.y); } - - /** Returns a new vector that is p percent between this and the vector passed in - * @param {Vector2} vector - * @param {Number} percent - * @return {Vector2} */ - lerp(v, p) { ASSERT(v.x!=undefined); return this.add(v.subtract(this).scale(clamp(p))); } - - /** Returns true if this vector is within the bounds of an array size passed in - * @param {Vector2} arraySize - * @return {Boolean} */ - arrayCheck(arraySize) { return this.x >= 0 && this.y >= 0 && this.x < arraySize.x && this.y < arraySize.y; } - - /** Returns this vector expressed as a string - * @param {float} digits - precision to display - * @return {String} */ - toString(digits=3) - { return `(${(this.x<0?'':' ') + this.x.toFixed(digits)},${(this.y<0?'':' ') + this.y.toFixed(digits)} )`; } -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Color object (red, green, blue, alpha) with some helpful functions - * @example - * let a = new Color; // white - * let b = new Color(1, 0, 0); // red - * let c = new Color(0, 0, 0, 0); // transparent black - */ -class Color -{ - /** Create a color with the components passed in, white by default - * @param {Number} [red=1] - * @param {Number} [green=1] - * @param {Number} [blue=1] - * @param {Number} [alpha=1] */ - constructor(r=1, g=1, b=1, a=1) - { - /** @property {Number} - Red */ - this.r = r; - /** @property {Number} - Green */ - this.g = g; - /** @property {Number} - Blue */ - this.b = b; - /** @property {Number} - Alpha */ - this.a = a; - } - - /** Returns a new color that is a copy of this - * @return {Color} */ - copy() { return new Color(this.r, this.g, this.b, this.a); } - - /** Returns a copy of this color plus the color passed in - * @param {Color} color - * @return {Color} */ - add(c) { return new Color(this.r+c.r, this.g+c.g, this.b+c.b, this.a+c.a); } - - /** Returns a copy of this color minus the color passed in - * @param {Color} color - * @return {Color} */ - subtract(c) { return new Color(this.r-c.r, this.g-c.g, this.b-c.b, this.a-c.a); } - - /** Returns a copy of this color times the color passed in - * @param {Color} color - * @return {Color} */ - multiply(c) { return new Color(this.r*c.r, this.g*c.g, this.b*c.b, this.a*c.a); } - - /** Returns a copy of this color divided by the color passed in - * @param {Color} color - * @return {Color} */ - divide(c) { return new Color(this.r/c.r, this.g/c.g, this.b/c.b, this.a/c.a); } - - /** Returns a copy of this color scaled by the value passed in, alpha can be scaled separately - * @param {Number} scale - * @param {Number} [alphaScale=scale] - * @return {Color} */ - scale(s, a=s) { return new Color(this.r*s, this.g*s, this.b*s, this.a*a); } - - /** Returns a copy of this color clamped to the valid range between 0 and 1 - * @return {Color} */ - clamp() { return new Color(clamp(this.r), clamp(this.g), clamp(this.b), clamp(this.a)); } - - /** Returns a new color that is p percent between this and the color passed in - * @param {Color} color - * @param {Number} percent - * @return {Color} */ - lerp(c, p) { return this.add(c.subtract(this).scale(clamp(p))); } - - /** Sets this color given a hue, saturation, lightness, and alpha - * @param {Number} [hue=0] - * @param {Number} [saturation=0] - * @param {Number} [lightness=1] - * @param {Number} [alpha=1] - * @return {Color} */ - setHSLA(h=0, s=0, l=1, a=1) - { - const q = l < .5 ? l*(1+s) : l+s-l*s, p = 2*l-q, - f = (p, q, t)=> - (t = ((t%1)+1)%1) < 1/6 ? p+(q-p)*6*t : - t < 1/2 ? q : - t < 2/3 ? p+(q-p)*(2/3-t)*6 : p; - - this.r = f(p, q, h + 1/3); - this.g = f(p, q, h); - this.b = f(p, q, h - 1/3); - this.a = a; - return this; - } - - /** Returns this color expressed in hsla format - * @return {Array} */ - getHSLA() - { - const r = this.r; - const g = this.g; - const b = this.b; - const a = this.a; - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const l = (max + min) / 2; - - let h = 0, s = 0; - if (max != min) - { - let d = max - min; - s = l > .5 ? d / (2 - max - min) : d / (max + min); - if (r == max) - h = (g - b) / d + (g < b ? 6 : 0); - else if (g == max) - h = (b - r) / d + 2; - else if (b == max) - h = (r - g) / d + 4; - } - - return [h / 6, s, l, a]; - } - - /** Returns a new color that has each component randomly adjusted - * @param {Number} [amount=.05] - * @param {Number} [alphaAmount=0] - * @return {Color} */ - mutate(amount=.05, alphaAmount=0) - { - return new Color - ( - this.r + rand(amount, -amount), - this.g + rand(amount, -amount), - this.b + rand(amount, -amount), - this.a + rand(alphaAmount, -alphaAmount) - ).clamp(); - } - - /** Returns this color expressed as an CSS color value - * @return {String} */ - toString() - { - ASSERT(this.r>=0 && this.r<=1 && this.g>=0 && this.g<=1 && this.b>=0 && this.b<=1 && this.a>=0 && this.a<=1); - return `rgb(${this.r*255|0},${this.g*255|0},${this.b*255|0},${this.a})`; - } - - /** Returns this color expressed as 32 bit integer RGBA value - * @return {Number} */ - rgbaInt() - { - ASSERT(this.r>=0 && this.r<=1 && this.g>=0 && this.g<=1 && this.b>=0 && this.b<=1 && this.a>=0 && this.a<=1); - return (this.r*255|0) + (this.g*255<<8) + (this.b*255<<16) + (this.a*255<<24); - } - - /** Set this color from a hex code - * @param {String} hex - html hex code - * @return {Color} */ - setHex(hex) - { - const fromHex = (a)=> parseInt(hex.slice(a,a+2), 16)/255; - this.r = fromHex(1); - this.g = fromHex(3), - this.b = fromHex(5); - this.a = 1; - ASSERT(this.r>=0 && this.r<=1 && this.g>=0 && this.g<=1 && this.b>=0 && this.b<=1); - return this; - } - - /** Returns this color expressed as a hex code - * @return {String} */ - getHex() - { - const toHex = (c)=> ((c=c*255|0)<16 ? '0' : '') + c.toString(16); - return '#' + toHex(this.r) + toHex(this.g) + toHex(this.b); - } -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Timer object tracks how long has passed since it was set - * @example - * let a = new Timer; // creates a timer that is not set - * a.set(3); // sets the timer to 3 seconds - * - * let b = new Timer(1); // creates a timer with 1 second left - * b.unset(); // unsets the timer - */ -class Timer -{ - /** Create a timer object set time passed in - * @param {Number} [timeLeft] - How much time left before the timer elapses in seconds */ - constructor(timeLeft) { this.time = timeLeft == undefined ? undefined : time + timeLeft; this.setTime = timeLeft; } - - /** Set the timer with seconds passed in - * @param {Number} [timeLeft=0] - How much time left before the timer is elapsed in seconds */ - set(timeLeft=0) { this.time = time + timeLeft; this.setTime = timeLeft; } - - /** Unset the timer */ - unset() { this.time = undefined; } - - /** Returns true if set - * @return {Boolean} */ - isSet() { return this.time != undefined; } - - /** Returns true if set and has not elapsed - * @return {Boolean} */ - active() { return time <= this.time; } - - /** Returns true if set and elapsed - * @return {Boolean} */ - elapsed() { return time > this.time; } - - /** Get how long since elapsed, returns 0 if not set (returns negative if currently active) - * @return {Number} */ - get() { return this.isSet()? time - this.time : 0; } - - /** Get percentage elapsed based on time it was set to, returns 0 if not set - * @return {Number} */ - getPercent() { return this.isSet()? percent(this.time - time, this.setTime, 0) : 0; } - - /** Returns this timer expressed as a string - * @return {String} */ - toString() { if (debug) { return this.unset() ? 'unset' : Math.abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ); } } -} -/** - * LittleJS Engine Settings - * @namespace Settings - */ - -'use strict'; - -/////////////////////////////////////////////////////////////////////////////// -// Display settings - -/** The max size of the canvas, centered if window is larger - * @type {Vector2} - * @default - * @memberof Settings */ -let canvasMaxSize = vec2(1920, 1200); - -/** Fixed size of the canvas, if enabled canvas size never changes - * - you may also need to set mainCanvasSize if using screen space coords in startup - * @type {Vector2} - * @default - * @memberof Settings */ -let canvasFixedSize = vec2(); - -/** Disables anti aliasing for pixel art if true - * @default - * @memberof Settings */ -let cavasPixelated = 1; - -/** Default font used for text rendering - * @default - * @memberof Settings */ -let fontDefault = 'arial'; - -/////////////////////////////////////////////////////////////////////////////// -// Tile sheet settings - -/** Default size of tiles in pixels - * @type {Vector2} - * @default - * @memberof Settings */ -let tileSizeDefault = vec2(16); - -/** Prevent tile bleeding from neighbors in pixels - * @default - * @memberof Settings */ -let tileFixBleedScale = .3; - -/////////////////////////////////////////////////////////////////////////////// -// Object settings - -/** Default size of objects - * @type {Vector2} - * @default - * @memberof Settings */ -let objectDefaultSize = vec2(1); - -/** Enable physics solver for collisions between objects - * @default - * @memberof Settings */ -let enablePhysicsSolver = 1; - -/** Default object mass for collison calcuations (how heavy objects are) - * @default - * @memberof Settings */ -let objectDefaultMass = 1; - -/** How much to slow velocity by each frame (0-1) - * @default - * @memberof Settings */ -let objectDefaultDamping = .99; - -/** How much to slow angular velocity each frame (0-1) - * @default - * @memberof Settings */ -let objectDefaultAngleDamping = .99; - -/** How much to bounce when a collision occurs (0-1) - * @default - * @memberof Settings */ -let objectDefaultElasticity = 0; - -/** How much to slow when touching (0-1) - * @default - * @memberof Settings */ -let objectDefaultFriction = .8; - -/** Clamp max speed to avoid fast objects missing collisions - * @default - * @memberof Settings */ -let objectMaxSpeed = 1; - -/** How much gravity to apply to objects along the Y axis, negative is down - * @default - * @memberof Settings */ -let gravity = 0; - -/** Scales emit rate of particles, useful for low graphics mode (0 disables particle emitters) - * @default - * @memberof Settings */ -let particleEmitRateScale = 1; - -/////////////////////////////////////////////////////////////////////////////// -// Camera settings - -/** Position of camera in world space - * @type {Vector2} - * @default - * @memberof Settings */ -let cameraPos = vec2(); - -/** Scale of camera in world space - * @default - * @memberof Settings */ -let cameraScale = max(tileSizeDefault.x, tileSizeDefault.y); - -/////////////////////////////////////////////////////////////////////////////// -// WebGL settings - -/** Enable webgl rendering, webgl can be disabled and removed from build (with some features disabled) - * @default - * @memberof Settings */ -let glEnable = 1; - -/** Fixes slow rendering in some browsers by not compositing the WebGL canvas - * @default - * @memberof Settings */ -let glOverlay = 1; - -/////////////////////////////////////////////////////////////////////////////// -// Input settings - -/** Should gamepads be allowed - * @default - * @memberof Settings */ -let gamepadsEnable = 1; - -/** If true, the dpad input is also routed to the left analog stick (for better accessability) - * @default - * @memberof Settings */ -let gamepadDirectionEmulateStick = 1; - -/** If true the WASD keys are also routed to the direction keys (for better accessability) - * @default - * @memberof Settings */ -let inputWASDEmulateDirection = 1; - -/** True if touch gamepad should appear on mobile devices - *
- Supports left analog stick, 4 face buttons and start button (button 9) - *
- Must be set by end of gameInit to be activated - * @default - * @memberof Settings */ -let touchGamepadEnable = 0; - -/** True if touch gamepad should be analog stick or false to use if 8 way dpad - * @default - * @memberof Settings */ -let touchGamepadAnalog = 1; - -/** Size of virutal gamepad for touch devices in pixels - * @default - * @memberof Settings */ -let touchGamepadSize = 80; - -/** Transparency of touch gamepad overlay - * @default - * @memberof Settings */ -let touchGamepadAlpha = .3; - -/** Allow vibration hardware if it exists - * @default - * @memberof Settings */ -let vibrateEnable = 1; - -/////////////////////////////////////////////////////////////////////////////// -// Audio settings - -/** Volume scale to apply to all sound, music and speech - * @default - * @memberof Settings */ -let soundVolume = .5; - -/** All audio code can be disabled and removed from build - * @default - * @memberof Settings */ -let soundEnable = 1; - -/** Default range where sound no longer plays - * @default - * @memberof Settings */ -let soundDefaultRange = 30; - -/** Default range percent to start tapering off sound (0-1) - * @default - * @memberof Settings */ -let soundDefaultTaper = .7; - -/////////////////////////////////////////////////////////////////////////////// -// Medals settings - -/** How long to show medals for in seconds - * @default - * @memberof Settings */ -let medalDisplayTime = 5; - -/** How quickly to slide on/off medals in seconds - * @default - * @memberof Settings */ -let medalDisplaySlideTime = .5; - -/** Width of medal display - * @default - * @memberof Settings */ -let medalDisplayWidth = 640; - -/** Height of medal display - * @default - * @memberof Settings */ -let medalDisplayHeight = 80; - -/** Size of icon in medal display - * @default - * @memberof Settings */ -let medalDisplayIconSize = 50; -/* - LittleJS - The Tiny JavaScript Game Engine That Can! - MIT License - Copyright 2021 Frank Force - - Engine Features - - Object oriented system with base class engine object - - Base class object handles update, physics, collision, rendering, etc - - Engine helper classes and functions like Vector2, Color, and Timer - - Super fast rendering system for tile sheets - - Sound effects audio with zzfx and music with zzfxm - - Input processing system with gamepad and touchscreen support - - Tile layer rendering and collision system - - Particle effect system - - Medal system tracks and displays achievements - - Debug tools and debug rendering system - - Call engineInit() to start it up! -*/ - -'use strict'; - -/** Name of engine */ -const engineName = 'LittleJS'; - -/** Version of engine */ -const engineVersion = '1.3.7'; - -/** Frames per second to update objects - * @default */ -const frameRate = 60; - -/** How many seconds each frame lasts, engine uses a fixed time step - * @default 1/60 */ -const timeDelta = 1/frameRate; - -/** Array containing all engine objects */ -let engineObjects = []; - -/** Array containing only objects that are set to collide with other objects this frame (for optimization) */ -let engineObjectsCollide = []; - -/** Current update frame, used to calculate time */ -let frame = 0; - -/** Current engine time since start in seconds, derived from frame */ -let time = 0; - -/** Actual clock time since start in seconds (not affected by pause or frame rate clamping) */ -let timeReal = 0; - -/** Is the game paused? Causes time and objects to not be updated. */ -let paused = 0; - -// Engine internal variables not exposed to documentation -let frameTimeLastMS = 0, frameTimeBufferMS = 0, tileImageSize, tileImageFixBleed; - -// Engine stat tracking, if showWatermark is true -let averageFPS, drawCount; - -// css text used for elements created by engine -const styleBody = 'margin:0;overflow:hidden;background:#000' + - ';touch-action:none;user-select:none;-webkit-user-select:none;-moz-user-select:none'; -const styleCanvas = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)'; - -/////////////////////////////////////////////////////////////////////////////// - -/** Start up LittleJS engine with your callback functions - * @param {Function} gameInit - Called once after the engine starts up, setup the game - * @param {Function} gameUpdate - Called every frame at 60 frames per second, handle input and update the game state - * @param {Function} gameUpdatePost - Called after physics and objects are updated, setup camera and prepare for render - * @param {Function} gameRender - Called before objects are rendered, draw any background effects that appear behind objects - * @param {Function} gameRenderPost - Called after objects are rendered, draw effects or hud that appear above all objects - * @param {String} [tileImageSource] - Tile image to use, everything starts when the image is finished loading - */ -function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, tileImageSource) -{ - // init engine when tiles load or fail to load - tileImage.onerror = tileImage.onload = ()=> - { - // save tile image info - tileImageFixBleed = vec2(tileFixBleedScale).divide(tileImageSize = vec2(tileImage.width, tileImage.height)); - debug && (tileImage.onload=()=>ASSERT(1)); // tile sheet can not reloaded - - // setup html - document.body.style = styleBody; - document.body.appendChild(mainCanvas = document.createElement('canvas')); - mainContext = mainCanvas.getContext('2d'); - mainCanvas.style = styleCanvas; - - // init stuff and start engine - debugInit(); - glInit(); - - // create overlay canvas for hud to appear above gl canvas - document.body.appendChild(overlayCanvas = document.createElement('canvas')); - overlayContext = overlayCanvas.getContext('2d'); - overlayCanvas.style = styleCanvas; - - gameInit(); - touchGamepadCreate(); - engineUpdate(); - }; - - // main update loop - const engineUpdate = (frameTimeMS=0)=> - { - // update time keeping - let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS; - frameTimeLastMS = frameTimeMS; - if (debug || showWatermark) - averageFPS = lerp(.05, averageFPS || 0, 1e3/(frameTimeDeltaMS||1)); - if (debug) - frameTimeDeltaMS *= keyIsDown(107) ? 5 : keyIsDown(109) ? .2 : 1; // +/- to speed/slow time - timeReal += frameTimeDeltaMS / 1e3; - frameTimeBufferMS = min(frameTimeBufferMS + !paused * frameTimeDeltaMS, 50); // clamp incase of slow framerate - - if (canvasFixedSize.x) - { - // clear set fixed size - overlayCanvas.width = mainCanvas.width = canvasFixedSize.x; - overlayCanvas.height = mainCanvas.height = canvasFixedSize.y; - - // fit to window by adding space on top or bottom if necessary - const aspect = innerWidth / innerHeight; - const fixedAspect = mainCanvas.width / mainCanvas.height; - mainCanvas.style.width = overlayCanvas.style.width = aspect < fixedAspect ? '100%' : ''; - mainCanvas.style.height = overlayCanvas.style.height = aspect < fixedAspect ? '' : '100%'; - if (glCanvas) - { - glCanvas.style.width = mainCanvas.style.width; - glCanvas.style.height = mainCanvas.style.height; - } - } - else - { - // clear and set size to same as window - overlayCanvas.width = mainCanvas.width = min(innerWidth, canvasMaxSize.x); - overlayCanvas.height = mainCanvas.height = min(innerHeight, canvasMaxSize.y); - } - - // save canvas size - mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); - - if (paused) - { - // do post update even when paused - inputUpdate(); - debugUpdate(); - gameUpdatePost(); - inputUpdatePost(); - } - else - { - // apply time delta smoothing, improves smoothness of framerate in some browsers - let deltaSmooth = 0; - if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9) - { - // force an update each frame if time is close enough (not just a fast refresh rate) - deltaSmooth = frameTimeBufferMS; - frameTimeBufferMS = 0; - } - - // update multiple frames if necessary in case of slow framerate - for (;frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate) - { - // update game and objects - inputUpdate(); - gameUpdate(); - engineObjectsUpdate(); - - // do post update - debugUpdate(); - gameUpdatePost(); - inputUpdatePost(); - } - - // add the time smoothing back in - frameTimeBufferMS += deltaSmooth; - } - - // render sort then render while removing destroyed objects - enginePreRender(); - gameRender(); - engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder); - for (const o of engineObjects) - o.destroyed || o.render(); - gameRenderPost(); - medalsRender(); - touchGamepadRender(); - debugRender(); - glCopyToContext(mainContext); - - if (showWatermark) - { - // update fps - overlayContext.textAlign = 'right'; - overlayContext.textBaseline = 'top'; - overlayContext.font = '1em monospace'; - overlayContext.fillStyle = '#000'; - const text = engineName + ' ' + 'v' + engineVersion + ' / ' - + drawCount + ' / ' + engineObjects.length + ' / ' + averageFPS.toFixed(1) - + ' ' + (glEnable ? 'GL' : '2D') ; - overlayContext.fillText(text, mainCanvas.width-3, 3); - overlayContext.fillStyle = '#fff'; - overlayContext.fillText(text, mainCanvas.width-2, 2); - drawCount = 0; - } - - requestAnimationFrame(engineUpdate); - } - - // set tile image source to load the image and start the engine - tileImageSource ? tileImage.src = tileImageSource : tileImage.onload(); -} - -// called by engine to setup render system -function enginePreRender() -{ - // save canvas size - mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); - - // disable smoothing for pixel art - mainContext.imageSmoothingEnabled = !cavasPixelated; - - // setup gl rendering if enabled - glPreRender(mainCanvas.width, mainCanvas.height, cameraPos.x, cameraPos.y, cameraScale); -} - -/////////////////////////////////////////////////////////////////////////////// - -/** Calls update on each engine object (recursively if child), removes destroyed objects, and updated time */ -function engineObjectsUpdate() -{ - // get list of solid objects for physics optimzation - engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects); - - // recursive object update - const updateObject = (o)=> - { - if (!o.destroyed) - { - o.update(); - for (const child of o.children) - updateObject(child); - } - } - for (const o of engineObjects) - o.parent || updateObject(o); - - // remove destroyed objects - engineObjects = engineObjects.filter(o=>!o.destroyed); - - // increment frame and update time - time = ++frame / frameRate; -} - -/** Destroy and remove all objects */ -function engineObjectsDestroy() -{ - for (const o of engineObjects) - o.parent || o.destroy(); - engineObjects = engineObjects.filter(o=>!o.destroyed); -} - -/** Triggers a callback for each object within a given area - * @param {Vector2} [pos] - Center of test area - * @param {Number} [size] - Radius of circle if float, rectangle size if Vector2 - * @param {Function} [callbackFunction] - Calls this function on every object that passes the test - * @param {Array} [objects=engineObjects] - List of objects to check */ -function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects) -{ - if (!pos) // all objects - { - for (const o of objects) - callbackFunction(o); - } - else if (size.x != undefined) // bounding box test - { - for (const o of objects) - isOverlapping(pos, size, o.pos, o.size) && callbackFunction(o); - } - else // circle test - { - const sizeSquared = size*size; - for (const o of objects) - pos.distanceSquared(o.pos) < sizeSquared && callbackFunction(o); - } -} -/* - LittleJS Object System -*/ - -'use strict'; - -/** - * LittleJS Object Base Object Class - *
- Base object class used by the engine - *
- Automatically adds self to object list - *
- Will be updated and rendered each frame - *
- Renders as a sprite from a tilesheet by default - *
- Can have color and addtive color applied - *
- 2d Physics and collision system - *
- Sorted by renderOrder - *
- Objects can have children attached - *
- Parents are updated before children, and set child transform - *
- Call destroy() to get rid of objects - *
- *
The physics system used by objects is simple and fast with some caveats... - *
- Collision uses the axis aligned size, the object's rotation angle is only for rendering - *
- Objects are guaranteed to not intersect tile collision from physics - *
- If an object starts or is moved inside tile collision, it will not collide with that tile - *
- Collision for objects can be set to be solid to block other objects - *
- Objects may get pushed into overlapping other solid objects, if so they will push away - *
- Solid objects are more performance intensive and should be used sparingly - * @example - * // create an engine object, normally you would first extend the class with your own - * const pos = vec2(2,3); - * const object = new EngineObject(pos); - */ -class EngineObject -{ - /** Create an engine object and adds it to the list of objects - * @param {Vector2} [position=new Vector2()] - World space position of the object - * @param {Vector2} [size=objectDefaultSize] - World space size of the object - * @param {Number} [tileIndex=-1] - Tile to use to render object (-1 is untextured) - * @param {Vector2} [tileSize=tileSizeDefault] - Size of tile in source pixels - * @param {Number} [angle=0] - Angle the object is rotated by - * @param {Color} [color] - Color to apply to tile when rendered - * @param {Number} [renderOrder=0] - Objects sorted by renderOrder before being rendered - */ - constructor(pos=vec2(), size=objectDefaultSize, tileIndex=-1, tileSize=tileSizeDefault, angle=0, color, renderOrder=0) - { - // set passed in params - ASSERT(pos && pos.x != undefined && size.x != undefined); // ensure pos and size are vec2s - - /** @property {Vector2} - World space position of the object */ - this.pos = pos.copy(); - /** @property {Vector2} - World space width and height of the object */ - this.size = size; - /** @property {Vector2} - Size of object used for drawing, uses size if not set */ - this.drawSize; - /** @property {Number} - Tile to use to render object (-1 is untextured) */ - this.tileIndex = tileIndex; - /** @property {Vector2} - Size of tile in source pixels */ - this.tileSize = tileSize; - /** @property {Number} - Angle to rotate the object */ - this.angle = angle; - /** @property {Color} - Color to apply when rendered */ - this.color = color; - /** @property {Color} - Additive color to apply when rendered */ - this.additiveColor; - - // set object defaults - /** @property {Number} [mass=objectDefaultMass] - How heavy the object is, static if 0 */ - this.mass = objectDefaultMass; - /** @property {Number} [damping=objectDefaultDamping] - How much to slow down velocity each frame (0-1) */ - this.damping = objectDefaultDamping; - /** @property {Number} [angleDamping=objectDefaultAngleDamping] - How much to slow down rotation each frame (0-1) */ - this.angleDamping = objectDefaultAngleDamping; - /** @property {Number} [elasticity=objectDefaultElasticity] - How bouncy the object is when colliding (0-1) */ - this.elasticity = objectDefaultElasticity; - /** @property {Number} [friction=objectDefaultFriction] - How much friction to apply when sliding (0-1) */ - this.friction = objectDefaultFriction; - /** @property {Number} [gravityScale=1] - How much to scale gravity by for this object */ - this.gravityScale = 1; - /** @property {Number} [renderOrder=0] - Objects are sorted by render order */ - this.renderOrder = renderOrder; - /** @property {Vector2} [velocity=new Vector2()] - Velocity of the object */ - this.velocity = new Vector2(); - /** @property {Number} [angleVelocity=0] - Angular velocity of the object */ - this.angleVelocity = 0; - - // init other internal object stuff - this.spawnTime = time; - this.children = []; - this.collideTiles = 1; - - // add to list of objects - engineObjects.push(this); - } - - /** Update the object transform and physics, called automatically by engine once each frame */ - update() - { - const parent = this.parent; - if (parent) - { - // copy parent pos/angle - this.pos = this.localPos.multiply(vec2(parent.getMirrorSign(),1)).rotate(-parent.angle).add(parent.pos); - this.angle = parent.getMirrorSign()*this.localAngle + parent.angle; - return; - } - - // limit max speed to prevent missing collisions - this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed); - this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed); - - // apply physics - const oldPos = this.pos.copy(); - this.pos.x += this.velocity.x = this.damping * this.velocity.x; - this.pos.y += this.velocity.y = this.damping * this.velocity.y + gravity * this.gravityScale; - this.angle += this.angleVelocity *= this.angleDamping; - - // physics sanity checks - ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1); - ASSERT(this.damping >= 0 && this.damping <= 1); - - if (!enablePhysicsSolver || !this.mass) // do not update collision for fixed objects - return; - - const wasMovingDown = this.velocity.y < 0; - if (this.groundObject) - { - // apply friction in local space of ground object - const groundSpeed = this.groundObject.velocity ? this.groundObject.velocity.x : 0; - this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * this.friction; - this.groundObject = 0; - //debugOverlay && debugPhysics && debugPoint(this.pos.subtract(vec2(0,this.size.y/2)), '#0f0'); - } - - if (this.collideSolidObjects) - { - // check collisions against solid objects - const epsilon = 1e-3; // necessary to push slightly outside of the collision - for (const o of engineObjectsCollide) - { - // non solid objects don't collide with eachother - if (!this.isSolid & !o.isSolid || o.destroyed || o.parent || o == this) - continue; - - // check collision - if (!isOverlapping(this.pos, this.size, o.pos, o.size)) - continue; - - // pass collision to objects - if (!this.collideWithObject(o) | !o.collideWithObject(this)) - continue; - - if (isOverlapping(oldPos, this.size, o.pos, o.size)) - { - // if already was touching, try to push away - const deltaPos = oldPos.subtract(o.pos); - const length = deltaPos.length(); - const pushAwayAccel = .001; // push away if already overlapping - const velocity = length < .01 ? randVector(pushAwayAccel) : deltaPos.scale(pushAwayAccel/length); - this.velocity = this.velocity.add(velocity); - if (o.mass) // push away if not fixed - o.velocity = o.velocity.subtract(velocity); - - debugOverlay && debugPhysics && debugAABB(this.pos, this.size, o.pos, o.size, '#f00'); - continue; - } - - // check for collision - const sizeBoth = this.size.add(o.size); - const smallStepUp = (oldPos.y - o.pos.y)*2 > sizeBoth.y + gravity; // prefer to push up if small delta - const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y; - const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x; - - if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision - { - // push outside object collision - this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y); - if (o.groundObject && wasMovingDown || !o.mass) - { - // set ground object if landed on something - if (wasMovingDown) - this.groundObject = o; - - // bounce if other object is fixed or grounded - this.velocity.y *= -this.elasticity; - } - else if (o.mass) - { - // inelastic collision - const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass); - - // elastic collision - const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass) - + o.velocity.y * 2 * o.mass / (this.mass + o.mass); - const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass) - + this.velocity.y * 2 * this.mass / (this.mass + o.mass); - - // lerp betwen elastic or inelastic based on elasticity - const elasticity = max(this.elasticity, o.elasticity); - this.velocity.y = lerp(elasticity, inelastic, elastic0); - o.velocity.y = lerp(elasticity, inelastic, elastic1); - } - } - if (!smallStepUp && (isBlockedX || !isBlockedY)) // resolve x collision - { - // push outside collision - this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x); - if (o.mass) - { - // inelastic collision - const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass); - - // elastic collision - const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass) - + o.velocity.x * 2 * o.mass / (this.mass + o.mass); - const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass) - + this.velocity.x * 2 * this.mass / (this.mass + o.mass); - - // lerp betwen elastic or inelastic based on elasticity - const elasticity = max(this.elasticity, o.elasticity); - this.velocity.x = lerp(elasticity, inelastic, elastic0); - o.velocity.x = lerp(elasticity, inelastic, elastic1); - } - else // bounce if other object is fixed - this.velocity.x *= -this.elasticity; - } - debugOverlay && debugPhysics && debugAABB(this.pos, this.size, o.pos, o.size, '#f0f'); - } - } - if (this.collideTiles) - { - // check collision against tiles - if (tileCollisionTest(this.pos, this.size, this)) - { - // if already was stuck in collision, don't do anything - // this should not happen unless something starts in collision - if (!tileCollisionTest(oldPos, this.size, this)) - { - // test which side we bounced off (or both if a corner) - const isBlockedY = tileCollisionTest(new Vector2(oldPos.x, this.pos.y), this.size, this); - const isBlockedX = tileCollisionTest(new Vector2(this.pos.x, oldPos.y), this.size, this); - if (isBlockedY || !isBlockedX) - { - // set if landed on ground - this.groundObject = wasMovingDown; - - // bounce velocity - this.velocity.y *= -this.elasticity; - - // adjust next velocity to settle on ground - const o = (oldPos.y - this.size.y/2|0) - (oldPos.y - this.size.y/2); - if (o < 0 && o > this.damping * this.velocity.y + gravity * this.gravityScale) - this.velocity.y = this.damping ? (o - gravity * this.gravityScale) / this.damping : 0; - - // move to previous position - this.pos.y = oldPos.y; - } - if (isBlockedX) - { - // move to previous position and bounce - this.pos.x = oldPos.x; - this.velocity.x *= -this.elasticity; - } - } - } - } - } - - /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */ - render() - { - // default object render - drawTile(this.pos, this.drawSize || this.size, this.tileIndex, this.tileSize, this.color, this.angle, this.mirror, this.additiveColor); - } - - /** Destroy this object, destroy it's children, detach it's parent, and mark it for removal */ - destroy() - { - if (this.destroyed) - return; - - // disconnect from parent and destroy chidren - this.destroyed = 1; - this.parent && this.parent.removeChild(this); - for (const child of this.children) - child.destroy(child.parent = 0); - } - - /** Called to check if a tile collision should be resolved - * @param {Number} tileData - the value of the tile at the position - * @param {Vector2} pos - tile where the collision occured - * @return {Boolean} - true if the collision should be resolved */ - collideWithTile(tileData, pos) { return tileData > 0; } - - /** Called to check if a tile raycast hit - * @param {Number} tileData - the value of the tile at the position - * @param {Vector2} pos - tile where the raycast is - * @return {Boolean} - true if the raycast should hit */ - collideWithTileRaycast(tileData, pos) { return tileData > 0; } - - /** Called to check if a tile raycast hit - * @param {EngineObject} object - the object to test against - * @return {Boolean} - true if the collision should be resolved - */ - collideWithObject(o) { return 1; } - - /** How long since the object was created - * @return {Number} */ - getAliveTime() { return time - this.spawnTime; } - - /** Apply acceleration to this object (adjust velocity, not affected by mass) - * @param {Vector2} acceleration */ - applyAcceleration(a) { if (this.mass) this.velocity = this.velocity.add(a); } - - /** Apply force to this object (adjust velocity, affected by mass) - * @param {Vector2} force */ - applyForce(force) { this.applyAcceleration(force.scale(1/this.mass)); } - - /** Get the direction of the mirror - * @return {Number} -1 if this.mirror is true, or 1 if not mirrored */ - getMirrorSign() { return this.mirror ? -1 : 1; } - - /** Attaches a child to this with a given local transform - * @param {EngineObject} child - * @param {Vector2} [localPos=new Vector2] - * @param {Number} [localAngle=0] */ - addChild(child, localPos=vec2(), localAngle=0) - { - ASSERT(!child.parent && !this.children.includes(child)); - this.children.push(child); - child.parent = this; - child.localPos = localPos.copy(); - child.localAngle = localAngle; - } - - /** Removes a child from this one - * @param {EngineObject} child */ - removeChild(child) - { - ASSERT(child.parent == this && this.children.includes(child)); - this.children.splice(this.children.indexOf(child), 1); - child.parent = 0; - } - - /** Set how this object collides - * @param {boolean} [collideSolidObjects=0] - Does it collide with solid objects - * @param {boolean} [isSolid=0] - Does it collide with and block other objects (expensive in large numbers) - * @param {boolean} [collideTiles=1] - Does it collide with the tile collision */ - setCollision(collideSolidObjects=0, isSolid=0, collideTiles=1) - { - ASSERT(collideSolidObjects || !isSolid); // solid objects must be set to collide - - this.collideSolidObjects = collideSolidObjects; - this.isSolid = isSolid; - this.collideTiles = collideTiles; - } - - toString() - { - if (debug) - { - let text = 'type = ' + this.constructor.name; - if (this.pos.x || this.pos.y) - text += '\npos = ' + this.pos; - if (this.velocity.x || this.velocity.y) - text += '\nvelocity = ' + this.velocity; - if (this.size.x || this.size.y) - text += '\nsize = ' + this.size; - if (this.angle) - text += '\nangle = ' + this.angle.toFixed(3); - if (this.color) - text += '\ncolor = ' + this.color; - return text; - } - } -} -/** - * LittleJS Drawing System - *
- Hybrid with both Canvas2D and WebGL available - *
- Super fast tile sheet rendering with WebGL - *
- Can apply rotation, mirror, color and additive color - *
- Many useful utility functions - *
- *
LittleJS uses a hybrid rendering solution with the best of both Canvas2D and WebGL. - *
There are 3 canvas/contexts available to draw to... - *
- mainCanvas - 2D background canvas, non WebGL stuff like tile layers are drawn here. - *
- glCanvas - Used by the accelerated WebGL batch rendering system. - *
- overlayCanvas - Another 2D canvas that appears on top of the other 2 canvases. - *
- *
The WebGL rendering system is very fast with some caveats... - *
- The default setup supports only 1 tile sheet, to support more call glCreateTexture and glSetTexture - *
- Switching blend modes (additive) or textures causes another draw call which is expensive in excess - *
- Group additive rendering together using renderOrder to mitigate this issue - *
- *
The LittleJS rendering solution is intentionally simple, feel free to adjust it for your needs! - * @namespace Draw - */ - -'use strict'; - -/** Tile sheet for batch rendering system - * @type {Image} - * @memberof Draw */ -const tileImage = new Image(); - -/** The primary 2D canvas visible to the user - * @type {HTMLCanvasElement} - * @memberof Draw */ -let mainCanvas; - -/** 2d context for mainCanvas - * @type {CanvasRenderingContext2D} - * @memberof Draw */ -let mainContext; - -/** A canvas that appears on top of everything the same size as mainCanvas - * @type {HTMLCanvasElement} - * @memberof Draw */ -let overlayCanvas; - -/** 2d context for overlayCanvas - * @type {CanvasRenderingContext2D} - * @memberof Draw */ -let overlayContext; - -/** The size of the main canvas (and other secondary canvases) - * @type {Vector2} - * @memberof Draw */ -let mainCanvasSize = vec2(); - -/** Convert from screen to world space coordinates - * - if calling outside of render, you may need to manually set mainCanvasSize - * @param {Vector2} screenPos - * @return {Vector2} - * @memberof Draw */ -const screenToWorld = (screenPos)=> -{ - ASSERT(mainCanvasSize.x && mainCanvasSize.y, 'mainCanvasSize is invalid'); - return screenPos.add(vec2(.5)).subtract(mainCanvasSize.scale(.5)).multiply(vec2(1/cameraScale,-1/cameraScale)).add(cameraPos); -} - -/** Convert from world to screen space coordinates - * - if calling outside of render, you may need to manually set mainCanvasSize - * @param {Vector2} worldPos - * @return {Vector2} - * @memberof Draw */ -const worldToScreen = (worldPos)=> -{ - ASSERT(mainCanvasSize.x && mainCanvasSize.y, 'mainCanvasSize is invalid'); - return worldPos.subtract(cameraPos).multiply(vec2(cameraScale,-cameraScale)).add(mainCanvasSize.scale(.5)).subtract(vec2(.5)); -} - -/** Draw textured tile centered in world space, with color applied if using WebGL - * @param {Vector2} pos - Center of the tile in world space - * @param {Vector2} [size=new Vector2(1,1)] - Size of the tile in world space, width and height - * @param {Number} [tileIndex=-1] - Tile index to use, negative is untextured - * @param {Vector2} [tileSize=tileSizeDefault] - Tile size in source pixels - * @param {Color} [color=new Color(1,1,1)] - Color to modulate with - * @param {Number} [angle=0] - Angle to rotate by - * @param {Boolean} [mirror=0] - If true image is flipped along the Y axis - * @param {Color} [additiveColor=new Color(0,0,0,0)] - Additive color to be applied - * @param {Boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering - * @memberof Draw */ -function drawTile(pos, size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, color=new Color, angle=0, mirror, - additiveColor=new Color(0,0,0,0), useWebGL=glEnable) -{ - showWatermark && ++drawCount; - if (glEnable && useWebGL) - { - if (tileIndex < 0 || !tileImage.width) - { - // if negative tile index or image not found, force untextured - glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, color.rgbaInt()); - } - else - { - // calculate uvs and render - const cols = tileImageSize.x / tileSize.x |0; - const uvSizeX = tileSize.x / tileImageSize.x; - const uvSizeY = tileSize.y / tileImageSize.y; - const uvX = (tileIndex%cols)*uvSizeX, uvY = (tileIndex/cols|0)*uvSizeY; - - glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle, - uvX + tileImageFixBleed.x, uvY + tileImageFixBleed.y, - uvX - tileImageFixBleed.x + uvSizeX, uvY - tileImageFixBleed.y + uvSizeY, - color.rgbaInt(), additiveColor.rgbaInt()); - } - } - else - { - // normal canvas 2D rendering method (slower) - drawCanvas2D(pos, size, angle, mirror, (context)=> - { - if (tileIndex < 0) - { - // if negative tile index, force untextured - context.fillStyle = color; - context.fillRect(-.5, -.5, 1, 1); - } - else - { - // calculate uvs and render - const cols = tileImageSize.x / tileSize.x |0; - const sX = (tileIndex%cols)*tileSize.x + tileFixBleedScale; - const sY = (tileIndex/cols|0)*tileSize.y + tileFixBleedScale; - const sWidth = tileSize.x - 2*tileFixBleedScale; - const sHeight = tileSize.y - 2*tileFixBleedScale; - context.globalAlpha = color.a; // only alpha is supported - context.drawImage(tileImage, sX, sY, sWidth, sHeight, -.5, -.5, 1, 1); - } - }); - } -} - -/** Draw colored rect centered on pos - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [angle=0] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function drawRect(pos, size, color, angle, useWebGL) -{ - drawTile(pos, size, -1, tileSizeDefault, color, angle, 0, 0, useWebGL); -} - -/** Draw textured tile centered on pos in screen space - * @param {Vector2} pos - Center of the tile - * @param {Vector2} [size=new Vector2(1,1)] - Size of the tile - * @param {Number} [tileIndex=-1] - Tile index to use, negative is untextured - * @param {Vector2} [tileSize=tileSizeDefault] - Tile size in source pixels - * @param {Color} [color=new Color] - * @param {Number} [angle=0] - * @param {Boolean} [mirror=0] - * @param {Color} [additiveColor=new Color(0,0,0,0)] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function drawTileScreenSpace(pos, size=vec2(1), tileIndex, tileSize, color, angle, mirror, additiveColor, useWebGL) -{ - drawTile(screenToWorld(pos), size.scale(1/cameraScale), tileIndex, tileSize, color, angle, mirror, additiveColor, useWebGL); -} - -/** Draw colored rectangle in screen space - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [angle=0] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function drawRectScreenSpace(pos, size, color, angle, useWebGL) -{ - drawTileScreenSpace(pos, size, -1, tileSizeDefault, color, angle, 0, 0, useWebGL); -} - -/** Draw colored line between two points - * @param {Vector2} posA - * @param {Vector2} posB - * @param {Number} [thickness=.1] - * @param {Color} [color=new Color(1,1,1)] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function drawLine(posA, posB, thickness=.1, color, useWebGL) -{ - const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2); - const size = vec2(thickness, halfDelta.length()*2); - drawRect(posA.add(halfDelta), size, color, halfDelta.angle(), 0, 0, useWebGL); -} - -/** Draw directly to a 2d canvas context in world space - * @param {Vector2} pos - * @param {Vector2} size - * @param {Number} angle - * @param {Boolean} mirror - * @param {Function} drawFunction - * @param {CanvasRenderingContext2D} [context=mainContext] - * @memberof Draw */ -function drawCanvas2D(pos, size, angle, mirror, drawFunction, context = mainContext) -{ - // create canvas transform from world space to screen space - pos = worldToScreen(pos); - size = size.scale(cameraScale); - context.save(); - context.translate(pos.x+.5|0, pos.y-.5|0); - context.rotate(angle); - context.scale(mirror ? -size.x : size.x, size.y); - drawFunction(context); - context.restore(); -} - -/** Enable normal or additive blend mode - * @param {Boolean} [additive=0] - * @param {Boolean} [useWebGL=glEnable] - * @memberof Draw */ -function setBlendMode(additive, useWebGL=glEnable) -{ - if (glEnable && useWebGL) - glSetBlendMode(additive); - else - mainContext.globalCompositeOperation = additive ? 'lighter' : 'source-over'; -} - -/** Draw text on overlay canvas in screen space - * Automatically splits new lines into rows - * @param {String} text - * @param {Vector2} pos - * @param {Number} [size=1] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [lineWidth=0] - * @param {Color} [lineColor=new Color(0,0,0)] - * @param {String} [textAlign='center'] - * @memberof Draw */ -function drawTextScreen(text, pos, size=1, color=new Color, lineWidth=0, lineColor=new Color(0,0,0), textAlign='center', font=fontDefault) -{ - overlayContext.fillStyle = color; - overlayContext.lineWidth = lineWidth; - overlayContext.strokeStyle = lineColor; - overlayContext.textAlign = textAlign; - overlayContext.font = size + 'px '+ font; - overlayContext.textBaseline = 'middle'; - overlayContext.lineJoin = 'round'; - - pos = pos.copy(); - (text+'').split('\n').forEach(line=> - { - lineWidth && overlayContext.strokeText(line, pos.x, pos.y); - overlayContext.fillText(line, pos.x, pos.y); - pos.y += size; - }); -} - -/** Draw text on overlay canvas in world space - * Automatically splits new lines into rows - * @param {String} text - * @param {Vector2} pos - * @param {Number} [size=1] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [lineWidth=0] - * @param {Color} [lineColor=new Color(0,0,0)] - * @param {String} [textAlign='center'] - * @memberof Draw */ -function drawText(text, pos, size=1, color, lineWidth, lineColor, textAlign, font) -{ - drawTextScreen(text, worldToScreen(pos), size*cameraScale, color, lineWidth*cameraScale, lineColor, textAlign, font); -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Font Image Object - Draw text on a 2D canvas by using characters in an image - *
- 96 characters (from space to tilde) are stored in an image - *
- Uses a default 8x8 font if none is supplied - *
- You can also use fonts from the main tile sheet - * @example - * // use built in font - * const font = new ImageFont; - * - * // draw text - * font.drawTextScreen("LittleJS\nHello World!", vec2(200, 50)); - */ - -let engineFontImage; - -class FontImage -{ - /** Create an image font - * @param {Image} [image] - The image the font is stored in, if undefined the default font is used - * @param {Vector2} [tileSize=vec2(8)] - The size of the font source tiles - * @param {Vector2} [paddingSize=vec2(0,1)] - How much extra space to add between characters - * @param {Number} [startTileIndex=0] - Tile index in image where font starts - * @param {CanvasRenderingContext2D} [context=overlayContext] - context to draw to - */ - constructor(image, tileSize=vec2(8), paddingSize=vec2(0,1), startTileIndex=0, context=overlayContext) - { - if (!image && !engineFontImage) - { - // load default font image - engineFontImage = new Image(); - engineFontImage.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAAYAQAAAAA9+x6JAAAAAnRSTlMAAHaTzTgAAAGiSURBVHjaZZABhxxBEIUf6ECLBdFY+Q0PMNgf0yCgsSAGZcT9sgIPtBWwIA5wgAPEoHUyJeeSlW+gjK+fegWwtROWpVQEyWh2npdpBmTUFVhb29RINgLIukoXr5LIAvYQ5ve+1FqWEMqNKTX3FAJHyQDRZvmKWubAACcv5z5Gtg2oyCWE+Yk/8JZQX1jTTCpKAFGIgza+dJCNBF2UskRlsgwitHbSV0QLgt9sTPtsRlvJjEr8C/FARWA2bJ/TtJ7lko34dNDn6usJUMzuErP89UUBJbWeozrwLLncXczd508deAjLWipLO4Q5XGPcJvPu92cNDaN0P5G1FL0nSOzddZOrJ6rNhbXGmeDvO3TF7DeJWl4bvaYQTNHCTeuqKZmbjHaSOFes+IX/+IhHrnAkXOAsfn24EM68XieIECoccD4KZLk/odiwzeo2rovYdhvb2HYFgyznJyDpYJdYOmfXgVdJTaUi4xA2uWYNYec9BLeqdl9EsoTw582mSFDX2DxVLbNt9U3YYoeatBad1c2Tj8t2akrjaIGJNywKB/7h75/gN3vCMSaadIUTAAAAAElFTkSuQmCC'; - } - - this.image = image || engineFontImage; - this.tileSize = tileSize; - this.paddingSize = paddingSize; - this.startTileIndex = startTileIndex; - this.context = context; - } - - /** Draw text in screen space using the image font - * @param {String} text - * @param {Vector2} pos - * @param {Number} [scale=4] - * @param {Boolean} [center] - */ - drawTextScreen(text, pos, scale=4, center) - { - const context = this.context; - context.save(); - context.imageSmoothingEnabled = !cavasPixelated; - - const size = this.tileSize; - const drawSize = size.add(this.paddingSize).scale(scale); - const cols = this.image.width / this.tileSize.x |0; - text.split('\n').forEach((line, i)=> - { - const centerOffset = center ? line.length * size.x * scale / 2 |0 : 0; - for(let j=line.length; j--;) - { - // draw each character - let charCode = line[j].charCodeAt(); - if (charCode < 32 || charCode > 127) - charCode = 127; // unknown character - - // get the character source location and draw it - const tile = this.startTileIndex + charCode - 32; - const x = tile % cols; - const y = tile / cols |0; - const drawPos = pos.add(vec2(j,i).multiply(drawSize)); - context.drawImage(this.image, x * size.x, y * size.y, size.x, size.y, - drawPos.x - centerOffset, drawPos.y, size.x * scale, size.y * scale); - } - }); - - context.restore(); - } - - /** Draw text in world space using the image font - * @param {String} text - * @param {Vector2} pos - * @param {Number} [scale=.25] - * @param {Boolean} [center] - */ - drawText(text, pos, scale=1, center) - { - this.drawTextScreen(text, worldToScreen(pos).floor(), scale*cameraScale|0, center); - } -} - -/////////////////////////////////////////////////////////////////////////////// -// Fullscreen mode - -/** Returns true if fullscreen mode is active - * @return {Boolean} - * @memberof Draw */ -const isFullscreen =()=> document.fullscreenElement; - -/** Toggle fullsceen mode - * @memberof Draw */ -function toggleFullscreen() -{ - if (isFullscreen()) - { - if (document.exitFullscreen) - document.exitFullscreen(); - else if (document.mozCancelFullScreen) - document.mozCancelFullScreen(); - } - else - { - if (document.body.webkitRequestFullScreen) - document.body.webkitRequestFullScreen(); - else if (document.body.mozRequestFullScreen) - document.body.mozRequestFullScreen(); - } -} -/** - * LittleJS Input System - *
- Tracks key down, pressed, and released - *
- Also tracks mouse buttons, position, and wheel - *
- Supports multiple gamepads - *
- Virtual gamepad for touch devices with touchGamepadSize - * @namespace Input - */ - -'use strict'; - -/** Returns true if device key is down - * @param {Number} key - * @param {Number} [device=0] - * @return {Boolean} - * @memberof Input */ -const keyIsDown = (key, device=0)=> inputData[device] && inputData[device][key] & 1 ? 1 : 0; - -/** Returns true if device key was pressed this frame - * @param {Number} key - * @param {Number} [device=0] - * @return {Boolean} - * @memberof Input */ -const keyWasPressed = (key, device=0)=> inputData[device] && inputData[device][key] & 2 ? 1 : 0; - -/** Returns true if device key was released this frame - * @param {Number} key - * @param {Number} [device=0] - * @return {Boolean} - * @memberof Input */ -const keyWasReleased = (key, device=0)=> inputData[device] && inputData[device][key] & 4 ? 1 : 0; - -/** Clears all input - * @memberof Input */ -const clearInput = ()=> inputData = [[]]; - -/** Returns true if mouse button is down - * @param {Number} button - * @return {Boolean} - * @memberof Input */ -const mouseIsDown = keyIsDown; - -/** Returns true if mouse button was pressed - * @param {Number} button - * @return {Boolean} - * @memberof Input */ -const mouseWasPressed = keyWasPressed; - -/** Returns true if mouse button was released - * @param {Number} button - * @return {Boolean} - * @memberof Input */ -const mouseWasReleased = keyWasReleased; - -/** Mouse pos in world space - * @type {Vector2} - * @memberof Input */ -let mousePos = vec2(); - -/** Mouse pos in screen space - * @type {Vector2} - * @memberof Input */ -let mousePosScreen = vec2(); - -/** Mouse wheel delta this frame - * @memberof Input */ -let mouseWheel = 0; - -/** Returns true if user is using gamepad (has more recently pressed a gamepad button) - * @memberof Input */ -let isUsingGamepad = 0; - -/** Prevents input continuing to the default browser handling (false by default) - * @memberof Input */ -let preventDefaultInput = 0; - -/** Returns true if gamepad button is down - * @param {Number} button - * @param {Number} [gamepad=0] - * @return {Boolean} - * @memberof Input */ -const gamepadIsDown = (button, gamepad=0)=> keyIsDown(button, gamepad+1); - -/** Returns true if gamepad button was pressed - * @param {Number} button - * @param {Number} [gamepad=0] - * @return {Boolean} - * @memberof Input */ -const gamepadWasPressed = (button, gamepad=0)=> keyWasPressed(button, gamepad+1); - -/** Returns true if gamepad button was released - * @param {Number} button - * @param {Number} [gamepad=0] - * @return {Boolean} - * @memberof Input */ -const gamepadWasReleased = (button, gamepad=0)=> keyWasReleased(button, gamepad+1); - -/** Returns gamepad stick value - * @param {Number} stick - * @param {Number} [gamepad=0] - * @return {Vector2} - * @memberof Input */ -const gamepadStick = (stick, gamepad=0)=> stickData[gamepad] ? stickData[gamepad][stick] || vec2() : vec2(); - -/////////////////////////////////////////////////////////////////////////////// -// Input update called by engine - -// store input as a bit field for each key: 1 = isDown, 2 = wasPressed, 4 = wasReleased -// mouse and keyboard are stored together in device 0, gamepads are in devices > 0 -let inputData = [[]]; - -function inputUpdate() -{ - // clear input when lost focus (prevent stuck keys) - document.hasFocus() || clearInput(); - - // update mouse world space position - mousePos = screenToWorld(mousePosScreen); - - // update gamepads if enabled - gamepadsUpdate(); -} - -function inputUpdatePost() -{ - // clear input to prepare for next frame - for (const deviceInputData of inputData) - for (const i in deviceInputData) - deviceInputData[i] &= 1; - mouseWheel = 0; -} - -/////////////////////////////////////////////////////////////////////////////// -// Keyboard event handlers - -onkeydown = (e)=> -{ - if (debug && e.target != document.body) return; - e.repeat || (inputData[isUsingGamepad = 0][remapKeyCode(e.keyCode)] = 3); - preventDefaultInput && e.preventDefault(); -} -onkeyup = (e)=> -{ - if (debug && e.target != document.body) return; - inputData[0][remapKeyCode(e.keyCode)] = 4; -} -const remapKeyCode = (c)=> inputWASDEmulateDirection ? c==87?38 : c==83?40 : c==65?37 : c==68?39 : c : c; - -/////////////////////////////////////////////////////////////////////////////// -// Mouse event handlers - -onmousedown = (e)=> {inputData[isUsingGamepad = 0][e.button] = 3; onmousemove(e); e.button && e.preventDefault();} -onmouseup = (e)=> inputData[0][e.button] = inputData[0][e.button] & 2 | 4; -onmousemove = (e)=> mousePosScreen = mouseToScreen(e); -onwheel = (e)=> e.ctrlKey || (mouseWheel = sign(e.deltaY)); -oncontextmenu = (e)=> !1; // prevent right click menu - -// convert a mouse or touch event position to screen space -const mouseToScreen = (mousePos)=> -{ - if (!mainCanvas) - return vec2(); // fix bug that can occur if user clicks before page loads - - const rect = mainCanvas.getBoundingClientRect(); - return vec2(mainCanvas.width, mainCanvas.height).multiply( - vec2(percent(mousePos.x, rect.left, rect.right), percent(mousePos.y, rect.top, rect.bottom))); -} - -/////////////////////////////////////////////////////////////////////////////// -// Gamepad input - -const stickData = []; -function gamepadsUpdate() -{ - if (touchGamepadEnable && touchGamepadTimer.isSet()) - { - // read virtual analog stick - const sticks = stickData[0] || (stickData[0] = []); - sticks[0] = vec2(touchGamepadStick.x, -touchGamepadStick.y); // flip vertical - - // read virtual gamepad buttons - const data = inputData[1] || (inputData[1] = []); - for (let i=10; i--;) - { - const j = i == 3 ? 2 : i == 2 ? 3 : i; // fix button locations - data[j] = touchGamepadButtons[i] ? 1 + 2*!gamepadIsDown(j,0) : 4*gamepadIsDown(j,0); - } - } - - if (!gamepadsEnable || !navigator.getGamepads || !document.hasFocus() && !debug) - return; - - // poll gamepads - const gamepads = navigator.getGamepads(); - for (let i = gamepads.length; i--;) - { - // get or create gamepad data - const gamepad = gamepads[i]; - const data = inputData[i+1] || (inputData[i+1] = []); - const sticks = stickData[i] || (stickData[i] = []); - - if (gamepad) - { - // read clamp dead zone of analog sticks - const deadZone = .3, deadZoneMax = .8; - const applyDeadZone = (v)=> - v > deadZone ? percent( v, deadZone, deadZoneMax) : - v < -deadZone ? -percent(-v, deadZone, deadZoneMax) : 0; - - // read analog sticks - for (let j = 0; j < gamepad.axes.length-1; j+=2) - sticks[j>>1] = vec2(applyDeadZone(gamepad.axes[j]), applyDeadZone(-gamepad.axes[j+1])).clampLength(); - - // read buttons - for (let j = gamepad.buttons.length; j--;) - { - const button = gamepad.buttons[j]; - data[j] = button.pressed ? 1 + 2*!gamepadIsDown(j,i) : 4*gamepadIsDown(j,i); - isUsingGamepad |= !i && button.pressed; - touchGamepadEnable && touchGamepadTimer.unset(); // disable touch gamepad if using real gamepad - } - - if (gamepadDirectionEmulateStick) - { - // copy dpad to left analog stick when pressed - const dpad = vec2(gamepadIsDown(15,i) - gamepadIsDown(14,i), gamepadIsDown(12,i) - gamepadIsDown(13,i)); - if (dpad.lengthSquared()) - sticks[0] = dpad.clampLength(); - } - } - } -} - -/////////////////////////////////////////////////////////////////////////////// - -/** Pulse the vibration hardware if it exists - * @param {Number} [pattern=100] - a single value in miliseconds or vibration interval array - * @memberof Input */ -const vibrate = (pattern)=> vibrateEnable && Navigator.vibrate && Navigator.vibrate(pattern); - -/** Cancel any ongoing vibration - * @memberof Input */ -const vibrateStop = ()=> vibrate(0); - -/////////////////////////////////////////////////////////////////////////////// -// Touch input - -/** True if a touch device has been detected - * @const {boolean} - * @memberof Input */ -const isTouchDevice = window.ontouchstart !== undefined; - -// try to enable touch mouse -if (isTouchDevice) -{ - // handle all touch events the same way - let wasTouching, hadTouchInput; - ontouchstart = ontouchmove = ontouchend = (e)=> - { - e.button = 0; // all touches are left click - - // check if touching and pass to mouse events - const touching = e.touches.length; - if (touching) - { - hadTouchInput || zzfx(0, hadTouchInput=1) ; // fix mobile audio, force it to play a sound the first time - - // set event pos and pass it along - e.x = e.touches[0].clientX; - e.y = e.touches[0].clientY; - wasTouching ? onmousemove(e) : onmousedown(e); - } - else if (wasTouching) - onmouseup(e); - - // set was touching - wasTouching = touching; - - // prevent normal mouse events from being called - return !e.cancelable; - } -} - -/////////////////////////////////////////////////////////////////////////////// -// touch gamepad, virtual on screen gamepad emulator for touch devices - -// touch input internal variables -let touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadStick = vec2(); - -// create the touch gamepad, called automatically by the engine -function touchGamepadCreate() -{ - if (!touchGamepadEnable || !isTouchDevice) - return; - - ontouchstart = ontouchmove = ontouchend = (e)=> - { - if (!touchGamepadEnable) - return; - - // clear touch gamepad input - touchGamepadStick = vec2(); - touchGamepadButtons = []; - - const touching = e.touches.length; - if (touching) - { - touchGamepadTimer.isSet() || zzfx(0) ; // fix mobile audio, force it to play a sound the first time - - // set that gamepad is active - isUsingGamepad = 1; - touchGamepadTimer.set(); - - if (paused) - { - // touch anywhere to press start when paused - touchGamepadButtons[9] = 1; - return; - } - } - - // get center of left and right sides - const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize); - const buttonCenter = mainCanvasSize.subtract(vec2(touchGamepadSize, touchGamepadSize)); - const startCenter = mainCanvasSize.scale(.5); - - // check each touch point - for (const touch of e.touches) - { - const touchPos = mouseToScreen(vec2(touch.clientX, touch.clientY)); - if (touchPos.distance(stickCenter) < touchGamepadSize) - { - // virtual analog stick - if (touchGamepadAnalog) - touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize).clampLength(); - else - { - // 8 way dpad - const angle = touchPos.subtract(stickCenter).angle(); - touchGamepadStick.setAngle((angle * 4 / PI + 8.5 | 0) * PI / 4); - } - } - else if (touchPos.distance(buttonCenter) < touchGamepadSize) - { - // virtual face buttons - const button = touchPos.subtract(buttonCenter).direction(); - touchGamepadButtons[button] = 1; - } - else if (touchPos.distance(startCenter) < touchGamepadSize) - { - // virtual start button in center - touchGamepadButtons[9] = 1; - } - } - } -} - -// render the touch gamepad, called automatically by the engine -function touchGamepadRender() -{ - if (!touchGamepadEnable || !touchGamepadTimer.isSet()) - return; - - // fade off when not touching or paused - const alpha = percent(touchGamepadTimer.get(), 4, 3); - if (!alpha || paused) - return; - - // setup the canvas - overlayContext.save(); - overlayContext.globalAlpha = alpha*touchGamepadAlpha; - overlayContext.strokeStyle = '#fff'; - overlayContext.lineWidth = 3; - - // draw left analog stick - overlayContext.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000'; - overlayContext.beginPath(); - - const leftCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize); - if (touchGamepadAnalog) - { - overlayContext.arc(leftCenter.x, leftCenter.y, touchGamepadSize/2, 0, 9); - overlayContext.fill(); - overlayContext.stroke(); - } - else // draw cross shaped gamepad - { - for(let i=10; i--;) - { - const angle = i*PI/4; - overlayContext.arc(leftCenter.x, leftCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8); - i%2 && overlayContext.arc(leftCenter.x, leftCenter.y, touchGamepadSize*.33, angle, angle); - i==1 && overlayContext.fill(); - } - overlayContext.stroke(); - } - - // draw right face buttons - const rightCenter = vec2(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize); - for (let i=4; i--;) - { - const pos = rightCenter.add((new Vector2).setAngle(i*PI/2, touchGamepadSize/2)); - overlayContext.fillStyle = touchGamepadButtons[i] ? '#fff' : '#000'; - overlayContext.beginPath(); - overlayContext.arc(pos.x, pos.y, touchGamepadSize/4, 0,9); - overlayContext.fill(); - overlayContext.stroke(); - } - - // set canvas back to normal - overlayContext.restore(); -} -/** - * LittleJS Audio System - *
- ZzFX Sound Effects - *
- ZzFXM Music - *
- Caches sounds and music for fast playback - *
- Can attenuate and apply stereo panning to sounds - *
- Ability to play mp3, ogg, and wave files - *
- Speech synthesis wrapper functions - */ - -'use strict'; - -/** - * Sound Object - Stores a zzfx sound for later use and can be played positionally - *
- *
Create sounds using the ZzFX Sound Designer. - * @example - * // create a sound - * const sound_example = new Sound([.5,.5]); - * - * // play the sound - * sound_example.play(); - */ -class Sound -{ - /** Create a sound object and cache the zzfx samples for later use - * @param {Array} zzfxSound - Array of zzfx parameters, ex. [.5,.5] - * @param {Number} [range=soundDefaultRange] - World space max range of sound, will not play if camera is farther away - * @param {Number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering off - */ - constructor(zzfxSound, range=soundDefaultRange, taper=soundDefaultTaper) - { - if (!soundEnable) return; - - /** @property {Number} - World space max range of sound, will not play if camera is farther away */ - this.range = range; - - /** @property {Number} - At what percentage of range should it start tapering off */ - this.taper = taper; - - // get randomness from sound parameters - this.randomness = zzfxSound[1] || 0; - zzfxSound[1] = 0; - - // generate sound now for fast playback - this.cachedSamples = zzfxG(...zzfxSound); - } - - /** Play the sound - * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null - * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) - * @param {Number} [pitch=1] - How much to scale pitch by (also adjusted by this.randomness) - * @param {Number} [randomnessScale=1] - How much to scale randomness - * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later - */ - play(pos, volume=1, pitch=1, randomnessScale=1) - { - if (!soundEnable) return; - - let pan = 0; - if (pos) - { - const range = this.range; - if (range) - { - // apply range based fade - const lengthSquared = cameraPos.distanceSquared(pos); - if (lengthSquared > range*range) - return; // out of range - - // attenuate volume by distance - volume *= percent(lengthSquared**.5, range, range*this.taper); - } - - // get pan from screen space coords - pan = worldToScreen(pos).x * 2/mainCanvas.width - 1; - } - - // play the sound - const playbackRate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1); - return playSamples([this.cachedSamples], volume, playbackRate, pan); - } - - /** Play the sound as a note with a semitone offset - * @param {Number} semitoneOffset - How many semitones to offset pitch - * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null - * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) - * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later - */ - playNote(semitoneOffset, pos, volume=1) - { - if (!soundEnable) return; - - return this.play(pos, volume, 2**(semitoneOffset/12), 0); - } -} - -/** - * Music Object - Stores a zzfx music track for later use - *
- *
Create music with the ZzFXM tracker. - * @example - * // create some music - * const music_example = new Music( - * [ - * [ // instruments - * [,0,400] // simple note - * ], - * [ // patterns - * [ // pattern 1 - * [ // channel 0 - * 0, -1, // instrument 0, left speaker - * 1, 0, 9, 1 // channel notes - * ], - * [ // channel 1 - * 0, 1, // instrument 1, right speaker - * 0, 12, 17, -1 // channel notes - * ] - * ], - * ], - * [0, 0, 0, 0], // sequence, play pattern 0 four times - * 90 // BPM - * ]); - * - * // play the music - * music_example.play(); - */ -class Music -{ - /** Create a music object and cache the zzfx music samples for later use - * @param {Array} zzfxMusic - Array of zzfx music parameters - */ - constructor(zzfxMusic) - { - if (!soundEnable) return; - - this.cachedSamples = zzfxM(...zzfxMusic); - } - - /** Play the music - * @param {Number} [volume=1] - How much to scale volume by - * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end - * @return {AudioBufferSourceNode} - The audio node, can be used to stop sound later - */ - play(volume = 1, loop = 1) - { - if (!soundEnable) return; - - return playSamples(this.cachedSamples, volume, 1, 0, loop); - } -} - -/** Play an mp3 or wav audio from a local file or url - * @param {String} url - Location of sound file to play - * @param {Number} [volume=1] - How much to scale volume by - * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end - * @return {HTMLAudioElement} - The audio element for this sound - * @memberof Audio */ -function playAudioFile(url, volume=1, loop=1) -{ - if (!soundEnable) return; - - const audio = new Audio(url); - audio.volume = soundVolume * volume; - audio.loop = loop; - audio.play(); - return audio; -} - -/** Speak text with passed in settings - * @param {String} text - The text to speak - * @param {String} [language] - The language/accent to use (examples: en, it, ru, ja, zh) - * @param {Number} [volume=1] - How much to scale volume by - * @param {Number} [rate=1] - How quickly to speak - * @param {Number} [pitch=1] - How much to change the pitch by - * @return {SpeechSynthesisUtterance} - The utterance that was spoken - * @memberof Audio */ -function speak(text, language='', volume=1, rate=1, pitch=1) -{ - if (!soundEnable || !speechSynthesis) return; - - // common languages (not supported by all browsers) - // en - english, it - italian, fr - french, de - german, es - spanish - // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean - - // build utterance and speak - const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = language; - utterance.volume = 2*volume*soundVolume; - utterance.rate = rate; - utterance.pitch = pitch; - speechSynthesis.speak(utterance); - return utterance; -} - -/** Stop all queued speech - * @memberof Audio */ -const speakStop = ()=> speechSynthesis && speechSynthesis.cancel(); - -/** Get frequency of a note on a musical scale - * @param {Number} semitoneOffset - How many semitones away from the root note - * @param {Number} [rootNoteFrequency=220] - Frequency at semitone offset 0 - * @return {Number} - The frequency of the note - * @memberof Audio */ -const getNoteFrequency = (semitoneOffset, rootFrequency=220)=> rootFrequency * 2**(semitoneOffset/12); - -/////////////////////////////////////////////////////////////////////////////// - -/** Audio context used by the engine - * @memberof Audio */ -let audioContext; - -/** Play cached audio samples with given settings - * @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback) - * @param {Number} [volume=1] - How much to scale volume by - * @param {Number} [rate=1] - The playback rate to use - * @param {Number} [pan=0] - How much to apply stereo panning - * @param {Boolean} [loop=0] - True if the sound should loop when it reaches the end - * @return {AudioBufferSourceNode} - The audio node of the sound played - * @memberof Audio */ -function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=0) -{ - if (!soundEnable) return; - - // create audio context - if (!audioContext) - audioContext = new (window.AudioContext||webkitAudioContext); - - // fix stalled audio - audioContext.resume(); - - // prevent sounds from building up if they can't be played - if (audioContext.state != 'running') - return; - - // create buffer and source - const buffer = audioContext.createBuffer(sampleChannels.length, sampleChannels[0].length, zzfxR), - source = audioContext.createBufferSource(); - - // copy samples to buffer and setup source - sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c)); - source.buffer = buffer; - source.playbackRate.value = rate; - source.loop = loop; - - // create and connect gain node (createGain is more widley spported then GainNode construtor) - const gainNode = audioContext.createGain(); - gainNode.gain.value = soundVolume*volume; - gainNode.connect(audioContext.destination); - - // connect source to gain - ( - window.StereoPannerNode ? // create pan node if possible - source.connect(new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)})) - : source - ) - .connect(gainNode); - - // play and return sound - source.start(); - return source; -} - -/////////////////////////////////////////////////////////////////////////////// -// ZzFXMicro - Zuper Zmall Zound Zynth - v1.1.8 by Frank Force - -/** Generate and play a ZzFX sound - *
- *
Create sounds using the ZzFX Sound Designer. - * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5] - * @return {Array} - Array of audio samples - * @memberof Audio */ -const zzfx = (...zzfxSound) => playSamples([zzfxG(...zzfxSound)]); - -/** Sample rate used for all ZzFX sounds - * @default 44100 - * @memberof Audio */ -const zzfxR = 44100; - -/** Generate samples for a ZzFX sound - * @memberof Audio */ -function zzfxG -( - // parameters - volume = 1, randomness = .05, frequency = 220, attack = 0, sustain = 0, - release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0, - pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0, - bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0 -) -{ - // init parameters - let PI2 = PI*2, startSlide = slide *= 500 * PI2 / zzfxR / zzfxR, b=[], - startFrequency = frequency *= (1 + randomness*rand(-1,1)) * PI2 / zzfxR, - t=0, tm=0, i=0, j=1, r=0, c=0, s=0, f, length; - - // scale by sample rate - attack = attack * zzfxR + 9; // minimum attack to prevent pop - decay *= zzfxR; - sustain *= zzfxR; - release *= zzfxR; - delay *= zzfxR; - deltaSlide *= 500 * PI2 / zzfxR**3; - modulation *= PI2 / zzfxR; - pitchJump *= PI2 / zzfxR; - pitchJumpTime *= zzfxR; - repeatTime = repeatTime * zzfxR | 0; - - // generate waveform - for (length = attack + decay + sustain + release + delay | 0; - i < length; b[i++] = s) - { - if (!(++c%(bitCrush*100|0))) // bit crush - { - s = shape? shape>1? shape>2? shape>3? // wave shape - Math.sin((t%PI2)**3) : // 4 noise - max(min(Math.tan(t),1),-1): // 3 tan - 1-(2*t/PI2%2+2)%2: // 2 saw - 1-4*abs(Math.round(t/PI2)-t/PI2): // 1 triangle - Math.sin(t); // 0 sin - - s = (repeatTime ? - 1 - tremolo + tremolo*Math.sin(PI2*i/repeatTime) // tremolo - : 1) * - sign(s)*(abs(s)**shapeCurve) * // curve 0=square, 2=pointy - volume * soundVolume * ( // envelope - i < attack ? i/attack : // attack - i < attack + decay ? // decay - 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff - i < attack + decay + sustain ? // sustain - sustainVolume : // sustain volume - i < length - delay ? // release - (length - i - delay)/release * // release falloff - sustainVolume : // release volume - 0); // post release - - s = delay ? s/2 + (delay > i ? 0 : // delay - (i pitchJumpTime) // pitch jump - { - frequency += pitchJump; // apply pitch jump - startFrequency += pitchJump; // also apply to start - j = 0; // reset pitch jump time - } - - if (repeatTime && !(++r % repeatTime)) // repeat - { - frequency = startFrequency; // reset frequency - slide = startSlide; // reset slide - j = j || 1; // reset pitch jump time - } - } - - return b; -} - -/////////////////////////////////////////////////////////////////////////////// -// ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force - -/** Generate samples for a ZzFM song with given parameters - * @param {Array} instruments - Array of ZzFX sound paramaters - * @param {Array} patterns - Array of pattern data - * @param {Array} sequence - Array of pattern indexes - * @param {Number} [BPM=125] - Playback speed of the song in BPM - * @returns {Array} - Left and right channel sample data - * @memberof Audio */ -function zzfxM(instruments, patterns, sequence, BPM = 125) -{ - let instrumentParameters; - let i; - let j; - let k; - let note; - let sample; - let patternChannel; - let notFirstBeat; - let stop; - let instrument; - let attenuation; - let outSampleOffset; - let isSequenceEnd; - let sampleOffset = 0; - let nextSampleOffset; - let sampleBuffer = []; - let leftChannelBuffer = []; - let rightChannelBuffer = []; - let channelIndex = 0; - let panning = 0; - let hasMore = 1; - let sampleCache = {}; - let beatLength = zzfxR / BPM * 60 >> 2; - - // for each channel in order until there are no more - for (; hasMore; channelIndex++) { - - // reset current values - sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0]; - - // for each pattern in sequence - sequence.forEach((patternIndex, sequenceIndex) => { - // get pattern for current channel, use empty 1 note pattern if none found - patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0]; - - // check if there are more channels - hasMore |= !!patterns[patternIndex][channelIndex]; - - // get next offset, use the length of first channel - nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - !notFirstBeat) * beatLength; - // for each beat in pattern, plus one extra if end of sequence - isSequenceEnd = sequenceIndex == sequence.length - 1; - for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) { - - // - note = patternChannel[i]; - - // stop if end, different instrument or new note - stop = i == patternChannel.length + isSequenceEnd - 1 && isSequenceEnd || - instrument != (patternChannel[0] || 0) | note | 0; - - // fill buffer with samples for previous beat, most cpu intensive part - for (j = 0; j < beatLength && notFirstBeat; - - // fade off attenuation at end of beat if stopping note, prevents clicking - j++ > beatLength - 99 && stop ? attenuation += (attenuation < 1) / 99 : 0 - ) { - // copy sample to stereo buffers with panning - sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0; - leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample; - rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample; - } - - // set up for next note - if (note) { - // set attenuation - attenuation = note % 1; - panning = patternChannel[1] || 0; - if (note |= 0) { - // get cached sample - sampleBuffer = sampleCache[ - [ - instrument = patternChannel[sampleOffset = 0] || 0, - note - ] - ] = sampleCache[[instrument, note]] || ( - // add sample to cache - instrumentParameters = [...instruments[instrument]], - instrumentParameters[2] *= 2 ** ((note - 12) / 12), - - // allow negative values to stop notes - note > 0 ? zzfxG(...instrumentParameters) : [] - ); - } - } - } - - // update the sample offset - outSampleOffset = nextSampleOffset; - }); - } - - return [leftChannelBuffer, rightChannelBuffer]; -} -/** - * LittleJS Tile Layer System - *
- Caches arrays of tiles to off screen canvas for fast rendering - *
- Unlimted numbers of layers, allocates canvases as needed - *
- Interfaces with EngineObject for collision - *
- Collision layer is separate from visible layers - *
- It is recommended to have a visible layer that matches the collision - *
- Tile layers can be drawn to using their context with canvas2d - *
- Drawn directly to the main canvas without using WebGL - * @namespace TileCollision - */ - -'use strict'; - -/** The tile collision layer array, use setTileCollisionData and getTileCollisionData to access - * @memberof TileCollision */ -let tileCollision = []; - -/** Size of the tile collision layer - * @type {Vector2} - * @memberof TileCollision */ -let tileCollisionSize = vec2(); - -/** Clear and initialize tile collision - * @param {Vector2} size - * @memberof TileCollision */ -function initTileCollision(size) -{ - tileCollisionSize = size; - tileCollision = []; - for (let i=tileCollision.length = tileCollisionSize.area(); i--;) - tileCollision[i] = 0; -} - -/** Set tile collision data - * @param {Vector2} pos - * @param {Number} [data=0] - * @memberof TileCollision */ -const setTileCollisionData = (pos, data=0)=> - pos.arrayCheck(tileCollisionSize) && (tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] = data); - -/** Get tile collision data - * @param {Vector2} pos - * @return {Number} - * @memberof TileCollision */ -const getTileCollisionData = (pos)=> - pos.arrayCheck(tileCollisionSize) ? tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] : 0; - -/** Check if collision with another object should occur - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {EngineObject} [object] - * @return {Boolean} - * @memberof TileCollision */ -function tileCollisionTest(pos, size=vec2(), object) -{ - const minX = max(pos.x - size.x/2|0, 0); - const minY = max(pos.y - size.y/2|0, 0); - const maxX = min(pos.x + size.x/2, tileCollisionSize.x); - const maxY = min(pos.y + size.y/2, tileCollisionSize.y); - for (let y = minY; y < maxY; ++y) - for (let x = minX; x < maxX; ++x) - { - const tileData = tileCollision[y*tileCollisionSize.x+x]; - if (tileData && (!object || object.collideWithTile(tileData, new Vector2(x, y)))) - return 1; - } -} - -/** Return the center of tile if any that is hit (this does not return the exact hit point) - * @param {Vector2} posStart - * @param {Vector2} posEnd - * @param {EngineObject} [object] - * @return {Vector2} - * @memberof TileCollision */ -function tileCollisionRaycast(posStart, posEnd, object) -{ - // test if a ray collides with tiles from start to end - // todo: a way to get the exact hit point, it must still register as inside the hit tile - posStart = posStart.floor(); - posEnd = posEnd.floor(); - const posDelta = posEnd.subtract(posStart); - const dx = abs(posDelta.x), dy = -abs(posDelta.y); - const sx = sign(posDelta.x), sy = sign(posDelta.y); - let e = dx + dy; - - for (let x = posStart.x, y = posStart.y;;) - { - const tileData = getTileCollisionData(vec2(x,y)); - if (tileData && (object ? object.collideWithTileRaycast(tileData, new Vector2(x, y)) : tileData > 0)) - { - debugRaycast && debugLine(posStart, posEnd, '#f00',.02, 1); - debugRaycast && debugPoint(new Vector2(x+.5, y+.5), '#ff0', 1); - return new Vector2(x+.5, y+.5); - } - - // update Bresenham line drawing algorithm - if (x == posEnd.x & y == posEnd.y) break; - const e2 = 2*e; - if (e2 >= dy) e += dy, x += sx; - if (e2 <= dx) e += dx, y += sy; - } - debugRaycast && debugLine(posStart, posEnd, '#00f',.02, 1); -} - -/////////////////////////////////////////////////////////////////////////////// -// Tile Layer Rendering System - -/** - * Tile layer data object stores info about how to render a tile - * @example - * // create tile layer data with tile index 0 and random orientation and color - * const tileIndex = 0; - * const direction = randInt(4) - * const mirror = randInt(2); - * const color = randColor(); - * const data = new TileLayerData(tileIndex, direction, mirror, color); - */ -class TileLayerData -{ - /** Create a tile layer data object, one for each tile in a TileLayer - * @param {Number} [tile] - The tile to use, untextured if undefined - * @param {Number} [direction=0] - Integer direction of tile, in 90 degree increments - * @param {Boolean} [mirror=0] - If the tile should be mirrored along the x axis - * @param {Color} [color=new Color(1,1,1)] - Color of the tile */ - constructor(tile, direction=0, mirror=0, color=new Color) - { - /** @property {Number} - The tile to use, untextured if undefined */ - this.tile = tile; - /** @property {Number} - Integer direction of tile, in 90 degree increments */ - this.direction = direction; - /** @property {Boolean} - If the tile should be mirrored along the x axis */ - this.mirror = mirror; - /** @property {Color} - Color of the tile */ - this.color = color; - } - - /** Set this tile to clear, it will not be rendered */ - clear() { this.tile = this.direction = this.mirror = 0; color = new Color; } -} - -/** - * Tile layer object - cached rendering system for tile layers - *
- Each Tile layer is rendered to an off screen canvas - *
- To allow dynamic modifications, layers are rendered using canvas 2d - *
- Some devices like mobile phones are limited to 4k texture resolution - *
- So with 16x16 tiles this limits layers to 256x256 on mobile devices - * @extends EngineObject - * @example - * // create tile collision and visible tile layer - * initTileCollision(vec2(200,100)); - * const tileLayer = new TileLayer(); - */ -class TileLayer extends EngineObject -{ -/** Create a tile layer object - * @param {Vector2} [position=new Vector2()] - World space position - * @param {Vector2} [size=tileCollisionSize] - World space size - * @param {Vector2} [tileSize=tileSizeDefault] - Size of tiles in source pixels - * @param {Vector2} [scale=new Vector2(1,1)] - How much to scale this layer when rendered - * @param {Number} [renderOrder=0] - Objects sorted by renderOrder before being rendered - */ -constructor(pos, size=tileCollisionSize, tileSize=tileSizeDefault, scale=vec2(1), renderOrder=0) - { - super(pos, size, -1, tileSize, 0, undefined, renderOrder); - - /** @property {HTMLCanvasElement} - The canvas used by this tile layer */ - this.canvas = document.createElement('canvas'); - /** @property {CanvasRenderingContext2D} - The 2D canvas context used by this tile layer */ - this.context = this.canvas.getContext('2d'); - /** @property {Vector2} - How much to scale this layer when rendered */ - this.scale = scale; - /** @property {Boolean} [isOverlay=0] - If true this layer will render to overlay canvas and appear above all objects */ - this.isOverlay; - - // init tile data - this.data = []; - for (let j = this.size.area(); j--;) - this.data.push(new TileLayerData()); - } - - /** Set data at a given position in the array - * @param {Vector2} position - Local position in array - * @param {TileLayerData} data - Data to set - * @param {Boolean} [redraw=0] - Force the tile to redraw if true */ - setData(layerPos, data, redraw) - { - if (layerPos.arrayCheck(this.size)) - { - this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data; - redraw && this.drawTileData(layerPos); - } - } - - /** Get data at a given position in the array - * @param {Vector2} layerPos - Local position in array - * @return {TileLayerData} */ - getData(layerPos) - { return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0]; } - - // Tile layers are not updated - update() {} - - // Render the tile layer, called automatically by the engine - render() - { - ASSERT(mainContext != this.context); // must call redrawEnd() after drawing tiles - - // flush and copy gl canvas because tile canvas does not use webgl - glEnable && !glOverlay && !this.isOverlay && glCopyToContext(mainContext); - - // draw the entire cached level onto the canvas - const pos = worldToScreen(this.pos.add(vec2(0,this.size.y*this.scale.y))); - (this.isOverlay ? overlayContext : mainContext).drawImage - ( - this.canvas, pos.x, pos.y, - cameraScale*this.size.x*this.scale.x, cameraScale*this.size.y*this.scale.y - ); - } - - /** Draw all the tile data to an offscreen canvas - * - This may be slow in some browsers - */ - redraw() - { - this.redrawStart(1); - this.drawAllTileData(); - this.redrawEnd(); - } - - /** Call to start the redraw process - * @param {Boolean} [clear=0] - Should it clear the canvas before drawing */ - redrawStart(clear = 0) - { - if (clear) - { - // clear and set size - this.canvas.width = this.size.x * this.tileSize.x; - this.canvas.height = this.size.y * this.tileSize.y; - } - - // save current render settings - this.savedRenderSettings = [mainCanvas, mainContext, cameraPos, cameraScale]; - - // use normal rendering system to render the tiles - mainCanvas = this.canvas; - mainContext = this.context; - cameraPos = this.size.scale(.5); - cameraScale = this.tileSize.x; - enginePreRender(); - } - - /** Call to end the redraw process */ - redrawEnd() - { - ASSERT(mainContext == this.context); // must call redrawStart() before drawing tiles - glCopyToContext(mainContext, 1); - //debugSaveCanvas(this.canvas); - - // set stuff back to normal - [mainCanvas, mainContext, cameraPos, cameraScale] = this.savedRenderSettings; - } - - /** Draw the tile at a given position - * @param {Vector2} layerPos */ - drawTileData(layerPos) - { - // first clear out where the tile was - const pos = layerPos.floor().add(this.pos).add(vec2(.5)); - this.drawCanvas2D(pos, vec2(1), 0, 0, (context)=>context.clearRect(-.5, -.5, 1, 1)); - - // draw the tile if not undefined - const d = this.getData(layerPos); - if (d.tile != undefined) - { - ASSERT(mainContext == this.context); // must call redrawStart() before drawing tiles - drawTile(pos, vec2(1), d.tile, this.tileSize, d.color, d.direction*PI/2, d.mirror); - } - } - - /** Draw all the tiles in this layer */ - drawAllTileData() - { - for (let x = this.size.x; x--;) - for (let y = this.size.y; y--;) - this.drawTileData(vec2(x,y)); - } - - /** Draw directly to the 2D canvas in world space (bipass webgl) - * @param {Vector2} pos - * @param {Vector2} size - * @param {Number} [angle=0] - * @param {Boolean} [mirror=0] - * @param {Function} drawFunction */ - drawCanvas2D(pos, size, angle=0, mirror, drawFunction) - { - const context = this.context; - context.save(); - pos = pos.subtract(this.pos).multiply(this.tileSize); - size = size.multiply(this.tileSize); - context.translate(pos.x, this.canvas.height - pos.y); - context.rotate(angle); - context.scale(mirror ? -size.x : size.x, size.y); - drawFunction(context); - context.restore(); - } - - /** Draw a tile directly onto the layer canvas - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {Number} [tileIndex=-1] - * @param {Vector2} [tileSize=tileSizeDefault] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [angle=0] - * @param {Boolean} [mirror=0] */ - drawTile(pos, size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, color=new Color, angle, mirror) - { - this.drawCanvas2D(pos, size, angle, mirror, (context)=> - { - if (tileIndex < 0) - { - // untextured - context.fillStyle = color; - context.fillRect(-.5, -.5, 1, 1); - } - else - { - const cols = tileImage.width/tileSize.x; - context.globalAlpha = color.a; // only alpha, no color, is supported in this mode - context.drawImage(tileImage, - (tileIndex%cols)*tileSize.x, (tileIndex/cols|0)*tileSize.x, - tileSize.x, tileSize.y, -.5, -.5, 1, 1); - } - }); - } - - /** Draw a rectangle directly onto the layer canvas - * @param {Vector2} pos - * @param {Vector2} [size=new Vector2(1,1)] - * @param {Color} [color=new Color(1,1,1)] - * @param {Number} [angle=0] */ - drawRect(pos, size, color, angle) - { this.drawTile(pos, size, -1, 0, color, angle); } -} -/* - LittleJS Particle System - - Spawns particles with randomness from parameters - - Updates particle physics - - Fast particle rendering -*/ - -'use strict'; - -/** - * Particle Emitter - Spawns particles with the given settings - * @extends EngineObject - * @example - * // create a particle emitter - * let pos = vec2(2,3); - * let particleEmiter = new ParticleEmitter - * ( - * pos, 0, 1, 0, 500, PI, // pos, angle, emitSize, emitTime, emitRate, emiteCone - * 0, vec2(16), // tileIndex, tileSize - * new Color(1,1,1), new Color(0,0,0), // colorStartA, colorStartB - * new Color(1,1,1,0), new Color(0,0,0,0), // colorEndA, colorEndB - * 2, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed - * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate, - * .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder - * ); - */ -class ParticleEmitter extends EngineObject -{ - /** Create a particle system with the given settings - * @param {Vector2} position - World space position of the emitter - * @param {Number} [angle=0] - Angle to emit the particles - * @param {Number} [emitSize=0] - World space size of the emitter (float for circle diameter, vec2 for rect) - * @param {Number} [emitTime=0] - How long to stay alive (0 is forever) - * @param {Number} [emitRate=100] - How many particles per second to spawn, does not emit if 0 - * @param {Number} [emitConeAngle=PI] - Local angle to apply velocity to particles from emitter - * @param {Number} [tileIndex=-1] - Index into tile sheet, if <0 no texture is applied - * @param {Number} [tileSize=tileSizeDefault] - Tile size for particles - * @param {Color} [colorStartA=new Color(1,1,1)] - Color at start of life 1, randomized between start colors - * @param {Color} [colorStartB=new Color(1,1,1)] - Color at start of life 2, randomized between start colors - * @param {Color} [colorEndA=new Color(1,1,1,0)] - Color at end of life 1, randomized between end colors - * @param {Color} [colorEndB=new Color(1,1,1,0)] - Color at end of life 2, randomized between end colors - * @param {Number} [particleTime=.5] - How long particles live - * @param {Number} [sizeStart=.1] - How big are particles at start - * @param {Number} [sizeEnd=1] - How big are particles at end - * @param {Number} [speed=.1] - How fast are particles when spawned - * @param {Number} [angleSpeed=.05] - How fast are particles rotating - * @param {Number} [damping=1] - How much to dampen particle speed - * @param {Number} [angleDamping=1] - How much to dampen particle angular speed - * @param {Number} [gravityScale=0] - How much does gravity effect particles - * @param {Number} [particleConeAngle=PI] - Cone for start particle angle - * @param {Number} [fadeRate=.1] - How quick to fade in particles at start/end in percent of life - * @param {Number} [randomness=.2] - Apply extra randomness percent - * @param {Boolean} [collideTiles=0] - Do particles collide against tiles - * @param {Boolean} [additive=0] - Should particles use addtive blend - * @param {Boolean} [randomColorLinear=1] - Should color be randomized linearly or across each component - * @param {Number} [renderOrder=0] - Render order for particles (additive is above other stuff by default) - */ - constructor - ( - pos, - angle, - emitSize = 0, - emitTime = 0, - emitRate = 100, - emitConeAngle = PI, - tileIndex = -1, - tileSize = tileSizeDefault, - colorStartA = new Color, - colorStartB = new Color, - colorEndA = new Color(1,1,1,0), - colorEndB = new Color(1,1,1,0), - particleTime = .5, - sizeStart = .1, - sizeEnd = 1, - speed = .1, - angleSpeed = .05, - damping = 1, - angleDamping = 1, - gravityScale = 0, - particleConeAngle = PI, - fadeRate = .1, - randomness = .2, - collideTiles, - additive, - randomColorLinear = 1, - renderOrder = additive ? 1e9 : 0 - ) - { - super(pos, new Vector2, tileIndex, tileSize, angle, undefined, renderOrder); - - // emitter settings - /** @property {Number} - World space size of the emitter (float for circle diameter, vec2 for rect) */ - this.emitSize = emitSize - /** @property {Number} - How long to stay alive (0 is forever) */ - this.emitTime = emitTime; - /** @property {Number} - How many particles per second to spawn, does not emit if 0 */ - this.emitRate = emitRate; - /** @property {Number} - Local angle to apply velocity to particles from emitter */ - this.emitConeAngle = emitConeAngle; - - // color settings - /** @property {Color} - Color at start of life 1, randomized between start colors */ - this.colorStartA = colorStartA; - /** @property {Color} - Color at start of life 2, randomized between start colors */ - this.colorStartB = colorStartB; - /** @property {Color} - Color at end of life 1, randomized between end colors */ - this.colorEndA = colorEndA; - /** @property {Color} - Color at end of life 2, randomized between end colors */ - this.colorEndB = colorEndB; - /** @property {Boolean} - Should color be randomized linearly or across each component */ - this.randomColorLinear = randomColorLinear; - - // particle settings - /** @property {Number} - How long particles live */ - this.particleTime = particleTime; - /** @property {Number} - How big are particles at start */ - this.sizeStart = sizeStart; - /** @property {Number} - How big are particles at end */ - this.sizeEnd = sizeEnd; - /** @property {Number} - How fast are particles when spawned */ - this.speed = speed; - /** @property {Number} - How fast are particles rotating */ - this.angleSpeed = angleSpeed; - /** @property {Number} - How much to dampen particle speed */ - this.damping = damping; - /** @property {Number} - How much to dampen particle angular speed */ - this.angleDamping = angleDamping; - /** @property {Number} - How much does gravity effect particles */ - this.gravityScale = gravityScale; - /** @property {Number} - Cone for start particle angle */ - this.particleConeAngle = particleConeAngle; - /** @property {Number} - How quick to fade in particles at start/end in percent of life */ - this.fadeRate = fadeRate; - /** @property {Number} - Apply extra randomness percent */ - this.randomness = randomness; - /** @property {Number} - Do particles collide against tiles */ - this.collideTiles = collideTiles; - /** @property {Number} - Should particles use addtive blend */ - this.additive = additive; - /** @property {Number} - If set the partile is drawn as a trail, stretched in the drection of velocity */ - this.trailScale = 0; - - // internal variables - this.emitTimeBuffer = 0; - } - - /** Update the emitter to spawn particles, called automatically by engine once each frame */ - update() - { - // only do default update to apply parent transforms - this.parent && super.update(); - - // update emitter - if (!this.emitTime || this.getAliveTime() <= this.emitTime) - { - // emit particles - if (this.emitRate * particleEmitRateScale) - { - const rate = 1/this.emitRate/particleEmitRateScale; - for (this.emitTimeBuffer += timeDelta; this.emitTimeBuffer > 0; this.emitTimeBuffer -= rate) - this.emitParticle(); - } - } - else - this.destroy(); - - debugParticles && debugRect(this.pos, vec2(this.emitSize), '#0f0', 0, this.angle); - } - - /** Spawn one particle - * @return {Particle} */ - emitParticle() - { - // spawn a particle - const pos = this.emitSize.x != undefined ? // check if vec2 was used for size - (new Vector2(rand(-.5,.5), rand(-.5,.5))).multiply(this.emitSize).rotate(this.angle) // box emitter - : randInCircle(this.emitSize * .5); // circle emitter - const particle = new Particle(this.pos.add(pos), this.tileIndex, this.tileSize, - this.angle + rand(this.particleConeAngle, -this.particleConeAngle)); - - // randomness scales each paremeter by a percentage - const randomness = this.randomness; - const randomizeScale = (v)=> v + v*rand(randomness, -randomness); - - // randomize particle settings - const particleTime = randomizeScale(this.particleTime); - const sizeStart = randomizeScale(this.sizeStart); - const sizeEnd = randomizeScale(this.sizeEnd); - const speed = randomizeScale(this.speed); - const angleSpeed = randomizeScale(this.angleSpeed) * randSign(); - const coneAngle = rand(this.emitConeAngle, -this.emitConeAngle); - const colorStart = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear); - const colorEnd = randColor(this.colorEndA, this.colorEndB, this.randomColorLinear); - - // build particle settings - particle.colorStart = colorStart; - particle.colorEndDelta = colorEnd.subtract(colorStart); - particle.velocity = (new Vector2).setAngle(this.angle + coneAngle, speed); - particle.angleVelocity = angleSpeed; - particle.lifeTime = particleTime; - particle.sizeStart = sizeStart; - particle.sizeEndDelta = sizeEnd - sizeStart; - particle.fadeRate = this.fadeRate; - particle.damping = this.damping; - particle.angleDamping = this.angleDamping; - particle.elasticity = this.elasticity; - particle.friction = this.friction; - particle.gravityScale = this.gravityScale; - particle.collideTiles = this.collideTiles; - particle.additive = this.additive; - particle.renderOrder = this.renderOrder; - particle.trailScale = this.trailScale; - particle.mirror = rand()<.5; - - // setup callbacks for particles - particle.destroyCallback = this.particleDestroyCallback; - this.particleCreateCallback && this.particleCreateCallback(particle); - - // return the newly created particle - return particle; - } - - // Particle emitters are not rendered, only the particles are - render() {} -} - -/////////////////////////////////////////////////////////////////////////////// -/** - * Particle Object - Created automatically by Particle Emitters - * @extends EngineObject - */ -class Particle extends EngineObject -{ - /** - * Create a particle with the given settings - * @param {Vector2} position - World space position of the particle - * @param {Number} [tileIndex=-1] - Tile to use to render, untextured if -1 - * @param {Vector2} [tileSize=tileSizeDefault] - Size of tile in source pixels - * @param {Number} [angle=0] - Angle to rotate the particle - */ - constructor(pos, tileIndex, tileSize, angle) { super(pos, new Vector2, tileIndex, tileSize, angle); } - - /** Render the particle, automatically called each frame, sorted by renderOrder */ - render() - { - // modulate size and color - const p = min((time - this.spawnTime) / this.lifeTime, 1); - const radius = this.sizeStart + p * this.sizeEndDelta; - const size = new Vector2(radius, radius); - const fadeRate = this.fadeRate/2; - const color = new Color( - this.colorStart.r + p * this.colorEndDelta.r, - this.colorStart.g + p * this.colorEndDelta.g, - this.colorStart.b + p * this.colorEndDelta.b, - (this.colorStart.a + p * this.colorEndDelta.a) * - (p < fadeRate ? p/fadeRate : p > 1-fadeRate ? (1-p)/fadeRate : 1)); // fade alpha - - // draw the particle - this.additive && setBlendMode(1); - if (this.trailScale) - { - // trail style particles - const speed = this.velocity.length(); - const direction = this.velocity.scale(1/speed); - const trailLength = speed * this.trailScale; - size.y = max(size.x, trailLength); - this.angle = direction.angle(); - drawTile(this.pos.add(direction.multiply(vec2(0,-trailLength/2))), size, this.tileIndex, this.tileSize, color, this.angle, this.mirror); - } - else - drawTile(this.pos, size, this.tileIndex, this.tileSize, color, this.angle, this.mirror); - this.additive && setBlendMode(); - debugParticles && debugRect(this.pos, size, '#f005', 0, this.angle); - - if (p == 1) - { - // destroy particle when it's time runs out - this.color = color; - this.size = size; - this.destroyCallback && this.destroyCallback(this); - this.destroyed = 1; - } - } -} -/** - * LittleJS Medal System - *
- Tracks and displays medals - *
- Saves medals to local storage - *
- Newgrounds and OS13k integration - * @namespace Medals - */ - -'use strict'; - -/** List of all medals - * @memberof Medals */ -const medals = []; - -/** Set to stop medals from being unlockable (like if cheats are enabled) - * @memberof Medals */ -let medalsPreventUnlock; - -/** This can used to enable Newgrounds functionality - * @type {Newgrounds} - * @memberof Medals */ -let newgrounds; - -// Engine internal variables not exposed to documentation -let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast; - -/////////////////////////////////////////////////////////////////////////////// - -/** Initialize medals with a save name used for storage - *
- Call this after creating all medals - *
- Checks if medals are unlocked - * @param {String} saveName - * @memberof Medals */ -function medalsInit(saveName) -{ - // check if medals are unlocked - medalsSaveName = saveName; - debugMedals || medals.forEach(medal=> localStorage[medal.storageKey()]); -} - -/** - * Medal Object - Tracks an unlockable medal - * @example - * // create a medal - * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️'); - * - * // initialize medals - * medalsInit('Example Game'); - * - * // unlock the medal - * medal_example.unlock(); - */ -class Medal -{ - /** Create an medal object and adds it to the list of medals - * @param {Number} id - The unique identifier of the medal - * @param {String} name - Name of the medal - * @param {String} [description] - Description of the medal - * @param {String} [icon='🏆'] - Icon for the medal - * @param {String} [src] - Image location for the medal - */ - constructor(id, name, description='', icon='🏆', src) - { - ASSERT(id >= 0 && !medals[id]); - - // save attributes and add to list of medals - medals[this.id = id] = this; - this.name = name; - this.description = description; - this.icon = icon; - this.image = new Image(); - if (src) - this.image.src = src; - } - - /** Unlocks a medal if not already unlocked */ - unlock() - { - if (medalsPreventUnlock || this.unlocked) - return; - - // save the medal - ASSERT(medalsSaveName); // save name must be set - localStorage[this.storageKey()] = this.unlocked = 1; - medalsDisplayQueue.push(this); - - // save for newgrounds and OS13K - newgrounds && newgrounds.unlockMedal(this.id); - localStorage['OS13kTrophy,' + this.icon + ',' + medalsSaveName + ',' + this.name] = this.description; - } - - /** Render a medal - * @param {Number} [hidePercent=0] - How much to slide the medal off screen - */ - render(hidePercent=0) - { - const context = overlayContext; - const width = min(medalDisplayWidth, mainCanvas.width); - const x = overlayCanvas.width - width; - const y = -medalDisplayHeight*hidePercent; - - // draw containing rect and clip to that region - context.save(); - context.beginPath(); - context.fillStyle = '#ddd' - context.fill(context.rect(x, y, width, medalDisplayHeight)); - context.strokeStyle = '#000'; - context.lineWidth = 3; - context.stroke(); - context.clip(); - - // draw the icon and text - this.renderIcon(x+15+medalDisplayIconSize/2, y+medalDisplayHeight/2); - context.textAlign = 'left'; - context.font = '38px '+ fontDefault; - context.fillText(this.name, x+medalDisplayIconSize+30, y+28); - context.font = '24px '+ fontDefault; - context.fillText(this.description, x+medalDisplayIconSize+30, y+60); - context.restore(); - } - - /** Render the icon for a medal - * @param {Number} x - Screen space X position - * @param {Number} y - Screen space Y position - * @param {Number} [size=medalDisplayIconSize] - Screen space size - */ - renderIcon(x, y, size=medalDisplayIconSize) - { - // draw the image or icon - const context = overlayContext; - context.fillStyle = '#000'; - context.textAlign = 'center'; - context.textBaseline = 'middle'; - context.font = size*.7 + 'px '+ fontDefault; - if (this.image.src) - context.drawImage(this.image, x-size/2, y-size/2, size, size); - else - context.fillText(this.icon, x, y); // show icon if there is no image - } - - // Get local storage key used by the medal - storageKey() { return medalsSaveName + '_' + this.id; } -} - -// engine automatically renders medals -function medalsRender() -{ - if (!medalsDisplayQueue.length) - return; - - // update first medal in queue - const medal = medalsDisplayQueue[0]; - const time = timeReal - medalsDisplayTimeLast; - if (!medalsDisplayTimeLast) - medalsDisplayTimeLast = timeReal; - else if (time > medalDisplayTime) - medalsDisplayQueue.shift(medalsDisplayTimeLast = 0); - else - { - // slide on/off medals - const slideOffTime = medalDisplayTime - medalDisplaySlideTime; - const hidePercent = - time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime : - time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0; - medal.render(hidePercent); - } -} - -/////////////////////////////////////////////////////////////////////////////// - -/** - * Newgrounds API wrapper object - * @example - * // create a newgrounds object, replace the app id and cipher with your own - * const app_id = '53123:1ZuSTQ9l'; - * const cipher = 'enF0vGH@Mj/FRASKL23Q=='; - * newgrounds = new Newgrounds(app_id, cipher); - */ -class Newgrounds -{ - /** Create a newgrounds object - * @param {Number} app_id - The newgrounds App ID - * @param {String} [cipher] - The encryption Key (AES-128/Base64) */ - constructor(app_id, cipher) - { - ASSERT(!newgrounds && app_id); - this.app_id = app_id; - this.cipher = cipher; - this.host = location ? location.hostname : ''; - - // create an instance of CryptoJS for encrypted calls - cipher && (this.cryptoJS = CryptoJS()); - - // get session id from url search params - const url = new URL(location.href); - this.session_id = url.searchParams.get('ngio_session_id') || 0; - - if (this.session_id == 0) - return; // only use newgrounds when logged in - - // get medals - const medalsResult = this.call('Medal.getList'); - this.medals = medalsResult ? medalsResult.result.data['medals'] : []; - debugMedals && console.log(this.medals); - for (const newgroundsMedal of this.medals) - { - const medal = medals[newgroundsMedal['id']]; - if (medal) - { - // copy newgrounds medal data - medal.image.src = newgroundsMedal['icon']; - medal.name = newgroundsMedal['name']; - medal.description = newgroundsMedal['description']; - medal.unlocked = newgroundsMedal['unlocked']; - medal.difficulty = newgroundsMedal['difficulty']; - medal.value = newgroundsMedal['value']; - - if (medal.value) - medal.description = medal.description + ' (' + medal.value + ')'; - } - } - - // get scoreboards - const scoreboardResult = this.call('ScoreBoard.getBoards'); - this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : []; - debugMedals && console.log(this.scoreboards); - - const keepAliveMS = 5 * 60 * 1e3; - setInterval(()=>this.call('Gateway.ping', 0, 1), keepAliveMS); - } - - /** Send message to unlock a medal by id - * @param {Number} id - The medal id */ - unlockMedal(id) { return this.call('Medal.unlock', {'id':id}, 1); } - - /** Send message to post score - * @param {Number} id - The scoreboard id - * @param {Number} value - The score value */ - postScore(id, value) { return this.call('ScoreBoard.postScore', {'id':id, 'value':value}, 1); } - - /** Get scores from a scoreboard - * @param {Number} id - The scoreboard id - * @param {String} [user=0] - A user's id or name - * @param {Number} [social=0] - If true, only social scores will be loaded - * @param {Number} [skip=0] - Number of scores to skip before start - * @param {Number} [limit=10] - Number of scores to include in the list - * @return {Object} - The response JSON object - */ - getScores(id, user=0, social=0, skip=0, limit=10) - { return this.call('ScoreBoard.getScores', {'id':id, 'user':user, 'social':social, 'skip':skip, 'limit':limit}); } - - /** Send message to log a view */ - logView() { return this.call('App.logView', {'host':this.host}, 1); } - - /** Send a message to call a component of the Newgrounds API - * @param {String} component - Name of the component - * @param {Object} [parameters=0] - Parameters to use for call - * @param {Boolean} [async=0] - If true, don't wait for response before continuing (avoid stall) - * @return {Object} - The response JSON object - */ - call(component, parameters=0, async=0) - { - const call = {'component':component, 'parameters':parameters}; - if (this.cipher) - { - // encrypt using AES-128 Base64 with cryptoJS - const cryptoJS = this.cryptoJS; - const aesKey = cryptoJS['enc']['Base64']['parse'](this.cipher); - const iv = cryptoJS['lib']['WordArray']['random'](16); - const encrypted = cryptoJS['AES']['encrypt'](JSON.stringify(call), aesKey, {'iv':iv}); - call['secure'] = cryptoJS['enc']['Base64']['stringify'](iv.concat(encrypted['ciphertext'])); - call['parameters'] = 0; - } - - // build the input object - const input = - { - 'app_id': this.app_id, - 'session_id': this.session_id, - 'call': call - }; - - // build post data - const formData = new FormData(); - formData.append('input', JSON.stringify(input)); - - // send post data - const xmlHttp = new XMLHttpRequest(); - const url = 'https://newgrounds.io/gateway_v3.php'; - xmlHttp.open('POST', url, !debugMedals && async); - xmlHttp.send(formData); - debugMedals && console.log(xmlHttp.responseText); - return xmlHttp.responseText && JSON.parse(xmlHttp.responseText); - } -} - -/////////////////////////////////////////////////////////////////////////////// -// Crypto-JS - https://github.com/brix/crypto-js [The MIT License (MIT)] -// Copyright (c) 2009-2013 Jeff Mott Copyright (c) 2013-2016 Evan Vosberg - -const CryptoJS=()=>eval(Function("[M='GBMGXz^oVYPPKKbB`agTXU|LxPc_ZBcMrZvCr~wyGfWrwk@ATqlqeTp^N?p{we}jIpEnB_sEr`l?YDkDhWhprc|Er|XETG?pTl`e}dIc[_N~}fzRycIfpW{HTolvoPB_FMe_eH~BTMx]yyOhv?biWPCGc]kABencBhgERHGf{OL`Dj`c^sh@canhy[secghiyotcdOWgO{tJIE^JtdGQRNSCrwKYciZOa]Y@tcRATYKzv|sXpboHcbCBf`}SKeXPFM|RiJsSNaIb]QPc[D]Jy_O^XkOVTZep`ONmntLL`Qz~UupHBX_Ia~WX]yTRJIxG`ioZ{fefLJFhdyYoyLPvqgH?b`[TMnTwwfzDXhfM?rKs^aFr|nyBdPmVHTtAjXoYUloEziWDCw_suyYT~lSMksI~ZNCS[Bex~j]Vz?kx`gdYSEMCsHpjbyxQvw|XxX_^nQYue{sBzVWQKYndtYQMWRef{bOHSfQhiNdtR{o?cUAHQAABThwHPT}F{VvFmgN`E@FiFYS`UJmpQNM`X|tPKHlccT}z}k{sACHL?Rt@MkWplxO`ASgh?hBsuuP|xD~LSH~KBlRs]t|l|_tQAroDRqWS^SEr[sYdPB}TAROtW{mIkE|dWOuLgLmJrucGLpebrAFKWjikTUzS|j}M}szasKOmrjy[?hpwnEfX[jGpLt@^v_eNwSQHNwtOtDgWD{rk|UgASs@mziIXrsHN_|hZuxXlPJOsA^^?QY^yGoCBx{ekLuZzRqQZdsNSx@ezDAn{XNj@fRXIwrDX?{ZQHwTEfu@GhxDOykqts|n{jOeZ@c`dvTY?e^]ATvWpb?SVyg]GC?SlzteilZJAL]mlhLjYZazY__qcVFYvt@|bIQnSno@OXyt]OulzkWqH`rYFWrwGs`v|~XeTsIssLrbmHZCYHiJrX}eEzSssH}]l]IhPQhPoQ}rCXLyhFIT[clhzYOvyHqigxmjz`phKUU^TPf[GRAIhNqSOdayFP@FmKmuIzMOeoqdpxyCOwCthcLq?n`L`tLIBboNn~uXeFcPE{C~mC`h]jUUUQe^`UqvzCutYCgct|SBrAeiYQW?X~KzCz}guXbsUw?pLsg@hDArw?KeJD[BN?GD@wgFWCiHq@Ypp_QKFixEKWqRp]oJFuVIEvjDcTFu~Zz]a{IcXhWuIdMQjJ]lwmGQ|]g~c]Hl]pl`Pd^?loIcsoNir_kikBYyg?NarXZEGYspt_vLBIoj}LI[uBFvm}tbqvC|xyR~a{kob|HlctZslTGtPDhBKsNsoZPuH`U`Fqg{gKnGSHVLJ^O`zmNgMn~{rsQuoymw^JY?iUBvw_~mMr|GrPHTERS[MiNpY[Mm{ggHpzRaJaoFomtdaQ_?xuTRm}@KjU~RtPsAdxa|uHmy}n^i||FVL[eQAPrWfLm^ndczgF~Nk~aplQvTUpHvnTya]kOenZlLAQIm{lPl@CCTchvCF[fI{^zPkeYZTiamoEcKmBMfZhk_j_~Fjp|wPVZlkh_nHu]@tP|hS@^G^PdsQ~f[RqgTDqezxNFcaO}HZhb|MMiNSYSAnQWCDJukT~e|OTgc}sf[cnr?fyzTa|EwEtRG|I~|IO}O]S|rp]CQ}}DWhSjC_|z|oY|FYl@WkCOoPuWuqr{fJu?Brs^_EBI[@_OCKs}?]O`jnDiXBvaIWhhMAQDNb{U`bqVR}oqVAvR@AZHEBY@depD]OLh`kf^UsHhzKT}CS}HQKy}Q~AeMydXPQztWSSzDnghULQgMAmbWIZ|lWWeEXrE^EeNoZApooEmrXe{NAnoDf`m}UNlRdqQ@jOc~HLOMWs]IDqJHYoMziEedGBPOxOb?[X`KxkFRg@`mgFYnP{hSaxwZfBQqTm}_?RSEaQga]w[vxc]hMne}VfSlqUeMo_iqmd`ilnJXnhdj^EEFifvZyxYFRf^VaqBhLyrGlk~qowqzHOBlOwtx?i{m~`n^G?Yxzxux}b{LSlx]dS~thO^lYE}bzKmUEzwW^{rPGhbEov[Plv??xtyKJshbG`KuO?hjBdS@Ru}iGpvFXJRrvOlrKN?`I_n_tplk}kgwSXuKylXbRQ]]?a|{xiT[li?k]CJpwy^o@ebyGQrPfF`aszGKp]baIx~H?ElETtFh]dz[OjGl@C?]VDhr}OE@V]wLTc[WErXacM{We`F|utKKjgllAxvsVYBZ@HcuMgLboFHVZmi}eIXAIFhS@A@FGRbjeoJWZ_NKd^oEH`qgy`q[Tq{x?LRP|GfBFFJV|fgZs`MLbpPYUdIV^]mD@FG]pYAT^A^RNCcXVrPsgk{jTrAIQPs_`mD}rOqAZA[}RETFz]WkXFTz_m{N@{W@_fPKZLT`@aIqf|L^Mb|crNqZ{BVsijzpGPEKQQZGlApDn`ruH}cvF|iXcNqK}cxe_U~HRnKV}sCYb`D~oGvwG[Ca|UaybXea~DdD~LiIbGRxJ_VGheI{ika}KC[OZJLn^IBkPrQj_EuoFwZ}DpoBRcK]Q}?EmTv~i_Tul{bky?Iit~tgS|o}JL_VYcCQdjeJ_MfaA`FgCgc[Ii|CBHwq~nbJeYTK{e`CNstKfTKPzw{jdhp|qsZyP_FcugxCFNpKitlR~vUrx^NrSVsSTaEgnxZTmKc`R|lGJeX}ccKLsQZQhsFkeFd|ckHIVTlGMg`~uPwuHRJS_CPuN_ogXe{Ba}dO_UBhuNXby|h?JlgBIqMKx^_u{molgL[W_iavNQuOq?ap]PGB`clAicnl@k~pA?MWHEZ{HuTLsCpOxxrKlBh]FyMjLdFl|nMIvTHyGAlPogqfZ?PlvlFJvYnDQd}R@uAhtJmDfe|iJqdkYr}r@mEjjIetDl_I`TELfoR|qTBu@Tic[BaXjP?dCS~MUK[HPRI}OUOwAaf|_}HZzrwXvbnNgltjTwkBE~MztTQhtRSWoQHajMoVyBBA`kdgK~h`o[J`dm~pm]tk@i`[F~F]DBlJKklrkR]SNw@{aG~Vhl`KINsQkOy?WhcqUMTGDOM_]bUjVd|Yh_KUCCgIJ|LDIGZCPls{RzbVWVLEhHvWBzKq|^N?DyJB|__aCUjoEgsARki}j@DQXS`RNU|DJ^a~d{sh_Iu{ONcUtSrGWW@cvUjefHHi}eSSGrNtO?cTPBShLqzwMVjWQQCCFB^culBjZHEK_{dO~Q`YhJYFn]jq~XSnG@[lQr]eKrjXpG~L^h~tDgEma^AUFThlaR{xyuP@[^VFwXSeUbVetufa@dX]CLyAnDV@Bs[DnpeghJw^?UIana}r_CKGDySoRudklbgio}kIDpA@McDoPK?iYcG?_zOmnWfJp}a[JLR[stXMo?_^Ng[whQlrDbrawZeSZ~SJstIObdDSfAA{MV}?gNunLOnbMv_~KFQUAjIMj^GkoGxuYtYbGDImEYiwEMyTpMxN_LSnSMdl{bg@dtAnAMvhDTBR_FxoQgANniRqxd`pWv@rFJ|mWNWmh[GMJz_Nq`BIN@KsjMPASXORcdHjf~rJfgZYe_uulzqM_KdPlMsuvU^YJuLtofPhGonVOQxCMuXliNvJIaoC?hSxcxKVVxWlNs^ENDvCtSmO~WxI[itnjs^RDvI@KqG}YekaSbTaB]ki]XM@[ZnDAP~@|BzLRgOzmjmPkRE@_sobkT|SszXK[rZN?F]Z_u}Yue^[BZgLtR}FHzWyxWEX^wXC]MJmiVbQuBzkgRcKGUhOvUc_bga|Tx`KEM`JWEgTpFYVeXLCm|mctZR@uKTDeUONPozBeIkrY`cz]]~WPGMUf`MNUGHDbxZuO{gmsKYkAGRPqjc|_FtblEOwy}dnwCHo]PJhN~JoteaJ?dmYZeB^Xd?X^pOKDbOMF@Ugg^hETLdhwlA}PL@_ur|o{VZosP?ntJ_kG][g{Zq`Tu]dzQlSWiKfnxDnk}KOzp~tdFstMobmy[oPYjyOtUzMWdjcNSUAjRuqhLS@AwB^{BFnqjCmmlk?jpn}TksS{KcKkDboXiwK]qMVjm~V`LgWhjS^nLGwfhAYrjDSBL_{cRus~{?xar_xqPlArrYFd?pHKdMEZzzjJpfC?Hv}mAuIDkyBxFpxhstTx`IO{rp}XGuQ]VtbHerlRc_LFGWK[XluFcNGUtDYMZny[M^nVKVeMllQI[xtvwQnXFlWYqxZZFp_|]^oWX[{pOMpxXxvkbyJA[DrPzwD|LW|QcV{Nw~U^dgguSpG]ClmO@j_TENIGjPWwgdVbHganhM?ema|dBaqla|WBd`poj~klxaasKxGG^xbWquAl~_lKWxUkDFagMnE{zHug{b`A~IYcQYBF_E}wiA}K@yxWHrZ{[d~|ARsYsjeNWzkMs~IOqqp[yzDE|WFrivsidTcnbHFRoW@XpAV`lv_zj?B~tPCppRjgbbDTALeFaOf?VcjnKTQMLyp{NwdylHCqmo?oelhjWuXj~}{fpuX`fra?GNkDiChYgVSh{R[BgF~eQa^WVz}ATI_CpY?g_diae]|ijH`TyNIF}|D_xpmBq_JpKih{Ba|sWzhnAoyraiDvk`h{qbBfsylBGmRH}DRPdryEsSaKS~tIaeF[s]I~xxHVrcNe@Jjxa@jlhZueLQqHh_]twVMqG_EGuwyab{nxOF?`HCle}nBZzlTQjkLmoXbXhOtBglFoMz?eqre`HiE@vNwBulglmQjj]DB@pPkPUgA^sjOAUNdSu_`oAzar?n?eMnw{{hYmslYi[TnlJD'",...']charCodeAtUinyxpf',"for(;e<10359;c[e++]=p-=128,A=A?p-A&&A:p==34&&p)for(p=1;p<128;y=f.map((n,x)=>(U=r[n]*2+1,U=Math.log(U/(h-U)),t-=a[x]*U,U/500)),t=~-h/(1+Math.exp(t))|1,i=o%h>17)-!i*t,f.map((n,x)=>(U=r[n]+=(i*h/2-r[n]<<13)/((C[n]+=C[n]<5)+1/20)>>13,a[x]+=y[x]*(i-t/h))),p=p*2+i)for(f='010202103203210431053105410642065206541'.split(t=0).map((n,x)=>(U=0,[...n].map((n,x)=>(U=U*997+(c[e-n]|0)|0)),h*32-1&U*997+p+!!A*129)*12+x);o - All webgl used by the engine is wrapped up here - *
- For normal stuff you won't need to see or call anything in this file - *
- For advanced stuff there are helper functions to create shaders, textures, etc - *
- Can be disabled with glEnable to revert to 2D canvas rendering - *
- Batches sprite rendering on GPU for incredibly fast performance - *
- Sprite transform math is done in the shader where possible - * @namespace WebGL - */ - -'use strict'; - -/** The WebGL canvas which appears above the main canvas and below the overlay canvas - * @type {HTMLCanvasElement} - * @memberof WebGL */ -let glCanvas; - -/** 2d context for glCanvas - * @type {WebGLRenderingContext} - * @memberof WebGL */ -let glContext; - -/** Main tile sheet texture automatically loaded by engine - * @type {WebGLTexture} - * @memberof WebGL */ -let glTileTexture; - -// WebGL internal variables not exposed to documentation -let glActiveTexture, glShader, glPositionData, glColorData, glBatchCount, glBatchAdditive, glAdditive; - -/////////////////////////////////////////////////////////////////////////////// - -// Init WebGL, called automatically by the engine -function glInit() -{ - if (!glEnable) return; - - // create the canvas and tile texture - glCanvas = document.createElement('canvas'); - glContext = glCanvas.getContext('webgl', {antialias: false}); - glCanvas.style = styleCanvas; - glTileTexture = glCreateTexture(tileImage); - - // some browsers are much faster without copying the gl buffer so we just overlay it instead - glOverlay && document.body.appendChild(glCanvas); - - // setup vertex and fragment shaders - glShader = glCreateProgram( - 'precision highp float;'+ // use highp for better accuracy - 'uniform mat4 m;'+ // transform matrix - 'attribute vec2 p,t;'+ // position, uv - 'attribute vec4 c,a;'+ // color, additiveColor - 'varying vec2 v;'+ // return uv - 'varying vec4 d,e;'+ // return color, additiveColor - 'void main(){'+ // shader entry point - 'gl_Position=m*vec4(p,1,1);'+ // transform position - 'v=t;d=c;e=a;'+ // pass stuff to fragment shader - '}' // end of shader - , - 'precision highp float;'+ // use highp for better accuracy - 'varying vec2 v;'+ // uv - 'varying vec4 d,e;'+ // color, additiveColor - 'uniform sampler2D s;'+ // texture - 'void main(){'+ // shader entry point - 'gl_FragColor=texture2D(s,v)*d+e;'+ // modulate texture by color plus additive - '}' // end of shader - ); - - // init buffers - const glVertexData = new ArrayBuffer(gl_MAX_BATCH * gl_VERTICES_PER_QUAD * gl_VERTEX_BYTE_STRIDE); - glCreateBuffer(gl_ARRAY_BUFFER, glVertexData.byteLength, gl_DYNAMIC_DRAW); - glPositionData = new Float32Array(glVertexData); - glColorData = new Uint32Array(glVertexData); - - // setup the vertex data array - let offset = glBatchCount = 0; - const initVertexAttribArray = (name, type, typeSize, size, normalize=0)=> - { - const location = glContext.getAttribLocation(glShader, name); - glContext.enableVertexAttribArray(location); - glContext.vertexAttribPointer(location, size, type, normalize, gl_VERTEX_BYTE_STRIDE, offset); - offset += size*typeSize; - } - initVertexAttribArray('p', gl_FLOAT, 4, 2); // position - initVertexAttribArray('t', gl_FLOAT, 4, 2); // texture coords - initVertexAttribArray('c', gl_UNSIGNED_BYTE, 1, 4, 1); // color - initVertexAttribArray('a', gl_UNSIGNED_BYTE, 1, 4, 1); // additiveColor -} - -/** Set the WebGl blend mode, normally you should call setBlendMode instead - * @param {Boolean} [additive=0] - * @memberof WebGL */ -function glSetBlendMode(additive) -{ - if (!glEnable) return; - - // setup blending - glAdditive = additive; -} - -/** Set the WebGl texture, not normally necessary unless multiple tile sheets are used - *
- This may also flush the gl buffer resulting in more draw calls and worse performance - * @param {WebGLTexture} [texture=glTileTexture] - * @memberof WebGL */ -function glSetTexture(texture=glTileTexture) -{ - if (!glEnable) return; - - // must flush cache with the old texture to set a new one - if (texture != glActiveTexture) - glFlush(); - - glContext.bindTexture(gl_TEXTURE_2D, glActiveTexture = texture); -} - -/** Compile WebGL shader of the given type, will throw errors if in debug mode - * @param {String} source - * @param type - * @return {WebGLShader} - * @memberof WebGL */ -function glCompileShader(source, type) -{ - if (!glEnable) return; - - // build the shader - const shader = glContext.createShader(type); - glContext.shaderSource(shader, source); - glContext.compileShader(shader); - - // check for errors - if (debug && !glContext.getShaderParameter(shader, gl_COMPILE_STATUS)) - throw glContext.getShaderInfoLog(shader); - return shader; -} - -/** Create WebGL program with given shaders - * @param {WebGLShader} vsSource - * @param {WebGLShader} fsSource - * @return {WebGLProgram} - * @memberof WebGL */ -function glCreateProgram(vsSource, fsSource) -{ - if (!glEnable) return; - - // build the program - const program = glContext.createProgram(); - glContext.attachShader(program, glCompileShader(vsSource, gl_VERTEX_SHADER)); - glContext.attachShader(program, glCompileShader(fsSource, gl_FRAGMENT_SHADER)); - glContext.linkProgram(program); - - // check for errors - if (debug && !glContext.getProgramParameter(program, gl_LINK_STATUS)) - throw glContext.getProgramInfoLog(program); - return program; -} - -/** Create WebGL buffer - * @param bufferType - * @param size - * @param usage - * @return {WebGLBuffer} - * @memberof WebGL */ -function glCreateBuffer(bufferType, size, usage) -{ - if (!glEnable) return; - - // build the buffer - const buffer = glContext.createBuffer(); - glContext.bindBuffer(bufferType, buffer); - glContext.bufferData(bufferType, size, usage); - return buffer; -} - -/** Create WebGL texture from an image and set the texture settings - * @param {Image} image - * @return {WebGLTexture} - * @memberof WebGL */ -function glCreateTexture(image) -{ - if (!glEnable || !image || !image.width) return; - - // build the texture - const texture = glContext.createTexture(); - glContext.bindTexture(gl_TEXTURE_2D, texture); - glContext.texImage2D(gl_TEXTURE_2D, 0, gl_RGBA, gl_RGBA, gl_UNSIGNED_BYTE, image); - - // use point filtering for pixelated rendering - glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MIN_FILTER, cavasPixelated ? gl_NEAREST : gl_LINEAR); - glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MAG_FILTER, cavasPixelated ? gl_NEAREST : gl_LINEAR); - glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_WRAP_S, gl_CLAMP_TO_EDGE); - glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_WRAP_T, gl_CLAMP_TO_EDGE); - return texture; -} - -// called automatically by engine before render -function glPreRender(width, height, cameraX, cameraY, cameraScale) -{ - if (!glEnable) return; - - // clear and set to same size as main canvas - glContext.viewport(0, 0, glCanvas.width = width, glCanvas.height = height); - glContext.clear(gl_COLOR_BUFFER_BIT); - - // set up the shader - glContext.bindTexture(gl_TEXTURE_2D, glActiveTexture = glTileTexture); - glContext.useProgram(glShader); - glSetBlendMode(); - - // build the transform matrix - const sx = 2 * cameraScale / width; - const sy = 2 * cameraScale / height; - glContext.uniformMatrix4fv(glContext.getUniformLocation(glShader, 'm'), 0, - new Float32Array([ - sx, 0, 0, 0, - 0, sy, 0, 0, - 1, 1, -1, 1, - -1-sx*cameraX, -1-sy*cameraY, 0, 0 - ]) - ); -} - -/** Draw all sprites and clear out the buffer, called automatically by the system whenever necessary - * @memberof WebGL */ -function glFlush() -{ - if (!glEnable || !glBatchCount) return; - - const destBlend = glBatchAdditive ? gl_ONE : gl_ONE_MINUS_SRC_ALPHA; - glContext.blendFuncSeparate(gl_SRC_ALPHA, destBlend, gl_ONE, destBlend); - glContext.enable(gl_BLEND); - - // draw all the sprites in the batch and reset the buffer - glContext.bufferSubData(gl_ARRAY_BUFFER, 0, - glPositionData.subarray(0, glBatchCount * gl_VERTICES_PER_QUAD * gl_INDICIES_PER_VERT)); - glContext.drawArrays(gl_TRIANGLES, 0, glBatchCount * gl_VERTICES_PER_QUAD); - glBatchCount = 0; - glBatchAdditive = glAdditive; -} - -/** Draw any sprites still in the buffer, copy to main canvas and clear - * @param {CanvasRenderingContext2D} context - * @param {Boolean} [forceDraw=0] - * @memberof WebGL */ -function glCopyToContext(context, forceDraw) -{ - if ((!glEnable || !glBatchCount) && !forceDraw) return; - - glFlush(); - - // do not draw in overlay mode because the canvas is visible - if (!glOverlay || forceDraw) - context.drawImage(glCanvas, 0, 0); -} - -/** Add a sprite to the gl draw list, used by all gl draw functions - * @param x - * @param y - * @param sizeX - * @param sizeY - * @param angle - * @param uv0X - * @param uv0Y - * @param uv1X - * @param uv1Y - * @param [rgba=0xffffffff] - * @param [rgbaAdditive=0] - * @memberof WebGL */ -function glDraw(x, y, sizeX, sizeY, angle, uv0X, uv0Y, uv1X, uv1Y, rgba=0xffffffff, rgbaAdditive=0) -{ - if (!glEnable) return; - - // flush if there is no room for more verts or if different blend mode - if (glBatchCount == gl_MAX_BATCH || glBatchAdditive != glAdditive) - glFlush(); - - // prepare to create the verts from size and angle - const c = Math.cos(angle)/2, s = Math.sin(angle)/2; - const cx = c*sizeX, cy = c*sizeY, sx = s*sizeX, sy = s*sizeY; - - // setup 2 triangles to form a quad - let offset = glBatchCount++ * gl_VERTICES_PER_QUAD * gl_INDICIES_PER_VERT; - - // vertex 0 - glPositionData[offset++] = x - cx - sy; - glPositionData[offset++] = y - cy + sx; - glPositionData[offset++] = uv0X; glPositionData[offset++] = uv1Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 1 - glPositionData[offset++] = x + cx + sy; - glPositionData[offset++] = y + cy - sx; - glPositionData[offset++] = uv1X; glPositionData[offset++] = uv0Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 2 - glPositionData[offset++] = x - cx + sy; - glPositionData[offset++] = y + cy + sx; - glPositionData[offset++] = uv0X; glPositionData[offset++] = uv0Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 0 - glPositionData[offset++] = x - cx - sy; - glPositionData[offset++] = y - cy + sx; - glPositionData[offset++] = uv0X; glPositionData[offset++] = uv1Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 3 - glPositionData[offset++] = x + cx - sy; - glPositionData[offset++] = y - cy - sx; - glPositionData[offset++] = uv1X; glPositionData[offset++] = uv1Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; - - // vertex 1 - glPositionData[offset++] = x + cx + sy; - glPositionData[offset++] = y + cy - sx; - glPositionData[offset++] = uv1X; glPositionData[offset++] = uv0Y; - glColorData[offset++] = rgba; glColorData[offset++] = rgbaAdditive; -} - -/////////////////////////////////////////////////////////////////////////////// -// store gl constants as integers so their name doesn't use space in minifed -const -gl_ONE = 1, -gl_TRIANGLES = 4, -gl_SRC_ALPHA = 770, -gl_ONE_MINUS_SRC_ALPHA = 771, -gl_BLEND = 3042, -gl_TEXTURE_2D = 3553, -gl_UNSIGNED_BYTE = 5121, -gl_FLOAT = 5126, -gl_RGBA = 6408, -gl_NEAREST = 9728, -gl_LINEAR = 9729, -gl_TEXTURE_MAG_FILTER = 10240, -gl_TEXTURE_MIN_FILTER = 10241, -gl_TEXTURE_WRAP_S = 10242, -gl_TEXTURE_WRAP_T = 10243, -gl_COLOR_BUFFER_BIT = 16384, -gl_CLAMP_TO_EDGE = 33071, -gl_ARRAY_BUFFER = 34962, -gl_DYNAMIC_DRAW = 35048, -gl_FRAGMENT_SHADER = 35632, -gl_VERTEX_SHADER = 35633, -gl_COMPILE_STATUS = 35713, -gl_LINK_STATUS = 35714, - -// constants for batch rendering -gl_VERTICES_PER_QUAD = 6, -gl_INDICIES_PER_VERT = 6, -gl_MAX_BATCH = 1<<16, -gl_VERTEX_BYTE_STRIDE = (4 * 2) * 2 + (4) * 2; // vec2 * 2 + (char * 4) * 2 +/* + LittleJS - Release Build + MIT License - Copyright 2021 Frank Force + + - This file is used for release builds in place of engineDebug.js + - Debug functionality will be disabled to lower size and increase performance +*/ + +'use strict'; + +let showWatermark = 0; +let godMode = 0; +const debug = 0; +const debugOverlay = 0; +const debugPhysics = 0; +const debugParticles = 0; +const debugRaycast = 0; +const debugGamepads = 0; +const debugMedals = 0; + +// debug commands are automatically removed from the final build +const ASSERT = ()=> {} +const debugInit = ()=> {} +const debugUpdate = ()=> {} +const debugRender = ()=> {} +const debugRect = ()=> {} +const debugCircle = ()=> {} +const debugPoint = ()=> {} +const debugLine = ()=> {} +const debugAABB = ()=> {} +const debugText = ()=> {} +const debugClear = ()=> {} +const debugSaveCanvas = ()=> {} +/** + * LittleJS Utility Classes and Functions + *
- General purpose math library + *
- Vector2 - fast, simple, easy 2D vector class + *
- Color - holds a rgba color with some math functions + *
- Timer - tracks time automatically + * @namespace Utilities + */ + +'use strict'; + +/** A shortcut to get Math.PI + * @type {Number} + * @default Math.PI + * @memberof Utilities */ +const PI = Math.PI; + +/** Returns absoulte value of value passed in + * @param {Number} value + * @return {Number} + * @memberof Utilities */ +const abs = (a)=> a < 0 ? -a : a; + +/** Returns lowest of two values passed in + * @param {Number} valueA + * @param {Number} valueB + * @return {Number} + * @memberof Utilities */ +const min = (a, b)=> a < b ? a : b; + +/** Returns highest of two values passed in + * @param {Number} valueA + * @param {Number} valueB + * @return {Number} + * @memberof Utilities */ +const max = (a, b)=> a > b ? a : b; + +/** Returns the sign of value passed in (also returns 1 if 0) + * @param {Number} value + * @return {Number} + * @memberof Utilities */ +const sign = (a)=> a < 0 ? -1 : 1; + +/** Returns first parm modulo the second param, but adjusted so negative numbers work as expected + * @param {Number} dividend + * @param {Number} [divisor=1] + * @return {Number} + * @memberof Utilities */ +const mod = (a, b=1)=> ((a % b) + b) % b; + +/** Clamps the value beween max and min + * @param {Number} value + * @param {Number} [min=0] + * @param {Number} [max=1] + * @return {Number} + * @memberof Utilities */ +const clamp = (v, min=0, max=1)=> v < min ? min : v > max ? max : v; + +/** Returns what percentage the value is between max and min + * @param {Number} value + * @param {Number} [min=0] + * @param {Number} [max=1] + * @return {Number} + * @memberof Utilities */ +const percent = (v, min=0, max=1)=> max-min ? clamp((v-min) / (max-min)) : 0; + +/** Linearly interpolates the percent value between max and min + * @param {Number} percent + * @param {Number} [min=0] + * @param {Number} [max=1] + * @return {Number} + * @memberof Utilities */ +const lerp = (p, min=0, max=1)=> min + clamp(p) * (max-min); + +/** Applies smoothstep function to the percentage value + * @param {Number} value + * @return {Number} + * @memberof Utilities */ +const smoothStep = (p)=> p * p * (3 - 2 * p); + +/** Returns the nearest power of two not less then the value + * @param {Number} value + * @return {Number} + * @memberof Utilities */ +const nearestPowerOfTwo = (v)=> 2**Math.ceil(Math.log2(v)); + +/** Returns true if two axis aligned bounding boxes are overlapping + * @param {Vector2} pointA - Center of box A + * @param {Vector2} sizeA - Size of box A + * @param {Vector2} pointB - Center of box B + * @param {Vector2} [sizeB] - Size of box B + * @return {Boolean} - True if overlapping + * @memberof Utilities */ +const isOverlapping = (pA, sA, pB, sB)=> abs(pA.x - pB.x)*2 < sA.x + sB.x && abs(pA.y - pB.y)*2 < sA.y + sB.y; + +/** Returns an oscillating wave between 0 and amplitude with frequency of 1 Hz by default + * @param {Number} [frequency=1] - Frequency of the wave in Hz + * @param {Number} [amplitude=1] - Amplitude (max height) of the wave + * @param {Number} [t=time] - Value to use for time of the wave + * @return {Number} - Value waving between 0 and amplitude + * @memberof Utilities */ +const wave = (frequency=1, amplitude=1, t=time)=> amplitude/2 * (1 - Math.cos(t*frequency*2*PI)); + +/** Formats seconds to mm:ss style for display purposes + * @param {Number} t - time in seconds + * @return {String} + * @memberof Utilities */ +const formatTime = (t)=> (t/60|0) + ':' + (t%60<10?'0':'') + (t%60|0); + +/////////////////////////////////////////////////////////////////////////////// + +/** Random global functions + * @namespace Random */ + +/** Returns a random value between the two values passed in + * @param {Number} [valueA=1] + * @param {Number} [valueB=0] + * @return {Number} + * @memberof Random */ +const rand = (a=1, b=0)=> b + (a-b)*Math.random(); + +/** Returns a floored random value the two values passed in + * @param {Number} [valueA=1] + * @param {Number} [valueB=0] + * @return {Number} + * @memberof Random */ +const randInt = (a=1, b=0)=> rand(a,b)|0; + +/** Randomly returns either -1 or 1 + * @return {Number} + * @memberof Random */ +const randSign = ()=> randInt(2) * 2 - 1; + +/** Returns a random Vector2 within a circular shape + * @param {Number} [radius=1] + * @param {Number} [minRadius=0] + * @return {Vector2} + * @memberof Random */ +const randInCircle = (radius=1, minRadius=0)=> radius > 0 ? randVector(radius * rand(minRadius / radius, 1)**.5) : new Vector2; + +/** Returns a random Vector2 with the passed in length + * @param {Number} [length=1] + * @return {Vector2} + * @memberof Random */ +const randVector = (length=1)=> new Vector2().setAngle(rand(2*PI), length); + +/** Returns a random color between the two passed in colors, combine components if linear + * @param {Color} [colorA=Color()] + * @param {Color} [colorB=Color(0,0,0,1)] + * @param {Boolean} [linear] + * @return {Color} + * @memberof Random */ +const randColor = (cA = new Color, cB = new Color(0,0,0,1), linear)=> + linear ? cA.lerp(cB, rand()) : new Color(rand(cA.r,cB.r),rand(cA.g,cB.g),rand(cA.b,cB.b),rand(cA.a,cB.a)); + +/** Seed used by the randSeeded function + * @type {Number} + * @default + * @memberof Random */ +let randSeed = 1; + +/** Set seed used by the randSeeded function, should not be 0 + * @param {Number} seed + * @memberof Random */ +const setRandSeed = (seed)=> randSeed = seed; + +/** Returns a seeded random value between the two values passed in using randSeed + * @param {Number} [valueA=1] + * @param {Number} [valueB=0] + * @return {Number} + * @memberof Random */ +const randSeeded = (a=1, b=0)=> +{ + randSeed ^= randSeed << 13; randSeed ^= randSeed >>> 17; randSeed ^= randSeed << 5; // xorshift + return b + (a-b) * abs(randSeed % 1e9) / 1e9; +} + +/////////////////////////////////////////////////////////////////////////////// + +/** + * Create a 2d vector, can take another Vector2 to copy, 2 scalars, or 1 scalar + * @param {Number} [x=0] + * @param {Number} [y=0] + * @return {Vector2} + * @example + * let a = vec2(0, 1); // vector with coordinates (0, 1) + * let b = vec2(a); // copy a into b + * a = vec2(5); // set a to (5, 5) + * b = vec2(); // set b to (0, 0) + * @memberof Utilities + */ +const vec2 = (x=0, y)=> x.x == undefined ? new Vector2(x, y == undefined? x : y) : new Vector2(x.x, x.y); + +/** + * Check if object is a valid Vector2 + * @param {Vector2} vector + * @return {Boolean} + * @memberof Utilities + */ +const isVector2 = (v)=> !isNaN(v.x) && !isNaN(v.y); + +/** + * 2D Vector object with vector math library + *
- Functions do not change this so they can be chained together + * @example + * let a = new Vector2(2, 3); // vector with coordinates (2, 3) + * let b = new Vector2; // vector with coordinates (0, 0) + * let c = vec2(4, 2); // use the vec2 function to make a Vector2 + * let d = a.add(b).scale(5); // operators can be chained + */ +class Vector2 +{ + /** Create a 2D vector with the x and y passed in, can also be created with vec2() + * @param {Number} [x=0] - X axis location + * @param {Number} [y=0] - Y axis location */ + constructor(x=0, y=0) + { + /** @property {Number} - X axis location */ + this.x = x; + /** @property {Number} - Y axis location */ + this.y = y; + } + + /** Returns a new vector that is a copy of this + * @return {Vector2} */ + copy() { return new Vector2(this.x, this.y); } + + /** Returns a copy of this vector plus the vector passed in + * @param {Vector2} vector + * @return {Vector2} */ + add(v) { ASSERT(isVector2(v)); return new Vector2(this.x + v.x, this.y + v.y); } + + /** Returns a copy of this vector minus the vector passed in + * @param {Vector2} vector + * @return {Vector2} */ + subtract(v) { ASSERT(isVector2(v)); return new Vector2(this.x - v.x, this.y - v.y); } + + /** Returns a copy of this vector times the vector passed in + * @param {Vector2} vector + * @return {Vector2} */ + multiply(v) { ASSERT(isVector2(v)); return new Vector2(this.x * v.x, this.y * v.y); } + + /** Returns a copy of this vector divided by the vector passed in + * @param {Vector2} vector + * @return {Vector2} */ + divide(v) { ASSERT(isVector2(v)); return new Vector2(this.x / v.x, this.y / v.y); } + + /** Returns a copy of this vector scaled by the vector passed in + * @param {Number} scale + * @return {Vector2} */ + scale(s) { ASSERT(!isVector2(s)); return new Vector2(this.x * s, this.y * s); } + + /** Returns the length of this vector + * @return {Number} */ + length() { return this.lengthSquared()**.5; } + + /** Returns the length of this vector squared + * @return {Number} */ + lengthSquared() { return this.x**2 + this.y**2; } + + /** Returns the distance from this vector to vector passed in + * @param {Vector2} vector + * @return {Number} */ + distance(v) { return this.distanceSquared(v)**.5; } + + /** Returns the distance squared from this vector to vector passed in + * @param {Vector2} vector + * @return {Number} */ + distanceSquared(v) { return (this.x - v.x)**2 + (this.y - v.y)**2; } + + /** Returns a new vector in same direction as this one with the length passed in + * @param {Number} [length=1] + * @return {Vector2} */ + normalize(length=1) { const l = this.length(); return l ? this.scale(length/l) : new Vector2(0, length); } + + /** Returns a new vector clamped to length passed in + * @param {Number} [length=1] + * @return {Vector2} */ + clampLength(length=1) { const l = this.length(); return l > length ? this.scale(length/l) : this; } + + /** Returns the dot product of this and the vector passed in + * @param {Vector2} vector + * @return {Number} */ + dot(v) { ASSERT(isVector2(v)); return this.x*v.x + this.y*v.y; } + + /** Returns the cross product of this and the vector passed in + * @param {Vector2} vector + * @return {Number} */ + cross(v) { ASSERT(isVector2(v)); return this.x*v.y - this.y*v.x; } + + /** Returns the angle of this vector, up is angle 0 + * @return {Number} */ + angle() { return Math.atan2(this.x, this.y); } + + /** Sets this vector with angle and length passed in + * @param {Number} [angle=0] + * @param {Number} [length=1] + * @return {Vector2} */ + setAngle(a=0, length=1) { this.x = length*Math.sin(a); this.y = length*Math.cos(a); return this; } + + /** Returns copy of this vector rotated by the angle passed in + * @param {Number} angle + * @return {Vector2} */ + rotate(a) { const c = Math.cos(a), s = Math.sin(a); return new Vector2(this.x*c-this.y*s, this.x*s+this.y*c); } + + /** Returns the integer direction of this vector, corrosponding to multiples of 90 degree rotation (0-3) + * @return {Number} */ + direction() { return abs(this.x) > abs(this.y) ? this.x < 0 ? 3 : 1 : this.y < 0 ? 2 : 0; } + + /** Returns a copy of this vector that has been inverted + * @return {Vector2} */ + invert() { return new Vector2(this.y, -this.x); } + + /** Returns a copy of this vector with each axis floored + * @return {Vector2} */ + floor() { return new Vector2(Math.floor(this.x), Math.floor(this.y)); } + + /** Returns the area this vector covers as a rectangle + * @return {Number} */ + area() { return abs(this.x * this.y); } + + /** Returns a new vector that is p percent between this and the vector passed in + * @param {Vector2} vector + * @param {Number} percent + * @return {Vector2} */ + lerp(v, p) { ASSERT(isVector2(v)); return this.add(v.subtract(this).scale(clamp(p))); } + + /** Returns true if this vector is within the bounds of an array size passed in + * @param {Vector2} arraySize + * @return {Boolean} */ + arrayCheck(arraySize) { return this.x >= 0 && this.y >= 0 && this.x < arraySize.x && this.y < arraySize.y; } + + /** Returns this vector expressed as a string + * @param {float} digits - precision to display + * @return {String} */ + toString(digits=3) + { if (debug) { return `(${(this.x<0?'':' ') + this.x.toFixed(digits)},${(this.y<0?'':' ') + this.y.toFixed(digits)} )`; }} +} + +/////////////////////////////////////////////////////////////////////////////// + +/** + * Create a color object with RGBA values + * @param {Number} [r=1] + * @param {Number} [g=1] + * @param {Number} [b=1] + * @param {Number} [a=1] + * @return {Color} + * @memberof Utilities + */ +const colorRGBA = (r, g, b, a)=> new Color(r, g, b, a); + +/** + * Create a color object with HSLA values + * @param {Number} [h=0] + * @param {Number} [s=0] + * @param {Number} [l=1] + * @param {Number} [a=1] + * @return {Color} + * @memberof Utilities + */ +const colorHSLA = (h, s, l, a)=> new Color().setHSLA(h, s, l, a); + +/** + * Color object (red, green, blue, alpha) with some helpful functions + * @example + * let a = new Color; // white + * let b = new Color(1, 0, 0); // red + * let c = new Color(0, 0, 0, 0); // transparent black + * let d = colorRGBA(0, 0, 1); // blue using rgb color + * let e = colorHSLA(.3, 1, .5); // green using hsl color + */ +class Color +{ + /** Create a color with the components passed in, white by default + * @param {Number} [red=1] + * @param {Number} [green=1] + * @param {Number} [blue=1] + * @param {Number} [alpha=1] */ + constructor(r=1, g=1, b=1, a=1) + { + /** @property {Number} - Red */ + this.r = r; + /** @property {Number} - Green */ + this.g = g; + /** @property {Number} - Blue */ + this.b = b; + /** @property {Number} - Alpha */ + this.a = a; + } + + /** Returns a new color that is a copy of this + * @return {Color} */ + copy() { return new Color(this.r, this.g, this.b, this.a); } + + /** Returns a copy of this color plus the color passed in + * @param {Color} color + * @return {Color} */ + add(c) { return new Color(this.r+c.r, this.g+c.g, this.b+c.b, this.a+c.a); } + + /** Returns a copy of this color minus the color passed in + * @param {Color} color + * @return {Color} */ + subtract(c) { return new Color(this.r-c.r, this.g-c.g, this.b-c.b, this.a-c.a); } + + /** Returns a copy of this color times the color passed in + * @param {Color} color + * @return {Color} */ + multiply(c) { return new Color(this.r*c.r, this.g*c.g, this.b*c.b, this.a*c.a); } + + /** Returns a copy of this color divided by the color passed in + * @param {Color} color + * @return {Color} */ + divide(c) { return new Color(this.r/c.r, this.g/c.g, this.b/c.b, this.a/c.a); } + + /** Returns a copy of this color scaled by the value passed in, alpha can be scaled separately + * @param {Number} scale + * @param {Number} [alphaScale=scale] + * @return {Color} */ + scale(s, a=s) { return new Color(this.r*s, this.g*s, this.b*s, this.a*a); } + + /** Returns a copy of this color clamped to the valid range between 0 and 1 + * @return {Color} */ + clamp() { return new Color(clamp(this.r), clamp(this.g), clamp(this.b), clamp(this.a)); } + + /** Returns a new color that is p percent between this and the color passed in + * @param {Color} color + * @param {Number} percent + * @return {Color} */ + lerp(c, p) { return this.add(c.subtract(this).scale(clamp(p))); } + + /** Sets this color given a hue, saturation, lightness, and alpha + * @param {Number} [hue=0] + * @param {Number} [saturation=0] + * @param {Number} [lightness=1] + * @param {Number} [alpha=1] + * @return {Color} */ + setHSLA(h=0, s=0, l=1, a=1) + { + const q = l < .5 ? l*(1+s) : l+s-l*s, p = 2*l-q, + f = (p, q, t)=> + (t = ((t%1)+1)%1) < 1/6 ? p+(q-p)*6*t : + t < 1/2 ? q : + t < 2/3 ? p+(q-p)*(2/3-t)*6 : p; + + this.r = f(p, q, h + 1/3); + this.g = f(p, q, h); + this.b = f(p, q, h - 1/3); + this.a = a; + return this; + } + + /** Returns this color expressed in hsla format + * @return {Array} */ + getHSLA() + { + const r = clamp(this.r); + const g = clamp(this.g); + const b = clamp(this.b); + const a = clamp(this.a); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + + let h = 0, s = 0; + if (max != min) + { + let d = max - min; + s = l > .5 ? d / (2 - max - min) : d / (max + min); + if (r == max) + h = (g - b) / d + (g < b ? 6 : 0); + else if (g == max) + h = (b - r) / d + 2; + else if (b == max) + h = (r - g) / d + 4; + } + + return [h / 6, s, l, a]; + } + + /** Returns a new color that has each component randomly adjusted + * @param {Number} [amount=.05] + * @param {Number} [alphaAmount=0] + * @return {Color} */ + mutate(amount=.05, alphaAmount=0) + { + return new Color + ( + this.r + rand(amount, -amount), + this.g + rand(amount, -amount), + this.b + rand(amount, -amount), + this.a + rand(alphaAmount, -alphaAmount) + ).clamp(); + } + + /** Returns this color expressed as a hex color code + * @param {Boolean} [useAlpha=1] - if alpha should be included in result + * @return {String} */ + toString(useAlpha = 1) + { + const toHex = (c)=> ((c=c*255|0)<16 ? '0' : '') + c.toString(16); + return '#' + toHex(this.r) + toHex(this.g) + toHex(this.b) + (useAlpha ? toHex(this.a) : ''); + } + + /** Set this color from a hex code + * @param {String} hex - html hex code + * @return {Color} */ + setHex(hex) + { + const fromHex = (c)=> clamp(parseInt(hex.slice(c,c+2),16)/255); + this.r = fromHex(1); + this.g = fromHex(3), + this.b = fromHex(5); + this.a = hex.length > 7 ? fromHex(7) : 1; + return this; + } + + /** Returns this color expressed as 32 bit RGBA value + * @return {Number} */ + rgbaInt() + { + const toByte = (c)=> clamp(c)*255|0; + const r = toByte(this.r); + const g = toByte(this.g)<<8; + const b = toByte(this.b)<<16; + const a = toByte(this.a)<<24; + return r + g + b + a; + } +} + +/////////////////////////////////////////////////////////////////////////////// + +/** + * Timer object tracks how long has passed since it was set + * @example + * let a = new Timer; // creates a timer that is not set + * a.set(3); // sets the timer to 3 seconds + * + * let b = new Timer(1); // creates a timer with 1 second left + * b.unset(); // unsets the timer + */ +class Timer +{ + /** Create a timer object set time passed in + * @param {Number} [timeLeft] - How much time left before the timer elapses in seconds */ + constructor(timeLeft) { this.time = timeLeft == undefined ? undefined : time + timeLeft; this.setTime = timeLeft; } + + /** Set the timer with seconds passed in + * @param {Number} [timeLeft=0] - How much time left before the timer is elapsed in seconds */ + set(timeLeft=0) { this.time = time + timeLeft; this.setTime = timeLeft; } + + /** Unset the timer */ + unset() { this.time = undefined; } + + /** Returns true if set + * @return {Boolean} */ + isSet() { return this.time != undefined; } + + /** Returns true if set and has not elapsed + * @return {Boolean} */ + active() { return time <= this.time; } + + /** Returns true if set and elapsed + * @return {Boolean} */ + elapsed() { return time > this.time; } + + /** Get how long since elapsed, returns 0 if not set (returns negative if currently active) + * @return {Number} */ + get() { return this.isSet()? time - this.time : 0; } + + /** Get percentage elapsed based on time it was set to, returns 0 if not set + * @return {Number} */ + getPercent() { return this.isSet()? percent(this.time - time, this.setTime, 0) : 0; } + + /** Returns this timer expressed as a string + * @return {String} */ + toString() { if (debug) { return this.unset() ? 'unset' : Math.abs(this.get()) + ' seconds ' + (this.get()<0 ? 'before' : 'after' ); }} + + /** Get how long since elapsed, returns 0 if not set (returns negative if currently active) + * @return {Number} */ + valueOf() { return this.get(); } +} +/** + * LittleJS Engine Settings + * @namespace Settings + */ + +'use strict'; + +/////////////////////////////////////////////////////////////////////////////// +// Camera settings + +/** Position of camera in world space + * @type {Vector2} + * @default Vector2() + * @memberof Settings */ +let cameraPos = vec2(); + +/** Scale of camera in world space + * @type {Number} + * @default + * @memberof Settings */ +let cameraScale = 32; + +/////////////////////////////////////////////////////////////////////////////// +// Display settings + +/** The max size of the canvas, centered if window is larger + * @type {Vector2} + * @default Vector2(1920,1200) + * @memberof Settings */ +let canvasMaxSize = vec2(1920, 1200); + +/** Fixed size of the canvas, if enabled canvas size never changes + * - you may also need to set mainCanvasSize if using screen space coords in startup + * @type {Vector2} + * @default Vector2() + * @memberof Settings */ +let canvasFixedSize = vec2(); + +/** Disables anti aliasing for pixel art if true + * @type {Boolean} + * @default + * @memberof Settings */ +let cavasPixelated = 1; + +/** Default font used for text rendering + * @type {String} + * @default + * @memberof Settings */ +let fontDefault = 'arial'; + +/////////////////////////////////////////////////////////////////////////////// +// WebGL settings + +/** Enable webgl rendering, webgl can be disabled and removed from build (with some features disabled) + * @type {Boolean} + * @default + * @memberof Settings */ +let glEnable = 1; + +/** Fixes slow rendering in some browsers by not compositing the WebGL canvas + * @type {Boolean} + * @default + * @memberof Settings */ +let glOverlay = 1; + +/////////////////////////////////////////////////////////////////////////////// +// Tile sheet settings + +/** Default size of tiles in pixels + * @type {Vector2} + * @default Vector2(16,16) + * @memberof Settings */ +let tileSizeDefault = vec2(16); + +/** Prevent tile bleeding from neighbors in pixels + * @type {Number} + * @default + * @memberof Settings */ +let tileFixBleedScale = .3; + +/////////////////////////////////////////////////////////////////////////////// +// Object settings + +/** Enable physics solver for collisions between objects + * @type {Boolean} + * @default + * @memberof Settings */ +let enablePhysicsSolver = 1; + +/** Default object mass for collison calcuations (how heavy objects are) + * @type {Number} + * @default + * @memberof Settings */ +let objectDefaultMass = 1; + +/** How much to slow velocity by each frame (0-1) + * @type {Number} + * @default + * @memberof Settings */ +let objectDefaultDamping = 1; + +/** How much to slow angular velocity each frame (0-1) + * @type {Number} + * @default + * @memberof Settings */ +let objectDefaultAngleDamping = 1; + +/** How much to bounce when a collision occurs (0-1) + * @type {Number} + * @default 0 + * @memberof Settings */ +let objectDefaultElasticity = 0; + +/** How much to slow when touching (0-1) + * @type {Number} + * @default + * @memberof Settings */ +let objectDefaultFriction = .8; + +/** Clamp max speed to avoid fast objects missing collisions + * @type {Number} + * @default + * @memberof Settings */ +let objectMaxSpeed = 1; + +/** How much gravity to apply to objects along the Y axis, negative is down + * @type {Number} + * @default 0 + * @memberof Settings */ +let gravity = 0; + +/** Scales emit rate of particles, useful for low graphics mode (0 disables particle emitters) + * @type {Number} + * @default + * @memberof Settings */ +let particleEmitRateScale = 1; + +/////////////////////////////////////////////////////////////////////////////// +// Input settings + +/** Should gamepads be allowed + * @type {Boolean} + * @default + * @memberof Settings */ +let gamepadsEnable = 1; + +/** If true, the dpad input is also routed to the left analog stick (for better accessability) + * @type {Boolean} + * @default + * @memberof Settings */ +let gamepadDirectionEmulateStick = 1; + +/** If true the WASD keys are also routed to the direction keys (for better accessability) + * @type {Boolean} + * @default + * @memberof Settings */ +let inputWASDEmulateDirection = 1; + +/** True if touch gamepad should appear on mobile devices + *
- Supports left analog stick, 4 face buttons and start button (button 9) + *
- Must be set by end of gameInit to be activated + * @type {Boolean} + * @default 0 + * @memberof Settings */ +let touchGamepadEnable = 0; + +/** True if touch gamepad should be analog stick or false to use if 8 way dpad + * @type {Boolean} + * @default + * @memberof Settings */ +let touchGamepadAnalog = 1; + +/** Size of virutal gamepad for touch devices in pixels + * @type {Number} + * @default + * @memberof Settings */ +let touchGamepadSize = 99; + +/** Transparency of touch gamepad overlay + * @type {Number} + * @default + * @memberof Settings */ +let touchGamepadAlpha = .3; + +/** Allow vibration hardware if it exists + * @type {Boolean} + * @default + * @memberof Settings */ +let vibrateEnable = 1; + +/////////////////////////////////////////////////////////////////////////////// +// Audio settings + +/** All audio code can be disabled and removed from build + * @type {Boolean} + * @default + * @memberof Settings */ +let soundEnable = 1; + +/** Volume scale to apply to all sound, music and speech + * @type {Number} + * @default + * @memberof Settings */ +let soundVolume = .5; + +/** Default range where sound no longer plays + * @type {Number} + * @default + * @memberof Settings */ +let soundDefaultRange = 40; + +/** Default range percent to start tapering off sound (0-1) + * @type {Number} + * @default + * @memberof Settings */ +let soundDefaultTaper = .7; + +/////////////////////////////////////////////////////////////////////////////// +// Medals settings + +/** How long to show medals for in seconds + * @type {Number} + * @default + * @memberof Settings */ +let medalDisplayTime = 5; + +/** How quickly to slide on/off medals in seconds + * @type {Number} + * @default + * @memberof Settings */ +let medalDisplaySlideTime = .5; + +/** Size of medal display + * @type {Vector2} + * @default Vector2(640,80) + * @memberof Settings */ +let medalDisplaySize = vec2(640, 80); + +/** Size of icon in medal display + * @type {Number} + * @default + * @memberof Settings */ +let medalDisplayIconSize = 50; + +/** Set to stop medals from being unlockable (like if cheats are enabled) + * @type {Boolean} + * @default 0 + * @memberof Settings */ +let medalsPreventUnlock; +/* + LittleJS - The Tiny JavaScript Game Engine That Can! + MIT License - Copyright 2021 Frank Force + + Engine Features + - Object oriented system with base class engine object + - Base class object handles update, physics, collision, rendering, etc + - Engine helper classes and functions like Vector2, Color, and Timer + - Super fast rendering system for tile sheets + - Sound effects audio with zzfx and music with zzfxm + - Input processing system with gamepad and touchscreen support + - Tile layer rendering and collision system + - Particle effect system + - Medal system tracks and displays achievements + - Debug tools and debug rendering system + - Post processing effects + - Call engineInit() to start it up! +*/ + +/** + * LittleJS Engine Globals + * @namespace Engine + */ + +'use strict'; + +/** Name of engine + * @type {String} + * @default + * @memberof Engine */ +const engineName = 'LittleJS'; + +/** Version of engine + * @type {String} + * @default + * @memberof Engine */ +const engineVersion = '1.6.0'; + +/** Frames per second to update objects + * @type {Number} + * @default + * @memberof Engine */ +const frameRate = 60; + +/** How many seconds each frame lasts, engine uses a fixed time step + * @type {Number} + * @default 1/60 + * @memberof Engine */ +const timeDelta = 1/frameRate; + +/** Array containing all engine objects + * @type {Array} + * @memberof Engine */ +let engineObjects = []; + +/** Array containing only objects that are set to collide with other objects this frame (for optimization) + * @type {Array} + * @memberof Engine */ +let engineObjectsCollide = []; + +/** Current update frame, used to calculate time + * @type {Number} + * @memberof Engine */ +let frame = 0; + +/** Current engine time since start in seconds, derived from frame + * @type {Number} + * @memberof Engine */ +let time = 0; + +/** Actual clock time since start in seconds (not affected by pause or frame rate clamping) + * @type {Number} + * @memberof Engine */ +let timeReal = 0; + +/** Is the game paused? Causes time and objects to not be updated + * @type {Boolean} + * @default 0 + * @memberof Engine */ +let paused = 0; + +/** Set if game is paused + * @param {Boolean} paused + * @memberof Engine */ +function setPaused(_paused) { paused = _paused; } + +/////////////////////////////////////////////////////////////////////////////// + +/** Start up LittleJS engine with your callback functions + * @param {Function} gameInit - Called once after the engine starts up, setup the game + * @param {Function} gameUpdate - Called every frame at 60 frames per second, handle input and update the game state + * @param {Function} gameUpdatePost - Called after physics and objects are updated, setup camera and prepare for render + * @param {Function} gameRender - Called before objects are rendered, draw any background effects that appear behind objects + * @param {Function} gameRenderPost - Called after objects are rendered, draw effects or hud that appear above all objects + * @param {String} [tileImageSource] - Tile image to use, everything starts when the image is finished loading + * @memberof Engine */ +function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, gameRenderPost, tileImageSource) +{ + // init engine when tiles load or fail to load + tileImage.onerror = tileImage.onload = ()=> + { + // save tile image info + tileImageFixBleed = vec2(tileFixBleedScale).divide(tileImageSize = vec2(tileImage.width, tileImage.height)); + debug && (tileImage.onload=()=>ASSERT(1)); // tile sheet can not reloaded + + // setup html + const styleBody = 'margin:0;overflow:hidden;background:#000' + // fill the window + ';touch-action:none' + // prevent mobile pinch to resize + ';user-select:none' + // prevent mobile hold to select + ';-webkit-user-select:none'; // compatibility for ios + document.body.style = styleBody; + document.body.appendChild(mainCanvas = document.createElement('canvas')); + mainContext = mainCanvas.getContext('2d'); + + // init stuff and start engine + debugInit(); + glEnable && glInit(); + + // create overlay canvas for hud to appear above gl canvas + document.body.appendChild(overlayCanvas = document.createElement('canvas')); + overlayContext = overlayCanvas.getContext('2d'); + + // set canvas style to fill the window + const styleCanvas = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)'; + (glCanvas||mainCanvas).style = mainCanvas.style = overlayCanvas.style = styleCanvas; + + gameInit(); + touchGamepadCreate(); + engineUpdate(); + }; + + // frame time tracking + let frameTimeLastMS = 0, frameTimeBufferMS, averageFPS; + + // main update loop + function engineUpdate(frameTimeMS=0) + { + // update time keeping + let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS; + frameTimeLastMS = frameTimeMS; + if (debug || showWatermark) + averageFPS = lerp(.05, averageFPS, 1e3/(frameTimeDeltaMS||1)); + const debugSpeedUp = debug && keyIsDown(107); // + + const debugSpeedDown = debug && keyIsDown(109); // - + if (debug) // +/- to speed/slow time + frameTimeDeltaMS *= debugSpeedUp ? 5 : debugSpeedDown ? .2 : 1; + timeReal += frameTimeDeltaMS / 1e3; + frameTimeBufferMS += !paused * frameTimeDeltaMS; + if (!debugSpeedUp) + frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp incase of slow framerate + + if (canvasFixedSize.x) + { + // clear canvas and set fixed size + mainCanvas.width = canvasFixedSize.x; + mainCanvas.height = canvasFixedSize.y; + + // fit to window by adding space on top or bottom if necessary + const aspect = innerWidth / innerHeight; + const fixedAspect = mainCanvas.width / mainCanvas.height; + (glCanvas||mainCanvas).style.width = mainCanvas.style.width = overlayCanvas.style.width = aspect < fixedAspect ? '100%' : ''; + (glCanvas||mainCanvas).style.height = mainCanvas.style.height = overlayCanvas.style.height = aspect < fixedAspect ? '' : '100%'; + } + else + { + // clear canvas and set size to same as window + mainCanvas.width = min(innerWidth, canvasMaxSize.x); + mainCanvas.height = min(innerHeight, canvasMaxSize.y); + } + + // clear overlay canvas and set size + overlayCanvas.width = mainCanvas.width; + overlayCanvas.height = mainCanvas.height; + + // save canvas size + mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); + + if (paused) + { + // do post update even when paused + inputUpdate(); + debugUpdate(); + gameUpdatePost(); + inputUpdatePost(); + } + else + { + // apply time delta smoothing, improves smoothness of framerate in some browsers + let deltaSmooth = 0; + if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9) + { + // force an update each frame if time is close enough (not just a fast refresh rate) + deltaSmooth = frameTimeBufferMS; + frameTimeBufferMS = 0; + } + + // update multiple frames if necessary in case of slow framerate + for (;frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3 / frameRate) + { + // update game and objects + inputUpdate(); + gameUpdate(); + engineObjectsUpdate(); + + // do post update + debugUpdate(); + gameUpdatePost(); + inputUpdatePost(); + } + + // add the time smoothing back in + frameTimeBufferMS += deltaSmooth; + } + + // render sort then render while removing destroyed objects + enginePreRender(); + gameRender(); + engineObjects.sort((a,b)=> a.renderOrder - b.renderOrder); + for (const o of engineObjects) + o.destroyed || o.render(); + gameRenderPost(); + glRenderPostProcess(); + medalsRender(); + touchGamepadRender(); + debugRender(); + glEnable && glCopyToContext(mainContext); + + if (showWatermark) + { + // update fps + overlayContext.textAlign = 'right'; + overlayContext.textBaseline = 'top'; + overlayContext.font = '1em monospace'; + overlayContext.fillStyle = '#000'; + const text = engineName + ' ' + 'v' + engineVersion + ' / ' + + drawCount + ' / ' + engineObjects.length + ' / ' + averageFPS.toFixed(1) + + (glEnable ? ' GL' : ' 2D') ; + overlayContext.fillText(text, mainCanvas.width-3, 3); + overlayContext.fillStyle = '#fff'; + overlayContext.fillText(text, mainCanvas.width-2, 2); + drawCount = 0; + } + + requestAnimationFrame(engineUpdate); + } + + // set tile image source to load the image and start the engine + tileImageSource ? tileImage.src = tileImageSource : tileImage.onload(); +} + +// Called automatically by engine to setup render system +function enginePreRender() +{ + // save canvas size + mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height); + + // disable smoothing for pixel art + mainContext.imageSmoothingEnabled = !cavasPixelated; + + // setup gl rendering if enabled + glEnable && glPreRender(); +} + +/** Update each engine object, remove destroyed objects, and update time + * @memberof Engine */ +function engineObjectsUpdate() +{ + // get list of solid objects for physics optimzation + engineObjectsCollide = engineObjects.filter(o=>o.collideSolidObjects); + + // recursive object update + const updateObject = (o)=> + { + if (!o.destroyed) + { + o.update(); + for (const child of o.children) + updateObject(child); + } + } + for (const o of engineObjects) + o.parent || updateObject(o); + + // remove destroyed objects + engineObjects = engineObjects.filter(o=>!o.destroyed); + + // increment frame and update time + time = ++frame / frameRate; +} + +/** Destroy and remove all objects + * @memberof Engine */ +function engineObjectsDestroy() +{ + for (const o of engineObjects) + o.parent || o.destroy(); + engineObjects = engineObjects.filter(o=>!o.destroyed); +} + +/** Triggers a callback for each object within a given area + * @param {Vector2} [pos] - Center of test area + * @param {Number} [size] - Radius of circle if float, rectangle size if Vector2 + * @param {Function} [callbackFunction] - Calls this function on every object that passes the test + * @param {Array} [objects=engineObjects] - List of objects to check + * @memberof Engine */ +function engineObjectsCallback(pos, size, callbackFunction, objects=engineObjects) +{ + if (!pos) // all objects + { + for (const o of objects) + callbackFunction(o); + } + else if (size.x != undefined) // bounding box test + { + for (const o of objects) + isOverlapping(pos, size, o.pos, o.size) && callbackFunction(o); + } + else // circle test + { + const sizeSquared = size*size; + for (const o of objects) + pos.distanceSquared(o.pos) < sizeSquared && callbackFunction(o); + } +} +/* + LittleJS Object System +*/ + +'use strict'; + +/** + * LittleJS Object Base Object Class + *
- Base object class used by the engine + *
- Automatically adds self to object list + *
- Will be updated and rendered each frame + *
- Renders as a sprite from a tilesheet by default + *
- Can have color and addtive color applied + *
- 2d Physics and collision system + *
- Sorted by renderOrder + *
- Objects can have children attached + *
- Parents are updated before children, and set child transform + *
- Call destroy() to get rid of objects + *
+ *
The physics system used by objects is simple and fast with some caveats... + *
- Collision uses the axis aligned size, the object's rotation angle is only for rendering + *
- Objects are guaranteed to not intersect tile collision from physics + *
- If an object starts or is moved inside tile collision, it will not collide with that tile + *
- Collision for objects can be set to be solid to block other objects + *
- Objects may get pushed into overlapping other solid objects, if so they will push away + *
- Solid objects are more performance intensive and should be used sparingly + * @example + * // create an engine object, normally you would first extend the class with your own + * const pos = vec2(2,3); + * const object = new EngineObject(pos); + */ +class EngineObject +{ + /** Create an engine object and adds it to the list of objects + * @param {Vector2} [position=Vector2()] - World space position of the object + * @param {Vector2} [size=Vector2(1,1)] - World space size of the object + * @param {Number} [tileIndex=-1] - Tile to use to render object (-1 is untextured) + * @param {Vector2} [tileSize=tileSizeDefault] - Size of tile in source pixels + * @param {Number} [angle=0] - Angle the object is rotated by + * @param {Color} [color=Color()] - Color to apply to tile when rendered + * @param {Number} [renderOrder=0] - Objects sorted by renderOrder before being rendered + */ + constructor(pos=vec2(), size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, angle=0, color, renderOrder=0) + { + // set passed in params + ASSERT(isVector2(pos) && isVector2(size)); // ensure pos and size are vec2s + + /** @property {Vector2} - World space position of the object */ + this.pos = pos.copy(); + /** @property {Vector2} - World space width and height of the object */ + this.size = size; + /** @property {Vector2} - Size of object used for drawing, uses size if not set */ + this.drawSize; + /** @property {Number} - Tile to use to render object (-1 is untextured) */ + this.tileIndex = tileIndex; + /** @property {Vector2} - Size of tile in source pixels */ + this.tileSize = tileSize; + /** @property {Number} - Angle to rotate the object */ + this.angle = angle; + /** @property {Color} - Color to apply when rendered */ + this.color = color; + /** @property {Color} - Additive color to apply when rendered */ + this.additiveColor; + + // set object defaults + /** @property {Number} [mass=objectDefaultMass] - How heavy the object is, static if 0 */ + this.mass = objectDefaultMass; + /** @property {Number} [damping=objectDefaultDamping] - How much to slow down velocity each frame (0-1) */ + this.damping = objectDefaultDamping; + /** @property {Number} [angleDamping=objectDefaultAngleDamping] - How much to slow down rotation each frame (0-1) */ + this.angleDamping = objectDefaultAngleDamping; + /** @property {Number} [elasticity=objectDefaultElasticity] - How bouncy the object is when colliding (0-1) */ + this.elasticity = objectDefaultElasticity; + /** @property {Number} [friction=objectDefaultFriction] - How much friction to apply when sliding (0-1) */ + this.friction = objectDefaultFriction; + /** @property {Number} [gravityScale=1] - How much to scale gravity by for this object */ + this.gravityScale = 1; + /** @property {Number} [renderOrder=0] - Objects are sorted by render order */ + this.renderOrder = renderOrder; + /** @property {Vector2} [velocity=Vector2()] - Velocity of the object */ + this.velocity = new Vector2(); + /** @property {Number} [angleVelocity=0] - Angular velocity of the object */ + this.angleVelocity = 0; + + // init other internal object stuff + this.spawnTime = time; + this.children = []; + this.collideTiles = 1; + + // add to list of objects + engineObjects.push(this); + } + + /** Update the object transform and physics, called automatically by engine once each frame */ + update() + { + const parent = this.parent; + if (parent) + { + // copy parent pos/angle + this.pos = this.localPos.multiply(vec2(parent.getMirrorSign(),1)).rotate(-parent.angle).add(parent.pos); + this.angle = parent.getMirrorSign()*this.localAngle + parent.angle; + return; + } + + // limit max speed to prevent missing collisions + this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed); + this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed); + + // apply physics + const oldPos = this.pos.copy(); + this.velocity.y += gravity * this.gravityScale; + this.pos.x += this.velocity.x *= this.damping; + this.pos.y += this.velocity.y *= this.damping; + + // physics sanity checks + ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1); + ASSERT(this.damping >= 0 && this.damping <= 1); + + if (!enablePhysicsSolver || !this.mass) // do not update collision for fixed objects + return; + + const wasMovingDown = this.velocity.y < 0; + if (this.groundObject) + { + // apply friction in local space of ground object + const groundSpeed = this.groundObject.velocity ? this.groundObject.velocity.x : 0; + this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * this.friction; + this.groundObject = 0; + //debugOverlay && debugPhysics && debugPoint(this.pos.subtract(vec2(0,this.size.y/2)), '#0f0'); + } + + if (this.collideSolidObjects) + { + // check collisions against solid objects + const epsilon = .001; // necessary to push slightly outside of the collision + for (const o of engineObjectsCollide) + { + // non solid objects don't collide with eachother + if (!this.isSolid & !o.isSolid || o.destroyed || o.parent || o == this) + continue; + + // check collision + if (!isOverlapping(this.pos, this.size, o.pos, o.size)) + continue; + + // pass collision to objects + if (!this.collideWithObject(o) | !o.collideWithObject(this)) + continue; + + if (isOverlapping(oldPos, this.size, o.pos, o.size)) + { + // if already was touching, try to push away + const deltaPos = oldPos.subtract(o.pos); + const length = deltaPos.length(); + const pushAwayAccel = .001; // push away if already overlapping + const velocity = length < .01 ? randVector(pushAwayAccel) : deltaPos.scale(pushAwayAccel/length); + this.velocity = this.velocity.add(velocity); + if (o.mass) // push away if not fixed + o.velocity = o.velocity.subtract(velocity); + + debugOverlay && debugPhysics && debugAABB(this.pos, this.size, o.pos, o.size, '#f00'); + continue; + } + + // check for collision + const sizeBoth = this.size.add(o.size); + const smallStepUp = (oldPos.y - o.pos.y)*2 > sizeBoth.y + gravity; // prefer to push up if small delta + const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y; + const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x; + + if (smallStepUp | isBlockedY | !isBlockedX) // resolve y collision + { + // push outside object collision + this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y); + if (o.groundObject && wasMovingDown || !o.mass) + { + // set ground object if landed on something + if (wasMovingDown) + this.groundObject = o; + + // bounce if other object is fixed or grounded + this.velocity.y *= -this.elasticity; + } + else if (o.mass) + { + // inelastic collision + const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass); + + // elastic collision + const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass) + + o.velocity.y * 2 * o.mass / (this.mass + o.mass); + const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass) + + this.velocity.y * 2 * this.mass / (this.mass + o.mass); + + // lerp betwen elastic or inelastic based on elasticity + const elasticity = max(this.elasticity, o.elasticity); + this.velocity.y = lerp(elasticity, inelastic, elastic0); + o.velocity.y = lerp(elasticity, inelastic, elastic1); + } + } + if (!smallStepUp & isBlockedX) // resolve x collision + { + // push outside collision + this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x); + if (o.mass) + { + // inelastic collision + const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass); + + // elastic collision + const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass) + + o.velocity.x * 2 * o.mass / (this.mass + o.mass); + const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass) + + this.velocity.x * 2 * this.mass / (this.mass + o.mass); + + // lerp betwen elastic or inelastic based on elasticity + const elasticity = max(this.elasticity, o.elasticity); + this.velocity.x = lerp(elasticity, inelastic, elastic0); + o.velocity.x = lerp(elasticity, inelastic, elastic1); + } + else // bounce if other object is fixed + this.velocity.x *= -this.elasticity; + } + debugOverlay && debugPhysics && debugAABB(this.pos, this.size, o.pos, o.size, '#f0f'); + } + } + if (this.collideTiles) + { + // check collision against tiles + if (tileCollisionTest(this.pos, this.size, this)) + { + // if already was stuck in collision, don't do anything + // this should not happen unless something starts in collision + if (!tileCollisionTest(oldPos, this.size, this)) + { + // test which side we bounced off (or both if a corner) + const isBlockedY = tileCollisionTest(new Vector2(oldPos.x, this.pos.y), this.size, this); + const isBlockedX = tileCollisionTest(new Vector2(this.pos.x, oldPos.y), this.size, this); + if (isBlockedY | !isBlockedX) + { + // set if landed on ground + this.groundObject = wasMovingDown; + + // bounce velocity + this.velocity.y *= -this.elasticity; + + // adjust next velocity to settle on ground + const o = (oldPos.y - this.size.y/2|0) - (oldPos.y - this.size.y/2); + if (o < 0 && o > this.damping * this.velocity.y + gravity * this.gravityScale) + this.velocity.y = this.damping ? (o - gravity * this.gravityScale) / this.damping : 0; + + // move to previous position + this.pos.y = oldPos.y; + } + if (isBlockedX) + { + // move to previous position and bounce + this.pos.x = oldPos.x; + this.velocity.x *= -this.elasticity; + } + } + } + } + } + + /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */ + render() + { + // default object render + drawTile(this.pos, this.drawSize || this.size, this.tileIndex, this.tileSize, this.color, this.angle, this.mirror, this.additiveColor); + } + + /** Destroy this object, destroy it's children, detach it's parent, and mark it for removal */ + destroy() + { + if (this.destroyed) + return; + + // disconnect from parent and destroy chidren + this.destroyed = 1; + this.parent && this.parent.removeChild(this); + for (const child of this.children) + child.destroy(child.parent = 0); + } + + /** Called to check if a tile collision should be resolved + * @param {Number} tileData - the value of the tile at the position + * @param {Vector2} pos - tile where the collision occured + * @return {Boolean} - true if the collision should be resolved */ + collideWithTile(tileData, pos) { return tileData > 0; } + + /** Called to check if a tile raycast hit + * @param {Number} tileData - the value of the tile at the position + * @param {Vector2} pos - tile where the raycast is + * @return {Boolean} - true if the raycast should hit */ + collideWithTileRaycast(tileData, pos) { return tileData > 0; } + + /** Called to check if a object collision should be resolved + * @param {EngineObject} object - the object to test against + * @return {Boolean} - true if the collision should be resolved + */ + collideWithObject(o) { return 1; } + + /** How long since the object was created + * @return {Number} */ + getAliveTime() { return time - this.spawnTime; } + + /** Apply acceleration to this object (adjust velocity, not affected by mass) + * @param {Vector2} acceleration */ + applyAcceleration(a) { if (this.mass) this.velocity = this.velocity.add(a); } + + /** Apply force to this object (adjust velocity, affected by mass) + * @param {Vector2} force */ + applyForce(force) { this.applyAcceleration(force.scale(1/this.mass)); } + + /** Get the direction of the mirror + * @return {Number} -1 if this.mirror is true, or 1 if not mirrored */ + getMirrorSign() { return this.mirror ? -1 : 1; } + + /** Attaches a child to this with a given local transform + * @param {EngineObject} child + * @param {Vector2} [localPos=Vector2()] + * @param {Number} [localAngle=0] */ + addChild(child, localPos=vec2(), localAngle=0) + { + ASSERT(!child.parent && !this.children.includes(child)); + this.children.push(child); + child.parent = this; + child.localPos = localPos.copy(); + child.localAngle = localAngle; + } + + /** Removes a child from this one + * @param {EngineObject} child */ + removeChild(child) + { + ASSERT(child.parent == this && this.children.includes(child)); + this.children.splice(this.children.indexOf(child), 1); + child.parent = 0; + } + + /** Set how this object collides + * @param {Boolean} [collideSolidObjects=1] - Does it collide with solid objects + * @param {Boolean} [isSolid=1] - Does it collide with and block other objects (expensive in large numbers) + * @param {Boolean} [collideTiles=1] - Does it collide with the tile collision */ + setCollision(collideSolidObjects=1, isSolid=1, collideTiles=1) + { + ASSERT(collideSolidObjects || !isSolid); // solid objects must be set to collide + + this.collideSolidObjects = collideSolidObjects; + this.isSolid = isSolid; + this.collideTiles = collideTiles; + } + + /** Returns string containg info about this object for debugging + * @return {String} */ + toString() + { + if (debug) + { + let text = 'type = ' + this.constructor.name; + if (this.pos.x || this.pos.y) + text += '\npos = ' + this.pos; + if (this.velocity.x || this.velocity.y) + text += '\nvelocity = ' + this.velocity; + if (this.size.x || this.size.y) + text += '\nsize = ' + this.size; + if (this.angle) + text += '\nangle = ' + this.angle.toFixed(3); + if (this.color) + text += '\ncolor = ' + this.color; + return text; + } + } +} +/** + * LittleJS Drawing System + *
- Hybrid with both Canvas2D and WebGL available + *
- Super fast tile sheet rendering with WebGL + *
- Can apply rotation, mirror, color and additive color + *
- Many useful utility functions + *
+ *
LittleJS uses a hybrid rendering solution with the best of both Canvas2D and WebGL. + *
There are 3 canvas/contexts available to draw to... + *
- mainCanvas - 2D background canvas, non WebGL stuff like tile layers are drawn here. + *
- glCanvas - Used by the accelerated WebGL batch rendering system. + *
- overlayCanvas - Another 2D canvas that appears on top of the other 2 canvases. + *
+ *
The WebGL rendering system is very fast with some caveats... + *
- The default setup supports only 1 tile sheet, to support more call glCreateTexture and glSetTexture + *
- Switching blend modes (additive) or textures causes another draw call which is expensive in excess + *
- Group additive rendering together using renderOrder to mitigate this issue + *
+ *
The LittleJS rendering solution is intentionally simple, feel free to adjust it for your needs! + * @namespace Draw + */ + +'use strict'; + +/** The primary 2D canvas visible to the user + * @type {HTMLCanvasElement} + * @memberof Draw */ +let mainCanvas; + +/** 2d context for mainCanvas + * @type {CanvasRenderingContext2D} + * @memberof Draw */ +let mainContext; + +/** A canvas that appears on top of everything the same size as mainCanvas + * @type {HTMLCanvasElement} + * @memberof Draw */ +let overlayCanvas; + +/** 2d context for overlayCanvas + * @type {CanvasRenderingContext2D} + * @memberof Draw */ +let overlayContext; + +/** The size of the main canvas (and other secondary canvases) + * @type {Vector2} + * @memberof Draw */ +let mainCanvasSize = vec2(); + +/** Tile sheet for batch rendering system + * @type {CanvasImageSource} + * @memberof Draw */ +const tileImage = new Image; + +// Engine internal variables not exposed to documentation +let tileImageSize, tileImageFixBleed, drawCount; + +/** Convert from screen to world space coordinates + * - if calling outside of render, you may need to manually set mainCanvasSize + * @param {Vector2} screenPos + * @return {Vector2} + * @memberof Draw */ +const screenToWorld = (screenPos)=> +{ + ASSERT(mainCanvasSize.x && mainCanvasSize.y, 'mainCanvasSize is invalid'); + return screenPos.add(vec2(.5)).subtract(mainCanvasSize.scale(.5)).multiply(vec2(1/cameraScale,-1/cameraScale)).add(cameraPos); +} + +/** Convert from world to screen space coordinates + * - if calling outside of render, you may need to manually set mainCanvasSize + * @param {Vector2} worldPos + * @return {Vector2} + * @memberof Draw */ +const worldToScreen = (worldPos)=> +{ + ASSERT(mainCanvasSize.x && mainCanvasSize.y, 'mainCanvasSize is invalid'); + return worldPos.subtract(cameraPos).multiply(vec2(cameraScale,-cameraScale)).add(mainCanvasSize.scale(.5)).subtract(vec2(.5)); +} + +/** Draw textured tile centered in world space, with color applied if using WebGL + * @param {Vector2} pos - Center of the tile in world space + * @param {Vector2} [size=Vector2(1,1)] - Size of the tile in world space + * @param {Number} [tileIndex=-1] - Tile index to use, negative is untextured + * @param {Vector2} [tileSize=tileSizeDefault] - Tile size in source pixels + * @param {Color} [color=Color()] - Color to modulate with + * @param {Number} [angle=0] - Angle to rotate by + * @param {Boolean} [mirror=0] - If true image is flipped along the Y axis + * @param {Color} [additiveColor=Color(0,0,0,0)] - Additive color to be applied + * @param {Boolean} [useWebGL=glEnable] - Use accelerated WebGL rendering + * @memberof Draw */ +function drawTile(pos, size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, color=new Color, angle=0, mirror, + additiveColor=new Color(0,0,0,0), useWebGL=glEnable) +{ + showWatermark && ++drawCount; + if (glEnable && useWebGL) + { + if (tileIndex < 0 || !tileImage.width) + { + // if negative tile index or image not found, force untextured + glDraw(pos.x, pos.y, size.x, size.y, angle, 0, 0, 0, 0, 0, color.rgbaInt()); + } + else + { + // calculate uvs and render + const cols = tileImageSize.x / tileSize.x |0; + const uvSizeX = tileSize.x / tileImageSize.x; + const uvSizeY = tileSize.y / tileImageSize.y; + const uvX = (tileIndex%cols)*uvSizeX, uvY = (tileIndex/cols|0)*uvSizeY; + + glDraw(pos.x, pos.y, mirror ? -size.x : size.x, size.y, angle, + uvX + tileImageFixBleed.x, uvY + tileImageFixBleed.y, + uvX - tileImageFixBleed.x + uvSizeX, uvY - tileImageFixBleed.y + uvSizeY, + color.rgbaInt(), additiveColor.rgbaInt()); + } + } + else + { + // normal canvas 2D rendering method (slower) + drawCanvas2D(pos, size, angle, mirror, (context)=> + { + if (tileIndex < 0) + { + // if negative tile index, force untextured + context.fillStyle = color; + context.fillRect(-.5, -.5, 1, 1); + } + else + { + // calculate uvs and render + const cols = tileImageSize.x / tileSize.x |0; + const sX = (tileIndex%cols)*tileSize.x + tileFixBleedScale; + const sY = (tileIndex/cols|0)*tileSize.y + tileFixBleedScale; + const sWidth = tileSize.x - 2*tileFixBleedScale; + const sHeight = tileSize.y - 2*tileFixBleedScale; + context.globalAlpha = color.a; // only alpha is supported + context.drawImage(tileImage, sX, sY, sWidth, sHeight, -.5, -.5, 1, 1); + } + }); + } +} + +/** Draw colored rect centered on pos + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {Color} [color=Color()] + * @param {Number} [angle=0] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function drawRect(pos, size, color, angle, useWebGL) +{ + drawTile(pos, size, -1, tileSizeDefault, color, angle, 0, 0, useWebGL); +} + +/** Draw textured tile centered on pos in screen space + * @param {Vector2} pos - Center of the tile + * @param {Vector2} [size=Vector2(1,1)] - Size of the tile + * @param {Number} [tileIndex=-1] - Tile index to use, negative is untextured + * @param {Vector2} [tileSize=tileSizeDefault] - Tile size in source pixels + * @param {Color} [color=Color()] + * @param {Number} [angle=0] + * @param {Boolean} [mirror=0] + * @param {Color} [additiveColor=Color(0,0,0,0)] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function drawTileScreenSpace(pos, size=vec2(1), tileIndex, tileSize, color, angle, mirror, additiveColor, useWebGL) +{ + drawTile(screenToWorld(pos), size.scale(1/cameraScale), tileIndex, tileSize, color, angle, mirror, additiveColor, useWebGL); +} + +/** Draw colored rectangle in screen space + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {Color} [color=Color()] + * @param {Number} [angle=0] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function drawRectScreenSpace(pos, size, color, angle, useWebGL) +{ + drawTileScreenSpace(pos, size, -1, tileSizeDefault, color, angle, 0, 0, useWebGL); +} + +/** Draw colored line between two points + * @param {Vector2} posA + * @param {Vector2} posB + * @param {Number} [thickness=.1] + * @param {Color} [color=Color()] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function drawLine(posA, posB, thickness=.1, color, useWebGL) +{ + const halfDelta = vec2((posB.x - posA.x)/2, (posB.y - posA.y)/2); + const size = vec2(thickness, halfDelta.length()*2); + drawRect(posA.add(halfDelta), size, color, halfDelta.angle(), useWebGL); +} + +/** Draw directly to a 2d canvas context in world space + * @param {Vector2} pos + * @param {Vector2} size + * @param {Number} angle + * @param {Boolean} mirror + * @param {Function} drawFunction + * @param {CanvasRenderingContext2D} [context=mainContext] + * @memberof Draw */ +function drawCanvas2D(pos, size, angle, mirror, drawFunction, context = mainContext) +{ + // create canvas transform from world space to screen space + pos = worldToScreen(pos); + size = size.scale(cameraScale); + context.save(); + context.translate(pos.x+.5|0, pos.y+.5|0); + context.rotate(angle); + context.scale(mirror ? -size.x : size.x, size.y); + drawFunction(context); + context.restore(); +} + +/** Enable normal or additive blend mode + * @param {Boolean} [additive=0] + * @param {Boolean} [useWebGL=glEnable] + * @memberof Draw */ +function setBlendMode(additive, useWebGL=glEnable) +{ + if (glEnable && useWebGL) + glSetBlendMode(additive); + else + mainContext.globalCompositeOperation = additive ? 'lighter' : 'source-over'; +} + +/** Draw text on overlay canvas in world space + * Automatically splits new lines into rows + * @param {String} text + * @param {Vector2} pos + * @param {Number} [size=1] + * @param {Color} [color=Color()] + * @param {Number} [lineWidth=0] + * @param {Color} [lineColor=Color(0,0,0)] + * @param {String} [textAlign='center'] + * @param {String} [font=fontDefault] + * @param {CanvasRenderingContext2D} [context=overlayContext] + * @memberof Draw */ +function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, font, context=overlayContext) +{ + drawTextScreen(text, worldToScreen(pos), size*cameraScale, color, lineWidth*cameraScale, lineColor, textAlign, font, context); +} + +/** Draw text on overlay canvas in screen space + * Automatically splits new lines into rows + * @param {String} text + * @param {Vector2} pos + * @param {Number} [size=1] + * @param {Color} [color=Color()] + * @param {Number} [lineWidth=0] + * @param {Color} [lineColor=Color(0,0,0)] + * @param {String} [textAlign='center'] + * @param {String} [font=fontDefault] + * @param {CanvasRenderingContext2D} [context=overlayContext] + * @memberof Draw */ +function drawTextScreen(text, pos, size=1, color=new Color, lineWidth=0, lineColor=new Color(0,0,0), textAlign='center', font=fontDefault, context=overlayContext) +{ + context.fillStyle = color; + context.lineWidth = lineWidth; + context.strokeStyle = lineColor; + context.textAlign = textAlign; + context.font = size + 'px '+ font; + context.textBaseline = 'middle'; + context.lineJoin = 'round'; + + pos = pos.copy(); + (text+'').split('\n').forEach(line=> + { + lineWidth && context.strokeText(line, pos.x, pos.y); + context.fillText(line, pos.x, pos.y); + pos.y += size; + }); +} + +/////////////////////////////////////////////////////////////////////////////// + +let engineFontImage; + +/** + * Font Image Object - Draw text on a 2D canvas by using characters in an image + *
- 96 characters (from space to tilde) are stored in an image + *
- Uses a default 8x8 font if none is supplied + *
- You can also use fonts from the main tile sheet + * @example + * // use built in font + * const font = new ImageFont; + * + * // draw text + * font.drawTextScreen("LittleJS\nHello World!", vec2(200, 50)); + */ +class FontImage +{ + /** Create an image font + * @param {HTMLImageElement} [image] - Image for the font, if undefined default font is used + * @param {Vector2} [tileSize=vec2(8)] - Size of the font source tiles + * @param {Vector2} [paddingSize=vec2(0,1)] - How much extra space to add between characters + * @param {Number} [startTileIndex=0] - Tile index in image where font starts + * @param {CanvasRenderingContext2D} [context=overlayContext] - context to draw to + */ + constructor(image, tileSize=vec2(8), paddingSize=vec2(0,1), startTileIndex=0, context=overlayContext) + { + // load default font image + if (!engineFontImage) + (engineFontImage = new Image).src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAAYAQAAAAA9+x6JAAAAAnRSTlMAAHaTzTgAAAGiSURBVHjaZZABhxxBEIUf6ECLBdFY+Q0PMNgf0yCgsSAGZcT9sgIPtBWwIA5wgAPEoHUyJeeSlW+gjK+fegWwtROWpVQEyWh2npdpBmTUFVhb29RINgLIukoXr5LIAvYQ5ve+1FqWEMqNKTX3FAJHyQDRZvmKWubAACcv5z5Gtg2oyCWE+Yk/8JZQX1jTTCpKAFGIgza+dJCNBF2UskRlsgwitHbSV0QLgt9sTPtsRlvJjEr8C/FARWA2bJ/TtJ7lko34dNDn6usJUMzuErP89UUBJbWeozrwLLncXczd508deAjLWipLO4Q5XGPcJvPu92cNDaN0P5G1FL0nSOzddZOrJ6rNhbXGmeDvO3TF7DeJWl4bvaYQTNHCTeuqKZmbjHaSOFes+IX/+IhHrnAkXOAsfn24EM68XieIECoccD4KZLk/odiwzeo2rovYdhvb2HYFgyznJyDpYJdYOmfXgVdJTaUi4xA2uWYNYec9BLeqdl9EsoTw582mSFDX2DxVLbNt9U3YYoeatBad1c2Tj8t2akrjaIGJNywKB/7h75/gN3vCMSaadIUTAAAAAElFTkSuQmCC'; + + this.image = image || engineFontImage; + this.tileSize = tileSize; + this.paddingSize = paddingSize; + this.startTileIndex = startTileIndex; + this.context = context; + } + + /** Draw text in screen space using the image font + * @param {String} text + * @param {Vector2} pos + * @param {Number} [scale=4] + * @param {Boolean} [center] + */ + drawTextScreen(text, pos, scale=4, center) + { + const context = this.context; + context.save(); + context.imageSmoothingEnabled = !cavasPixelated; + + const size = this.tileSize; + const drawSize = size.add(this.paddingSize).scale(scale); + const cols = this.image.width / this.tileSize.x |0; + (text+'').split('\n').forEach((line, i)=> + { + const centerOffset = center ? line.length * size.x * scale / 2 |0 : 0; + for(let j=line.length; j--;) + { + // draw each character + let charCode = line[j].charCodeAt(); + if (charCode < 32 || charCode > 127) + charCode = 127; // unknown character + + // get the character source location and draw it + const tile = this.startTileIndex + charCode - 32; + const x = tile % cols; + const y = tile / cols |0; + const drawPos = pos.add(vec2(j,i).multiply(drawSize)); + context.drawImage(this.image, x * size.x, y * size.y, size.x, size.y, + drawPos.x - centerOffset, drawPos.y, size.x * scale, size.y * scale); + } + }); + + context.restore(); + } + + /** Draw text in world space using the image font + * @param {String} text + * @param {Vector2} pos + * @param {Number} [scale=.25] + * @param {Boolean} [center] + */ + drawText(text, pos, scale=1, center) + { + this.drawTextScreen(text, worldToScreen(pos).floor(), scale*cameraScale|0, center); + } +} + +/////////////////////////////////////////////////////////////////////////////// +// Fullscreen mode + +/** Returns true if fullscreen mode is active + * @return {Boolean} + * @memberof Draw */ +const isFullscreen = ()=> document.fullscreenElement; + +/** Toggle fullsceen mode + * @memberof Draw */ +function toggleFullscreen() +{ + if (isFullscreen()) + { + if (document.exitFullscreen) + document.exitFullscreen(); + } + else if (document.body.requestFullscreen) + document.body.requestFullscreen(); +} + +/** + * LittleJS Input System + *
- Tracks key down, pressed, and released + *
- Also tracks mouse buttons, position, and wheel + *
- Supports multiple gamepads + *
- Virtual gamepad for touch devices with touchGamepadSize + * @namespace Input + */ + +'use strict'; + +/** Returns true if device key is down + * @param {Number} key + * @param {Number} [device=0] + * @return {Boolean} + * @memberof Input */ +const keyIsDown = (key, device=0)=> inputData[device] && inputData[device][key] & 1; + +/** Returns true if device key was pressed this frame + * @param {Number} key + * @param {Number} [device=0] + * @return {Boolean} + * @memberof Input */ +const keyWasPressed = (key, device=0)=> inputData[device] && inputData[device][key] & 2 ? 1 : 0; + +/** Returns true if device key was released this frame + * @param {Number} key + * @param {Number} [device=0] + * @return {Boolean} + * @memberof Input */ +const keyWasReleased = (key, device=0)=> inputData[device] && inputData[device][key] & 4 ? 1 : 0; + +/** Clears all input + * @memberof Input */ +const clearInput = ()=> inputData = [[]]; + +/** Returns true if mouse button is down + * @function + * @param {Number} button + * @return {Boolean} + * @memberof Input */ +const mouseIsDown = keyIsDown; + +/** Returns true if mouse button was pressed + * @function + * @param {Number} button + * @return {Boolean} + * @memberof Input */ +const mouseWasPressed = keyWasPressed; + +/** Returns true if mouse button was released + * @function + * @param {Number} button + * @return {Boolean} + * @memberof Input */ +const mouseWasReleased = keyWasReleased; + +/** Mouse pos in world space + * @type {Vector2} + * @memberof Input */ +let mousePos = vec2(); + +/** Mouse pos in screen space + * @type {Vector2} + * @memberof Input */ +let mousePosScreen = vec2(); + +/** Mouse wheel delta this frame + * @type {Number} + * @memberof Input */ +let mouseWheel = 0; + +/** Returns true if user is using gamepad (has more recently pressed a gamepad button) + * @type {Boolean} + * @memberof Input */ +let isUsingGamepad = 0; + +/** Prevents input continuing to the default browser handling (false by default) + * @type {Boolean} + * @memberof Input */ +let preventDefaultInput = 0; + +/** Returns true if gamepad button is down + * @param {Number} button + * @param {Number} [gamepad=0] + * @return {Boolean} + * @memberof Input */ +const gamepadIsDown = (button, gamepad=0)=> keyIsDown(button, gamepad+1); + +/** Returns true if gamepad button was pressed + * @param {Number} button + * @param {Number} [gamepad=0] + * @return {Boolean} + * @memberof Input */ +const gamepadWasPressed = (button, gamepad=0)=> keyWasPressed(button, gamepad+1); + +/** Returns true if gamepad button was released + * @param {Number} button + * @param {Number} [gamepad=0] + * @return {Boolean} + * @memberof Input */ +const gamepadWasReleased = (button, gamepad=0)=> keyWasReleased(button, gamepad+1); + +/** Returns gamepad stick value + * @param {Number} stick + * @param {Number} [gamepad=0] + * @return {Vector2} + * @memberof Input */ +const gamepadStick = (stick, gamepad=0)=> stickData[gamepad] ? stickData[gamepad][stick] || vec2() : vec2(); + +/////////////////////////////////////////////////////////////////////////////// +// Input update called by engine + +// store input as a bit field for each key: 1 = isDown, 2 = wasPressed, 4 = wasReleased +// mouse and keyboard are stored together in device 0, gamepads are in devices > 0 +let inputData = [[]]; + +function inputUpdate() +{ + // clear input when lost focus (prevent stuck keys) + isTouchDevice || document.hasFocus() || clearInput(); + + // update mouse world space position + mousePos = screenToWorld(mousePosScreen); + + // update gamepads if enabled + gamepadsUpdate(); +} + +function inputUpdatePost() +{ + // clear input to prepare for next frame + for (const deviceInputData of inputData) + for (const i in deviceInputData) + deviceInputData[i] &= 1; + mouseWheel = 0; +} + +/////////////////////////////////////////////////////////////////////////////// +// Keyboard event handlers + +onkeydown = (e)=> +{ + if (debug && e.target != document.body) return; + e.repeat || (inputData[isUsingGamepad = 0][remapKey(e.which)] = 3); + preventDefaultInput && e.preventDefault(); +} +onkeyup = (e)=> +{ + if (debug && e.target != document.body) return; + inputData[0][remapKey(e.which)] = 4; +} +const remapKey = (c)=> inputWASDEmulateDirection ? c==87?38 : c==83?40 : c==65?37 : c==68?39 : c : c; + +/////////////////////////////////////////////////////////////////////////////// +// Mouse event handlers + +onmousedown = (e)=> {inputData[isUsingGamepad = 0][e.button] = 3; onmousemove(e); e.button && e.preventDefault();} +onmouseup = (e)=> inputData[0][e.button] = inputData[0][e.button] & 2 | 4; +onmousemove = (e)=> mousePosScreen = mouseToScreen(e); +onwheel = (e)=> e.ctrlKey || (mouseWheel = sign(e.deltaY)); +oncontextmenu = (e)=> !1; // prevent right click menu + +// convert a mouse or touch event position to screen space +const mouseToScreen = (mousePos)=> +{ + if (!mainCanvas) + return vec2(); // fix bug that can occur if user clicks before page loads + + const rect = mainCanvas.getBoundingClientRect(); + return vec2(mainCanvas.width, mainCanvas.height).multiply( + vec2(percent(mousePos.x, rect.left, rect.right), percent(mousePos.y, rect.top, rect.bottom))); +} + +/////////////////////////////////////////////////////////////////////////////// +// Gamepad input + +const stickData = []; +function gamepadsUpdate() +{ + if (touchGamepadEnable && touchGamepadTimer.isSet()) + { + // read virtual analog stick + const sticks = stickData[0] || (stickData[0] = []); + sticks[0] = vec2(touchGamepadStick.x, -touchGamepadStick.y); // flip vertical + + // read virtual gamepad buttons + const data = inputData[1] || (inputData[1] = []); + for (let i=10; i--;) + { + const j = i == 3 ? 2 : i == 2 ? 3 : i; // fix button locations + data[j] = touchGamepadButtons[i] ? 1 + 2*!gamepadIsDown(j,0) : 4*gamepadIsDown(j,0); + } + } + + if (!gamepadsEnable || !navigator || !navigator.getGamepads || !document.hasFocus() && !debug) + return; + + // poll gamepads + const gamepads = navigator.getGamepads(); + for (let i = gamepads.length; i--;) + { + // get or create gamepad data + const gamepad = gamepads[i]; + const data = inputData[i+1] || (inputData[i+1] = []); + const sticks = stickData[i] || (stickData[i] = []); + + if (gamepad) + { + // read clamp dead zone of analog sticks + const deadZone = .3, deadZoneMax = .8; + const applyDeadZone = (v)=> + v > deadZone ? percent( v, deadZone, deadZoneMax) : + v < -deadZone ? -percent(-v, deadZone, deadZoneMax) : 0; + + // read analog sticks + for (let j = 0; j < gamepad.axes.length-1; j+=2) + sticks[j>>1] = vec2(applyDeadZone(gamepad.axes[j]), applyDeadZone(-gamepad.axes[j+1])).clampLength(); + + // read buttons + for (let j = gamepad.buttons.length; j--;) + { + const button = gamepad.buttons[j]; + data[j] = button.pressed ? 1 + 2*!gamepadIsDown(j,i) : 4*gamepadIsDown(j,i); + isUsingGamepad |= !i && button.pressed; + touchGamepadEnable && touchGamepadTimer.unset(); // disable touch gamepad if using real gamepad + } + + if (gamepadDirectionEmulateStick) + { + // copy dpad to left analog stick when pressed + const dpad = vec2(gamepadIsDown(15,i) - gamepadIsDown(14,i), gamepadIsDown(12,i) - gamepadIsDown(13,i)); + if (dpad.lengthSquared()) + sticks[0] = dpad.clampLength(); + } + } + } +} + +/////////////////////////////////////////////////////////////////////////////// + +/** Pulse the vibration hardware if it exists + * @param {Number} [pattern=100] - a single value in miliseconds or vibration interval array + * @memberof Input */ +const vibrate = (pattern)=> vibrateEnable && navigator && navigator.vibrate && navigator.vibrate(pattern); + +/** Cancel any ongoing vibration + * @memberof Input */ +const vibrateStop = ()=> vibrate(0); + +/////////////////////////////////////////////////////////////////////////////// +// Touch input + +/** True if a touch device has been detected + * @memberof Input */ +const isTouchDevice = window.ontouchstart !== undefined; + +// try to enable touch mouse +if (isTouchDevice) +{ + // override mouse events + let wasTouching, mouseDown = onmousedown, mouseUp = onmouseup; + onmousedown = onmouseup = ()=> 0; + + // setup touch input + ontouchstart = (e)=> + { + // fix mobile audio, force it to play a sound on first touch + zzfx(0); + + // handle all touch events the same way + ontouchstart = ontouchmove = ontouchend = (e)=> + { + e.button = 0; // all touches are left click + + // check if touching and pass to mouse events + const touching = e.touches.length; + if (touching) + { + // set event pos and pass it along + e.x = e.touches[0].clientX; + e.y = e.touches[0].clientY; + wasTouching ? onmousemove(e) : mouseDown(e); + } + else if (wasTouching) + mouseUp(e); + + // set was touching + wasTouching = touching; + + // must return true so the document will get focus + return true; + } + + return ontouchstart(e); + } +} + +/////////////////////////////////////////////////////////////////////////////// +// touch gamepad, virtual on screen gamepad emulator for touch devices + +// touch input internal variables +let touchGamepadTimer = new Timer, touchGamepadButtons, touchGamepadStick; + +// create the touch gamepad, called automatically by the engine +function touchGamepadCreate() +{ + if (!touchGamepadEnable || !isTouchDevice) + return; + + // touch input internal variables + touchGamepadButtons = []; + touchGamepadStick = vec2(); + + // setup touch input + ontouchstart = (e)=> + { + // fix mobile audio, force it to play a sound on first touch + zzfx(0); + + ontouchstart = ontouchmove = ontouchend = (e)=> + { + // clear touch gamepad input + touchGamepadStick = vec2(); + touchGamepadButtons = []; + + const touching = e.touches.length; + if (touching) + { + // set that gamepad is active + isUsingGamepad = 1; + touchGamepadTimer.set(); + + if (paused) + { + // touch anywhere to press start when paused + touchGamepadButtons[9] = 1; + return; + } + } + + // get center of left and right sides + const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize); + const buttonCenter = mainCanvasSize.subtract(vec2(touchGamepadSize, touchGamepadSize)); + const startCenter = mainCanvasSize.scale(.5); + + // check each touch point + for (const touch of e.touches) + { + const touchPos = mouseToScreen(vec2(touch.clientX, touch.clientY)); + if (touchPos.distance(stickCenter) < touchGamepadSize) + { + // virtual analog stick + if (touchGamepadAnalog) + touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize).clampLength(); + else + { + // 8 way dpad + const angle = touchPos.subtract(stickCenter).angle(); + touchGamepadStick.setAngle((angle * 4 / PI + 8.5 | 0) * PI / 4); + } + } + else if (touchPos.distance(buttonCenter) < touchGamepadSize) + { + // virtual face buttons + const button = touchPos.subtract(buttonCenter).direction(); + touchGamepadButtons[button] = 1; + } + else if (touchPos.distance(startCenter) < touchGamepadSize) + { + // virtual start button in center + touchGamepadButtons[9] = 1; + } + } + } + + return ontouchstart(e); + } +} + +// render the touch gamepad, called automatically by the engine +function touchGamepadRender() +{ + if (!touchGamepadEnable || !touchGamepadTimer.isSet()) + return; + + // fade off when not touching or paused + const alpha = percent(touchGamepadTimer, 4, 3); + if (!alpha || paused) + return; + + // setup the canvas + overlayContext.save(); + overlayContext.globalAlpha = alpha*touchGamepadAlpha; + overlayContext.strokeStyle = '#fff'; + overlayContext.lineWidth = 3; + + // draw left analog stick + overlayContext.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000'; + overlayContext.beginPath(); + + const leftCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize); + if (touchGamepadAnalog) + { + overlayContext.arc(leftCenter.x, leftCenter.y, touchGamepadSize/2, 0, 9); + overlayContext.fill(); + overlayContext.stroke(); + } + else // draw cross shaped gamepad + { + for(let i=10; i--;) + { + const angle = i*PI/4; + overlayContext.arc(leftCenter.x, leftCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8); + i%2 && overlayContext.arc(leftCenter.x, leftCenter.y, touchGamepadSize*.33, angle, angle); + i==1 && overlayContext.fill(); + } + overlayContext.stroke(); + } + + // draw right face buttons + const rightCenter = vec2(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize); + for (let i=4; i--;) + { + const pos = rightCenter.add((new Vector2).setAngle(i*PI/2, touchGamepadSize/2)); + overlayContext.fillStyle = touchGamepadButtons[i] ? '#fff' : '#000'; + overlayContext.beginPath(); + overlayContext.arc(pos.x, pos.y, touchGamepadSize/4, 0,9); + overlayContext.fill(); + overlayContext.stroke(); + } + + // set canvas back to normal + overlayContext.restore(); +} +/** + * LittleJS Audio System + *
- ZzFX Sound Effects + *
- ZzFXM Music + *
- Caches sounds and music for fast playback + *
- Can attenuate and apply stereo panning to sounds + *
- Ability to play mp3, ogg, and wave files + *
- Speech synthesis wrapper functions + */ + +'use strict'; + +/** + * Sound Object - Stores a zzfx sound for later use and can be played positionally + *
+ *
Create sounds using the ZzFX Sound Designer. + * @example + * // create a sound + * const sound_example = new Sound([.5,.5]); + * + * // play the sound + * sound_example.play(); + */ +class Sound +{ + /** Create a sound object and cache the zzfx samples for later use + * @param {Array} zzfxSound - Array of zzfx parameters, ex. [.5,.5] + * @param {Number} [range=soundDefaultRange] - World space max range of sound, will not play if camera is farther away + * @param {Number} [taper=soundDefaultTaper] - At what percentage of range should it start tapering off + */ + constructor(zzfxSound, range=soundDefaultRange, taper=soundDefaultTaper) + { + if (!soundEnable) return; + + /** @property {Number} - World space max range of sound, will not play if camera is farther away */ + this.range = range; + + /** @property {Number} - At what percentage of range should it start tapering off */ + this.taper = taper; + + // get randomness from sound parameters + this.randomness = zzfxSound[1] || 0; + zzfxSound[1] = 0; + + // generate sound now for fast playback + this.cachedSamples = zzfxG(...zzfxSound); + } + + /** Play the sound + * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null + * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) + * @param {Number} [pitch=1] - How much to scale pitch by (also adjusted by this.randomness) + * @param {Number} [randomnessScale=1] - How much to scale randomness + * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later + */ + play(pos, volume=1, pitch=1, randomnessScale=1) + { + if (!soundEnable) return; + + let pan; + if (pos) + { + const range = this.range; + if (range) + { + // apply range based fade + const lengthSquared = cameraPos.distanceSquared(pos); + if (lengthSquared > range*range) + return; // out of range + + // attenuate volume by distance + volume *= percent(lengthSquared**.5, range, range*this.taper); + } + + // get pan from screen space coords + pan = worldToScreen(pos).x * 2/mainCanvas.width - 1; + } + + // play the sound + const playbackRate = pitch + pitch * this.randomness*randomnessScale*rand(-1,1); + return playSamples([this.cachedSamples], volume, playbackRate, pan); + } + + /** Play the sound as a note with a semitone offset + * @param {Number} semitoneOffset - How many semitones to offset pitch + * @param {Vector2} [pos] - World space position to play the sound, sound is not attenuated if null + * @param {Number} [volume=1] - How much to scale volume by (in addition to range fade) + * @return {AudioBufferSourceNode} - The audio, can be used to stop sound later + */ + playNote(semitoneOffset, pos, volume) + { + if (!soundEnable) return; + + return this.play(pos, volume, 2**(semitoneOffset/12), 0); + } +} + +/** + * Music Object - Stores a zzfx music track for later use + *
+ *
Create music with the ZzFXM tracker. + * @example + * // create some music + * const music_example = new Music( + * [ + * [ // instruments + * [,0,400] // simple note + * ], + * [ // patterns + * [ // pattern 1 + * [ // channel 0 + * 0, -1, // instrument 0, left speaker + * 1, 0, 9, 1 // channel notes + * ], + * [ // channel 1 + * 0, 1, // instrument 1, right speaker + * 0, 12, 17, -1 // channel notes + * ] + * ], + * ], + * [0, 0, 0, 0], // sequence, play pattern 0 four times + * 90 // BPM + * ]); + * + * // play the music + * music_example.play(); + */ +class Music +{ + /** Create a music object and cache the zzfx music samples for later use + * @param {Array} zzfxMusic - Array of zzfx music parameters + */ + constructor(zzfxMusic) + { + if (!soundEnable) return; + + this.cachedSamples = zzfxM(...zzfxMusic); + } + + /** Play the music + * @param {Number} [volume=1] - How much to scale volume by + * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end + * @return {AudioBufferSourceNode} - The audio node, can be used to stop sound later + */ + play(volume, loop = 1) + { + if (!soundEnable) return; + + return this.source = playSamples(this.cachedSamples, volume, 1, 0, loop); + } + + /** Stop the music */ + stop() + { + if (this.source) + this.source.stop(); + this.source = 0; + } + + /** Check if music is playing + * @return {Boolean} + */ + isPlaying() { return this.source; } +} + +/** Play an mp3 or wav audio from a local file or url + * @param {String} url - Location of sound file to play + * @param {Number} [volume=1] - How much to scale volume by + * @param {Boolean} [loop=1] - True if the music should loop when it reaches the end + * @return {HTMLAudioElement} - The audio element for this sound + * @memberof Audio */ +function playAudioFile(url, volume=1, loop=1) +{ + if (!soundEnable) return; + + const audio = new Audio(url); + audio.volume = soundVolume * volume; + audio.loop = loop; + audio.play(); + return audio; +} + +/** Speak text with passed in settings + * @param {String} text - The text to speak + * @param {String} [language] - The language/accent to use (examples: en, it, ru, ja, zh) + * @param {Number} [volume=1] - How much to scale volume by + * @param {Number} [rate=1] - How quickly to speak + * @param {Number} [pitch=1] - How much to change the pitch by + * @return {SpeechSynthesisUtterance} - The utterance that was spoken + * @memberof Audio */ +function speak(text, language='', volume=1, rate=1, pitch=1) +{ + if (!soundEnable || !speechSynthesis) return; + + // common languages (not supported by all browsers) + // en - english, it - italian, fr - french, de - german, es - spanish + // ja - japanese, ru - russian, zh - chinese, hi - hindi, ko - korean + + // build utterance and speak + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = language; + utterance.volume = 2*volume*soundVolume; + utterance.rate = rate; + utterance.pitch = pitch; + speechSynthesis.speak(utterance); + return utterance; +} + +/** Stop all queued speech + * @memberof Audio */ +const speakStop = ()=> speechSynthesis && speechSynthesis.cancel(); + +/** Get frequency of a note on a musical scale + * @param {Number} semitoneOffset - How many semitones away from the root note + * @param {Number} [rootNoteFrequency=220] - Frequency at semitone offset 0 + * @return {Number} - The frequency of the note + * @memberof Audio */ +const getNoteFrequency = (semitoneOffset, rootFrequency=220)=> rootFrequency * 2**(semitoneOffset/12); + +/////////////////////////////////////////////////////////////////////////////// + +/** Audio context used by the engine + * @memberof Audio */ +let audioContext; + +/** Play cached audio samples with given settings + * @param {Array} sampleChannels - Array of arrays of samples to play (for stereo playback) + * @param {Number} [volume=1] - How much to scale volume by + * @param {Number} [rate=1] - The playback rate to use + * @param {Number} [pan=0] - How much to apply stereo panning + * @param {Boolean} [loop=0] - True if the sound should loop when it reaches the end + * @return {AudioBufferSourceNode} - The audio node of the sound played + * @memberof Audio */ +function playSamples(sampleChannels, volume=1, rate=1, pan=0, loop=0) +{ + if (!soundEnable) return; + + // create audio context + if (!audioContext) + audioContext = new AudioContext; + + // fix stalled audio + audioContext.resume(); + + // prevent sounds from building up if they can't be played + if (audioContext.state != 'running') + return; + + // create buffer and source + const buffer = audioContext.createBuffer(sampleChannels.length, sampleChannels[0].length, zzfxR), + source = audioContext.createBufferSource(); + + // copy samples to buffer and setup source + sampleChannels.forEach((c,i)=> buffer.getChannelData(i).set(c)); + source.buffer = buffer; + source.playbackRate.value = rate; + source.loop = loop; + + // create and connect gain node (createGain is more widely spported then GainNode construtor) + const gainNode = audioContext.createGain(); + gainNode.gain.value = soundVolume*volume; + gainNode.connect(audioContext.destination); + + // connect source to stereo panner and gain + source.connect(new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)})).connect(gainNode); + + // play and return sound + source.start(); + return source; +} + +/////////////////////////////////////////////////////////////////////////////// +// ZzFXMicro - Zuper Zmall Zound Zynth - v1.1.8 by Frank Force + +/** Generate and play a ZzFX sound + *
+ *
Create sounds using the ZzFX Sound Designer. + * @param {Array} zzfxSound - Array of ZzFX parameters, ex. [.5,.5] + * @return {Array} - Array of audio samples + * @memberof Audio */ +const zzfx = (...zzfxSound) => playSamples([zzfxG(...zzfxSound)]); + +/** Sample rate used for all ZzFX sounds + * @default 44100 + * @memberof Audio */ +const zzfxR = 44100; + +/** Generate samples for a ZzFX sound + * @memberof Audio */ +function zzfxG +( + // parameters + volume = 1, randomness = .05, frequency = 220, attack = 0, sustain = 0, + release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0, + pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0, + bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0 +) +{ + // init parameters + let PI2 = PI*2, startSlide = slide *= 500 * PI2 / zzfxR / zzfxR, b=[], + startFrequency = frequency *= (1 + randomness*rand(-1,1)) * PI2 / zzfxR, + t=0, tm=0, i=0, j=1, r=0, c=0, s=0, f, length; + + // scale by sample rate + attack = attack * zzfxR + 9; // minimum attack to prevent pop + decay *= zzfxR; + sustain *= zzfxR; + release *= zzfxR; + delay *= zzfxR; + deltaSlide *= 500 * PI2 / zzfxR**3; + modulation *= PI2 / zzfxR; + pitchJump *= PI2 / zzfxR; + pitchJumpTime *= zzfxR; + repeatTime = repeatTime * zzfxR | 0; + + // generate waveform + for (length = attack + decay + sustain + release + delay | 0; + i < length; b[i++] = s) + { + if (!(++c%(bitCrush*100|0))) // bit crush + { + s = shape? shape>1? shape>2? shape>3? // wave shape + Math.sin((t%PI2)**3) : // 4 noise + max(min(Math.tan(t),1),-1): // 3 tan + 1-(2*t/PI2%2+2)%2: // 2 saw + 1-4*abs(Math.round(t/PI2)-t/PI2): // 1 triangle + Math.sin(t); // 0 sin + + s = (repeatTime ? + 1 - tremolo + tremolo*Math.sin(PI2*i/repeatTime) // tremolo + : 1) * + sign(s)*(abs(s)**shapeCurve) * // curve 0=square, 2=pointy + volume * soundVolume * ( // envelope + i < attack ? i/attack : // attack + i < attack + decay ? // decay + 1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff + i < attack + decay + sustain ? // sustain + sustainVolume : // sustain volume + i < length - delay ? // release + (length - i - delay)/release * // release falloff + sustainVolume : // release volume + 0); // post release + + s = delay ? s/2 + (delay > i ? 0 : // delay + (i pitchJumpTime) // pitch jump + { + frequency += pitchJump; // apply pitch jump + startFrequency += pitchJump; // also apply to start + j = 0; // reset pitch jump time + } + + if (repeatTime && !(++r % repeatTime)) // repeat + { + frequency = startFrequency; // reset frequency + slide = startSlide; // reset slide + j = j || 1; // reset pitch jump time + } + } + + return b; +} + +/////////////////////////////////////////////////////////////////////////////// +// ZzFX Music Renderer v2.0.3 by Keith Clark and Frank Force + +/** Generate samples for a ZzFM song with given parameters + * @param {Array} instruments - Array of ZzFX sound paramaters + * @param {Array} patterns - Array of pattern data + * @param {Array} sequence - Array of pattern indexes + * @param {Number} [BPM=125] - Playback speed of the song in BPM + * @returns {Array} - Left and right channel sample data + * @memberof Audio */ +function zzfxM(instruments, patterns, sequence, BPM = 125) +{ + let i, j, k; + let instrumentParameters; + let note; + let sample; + let patternChannel; + let notFirstBeat; + let stop; + let instrument; + let attenuation; + let outSampleOffset; + let isSequenceEnd; + let sampleOffset = 0; + let nextSampleOffset; + let sampleBuffer = []; + let leftChannelBuffer = []; + let rightChannelBuffer = []; + let channelIndex = 0; + let panning = 0; + let hasMore = 1; + let sampleCache = {}; + let beatLength = zzfxR / BPM * 60 >> 2; + + // for each channel in order until there are no more + for (; hasMore; channelIndex++) { + + // reset current values + sampleBuffer = [hasMore = notFirstBeat = outSampleOffset = 0]; + + // for each pattern in sequence + sequence.forEach((patternIndex, sequenceIndex) => { + // get pattern for current channel, use empty 1 note pattern if none found + patternChannel = patterns[patternIndex][channelIndex] || [0, 0, 0]; + + // check if there are more channels + hasMore |= !!patterns[patternIndex][channelIndex]; + + // get next offset, use the length of first channel + nextSampleOffset = outSampleOffset + (patterns[patternIndex][0].length - 2 - !notFirstBeat) * beatLength; + // for each beat in pattern, plus one extra if end of sequence + isSequenceEnd = sequenceIndex == sequence.length - 1; + for (i = 2, k = outSampleOffset; i < patternChannel.length + isSequenceEnd; notFirstBeat = ++i) { + + // + note = patternChannel[i]; + + // stop if end, different instrument or new note + stop = i == patternChannel.length + isSequenceEnd - 1 && isSequenceEnd || + instrument != (patternChannel[0] || 0) | note | 0; + + // fill buffer with samples for previous beat, most cpu intensive part + for (j = 0; j < beatLength && notFirstBeat; + + // fade off attenuation at end of beat if stopping note, prevents clicking + j++ > beatLength - 99 && stop ? attenuation += (attenuation < 1) / 99 : 0 + ) { + // copy sample to stereo buffers with panning + sample = (1 - attenuation) * sampleBuffer[sampleOffset++] / 2 || 0; + leftChannelBuffer[k] = (leftChannelBuffer[k] || 0) - sample * panning + sample; + rightChannelBuffer[k] = (rightChannelBuffer[k++] || 0) + sample * panning + sample; + } + + // set up for next note + if (note) { + // set attenuation + attenuation = note % 1; + panning = patternChannel[1] || 0; + if (note |= 0) { + // get cached sample + sampleBuffer = sampleCache[ + [ + instrument = patternChannel[sampleOffset = 0] || 0, + note + ] + ] = sampleCache[[instrument, note]] || ( + // add sample to cache + instrumentParameters = [...instruments[instrument]], + instrumentParameters[2] *= 2 ** ((note - 12) / 12), + + // allow negative values to stop notes + note > 0 ? zzfxG(...instrumentParameters) : [] + ); + } + } + } + + // update the sample offset + outSampleOffset = nextSampleOffset; + }); + } + + return [leftChannelBuffer, rightChannelBuffer]; +} +/** + * LittleJS Tile Layer System + *
- Caches arrays of tiles to off screen canvas for fast rendering + *
- Unlimted numbers of layers, allocates canvases as needed + *
- Interfaces with EngineObject for collision + *
- Collision layer is separate from visible layers + *
- It is recommended to have a visible layer that matches the collision + *
- Tile layers can be drawn to using their context with canvas2d + *
- Drawn directly to the main canvas without using WebGL + * @namespace TileCollision + */ + +'use strict'; + +/** The tile collision layer array, use setTileCollisionData and getTileCollisionData to access + * @type {Array} + * @memberof TileCollision */ +let tileCollision = []; + +/** Size of the tile collision layer + * @type {Vector2} + * @memberof TileCollision */ +let tileCollisionSize = vec2(); + +/** Clear and initialize tile collision + * @param {Vector2} size + * @memberof TileCollision */ +function initTileCollision(size) +{ + tileCollisionSize = size; + tileCollision = []; + for (let i=tileCollision.length = tileCollisionSize.area(); i--;) + tileCollision[i] = 0; +} + +/** Set tile collision data + * @param {Vector2} pos + * @param {Number} [data=0] + * @memberof TileCollision */ +const setTileCollisionData = (pos, data=0)=> + pos.arrayCheck(tileCollisionSize) && (tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] = data); + +/** Get tile collision data + * @param {Vector2} pos + * @return {Number} + * @memberof TileCollision */ +const getTileCollisionData = (pos)=> + pos.arrayCheck(tileCollisionSize) ? tileCollision[(pos.y|0)*tileCollisionSize.x+pos.x|0] : 0; + +/** Check if collision with another object should occur + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {EngineObject} [object] + * @return {Boolean} + * @memberof TileCollision */ +function tileCollisionTest(pos, size=vec2(), object) +{ + const minX = max(pos.x - size.x/2|0, 0); + const minY = max(pos.y - size.y/2|0, 0); + const maxX = min(pos.x + size.x/2, tileCollisionSize.x); + const maxY = min(pos.y + size.y/2, tileCollisionSize.y); + for (let y = minY; y < maxY; ++y) + for (let x = minX; x < maxX; ++x) + { + const tileData = tileCollision[y*tileCollisionSize.x+x]; + if (tileData && (!object || object.collideWithTile(tileData, new Vector2(x, y)))) + return 1; + } +} + +/** Return the center of tile if any that is hit (this does not return the exact hit point) + * @param {Vector2} posStart + * @param {Vector2} posEnd + * @param {EngineObject} [object] + * @return {Vector2} + * @memberof TileCollision */ +function tileCollisionRaycast(posStart, posEnd, object) +{ + // test if a ray collides with tiles from start to end + // todo: a way to get the exact hit point, it must still register as inside the hit tile + const posDelta = (posEnd = posEnd.floor()).subtract(posStart = posStart.floor()); + const dx = abs(posDelta.x), dy = -abs(posDelta.y); + const sx = sign(posDelta.x), sy = sign(posDelta.y); + + for (let x = posStart.x, y = posStart.y, e = dx + dy;;) + { + const tileData = getTileCollisionData(vec2(x,y)); + if (tileData && (object ? object.collideWithTileRaycast(tileData, new Vector2(x, y)) : tileData > 0)) + { + debugRaycast && debugLine(posStart, posEnd, '#f00',.02, 1); + debugRaycast && debugPoint(new Vector2(x+.5, y+.5), '#ff0', 1); + return new Vector2(x+.5, y+.5); + } + + // update Bresenham line drawing algorithm + if (x == posEnd.x & y == posEnd.y) break; + const e2 = 2*e; + if (e2 >= dy) e += dy, x += sx; + if (e2 <= dx) e += dx, y += sy; + } + debugRaycast && debugLine(posStart, posEnd, '#00f',.02, 1); +} + +/////////////////////////////////////////////////////////////////////////////// +// Tile Layer Rendering System + +/** + * Tile layer data object stores info about how to render a tile + * @example + * // create tile layer data with tile index 0 and random orientation and color + * const tileIndex = 0; + * const direction = randInt(4) + * const mirror = randInt(2); + * const color = randColor(); + * const data = new TileLayerData(tileIndex, direction, mirror, color); + */ +class TileLayerData +{ + /** Create a tile layer data object, one for each tile in a TileLayer + * @param {Number} [tile] - The tile to use, untextured if undefined + * @param {Number} [direction=0] - Integer direction of tile, in 90 degree increments + * @param {Boolean} [mirror=0] - If the tile should be mirrored along the x axis + * @param {Color} [color=Color()] - Color of the tile */ + constructor(tile, direction=0, mirror=0, color=new Color()) + { + /** @property {Number} - The tile to use, untextured if undefined */ + this.tile = tile; + /** @property {Number} - Integer direction of tile, in 90 degree increments */ + this.direction = direction; + /** @property {Boolean} - If the tile should be mirrored along the x axis */ + this.mirror = mirror; + /** @property {Color} - Color of the tile */ + this.color = color; + } + + /** Set this tile to clear, it will not be rendered */ + clear() { this.tile = this.direction = this.mirror = 0; color = new Color; } +} + +/** + * Tile layer object - cached rendering system for tile layers + *
- Each Tile layer is rendered to an off screen canvas + *
- To allow dynamic modifications, layers are rendered using canvas 2d + *
- Some devices like mobile phones are limited to 4k texture resolution + *
- So with 16x16 tiles this limits layers to 256x256 on mobile devices + * @extends EngineObject + * @example + * // create tile collision and visible tile layer + * initTileCollision(vec2(200,100)); + * const tileLayer = new TileLayer(); + */ +class TileLayer extends EngineObject +{ +/** Create a tile layer object + * @param {Vector2} [position=Vector2()] - World space position + * @param {Vector2} [size=tileCollisionSize] - World space size + * @param {Vector2} [tileSize=tileSizeDefault] - Size of tiles in source pixels + * @param {Vector2} [scale=Vector2(1,1)] - How much to scale this layer when rendered + * @param {Number} [renderOrder=0] - Objects sorted by renderOrder before being rendered + */ +constructor(pos, size=tileCollisionSize, tileSize=tileSizeDefault, scale=vec2(1), renderOrder=0) + { + super(pos, size, -1, tileSize, 0, undefined, renderOrder); + + /** @property {HTMLCanvasElement} - The canvas used by this tile layer */ + this.canvas = document.createElement('canvas'); + /** @property {CanvasRenderingContext2D} - The 2D canvas context used by this tile layer */ + this.context = this.canvas.getContext('2d'); + /** @property {Vector2} - How much to scale this layer when rendered */ + this.scale = scale; + /** @property {Boolean} [isOverlay=0] - If true this layer will render to overlay canvas and appear above all objects */ + this.isOverlay; + + // init tile data + this.data = []; + for (let j = this.size.area(); j--;) + this.data.push(new TileLayerData); + } + + /** Set data at a given position in the array + * @param {Vector2} position - Local position in array + * @param {TileLayerData} data - Data to set + * @param {Boolean} [redraw=0] - Force the tile to redraw if true */ + setData(layerPos, data, redraw) + { + if (layerPos.arrayCheck(this.size)) + { + this.data[(layerPos.y|0)*this.size.x+layerPos.x|0] = data; + redraw && this.drawTileData(layerPos); + } + } + + /** Get data at a given position in the array + * @param {Vector2} layerPos - Local position in array + * @return {TileLayerData} */ + getData(layerPos) + { return layerPos.arrayCheck(this.size) && this.data[(layerPos.y|0)*this.size.x+layerPos.x|0]; } + + // Tile layers are not updated + update() {} + + // Render the tile layer, called automatically by the engine + render() + { + ASSERT(mainContext != this.context); // must call redrawEnd() after drawing tiles + + // flush and copy gl canvas because tile canvas does not use webgl + glEnable && !glOverlay && !this.isOverlay && glCopyToContext(mainContext); + + // draw the entire cached level onto the canvas + const pos = worldToScreen(this.pos.add(vec2(0,this.size.y*this.scale.y))); + (this.isOverlay ? overlayContext : mainContext).drawImage + ( + this.canvas, pos.x, pos.y, + cameraScale*this.size.x*this.scale.x, cameraScale*this.size.y*this.scale.y + ); + } + + /** Draw all the tile data to an offscreen canvas + * - This may be slow in some browsers + */ + redraw() + { + this.redrawStart(1); + this.drawAllTileData(); + this.redrawEnd(); + } + + /** Call to start the redraw process + * @param {Boolean} [clear=0] - Should it clear the canvas before drawing */ + redrawStart(clear = 0) + { + // save current render settings + this.savedRenderSettings = [mainCanvas, mainContext, mainCanvasSize, cameraPos, cameraScale]; + + // hack: use normal rendering system to render the tiles + mainCanvas = this.canvas; + mainContext = this.context; + cameraPos = this.size.scale(.5); + cameraScale = this.tileSize.x; + + if (clear) + { + // clear and set size + mainCanvas.width = this.size.x * this.tileSize.x; + mainCanvas.height = this.size.y * this.tileSize.y; + } + + // begin a new render for the tile canvas + enginePreRender(); + } + + /** Call to end the redraw process */ + redrawEnd() + { + ASSERT(mainContext == this.context); // must call redrawStart() before drawing tiles + glEnable && glCopyToContext(mainContext, 1); + //debugSaveCanvas(this.canvas); + + // set stuff back to normal + [mainCanvas, mainContext, mainCanvasSize, cameraPos, cameraScale] = this.savedRenderSettings; + } + + /** Draw the tile at a given position + * @param {Vector2} layerPos */ + drawTileData(layerPos) + { + // first clear out where the tile was + const pos = layerPos.floor().add(this.pos).add(vec2(.5)); + this.drawCanvas2D(pos, vec2(1), 0, 0, (context)=>context.clearRect(-.5, -.5, 1, 1)); + + // draw the tile if not undefined + const d = this.getData(layerPos); + if (d.tile != undefined) + { + ASSERT(mainContext == this.context); // must call redrawStart() before drawing tiles + drawTile(pos, vec2(1), d.tile, this.tileSize, d.color, d.direction*PI/2, d.mirror); + } + } + + /** Draw all the tiles in this layer */ + drawAllTileData() + { + for (let x = this.size.x; x--;) + for (let y = this.size.y; y--;) + this.drawTileData(vec2(x,y)); + } + + /** Draw directly to the 2D canvas in world space (bipass webgl) + * @param {Vector2} pos + * @param {Vector2} size + * @param {Number} [angle=0] + * @param {Boolean} [mirror=0] + * @param {Function} drawFunction */ + drawCanvas2D(pos, size, angle=0, mirror, drawFunction) + { + const context = this.context; + context.save(); + pos = pos.subtract(this.pos).multiply(this.tileSize); + size = size.multiply(this.tileSize); + context.translate(pos.x, this.canvas.height - pos.y); + context.rotate(angle); + context.scale(mirror ? -size.x : size.x, size.y); + drawFunction(context); + context.restore(); + } + + /** Draw a tile directly onto the layer canvas + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {Number} [tileIndex=-1] + * @param {Vector2} [tileSize=tileSizeDefault] + * @param {Color} [color=Color()] + * @param {Number} [angle=0] + * @param {Boolean} [mirror=0] */ + drawTile(pos, size=vec2(1), tileIndex=-1, tileSize=tileSizeDefault, color=new Color, angle, mirror) + { + this.drawCanvas2D(pos, size, angle, mirror, (context)=> + { + if (tileIndex < 0) + { + // untextured + context.fillStyle = color; + context.fillRect(-.5, -.5, 1, 1); + } + else + { + const cols = tileImage.width/tileSize.x; + context.globalAlpha = color.a; // only alpha, no color, is supported in this mode + context.drawImage(tileImage, + (tileIndex%cols)*tileSize.x, (tileIndex/cols|0)*tileSize.y, + tileSize.x, tileSize.y, -.5, -.5, 1, 1); + } + }); + } + + /** Draw a rectangle directly onto the layer canvas + * @param {Vector2} pos + * @param {Vector2} [size=Vector2(1,1)] + * @param {Color} [color=Color()] + * @param {Number} [angle=0] */ + drawRect(pos, size, color, angle) + { this.drawTile(pos, size, -1, 0, color, angle); } +} +/* + LittleJS Particle System + - Spawns particles with randomness from parameters + - Updates particle physics + - Fast particle rendering +*/ + +'use strict'; + +/** + * Particle Emitter - Spawns particles with the given settings + * @extends EngineObject + * @example + * // create a particle emitter + * let pos = vec2(2,3); + * let particleEmiter = new ParticleEmitter + * ( + * pos, 0, 1, 0, 500, PI, // pos, angle, emitSize, emitTime, emitRate, emiteCone + * 0, vec2(16), // tileIndex, tileSize + * new Color(1,1,1), new Color(0,0,0), // colorStartA, colorStartB + * new Color(1,1,1,0), new Color(0,0,0,0), // colorEndA, colorEndB + * 2, .2, .2, .1, .05, // particleTime, sizeStart, sizeEnd, particleSpeed, particleAngleSpeed + * .99, 1, 1, PI, .05, // damping, angleDamping, gravityScale, particleCone, fadeRate, + * .5, 1 // randomness, collide, additive, randomColorLinear, renderOrder + * ); + */ +class ParticleEmitter extends EngineObject +{ + /** Create a particle system with the given settings + * @param {Vector2} position - World space position of the emitter + * @param {Number} [angle=0] - Angle to emit the particles + * @param {Number} [emitSize=0] - World space size of the emitter (float for circle diameter, vec2 for rect) + * @param {Number} [emitTime=0] - How long to stay alive (0 is forever) + * @param {Number} [emitRate=100] - How many particles per second to spawn, does not emit if 0 + * @param {Number} [emitConeAngle=PI] - Local angle to apply velocity to particles from emitter + * @param {Number} [tileIndex=-1] - Index into tile sheet, if <0 no texture is applied + * @param {Vector2} [tileSize=tileSizeDefault] - Tile size for particles + * @param {Color} [colorStartA=Color()] - Color at start of life 1, randomized between start colors + * @param {Color} [colorStartB=Color()] - Color at start of life 2, randomized between start colors + * @param {Color} [colorEndA=Color(1,1,1,0)] - Color at end of life 1, randomized between end colors + * @param {Color} [colorEndB=Color(1,1,1,0)] - Color at end of life 2, randomized between end colors + * @param {Number} [particleTime=.5] - How long particles live + * @param {Number} [sizeStart=.1] - How big are particles at start + * @param {Number} [sizeEnd=1] - How big are particles at end + * @param {Number} [speed=.1] - How fast are particles when spawned + * @param {Number} [angleSpeed=.05] - How fast are particles rotating + * @param {Number} [damping=1] - How much to dampen particle speed + * @param {Number} [angleDamping=1] - How much to dampen particle angular speed + * @param {Number} [gravityScale=0] - How much does gravity effect particles + * @param {Number} [particleConeAngle=PI] - Cone for start particle angle + * @param {Number} [fadeRate=.1] - How quick to fade in particles at start/end in percent of life + * @param {Number} [randomness=.2] - Apply extra randomness percent + * @param {Boolean} [collideTiles=0] - Do particles collide against tiles + * @param {Boolean} [additive=0] - Should particles use addtive blend + * @param {Boolean} [randomColorLinear=1] - Should color be randomized linearly or across each component + * @param {Number} [renderOrder=0] - Render order for particles (additive is above other stuff by default) + * @param {Boolean} [localSpace=0] - Should it be in local space of emitter (world space is default) + */ + constructor + ( + pos, + angle, + emitSize = 0, + emitTime = 0, + emitRate = 100, + emitConeAngle = PI, + tileIndex = -1, + tileSize = tileSizeDefault, + colorStartA = new Color, + colorStartB = new Color, + colorEndA = new Color(1,1,1,0), + colorEndB = new Color(1,1,1,0), + particleTime = .5, + sizeStart = .1, + sizeEnd = 1, + speed = .1, + angleSpeed = .05, + damping = 1, + angleDamping = 1, + gravityScale = 0, + particleConeAngle = PI, + fadeRate = .1, + randomness = .2, + collideTiles, + additive, + randomColorLinear = 1, + renderOrder = additive ? 1e9 : 0, + localSpace + ) + { + super(pos, new Vector2, tileIndex, tileSize, angle, undefined, renderOrder); + + // emitter settings + /** @property {Number} - World space size of the emitter (float for circle diameter, vec2 for rect) */ + this.emitSize = emitSize + /** @property {Number} - How long to stay alive (0 is forever) */ + this.emitTime = emitTime; + /** @property {Number} - How many particles per second to spawn, does not emit if 0 */ + this.emitRate = emitRate; + /** @property {Number} - Local angle to apply velocity to particles from emitter */ + this.emitConeAngle = emitConeAngle; + + // color settings + /** @property {Color} - Color at start of life 1, randomized between start colors */ + this.colorStartA = colorStartA; + /** @property {Color} - Color at start of life 2, randomized between start colors */ + this.colorStartB = colorStartB; + /** @property {Color} - Color at end of life 1, randomized between end colors */ + this.colorEndA = colorEndA; + /** @property {Color} - Color at end of life 2, randomized between end colors */ + this.colorEndB = colorEndB; + /** @property {Boolean} - Should color be randomized linearly or across each component */ + this.randomColorLinear = randomColorLinear; + + // particle settings + /** @property {Number} - How long particles live */ + this.particleTime = particleTime; + /** @property {Number} - How big are particles at start */ + this.sizeStart = sizeStart; + /** @property {Number} - How big are particles at end */ + this.sizeEnd = sizeEnd; + /** @property {Number} - How fast are particles when spawned */ + this.speed = speed; + /** @property {Number} - How fast are particles rotating */ + this.angleSpeed = angleSpeed; + /** @property {Number} - How much to dampen particle speed */ + this.damping = damping; + /** @property {Number} - How much to dampen particle angular speed */ + this.angleDamping = angleDamping; + /** @property {Number} - How much does gravity effect particles */ + this.gravityScale = gravityScale; + /** @property {Number} - Cone for start particle angle */ + this.particleConeAngle = particleConeAngle; + /** @property {Number} - How quick to fade in particles at start/end in percent of life */ + this.fadeRate = fadeRate; + /** @property {Number} - Apply extra randomness percent */ + this.randomness = randomness; + /** @property {Number} - Do particles collide against tiles */ + this.collideTiles = collideTiles; + /** @property {Number} - Should particles use addtive blend */ + this.additive = additive; + /** @property {Boolean} - Should it be in local space of emitter */ + this.localSpace = localSpace; + /** @property {Number} - If set the partile is drawn as a trail, stretched in the drection of velocity */ + this.trailScale = 0; + + // internal variables + this.emitTimeBuffer = 0; + } + + /** Update the emitter to spawn particles, called automatically by engine once each frame */ + update() + { + // only do default update to apply parent transforms + this.parent && super.update(); + + // update emitter + if (!this.emitTime | this.getAliveTime() <= this.emitTime) + { + // emit particles + if (this.emitRate * particleEmitRateScale) + { + const rate = 1/this.emitRate/particleEmitRateScale; + for (this.emitTimeBuffer += timeDelta; this.emitTimeBuffer > 0; this.emitTimeBuffer -= rate) + this.emitParticle(); + } + } + else + this.destroy(); + + debugParticles && debugRect(this.pos, vec2(this.emitSize), '#0f0', 0, this.angle); + } + + /** Spawn one particle + * @return {Particle} */ + emitParticle() + { + // spawn a particle + let pos = this.emitSize.x != undefined ? // check if vec2 was used for size + (new Vector2(rand(-.5,.5), rand(-.5,.5))) + .multiply(this.emitSize).rotate(this.angle) // box emitter + : randInCircle(this.emitSize/2); // circle emitter + let angle = rand(this.particleConeAngle, -this.particleConeAngle); + if (!this.localSpace) + { + pos = this.pos.add(pos); + angle += this.angle; + } + + const particle = new Particle(pos, this.tileIndex, this.tileSize, angle); + + // randomness scales each paremeter by a percentage + const randomness = this.randomness; + const randomizeScale = (v)=> v + v*rand(randomness, -randomness); + + // randomize particle settings + const particleTime = randomizeScale(this.particleTime); + const sizeStart = randomizeScale(this.sizeStart); + const sizeEnd = randomizeScale(this.sizeEnd); + const speed = randomizeScale(this.speed); + const angleSpeed = randomizeScale(this.angleSpeed) * randSign(); + const coneAngle = rand(this.emitConeAngle, -this.emitConeAngle); + const colorStart = randColor(this.colorStartA, this.colorStartB, this.randomColorLinear); + const colorEnd = randColor(this.colorEndA, this.colorEndB, this.randomColorLinear); + const velocityAngle = this.localSpace ? coneAngle : this.angle + coneAngle; + + // build particle settings + particle.colorStart = colorStart; + particle.colorEndDelta = colorEnd.subtract(colorStart); + particle.velocity = (new Vector2).setAngle(velocityAngle, speed); + particle.angleVelocity = angleSpeed; + particle.lifeTime = particleTime; + particle.sizeStart = sizeStart; + particle.sizeEndDelta = sizeEnd - sizeStart; + particle.fadeRate = this.fadeRate; + particle.damping = this.damping; + particle.angleDamping = this.angleDamping; + particle.elasticity = this.elasticity; + particle.friction = this.friction; + particle.gravityScale = this.gravityScale; + particle.collideTiles = this.collideTiles; + particle.additive = this.additive; + particle.renderOrder = this.renderOrder; + particle.trailScale = this.trailScale; + particle.mirror = randInt(2); + particle.localSpaceEmitter = this.localSpace && this; + + // setup callbacks for particles + particle.destroyCallback = this.particleDestroyCallback; + this.particleCreateCallback && this.particleCreateCallback(particle); + + // return the newly created particle + return particle; + } + + // Particle emitters are not rendered, only the particles are + render() {} +} + +/////////////////////////////////////////////////////////////////////////////// +/** + * Particle Object - Created automatically by Particle Emitters + * @extends EngineObject + */ +class Particle extends EngineObject +{ + /** + * Create a particle with the given settings + * @param {Vector2} position - World space position of the particle + * @param {Number} [tileIndex=-1] - Tile to use to render, untextured if -1 + * @param {Vector2} [tileSize=tileSizeDefault] - Size of tile in source pixels + * @param {Number} [angle=0] - Angle to rotate the particle + */ + constructor(pos, tileIndex, tileSize, angle) + { super(pos, new Vector2, tileIndex, tileSize, angle); } + + /** Render the particle, automatically called each frame, sorted by renderOrder */ + render() + { + // modulate size and color + const p = min((time - this.spawnTime) / this.lifeTime, 1); + const radius = this.sizeStart + p * this.sizeEndDelta; + const size = new Vector2(radius, radius); + const fadeRate = this.fadeRate/2; + const color = new Color( + this.colorStart.r + p * this.colorEndDelta.r, + this.colorStart.g + p * this.colorEndDelta.g, + this.colorStart.b + p * this.colorEndDelta.b, + (this.colorStart.a + p * this.colorEndDelta.a) * + (p < fadeRate ? p/fadeRate : p > 1-fadeRate ? (1-p)/fadeRate : 1)); // fade alpha + + // draw the particle + this.additive && setBlendMode(1); + + let pos = this.pos, angle = this.angle; + if (this.localSpaceEmitter) + { + // in local space of emitter + pos = this.localSpaceEmitter.pos.add(pos.rotate(-this.localSpaceEmitter.angle)); + angle += this.localSpaceEmitter.angle; + } + if (this.trailScale) + { + // trail style particles + let velocity = this.velocity; + if (this.localSpaceEmitter) + velocity = velocity.rotate(-this.localSpaceEmitter.angle); + const speed = velocity.length(); + const direction = velocity.scale(1/speed); + const trailLength = speed * this.trailScale; + size.y = max(size.x, trailLength); + angle = direction.angle(); + drawTile(pos.add(direction.multiply(vec2(0,-trailLength/2))), size, this.tileIndex, this.tileSize, color, angle, this.mirror); + } + else + drawTile(pos, size, this.tileIndex, this.tileSize, color, angle, this.mirror); + this.additive && setBlendMode(); + debugParticles && debugRect(pos, size, '#f005', 0, angle); + + if (p == 1) + { + // destroy particle when it's time runs out + this.color = color; + this.size = size; + this.destroyCallback && this.destroyCallback(this); + this.destroyed = 1; + } + } +} +/** + * LittleJS Medal System + *
- Tracks and displays medals + *
- Saves medals to local storage + *
- Newgrounds integration + * @namespace Medals + */ + +'use strict'; + +/** List of all medals + * @type {Array} + * @memberof Medals */ +const medals = []; + +// Engine internal variables not exposed to documentation +let medalsDisplayQueue = [], medalsSaveName, medalsDisplayTimeLast; + +/////////////////////////////////////////////////////////////////////////////// + +/** Initialize medals with a save name used for storage + *
- Call this after creating all medals + *
- Checks if medals are unlocked + * @param {String} saveName + * @memberof Medals */ +function medalsInit(saveName) +{ + // check if medals are unlocked + medalsSaveName = saveName; + debugMedals || medals.forEach(medal=> medal.unlocked = (localStorage[medal.storageKey()] | 0)); +} + +/** + * Medal Object - Tracks an unlockable medal + * @example + * // create a medal + * const medal_example = new Medal(0, 'Example Medal', 'More info about the medal goes here.', '🎖️'); + * + * // initialize medals + * medalsInit('Example Game'); + * + * // unlock the medal + * medal_example.unlock(); + */ +class Medal +{ + /** Create an medal object and adds it to the list of medals + * @param {Number} id - The unique identifier of the medal + * @param {String} name - Name of the medal + * @param {String} [description] - Description of the medal + * @param {String} [icon='🏆'] - Icon for the medal + * @param {String} [src] - Image location for the medal + */ + constructor(id, name, description='', icon='🏆', src) + { + ASSERT(id >= 0 && !medals[id]); + + // save attributes and add to list of medals + medals[this.id = id] = this; + this.name = name; + this.description = description; + this.icon = icon; + if (src) + (this.image = new Image).src = src; + } + + /** Unlocks a medal if not already unlocked */ + unlock() + { + if (medalsPreventUnlock || this.unlocked) + return; + + // save the medal + ASSERT(medalsSaveName); // save name must be set + localStorage[this.storageKey()] = this.unlocked = 1; + medalsDisplayQueue.push(this); + newgrounds && newgrounds.unlockMedal(this.id); + } + + /** Render a medal + * @param {Number} [hidePercent=0] - How much to slide the medal off screen + */ + render(hidePercent=0) + { + const context = overlayContext; + const width = min(medalDisplaySize.x, mainCanvas.width); + const x = overlayCanvas.width - width; + const y = -medalDisplaySize.y*hidePercent; + + // draw containing rect and clip to that region + context.save(); + context.beginPath(); + context.fillStyle = new Color(.9,.9,.9); + context.strokeStyle = new Color(0,0,0); + context.lineWidth = 3; + context.fill(context.rect(x, y, width, medalDisplaySize.y)); + context.stroke(); + context.clip(); + + // draw the icon and text + this.renderIcon(vec2(x+15+medalDisplayIconSize/2, y+medalDisplaySize.y/2)); + const pos = vec2(x+medalDisplayIconSize+30, y+28); + drawTextScreen(this.name, pos, 38, new Color(0,0,0), 0, 0, 'left'); + pos.y += 32; + drawTextScreen(this.description, pos, 24, new Color(0,0,0), 0, 0, 'left'); + context.restore(); + } + + /** Render the icon for a medal + * @param {Number} x - Screen space X position + * @param {Number} y - Screen space Y position + * @param {Number} [size=medalDisplayIconSize] - Screen space size + */ + renderIcon(pos, size=medalDisplayIconSize) + { + // draw the image or icon + if (this.image) + overlayContext.drawImage(this.image, pos.x-size/2, pos.y-size/2, size, size); + else + drawTextScreen(this.icon, pos, size*.7, new Color(0,0,0)); + } + + // Get local storage key used by the medal + storageKey() { return medalsSaveName + '_' + this.id; } +} + +// engine automatically renders medals +function medalsRender() +{ + if (!medalsDisplayQueue.length) + return; + + // update first medal in queue + const medal = medalsDisplayQueue[0]; + const time = timeReal - medalsDisplayTimeLast; + if (!medalsDisplayTimeLast) + medalsDisplayTimeLast = timeReal; + else if (time > medalDisplayTime) + medalsDisplayQueue.shift(medalsDisplayTimeLast = 0); + else + { + // slide on/off medals + const slideOffTime = medalDisplayTime - medalDisplaySlideTime; + const hidePercent = + time < medalDisplaySlideTime ? 1 - time / medalDisplaySlideTime : + time > slideOffTime ? (time - slideOffTime) / medalDisplaySlideTime : 0; + medal.render(hidePercent); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +// global Newgrounds object +let newgrounds; + +/** This can used to enable Newgrounds functionality + * @param {Number} app_id - The newgrounds App ID + * @param {String} [cipher] - The encryption Key (AES-128/Base64) + * @memberof Medals */ +function newgroundsInit(app_id, cipher) { newgrounds = new Newgrounds(app_id, cipher); } + +/** + * Newgrounds API wrapper object + * @example + * // create a newgrounds object, replace the app id and cipher with your own + * const app_id = '53123:1ZuSTQ9l'; + * const cipher = 'enF0vGH@Mj/FRASKL23Q=='; + * newgrounds = new Newgrounds(app_id, cipher); + */ +class Newgrounds +{ + /** Create a newgrounds object + * @param {Number} app_id - The newgrounds App ID + * @param {String} [cipher] - The encryption Key (AES-128/Base64) */ + constructor(app_id, cipher) + { + ASSERT(!newgrounds && app_id); + + this.app_id = app_id; + this.cipher = cipher; + this.host = location ? location.hostname : ''; + + // create an instance of CryptoJS for encrypted calls + if (cipher) + this.cryptoJS = this.CryptoJS(); + + // get session id from url search params + const url = new URL(location.href); + this.session_id = url.searchParams.get('ngio_session_id'); + + if (!this.session_id) + return; // only use newgrounds when logged in + + // get medals + const medalsResult = this.call('Medal.getList'); + this.medals = medalsResult ? medalsResult.result.data['medals'] : []; + debugMedals && console.log(this.medals); + for (const newgroundsMedal of this.medals) + { + const medal = medals[newgroundsMedal['id']]; + if (medal) + { + // copy newgrounds medal data + medal.image = new Image; + medal.image.src = newgroundsMedal['icon']; + medal.name = newgroundsMedal['name']; + medal.description = newgroundsMedal['description']; + medal.unlocked = newgroundsMedal['unlocked']; + medal.difficulty = newgroundsMedal['difficulty']; + medal.value = newgroundsMedal['value']; + + if (medal.value) + medal.description = medal.description + ' (' + medal.value + ')'; + } + } + + // get scoreboards + const scoreboardResult = this.call('ScoreBoard.getBoards'); + this.scoreboards = scoreboardResult ? scoreboardResult.result.data.scoreboards : []; + debugMedals && console.log(this.scoreboards); + + const keepAliveMS = 5 * 60 * 1e3; + setInterval(()=>this.call('Gateway.ping', 0, 1), keepAliveMS); + } + + /** Send message to unlock a medal by id + * @param {Number} id - The medal id */ + unlockMedal(id) { return this.call('Medal.unlock', {'id':id}, 1); } + + /** Send message to post score + * @param {Number} id - The scoreboard id + * @param {Number} value - The score value */ + postScore(id, value) { return this.call('ScoreBoard.postScore', {'id':id, 'value':value}, 1); } + + /** Get scores from a scoreboard + * @param {Number} id - The scoreboard id + * @param {String} [user=0] - A user's id or name + * @param {Number} [social=0] - If true, only social scores will be loaded + * @param {Number} [skip=0] - Number of scores to skip before start + * @param {Number} [limit=10] - Number of scores to include in the list + * @return {Object} - The response JSON object + */ + getScores(id, user=0, social=0, skip=0, limit=10) + { return this.call('ScoreBoard.getScores', {'id':id, 'user':user, 'social':social, 'skip':skip, 'limit':limit}); } + + /** Send message to log a view */ + logView() { return this.call('App.logView', {'host':this.host}, 1); } + + /** Send a message to call a component of the Newgrounds API + * @param {String} component - Name of the component + * @param {Object} [parameters=0] - Parameters to use for call + * @param {Boolean} [async=0] - If true, don't wait for response before continuing (avoid stall) + * @return {Object} - The response JSON object + */ + call(component, parameters=0, async=0) + { + const call = {'component':component, 'parameters':parameters}; + if (this.cipher) + { + // encrypt using AES-128 Base64 with cryptoJS + const cryptoJS = this.cryptoJS; + const aesKey = cryptoJS['enc']['Base64']['parse'](this.cipher); + const iv = cryptoJS['lib']['WordArray']['random'](16); + const encrypted = cryptoJS['AES']['encrypt'](JSON.stringify(call), aesKey, {'iv':iv}); + call['secure'] = cryptoJS['enc']['Base64']['stringify'](iv.concat(encrypted['ciphertext'])); + call['parameters'] = 0; + } + + // build the input object + const input = + { + 'app_id': this.app_id, + 'session_id': this.session_id, + 'call': call + }; + + // build post data + const formData = new FormData(); + formData.append('input', JSON.stringify(input)); + + // send post data + const xmlHttp = new XMLHttpRequest(); + const url = 'https://newgrounds.io/gateway_v3.php'; + xmlHttp.open('POST', url, !debugMedals && async); + xmlHttp.send(formData); + debugMedals && console.log(xmlHttp.responseText); + return xmlHttp.responseText && JSON.parse(xmlHttp.responseText); + } + + CryptoJS() + { + /////////////////////////////////////////////////////////////////////////////// + // Crypto-JS - https://github.com/brix/crypto-js [The MIT License (MIT)] + // Copyright (c) 2009-2013 Jeff Mott Copyright (c) 2013-2016 Evan Vosberg + + return eval(Function("[M='GBMGXz^oVYPPKKbB`agTXU|LxPc_ZBcMrZvCr~wyGfWrwk@ATqlqeTp^N?p{we}jIpEnB_sEr`l?YDkDhWhprc|Er|XETG?pTl`e}dIc[_N~}fzRycIfpW{HTolvoPB_FMe_eH~BTMx]yyOhv?biWPCGc]kABencBhgERHGf{OL`Dj`c^sh@canhy[secghiyotcdOWgO{tJIE^JtdGQRNSCrwKYciZOa]Y@tcRATYKzv|sXpboHcbCBf`}SKeXPFM|RiJsSNaIb]QPc[D]Jy_O^XkOVTZep`ONmntLL`Qz~UupHBX_Ia~WX]yTRJIxG`ioZ{fefLJFhdyYoyLPvqgH?b`[TMnTwwfzDXhfM?rKs^aFr|nyBdPmVHTtAjXoYUloEziWDCw_suyYT~lSMksI~ZNCS[Bex~j]Vz?kx`gdYSEMCsHpjbyxQvw|XxX_^nQYue{sBzVWQKYndtYQMWRef{bOHSfQhiNdtR{o?cUAHQAABThwHPT}F{VvFmgN`E@FiFYS`UJmpQNM`X|tPKHlccT}z}k{sACHL?Rt@MkWplxO`ASgh?hBsuuP|xD~LSH~KBlRs]t|l|_tQAroDRqWS^SEr[sYdPB}TAROtW{mIkE|dWOuLgLmJrucGLpebrAFKWjikTUzS|j}M}szasKOmrjy[?hpwnEfX[jGpLt@^v_eNwSQHNwtOtDgWD{rk|UgASs@mziIXrsHN_|hZuxXlPJOsA^^?QY^yGoCBx{ekLuZzRqQZdsNSx@ezDAn{XNj@fRXIwrDX?{ZQHwTEfu@GhxDOykqts|n{jOeZ@c`dvTY?e^]ATvWpb?SVyg]GC?SlzteilZJAL]mlhLjYZazY__qcVFYvt@|bIQnSno@OXyt]OulzkWqH`rYFWrwGs`v|~XeTsIssLrbmHZCYHiJrX}eEzSssH}]l]IhPQhPoQ}rCXLyhFIT[clhzYOvyHqigxmjz`phKUU^TPf[GRAIhNqSOdayFP@FmKmuIzMOeoqdpxyCOwCthcLq?n`L`tLIBboNn~uXeFcPE{C~mC`h]jUUUQe^`UqvzCutYCgct|SBrAeiYQW?X~KzCz}guXbsUw?pLsg@hDArw?KeJD[BN?GD@wgFWCiHq@Ypp_QKFixEKWqRp]oJFuVIEvjDcTFu~Zz]a{IcXhWuIdMQjJ]lwmGQ|]g~c]Hl]pl`Pd^?loIcsoNir_kikBYyg?NarXZEGYspt_vLBIoj}LI[uBFvm}tbqvC|xyR~a{kob|HlctZslTGtPDhBKsNsoZPuH`U`Fqg{gKnGSHVLJ^O`zmNgMn~{rsQuoymw^JY?iUBvw_~mMr|GrPHTERS[MiNpY[Mm{ggHpzRaJaoFomtdaQ_?xuTRm}@KjU~RtPsAdxa|uHmy}n^i||FVL[eQAPrWfLm^ndczgF~Nk~aplQvTUpHvnTya]kOenZlLAQIm{lPl@CCTchvCF[fI{^zPkeYZTiamoEcKmBMfZhk_j_~Fjp|wPVZlkh_nHu]@tP|hS@^G^PdsQ~f[RqgTDqezxNFcaO}HZhb|MMiNSYSAnQWCDJukT~e|OTgc}sf[cnr?fyzTa|EwEtRG|I~|IO}O]S|rp]CQ}}DWhSjC_|z|oY|FYl@WkCOoPuWuqr{fJu?Brs^_EBI[@_OCKs}?]O`jnDiXBvaIWhhMAQDNb{U`bqVR}oqVAvR@AZHEBY@depD]OLh`kf^UsHhzKT}CS}HQKy}Q~AeMydXPQztWSSzDnghULQgMAmbWIZ|lWWeEXrE^EeNoZApooEmrXe{NAnoDf`m}UNlRdqQ@jOc~HLOMWs]IDqJHYoMziEedGBPOxOb?[X`KxkFRg@`mgFYnP{hSaxwZfBQqTm}_?RSEaQga]w[vxc]hMne}VfSlqUeMo_iqmd`ilnJXnhdj^EEFifvZyxYFRf^VaqBhLyrGlk~qowqzHOBlOwtx?i{m~`n^G?Yxzxux}b{LSlx]dS~thO^lYE}bzKmUEzwW^{rPGhbEov[Plv??xtyKJshbG`KuO?hjBdS@Ru}iGpvFXJRrvOlrKN?`I_n_tplk}kgwSXuKylXbRQ]]?a|{xiT[li?k]CJpwy^o@ebyGQrPfF`aszGKp]baIx~H?ElETtFh]dz[OjGl@C?]VDhr}OE@V]wLTc[WErXacM{We`F|utKKjgllAxvsVYBZ@HcuMgLboFHVZmi}eIXAIFhS@A@FGRbjeoJWZ_NKd^oEH`qgy`q[Tq{x?LRP|GfBFFJV|fgZs`MLbpPYUdIV^]mD@FG]pYAT^A^RNCcXVrPsgk{jTrAIQPs_`mD}rOqAZA[}RETFz]WkXFTz_m{N@{W@_fPKZLT`@aIqf|L^Mb|crNqZ{BVsijzpGPEKQQZGlApDn`ruH}cvF|iXcNqK}cxe_U~HRnKV}sCYb`D~oGvwG[Ca|UaybXea~DdD~LiIbGRxJ_VGheI{ika}KC[OZJLn^IBkPrQj_EuoFwZ}DpoBRcK]Q}?EmTv~i_Tul{bky?Iit~tgS|o}JL_VYcCQdjeJ_MfaA`FgCgc[Ii|CBHwq~nbJeYTK{e`CNstKfTKPzw{jdhp|qsZyP_FcugxCFNpKitlR~vUrx^NrSVsSTaEgnxZTmKc`R|lGJeX}ccKLsQZQhsFkeFd|ckHIVTlGMg`~uPwuHRJS_CPuN_ogXe{Ba}dO_UBhuNXby|h?JlgBIqMKx^_u{molgL[W_iavNQuOq?ap]PGB`clAicnl@k~pA?MWHEZ{HuTLsCpOxxrKlBh]FyMjLdFl|nMIvTHyGAlPogqfZ?PlvlFJvYnDQd}R@uAhtJmDfe|iJqdkYr}r@mEjjIetDl_I`TELfoR|qTBu@Tic[BaXjP?dCS~MUK[HPRI}OUOwAaf|_}HZzrwXvbnNgltjTwkBE~MztTQhtRSWoQHajMoVyBBA`kdgK~h`o[J`dm~pm]tk@i`[F~F]DBlJKklrkR]SNw@{aG~Vhl`KINsQkOy?WhcqUMTGDOM_]bUjVd|Yh_KUCCgIJ|LDIGZCPls{RzbVWVLEhHvWBzKq|^N?DyJB|__aCUjoEgsARki}j@DQXS`RNU|DJ^a~d{sh_Iu{ONcUtSrGWW@cvUjefHHi}eSSGrNtO?cTPBShLqzwMVjWQQCCFB^culBjZHEK_{dO~Q`YhJYFn]jq~XSnG@[lQr]eKrjXpG~L^h~tDgEma^AUFThlaR{xyuP@[^VFwXSeUbVetufa@dX]CLyAnDV@Bs[DnpeghJw^?UIana}r_CKGDySoRudklbgio}kIDpA@McDoPK?iYcG?_zOmnWfJp}a[JLR[stXMo?_^Ng[whQlrDbrawZeSZ~SJstIObdDSfAA{MV}?gNunLOnbMv_~KFQUAjIMj^GkoGxuYtYbGDImEYiwEMyTpMxN_LSnSMdl{bg@dtAnAMvhDTBR_FxoQgANniRqxd`pWv@rFJ|mWNWmh[GMJz_Nq`BIN@KsjMPASXORcdHjf~rJfgZYe_uulzqM_KdPlMsuvU^YJuLtofPhGonVOQxCMuXliNvJIaoC?hSxcxKVVxWlNs^ENDvCtSmO~WxI[itnjs^RDvI@KqG}YekaSbTaB]ki]XM@[ZnDAP~@|BzLRgOzmjmPkRE@_sobkT|SszXK[rZN?F]Z_u}Yue^[BZgLtR}FHzWyxWEX^wXC]MJmiVbQuBzkgRcKGUhOvUc_bga|Tx`KEM`JWEgTpFYVeXLCm|mctZR@uKTDeUONPozBeIkrY`cz]]~WPGMUf`MNUGHDbxZuO{gmsKYkAGRPqjc|_FtblEOwy}dnwCHo]PJhN~JoteaJ?dmYZeB^Xd?X^pOKDbOMF@Ugg^hETLdhwlA}PL@_ur|o{VZosP?ntJ_kG][g{Zq`Tu]dzQlSWiKfnxDnk}KOzp~tdFstMobmy[oPYjyOtUzMWdjcNSUAjRuqhLS@AwB^{BFnqjCmmlk?jpn}TksS{KcKkDboXiwK]qMVjm~V`LgWhjS^nLGwfhAYrjDSBL_{cRus~{?xar_xqPlArrYFd?pHKdMEZzzjJpfC?Hv}mAuIDkyBxFpxhstTx`IO{rp}XGuQ]VtbHerlRc_LFGWK[XluFcNGUtDYMZny[M^nVKVeMllQI[xtvwQnXFlWYqxZZFp_|]^oWX[{pOMpxXxvkbyJA[DrPzwD|LW|QcV{Nw~U^dgguSpG]ClmO@j_TENIGjPWwgdVbHganhM?ema|dBaqla|WBd`poj~klxaasKxGG^xbWquAl~_lKWxUkDFagMnE{zHug{b`A~IYcQYBF_E}wiA}K@yxWHrZ{[d~|ARsYsjeNWzkMs~IOqqp[yzDE|WFrivsidTcnbHFRoW@XpAV`lv_zj?B~tPCppRjgbbDTALeFaOf?VcjnKTQMLyp{NwdylHCqmo?oelhjWuXj~}{fpuX`fra?GNkDiChYgVSh{R[BgF~eQa^WVz}ATI_CpY?g_diae]|ijH`TyNIF}|D_xpmBq_JpKih{Ba|sWzhnAoyraiDvk`h{qbBfsylBGmRH}DRPdryEsSaKS~tIaeF[s]I~xxHVrcNe@Jjxa@jlhZueLQqHh_]twVMqG_EGuwyab{nxOF?`HCle}nBZzlTQjkLmoXbXhOtBglFoMz?eqre`HiE@vNwBulglmQjj]DB@pPkPUgA^sjOAUNdSu_`oAzar?n?eMnw{{hYmslYi[TnlJD'",...']charCodeAtUinyxpf',"for(;e<10359;c[e++]=p-=128,A=A?p-A&&A:p==34&&p)for(p=1;p<128;y=f.map((n,x)=>(U=r[n]*2+1,U=Math.log(U/(h-U)),t-=a[x]*U,U/500)),t=~-h/(1+Math.exp(t))|1,i=o%h>17)-!i*t,f.map((n,x)=>(U=r[n]+=(i*h/2-r[n]<<13)/((C[n]+=C[n]<5)+1/20)>>13,a[x]+=y[x]*(i-t/h))),p=p*2+i)for(f='010202103203210431053105410642065206541'.split(t=0).map((n,x)=>(U=0,[...n].map((n,x)=>(U=U*997+(c[e-n]|0)|0)),h*32-1&U*997+p+!!A*129)*12+x);o - All webgl used by the engine is wrapped up here + *
- For normal stuff you won't need to see or call anything in this file + *
- For advanced stuff there are helper functions to create shaders, textures, etc + *
- Can be disabled with glEnable to revert to 2D canvas rendering + *
- Batches sprite rendering on GPU for incredibly fast performance + *
- Sprite transform math is done in the shader where possible + * @namespace WebGL + */ + +'use strict'; + +/** The WebGL canvas which appears above the main canvas and below the overlay canvas + * @type {HTMLCanvasElement} + * @memberof WebGL */ +let glCanvas; + +/** 2d context for glCanvas + * @type {WebGLRenderingContext} + * @memberof WebGL */ +let glContext; + +/** Main tile sheet texture automatically loaded by engine + * @type {WebGLTexture} + * @memberof WebGL */ +let glTileTexture; + +// WebGL internal variables not exposed to documentation +let glActiveTexture, glShader, glArrayBuffer, glPositionData, glColorData, glBatchCount, glBatchAdditive, glAdditive; + +/////////////////////////////////////////////////////////////////////////////// + +// Init WebGL, called automatically by the engine +function glInit() +{ + // create the canvas and tile texture + glCanvas = document.createElement('canvas'); + glContext = glCanvas.getContext('webgl', {antialias: false}); + glTileTexture = glCreateTexture(tileImage); + + // some browsers are much faster without copying the gl buffer so we just overlay it instead + glOverlay && document.body.appendChild(glCanvas); + + // setup vertex and fragment shaders + glShader = glCreateProgram( + 'precision highp float;'+ // use highp for better accuracy + 'uniform mat4 m;'+ // transform matrix + 'attribute vec2 p,t;'+ // position, uv + 'attribute vec4 c,a;'+ // color, additiveColor + 'varying vec4 v,d,e;'+ // return uv, color, additiveColor + 'void main(){'+ // shader entry point + 'gl_Position=m*vec4(p,1,1);'+ // transform position + 'v=vec4(t,p);d=c;e=a;'+ // pass stuff to fragment shader + '}' // end of shader + , + 'precision highp float;'+ // use highp for better accuracy + 'varying vec4 v,d,e;'+ // uv, color, additiveColor + 'uniform sampler2D s;'+ // texture + 'void main(){'+ // shader entry point + 'gl_FragColor=texture2D(s,v.xy)*d+e;'+ // modulate texture by color plus additive + '}' // end of shader + ); + + // init buffers + const vertexData = new ArrayBuffer(gl_VERTEX_BUFFER_SIZE); + glArrayBuffer = glContext.createBuffer(); + glPositionData = new Float32Array(vertexData); + glColorData = new Uint32Array(vertexData); + glBatchCount = 0; +} + +/** Set the WebGl blend mode, normally you should call setBlendMode instead + * @param {Boolean} [additive=0] + * @memberof WebGL */ +function glSetBlendMode(additive) +{ + // setup blending + glAdditive = additive; +} + +/** Set the WebGl texture, not normally necessary unless multiple tile sheets are used + *
- This may also flush the gl buffer resulting in more draw calls and worse performance + * @param {WebGLTexture} [texture=glTileTexture] + * @memberof WebGL */ +function glSetTexture(texture=glTileTexture) +{ + // must flush cache with the old texture to set a new one + if (texture != glActiveTexture) + glFlush(); + + glContext.bindTexture(gl_TEXTURE_2D, glActiveTexture = texture); +} + +/** Compile WebGL shader of the given type, will throw errors if in debug mode + * @param {String} source + * @param type + * @return {WebGLShader} + * @memberof WebGL */ +function glCompileShader(source, type) +{ + // build the shader + const shader = glContext.createShader(type); + glContext.shaderSource(shader, source); + glContext.compileShader(shader); + + // check for errors + if (debug && !glContext.getShaderParameter(shader, gl_COMPILE_STATUS)) + throw glContext.getShaderInfoLog(shader); + return shader; +} + +/** Create WebGL program with given shaders + * @param {WebGLShader} vsSource + * @param {WebGLShader} fsSource + * @return {WebGLProgram} + * @memberof WebGL */ +function glCreateProgram(vsSource, fsSource) +{ + // build the program + const program = glContext.createProgram(); + glContext.attachShader(program, glCompileShader(vsSource, gl_VERTEX_SHADER)); + glContext.attachShader(program, glCompileShader(fsSource, gl_FRAGMENT_SHADER)); + glContext.linkProgram(program); + + // check for errors + if (debug && !glContext.getProgramParameter(program, gl_LINK_STATUS)) + throw glContext.getProgramInfoLog(program); + return program; +} + +/** Create WebGL texture from an image and set the texture settings + * @param {Image} image + * @return {WebGLTexture} + * @memberof WebGL */ +function glCreateTexture(image) +{ + // build the texture + const texture = glContext.createTexture(); + glContext.bindTexture(gl_TEXTURE_2D, texture); + image && image.width && glContext.texImage2D(gl_TEXTURE_2D, 0, gl_RGBA, gl_RGBA, gl_UNSIGNED_BYTE, image); + + // use point filtering for pixelated rendering + const filter = cavasPixelated ? gl_NEAREST : gl_LINEAR; + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MIN_FILTER, filter); + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_MAG_FILTER, filter); + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_WRAP_S, gl_CLAMP_TO_EDGE); + glContext.texParameteri(gl_TEXTURE_2D, gl_TEXTURE_WRAP_T, gl_CLAMP_TO_EDGE); + return texture; +} + +// called automatically by engine before render +function glPreRender() +{ + // clear and set to same size as main canvas + glContext.viewport(0, 0, glCanvas.width = mainCanvas.width, glCanvas.height = mainCanvas.height); + glContext.clear(gl_COLOR_BUFFER_BIT); + + // set up the shader + glContext.useProgram(glShader); + glContext.activeTexture(gl_TEXTURE0); + glContext.bindTexture(gl_TEXTURE_2D, glActiveTexture = glTileTexture); + glContext.bindBuffer(gl_ARRAY_BUFFER, glArrayBuffer); + glContext.bufferData(gl_ARRAY_BUFFER, gl_VERTEX_BUFFER_SIZE, gl_DYNAMIC_DRAW); + glSetBlendMode(); + + // set vertex attributes + let offset = 0; + const initVertexAttribArray = (name, type, typeSize, size, normalize=0)=> + { + const location = glContext.getAttribLocation(glShader, name); + glContext.enableVertexAttribArray(location); + glContext.vertexAttribPointer(location, size, type, normalize, gl_VERTEX_BYTE_STRIDE, offset); + offset += size*typeSize; + } + initVertexAttribArray('p', gl_FLOAT, 4, 2); // position + initVertexAttribArray('t', gl_FLOAT, 4, 2); // texture coords + initVertexAttribArray('c', gl_UNSIGNED_BYTE, 1, 4, 1); // color + initVertexAttribArray('a', gl_UNSIGNED_BYTE, 1, 4, 1); // additiveColor + + // build the transform matrix + const sx = 2 * cameraScale / mainCanvas.width; + const sy = 2 * cameraScale / mainCanvas.height; + glContext.uniformMatrix4fv(glContext.getUniformLocation(glShader, 'm'), 0, + new Float32Array([ + sx, 0, 0, 0, + 0, sy, 0, 0, + 1, 1, -1, 1, + -1-sx*cameraPos.x, -1-sy*cameraPos.y, 0, 0 + ]) + ); +} + +/** Draw all sprites and clear out the buffer, called automatically by the system whenever necessary + * @memberof WebGL */ +function glFlush() +{ + if (!glBatchCount) return; + + const destBlend = glBatchAdditive ? gl_ONE : gl_ONE_MINUS_SRC_ALPHA; + glContext.blendFuncSeparate(gl_SRC_ALPHA, destBlend, gl_ONE, destBlend); + glContext.enable(gl_BLEND); + + // draw all the sprites in the batch and reset the buffer + glContext.bufferSubData(gl_ARRAY_BUFFER, 0, + glPositionData.subarray(0, glBatchCount * gl_VERTICES_PER_QUAD * gl_INDICIES_PER_VERT)); + glContext.drawArrays(gl_TRIANGLES, 0, glBatchCount * gl_VERTICES_PER_QUAD); + glBatchCount = 0; + glBatchAdditive = glAdditive; +} + +/** Draw any sprites still in the buffer, copy to main canvas and clear + * @param {CanvasRenderingContext2D} context + * @param {Boolean} [forceDraw=0] + * @memberof WebGL */ +function glCopyToContext(context, forceDraw) +{ + if (!glBatchCount && !forceDraw) return; + + glFlush(); + + // do not draw in overlay mode because the canvas is visible + if (!glOverlay || forceDraw) + context.drawImage(glCanvas, 0, 0); +} + +/** Add a sprite to the gl draw list, used by all gl draw functions + * @param x + * @param y + * @param sizeX + * @param sizeY + * @param angle + * @param uv0X + * @param uv0Y + * @param uv1X + * @param uv1Y + * @param rgba + * @param [rgbaAdditive=0] + * @memberof WebGL */ +function glDraw(x, y, sizeX, sizeY, angle, uv0X, uv0Y, uv1X, uv1Y, rgba, rgbaAdditive=0) +{ + // flush if there is no room for more verts or if different blend mode + if (glBatchCount == gl_MAX_BATCH || glBatchAdditive != glAdditive) + glFlush(); + + // prepare to create the verts from size and angle + const c = Math.cos(angle)/2, s = Math.sin(angle)/2; + const cx = c*sizeX, cy = c*sizeY, sx = s*sizeX, sy = s*sizeY; + + // setup 2 triangles to form a quad + for(let i=6, offset = glBatchCount++ * gl_VERTICES_PER_QUAD * gl_INDICIES_PER_VERT; i--;) + { + const a = i-4&&i>1, b = i-5&&i-2&&i-1; + glPositionData[offset++] = x + (a?-cx:cx) + (b?sy:-sy); + glPositionData[offset++] = y + (b?cy:-cy) + (a?sx:-sx); + glPositionData[offset++] = a ? uv0X : uv1X; + glPositionData[offset++] = b ? uv0Y : uv1Y; + glColorData[offset++] = rgba; + glColorData[offset++] = rgbaAdditive; + } +} + +/////////////////////////////////////////////////////////////////////////////// +// post processing - can be enabled to pass other canvases through a final shader + +let glPostShader, glPostArrayBuffer, glPostTexture, glPostIncludeOverlay; + +/** Set up a post processing shader + * @param {String} shaderCode + * @param {Boolean} includeOverlay + * @memberof WebGL */ +function glInitPostProcess(shaderCode, includeOverlay) +{ + ASSERT(!glPostShader); // can only have 1 post effects shader + + if (!shaderCode) // default shader + shaderCode = 'void mainImage(out vec4 c,vec2 p){c=texture2D(iChannel0,p/iResolution.xy);}'; + + // create the shader + glPostShader = glCreateProgram( + 'precision highp float;'+ // use highp for better accuracy + 'attribute vec2 p;'+ // position + 'void main(){'+ // shader entry point + 'gl_Position=vec4(p,1,1);'+ // set position + '}' // end of shader + , + 'precision highp float;'+ // use highp for better accuracy + 'uniform sampler2D iChannel0;'+ // input texture + 'uniform vec3 iResolution;'+ // size of output texture + 'uniform float iTime;'+ // time passed + '\n' + shaderCode + '\n'+ // insert custom shader code + 'void main(){'+ // shader entry point + 'mainImage(gl_FragColor,gl_FragCoord.xy);'+ // call post process function + 'gl_FragColor.a=1.;'+ // always use full alpha + '}' // end of shader + ); + + // create buffer and texture + glPostArrayBuffer = glContext.createBuffer(); + glPostTexture = glCreateTexture(); + glPostIncludeOverlay = includeOverlay; + + // hide the original 2d canvas + mainCanvas.style.visibility = 'hidden'; +} + +// Render the post processing shader, called automatically by the engine +function glRenderPostProcess() +{ + if (!glPostShader) + return; + + // prepare to render post process shader + if (glEnable) + { + glFlush(); // clear out the buffer + mainContext.drawImage(glCanvas, 0, 0); // copy to the main canvas + } + else // set viewport + glContext.viewport(0, 0, glCanvas.width = mainCanvas.width, glCanvas.height = mainCanvas.height); + + if (glPostIncludeOverlay) + { + // copy overlay canvas so it will be included in post processing + mainContext.drawImage(overlayCanvas, 0, 0); + + // clear overlay canvas + overlayCanvas.width = mainCanvas.width; + } + + // setup shader program to draw one triangle + glContext.useProgram(glPostShader); + glContext.disable(gl_BLEND); + glContext.bindBuffer(gl_ARRAY_BUFFER, glPostArrayBuffer); + glContext.bufferData(gl_ARRAY_BUFFER, new Float32Array([-3,1,1,-3,1,1]), gl_STATIC_DRAW); + glContext.pixelStorei(gl_UNPACK_FLIP_Y_WEBGL, true); + + // set textures, pass in the 2d canvas and gl canvas in separate texture channels + glContext.activeTexture(gl_TEXTURE0); + glContext.bindTexture(gl_TEXTURE_2D, glPostTexture); + glContext.texImage2D(gl_TEXTURE_2D, 0, gl_RGBA, gl_RGBA, gl_UNSIGNED_BYTE, mainCanvas); + + // set vertex position attribute + const vertexByteStride = 8; + const pLocation = glContext.getAttribLocation(glPostShader, 'p'); + glContext.enableVertexAttribArray(pLocation); + glContext.vertexAttribPointer(pLocation, 2, gl_FLOAT, 0, vertexByteStride, 0); + + // set uniforms and draw + const uniformLocation = (name)=>glContext.getUniformLocation(glPostShader, name); + glContext.uniform1i(uniformLocation('iChannel0'), 0); + glContext.uniform1f(uniformLocation('iTime'), time); + glContext.uniform3f(uniformLocation('iResolution'), mainCanvas.width, mainCanvas.height, 1); + glContext.drawArrays(gl_TRIANGLES, 0, 3); +} + +/////////////////////////////////////////////////////////////////////////////// +// store gl constants as integers so their name doesn't use space in minifed +const +gl_ONE = 1, +gl_TRIANGLES = 4, +gl_SRC_ALPHA = 770, +gl_ONE_MINUS_SRC_ALPHA = 771, +gl_BLEND = 3042, +gl_TEXTURE_2D = 3553, +gl_UNSIGNED_BYTE = 5121, +gl_BYTE = 5120, +gl_FLOAT = 5126, +gl_RGBA = 6408, +gl_NEAREST = 9728, +gl_LINEAR = 9729, +gl_TEXTURE_MAG_FILTER = 10240, +gl_TEXTURE_MIN_FILTER = 10241, +gl_TEXTURE_WRAP_S = 10242, +gl_TEXTURE_WRAP_T = 10243, +gl_COLOR_BUFFER_BIT = 16384, +gl_CLAMP_TO_EDGE = 33071, +gl_TEXTURE0 = 33984, +gl_TEXTURE1 = 33985, +gl_ARRAY_BUFFER = 34962, +gl_STATIC_DRAW = 35044, +gl_DYNAMIC_DRAW = 35048, +gl_FRAGMENT_SHADER = 35632, +gl_VERTEX_SHADER = 35633, +gl_COMPILE_STATUS = 35713, +gl_LINK_STATUS = 35714, +gl_UNPACK_FLIP_Y_WEBGL = 37440, + +// constants for batch rendering +gl_VERTICES_PER_QUAD = 6, +gl_INDICIES_PER_VERT = 6, +gl_MAX_BATCH = 1<<16, +gl_VERTEX_BYTE_STRIDE = (4 * 2) * 2 + (4) * 2, // vec2 * 2 + (char * 4) * 2 +gl_VERTEX_BUFFER_SIZE = gl_MAX_BATCH * gl_VERTICES_PER_QUAD * gl_VERTEX_BYTE_STRIDE; diff --git a/LittleJS/package.json b/LittleJS/package.json new file mode 100644 index 0000000..5ec874b --- /dev/null +++ b/LittleJS/package.json @@ -0,0 +1,33 @@ +{ + "name": "LittleJS Breakout Demo", + "version": "1.6.0", + "description": "A simple breakout demo using LittleJS Engine", + "repository": { + "type": "git", + "url": "git+https://github.com/KilledByAPixel/LittleJS.git" + }, + "keywords": [ + "HTML5", + "JavaScript", + "game", + "engine", + "library", + "JS13K", + "webgl" + ], + "author": "Frank Force", + "license": "MIT", + "bugs": { + "url": "https://github.com/KilledByAPixel/LittleJS/issues" + }, + "homepage": "https://github.com/KilledByAPixel/LittleJS", + "scripts": { + "build": "build.bat" + }, + "devDependencies": { + "google-closure-compiler": "~20230502.0.0", + "uglify-js": "~3.17.4", + "roadroller": "~2.1.0", + "ect-bin": "~1.4.1" + } +}