diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml new file mode 100644 index 0000000..d82b4d1 --- /dev/null +++ b/.github/workflows/moodle-ci.yml @@ -0,0 +1,105 @@ +name: Moodle Plugin CI + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-18.04 + + services: + postgres: + image: postgres:9.6 + env: + POSTGRES_USER: 'postgres' + POSTGRES_HOST_AUTH_METHOD: 'trust' + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 + mariadb: + image: mariadb:10 + env: + MYSQL_USER: 'root' + MYSQL_ALLOW_EMPTY_PASSWORD: "true" + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 3 + + strategy: + fail-fast: false + matrix: + php: ['7.3', '7.4'] + moodle-branch: ['MOODLE_311_STABLE'] + database: [pgsql, mariadb] + + steps: + - name: Check out repository code + uses: actions/checkout@v2 + with: + path: plugin + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Initialise moodle-plugin-ci + run: | + composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^3 + echo $(cd ci/bin; pwd) >> $GITHUB_PATH + echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH + sudo locale-gen en_AU.UTF-8 + echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV + + - name: Install moodle-plugin-ci + run: | + moodle-plugin-ci install --plugin ./plugin --db-host=127.0.0.1 + env: + DB: ${{ matrix.database }} + MOODLE_BRANCH: ${{ matrix.moodle-branch }} + + - name: PHP Lint + if: ${{ always() }} + run: moodle-plugin-ci phplint + + - name: PHP Copy/Paste Detector + continue-on-error: true # This step will show errors but will not fail + if: ${{ always() }} + run: moodle-plugin-ci phpcpd + + - name: PHP Mess Detector + continue-on-error: true # This step will show errors but will not fail + if: ${{ always() }} + run: moodle-plugin-ci phpmd + + - name: Moodle Code Checker + if: ${{ always() }} + run: moodle-plugin-ci codechecker --max-warnings 0 + + - name: Moodle PHPDoc Checker + if: ${{ always() }} + run: moodle-plugin-ci phpdoc + + - name: Validating + if: ${{ always() }} + run: moodle-plugin-ci validate + + - name: Check upgrade savepoints + if: ${{ always() }} + run: moodle-plugin-ci savepoints + + - name: Mustache Lint + if: ${{ always() }} + run: moodle-plugin-ci mustache + + - name: Grunt + if: ${{ always() }} + run: moodle-plugin-ci grunt --max-lint-warnings 0 + + - name: PHPUnit tests + if: ${{ always() }} + run: moodle-plugin-ci phpunit + + - name: Behat features + if: ${{ always() }} + run: moodle-plugin-ci behat --profile chrome diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f0bcf69..0000000 --- a/.travis.yml +++ /dev/null @@ -1,53 +0,0 @@ -language: php - -sudo: false - -addons: - firefox: "47.0.1" - postgresql: "9.4" - apt: - packages: - - openjdk-8-jre-headless - -cache: - directories: - - $HOME/.composer/cache - - $HOME/.npm - -php: - - 7.1 - - 7.2 - - 7.3 - -addons: - postgresql: 9.4 - -env: - matrix: - - DB=pgsql MOODLE_BRANCH=master - - DB=mysqli MOODLE_BRANCH=master - -before_install: - - phpenv config-rm xdebug.ini - - nvm install 8.9 - - nvm use 8.9 - - cd ../.. - - composer create-project -n --no-dev --prefer-dist blackboard-open-source/moodle-plugin-ci ci ^2 - - export PATH="$(cd ci/bin; pwd):$(cd ci/vendor/bin; pwd):$PATH" - -install: - - moodle-plugin-ci install - -script: - - moodle-plugin-ci phplint - - moodle-plugin-ci phpcpd - - moodle-plugin-ci phpmd - - moodle-plugin-ci codechecker - - moodle-plugin-ci validate - - moodle-plugin-ci savepoints - - moodle-plugin-ci mustache - - moodle-plugin-ci grunt - - moodle-plugin-ci phpdoc - - moodle-plugin-ci phpunit - - moodle-plugin-ci behat - diff --git a/amd/build/quizgame.min.js b/amd/build/quizgame.min.js index 9cfe418..2670191 100644 --- a/amd/build/quizgame.min.js +++ b/amd/build/quizgame.min.js @@ -1 +1,2 @@ -define(["jquery","core/yui","core/notification","core/ajax"],function(a,b,c,d){function e(a){if(document.getElementById("mod_quizgame_sound_on").checked){var b=document.getElementById("mod_quizgame_sound_"+a);b.currentTime=0,b.play()}}function f(){wa=!1,ca.removeAttribute("width"),ca.removeAttribute("height"),ca.removeAttribute("style"),ca.classList.remove("floating-game-canvas"),a("#button_container").removeClass("floating-button-container fixed-bottom"),pa.width=ca.clientWidth,pa.height=ca.clientHeight,ca.style.width=pa.width,ca.style.height=pa.height,i(ca)}function g(){wa&&f()}function h(){var b=window.matchMedia("(orientation: landscape)").matches;ca.requestFullscreen?ca.requestFullscreen():ca.msRequestFullscreen?ca.msRequestFullscreen():ca.mozRequestFullScreen&&ca.mozRequestFullScreen(),wa=!0;var c=a("#button_container"),d=window.innerWidth,e=a(window).height();b&&d0?(ha.fillText(M.util.get_string("spacetostart","mod_quizgame"),pa.width/2,pa.height/2),l()):ha.fillText(M.util.get_string("emptyquiz","mod_quizgame"),pa.width/2,pa.height/2)}function n(){$(aa),na?q():(la.forEach(function(a){var b=new Image;b.src=a,b.onload=function(){ma++,ma>=la.length&&p()}}),na=!0)}function o(){d.call([{methodname:"mod_quizgame_update_score",args:{quizgameid:ba,score:Math.trunc(ia)},fail:c.exception}]),l()}function p(){clearInterval(fa),fa=setInterval(function(){t(ha,pa,ka,ja,qa),u(pa,ka,ja)},40),q()}function q(){ia=0,ka=[],ja=[],oa=-1,ga=.5,ra=!1,sa=!1,d.call([{methodname:"mod_quizgame_start_game",args:{quizgameid:ba},fail:c.exception}]),da=new x("pix/ship.png",0,0),da.x=pa.width/2,da.y=pa.height/2,ka.push(da),ea=new y("pix/planet.png",0,0),ea.image.width=pa.width,ea.image.height=pa.height,ea.direction.y=1,ea.movespeed.y=.7,ja.push(ea),r(),document.onkeyup=S,document.onkeydown=R,document.onmouseup=U,document.onmousedown=T,document.onmousemove=V,document.ontouchstart=X,document.ontouchend=Y,document.ontouchmove=Z,window.onresize=j,document.addEventListener("gesturestart",W,!1),document.addEventListener("gesturechange",W,!1),document.addEventListener("gestureend",W,!1)}function r(){oa++,oa>=aa.length&&(oa=0,ga*=1.3),qa=s(aa,oa,pa)}function s(a,b,c){if(ta=[],ua=0,va=0,"truefalse"==a[b].type)a[b].answers.forEach(function(a){var b=new B(Math.random()*c.width,-Math.random()*c.height/2,a.text,a.fraction);ta.push(b),ka.push(b)}),va=0;else if("multichoice"==a[b].type)a[b].answers.forEach(function(d){var e=new C(Math.random()*c.width,-Math.random()*c.height/2,d.text,d.fraction,a[b].single);d.fraction<1&&(ta.push(e),d.fraction>0&&(va+=parseFloat(d.fraction))),ka.push(e)});else if("match"==a[b].type){var d=0,e=1/(2*a[b].stems.length);va+=1,a[b].stems.forEach(function(a){d++;var b=new D(Math.random()*c.width,-Math.random()*c.height/2,a.question,e,(-d),(!0)),f=new D(Math.random()*c.width,-Math.random()*c.height/2,a.answer,e,d);ta.push(b),ta.push(f),ka.push(b),ka.push(f)})}return a[b].question}function t(a,b,c,d,e){a.clearRect(0,0,b.width,b.height);for(var f=0;fe?(h.push({text:k,y:g+=d}),k=b):k=c}),h.push({text:k,y:g+=d});var l=g-i;h.forEach(function(b){var d=c?-l:0;a.fillText(b.text,f,b.y+d)})}function N(){var a=ta.filter(function(a){return a.alive}).length;0===a&&(va1?da.Shoot():(ra=!0,Z(a)),a.preventDefault())}function Y(a){0===a.touches.length&&(ra=!1),da.direction.x=0,da.direction.y=0,a.target===ca&&a.preventDefault()}function Z(a){var b=a.target.getBoundingClientRect(),c=a.touches[0].pageX-b.left,d=a.touches[0].clientY-b.top;window.stage=ca,da.mouse.x=c,da.mouse.y=d,a.target===ca&&a.preventDefault()}function $(a){for(var b,c,d=a.length;0!==d;)c=Math.floor(Math.random()*d),d-=1,b=a[d],a[d]=a[c],a[c]=b;return a}function _(a,b){aa=a,ba=b,document.addEventListener&&(document.addEventListener("fullscreenchange",g,!1),document.addEventListener("MSFullscreenChange",g,!1),document.addEventListener("mozfullscreenchange",g,!1),document.addEventListener("webkitfullscreenchange",g,!1)),ca=document.getElementById("mod_quizgame_game"),ha=ca.getContext("2d"),f(),fa=setInterval(function(){m()},500)}var aa,ba,ca,da,ea,fa,ga,ha,ia=0,ja=[],ka=[],la=["pix/icon.gif","pix/planet.png","pix/ship.png","pix/enemy.png","pix/enemystem.png","pix/enemychoice.png","pix/enemystemselected.png","pix/enemychoiceselected.png","pix/laser.png","pix/enemylaser.png"],ma=0,na=!1,oa=-1,pa={x:0,y:0,width:0,height:0},qa="",ra=!1,sa=!1,ta=[],ua=0,va=0,wa=!1;a("#mod_quizgame_fullscreen_button").on("click",function(){wa?(wa=!1,f()):h()}),v.prototype.right=function(){return this.left+this.width},v.prototype.bottom=function(){return this.top+this.height},v.prototype.Contains=function(a){return a.x>this.left&&a.xthis.top&&a.ythis.right()||a.right()this.bottom()||a.bottom()this.mouse.x?da.direction.x=-1:da.direction.x=0,this.ythis.mouse.y?da.direction.y=-1:da.direction.y=0),w.prototype.update.call(this,a),this.xa.width&&(this.x=a.x-this.image.width),this.ya.height-this.image.height&&(this.y=a.height-this.image.height)},x.prototype.Shoot=function(){e("laser"),ka.unshift(new E(da.x,da.y,(!0),24)),xa=!1},x.prototype.die=function(){w.prototype.die.call(this),e("explosion"),K(this.x+this.image.width/2,this.y+this.image.height/2,200,"#FFCC00"),this.lastScore=ia,o()},x.prototype.gotShot=function(a){a.alive&&(this.lives<=1?this.die():(this.lives--,K(this.x+this.image.width/2,this.y+this.image.height/2,100,"#FFCC00")))},y.prototype=Object.create(w.prototype),y.prototype.update=function(a){ea.image.width=pa.width,ea.image.height=pa.height,w.prototype.update.call(this,a)},z.prototype=Object.create(w.prototype),z.prototype.update=function(a){if(this.y9*a.height/10?(this.movespeed.x=1*this.xspeed,this.movespeed.y=5*this.yspeed):(this.movespeed.x=this.xspeed,this.movespeed.y=this.yspeed),w.prototype.update.call(this,a),this.movementClock--,this.movementClock<=0&&(this.direction.x=Math.floor(3*Math.random())-1,this.movementClock=30*(2+Math.random())),this.shotClock-=ga,this.shotClock<=0&&this.y<.6*a.height){e("enemylaser");var b=new E(this.x,this.y);b.direction.y=1,b.friendly=!1,ka.unshift(b),this.shotClock=(1+Math.random())*this.shotFrequency}this.xa.width&&(this.x=a.x-this.image.width),this.y>a.height+this.image.height&&this.alive&&(this.alive=!1,this.fraction>0&&(va-=this.fraction,ia-=1e3*this.fraction),N.call(this))},z.prototype.draw=function(a){w.prototype.draw.call(this,a),a.fillStyle="#FFFFFF",a.font="15px Audiowide",a.textAlign="center",L(a,this.text,!0,17,.2*pa.width,this.x+this.image.width/2,this.y-5)},z.prototype.die=function(){w.prototype.die.call(this),K(this.x+this.image.width,this.y+this.image.height,50+150*this.fraction,"#FF0000"),ia+=1e3*this.fraction,e("explosion")},z.prototype.gotShot=function(a){a.die(),this.die()},B.prototype=Object.create(z.prototype),B.prototype.die=function(){z.prototype.die.call(this),A(),this.fraction>0&&r()},B.prototype.gotShot=function(a){this.fraction>0?(a.die(),this.die()):(ia+=600*(this.fraction-.5),a.deflect())},C.prototype=Object.create(z.prototype),C.prototype.die=function(){z.prototype.die.call(this),this.fraction>0&&(va-=this.fraction),(this.single&&1===this.fraction&&this.fraction>=1||this.fraction>0&&va<=0)&&(A(),r())},C.prototype.gotShot=function(a){this.fraction>=1||this.fraction>0&&!this.single?(a.die(),this.die()):(ia+=600*(this.fraction-.5),a.deflect())},D.prototype=Object.create(z.prototype),D.prototype.die=function(){va-=this.fraction,this.fraction=0,z.prototype.die.call(this)},D.prototype.gotShot=function(a){if(a.alive&&this.alive)if(ua==-this.pairid){ia+=1e3*this.fraction*2,a.die(),this.die();var b=0;ta.forEach(function(a){a.pairid==ua&&a.die(),a.alive&&b++}),b<=0&&r()}else ua==this.pairid?a.deflect():(a.die(),this.hightlight(),ua=this.pairid)},D.prototype.hightlight=function(){ta.forEach(function(a){a.unhightlight()}),this.stem?this.loadImage("pix/enemystemselected.png"):this.loadImage("pix/enemychoiceselected.png"),this.hightlighted=!0},D.prototype.unhightlight=function(){this.hightlighted&&(this.stem?this.loadImage("pix/enemystem.png"):this.loadImage("pix/enemychoice.png")),this.hightlighted=!1},E.prototype=Object.create(w.prototype),E.prototype.update=function(a){w.prototype.update.call(this,a),(this.xa.width||this.ya.height)&&(this.alive=!1),this.velocity.y=this.laserSpeed*this.direction.y},E.prototype.deflect=function(){this.image=this.loadImage("pix/enemylaser.png"),this.direction.y*=-1,this.friendly=!this.friendly,e("deflect")},F.prototype=Object.create(w.prototype),F.prototype.update=function(a){w.prototype.update.call(this,a),(this.xa.width||this.ya.height)&&(this.alive=!1),this.aliveTime++,this.aliveTime>15*Math.random()+5&&(this.alive=!1)},F.prototype.getRect=function(){return new v(this.x,this.y,this.width,this.height)},F.prototype.draw=function(a){a.fillStyle=this.colour,a.fillRect(this.x,this.y,this.width,this.height),a.stroke()},G.prototype=Object.create(w.prototype),G.prototype.update=function(a){w.prototype.update.call(this,a),this.y>a.height&&(this.alive=!1)},G.prototype.draw=function(a){a.fillStyle="#9999AA",a.fillRect(this.x,this.y,this.width,this.height),a.stroke()};var xa=!0;return{init:_}}); \ No newline at end of file +define ("mod_quizgame/quizgame",["jquery","core/yui","core/notification","core/ajax"],function(a,b,c,d){var aa,ba,ca,da=0,ea=[],fa=[],ga=["pix/icon.gif","pix/planet.png","pix/ship.png","pix/enemy.png","pix/enemystem.png","pix/enemychoice.png","pix/enemystemselected.png","pix/enemychoiceselected.png","pix/laser.png","pix/enemylaser.png"],ha=0,ia=!1,ja,ka,la=-1,ma={x:0,y:0,width:0,height:0},na="",oa,pa,qa=!1,ra=!1,sa=[],ta=0,ua=0,va,wa=!1;a("#mod_quizgame_fullscreen_button").on("click",function(){if(wa){wa=!1;f()}else{h()}});function e(a){if(document.getElementById("mod_quizgame_sound_on").checked){var b=document.getElementById("mod_quizgame_sound_"+a);b.currentTime=0;b.play()}}function f(){wa=!1;ca.removeAttribute("width");ca.removeAttribute("height");ca.removeAttribute("style");ca.classList.remove("floating-game-canvas");a("#button_container").removeClass("floating-button-container fixed-bottom");ma.width=ca.clientWidth;ma.height=ca.clientHeight;ca.style.width=ma.width;ca.style.height=ma.height;i(ca)}function g(){if(wa){f()}}function h(){var b=window.matchMedia("(orientation: landscape)").matches;if(ca.requestFullscreen){ca.requestFullscreen()}else if(ca.msRequestFullscreen){ca.msRequestFullscreen()}else if(ca.mozRequestFullScreen){ca.mozRequestFullScreen()}wa=!0;var c=a("#button_container"),d=window.innerWidth,e=a(window).height();if(b&&d=ga.length){p()}}});ia=!0}else{q()}}function o(){d.call([{methodname:"mod_quizgame_update_score",args:{quizgameid:ba,score:Math.trunc(da)},fail:c.exception}]);l()}function p(){clearInterval(oa);oa=setInterval(function(){t(va,ma,fa,ea,na);u(ma,fa,ea)},40);q()}function q(){da=0;fa=[];ea=[];la=-1;pa=.5;qa=!1;ra=!1;d.call([{methodname:"mod_quizgame_start_game",args:{quizgameid:ba},fail:c.exception}]);ja=new x("pix/ship.png",0,0);ja.x=ma.width/2;ja.y=ma.height/2;fa.push(ja);ka=new y("pix/planet.png",0,0);ka.image.width=ma.width;ka.image.height=ma.height;ka.direction.y=1;ka.movespeed.y=.7;ea.push(ka);r();document.onkeyup=S;document.onkeydown=R;document.onmouseup=U;document.onmousedown=T;document.onmousemove=V;document.ontouchstart=X;document.ontouchend=Y;document.ontouchmove=Z;window.onresize=j;document.addEventListener("gesturestart",W,!1);document.addEventListener("gesturechange",W,!1);document.addEventListener("gestureend",W,!1)}function r(){la++;if(la>=aa.length){la=0;pa*=1.3}na=s(aa,la,ma)}function s(a,b,c){sa=[];ta=0;ua=0;if("truefalse"==a[b].type){a[b].answers.forEach(function(a){var b=new B(Math.random()*c.width,-Math.random()*c.height/2,a.text,a.fraction);sa.push(b);fa.push(b)});ua=0}else if("multichoice"==a[b].type){a[b].answers.forEach(function(d){var e=new C(Math.random()*c.width,-Math.random()*c.height/2,d.text,d.fraction,a[b].single);if(1>d.fraction){sa.push(e);if(0d;d++){c.push(new G(a))}for(d=0;dthis.left&&a.xthis.top&&a.ythis.right()||a.right()this.bottom()||a.bottom()this.mouse.x){ja.direction.x=-1}else{ja.direction.x=0}if(this.ythis.mouse.y){ja.direction.y=-1}else{ja.direction.y=0}}w.prototype.update.call(this,a);if(this.xa.width){this.x=a.x-this.image.width}if(this.ya.height-this.image.height){this.y=a.height-this.image.height}};x.prototype.Shoot=function(){e("laser");fa.unshift(new E(ja.x,ja.y,!0,24));xa=!1};x.prototype.die=function(){w.prototype.die.call(this);e("explosion");K(this.x+this.image.width/2,this.y+this.image.height/2,200,"#FFCC00");this.lastScore=da;o()};x.prototype.gotShot=function(a){if(a.alive){if(1>=this.lives){this.die()}else{this.lives--;K(this.x+this.image.width/2,this.y+this.image.height/2,100,"#FFCC00")}}};function y(a,b,c){w.call(this,a,b,c)}y.prototype=Object.create(w.prototype);y.prototype.update=function(a){ka.image.width=ma.width;ka.image.height=ma.height;w.prototype.update.call(this,a)};function z(a,b,c,d,e){w.call(this,a,b,c);this.xspeed=pa;this.yspeed=pa*(2+Math.random())/4;this.movespeed.x=0;this.movespeed.y=0;this.direction.y=1;this.text=d;this.fraction=e;this.movementClock=0;this.shotFrequency=80;this.shotClock=(1+Math.random())*this.shotFrequency;this.level=la}z.prototype=Object.create(w.prototype);z.prototype.update=function(a){if(this.y9*a.height/10){this.movespeed.x=1*this.xspeed;this.movespeed.y=5*this.yspeed}else{this.movespeed.x=this.xspeed;this.movespeed.y=this.yspeed}w.prototype.update.call(this,a);this.movementClock--;if(0>=this.movementClock){this.direction.x=Math.floor(3*Math.random())-1;this.movementClock=30*(2+Math.random())}this.shotClock-=pa;if(0>=this.shotClock){if(this.y<.6*a.height){e("enemylaser");var b=new E(this.x,this.y);b.direction.y=1;b.friendly=!1;fa.unshift(b);this.shotClock=(1+Math.random())*this.shotFrequency}}if(this.xa.width){this.x=a.x-this.image.width}if(this.y>a.height+this.image.height&&this.alive){this.alive=!1;if(0=ua){A();r()}};C.prototype.gotShot=function(a){if(1<=this.fraction||0=b){r()}}else{if(ta==this.pairid){a.deflect()}else{a.die();this.hightlight();ta=this.pairid}}}};D.prototype.hightlight=function(){sa.forEach(function(a){a.unhightlight()});if(this.stem){this.loadImage("pix/enemystemselected.png")}else{this.loadImage("pix/enemychoiceselected.png")}this.hightlighted=!0};D.prototype.unhightlight=function(){if(this.hightlighted){if(this.stem){this.loadImage("pix/enemystem.png")}else{this.loadImage("pix/enemychoice.png")}}this.hightlighted=!1};function E(a,b,c,d){w.call(this,c?"pix/laser.png":"pix/enemylaser.png",a,b);this.direction.y=-1;this.friendly=c?1:0;this.laserSpeed=d||12}E.prototype=Object.create(w.prototype);E.prototype.update=function(a){w.prototype.update.call(this,a);if(this.xa.width||this.ya.height){this.alive=!1}this.velocity.y=this.laserSpeed*this.direction.y};E.prototype.deflect=function(){this.image=this.loadImage("pix/enemylaser.png");this.direction.y*=-1;this.friendly=!this.friendly;e("deflect")};function F(a,b,c,d){w.call(this,null,a,b);this.width=2;this.height=2;this.velocity.x=c.x;this.velocity.y=c.y;this.aliveTime=0;this.colour=d;this.decay=1}F.prototype=Object.create(w.prototype);F.prototype.update=function(a){w.prototype.update.call(this,a);if(this.xa.width||this.ya.height){this.alive=!1}this.aliveTime++;if(this.aliveTime>15*Math.random()+5){this.alive=!1}};F.prototype.getRect=function(){return new v(this.x,this.y,this.width,this.height)};F.prototype.draw=function(a){a.fillStyle=this.colour;a.fillRect(this.x,this.y,this.width,this.height);a.stroke()};function G(a){w.call(this,null,Math.random()*a.width,0);this.width=2;this.height=2;this.direction.y=1;this.movespeed.y=.2+Math.random()/2;this.aliveTime=0}G.prototype=Object.create(w.prototype);G.prototype.update=function(a){w.prototype.update.call(this,a);if(this.y>a.height){this.alive=!1}};G.prototype.draw=function(a){a.fillStyle="#9999AA";a.fillRect(this.x,this.y,this.width,this.height);a.stroke()};function H(a,b){return a.alive&&b.alive&&(I(a,b)||I(b,a))}function I(a,b){if(a instanceof E&&b instanceof x){if(!a.friendly&&J(a,b)){b.gotShot(a);a.die();return!0}}if(a instanceof E&&b instanceof z){if(a.friendly&&J(a,b)){b.gotShot(a);return!0}}if(a instanceof x&&b instanceof z){if(J(a,b)){a.die();return!0}}return!1}function J(a,b){var c=a.getRect(),d=b.getRect();return c.Intersect(d)}function K(a,b,c,d){for(var e=0;ee){h.push({text:k,y:g+=d});k=b}else{k=c}});h.push({text:k,y:g+=d});var l=g-i;h.forEach(function(b){var d=c?-l:0;a.fillText(b.text,f,b.y+d)})}function N(){var a=sa.filter(function(a){return a.alive}).length;if(0===a&&(ua=ua)&&this.level===la&&ja.alive){r()}}var xa=!0;function O(a){if(-1!==[32,37,38,39,40].indexOf(a.keyCode)){a.preventDefault();if(32===a.keyCode){n()}}}function P(a){if(a.target===ca){n()}}function Q(a){if(a.target===ca){n()}}function R(a){if(-1!==[32,37,38,39,40].indexOf(a.keyCode)){a.preventDefault();if(32===a.keyCode&&ja.alive&&xa){ja.Shoot()}else if(37===a.keyCode){ja.direction.x=-1}else if(38===a.keyCode){ja.direction.y=-1}else if(39===a.keyCode){ja.direction.x=1}else if(40===a.keyCode){ja.direction.y=1}}}function S(a){if(32===a.keyCode){xa=!0}else if(-1!==[37,39].indexOf(a.keyCode)){ja.direction.x=0}else if(-1!==[38,40].indexOf(a.keyCode)){ja.direction.y=0}}function T(a){if(a.target===ca){var b=ja.getRect().Contains({x:a.offsetX,y:a.offsetY});if(b&&ja.alive){ja.Shoot()}if(!ra){ja.mouse.x=a.offsetX;ja.mouse.y=a.offsetY;ra=!0}}}function U(){ja.direction.x=0;ja.direction.y=0;ra=!1}function V(a){ja.mouse.x=a.offsetX;ja.mouse.y=a.offsetY}function W(a){if(a.target===ca){a.preventDefault()}}function X(a){if(a.target===ca){if(ja.alive&&1.\n\n/**\n * This class manages the confirmation pop-up (also called the pre-flight check)\n * that is sometimes shown when a use clicks the start attempt button.\n *\n * This is also responsible for opening the pop-up window, if the quiz requires to be in one.\n *\n * @module mod_quizgame/quizgame\n * @class quizgame\n * @package mod_quizgame\n * @copyright 2016 John Okely \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery', 'core/yui', 'core/notification', 'core/ajax'], function($, Y, notification, ajax) {\n var questions;\n var quizgame;\n var stage;\n var score = 0;\n var particles = [];\n var gameObjects = [];\n var images = [\n 'pix/icon.gif',\n 'pix/planet.png',\n 'pix/ship.png',\n 'pix/enemy.png',\n 'pix/enemystem.png',\n 'pix/enemychoice.png',\n 'pix/enemystemselected.png',\n 'pix/enemychoiceselected.png',\n 'pix/laser.png',\n 'pix/enemylaser.png'\n ];\n var imagesLoaded = 0;\n var loaded = false;\n var player;\n var planet;\n var level = -1;\n var displayRect = {x: 0, y: 0, width: 0, height: 0};\n var question = \"\";\n var interval;\n var enemySpeed;\n var touchDown = false;\n var mouseDown = false;\n var currentTeam = [];\n var lastShot = 0;\n var currentPointsLeft = 0;\n var context;\n var inFullscreen = false;\n\n $('#mod_quizgame_fullscreen_button').on('click', function() {\n if (inFullscreen) {\n inFullscreen = false;\n smallscreen();\n } else {\n fullscreen();\n }\n });\n\n /**\n * Play sound effect\n * @param {string} soundName\n */\n function playSound(soundName) {\n if (document.getElementById(\"mod_quizgame_sound_on\").checked) {\n var soundElement = document.getElementById(\"mod_quizgame_sound_\" + soundName);\n soundElement.currentTime = 0;\n soundElement.play();\n }\n }\n\n /**\n * Adjust for small screens.\n */\n function smallscreen() {\n inFullscreen = false;\n stage.removeAttribute(\"width\");\n stage.removeAttribute(\"height\");\n stage.removeAttribute(\"style\");\n\n stage.classList.remove(\"floating-game-canvas\");\n $(\"#button_container\").removeClass(\"floating-button-container fixed-bottom\");\n\n displayRect.width = stage.clientWidth;\n displayRect.height = stage.clientHeight;\n stage.style.width = displayRect.width;\n stage.style.height = displayRect.height;\n\n sizeScreen(stage);\n }\n\n /**\n * Adjust screen size (switch between modes).\n */\n function fschange() {\n if (inFullscreen) {\n smallscreen();\n }\n }\n\n /**\n * Expand to full screen.\n */\n function fullscreen() {\n var landscape = window.matchMedia(\"(orientation: landscape)\").matches;\n\n if (stage.requestFullscreen) {\n stage.requestFullscreen();\n } else if (stage.msRequestFullscreen) {\n stage.msRequestFullscreen();\n } else if (stage.mozRequestFullScreen) {\n stage.mozRequestFullScreen();\n }\n // The stage.webkitRequestFullscreen() method was removed, due to very easily exiting of full screen in iOS,\n // along with browser messages asking if you are typing in fullscreen.\n\n inFullscreen = true;\n var buttonContainer = $(\"#button_container\");\n\n var width = window.innerWidth;\n\n // The window.innerHeight returns an offset value on iOS devices in safari only\n // while in portrait mode for some reason.\n var height = $(window).height();\n\n // Switch width and height\n if (landscape && width < height) {\n height = [width, width = height][0];\n }\n\n // Gets the actual button container height, then adds 16px; 8px on the\n // top and 8px on the bottom for the page margin.\n height -= buttonContainer.height() + 16;\n\n displayRect.width = width;\n displayRect.height = height;\n\n stage.style.width = width + \"px\";\n stage.style.height = height + \"px\";\n\n // Makes the canvas float.\n stage.classList.add(\"floating-game-canvas\");\n\n // This makes the button container float below the game canvas.\n buttonContainer.addClass(\"floating-button-container fixed-bottom\");\n\n $(\"#mod_quizgame_fullscreen_button\").blur(); // The button pressed was still focused, so a blur is necessary.\n\n sizeScreen(stage);\n }\n\n /**\n * Adjust screen size based on browser window.\n * @param {object} stage\n */\n function sizeScreen(stage) {\n\n stage.width = displayRect.width;\n stage.height = displayRect.height;\n context.imageSmoothingEnabled = false;\n }\n\n /**\n * Helper function for when the screen size chages due to rotating on mobile.\n */\n function orientationChange() {\n if (inFullscreen) {\n fullscreen();\n } else {\n smallscreen();\n }\n }\n\n /**\n * Helper function to clear all events.\n */\n function clearEvents() {\n document.onkeydown = null;\n document.onkeyup = null;\n document.onmousedown = null;\n document.onmouseup = null;\n document.onmousemove = null;\n document.ontouchstart = null;\n document.ontouchend = null;\n document.ontouchmove = null;\n window.onresize = null;\n }\n\n /**\n * Helper function to handle JS Events.\n */\n function menuEvents() {\n clearEvents();\n document.onkeydown = menukeydown;\n document.onmouseup = menumousedown;\n document.ontouchend = menutouchend;\n window.onresize = orientationChange;\n }\n\n /**\n * Helper function to display game start screen\n */\n function showMenu() {\n\n context.clearRect(0, 0, displayRect.width, displayRect.height);\n\n context.fillStyle = '#FFFFFF';\n context.font = \"18px Audiowide\";\n context.textAlign = 'center';\n\n if (questions !== null && questions.length > 0) {\n context.fillText(M.util.get_string('spacetostart', 'mod_quizgame'), displayRect.width / 2, displayRect.height / 2);\n menuEvents();\n } else {\n context.fillText(M.util.get_string('emptyquiz', 'mod_quizgame'), displayRect.width / 2, displayRect.height / 2);\n }\n }\n\n /**\n * Helper function to load game objects\n */\n function loadGame() {\n\n shuffle(questions);\n\n if (!loaded) {\n images.forEach(function(src) {\n var image = new Image();\n image.src = src;\n image.onload = function() {\n imagesLoaded++;\n if (imagesLoaded >= images.length) {\n gameLoaded();\n }\n };\n });\n loaded = true;\n } else {\n startGame();\n }\n }\n\n /**\n * Helper function process game-over.\n */\n function endGame() {\n ajax.call([{\n methodname: 'mod_quizgame_update_score',\n args: {quizgameid: quizgame, score: Math.trunc(score)},\n fail: notification.exception\n }]);\n menuEvents();\n }\n\n /**\n * Helper function process game ready.\n */\n function gameLoaded() {\n\n clearInterval(interval);\n\n interval = setInterval(function() {\n draw(context, displayRect, gameObjects, particles, question);\n update(displayRect, gameObjects, particles);\n }, 40);\n\n startGame();\n }\n\n /**\n * Helper function process game start.\n */\n function startGame() {\n\n score = 0;\n gameObjects = [];\n particles = [];\n level = -1;\n enemySpeed = 0.5;\n touchDown = false;\n mouseDown = false;\n\n // Queue & trigger the game_started event.\n ajax.call([{\n methodname: 'mod_quizgame_start_game',\n args: {quizgameid: quizgame},\n fail: notification.exception\n }]);\n\n player = new Player(\"pix/ship.png\", 0, 0);\n player.x = displayRect.width / 2;\n player.y = displayRect.height / 2;\n gameObjects.push(player);\n\n planet = new Planet(\"pix/planet.png\", 0, 0);\n planet.image.width = displayRect.width;\n planet.image.height = displayRect.height;\n planet.direction.y = 1;\n planet.movespeed.y = 0.7;\n particles.push(planet);\n\n nextLevel();\n\n document.onkeyup = keyup;\n document.onkeydown = keydown;\n document.onmouseup = mouseup;\n document.onmousedown = mousedown;\n document.onmousemove = mousemove;\n document.ontouchstart = touchstart;\n document.ontouchend = touchend;\n document.ontouchmove = touchmove;\n window.onresize = orientationChange;\n\n document.addEventListener(\"gesturestart\", cancelled, false);\n document.addEventListener(\"gesturechange\", cancelled, false);\n document.addEventListener(\"gestureend\", cancelled, false);\n }\n\n /**\n * Helper function process next level (question).\n */\n function nextLevel() {\n level++;\n if (level >= questions.length) {\n level = 0;\n enemySpeed *= 1.3;\n }\n question = runLevel(questions, level, displayRect);\n }\n\n /**\n * Helper function process current level.\n * @param {array} questions\n * @param {object} level\n * @param {object} bounds\n * @returns {string}\n */\n function runLevel(questions, level, bounds) {\n currentTeam = [];\n lastShot = 0;\n currentPointsLeft = 0;\n\n if (questions[level].type == 'truefalse') {\n questions[level].answers.forEach(function(answer) {\n var enemy = new TFEnemy(Math.random() * bounds.width, -Math.random() * bounds.height / 2,\n answer.text, answer.fraction);\n currentTeam.push(enemy);\n gameObjects.push(enemy);\n });\n currentPointsLeft = 0; // This is unused by TrueFalse questions.\n } else if (questions[level].type == 'multichoice') {\n questions[level].answers.forEach(function(answer) {\n var enemy = new MultiEnemy(Math.random() * bounds.width, -Math.random() * bounds.height / 2,\n answer.text, answer.fraction, questions[level].single);\n if (answer.fraction < 1) {\n currentTeam.push(enemy);\n if (answer.fraction > 0) {\n currentPointsLeft += parseFloat(answer.fraction);\n }\n }\n gameObjects.push(enemy);\n });\n } else if (questions[level].type == 'match') {\n var i = 0;\n var fraction = 1 / (questions[level].stems.length * 2);\n currentPointsLeft += 1;\n questions[level].stems.forEach(function(stem) {\n i++;\n var question = new MatchEnemy(Math.random() * bounds.width, -Math.random() * bounds.height / 2,\n stem.question, fraction, -i, true);\n var answer = new MatchEnemy(Math.random() * bounds.width, -Math.random() * bounds.height / 2,\n stem.answer, fraction, i);\n currentTeam.push(question);\n currentTeam.push(answer);\n gameObjects.push(question);\n gameObjects.push(answer);\n });\n }\n return questions[level].question;\n }\n\n /**\n * Helper function to place text on screen\n * @param {object} context\n * @param {object} displayRect\n * @param {objectc} objects\n * @param {object} particles\n * @param {string} question\n */\n function draw(context, displayRect, objects, particles, question) {\n context.clearRect(0, 0, displayRect.width, displayRect.height);\n var i = 0;\n for (i = 0; i < particles.length; i++) {\n particles[i].draw(context);\n }\n\n for (i = 0; i < objects.length; i++) {\n objects[i].draw(context);\n }\n\n if (player.alive) {\n context.fillStyle = '#FFFFFF';\n context.font = \"18px Audiowide\";\n context.textAlign = 'left';\n context.fillText(M.util.get_string('score', 'mod_quizgame',\n {\n \"score\": Math.round(score), \"lives\": player.lives\n }),\n 5, displayRect.height - 20);\n context.textAlign = 'center';\n\n wrapText(context, question, false, 20, displayRect.width * 0.9, displayRect.width / 2, 20);\n } else {\n context.fillStyle = '#FFFFFF';\n context.font = \"18px Audiowide\";\n context.textAlign = 'center';\n context.fillText(M.util.get_string('endofgame', 'mod_quizgame',\n Math.round(player.lastScore)),\n displayRect.width / 2, displayRect.height / 2);\n }\n }\n\n /**\n * Helper function main game logic: process movements and behaviours of game objects\n * @param {object} bounds\n * @param {object} objects\n * @param {object} particles\n */\n function update(bounds, objects, particles) {\n var i = 0;\n for (i = 0; i < 3; i++) {\n particles.push(new Star(bounds));\n }\n for (i = 0; i < particles.length; i++) {\n particles[i].update(bounds);\n if (!particles[i].alive) {\n particles.splice(i, 1);\n i--;\n }\n }\n for (i = 0; i < objects.length; i++) {\n objects[i].update(bounds);\n for (var j = i + 1; j < objects.length; j++) {\n collide(objects[i], objects[j]);\n }\n if (!objects[i].alive) {\n objects.splice(i, 1);\n i--;\n }\n }\n }\n\n /**\n * Constructor for storing information about a rectangle shape\n * @param {int} left\n * @param {int} top\n * @param {int} width\n * @param {int} height\n */\n function Rectangle(left, top, width, height) {\n this.left = left || 0;\n this.top = top || 0;\n this.width = width || 0;\n this.height = height || 0;\n }\n\n Rectangle.prototype.right = function() {\n return this.left + this.width;\n };\n\n Rectangle.prototype.bottom = function() {\n return this.top + this.height;\n };\n\n Rectangle.prototype.Contains = function(point) {\n return point.x > this.left &&\n point.x < this.right() &&\n point.y > this.top &&\n point.y < this.bottom();\n };\n\n Rectangle.prototype.Intersect = function(rectangle) {\n var retval = !(rectangle.left > this.right() ||\n rectangle.right() < this.left ||\n rectangle.top > this.bottom() ||\n rectangle.bottom() < this.top);\n return retval;\n };\n\n /**\n * Generate Game Object.\n * @param {text} src\n * @param {int} x\n * @param {int} y\n */\n function GameObject(src, x, y) {\n if (src !== null) {\n this.image = this.loadImage(src);\n }\n this.x = x;\n this.y = y;\n this.velocity = {x: 0, y: 0};\n this.direction = {x: 0, y: 0};\n this.movespeed = {x: 5, y: 3};\n this.alive = true;\n this.decay = 0.7;\n }\n\n GameObject.prototype.loadImage = function(src) {\n if (!this.image) {\n this.image = new Image();\n }\n this.image.src = src;\n return this.image;\n };\n\n GameObject.prototype.update = function() {\n this.velocity.x += this.direction.x * this.movespeed.x;\n this.velocity.y += this.direction.y * this.movespeed.y;\n this.x += this.velocity.x;\n this.y += this.velocity.y;\n this.velocity.y *= this.decay;\n this.velocity.x *= this.decay;\n };\n\n GameObject.prototype.draw = function(context) {\n context.drawImage(this.image, this.x, this.y, this.image.width, this.image.height);\n };\n\n GameObject.prototype.getRect = function() {\n return new Rectangle(this.x, this.y, this.image.width, this.image.height);\n };\n\n GameObject.prototype.die = function() {\n this.alive = false;\n };\n\n /**\n * Constructor for Player class, all the information about the player\n * @param {string} src\n * @param {int} x\n * @param {int} y\n */\n function Player(src, x, y) {\n GameObject.call(this, src, x, y);\n this.mouse = {x: 0, y: 0};\n this.movespeed = {x: 6, y: 4};\n this.lives = 3;\n this.lastScore = 0;\n }\n\n Player.prototype = Object.create(GameObject.prototype);\n Player.prototype.update = function(bounds) {\n if (mouseDown || touchDown) {\n if (this.x < this.mouse.x - (this.image.width)) {\n player.direction.x = 1;\n } else if (this.x > this.mouse.x) {\n player.direction.x = -1;\n } else {\n player.direction.x = 0;\n }\n if (this.y < this.mouse.y - (this.image.height)) {\n player.direction.y = 1;\n } else if (this.y > this.mouse.y) {\n player.direction.y = -1;\n } else {\n player.direction.y = 0;\n }\n }\n GameObject.prototype.update.call(this, bounds);\n if (this.x < bounds.x - this.image.width) {\n this.x = bounds.width;\n } else if (this.x > bounds.width) {\n this.x = bounds.x - this.image.width;\n }\n if (this.y < bounds.y) {\n this.y = bounds.y;\n } else if (this.y > bounds.height - this.image.height) {\n this.y = bounds.height - this.image.height;\n }\n };\n\n Player.prototype.Shoot = function() {\n playSound(\"laser\");\n gameObjects.unshift(new Laser(player.x, player.y, true, 24));\n canShoot = false;\n };\n\n Player.prototype.die = function() {\n GameObject.prototype.die.call(this);\n playSound(\"explosion\");\n spray(this.x + this.image.width / 2, this.y + this.image.height / 2, 200, \"#FFCC00\");\n this.lastScore = score;\n endGame();\n };\n\n Player.prototype.gotShot = function(shot) {\n if (shot.alive) {\n if (this.lives <= 1) {\n this.die();\n } else {\n this.lives--;\n spray(this.x + this.image.width / 2, this.y + this.image.height / 2, 100, \"#FFCC00\");\n }\n }\n };\n\n /**\n * Constructor for Planet (background objects) extends GameObject\n * @param {string} src\n * @param {int} x\n * @param {int} y\n */\n function Planet(src, x, y) {\n GameObject.call(this, src, x, y);\n }\n\n Planet.prototype = Object.create(GameObject.prototype);\n Planet.prototype.update = function(bounds) {\n planet.image.width = displayRect.width;\n planet.image.height = displayRect.height;\n GameObject.prototype.update.call(this, bounds);\n };\n\n /**\n * Constructor for enemy craft (answers) extends GameObject\n * @param {string} src\n * @param {int} x\n * @param {int} y\n * @param {string} text\n * @param {float} fraction\n */\n function Enemy(src, x, y, text, fraction) {\n GameObject.call(this, src, x, y);\n this.xspeed = enemySpeed;\n this.yspeed = enemySpeed * (2 + Math.random()) / 4;\n this.movespeed.x = 0;\n this.movespeed.y = 0;\n this.direction.y = 1;\n this.text = text;\n this.fraction = fraction;\n this.movementClock = 0;\n this.shotFrequency = 80;\n this.shotClock = (1 + Math.random()) * this.shotFrequency;\n this.level = level;\n }\n\n Enemy.prototype = Object.create(GameObject.prototype);\n\n Enemy.prototype.update = function(bounds) {\n\n if (this.y < bounds.height / 10 || this.y > bounds.height * 9 / 10) {\n this.movespeed.x = this.xspeed * 1;\n this.movespeed.y = this.yspeed * 5;\n } else {\n this.movespeed.x = this.xspeed;\n this.movespeed.y = this.yspeed;\n }\n\n GameObject.prototype.update.call(this, bounds);\n\n this.movementClock--;\n\n if (this.movementClock <= 0) {\n this.direction.x = Math.floor(Math.random() * 3) - 1;\n this.movementClock = (2 + Math.random()) * 30;\n }\n\n this.shotClock -= enemySpeed;\n\n if (this.shotClock <= 0) {\n if (this.y < bounds.height * 0.6) {\n playSound(\"enemylaser\");\n var laser = new Laser(this.x, this.y);\n laser.direction.y = 1;\n laser.friendly = false;\n gameObjects.unshift(laser);\n this.shotClock = (1 + Math.random()) * this.shotFrequency;\n }\n }\n\n if (this.x < bounds.x - this.image.width) {\n this.x = bounds.width;\n } else if (this.x > bounds.width) {\n this.x = bounds.x - this.image.width;\n }\n if (this.y > bounds.height + this.image.height && this.alive) {\n this.alive = false;\n if (this.fraction > 0) {\n currentPointsLeft -= this.fraction;\n score -= 1000 * this.fraction;\n }\n\n shipReachedEnd.call(this);\n }\n };\n\n Enemy.prototype.draw = function(context) {\n GameObject.prototype.draw.call(this, context);\n\n context.fillStyle = '#FFFFFF';\n context.font = \"15px Audiowide\";\n context.textAlign = 'center';\n\n wrapText(context, this.text, true, 17, displayRect.width * 0.2, this.x + this.image.width / 2, this.y - 5);\n };\n\n Enemy.prototype.die = function() {\n GameObject.prototype.die.call(this);\n spray(this.x + this.image.width, this.y + this.image.height, 50 + (this.fraction * 150), \"#FF0000\");\n\n // Adjust Score.\n score += this.fraction * 1000;\n\n // Kill off the ship.\n playSound(\"explosion\");\n };\n\n Enemy.prototype.gotShot = function(shot) {\n // Default behaviour, to be overridden.\n shot.die();\n this.die();\n };\n\n /**\n * Helper function to remove any stray ships on level advance\n */\n function killAllAlive() {\n currentTeam.forEach(function(enemy) {\n if (enemy.alive) {\n // Make the fraction 0 so it won't count as anything and make a new level.\n enemy.fraction = 0;\n enemy.die();\n }\n });\n currentTeam = [];\n }\n\n /**\n * Helper function for True/False questions\n * @param {int} x\n * @param {int} y\n * @param {string} text\n * @param {float} fraction\n */\n function TFEnemy(x, y, text, fraction) {\n Enemy.call(this, \"pix/enemy.png\", x, y, text, fraction);\n }\n\n TFEnemy.prototype = Object.create(Enemy.prototype);\n\n TFEnemy.prototype.die = function() {\n // TrueFalse questions are very simple, if either of the ships die, Enemy.prototype.die will handle\n // the score adding of 1000 or 0, and then this will kill the other remaining ship.\n Enemy.prototype.die.call(this);\n killAllAlive();\n // Only goes to the next level if the result is \"true\", as no matter what enemy dies first, the opposite will\n // die immediately after.\n if (this.fraction > 0) {\n nextLevel();\n }\n };\n\n TFEnemy.prototype.gotShot = function(shot) {\n if (this.fraction > 0) {\n shot.die();\n this.die();\n } else {\n score += (this.fraction - 0.5) * 600;\n shot.deflect();\n }\n };\n\n /**\n * Helper function for multiple choice questions (MCQ)\n * @param {int} x\n * @param {int} y\n * @param {string} text\n * @param {float} fraction\n * @param {boolean} single\n */\n function MultiEnemy(x, y, text, fraction, single) {\n Enemy.call(this, \"pix/enemy.png\", x, y, text, fraction);\n this.single = single;\n }\n\n MultiEnemy.prototype = Object.create(Enemy.prototype);\n\n MultiEnemy.prototype.die = function() {\n Enemy.prototype.die.call(this);\n if (this.fraction > 0) {\n currentPointsLeft -= this.fraction;\n }\n if ((this.single && this.fraction === 1) && this.fraction >= 1 || (this.fraction > 0 && currentPointsLeft <= 0)) {\n killAllAlive();\n nextLevel();\n }\n };\n\n MultiEnemy.prototype.gotShot = function(shot) {\n if (this.fraction >= 1 || (this.fraction > 0 && !this.single)) {\n shot.die();\n this.die();\n } else {\n score += (this.fraction - 0.5) * 600;\n shot.deflect();\n }\n };\n\n /**\n * Helper function for matching questions\n * @param {int} x\n * @param {int} y\n * @param {string} text\n * @param {float} fraction\n * @param {int} pairid\n * @param {boolean} stem\n */\n function MatchEnemy(x, y, text, fraction, pairid, stem) {\n this.stem = stem ? true : false;\n if (this.stem) {\n Enemy.call(this, \"pix/enemystem.png\", x, y, text, fraction);\n } else {\n Enemy.call(this, \"pix/enemychoice.png\", x, y, text, fraction);\n }\n this.pairid = pairid;\n this.shotFrequency = 160;\n this.hightlighted = false;\n }\n\n MatchEnemy.prototype = Object.create(Enemy.prototype);\n\n MatchEnemy.prototype.die = function() {\n currentPointsLeft -= this.fraction;\n // Sets the fraction as 0 to stop it adding to the score in #die()\n this.fraction = 0;\n Enemy.prototype.die.call(this);\n };\n\n MatchEnemy.prototype.gotShot = function(shot) {\n if (shot.alive && this.alive) {\n if (lastShot == -this.pairid) {\n\n // Increasing the score here instead of in #die(), due to rounding issues being a few numbers off.\n // This must be done before because when #die is invoked, as it sets the fraction as 0.\n score += this.fraction * 1000 * 2;\n\n shot.die();\n this.die();\n var alives = 0;\n currentTeam.forEach(function(match) {\n if (match.pairid == lastShot) {\n match.die();\n }\n if (match.alive) {\n alives++;\n }\n });\n\n if (alives <= 0) {\n nextLevel();\n }\n } else {\n if (lastShot == this.pairid) {\n shot.deflect();\n } else {\n shot.die();\n this.hightlight();\n lastShot = this.pairid;\n }\n }\n }\n };\n\n MatchEnemy.prototype.hightlight = function() {\n currentTeam.forEach(function(match) {\n match.unhightlight();\n });\n if (this.stem) {\n this.loadImage(\"pix/enemystemselected.png\");\n } else {\n this.loadImage(\"pix/enemychoiceselected.png\");\n }\n this.hightlighted = true;\n };\n\n MatchEnemy.prototype.unhightlight = function() {\n if (this.hightlighted) {\n if (this.stem) {\n this.loadImage(\"pix/enemystem.png\");\n } else {\n this.loadImage(\"pix/enemychoice.png\");\n }\n }\n this.hightlighted = false;\n };\n\n /**\n * Constructor for laser shots extends GameObject\n * @param {int} x\n * @param {int} y\n * @param {bool} friendly\n * @param {float} laserSpeed\n */\n function Laser(x, y, friendly, laserSpeed) {\n GameObject.call(this, friendly ? \"pix/laser.png\" : \"pix/enemylaser.png\", x, y);\n this.direction.y = -1;\n this.friendly = friendly ? 1 : 0;\n this.laserSpeed = laserSpeed || 12;\n }\n Laser.prototype = Object.create(GameObject.prototype);\n\n Laser.prototype.update = function(bounds) {\n GameObject.prototype.update.call(this, bounds);\n if (this.x < bounds.x - this.image.width ||\n this.x > bounds.width ||\n this.y < bounds.y - this.image.height ||\n this.y > bounds.height) {\n this.alive = false;\n }\n this.velocity.y = this.laserSpeed * this.direction.y;\n };\n\n Laser.prototype.deflect = function() {\n this.image = this.loadImage(\"pix/enemylaser.png\");\n this.direction.y *= -1;\n this.friendly = !this.friendly;\n playSound(\"deflect\");\n };\n\n /**\n * Constructor for explosion particle effects extends GameObject\n * @param {int} x\n * @param {int} y\n * @param {float} velocity\n * @param {string} colour\n */\n function Particle(x, y, velocity, colour) {\n GameObject.call(this, null, x, y);\n this.width = 2;\n this.height = 2;\n this.velocity.x = velocity.x;\n this.velocity.y = velocity.y;\n this.aliveTime = 0;\n this.colour = colour;\n this.decay = 1;\n }\n\n Particle.prototype = Object.create(GameObject.prototype);\n\n Particle.prototype.update = function(bounds) {\n GameObject.prototype.update.call(this, bounds);\n if (this.x < bounds.x - this.width ||\n this.x > bounds.width ||\n this.y < bounds.y - this.height ||\n this.y > bounds.height) {\n this.alive = false;\n }\n this.aliveTime++;\n if (this.aliveTime > (Math.random() * 15) + 5) {\n this.alive = false;\n }\n };\n\n Particle.prototype.getRect = function() {\n return new Rectangle(this.x, this.y, this.width, this.height);\n };\n\n Particle.prototype.draw = function(context) {\n context.fillStyle = this.colour;\n context.fillRect(this.x, this.y, this.width, this.height);\n context.stroke();\n };\n\n /**\n * Helper function for background stars extends GameObject\n * @param {object} bounds\n */\n function Star(bounds) {\n GameObject.call(this, null, Math.random() * bounds.width, 0);\n this.width = 2;\n this.height = 2;\n this.direction.y = 1;\n this.movespeed.y = 0.2 + (Math.random() / 2);\n this.aliveTime = 0;\n }\n Star.prototype = Object.create(GameObject.prototype);\n\n Star.prototype.update = function(bounds) {\n GameObject.prototype.update.call(this, bounds);\n if (this.y > bounds.height) {\n this.alive = false;\n }\n };\n\n Star.prototype.draw = function(context) {\n context.fillStyle = '#9999AA';\n context.fillRect(this.x, this.y, this.width, this.height);\n context.stroke();\n };\n\n /**\n * Helper function to handle collisions between gameobjects.\n * @param {object} object1\n * @param {object} object2\n * @return {boolean}\n */\n function collide(object1, object2) {\n return object1.alive && object2.alive && (collideOrdered(object1, object2) || collideOrdered(object2, object1));\n }\n\n /**\n * Helper funcction to handle collisions.\n * @param {object} object1\n * @param {object} object2\n * @returns {boolean}\n */\n function collideOrdered(object1, object2) {\n if (object1 instanceof Laser && object2 instanceof Player) {\n if (!object1.friendly && objectsIntersect(object1, object2)) {\n object2.gotShot(object1);\n object1.die();\n return true;\n }\n }\n if (object1 instanceof Laser && object2 instanceof Enemy) {\n if (object1.friendly && objectsIntersect(object1, object2)) {\n object2.gotShot(object1);\n return true;\n }\n }\n if (object1 instanceof Player && object2 instanceof Enemy) {\n if (objectsIntersect(object1, object2)) {\n object1.die();\n return true;\n }\n }\n return false;\n }\n\n /**\n * Helper function to handle intersections between GameObjects.\n * @param {object} object1\n * @param {object} object2\n * @return {boolean}\n */\n function objectsIntersect(object1, object2) {\n var rect1 = object1.getRect();\n var rect2 = object2.getRect();\n return rect1.Intersect(rect2);\n }\n\n /**\n * Helper function for spraying particle (explosion) effects\n * @param {int} x\n * @param {int} y\n * @param {int} num\n * @param {string} colour\n */\n function spray(x, y, num, colour) {\n for (var i = 0; i < num; i++) {\n particles.push(new Particle(x, y, {x: (Math.random() - 0.5) * 16, y: ((Math.random() - 0.5) * 16) + 3}, colour));\n }\n }\n\n /**\n * Helper function to display answers.\n * @param {object} context\n * @param {string} input\n * @param {bool} wrapUpwards\n * @param {int} textHeight\n * @param {int} maxLineWidth\n * @param {int} x\n * @param {int} y\n */\n function wrapText(context, input, wrapUpwards, textHeight, maxLineWidth, x, y) {\n var drawLines = [];\n var originalY = y;\n var words = input.split(' ');\n var line = '';\n\n // Loops through the words, and preprocesses each line with the correct string value and y location.\n words.forEach(function(word) {\n var tempLine = line + ' ' + word;\n var metrics = context.measureText(tempLine);\n var testWidth = metrics.width;\n\n // If the line with the new word is too long, then push the current line without the new word to drawLines.\n if (testWidth > maxLineWidth) {\n drawLines.push({\n text: line,\n y: y += textHeight\n });\n\n line = word;\n } else {\n // If it's shorted than the limit, just add the word to the line and move on.\n line = tempLine;\n }\n });\n\n // Push the last line, if it exists.\n drawLines.push({\n text: line,\n y: y += textHeight\n });\n\n // The offset the text was created.\n var yOffset = y - originalY;\n\n drawLines.forEach(function(drawLine) {\n // If it is suppose to wrap upwards (i.e. for enemy ships) it shifts all questions upwards the amount the\n // questions go down.\n var modifier = wrapUpwards ? -yOffset : 0;\n\n context.fillText(drawLine.text, x, drawLine.y + modifier);\n });\n }\n\n /**\n * Helper function for end of level.\n */\n function shipReachedEnd() {\n var amountLeft = currentTeam.filter(function(enemy) {\n return enemy.alive;\n }).length;\n\n if (amountLeft === 0 && (currentPointsLeft < this.fraction || currentPointsLeft <= 0)\n && this.level === level && player.alive) {\n nextLevel();\n }\n }\n\n // Input.\n\n var canShoot = true;\n\n /**\n * Helper function for game menu from keyboard.\n * @param {object} e\n */\n function menukeydown(e) {\n if ([32, 37, 38, 39, 40].indexOf(e.keyCode) !== -1) {\n e.preventDefault();\n if (e.keyCode === 32) {\n loadGame();\n }\n }\n }\n\n /**\n * Helper function for game menu on mobile.\n * @param {object} e\n */\n function menumousedown(e) {\n if (e.target === stage) {\n loadGame();\n }\n }\n\n /**\n * Helper function for game menu on mobile.\n * @param {object} e\n */\n function menutouchend(e) {\n if (e.target === stage) {\n loadGame();\n }\n }\n\n /**\n * Helper function for keyboard movement.\n * @param {object} e\n */\n function keydown(e) {\n if ([32, 37, 38, 39, 40].indexOf(e.keyCode) !== -1) {\n e.preventDefault();\n if (e.keyCode === 32 && player.alive && canShoot) {\n player.Shoot();\n } else if (e.keyCode === 37) {\n player.direction.x = -1;\n } else if (e.keyCode === 38) {\n player.direction.y = -1;\n } else if (e.keyCode === 39) {\n player.direction.x = 1;\n } else if (e.keyCode === 40) {\n player.direction.y = 1;\n }\n }\n }\n\n /**\n * Helper function for keyboard movement.\n * @param {object} e\n */\n function keyup(e) {\n if (e.keyCode === 32) {\n canShoot = true;\n } else if ([37, 39].indexOf(e.keyCode) !== -1) {\n player.direction.x = 0;\n } else if ([38, 40].indexOf(e.keyCode) !== -1) {\n player.direction.y = 0;\n }\n }\n\n /**\n * Helper function for mouse click (starts the player shooting if you clicked on them, moving if you didn't).\n * @param {object} e\n */\n function mousedown(e) {\n if (e.target === stage) {\n var playerWasClicked = player.getRect().Contains({x: e.offsetX, y: e.offsetY});\n if (playerWasClicked && player.alive) {\n player.Shoot();\n }\n if (!mouseDown) {\n player.mouse.x = e.offsetX;\n player.mouse.y = e.offsetY;\n mouseDown = true;\n }\n }\n }\n\n /**\n * Helper function for mouse release. (stops the Player) for mouse mode\n * @param {object} e\n */\n function mouseup() {\n player.direction.x = 0;\n player.direction.y = 0;\n mouseDown = false;\n }\n\n /**\n * Helper function for mouse movement.\n * @param {object} e\n */\n function mousemove(e) {\n player.mouse.x = e.offsetX;\n player.mouse.y = e.offsetY;\n }\n\n /**\n * Helper function for cancelled event.\n * @param {object} event\n */\n function cancelled(event) {\n if (event.target === stage) {\n event.preventDefault();\n }\n }\n\n /**\n * Helper function for movement on mobile touch devices.\n * @param {object} e\n */\n function touchstart(e) {\n if (e.target === stage) {\n if (player.alive && e.touches.length > 1) {\n player.Shoot();\n } else {\n touchDown = true;\n touchmove(e);\n }\n\n e.preventDefault();\n }\n }\n\n /**\n * Helper function for movement on mobile touch devices.\n * @param {object} e\n */\n function touchend(e) {\n if (e.touches.length === 0) {\n touchDown = false;\n }\n player.direction.x = 0;\n player.direction.y = 0;\n\n if (e.target === stage) {\n e.preventDefault();\n }\n }\n\n /**\n * Helper function for movement on mobile touch devices.\n * @param {object} e\n */\n function touchmove(e) {\n var rect = e.target.getBoundingClientRect();\n // Required for getting the stage's relative touch position, due to a previous significant offset.\n var x = e.touches[0].pageX - rect.left;\n var y = e.touches[0].clientY - rect.top;\n\n window.stage = stage;\n player.mouse.x = x;\n player.mouse.y = y;\n\n if (e.target === stage) {\n e.preventDefault();\n }\n }\n\n /**\n * Helper function to shuffle levels.\n * @param {array} array\n * @return {array}\n */\n function shuffle(array) {\n var currentIndex = array.length;\n var temporaryValue;\n var randomIndex;\n\n while (0 !== currentIndex) {\n\n randomIndex = Math.floor(Math.random() * currentIndex);\n currentIndex -= 1;\n\n temporaryValue = array[currentIndex];\n array[currentIndex] = array[randomIndex];\n array[randomIndex] = temporaryValue;\n }\n\n return array;\n }\n\n /**\n * Initialization of the game.\n * @param {array} q\n * @param {array} qid\n */\n function doInitialize(q, qid) {\n questions = q;\n quizgame = qid;\n if (document.addEventListener) {\n document.addEventListener('fullscreenchange', fschange, false);\n document.addEventListener('MSFullscreenChange', fschange, false);\n document.addEventListener('mozfullscreenchange', fschange, false);\n document.addEventListener('webkitfullscreenchange', fschange, false);\n }\n stage = document.getElementById(\"mod_quizgame_game\");\n context = stage.getContext(\"2d\");\n smallscreen();\n interval = setInterval(function() {\n showMenu();\n }, 500);\n }\n\n return {\n init: doInitialize,\n };\n});\n"],"file":"quizgame.min.js"} \ No newline at end of file diff --git a/amd/src/quizgame.js b/amd/src/quizgame.js index 37c99f7..d13d0b3 100644 --- a/amd/src/quizgame.js +++ b/amd/src/quizgame.js @@ -25,7 +25,7 @@ * @copyright 2016 John Okely * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, notification, ajax) { +define(['jquery', 'core/yui', 'core/notification', 'core/ajax'], function($, Y, notification, ajax) { var questions; var quizgame; var stage; @@ -61,7 +61,7 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n var context; var inFullscreen = false; - $('#mod_quizgame_fullscreen_button').on('click', function () { + $('#mod_quizgame_fullscreen_button').on('click', function() { if (inFullscreen) { inFullscreen = false; smallscreen(); @@ -70,6 +70,10 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } }); + /** + * Play sound effect + * @param {string} soundName + */ function playSound(soundName) { if (document.getElementById("mod_quizgame_sound_on").checked) { var soundElement = document.getElementById("mod_quizgame_sound_" + soundName); @@ -78,6 +82,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Adjust for small screens. + */ function smallscreen() { inFullscreen = false; stage.removeAttribute("width"); @@ -95,12 +102,18 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n sizeScreen(stage); } + /** + * Adjust screen size (switch between modes). + */ function fschange() { if (inFullscreen) { smallscreen(); } } + /** + * Expand to full screen. + */ function fullscreen() { var landscape = window.matchMedia("(orientation: landscape)").matches; @@ -149,6 +162,10 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n sizeScreen(stage); } + /** + * Adjust screen size based on browser window. + * @param {object} stage + */ function sizeScreen(stage) { stage.width = displayRect.width; @@ -156,6 +173,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n context.imageSmoothingEnabled = false; } + /** + * Helper function for when the screen size chages due to rotating on mobile. + */ function orientationChange() { if (inFullscreen) { fullscreen(); @@ -164,6 +184,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function to clear all events. + */ function clearEvents() { document.onkeydown = null; document.onkeyup = null; @@ -176,6 +199,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n window.onresize = null; } + /** + * Helper function to handle JS Events. + */ function menuEvents() { clearEvents(); document.onkeydown = menukeydown; @@ -184,6 +210,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n window.onresize = orientationChange; } + /** + * Helper function to display game start screen + */ function showMenu() { context.clearRect(0, 0, displayRect.width, displayRect.height); @@ -200,6 +229,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function to load game objects + */ function loadGame() { shuffle(questions); @@ -221,6 +253,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function process game-over. + */ function endGame() { ajax.call([{ methodname: 'mod_quizgame_update_score', @@ -230,6 +265,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n menuEvents(); } + /** + * Helper function process game ready. + */ function gameLoaded() { clearInterval(interval); @@ -242,6 +280,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n startGame(); } + /** + * Helper function process game start. + */ function startGame() { score = 0; @@ -288,6 +329,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n document.addEventListener("gestureend", cancelled, false); } + /** + * Helper function process next level (question). + */ function nextLevel() { level++; if (level >= questions.length) { @@ -296,6 +340,14 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } question = runLevel(questions, level, displayRect); } + + /** + * Helper function process current level. + * @param {array} questions + * @param {object} level + * @param {object} bounds + * @returns {string} + */ function runLevel(questions, level, bounds) { currentTeam = []; lastShot = 0; @@ -340,10 +392,18 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n return questions[level].question; } + /** + * Helper function to place text on screen + * @param {object} context + * @param {object} displayRect + * @param {objectc} objects + * @param {object} particles + * @param {string} question + */ function draw(context, displayRect, objects, particles, question) { context.clearRect(0, 0, displayRect.width, displayRect.height); - - for (var i = 0; i < particles.length; i++) { + var i = 0; + for (i = 0; i < particles.length; i++) { particles[i].draw(context); } @@ -373,8 +433,15 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function main game logic: process movements and behaviours of game objects + * @param {object} bounds + * @param {object} objects + * @param {object} particles + */ function update(bounds, objects, particles) { - for (var i = 0; i < 3; i++) { + var i = 0; + for (i = 0; i < 3; i++) { particles.push(new Star(bounds)); } for (i = 0; i < particles.length; i++) { @@ -396,26 +463,36 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } - function Rectangle(left, top, width, height) - { + /** + * Constructor for storing information about a rectangle shape + * @param {int} left + * @param {int} top + * @param {int} width + * @param {int} height + */ + function Rectangle(left, top, width, height) { this.left = left || 0; this.top = top || 0; this.width = width || 0; this.height = height || 0; } - Rectangle.prototype.right = function () { + + Rectangle.prototype.right = function() { return this.left + this.width; }; - Rectangle.prototype.bottom = function () { + + Rectangle.prototype.bottom = function() { return this.top + this.height; }; - Rectangle.prototype.Contains = function (point) { + + Rectangle.prototype.Contains = function(point) { return point.x > this.left && point.x < this.right() && point.y > this.top && point.y < this.bottom(); }; - Rectangle.prototype.Intersect = function (rectangle) { + + Rectangle.prototype.Intersect = function(rectangle) { var retval = !(rectangle.left > this.right() || rectangle.right() < this.left || rectangle.top > this.bottom() || @@ -423,6 +500,12 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n return retval; }; + /** + * Generate Game Object. + * @param {text} src + * @param {int} x + * @param {int} y + */ function GameObject(src, x, y) { if (src !== null) { this.image = this.loadImage(src); @@ -435,14 +518,16 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.alive = true; this.decay = 0.7; } - GameObject.prototype.loadImage = function (src) { + + GameObject.prototype.loadImage = function(src) { if (!this.image) { this.image = new Image(); } this.image.src = src; return this.image; }; - GameObject.prototype.update = function () { + + GameObject.prototype.update = function() { this.velocity.x += this.direction.x * this.movespeed.x; this.velocity.y += this.direction.y * this.movespeed.y; this.x += this.velocity.x; @@ -450,16 +535,25 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.velocity.y *= this.decay; this.velocity.x *= this.decay; }; - GameObject.prototype.draw = function (context) { + + GameObject.prototype.draw = function(context) { context.drawImage(this.image, this.x, this.y, this.image.width, this.image.height); }; - GameObject.prototype.getRect = function () { + + GameObject.prototype.getRect = function() { return new Rectangle(this.x, this.y, this.image.width, this.image.height); }; - GameObject.prototype.die = function () { + + GameObject.prototype.die = function() { this.alive = false; }; + /** + * Constructor for Player class, all the information about the player + * @param {string} src + * @param {int} x + * @param {int} y + */ function Player(src, x, y) { GameObject.call(this, src, x, y); this.mouse = {x: 0, y: 0}; @@ -467,8 +561,9 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.lives = 3; this.lastScore = 0; } + Player.prototype = Object.create(GameObject.prototype); - Player.prototype.update = function (bounds) { + Player.prototype.update = function(bounds) { if (mouseDown || touchDown) { if (this.x < this.mouse.x - (this.image.width)) { player.direction.x = 1; @@ -497,11 +592,13 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.y = bounds.height - this.image.height; } }; - Player.prototype.Shoot = function () { + + Player.prototype.Shoot = function() { playSound("laser"); gameObjects.unshift(new Laser(player.x, player.y, true, 24)); canShoot = false; }; + Player.prototype.die = function() { GameObject.prototype.die.call(this); playSound("explosion"); @@ -509,8 +606,8 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.lastScore = score; endGame(); }; - Player.prototype.gotShot = function(shot) - { + + Player.prototype.gotShot = function(shot) { if (shot.alive) { if (this.lives <= 1) { this.die(); @@ -521,16 +618,31 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } }; + /** + * Constructor for Planet (background objects) extends GameObject + * @param {string} src + * @param {int} x + * @param {int} y + */ function Planet(src, x, y) { GameObject.call(this, src, x, y); } + Planet.prototype = Object.create(GameObject.prototype); - Planet.prototype.update = function (bounds) { + Planet.prototype.update = function(bounds) { planet.image.width = displayRect.width; planet.image.height = displayRect.height; GameObject.prototype.update.call(this, bounds); }; + /** + * Constructor for enemy craft (answers) extends GameObject + * @param {string} src + * @param {int} x + * @param {int} y + * @param {string} text + * @param {float} fraction + */ function Enemy(src, x, y, text, fraction) { GameObject.call(this, src, x, y); this.xspeed = enemySpeed; @@ -545,8 +657,10 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.shotClock = (1 + Math.random()) * this.shotFrequency; this.level = level; } + Enemy.prototype = Object.create(GameObject.prototype); - Enemy.prototype.update = function (bounds) { + + Enemy.prototype.update = function(bounds) { if (this.y < bounds.height / 10 || this.y > bounds.height * 9 / 10) { this.movespeed.x = this.xspeed * 1; @@ -593,7 +707,8 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n shipReachedEnd.call(this); } }; - Enemy.prototype.draw = function (context) { + + Enemy.prototype.draw = function(context) { GameObject.prototype.draw.call(this, context); context.fillStyle = '#FFFFFF'; @@ -602,6 +717,7 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n wrapText(context, this.text, true, 17, displayRect.width * 0.2, this.x + this.image.width / 2, this.y - 5); }; + Enemy.prototype.die = function() { GameObject.prototype.die.call(this); spray(this.x + this.image.width, this.y + this.image.height, 50 + (this.fraction * 150), "#FF0000"); @@ -612,14 +728,18 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n // Kill off the ship. playSound("explosion"); }; + Enemy.prototype.gotShot = function(shot) { // Default behaviour, to be overridden. shot.die(); this.die(); }; + /** + * Helper function to remove any stray ships on level advance + */ function killAllAlive() { - currentTeam.forEach(function (enemy) { + currentTeam.forEach(function(enemy) { if (enemy.alive) { // Make the fraction 0 so it won't count as anything and make a new level. enemy.fraction = 0; @@ -629,10 +749,19 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n currentTeam = []; } + /** + * Helper function for True/False questions + * @param {int} x + * @param {int} y + * @param {string} text + * @param {float} fraction + */ function TFEnemy(x, y, text, fraction) { Enemy.call(this, "pix/enemy.png", x, y, text, fraction); } + TFEnemy.prototype = Object.create(Enemy.prototype); + TFEnemy.prototype.die = function() { // TrueFalse questions are very simple, if either of the ships die, Enemy.prototype.die will handle // the score adding of 1000 or 0, and then this will kill the other remaining ship. @@ -644,6 +773,7 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n nextLevel(); } }; + TFEnemy.prototype.gotShot = function(shot) { if (this.fraction > 0) { shot.die(); @@ -654,11 +784,21 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } }; + /** + * Helper function for multiple choice questions (MCQ) + * @param {int} x + * @param {int} y + * @param {string} text + * @param {float} fraction + * @param {boolean} single + */ function MultiEnemy(x, y, text, fraction, single) { Enemy.call(this, "pix/enemy.png", x, y, text, fraction); this.single = single; } + MultiEnemy.prototype = Object.create(Enemy.prototype); + MultiEnemy.prototype.die = function() { Enemy.prototype.die.call(this); if (this.fraction > 0) { @@ -669,6 +809,7 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n nextLevel(); } }; + MultiEnemy.prototype.gotShot = function(shot) { if (this.fraction >= 1 || (this.fraction > 0 && !this.single)) { shot.die(); @@ -679,6 +820,15 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } }; + /** + * Helper function for matching questions + * @param {int} x + * @param {int} y + * @param {string} text + * @param {float} fraction + * @param {int} pairid + * @param {boolean} stem + */ function MatchEnemy(x, y, text, fraction, pairid, stem) { this.stem = stem ? true : false; if (this.stem) { @@ -690,13 +840,16 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.shotFrequency = 160; this.hightlighted = false; } + MatchEnemy.prototype = Object.create(Enemy.prototype); + MatchEnemy.prototype.die = function() { currentPointsLeft -= this.fraction; // Sets the fraction as 0 to stop it adding to the score in #die() this.fraction = 0; Enemy.prototype.die.call(this); }; + MatchEnemy.prototype.gotShot = function(shot) { if (shot.alive && this.alive) { if (lastShot == -this.pairid) { @@ -731,6 +884,7 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } }; + MatchEnemy.prototype.hightlight = function() { currentTeam.forEach(function(match) { match.unhightlight(); @@ -742,6 +896,7 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } this.hightlighted = true; }; + MatchEnemy.prototype.unhightlight = function() { if (this.hightlighted) { if (this.stem) { @@ -753,6 +908,13 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.hightlighted = false; }; + /** + * Constructor for laser shots extends GameObject + * @param {int} x + * @param {int} y + * @param {bool} friendly + * @param {float} laserSpeed + */ function Laser(x, y, friendly, laserSpeed) { GameObject.call(this, friendly ? "pix/laser.png" : "pix/enemylaser.png", x, y); this.direction.y = -1; @@ -760,7 +922,8 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.laserSpeed = laserSpeed || 12; } Laser.prototype = Object.create(GameObject.prototype); - Laser.prototype.update = function (bounds) { + + Laser.prototype.update = function(bounds) { GameObject.prototype.update.call(this, bounds); if (this.x < bounds.x - this.image.width || this.x > bounds.width || @@ -770,13 +933,21 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } this.velocity.y = this.laserSpeed * this.direction.y; }; - Laser.prototype.deflect = function () { + + Laser.prototype.deflect = function() { this.image = this.loadImage("pix/enemylaser.png"); this.direction.y *= -1; this.friendly = !this.friendly; playSound("deflect"); }; + /** + * Constructor for explosion particle effects extends GameObject + * @param {int} x + * @param {int} y + * @param {float} velocity + * @param {string} colour + */ function Particle(x, y, velocity, colour) { GameObject.call(this, null, x, y); this.width = 2; @@ -787,8 +958,10 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.colour = colour; this.decay = 1; } + Particle.prototype = Object.create(GameObject.prototype); - Particle.prototype.update = function (bounds) { + + Particle.prototype.update = function(bounds) { GameObject.prototype.update.call(this, bounds); if (this.x < bounds.x - this.width || this.x > bounds.width || @@ -801,15 +974,21 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.alive = false; } }; - Particle.prototype.getRect = function () { + + Particle.prototype.getRect = function() { return new Rectangle(this.x, this.y, this.width, this.height); }; - Particle.prototype.draw = function (context) { + + Particle.prototype.draw = function(context) { context.fillStyle = this.colour; context.fillRect(this.x, this.y, this.width, this.height); context.stroke(); }; + /** + * Helper function for background stars extends GameObject + * @param {object} bounds + */ function Star(bounds) { GameObject.call(this, null, Math.random() * bounds.width, 0); this.width = 2; @@ -819,23 +998,37 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n this.aliveTime = 0; } Star.prototype = Object.create(GameObject.prototype); - Star.prototype.update = function (bounds) { + + Star.prototype.update = function(bounds) { GameObject.prototype.update.call(this, bounds); if (this.y > bounds.height) { this.alive = false; } }; - Star.prototype.draw = function (context) { + + Star.prototype.draw = function(context) { context.fillStyle = '#9999AA'; context.fillRect(this.x, this.y, this.width, this.height); context.stroke(); }; + /** + * Helper function to handle collisions between gameobjects. + * @param {object} object1 + * @param {object} object2 + * @return {boolean} + */ function collide(object1, object2) { - return object1.alive && object2.alive && (collide_ordered(object1, object2) || collide_ordered(object2, object1)); + return object1.alive && object2.alive && (collideOrdered(object1, object2) || collideOrdered(object2, object1)); } - function collide_ordered(object1, object2) { + /** + * Helper funcction to handle collisions. + * @param {object} object1 + * @param {object} object2 + * @returns {boolean} + */ + function collideOrdered(object1, object2) { if (object1 instanceof Laser && object2 instanceof Player) { if (!object1.friendly && objectsIntersect(object1, object2)) { object2.gotShot(object1); @@ -855,20 +1048,44 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n return true; } } + return false; } + /** + * Helper function to handle intersections between GameObjects. + * @param {object} object1 + * @param {object} object2 + * @return {boolean} + */ function objectsIntersect(object1, object2) { var rect1 = object1.getRect(); var rect2 = object2.getRect(); return rect1.Intersect(rect2); } + /** + * Helper function for spraying particle (explosion) effects + * @param {int} x + * @param {int} y + * @param {int} num + * @param {string} colour + */ function spray(x, y, num, colour) { for (var i = 0; i < num; i++) { particles.push(new Particle(x, y, {x: (Math.random() - 0.5) * 16, y: ((Math.random() - 0.5) * 16) + 3}, colour)); } } + /** + * Helper function to display answers. + * @param {object} context + * @param {string} input + * @param {bool} wrapUpwards + * @param {int} textHeight + * @param {int} maxLineWidth + * @param {int} x + * @param {int} y + */ function wrapText(context, input, wrapUpwards, textHeight, maxLineWidth, x, y) { var drawLines = []; var originalY = y; @@ -913,8 +1130,13 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n }); } + /** + * Helper function for end of level. + */ function shipReachedEnd() { - var amountLeft = currentTeam.filter(function (enemy) { return enemy.alive; }).length; + var amountLeft = currentTeam.filter(function(enemy) { + return enemy.alive; + }).length; if (amountLeft === 0 && (currentPointsLeft < this.fraction || currentPointsLeft <= 0) && this.level === level && player.alive) { @@ -926,6 +1148,10 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n var canShoot = true; + /** + * Helper function for game menu from keyboard. + * @param {object} e + */ function menukeydown(e) { if ([32, 37, 38, 39, 40].indexOf(e.keyCode) !== -1) { e.preventDefault(); @@ -935,18 +1161,30 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function for game menu on mobile. + * @param {object} e + */ function menumousedown(e) { if (e.target === stage) { loadGame(); } } + /** + * Helper function for game menu on mobile. + * @param {object} e + */ function menutouchend(e) { if (e.target === stage) { loadGame(); } } + /** + * Helper function for keyboard movement. + * @param {object} e + */ function keydown(e) { if ([32, 37, 38, 39, 40].indexOf(e.keyCode) !== -1) { e.preventDefault(); @@ -964,6 +1202,10 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function for keyboard movement. + * @param {object} e + */ function keyup(e) { if (e.keyCode === 32) { canShoot = true; @@ -974,6 +1216,10 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function for mouse click (starts the player shooting if you clicked on them, moving if you didn't). + * @param {object} e + */ function mousedown(e) { if (e.target === stage) { var playerWasClicked = player.getRect().Contains({x: e.offsetX, y: e.offsetY}); @@ -988,25 +1234,41 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function for mouse release. (stops the Player) for mouse mode + * @param {object} e + */ function mouseup() { player.direction.x = 0; player.direction.y = 0; mouseDown = false; } + /** + * Helper function for mouse movement. + * @param {object} e + */ function mousemove(e) { player.mouse.x = e.offsetX; player.mouse.y = e.offsetY; } + /** + * Helper function for cancelled event. + * @param {object} event + */ function cancelled(event) { if (event.target === stage) { event.preventDefault(); } } + /** + * Helper function for movement on mobile touch devices. + * @param {object} e + */ function touchstart(e) { - if (e.target === stage ) { + if (e.target === stage) { if (player.alive && e.touches.length > 1) { player.Shoot(); } else { @@ -1018,6 +1280,10 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function for movement on mobile touch devices. + * @param {object} e + */ function touchend(e) { if (e.touches.length === 0) { touchDown = false; @@ -1030,10 +1296,13 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } - + /** + * Helper function for movement on mobile touch devices. + * @param {object} e + */ function touchmove(e) { var rect = e.target.getBoundingClientRect(); - // Required for getting the stage's relative touch position, due to a previous significant offset + // Required for getting the stage's relative touch position, due to a previous significant offset. var x = e.touches[0].pageX - rect.left; var y = e.touches[0].clientY - rect.top; @@ -1046,8 +1315,15 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n } } + /** + * Helper function to shuffle levels. + * @param {array} array + * @return {array} + */ function shuffle(array) { - var currentIndex = array.length, temporaryValue, randomIndex; + var currentIndex = array.length; + var temporaryValue; + var randomIndex; while (0 !== currentIndex) { @@ -1062,6 +1338,11 @@ define(['jquery','core/yui', 'core/notification', 'core/ajax'], function($, Y, n return array; } + /** + * Initialization of the game. + * @param {array} q + * @param {array} qid + */ function doInitialize(q, qid) { questions = q; quizgame = qid; diff --git a/backup/moodle2/backup_quizgame_activity_task.class.php b/backup/moodle2/backup_quizgame_activity_task.class.php index 3c5ec28..cef7988 100644 --- a/backup/moodle2/backup_quizgame_activity_task.class.php +++ b/backup/moodle2/backup_quizgame_activity_task.class.php @@ -57,7 +57,7 @@ protected function define_my_steps() { * @param string $content * @return string */ - static public function encode_content_links($content) { + public static function encode_content_links($content) { global $CFG; $base = preg_quote($CFG->wwwroot, "/"); diff --git a/backup/moodle2/restore_quizgame_activity_task.class.php b/backup/moodle2/restore_quizgame_activity_task.class.php index 25babd9..83fa635 100644 --- a/backup/moodle2/restore_quizgame_activity_task.class.php +++ b/backup/moodle2/restore_quizgame_activity_task.class.php @@ -55,7 +55,7 @@ protected function define_my_steps() { * Define the contents in the activity that must be * processed by the link decoder */ - static public function define_decode_contents() { + public static function define_decode_contents() { $contents = array(); $contents[] = new restore_decode_content('quizgame', array('intro'), 'quizgame'); @@ -67,7 +67,7 @@ static public function define_decode_contents() { * Define the decoding rules for links belonging * to the activity to be executed by the link decoder */ - static public function define_decode_rules() { + public static function define_decode_rules() { $rules = array(); $rules[] = new restore_decode_rule('QUIZVENTUREVIEWBYID', '/mod/quizgame/view.php?id=$1', 'course_module'); @@ -79,15 +79,15 @@ static public function define_decode_rules() { /** * Define the restore log rules that will be applied - * by the {@link restore_logs_processor} when restoring + * by the {link restore_logs_processor} when restoring * course logs. It must return one array - * of {@link restore_log_rule} objects + * of {link restore_log_rule} objects * * Note this rules are applied when restoring course logs * by the restore final task, but are defined here at * activity level. All them are rules not linked to any module instance (cmid = 0) */ - static public function define_restore_log_rules_for_course() { + public static function define_restore_log_rules_for_course() { $rules = array(); // Fix old wrong uses (missing extension). diff --git a/classes/completion/custom_completion.php b/classes/completion/custom_completion.php new file mode 100644 index 0000000..6fa5d17 --- /dev/null +++ b/classes/completion/custom_completion.php @@ -0,0 +1,93 @@ +. + +declare(strict_types=1); + +namespace mod_quizgame\completion; + +use core_completion\activity_custom_completion; + +/** + * Activity custom completion subclass for the quizgame activity. + * + * Class for defining mod_quizgame's custom completion rules and fetching the completion statuses + * of the custom completion rules for a given quizgame instance and a user. + * + * @package mod_quizgame + * @copyright 2021 Stephen Bourget + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_completion extends activity_custom_completion { + + /** + * Fetches the completion state for a given completion rule. + * + * @param string $rule The completion rule. + * @return int The completion state. + */ + public function get_state(string $rule): int { + global $DB; + + $this->validate_rule($rule); + + $quizgameid = $this->cm->instance; + $userid = $this->userid; + $completionscore = $this->cm->customdata['customcompletionrules']['completionscore']; + + $where = ' quizgameid = :quizgameid AND userid = :userid AND score >= :score'; + $params = array( + 'quizgameid' => $quizgameid, + 'userid' => $userid, + 'score' => $completionscore, + ); + $highscore = $DB->count_records_select('quizgame_scores', $where, $params) > 0; + + return ($highscore >= 1) ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE; + } + + /** + * Fetch the list of custom completion rules that this module defines. + * + * @return array + */ + public static function get_defined_custom_rules(): array { + return ['completionscore']; + } + + /** + * Returns an associative array of the descriptions of custom completion rules. + * + * @return array + */ + public function get_custom_rule_descriptions(): array { + $completionhighscore = $this->cm->customdata['customcompletionrules']['completionscore'] ?? 0; + return [ + 'completionscore' => get_string('completiondetail:score', 'quizgame', $completionhighscore), + ]; + } + + /** + * Returns an array of all completion rules, in the order they should be displayed to users. + * + * @return array + */ + public function get_sort_order(): array { + return [ + 'completionview', + 'completionscore', + ]; + } +} diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index dbc2b11..431579c 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -26,8 +26,10 @@ use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\deletion_criteria; +use core_privacy\local\request\userlist; use core_privacy\local\request\helper; use core_privacy\local\request\writer; @@ -42,7 +44,8 @@ class provider implements // This plugin stores personal data. \core_privacy\local\metadata\provider, - + // This plugin is capable of determining which users have data within it. + \core_privacy\local\request\core_userlist_provider, // This plugin is a core_user_data_provider. \core_privacy\local\request\plugin\provider { /** @@ -93,6 +96,73 @@ public static function get_contexts_for_userid(int $userid) : contextlist { return $contextlist; } + /** + * Get the list of users who have data within a context. + * + * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination. + * + */ + public static function get_users_in_context(userlist $userlist) { + $context = $userlist->get_context(); + + if (!is_a($context, \context_module::class)) { + return; + } + + // Find users with quizgame scores. + $sql = "SELECT qs.userid + FROM {context} c + JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel + JOIN {modules} m ON m.id = cm.module AND m.name = :modname + JOIN {quizgame} q ON q.id = cm.instance + JOIN {quizgame_scores} qs ON qs.quizgameid = q.id + WHERE c.id = :contextid"; + + $params = [ + 'contextid' => $context->id, + 'contextlevel' => CONTEXT_MODULE, + 'modname' => 'quizgame', + ]; + + $userlist->add_from_sql('userid', $sql, $params); + } + + /** + * Delete multiple users within a single context. + * + * @param approved_userlist $userlist The approved context and user information to delete information for. + */ + public static function delete_data_for_users(approved_userlist $userlist) { + global $DB; + + $context = $userlist->get_context(); + $userids = $userlist->get_userids(); + $instanceid = $DB->get_field('course_modules', 'instance', ['id' => $context->instanceid], MUST_EXIST); + list($userinsql, $userinparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED); + + $quizgamescoreswhere = "quizgameid = :instanceid AND userid {$userinsql}"; + $userinstanceparams = $userinparams + ['instanceid' => $instanceid]; + + $scoresobject = $DB->get_recordset_select('quizgame_scores', $quizgamescoreswhere, $userinstanceparams, 'id', 'id'); + $scores = []; + + foreach ($scoresobject as $score) { + $scores[] = $score->id; + } + + $scoresobject->close(); + + if (!$scores) { + return; + } + + list($insql, $inparams) = $DB->get_in_or_equal($scores, SQL_PARAMS_NAMED); + + // Now delete all user related scores. + $deletewhere = "quizgameid = :instanceid AND userid {$userinsql}"; + $DB->delete_records_select('quizgame_scores', $deletewhere, $userinstanceparams); + } + /** * Export personal data for the given approved_contextlist. User and context information is contained within the contextlist. * diff --git a/classes/table_scores.php b/classes/table_scores.php index 73915be..0a8b2a9 100644 --- a/classes/table_scores.php +++ b/classes/table_scores.php @@ -86,7 +86,9 @@ public function col_userid($record) { // If we reach that point new users logs have been generated since the last users db query. list($usql, $uparams) = $DB->get_in_or_equal($record->userid); - $sql = "SELECT id," . get_all_user_name_fields(true) . " FROM {user} WHERE id " . $usql; + $userfieldsapi = \core_user\fields::for_name(); + $allusernames = $userfieldsapi->get_sql('', false, '', '', false)->selects; + $sql = "SELECT id," . $allusernames . " FROM {user} WHERE id " . $usql; if (!$user = $DB->get_records_sql($sql, $uparams)) { // This should never happen. return 'UNKNOWN'; diff --git a/lang/en/quizgame.php b/lang/en/quizgame.php index ad5440e..c970104 100644 --- a/lang/en/quizgame.php +++ b/lang/en/quizgame.php @@ -30,7 +30,9 @@ $string['achievedhighscoreof'] = 'Achieved a high score of {$a}'; $string['attempt'] = 'Attempt #{$a}'; +$string['completiondetail:score'] = 'Get a minimum score of {$a}'; $string['completionscore'] = 'Student must achieve a minimum score of:'; +$string['completionscoredesc'] = 'Student must achieve a minimum score of: {$a}'; $string['completionscoregroup'] = 'Require score'; $string['completionscoregroup_help'] = 'If enabled, you can require a minimum score is met before the activity is marked as complete. @@ -49,6 +51,7 @@ Press the spacebar or click the mouse button to shoot, or tap with two fingers anywhere on the game. Clear as many questions as possible by shooting the correct answer. Good Luck!'; +$string['invalidcmorid'] = 'Error: You must specify a course_module ID or an instance ID'; $string['modulename_help'] = 'Students procastinating too much? Are they playing games instead of studying? Well now you can motivate them by allowing them to do both at once! Quizventure is an activity module that loads quiz questions from the course it\'s added to. The possible answers come down as space ships and you have to shoot the correct one. diff --git a/lib.php b/lib.php index 615277e..a8425c8 100644 --- a/lib.php +++ b/lib.php @@ -248,7 +248,7 @@ function quizgame_print_recent_activity($course, $viewfullnames, $timestart) { * * This callback function is supposed to populate the passed array with * custom activity records. These records are then rendered into HTML via - * {@link quizgame_print_recent_mod_activity()}. + * {quizgame_print_recent_mod_activity()}. * * @param array $activities sequentially indexed array of objects with the 'cmid' property * @param int $index the index in the $activities to use for the next record @@ -383,7 +383,7 @@ function quizgame_update_grades(stdClass $quizgame, $userid = 0) { * Returns the lists of all browsable file areas within the given module context * * The file area 'intro' for the activity introduction field is added automatically - * by {@link file_browser::get_file_info_context_module()} + * by {file_browser::get_file_info_context_module()} * * @param stdClass $course * @param stdClass $cm @@ -462,8 +462,8 @@ function quizgame_extend_navigation(navigation_node $navref, stdclass $course, s * This function is called when the context for the page is a quizgame module. This is not called by AJAX * so it is safe to rely on the $PAGE. * - * @param settings_navigation $settingsnav {@link settings_navigation} - * @param navigation_node $quizgamenode {@link navigation_node} + * @param settings_navigation $settingsnav {settings_navigation} + * @param navigation_node $quizgamenode {navigation_node} */ function quizgame_extend_settings_navigation(settings_navigation $settingsnav, navigation_node $quizgamenode=null) { } @@ -570,3 +570,67 @@ function mod_quizgame_core_calendar_provide_event_action(calendar_event $event, true ); } + +/** + * Callback which returns human-readable strings describing the active completion custom rules for the module instance. + * + * @param object $cm the cm_info object. + * @return array $descriptions the array of descriptions for the custom rules. + */ +function mod_quizgame_get_completion_active_rule_descriptions($cm) { + // Values will be present in cm_info, and we assume these are up to date. + if (!$cm instanceof cm_info || !isset($cm->customdata['customcompletionrules']) + || $cm->completion != COMPLETION_TRACKING_AUTOMATIC) { + return []; + } + + $descriptions = []; + foreach ($cm->customdata['customcompletionrules'] as $key => $val) { + switch ($key) { + case 'completionscore': + if (!empty($val)) { + $descriptions[] = get_string('completionscoredesc', 'quizgame', $val); + } + break; + default: + break; + } + } + return $descriptions; +} + +/** + * Add a get_coursemodule_info function in case any pcast type wants to add 'extra' information + * for the course (see resource). + * + * Given a course_module object, this function returns any "extra" information that may be needed + * when printing this activity in a course listing. See get_array_of_activities() in course/lib.php. + * + * @param stdClass $coursemodule The coursemodule object (record). + * @return cached_cm_info An object on information that the courses + * will know about (most noticeably, an icon). + */ +function quizgame_get_coursemodule_info($coursemodule) { + global $DB; + + $dbparams = ['id' => $coursemodule->instance]; + $fields = 'id, name, intro, introformat, completionscore'; + if (!$quizgame = $DB->get_record('quizgame', $dbparams, $fields)) { + return false; + } + + $result = new cached_cm_info(); + $result->name = $quizgame->name; + + if ($coursemodule->showdescription) { + // Convert intro to html. Do not filter cached version, filters run at display time. + $result->content = format_module_intro('quizgame', $quizgame, $coursemodule->id, false); + } + + // Populate the custom completion rules as key => value pairs, but only if the completion mode is 'automatic'. + if ($coursemodule->completion == COMPLETION_TRACKING_AUTOMATIC) { + $result->customdata['customcompletionrules']['completionscore'] = $quizgame->completionscore; + } + + return $result; +} diff --git a/tests/behat/quizgame_add.feature b/tests/behat/quizgame_add.feature index 75d2fb7..d0aa9a3 100644 --- a/tests/behat/quizgame_add.feature +++ b/tests/behat/quizgame_add.feature @@ -23,10 +23,10 @@ Feature: Teachers can create a quizgame activity for students to review content | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | + @javascript Scenario: Create the activity. Given I log in as "teacher1" - And I am on "Course 1" course homepage - And I turn editing mode on + And I am on "Course 1" course homepage with editing mode on When I add a "Quizventure" to section "1" and I fill the form with: | Quizventure name | Test quizventure name | | Description | Test quizventure description | diff --git a/tests/custom_completion_test.php b/tests/custom_completion_test.php new file mode 100644 index 0000000..096ed02 --- /dev/null +++ b/tests/custom_completion_test.php @@ -0,0 +1,217 @@ +. + +/** + * Contains unit tests for core_completion/activity_custom_completion. + * + * @package mod_quizgame + * @copyright 2021 Stephen Bourget + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace mod_quizgame; + +use advanced_testcase; +use cm_info; +use coding_exception; +use mod_quizgame\completion\custom_completion; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/completionlib.php'); + +/** + * Class for unit testing mod_quizgame/activity_custom_completion. + * + * @package mod_quizgame + * @copyright 2021 Stephen Bourget + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_completion_test extends advanced_testcase { + + /** + * Data provider for get_state(). + * + * @return array[] + */ + public function get_state_provider(): array { + return [ + 'Undefined rule' => [ + 'somenonexistentrule', COMPLETION_DISABLED, 0, null, coding_exception::class + ], + 'Rule not available' => [ + 'completionscore', COMPLETION_DISABLED, 0, null, moodle_exception::class + ], + 'Rule available, user has not finished' => [ + 'completionscore', COMPLETION_ENABLED, 0, COMPLETION_INCOMPLETE, null + ], + 'Rule available, user has finished' => [ + 'completionscore', COMPLETION_ENABLED, 1, COMPLETION_COMPLETE, null + ], + ]; + } + + /** + * Test for get_state(). + * + * @dataProvider get_state_provider + * @param string $rule The custom completion rule. + * @param int $available Whether this rule is available. + * @param int $highscorecount The number of runs exceeding the high score. + * @param int|null $status Expected status. + * @param string|null $exception Expected exception. + */ + public function test_get_state(string $rule, int $available, int $highscorecount, ?int $status, ?string $exception) { + global $DB; + + if (!is_null($exception)) { + $this->expectException($exception); + } + + // Custom completion rule data for cm_info::customdata. + $customdataval = [ + 'customcompletionrules' => [ + $rule => $available + ] + ]; + + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + + // Mock the return of the magic getter method when fetching the cm_info object's customdata and instance values. + $mockcminfo->expects($this->any()) + ->method('__get') + ->will($this->returnValueMap([ + ['customdata', $customdataval], + ['instance', 1], + ])); + + // Mock the DB calls. + $DB = $this->createMock(get_class($DB)); + $DB->expects($this->atMost(2)) + ->method('count_records_select') + ->willReturn($highscorecount); + + $customcompletion = new custom_completion($mockcminfo, 2); + $this->assertEquals($status, $customcompletion->get_state($rule)); + } + + /** + * Test for get_defined_custom_rules(). + */ + public function test_get_defined_custom_rules() { + $rules = custom_completion::get_defined_custom_rules(); + $this->assertCount(1, $rules); + $this->assertEquals('completionscore', $rules[0]); + } + + /** + * Test for get_defined_custom_rule_descriptions(). + */ + public function test_get_custom_rule_descriptions() { + // Get defined custom rules. + $rules = custom_completion::get_defined_custom_rules(); + + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + + // Instantiate a custom_completion object using the mocked cm_info. + $customcompletion = new custom_completion($mockcminfo, 1); + + // Get custom rule descriptions. + $ruledescriptions = $customcompletion->get_custom_rule_descriptions(); + + // Confirm that defined rules and rule descriptions are consistent with each other. + $this->assertEquals(count($rules), count($ruledescriptions)); + foreach ($rules as $rule) { + $this->assertArrayHasKey($rule, $ruledescriptions); + } + } + + /** + * Test for is_defined(). + */ + public function test_is_defined() { + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->getMock(); + + $customcompletion = new custom_completion($mockcminfo, 1); + + // Rule is defined. + $this->assertTrue($customcompletion->is_defined('completionscore')); + + // Undefined rule. + $this->assertFalse($customcompletion->is_defined('somerandomrule')); + } + + /** + * Data provider for test_get_available_custom_rules(). + * + * @return array[] + */ + public function get_available_custom_rules_provider(): array { + return [ + 'Completion submit available' => [ + COMPLETION_ENABLED, ['completionscore'] + ], + 'Completion submit not available' => [ + COMPLETION_DISABLED, [] + ], + ]; + } + + /** + * Test for get_available_custom_rules(). + * + * @dataProvider get_available_custom_rules_provider + * @param int $status + * @param array $expected + */ + public function test_get_available_custom_rules(int $status, array $expected) { + $customdataval = [ + 'customcompletionrules' => [ + 'completionscore' => $status + ] + ]; + + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + + // Mock the return of magic getter for the customdata attribute. + $mockcminfo->expects($this->any()) + ->method('__get') + ->with('customdata') + ->willReturn($customdataval); + + $customcompletion = new custom_completion($mockcminfo, 1); + $this->assertEquals($expected, $customcompletion->get_available_custom_rules()); + } +} diff --git a/tests/events_test.php b/tests/events_test.php index 94d6e74..f8f16de 100644 --- a/tests/events_test.php +++ b/tests/events_test.php @@ -41,7 +41,7 @@ class mod_quizgame_event_testcase extends advanced_testcase { /** * Test setup. */ - public function setUp() { + public function setUp(): void { $this->resetAfterTest(); } diff --git a/tests/privacy_provider_test.php b/tests/privacy_provider_test.php index 355b0ad..0485bad 100644 --- a/tests/privacy_provider_test.php +++ b/tests/privacy_provider_test.php @@ -48,7 +48,7 @@ class mod_quizgame_privacy_provider_testcase extends \core_privacy\tests\provide /** * {@inheritdoc} */ - protected function setUp() { + protected function setUp(): void { $this->resetAfterTest(); global $DB; diff --git a/version.php b/version.php index 3ec663e..2011468 100644 --- a/version.php +++ b/version.php @@ -28,10 +28,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2018062004; // If version == 0 then module will not be installed. - -$plugin->requires = 2014051200.00; // Requires this Moodle version (2.7) -$plugin->cron = 0; // Period for cron to check this module (secs). +$plugin->version = 2021042700; // If version == 0 then module will not be installed. +$plugin->requires = 2021042300; // Requires Moodle version 3.11 or later. +$plugin->cron = 0; // Period for cron to check this module in seconds. $plugin->component = 'mod_quizgame'; // To check on upgrade, that module sits in correct place. -$plugin->maturity = MATURITY_ALPHA; -$plugin->release = 'v3.7-r1'; +$plugin->maturity = MATURITY_STABLE; +$plugin->release = 'v3.11-r1'; diff --git a/view.php b/view.php index 557fad3..f055f0f 100644 --- a/view.php +++ b/view.php @@ -41,9 +41,10 @@ $course = $DB->get_record('course', array('id' => $quizgame->course), '*', MUST_EXIST); $cm = get_coursemodule_from_instance('quizgame', $quizgame->id, $course->id, false, MUST_EXIST); } else { - error('You must specify a course_module ID or an instance ID'); + throw new moodle_exception('invalidcmorid', 'quizgame'); } +$cm = cm_info::create($cm); require_login($course, true, $cm); $context = context_module::instance($cm->id); @@ -81,6 +82,11 @@ // Output header and directions. echo $OUTPUT->heading_with_help(get_string('modulename', 'mod_quizgame'), 'howtoplay', 'mod_quizgame'); +// Render the activity information. +$completiondetails = \core_completion\cm_completion_details::get_instance($cm, $USER->id); +$activitydates = \core\activity_dates::get_dates_for_module($cm, $USER->id); +echo $OUTPUT->activity_information($cm, $completiondetails, $activitydates); + // Game here. echo ""; echo $renderer->render_game($quizgame, $context);