From 57cb73128f112a7898bc87d34492c1fb94e106c3 Mon Sep 17 00:00:00 2001 From: Renan LE CARO Date: Thu, 13 Mar 2025 08:53:02 +0100 Subject: [PATCH] Build and deploy of version 29030872 --- app/build.gradle.kts | 4 +- app/src/main/assets/index.html | 56 +- dist/index.html | 3854 +----------------------- src/backgrounds.json | 2 +- src/game.ts | 5028 ++++++++++++++++---------------- src/levels.json | 2 +- src/loadGameData.ts | 22 +- src/sounds.ts | 342 +-- src/types.d.ts | 2 +- src/version.json | 2 +- 10 files changed, 2827 insertions(+), 6487 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f759b1..9759143 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "me.lecaro.breakout" minSdk = 21 targetSdk = 34 - versionCode = 29028296 - versionName = "29028296" + versionCode = 29030872 + versionName = "29030872" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/assets/index.html b/app/src/main/assets/index.html index a870186..75af1b1 100644 --- a/app/src/main/assets/index.html +++ b/app/src/main/assets/index.html @@ -1,4 +1,4 @@ -Breakout 71 \ No newline at end of file +

`,actions:e,allowClose:!0});r&&(!H||await el({title:"Restart run to try this item?",text:"You're about to start a new run with the selected unlocked item, is that really what you wanted ? ",actions:[{value:!0,text:"Restart game to test item"},{value:!1,text:"Cancel"}]}))&&(_A=r,_q())}function eh(_,e){return Math.sqrt(Math.pow(_.x-e.x,2)+Math.pow(_.y-e.y,2))}function eb(){return`hsl(${2*Math.round(e_/4)%360},100%,70%)`}function ey(_,e,t,r){let o=eh(_,e),l=__/2;if(o>l)return;let s=(_.x-e.x)/o,a=(_.y-e.y)/o,i=-t*(l-o)/(1.2*l)/3*Math.min(500,e_)/500;r&&void 0!==e.vx&&void 0!==e.vy&&(e.vx+=s*i,e.vy+=a*i),_.vx-=s*i,_.vy-=a*i,_x.push({type:"particle",duration:100,time:e_,size:E/2,color:eb(),ethereal:!0,x:_.x,y:_.y,vx:-(10*s)+_.vx+(Math.random()-.5)*2,vy:-(10*a)+_.vy+(Math.random()-.5)*2}),r&&void 0!==e.vx&&void 0!==e.vy&&_x.push({type:"particle",duration:100,time:e_,size:E/2,color:eb(),ethereal:!0,x:e.x,y:e.y,vx:10*s+e.vx+(Math.random()-.5)*2,vy:10*a+e.vy+(Math.random()-.5)*2})}function eu(){ea("record")&&r?.state==="recording"&&r?.pause()}function em(){ea("record")&&r&&(r?.stop(),r=null)}function eg(){try{if(null!==document.fullscreenElement)document.exitFullscreen?document.exitFullscreen().then():document.webkitCancelFullScreen&&document.webkitCancelFullScreen();else{let _=document.documentElement;_.requestFullscreen?_.requestFullscreen().then():_.webkitRequestFullscreen&&_.webkitRequestFullscreen()}}catch(_){console.warn(_)}}et.addEventListener("click",_=>{_.preventDefault(),ei().then()}),document.getElementById("menu")?.addEventListener("click",_=>{_.preventDefault(),en().then()});const ed={ArrowLeft:0,ArrowRight:0,Shift:0};function ep(_,e){ed[_]=e,_V=(ed.ArrowRight-ed.ArrowLeft)*(1+2*ed.Shift)*__/50}document.addEventListener("keydown",_=>{"f"!==_.key.toLowerCase()||_.ctrlKey||_.metaKey?_.key in ed&&ep(_.key,1):eg()," "===_.key&&!er&&(j?J(!0):U(),_.preventDefault())}),document.addEventListener("keyup",_=>{let e=document.querySelector("button:focus");if(_.key in ed)ep(_.key,0);else if("ArrowDown"===_.key&&e?.nextElementSibling?.tagName==="BUTTON")e?.nextElementSibling?.focus();else if("ArrowUp"===_.key&&e?.previousElementSibling?.tagName==="BUTTON")e?.previousElementSibling?.focus();else if("Escape"===_.key&&eo)eo();else if("Escape"===_.key&&j)J(!0);else if("m"!==_.key.toLowerCase()||er){if("s"!==_.key.toLowerCase()||er)return;ei().then()}else en().then();_.preventDefault()}),_n(),_q(),function _(){_c();let e=performance.now();if(q=__/12*(3-V.smaller_puck+V.bigger_puck),_V&&_I(Z+_V),j){e_+=e-_F,_J.runTime+=e-_F,_J.max_combo=Math.max(_J.max_combo,F);let _=Math.min(4,(e-_F)/(1e3/60));_*=+!!j,_M=_M.filter(_=>!_.destroyed),_W=_W.filter(_=>!_.destroyed);let t=_w.filter(_=>_&&"black"!==_).length;if(e_>_Z+1e3&&V.hot_start&&(_Z=e_,function(_,e,t){let r=Math.max(0,F-(F=Math.max(D(),F-_)));r&&(x.comboDecrease(),void 0!==e&&void 0!==t&&_x.push({type:"text",text:"-"+r,time:e_,color:"red",x:e,y:t,duration:300,size:20}))}(V.hot_start,Z,_t-40)),t<=V.skip_last&&!ee&&(_w.forEach((_,e)=>{_&&_0(e,_W[0],!0)}),ee++),t||_M.length){if(j||e_){let e=!1,t=Math.round(E/2);if(_M.forEach(r=>{if(r.destroyed)return;if(V.coin_magnet){let e=_*(Z-r.x)/(100+Math.pow(r.y-_t,2)+Math.pow(r.x-Z,2))*V.coin_magnet*100;r.vx+=e,r.sa-=e/10}let o=1-(.03*V.viscosity+.005)*_;r.vy*=o,r.vx*=o,r.vx>7*I&&(r.vx=7*I),r.vx<-7*I&&(r.vx=-7*I),r.vy>7*I&&(r.vy=7*I),r.vy<-7*I&&(r.vy=-7*I),r.a+=r.sa,r.vy+=_*r.weight*.8;let l=Math.abs(r.sx)+Math.abs(r.sx),s=_j(r,t,_);if(r.y>_t-t-20&&r.y<_t+20+r.vy&&Math.abs(r.x-Z)_d&&!_H&&(_d=_m,localStorage.setItem("breakout-3-hs",_m.toString())),ea("basic")||_x.push({type:"particle",duration:100+50*Math.random(),time:e_,size:E/2,color:r.color,x:r.previousX,y:r.previousY,vx:(_a-r.x)/100,vy:-r.y/100,ethereal:!0}),Date.now()-_p>16&&(_p=Date.now(),x.coinCatch(r.x)),_J.score+=r.points;else r.y>_i+t&&(r.destroyed=!0,V.compound_interest&&N(r.x,r.y));let a=function(_){let e=E/2,{x:t,y:r,previousX:o,previousY:l}=_,s=_Y(o,r,e),a=_Y(t,l,e),i=void 0===s&&void 0===a&&_Y(t,r,e)||void 0;if(void 0!==s||void 0!==i){_.y=_.previousY,_.vy*=-1;let o=_w[_D(t-e,r+e)],l=_w[_D(t+e,r+e)];o&&!l&&(_.vx+=1,_.sa-=1),!o&&l&&(_.vx-=1,_.sa+=1)}return(void 0!==a||void 0!==i)&&(_.x=_.previousX,_.vx*=-1),s??a??i}(r);V.metamorphosis&&void 0!==a&&_w[a]&&r.color!==_w[a]&&"black"!==_w[a]&&!r.coloredABrick&&(_w[a]=r.color,r.coloredABrick=!0),(void 0!==a||s)&&(r.vx*=.8,r.vy*=.8,r.sa*=.9,l>20&&!e&&(e=!0,x.coinBounce(r.x,.2)),3>Math.abs(r.vy)&&(r.vy=0))}),_W.forEach(e=>(function(_,e){_.previousVX=_.vx,_.previousVY=_.vy;let t=1+V.telekinesis+V.ball_repulse_ball+V.puck_repulse_ball+V.ball_attract_ball;if(_X(_)&&(t+=3,_.vx+=(Z-_.x)/1e3*e*V.telekinesis),_.vx*_.vx+_.vy*_.vy0?1:-1)*.02/t),V.ball_repulse_ball)for(let e of _W)e.x>=_.x||ey(_,e,V.ball_repulse_ball,!0);if(V.ball_attract_ball)for(let e of _W)e.x>=_.x||function(_,e,t){let r=eh(_,e),o=.5*__;if(r1&&!ea("basic"))for(let e=0;e<_.hitItem?.length-1&&e.5,l=Math.random()>.5?1:-1,s=Math.random()>.5?1:-1;_x.push({type:"particle",duration:250,ethereal:!0,time:e_,size:E/2,color:r,x:_h(t)+l*_r/2,y:_b(t)+s*_r/2,vx:o?0:-l*I,vy:o?-s*I:0})}let r=_j(_,10,e);r&&(V.left_is_lava&&r%2&&_.xK+__/2&&N(_.x,_.y),V.top_is_lava&&r>=2&&N(_.x,_.y+20),x.wallBeep(_.x),_.bouncesList?.push({x:_.previousX,y:_.previousY}));let o=_t-20-10,l=Math.abs(_.x-Z)<10+q/2;if(_.y>o&&_.vy>0&&(l||V.extra_life&&_.y>o+10)){if(l){let e=Math.sqrt(_.vx*_.vx+_.vy*_.vy),t=Math.atan2(-q/2,_.x-Z);_.vx=e*Math.cos(t),_.vy=e*Math.sin(t),x.wallBeep(_.x)}else if(_.vy*=-1,V.extra_life=Math.max(0,V.extra_life-1),x.lifeLost(_.x),!ea("basic"))for(let e=0;e<10;e++)_x.push({type:"particle",ethereal:!1,color:"red",destroyed:!1,duration:150,size:E/2,time:e_,x:_.x,y:_.y,vx:Math.random()*I*3,vy:3*I});V.streak_shots&&N(_.x,_.y),V.respawn&&_.hitItem.slice(0,-1).slice(0,V.respawn).forEach(({index:_,color:e})=>{_w[_]||"black"===e||(_w[_]=e)}),_.hitItem=[],_.hitSinceBounce||(_J.misses++,_B++,N(_.x,_.y),_x.push({type:"text",text:"miss",duration:500,time:e_,size:30,color:"red",x:Z,y:_t-40})),_J.puck_bounces++,_.hitSinceBounce=0,_.sapperUses=0,_.piercedSinceBounce=0,_.bouncesList=[{x:_.previousX,y:_.previousY}]}_.y>_t+10&&j&&(_.destroyed=!0,_J.balls_lost++,_W.find(_=>!_.destroyed)||_Q("Game Over","You dropped the ball after catching "+_m+" coins. "));let{x:s,y:a,previousX:i,previousY:n}=_,c=_Y(i,a,10),h=_Y(s,n,10),b=void 0===c&&void 0===h&&_Y(s,a,10)||void 0,y=c??h??b,u=y&&"black"!==_w[y]&&V.sturdy_bricks&&V.sturdy_bricks>5*Math.random(),m=!1;if(u||void 0===y);else(V.pierce_color&&(void 0===c||_w[c]===_v)&&(void 0===h||_w[h]===_v)&&(void 0===b||_w[b]===_v)?0:1)?_.piercedSinceBounce<3*V.pierce&&(m=!0,_.piercedSinceBounce++):m=!0;if(void 0===c&&void 0===b||m||(_.y=_.previousY,_.vy*=-1),void 0===h&&void 0===b||m||(_.x=_.previousX,_.vx*=-1),u){x.wallBeep(s);return}if(void 0!==y){let e=_w[y];_0(y,_,!1),_.sapperUses1&&(_x.push({type:"particle",duration:100*_.sparks,time:e_,size:E/2,color:_v,x:_.x,y:_.y,vx:(Math.random()-.5)*I,vy:(Math.random()-.5)*I,ethereal:!1}),_.sparks=0))})(e,_)),V.wind){let _=(Z-(K+__/2))/__*2*V.wind;for(let e=0;e.5&&_x.push({type:"particle",duration:150,ethereal:!0,time:e_,size:E/2,color:eb(),x:Q+Math.random()*_e,y:Math.random()*_t,vx:8*_,vy:0})}_x.forEach(e=>{"particle"===e.type&&(e.x+=e.vx*_,e.y+=e.vy*_,!e.ethereal&&(e.vy+=.5,_N(_D(e.x,e.y))&&(e.destroyed=!0)))})}}else H+1<_1()?_L(H+1):_Q("Run finished with "+_m+" points","You cleared all levels for this run.");if(F>D()){let _=!ea("basic")&&(F-D())*Math.random()>5&&j&&{type:"particle",duration:100*(Math.random()+1),time:e_,size:E/2,color:"red",ethereal:!0};if(V.top_is_lava&&_&&_x.push({..._,x:Q+Math.random()*_e,y:0,vx:(Math.random()-.5)*10,vy:5}),V.left_is_lava&&_&&_x.push({..._,x:Q,y:Math.random()*_t,vx:5,vy:(Math.random()-.5)*10}),V.right_is_lava&&_&&_x.push({..._,x:Q+_e,y:Math.random()*_t,vx:-5,vy:(Math.random()-.5)*10}),V.compound_interest){let e=Z,t=0;do e=Q+_e*Math.random(),t++;while(Math.abs(e-Z){let{x:e,y:t,time:r,color:o,size:l,type:s,duration:a}=_;P.globalAlpha=Math.min(1,2-(e_-r)/a*2),"particle"===s&&_3(P,o,l,e,t)});else if(P.globalCompositeOperation="source-over",P.globalAlpha=1,P.fillStyle="#000",P.fillRect(0,0,e,t),P.globalCompositeOperation="screen",P.globalAlpha=.6,_M.forEach(_=>{_.destroyed||_8(P,_.color,2*E,_.x,_.y)}),_W.forEach(_=>{_8(P,_v,40,_.x,_.y)}),P.globalAlpha=.5,_w.forEach((_,e)=>{if(!_)return;let t=_h(e),r=_b(e);_8(P,"black"==_?"#666":_,_r,t,r)}),P.globalAlpha=1,_x.forEach(_=>{let{x:e,y:t,time:r,color:o,size:l,type:s,duration:a}=_;P.globalAlpha=Math.min(1,2-(e_-r)/a*2),"ball"===s&&_8(P,o,l,e,t),"particle"===s&&_8(P,o,3*l,e,t)}),P.globalAlpha=.2,P.globalCompositeOperation="multiply",P.fillStyle="black",P.fillRect(0,0,e,t),P.globalAlpha=.8,P.globalCompositeOperation="multiply",_.svg&&_l.width&&_l.complete){if(_s.title!==_.name){_s.title=_.name,_s.width=_a,_s.height=_i;let r=_s.getContext("2d");r.fillStyle=_.color||"#000",r.fillRect(0,0,_a,_i);let o=P.createPattern(_l,"repeat");o&&(r.fillStyle=o,r.fillRect(0,0,e,t))}P.drawImage(_s,0,0)}else P.fillStyle="#000",P.fillRect(0,0,e,t);P.globalAlpha=1,P.globalCompositeOperation="source-over";let r=Date.now()-_g+5,i=r<200;if(i){let _=(V.bigger_explosions+1)*50/r;P.translate(Math.sin(Date.now())*_,Math.sin(Date.now()+36)*_)}if(P.globalAlpha=1,_M.forEach(e=>{e.destroyed||(P.globalCompositeOperation="gold"===e.color||_.color?"source-over":"screen",_7(P,e.color,E,e.x,e.y,_.color||"black",e.a))}),ea("basic")||(P.globalCompositeOperation="source-over",P.globalAlpha=Math.min(.8,_M.length/20),_W.forEach(e=>{_3(P,_.color||"#000",120,e.x,e.y)})),P.globalCompositeOperation="source-over",function(){P.globalAlpha=1;let _=F>D()&&V.picky_eater,e=__+"_"+_w.join("_")+$.complete+"_"+_+"_"+_v+"_"+V.pierce_color;if(e!==_4){_4=e,_2.width=__,_2.height=__+1;let t=_2.getContext("2d");t.clearRect(0,0,__,__),t.resetTransform(),t.translate(-K,0),_w.forEach((e,r)=>{let o=_h(r),l=_b(r);if(!e)return;let s=_v!==e&&"black"!==e&&_&&"red"||e;(function(_,e,t,r,o){let l=Math.ceil(r-_r/2),s=Math.ceil(o-_r/2),a=Math.ceil(r+_r/2)-1-l,i=Math.ceil(o+_r/2)-1-s,n="brick"+e+"_"+t+"_"+a+"_"+i;if(!_5[n]){var c,h,b,y,u,m;let _=document.createElement("canvas");_.width=a,_.height=i;let r=_.getContext("2d");r.fillStyle=e,r.strokeStyle=t,r.lineJoin="round",r.lineWidth=2,c=r,h=1,b=1,y=a-2,u=i-2,m=2,c.beginPath(),c.moveTo(3,1),c.lineTo(h+y-m,b),c.quadraticCurveTo(h+y,b,h+y,b+m),c.lineTo(h+y,b+u-m),c.quadraticCurveTo(h+y,b+u,h+y-m,b+u),c.lineTo(h+m,b+u),c.quadraticCurveTo(h,b+u,h,b+u-m),c.lineTo(h,b+m),c.quadraticCurveTo(h,b,h+m,b),c.closePath(),r.fill(),r.stroke(),_5[n]=_}_.drawImage(_5[n],l,s,a,i)})(t,e,s,o,l),"black"===e&&(t.globalCompositeOperation="source-over",function(_,e,t,r,o){let l="svg"+e+"_"+t+"_"+e.complete;if(!_5[l]){let _=document.createElement("canvas");_.width=t,_.height=t;let r=_.getContext("2d"),o=t/Math.max(e.width,e.height),s=e.width*o,a=e.height*o;r.drawImage(e,(t-s)/2,(t-a)/2,s,a),_5[l]=_}_.drawImage(_5[l],Math.round(r-t/2),Math.round(o-t/2))}(t,$,_r,o,l))})}P.drawImage(_2,K,0)}(),P.globalCompositeOperation="screen",(_x=_x.filter(_=>e_-_.time<_.duration&&!_.destroyed)).forEach(_=>{let{x:e,y:t,time:r,color:o,size:l,type:s,duration:a}=_,i=e_-r;P.globalAlpha=Math.max(0,Math.min(1,2-i/a*2)),"text"===s?(P.globalCompositeOperation="source-over",_9(P,_.text,o,l,e,t-i/10)):"particle"===s&&(P.globalCompositeOperation="screen",_3(P,o,l,e,t),_8(P,o,l,e,t))}),V.extra_life){P.globalAlpha=1,P.globalCompositeOperation="source-over",P.fillStyle=T;for(let _=0;_{_3(P,_v,20,_.x,_.y,T),_X(_)&&(P.strokeStyle=T,P.beginPath(),P.bezierCurveTo(Z,_t,Z,_.y,_.x,_.y),P.stroke())}),P.globalAlpha=1,P.globalCompositeOperation="source-over",V.streak_shots&&F>D()&&_6(P,"red",q,20,-2),_6(P,T,q,20),F>1){P.globalCompositeOperation="source-over";let _="x "+F,e=20*_.length/1.8+2*E,t=Z-e/2;eD();P.globalCompositeOperation="source-over",Q?(P.fillStyle=n&&V.left_is_lava?"red":T,P.fillRect(K-1,0,1,t),P.fillStyle=n&&V.right_is_lava?"red":T,P.fillRect(e-K+1,0,1,t)):(P.fillStyle="red",n&&V.left_is_lava&&P.fillRect(0,0,1,t),n&&V.right_is_lava&&P.fillRect(e-1,0,1,t)),V.top_is_lava&&F>D()&&(P.fillStyle="red",P.fillRect(Q,0,_e,1));let c=V.compound_interest&&F>D();P.fillStyle=c?"red":T,ea("mobile-mode")?(P.fillRect(Q,_t,_e,1),j||_9(P,"Press and hold here to play",T,20,_a/2,_t+(_i-_t)/2)):c&&P.fillRect(Q,_t-1,_e,1),i&&P.resetTransform(),ea("record")&&j&&o&&(a&&(a.drawImage(C,Q,0,_e,_t,0,0,s.width,s.height),a.fillStyle="#FFF",a.textBaseline="top",a.font="12px monospace",a.textAlign="right",a.fillText(_m.toString(),s.width-12,12),a.textAlign="left",a.fillText("Level "+(H+1)+"/"+_1(),12,12)),l?.requestFrame?l?.requestFrame():o?.requestFrame&&o.requestFrame())})(),requestAnimationFrame(_),_F=e}(); \ No newline at end of file diff --git a/dist/index.html b/dist/index.html index c5ad756..75af1b1 100644 --- a/dist/index.html +++ b/dist/index.html @@ -1,2729 +1,102 @@ - - - - - - - Breakout 71 - - - - - - - - - - - +

`,actions:e,allowClose:!0});r&&(!H||await el({title:"Restart run to try this item?",text:"You're about to start a new run with the selected unlocked item, is that really what you wanted ? ",actions:[{value:!0,text:"Restart game to test item"},{value:!1,text:"Cancel"}]}))&&(_A=r,_q())}function eh(_,e){return Math.sqrt(Math.pow(_.x-e.x,2)+Math.pow(_.y-e.y,2))}function eb(){return`hsl(${2*Math.round(e_/4)%360},100%,70%)`}function ey(_,e,t,r){let o=eh(_,e),l=__/2;if(o>l)return;let s=(_.x-e.x)/o,a=(_.y-e.y)/o,i=-t*(l-o)/(1.2*l)/3*Math.min(500,e_)/500;r&&void 0!==e.vx&&void 0!==e.vy&&(e.vx+=s*i,e.vy+=a*i),_.vx-=s*i,_.vy-=a*i,_x.push({type:"particle",duration:100,time:e_,size:E/2,color:eb(),ethereal:!0,x:_.x,y:_.y,vx:-(10*s)+_.vx+(Math.random()-.5)*2,vy:-(10*a)+_.vy+(Math.random()-.5)*2}),r&&void 0!==e.vx&&void 0!==e.vy&&_x.push({type:"particle",duration:100,time:e_,size:E/2,color:eb(),ethereal:!0,x:e.x,y:e.y,vx:10*s+e.vx+(Math.random()-.5)*2,vy:10*a+e.vy+(Math.random()-.5)*2})}function eu(){ea("record")&&r?.state==="recording"&&r?.pause()}function em(){ea("record")&&r&&(r?.stop(),r=null)}function eg(){try{if(null!==document.fullscreenElement)document.exitFullscreen?document.exitFullscreen().then():document.webkitCancelFullScreen&&document.webkitCancelFullScreen();else{let _=document.documentElement;_.requestFullscreen?_.requestFullscreen().then():_.webkitRequestFullscreen&&_.webkitRequestFullscreen()}}catch(_){console.warn(_)}}et.addEventListener("click",_=>{_.preventDefault(),ei().then()}),document.getElementById("menu")?.addEventListener("click",_=>{_.preventDefault(),en().then()});const ed={ArrowLeft:0,ArrowRight:0,Shift:0};function ep(_,e){ed[_]=e,_V=(ed.ArrowRight-ed.ArrowLeft)*(1+2*ed.Shift)*__/50}document.addEventListener("keydown",_=>{"f"!==_.key.toLowerCase()||_.ctrlKey||_.metaKey?_.key in ed&&ep(_.key,1):eg()," "===_.key&&!er&&(j?J(!0):U(),_.preventDefault())}),document.addEventListener("keyup",_=>{let e=document.querySelector("button:focus");if(_.key in ed)ep(_.key,0);else if("ArrowDown"===_.key&&e?.nextElementSibling?.tagName==="BUTTON")e?.nextElementSibling?.focus();else if("ArrowUp"===_.key&&e?.previousElementSibling?.tagName==="BUTTON")e?.previousElementSibling?.focus();else if("Escape"===_.key&&eo)eo();else if("Escape"===_.key&&j)J(!0);else if("m"!==_.key.toLowerCase()||er){if("s"!==_.key.toLowerCase()||er)return;ei().then()}else en().then();_.preventDefault()}),_n(),_q(),function _(){_c();let e=performance.now();if(q=__/12*(3-V.smaller_puck+V.bigger_puck),_V&&_I(Z+_V),j){e_+=e-_F,_J.runTime+=e-_F,_J.max_combo=Math.max(_J.max_combo,F);let _=Math.min(4,(e-_F)/(1e3/60));_*=+!!j,_M=_M.filter(_=>!_.destroyed),_W=_W.filter(_=>!_.destroyed);let t=_w.filter(_=>_&&"black"!==_).length;if(e_>_Z+1e3&&V.hot_start&&(_Z=e_,function(_,e,t){let r=Math.max(0,F-(F=Math.max(D(),F-_)));r&&(x.comboDecrease(),void 0!==e&&void 0!==t&&_x.push({type:"text",text:"-"+r,time:e_,color:"red",x:e,y:t,duration:300,size:20}))}(V.hot_start,Z,_t-40)),t<=V.skip_last&&!ee&&(_w.forEach((_,e)=>{_&&_0(e,_W[0],!0)}),ee++),t||_M.length){if(j||e_){let e=!1,t=Math.round(E/2);if(_M.forEach(r=>{if(r.destroyed)return;if(V.coin_magnet){let e=_*(Z-r.x)/(100+Math.pow(r.y-_t,2)+Math.pow(r.x-Z,2))*V.coin_magnet*100;r.vx+=e,r.sa-=e/10}let o=1-(.03*V.viscosity+.005)*_;r.vy*=o,r.vx*=o,r.vx>7*I&&(r.vx=7*I),r.vx<-7*I&&(r.vx=-7*I),r.vy>7*I&&(r.vy=7*I),r.vy<-7*I&&(r.vy=-7*I),r.a+=r.sa,r.vy+=_*r.weight*.8;let l=Math.abs(r.sx)+Math.abs(r.sx),s=_j(r,t,_);if(r.y>_t-t-20&&r.y<_t+20+r.vy&&Math.abs(r.x-Z)_d&&!_H&&(_d=_m,localStorage.setItem("breakout-3-hs",_m.toString())),ea("basic")||_x.push({type:"particle",duration:100+50*Math.random(),time:e_,size:E/2,color:r.color,x:r.previousX,y:r.previousY,vx:(_a-r.x)/100,vy:-r.y/100,ethereal:!0}),Date.now()-_p>16&&(_p=Date.now(),x.coinCatch(r.x)),_J.score+=r.points;else r.y>_i+t&&(r.destroyed=!0,V.compound_interest&&N(r.x,r.y));let a=function(_){let e=E/2,{x:t,y:r,previousX:o,previousY:l}=_,s=_Y(o,r,e),a=_Y(t,l,e),i=void 0===s&&void 0===a&&_Y(t,r,e)||void 0;if(void 0!==s||void 0!==i){_.y=_.previousY,_.vy*=-1;let o=_w[_D(t-e,r+e)],l=_w[_D(t+e,r+e)];o&&!l&&(_.vx+=1,_.sa-=1),!o&&l&&(_.vx-=1,_.sa+=1)}return(void 0!==a||void 0!==i)&&(_.x=_.previousX,_.vx*=-1),s??a??i}(r);V.metamorphosis&&void 0!==a&&_w[a]&&r.color!==_w[a]&&"black"!==_w[a]&&!r.coloredABrick&&(_w[a]=r.color,r.coloredABrick=!0),(void 0!==a||s)&&(r.vx*=.8,r.vy*=.8,r.sa*=.9,l>20&&!e&&(e=!0,x.coinBounce(r.x,.2)),3>Math.abs(r.vy)&&(r.vy=0))}),_W.forEach(e=>(function(_,e){_.previousVX=_.vx,_.previousVY=_.vy;let t=1+V.telekinesis+V.ball_repulse_ball+V.puck_repulse_ball+V.ball_attract_ball;if(_X(_)&&(t+=3,_.vx+=(Z-_.x)/1e3*e*V.telekinesis),_.vx*_.vx+_.vy*_.vy0?1:-1)*.02/t),V.ball_repulse_ball)for(let e of _W)e.x>=_.x||ey(_,e,V.ball_repulse_ball,!0);if(V.ball_attract_ball)for(let e of _W)e.x>=_.x||function(_,e,t){let r=eh(_,e),o=.5*__;if(r1&&!ea("basic"))for(let e=0;e<_.hitItem?.length-1&&e.5,l=Math.random()>.5?1:-1,s=Math.random()>.5?1:-1;_x.push({type:"particle",duration:250,ethereal:!0,time:e_,size:E/2,color:r,x:_h(t)+l*_r/2,y:_b(t)+s*_r/2,vx:o?0:-l*I,vy:o?-s*I:0})}let r=_j(_,10,e);r&&(V.left_is_lava&&r%2&&_.xK+__/2&&N(_.x,_.y),V.top_is_lava&&r>=2&&N(_.x,_.y+20),x.wallBeep(_.x),_.bouncesList?.push({x:_.previousX,y:_.previousY}));let o=_t-20-10,l=Math.abs(_.x-Z)<10+q/2;if(_.y>o&&_.vy>0&&(l||V.extra_life&&_.y>o+10)){if(l){let e=Math.sqrt(_.vx*_.vx+_.vy*_.vy),t=Math.atan2(-q/2,_.x-Z);_.vx=e*Math.cos(t),_.vy=e*Math.sin(t),x.wallBeep(_.x)}else if(_.vy*=-1,V.extra_life=Math.max(0,V.extra_life-1),x.lifeLost(_.x),!ea("basic"))for(let e=0;e<10;e++)_x.push({type:"particle",ethereal:!1,color:"red",destroyed:!1,duration:150,size:E/2,time:e_,x:_.x,y:_.y,vx:Math.random()*I*3,vy:3*I});V.streak_shots&&N(_.x,_.y),V.respawn&&_.hitItem.slice(0,-1).slice(0,V.respawn).forEach(({index:_,color:e})=>{_w[_]||"black"===e||(_w[_]=e)}),_.hitItem=[],_.hitSinceBounce||(_J.misses++,_B++,N(_.x,_.y),_x.push({type:"text",text:"miss",duration:500,time:e_,size:30,color:"red",x:Z,y:_t-40})),_J.puck_bounces++,_.hitSinceBounce=0,_.sapperUses=0,_.piercedSinceBounce=0,_.bouncesList=[{x:_.previousX,y:_.previousY}]}_.y>_t+10&&j&&(_.destroyed=!0,_J.balls_lost++,_W.find(_=>!_.destroyed)||_Q("Game Over","You dropped the ball after catching "+_m+" coins. "));let{x:s,y:a,previousX:i,previousY:n}=_,c=_Y(i,a,10),h=_Y(s,n,10),b=void 0===c&&void 0===h&&_Y(s,a,10)||void 0,y=c??h??b,u=y&&"black"!==_w[y]&&V.sturdy_bricks&&V.sturdy_bricks>5*Math.random(),m=!1;if(u||void 0===y);else(V.pierce_color&&(void 0===c||_w[c]===_v)&&(void 0===h||_w[h]===_v)&&(void 0===b||_w[b]===_v)?0:1)?_.piercedSinceBounce<3*V.pierce&&(m=!0,_.piercedSinceBounce++):m=!0;if(void 0===c&&void 0===b||m||(_.y=_.previousY,_.vy*=-1),void 0===h&&void 0===b||m||(_.x=_.previousX,_.vx*=-1),u){x.wallBeep(s);return}if(void 0!==y){let e=_w[y];_0(y,_,!1),_.sapperUses1&&(_x.push({type:"particle",duration:100*_.sparks,time:e_,size:E/2,color:_v,x:_.x,y:_.y,vx:(Math.random()-.5)*I,vy:(Math.random()-.5)*I,ethereal:!1}),_.sparks=0))})(e,_)),V.wind){let _=(Z-(K+__/2))/__*2*V.wind;for(let e=0;e.5&&_x.push({type:"particle",duration:150,ethereal:!0,time:e_,size:E/2,color:eb(),x:Q+Math.random()*_e,y:Math.random()*_t,vx:8*_,vy:0})}_x.forEach(e=>{"particle"===e.type&&(e.x+=e.vx*_,e.y+=e.vy*_,!e.ethereal&&(e.vy+=.5,_N(_D(e.x,e.y))&&(e.destroyed=!0)))})}}else H+1<_1()?_L(H+1):_Q("Run finished with "+_m+" points","You cleared all levels for this run.");if(F>D()){let _=!ea("basic")&&(F-D())*Math.random()>5&&j&&{type:"particle",duration:100*(Math.random()+1),time:e_,size:E/2,color:"red",ethereal:!0};if(V.top_is_lava&&_&&_x.push({..._,x:Q+Math.random()*_e,y:0,vx:(Math.random()-.5)*10,vy:5}),V.left_is_lava&&_&&_x.push({..._,x:Q,y:Math.random()*_t,vx:5,vy:(Math.random()-.5)*10}),V.right_is_lava&&_&&_x.push({..._,x:Q+_e,y:Math.random()*_t,vx:-5,vy:(Math.random()-.5)*10}),V.compound_interest){let e=Z,t=0;do e=Q+_e*Math.random(),t++;while(Math.abs(e-Z){let{x:e,y:t,time:r,color:o,size:l,type:s,duration:a}=_;P.globalAlpha=Math.min(1,2-(e_-r)/a*2),"particle"===s&&_3(P,o,l,e,t)});else if(P.globalCompositeOperation="source-over",P.globalAlpha=1,P.fillStyle="#000",P.fillRect(0,0,e,t),P.globalCompositeOperation="screen",P.globalAlpha=.6,_M.forEach(_=>{_.destroyed||_8(P,_.color,2*E,_.x,_.y)}),_W.forEach(_=>{_8(P,_v,40,_.x,_.y)}),P.globalAlpha=.5,_w.forEach((_,e)=>{if(!_)return;let t=_h(e),r=_b(e);_8(P,"black"==_?"#666":_,_r,t,r)}),P.globalAlpha=1,_x.forEach(_=>{let{x:e,y:t,time:r,color:o,size:l,type:s,duration:a}=_;P.globalAlpha=Math.min(1,2-(e_-r)/a*2),"ball"===s&&_8(P,o,l,e,t),"particle"===s&&_8(P,o,3*l,e,t)}),P.globalAlpha=.2,P.globalCompositeOperation="multiply",P.fillStyle="black",P.fillRect(0,0,e,t),P.globalAlpha=.8,P.globalCompositeOperation="multiply",_.svg&&_l.width&&_l.complete){if(_s.title!==_.name){_s.title=_.name,_s.width=_a,_s.height=_i;let r=_s.getContext("2d");r.fillStyle=_.color||"#000",r.fillRect(0,0,_a,_i);let o=P.createPattern(_l,"repeat");o&&(r.fillStyle=o,r.fillRect(0,0,e,t))}P.drawImage(_s,0,0)}else P.fillStyle="#000",P.fillRect(0,0,e,t);P.globalAlpha=1,P.globalCompositeOperation="source-over";let r=Date.now()-_g+5,i=r<200;if(i){let _=(V.bigger_explosions+1)*50/r;P.translate(Math.sin(Date.now())*_,Math.sin(Date.now()+36)*_)}if(P.globalAlpha=1,_M.forEach(e=>{e.destroyed||(P.globalCompositeOperation="gold"===e.color||_.color?"source-over":"screen",_7(P,e.color,E,e.x,e.y,_.color||"black",e.a))}),ea("basic")||(P.globalCompositeOperation="source-over",P.globalAlpha=Math.min(.8,_M.length/20),_W.forEach(e=>{_3(P,_.color||"#000",120,e.x,e.y)})),P.globalCompositeOperation="source-over",function(){P.globalAlpha=1;let _=F>D()&&V.picky_eater,e=__+"_"+_w.join("_")+$.complete+"_"+_+"_"+_v+"_"+V.pierce_color;if(e!==_4){_4=e,_2.width=__,_2.height=__+1;let t=_2.getContext("2d");t.clearRect(0,0,__,__),t.resetTransform(),t.translate(-K,0),_w.forEach((e,r)=>{let o=_h(r),l=_b(r);if(!e)return;let s=_v!==e&&"black"!==e&&_&&"red"||e;(function(_,e,t,r,o){let l=Math.ceil(r-_r/2),s=Math.ceil(o-_r/2),a=Math.ceil(r+_r/2)-1-l,i=Math.ceil(o+_r/2)-1-s,n="brick"+e+"_"+t+"_"+a+"_"+i;if(!_5[n]){var c,h,b,y,u,m;let _=document.createElement("canvas");_.width=a,_.height=i;let r=_.getContext("2d");r.fillStyle=e,r.strokeStyle=t,r.lineJoin="round",r.lineWidth=2,c=r,h=1,b=1,y=a-2,u=i-2,m=2,c.beginPath(),c.moveTo(3,1),c.lineTo(h+y-m,b),c.quadraticCurveTo(h+y,b,h+y,b+m),c.lineTo(h+y,b+u-m),c.quadraticCurveTo(h+y,b+u,h+y-m,b+u),c.lineTo(h+m,b+u),c.quadraticCurveTo(h,b+u,h,b+u-m),c.lineTo(h,b+m),c.quadraticCurveTo(h,b,h+m,b),c.closePath(),r.fill(),r.stroke(),_5[n]=_}_.drawImage(_5[n],l,s,a,i)})(t,e,s,o,l),"black"===e&&(t.globalCompositeOperation="source-over",function(_,e,t,r,o){let l="svg"+e+"_"+t+"_"+e.complete;if(!_5[l]){let _=document.createElement("canvas");_.width=t,_.height=t;let r=_.getContext("2d"),o=t/Math.max(e.width,e.height),s=e.width*o,a=e.height*o;r.drawImage(e,(t-s)/2,(t-a)/2,s,a),_5[l]=_}_.drawImage(_5[l],Math.round(r-t/2),Math.round(o-t/2))}(t,$,_r,o,l))})}P.drawImage(_2,K,0)}(),P.globalCompositeOperation="screen",(_x=_x.filter(_=>e_-_.time<_.duration&&!_.destroyed)).forEach(_=>{let{x:e,y:t,time:r,color:o,size:l,type:s,duration:a}=_,i=e_-r;P.globalAlpha=Math.max(0,Math.min(1,2-i/a*2)),"text"===s?(P.globalCompositeOperation="source-over",_9(P,_.text,o,l,e,t-i/10)):"particle"===s&&(P.globalCompositeOperation="screen",_3(P,o,l,e,t),_8(P,o,l,e,t))}),V.extra_life){P.globalAlpha=1,P.globalCompositeOperation="source-over",P.fillStyle=T;for(let _=0;_{_3(P,_v,20,_.x,_.y,T),_X(_)&&(P.strokeStyle=T,P.beginPath(),P.bezierCurveTo(Z,_t,Z,_.y,_.x,_.y),P.stroke())}),P.globalAlpha=1,P.globalCompositeOperation="source-over",V.streak_shots&&F>D()&&_6(P,"red",q,20,-2),_6(P,T,q,20),F>1){P.globalCompositeOperation="source-over";let _="x "+F,e=20*_.length/1.8+2*E,t=Z-e/2;eD();P.globalCompositeOperation="source-over",Q?(P.fillStyle=n&&V.left_is_lava?"red":T,P.fillRect(K-1,0,1,t),P.fillStyle=n&&V.right_is_lava?"red":T,P.fillRect(e-K+1,0,1,t)):(P.fillStyle="red",n&&V.left_is_lava&&P.fillRect(0,0,1,t),n&&V.right_is_lava&&P.fillRect(e-1,0,1,t)),V.top_is_lava&&F>D()&&(P.fillStyle="red",P.fillRect(Q,0,_e,1));let c=V.compound_interest&&F>D();P.fillStyle=c?"red":T,ea("mobile-mode")?(P.fillRect(Q,_t,_e,1),j||_9(P,"Press and hold here to play",T,20,_a/2,_t+(_i-_t)/2)):c&&P.fillRect(Q,_t-1,_e,1),i&&P.resetTransform(),ea("record")&&j&&o&&(a&&(a.drawImage(C,Q,0,_e,_t,0,0,s.width,s.height),a.fillStyle="#FFF",a.textBaseline="top",a.font="12px monospace",a.textAlign="right",a.fillText(_m.toString(),s.width-12,12),a.textAlign="left",a.fillText("Level "+(H+1)+"/"+_1(),12,12)),l?.requestFrame?l?.requestFrame():o?.requestFrame&&o.requestFrame())})(),requestAnimationFrame(_),_F=e}(); \ No newline at end of file diff --git a/src/backgrounds.json b/src/backgrounds.json index f3b98bd..be8490c 100644 --- a/src/backgrounds.json +++ b/src/backgrounds.json @@ -31,4 +31,4 @@ "", "", "" -] \ No newline at end of file +] diff --git a/src/game.ts b/src/game.ts index 831582a..5437012 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,25 +1,25 @@ -import {allLevels, appVersion, icons, upgrades} from "./loadGameData"; +import { allLevels, appVersion, icons, upgrades } from "./loadGameData"; import { - Ball, - BallLike, - Coin, - colorString, - Flash, - Level, - PerkId, - PerksMap, - RunHistoryItem, - RunStats, - Upgrade, + Ball, + BallLike, + Coin, + colorString, + Flash, + Level, + PerkId, + PerksMap, + RunHistoryItem, + RunStats, + Upgrade, } from "./types"; -import {OptionId, options} from "./options"; -import {getAudioContext, getAudioRecordingTrack, sounds} from "./sounds"; +import { OptionId, options } from "./options"; +import { getAudioContext, getAudioRecordingTrack, sounds } from "./sounds"; const MAX_COINS = 400; const MAX_PARTICLES = 600; export const gameCanvas = document.getElementById("game") as HTMLCanvasElement; const ctx = gameCanvas.getContext("2d", { - alpha: false, + alpha: false, }) as CanvasRenderingContext2D; const puckColor = "#FFF"; @@ -33,8 +33,8 @@ let currentLevel = 0; const bombSVG = document.createElement("img"); bombSVG.src = - "data:image/svg+xml;base64," + - btoa(` + "data:image/svg+xml;base64," + + btoa(` `); @@ -42,9 +42,9 @@ bombSVG.src = let puckWidth = 200; const makeEmptyPerksMap = () => { - const p = {} as any; - upgrades.forEach((u) => (p[u.id] = 0)); - return p as PerksMap; + const p = {} as any; + upgrades.forEach((u) => (p[u.id] = 0)); + return p as PerksMap; }; const perks: PerksMap = makeEmptyPerksMap(); @@ -53,214 +53,214 @@ let baseSpeed = 12; // applied to x and y let combo = 1; function baseCombo() { - return 1 + perks.base_combo * 3 + perks.smaller_puck * 5; + return 1 + perks.base_combo * 3 + perks.smaller_puck * 5; } function resetCombo(x: number | undefined, y: number | undefined) { - const prev = combo; - combo = baseCombo(); - if (!levelTime) { - combo += perks.hot_start * 15; + const prev = combo; + combo = baseCombo(); + if (!levelTime) { + combo += perks.hot_start * 15; + } + if (prev > combo && perks.soft_reset) { + combo += Math.floor((prev - combo) / (1 + perks.soft_reset)); + } + const lost = Math.max(0, prev - combo); + if (lost) { + for (let i = 0; i < lost && i < 8; i++) { + setTimeout(() => sounds.comboDecrease(), i * 100); } - if (prev > combo && perks.soft_reset) { - combo += Math.floor((prev - combo) / (1 + perks.soft_reset)); + if (typeof x !== "undefined" && typeof y !== "undefined") { + flashes.push({ + type: "text", + text: "-" + lost, + time: levelTime, + color: "red", + x: x, + y: y, + duration: 150, + size: puckHeight, + }); } - const lost = Math.max(0, prev - combo); - if (lost) { - for (let i = 0; i < lost && i < 8; i++) { - setTimeout(() => sounds.comboDecrease(), i * 100); - } - if (typeof x !== "undefined" && typeof y !== "undefined") { - flashes.push({ - type: "text", - text: "-" + lost, - time: levelTime, - color: "red", - x: x, - y: y, - duration: 150, - size: puckHeight, - }); - } - } - return lost; + } + return lost; } function decreaseCombo(by: number, x: number, y: number) { - const prev = combo; - combo = Math.max(baseCombo(), combo - by); - const lost = Math.max(0, prev - combo); + const prev = combo; + combo = Math.max(baseCombo(), combo - by); + const lost = Math.max(0, prev - combo); - if (lost) { - sounds.comboDecrease(); - if (typeof x !== "undefined" && typeof y !== "undefined") { - flashes.push({ - type: "text", - text: "-" + lost, - time: levelTime, - color: "red", - x: x, - y: y, - duration: 300, - size: puckHeight, - }); - } + if (lost) { + sounds.comboDecrease(); + if (typeof x !== "undefined" && typeof y !== "undefined") { + flashes.push({ + type: "text", + text: "-" + lost, + time: levelTime, + color: "red", + x: x, + y: y, + duration: 300, + size: puckHeight, + }); } + } } let gridSize = 12; let running = false, - puck = 400, - pauseTimeout: number | null = null; + puck = 400, + pauseTimeout: number | null = null; function play() { - if (running) return; - running = true; - getAudioContext()?.resume().then(); - resumeRecording(); - document.body.className = running ? " running " : " paused "; + if (running) return; + running = true; + getAudioContext()?.resume().then(); + resumeRecording(); + document.body.className = running ? " running " : " paused "; } function pause(playerAskedForPause: boolean) { - if (!running) return; - if (pauseTimeout) return; + if (!running) return; + if (pauseTimeout) return; - pauseTimeout = setTimeout( - () => { - running = false; - needsRender = true; + pauseTimeout = setTimeout( + () => { + running = false; + needsRender = true; - setTimeout(() => { - if (!running) getAudioContext()?.suspend().then(); - }, 1000); + setTimeout(() => { + if (!running) getAudioContext()?.suspend().then(); + }, 1000); - pauseRecording(); - pauseTimeout = null; - document.body.className = running ? " running " : " paused "; - }, - Math.min(Math.max(0, pauseUsesDuringRun - 5) * 50, 500), - ); + pauseRecording(); + pauseTimeout = null; + document.body.className = running ? " running " : " paused "; + }, + Math.min(Math.max(0, pauseUsesDuringRun - 5) * 50, 500), + ); - if (playerAskedForPause) { - // Pausing many times in a run will make pause slower - pauseUsesDuringRun++; - } + if (playerAskedForPause) { + // Pausing many times in a run will make pause slower + pauseUsesDuringRun++; + } - if (document.exitPointerLock) { - document.exitPointerLock(); - } + if (document.exitPointerLock) { + document.exitPointerLock(); + } } export let offsetX: number, - offsetXRoundedDown: number, - gameZoneWidth: number, - gameZoneWidthRoundedUp: number, - gameZoneHeight: number, - brickWidth: number, - needsRender = true; + offsetXRoundedDown: number, + gameZoneWidth: number, + gameZoneWidthRoundedUp: number, + gameZoneHeight: number, + brickWidth: number, + needsRender = true; const background = document.createElement("img"); const backgroundCanvas = document.createElement("canvas"); background.addEventListener("load", () => { - needsRender = true; + needsRender = true; }); let lastWidth = 0, - lastHeight = 0; + lastHeight = 0; export const fitSize = () => { - const {width, height} = gameCanvas.getBoundingClientRect(); - lastWidth = width; - lastHeight = height; - gameCanvas.width = width; - gameCanvas.height = height; - ctx.fillStyle = currentLevelInfo()?.color || "black"; - ctx.globalAlpha = 1; - ctx.fillRect(0, 0, width, height); - backgroundCanvas.width = width; - backgroundCanvas.height = height; + const { width, height } = gameCanvas.getBoundingClientRect(); + lastWidth = width; + lastHeight = height; + gameCanvas.width = width; + gameCanvas.height = height; + ctx.fillStyle = currentLevelInfo()?.color || "black"; + ctx.globalAlpha = 1; + ctx.fillRect(0, 0, width, height); + backgroundCanvas.width = width; + backgroundCanvas.height = height; - gameZoneHeight = isSettingOn("mobile-mode") ? (height * 80) / 100 : height; - const baseWidth = Math.round(Math.min(lastWidth, gameZoneHeight * 0.73)); - brickWidth = Math.floor(baseWidth / gridSize / 2) * 2; - gameZoneWidth = brickWidth * gridSize; - offsetX = Math.floor((lastWidth - gameZoneWidth) / 2); - offsetXRoundedDown = offsetX; - if (offsetX < ballSize) offsetXRoundedDown = 0; - gameZoneWidthRoundedUp = width - 2 * offsetXRoundedDown; - backgroundCanvas.title = "resized"; - // Ensure puck stays within bounds - setMousePos(puck); - coins = []; - flashes = []; - pause(true); - putBallsAtPuck(); - // For safari mobile https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - document.documentElement.style.setProperty( - "--vh", - `${window.innerHeight * 0.01}px`, - ); + gameZoneHeight = isSettingOn("mobile-mode") ? (height * 80) / 100 : height; + const baseWidth = Math.round(Math.min(lastWidth, gameZoneHeight * 0.73)); + brickWidth = Math.floor(baseWidth / gridSize / 2) * 2; + gameZoneWidth = brickWidth * gridSize; + offsetX = Math.floor((lastWidth - gameZoneWidth) / 2); + offsetXRoundedDown = offsetX; + if (offsetX < ballSize) offsetXRoundedDown = 0; + gameZoneWidthRoundedUp = width - 2 * offsetXRoundedDown; + backgroundCanvas.title = "resized"; + // Ensure puck stays within bounds + setMousePos(puck); + coins = []; + flashes = []; + pause(true); + putBallsAtPuck(); + // For safari mobile https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + document.documentElement.style.setProperty( + "--vh", + `${window.innerHeight * 0.01}px`, + ); }; window.addEventListener("resize", fitSize); window.addEventListener("fullscreenchange", fitSize); setInterval(() => { - // Sometimes, the page changes size without triggering the event (when switching to fullscreen, closing debug panel...) - const {width, height} = gameCanvas.getBoundingClientRect(); - if (width !== lastWidth || height !== lastHeight) fitSize(); + // Sometimes, the page changes size without triggering the event (when switching to fullscreen, closing debug panel...) + const { width, height } = gameCanvas.getBoundingClientRect(); + if (width !== lastWidth || height !== lastHeight) fitSize(); }, 1000); function recomputeTargetBaseSpeed() { - // We never want the ball to completely stop, it will move at least 3px per frame - baseSpeed = Math.max( - 3, - gameZoneWidth / 12 / 10 + - currentLevel / 3 + - levelTime / (30 * 1000) - - perks.slow_down * 2, - ); + // We never want the ball to completely stop, it will move at least 3px per frame + baseSpeed = Math.max( + 3, + gameZoneWidth / 12 / 10 + + currentLevel / 3 + + levelTime / (30 * 1000) - + perks.slow_down * 2, + ); } function brickCenterX(index: number) { - return offsetX + ((index % gridSize) + 0.5) * brickWidth; + return offsetX + ((index % gridSize) + 0.5) * brickWidth; } function brickCenterY(index: number) { - return (Math.floor(index / gridSize) + 0.5) * brickWidth; + return (Math.floor(index / gridSize) + 0.5) * brickWidth; } function getRowColIndex(row: number, col: number) { - if (row < 0 || col < 0 || row >= gridSize || col >= gridSize) return -1; - return row * gridSize + col; + if (row < 0 || col < 0 || row >= gridSize || col >= gridSize) return -1; + return row * gridSize + col; } function spawnExplosion( - count: number, - x: number, - y: number, - color: string, - duration = 150, - size = coinSize, + count: number, + x: number, + y: number, + color: string, + duration = 150, + size = coinSize, ) { - if (!!isSettingOn("basic")) return; - if (flashes.length > MAX_PARTICLES) { - // Avoid freezing when lots of explosion happen at once - count = 1; - } - for (let i = 0; i < count; i++) { - flashes.push({ - type: "particle", - time: levelTime, - size, - x: x + ((Math.random() - 0.5) * brickWidth) / 2, - y: y + ((Math.random() - 0.5) * brickWidth) / 2, - vx: (Math.random() - 0.5) * 30, - vy: (Math.random() - 0.5) * 30, - color, - duration, - ethereal: false, - }); - } + if (!!isSettingOn("basic")) return; + if (flashes.length > MAX_PARTICLES) { + // Avoid freezing when lots of explosion happen at once + count = 1; + } + for (let i = 0; i < count; i++) { + flashes.push({ + type: "particle", + time: levelTime, + size, + x: x + ((Math.random() - 0.5) * brickWidth) / 2, + y: y + ((Math.random() - 0.5) * brickWidth) / 2, + vx: (Math.random() - 0.5) * 30, + vy: (Math.random() - 0.5) * 30, + color, + duration, + ethereal: false, + }); + } } let score = 0; @@ -271,93 +271,93 @@ let highScore = parseFloat(localStorage.getItem("breakout-3-hs") || "0"); let lastPlayedCoinGrab = 0; function addToScore(coin: Coin) { - coin.destroyed = true; - score += coin.points; + coin.destroyed = true; + score += coin.points; - addToTotalScore(coin.points); - if (score > highScore && !isCreativeModeRun) { - highScore = score; - localStorage.setItem("breakout-3-hs", score.toString()); - } - if (!isSettingOn("basic")) { - flashes.push({ - type: "particle", - duration: 100 + Math.random() * 50, - time: levelTime, - size: coinSize / 2, - color: coin.color, - x: coin.previousX, - y: coin.previousY, - vx: (lastWidth - coin.x) / 100, - vy: -coin.y / 100, - ethereal: true, - }); - } + addToTotalScore(coin.points); + if (score > highScore && !isCreativeModeRun) { + highScore = score; + localStorage.setItem("breakout-3-hs", score.toString()); + } + if (!isSettingOn("basic")) { + flashes.push({ + type: "particle", + duration: 100 + Math.random() * 50, + time: levelTime, + size: coinSize / 2, + color: coin.color, + x: coin.previousX, + y: coin.previousY, + vx: (lastWidth - coin.x) / 100, + vy: -coin.y / 100, + ethereal: true, + }); + } - if (Date.now() - lastPlayedCoinGrab > 16) { - lastPlayedCoinGrab = Date.now(); - sounds.coinCatch(coin.x); - } - runStatistics.score += coin.points; + if (Date.now() - lastPlayedCoinGrab > 16) { + lastPlayedCoinGrab = Date.now(); + sounds.coinCatch(coin.x); + } + runStatistics.score += coin.points; } let balls: Ball[] = []; let ballsColor: colorString = "white"; function resetBalls() { - const count = 1 + (perks?.multiball || 0); - const perBall = puckWidth / (count + 1); - balls = []; - ballsColor = "#FFF"; - if (perks.picky_eater || perks.pierce_color) { - ballsColor = getMajorityValue(bricks.filter((i) => i)) || "#FFF"; - } - for (let i = 0; i < count; i++) { - const x = puck - puckWidth / 2 + perBall * (i + 1); - const vx = Math.random() > 0.5 ? baseSpeed : -baseSpeed; + const count = 1 + (perks?.multiball || 0); + const perBall = puckWidth / (count + 1); + balls = []; + ballsColor = "#FFF"; + if (perks.picky_eater || perks.pierce_color) { + ballsColor = getMajorityValue(bricks.filter((i) => i)) || "#FFF"; + } + for (let i = 0; i < count; i++) { + const x = puck - puckWidth / 2 + perBall * (i + 1); + const vx = Math.random() > 0.5 ? baseSpeed : -baseSpeed; - balls.push({ - x, - previousX: x, - y: gameZoneHeight - 1.5 * ballSize, - previousY: gameZoneHeight - 1.5 * ballSize, - vx, - previousVX: vx, - vy: -baseSpeed, - previousVY: -baseSpeed, + balls.push({ + x, + previousX: x, + y: gameZoneHeight - 1.5 * ballSize, + previousY: gameZoneHeight - 1.5 * ballSize, + vx, + previousVX: vx, + vy: -baseSpeed, + previousVY: -baseSpeed, - sx: 0, - sy: 0, - sparks: 0, - piercedSinceBounce: 0, - hitSinceBounce: 0, - hitItem: [], - bouncesList: [], - sapperUses: 0, - }); - } + sx: 0, + sy: 0, + sparks: 0, + piercedSinceBounce: 0, + hitSinceBounce: 0, + hitItem: [], + bouncesList: [], + sapperUses: 0, + }); + } } function putBallsAtPuck() { - // This reset could be abused to cheat quite easily - const count = balls.length; - const perBall = puckWidth / (count + 1); - balls.forEach((ball, i) => { - const x = puck - puckWidth / 2 + perBall * (i + 1); - ball.x = x; - ball.previousX = x; - ball.y = gameZoneHeight - 1.5 * ballSize; - ball.previousY = ball.y; - ball.vx = Math.random() > 0.5 ? baseSpeed : -baseSpeed; - ball.previousVX = ball.vx; - ball.vy = -baseSpeed; - ball.previousVY = ball.vy; - ball.sx = 0; - ball.sy = 0; - ball.hitItem = []; - ball.hitSinceBounce = 0; - ball.piercedSinceBounce = 0; - }); + // This reset could be abused to cheat quite easily + const count = balls.length; + const perBall = puckWidth / (count + 1); + balls.forEach((ball, i) => { + const x = puck - puckWidth / 2 + perBall * (i + 1); + ball.x = x; + ball.previousX = x; + ball.y = gameZoneHeight - 1.5 * ballSize; + ball.previousY = ball.y; + ball.vx = Math.random() > 0.5 ? baseSpeed : -baseSpeed; + ball.previousVX = ball.vx; + ball.vy = -baseSpeed; + ball.previousVY = ball.vy; + ball.sx = 0; + ball.sy = 0; + ball.hitItem = []; + ball.hitSinceBounce = 0; + ball.piercedSinceBounce = 0; + }); } resetBalls(); @@ -370,194 +370,192 @@ let levelMisses = 0; let levelSpawnedCoins = 0; function pickedUpgradesHTMl() { - let list = ""; - for (let u of upgrades) { - for (let i = 0; i < perks[u.id]; i++) list += icons["icon:" + u.id] + " "; - } - return list; + let list = ""; + for (let u of upgrades) { + for (let i = 0; i < perks[u.id]; i++) list += icons["icon:" + u.id] + " "; + } + return list; } async function openUpgradesPicker() { - const catchRate = (score - levelStartScore) / (levelSpawnedCoins || 1); + const catchRate = (score - levelStartScore) / (levelSpawnedCoins || 1); - let repeats = 1; - let choices = 3; + let repeats = 1; + let choices = 3; - let timeGain = "", - catchGain = "", - missesGain = ""; - if (levelTime < 30 * 1000) { - repeats++; - choices++; - timeGain = " (+1 upgrade and choice)"; - } else if (levelTime < 60 * 1000) { - choices++; - timeGain = " (+1 choice)"; - } - if (catchRate === 1) { - repeats++; - choices++; - catchGain = " (+1 upgrade and choice)"; - } else if (catchRate > 0.9) { - choices++; - catchGain = " (+1 choice)"; - } - if (levelMisses === 0) { - repeats++; - choices++; - missesGain = " (+1 upgrade and choice)"; - } else if (levelMisses <= 3) { - choices++; - missesGain = " (+1 choice)"; - } + let timeGain = "", + catchGain = "", + missesGain = ""; + if (levelTime < 30 * 1000) { + repeats++; + choices++; + timeGain = " (+1 upgrade and choice)"; + } else if (levelTime < 60 * 1000) { + choices++; + timeGain = " (+1 choice)"; + } + if (catchRate === 1) { + repeats++; + choices++; + catchGain = " (+1 upgrade and choice)"; + } else if (catchRate > 0.9) { + choices++; + catchGain = " (+1 choice)"; + } + if (levelMisses === 0) { + repeats++; + choices++; + missesGain = " (+1 upgrade and choice)"; + } else if (levelMisses <= 3) { + choices++; + missesGain = " (+1 choice)"; + } - while (repeats--) { - const actions = pickRandomUpgrades( - choices + perks.one_more_choice - perks.instant_upgrade, - ); - if (!actions.length) break; - let textAfterButtons = ` + while (repeats--) { + const actions = pickRandomUpgrades( + choices + perks.one_more_choice - perks.instant_upgrade, + ); + if (!actions.length) break; + let textAfterButtons = `

You just finished level ${currentLevel + 1}/${max_levels()} and picked those upgrades so far :

${pickedUpgradesHTMl()}

`; - const upgradeId = (await asyncAlert({ - title: "Pick an upgrade " + (repeats ? "(" + (repeats + 1) + ")" : ""), - actions, - text: `

+ const upgradeId = (await asyncAlert({ + title: "Pick an upgrade " + (repeats ? "(" + (repeats + 1) + ")" : ""), + actions, + text: `

You caught ${score - levelStartScore} coins ${catchGain} out of ${levelSpawnedCoins} in ${Math.round(levelTime / 1000)} seconds${timeGain}. You missed ${levelMisses} times ${missesGain}. ${(timeGain && catchGain && missesGain && "Impressive, keep it up !") || ((timeGain || catchGain || missesGain) && "Well done !") || "Try to catch all coins, never miss the bricks or clear the level under 30s to gain additional choices and upgrades."}

`, - allowClose: false, - textAfterButtons, - })) as PerkId; + allowClose: false, + textAfterButtons, + })) as PerkId; - perks[upgradeId]++; - if (upgradeId === "instant_upgrade") { - repeats += 2; - } - - runStatistics.upgrades_picked++; + perks[upgradeId]++; + if (upgradeId === "instant_upgrade") { + repeats += 2; } - resetCombo(undefined, undefined); - resetBalls(); + + runStatistics.upgrades_picked++; + } + resetCombo(undefined, undefined); + resetBalls(); } function setLevel(l: number) { - pause(false); - if (l > 0) { - openUpgradesPicker().then(); - } - currentLevel = l; + pause(false); + if (l > 0) { + openUpgradesPicker().then(); + } + currentLevel = l; - levelTime = 0; - level_skip_last_uses = 0; - lastTickDown = levelTime; - levelStartScore = score; - levelSpawnedCoins = 0; - levelMisses = 0; - runStatistics.levelsPlayed++; + levelTime = 0; + level_skip_last_uses = 0; + lastTickDown = levelTime; + levelStartScore = score; + levelSpawnedCoins = 0; + levelMisses = 0; + runStatistics.levelsPlayed++; - resetCombo(undefined, undefined); - recomputeTargetBaseSpeed(); - resetBalls(); + resetCombo(undefined, undefined); + recomputeTargetBaseSpeed(); + resetBalls(); - const lvl = currentLevelInfo(); - if (lvl.size !== gridSize) { - gridSize = lvl.size; - fitSize(); - } - coins = []; - bricks = [...lvl.bricks]; - flashes = []; + const lvl = currentLevelInfo(); + if (lvl.size !== gridSize) { + gridSize = lvl.size; + fitSize(); + } + coins = []; + bricks = [...lvl.bricks]; + flashes = []; - // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons - // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg) - background.src = "data:image/svg+xml;UTF8," + lvl.svg; - stopRecording(); - startRecordingGame(); + // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons + // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg) + background.src = "data:image/svg+xml;UTF8," + lvl.svg; + stopRecording(); + startRecordingGame(); } function currentLevelInfo() { - return runLevels[currentLevel % runLevels.length]; + return runLevels[currentLevel % runLevels.length]; } let totalScoreAtRunStart = getTotalScore(); function getPossibleUpgrades() { - return upgrades - .filter((u) => totalScoreAtRunStart >= u.threshold) - .filter((u) => !u?.requires || perks[u?.requires]); + return upgrades + .filter((u) => totalScoreAtRunStart >= u.threshold) + .filter((u) => !u?.requires || perks[u?.requires]); } function shuffleLevels(nameToAvoid: string | null = null) { - const target = nextRunOverrides?.level; - delete nextRunOverrides.level; - const firstLevel = target - ? allLevels.filter((l) => l.name === target) - : []; + const target = nextRunOverrides?.level; + delete nextRunOverrides.level; + const firstLevel = target ? allLevels.filter((l) => l.name === target) : []; - const restInRandomOrder = allLevels - .filter((l) => totalScoreAtRunStart >= l.threshold) - .filter((l) => l.name !== target) - .filter((l) => l.name !== nameToAvoid || allLevels.length === 1) - .sort(() => Math.random() - 0.5); + const restInRandomOrder = allLevels + .filter((l) => totalScoreAtRunStart >= l.threshold) + .filter((l) => l.name !== target) + .filter((l) => l.name !== nameToAvoid || allLevels.length === 1) + .sort(() => Math.random() - 0.5); - runLevels = firstLevel.concat( - restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey), - ); + runLevels = firstLevel.concat( + restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey), + ); } function getUpgraderUnlockPoints() { - let list = [] as { threshold: number; title: string }[]; + let list = [] as { threshold: number; title: string }[]; - upgrades.forEach((u) => { - if (u.threshold) { - list.push({ - threshold: u.threshold, - title: u.name + " (Perk)", - }); - } + upgrades.forEach((u) => { + if (u.threshold) { + list.push({ + threshold: u.threshold, + title: u.name + " (Perk)", + }); + } + }); + + allLevels.forEach((l) => { + list.push({ + threshold: l.threshold, + title: l.name + " (Level)", }); + }); - allLevels.forEach((l) => { - list.push({ - threshold: l.threshold, - title: l.name + " (Level)", - }); - }); - - return list - .filter((o) => o.threshold) - .sort((a, b) => a.threshold - b.threshold); + return list + .filter((o) => o.threshold) + .sort((a, b) => a.threshold - b.threshold); } let lastOffered = {} as { [k in PerkId]: number }; function dontOfferTooSoon(id: PerkId) { - lastOffered[id] = Math.round(Date.now() / 1000); + lastOffered[id] = Math.round(Date.now() / 1000); } function pickRandomUpgrades(count: number) { - let list = getPossibleUpgrades() - .map((u) => ({...u, score: Math.random() + (lastOffered[u.id] || 0)})) - .sort((a, b) => a.score - b.score) - .filter((u) => perks[u.id] < u.max) - .slice(0, count) - .sort((a, b) => (a.id > b.id ? 1 : -1)); + let list = getPossibleUpgrades() + .map((u) => ({ ...u, score: Math.random() + (lastOffered[u.id] || 0) })) + .sort((a, b) => a.score - b.score) + .filter((u) => perks[u.id] < u.max) + .slice(0, count) + .sort((a, b) => (a.id > b.id ? 1 : -1)); - list.forEach((u) => { - dontOfferTooSoon(u.id); - }); + list.forEach((u) => { + dontOfferTooSoon(u.id); + }); - return list.map((u) => ({ - text: u.name + (perks[u.id] ? " lvl " + (perks[u.id] + 1) : ""), - icon: icons["icon:" + u.id], - value: u.id as PerkId, - help: u.help(perks[u.id] + 1), - })); + return list.map((u) => ({ + text: u.name + (perks[u.id] ? " lvl " + (perks[u.id] + 1) : ""), + icon: icons["icon:" + u.id], + value: u.id as PerkId, + help: u.help(perks[u.id] + 1), + })); } type RunOverrides = { level?: PerkId; perk?: string }; @@ -568,2127 +566,2125 @@ let isCreativeModeRun = false; let pauseUsesDuringRun = 0; function restart(creativeModePerks: Partial | undefined = undefined) { - // When restarting, we want to avoid restarting with the same level we're on, so we exclude from the next - // run's level list - totalScoreAtRunStart = getTotalScore(); + // When restarting, we want to avoid restarting with the same level we're on, so we exclude from the next + // run's level list + totalScoreAtRunStart = getTotalScore(); - shuffleLevels(levelTime || score ? currentLevelInfo().name : null); - resetRunStatistics(); - score = 0; - pauseUsesDuringRun = 0; + shuffleLevels(levelTime || score ? currentLevelInfo().name : null); + resetRunStatistics(); + score = 0; + pauseUsesDuringRun = 0; - for (let u of upgrades) { - perks[u.id] = 0; - } - if (creativeModePerks) { - Object.assign(perks, creativeModePerks); - isCreativeModeRun = true; - } else { - isCreativeModeRun = false; + for (let u of upgrades) { + perks[u.id] = 0; + } + if (creativeModePerks) { + Object.assign(perks, creativeModePerks); + isCreativeModeRun = true; + } else { + isCreativeModeRun = false; - const giftable = getPossibleUpgrades().filter((u) => u.giftable); - const randomGift = - (nextRunOverrides?.perk as PerkId) || - (isSettingOn("easy") && "slow_down") || - giftable[Math.floor(Math.random() * giftable.length)].id; + const giftable = getPossibleUpgrades().filter((u) => u.giftable); + const randomGift = + (nextRunOverrides?.perk as PerkId) || + (isSettingOn("easy") && "slow_down") || + giftable[Math.floor(Math.random() * giftable.length)].id; - perks[randomGift] = 1; - delete nextRunOverrides.perk; + perks[randomGift] = 1; + delete nextRunOverrides.perk; - dontOfferTooSoon(randomGift); - } + dontOfferTooSoon(randomGift); + } - setLevel(0); - pauseRecording(); + setLevel(0); + pauseRecording(); } let keyboardPuckSpeed = 0; function setMousePos(x: number) { - needsRender = true; - puck = x; + needsRender = true; + puck = x; - // We have borders visible, enforce them - if (puck < offsetXRoundedDown + puckWidth / 2) { - puck = offsetXRoundedDown + puckWidth / 2; - } - if (puck > offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2) { - puck = offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2; - } - if (!running && !levelTime) { - putBallsAtPuck(); - } + // We have borders visible, enforce them + if (puck < offsetXRoundedDown + puckWidth / 2) { + puck = offsetXRoundedDown + puckWidth / 2; + } + if (puck > offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2) { + puck = offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2; + } + if (!running && !levelTime) { + putBallsAtPuck(); + } } gameCanvas.addEventListener("mouseup", (e) => { - if (e.button !== 0) return; - if (running) { - pause(true); - } else { - play(); - if (isSettingOn("pointerLock")) { - gameCanvas.requestPointerLock().then(); - } + if (e.button !== 0) return; + if (running) { + pause(true); + } else { + play(); + if (isSettingOn("pointerLock")) { + gameCanvas.requestPointerLock().then(); } + } }); gameCanvas.addEventListener("mousemove", (e) => { - if (document.pointerLockElement === gameCanvas) { - setMousePos(puck + e.movementX); - } else { - setMousePos(e.x); - } + if (document.pointerLockElement === gameCanvas) { + setMousePos(puck + e.movementX); + } else { + setMousePos(e.x); + } }); gameCanvas.addEventListener("touchstart", (e) => { - e.preventDefault(); - if (!e.touches?.length) return; - setMousePos(e.touches[0].pageX); - play(); + e.preventDefault(); + if (!e.touches?.length) return; + setMousePos(e.touches[0].pageX); + play(); }); gameCanvas.addEventListener("touchend", (e) => { - e.preventDefault(); - pause(true); + e.preventDefault(); + pause(true); }); gameCanvas.addEventListener("touchcancel", (e) => { - e.preventDefault(); - pause(true); - needsRender = true; + e.preventDefault(); + pause(true); + needsRender = true; }); gameCanvas.addEventListener("touchmove", (e) => { - if (!e.touches?.length) return; - setMousePos(e.touches[0].pageX); + if (!e.touches?.length) return; + setMousePos(e.touches[0].pageX); }); let lastTick = performance.now(); function brickIndex(x: number, y: number) { - return getRowColIndex( - Math.floor(y / brickWidth), - Math.floor((x - offsetX) / brickWidth), - ); + return getRowColIndex( + Math.floor(y / brickWidth), + Math.floor((x - offsetX) / brickWidth), + ); } function hasBrick(index: number): number | undefined { - if (bricks[index]) return index; + if (bricks[index]) return index; } function hitsSomething(x: number, y: number, radius: number) { - return ( - hasBrick(brickIndex(x - radius, y - radius)) ?? - hasBrick(brickIndex(x + radius, y - radius)) ?? - hasBrick(brickIndex(x + radius, y + radius)) ?? - hasBrick(brickIndex(x - radius, y + radius)) - ); + return ( + hasBrick(brickIndex(x - radius, y - radius)) ?? + hasBrick(brickIndex(x + radius, y - radius)) ?? + hasBrick(brickIndex(x + radius, y + radius)) ?? + hasBrick(brickIndex(x - radius, y + radius)) + ); } function shouldPierceByColor( - vhit: number | undefined, - hhit: number | undefined, - chit: number | undefined, + vhit: number | undefined, + hhit: number | undefined, + chit: number | undefined, ) { - if (!perks.pierce_color) return false; - if (typeof vhit !== "undefined" && bricks[vhit] !== ballsColor) { - return false; - } - if (typeof hhit !== "undefined" && bricks[hhit] !== ballsColor) { - return false; - } - if (typeof chit !== "undefined" && bricks[chit] !== ballsColor) { - return false; - } - return true; + if (!perks.pierce_color) return false; + if (typeof vhit !== "undefined" && bricks[vhit] !== ballsColor) { + return false; + } + if (typeof hhit !== "undefined" && bricks[hhit] !== ballsColor) { + return false; + } + if (typeof chit !== "undefined" && bricks[chit] !== ballsColor) { + return false; + } + return true; } - function coinBrickHitCheck(coin: Coin) { - // Make ball/coin bonce, and return bricks that were hit - const radius = coinSize / 2; - const {x, y, previousX, previousY} = coin; + // Make ball/coin bonce, and return bricks that were hit + const radius = coinSize / 2; + const { x, y, previousX, previousY } = coin; - const vhit = hitsSomething(previousX, y, radius); - const hhit = hitsSomething(x, previousY, radius); - const chit = - (typeof vhit == "undefined" && - typeof hhit == "undefined" && - hitsSomething(x, y, radius)) || - undefined; + const vhit = hitsSomething(previousX, y, radius); + const hhit = hitsSomething(x, previousY, radius); + const chit = + (typeof vhit == "undefined" && + typeof hhit == "undefined" && + hitsSomething(x, y, radius)) || + undefined; - if (typeof vhit !== "undefined" || typeof chit !== "undefined") { - coin.y = coin.previousY; - coin.vy *= -1; + if (typeof vhit !== "undefined" || typeof chit !== "undefined") { + coin.y = coin.previousY; + coin.vy *= -1; - // Roll on corners - const leftHit = bricks[brickIndex(x - radius, y + radius)]; - const rightHit = bricks[brickIndex(x + radius, y + radius)]; + // Roll on corners + const leftHit = bricks[brickIndex(x - radius, y + radius)]; + const rightHit = bricks[brickIndex(x + radius, y + radius)]; - if (leftHit && !rightHit) { - coin.vx += 1; - coin.sa -= 1; - } - if (!leftHit && rightHit) { - coin.vx -= 1; - coin.sa += 1; - } + if (leftHit && !rightHit) { + coin.vx += 1; + coin.sa -= 1; } - if (typeof hhit !== "undefined" || typeof chit !== "undefined") { - coin.x = coin.previousX; - coin.vx *= -1; + if (!leftHit && rightHit) { + coin.vx -= 1; + coin.sa += 1; } - return vhit ?? hhit ?? chit; + } + if (typeof hhit !== "undefined" || typeof chit !== "undefined") { + coin.x = coin.previousX; + coin.vx *= -1; + } + return vhit ?? hhit ?? chit; } function bordersHitCheck(coin: Coin | Ball, radius: number, delta: number) { - if (coin.destroyed) return; - coin.previousX = coin.x; - coin.previousY = coin.y; - coin.x += coin.vx * delta; - coin.y += coin.vy * delta; - coin.sx ||= 0; - coin.sy ||= 0; - coin.sx += coin.previousX - coin.x; - coin.sy += coin.previousY - coin.y; - coin.sx *= 0.9; - coin.sy *= 0.9; + if (coin.destroyed) return; + coin.previousX = coin.x; + coin.previousY = coin.y; + coin.x += coin.vx * delta; + coin.y += coin.vy * delta; + coin.sx ||= 0; + coin.sy ||= 0; + coin.sx += coin.previousX - coin.x; + coin.sy += coin.previousY - coin.y; + coin.sx *= 0.9; + coin.sy *= 0.9; - if (perks.wind) { - coin.vx += - ((puck - (offsetX + gameZoneWidth / 2)) / gameZoneWidth) * - perks.wind * - 0.5; - } + if (perks.wind) { + coin.vx += + ((puck - (offsetX + gameZoneWidth / 2)) / gameZoneWidth) * + perks.wind * + 0.5; + } - let vhit = 0, - hhit = 0; + let vhit = 0, + hhit = 0; - if (coin.x < offsetXRoundedDown + radius) { - coin.x = offsetXRoundedDown + radius + (offsetXRoundedDown + radius - coin.x); - coin.vx *= -1; - hhit = 1; - } - if (coin.y < radius) { - coin.y = radius + (radius - coin.y); - coin.vy *= -1; - vhit = 1; - } - if (coin.x > lastWidth - offsetXRoundedDown - radius) { - coin.x = lastWidth - offsetXRoundedDown - radius - (coin.x - (lastWidth - offsetXRoundedDown - radius)); - coin.vx *= -1; - hhit = 1; - } + if (coin.x < offsetXRoundedDown + radius) { + coin.x = + offsetXRoundedDown + radius + (offsetXRoundedDown + radius - coin.x); + coin.vx *= -1; + hhit = 1; + } + if (coin.y < radius) { + coin.y = radius + (radius - coin.y); + coin.vy *= -1; + vhit = 1; + } + if (coin.x > lastWidth - offsetXRoundedDown - radius) { + coin.x = + lastWidth - + offsetXRoundedDown - + radius - + (coin.x - (lastWidth - offsetXRoundedDown - radius)); + coin.vx *= -1; + hhit = 1; + } - return hhit + vhit * 2; + return hhit + vhit * 2; } - let lastTickDown = 0; function tick() { - recomputeTargetBaseSpeed(); - const currentTick = performance.now(); + recomputeTargetBaseSpeed(); + const currentTick = performance.now(); - puckWidth = - (gameZoneWidth / 12) * (3 - perks.smaller_puck + perks.bigger_puck); + puckWidth = + (gameZoneWidth / 12) * (3 - perks.smaller_puck + perks.bigger_puck); - if (keyboardPuckSpeed) { - setMousePos(puck + keyboardPuckSpeed); + if (keyboardPuckSpeed) { + setMousePos(puck + keyboardPuckSpeed); + } + + if (running) { + levelTime += currentTick - lastTick; + runStatistics.runTime += currentTick - lastTick; + runStatistics.max_combo = Math.max(runStatistics.max_combo, combo); + + // How many times to compute + let delta = Math.min(4, (currentTick - lastTick) / (1000 / 60)); + delta *= running ? 1 : 0; + + coins = coins.filter((coin) => !coin.destroyed); + balls = balls.filter((ball) => !ball.destroyed); + + const remainingBricks = bricks.filter((b) => b && b !== "black").length; + + if (levelTime > lastTickDown + 1000 && perks.hot_start) { + lastTickDown = levelTime; + decreaseCombo(perks.hot_start, puck, gameZoneHeight - 2 * puckHeight); } - if (running) { - levelTime += currentTick - lastTick; - runStatistics.runTime += currentTick - lastTick; - runStatistics.max_combo = Math.max(runStatistics.max_combo, combo); + if (remainingBricks <= perks.skip_last && !level_skip_last_uses) { + bricks.forEach((type, index) => { + if (type) { + explodeBrick(index, balls[0], true); + } + }); + level_skip_last_uses++; + } + if (!remainingBricks && !coins.length) { + if (currentLevel + 1 < max_levels()) { + setLevel(currentLevel + 1); + } else { + gameOver( + "Run finished with " + score + " points", + "You cleared all levels for this run.", + ); + } + } else if (running || levelTime) { + let playedCoinBounce = false; + const coinRadius = Math.round(coinSize / 2); - // How many times to compute - let delta = Math.min(4, (currentTick - lastTick) / (1000 / 60)); - delta *= running ? 1 : 0; - - coins = coins.filter((coin) => !coin.destroyed); - balls = balls.filter((ball) => !ball.destroyed); - - const remainingBricks = bricks.filter((b) => b && b !== "black").length; - - if (levelTime > lastTickDown + 1000 && perks.hot_start) { - lastTickDown = levelTime; - decreaseCombo(perks.hot_start, puck, gameZoneHeight - 2 * puckHeight); + coins.forEach((coin) => { + if (coin.destroyed) return; + if (perks.coin_magnet) { + const attractionX = + ((delta * (puck - coin.x)) / + (100 + + Math.pow(coin.y - gameZoneHeight, 2) + + Math.pow(coin.x - puck, 2))) * + perks.coin_magnet * + 100; + coin.vx += attractionX; + coin.sa -= attractionX / 10; } - if (remainingBricks <= perks.skip_last && !level_skip_last_uses) { - bricks.forEach((type, index) => { - if (type) { - explodeBrick(index, balls[0], true); - } + const ratio = 1 - (perks.viscosity * 0.03 + 0.005) * delta; + + coin.vy *= ratio; + coin.vx *= ratio; + if (coin.vx > 7 * baseSpeed) coin.vx = 7 * baseSpeed; + if (coin.vx < -7 * baseSpeed) coin.vx = -7 * baseSpeed; + if (coin.vy > 7 * baseSpeed) coin.vy = 7 * baseSpeed; + if (coin.vy < -7 * baseSpeed) coin.vy = -7 * baseSpeed; + coin.a += coin.sa; + + // Gravity + coin.vy += delta * coin.weight * 0.8; + + const speed = Math.abs(coin.sx) + Math.abs(coin.sx); + const hitBorder = bordersHitCheck(coin, coinRadius, delta); + + if ( + coin.y > gameZoneHeight - coinRadius - puckHeight && + coin.y < gameZoneHeight + puckHeight + coin.vy && + Math.abs(coin.x - puck) < + coinRadius + + puckWidth / 2 + // a bit of margin to be nice + puckHeight + ) { + addToScore(coin); + } else if (coin.y > lastHeight + coinRadius) { + coin.destroyed = true; + if (perks.compound_interest) { + resetCombo(coin.x, coin.y); + } + } + + const hitBrick = coinBrickHitCheck(coin); + + if (perks.metamorphosis && typeof hitBrick !== "undefined") { + if ( + bricks[hitBrick] && + coin.color !== bricks[hitBrick] && + bricks[hitBrick] !== "black" && + !coin.coloredABrick + ) { + bricks[hitBrick] = coin.color; + coin.coloredABrick = true; + } + } + if (typeof hitBrick !== "undefined" || hitBorder) { + coin.vx *= 0.8; + coin.vy *= 0.8; + coin.sa *= 0.9; + if (speed > 20 && !playedCoinBounce) { + playedCoinBounce = true; + sounds.coinBounce(coin.x, 0.2); + } + + if (Math.abs(coin.vy) < 3) { + coin.vy = 0; + } + } + }); + + balls.forEach((ball) => ballTick(ball, delta)); + + if (perks.wind) { + const windD = + ((puck - (offsetX + gameZoneWidth / 2)) / gameZoneWidth) * + 2 * + perks.wind; + for (let i = 0; i < perks.wind; i++) { + if (Math.random() * Math.abs(windD) > 0.5) { + flashes.push({ + type: "particle", + duration: 150, + ethereal: true, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + x: offsetXRoundedDown + Math.random() * gameZoneWidthRoundedUp, + y: Math.random() * gameZoneHeight, + vx: windD * 8, + vy: 0, }); - level_skip_last_uses++; + } } - if (!remainingBricks && !coins.length) { - if (currentLevel + 1 < max_levels()) { - setLevel(currentLevel + 1); - } else { - gameOver( - "Run finished with " + score + " points", - "You cleared all levels for this run.", - ); - } - } else if (running || levelTime) { - let playedCoinBounce = false; - const coinRadius = Math.round(coinSize / 2); - - coins.forEach((coin) => { - if (coin.destroyed) return; - if (perks.coin_magnet) { - const attractionX = ((delta * (puck - coin.x)) / - (100 + - Math.pow(coin.y - gameZoneHeight, 2) + - Math.pow(coin.x - puck, 2))) * - perks.coin_magnet * - 100 - coin.vx += attractionX; - coin.sa -= attractionX / 10 - } - - const ratio = 1 - (perks.viscosity * 0.03 + 0.005) * delta; - - coin.vy *= ratio; - coin.vx *= ratio; - if (coin.vx > 7 * baseSpeed) coin.vx = 7 * baseSpeed; - if (coin.vx < -7 * baseSpeed) coin.vx = -7 * baseSpeed; - if (coin.vy > 7 * baseSpeed) coin.vy = 7 * baseSpeed; - if (coin.vy < -7 * baseSpeed) coin.vy = -7 * baseSpeed; - coin.a += coin.sa; - - // Gravity - coin.vy += delta * coin.weight * 0.8; - - const speed = Math.abs(coin.sx) + Math.abs(coin.sx); - const hitBorder = bordersHitCheck(coin, coinRadius, delta); - - if ( - coin.y > gameZoneHeight - coinRadius - puckHeight && - coin.y < gameZoneHeight + puckHeight + coin.vy && - Math.abs(coin.x - puck) < - coinRadius + - puckWidth / 2 + // a bit of margin to be nice - puckHeight - ) { - addToScore(coin); - } else if (coin.y > lastHeight + coinRadius) { - coin.destroyed = true; - if (perks.compound_interest) { - resetCombo(coin.x, coin.y); - } - } - - const hitBrick = coinBrickHitCheck(coin); - - if (perks.metamorphosis && typeof hitBrick !== "undefined") { - if ( - bricks[hitBrick] && - coin.color !== bricks[hitBrick] && - bricks[hitBrick] !== "black" && - !coin.coloredABrick - ) { - bricks[hitBrick] = coin.color; - coin.coloredABrick = true; - } - } - if (typeof hitBrick !== "undefined" || hitBorder) { - coin.vx *= 0.8; - coin.vy *= 0.8; - coin.sa *= 0.9; - if (speed > 20 && !playedCoinBounce) { - playedCoinBounce = true; - sounds.coinBounce(coin.x, 0.2); - } - - if (Math.abs(coin.vy) < 3) { - coin.vy = 0; - } - } - }); - - balls.forEach((ball) => ballTick(ball, delta)); - - if (perks.wind) { - const windD = - ((puck - (offsetX + gameZoneWidth / 2)) / gameZoneWidth) * - 2 * - perks.wind; - for (let i = 0; i < perks.wind; i++) { - if (Math.random() * Math.abs(windD) > 0.5) { - flashes.push({ - type: "particle", - duration: 150, - ethereal: true, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - x: offsetXRoundedDown + Math.random() * gameZoneWidthRoundedUp, - y: Math.random() * gameZoneHeight, - vx: windD * 8, - vy: 0, - }); - } - } - } - - flashes.forEach((flash) => { - if (flash.type === "particle") { - flash.x += flash.vx * delta; - flash.y += flash.vy * delta; - if (!flash.ethereal) { - flash.vy += 0.5; - if (hasBrick(brickIndex(flash.x, flash.y))) { - flash.destroyed = true; - } - } - } - }); - } - - if (combo > baseCombo()) { - // The red should still be visible on a white bg - const baseParticle = !isSettingOn("basic") && - (combo - baseCombo()) * Math.random() > 5 && - running && { - type: "particle" as const, - duration: 100 * (Math.random() + 1), - time: levelTime, - size: coinSize / 2, - color: "red", - ethereal: true, - }; - - if (perks.top_is_lava) { - baseParticle && - flashes.push({ - ...baseParticle, - x: offsetXRoundedDown + Math.random() * gameZoneWidthRoundedUp, - y: 0, - vx: (Math.random() - 0.5) * 10, - vy: 5, - }); - } - - if (perks.left_is_lava && baseParticle) { - flashes.push({ - ...baseParticle, - x: offsetXRoundedDown, - y: Math.random() * gameZoneHeight, - vx: 5, - vy: (Math.random() - 0.5) * 10, - }); - } - - if (perks.right_is_lava && baseParticle) { - flashes.push({ - ...baseParticle, - x: offsetXRoundedDown + gameZoneWidthRoundedUp, - y: Math.random() * gameZoneHeight, - vx: -5, - vy: (Math.random() - 0.5) * 10, - }); - } - - if (perks.compound_interest) { - let x = puck, - attemps = 0; - do { - x = offsetXRoundedDown + gameZoneWidthRoundedUp * Math.random(); - attemps++; - } while (Math.abs(x - puck) < puckWidth / 2 && attemps < 10); - baseParticle && - flashes.push({ - ...baseParticle, - x, - y: gameZoneHeight, - vx: (Math.random() - 0.5) * 10, - vy: -5, - }); - } - if (perks.streak_shots) { - const pos = 0.5 - Math.random(); - baseParticle && - flashes.push({ - ...baseParticle, - duration: 100, - x: puck + puckWidth * pos, - y: gameZoneHeight - puckHeight, - vx: pos * 10, - vy: -5, - }); + } + + flashes.forEach((flash) => { + if (flash.type === "particle") { + flash.x += flash.vx * delta; + flash.y += flash.vy * delta; + if (!flash.ethereal) { + flash.vy += 0.5; + if (hasBrick(brickIndex(flash.x, flash.y))) { + flash.destroyed = true; } + } } + }); } - render(); + if (combo > baseCombo()) { + // The red should still be visible on a white bg + const baseParticle = !isSettingOn("basic") && + (combo - baseCombo()) * Math.random() > 5 && + running && { + type: "particle" as const, + duration: 100 * (Math.random() + 1), + time: levelTime, + size: coinSize / 2, + color: "red", + ethereal: true, + }; - requestAnimationFrame(tick); - lastTick = currentTick; + if (perks.top_is_lava) { + baseParticle && + flashes.push({ + ...baseParticle, + x: offsetXRoundedDown + Math.random() * gameZoneWidthRoundedUp, + y: 0, + vx: (Math.random() - 0.5) * 10, + vy: 5, + }); + } + + if (perks.left_is_lava && baseParticle) { + flashes.push({ + ...baseParticle, + x: offsetXRoundedDown, + y: Math.random() * gameZoneHeight, + vx: 5, + vy: (Math.random() - 0.5) * 10, + }); + } + + if (perks.right_is_lava && baseParticle) { + flashes.push({ + ...baseParticle, + x: offsetXRoundedDown + gameZoneWidthRoundedUp, + y: Math.random() * gameZoneHeight, + vx: -5, + vy: (Math.random() - 0.5) * 10, + }); + } + + if (perks.compound_interest) { + let x = puck, + attemps = 0; + do { + x = offsetXRoundedDown + gameZoneWidthRoundedUp * Math.random(); + attemps++; + } while (Math.abs(x - puck) < puckWidth / 2 && attemps < 10); + baseParticle && + flashes.push({ + ...baseParticle, + x, + y: gameZoneHeight, + vx: (Math.random() - 0.5) * 10, + vy: -5, + }); + } + if (perks.streak_shots) { + const pos = 0.5 - Math.random(); + baseParticle && + flashes.push({ + ...baseParticle, + duration: 100, + x: puck + puckWidth * pos, + y: gameZoneHeight - puckHeight, + vx: pos * 10, + vy: -5, + }); + } + } + } + + render(); + + requestAnimationFrame(tick); + lastTick = currentTick; } function isTelekinesisActive(ball: Ball) { - return perks.telekinesis && !ball.hitSinceBounce && ball.vy < 0; + return perks.telekinesis && !ball.hitSinceBounce && ball.vy < 0; } function ballTick(ball: Ball, delta: number) { - ball.previousVX = ball.vx; - ball.previousVY = ball.vy; + ball.previousVX = ball.vx; + ball.previousVY = ball.vy; - let speedLimitDampener = - 1 + - perks.telekinesis + - perks.ball_repulse_ball + - perks.puck_repulse_ball + - perks.ball_attract_ball; - if (isTelekinesisActive(ball)) { - speedLimitDampener += 3; - ball.vx += ((puck - ball.x) / 1000) * delta * perks.telekinesis; + let speedLimitDampener = + 1 + + perks.telekinesis + + perks.ball_repulse_ball + + perks.puck_repulse_ball + + perks.ball_attract_ball; + if (isTelekinesisActive(ball)) { + speedLimitDampener += 3; + ball.vx += ((puck - ball.x) / 1000) * delta * perks.telekinesis; + } + + if (ball.vx * ball.vx + ball.vy * ball.vy < baseSpeed * baseSpeed * 2) { + ball.vx *= 1 + 0.02 / speedLimitDampener; + ball.vy *= 1 + 0.02 / speedLimitDampener; + } else { + ball.vx *= 1 - 0.02 / speedLimitDampener; + ball.vy *= 1 - 0.02 / speedLimitDampener; + } + // Ball could get stuck horizontally because of ball-ball interactions in repulse/attract + if (Math.abs(ball.vy) < 0.2 * baseSpeed) { + ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener; + } + + if (perks.ball_repulse_ball) { + for (let b2 of balls) { + // avoid computing this twice, and repulsing itself + if (b2.x >= ball.x) continue; + repulse(ball, b2, perks.ball_repulse_ball, true); + } + } + if (perks.ball_attract_ball) { + for (let b2 of balls) { + // avoid computing this twice, and repulsing itself + if (b2.x >= ball.x) continue; + attract(ball, b2, perks.ball_attract_ball); + } + } + if ( + perks.puck_repulse_ball && + Math.abs(ball.x - puck) < + puckWidth / 2 + (ballSize * (9 + perks.puck_repulse_ball)) / 10 + ) { + repulse( + ball, + { + x: puck, + y: gameZoneHeight, + }, + perks.puck_repulse_ball + 1, + false, + ); + } + + if (perks.respawn && ball.hitItem?.length > 1 && !isSettingOn("basic")) { + for (let i = 0; i < ball.hitItem?.length - 1 && i < perks.respawn; i++) { + const { index, color } = ball.hitItem[i]; + if (bricks[index] || color === "black") continue; + const vertical = Math.random() > 0.5; + const dx = Math.random() > 0.5 ? 1 : -1; + const dy = Math.random() > 0.5 ? 1 : -1; + + flashes.push({ + type: "particle", + duration: 250, + ethereal: true, + time: levelTime, + size: coinSize / 2, + color, + x: brickCenterX(index) + (dx * brickWidth) / 2, + y: brickCenterY(index) + (dy * brickWidth) / 2, + vx: vertical ? 0 : -dx * baseSpeed, + vy: vertical ? -dy * baseSpeed : 0, + }); + } + } + + const borderHitCode = bordersHitCheck(ball, ballSize / 2, delta); + if (borderHitCode) { + if ( + perks.left_is_lava && + borderHitCode % 2 && + ball.x < offsetX + gameZoneWidth / 2 + ) { + resetCombo(ball.x, ball.y); } - if (ball.vx * ball.vx + ball.vy * ball.vy < baseSpeed * baseSpeed * 2) { - ball.vx *= 1 + 0.02 / speedLimitDampener; - ball.vy *= 1 + 0.02 / speedLimitDampener; + if ( + perks.right_is_lava && + borderHitCode % 2 && + ball.x > offsetX + gameZoneWidth / 2 + ) { + resetCombo(ball.x, ball.y); + } + + if (perks.top_is_lava && borderHitCode >= 2) { + resetCombo(ball.x, ball.y + ballSize); + } + sounds.wallBeep(ball.x); + ball.bouncesList?.push({ x: ball.previousX, y: ball.previousY }); + } + + // Puck collision + const ylimit = gameZoneHeight - puckHeight - ballSize / 2; + const ballIsUnderPuck = + Math.abs(ball.x - puck) < ballSize / 2 + puckWidth / 2; + if ( + ball.y > ylimit && + ball.vy > 0 && + (ballIsUnderPuck || (perks.extra_life && ball.y > ylimit + puckHeight / 2)) + ) { + if (ballIsUnderPuck) { + const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); + const angle = Math.atan2(-puckWidth / 2, ball.x - puck); + ball.vx = speed * Math.cos(angle); + ball.vy = speed * Math.sin(angle); + sounds.wallBeep(ball.x); } else { - ball.vx *= 1 - 0.02 / speedLimitDampener; - ball.vy *= 1 - 0.02 / speedLimitDampener; + ball.vy *= -1; + perks.extra_life = Math.max(0, perks.extra_life - 1); + sounds.lifeLost(ball.x); + if (!isSettingOn("basic")) { + for (let i = 0; i < 10; i++) + flashes.push({ + type: "particle", + ethereal: false, + color: "red", + destroyed: false, + duration: 150, + size: coinSize / 2, + time: levelTime, + x: ball.x, + y: ball.y, + vx: Math.random() * baseSpeed * 3, + vy: baseSpeed * 3, + }); + } } - // Ball could get stuck horizontally because of ball-ball interactions in repulse/attract - if (Math.abs(ball.vy) < 0.2 * baseSpeed) { - ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener; + if (perks.streak_shots) { + resetCombo(ball.x, ball.y); } - if (perks.ball_repulse_ball) { - for (let b2 of balls) { - // avoid computing this twice, and repulsing itself - if (b2.x >= ball.x) continue; - repulse(ball, b2, perks.ball_repulse_ball, true); - } + if (perks.respawn) { + ball.hitItem + .slice(0, -1) + .slice(0, perks.respawn) + .forEach(({ index, color }) => { + if (!bricks[index] && color !== "black") bricks[index] = color; + }); } - if (perks.ball_attract_ball) { - for (let b2 of balls) { - // avoid computing this twice, and repulsing itself - if (b2.x >= ball.x) continue; - attract(ball, b2, perks.ball_attract_ball); - } + ball.hitItem = []; + if (!ball.hitSinceBounce) { + runStatistics.misses++; + levelMisses++; + resetCombo(ball.x, ball.y); + flashes.push({ + type: "text", + text: "miss", + duration: 500, + time: levelTime, + size: puckHeight * 1.5, + color: "red", + x: puck, + y: gameZoneHeight - puckHeight * 2, + }); } + runStatistics.puck_bounces++; + ball.hitSinceBounce = 0; + ball.sapperUses = 0; + ball.piercedSinceBounce = 0; + ball.bouncesList = [ + { + x: ball.previousX, + y: ball.previousY, + }, + ]; + } + + if (ball.y > gameZoneHeight + ballSize / 2 && running) { + ball.destroyed = true; + runStatistics.balls_lost++; + if (!balls.find((b) => !b.destroyed)) { + gameOver( + "Game Over", + "You dropped the ball after catching " + score + " coins. ", + ); + } + } + const radius = ballSize / 2; + // Make ball/coin bonce, and return bricks that were hit + const { x, y, previousX, previousY } = ball; + + const vhit = hitsSomething(previousX, y, radius); + const hhit = hitsSomething(x, previousY, radius); + const chit = + (typeof vhit == "undefined" && + typeof hhit == "undefined" && + hitsSomething(x, y, radius)) || + undefined; + + const hitBrick = vhit ?? hhit ?? chit; + let sturdyBounce = + hitBrick && + bricks[hitBrick] !== "black" && + perks.sturdy_bricks && + perks.sturdy_bricks > Math.random() * 5; + + let pierce = false; + if (sturdyBounce || typeof hitBrick === "undefined") { + // cannot pierce + } else if (shouldPierceByColor(vhit, hhit, chit)) { + pierce = true; + } else if (ball.piercedSinceBounce < perks.pierce * 3) { + pierce = true; + ball.piercedSinceBounce++; + } + + if (typeof vhit !== "undefined" || typeof chit !== "undefined") { + if (!pierce) { + ball.y = ball.previousY; + ball.vy *= -1; + } + } + if (typeof hhit !== "undefined" || typeof chit !== "undefined") { + if (!pierce) { + ball.x = ball.previousX; + ball.vx *= -1; + } + } + + if (sturdyBounce) { + sounds.wallBeep(x); + return; + } + if (typeof hitBrick !== "undefined") { + const initialBrickColor = bricks[hitBrick]; + + explodeBrick(hitBrick, ball, false); + if ( - perks.puck_repulse_ball && - Math.abs(ball.x - puck) < - puckWidth / 2 + (ballSize * (9 + perks.puck_repulse_ball)) / 10 + ball.sapperUses < perks.sapper && + initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks + !bricks[hitBrick] ) { - repulse( - ball, - { - x: puck, - y: gameZoneHeight, - }, - perks.puck_repulse_ball + 1, - false, - ); + bricks[hitBrick] = "black"; + ball.sapperUses++; } + } - if (perks.respawn && ball.hitItem?.length > 1 && !isSettingOn("basic")) { - for (let i = 0; i < ball.hitItem?.length - 1 && i < perks.respawn; i++) { - const {index, color} = ball.hitItem[i]; - if (bricks[index] || color === "black") continue; - const vertical = Math.random() > 0.5; - const dx = Math.random() > 0.5 ? 1 : -1; - const dy = Math.random() > 0.5 ? 1 : -1; - - flashes.push({ - type: "particle", - duration: 250, - ethereal: true, - time: levelTime, - size: coinSize / 2, - color, - x: brickCenterX(index) + (dx * brickWidth) / 2, - y: brickCenterY(index) + (dy * brickWidth) / 2, - vx: vertical ? 0 : -dx * baseSpeed, - vy: vertical ? -dy * baseSpeed : 0, - }); - } - } - - const borderHitCode = bordersHitCheck(ball, ballSize / 2, delta); - if (borderHitCode) { - if ( - perks.left_is_lava && - borderHitCode % 2 && - ball.x < offsetX + gameZoneWidth / 2 - ) { - resetCombo(ball.x, ball.y); - } - - if ( - perks.right_is_lava && - borderHitCode % 2 && - ball.x > offsetX + gameZoneWidth / 2 - ) { - resetCombo(ball.x, ball.y); - } - - if (perks.top_is_lava && borderHitCode >= 2) { - resetCombo(ball.x, ball.y + ballSize); - } - sounds.wallBeep(ball.x); - ball.bouncesList?.push({x: ball.previousX, y: ball.previousY}); - } - - // Puck collision - const ylimit = gameZoneHeight - puckHeight - ballSize / 2; - const ballIsUnderPuck = Math.abs(ball.x - puck) < ballSize / 2 + puckWidth / 2 - if ( - ball.y > ylimit && - ball.vy > 0 && ( - ballIsUnderPuck - || (perks.extra_life && ball.y > ylimit + puckHeight / 2) - ) - ) { - - - if (ballIsUnderPuck) { - const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); - const angle = Math.atan2(-puckWidth / 2, ball.x - puck); - ball.vx = speed * Math.cos(angle); - ball.vy = speed * Math.sin(angle); - sounds.wallBeep(ball.x); - } else { - ball.vy *= -1 - perks.extra_life = Math.max(0, perks.extra_life - 1) - sounds.lifeLost(ball.x) - if (!isSettingOn("basic")) { - for (let i = 0; i < 10; i++) - flashes.push({ - type: 'particle', - ethereal: false, - color: 'red', - destroyed: false, - duration: 150, - size: coinSize / 2, - time: levelTime, - x: ball.x, - y: ball.y, - vx: Math.random() * baseSpeed * 3, - vy: baseSpeed * 3 - }) - } - } - if (perks.streak_shots) { - resetCombo(ball.x, ball.y); - } - - if (perks.respawn) { - ball.hitItem - .slice(0, -1) - .slice(0, perks.respawn) - .forEach(({index, color}) => { - if (!bricks[index] && color !== "black") bricks[index] = color; - }); - } - ball.hitItem = []; - if (!ball.hitSinceBounce) { - runStatistics.misses++; - levelMisses++; - resetCombo(ball.x, ball.y); - flashes.push({ - type: "text", - text: "miss", - duration: 500, - time: levelTime, - size: puckHeight * 1.5, - color: "red", - x: puck, - y: gameZoneHeight - puckHeight * 2, - }); - } - runStatistics.puck_bounces++; - ball.hitSinceBounce = 0; - ball.sapperUses = 0; - ball.piercedSinceBounce = 0; - ball.bouncesList = [ - { - x: ball.previousX, - y: ball.previousY, - }, - ]; - } - - if (ball.y > gameZoneHeight + ballSize / 2 && running) { - ball.destroyed = true; - runStatistics.balls_lost++; - if (!balls.find((b) => !b.destroyed)) { - gameOver( - "Game Over", - "You dropped the ball after catching " + score + " coins. ", - ); - } - } - const radius = ballSize / 2; - // Make ball/coin bonce, and return bricks that were hit - const {x, y, previousX, previousY} = ball; - - const vhit = hitsSomething(previousX, y, radius); - const hhit = hitsSomething(x, previousY, radius); - const chit = - (typeof vhit == "undefined" && - typeof hhit == "undefined" && - hitsSomething(x, y, radius)) || - undefined; - - const hitBrick = vhit ?? hhit ?? chit; - let sturdyBounce=hitBrick && bricks[hitBrick]!=='black' && perks.sturdy_bricks && perks.sturdy_bricks > Math.random() * 5 - - let pierce = false; - if(sturdyBounce || typeof hitBrick === "undefined") { - // cannot pierce - }else if(shouldPierceByColor(vhit, hhit, chit)){ - pierce = true; - }else if (ball.piercedSinceBounce < perks.pierce * 3 ){ - pierce=true - ball.piercedSinceBounce++; - } - - if (typeof vhit !== "undefined" || typeof chit !== "undefined") { - if (!pierce) { - ball.y = ball.previousY; - ball.vy *= -1; - } - } - if (typeof hhit !== "undefined" || typeof chit !== "undefined") { - if (!pierce) { - ball.x = ball.previousX; - ball.vx *= -1; - } - } - - if(sturdyBounce){ - sounds.wallBeep(x) - return - } - if (typeof hitBrick !== "undefined") { - const initialBrickColor = bricks[hitBrick]; - - explodeBrick(hitBrick, ball, false); - - if ( - ball.sapperUses < perks.sapper && - initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks - !bricks[hitBrick] - ) { - bricks[hitBrick] = "black"; - ball.sapperUses++; - } - } - - if (!isSettingOn("basic")) { - ball.sparks += (delta * (combo - 1)) / 30; - if (ball.sparks > 1) { - flashes.push({ - type: "particle", - duration: 100 * ball.sparks, - time: levelTime, - size: coinSize / 2, - color: ballsColor, - x: ball.x, - y: ball.y, - vx: (Math.random() - 0.5) * baseSpeed, - vy: (Math.random() - 0.5) * baseSpeed, - ethereal: false, - }); - ball.sparks = 0; - } + if (!isSettingOn("basic")) { + ball.sparks += (delta * (combo - 1)) / 30; + if (ball.sparks > 1) { + flashes.push({ + type: "particle", + duration: 100 * ball.sparks, + time: levelTime, + size: coinSize / 2, + color: ballsColor, + x: ball.x, + y: ball.y, + vx: (Math.random() - 0.5) * baseSpeed, + vy: (Math.random() - 0.5) * baseSpeed, + ethereal: false, + }); + ball.sparks = 0; } + } } const defaultRunStats = () => - ({ - started: Date.now(), - levelsPlayed: 0, - runTime: 0, - coins_spawned: 0, - score: 0, - bricks_broken: 0, - misses: 0, - balls_lost: 0, - puck_bounces: 0, - upgrades_picked: 1, - max_combo: 1, - max_level: 0, - }) as RunStats; + ({ + started: Date.now(), + levelsPlayed: 0, + runTime: 0, + coins_spawned: 0, + score: 0, + bricks_broken: 0, + misses: 0, + balls_lost: 0, + puck_bounces: 0, + upgrades_picked: 1, + max_combo: 1, + max_level: 0, + }) as RunStats; let runStatistics = defaultRunStats(); function resetRunStatistics() { - runStatistics = defaultRunStats(); + runStatistics = defaultRunStats(); } function getTotalScore() { - try { - return JSON.parse(localStorage.getItem("breakout_71_total_score") || "0"); - } catch (e) { - return 0; - } + try { + return JSON.parse(localStorage.getItem("breakout_71_total_score") || "0"); + } catch (e) { + return 0; + } } function addToTotalScore(points: number) { - if (isCreativeModeRun) return; - try { - localStorage.setItem( - "breakout_71_total_score", - JSON.stringify(getTotalScore() + points), - ); - } catch (e) { - } + if (isCreativeModeRun) return; + try { + localStorage.setItem( + "breakout_71_total_score", + JSON.stringify(getTotalScore() + points), + ); + } catch (e) {} } function addToTotalPlayTime(ms: number) { - try { - localStorage.setItem( - "breakout_71_total_play_time", - JSON.stringify( - JSON.parse(localStorage.getItem("breakout_71_total_play_time") || "0") + - ms, - ), - ); - } catch (e) { - } + try { + localStorage.setItem( + "breakout_71_total_play_time", + JSON.stringify( + JSON.parse(localStorage.getItem("breakout_71_total_play_time") || "0") + + ms, + ), + ); + } catch (e) {} } function gameOver(title: string, intro: string) { - if (!running) return; - pause(true); - stopRecording(); - addToTotalPlayTime(runStatistics.runTime); - runStatistics.max_level = currentLevel + 1; + if (!running) return; + pause(true); + stopRecording(); + addToTotalPlayTime(runStatistics.runTime); + runStatistics.max_level = currentLevel + 1; - let animationDelay = -300; - const getDelay = () => { - animationDelay += 800; - return "animation-delay:" + animationDelay + "ms;"; - }; - // unlocks - let unlocksInfo = ""; - const endTs = getTotalScore(); - const startTs = endTs - score; - const list = getUpgraderUnlockPoints(); - list - .filter((u) => u.threshold > startTs && u.threshold < endTs) - .forEach((u) => { - unlocksInfo += ` + let animationDelay = -300; + const getDelay = () => { + animationDelay += 800; + return "animation-delay:" + animationDelay + "ms;"; + }; + // unlocks + let unlocksInfo = ""; + const endTs = getTotalScore(); + const startTs = endTs - score; + const list = getUpgraderUnlockPoints(); + list + .filter((u) => u.threshold > startTs && u.threshold < endTs) + .forEach((u) => { + unlocksInfo += `

${u.title}

`; - }); - const previousUnlockAt = - findLast(list, (u) => u.threshold <= endTs)?.threshold || 0; - const nextUnlock = list.find((u) => u.threshold > endTs); + }); + const previousUnlockAt = + findLast(list, (u) => u.threshold <= endTs)?.threshold || 0; + const nextUnlock = list.find((u) => u.threshold > endTs); - if (nextUnlock) { - const total = nextUnlock?.threshold - previousUnlockAt; - const done = endTs - previousUnlockAt; - intro += `Score ${nextUnlock.threshold - endTs} more points to reach the next unlock.`; + if (nextUnlock) { + const total = nextUnlock?.threshold - previousUnlockAt; + const done = endTs - previousUnlockAt; + intro += `Score ${nextUnlock.threshold - endTs} more points to reach the next unlock.`; - const scaleX = (done / total).toFixed(2); - unlocksInfo += ` + const scaleX = (done / total).toFixed(2); + unlocksInfo += `

${nextUnlock.title}

`; - list - .slice(list.indexOf(nextUnlock) + 1) - .slice(0, 3) - .forEach((u) => { - unlocksInfo += ` + list + .slice(list.indexOf(nextUnlock) + 1) + .slice(0, 3) + .forEach((u) => { + unlocksInfo += `

${u.title}

`; - }); - } + }); + } - // Avoid the sad sound right as we restart a new games - combo = 1; + // Avoid the sad sound right as we restart a new games + combo = 1; - asyncAlert({ - allowClose: true, - title, - text: ` + asyncAlert({ + allowClose: true, + title, + text: ` ${isCreativeModeRun ? "

This test run and its score are not being recorded

" : ""}

${intro}

${unlocksInfo} `, - actions: [ - { - value: null, - text: "Start a new run", - help: "", - }, - ], - textAfterButtons: `
+ actions: [ + { + value: null, + text: "Start a new run", + help: "", + }, + ], + textAfterButtons: `
${getHistograms()} `, - }).then(() => restart()); + }).then(() => restart()); } function getHistograms() { - let runStats = ""; - try { - // Stores only top 100 runs - let runsHistory = JSON.parse( - localStorage.getItem("breakout_71_runs_history") || "[]", - ) as RunHistoryItem[]; - runsHistory.sort((a, b) => a.score - b.score).reverse(); - runsHistory = runsHistory.slice(0, 100); + let runStats = ""; + try { + // Stores only top 100 runs + let runsHistory = JSON.parse( + localStorage.getItem("breakout_71_runs_history") || "[]", + ) as RunHistoryItem[]; + runsHistory.sort((a, b) => a.score - b.score).reverse(); + runsHistory = runsHistory.slice(0, 100); - runsHistory.push({...runStatistics, perks, appVersion}); + runsHistory.push({ ...runStatistics, perks, appVersion }); - // Generate some histogram - if (!isCreativeModeRun) - localStorage.setItem( - "breakout_71_runs_history", - JSON.stringify(runsHistory, null, 2), - ); + // Generate some histogram + if (!isCreativeModeRun) + localStorage.setItem( + "breakout_71_runs_history", + JSON.stringify(runsHistory, null, 2), + ); - const makeHistogram = ( - title: string, - getter: (hi: RunHistoryItem) => number, - unit: string, - ) => { - let values = runsHistory.map((h) => getter(h) || 0); - let min = Math.min(...values); - let max = Math.max(...values); - // No point - if (min === max) return ""; - if (max - min < 10) { - // This is mostly useful for levels - min = Math.max(0, max - 10); - max = Math.max(max, min + 10); - } - // One bin per unique value, max 10 - const binsCount = Math.min(values.length, 10); - if (binsCount < 3) return ""; - const bins = [] as number[]; - const binsTotal = [] as number[]; - for (let i = 0; i < binsCount; i++) { - bins.push(0); - binsTotal.push(0); - } - const binSize = (max - min) / bins.length; - const binIndexOf = (v: number) => - Math.min(bins.length - 1, Math.floor((v - min) / binSize)); - values.forEach((v) => { - if (isNaN(v)) return; - const index = binIndexOf(v); - bins[index]++; - binsTotal[index] += v; - }); - if (bins.filter((b) => b).length < 3) return ""; - const maxBin = Math.max(...bins); - const lastValue = values[values.length - 1]; - const activeBin = binIndexOf(lastValue); + const makeHistogram = ( + title: string, + getter: (hi: RunHistoryItem) => number, + unit: string, + ) => { + let values = runsHistory.map((h) => getter(h) || 0); + let min = Math.min(...values); + let max = Math.max(...values); + // No point + if (min === max) return ""; + if (max - min < 10) { + // This is mostly useful for levels + min = Math.max(0, max - 10); + max = Math.max(max, min + 10); + } + // One bin per unique value, max 10 + const binsCount = Math.min(values.length, 10); + if (binsCount < 3) return ""; + const bins = [] as number[]; + const binsTotal = [] as number[]; + for (let i = 0; i < binsCount; i++) { + bins.push(0); + binsTotal.push(0); + } + const binSize = (max - min) / bins.length; + const binIndexOf = (v: number) => + Math.min(bins.length - 1, Math.floor((v - min) / binSize)); + values.forEach((v) => { + if (isNaN(v)) return; + const index = binIndexOf(v); + bins[index]++; + binsTotal[index] += v; + }); + if (bins.filter((b) => b).length < 3) return ""; + const maxBin = Math.max(...bins); + const lastValue = values[values.length - 1]; + const activeBin = binIndexOf(lastValue); - const bars = bins - .map((v, vi) => { - const style = `height: ${(v / maxBin) * 80}px`; - return ` { + const style = `height: ${(v / maxBin) * 80}px`; + return `${(!v && " ") || (vi == activeBin && lastValue + unit) || Math.round(binsTotal[vi] / v) + unit}`; - }) - .join(""); + }) + .join(""); - return `

${title} : ${lastValue}${unit}

+ return `

${title} : ${lastValue}${unit}

${bars}
`; - }; + }; - runStats += makeHistogram("Total score", (r) => r.score, ""); - runStats += makeHistogram( - "Catch rate", - (r) => Math.round((r.score / r.coins_spawned) * 100), - "%", - ); - runStats += makeHistogram("Bricks broken", (r) => r.bricks_broken, ""); - runStats += makeHistogram( - "Bricks broken per minute", - (r) => Math.round((r.bricks_broken / r.runTime) * 1000 * 60), - " bpm", - ); - runStats += makeHistogram( - "Hit rate", - (r) => Math.round((1 - r.misses / r.puck_bounces) * 100), - "%", - ); - runStats += makeHistogram( - "Duration per level", - (r) => Math.round(r.runTime / 1000 / r.levelsPlayed), - "s", - ); - runStats += makeHistogram("Level reached", (r) => r.levelsPlayed, ""); - runStats += makeHistogram("Upgrades applied", (r) => r.upgrades_picked, ""); - runStats += makeHistogram("Balls lost", (r) => r.balls_lost, ""); - runStats += makeHistogram( - "Average combo", - (r) => Math.round(r.coins_spawned / r.bricks_broken), - "", - ); - runStats += makeHistogram("Max combo", (r) => r.max_combo, ""); + runStats += makeHistogram("Total score", (r) => r.score, ""); + runStats += makeHistogram( + "Catch rate", + (r) => Math.round((r.score / r.coins_spawned) * 100), + "%", + ); + runStats += makeHistogram("Bricks broken", (r) => r.bricks_broken, ""); + runStats += makeHistogram( + "Bricks broken per minute", + (r) => Math.round((r.bricks_broken / r.runTime) * 1000 * 60), + " bpm", + ); + runStats += makeHistogram( + "Hit rate", + (r) => Math.round((1 - r.misses / r.puck_bounces) * 100), + "%", + ); + runStats += makeHistogram( + "Duration per level", + (r) => Math.round(r.runTime / 1000 / r.levelsPlayed), + "s", + ); + runStats += makeHistogram("Level reached", (r) => r.levelsPlayed, ""); + runStats += makeHistogram("Upgrades applied", (r) => r.upgrades_picked, ""); + runStats += makeHistogram("Balls lost", (r) => r.balls_lost, ""); + runStats += makeHistogram( + "Average combo", + (r) => Math.round(r.coins_spawned / r.bricks_broken), + "", + ); + runStats += makeHistogram("Max combo", (r) => r.max_combo, ""); - if (runStats) { - runStats = - `

Find below your run statistics compared to your ${runsHistory.length - 1} best runs.

` + - runStats; - } - } catch (e) { - console.warn(e); + if (runStats) { + runStats = + `

Find below your run statistics compared to your ${runsHistory.length - 1} best runs.

` + + runStats; } - return runStats; + } catch (e) { + console.warn(e); + } + return runStats; } function explodeBrick(index: number, ball: Ball, isExplosion: boolean) { - const color = bricks[index]; - if (!color) return; + const color = bricks[index]; + if (!color) return; - if (color === "black") { - delete bricks[index]; - const x = brickCenterX(index), - y = brickCenterY(index); + if (color === "black") { + delete bricks[index]; + const x = brickCenterX(index), + y = brickCenterY(index); - sounds.explode(ball.x); + sounds.explode(ball.x); - const col = index % gridSize; - const row = Math.floor(index / gridSize); - const size = 1 + perks.bigger_explosions; - // Break bricks around - for (let dx = -size; dx <= size; dx++) { - for (let dy = -size; dy <= size; dy++) { - const i = getRowColIndex(row + dy, col + dx); - if (bricks[i] && i !== -1) { - // Study bricks resist explisions too - if(bricks[i]!=='black' && perks.sturdy_bricks > Math.random() * 5) continue - explodeBrick(i, ball, true); - } - } + const col = index % gridSize; + const row = Math.floor(index / gridSize); + const size = 1 + perks.bigger_explosions; + // Break bricks around + for (let dx = -size; dx <= size; dx++) { + for (let dy = -size; dy <= size; dy++) { + const i = getRowColIndex(row + dy, col + dx); + if (bricks[i] && i !== -1) { + // Study bricks resist explisions too + if (bricks[i] !== "black" && perks.sturdy_bricks > Math.random() * 5) + continue; + explodeBrick(i, ball, true); } - - // Blow nearby coins - coins.forEach((c) => { - const dx = c.x - x; - const dy = c.y - y; - const d2 = Math.max(brickWidth, Math.abs(dx) + Math.abs(dy)); - c.vx += ((dx / d2) * 10 * size) / c.weight; - c.vy += ((dy / d2) * 10 * size) / c.weight; - }); - lastExplosion = Date.now(); - - flashes.push({ - type: "ball", - duration: 150, - time: levelTime, - size: brickWidth * 2, - color: "white", - x, - y, - }); - spawnExplosion( - 7 * (1 + perks.bigger_explosions), - x, - y, - "white", - 150, - coinSize, - ); - ball.hitSinceBounce++; - runStatistics.bricks_broken++; - } else if (color) { - // Even if it bounces we don't want to count that as a miss - ball.hitSinceBounce++; - - // Flashing is take care of by the tick loop - const x = brickCenterX(index), - y = brickCenterY(index); - - bricks[index] = ""; - - // coins = coins.filter((c) => !c.destroyed); - let coinsToSpawn = combo; - if (perks.sturdy_bricks) { - // +10% per level - coinsToSpawn += Math.ceil( - ((10 + perks.sturdy_bricks) / 10) * coinsToSpawn, - ); - } - - levelSpawnedCoins += coinsToSpawn; - runStatistics.coins_spawned += coinsToSpawn; - runStatistics.bricks_broken++; - const maxCoins = MAX_COINS * (isSettingOn("basic") ? 0.5 : 1); - const spawnableCoins = - coins.length > MAX_COINS ? 1 : Math.floor(maxCoins - coins.length) / 3; - - const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins)); - - while (coinsToSpawn > 0) { - const points = Math.min(pointsPerCoin, coinsToSpawn); - if (points < 0 || isNaN(points)) { - console.error({points}); - debugger; - } - - coinsToSpawn -= points; - - const cx = x + (Math.random() - 0.5) * (brickWidth - coinSize), - cy = y + (Math.random() - 0.5) * (brickWidth - coinSize); - - coins.push({ - points, - color: perks.metamorphosis ? color : "gold", - x: cx, - y: cy, - previousX: cx, - previousY: cy, - // Use previous speed because the ball has already bounced - vx: ball.previousVX * (0.5 + Math.random()), - vy: ball.previousVY * (0.5 + Math.random()), - sx: 0, - sy: 0, - a: Math.random() * Math.PI * 2, - sa: Math.random() - 0.5, - weight: 0.8 + Math.random() * 0.2, - }); - } - - combo += Math.max( - 0, - perks.streak_shots + - perks.compound_interest + - perks.left_is_lava + - perks.right_is_lava + - perks.top_is_lava + - perks.picky_eater - - Math.round(Math.random() * perks.soft_reset), - ); - - if (!isExplosion) { - // color change - if ( - (perks.picky_eater || perks.pierce_color) && - color !== ballsColor && - color - ) { - if (perks.picky_eater) { - resetCombo(ball.x, ball.y); - } - - ballsColor = color; - } else { - sounds.comboIncreaseMaybe(combo, ball.x, 1); - } - } - - flashes.push({ - type: "ball", - duration: 40, - time: levelTime, - size: brickWidth, - color: color, - x, - y, - }); - spawnExplosion(5 + Math.min(combo, 30), x, y, color, 150, coinSize / 2); + } } - if (!bricks[index] && color !== "black") { - ball.hitItem?.push({ - index, - color, - }); + // Blow nearby coins + coins.forEach((c) => { + const dx = c.x - x; + const dy = c.y - y; + const d2 = Math.max(brickWidth, Math.abs(dx) + Math.abs(dy)); + c.vx += ((dx / d2) * 10 * size) / c.weight; + c.vy += ((dy / d2) * 10 * size) / c.weight; + }); + lastExplosion = Date.now(); + + flashes.push({ + type: "ball", + duration: 150, + time: levelTime, + size: brickWidth * 2, + color: "white", + x, + y, + }); + spawnExplosion( + 7 * (1 + perks.bigger_explosions), + x, + y, + "white", + 150, + coinSize, + ); + ball.hitSinceBounce++; + runStatistics.bricks_broken++; + } else if (color) { + // Even if it bounces we don't want to count that as a miss + ball.hitSinceBounce++; + + // Flashing is take care of by the tick loop + const x = brickCenterX(index), + y = brickCenterY(index); + + bricks[index] = ""; + + // coins = coins.filter((c) => !c.destroyed); + let coinsToSpawn = combo; + if (perks.sturdy_bricks) { + // +10% per level + coinsToSpawn += Math.ceil( + ((10 + perks.sturdy_bricks) / 10) * coinsToSpawn, + ); } + + levelSpawnedCoins += coinsToSpawn; + runStatistics.coins_spawned += coinsToSpawn; + runStatistics.bricks_broken++; + const maxCoins = MAX_COINS * (isSettingOn("basic") ? 0.5 : 1); + const spawnableCoins = + coins.length > MAX_COINS ? 1 : Math.floor(maxCoins - coins.length) / 3; + + const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins)); + + while (coinsToSpawn > 0) { + const points = Math.min(pointsPerCoin, coinsToSpawn); + if (points < 0 || isNaN(points)) { + console.error({ points }); + debugger; + } + + coinsToSpawn -= points; + + const cx = x + (Math.random() - 0.5) * (brickWidth - coinSize), + cy = y + (Math.random() - 0.5) * (brickWidth - coinSize); + + coins.push({ + points, + color: perks.metamorphosis ? color : "gold", + x: cx, + y: cy, + previousX: cx, + previousY: cy, + // Use previous speed because the ball has already bounced + vx: ball.previousVX * (0.5 + Math.random()), + vy: ball.previousVY * (0.5 + Math.random()), + sx: 0, + sy: 0, + a: Math.random() * Math.PI * 2, + sa: Math.random() - 0.5, + weight: 0.8 + Math.random() * 0.2, + }); + } + + combo += Math.max( + 0, + perks.streak_shots + + perks.compound_interest + + perks.left_is_lava + + perks.right_is_lava + + perks.top_is_lava + + perks.picky_eater - + Math.round(Math.random() * perks.soft_reset), + ); + + if (!isExplosion) { + // color change + if ( + (perks.picky_eater || perks.pierce_color) && + color !== ballsColor && + color + ) { + if (perks.picky_eater) { + resetCombo(ball.x, ball.y); + } + + ballsColor = color; + } else { + sounds.comboIncreaseMaybe(combo, ball.x, 1); + } + } + + flashes.push({ + type: "ball", + duration: 40, + time: levelTime, + size: brickWidth, + color: color, + x, + y, + }); + spawnExplosion(5 + Math.min(combo, 30), x, y, color, 150, coinSize / 2); + } + + if (!bricks[index] && color !== "black") { + ball.hitItem?.push({ + index, + color, + }); + } } function max_levels() { - return 7 + perks.extra_levels; + return 7 + perks.extra_levels; } function render() { - if (running) needsRender = true; - if (!needsRender) { - return; - } - needsRender = false; + if (running) needsRender = true; + if (!needsRender) { + return; + } + needsRender = false; - const level = currentLevelInfo(); - const {width, height} = gameCanvas; - if (!width || !height) return; + const level = currentLevelInfo(); + const { width, height } = gameCanvas; + if (!width || !height) return; - scoreDisplay.innerText = `L${currentLevel + 1}/${max_levels()} $${score}`; - // Clear - if (!isSettingOn("basic") && !level.color && level.svg) { - // Without this the light trails everything - ctx.globalCompositeOperation = "source-over"; - ctx.globalAlpha = 1; - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, width, height); - - ctx.globalCompositeOperation = "screen"; - ctx.globalAlpha = 0.6; - coins.forEach((coin) => { - if (!coin.destroyed) - drawFuzzyBall(ctx, coin.color, coinSize * 2, coin.x, coin.y); - }); - balls.forEach((ball) => { - drawFuzzyBall(ctx, ballsColor, ballSize * 2, ball.x, ball.y); - }); - ctx.globalAlpha = 0.5; - bricks.forEach((color, index) => { - if (!color) return; - const x = brickCenterX(index), - y = brickCenterY(index); - drawFuzzyBall(ctx, color == "black" ? "#666" : color, brickWidth, x, y); - }); - ctx.globalAlpha = 1; - flashes.forEach((flash) => { - const {x, y, time, color, size, type, duration} = flash; - const elapsed = levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); - if (type === "ball") { - drawFuzzyBall(ctx, color, size, x, y); - } - if (type === "particle") { - drawFuzzyBall(ctx, color, size * 3, x, y); - } - }); - // Decides how brights the bg black parts can get - ctx.globalAlpha = 0.2; - ctx.globalCompositeOperation = "multiply"; - ctx.fillStyle = "black"; - ctx.fillRect(0, 0, width, height); - // Decides how dark the background black parts are when lit (1=black) - ctx.globalAlpha = 0.8; - ctx.globalCompositeOperation = "multiply"; - if (level.svg && background.width && background.complete) { - if (backgroundCanvas.title !== level.name) { - backgroundCanvas.title = level.name; - backgroundCanvas.width = lastWidth; - backgroundCanvas.height = lastHeight; - const bgctx = backgroundCanvas.getContext( - "2d", - ) as CanvasRenderingContext2D; - bgctx.fillStyle = level.color || "#000"; - bgctx.fillRect(0, 0, lastWidth, lastHeight); - const pattern = ctx.createPattern(background, "repeat"); - if (pattern) { - bgctx.fillStyle = pattern; - bgctx.fillRect(0, 0, width, height); - } - } - - ctx.drawImage(backgroundCanvas, 0, 0); - } else { - // Background not loaded yes - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, width, height); - } - } else { - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = level.color || "#000"; - ctx.fillRect(0, 0, width, height); - - flashes.forEach((flash) => { - const {x, y, time, color, size, type, duration} = flash; - const elapsed = levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); - if (type === "particle") { - drawBall(ctx, color, size, x, y); - } - }); - } - - ctx.globalAlpha = 1; + scoreDisplay.innerText = `L${currentLevel + 1}/${max_levels()} $${score}`; + // Clear + if (!isSettingOn("basic") && !level.color && level.svg) { + // Without this the light trails everything ctx.globalCompositeOperation = "source-over"; - const lastExplosionDelay = Date.now() - lastExplosion + 5; - const shaked = lastExplosionDelay < 200; - if (shaked) { - const amplitude = ((perks.bigger_explosions + 1) * 50) / lastExplosionDelay; - ctx.translate( - Math.sin(Date.now()) * amplitude, - Math.sin(Date.now() + 36) * amplitude, - ); - } - - - // Coins ctx.globalAlpha = 1; - - - coins.forEach((coin) => { - if (!coin.destroyed) { - - ctx.globalCompositeOperation = (coin.color === 'gold' || level.color ? "source-over" : "screen"); - drawCoin( - ctx, - coin.color, - coinSize, - coin.x, - coin.y, - level.color || "black", - coin.a, - ); - } - }); - - - // Black shadow around balls - if (!isSettingOn("basic")) { - ctx.globalCompositeOperation = "source-over"; - ctx.globalAlpha = Math.min(0.8, coins.length / 20); - balls.forEach((ball) => { - drawBall(ctx, level.color || "#000", ballSize * 6, ball.x, ball.y); - }); - } - - ctx.globalCompositeOperation = "source-over"; - renderAllBricks(); - + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "screen"; - flashes = flashes.filter( - (f) => levelTime - f.time < f.duration && !f.destroyed, - ); + ctx.globalAlpha = 0.6; + coins.forEach((coin) => { + if (!coin.destroyed) + drawFuzzyBall(ctx, coin.color, coinSize * 2, coin.x, coin.y); + }); + balls.forEach((ball) => { + drawFuzzyBall(ctx, ballsColor, ballSize * 2, ball.x, ball.y); + }); + ctx.globalAlpha = 0.5; + bricks.forEach((color, index) => { + if (!color) return; + const x = brickCenterX(index), + y = brickCenterY(index); + drawFuzzyBall(ctx, color == "black" ? "#666" : color, brickWidth, x, y); + }); + ctx.globalAlpha = 1; + flashes.forEach((flash) => { + const { x, y, time, color, size, type, duration } = flash; + const elapsed = levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); + if (type === "ball") { + drawFuzzyBall(ctx, color, size, x, y); + } + if (type === "particle") { + drawFuzzyBall(ctx, color, size * 3, x, y); + } + }); + // Decides how brights the bg black parts can get + ctx.globalAlpha = 0.2; + ctx.globalCompositeOperation = "multiply"; + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, width, height); + // Decides how dark the background black parts are when lit (1=black) + ctx.globalAlpha = 0.8; + ctx.globalCompositeOperation = "multiply"; + if (level.svg && background.width && background.complete) { + if (backgroundCanvas.title !== level.name) { + backgroundCanvas.title = level.name; + backgroundCanvas.width = lastWidth; + backgroundCanvas.height = lastHeight; + const bgctx = backgroundCanvas.getContext( + "2d", + ) as CanvasRenderingContext2D; + bgctx.fillStyle = level.color || "#000"; + bgctx.fillRect(0, 0, lastWidth, lastHeight); + const pattern = ctx.createPattern(background, "repeat"); + if (pattern) { + bgctx.fillStyle = pattern; + bgctx.fillRect(0, 0, width, height); + } + } + + ctx.drawImage(backgroundCanvas, 0, 0); + } else { + // Background not loaded yes + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, width, height); + } + } else { + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = level.color || "#000"; + ctx.fillRect(0, 0, width, height); flashes.forEach((flash) => { - const {x, y, time, color, size, type, duration} = flash; - const elapsed = levelTime - time; - ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); - if (type === "text") { - ctx.globalCompositeOperation = "source-over"; - drawText(ctx, flash.text, color, size, x, y - elapsed / 10); - } else if (type === "particle") { - ctx.globalCompositeOperation = "screen"; - drawBall(ctx, color, size, x, y); - drawFuzzyBall(ctx, color, size, x, y); - } + const { x, y, time, color, size, type, duration } = flash; + const elapsed = levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); + if (type === "particle") { + drawBall(ctx, color, size, x, y); + } }); + } + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + const lastExplosionDelay = Date.now() - lastExplosion + 5; + const shaked = lastExplosionDelay < 200; + if (shaked) { + const amplitude = ((perks.bigger_explosions + 1) * 50) / lastExplosionDelay; + ctx.translate( + Math.sin(Date.now()) * amplitude, + Math.sin(Date.now() + 36) * amplitude, + ); + } - if (perks.extra_life) { - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = puckColor; - for (let i = 0; i < perks.extra_life; i++) { + // Coins + ctx.globalAlpha = 1; - ctx.fillRect(offsetXRoundedDown, gameZoneHeight - puckHeight / 2 + 2 * i, gameZoneWidthRoundedUp, 1); - } + coins.forEach((coin) => { + if (!coin.destroyed) { + ctx.globalCompositeOperation = + coin.color === "gold" || level.color ? "source-over" : "screen"; + drawCoin( + ctx, + coin.color, + coinSize, + coin.x, + coin.y, + level.color || "black", + coin.a, + ); } + }); - ctx.globalAlpha = 1; + // Black shadow around balls + if (!isSettingOn("basic")) { ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = Math.min(0.8, coins.length / 20); balls.forEach((ball) => { - // The white border around is to distinguish colored balls from coins/bg - drawBall(ctx, ballsColor, ballSize, ball.x, ball.y, puckColor); - - if (isTelekinesisActive(ball)) { - ctx.strokeStyle = puckColor; - ctx.beginPath(); - ctx.bezierCurveTo(puck, gameZoneHeight, puck, ball.y, ball.x, ball.y); - ctx.stroke(); - } + drawBall(ctx, level.color || "#000", ballSize * 6, ball.x, ball.y); }); - // The puck + } + + ctx.globalCompositeOperation = "source-over"; + renderAllBricks(); + + ctx.globalCompositeOperation = "screen"; + flashes = flashes.filter( + (f) => levelTime - f.time < f.duration && !f.destroyed, + ); + + flashes.forEach((flash) => { + const { x, y, time, color, size, type, duration } = flash; + const elapsed = levelTime - time; + ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); + if (type === "text") { + ctx.globalCompositeOperation = "source-over"; + drawText(ctx, flash.text, color, size, x, y - elapsed / 10); + } else if (type === "particle") { + ctx.globalCompositeOperation = "screen"; + drawBall(ctx, color, size, x, y); + drawFuzzyBall(ctx, color, size, x, y); + } + }); + + if (perks.extra_life) { ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; - if (perks.streak_shots && combo > baseCombo()) { - drawPuck(ctx, "red", puckWidth, puckHeight, -2); + ctx.fillStyle = puckColor; + for (let i = 0; i < perks.extra_life; i++) { + ctx.fillRect( + offsetXRoundedDown, + gameZoneHeight - puckHeight / 2 + 2 * i, + gameZoneWidthRoundedUp, + 1, + ); } - drawPuck(ctx, puckColor, puckWidth, puckHeight); + } - if (combo > 1) { - ctx.globalCompositeOperation = "source-over"; - const comboText = "x " + combo; - const comboTextWidth = (comboText.length * puckHeight) / 1.8; - const totalWidth = comboTextWidth + coinSize * 2; - const left = puck - totalWidth / 2; - if (totalWidth < puckWidth) { - drawCoin( - ctx, - "gold", - coinSize, - left + coinSize / 2, - gameZoneHeight - puckHeight / 2, - puckColor, - 0, - ); - drawText( - ctx, - comboText, - "#000", - puckHeight, - left + coinSize * 1.5, - gameZoneHeight - puckHeight / 2, - true, - ); - } else { - drawText( - ctx, - comboText, - "#FFF", - puckHeight, - puck, - gameZoneHeight - puckHeight / 2, - false, - ); - } + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + balls.forEach((ball) => { + // The white border around is to distinguish colored balls from coins/bg + drawBall(ctx, ballsColor, ballSize, ball.x, ball.y, puckColor); + if (isTelekinesisActive(ball)) { + ctx.strokeStyle = puckColor; + ctx.beginPath(); + ctx.bezierCurveTo(puck, gameZoneHeight, puck, ball.y, ball.x, ball.y); + ctx.stroke(); } - // Borders - const hasCombo = combo > baseCombo(); + }); + // The puck + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + if (perks.streak_shots && combo > baseCombo()) { + drawPuck(ctx, "red", puckWidth, puckHeight, -2); + } + drawPuck(ctx, puckColor, puckWidth, puckHeight); + + if (combo > 1) { ctx.globalCompositeOperation = "source-over"; - if (offsetXRoundedDown) { - // draw outside of gaming area to avoid capturing borders in recordings - ctx.fillStyle = hasCombo && perks.left_is_lava ? "red" : puckColor; - ctx.fillRect(offsetX - 1, 0, 1, height); - ctx.fillStyle = hasCombo && perks.right_is_lava ? "red" : puckColor; - ctx.fillRect(width - offsetX + 1, 0, 1, height); + const comboText = "x " + combo; + const comboTextWidth = (comboText.length * puckHeight) / 1.8; + const totalWidth = comboTextWidth + coinSize * 2; + const left = puck - totalWidth / 2; + if (totalWidth < puckWidth) { + drawCoin( + ctx, + "gold", + coinSize, + left + coinSize / 2, + gameZoneHeight - puckHeight / 2, + puckColor, + 0, + ); + drawText( + ctx, + comboText, + "#000", + puckHeight, + left + coinSize * 1.5, + gameZoneHeight - puckHeight / 2, + true, + ); } else { - ctx.fillStyle = "red"; - if (hasCombo && perks.left_is_lava) ctx.fillRect(0, 0, 1, height); - if (hasCombo && perks.right_is_lava) ctx.fillRect(width - 1, 0, 1, height); + drawText( + ctx, + comboText, + "#FFF", + puckHeight, + puck, + gameZoneHeight - puckHeight / 2, + false, + ); } + } + // Borders + const hasCombo = combo > baseCombo(); + ctx.globalCompositeOperation = "source-over"; + if (offsetXRoundedDown) { + // draw outside of gaming area to avoid capturing borders in recordings + ctx.fillStyle = hasCombo && perks.left_is_lava ? "red" : puckColor; + ctx.fillRect(offsetX - 1, 0, 1, height); + ctx.fillStyle = hasCombo && perks.right_is_lava ? "red" : puckColor; + ctx.fillRect(width - offsetX + 1, 0, 1, height); + } else { + ctx.fillStyle = "red"; + if (hasCombo && perks.left_is_lava) ctx.fillRect(0, 0, 1, height); + if (hasCombo && perks.right_is_lava) ctx.fillRect(width - 1, 0, 1, height); + } - if (perks.top_is_lava && combo > baseCombo()){ - ctx.fillStyle = "red"; - ctx.fillRect(offsetXRoundedDown, 0, gameZoneWidthRoundedUp, 1); - } - const redBottom = perks.compound_interest && combo > baseCombo(); - ctx.fillStyle = redBottom ? "red" : puckColor; - if (isSettingOn("mobile-mode")) { - ctx.fillRect(offsetXRoundedDown, gameZoneHeight, gameZoneWidthRoundedUp, 1); - if (!running) { - drawText( - ctx, - "Press and hold here to play", - puckColor, - puckHeight, - lastWidth / 2, - gameZoneHeight + (lastHeight - gameZoneHeight) / 2, - ); - } - } else if (redBottom) { - ctx.fillRect( - offsetXRoundedDown, - gameZoneHeight - 1, - gameZoneWidthRoundedUp, - 1, - ); + if (perks.top_is_lava && combo > baseCombo()) { + ctx.fillStyle = "red"; + ctx.fillRect(offsetXRoundedDown, 0, gameZoneWidthRoundedUp, 1); + } + const redBottom = perks.compound_interest && combo > baseCombo(); + ctx.fillStyle = redBottom ? "red" : puckColor; + if (isSettingOn("mobile-mode")) { + ctx.fillRect(offsetXRoundedDown, gameZoneHeight, gameZoneWidthRoundedUp, 1); + if (!running) { + drawText( + ctx, + "Press and hold here to play", + puckColor, + puckHeight, + lastWidth / 2, + gameZoneHeight + (lastHeight - gameZoneHeight) / 2, + ); } + } else if (redBottom) { + ctx.fillRect( + offsetXRoundedDown, + gameZoneHeight - 1, + gameZoneWidthRoundedUp, + 1, + ); + } - if (shaked) { - ctx.resetTransform(); - } + if (shaked) { + ctx.resetTransform(); + } - recordOneFrame(); + recordOneFrame(); } let cachedBricksRender = document.createElement("canvas"); let cachedBricksRenderKey = ""; function renderAllBricks() { - ctx.globalAlpha = 1; + ctx.globalAlpha = 1; - const redBorderOnBricksWithWrongColor = - combo > baseCombo() && perks.picky_eater; + const redBorderOnBricksWithWrongColor = + combo > baseCombo() && perks.picky_eater; - const newKey = - gameZoneWidth + - "_" + - bricks.join("_") + - bombSVG.complete + - "_" + - redBorderOnBricksWithWrongColor + - "_" + - ballsColor + - "_" + - perks.pierce_color; - if (newKey !== cachedBricksRenderKey) { - cachedBricksRenderKey = newKey; + const newKey = + gameZoneWidth + + "_" + + bricks.join("_") + + bombSVG.complete + + "_" + + redBorderOnBricksWithWrongColor + + "_" + + ballsColor + + "_" + + perks.pierce_color; + if (newKey !== cachedBricksRenderKey) { + cachedBricksRenderKey = newKey; - cachedBricksRender.width = gameZoneWidth; - cachedBricksRender.height = gameZoneWidth + 1; - const canctx = cachedBricksRender.getContext( - "2d", - ) as CanvasRenderingContext2D; - canctx.clearRect(0, 0, gameZoneWidth, gameZoneWidth); - canctx.resetTransform(); - canctx.translate(-offsetX, 0); - // Bricks - bricks.forEach((color, index) => { - const x = brickCenterX(index), - y = brickCenterY(index); + cachedBricksRender.width = gameZoneWidth; + cachedBricksRender.height = gameZoneWidth + 1; + const canctx = cachedBricksRender.getContext( + "2d", + ) as CanvasRenderingContext2D; + canctx.clearRect(0, 0, gameZoneWidth, gameZoneWidth); + canctx.resetTransform(); + canctx.translate(-offsetX, 0); + // Bricks + bricks.forEach((color, index) => { + const x = brickCenterX(index), + y = brickCenterY(index); - if (!color) return; + if (!color) return; - const borderColor = - (ballsColor !== color && - color !== "black" && - redBorderOnBricksWithWrongColor && - "red") || - color; + const borderColor = + (ballsColor !== color && + color !== "black" && + redBorderOnBricksWithWrongColor && + "red") || + color; - drawBrick(canctx, color, borderColor, x, y); - if (color === "black") { - canctx.globalCompositeOperation = "source-over"; - drawIMG(canctx, bombSVG, brickWidth, x, y); - } - }); - } + drawBrick(canctx, color, borderColor, x, y); + if (color === "black") { + canctx.globalCompositeOperation = "source-over"; + drawIMG(canctx, bombSVG, brickWidth, x, y); + } + }); + } - ctx.drawImage(cachedBricksRender, offsetX, 0); + ctx.drawImage(cachedBricksRender, offsetX, 0); } let cachedGraphics: { [k: string]: HTMLCanvasElement } = {}; function drawPuck( - ctx: CanvasRenderingContext2D, - color: colorString, - puckWidth: number, - puckHeight: number, - yOffset = 0, + ctx: CanvasRenderingContext2D, + color: colorString, + puckWidth: number, + puckHeight: number, + yOffset = 0, ) { - const key = "puck" + color + "_" + puckWidth + "_" + puckHeight; + const key = "puck" + color + "_" + puckWidth + "_" + puckHeight; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = puckWidth; - can.height = puckHeight * 2; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.fillStyle = color; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = puckWidth; + can.height = puckHeight * 2; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.fillStyle = color; - canctx.beginPath(); - canctx.moveTo(0, puckHeight * 2); - canctx.lineTo(0, puckHeight * 1.25); - canctx.bezierCurveTo( - 0, - puckHeight * 0.75, - puckWidth, - puckHeight * 0.75, - puckWidth, - puckHeight * 1.25, - ); - canctx.lineTo(puckWidth, puckHeight * 2); - canctx.fill(); - cachedGraphics[key] = can; - } - - ctx.drawImage( - cachedGraphics[key], - Math.round(puck - puckWidth / 2), - gameZoneHeight - puckHeight * 2 + yOffset, + canctx.beginPath(); + canctx.moveTo(0, puckHeight * 2); + canctx.lineTo(0, puckHeight * 1.25); + canctx.bezierCurveTo( + 0, + puckHeight * 0.75, + puckWidth, + puckHeight * 0.75, + puckWidth, + puckHeight * 1.25, ); + canctx.lineTo(puckWidth, puckHeight * 2); + canctx.fill(); + cachedGraphics[key] = can; + } + + ctx.drawImage( + cachedGraphics[key], + Math.round(puck - puckWidth / 2), + gameZoneHeight - puckHeight * 2 + yOffset, + ); } function drawBall( - ctx: CanvasRenderingContext2D, - color: colorString, - width: number, - x: number, - y: number, - borderColor = "", + ctx: CanvasRenderingContext2D, + color: colorString, + width: number, + x: number, + y: number, + borderColor = "", ) { - const key = "ball" + color + "_" + width + "_" + borderColor; + const key = "ball" + color + "_" + width + "_" + borderColor; - const size = Math.round(width); - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + const size = Math.round(width); + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.beginPath(); - canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI); - canctx.fillStyle = color; - canctx.fill(); - if (borderColor) { - canctx.lineWidth = 2; - canctx.strokeStyle = borderColor; - canctx.stroke(); - } - - cachedGraphics[key] = can; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.beginPath(); + canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI); + canctx.fillStyle = color; + canctx.fill(); + if (borderColor) { + canctx.lineWidth = 2; + canctx.strokeStyle = borderColor; + canctx.stroke(); } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), - ); + + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } const angles = 32; function drawCoin( - ctx: CanvasRenderingContext2D, - color: colorString, - size: number, - x: number, - y: number, - borderColor: colorString, - rawAngle: number, + ctx: CanvasRenderingContext2D, + color: colorString, + size: number, + x: number, + y: number, + borderColor: colorString, + rawAngle: number, ) { - const angle = - ((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) % - angles; - const key = - "coin with halo" + - "_" + - color + - "_" + - size + - "_" + - borderColor + - "_" + - (color === "gold" ? angle : "whatever"); + const angle = + ((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) % + angles; + const key = + "coin with halo" + + "_" + + color + + "_" + + size + + "_" + + borderColor + + "_" + + (color === "gold" ? angle : "whatever"); - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; - // coin - canctx.beginPath(); - canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); - canctx.fillStyle = color; - canctx.fill(); + // coin + canctx.beginPath(); + canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); + canctx.fillStyle = color; + canctx.fill(); - if (color === "gold") { - canctx.strokeStyle = borderColor; - canctx.stroke(); + if (color === "gold") { + canctx.strokeStyle = borderColor; + canctx.stroke(); - canctx.beginPath(); - canctx.arc(size / 2, size / 2, (size / 2) * 0.6, 0, 2 * Math.PI); - canctx.fillStyle = "rgba(255,255,255,0.5)"; - canctx.fill(); + canctx.beginPath(); + canctx.arc(size / 2, size / 2, (size / 2) * 0.6, 0, 2 * Math.PI); + canctx.fillStyle = "rgba(255,255,255,0.5)"; + canctx.fill(); - canctx.translate(size / 2, size / 2); - canctx.rotate(angle / 16); - canctx.translate(-size / 2, -size / 2); + canctx.translate(size / 2, size / 2); + canctx.rotate(angle / 16); + canctx.translate(-size / 2, -size / 2); - canctx.globalCompositeOperation = "multiply"; - drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); - drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); - } - cachedGraphics[key] = can; + canctx.globalCompositeOperation = "multiply"; + drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); + drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), - ); + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } function drawFuzzyBall( - ctx: CanvasRenderingContext2D, - color: colorString, - width: number, - x: number, - y: number, + ctx: CanvasRenderingContext2D, + color: colorString, + width: number, + x: number, + y: number, ) { - const key = "fuzzy-circle" + color + "_" + width; - if (!color) debugger; - const size = Math.round(width * 3); - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + const key = "fuzzy-circle" + color + "_" + width; + if (!color) debugger; + const size = Math.round(width * 3); + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - const gradient = canctx.createRadialGradient( - size / 2, - size / 2, - 0, - size / 2, - size / 2, - size / 2, - ); - gradient.addColorStop(0, color); - gradient.addColorStop(1, "transparent"); - canctx.fillStyle = gradient; - canctx.fillRect(0, 0, size, size); - cachedGraphics[key] = can; - } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + const gradient = canctx.createRadialGradient( + size / 2, + size / 2, + 0, + size / 2, + size / 2, + size / 2, ); + gradient.addColorStop(0, color); + gradient.addColorStop(1, "transparent"); + canctx.fillStyle = gradient; + canctx.fillRect(0, 0, size, size); + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } function drawBrick( - ctx: CanvasRenderingContext2D, - color: colorString, - borderColor: colorString, - x: number, - y: number, + ctx: CanvasRenderingContext2D, + color: colorString, + borderColor: colorString, + x: number, + y: number, ) { - const tlx = Math.ceil(x - brickWidth / 2); - const tly = Math.ceil(y - brickWidth / 2); - const brx = Math.ceil(x + brickWidth / 2) - 1; - const bry = Math.ceil(y + brickWidth / 2) - 1; + const tlx = Math.ceil(x - brickWidth / 2); + const tly = Math.ceil(y - brickWidth / 2); + const brx = Math.ceil(x + brickWidth / 2) - 1; + const bry = Math.ceil(y + brickWidth / 2) - 1; - const width = brx - tlx, - height = bry - tly; - const key = "brick" + color + "_" + borderColor + "_" + width + "_" + height; + const width = brx - tlx, + height = bry - tly; + const key = "brick" + color + "_" + borderColor + "_" + width + "_" + height; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = width; - can.height = height; - const bord = 2; - const cornerRadius = 2; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = width; + can.height = height; + const bord = 2; + const cornerRadius = 2; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.fillStyle = color; - canctx.strokeStyle = borderColor; - canctx.lineJoin = "round"; - canctx.lineWidth = bord; - roundRect( - canctx, - bord / 2, - bord / 2, - width - bord, - height - bord, - cornerRadius, - ); - canctx.fill(); - canctx.stroke(); + canctx.fillStyle = color; + canctx.strokeStyle = borderColor; + canctx.lineJoin = "round"; + canctx.lineWidth = bord; + roundRect( + canctx, + bord / 2, + bord / 2, + width - bord, + height - bord, + cornerRadius, + ); + canctx.fill(); + canctx.stroke(); - cachedGraphics[key] = can; - } - ctx.drawImage(cachedGraphics[key], tlx, tly, width, height); - // It's not easy to have a 1px gap between bricks without antialiasing + cachedGraphics[key] = can; + } + ctx.drawImage(cachedGraphics[key], tlx, tly, width, height); + // It's not easy to have a 1px gap between bricks without antialiasing } function roundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number, + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, ) { - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + width - radius, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius); - ctx.lineTo(x + width, y + height - radius); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); - ctx.lineTo(x + radius, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); } - function drawIMG( - ctx: CanvasRenderingContext2D, - img: HTMLImageElement, - size: number, - x: number, - y: number, + ctx: CanvasRenderingContext2D, + img: HTMLImageElement, + size: number, + x: number, + y: number, ) { - const key = "svg" + img + "_" + size + "_" + img.complete; + const key = "svg" + img + "_" + size + "_" + img.complete; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; - const ratio = size / Math.max(img.width, img.height); - const w = img.width * ratio; - const h = img.height * ratio; - canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h); + const ratio = size / Math.max(img.width, img.height); + const w = img.width * ratio; + const h = img.height * ratio; + canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h); - cachedGraphics[key] = can; - } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), - ); + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } function drawText( - ctx: CanvasRenderingContext2D, - text: string, - color: colorString, - fontSize: number, - x: number, - y: number, - left = false, + ctx: CanvasRenderingContext2D, + text: string, + color: colorString, + fontSize: number, + x: number, + y: number, + left = false, ) { - const key = "text" + text + "_" + color + "_" + fontSize + "_" + left; + const key = "text" + text + "_" + color + "_" + fontSize + "_" + left; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = fontSize * text.length; - can.height = fontSize; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.fillStyle = color; - canctx.textAlign = left ? "left" : "center"; - canctx.textBaseline = "middle"; - canctx.font = fontSize + "px monospace"; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = fontSize * text.length; + can.height = fontSize; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.fillStyle = color; + canctx.textAlign = left ? "left" : "center"; + canctx.textBaseline = "middle"; + canctx.font = fontSize + "px monospace"; - canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width); + canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width); - cachedGraphics[key] = can; - } - ctx.drawImage( - cachedGraphics[key], - left ? x : Math.round(x - cachedGraphics[key].width / 2), - Math.round(y - cachedGraphics[key].height / 2), - ); + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + left ? x : Math.round(x - cachedGraphics[key].width / 2), + Math.round(y - cachedGraphics[key].height / 2), + ); } - let levelTime = 0; // Limits skip last to one use per level let level_skip_last_uses = 0; window.addEventListener("visibilitychange", () => { - if (document.hidden) { - pause(true); - } + if (document.hidden) { + pause(true); + } }); const scoreDisplay = document.getElementById("score") as HTMLButtonElement; let alertsOpen = 0, - closeModal: null | (() => void) = null; + closeModal: null | (() => void) = null; type AsyncAlertAction = { - text?: string; - value?: t; - help?: string; - disabled?: boolean; - icon?: string; - className?: string; + text?: string; + value?: t; + help?: string; + disabled?: boolean; + icon?: string; + className?: string; }; function asyncAlert({ - title, - text, - actions, - allowClose = true, - textAfterButtons = "", - actionsAsGrid = false, - }: { - title?: string; - text?: string; - actions?: AsyncAlertAction[]; - textAfterButtons?: string; - allowClose?: boolean; - actionsAsGrid?: boolean; + title, + text, + actions, + allowClose = true, + textAfterButtons = "", + actionsAsGrid = false, +}: { + title?: string; + text?: string; + actions?: AsyncAlertAction[]; + textAfterButtons?: string; + allowClose?: boolean; + actionsAsGrid?: boolean; }): Promise { - alertsOpen++; - return new Promise((resolve) => { - const popupWrap = document.createElement("div"); - document.body.appendChild(popupWrap); - popupWrap.className = "popup " + (actionsAsGrid ? "actionsAsGrid " : ""); + alertsOpen++; + return new Promise((resolve) => { + const popupWrap = document.createElement("div"); + document.body.appendChild(popupWrap); + popupWrap.className = "popup " + (actionsAsGrid ? "actionsAsGrid " : ""); - function closeWithResult(value: t | undefined) { - resolve(value); - // Doing this async lets the menu scroll persist if it's shown a second time - setTimeout(() => { - document.body.removeChild(popupWrap); - }); - } + function closeWithResult(value: t | undefined) { + resolve(value); + // Doing this async lets the menu scroll persist if it's shown a second time + setTimeout(() => { + document.body.removeChild(popupWrap); + }); + } - if (allowClose) { - const closeButton = document.createElement("button"); - closeButton.title = "close"; - closeButton.className = "close-modale"; - closeButton.addEventListener("click", (e) => { - e.preventDefault(); - closeWithResult(undefined); - }); - closeModal = () => { - closeWithResult(undefined); - }; - popupWrap.appendChild(closeButton); - } + if (allowClose) { + const closeButton = document.createElement("button"); + closeButton.title = "close"; + closeButton.className = "close-modale"; + closeButton.addEventListener("click", (e) => { + e.preventDefault(); + closeWithResult(undefined); + }); + closeModal = () => { + closeWithResult(undefined); + }; + popupWrap.appendChild(closeButton); + } - const popup = document.createElement("div"); + const popup = document.createElement("div"); - if (title) { - const p = document.createElement("h2"); - p.innerHTML = title; - popup.appendChild(p); - } + if (title) { + const p = document.createElement("h2"); + p.innerHTML = title; + popup.appendChild(p); + } - if (text) { - const p = document.createElement("div"); - p.innerHTML = text; - popup.appendChild(p); - } + if (text) { + const p = document.createElement("div"); + p.innerHTML = text; + popup.appendChild(p); + } - const buttons = document.createElement("section"); - popup.appendChild(buttons); + const buttons = document.createElement("section"); + popup.appendChild(buttons); - actions - ?.filter((i) => i) - .forEach(({text, value, help, disabled, className = "", icon = ""}) => { - const button = document.createElement("button"); + actions + ?.filter((i) => i) + .forEach(({ text, value, help, disabled, className = "", icon = "" }) => { + const button = document.createElement("button"); - button.innerHTML = ` + button.innerHTML = ` ${icon}
${text} ${help || ""}
`; - if (disabled) { - button.setAttribute("disabled", "disabled"); - } else { - button.addEventListener("click", (e) => { - e.preventDefault(); - closeWithResult(value); - }); - } - button.className = className; - buttons.appendChild(button); - }); - - if (textAfterButtons) { - const p = document.createElement("div"); - p.className = "textAfterButtons"; - p.innerHTML = textAfterButtons; - popup.appendChild(p); + if (disabled) { + button.setAttribute("disabled", "disabled"); + } else { + button.addEventListener("click", (e) => { + e.preventDefault(); + closeWithResult(value); + }); } + button.className = className; + buttons.appendChild(button); + }); - popupWrap.appendChild(popup); - ( - popup.querySelector("button:not([disabled])") as HTMLButtonElement - )?.focus(); - }).then( - (v: unknown) => { - alertsOpen--; - closeModal = null; - return v as t | undefined; - }, - () => { - closeModal = null; - alertsOpen--; - }, - ); + if (textAfterButtons) { + const p = document.createElement("div"); + p.className = "textAfterButtons"; + p.innerHTML = textAfterButtons; + popup.appendChild(p); + } + + popupWrap.appendChild(popup); + ( + popup.querySelector("button:not([disabled])") as HTMLButtonElement + )?.focus(); + }).then( + (v: unknown) => { + alertsOpen--; + closeModal = null; + return v as t | undefined; + }, + () => { + closeModal = null; + alertsOpen--; + }, + ); } // Settings let cachedSettings: Partial<{ [key in OptionId]: boolean }> = {}; export function isSettingOn(key: OptionId) { - if (typeof cachedSettings[key] == "undefined") { - try { - const ls = localStorage.getItem("breakout-settings-enable-" + key); - if (ls) cachedSettings[key] = JSON.parse(ls) as boolean; - } catch (e) { - console.warn(e); - } + if (typeof cachedSettings[key] == "undefined") { + try { + const ls = localStorage.getItem("breakout-settings-enable-" + key); + if (ls) cachedSettings[key] = JSON.parse(ls) as boolean; + } catch (e) { + console.warn(e); } - return cachedSettings[key] ?? options[key]?.default ?? false; + } + return cachedSettings[key] ?? options[key]?.default ?? false; } export function toggleSetting(key: OptionId) { - cachedSettings[key] = !isSettingOn(key); - try { - localStorage.setItem( - "breakout-settings-enable-" + key, - JSON.stringify(cachedSettings[key]), - ); - } catch (e) { - console.warn(e); - } - options[key].afterChange(); + cachedSettings[key] = !isSettingOn(key); + try { + localStorage.setItem( + "breakout-settings-enable-" + key, + JSON.stringify(cachedSettings[key]), + ); + } catch (e) { + console.warn(e); + } + options[key].afterChange(); } scoreDisplay.addEventListener("click", (e) => { - e.preventDefault(); - openScorePanel().then(); + e.preventDefault(); + openScorePanel().then(); }); async function openScorePanel() { - pause(true); - const cb = await asyncAlert({ - title: ` ${score} points at level ${currentLevel + 1} / ${max_levels()}`, - text: ` + pause(true); + const cb = await asyncAlert({ + title: ` ${score} points at level ${currentLevel + 1} / ${max_levels()}`, + text: ` ${isCreativeModeRun ? "

This is a test run, score is not recorded permanently

" : ""}

Upgrades picked so far :

${pickedUpgradesHTMl()}

`, - allowClose: true, - actions: [ - { - text: "Resume", - help: "Return to your run", - value: () => { - }, - }, - { - text: "Restart", - help: "Start a brand new run.", - value: () => { - restart(); - }, - }, - ], - }); - if (cb) { - cb(); - } + allowClose: true, + actions: [ + { + text: "Resume", + help: "Return to your run", + value: () => {}, + }, + { + text: "Restart", + help: "Start a brand new run.", + value: () => { + restart(); + }, + }, + ], + }); + if (cb) { + cb(); + } } document.getElementById("menu")?.addEventListener("click", (e) => { - e.preventDefault(); - openSettingsPanel().then(); + e.preventDefault(); + openSettingsPanel().then(); }); async function openSettingsPanel() { - pause(true); + pause(true); - const actions: AsyncAlertAction<() => void>[] = [ - { - text: "Resume", - help: "Return to your run", - value() { - }, - }, - { - text: "Starting perk", - help: "Try perks and levels you unlocked", - value() { - openUnlocksList(); - }, - }, - ]; + const actions: AsyncAlertAction<() => void>[] = [ + { + text: "Resume", + help: "Return to your run", + value() {}, + }, + { + text: "Starting perk", + help: "Try perks and levels you unlocked", + value() { + openUnlocksList(); + }, + }, + ]; - for (const key of Object.keys(options) as OptionId[]) { - if (options[key]) - actions.push({ - disabled: options[key].disabled(), - icon: isSettingOn(key) - ? icons["icon:checkmark_checked"] - : icons["icon:checkmark_unchecked"], - text: options[key].name, - help: options[key].help, - value: () => { - toggleSetting(key); - openSettingsPanel(); - }, - }); + for (const key of Object.keys(options) as OptionId[]) { + if (options[key]) + actions.push({ + disabled: options[key].disabled(), + icon: isSettingOn(key) + ? icons["icon:checkmark_checked"] + : icons["icon:checkmark_unchecked"], + text: options[key].name, + help: options[key].help, + value: () => { + toggleSetting(key); + openSettingsPanel(); + }, + }); + } + const creativeModeThreshold = Math.max(...upgrades.map((u) => u.threshold)); + + if (document.fullscreenEnabled || document.webkitFullscreenEnabled) { + if (document.fullscreenElement !== null) { + actions.push({ + text: "Exit Fullscreen", + icon: icons["icon:exit_fullscreen"], + help: "Might not work on some machines", + value() { + toggleFullScreen(); + }, + }); + } else { + actions.push({ + icon: icons["icon:fullscreen"], + text: "Fullscreen", + help: "Might not work on some machines", + value() { + toggleFullScreen(); + }, + }); } - const creativeModeThreshold = Math.max(...upgrades.map((u) => u.threshold)); + } + actions.push({ + text: "Creative mode", + help: + getTotalScore() < creativeModeThreshold + ? "Unlocks at total score $" + creativeModeThreshold + : "Test runs with custom perks", + disabled: getTotalScore() < creativeModeThreshold, + async value() { + let creativeModePerks: Partial<{ [id in PerkId]: number }> = {}, + choice: "start" | Upgrade | void; + while ( + (choice = await asyncAlert<"start" | Upgrade>({ + title: "Select perks", + text: 'Select perks below and press "start run" to try them out in a test run. Scores and stats are not recorded.', + actionsAsGrid: true, + actions: [ + ...upgrades.map((u) => ({ + icon: u.icon, + text: u.name, + help: (creativeModePerks[u.id] || 0) + "/" + u.max, + value: u, + className: creativeModePerks[u.id] + ? "" + : "grey-out-unless-hovered", + })), + { + text: "Start run", + value: "start", + }, + ], + })) + ) { + if (choice === "start") { + restart(creativeModePerks); - if (document.fullscreenEnabled || document.webkitFullscreenEnabled) { - if (document.fullscreenElement !== null) { - actions.push({ - text: "Exit Fullscreen", - icon: icons["icon:exit_fullscreen"], - help: "Might not work on some machines", - value() { - toggleFullScreen(); - }, - }); - } else { - actions.push({ - icon: icons["icon:fullscreen"], - text: "Fullscreen", - help: "Might not work on some machines", - value() { - toggleFullScreen(); - }, - }); + break; + } else if (choice) { + creativeModePerks[choice.id] = + ((creativeModePerks[choice.id] || 0) + 1) % (choice.max + 1); } - } - actions.push({ - text: "Creative mode", - help: - getTotalScore() < creativeModeThreshold - ? "Unlocks at total score $" + creativeModeThreshold - : "Test runs with custom perks", - disabled: getTotalScore() < creativeModeThreshold, - async value() { - let creativeModePerks: Partial<{ [id in PerkId]: number }> = {}, - choice: "start" | Upgrade | void; - while ( - (choice = await asyncAlert<"start" | Upgrade>({ - title: "Select perks", - text: 'Select perks below and press "start run" to try them out in a test run. Scores and stats are not recorded.', - actionsAsGrid: true, - actions: [ - ...upgrades.map((u) => ({ - icon: u.icon, - text: u.name, - help: (creativeModePerks[u.id] || 0) + "/" + u.max, - value: u, - className: creativeModePerks[u.id] - ? "" - : "grey-out-unless-hovered", - })), - { - text: "Start run", - value: "start", - }, - ], - })) - ) { - if (choice === "start") { - restart(creativeModePerks); + } + }, + }); + actions.push({ + text: "Reset Game", + help: "Erase high score and statistics", + async value() { + if ( + await asyncAlert({ + title: "Reset", + actions: [ + { + text: "Yes", + value: true, + }, + { + text: "No", + value: false, + }, + ], + allowClose: true, + }) + ) { + localStorage.clear(); + window.location.reload(); + } + }, + }); - break; - } else if (choice) { - creativeModePerks[choice.id] = - ((creativeModePerks[choice.id] || 0) + 1) % (choice.max + 1); - } - } - }, - }); - actions.push({ - text: "Reset Game", - help: "Erase high score and statistics", - async value() { - if ( - await asyncAlert({ - title: "Reset", - actions: [ - { - text: "Yes", - value: true, - }, - { - text: "No", - value: false, - }, - ], - allowClose: true, - }) - ) { - localStorage.clear(); - window.location.reload(); - } - }, - }); - - const cb = await asyncAlert<() => void>({ - title: "Breakout 71", - text: ``, - allowClose: true, - actions, - textAfterButtons: ` + const cb = await asyncAlert<() => void>({ + title: "Breakout 71", + text: ``, + allowClose: true, + actions, + textAfterButtons: `

Made in France by Renan LE CARO. Privacy Policy @@ -2701,455 +2697,455 @@ async function openSettingsPanel() { v.${appVersion}

`, - }); - if (cb) { - cb(); - } + }); + if (cb) { + cb(); + } } async function openUnlocksList() { - const ts = getTotalScore(); - const actions = [ - ...upgrades - .sort((a, b) => a.threshold - b.threshold) - .map(({name, id, threshold, icon, fullHelp}) => ({ - text: name, - help: - ts >= threshold ? fullHelp : `Unlocks at total score ${threshold}.`, - disabled: ts < threshold, - value: {perk: id} as RunOverrides, - icon, - })), - ...allLevels - .sort((a, b) => a.threshold - b.threshold) - .map((l) => { - const available = ts >= l.threshold; - return { - text: l.name, - help: available - ? `A ${l.size}x${l.size} level with ${l.bricks.filter((i) => i).length} bricks` - : `Unlocks at total score ${l.threshold}.`, - disabled: !available, - value: {level: l.name} as RunOverrides, - icon: icons[l.name], - }; - }), - ]; + const ts = getTotalScore(); + const actions = [ + ...upgrades + .sort((a, b) => a.threshold - b.threshold) + .map(({ name, id, threshold, icon, fullHelp }) => ({ + text: name, + help: + ts >= threshold ? fullHelp : `Unlocks at total score ${threshold}.`, + disabled: ts < threshold, + value: { perk: id } as RunOverrides, + icon, + })), + ...allLevels + .sort((a, b) => a.threshold - b.threshold) + .map((l) => { + const available = ts >= l.threshold; + return { + text: l.name, + help: available + ? `A ${l.size}x${l.size} level with ${l.bricks.filter((i) => i).length} bricks` + : `Unlocks at total score ${l.threshold}.`, + disabled: !available, + value: { level: l.name } as RunOverrides, + icon: icons[l.name], + }; + }), + ]; - const percentUnlock = Math.round( - (actions.filter((a) => !a.disabled).length / actions.length) * 100, - ); - const tryOn = await asyncAlert({ - title: `You unlocked ${percentUnlock}% of the game.`, - text: ` + const percentUnlock = Math.round( + (actions.filter((a) => !a.disabled).length / actions.length) * 100, + ); + const tryOn = await asyncAlert({ + title: `You unlocked ${percentUnlock}% of the game.`, + text: `

Your total score is ${ts}. Below are all the upgrades and levels the games has to offer. ${percentUnlock < 100 ? "The greyed out ones can be unlocked by increasing your total score. The total score increases every time you score in game." : ""}

`, - textAfterButtons: `

+ textAfterButtons: `

Your high score is ${highScore}. Click an item above to start a run with it.

`, - actions, - allowClose: true, - }); - if (tryOn) { - if ( - !currentLevel || - (await asyncAlert({ - title: "Restart run to try this item?", - text: "You're about to start a new run with the selected unlocked item, is that really what you wanted ? ", - actions: [ - { - value: true, - text: "Restart game to test item", - }, - { - value: false, - text: "Cancel", - }, - ], - })) - ) { - nextRunOverrides = tryOn; - restart(); - } + actions, + allowClose: true, + }); + if (tryOn) { + if ( + !currentLevel || + (await asyncAlert({ + title: "Restart run to try this item?", + text: "You're about to start a new run with the selected unlocked item, is that really what you wanted ? ", + actions: [ + { + value: true, + text: "Restart game to test item", + }, + { + value: false, + text: "Cancel", + }, + ], + })) + ) { + nextRunOverrides = tryOn; + restart(); } + } } function distance2(a: { x: number; y: number }, b: { x: number; y: number }) { - return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2); + return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2); } function distanceBetween( - a: { x: number; y: number }, - b: { x: number; y: number }, + a: { x: number; y: number }, + b: { x: number; y: number }, ) { - return Math.sqrt(distance2(a, b)); + return Math.sqrt(distance2(a, b)); } function rainbowColor(): colorString { - return `hsl(${(Math.round(levelTime / 4) * 2) % 360},100%,70%)`; + return `hsl(${(Math.round(levelTime / 4) * 2) % 360},100%,70%)`; } function repulse(a: Ball, b: BallLike, power: number, impactsBToo: boolean) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const max = gameZoneWidth / 2; - if (distance > max) return; - // Unit vector - const dx = (a.x - b.x) / distance; - const dy = (a.y - b.y) / distance; - const fact = - (((-power * (max - distance)) / (max * 1.2) / 3) * - Math.min(500, levelTime)) / - 500; - if ( - impactsBToo && - typeof b.vx !== "undefined" && - typeof b.vy !== "undefined" - ) { - b.vx += dx * fact; - b.vy += dy * fact; - } - a.vx -= dx * fact; - a.vy -= dy * fact; + const distance = distanceBetween(a, b); + // Ensure we don't get soft locked + const max = gameZoneWidth / 2; + if (distance > max) return; + // Unit vector + const dx = (a.x - b.x) / distance; + const dy = (a.y - b.y) / distance; + const fact = + (((-power * (max - distance)) / (max * 1.2) / 3) * + Math.min(500, levelTime)) / + 500; + if ( + impactsBToo && + typeof b.vx !== "undefined" && + typeof b.vy !== "undefined" + ) { + b.vx += dx * fact; + b.vy += dy * fact; + } + a.vx -= dx * fact; + a.vy -= dy * fact; - const speed = 10; - const rand = 2; + const speed = 10; + const rand = 2; + flashes.push({ + type: "particle", + duration: 100, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: a.x, + y: a.y, + vx: -dx * speed + a.vx + (Math.random() - 0.5) * rand, + vy: -dy * speed + a.vy + (Math.random() - 0.5) * rand, + }); + if ( + impactsBToo && + typeof b.vx !== "undefined" && + typeof b.vy !== "undefined" + ) { flashes.push({ - type: "particle", - duration: 100, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: a.x, - y: a.y, - vx: -dx * speed + a.vx + (Math.random() - 0.5) * rand, - vy: -dy * speed + a.vy + (Math.random() - 0.5) * rand, + type: "particle", + duration: 100, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: b.x, + y: b.y, + vx: dx * speed + b.vx + (Math.random() - 0.5) * rand, + vy: dy * speed + b.vy + (Math.random() - 0.5) * rand, }); - if ( - impactsBToo && - typeof b.vx !== "undefined" && - typeof b.vy !== "undefined" - ) { - flashes.push({ - type: "particle", - duration: 100, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: b.x, - y: b.y, - vx: dx * speed + b.vx + (Math.random() - 0.5) * rand, - vy: dy * speed + b.vy + (Math.random() - 0.5) * rand, - }); - } + } } function attract(a: Ball, b: Ball, power: number) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const min = gameZoneWidth * 0.5; - if (distance < min) return; - // Unit vector - const dx = (a.x - b.x) / distance; - const dy = (a.y - b.y) / distance; + const distance = distanceBetween(a, b); + // Ensure we don't get soft locked + const min = gameZoneWidth * 0.5; + if (distance < min) return; + // Unit vector + const dx = (a.x - b.x) / distance; + const dy = (a.y - b.y) / distance; - const fact = - (((power * (distance - min)) / min) * Math.min(500, levelTime)) / 500; - b.vx += dx * fact; - b.vy += dy * fact; - a.vx -= dx * fact; - a.vy -= dy * fact; + const fact = + (((power * (distance - min)) / min) * Math.min(500, levelTime)) / 500; + b.vx += dx * fact; + b.vy += dy * fact; + a.vx -= dx * fact; + a.vy -= dy * fact; - const speed = 10; - const rand = 2; - flashes.push({ - type: "particle", - duration: 100, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: a.x, - y: a.y, - vx: dx * speed + a.vx + (Math.random() - 0.5) * rand, - vy: dy * speed + a.vy + (Math.random() - 0.5) * rand, - }); - flashes.push({ - type: "particle", - duration: 100, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: b.x, - y: b.y, - vx: -dx * speed + b.vx + (Math.random() - 0.5) * rand, - vy: -dy * speed + b.vy + (Math.random() - 0.5) * rand, - }); + const speed = 10; + const rand = 2; + flashes.push({ + type: "particle", + duration: 100, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: a.x, + y: a.y, + vx: dx * speed + a.vx + (Math.random() - 0.5) * rand, + vy: dy * speed + a.vy + (Math.random() - 0.5) * rand, + }); + flashes.push({ + type: "particle", + duration: 100, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: b.x, + y: b.y, + vx: -dx * speed + b.vx + (Math.random() - 0.5) * rand, + vy: -dy * speed + b.vy + (Math.random() - 0.5) * rand, + }); } let mediaRecorder: MediaRecorder | null, - captureStream: MediaStream, - captureTrack: CanvasCaptureMediaStreamTrack, - recordCanvas: HTMLCanvasElement, - recordCanvasCtx: CanvasRenderingContext2D; + captureStream: MediaStream, + captureTrack: CanvasCaptureMediaStreamTrack, + recordCanvas: HTMLCanvasElement, + recordCanvasCtx: CanvasRenderingContext2D; function recordOneFrame() { - if (!isSettingOn("record")) { - return; - } - if (!running) return; - if (!captureStream) return; - drawMainCanvasOnSmallCanvas(); - if (captureTrack?.requestFrame) { - captureTrack?.requestFrame(); - } else if (captureStream?.requestFrame) { - captureStream.requestFrame(); - } + if (!isSettingOn("record")) { + return; + } + if (!running) return; + if (!captureStream) return; + drawMainCanvasOnSmallCanvas(); + if (captureTrack?.requestFrame) { + captureTrack?.requestFrame(); + } else if (captureStream?.requestFrame) { + captureStream.requestFrame(); + } } function drawMainCanvasOnSmallCanvas() { - if (!recordCanvasCtx) return; - recordCanvasCtx.drawImage( - gameCanvas, - offsetXRoundedDown, - 0, - gameZoneWidthRoundedUp, - gameZoneHeight, - 0, - 0, - recordCanvas.width, - recordCanvas.height, - ); + if (!recordCanvasCtx) return; + recordCanvasCtx.drawImage( + gameCanvas, + offsetXRoundedDown, + 0, + gameZoneWidthRoundedUp, + gameZoneHeight, + 0, + 0, + recordCanvas.width, + recordCanvas.height, + ); - // Here we don't use drawText as we don't want to cache a picture for each distinct value of score - recordCanvasCtx.fillStyle = "#FFF"; - recordCanvasCtx.textBaseline = "top"; - recordCanvasCtx.font = "12px monospace"; - recordCanvasCtx.textAlign = "right"; - recordCanvasCtx.fillText(score.toString(), recordCanvas.width - 12, 12); + // Here we don't use drawText as we don't want to cache a picture for each distinct value of score + recordCanvasCtx.fillStyle = "#FFF"; + recordCanvasCtx.textBaseline = "top"; + recordCanvasCtx.font = "12px monospace"; + recordCanvasCtx.textAlign = "right"; + recordCanvasCtx.fillText(score.toString(), recordCanvas.width - 12, 12); - recordCanvasCtx.textAlign = "left"; - recordCanvasCtx.fillText( - "Level " + (currentLevel + 1) + "/" + max_levels(), - 12, - 12, - ); + recordCanvasCtx.textAlign = "left"; + recordCanvasCtx.fillText( + "Level " + (currentLevel + 1) + "/" + max_levels(), + 12, + 12, + ); } function startRecordingGame() { - if (!isSettingOn("record")) { - return; + if (!isSettingOn("record")) { + return; + } + if (!recordCanvas) { + // Smaller canvas with fewer details + recordCanvas = document.createElement("canvas"); + recordCanvasCtx = recordCanvas.getContext("2d", { + antialias: false, + alpha: false, + }) as CanvasRenderingContext2D; + + captureStream = recordCanvas.captureStream(0); + captureTrack = + captureStream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack; + + const track = getAudioRecordingTrack(); + if (track) { + captureStream.addTrack(track.stream.getAudioTracks()[0]); } - if (!recordCanvas) { - // Smaller canvas with fewer details - recordCanvas = document.createElement("canvas"); - recordCanvasCtx = recordCanvas.getContext("2d", { - antialias: false, - alpha: false, - }) as CanvasRenderingContext2D; + } - captureStream = recordCanvas.captureStream(0); - captureTrack = - captureStream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack; + recordCanvas.width = gameZoneWidthRoundedUp; + recordCanvas.height = gameZoneHeight; - const track = getAudioRecordingTrack() - if (track) { - captureStream.addTrack(track.stream.getAudioTracks()[0]); - } + // drawMainCanvasOnSmallCanvas() + const recordedChunks: Blob[] = []; + + const instance = new MediaRecorder(captureStream, { + videoBitsPerSecond: 3500000, + }); + mediaRecorder = instance; + instance.start(); + mediaRecorder.pause(); + instance.ondataavailable = function (event) { + recordedChunks.push(event.data); + }; + + instance.onstop = async function () { + let targetDiv: HTMLElement | null; + let blob = new Blob(recordedChunks, { type: "video/webm" }); + if (blob.size < 200000) return; // under 0.2MB, probably bugged out or pointlessly short + + while ( + !(targetDiv = document.getElementById("level-recording-container")) + ) { + await new Promise((r) => setTimeout(r, 200)); } + const video = document.createElement("video"); + video.autoplay = true; + video.controls = false; + video.disablePictureInPicture = true; + video.disableRemotePlayback = true; + video.width = recordCanvas.width; + video.height = recordCanvas.height; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.src = URL.createObjectURL(blob); - recordCanvas.width = gameZoneWidthRoundedUp; - recordCanvas.height = gameZoneHeight; - - // drawMainCanvasOnSmallCanvas() - const recordedChunks: Blob[] = []; - - const instance = new MediaRecorder(captureStream, { - videoBitsPerSecond: 3500000, - }); - mediaRecorder = instance; - instance.start(); - mediaRecorder.pause(); - instance.ondataavailable = function (event) { - recordedChunks.push(event.data); - }; - - instance.onstop = async function () { - let targetDiv: HTMLElement | null; - let blob = new Blob(recordedChunks, {type: "video/webm"}); - if (blob.size < 200000) return; // under 0.2MB, probably bugged out or pointlessly short - - while ( - !(targetDiv = document.getElementById("level-recording-container")) - ) { - await new Promise((r) => setTimeout(r, 200)); - } - const video = document.createElement("video"); - video.autoplay = true; - video.controls = false; - video.disablePictureInPicture = true; - video.disableRemotePlayback = true; - video.width = recordCanvas.width; - video.height = recordCanvas.height; - video.loop = true; - video.muted = true; - video.playsInline = true; - video.src = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.download = captureFileName("webm"); - a.target = "_blank"; - a.href = video.src; - a.textContent = `Download video (${(blob.size / 1000000).toFixed(2)}MB)`; - targetDiv.appendChild(video); - targetDiv.appendChild(a); - }; + const a = document.createElement("a"); + a.download = captureFileName("webm"); + a.target = "_blank"; + a.href = video.src; + a.textContent = `Download video (${(blob.size / 1000000).toFixed(2)}MB)`; + targetDiv.appendChild(video); + targetDiv.appendChild(a); + }; } function pauseRecording() { - if (!isSettingOn("record")) { - return; - } - if (mediaRecorder?.state === "recording") { - mediaRecorder?.pause(); - } + if (!isSettingOn("record")) { + return; + } + if (mediaRecorder?.state === "recording") { + mediaRecorder?.pause(); + } } function resumeRecording() { - if (!isSettingOn("record")) { - return; - } - if (mediaRecorder?.state === "paused") { - mediaRecorder.resume(); - } + if (!isSettingOn("record")) { + return; + } + if (mediaRecorder?.state === "paused") { + mediaRecorder.resume(); + } } function stopRecording() { - if (!isSettingOn("record")) { - return; - } - if (!mediaRecorder) return; - mediaRecorder?.stop(); - mediaRecorder = null; + if (!isSettingOn("record")) { + return; + } + if (!mediaRecorder) return; + mediaRecorder?.stop(); + mediaRecorder = null; } function captureFileName(ext = "webm") { - return ( - "breakout-71-capture-" + - new Date().toISOString().replace(/[^0-9\-]+/gi, "-") + - "." + - ext - ); + return ( + "breakout-71-capture-" + + new Date().toISOString().replace(/[^0-9\-]+/gi, "-") + + "." + + ext + ); } function findLast( - arr: T[], - predicate: (item: T, index: number, array: T[]) => boolean, + arr: T[], + predicate: (item: T, index: number, array: T[]) => boolean, ) { - let i = arr.length; - while (--i) - if (predicate(arr[i], i, arr)) { - return arr[i]; - } + let i = arr.length; + while (--i) + if (predicate(arr[i], i, arr)) { + return arr[i]; + } } function toggleFullScreen() { - try { - if (document.fullscreenElement !== null) { - if (document.exitFullscreen) { - document.exitFullscreen().then(); - } else if (document.webkitCancelFullScreen) { - document.webkitCancelFullScreen(); - } - } else { - const docel = document.documentElement; - if (docel.requestFullscreen) { - docel.requestFullscreen().then(); - } else if (docel.webkitRequestFullscreen) { - docel.webkitRequestFullscreen(); - } - } - } catch (e) { - console.warn(e); + try { + if (document.fullscreenElement !== null) { + if (document.exitFullscreen) { + document.exitFullscreen().then(); + } else if (document.webkitCancelFullScreen) { + document.webkitCancelFullScreen(); + } + } else { + const docel = document.documentElement; + if (docel.requestFullscreen) { + docel.requestFullscreen().then(); + } else if (docel.webkitRequestFullscreen) { + docel.webkitRequestFullscreen(); + } } + } catch (e) { + console.warn(e); + } } const pressed: { [k: string]: number } = { - ArrowLeft: 0, - ArrowRight: 0, - Shift: 0, + ArrowLeft: 0, + ArrowRight: 0, + Shift: 0, }; function setKeyPressed(key: string, on: 0 | 1) { - pressed[key] = on; - keyboardPuckSpeed = - ((pressed.ArrowRight - pressed.ArrowLeft) * - (1 + pressed.Shift * 2) * - gameZoneWidth) / - 50; + pressed[key] = on; + keyboardPuckSpeed = + ((pressed.ArrowRight - pressed.ArrowLeft) * + (1 + pressed.Shift * 2) * + gameZoneWidth) / + 50; } document.addEventListener("keydown", (e) => { - if (e.key.toLowerCase() === "f" && !e.ctrlKey && !e.metaKey) { - toggleFullScreen(); - } else if (e.key in pressed) { - setKeyPressed(e.key, 1); - } - if (e.key === " " && !alertsOpen) { - if (running) { - pause(true); - } else { - play(); - } + if (e.key.toLowerCase() === "f" && !e.ctrlKey && !e.metaKey) { + toggleFullScreen(); + } else if (e.key in pressed) { + setKeyPressed(e.key, 1); + } + if (e.key === " " && !alertsOpen) { + if (running) { + pause(true); } else { - return; + play(); } - e.preventDefault(); + } else { + return; + } + e.preventDefault(); }); document.addEventListener("keyup", (e) => { - const focused = document.querySelector("button:focus"); - if (e.key in pressed) { - setKeyPressed(e.key, 0); - } else if ( - e.key === "ArrowDown" && - focused?.nextElementSibling?.tagName === "BUTTON" - ) { - (focused?.nextElementSibling as HTMLButtonElement)?.focus(); - } else if ( - e.key === "ArrowUp" && - focused?.previousElementSibling?.tagName === "BUTTON" - ) { - (focused?.previousElementSibling as HTMLButtonElement)?.focus(); - } else if (e.key === "Escape" && closeModal) { - closeModal(); - } else if (e.key === "Escape" && running) { - pause(true); - } else if (e.key.toLowerCase() === "m" && !alertsOpen) { - openSettingsPanel().then(); - } else if (e.key.toLowerCase() === "s" && !alertsOpen) { - openScorePanel().then(); - } else { - return; - } - e.preventDefault(); + const focused = document.querySelector("button:focus"); + if (e.key in pressed) { + setKeyPressed(e.key, 0); + } else if ( + e.key === "ArrowDown" && + focused?.nextElementSibling?.tagName === "BUTTON" + ) { + (focused?.nextElementSibling as HTMLButtonElement)?.focus(); + } else if ( + e.key === "ArrowUp" && + focused?.previousElementSibling?.tagName === "BUTTON" + ) { + (focused?.previousElementSibling as HTMLButtonElement)?.focus(); + } else if (e.key === "Escape" && closeModal) { + closeModal(); + } else if (e.key === "Escape" && running) { + pause(true); + } else if (e.key.toLowerCase() === "m" && !alertsOpen) { + openSettingsPanel().then(); + } else if (e.key.toLowerCase() === "s" && !alertsOpen) { + openScorePanel().then(); + } else { + return; + } + e.preventDefault(); }); function sample(arr: T[]): T { - return arr[Math.floor(arr.length * Math.random())]; + return arr[Math.floor(arr.length * Math.random())]; } function getMajorityValue(arr: string[]): string { - const count: { [k: string]: number } = {}; - arr.forEach((v) => (count[v] = (count[v] || 0) + 1)); - // Object.values inline polyfill - const max = Math.max(...Object.keys(count).map((k) => count[k])); - return sample(Object.keys(count).filter((k) => count[k] == max)); + const count: { [k: string]: number } = {}; + arr.forEach((v) => (count[v] = (count[v] || 0) + 1)); + // Object.values inline polyfill + const max = Math.max(...Object.keys(count).map((k) => count[k])); + return sample(Object.keys(count).filter((k) => count[k] == max)); } fitSize(); diff --git a/src/levels.json b/src/levels.json index 48003e9..ddf7d7e 100644 --- a/src/levels.json +++ b/src/levels.json @@ -835,4 +835,4 @@ "bricks": "_W__W_WW__WW____________WW__WW_W__W_", "svg": null } -] \ No newline at end of file +] diff --git a/src/loadGameData.ts b/src/loadGameData.ts index 4e008f9..7da8195 100644 --- a/src/loadGameData.ts +++ b/src/loadGameData.ts @@ -4,14 +4,13 @@ import _rawLevelsList from "./levels.json"; import _appVersion from "./version.json"; import { rawUpgrades } from "./rawUpgrades"; import _backgrounds from "./backgrounds.json"; -const backgrounds = _backgrounds as string[] +const backgrounds = _backgrounds as string[]; const palette = _palette as Palette; const rawLevelsList = _rawLevelsList as RawLevel[]; export const appVersion = _appVersion as string; - let levelIconHTMLCanvas = document.createElement("canvas"); const levelIconHTMLCanvasCtx = levelIconHTMLCanvas.getContext("2d", { antialias: false, @@ -65,7 +64,7 @@ export const allLevels = rawLevelsList .slice(0, level.size * level.size); const icon = levelIconHTML(bricks, level.size, level.name, level.color); icons[level.name] = icon; - let svg = level.svg!==null && backgrounds[level.svg] + let svg = level.svg !== null && backgrounds[level.svg]; if (!level.color && !svg) { svg = backgrounds[hashCode(level.name) % backgrounds.length]; @@ -94,13 +93,12 @@ export const upgrades = rawUpgrades.map((u) => ({ icon: icons["icon:" + u.id], })) as Upgrade[]; - -function hashCode(string:string){ - let hash = 0; - for (let i = 0; i < string.length; i++) { - let code = string.charCodeAt(i); - hash = ((hash<<5)-hash)+code; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash); +function hashCode(string: string) { + let hash = 0; + for (let i = 0; i < string.length; i++) { + let code = string.charCodeAt(i); + hash = (hash << 5) - hash + code; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); } diff --git a/src/sounds.ts b/src/sounds.ts index c0a3374..dbca716 100644 --- a/src/sounds.ts +++ b/src/sounds.ts @@ -1,223 +1,235 @@ -import {gameZoneWidthRoundedUp, isSettingOn, offsetX, offsetXRoundedDown} from "./game"; +import { + gameZoneWidthRoundedUp, + isSettingOn, + offsetX, + offsetXRoundedDown, +} from "./game"; export const sounds = { - wallBeep: (pan: number) => { - if (!isSettingOn("sound")) return; - createSingleBounceSound(800, pixelsToPan(pan)); - }, + wallBeep: (pan: number) => { + if (!isSettingOn("sound")) return; + createSingleBounceSound(800, pixelsToPan(pan)); + }, - comboIncreaseMaybe: (combo: number, x: number, volume: number) => { - if (!isSettingOn("sound")) return; - let delta = 0; - if (!isNaN(lastComboPlayed)) { - if (lastComboPlayed < combo) delta = 1; - if (lastComboPlayed > combo) delta = -1; - } - playShepard(delta, pixelsToPan(x), volume); - lastComboPlayed = combo; - }, + comboIncreaseMaybe: (combo: number, x: number, volume: number) => { + if (!isSettingOn("sound")) return; + let delta = 0; + if (!isNaN(lastComboPlayed)) { + if (lastComboPlayed < combo) delta = 1; + if (lastComboPlayed > combo) delta = -1; + } + playShepard(delta, pixelsToPan(x), volume); + lastComboPlayed = combo; + }, - comboDecrease() { - if (!isSettingOn("sound")) return; - playShepard(-1, 0.5, 0.5); - }, - coinBounce: (pan: number, volume: number) => { - if (!isSettingOn("sound")) return; - createSingleBounceSound(1200, pixelsToPan(pan), volume, 0.1, "triangle"); - }, - explode: (pan: number) => { - if (!isSettingOn("sound")) return; - createExplosionSound(pixelsToPan(pan)); - }, - lifeLost(pan:number){ - if (!isSettingOn("sound")) return; - createShatteredGlassSound(pixelsToPan(pan)) - }, + comboDecrease() { + if (!isSettingOn("sound")) return; + playShepard(-1, 0.5, 0.5); + }, + coinBounce: (pan: number, volume: number) => { + if (!isSettingOn("sound")) return; + createSingleBounceSound(1200, pixelsToPan(pan), volume, 0.1, "triangle"); + }, + explode: (pan: number) => { + if (!isSettingOn("sound")) return; + createExplosionSound(pixelsToPan(pan)); + }, + lifeLost(pan: number) { + if (!isSettingOn("sound")) return; + createShatteredGlassSound(pixelsToPan(pan)); + }, - coinCatch(pan: number) { - if (!isSettingOn("sound")) return; - createSingleBounceSound(900, pixelsToPan(pan), 0.8, 0.1, "triangle"); - }, + coinCatch(pan: number) { + if (!isSettingOn("sound")) return; + createSingleBounceSound(900, pixelsToPan(pan), 0.8, 0.1, "triangle"); + }, }; // How to play the code on the leftconst context = new window.AudioContext(); -let audioContext: AudioContext, audioRecordingTrack: MediaStreamAudioDestinationNode; +let audioContext: AudioContext, + audioRecordingTrack: MediaStreamAudioDestinationNode; export function getAudioContext() { - if (!audioContext) { - if (!isSettingOn('sound')) return null - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - audioRecordingTrack = audioContext.createMediaStreamDestination(); - } - return audioContext; + if (!audioContext) { + if (!isSettingOn("sound")) return null; + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + audioRecordingTrack = audioContext.createMediaStreamDestination(); + } + return audioContext; } export function getAudioRecordingTrack() { - getAudioContext() - return audioRecordingTrack + getAudioContext(); + return audioRecordingTrack; } function createSingleBounceSound( - baseFreq = 800, - pan = 0.5, - volume = 1, - duration = 0.1, - type: OscillatorType = "sine", + baseFreq = 800, + pan = 0.5, + volume = 1, + duration = 0.1, + type: OscillatorType = "sine", ) { - const context = getAudioContext(); - if (!context) return - const oscillator = createOscillator(context, baseFreq, type); + const context = getAudioContext(); + if (!context) return; + const oscillator = createOscillator(context, baseFreq, type); - // Create a gain node to control the volume - const gainNode = context.createGain(); - oscillator.connect(gainNode); + // Create a gain node to control the volume + const gainNode = context.createGain(); + oscillator.connect(gainNode); - // Create a stereo panner node for left-right panning - const panner = context.createStereoPanner(); - panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); - gainNode.connect(panner); - panner.connect(context.destination); - panner.connect(audioRecordingTrack); + // Create a stereo panner node for left-right panning + const panner = context.createStereoPanner(); + panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); + gainNode.connect(panner); + panner.connect(context.destination); + panner.connect(audioRecordingTrack); - // Set up the gain envelope to simulate the impact and quick decay - gainNode.gain.setValueAtTime(0.8 * volume, context.currentTime); // Initial impact - gainNode.gain.exponentialRampToValueAtTime( - 0.001, - context.currentTime + duration, - ); // Quick decay + // Set up the gain envelope to simulate the impact and quick decay + gainNode.gain.setValueAtTime(0.8 * volume, context.currentTime); // Initial impact + gainNode.gain.exponentialRampToValueAtTime( + 0.001, + context.currentTime + duration, + ); // Quick decay - // Start the oscillator - oscillator.start(context.currentTime); + // Start the oscillator + oscillator.start(context.currentTime); - // Stop the oscillator after the decay - oscillator.stop(context.currentTime + duration); + // Stop the oscillator after the decay + oscillator.stop(context.currentTime + duration); } let noiseBuffer: AudioBuffer; -function getNoiseBuffer(context:AudioContext) { +function getNoiseBuffer(context: AudioContext) { + if (!noiseBuffer) { + const bufferSize = context.sampleRate * 2; // 2 seconds + noiseBuffer = context.createBuffer(1, bufferSize, context.sampleRate); + const output = noiseBuffer.getChannelData(0); - if (!noiseBuffer) { - const bufferSize = context.sampleRate * 2; // 2 seconds - noiseBuffer = context.createBuffer(1, bufferSize, context.sampleRate); - const output = noiseBuffer.getChannelData(0); - - // Fill the buffer with random noise - for (let i = 0; i < bufferSize; i++) { - output[i] = Math.random() * 2 - 1; - } + // Fill the buffer with random noise + for (let i = 0; i < bufferSize; i++) { + output[i] = Math.random() * 2 - 1; } - return noiseBuffer + } + return noiseBuffer; } function createExplosionSound(pan = 0.5) { - const context = getAudioContext(); - if (!context) return - // Create an audio buffer + const context = getAudioContext(); + if (!context) return; + // Create an audio buffer + // Create a noise source + const noiseSource = context.createBufferSource(); + noiseSource.buffer = getNoiseBuffer(context); - // Create a noise source - const noiseSource = context.createBufferSource(); - noiseSource.buffer = getNoiseBuffer(context); + // Create a gain node to control the volume + const gainNode = context.createGain(); + noiseSource.connect(gainNode); - // Create a gain node to control the volume - const gainNode = context.createGain(); - noiseSource.connect(gainNode); + // Create a filter to shape the explosion sound + const filter = context.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.setValueAtTime(1000, context.currentTime); // Set the initial frequency + gainNode.connect(filter); - // Create a filter to shape the explosion sound - const filter = context.createBiquadFilter(); - filter.type = "lowpass"; - filter.frequency.setValueAtTime(1000, context.currentTime); // Set the initial frequency - gainNode.connect(filter); + // Create a stereo panner node for left-right panning + const panner = context.createStereoPanner(); + panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); // pan 0 to 1 maps to -1 to 1 - // Create a stereo panner node for left-right panning - const panner = context.createStereoPanner(); - panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); // pan 0 to 1 maps to -1 to 1 + // Connect filter to panner and then to the destination (speakers) + filter.connect(panner); + panner.connect(context.destination); + panner.connect(audioRecordingTrack); - // Connect filter to panner and then to the destination (speakers) - filter.connect(panner); - panner.connect(context.destination); - panner.connect(audioRecordingTrack); + // Ramp down the gain to simulate the explosion's fade-out + gainNode.gain.setValueAtTime(1, context.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, context.currentTime + 1); - // Ramp down the gain to simulate the explosion's fade-out - gainNode.gain.setValueAtTime(1, context.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, context.currentTime + 1); + // Lower the filter frequency over time to create the "explosive" effect + filter.frequency.exponentialRampToValueAtTime(60, context.currentTime + 1); - // Lower the filter frequency over time to create the "explosive" effect - filter.frequency.exponentialRampToValueAtTime(60, context.currentTime + 1); + // Start the noise source + noiseSource.start(context.currentTime); - // Start the noise source - noiseSource.start(context.currentTime); - - // Stop the noise source after the sound has played - noiseSource.stop(context.currentTime + 1); + // Stop the noise source after the sound has played + noiseSource.stop(context.currentTime + 1); } function pixelsToPan(pan: number) { - return Math.max(0, Math.min(1, (pan - offsetXRoundedDown) / gameZoneWidthRoundedUp)); + return Math.max( + 0, + Math.min(1, (pan - offsetXRoundedDown) / gameZoneWidthRoundedUp), + ); } let lastComboPlayed = NaN, - shepard = 6; + shepard = 6; function playShepard(delta: number, pan: number, volume: number) { - const shepardMax = 11, - factor = 1.05945594920268, - baseNote = 392; - shepard += delta; - if (shepard > shepardMax) shepard = 0; - if (shepard < 0) shepard = shepardMax; + const shepardMax = 11, + factor = 1.05945594920268, + baseNote = 392; + shepard += delta; + if (shepard > shepardMax) shepard = 0; + if (shepard < 0) shepard = shepardMax; - const play = (note: number) => { - const freq = baseNote * Math.pow(factor, note); - const diff = Math.abs(note - shepardMax * 0.5); - const maxDistanceToIdeal = 1.5 * shepardMax; - const vol = Math.max(0, volume * (1 - diff / maxDistanceToIdeal)); - createSingleBounceSound(freq, pan, vol); - return freq.toFixed(2) + " at " + Math.floor(vol * 100) + "% diff " + diff; - }; + const play = (note: number) => { + const freq = baseNote * Math.pow(factor, note); + const diff = Math.abs(note - shepardMax * 0.5); + const maxDistanceToIdeal = 1.5 * shepardMax; + const vol = Math.max(0, volume * (1 - diff / maxDistanceToIdeal)); + createSingleBounceSound(freq, pan, vol); + return freq.toFixed(2) + " at " + Math.floor(vol * 100) + "% diff " + diff; + }; - play(1 + shepardMax + shepard); - play(shepard); - play(-1 - shepardMax + shepard); + play(1 + shepardMax + shepard); + play(shepard); + play(-1 - shepardMax + shepard); } -function createShatteredGlassSound(pan:number) { - const context = getAudioContext(); - if (!context) return - const oscillators = [ - createOscillator(context, 3000, 'square'), - createOscillator(context, 4500, 'square'), - createOscillator(context, 6000, 'square') - ]; - const gainNode = context.createGain(); - const noiseSource = context.createBufferSource(); - noiseSource.buffer = getNoiseBuffer(context); +function createShatteredGlassSound(pan: number) { + const context = getAudioContext(); + if (!context) return; + const oscillators = [ + createOscillator(context, 3000, "square"), + createOscillator(context, 4500, "square"), + createOscillator(context, 6000, "square"), + ]; + const gainNode = context.createGain(); + const noiseSource = context.createBufferSource(); + noiseSource.buffer = getNoiseBuffer(context); - oscillators.forEach(oscillator => oscillator.connect(gainNode)); - noiseSource.connect(gainNode); - gainNode.gain.setValueAtTime(0.2, context.currentTime); - oscillators.forEach(oscillator => oscillator.start()); - noiseSource.start(); - oscillators.forEach(oscillator => oscillator.stop(context.currentTime + 0.2)); - noiseSource.stop(context.currentTime + 0.2); - gainNode.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.2); + oscillators.forEach((oscillator) => oscillator.connect(gainNode)); + noiseSource.connect(gainNode); + gainNode.gain.setValueAtTime(0.2, context.currentTime); + oscillators.forEach((oscillator) => oscillator.start()); + noiseSource.start(); + oscillators.forEach((oscillator) => + oscillator.stop(context.currentTime + 0.2), + ); + noiseSource.stop(context.currentTime + 0.2); + gainNode.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.2); + // Create a stereo panner node for left-right panning + const panner = context.createStereoPanner(); + panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); + gainNode.connect(panner); + panner.connect(context.destination); + panner.connect(audioRecordingTrack); - // Create a stereo panner node for left-right panning - const panner = context.createStereoPanner(); - panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); - gainNode.connect(panner); - panner.connect(context.destination); - panner.connect(audioRecordingTrack); - - gainNode.connect(panner); + gainNode.connect(panner); } // Helper function to create an oscillator with a specific frequency -function createOscillator(context:AudioContext, frequency:number, type:OscillatorType) { - const oscillator = context.createOscillator(); - oscillator.type =type - oscillator.frequency.setValueAtTime(frequency, context.currentTime); - return oscillator; -} \ No newline at end of file +function createOscillator( + context: AudioContext, + frequency: number, + type: OscillatorType, +) { + const oscillator = context.createOscillator(); + oscillator.type = type; + oscillator.frequency.setValueAtTime(frequency, context.currentTime); + return oscillator; +} diff --git a/src/types.d.ts b/src/types.d.ts index 5c47bd3..bd556a8 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -6,7 +6,7 @@ export type RawLevel = { name: string; size: number; bricks: string; - svg: number|null; + svg: number | null; color: string; }; export type Level = { diff --git a/src/version.json b/src/version.json index fdc3a0b..0c50a5f 100644 --- a/src/version.json +++ b/src/version.json @@ -1 +1 @@ -"29028296" +"29030872"