From 5a18899dc6a6e34bd8a78f670f1d1bd4f68b5e3b Mon Sep 17 00:00:00 2001 From: Nanoy Date: Sun, 20 Jan 2019 09:28:11 +0100 Subject: [PATCH] Coope-runner --- coopeV3/urls.py | 1 + coopeV3/views.py | 3 + staticfiles/css/runner.css | 136 + .../default_100_percent/100-disabled.png | Bin 0 -> 382 bytes .../default_100_percent/100-error-offline.png | Bin 0 -> 196 bytes .../100-offline-sprite.png | Bin 0 -> 10330 bytes .../100-offline-sprite.xcf | Bin 0 -> 48215 bytes staticfiles/runner.js | 2715 +++++++++++++++++ templates/404.html | 26 + templates/base.html | 4 +- templates/coope-runner.html | 86 + 11 files changed, 2970 insertions(+), 1 deletion(-) create mode 100644 staticfiles/css/runner.css create mode 100644 staticfiles/runner-assets/default_100_percent/100-disabled.png create mode 100644 staticfiles/runner-assets/default_100_percent/100-error-offline.png create mode 100644 staticfiles/runner-assets/default_100_percent/100-offline-sprite.png create mode 100644 staticfiles/runner-assets/default_100_percent/100-offline-sprite.xcf create mode 100644 staticfiles/runner.js create mode 100644 templates/coope-runner.html diff --git a/coopeV3/urls.py b/coopeV3/urls.py index bce380c..ec4148f 100644 --- a/coopeV3/urls.py +++ b/coopeV3/urls.py @@ -21,6 +21,7 @@ from . import views urlpatterns = [ path('', views.home, name="home"), path('home', views.homepage, name="homepage"), + path('coope-runner', views.coope_runner, name="coope-runner"), path('admin/doc/', include('django.contrib.admindocs.urls')), path('admin/', admin.site.urls), path('users/', include('users.urls')), diff --git a/coopeV3/views.py b/coopeV3/views.py index 84734f9..612a342 100644 --- a/coopeV3/views.py +++ b/coopeV3/views.py @@ -17,3 +17,6 @@ def homepage(request): gp, _ = GeneralPreferences.objects.get_or_create(pk=1) kegs = Keg.objects.filter(is_active=True) return render(request, "home.html", {"home_text": gp.home_text, "kegs": kegs}) + +def coope_runner(request): + return render(request, "coope-runner.html") diff --git a/staticfiles/css/runner.css b/staticfiles/css/runner.css new file mode 100644 index 0000000..b7e52b8 --- /dev/null +++ b/staticfiles/css/runner.css @@ -0,0 +1,136 @@ +/* Copyright 2013 The Chromium Authors. All rights reserved. + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. */ + +html, body { + padding: 0; + margin: 0; + width: 100%; + height: 100%; +} + +.icon { + -webkit-user-select: none; + user-select: none; + display: inline-block; +} + +.icon-offline { + content: -webkit-image-set( url(../runner-assets/default_100_percent/100-error-offline.png) 1x, url(../runner-assets/default_200_percent/200-error-offline.png) 2x); + position: relative; +} + +.hidden { + display: none; +} + + +/* Offline page */ + +.offline .interstitial-wrapper { + color: #2b2b2b; + font-size: 1em; + line-height: 1.55; + margin: 0 auto; + max-width: 600px; + padding-top: 100px; + width: 100%; +} + +.offline .runner-container { + height: 150px; + max-width: 600px; + overflow: hidden; + position: absolute; + top: 35px; + width: 44px; +} + +.offline .runner-canvas { + height: 150px; + max-width: 600px; + opacity: 1; + overflow: hidden; + position: absolute; + top: 0; + z-index: 2; +} + +.offline .controller { + background: rgba(247, 247, 247, .1); + height: 100vh; + left: 0; + position: absolute; + top: 0; + width: 100vw; + z-index: 1; +} + +#offline-resources { + display: none; +} + +@media (max-width: 420px) { + .suggested-left > #control-buttons, .suggested-right > #control-buttons { + float: none; + } + .snackbar { + left: 0; + bottom: 0; + width: 100%; + border-radius: 0; + } +} + +@media (max-height: 350px) { + h1 { + margin: 0 0 15px; + } + .icon-offline { + margin: 0 0 10px; + } + .interstitial-wrapper { + margin-top: 5%; + } + .nav-wrapper { + margin-top: 30px; + } +} + +@media (min-width: 600px) and (max-width: 736px) and (orientation: landscape) { + .offline .interstitial-wrapper { + margin-left: 0; + margin-right: 0; + } +} + +@media (min-width: 420px) and (max-width: 736px) and (min-height: 240px) and (max-height: 420px) and (orientation:landscape) { + .interstitial-wrapper { + margin-bottom: 100px; + } +} + +@media (min-height: 240px) and (orientation: landscape) { + .offline .interstitial-wrapper { + margin-bottom: 90px; + } + .icon-offline { + margin-bottom: 20px; + } +} + +@media (max-height: 320px) and (orientation: landscape) { + .icon-offline { + margin-bottom: 0; + } + .offline .runner-container { + top: 10px; + } +} + +@media (max-width: 240px) { + .interstitial-wrapper { + overflow: inherit; + padding: 0 8px; + } +} diff --git a/staticfiles/runner-assets/default_100_percent/100-disabled.png b/staticfiles/runner-assets/default_100_percent/100-disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..526075e6d55359bb31f0ba3b1154524a04dea4cf GIT binary patch literal 382 zcmV-^0fGLBP)h^Cw))S&|eF$oR3&qP*utvWl5u)5BJ5eLq%R(0>ytNZKjsL!f$nCK+vNJvPS3i8q#NJz+2f3KzAApbqT{$x*pgoGC6 z^F`NP!_14y#m(8;*1?L(9pqv~W#w&ajfCXAT$y2Cx7vs)`)rS|_fp{-at)bSXW22; z$V9Fa!sj?sSxdt<)!D0@DY5q^Yf9G9JzWdO)Vy>hh9ep`h}{#q zZn`%B$rf@Kl&wwE8E8h>Ru5oQN5 zycnm2kiikdG4%8AWk4KO_@IeQz*(I!=b`916S@cj=T+QmyY7SYwzP-wBe)+6DGz*f zFsW&&53V+Fp~LW;4nxT986NIF5b&<+*H*a+j&ZZQ-Eib1(LvNyRtz?7yxYwl?}jlR5kSw&sOON?$<}kQy_dzf{d)&z<--Rw>b%zKAh1%Qpw_T z&R42#yIOBBNg#UlKGrL!Bf_p=-DpKtgd)jJ|65??XP#1V+{vWoNv+smnno?PY+#6!;F zmQm+S)JHm~Z*EF+KJ}k6xCO?n%p3-W8QiH2iV`+Iqq-uHaJJl)(ym%Wiayg%*c@*Hr0FyuspcoR6|udr6R>#>@X{J zo~dqcunEHZ`JL(2JC5VTluRL!VMF7xI7X%^u_)>-9SI4cdZy&e!jj&uei(_vMkgu z_{$e7mq0O5?W{fBbaZBMOlk}P4OKIBaw`9rxeHpTs$)D$OH3W}K3$W&3VKJMIUn!J zv&FW4>LI>UQAZ0=;LC#^EKcaV;TVdV6L?bpjtJv)w*HW?L^pV~%eFzurUNw9$!L)ECWT|+TXj9E&RobWsYOlIG^(;GGJgoqkh8DS{56ZXx zpdNqwCCUPBHpFU=5%@VuFU0!*2ygR|z)7bp166bFpu<=PBuHpZ7kex*lt3wNpAFua zILkv>f1+KHq4m$Q_grvM)*spZsO`u9Bqv2Mwh$ep`Kb+(ef0wzG(ib%whmqmU-hfl?0i(PcggLko4sQXJa zQ%UVNjV%Y*09x7PtB5bvsbj&cisG(#wGPMcv}^U|s~^i=)c)jk5`Oh7i>A=t%LDA< zvNVDGpbDZMIWBo|5TBdz;aG7-XgYPyiPQPIfT5Odm7YvMc2w_Xx8E9jYcgC%MMr2e z=u=fYDpN~MLcD?oN#vX5c>-t<@FlIq9owXXcanJ?HoG$arI7_2BiKU79Grz3GBa!` zE}E}Bnt1WEzuQ}w9DlA0wdW(7NVh1qeqqe_nD*T(dUD^-sWYHrcbcgKxleED9mUz= z6HMdWyrgCBOQo@!WHsd+qr%KkWNMj7L<7F>h0oXakt}H236*w&D|+&yM{?#iOVK!0 zM6!sr@oZRK(1Lmzd8BZ}X=nxB33%4nQlNffAk1x3TRSE~P#f>I{tDzAFs%jiv2j;Y z4EBDpDC#B{`l<}0`{{tZBsj(Oo~H~|#W%f2WyZK{A}F>h`153L4JT85>`^FQ*u8Cn z>fpMXNH7;ljFd`P1Xb|2^y{@xh)g6|y*6G!ejfJUCBKg2CGyfy_A5`i04tOCtgXdO z)Y+_AF&G}nWshCGS1x)A5z6_88Z=@)D>46%j5Bc{6EikoLs>=G& z41347e;NZ4O71Pe_<{7(ull5H#K2?KqYa$k9~%?f9JIOdf|YX$_2QguDQ(wKeJ6K-zXEj_ z5vOP%8rt_QiHD>{xXpBf%d3qej$QQ| z#|-ho5PtF~H>MJcvpP$ZT3#<|Eh9p+jDonGOja4+^#ke+ZCb?IGSNbg+6TH>W|jBi zZXE9+d(M^{$i}q;@|P?H8X(PtaqMq;6e%K#{s>?hwFjVe|6hiJf=>EOk1!TvM*9BIxZo4FQ-N~-x3+hV zY!tJ=6A{_1UUWl;6B6&;Gf@+@^j9${?YC?%95wb76lvd`#A)Jv`${zufYEWCTpRxV z&)4wz{fDo}O{#vv+}ko#+DgPBKsvEw!yFiE3!-CYCO5mE=hS zKBateO8v?Q&NU3W7_b0;ER<`=FN=j&86r6_4@lTPF+X-_)|C{c{(RX}b!8Wo99*ca z?YYmyggIqxIOTK{ivNb~$wG&#k>(I`xk-plqUW2sOR6T>T^Y`o=g^3La)F5ak^D1L zOTo_}4*I&K2JfV+w5Hrls(}o<(|YydD^Ph`XrE!QDqCoIzI| z%Zi=9yzHg9lAJWsb40>u$KN|N7kND(5)wP@KgSF2LUGT(4^iC}RAf+LFK`JlkrJ?% zu8@!@^cAE(eeqsC0sGdhYWoj}%PqIvoVIzA3A<9zEno(Jpnom$PJWtnz};H_T8QoA zTomR2ur2OPNefoBS|h$LxVhm}n}d%XOwBfmM~z+4|6e3#i9Jp&s&G0`a&5 zwaJU^E$v=~GvaiWP!?PIlS4pZ%id8`{XB(#piM#nu>C$KT|(*~%*wV`W?25mDB^^xSTKQSqAxQ7=j zA30(x5vCY}{$NT$Bk;lO!wcv>x;+yrVkA2gg`xx#TIM(l_J=(Jh^l4E=o3?_rCU?~ z4v&t;=~butGbh&_#|~VNmo^5#$W>7A{2RflLQ^Lu=D4&xH>c7@r_MunC8+D`Wk#B@ zWE+J<1KL}gDXn|z%H5cx>?kiuk_88zG$kIZyAqeAvaX|6)zTqbZaBEEqnwBFS6U%l z9y9u9qiTN*^7*!YEB41lE@DF3Tlm}N~Dbt0cp8 z?s2}A(P_3Ntx?=$x5>J}a%d0s@0qK&&91knDWQ-Xt5!+%_*5^}DqQaG469(06w{vH zmMU5}Q3Tyh$^D+BE05`DM5i6B$~VXt%PTt^kPOliw_pT044Kq=fzpge)IjX3R zQaG>gw1_mQe&^Ctm{v|LVq0nY!mfi>Ju-90Bf38J=lnubzNtG}_^UW;*|dv*q@}KO zu`dwH$gIdv-BO;-!8$Hlo%n?W(JQtURkyh}^{r47sC64SeSvUVud&F&!EPT>$3uC! zb*=B|+C0XJ!G8*0?>G}Y3P7ZIpa;O~lXta-3=0j*S;LsTru2F7ZD>vqI|9z|b$Q;7 zXjO)(Ry|hC*BvcJLCL#HMO&fwSZ^tlmiG#6hn9BR6wAk8$m$qLx^G>q-DB zTXsN7dJdx;i`{2Q995EI+%00To+vb6X*-Z~Cms`?cR3IZI{4iz0OnoyTH4Lj9+5$? z+%Z30bHIONwrw=Bt6+3WnobAIaQN>a)pVn7&$Q|pk1%YL(@+}kdB23e8GUOKoXR_j zJL*BolX8@5HcD&sE2Z!Uz5W@EqamHnsQN})8w_}H2eQJIl<^cT;z+DRAF!1<*R8&F z>a$d1acp+Av?!zwiF@=7GJz31&5B-!V~@Xqdn|zLu7|~6rQPCDzS<;js#@)v?&d-ZY#vO2DD4V)1B$Z~ zVK6GEA(UKE7qy7^HoG{PVB4wbnQy>-x-M3b#TY=Jpq?c*M509lLmN;Zi0u;r#rB%D&PR z>c)E4TCkyRy=d4K@uEuz`%hpl+}RrYqAHAM#0YoqzeaTC(%S+Lz3FgyCU}E^p{#5- zTcT+bE<~WlmcvTp!v>akD{Py5`Y>AlBD%b01>Fija$2GKA5l`(09KR@b7@ODA4W)| z@_laWTJ@y;=Q9`!7QP9NeDwS3<(2{titAHp#X@4KNeUmr06J*~W^DY1`PIt*Hwb*D zo|Kj#!b!5>I~?U6AbZPN6IQ9~mz69dU0sL|FjMD3OWYMO?&|6S$v2csgVG!3J1$U{Ivya}!a(#|RUJycxo&p`~Zbgd8WA=q?wr=e|)jLOiRFau|0L zo^(U0`uIIFRtCfTO3cqd{1%DoL%su)4?IC@_FDxjohxse+pzK|73OBkfB*0I)G+#r zrIEd>9PiaUK>v`;PGH1J7lH!pldi_RarNt(KNep9J_tL|Dh`;-dJch(01eTz9dq{C zHbPcu*a0Njj+7B7BYX8mBEFli;Vbx3r>4O+IuooWgY)v!Hrf-&OIbDt7S6^&T}$(> zNaHpw-rDV(sqO^VV+@_)EjnB32DTG`qV~}Gfj?G16cdYz@k(5$p`qH6gpQ_YUzT`C z9i)HA)N`$&i1$(1Yqc_GmZ#XK7XFYd82a=)fp4vf;>ha2s*|^*o2~QZGKF3vo)~;a z+O-|&bw<6(bfyUE7BnTg-lz-NbPXU1sOo3%e*5d<=Q{D;nU&{-+^>h=M~+sZbL+>q z>*?`pWtp&=z3^0YVM{wJrF*b~mTG*)gW>ELq2<5KZ#%lfKz~;2U!a6~wR(ipxRa|DzLL ztLH_k{hetE;g*u*UoQyIlaa@%D{P%+-2cD;uStyKNN~4pI?s&C`~Ze`A`VdcGG&Jn{uAlbqO!1AL7RNc#E(<#<+a6nO}3URQRcUSI}M? z)My96B5Br5<4Iqg)3IDoIcMQ^hUFsYSiCX_Riy{NR*X`>%M99K=U1mOX!CDk-p6lZ zr6*TqSR9*dxUKr(9#SJyd$I4s!v--6)|stN#11YQ@}j=w?pTEUvfgmq z%U%V#f z)2_^UYvX1&*;!3~>%3US;vwZ+)nBx|#=If&`{`n=o3dqOac&l$WCL>D8*43LEx!A` zpvd?FPCcTM){b$Av;wQb>nsEgB4*wD^`ksx% z{8o~fO-J_4)^So(9$j?kE9C?o!jB#j)z~^>sBHK3{n8K!%WZ&5$nsHN#E!Vz+m~Hr zd|vE7#Pf!T1!i;KE;jL|W9tS>mBA|;Qm+8M9X`mOkaCFVShH=V zG$BKVbh)LQXQf^!5?QfIG6y z=cmmZj~tl#E@u-_5bpIcvm7)dY+oARZeT~~NJO#i zYe;M2c?A2-w)lI-%~fK=WMI#{@3CM>D$gs!T~T$**Mz2@R%v2u1S(K{iez3!hM~52 z2+Fh?s8LA^kB&_lJ-CZWj|^CEB`X?|n)kED?IKWXR%sJ&u>-5ZFAO15i2*#KTCGbN z^-fReX~I_;`+kNvz@O)F-RLelbA>{>6}7wQDwBm-(l zybYSq$xJqX^;VB`?ApbjYXyL)4p8ia3E^z2OxRC#yAlfg{ifLI`ZE{P?2cqHH#Xfn zi)5t*h`+q-xSVf)PR00#O5V{FAz*BgZLUFp8Qp8xpXN3&?%X^$-rll8PT6*2*j9)Z zlTf3tJgG^sX*>aL5nj*Ng$3M8&|Alf#OjPYtmH-wxk^f#i#Gs-XnU!btXToNY5#+gQBwwOV<@N*XBqs54CA?kZk}Fu+wZ0(rUoK z4lfGd$HzbO%A(Gm)*9!#L!yI6W`?_)-cSd>5h)wDfL_a}Bd)Elpx*;6zWf+pk>^42 z3$>Vm;)Nrl;G(4q;avfU;8)>KTaxUjAkg>|%0#{ON%N6g>`m17t9KJ&kHXs#y*RMT z)8vIk=YOciX-I>{Dsxo2p|vEL;{cZZI#px6o(24p{d(g*cy3yy*+*c+i90+UK;7@y^M@A1(~NDdmM#7MpNxBv9t~CL` zJMuSJ1kA6Ub4Irgyzg94L0PR_A9sHwr|B@Qj#o7KUgKZIl-9<8kM|YCLxuP-yr_Ac zeN_z2ESp-AmFR$@uX|Hmyg)At%-)9D-zgkzsgcENq&(BAWenID8*7q39&f$YV>wJz zoY{vC&t+_UO1{_`&|WydeH=Q*Tn_e!OxOIPY0KQoPfyY;00vD<+sYbl)iuc_<4dc< zCYCi~pbCPvh_5#r0QNc4;R2ok6>%(Cn4;JGe$0H4;0@gdtgsbnRxjbwa(7YUT(^It zC|ZXenCb>**l=av(WtT}1fI?x8%HD~VPIe!x1WC5iwv%pDnULRy%IDu%GpCUVxk)# zS5oS~B8&RoVk>1fbrm~)YX>7z(z4=U&@Vh*&&sBX{!X_^K11WEq59o(YWSq!@k!|v z^dQ^{-D{D~jp;+VIcI6rYW3)DwX{|piFxVNF~5`?b$6oBGPOJCg|z6HO#o#5+~ZEz z+3*q^9xs0`^W!zqV%wv5+ZwKnvkbotuq>G@`9i$$xWt=FJ~W+3$K3n<3DOffGARlk zep}H~;_z~;8c|ZoxLcGs^Qg7USKV7O(kSwEB1-|^P>7PHv#WhVn|fw6AEJ&d)cnI* z?bP@m!c|A>Xs&h%QbR5_0avOOviMK;pWY zZY)RSoMZ`eT#x65b*}e)k;Hs{x{ZAXLzVOM{(MNDJt14g2P~ksM~&12R?Zd^QvC>O zV|c{NjkZqVrdY%)xq0u89J^h|$0qyi2|dQS+AF{Seo%;8%PagsElU(71v*>5%z-y~f?ayglth?Vefc%9qm$P=${I34NYmD_Ay z?$2Rw7sxKs0RRVrUbxj7JAJj{F7u9$pO~4ksqv@?N}4d9cqFgVGwG}^53?xRhZjs? z!L*45DXvpqL^3kff^~q0M2_a#$w8g6;x>jhVU81G7#fsdY+3|6lUE4Ty751Y2psTM4`Fq`6s~2Z~xg_gpkNY(d)=O1#fO;Z*bD|8)8e` zNaMHs&M@BMra|(xZ9oB@?No?;qx^_Zpj85wcX)JWrfuP_3xlJNtGsimR)bGaK=FWd zovPzt+KmAy=Xrg(=R^r&MAR!8G~cj%}F&!1D0? zQ?>8tYuV}@ruq=&tThGAogcOs!_;tKv;U|1@vme%a~2tYNJON<C0i4MukOr3CWu@)dByTBvFr^r5?{ak$GT@T@kcXudKEME|qnIt-jqO~1O z*_b7bL_f2(81K#s6J~KvGn7kEAg%UuM5H|Owk%E?aBI`7cFe?K*Y8ipy^}Dqcr?49 zuIj^k+b`Luq&eC96T-sSEE$#7r(l0TA|K@^595_7e%Bos_|Du-eVCnmX6SD6{`u%*Ygl&j4>}3mLpLSQT_bYZ;$wYVv zEjxVOHJ;}h%`6P`zbtOIYyaZJ;Kcgf(N9Ut?@((GgMO-GhSI17kfl)&2a$|)lU@?h zvHUy^I{nVE`n^pjtrdk_;9Mq-jQFR76Gc-MR<-OWHJnh3f!(D@!_p^rCeVd>JZUmN zyVpYo^bEKr{=w0(JcUHs6&LK$;-=%9zd&S~CQU*p4*3FIa+DwS4(6~u{6IJr&2CV9_6VzVa}LJACQ zl|<86-}}ASRfPkFjnF{b-buXP(Ite5 z(R!D{_Y;T~fvLQd8<(P{FPHCF6Yim?=f3jo#MJ#H&lZp(T@crzGVekSOntxJsy8QK zv@E`&45&qtNC)v}*i>G&GMYKzK*i5tr+Z@wAbbnU_JOYoaYk)>x2Ynu31=JGKjOPy zXT#k>yJCgyXAeSneGJ$I*UHB?P?9{B9nd0W{sz;|_tO%lG5Nnsp!s})1U>1h!w_}lZROT69P zrkIOwc-^ewCTr7Y|CYezU|BLq5q;PSL8vwq)s#?~UlwGqb3X3I#`wTib{7w-QnzAX z^JLnrdo(*>ES%N72*&3gLf#v_%QE)9JI2aMPaqP{NL8PLWM`%tRKzpV_?*6X)GHHo z*3zja$8l zLvf6Mz-?0RdH>eNjq`P&u9KPtVJKhx)$|Ehl_9rNck1V9&Hx8TwH0I=NMSLkw{@|# zU_qrRj(Gf!k*8S4pVYqR<^oc}HtO23E3fQO`~U_k_s|oAzUk%DimA7$0H0(A%)f|x zs0|M`&EcCumb>GYuH`a-Ej72uG>PsHmkt@}|y1q6I=E0Gw z85zk!1-@$3#PLe@BGjOOUA*o~9H{>tK%i|Jw-~2JzU?G_qHVE<`CHZuNy9Bk^IXZj zu}=tY!0@zZ6Rrf6@D%+-6edm~+^i9`8>eLdnt#N0B!GeKFe6-62x>KtD9X0MI~(ES z<;Q(EQ=(Ai3B{;fGA8(M)kMC3rSeJQhGLsEXO|o2oR{Eb&l7PA>l6zofur8xQuYjc zPu`x$Pb(gcGCbG*ng{;Ww@06+?H5~gWzeXxb`OozJU-`Vbf!uGrrUpoJm{YA*+ z!RJE4t!$l;g1PURG;t(GOL?TBb!GUzq;#L&LJtT4D!+eHJ4XVV3ZG)l^4Pumy}6l< zFfikodhmhAJ2S26dO}1v25{X6LFXSinY#G@vI2c8CI@Xs6Zan5&(q{HlaU0pt}GEU z8+wNmL>~?xc#_(x%r=T*a&x&nJi2diC{rP**84QN@FP*TG#Q^1?DOT=3Heu6tJ|DaHk1uZLXD@}FV_)E5F}I~MSj;bd1d-W!(# ziiA>97W!PFeLxA?N!_a2aR-I}cuZy`vx`qkym5-dKP$ND;XHZLMvM5Q*VU$91*kJV zgopR@O1h!PjOa;SHupOm$}c;_a*2QbpGJVFqFi;7#Gl0DL^2Mx!9}?*2AUwR1CR`d zRezlYh8G86+52Y^rH1~?9V+Ya(EX(thAEsWf};4)gB--)ObSJY{BP!=`d_T@@|7ds{~Yb=$M*+ovZP$2LdCMBhLkEWGM=o%Ifz zv`CuUr~jGY(jvX5a@&wfE1fZLw=rtas%5D4fo?}xoT}MpO(ZU_s@mPZF!!jOdtS#s z#z{KU2SjvcbsWoR69JK3UDLcC|4|~M{Ya7J&R}SGcW|#LwV>17UZ(hW%>M_$Gj_SH Xm=AAFT|kRko&40kNS0m#7Gin*6dEjYTGjl9@qhuoVHDRuPSb3x;Tz zsL6$@80*SUR76v%OoT7uY&jP zz2}^}oqO&%=iPVT?YYg@EUa5RYfjzujT0wonr3z4b|Eeu*ZsJv8E2vvm;VBQc;VL% zmxXIQt^szObp18W_#wi6b5V3d^EET)HZ@$+)H-wa%+{FzvtWJZlxd5vnOApxQ_G^} z1@q4xJ#l>F=(?u)vlh&5o`f<`7^I+I=BAQ zi<{?+{~U`Re|~f8qDz`uf-|pKIIpRG>Zj`PZ^{Ia_MJs${9@Dmxvf{$Pd(!UQGd00 zHi;)rnmqX!gl9B_CWrvsEQ3UQ^)-f2YFwMJhby-&8p1DHWdmf(p;yt-{amRpFQ4Q{mTA zD*Pv(3g=d;Fz5aSq)x?U;+lQIf`!dZb+eo5K3Zf_Q&Zy^lV&!}nKi5Fj9HV;I_-*y zjb}}4oOxQ~X;(B&o^!?ch4bgKaxX4USb=LK(myW&G#@qjB{ z=ZcrQ;$^OQxhqaypg!w9grp~rSU1Z*$&Mq?PGnr*hj5fD?kF9$!S%B`TnUG_hJsm# zRajwOPadqnYNB{L-tna7p`_A1fjPJyj-mv8B z!8m2qQy5Yx%%4M%{$U79$IZZ zk$h?s=#4G0w)Sv)d)w>XW=c=Kw}18G??n@dcOs0}X>R|Vue$e{;cq)~Q#db5VwZ41S_>l`A+Zb+ZZ})^# z9Xob*dcsHA+P5QD+xG2U?-{9gld06oY?W_Zb=&h z?N!lK8rhP&Ba&91k!V`=-18f^3=XGvz7@ApiR7UTOPXV3phFXz@r|p0{QUD9o+r&a zi42XAiY4RmJ*prO&67lCIE}v5NK#LV&AT$n)@3MZkqjml59e+B=LJQzCN|(m(M-7CA=LBZfmo^ z+tP3vR-v5i9k6^5Czp{lTB(7?vZENS7y~cZvqrX_U`T5OTwssvdl6tB;dGOgK_xv#q*)}3QFYJNx_(SI=^TG6^8#frw zrJh}L=V)_8YWS!x%`q3IuetUP;~xFK)PFqv%G>5U(KlbXXUY2RR3eI;H~q0Rk`8a% zVs1)5y?mHu^_;iRY)M=@Z_$#QZZd98-qd>cLq9eiiaqhWJO2K=+ulq<*EBx7dvGLu z({-(8OV82=Jr5oHj|Zq*R^HV*@6xZ|yQ!V3Yr@03>muT3-iOnxH*PXEM>qZY#*>FH z`q2{|XeMxDB4V4hHMZrqO9l^UeekCq0DnTwtM21}@;c%<_a94$yf^SZUNd!%VVEh) zjW#32wJGLFh_qj%68G0x6~G!++IQad#*M}jgWO#DbTl3}KvE@=@wK;1 zHOsW9d2Z_sH!NA(9V?BC)Bur4J-v3{ZB=Dj+8a7##6;>O5s%;T?bxP^tWv;kONFyNTH5sb0^YR${E%^LV(D#9!ho3=BMfiq(bKGYii=nGr2OaI9Kz^-Gqk4L7f&7e z@u|jGpgf+WZ`zWZZ^pPUN#AguH3`z-@tBq{LWWNZ>3@#=Fi>9AZ_u#%F%75G)C?Sc z^7ykZyyP2~jj60Xp?=)ye|Pae%xWwts~$LF^rUHDysXjV3sm$Qdg6pBmo}P~x1_vg z@W_VGH|mDz@t0N&th=C5L)`KODhEz&gD>qP3O=cIiy{|M%u3)G%aM*L_DvF-R5iJ z^nBeH-0zPI+i^2)A3k$rn!efLN_fi=wTWGtRa9O>TI^?Luv;n(&Y zN~F`tbRwNh7->C$C9Bw5ukNmiBwu|qo{SqwGkqwT2AZpLskis+u8JgH?|dbmz!I6B zelMO*rxK}HEEUNg}Mj+qdG9?#+Q6>tN z|1&w!9%f~E$P1!O&g42{6&8fkM$#ZlK!Zs7TWrmHyYKtr{sRZGSc)uYq?w8zI&k1! ztuL8?X+$w`X6snGo+AdlNgu;oln%*F@65SYffl>JmFGtkFJCAjw~9FAFDL zwyEOGAMWTV4JX@n$b1UY-d+-p@9f;(Az?94ZR-lRZx4i%+uLl=DGzN9hj+G>gcCd4 z+y0%Ur(XDn#hc5+iH`8*D`&r08;M`NcyrrJ%$4fcd}T{N2?&Q@XanF65|9Q(WjHP4 zNl9I9r--Qtr?&l``BL+5cy?P^IQjC{f17X}l3m8Ct$}c2dq?NidyW2?blZ*>WI927 znAN^4({ZNTU)~uAr?csfju)JCOwzyD`C^GpzzR0|!aZ%BSoGawS224HAv*y1?|)>a zdwy>G%8OSC;TLuo;q+t1ZwExuYY9rf*v@>98JiTu7ml{=cyW6;?1H3U-rg>AfhM)q z`1KByy`$5CBv8)w@Jl7()Ml9{{@i(&Z49@!`NOFV=YM+Jz({=Mr#J3sYv1Jgy&g_) z81&#ELbkQ9TesQTkXZZl1{-wY+TXsgWvdyEZ{70zGiwJ*vhm-#Z`G^$gjnv=`~cWggL9+0ndUOGMsQ9XP*XuOF|I~RIxHj( z7FD>awSl7MsF$ekFZyYPul`!C81NCVfm*c~ly{YBfk@Bpt;Un$3EvFyJA~~6B0YQB zs@99n0TIz&wZ3QLtNlQ-y?VWP9+5q5H8LXFYuAfE)ezHfeQiXv^eE8h{td|ItqM${c#)xIvB3s zs9(Dh4g+mRVbmWL?v%_sY@{6;L`TJJ{0S;7ItoQcq39?S9fkPHqN7lB6pj-|;k=_b z3df;;V7~M*{R8gjbN$BcbSDt!l8o&ijI-_kq}2sz06KM$f=@cz!50@2iVF$Fg@ocl zLeW1c`Ul5}e{g1R|KNCZ3x4)7-2#te!c{o=9aq{}60qHZ)9gDEQf5 zcJM{Fpy(D9-GZW9P;?85ZozTl7L3oh1;?W|aP!CX2KaHE>vvAvmB{l3rVLZK^Z!tQ z2B0bD+W3ET@I`N+=nWLTfuc81^ahIFz;WUYoOC=o0hfGCCxF{5T)zu?T!}m<;KF)^ z`+Qdc8h|c5-^R~&@I@z}=mZpqoE!ZdM zP#oL94rrp2ud^Jd=I)B=z^g=^lJYi71w zeci&k`Y9?hbqdZ}nqN0(!Ti>9XVeF0&R^89sHvrS&Ww5+v#5DV6EJ5^K66qAHfKRg z)7+K?*Ug^|%&5~R^B<64@stTHZfbsD*Hw=bn^;qwt0D*1z>4b5nB*Kar6?s-o#nK! zd2!P`9s@OVerxm0dCfBy)!itMg_<{W{@m+uE?8atoR)?;EiCYgnTwhlW;eGq&ElzC zb@N(V0L(w~3g<{oo^<+YXEpYY@U*I{u3OaFJm%Gn4c9EdiA}R_#DQeZ zvuuz&p(@j~E1S_a9B?HYMxMKV7v1VgIHGC4mAQzgr#U|dhxA315BUpe%Qf)@D`;vx z{!5Ku;!E1a!QgZpUohPWis@y+9_ zE=3m#U@DwOBBPO$#|(-~(akTJ4u#~7yFyZsP1;dO{?0&;zHwBVxOV=RzkCr?%TcQG z_Kp1#c=u(mhH?C|YozR!o)FUhqJ^|?Ye}oRx)i(1Q{JlTQUeELRQq^vL!ip51p?JE z=&@=04?G?)wIlk~Zyop%G{B;L&7koA^PexaR+Jo!zWp^E-Iq@6n+%ewClcvLKyp)Z zAxT!gKiyhx>`lFKxaXj;w|h5+_=05T-a8CKJ2LT&b@5zM&1(cwRrhu`n}N5WYA#6r z?8yz#tVZbF=2(U@B@0DsS9H(vYWprou4>tLfv(kdtp4oLh(^6}pGUj+?YUDFQR*&h zeCqT^&^&iZB@gZz;{X1uW6x1UBJD|~d-7=X>HB65f9YY%rs>&bJ(t?BH=k(4j-Smv z`~}@60yFef$piUB#=q?O(CmPqT}IbEA_IJMk!ETZx|SDmi)O_Tzg@F_#I&#F@A z$5xWy>?8jLhTj;Q55D;CKLEi`dG9`Sg;z_LjUS4WFiIOnKtu+N?+1iF^wdfQCp6TA zw7t|Hj!wy%LV38u|0Ppchi(>4;c;OKr+B~no5G3IET1G(cqiKPah?iCAb-&m9v7zY@|6@m`{$TD+Z0~@D+htl!6AD2 zHV0lbg+)_Xd@3wH75)!A6BEs7wurt4i@cT(GGqzJDB+cI9weaII*#@0e?eeUi18>hD8fonp>NkzoucUqfeBH zbTs2feSzro$u8&0lbr(bPO%g53vt;rS6-!XPYtod(RL``s%qL()9myYRLFa#48)t) zD)@YsiQl#M9;KGMRr>n9D*UFsSLsfBCGZ}spYyl!Q5F8c4*%VLIrh*)3jSZ`sPGX! zI^*}K{VCIGyFI_+5z+Ylx83gF+2I=7ysWkJubrjn*4}Q1cKK@`x6|!*_?jI?Rrn;w z0KX^s_|4Gf$CGpIbjS|vN4_V2Yo|Bc;Y)USzz&59pBkt_oZzA@(}qRsG!Y#Z6w%=b zM<5)e=}{f=k%;%#%s%3v36iQDR5g+^3(=$1c@QI7l>_ky{ZTV$MEyBXUoa3gf@aj0 z1N8!G2CXRG`=LEfEnehi2@DHibd*vUBT#E0O!-lvIQc1BN^ZzrvsQ<`FHAJ5Y0+pf z7>)Wh3vDI0viS;QYeA94c#zL4^ZA0+=uaVxKUj(WBxPM@&=)O{^dtL(c);J%9Gc#c z{yF&k6yEwuIl=kw(f)XWtfU7gzej7abT%yNm9cTSR|nO-0rAS9x=%p7Jm}obs3)j{ zH={Kl+NI(XK>=<-?l{)HQy77$DRB*WBP!*=PWPtGmNOc(9?geR&1lFtUpqG#($?UO zDaJIoNX-OMr;QN9wUIdTN*B5|ND*%b%cmf+dJMcTaRO;=E&CX6}@8N-=TS zSeBO1GbPTsQ;yG)((c@K%$Kv!Ti(s+Ij*Zx&bo6kLtGc~pF`e6%QS(R$T@JcHVt#& zC8R5X`HC6?fib|i8Uka0W8-ulSSLyl#|Qu>?6?r%Kjfqq2h%a0hHb$Kdj}TcZ-XIP zDZqXVtq@>84ir-`FkcMR(vLSH8<;PKAO!10y$ppIg%E5PY0!i1Vg%A)I%tOr@gpUU zN-VRYsKl~VOvZHR`|^o1QJ=az;w*GA_c8?-XkV;H7hs+1QaG><;%y4aI*2b=hkoh; ztTU>>(y?H<=&H!X%8=&3og|eN#oC>QIiP?`rH9iDK}k{bv`Ee%FeT5WJh_##^5LhX~A?su*s;e*oE`z9kS__#=HBlD|2yGh1 z0dN7h09?BRc}~<|oN-x*<4^3sr4TUP46_E5P7n6>L`!4{DU&t!hO(V>jIe8oCKiW8 zB1-Zs0=73A1S5%2Hf*S(k`-eUPM>hEO+QeRg`;3(iL)M7Wg`b@NffWKT7{JH_(Wg@fAwwc5Kr=IaYLz?FyfvVCrtKbS_}oE}s|snVff z+NU^P2xYkmpjJQx z)nZ2`LEsy!hdPl8L(LUHWXoY>p$v^+PgGzJBeLM{l`)iA)GYmo8Eo~1;tJLgM{iVj z-L=?_JW3{)3RD@DIz;>Xs0ibrdf={y@&BDHP)KO$2X54dA%_ALP{ALdc#l-60=6Mb z>?q|7KdD0ThwN;buv?s~rm>$j3fE3bEX3UDR-VQ-RBe1n$5G2@BGiOECOPUGFpL#y z>`{{g#ZX8bRh}J13U77>8F61tMc%E2wHLi(p9*8us^JU_Noe(g;tjU&RVK)u@`>iqur{s)9eZDS4Aex z!@_+iL(w}NV4f@&SS~!03|Ck##MPB%zQ1-_mv+$}W!gJ6!Xm^G#?j;15Kb1>m>~L7#^!b#x&amh=ggCXc2ypIjL>I$8M`WxqI^+!(rUaMYpg z)NFO*IO83=m!I#$;vk6fNrL5bS&U9+ZrRFo1I;FRgWa@&TpE;qy=)(JuFU6^9pe2Ev9$fRM0p?1-M?xvtT;-?VS%$|`ey^gYT`3<{0i}UevpxV_!N~%>BEvp) z0Tx(tr!+irnZtp9GSYI4u|9;R$*6pyk0PZO2k>_xARw7!e=QAv)~`tY?!-W?CyG^> zG8LRCQHuIR6h07FNWV)y**ygA4#8r`$;IynJ7SP1d)F$+>7aH1&bvp~%*AHI$ zVKH*!w5_Es9BpPGlP&?h%IAkgbK*W&yQ9Tvmxx z+RA+NYX+cwj1K@mxwdb?gXjlXEj$g5M-9Si;OWIwNsol`5zxvY6J>z8WyO^8Lm3)| z>whYw{FR|RhLC;%r-velzuDqM`=ceq`t8uB!WM~x5 zeAI^%leKHTX!9KsWgS$jbBXi4k-r9Ij9!;oeQ+~C^(?;uAf|`LP8%6s7A94z!R0o; zk4{6Qdo&6@W~!2U`!T8>t};XW9kmoCk+j1{tBX|hbV`p&g};SPz6Fj!C|^V4JqC2$q4yab{=~RhkK@gbfO*S>xTh| zGaqqW*69zJlP&{{WjpCo@SMv$>NST4eWRm9=UCeSbF153mPaA!&(1Gm5DtU0FK^JIggHdcACL8RC5B zA(y+@ehXk7i+#!&!#smSwp2P~m}dg>46k)`!Fl#QOv=I7vVm7AQcDcNwu~W&Q%}}v zURT`gavZd3&}ZYMch*ek09knV&J82b^=@}dfz=Xk)?X?|72HDt6_NE{SU2W2-{F4< z1l=q29JxICxiY>LT<=17Wu56g9t8~|lw6MODtV1nx&KCJWkHpyWyOvgldT>z5UQ7U zmM&9oJmkq4nF`yH$Y%ZkNYw(z@yXAcpo5}iJf18XF;wfk2XlNK7hb*zQ?G{Pas)Fd z2TBc!dR6AUJ)neg>mjXDVSE{Ijy&Y&rNe^lfKtNpar8Fu$W;(WZzW)hY<@oFSuh{A zEM+>Qtmd(B)tGfv*qo&NBvNyZm1}fEIWGvQ#cZJERBtgHm>1it6i*zlgBz=8{`oAC zg?a25nT3tcqxo=YIi4$6JwL??724I&R60g=Vpo*~G!uK0$9YduJ+g$sh9=*_< z*=K&b6Hp$u$tFHGO*eq5wE}K|vmQVwd6sB+cAeShS|2ZI^XL_2MC^1{l{18~6X2E8i2_MsQxz-tkwwRTuh4L0#b z4B5hXh0RLF%VG8iC*E1^V7wF-n?uAnEjVfTvL-iytzoQVh?XiQR<%V3#w`GuP+G7YcY%hcwiy;td zZ0u97Ywncun{&JrGnk=@! zpEtCiAGAJvQefc{K@u8vc$+Pjua z0dr)Cj|sX$-X5uj?nU{Rp;s46HN+1jOk6sq6IWC?txQY_ZWf>eYvSU&G5XZ}7Km^z zxl#2HM?ag-226WU4tFjn7{mbdCekch?Tw-X%e>4(IG6iaE|&}GSmx*Fu3whRJiJ&x z>vHnbspbwOK2tyhf#b5LDx3M7_BnX&W>U*uI&l0RDBFIPOJGNw=M@J6p>=!;JI7=*XV(ix&2piaRE2KqVw5f-Ix2PGUB* z5R9)`=~d*@sx++vEkY8TaW%;bH=OmbdknK>@EL=eEnBA3csI-`6QU;D$Ie64Bop}E ziWD_yJBS*zoe&hYTOqL23SbT{3nI=B3zf)ID`0gQ+JZ1V`6I4EH{PIz%V-U1GlWMQE=53)&*HY-F%*Q z*Q@YOJKS`v$ANP`yW8u~5zf9jJW*;^y(Lm{>J^n&^@(b@Z+wWdgqBiM1O#Ns73boM z(n634)JPwRYwU&Me&i*bTvSU1S=z!dRi{&4Uu`gilqCUKLci21Klu(>f_c!fJ!2@w zutq?ZaG3(Kgz?b@Woap75Z~ej&xUM4cNM&**fb$nZ!?JZaG8002X8Ba z7pZ>AZw}E<*LQ`var9Ht$bP~sIaYuhugsh@$BmaWKD189PII-4vcl&KnmsKPwArmWETr*PjJ8{`hInL;(KtJJ~>sSI6g8h70tHt+!wfGj$dnE98e)dUQ0_ zjA0c7Kbg&sJNOS0;$xuTK$KO2FUE4?YV_e=;3cqX17s0zC5{fqtdL&-@>Bg1e8UJi zMw+>(`TN51i9zMV@+@9Gc(ACf^7V~Tc9G@tbA9&J$&;wkn*H;0JuxT;pl_{Y4%O=pPyB$zPi&+eunPyRGOdo`v6yA$e1qO?heeu3AoOM zjQ&PTju=0pU)ecYsA5%pzho+0zmV}c%#AbTrRLUYkZ#T~C&Y4V-9)5mF;0U; zQC}2Or<%y;z*QLXg!zrC(Ii)3QNR37#`9ayOwnMBi$hpG9Hge-?&XD&yKhl=c z^0!QJc4h0!rYJ4W+{zq_Q4p3ppZGDQJPJdG*?E?Q=guQYwtOR}!tDG~X*~C4<_~8~ zXaV+OJ!%sKsj4@&D7Be012|K#$R~w8E}I-F3|BL5FlHXsyW}inJY;-E&cG*OD^Vwo`Vg@Ap^Bw)UH~e$X#xt{M}MFi zeXT`qC!`gTGXtS64}-|2$bHHuwJ7=^tqRjFYd4ODQMD->;?(V= z)GXmA*m$2I@9KkWm7;vnqCjKd&iO-|QZ7@RL7YALDagL$EiDZ4=cF<-Na{*^HX+V~ zgZhE*oGrWxK`b0@q_E^bHFS$RJyG*O!W4Jhah^xOwx5{^eTY-CFOibH0TPXOfx)k% zr6n231{A)NApnK{7SisAR3FA_=s^I7!@L6QJS|lIAf8N6irl-!6)}4J3{i@F!sfFF zSD;XeeBx7w#_+vl0P9dFMW7V?<(W$OU_1z_7Dv=zjzYK@+H#&3RajCMx}1_qz*i6U zlc|vHVGK{RK`uY`gJ+ft86+y$Pl5G%Q3V=z$`oak7Kw+=&%~geL<36Yoo|E2*>^r6 zjmEPe@0eHOBzYed#Jmy%&^vD?W|7~bAFL8z*qbtizfXi#a#ce?*k&w=qD>6>9oSHm zqhQPjj!U>M;TUnG)$_IkA1-MeIG2+7{E0OY(DCRcce)1U*W2_syCxHN&dCYs?U0IT zC}FBRDr*}g;Z#7d`K84Q;fC42`GzC*Q}$)754-y)YGvnr=N^xN6dpyU?@Xa zkjBS?9HUVv!%1^IMk1}o$0x^alruKiM{*M4%u|9fapJHol0F-%r;CtrnKltK@uzZ( z__?(aT%d(Y@R_rI8m`G&+AvMaGCVz&ajNFguh-JL<(s0Vt@7$~v>vMlQFBmZ$Xu>X z#Mq!5oVUbJz6^nK^y%1z!2K>3W)P)L-C=YP|FC!Lb3iV2$7(amRCj*HT!OtQt8q@! z&69 zYh0Pchdxq!g|zid%MX93hnQwAEW#s>H9@4=x(OjYP?s(Iu_EGNcb-pBK-?( zCfdb3Tu(<_zUW{az6)Uc7=mt}hW$2JFCuI;67+!>7wg$(`cKn=ifjHnl#~*fJzuA{J7{^N*w)+ij1mM|z zZeiNh&n?=OGSBxJg8uv3jk50B7$SX}HWu|^Z!XGR!#Fm43T~Se wza;5aF)k1Xe3f 1; + + /** @const */ + var IS_IOS = /iPad|iPhone|iPod/.test(window.navigator.platform); + + /** @const */ + var IS_MOBILE = /Android/.test(window.navigator.userAgent) || IS_IOS; + + /** @const */ + var IS_TOUCH_ENABLED = 'ontouchstart' in window; + + /** + * Default game configuration. + * @enum {number} + */ + Runner.config = { + ACCELERATION: 0.001, + BG_CLOUD_SPEED: 0.2, + BOTTOM_PAD: 10, + CLEAR_TIME: 3000, + CLOUD_FREQUENCY: 0.5, + GAMEOVER_CLEAR_TIME: 750, + GAP_COEFFICIENT: 0.6, + GRAVITY: 0.6, + INITIAL_JUMP_VELOCITY: 12, + INVERT_FADE_DURATION: 12000, + INVERT_DISTANCE: 700, + MAX_BLINK_COUNT: 3, + MAX_CLOUDS: 6, + MAX_OBSTACLE_LENGTH: 3, + MAX_OBSTACLE_DUPLICATION: 2, + MAX_SPEED: 13, + MIN_JUMP_HEIGHT: 35, + MOBILE_SPEED_COEFFICIENT: 1.2, + RESOURCE_TEMPLATE_ID: 'audio-resources', + SPEED: 6, + SPEED_DROP_COEFFICIENT: 3 + }; + + + /** + * Default dimensions. + * @enum {string} + */ + Runner.defaultDimensions = { + WIDTH: DEFAULT_WIDTH, + HEIGHT: 150 + }; + + + /** + * CSS class names. + * @enum {string} + */ + Runner.classes = { + CANVAS: 'runner-canvas', + CONTAINER: 'runner-container', + CRASHED: 'crashed', + ICON: 'icon-offline', + INVERTED: 'inverted', + SNACKBAR: 'snackbar', + SNACKBAR_SHOW: 'snackbar-show', + TOUCH_CONTROLLER: 'controller' + }; + + + /** + * Sprite definition layout of the spritesheet. + * @enum {Object} + */ + Runner.spriteDefinition = { + LDPI: { + CACTUS_LARGE: { x: 332, y: 2 }, + CACTUS_SMALL: { x: 228, y: 2 }, + CLOUD: { x: 86, y: 2 }, + HORIZON: { x: 2, y: 54 }, + MOON: { x: 484, y: 2 }, + PTERODACTYL: { x: 134, y: 2 }, + RESTART: { x: 2, y: 2 }, + TEXT_SPRITE: { x: 655, y: 2 }, + TREX: { x: 848, y: 2 }, + STAR: { x: 645, y: 2 } + }, + HDPI: { + CACTUS_LARGE: { x: 652, y: 2 }, + CACTUS_SMALL: { x: 446, y: 2 }, + CLOUD: { x: 166, y: 2 }, + HORIZON: { x: 2, y: 104 }, + MOON: { x: 954, y: 2 }, + PTERODACTYL: { x: 260, y: 2 }, + RESTART: { x: 2, y: 2 }, + TEXT_SPRITE: { x: 1294, y: 2 }, + TREX: { x: 1678, y: 2 }, + STAR: { x: 1276, y: 2 } + } + }; + + + /** + * Sound FX. Reference to the ID of the audio tag on interstitial page. + * @enum {string} + */ + Runner.sounds = { + BUTTON_PRESS: 'offline-sound-press', + HIT: 'offline-sound-hit', + SCORE: 'offline-sound-reached' + }; + + + /** + * Key code mapping. + * @enum {Object} + */ + Runner.keycodes = { + JUMP: { '38': 1, '32': 1 }, // Up, spacebar + DUCK: { '40': 1 }, // Down + RESTART: { '13': 1 } // Enter + }; + + + /** + * Runner event names. + * @enum {string} + */ + Runner.events = { + ANIM_END: 'webkitAnimationEnd', + CLICK: 'click', + KEYDOWN: 'keydown', + KEYUP: 'keyup', + MOUSEDOWN: 'mousedown', + MOUSEUP: 'mouseup', + RESIZE: 'resize', + TOUCHEND: 'touchend', + TOUCHSTART: 'touchstart', + VISIBILITY: 'visibilitychange', + BLUR: 'blur', + FOCUS: 'focus', + LOAD: 'load' + }; + + + Runner.prototype = { + /** + * Whether the easter egg has been disabled. CrOS enterprise enrolled devices. + * @return {boolean} + */ + isDisabled: function () { + // return loadTimeData && loadTimeData.valueExists('disabledEasterEgg'); + return false; + }, + + /** + * For disabled instances, set up a snackbar with the disabled message. + */ + setupDisabledRunner: function () { + this.containerEl = document.createElement('div'); + this.containerEl.className = Runner.classes.SNACKBAR; + this.containerEl.textContent = loadTimeData.getValue('disabledEasterEgg'); + this.outerContainerEl.appendChild(this.containerEl); + + // Show notification when the activation key is pressed. + document.addEventListener(Runner.events.KEYDOWN, function (e) { + if (Runner.keycodes.JUMP[e.keyCode]) { + this.containerEl.classList.add(Runner.classes.SNACKBAR_SHOW); + document.querySelector('.icon').classList.add('icon-disabled'); + } + }.bind(this)); + }, + + /** + * Setting individual settings for debugging. + * @param {string} setting + * @param {*} value + */ + updateConfigSetting: function (setting, value) { + if (setting in this.config && value != undefined) { + this.config[setting] = value; + + switch (setting) { + case 'GRAVITY': + case 'MIN_JUMP_HEIGHT': + case 'SPEED_DROP_COEFFICIENT': + this.tRex.config[setting] = value; + break; + case 'INITIAL_JUMP_VELOCITY': + this.tRex.setJumpVelocity(value); + break; + case 'SPEED': + this.setSpeed(value); + break; + } + } + }, + + /** + * Cache the appropriate image sprite from the page and get the sprite sheet + * definition. + */ + loadImages: function () { + if (IS_HIDPI) { + Runner.imageSprite = document.getElementById('offline-resources-1x'); + this.spriteDef = Runner.spriteDefinition.HDPI; + } else { + Runner.imageSprite = document.getElementById('offline-resources-1x'); + this.spriteDef = Runner.spriteDefinition.LDPI; + } + + if (Runner.imageSprite.complete) { + this.init(); + } else { + // If the images are not yet loaded, add a listener. + Runner.imageSprite.addEventListener(Runner.events.LOAD, + this.init.bind(this)); + } + }, + + /** + * Load and decode base 64 encoded sounds. + */ + loadSounds: function () { + if (!IS_IOS) { + this.audioContext = new AudioContext(); + + var resourceTemplate = + document.getElementById(this.config.RESOURCE_TEMPLATE_ID).content; + + for (var sound in Runner.sounds) { + var soundSrc = + resourceTemplate.getElementById(Runner.sounds[sound]).src; + soundSrc = soundSrc.substr(soundSrc.indexOf(',') + 1); + var buffer = decodeBase64ToArrayBuffer(soundSrc); + + // Async, so no guarantee of order in array. + this.audioContext.decodeAudioData(buffer, function (index, audioData) { + this.soundFx[index] = audioData; + }.bind(this, sound)); + } + } + }, + + /** + * Sets the game speed. Adjust the speed accordingly if on a smaller screen. + * @param {number} opt_speed + */ + setSpeed: function (opt_speed) { + var speed = opt_speed || this.currentSpeed; + + // Reduce the speed on smaller mobile screens. + if (this.dimensions.WIDTH < DEFAULT_WIDTH) { + var mobileSpeed = speed * this.dimensions.WIDTH / DEFAULT_WIDTH * + this.config.MOBILE_SPEED_COEFFICIENT; + this.currentSpeed = mobileSpeed > speed ? speed : mobileSpeed; + } else if (opt_speed) { + this.currentSpeed = opt_speed; + } + }, + + /** + * Game initialiser. + */ + init: function () { + // Hide the static icon. + document.querySelector('.' + Runner.classes.ICON).style.visibility = + 'hidden'; + + this.adjustDimensions(); + this.setSpeed(); + + this.containerEl = document.createElement('div'); + this.containerEl.className = Runner.classes.CONTAINER; + + // Player canvas container. + this.canvas = createCanvas(this.containerEl, this.dimensions.WIDTH, + this.dimensions.HEIGHT, Runner.classes.PLAYER); + + this.canvasCtx = this.canvas.getContext('2d'); + this.canvasCtx.fillStyle = '#f7f7f7'; + this.canvasCtx.fill(); + Runner.updateCanvasScaling(this.canvas); + + // Horizon contains clouds, obstacles and the ground. + this.horizon = new Horizon(this.canvas, this.spriteDef, this.dimensions, + this.config.GAP_COEFFICIENT); + + // Distance meter + this.distanceMeter = new DistanceMeter(this.canvas, + this.spriteDef.TEXT_SPRITE, this.dimensions.WIDTH); + + // Draw t-rex + this.tRex = new Trex(this.canvas, this.spriteDef.TREX); + + this.outerContainerEl.appendChild(this.containerEl); + + if (IS_MOBILE) { + this.createTouchController(); + } + + this.startListening(); + this.update(); + + window.addEventListener(Runner.events.RESIZE, + this.debounceResize.bind(this)); + }, + + /** + * Create the touch controller. A div that covers whole screen. + */ + createTouchController: function () { + this.touchController = document.createElement('div'); + this.touchController.className = Runner.classes.TOUCH_CONTROLLER; + this.outerContainerEl.appendChild(this.touchController); + }, + + /** + * Debounce the resize event. + */ + debounceResize: function () { + if (!this.resizeTimerId_) { + this.resizeTimerId_ = + setInterval(this.adjustDimensions.bind(this), 250); + } + }, + + /** + * Adjust game space dimensions on resize. + */ + adjustDimensions: function () { + clearInterval(this.resizeTimerId_); + this.resizeTimerId_ = null; + + var boxStyles = window.getComputedStyle(this.outerContainerEl); + var padding = Number(boxStyles.paddingLeft.substr(0, + boxStyles.paddingLeft.length - 2)); + + this.dimensions.WIDTH = this.outerContainerEl.offsetWidth - padding * 2; + + // Redraw the elements back onto the canvas. + if (this.canvas) { + this.canvas.width = this.dimensions.WIDTH; + this.canvas.height = this.dimensions.HEIGHT; + + Runner.updateCanvasScaling(this.canvas); + + this.distanceMeter.calcXPos(this.dimensions.WIDTH); + this.clearCanvas(); + this.horizon.update(0, 0, true); + this.tRex.update(0); + + // Outer container and distance meter. + if (this.playing || this.crashed || this.paused) { + this.containerEl.style.width = this.dimensions.WIDTH + 'px'; + this.containerEl.style.height = this.dimensions.HEIGHT + 'px'; + this.distanceMeter.update(0, Math.ceil(this.distanceRan)); + this.stop(); + } else { + this.tRex.draw(0, 0); + } + + // Game over panel. + if (this.crashed && this.gameOverPanel) { + this.gameOverPanel.updateDimensions(this.dimensions.WIDTH); + this.gameOverPanel.draw(); + } + } + }, + + /** + * Play the game intro. + * Canvas container width expands out to the full width. + */ + playIntro: function () { + if (!this.activated && !this.crashed) { + this.playingIntro = true; + this.tRex.playingIntro = true; + + // CSS animation definition. + var keyframes = '@-webkit-keyframes intro { ' + + 'from { width:' + Trex.config.WIDTH + 'px }' + + 'to { width: ' + this.dimensions.WIDTH + 'px }' + + '}'; + + // create a style sheet to put the keyframe rule in + // and then place the style sheet in the html head + var sheet = document.createElement('style'); + sheet.innerHTML = keyframes; + document.head.appendChild(sheet); + + this.containerEl.addEventListener(Runner.events.ANIM_END, + this.startGame.bind(this)); + + this.containerEl.style.webkitAnimation = 'intro .4s ease-out 1 both'; + this.containerEl.style.width = this.dimensions.WIDTH + 'px'; + + // if (this.touchController) { + // this.outerContainerEl.appendChild(this.touchController); + // } + this.playing = true; + this.activated = true; + } else if (this.crashed) { + this.restart(); + } + }, + + + /** + * Update the game status to started. + */ + startGame: function () { + this.runningTime = 0; + this.playingIntro = false; + this.tRex.playingIntro = false; + this.containerEl.style.webkitAnimation = ''; + this.playCount++; + + // Handle tabbing off the page. Pause the current game. + document.addEventListener(Runner.events.VISIBILITY, + this.onVisibilityChange.bind(this)); + + window.addEventListener(Runner.events.BLUR, + this.onVisibilityChange.bind(this)); + + window.addEventListener(Runner.events.FOCUS, + this.onVisibilityChange.bind(this)); + }, + + clearCanvas: function () { + this.canvasCtx.clearRect(0, 0, this.dimensions.WIDTH, + this.dimensions.HEIGHT); + }, + + /** + * Update the game frame and schedules the next one. + */ + update: function () { + this.updatePending = false; + + var now = getTimeStamp(); + var deltaTime = now - (this.time || now); + this.time = now; + + if (this.playing) { + this.clearCanvas(); + + if (this.tRex.jumping) { + this.tRex.updateJump(deltaTime); + } + + this.runningTime += deltaTime; + var hasObstacles = this.runningTime > this.config.CLEAR_TIME; + + // First jump triggers the intro. + if (this.tRex.jumpCount == 1 && !this.playingIntro) { + this.playIntro(); + } + + // The horizon doesn't move until the intro is over. + if (this.playingIntro) { + this.horizon.update(0, this.currentSpeed, hasObstacles); + } else { + deltaTime = !this.activated ? 0 : deltaTime; + this.horizon.update(deltaTime, this.currentSpeed, hasObstacles, + this.inverted); + } + + // Check for collisions. + var collision = hasObstacles && + checkForCollision(this.horizon.obstacles[0], this.tRex); + + if (!collision) { + this.distanceRan += this.currentSpeed * deltaTime / this.msPerFrame; + + if (this.currentSpeed < this.config.MAX_SPEED) { + this.currentSpeed += this.config.ACCELERATION; + } + } else { + this.gameOver(); + } + + var playAchievementSound = this.distanceMeter.update(deltaTime, + Math.ceil(this.distanceRan)); + + if (playAchievementSound) { + this.playSound(this.soundFx.SCORE); + } + + // Night mode. + if (this.invertTimer > this.config.INVERT_FADE_DURATION) { + this.invertTimer = 0; + this.invertTrigger = false; + this.invert(); + } else if (this.invertTimer) { + this.invertTimer += deltaTime; + } else { + var actualDistance = + this.distanceMeter.getActualDistance(Math.ceil(this.distanceRan)); + + if (actualDistance > 0) { + this.invertTrigger = !(actualDistance % + this.config.INVERT_DISTANCE); + + if (this.invertTrigger && this.invertTimer === 0) { + this.invertTimer += deltaTime; + this.invert(); + } + } + } + } + + if (this.playing || (!this.activated && + this.tRex.blinkCount < Runner.config.MAX_BLINK_COUNT)) { + this.tRex.update(deltaTime); + this.scheduleNextUpdate(); + } + }, + + /** + * Event handler. + */ + handleEvent: function (e) { + return (function (evtType, events) { + switch (evtType) { + case events.KEYDOWN: + case events.TOUCHSTART: + case events.MOUSEDOWN: + this.onKeyDown(e); + break; + case events.KEYUP: + case events.TOUCHEND: + case events.MOUSEUP: + this.onKeyUp(e); + break; + } + }.bind(this))(e.type, Runner.events); + }, + + /** + * Bind relevant key / mouse / touch listeners. + */ + startListening: function () { + // Keys. + document.addEventListener(Runner.events.KEYDOWN, this); + document.addEventListener(Runner.events.KEYUP, this); + + if (IS_MOBILE) { + // Mobile only touch devices. + this.touchController.addEventListener(Runner.events.TOUCHSTART, this); + this.touchController.addEventListener(Runner.events.TOUCHEND, this); + this.containerEl.addEventListener(Runner.events.TOUCHSTART, this); + } else { + // Mouse. + document.addEventListener(Runner.events.MOUSEDOWN, this); + document.addEventListener(Runner.events.MOUSEUP, this); + } + }, + + /** + * Remove all listeners. + */ + stopListening: function () { + document.removeEventListener(Runner.events.KEYDOWN, this); + document.removeEventListener(Runner.events.KEYUP, this); + + if (IS_MOBILE) { + this.touchController.removeEventListener(Runner.events.TOUCHSTART, this); + this.touchController.removeEventListener(Runner.events.TOUCHEND, this); + this.containerEl.removeEventListener(Runner.events.TOUCHSTART, this); + } else { + document.removeEventListener(Runner.events.MOUSEDOWN, this); + document.removeEventListener(Runner.events.MOUSEUP, this); + } + }, + + /** + * Process keydown. + * @param {Event} e + */ + onKeyDown: function (e) { + // Prevent native page scrolling whilst tapping on mobile. + if (IS_MOBILE && this.playing) { + e.preventDefault(); + } + + if (e.target != this.detailsButton) { + if (!this.crashed && (Runner.keycodes.JUMP[e.keyCode] || + e.type == Runner.events.TOUCHSTART)) { + if (!this.playing) { + this.loadSounds(); + this.playing = true; + this.update(); + if (window.errorPageController) { + errorPageController.trackEasterEgg(); + } + } + // Play sound effect and jump on starting the game for the first time. + if (!this.tRex.jumping && !this.tRex.ducking) { + this.playSound(this.soundFx.BUTTON_PRESS); + this.tRex.startJump(this.currentSpeed); + } + } + + if (this.crashed && e.type == Runner.events.TOUCHSTART && + e.currentTarget == this.containerEl) { + this.restart(); + } + } + + if (this.playing && !this.crashed && Runner.keycodes.DUCK[e.keyCode]) { + e.preventDefault(); + if (this.tRex.jumping) { + // Speed drop, activated only when jump key is not pressed. + this.tRex.setSpeedDrop(); + } else if (!this.tRex.jumping && !this.tRex.ducking) { + // Duck. + this.tRex.setDuck(true); + } + } + }, + + + /** + * Process key up. + * @param {Event} e + */ + onKeyUp: function (e) { + var keyCode = String(e.keyCode); + var isjumpKey = Runner.keycodes.JUMP[keyCode] || + e.type == Runner.events.TOUCHEND || + e.type == Runner.events.MOUSEDOWN; + + if (this.isRunning() && isjumpKey) { + this.tRex.endJump(); + } else if (Runner.keycodes.DUCK[keyCode]) { + this.tRex.speedDrop = false; + this.tRex.setDuck(false); + } else if (this.crashed) { + // Check that enough time has elapsed before allowing jump key to restart. + var deltaTime = getTimeStamp() - this.time; + + if (Runner.keycodes.RESTART[keyCode] || this.isLeftClickOnCanvas(e) || + (deltaTime >= this.config.GAMEOVER_CLEAR_TIME && + Runner.keycodes.JUMP[keyCode])) { + this.restart(); + } + } else if (this.paused && isjumpKey) { + // Reset the jump state + this.tRex.reset(); + this.play(); + } + }, + + /** + * Returns whether the event was a left click on canvas. + * On Windows right click is registered as a click. + * @param {Event} e + * @return {boolean} + */ + isLeftClickOnCanvas: function (e) { + return e.button != null && e.button < 2 && + e.type == Runner.events.MOUSEUP && e.target == this.canvas; + }, + + /** + * RequestAnimationFrame wrapper. + */ + scheduleNextUpdate: function () { + if (!this.updatePending) { + this.updatePending = true; + this.raqId = requestAnimationFrame(this.update.bind(this)); + } + }, + + /** + * Whether the game is running. + * @return {boolean} + */ + isRunning: function () { + return !!this.raqId; + }, + + /** + * Game over state. + */ + gameOver: function () { + this.playSound(this.soundFx.HIT); + vibrate(200); + + this.stop(); + this.crashed = true; + this.distanceMeter.acheivement = false; + + this.tRex.update(100, Trex.status.CRASHED); + + // Game over panel. + if (!this.gameOverPanel) { + this.gameOverPanel = new GameOverPanel(this.canvas, + this.spriteDef.TEXT_SPRITE, this.spriteDef.RESTART, + this.dimensions); + } else { + this.gameOverPanel.draw(); + } + + // Update the high score. + if (this.distanceRan > this.highestScore) { + this.highestScore = Math.ceil(this.distanceRan); + this.distanceMeter.setHighScore(this.highestScore); + } + + // Reset the time clock. + this.time = getTimeStamp(); + }, + + stop: function () { + this.playing = false; + this.paused = true; + cancelAnimationFrame(this.raqId); + this.raqId = 0; + }, + + play: function () { + if (!this.crashed) { + this.playing = true; + this.paused = false; + this.tRex.update(0, Trex.status.RUNNING); + this.time = getTimeStamp(); + this.update(); + } + }, + + restart: function () { + if (!this.raqId) { + this.playCount++; + this.runningTime = 0; + this.playing = true; + this.crashed = false; + this.distanceRan = 0; + this.setSpeed(this.config.SPEED); + this.time = getTimeStamp(); + this.containerEl.classList.remove(Runner.classes.CRASHED); + this.clearCanvas(); + this.distanceMeter.reset(this.highestScore); + this.horizon.reset(); + this.tRex.reset(); + this.playSound(this.soundFx.BUTTON_PRESS); + this.invert(true); + this.update(); + } + }, + + /** + * Pause the game if the tab is not in focus. + */ + onVisibilityChange: function (e) { + if (document.hidden || document.webkitHidden || e.type == 'blur' || + document.visibilityState != 'visible') { + this.stop(); + } else if (!this.crashed) { + this.tRex.reset(); + this.play(); + } + }, + + /** + * Play a sound. + * @param {SoundBuffer} soundBuffer + */ + playSound: function (soundBuffer) { + if (soundBuffer) { + var sourceNode = this.audioContext.createBufferSource(); + sourceNode.buffer = soundBuffer; + sourceNode.connect(this.audioContext.destination); + sourceNode.start(0); + } + }, + + /** + * Inverts the current page / canvas colors. + * @param {boolean} Whether to reset colors. + */ + invert: function (reset) { + if (reset) { + document.body.classList.toggle(Runner.classes.INVERTED, false); + this.invertTimer = 0; + this.inverted = false; + } else { + this.inverted = document.body.classList.toggle(Runner.classes.INVERTED, + this.invertTrigger); + } + } + }; + + + /** + * Updates the canvas size taking into + * account the backing store pixel ratio and + * the device pixel ratio. + * + * See article by Paul Lewis: + * http://www.html5rocks.com/en/tutorials/canvas/hidpi/ + * + * @param {HTMLCanvasElement} canvas + * @param {number} opt_width + * @param {number} opt_height + * @return {boolean} Whether the canvas was scaled. + */ + Runner.updateCanvasScaling = function (canvas, opt_width, opt_height) { + var context = canvas.getContext('2d'); + + // Query the various pixel ratios + var devicePixelRatio = Math.floor(window.devicePixelRatio) || 1; + var backingStoreRatio = Math.floor(context.webkitBackingStorePixelRatio) || 1; + var ratio = devicePixelRatio / backingStoreRatio; + + // Upscale the canvas if the two ratios don't match + if (devicePixelRatio !== backingStoreRatio) { + var oldWidth = opt_width || canvas.width; + var oldHeight = opt_height || canvas.height; + + canvas.width = oldWidth * ratio; + canvas.height = oldHeight * ratio; + + canvas.style.width = oldWidth + 'px'; + canvas.style.height = oldHeight + 'px'; + + // Scale the context to counter the fact that we've manually scaled + // our canvas element. + context.scale(ratio, ratio); + return true; + } else if (devicePixelRatio == 1) { + // Reset the canvas width / height. Fixes scaling bug when the page is + // zoomed and the devicePixelRatio changes accordingly. + canvas.style.width = canvas.width + 'px'; + canvas.style.height = canvas.height + 'px'; + } + return false; + }; + + + /** + * Get random number. + * @param {number} min + * @param {number} max + * @param {number} + */ + function getRandomNum(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + + /** + * Vibrate on mobile devices. + * @param {number} duration Duration of the vibration in milliseconds. + */ + function vibrate(duration) { + if (IS_MOBILE && window.navigator.vibrate) { + window.navigator.vibrate(duration); + } + } + + + /** + * Create canvas element. + * @param {HTMLElement} container Element to append canvas to. + * @param {number} width + * @param {number} height + * @param {string} opt_classname + * @return {HTMLCanvasElement} + */ + function createCanvas(container, width, height, opt_classname) { + var canvas = document.createElement('canvas'); + canvas.className = opt_classname ? Runner.classes.CANVAS + ' ' + + opt_classname : Runner.classes.CANVAS; + canvas.width = width; + canvas.height = height; + container.appendChild(canvas); + + return canvas; + } + + + /** + * Decodes the base 64 audio to ArrayBuffer used by Web Audio. + * @param {string} base64String + */ + function decodeBase64ToArrayBuffer(base64String) { + var len = (base64String.length / 4) * 3; + var str = atob(base64String); + var arrayBuffer = new ArrayBuffer(len); + var bytes = new Uint8Array(arrayBuffer); + + for (var i = 0; i < len; i++) { + bytes[i] = str.charCodeAt(i); + } + return bytes.buffer; + } + + + /** + * Return the current timestamp. + * @return {number} + */ + function getTimeStamp() { + return IS_IOS ? new Date().getTime() : performance.now(); + } + + + //****************************************************************************** + + + /** + * Game over panel. + * @param {!HTMLCanvasElement} canvas + * @param {Object} textImgPos + * @param {Object} restartImgPos + * @param {!Object} dimensions Canvas dimensions. + * @constructor + */ + function GameOverPanel(canvas, textImgPos, restartImgPos, dimensions) { + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.canvasDimensions = dimensions; + this.textImgPos = textImgPos; + this.restartImgPos = restartImgPos; + this.draw(); + }; + + + /** + * Dimensions used in the panel. + * @enum {number} + */ + GameOverPanel.dimensions = { + TEXT_X: 0, + TEXT_Y: 13, + TEXT_WIDTH: 191, + TEXT_HEIGHT: 11, + RESTART_WIDTH: 36, + RESTART_HEIGHT: 32 + }; + + + GameOverPanel.prototype = { + /** + * Update the panel dimensions. + * @param {number} width New canvas width. + * @param {number} opt_height Optional new canvas height. + */ + updateDimensions: function (width, opt_height) { + this.canvasDimensions.WIDTH = width; + if (opt_height) { + this.canvasDimensions.HEIGHT = opt_height; + } + }, + + /** + * Draw the panel. + */ + draw: function () { + var dimensions = GameOverPanel.dimensions; + + var centerX = this.canvasDimensions.WIDTH / 2; + + // Game over text. + var textSourceX = dimensions.TEXT_X; + var textSourceY = dimensions.TEXT_Y; + var textSourceWidth = dimensions.TEXT_WIDTH; + var textSourceHeight = dimensions.TEXT_HEIGHT; + + var textTargetX = Math.round(centerX - (dimensions.TEXT_WIDTH / 2)); + var textTargetY = Math.round((this.canvasDimensions.HEIGHT - 25) / 3); + var textTargetWidth = dimensions.TEXT_WIDTH; + var textTargetHeight = dimensions.TEXT_HEIGHT; + + var restartSourceWidth = dimensions.RESTART_WIDTH; + var restartSourceHeight = dimensions.RESTART_HEIGHT; + var restartTargetX = centerX - (dimensions.RESTART_WIDTH / 2); + var restartTargetY = this.canvasDimensions.HEIGHT / 2; + + if (IS_HIDPI) { + textSourceY *= 2; + textSourceX *= 2; + textSourceWidth *= 2; + textSourceHeight *= 2; + restartSourceWidth *= 2; + restartSourceHeight *= 2; + } + + textSourceX += this.textImgPos.x; + textSourceY += this.textImgPos.y; + + // Game over text from sprite. + this.canvasCtx.drawImage(Runner.imageSprite, + textSourceX, textSourceY, textSourceWidth, textSourceHeight, + textTargetX, textTargetY, textTargetWidth, textTargetHeight); + + // Restart button. + this.canvasCtx.drawImage(Runner.imageSprite, + this.restartImgPos.x, this.restartImgPos.y, + restartSourceWidth, restartSourceHeight, + restartTargetX, restartTargetY, dimensions.RESTART_WIDTH, + dimensions.RESTART_HEIGHT); + } + }; + + + //****************************************************************************** + + /** + * Check for a collision. + * @param {!Obstacle} obstacle + * @param {!Trex} tRex T-rex object. + * @param {HTMLCanvasContext} opt_canvasCtx Optional canvas context for drawing + * collision boxes. + * @return {Array} + */ + function checkForCollision(obstacle, tRex, opt_canvasCtx) { + var obstacleBoxXPos = Runner.defaultDimensions.WIDTH + obstacle.xPos; + + // Adjustments are made to the bounding box as there is a 1 pixel white + // border around the t-rex and obstacles. + var tRexBox = new CollisionBox( + tRex.xPos + 1, + tRex.yPos + 1, + tRex.config.WIDTH - 2, + tRex.config.HEIGHT - 2); + + var obstacleBox = new CollisionBox( + obstacle.xPos + 1, + obstacle.yPos + 1, + obstacle.typeConfig.width * obstacle.size - 2, + obstacle.typeConfig.height - 2); + + // Debug outer box + if (opt_canvasCtx) { + drawCollisionBoxes(opt_canvasCtx, tRexBox, obstacleBox); + } + + // Simple outer bounds check. + if (boxCompare(tRexBox, obstacleBox)) { + var collisionBoxes = obstacle.collisionBoxes; + var tRexCollisionBoxes = tRex.ducking ? + Trex.collisionBoxes.DUCKING : Trex.collisionBoxes.RUNNING; + + // Detailed axis aligned box check. + for (var t = 0; t < tRexCollisionBoxes.length; t++) { + for (var i = 0; i < collisionBoxes.length; i++) { + // Adjust the box to actual positions. + var adjTrexBox = + createAdjustedCollisionBox(tRexCollisionBoxes[t], tRexBox); + var adjObstacleBox = + createAdjustedCollisionBox(collisionBoxes[i], obstacleBox); + var crashed = boxCompare(adjTrexBox, adjObstacleBox); + + // Draw boxes for debug. + if (opt_canvasCtx) { + drawCollisionBoxes(opt_canvasCtx, adjTrexBox, adjObstacleBox); + } + + if (crashed) { + return [adjTrexBox, adjObstacleBox]; + } + } + } + } + return false; + }; + + + /** + * Adjust the collision box. + * @param {!CollisionBox} box The original box. + * @param {!CollisionBox} adjustment Adjustment box. + * @return {CollisionBox} The adjusted collision box object. + */ + function createAdjustedCollisionBox(box, adjustment) { + return new CollisionBox( + box.x + adjustment.x, + box.y + adjustment.y, + box.width, + box.height); + }; + + + /** + * Draw the collision boxes for debug. + */ + function drawCollisionBoxes(canvasCtx, tRexBox, obstacleBox) { + canvasCtx.save(); + canvasCtx.strokeStyle = '#f00'; + canvasCtx.strokeRect(tRexBox.x, tRexBox.y, tRexBox.width, tRexBox.height); + + canvasCtx.strokeStyle = '#0f0'; + canvasCtx.strokeRect(obstacleBox.x, obstacleBox.y, + obstacleBox.width, obstacleBox.height); + canvasCtx.restore(); + }; + + + /** + * Compare two collision boxes for a collision. + * @param {CollisionBox} tRexBox + * @param {CollisionBox} obstacleBox + * @return {boolean} Whether the boxes intersected. + */ + function boxCompare(tRexBox, obstacleBox) { + var crashed = false; + var tRexBoxX = tRexBox.x; + var tRexBoxY = tRexBox.y; + + var obstacleBoxX = obstacleBox.x; + var obstacleBoxY = obstacleBox.y; + + // Axis-Aligned Bounding Box method. + if (tRexBox.x < obstacleBoxX + obstacleBox.width && + tRexBox.x + tRexBox.width > obstacleBoxX && + tRexBox.y < obstacleBox.y + obstacleBox.height && + tRexBox.height + tRexBox.y > obstacleBox.y) { + crashed = true; + } + + return crashed; + }; + + + //****************************************************************************** + + /** + * Collision box object. + * @param {number} x X position. + * @param {number} y Y Position. + * @param {number} w Width. + * @param {number} h Height. + */ + function CollisionBox(x, y, w, h) { + this.x = x; + this.y = y; + this.width = w; + this.height = h; + }; + + + //****************************************************************************** + + /** + * Obstacle. + * @param {HTMLCanvasCtx} canvasCtx + * @param {Obstacle.type} type + * @param {Object} spritePos Obstacle position in sprite. + * @param {Object} dimensions + * @param {number} gapCoefficient Mutipler in determining the gap. + * @param {number} speed + * @param {number} opt_xOffset + */ + function Obstacle(canvasCtx, type, spriteImgPos, dimensions, + gapCoefficient, speed, opt_xOffset) { + + this.canvasCtx = canvasCtx; + this.spritePos = spriteImgPos; + this.typeConfig = type; + this.gapCoefficient = gapCoefficient; + this.size = getRandomNum(1, Obstacle.MAX_OBSTACLE_LENGTH); + this.dimensions = dimensions; + this.remove = false; + this.xPos = dimensions.WIDTH + (opt_xOffset || 0); + this.yPos = 0; + this.width = 0; + this.collisionBoxes = []; + this.gap = 0; + this.speedOffset = 0; + + // For animated obstacles. + this.currentFrame = 0; + this.timer = 0; + + this.init(speed); + }; + + /** + * Coefficient for calculating the maximum gap. + * @const + */ + Obstacle.MAX_GAP_COEFFICIENT = 1.5; + + /** + * Maximum obstacle grouping count. + * @const + */ + Obstacle.MAX_OBSTACLE_LENGTH = 3, + + + Obstacle.prototype = { + /** + * Initialise the DOM for the obstacle. + * @param {number} speed + */ + init: function (speed) { + this.cloneCollisionBoxes(); + + // Only allow sizing if we're at the right speed. + if (this.size > 1 && this.typeConfig.multipleSpeed > speed) { + this.size = 1; + } + + this.width = this.typeConfig.width * this.size; + + // Check if obstacle can be positioned at various heights. + if (Array.isArray(this.typeConfig.yPos)) { + var yPosConfig = IS_MOBILE ? this.typeConfig.yPosMobile : + this.typeConfig.yPos; + this.yPos = yPosConfig[getRandomNum(0, yPosConfig.length - 1)]; + } else { + this.yPos = this.typeConfig.yPos; + } + + this.draw(); + + // Make collision box adjustments, + // Central box is adjusted to the size as one box. + // ____ ______ ________ + // _| |-| _| |-| _| |-| + // | |<->| | | |<--->| | | |<----->| | + // | | 1 | | | | 2 | | | | 3 | | + // |_|___|_| |_|_____|_| |_|_______|_| + // + if (this.size > 1) { + this.collisionBoxes[1].width = this.width - this.collisionBoxes[0].width - + this.collisionBoxes[2].width; + this.collisionBoxes[2].x = this.width - this.collisionBoxes[2].width; + } + + // For obstacles that go at a different speed from the horizon. + if (this.typeConfig.speedOffset) { + this.speedOffset = Math.random() > 0.5 ? this.typeConfig.speedOffset : + -this.typeConfig.speedOffset; + } + + this.gap = this.getGap(this.gapCoefficient, speed); + }, + + /** + * Draw and crop based on size. + */ + draw: function () { + var sourceWidth = this.typeConfig.width; + var sourceHeight = this.typeConfig.height; + + if (IS_HIDPI) { + sourceWidth = sourceWidth * 2; + sourceHeight = sourceHeight * 2; + } + + // X position in sprite. + var sourceX = (sourceWidth * this.size) * (0.5 * (this.size - 1)) + + this.spritePos.x; + + // Animation frames. + if (this.currentFrame > 0) { + sourceX += sourceWidth * this.currentFrame; + } + + this.canvasCtx.drawImage(Runner.imageSprite, + sourceX, this.spritePos.y, + sourceWidth * this.size, sourceHeight, + this.xPos, this.yPos, + this.typeConfig.width * this.size, this.typeConfig.height); + }, + + /** + * Obstacle frame update. + * @param {number} deltaTime + * @param {number} speed + */ + update: function (deltaTime, speed) { + if (!this.remove) { + if (this.typeConfig.speedOffset) { + speed += this.speedOffset; + } + this.xPos -= Math.floor((speed * FPS / 1000) * deltaTime); + + // Update frame + if (this.typeConfig.numFrames) { + this.timer += deltaTime; + if (this.timer >= this.typeConfig.frameRate) { + this.currentFrame = + this.currentFrame == this.typeConfig.numFrames - 1 ? + 0 : this.currentFrame + 1; + this.timer = 0; + } + } + this.draw(); + + if (!this.isVisible()) { + this.remove = true; + } + } + }, + + /** + * Calculate a random gap size. + * - Minimum gap gets wider as speed increses + * @param {number} gapCoefficient + * @param {number} speed + * @return {number} The gap size. + */ + getGap: function (gapCoefficient, speed) { + var minGap = Math.round(this.width * speed + + this.typeConfig.minGap * gapCoefficient); + var maxGap = Math.round(minGap * Obstacle.MAX_GAP_COEFFICIENT); + return getRandomNum(minGap, maxGap); + }, + + /** + * Check if obstacle is visible. + * @return {boolean} Whether the obstacle is in the game area. + */ + isVisible: function () { + return this.xPos + this.width > 0; + }, + + /** + * Make a copy of the collision boxes, since these will change based on + * obstacle type and size. + */ + cloneCollisionBoxes: function () { + var collisionBoxes = this.typeConfig.collisionBoxes; + + for (var i = collisionBoxes.length - 1; i >= 0; i--) { + this.collisionBoxes[i] = new CollisionBox(collisionBoxes[i].x, + collisionBoxes[i].y, collisionBoxes[i].width, + collisionBoxes[i].height); + } + } + }; + + + /** + * Obstacle definitions. + * minGap: minimum pixel space betweeen obstacles. + * multipleSpeed: Speed at which multiples are allowed. + * speedOffset: speed faster / slower than the horizon. + * minSpeed: Minimum speed which the obstacle can make an appearance. + */ + Obstacle.types = [ + { + type: 'CACTUS_SMALL', + width: 17, + height: 35, + yPos: 105, + multipleSpeed: 4, + minGap: 120, + minSpeed: 0, + collisionBoxes: [ + new CollisionBox(0, 7, 5, 27), + new CollisionBox(4, 0, 6, 34), + new CollisionBox(10, 4, 7, 14) + ] + }, + { + type: 'CACTUS_LARGE', + width: 25, + height: 50, + yPos: 90, + multipleSpeed: 7, + minGap: 120, + minSpeed: 0, + collisionBoxes: [ + new CollisionBox(0, 12, 7, 38), + new CollisionBox(8, 0, 7, 49), + new CollisionBox(13, 10, 10, 38) + ] + }, + { + type: 'PTERODACTYL', + width: 46, + height: 40, + yPos: [100, 75, 50], // Variable height. + yPosMobile: [100, 50], // Variable height mobile. + multipleSpeed: 999, + minSpeed: 8.5, + minGap: 150, + collisionBoxes: [ + new CollisionBox(15, 15, 16, 5), + new CollisionBox(18, 21, 24, 6), + new CollisionBox(2, 14, 4, 3), + new CollisionBox(6, 10, 4, 7), + new CollisionBox(10, 8, 6, 9) + ], + numFrames: 2, + frameRate: 1000 / 6, + speedOffset: .8 + } + ]; + + + //****************************************************************************** + /** + * T-rex game character. + * @param {HTMLCanvas} canvas + * @param {Object} spritePos Positioning within image sprite. + * @constructor + */ + function Trex(canvas, spritePos) { + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.spritePos = spritePos; + this.xPos = 0; + this.yPos = 0; + // Position when on the ground. + this.groundYPos = 0; + this.currentFrame = 0; + this.currentAnimFrames = []; + this.blinkDelay = 0; + this.blinkCount = 0; + this.animStartTime = 0; + this.timer = 0; + this.msPerFrame = 1000 / FPS; + this.config = Trex.config; + // Current status. + this.status = Trex.status.WAITING; + + this.jumping = false; + this.ducking = false; + this.jumpVelocity = 0; + this.reachedMinHeight = false; + this.speedDrop = false; + this.jumpCount = 0; + this.jumpspotX = 0; + + this.init(); + }; + + + /** + * T-rex player config. + * @enum {number} + */ + Trex.config = { + DROP_VELOCITY: -5, + GRAVITY: 0.6, + HEIGHT: 47, + HEIGHT_DUCK: 25, + INIITAL_JUMP_VELOCITY: -10, + INTRO_DURATION: 1500, + MAX_JUMP_HEIGHT: 30, + MIN_JUMP_HEIGHT: 30, + SPEED_DROP_COEFFICIENT: 3, + SPRITE_WIDTH: 262, + START_X_POS: 50, + WIDTH: 44, + WIDTH_DUCK: 59 + }; + + + /** + * Used in collision detection. + * @type {Array} + */ + Trex.collisionBoxes = { + DUCKING: [ + new CollisionBox(1, 18, 55, 25) + ], + RUNNING: [ + new CollisionBox(22, 0, 17, 16), + new CollisionBox(1, 18, 30, 9), + new CollisionBox(10, 35, 14, 8), + new CollisionBox(1, 24, 29, 5), + new CollisionBox(5, 30, 21, 4), + new CollisionBox(9, 34, 15, 4) + ] + }; + + + /** + * Animation states. + * @enum {string} + */ + Trex.status = { + CRASHED: 'CRASHED', + DUCKING: 'DUCKING', + JUMPING: 'JUMPING', + RUNNING: 'RUNNING', + WAITING: 'WAITING' + }; + + /** + * Blinking coefficient. + * @const + */ + Trex.BLINK_TIMING = 7000; + + + /** + * Animation config for different states. + * @enum {Object} + */ + Trex.animFrames = { + WAITING: { + frames: [44, 0], + msPerFrame: 1000 / 3 + }, + RUNNING: { + frames: [88, 132], + msPerFrame: 1000 / 12 + }, + CRASHED: { + frames: [220], + msPerFrame: 1000 / 60 + }, + JUMPING: { + frames: [0], + msPerFrame: 1000 / 60 + }, + DUCKING: { + frames: [264, 323], + msPerFrame: 1000 / 8 + } + }; + + + Trex.prototype = { + /** + * T-rex player initaliser. + * Sets the t-rex to blink at random intervals. + */ + init: function () { + this.groundYPos = Runner.defaultDimensions.HEIGHT - this.config.HEIGHT - + Runner.config.BOTTOM_PAD; + this.yPos = this.groundYPos; + this.minJumpHeight = this.groundYPos - this.config.MIN_JUMP_HEIGHT; + + this.draw(0, 0); + this.update(0, Trex.status.WAITING); + }, + + /** + * Setter for the jump velocity. + * The approriate drop velocity is also set. + */ + setJumpVelocity: function (setting) { + this.config.INIITAL_JUMP_VELOCITY = -setting; + this.config.DROP_VELOCITY = -setting / 2; + }, + + /** + * Set the animation status. + * @param {!number} deltaTime + * @param {Trex.status} status Optional status to switch to. + */ + update: function (deltaTime, opt_status) { + this.timer += deltaTime; + + // Update the status. + if (opt_status) { + this.status = opt_status; + this.currentFrame = 0; + this.msPerFrame = Trex.animFrames[opt_status].msPerFrame; + this.currentAnimFrames = Trex.animFrames[opt_status].frames; + + if (opt_status == Trex.status.WAITING) { + this.animStartTime = getTimeStamp(); + this.setBlinkDelay(); + } + } + + // Game intro animation, T-rex moves in from the left. + if (this.playingIntro && this.xPos < this.config.START_X_POS) { + this.xPos += Math.round((this.config.START_X_POS / + this.config.INTRO_DURATION) * deltaTime); + } + + if (this.status == Trex.status.WAITING) { + this.blink(getTimeStamp()); + } else { + this.draw(this.currentAnimFrames[this.currentFrame], 0); + } + + // Update the frame position. + if (this.timer >= this.msPerFrame) { + this.currentFrame = this.currentFrame == + this.currentAnimFrames.length - 1 ? 0 : this.currentFrame + 1; + this.timer = 0; + } + + // Speed drop becomes duck if the down key is still being pressed. + if (this.speedDrop && this.yPos == this.groundYPos) { + this.speedDrop = false; + this.setDuck(true); + } + }, + + /** + * Draw the t-rex to a particular position. + * @param {number} x + * @param {number} y + */ + draw: function (x, y) { + var sourceX = x; + var sourceY = y; + var sourceWidth = this.ducking && this.status != Trex.status.CRASHED ? + this.config.WIDTH_DUCK : this.config.WIDTH; + var sourceHeight = this.config.HEIGHT; + + if (IS_HIDPI) { + sourceX *= 2; + sourceY *= 2; + sourceWidth *= 2; + sourceHeight *= 2; + } + + // Adjustments for sprite sheet position. + sourceX += this.spritePos.x; + sourceY += this.spritePos.y; + + // Ducking. + if (this.ducking && this.status != Trex.status.CRASHED) { + this.canvasCtx.drawImage(Runner.imageSprite, sourceX, sourceY, + sourceWidth, sourceHeight, + this.xPos, this.yPos, + this.config.WIDTH_DUCK, this.config.HEIGHT); + } else { + // Crashed whilst ducking. Trex is standing up so needs adjustment. + if (this.ducking && this.status == Trex.status.CRASHED) { + this.xPos++; + } + // Standing / running + this.canvasCtx.drawImage(Runner.imageSprite, sourceX, sourceY, + sourceWidth, sourceHeight, + this.xPos, this.yPos, + this.config.WIDTH, this.config.HEIGHT); + } + }, + + /** + * Sets a random time for the blink to happen. + */ + setBlinkDelay: function () { + this.blinkDelay = Math.ceil(Math.random() * Trex.BLINK_TIMING); + }, + + /** + * Make t-rex blink at random intervals. + * @param {number} time Current time in milliseconds. + */ + blink: function (time) { + var deltaTime = time - this.animStartTime; + + if (deltaTime >= this.blinkDelay) { + this.draw(this.currentAnimFrames[this.currentFrame], 0); + + if (this.currentFrame == 1) { + // Set new random delay to blink. + this.setBlinkDelay(); + this.animStartTime = time; + this.blinkCount++; + } + } + }, + + /** + * Initialise a jump. + * @param {number} speed + */ + startJump: function (speed) { + if (!this.jumping) { + this.update(0, Trex.status.JUMPING); + // Tweak the jump velocity based on the speed. + this.jumpVelocity = this.config.INIITAL_JUMP_VELOCITY - (speed / 10); + this.jumping = true; + this.reachedMinHeight = false; + this.speedDrop = false; + } + }, + + /** + * Jump is complete, falling down. + */ + endJump: function () { + if (this.reachedMinHeight && + this.jumpVelocity < this.config.DROP_VELOCITY) { + this.jumpVelocity = this.config.DROP_VELOCITY; + } + }, + + /** + * Update frame for a jump. + * @param {number} deltaTime + * @param {number} speed + */ + updateJump: function (deltaTime, speed) { + var msPerFrame = Trex.animFrames[this.status].msPerFrame; + var framesElapsed = deltaTime / msPerFrame; + + // Speed drop makes Trex fall faster. + if (this.speedDrop) { + this.yPos += Math.round(this.jumpVelocity * + this.config.SPEED_DROP_COEFFICIENT * framesElapsed); + } else { + this.yPos += Math.round(this.jumpVelocity * framesElapsed); + } + + this.jumpVelocity += this.config.GRAVITY * framesElapsed; + + // Minimum height has been reached. + if (this.yPos < this.minJumpHeight || this.speedDrop) { + this.reachedMinHeight = true; + } + + // Reached max height + if (this.yPos < this.config.MAX_JUMP_HEIGHT || this.speedDrop) { + this.endJump(); + } + + // Back down at ground level. Jump completed. + if (this.yPos > this.groundYPos) { + this.reset(); + this.jumpCount++; + } + + this.update(deltaTime); + }, + + /** + * Set the speed drop. Immediately cancels the current jump. + */ + setSpeedDrop: function () { + this.speedDrop = true; + this.jumpVelocity = 1; + }, + + /** + * @param {boolean} isDucking. + */ + setDuck: function (isDucking) { + if (isDucking && this.status != Trex.status.DUCKING) { + this.update(0, Trex.status.DUCKING); + this.ducking = true; + } else if (this.status == Trex.status.DUCKING) { + this.update(0, Trex.status.RUNNING); + this.ducking = false; + } + }, + + /** + * Reset the t-rex to running at start of game. + */ + reset: function () { + this.yPos = this.groundYPos; + this.jumpVelocity = 0; + this.jumping = false; + this.ducking = false; + this.update(0, Trex.status.RUNNING); + this.midair = false; + this.speedDrop = false; + this.jumpCount = 0; + } + }; + + + //****************************************************************************** + + /** + * Handles displaying the distance meter. + * @param {!HTMLCanvasElement} canvas + * @param {Object} spritePos Image position in sprite. + * @param {number} canvasWidth + * @constructor + */ + function DistanceMeter(canvas, spritePos, canvasWidth) { + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.image = Runner.imageSprite; + this.spritePos = spritePos; + this.x = 0; + this.y = 5; + + this.currentDistance = 0; + this.maxScore = 0; + this.highScore = 0; + this.container = null; + + this.digits = []; + this.acheivement = false; + this.defaultString = ''; + this.flashTimer = 0; + this.flashIterations = 0; + this.invertTrigger = false; + + this.config = DistanceMeter.config; + this.maxScoreUnits = this.config.MAX_DISTANCE_UNITS; + this.init(canvasWidth); + }; + + + /** + * @enum {number} + */ + DistanceMeter.dimensions = { + WIDTH: 10, + HEIGHT: 13, + DEST_WIDTH: 11 + }; + + + /** + * Y positioning of the digits in the sprite sheet. + * X position is always 0. + * @type {Array} + */ + DistanceMeter.yPos = [0, 13, 27, 40, 53, 67, 80, 93, 107, 120]; + + + /** + * Distance meter config. + * @enum {number} + */ + DistanceMeter.config = { + // Number of digits. + MAX_DISTANCE_UNITS: 5, + + // Distance that causes achievement animation. + ACHIEVEMENT_DISTANCE: 100, + + // Used for conversion from pixel distance to a scaled unit. + COEFFICIENT: 0.025, + + // Flash duration in milliseconds. + FLASH_DURATION: 1000 / 4, + + // Flash iterations for achievement animation. + FLASH_ITERATIONS: 3 + }; + + + DistanceMeter.prototype = { + /** + * Initialise the distance meter to '00000'. + * @param {number} width Canvas width in px. + */ + init: function (width) { + var maxDistanceStr = ''; + + this.calcXPos(width); + this.maxScore = this.maxScoreUnits; + for (var i = 0; i < this.maxScoreUnits; i++) { + this.draw(i, 0); + this.defaultString += '0'; + maxDistanceStr += '9'; + } + + this.maxScore = parseInt(maxDistanceStr); + }, + + /** + * Calculate the xPos in the canvas. + * @param {number} canvasWidth + */ + calcXPos: function (canvasWidth) { + this.x = canvasWidth - (DistanceMeter.dimensions.DEST_WIDTH * + (this.maxScoreUnits + 1)); + }, + + /** + * Draw a digit to canvas. + * @param {number} digitPos Position of the digit. + * @param {number} value Digit value 0-9. + * @param {boolean} opt_highScore Whether drawing the high score. + */ + draw: function (digitPos, value, opt_highScore) { + var sourceWidth = DistanceMeter.dimensions.WIDTH; + var sourceHeight = DistanceMeter.dimensions.HEIGHT; + var sourceX = DistanceMeter.dimensions.WIDTH * value; + var sourceY = 0; + + var targetX = digitPos * DistanceMeter.dimensions.DEST_WIDTH; + var targetY = this.y; + var targetWidth = DistanceMeter.dimensions.WIDTH; + var targetHeight = DistanceMeter.dimensions.HEIGHT; + + // For high DPI we 2x source values. + if (IS_HIDPI) { + sourceWidth *= 2; + sourceHeight *= 2; + sourceX *= 2; + } + + sourceX += this.spritePos.x; + sourceY += this.spritePos.y; + + this.canvasCtx.save(); + + if (opt_highScore) { + // Left of the current score. + var highScoreX = this.x - (this.maxScoreUnits * 2) * + DistanceMeter.dimensions.WIDTH; + this.canvasCtx.translate(highScoreX, this.y); + } else { + this.canvasCtx.translate(this.x, this.y); + } + + this.canvasCtx.drawImage(this.image, sourceX, sourceY, + sourceWidth, sourceHeight, + targetX, targetY, + targetWidth, targetHeight + ); + + this.canvasCtx.restore(); + }, + + /** + * Covert pixel distance to a 'real' distance. + * @param {number} distance Pixel distance ran. + * @return {number} The 'real' distance ran. + */ + getActualDistance: function (distance) { + return distance ? Math.round(distance * this.config.COEFFICIENT) : 0; + }, + + /** + * Update the distance meter. + * @param {number} distance + * @param {number} deltaTime + * @return {boolean} Whether the acheivement sound fx should be played. + */ + update: function (deltaTime, distance) { + var paint = true; + var playSound = false; + + if (!this.acheivement) { + distance = this.getActualDistance(distance); + // Score has gone beyond the initial digit count. + if (distance > this.maxScore && this.maxScoreUnits == + this.config.MAX_DISTANCE_UNITS) { + this.maxScoreUnits++; + this.maxScore = parseInt(this.maxScore + '9'); + } else { + this.distance = 0; + } + + if (distance > 0) { + // Acheivement unlocked + if (distance % this.config.ACHIEVEMENT_DISTANCE == 0) { + // Flash score and play sound. + this.acheivement = true; + this.flashTimer = 0; + playSound = true; + } + + // Create a string representation of the distance with leading 0. + var distanceStr = (this.defaultString + + distance).substr(-this.maxScoreUnits); + this.digits = distanceStr.split(''); + } else { + this.digits = this.defaultString.split(''); + } + } else { + // Control flashing of the score on reaching acheivement. + if (this.flashIterations <= this.config.FLASH_ITERATIONS) { + this.flashTimer += deltaTime; + + if (this.flashTimer < this.config.FLASH_DURATION) { + paint = false; + } else if (this.flashTimer > + this.config.FLASH_DURATION * 2) { + this.flashTimer = 0; + this.flashIterations++; + } + } else { + this.acheivement = false; + this.flashIterations = 0; + this.flashTimer = 0; + } + } + + // Draw the digits if not flashing. + if (paint) { + for (var i = this.digits.length - 1; i >= 0; i--) { + this.draw(i, parseInt(this.digits[i])); + } + } + + this.drawHighScore(); + return playSound; + }, + + /** + * Draw the high score. + */ + drawHighScore: function () { + this.canvasCtx.save(); + this.canvasCtx.globalAlpha = .8; + for (var i = this.highScore.length - 1; i >= 0; i--) { + this.draw(i, parseInt(this.highScore[i], 10), true); + } + this.canvasCtx.restore(); + }, + + /** + * Set the highscore as a array string. + * Position of char in the sprite: H - 10, I - 11. + * @param {number} distance Distance ran in pixels. + */ + setHighScore: function (distance) { + distance = this.getActualDistance(distance); + var highScoreStr = (this.defaultString + + distance).substr(-this.maxScoreUnits); + + this.highScore = ['10', '11', ''].concat(highScoreStr.split('')); + }, + + /** + * Reset the distance meter back to '00000'. + */ + reset: function () { + this.update(0); + this.acheivement = false; + } + }; + + + //****************************************************************************** + + /** + * Cloud background item. + * Similar to an obstacle object but without collision boxes. + * @param {HTMLCanvasElement} canvas Canvas element. + * @param {Object} spritePos Position of image in sprite. + * @param {number} containerWidth + */ + function Cloud(canvas, spritePos, containerWidth) { + this.canvas = canvas; + this.canvasCtx = this.canvas.getContext('2d'); + this.spritePos = spritePos; + this.containerWidth = containerWidth; + this.xPos = containerWidth; + this.yPos = 0; + this.remove = false; + this.cloudGap = getRandomNum(Cloud.config.MIN_CLOUD_GAP, + Cloud.config.MAX_CLOUD_GAP); + + this.init(); + }; + + + /** + * Cloud object config. + * @enum {number} + */ + Cloud.config = { + HEIGHT: 14, + MAX_CLOUD_GAP: 400, + MAX_SKY_LEVEL: 30, + MIN_CLOUD_GAP: 100, + MIN_SKY_LEVEL: 71, + WIDTH: 46 + }; + + + Cloud.prototype = { + /** + * Initialise the cloud. Sets the Cloud height. + */ + init: function () { + this.yPos = getRandomNum(Cloud.config.MAX_SKY_LEVEL, + Cloud.config.MIN_SKY_LEVEL); + this.draw(); + }, + + /** + * Draw the cloud. + */ + draw: function () { + this.canvasCtx.save(); + var sourceWidth = Cloud.config.WIDTH; + var sourceHeight = Cloud.config.HEIGHT; + + if (IS_HIDPI) { + sourceWidth = sourceWidth * 2; + sourceHeight = sourceHeight * 2; + } + + this.canvasCtx.drawImage(Runner.imageSprite, this.spritePos.x, + this.spritePos.y, + sourceWidth, sourceHeight, + this.xPos, this.yPos, + Cloud.config.WIDTH, Cloud.config.HEIGHT); + + this.canvasCtx.restore(); + }, + + /** + * Update the cloud position. + * @param {number} speed + */ + update: function (speed) { + if (!this.remove) { + this.xPos -= Math.ceil(speed); + this.draw(); + + // Mark as removeable if no longer in the canvas. + if (!this.isVisible()) { + this.remove = true; + } + } + }, + + /** + * Check if the cloud is visible on the stage. + * @return {boolean} + */ + isVisible: function () { + return this.xPos + Cloud.config.WIDTH > 0; + } + }; + + + //****************************************************************************** + + /** + * Nightmode shows a moon and stars on the horizon. + */ + function NightMode(canvas, spritePos, containerWidth) { + this.spritePos = spritePos; + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.xPos = containerWidth - 50; + this.yPos = 30; + this.currentPhase = 0; + this.opacity = 0; + this.containerWidth = containerWidth; + this.stars = []; + this.drawStars = false; + this.placeStars(); + }; + + /** + * @enum {number} + */ + NightMode.config = { + FADE_SPEED: 0.035, + HEIGHT: 40, + MOON_SPEED: 0.25, + NUM_STARS: 2, + STAR_SIZE: 9, + STAR_SPEED: 0.3, + STAR_MAX_Y: 70, + WIDTH: 20 + }; + + NightMode.phases = [140, 120, 100, 60, 40, 20, 0]; + + NightMode.prototype = { + /** + * Update moving moon, changing phases. + * @param {boolean} activated Whether night mode is activated. + * @param {number} delta + */ + update: function (activated, delta) { + // Moon phase. + if (activated && this.opacity == 0) { + this.currentPhase++; + + if (this.currentPhase >= NightMode.phases.length) { + this.currentPhase = 0; + } + } + + // Fade in / out. + if (activated && (this.opacity < 1 || this.opacity == 0)) { + this.opacity += NightMode.config.FADE_SPEED; + } else if (this.opacity > 0) { + this.opacity -= NightMode.config.FADE_SPEED; + } + + // Set moon positioning. + if (this.opacity > 0) { + this.xPos = this.updateXPos(this.xPos, NightMode.config.MOON_SPEED); + + // Update stars. + if (this.drawStars) { + for (var i = 0; i < NightMode.config.NUM_STARS; i++) { + this.stars[i].x = this.updateXPos(this.stars[i].x, + NightMode.config.STAR_SPEED); + } + } + this.draw(); + } else { + this.opacity = 0; + this.placeStars(); + } + this.drawStars = true; + }, + + updateXPos: function (currentPos, speed) { + if (currentPos < -NightMode.config.WIDTH) { + currentPos = this.containerWidth; + } else { + currentPos -= speed; + } + return currentPos; + }, + + draw: function () { + var moonSourceWidth = this.currentPhase == 3 ? NightMode.config.WIDTH * 2 : + NightMode.config.WIDTH; + var moonSourceHeight = NightMode.config.HEIGHT; + var moonSourceX = this.spritePos.x + NightMode.phases[this.currentPhase]; + var moonOutputWidth = moonSourceWidth; + var starSize = NightMode.config.STAR_SIZE; + var starSourceX = Runner.spriteDefinition.LDPI.STAR.x; + + if (IS_HIDPI) { + moonSourceWidth *= 2; + moonSourceHeight *= 2; + moonSourceX = this.spritePos.x + + (NightMode.phases[this.currentPhase] * 2); + starSize *= 2; + starSourceX = Runner.spriteDefinition.HDPI.STAR.x; + } + + this.canvasCtx.save(); + this.canvasCtx.globalAlpha = this.opacity; + + // Stars. + if (this.drawStars) { + for (var i = 0; i < NightMode.config.NUM_STARS; i++) { + this.canvasCtx.drawImage(Runner.imageSprite, + starSourceX, this.stars[i].sourceY, starSize, starSize, + Math.round(this.stars[i].x), this.stars[i].y, + NightMode.config.STAR_SIZE, NightMode.config.STAR_SIZE); + } + } + + // Moon. + this.canvasCtx.drawImage(Runner.imageSprite, moonSourceX, + this.spritePos.y, moonSourceWidth, moonSourceHeight, + Math.round(this.xPos), this.yPos, + moonOutputWidth, NightMode.config.HEIGHT); + + this.canvasCtx.globalAlpha = 1; + this.canvasCtx.restore(); + }, + + // Do star placement. + placeStars: function () { + var segmentSize = Math.round(this.containerWidth / + NightMode.config.NUM_STARS); + + for (var i = 0; i < NightMode.config.NUM_STARS; i++) { + this.stars[i] = {}; + this.stars[i].x = getRandomNum(segmentSize * i, segmentSize * (i + 1)); + this.stars[i].y = getRandomNum(0, NightMode.config.STAR_MAX_Y); + + if (IS_HIDPI) { + this.stars[i].sourceY = Runner.spriteDefinition.HDPI.STAR.y + + NightMode.config.STAR_SIZE * 2 * i; + } else { + this.stars[i].sourceY = Runner.spriteDefinition.LDPI.STAR.y + + NightMode.config.STAR_SIZE * i; + } + } + }, + + reset: function () { + this.currentPhase = 0; + this.opacity = 0; + this.update(false); + } + + }; + + + //****************************************************************************** + + /** + * Horizon Line. + * Consists of two connecting lines. Randomly assigns a flat / bumpy horizon. + * @param {HTMLCanvasElement} canvas + * @param {Object} spritePos Horizon position in sprite. + * @constructor + */ + function HorizonLine(canvas, spritePos) { + this.spritePos = spritePos; + this.canvas = canvas; + this.canvasCtx = canvas.getContext('2d'); + this.sourceDimensions = {}; + this.dimensions = HorizonLine.dimensions; + this.sourceXPos = [this.spritePos.x, this.spritePos.x + + this.dimensions.WIDTH]; + this.xPos = []; + this.yPos = 0; + this.bumpThreshold = 0.5; + + this.setSourceDimensions(); + this.draw(); + }; + + + /** + * Horizon line dimensions. + * @enum {number} + */ + HorizonLine.dimensions = { + WIDTH: 600, + HEIGHT: 12, + YPOS: 127 + }; + + + HorizonLine.prototype = { + /** + * Set the source dimensions of the horizon line. + */ + setSourceDimensions: function () { + + for (var dimension in HorizonLine.dimensions) { + if (IS_HIDPI) { + if (dimension != 'YPOS') { + this.sourceDimensions[dimension] = + HorizonLine.dimensions[dimension] * 2; + } + } else { + this.sourceDimensions[dimension] = + HorizonLine.dimensions[dimension]; + } + this.dimensions[dimension] = HorizonLine.dimensions[dimension]; + } + + this.xPos = [0, HorizonLine.dimensions.WIDTH]; + this.yPos = HorizonLine.dimensions.YPOS; + }, + + /** + * Return the crop x position of a type. + */ + getRandomType: function () { + return Math.random() > this.bumpThreshold ? this.dimensions.WIDTH : 0; + }, + + /** + * Draw the horizon line. + */ + draw: function () { + this.canvasCtx.drawImage(Runner.imageSprite, this.sourceXPos[0], + this.spritePos.y, + this.sourceDimensions.WIDTH, this.sourceDimensions.HEIGHT, + this.xPos[0], this.yPos, + this.dimensions.WIDTH, this.dimensions.HEIGHT); + + this.canvasCtx.drawImage(Runner.imageSprite, this.sourceXPos[1], + this.spritePos.y, + this.sourceDimensions.WIDTH, this.sourceDimensions.HEIGHT, + this.xPos[1], this.yPos, + this.dimensions.WIDTH, this.dimensions.HEIGHT); + }, + + /** + * Update the x position of an indivdual piece of the line. + * @param {number} pos Line position. + * @param {number} increment + */ + updateXPos: function (pos, increment) { + var line1 = pos; + var line2 = pos == 0 ? 1 : 0; + + this.xPos[line1] -= increment; + this.xPos[line2] = this.xPos[line1] + this.dimensions.WIDTH; + + if (this.xPos[line1] <= -this.dimensions.WIDTH) { + this.xPos[line1] += this.dimensions.WIDTH * 2; + this.xPos[line2] = this.xPos[line1] - this.dimensions.WIDTH; + this.sourceXPos[line1] = this.getRandomType() + this.spritePos.x; + } + }, + + /** + * Update the horizon line. + * @param {number} deltaTime + * @param {number} speed + */ + update: function (deltaTime, speed) { + var increment = Math.floor(speed * (FPS / 1000) * deltaTime); + + if (this.xPos[0] <= 0) { + this.updateXPos(0, increment); + } else { + this.updateXPos(1, increment); + } + this.draw(); + }, + + /** + * Reset horizon to the starting position. + */ + reset: function () { + this.xPos[0] = 0; + this.xPos[1] = HorizonLine.dimensions.WIDTH; + } + }; + + + //****************************************************************************** + + /** + * Horizon background class. + * @param {HTMLCanvasElement} canvas + * @param {Object} spritePos Sprite positioning. + * @param {Object} dimensions Canvas dimensions. + * @param {number} gapCoefficient + * @constructor + */ + function Horizon(canvas, spritePos, dimensions, gapCoefficient) { + this.canvas = canvas; + this.canvasCtx = this.canvas.getContext('2d'); + this.config = Horizon.config; + this.dimensions = dimensions; + this.gapCoefficient = gapCoefficient; + this.obstacles = []; + this.obstacleHistory = []; + this.horizonOffsets = [0, 0]; + this.cloudFrequency = this.config.CLOUD_FREQUENCY; + this.spritePos = spritePos; + this.nightMode = null; + + // Cloud + this.clouds = []; + this.cloudSpeed = this.config.BG_CLOUD_SPEED; + + // Horizon + this.horizonLine = null; + this.init(); + }; + + + /** + * Horizon config. + * @enum {number} + */ + Horizon.config = { + BG_CLOUD_SPEED: 0.2, + BUMPY_THRESHOLD: .3, + CLOUD_FREQUENCY: .5, + HORIZON_HEIGHT: 16, + MAX_CLOUDS: 6 + }; + + + Horizon.prototype = { + /** + * Initialise the horizon. Just add the line and a cloud. No obstacles. + */ + init: function () { + this.addCloud(); + this.horizonLine = new HorizonLine(this.canvas, this.spritePos.HORIZON); + this.nightMode = new NightMode(this.canvas, this.spritePos.MOON, + this.dimensions.WIDTH); + }, + + /** + * @param {number} deltaTime + * @param {number} currentSpeed + * @param {boolean} updateObstacles Used as an override to prevent + * the obstacles from being updated / added. This happens in the + * ease in section. + * @param {boolean} showNightMode Night mode activated. + */ + update: function (deltaTime, currentSpeed, updateObstacles, showNightMode) { + this.runningTime += deltaTime; + this.horizonLine.update(deltaTime, currentSpeed); + this.nightMode.update(showNightMode); + this.updateClouds(deltaTime, currentSpeed); + + if (updateObstacles) { + this.updateObstacles(deltaTime, currentSpeed); + } + }, + + /** + * Update the cloud positions. + * @param {number} deltaTime + * @param {number} currentSpeed + */ + updateClouds: function (deltaTime, speed) { + var cloudSpeed = this.cloudSpeed / 1000 * deltaTime * speed; + var numClouds = this.clouds.length; + + if (numClouds) { + for (var i = numClouds - 1; i >= 0; i--) { + this.clouds[i].update(cloudSpeed); + } + + var lastCloud = this.clouds[numClouds - 1]; + + // Check for adding a new cloud. + if (numClouds < this.config.MAX_CLOUDS && + (this.dimensions.WIDTH - lastCloud.xPos) > lastCloud.cloudGap && + this.cloudFrequency > Math.random()) { + this.addCloud(); + } + + // Remove expired clouds. + this.clouds = this.clouds.filter(function (obj) { + return !obj.remove; + }); + } else { + this.addCloud(); + } + }, + + /** + * Update the obstacle positions. + * @param {number} deltaTime + * @param {number} currentSpeed + */ + updateObstacles: function (deltaTime, currentSpeed) { + // Obstacles, move to Horizon layer. + var updatedObstacles = this.obstacles.slice(0); + + for (var i = 0; i < this.obstacles.length; i++) { + var obstacle = this.obstacles[i]; + obstacle.update(deltaTime, currentSpeed); + + // Clean up existing obstacles. + if (obstacle.remove) { + updatedObstacles.shift(); + } + } + this.obstacles = updatedObstacles; + + if (this.obstacles.length > 0) { + var lastObstacle = this.obstacles[this.obstacles.length - 1]; + + if (lastObstacle && !lastObstacle.followingObstacleCreated && + lastObstacle.isVisible() && + (lastObstacle.xPos + lastObstacle.width + lastObstacle.gap) < + this.dimensions.WIDTH) { + this.addNewObstacle(currentSpeed); + lastObstacle.followingObstacleCreated = true; + } + } else { + // Create new obstacles. + this.addNewObstacle(currentSpeed); + } + }, + + removeFirstObstacle: function () { + this.obstacles.shift(); + }, + + /** + * Add a new obstacle. + * @param {number} currentSpeed + */ + addNewObstacle: function (currentSpeed) { + var obstacleTypeIndex = getRandomNum(0, Obstacle.types.length - 1); + var obstacleType = Obstacle.types[obstacleTypeIndex]; + + // Check for multiples of the same type of obstacle. + // Also check obstacle is available at current speed. + if (this.duplicateObstacleCheck(obstacleType.type) || + currentSpeed < obstacleType.minSpeed) { + this.addNewObstacle(currentSpeed); + } else { + var obstacleSpritePos = this.spritePos[obstacleType.type]; + + this.obstacles.push(new Obstacle(this.canvasCtx, obstacleType, + obstacleSpritePos, this.dimensions, + this.gapCoefficient, currentSpeed, obstacleType.width)); + + this.obstacleHistory.unshift(obstacleType.type); + + if (this.obstacleHistory.length > 1) { + this.obstacleHistory.splice(Runner.config.MAX_OBSTACLE_DUPLICATION); + } + } + }, + + /** + * Returns whether the previous two obstacles are the same as the next one. + * Maximum duplication is set in config value MAX_OBSTACLE_DUPLICATION. + * @return {boolean} + */ + duplicateObstacleCheck: function (nextObstacleType) { + var duplicateCount = 0; + + for (var i = 0; i < this.obstacleHistory.length; i++) { + duplicateCount = this.obstacleHistory[i] == nextObstacleType ? + duplicateCount + 1 : 0; + } + return duplicateCount >= Runner.config.MAX_OBSTACLE_DUPLICATION; + }, + + /** + * Reset the horizon layer. + * Remove existing obstacles and reposition the horizon line. + */ + reset: function () { + this.obstacles = []; + this.horizonLine.reset(); + this.nightMode.reset(); + }, + + /** + * Update the canvas width and scaling. + * @param {number} width Canvas width. + * @param {number} height Canvas height. + */ + resize: function (width, height) { + this.canvas.width = width; + this.canvas.height = height; + }, + + /** + * Add a new cloud to the horizon. + */ + addCloud: function () { + this.clouds.push(new Cloud(this.canvas, this.spritePos.CLOUD, + this.dimensions.WIDTH)); + } + }; +})(); + + +function onDocumentLoad() { + new Runner('.interstitial-wrapper'); +} + +document.addEventListener('DOMContentLoaded', onDocumentLoad); diff --git a/templates/404.html b/templates/404.html index b09fd6d..5f83c26 100644 --- a/templates/404.html +++ b/templates/404.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load static %} {% block entete %}Page introuvable{% endblock %} {% block navbar %}
    @@ -6,6 +7,30 @@
{% endblock %} {% block content %} +

Erreur 404

@@ -13,5 +38,6 @@
Une erreur s'est produite lors de l'accès à cette page (la page que vous demandez n'existe pas). Vous pouvez revenir à l'accueil en cliquant ici.
+ {% endblock %} diff --git a/templates/base.html b/templates/base.html index d9c654f..98c4c04 100644 --- a/templates/base.html +++ b/templates/base.html @@ -8,7 +8,9 @@ - + + {% block extra_css %}{% endblock %} + {% block extra_script %}{% endblock %} diff --git a/templates/coope-runner.html b/templates/coope-runner.html new file mode 100644 index 0000000..5493d4d --- /dev/null +++ b/templates/coope-runner.html @@ -0,0 +1,86 @@ +{% load static %} + + + + + + + + + + + + + + +
+

Press up arrow to start

+
+
+
+
+
+
+
+ + + +
+
+ + + + + \ No newline at end of file