├── README.md ├── app ├── .gitignore ├── Jakefile ├── Makefile ├── README.md ├── audio │ ├── achievement.mp3 │ ├── achievement.ogg │ ├── achievement.wav │ ├── click.mp3 │ ├── click.ogg │ ├── click.wav │ ├── coin-1.mp3 │ ├── coin-1.ogg │ ├── coin-1.wav │ ├── coin-2.mp3 │ ├── coin-2.ogg │ ├── coin-2.wav │ ├── coin-3.mp3 │ ├── coin-3.ogg │ ├── coin-3.wav │ ├── defeat.mp3 │ ├── defeat.ogg │ ├── defeat.wav │ ├── end.mp3 │ ├── end.ogg │ ├── end.wav │ ├── flawless-victory.mp3 │ ├── flawless-victory.ogg │ ├── flawless-victory.wav │ ├── guess-2.mp3 │ ├── guess-2.ogg │ ├── guess-2.wav │ ├── guess.mp3 │ ├── guess.ogg │ ├── guess.wav │ ├── miss-2.mp3 │ ├── miss-2.ogg │ ├── miss-2.wav │ ├── miss.mp3 │ ├── miss.ogg │ ├── miss.wav │ ├── power-commit.mp3 │ ├── power-commit.ogg │ ├── power-commit.wav │ ├── power-half.mp3 │ ├── power-half.ogg │ ├── power-half.wav │ ├── power-repo.mp3 │ ├── power-repo.ogg │ ├── power-repo.wav │ ├── power-time.mp3 │ ├── power-time.ogg │ ├── power-time.wav │ ├── timer-beep-2.mp3 │ ├── timer-beep-2.ogg │ ├── timer-beep-2.wav │ ├── timer-beep.mp3 │ ├── timer-beep.ogg │ ├── timer-beep.wav │ ├── timer-tick.mp3 │ ├── timer-tick.ogg │ ├── timer-tick.wav │ ├── victory.mp3 │ ├── victory.ogg │ └── victory.wav ├── component.json ├── img │ ├── favicon.png │ └── type.png ├── index.html ├── lib │ ├── audio │ │ ├── audio.css │ │ ├── audio.js │ │ └── component.json │ ├── boot │ │ ├── boot.css │ │ ├── component.json │ │ ├── game.js │ │ ├── index.js │ │ └── router.js │ ├── commit-display │ │ ├── commit-display.css │ │ ├── commit-display.js │ │ ├── component.json │ │ └── template.html │ ├── finish-screen │ │ ├── component.json │ │ ├── finish-screen.css │ │ ├── finish-screen.js │ │ ├── header-template.html │ │ └── template.html │ ├── hearts │ │ ├── component.json │ │ ├── hearts.css │ │ ├── hearts.js │ │ └── template.html │ ├── level-hub │ │ ├── component.json │ │ ├── level-hub.css │ │ ├── level-hub.js │ │ ├── level-template.html │ │ └── template.html │ ├── level-stats │ │ ├── component.json │ │ ├── level-stats.css │ │ ├── level-stats.js │ │ └── template.html │ ├── models │ │ ├── campaign.js │ │ ├── commit.js │ │ ├── component.json │ │ ├── level.js │ │ ├── models.js │ │ ├── plugins.js │ │ ├── power.js │ │ ├── repo.js │ │ ├── round.js │ │ ├── shuffle.js │ │ ├── user-level-progress.js │ │ └── user.js │ ├── power-list │ │ ├── component.json │ │ ├── power-list.css │ │ ├── power-list.js │ │ └── template.html │ ├── repo-list │ │ ├── component.json │ │ ├── repo-list.css │ │ ├── repo-list.js │ │ └── template.html │ ├── score-card │ │ ├── component.json │ │ ├── score-card.css │ │ ├── score-card.js │ │ └── template.html │ ├── share-buttons │ │ ├── component.json │ │ ├── share-buttons.css │ │ └── share-buttons.js │ ├── timer │ │ ├── component.json │ │ ├── template.html │ │ ├── timer.css │ │ └── timer.js │ ├── track │ │ ├── component.json │ │ └── track.js │ └── tutorial │ │ ├── component.json │ │ ├── tutorial.css │ │ └── tutorial.js └── package.json ├── backend ├── .gitignore ├── app.wsgi ├── config.py ├── crawl.py ├── equalize.py ├── github.py ├── grade.py ├── model.py ├── schema.sql └── server.py ├── mocks ├── game-screen-1.png ├── game-screen-1.xcf ├── game-screen-2.png ├── game-screen-3.png ├── hub-with-powers.png ├── hub.png ├── intro.gif └── progress-bar-mock.png ├── screenshot.png └── todo-priorities /README.md: -------------------------------------------------------------------------------- 1 | ## GuessHub 2 | ##### GGO13 Entry by @max99x & @amasad with design help from [Haya Odeh](http://www.behance.net/hayaodeh) 3 | 4 | Given a patch (change) taken from a GitHub commit, guess which repository 5 | it comes from. 6 | 7 | ![Screenshot](screenshot.png?raw=true) 8 | 9 | ### Open Source Projects Used 10 | 11 | #### Backend 12 | * [Flask](http://flask.pocoo.org/) 13 | * [MariaDB](https://mariadb.org/) 14 | 15 | #### JavaScript 16 | * [jQuery](http://jquery.com/) 17 | * [Jake](https://github.com/mde/jake) 18 | * [d3](http://d3js.org/) 19 | * [twitter/hogan.js](http://twitter.github.io/hogan.js/) 20 | * [component](http://component.io/) 21 | * [component/model](http://component.io/component/model) 22 | * [segmentio/model-defaults](http://component.io/segmentio/model-defaults) 23 | * [component/humanize-number](http://component.io/component/humanize-number) 24 | * [component/overlay](http://component.io/component/overlay) 25 | * [component/tip](http://component.io/component/tip) 26 | * [ianstormtaylor/animate](http://github.com/ianstormtaylor/animate) 27 | * [howler.js](http://goldfirestudios.com/blog/104/howler.js-Modern-Web-Audio-Javascript-Library) 28 | * [Prism](http://prismjs.com/) 29 | 30 | #### CSS 31 | * [enricomarino/css-reset](https://github.com/enricomarino/css-reset) 32 | * [animate.css](https://github.com/ianstormtaylor/animate) 33 | 34 | #### Fonts 35 | * [Ubuntu Fonts](http://font.ubuntu.com/) 36 | * [Font-Awesome](https://github.com/FortAwesome/Font-Awesome) 37 | 38 | ### License 39 | 40 | GuessHub is licensed under [the MIT license](http://opensource.org/licenses/MIT). 41 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | components 3 | node_modules 4 | lib/*/*template.js 5 | .DS_STORE 6 | dist 7 | -------------------------------------------------------------------------------- /app/Jakefile: -------------------------------------------------------------------------------- 1 | task('Install components'); 2 | task('install', { async: true }, function () { 3 | jake.exec('component install', { printStdout: true }, function () { 4 | console.log('install success!'); 5 | complete(); 6 | }); 7 | }); 8 | 9 | var templatesGlobStr = 'lib/*/*.html'; 10 | 11 | task('Convert templates'); 12 | task('convert', { async: true }, function () { 13 | var glob = require('glob'); 14 | var cmd = 'component convert '; 15 | var cmds = glob.sync(templatesGlobStr).map(function (file) { 16 | return cmd + file; 17 | }); 18 | jake.exec(cmds, { printStdout: true }, function () { 19 | console.log('convert success!'); 20 | complete(); 21 | }); 22 | }); 23 | 24 | desc('Build components'); 25 | task('build', ['install', 'convert'], { async: true }, function () { 26 | jake.exec('component build -v --dev', { printStdout: true }, function () { 27 | console.log('build success!'); 28 | complete(); 29 | }); 30 | }); 31 | 32 | task('Clean generated stuff'); 33 | task('clean', { async: true }, function () { 34 | var glob = require('glob'); 35 | var cmd = 'rm -rf '; 36 | var cmds = [cmd + 'components']; 37 | cmds = cmds.concat(glob.sync(templatesGlobStr).map(function (file) { 38 | return cmd + file.replace('.html', '.js'); 39 | })); 40 | jake.exec(cmds, { printStdout: true }, function () { 41 | console.log('clean success!'); 42 | complete(); 43 | }) 44 | }); 45 | 46 | task('minify', { async: true }, function () { 47 | var Builder = require('component-builder'); 48 | var minify = require('component-minify'); 49 | var fs = require('fs'); 50 | var path = require('path'); 51 | var mkdir = require('mkdirp'); 52 | var rimraf = require('rimraf'); 53 | 54 | var DIST = 'dist'; 55 | rimraf.sync(DIST); 56 | 57 | var builddir = path.join(DIST, 'build'); 58 | 59 | var builder = new Builder(__dirname).use(minify); 60 | builder.copyAssetsTo(builddir); 61 | builder.build(function (err, res) { 62 | if (err) throw err; 63 | 64 | mkdir(builddir); 65 | 66 | var js = res.require + res.js; 67 | fs.writeFileSync(path.join(builddir, 'build.js'), js); 68 | fs.writeFileSync(path.join(builddir, 'build.css'), res.css); 69 | fs.writeFileSync( 70 | path.join(DIST, 'index.html'), 71 | fs.readFileSync('index.html') 72 | ); 73 | 74 | function copyAssets(dirName) { 75 | var dir = path.join(DIST, dirName); 76 | mkdir(dir); 77 | 78 | fs.readdirSync(dirName).forEach(function (f) { 79 | fs.writeFileSync( 80 | path.join(dir, f), 81 | fs.readFileSync(path.join(dirName, f)) 82 | ); 83 | }); 84 | } 85 | copyAssets('img'); 86 | copyAssets('audio'); 87 | 88 | complete(); 89 | }); 90 | }); 91 | 92 | // For this to work on Windows, patch jake as follows: 93 | // 94 | // jake/lib/watch_task.js:23 95 | // - return item == filePath; 96 | // + return item.split(/[\\\/]/).join('/') == filePath.split(/[\\\/]/).join('/'); 97 | // 98 | // jake/lib/task/task.js:162 99 | // + this._currentPrereqIndex = 0; 100 | // 101 | // jake/node_modules/utilities/lib/file.js:201 102 | // - if (inclPat.test(p) && !exclPat.test(p)) { 103 | // + if (p && inclPat.test(p) && !exclPat.test(p)) { 104 | watchTask(['build'], function () { 105 | this.watchFiles.include('lib/*/template.html'); 106 | 107 | this.watchFiles.exclude(/^build\b/); 108 | this.watchFiles.exclude(/^components\b/); 109 | this.watchFiles.exclude(/\btemplate\.js$/); 110 | }); 111 | -------------------------------------------------------------------------------- /app/Makefile: -------------------------------------------------------------------------------- 1 | SRC = $(wildcard lib/*/*.js) 2 | CSS = $(wildcard lib/*/*.css) 3 | HTML = $(wildcard lib/*/*.html) 4 | COMPONENTJSON = $(wildcard lib/*/component.json) 5 | TEMPLATES = $(HTML:.html=.js) 6 | 7 | build: components $(SRC) $(CSS) $(TEMPLATES) 8 | @echo building 9 | @component build --dev 10 | 11 | components: component.json $(COMPONENTJSON) 12 | @echo installing 13 | @component install 14 | 15 | %.js: %.html 16 | @echo converting 17 | @component convert $< 18 | 19 | clean: 20 | @echo cleaning 21 | rm -fr build components $(TEMPLATES) 22 | 23 | .PHONY: clean -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Setup 3 | 4 | Install [component](https://github.com/component) 5 | 6 | npm install component -g 7 | 8 | Run 9 | 10 | make 11 | 12 | open `index.html` in your browser. 13 | 14 | 15 | Install [watch](https://github.com/visionmedia/watch) for a nicer workflow and run 16 | 17 | watch make 18 | 19 | 20 | To build for production 21 | 22 | npm install -g jake 23 | npm install 24 | jake minify 25 | 26 | ## How does this work? 27 | 28 | Uses [component](https://github.com/component) to manage third party deps, modules, and building. 29 | 30 | ### Structure 31 | 32 | `boot` is the main app entry point, any new modules should be created `component create` and 33 | required from other modules. It uses CommonJS module pattern which is what node uses. Look at the 34 | sample apps sections in the component/component page to learn more about the file structure. 35 | 36 | ### Third party module registry 37 | 38 | component search $MODULE_NAME 39 | 40 | http://component.io/ 41 | 42 | https://github.com/component 43 | -------------------------------------------------------------------------------- /app/audio/achievement.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/achievement.mp3 -------------------------------------------------------------------------------- /app/audio/achievement.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/achievement.ogg -------------------------------------------------------------------------------- /app/audio/achievement.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/achievement.wav -------------------------------------------------------------------------------- /app/audio/click.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/click.mp3 -------------------------------------------------------------------------------- /app/audio/click.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/click.ogg -------------------------------------------------------------------------------- /app/audio/click.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/click.wav -------------------------------------------------------------------------------- /app/audio/coin-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/coin-1.mp3 -------------------------------------------------------------------------------- /app/audio/coin-1.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/coin-1.ogg -------------------------------------------------------------------------------- /app/audio/coin-1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/coin-1.wav -------------------------------------------------------------------------------- /app/audio/coin-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/coin-2.mp3 -------------------------------------------------------------------------------- /app/audio/coin-2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/coin-2.ogg -------------------------------------------------------------------------------- /app/audio/coin-2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/coin-2.wav -------------------------------------------------------------------------------- /app/audio/coin-3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/coin-3.mp3 -------------------------------------------------------------------------------- /app/audio/coin-3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/coin-3.ogg -------------------------------------------------------------------------------- /app/audio/coin-3.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/coin-3.wav -------------------------------------------------------------------------------- /app/audio/defeat.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/defeat.mp3 -------------------------------------------------------------------------------- /app/audio/defeat.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/defeat.ogg -------------------------------------------------------------------------------- /app/audio/defeat.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/defeat.wav -------------------------------------------------------------------------------- /app/audio/end.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/end.mp3 -------------------------------------------------------------------------------- /app/audio/end.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/end.ogg -------------------------------------------------------------------------------- /app/audio/end.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/end.wav -------------------------------------------------------------------------------- /app/audio/flawless-victory.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/flawless-victory.mp3 -------------------------------------------------------------------------------- /app/audio/flawless-victory.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/flawless-victory.ogg -------------------------------------------------------------------------------- /app/audio/flawless-victory.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/flawless-victory.wav -------------------------------------------------------------------------------- /app/audio/guess-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/guess-2.mp3 -------------------------------------------------------------------------------- /app/audio/guess-2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/guess-2.ogg -------------------------------------------------------------------------------- /app/audio/guess-2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/guess-2.wav -------------------------------------------------------------------------------- /app/audio/guess.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/guess.mp3 -------------------------------------------------------------------------------- /app/audio/guess.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/guess.ogg -------------------------------------------------------------------------------- /app/audio/guess.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/guess.wav -------------------------------------------------------------------------------- /app/audio/miss-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/miss-2.mp3 -------------------------------------------------------------------------------- /app/audio/miss-2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/miss-2.ogg -------------------------------------------------------------------------------- /app/audio/miss-2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/miss-2.wav -------------------------------------------------------------------------------- /app/audio/miss.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/miss.mp3 -------------------------------------------------------------------------------- /app/audio/miss.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/miss.ogg -------------------------------------------------------------------------------- /app/audio/miss.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/miss.wav -------------------------------------------------------------------------------- /app/audio/power-commit.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-commit.mp3 -------------------------------------------------------------------------------- /app/audio/power-commit.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-commit.ogg -------------------------------------------------------------------------------- /app/audio/power-commit.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-commit.wav -------------------------------------------------------------------------------- /app/audio/power-half.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-half.mp3 -------------------------------------------------------------------------------- /app/audio/power-half.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-half.ogg -------------------------------------------------------------------------------- /app/audio/power-half.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-half.wav -------------------------------------------------------------------------------- /app/audio/power-repo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-repo.mp3 -------------------------------------------------------------------------------- /app/audio/power-repo.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-repo.ogg -------------------------------------------------------------------------------- /app/audio/power-repo.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-repo.wav -------------------------------------------------------------------------------- /app/audio/power-time.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-time.mp3 -------------------------------------------------------------------------------- /app/audio/power-time.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-time.ogg -------------------------------------------------------------------------------- /app/audio/power-time.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/power-time.wav -------------------------------------------------------------------------------- /app/audio/timer-beep-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/timer-beep-2.mp3 -------------------------------------------------------------------------------- /app/audio/timer-beep-2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/timer-beep-2.ogg -------------------------------------------------------------------------------- /app/audio/timer-beep-2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/timer-beep-2.wav -------------------------------------------------------------------------------- /app/audio/timer-beep.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/timer-beep.mp3 -------------------------------------------------------------------------------- /app/audio/timer-beep.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/timer-beep.ogg -------------------------------------------------------------------------------- /app/audio/timer-beep.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/timer-beep.wav -------------------------------------------------------------------------------- /app/audio/timer-tick.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/timer-tick.mp3 -------------------------------------------------------------------------------- /app/audio/timer-tick.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/timer-tick.ogg -------------------------------------------------------------------------------- /app/audio/timer-tick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/timer-tick.wav -------------------------------------------------------------------------------- /app/audio/victory.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/victory.mp3 -------------------------------------------------------------------------------- /app/audio/victory.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/victory.ogg -------------------------------------------------------------------------------- /app/audio/victory.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/audio/victory.wav -------------------------------------------------------------------------------- /app/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guesshub", 3 | "description": "guess the commit", 4 | "version": "1.0.0", 5 | "local": [ 6 | "boot" 7 | ], 8 | "paths": [ 9 | "lib" 10 | ], 11 | "remotes": [], 12 | "dependencies": { 13 | "enricomarino/css-reset": "*", 14 | "FortAwesome/Font-Awesome": "*" 15 | } 16 | } -------------------------------------------------------------------------------- /app/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/img/favicon.png -------------------------------------------------------------------------------- /app/img/type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/app/img/type.png -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | GuessHub 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 25 |
26 |
27 |
28 |
29 |
30 | 31 |
32 | 33 | 34 | 35 | 36 |
37 | 41 | 43 | 44 | 45 | 49 |
50 |
51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/lib/audio/audio.css: -------------------------------------------------------------------------------- 1 | #audio-toggle { 2 | display: block; 3 | position: fixed; 4 | bottom: 0; 5 | left: 0; 6 | font-size: 40px; 7 | padding: 20px 20px 10px 20px; 8 | color: #777; 9 | cursor: pointer; 10 | } 11 | 12 | #audio-toggle:hover { 13 | color: #bbb; 14 | } 15 | -------------------------------------------------------------------------------- /app/lib/audio/audio.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var howlerjs = require('howler.js'); 3 | 4 | var Howl = howlerjs.Howl; 5 | var Howler = howlerjs.Howler; 6 | 7 | var AudioPlayer = {}; 8 | 9 | var FILES = { 10 | 'click': '/audio/click', 11 | 'timer-tick': '/audio/timer-tick', 12 | 'timer-beep': '/audio/timer-beep-2', 13 | 'coin-1': '/audio/coin-1', 14 | 'coin-2': '/audio/coin-2', 15 | 'coin-3': '/audio/coin-3', 16 | 'miss': '/audio/miss', 17 | 'guess': '/audio/guess', 18 | 'achievement': '/audio/achievement', 19 | 'defeat': '/audio/defeat', 20 | 'end': '/audio/end', 21 | 'victory': '/audio/victory', 22 | 'flawless-victory': '/audio/flawless-victory', 23 | 'power-time': '/audio/power-time', 24 | 'power-commit': '/audio/power-commit', 25 | 'power-half': '/audio/power-half', 26 | 'power-repo': '/audio/power-repo', 27 | 'buy-power': '/audio/guess-2', 28 | 'tutorial-tip': '/audio/click', 29 | }; 30 | 31 | var initialized = false; 32 | var effects = {}; 33 | 34 | AudioPlayer.initialize = function ($toggle) { 35 | if (!initialized) { 36 | // Note: this needs to be ran on window#load. 37 | $(window).on('load', function () { 38 | for (var effectName in FILES) { 39 | effects[effectName] = new Howl({ 40 | urls: AudioPlayer.generateUrls(FILES[effectName]) 41 | }); 42 | } 43 | 44 | // Audio toggle. 45 | $toggle.on('click', function() { 46 | if (AudioPlayer.isEnabled()) { 47 | AudioPlayer.disable(); 48 | $(this).attr({ 49 | class: 'fa fa-volume-off', 50 | title: 'Unmute' 51 | }); 52 | localStorage.setItem('audio', 'off'); 53 | } else { 54 | AudioPlayer.enable(); 55 | $(this).attr({ 56 | class: 'fa fa-volume-up', 57 | title: 'Mute' 58 | }); 59 | localStorage.setItem('audio', 'on'); 60 | } 61 | }); 62 | 63 | if (localStorage.getItem('audio') === 'off') { 64 | $toggle.click(); 65 | } 66 | 67 | initialized = true; 68 | }); 69 | } 70 | }; 71 | 72 | // Chrome and IE are fine with MP3 but FF wants OGG. 73 | // Use http://media.io/ for converting. 74 | AudioPlayer.generateUrls = function (url) { 75 | return [url + '.mp3', url + '.ogg', url + '.wav']; 76 | }; 77 | 78 | AudioPlayer.play = function (effectName, onEnd) { 79 | if (!initialized) return; 80 | 81 | var effect = effects[effectName]; 82 | if (effect) { 83 | if (onEnd) { 84 | var listener = function () { 85 | onEnd(); 86 | effect.off('end', listener); 87 | }; 88 | effect.on('end', listener); 89 | } 90 | effect.play(); 91 | } else { 92 | if (onEnd) onEnd(); 93 | } 94 | }; 95 | 96 | AudioPlayer.stopAllSounds = function () { 97 | for (var effectName in effects) { 98 | effects[effectName].stop(); 99 | } 100 | }; 101 | 102 | AudioPlayer.disable = function () { 103 | Howler.mute(); 104 | }; 105 | 106 | AudioPlayer.enable = function () { 107 | Howler.unmute(); 108 | }; 109 | 110 | AudioPlayer.isEnabled = function () { 111 | return Howler.volume() !== 0; 112 | }; 113 | 114 | module.exports = AudioPlayer; 115 | -------------------------------------------------------------------------------- /app/lib/audio/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "audio", 3 | "description": "Sound effect list and playback.", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "amasad/howler.js": "*" 7 | }, 8 | "development": {}, 9 | "main": "audio.js", 10 | "scripts": ["audio.js"], 11 | "styles": ["audio.css"] 12 | } 13 | -------------------------------------------------------------------------------- /app/lib/boot/boot.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | min-height: 100%; 7 | background: #1B1B1B url(/img/type.png); 8 | color: #fff; 9 | padding-top: 25px; 10 | -webkit-user-select: none; 11 | -moz-user-select: none; 12 | user-select: none; 13 | cursor: default; 14 | overflow-x: hidden; 15 | } 16 | 17 | #trophies-icon { 18 | width: 60px; 19 | } 20 | 21 | #content { 22 | margin: 0 auto; 23 | width: 900px; 24 | position: relative; 25 | display: none; 26 | } 27 | 28 | #header { 29 | width: 100%; 30 | height: 184px; 31 | } 32 | 33 | #logo { 34 | text-align: center; 35 | font-family: Ubuntu, sans-serif; 36 | font-weight: bold; 37 | font-size: 55px; 38 | top: 32px; 39 | position: relative; 40 | line-height: 100%; 41 | } 42 | 43 | #timer { 44 | margin-bottom: 20px; 45 | height: 150px; 46 | } 47 | 48 | #score-card { 49 | width: 50%; 50 | height: 50%; 51 | position: absolute; 52 | right: 0; 53 | top: 0; 54 | text-align: center; 55 | } 56 | 57 | #level-stats { 58 | position: absolute; 59 | width: 50%; 60 | height: 100%; 61 | text-align: center; 62 | top: 0; 63 | left: 0; 64 | } 65 | 66 | #hearts { 67 | width: 50%; 68 | height: 50%; 69 | position: absolute; 70 | text-align: center; 71 | bottom: 0; 72 | right: 0; 73 | } 74 | 75 | .header-left, 76 | .header-center, 77 | .header-right { 78 | position: absolute; 79 | width: 300px; 80 | height: 184px; 81 | } 82 | 83 | .header-left { 84 | left: 0px; 85 | } 86 | 87 | .header-center { 88 | left: 300px; 89 | } 90 | 91 | .header-right { 92 | left: 600px; 93 | } 94 | -------------------------------------------------------------------------------- /app/lib/boot/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boot", 3 | "description": "", 4 | "development": {}, 5 | "main": "index.js", 6 | "scripts": [ 7 | "index.js", 8 | "game.js", 9 | "router.js" 10 | ], 11 | "styles": [ 12 | "boot.css" 13 | ], 14 | "remotes": [], 15 | "local": [ 16 | "timer", 17 | "level-stats", 18 | "models", 19 | "repo-list", 20 | "commit-display", 21 | "score-card", 22 | "power-list", 23 | "level-hub", 24 | "finish-screen", 25 | "hearts", 26 | "audio", 27 | "tutorial", 28 | "track", 29 | "share-buttons" 30 | ], 31 | "dependencies": { 32 | "component/jquery": "*", 33 | "component/spin": "*", 34 | "component/overlay": "*", 35 | "ianstormtaylor/animate": "*", 36 | "component/router": "*" 37 | } 38 | } -------------------------------------------------------------------------------- /app/lib/boot/game.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | 3 | var models = require('models'); 4 | var Level = models.Level; 5 | var UserLevelProgress = models.UserLevelProgress; 6 | var Power = models.Power; 7 | 8 | var audio = require('audio'); 9 | var scoreCard = require('score-card'); 10 | var powerList = require('power-list'); 11 | var levelStats = require('level-stats'); 12 | var levelHub = require('level-hub'); 13 | var hearts = require('hearts'); 14 | var spin = require('spin'); 15 | var overlay = require('overlay'); 16 | var animate = require('animate'); 17 | var router = require('./router'); 18 | 19 | var CommitDisplay = require('commit-display'); 20 | var Timer = require('timer'); 21 | var RepoList = require('repo-list'); 22 | var FinishScreen = require('finish-screen'); 23 | var Tutorial = require('tutorial'); 24 | var Track = require('track'); 25 | 26 | module.exports = Game; 27 | 28 | function Game (options) { 29 | // Game state. 30 | this.user = options.user; 31 | this.campaign = options.campaign; 32 | 33 | // Level state. 34 | this.level = null; 35 | this.levelProgress = null; 36 | this.levelRounds = null; 37 | 38 | // Round state. 39 | this.round = null; 40 | this.startTime = null; 41 | 42 | // DOM references. 43 | this.$finishScreen = options.$finishScreen; 44 | this.$finishHeader = options.$finishHeader; 45 | this.$repos = options.$repos; 46 | this.$timer = options.$timer; 47 | this.$scoreCard = options.$scoreCard; 48 | this.$levelStats = options.$levelStats; 49 | this.$commitDisplay = options.$commitDisplay; 50 | this.$powerList = options.$powerList; 51 | this.$levelHub = options.$levelHub; 52 | this.$hearts = options.$hearts; 53 | this.$logo = options.$logo; 54 | this.$container = options.$container; 55 | 56 | // Widget references. 57 | this.commitDisplay = null; 58 | this.timer = null; 59 | this.repoList = null; 60 | this.finishScreen = new FinishScreen( 61 | this.user, 62 | this.$finishHeader, 63 | this.$finishScreen, 64 | this.showHub.bind(this), 65 | function() { this.showLevel(this.level); }.bind(this)); 66 | } 67 | 68 | /**** State Control ****/ 69 | 70 | Game.prototype.start = function () { 71 | var Commit = models.Commit; 72 | var Repo = models.Repo; 73 | var Round = models.Round; 74 | if (this.user.seen_tutorial()) { 75 | this.showHub(); 76 | } else { 77 | new Tutorial(this).start(); 78 | } 79 | Track.visit(); 80 | }; 81 | 82 | Game.prototype.clear = function () { 83 | this.$repos.empty().hide(); 84 | this.$timer.empty().hide(); 85 | this.$scoreCard.empty().hide(); 86 | this.$levelStats.empty().hide(); 87 | this.$commitDisplay.empty().hide(); 88 | this.$powerList.empty().hide(); 89 | this.$levelHub.empty().hide(); 90 | this.$finishScreen.empty().hide(); 91 | this.$finishHeader.empty().hide(); 92 | this.$hearts.empty().hide(); 93 | 94 | this.$logo.hide(); 95 | 96 | // TODO: Properly destroy widgets? 97 | this.commitDisplay = null; 98 | if (this.timer) { 99 | this.timer.stop(); 100 | } 101 | this.timer = null; 102 | 103 | audio.stopAllSounds(); 104 | 105 | window.onbeforeunload = null; 106 | }; 107 | 108 | Game.prototype.showHub = function () { 109 | this.clear(); 110 | this.$logo.show(); 111 | 112 | // TODO: Add achievements UI. 113 | this._renderScoreCard(this.user.score()); 114 | this._renderPowers('buy'); 115 | this._renderHub(); 116 | 117 | if (!this.user.seen_power_hint()) { 118 | new Tutorial(this).showPowerHint(); 119 | } 120 | 121 | router.navigate('/'); 122 | }; 123 | 124 | Game.prototype.loadLevel = function (level, callback, isTutorial) { 125 | var ov = overlay(); 126 | ov.show(); 127 | 128 | // Sorounding with try/catch because using undocumented 129 | // internal data members. 130 | animate.in(ov.el.get(0), 'fade'); 131 | 132 | var spinner = spin(ov.el.get(0), { 133 | size: 50, 134 | delay: 1 135 | }); 136 | 137 | spinner.light(); 138 | $(spinner.el).css('z-index', 501); 139 | animate.in(spinner.el, 'fade'); 140 | 141 | level.fetchRounds(function () { 142 | spinner.remove(); 143 | ov.remove(); 144 | // overlay#remove waits 2 seconds but we don't want to wait that long. 145 | ov.el.remove(); 146 | callback.apply(this, arguments); 147 | router.navigate('/level/' + level.id(), isTutorial); 148 | }.bind(this)); 149 | }; 150 | 151 | Game.prototype.showLevel = function (level) { 152 | if (this.campaign.isUnlocked(level.id(), this.user.completed_level_ids())) { 153 | this.loadLevel(level, function (rounds) { 154 | this.initLevel(level, rounds); 155 | }.bind(this)); 156 | } else { 157 | this.showHub(); 158 | } 159 | }; 160 | 161 | Game.prototype.initLevel = function(level, rounds) { 162 | this.clear(); 163 | 164 | this.level = level; 165 | this.levelRounds = rounds; 166 | this.levelProgress = UserLevelProgress.create(level, this.user); 167 | 168 | this._renderScoreCard(0); 169 | this._renderPowers('use'); 170 | this._renderLevelStats(); 171 | this._renderHearts(); 172 | 173 | this.startRound(); 174 | 175 | window.onbeforeunload = function() { 176 | return "Are you sure you want to leave the game?\n" + 177 | "Level progress will be lost."; 178 | }; 179 | }; 180 | 181 | Game.prototype.showFinishScreen = function () { 182 | this.clear(); 183 | 184 | this._renderFinishScreen(); 185 | }; 186 | 187 | Game.prototype.startRound = function () { 188 | this.round = this.levelRounds[this.levelProgress.completed_round()]; 189 | this._renderTimer(this.round.timer()); 190 | this._renderRepos(this.round.repos()); 191 | this._renderCommitDisplay(this.round.commit()); 192 | this._renderLevelStats(); 193 | this.timer.start(); 194 | this.startTime = Date.now(); 195 | this.powersUsed = []; 196 | }; 197 | 198 | /**** Event Handling ****/ 199 | 200 | Game.prototype._onGuess = function (repo) { 201 | this._finishRound(repo.name() === this.round.commit().repository()); 202 | }; 203 | 204 | Game.prototype._onPower = function (mode, power) { 205 | switch (mode) { 206 | case 'buy': 207 | Track.event('power', 'buy', power); 208 | audio.play('buy-power'); 209 | this.user.addPower(power); 210 | this.user.subtractScore(power.price()); 211 | this._renderScoreCard(this.user.score()); 212 | break; 213 | case 'use': 214 | Track.event('power', 'use', power); 215 | // TODO: Maybe move these into Power.use()? 216 | switch (power.id()) { 217 | case 'time': 218 | audio.play('power-time'); 219 | this.timer.rewind(0.25); 220 | break; 221 | case 'commit': 222 | audio.play('power-commit'); 223 | this.commitDisplay.showMetadata(); 224 | break; 225 | case 'repo': 226 | // RepoList handles audio. 227 | this.repoList.showDescription(); 228 | break; 229 | case 'half': 230 | audio.play('power-half'); 231 | this.repoList.hideRepos(this.round.commit().repository()); 232 | break; 233 | default: 234 | throw new Error('Unexpected power: ' + power.id()); 235 | } 236 | if (this.powersUsed.indexOf(power.id()) === -1) { 237 | this.user.removePower(power); 238 | } 239 | // Only time powerup is allowed to be used more than once. 240 | if (power.id() !== 'time') { 241 | this.powersUsed.push(power.id()); 242 | } 243 | break; 244 | case 'inactive': 245 | // No interaction possible. 246 | break; 247 | default: 248 | throw Error('Invalid mode: ' + mode); 249 | } 250 | }; 251 | 252 | Game.prototype._finishRound = function (won) { 253 | this.timer.stop(); 254 | this.repoList.destroy(); 255 | 256 | var progress = this.levelProgress; 257 | 258 | // Record round win/loss. 259 | var outcome; 260 | var secondsTaken = Math.floor((Date.now() - this.startTime) / 1000); 261 | if (won) { 262 | var grade = 50 + this.round.commit().grade(); 263 | var pointsEarned = 264 | Math.round(Math.pow(grade, 1.25) / (Math.sqrt(1 + secondsTaken) * 2)); 265 | progress.recordRoundGuessed(pointsEarned); 266 | this._renderScoreCard(progress.commmitScore()); 267 | this._triggerAnimation(this.$scoreCard); 268 | outcome = 'guess'; 269 | } else { 270 | if (this.timer.timeLeft == 0) { 271 | outcome = 'timeout'; 272 | } else { 273 | outcome = 'miss'; 274 | } 275 | progress.recordRoundMissed(); 276 | } 277 | Track.event('round', outcome, this.level.id(), secondsTaken); 278 | 279 | // Find out the final outcome. 280 | var wonLevel = progress.completed_round() === progress.rounds(); 281 | var lostLevel = progress.mistakes_left() < 0; 282 | 283 | // Play audio, making sure not to overlap the level-end effect. 284 | if (won && !wonLevel) { 285 | audio.play('guess'); 286 | } else if (!won && !lostLevel) { 287 | audio.play('miss'); 288 | } 289 | 290 | if (lostLevel) { 291 | this.showFinishScreen(); 292 | } else if (wonLevel) { 293 | var scoreEarned = progress.totalScore(); 294 | Track.event('score', 'earn', this.level.id(), scoreEarned); 295 | this.user.addScore(scoreEarned); 296 | this.user.completeLevel(this.level); 297 | this.showFinishScreen(); 298 | } else { 299 | this.startRound(); 300 | } 301 | }; 302 | 303 | /**** Rendering ****/ 304 | 305 | Game.prototype._triggerAnimation = function (elem) { 306 | elem.removeClass('animate'); 307 | setTimeout(elem.addClass.bind(elem, 'animate'), 0); 308 | }; 309 | 310 | Game.prototype._renderLevelStats = function () { 311 | this.$levelStats.empty().append(levelStats(this.levelProgress)); 312 | this.$levelStats.show(); 313 | }; 314 | 315 | Game.prototype._renderTimer = function (seconds) { 316 | this.timer = new Timer({ 317 | interval: seconds, 318 | outerRadius: this.$timer.outerHeight() / 2, 319 | progressWidth: 10, 320 | onComplete: this._finishRound.bind(this, false) 321 | }); 322 | this.$timer.empty().append(this.timer.$el); 323 | this.$timer.show(); 324 | }; 325 | 326 | Game.prototype._renderRepos = function (repos) { 327 | this.repoList = new RepoList(repos, this._onGuess.bind(this)); 328 | this.$repos.empty().append(this.repoList.$el); 329 | this.$repos.show(); 330 | }; 331 | 332 | Game.prototype._renderCommitDisplay = function (commit) { 333 | this.commitDisplay = new CommitDisplay(commit); 334 | this.$commitDisplay.empty().append(this.commitDisplay.$el); 335 | this.$commitDisplay.show(); 336 | }; 337 | 338 | Game.prototype._renderScoreCard = function(score) { 339 | this.$scoreCard.empty().append(scoreCard(score)); 340 | this.$scoreCard.show(); 341 | }; 342 | 343 | Game.prototype._renderPowers = function(mode) { 344 | var callback = this._onPower.bind(this, mode); 345 | this.$powerList.append(powerList(Power.ALL, this.user, mode, callback)); 346 | this.$powerList.show(); 347 | }; 348 | 349 | Game.prototype._renderHub = function() { 350 | this.$levelHub.append( 351 | levelHub(this.campaign, this.user, this.showLevel.bind(this))); 352 | this.$levelHub.show(); 353 | }; 354 | 355 | Game.prototype._renderFinishScreen = function() { 356 | var rounds = this.levelRounds.map(function(r) { return r.commit(); }); 357 | this.finishScreen.render(this.level, rounds, this.levelProgress); 358 | this.$finishScreen.show(); 359 | this.$finishHeader.show(); 360 | }; 361 | 362 | Game.prototype._renderHearts = function () { 363 | this.$hearts.empty().append(hearts(this.levelProgress)); 364 | this.$hearts.show(); 365 | }; 366 | -------------------------------------------------------------------------------- /app/lib/boot/index.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var Game = require('./game'); 3 | var User = require('models').User; 4 | var Campaign = require('models').Campaign; 5 | var Track = require('track'); 6 | 7 | var shareButtons = require('share-buttons'); 8 | var router = require('./router'); 9 | var audio = require('audio'); 10 | 11 | audio.initialize($('#audio-toggle')); 12 | Track.initialize(); 13 | 14 | var user = User.loadOrCreate(); 15 | 16 | var game = new Game({ 17 | user: user, 18 | campaign: Campaign.MAIN, 19 | $container: $('#content'), 20 | $timer: $('#timer'), 21 | $repos: $('#repo-selector'), 22 | $scoreCard: $('#score-card'), 23 | $levelStats: $('#level-stats'), 24 | $commitDisplay: $('#commit-display'), 25 | $powerList: $('#power-list'), 26 | $levelHub: $('#level-hub'), 27 | $finishScreen: $('#finish-screen'), 28 | $finishHeader: $('#finish-header'), 29 | $hearts: $('#hearts'), 30 | $logo: $('#logo') 31 | }); 32 | 33 | $('#content').show(); 34 | 35 | router.start(game); 36 | if (user.seen_tutorial()) { 37 | router.dispatch(); 38 | } else { 39 | game.start(); 40 | } 41 | 42 | window.onunload = function () { 43 | user.persist(); 44 | }; 45 | 46 | shareButtons(); 47 | 48 | // TODO: Add support for switchable background music. 49 | -------------------------------------------------------------------------------- /app/lib/boot/router.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var Router = require('router'); 3 | var Campaign = require('models').Campaign; 4 | 5 | var router = new Router(); 6 | var location = window.location; 7 | 8 | var pushStateSupported = 'pushState' in window.history; 9 | 10 | var prevPath = location.pathname; 11 | 12 | exports.start = function (game) { 13 | if (!pushStateSupported) { 14 | return; 15 | } 16 | 17 | router.get('/', function () { 18 | game.showHub(); 19 | }); 20 | 21 | router.get('/level/:id', function (id) { 22 | id = parseInt(id, 10); 23 | game.showLevel(Campaign.MAIN.getLevelById(id)); 24 | }); 25 | 26 | $(window).on('popstate', function (e) { 27 | // Make sure it's not initial chrome popstate bug. 28 | if (e.originalEvent.state && prevPath !== location.pathname) { 29 | var dispatch = true; 30 | if (prevPath.match(/^\/level/)) { 31 | dispatch = confirm( 32 | 'Are you sure you want to navigate away?\n' + 33 | 'Level progress will be lost.' 34 | ); 35 | } 36 | if (dispatch) { 37 | prevPath = location.pathname; 38 | router.dispatch(location.pathname); 39 | } else { 40 | exports.navigate(prevPath); 41 | } 42 | } 43 | }); 44 | 45 | // Make sure it's not initial chrome popstate bug. 46 | window.history.replaceState({}, null, location.pathname); 47 | }; 48 | 49 | exports.navigate = function (path, replaceState) { 50 | if (pushStateSupported && path !== location.pathname) { 51 | prevPath = path; 52 | if (replaceState) { 53 | window.history.replaceState({}, null, path); 54 | } else { 55 | window.history.pushState({}, null, path); 56 | } 57 | } 58 | }; 59 | 60 | exports.dispatch = function () { 61 | if (pushStateSupported) { 62 | router.dispatch(location.pathname); 63 | } 64 | }; -------------------------------------------------------------------------------- /app/lib/commit-display/commit-display.css: -------------------------------------------------------------------------------- 1 | /* Reset any stupidity from highlight lib. */ 2 | .commit-display code { 3 | border: none; 4 | word-wrap: break-word; 5 | padding: none; 6 | line-height: inherit; 7 | margin-bottom: inherit; 8 | font-family: inherit; 9 | border-radius: inherit; 10 | margin: 0; 11 | white-space: pre; 12 | background: none !important; 13 | } 14 | 15 | .commit-display .metadata { 16 | border-radius: 20px; 17 | display: none; 18 | color: white; 19 | text-decoration: none; 20 | text-align: left; 21 | line-height: 29px; 22 | height: 32px; 23 | background: #36362C; 24 | margin-bottom: 15px; 25 | } 26 | 27 | .commit-display .metadata .author { 28 | display: inline-block; 29 | float: left; 30 | line-height: 32px; 31 | } 32 | 33 | .commit-display .metadata .author img { 34 | border-radius: 20px; 35 | height: 32px; 36 | width: 32px; 37 | } 38 | 39 | .commit-display .metadata .author .fa { 40 | font-size: 36px; 41 | line-height: 32px; 42 | } 43 | 44 | .commit-display .metadata .author .name { 45 | display: inline-block; 46 | padding-left: 5px; 47 | vertical-align: top; 48 | line-height: 31px; 49 | } 50 | 51 | .commit-display .metadata .message { 52 | text-overflow: ellipsis; 53 | overflow: hidden; 54 | white-space: nowrap; 55 | display: block; 56 | padding: 0px 20px; 57 | line-height: 32px; 58 | } 59 | 60 | .commit-display .metadata .filename { 61 | float: right; 62 | margin-right: 12px; 63 | white-space: nowrap; 64 | text-align: right; 65 | line-height: 32px; 66 | } 67 | 68 | .commit-display .metadata.hide, 69 | .commit-display .metadata .hide { 70 | display: none; 71 | } 72 | 73 | .commit-display .diff { 74 | font-family: "Ubuntu Mono"; 75 | font-size: 16px; 76 | } 77 | 78 | .commit-display .diff .line { 79 | display: block; 80 | position: relative; 81 | height: 24px; 82 | line-height: 12px; 83 | white-space: nowrap; 84 | } 85 | 86 | .commit-display .diff .line.ins { 87 | background: #274204; 88 | } 89 | 90 | .commit-display .diff .line.del { 91 | background: #470A03; 92 | } 93 | 94 | .commit-display .diff .line.first-in-run { 95 | border-top-left-radius: 15px; 96 | border-top-right-radius: 15px; 97 | } 98 | 99 | .commit-display .diff .line.last-in-run { 100 | border-bottom-left-radius: 15px; 101 | border-bottom-right-radius: 15px; 102 | } 103 | 104 | .commit-display .diff .line > * { 105 | padding-top: 5px; 106 | display: inline-block; 107 | padding-bottom: 5px; 108 | } 109 | 110 | .commit-display .diff .block-name { 111 | color: #969696 !important; 112 | border-bottom: 1px #333 solid; 113 | } 114 | 115 | .commit-display .diff .old-num, 116 | .commit-display .diff .new-num { 117 | border-right: 1px #333 solid; 118 | color: #aaa; 119 | text-align: center; 120 | width: 45px; 121 | height: 24px; 122 | } 123 | 124 | .commit-display .diff .del .old-num, 125 | .commit-display .diff .del .new-num { 126 | border-color: #4D1A1A; 127 | } 128 | 129 | .commit-display .diff .ins .old-num, 130 | .commit-display .diff .ins .new-num { 131 | border-color: #2C381F; 132 | } 133 | 134 | .commit-display .diff .op { 135 | color: #ccc; 136 | text-align: center; 137 | width: 20px; 138 | } 139 | 140 | .commit-display .diff .content { 141 | padding-left: 10px; 142 | } 143 | -------------------------------------------------------------------------------- /app/lib/commit-display/commit-display.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var Hogan = require('hogan.js'); 3 | var prism = require('prism'); 4 | var animate = require('animate'); 5 | 6 | var template = Hogan.compile(require('./template')); 7 | 8 | function CommitDisplay (model) { 9 | this.model = model; 10 | this.render(); 11 | } 12 | 13 | CommitDisplay.prototype.showMetadata = function () { 14 | var $metadata = this.$el.find('.metadata'); 15 | $metadata.show(); 16 | animate.in($metadata[0], 'fold'); 17 | } 18 | 19 | CommitDisplay.prototype._setElementVisibility = function ($el, show) { 20 | $el.toggleClass('hide', !show); 21 | }; 22 | 23 | CommitDisplay.prototype.render = function() { 24 | var oldNum = this.model.old_start_line(); 25 | var newNum = this.model.new_start_line(); 26 | var model = this.model.toJSON(); 27 | var code = []; 28 | 29 | var lastLineObj; 30 | var lastOp; 31 | model.diff_lines = this.model.diff_lines().split('\n').map(function (line) { 32 | var ret = {}; 33 | ret.op = line[0]; 34 | ret.content = line.slice(1); 35 | ret.cls = 'context'; 36 | switch (ret.op) { 37 | case '+': 38 | ret.old_num = ' '; 39 | ret.new_num = ++newNum; 40 | ret.cls = 'ins'; 41 | break; 42 | case '-': 43 | ret.old_num = ++oldNum; 44 | ret.new_num = ' '; 45 | ret.cls = 'del'; 46 | break; 47 | case '\\': 48 | ret.content = ''; 49 | break; 50 | case ' ': 51 | ret.old_num = ++oldNum; 52 | ret.new_num = ++newNum; 53 | break; 54 | default: 55 | ret.content = line; 56 | } 57 | if (ret.op !== '-' && ret.op !== '+') { 58 | ret.op = ' '; 59 | ret.cls = 'context'; 60 | } 61 | code.push(ret.content); 62 | 63 | 64 | if (lastLineObj) { 65 | lastLineObj.last_in_run = ret.first_in_run = (ret.op != lastOp); 66 | } 67 | lastLineObj = ret; 68 | lastOp = ret.op; 69 | 70 | return ret; 71 | }); 72 | model.diff_lines[0].first_in_run = true; 73 | model.diff_lines[model.diff_lines.length - 1].last_in_run = true; 74 | 75 | // Syntax-highlight the code. Can't do that on the final DOM because it 76 | // includes line numbers and diff operators. 77 | var language = this._getCommitLanguage(); 78 | var codeEl = $('
')
 79 |     .append('')
 80 |     .find('code')
 81 |     .addClass('language-' + language)
 82 |     .text(code.join('\n'))
 83 |     .get(0);
 84 | 
 85 |   prism.highlightElement(codeEl, false);
 86 |   $(codeEl).html().split('\n').forEach(function (highlightedLine, i) {
 87 |     model.diff_lines[i].content = highlightedLine;
 88 |   });
 89 | 
 90 |   this.$el = $(template.render(model));
 91 | 
 92 |   var lines = $('.line', this.$el);
 93 |   setTimeout(function() {
 94 |     lines.each(function() {
 95 |       if ($(this).hasClass('del')) {
 96 |         animate.in(this, 'bounce-left');
 97 |       } else if ($(this).hasClass('ins')) {
 98 |         animate.in(this, 'bounce-right');
 99 |       }
100 |     });
101 |   }, 1);
102 | };
103 | 
104 | CommitDisplay.prototype._getCommitLanguage = function() {
105 |   var ext = /\.([^.]+)$/.exec(this.model.filename());
106 |   ext = (ext ? ext[1] : '').toLowerCase().trim();
107 | 
108 |   var lang = {
109 |         ''                  : null,
110 |         'c'                 : 'c',
111 |         'h'                 : 'c',
112 |         'cpp'               : 'cpp',
113 |         'hpp'               : 'cpp',
114 |         'cxx'               : 'cpp',
115 |         'hxx'               : 'cpp',
116 |         'cc'                : 'cpp',
117 |         'vim'               : 'clike',
118 |         'pbxproj'           : 'clike',
119 |         'm'                 : 'clike',
120 |         'go'                : 'clike',
121 |         'coffee'            : 'coffeescript',
122 |         'coffeescript'      : 'coffeescript',
123 |         'litcoffee'         : 'coffeescript',
124 |         'cs'                : 'csharp',
125 |         'css'               : 'css',
126 |         'less'              : 'css',
127 |         'd'                 : 'd',
128 |         'hs'                : 'haskell',
129 |         'lhs'               : 'haskell',
130 |         'html'              : 'html',
131 |         'xml'               : 'markup',
132 |         'java'              : 'java',
133 |         'scala'             : 'java',
134 |         'js'                : 'javascript',
135 |         'json'              : 'javascript',
136 |         'ts'                : 'javascript',
137 |         'lua'               : 'lua',
138 |         'php'               : 'php',
139 |         'phtml'             : 'php',
140 |         'py'                : 'python',
141 |         'pyw'               : 'python',
142 |         'r'                 : 'r',
143 |         'rb'                : 'ruby',
144 |         'scm'               : 'scheme',
145 |         'sh'                : 'bash',
146 |         'bash'              : 'bash',
147 |         'zsh'               : 'bash',
148 |         'sql'               : 'sql',
149 |         'scss'              : 'scss',
150 |         'groovy'            : 'groovy',
151 |         'gvy'               : 'groovy',
152 |         'gy'                : 'groovy',
153 |         'gsh'               : 'gsh',
154 |         'vsh'               : 'gsh',
155 |         'fsh'               : 'gsh',
156 |         'shader'            : 'gsh',
157 |         'feature'           : 'gherkin'
158 |   }[ext] || 'generic';
159 |   return lang;
160 | };
161 | 
162 | 
163 | module.exports = CommitDisplay;
164 | 


--------------------------------------------------------------------------------
/app/lib/commit-display/component.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "commit-display",
 3 |   "description": "Displays commit info",
 4 |   "dependencies": {
 5 |     "component/jquery": "*",
 6 |     "twitter/hogan.js": "*",
 7 |     "amasad/prism": "*",
 8 |     "ianstormtaylor/animate": "*"
 9 |   },
10 |   "development": {},
11 |   "main": "commit-display.js",
12 |   "scripts": [
13 |     "commit-display.js",
14 |     "template.js"
15 |   ],
16 |   "styles": [
17 |     "commit-display.css"
18 |   ]
19 | }


--------------------------------------------------------------------------------
/app/lib/commit-display/template.html:
--------------------------------------------------------------------------------
 1 | 
2 | 15 | 16 |
17 |
18 |    {{block_name}} 19 |
20 | {{#diff_lines}} 21 |
24 | {{{old_num}}}{{{new_num}}}{{{op}}}{{{content}}} 25 |
26 | {{/diff_lines}} 27 |
28 |
29 | -------------------------------------------------------------------------------- /app/lib/finish-screen/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "finish-screen", 3 | "description": "a level results screen", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "twitter/hogan.js": "*", 7 | "component/humanize-number": "*" 8 | }, 9 | "development": {}, 10 | "main": "finish-screen.js", 11 | "scripts": [ 12 | "finish-screen.js", 13 | "template.js", 14 | "header-template.js" 15 | ], 16 | "styles": [ 17 | "finish-screen.css" 18 | ], 19 | "local": [ 20 | "audio", 21 | "models", 22 | "track" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /app/lib/finish-screen/finish-screen.css: -------------------------------------------------------------------------------- 1 | #finish-header { 2 | text-align: center; 3 | } 4 | 5 | #finish-header .icon { 6 | font-size: 90px; 7 | position: relative; 8 | top: -10px; 9 | } 10 | 11 | #finish-header .outcome { 12 | font-size: 35px; 13 | margin-top: 10px; 14 | font-weight: bold; 15 | font-family: Ubuntu, sans-serif; 16 | } 17 | 18 | #finish-screen { 19 | text-align: center; 20 | line-height: 30px; 21 | margin-top: 16px; 22 | } 23 | 24 | #finish-screen .score-previous { 25 | font-weight: bold; 26 | font-size: 20px; 27 | width: 100%; 28 | text-align: right; 29 | height: 33px; 30 | } 31 | 32 | #finish-screen .score-new { 33 | font-weight: bold; 34 | font-size: 30px; 35 | width: 100%; 36 | text-align: right; 37 | color: #66FF00; 38 | border-top: 1px #777 solid; 39 | padding-top: 10px; 40 | } 41 | 42 | #finish-screen .commit { 43 | border-radius: 20px; 44 | display: block; 45 | margin-top: 10px; 46 | color: white; 47 | text-decoration: none; 48 | text-align: left; 49 | width: 825px; 50 | line-height: 29px; 51 | position: relative; 52 | height: 30px; 53 | } 54 | 55 | #finish-screen .commit.missed { 56 | background: #AF4127; 57 | } 58 | 59 | #finish-screen .commit.missed:hover { 60 | background: #D14929; 61 | } 62 | 63 | #finish-screen .commit.guessed { 64 | background: #6BA81D; 65 | } 66 | 67 | #finish-screen .commit.guessed:hover { 68 | background: #7ECC1B; 69 | } 70 | 71 | #finish-screen .commit .icon { 72 | width: 30px; 73 | border-radius: 16px; 74 | float: left; 75 | margin-right: 8px; 76 | } 77 | 78 | #finish-screen .commit .fa { 79 | font-size: 35px; 80 | line-height: 32px; 81 | } 82 | 83 | #finish-screen .commit .message { 84 | display: block; 85 | overflow: hidden; 86 | text-overflow: ellipsis; 87 | white-space: nowrap; 88 | } 89 | 90 | #finish-screen .commit .repo { 91 | float: right; 92 | margin-right: 12px; 93 | margin-left: 12px; 94 | text-align: right; 95 | line-height: 31px; 96 | } 97 | 98 | #finish-screen .commit .score { 99 | position: absolute; 100 | right: -75px; 101 | font-weight: bold; 102 | font-size: 20px; 103 | line-height: 33px; 104 | text-align: right; 105 | top: 0; 106 | } 107 | 108 | #finish-screen .commit.guessed .score { 109 | color: #7ECC1B; 110 | } 111 | 112 | #finish-screen .commit.missed .score, 113 | #finish-screen .commits.Defeat .commit.guessed .score { 114 | color: #777; 115 | } 116 | 117 | .lives { 118 | text-align: right; 119 | height: 38px; 120 | line-height: 36px; 121 | padding-top: 2px; 122 | } 123 | 124 | .lives .fa { 125 | color: #777; 126 | font-size: 22px; 127 | } 128 | 129 | .lives .fa.is-left { 130 | color: #CA1C04; 131 | } 132 | 133 | .lives .score { 134 | display: inline-block; 135 | width: 75px; 136 | color: #7ECC1B; 137 | font-weight: bold; 138 | font-size: 22px; 139 | } 140 | 141 | #finish-screen .buttons { 142 | text-align: right; 143 | margin-right: -10px; 144 | clear: both; 145 | } 146 | 147 | #finish-screen .button { 148 | display: inline-block; 149 | width: 126px; 150 | cursor: pointer; 151 | text-align: center; 152 | border-top-width: 4px; 153 | border-radius: 10px; 154 | color: white; 155 | margin: 10px; 156 | font-family: Ubuntu, sans-serif; 157 | font-weight: bold; 158 | font-size: 18px; 159 | line-height: 42px; 160 | letter-spacing: 2px; 161 | } 162 | 163 | #finish-screen .button.retry { 164 | background: #D69201; 165 | } 166 | 167 | #finish-screen .button.retry:hover, 168 | #finish-screen .button.retry:focus { 169 | background: #E4B703; 170 | } 171 | 172 | #finish-screen .button.to-hub { 173 | background: #2451B1; 174 | } 175 | 176 | #finish-screen .button.to-hub:hover, 177 | #finish-screen .button.to-hub:focus { 178 | background: #3C65FC; 179 | } 180 | 181 | #finish-screen .currency { 182 | color: #F1CE2A; 183 | font-weight: bold; 184 | font-family: Ubuntu, sans-serif; 185 | } 186 | -------------------------------------------------------------------------------- /app/lib/finish-screen/finish-screen.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var audio = require('audio'); 3 | var Hogan = require('hogan.js'); 4 | var humanize = require('humanize-number'); 5 | var template = Hogan.compile(require('./template')); 6 | var headerTemplate = Hogan.compile(require('./header-template')); 7 | var UserLevelProgress = require('models').UserLevelProgress; 8 | var Track = require('track'); 9 | 10 | module.exports = FinishScreen; 11 | 12 | function FinishScreen (user, $finishHeader, $finishScreen, onHub, onRetry) { 13 | this.user = user; 14 | this.$finishHeader = $finishHeader; 15 | this.$finishScreen = $finishScreen; 16 | this.onHub = onHub; 17 | this.onRetry = onRetry; 18 | this.active = false; 19 | 20 | // Listen to clicks. 21 | this.$finishScreen.on('click', '.button.to-hub', function() { 22 | this.onHub(); 23 | this.active = false; 24 | }.bind(this)); 25 | this.$finishScreen.on('click', '.button.retry', function() { 26 | this.onRetry(); 27 | this.active = false; 28 | }.bind(this)); 29 | } 30 | 31 | FinishScreen.prototype.render = function (level, commits, levelProgress) { 32 | this.active = true; 33 | var outcome = this.outcome(level.name() == 'survival', levelProgress); 34 | this.renderHead(outcome); 35 | this.renderDetails( 36 | outcome, 37 | level.num_mistakes_allowed(), 38 | levelProgress, 39 | this.commitsArg(commits, levelProgress)); 40 | 41 | // TODO: Add a spinning animation to the icon in time with the audio. 42 | switch (outcome) { 43 | case 'Flawless': 44 | Track.event('level', 'flawless', level.id()); 45 | audio.play('flawless-victory'); 46 | break; 47 | case 'Victory': 48 | Track.event('level', 'win', level.id()); 49 | audio.play('victory'); 50 | break; 51 | case 'The End': 52 | Track.event('level', 'end', level.id()); 53 | audio.play('end'); 54 | break; 55 | case 'Defeat': 56 | Track.event('level', 'lose', level.id()); 57 | audio.play('defeat'); 58 | break; 59 | default: 60 | throw Error('Unknown outcome: ' + outcome); 61 | } 62 | }; 63 | 64 | FinishScreen.prototype.renderHead = function (outcome) { 65 | switch (outcome) { 66 | case 'Flawless': 67 | glyph = 'star'; 68 | break; 69 | case 'Victory': 70 | glyph = 'thumbs-up'; 71 | break; 72 | case 'The End': 73 | glyph = 'bell'; 74 | break; 75 | case 'Defeat': 76 | glyph = 'thumbs-down'; 77 | break; 78 | default: 79 | throw Error('Unknown outcome: ' + outcome); 80 | } 81 | 82 | // Render the template. 83 | var args = {outcome: outcome, glyph: glyph}; 84 | this.$finishHeader.empty().html(headerTemplate.render(args)); 85 | }; 86 | 87 | FinishScreen.prototype.renderDetails = 88 | function (outcome, maxLives, progress, commits) { 89 | var isVictory = outcome != 'Defeat'; 90 | 91 | // Count lives. 92 | var lives = []; 93 | for (var i = 0; i < maxLives; i++) { 94 | lives.push({is_left: (maxLives - i) <= progress.mistakes_left()}); 95 | } 96 | var livesScore = Math.max(0, progress.mistakes_left()) 97 | * UserLevelProgress.SCORE_PER_LIFE; 98 | 99 | // Sum up the score. 100 | var prevScore = this.user.score() - progress.totalScore(); 101 | var newScore = isVictory ? this.user.score() : prevScore; 102 | 103 | // Set up args. 104 | var args = { 105 | outcome: outcome, 106 | is_victory: isVictory, 107 | score_previous: prevScore, 108 | score_new: newScore, 109 | achievements: [], 110 | commits: commits, 111 | lives: lives, 112 | lives_score: livesScore 113 | }; 114 | 115 | // Render the template. 116 | this.$finishScreen.empty().html(template.render(args)); 117 | 118 | // Start the counting animation. 119 | this.showScores(outcome != 'Defeat'); 120 | }; 121 | 122 | FinishScreen.prototype.outcome = function (isSurvival, levelProgress) { 123 | if (levelProgress.mistakes_left() >= 0) { 124 | if (levelProgress.missed() == 0) { 125 | return 'Flawless'; 126 | } else { 127 | return 'Victory'; 128 | } 129 | } else { 130 | if (isSurvival) { 131 | // Special case: the survival level has no defeat. 132 | return 'The End'; 133 | } else { 134 | return 'Defeat'; 135 | } 136 | } 137 | }; 138 | 139 | FinishScreen.prototype.commitsArg = function (commits, levelProgress) { 140 | var scores = levelProgress.score_earned(); 141 | var commitArgs = []; 142 | for (var i = 0; i < scores.length; i++) { 143 | var commit = commits[i]; 144 | var score = scores[i]; 145 | var json = commit.toJSON(); 146 | json.score_earned = humanize(score); 147 | json.is_guessed = score > 0; 148 | commitArgs.push(json); 149 | } 150 | return commitArgs; 151 | }; 152 | 153 | FinishScreen.prototype.showScores = function (animate) { 154 | var suffix = ' G'; 155 | var delay = 50; 156 | var step = 5; 157 | 158 | var startCounting = function($el, $rest, cb) { 159 | var start = parseInt($el.data('from'), 10); 160 | var end = parseInt($el.data('to'), 10); 161 | var prefix = $el.data('prefix') || ''; 162 | 163 | var countTo = function (value, end) { 164 | $el.html(prefix + humanize(value) + suffix); 165 | if (value < end) { 166 | setTimeout(countTo.bind(null, Math.min(value + step, end), end), delay); 167 | } else if ($rest.length) { 168 | startCounting($rest.first(), $rest.slice(1)); 169 | } else if (cb) { 170 | cb(); 171 | } 172 | step += 1; 173 | }; 174 | 175 | countTo(animate ? start : end, end); 176 | }; 177 | 178 | var $scores = $('.score', this.$finishScreen); 179 | startCounting($scores.first(), $scores.slice(1, -1)); // Separate 180 | 181 | var done = false; 182 | var jingle = function(last) { 183 | if (done || !animate || !this.active) return; 184 | var rand = Math.random() < 0.5; 185 | var num = last == 1 ? (rand ? 2 : 3) : 186 | last == 2 ? (rand ? 1 : 3) : 187 | last == 3 ? (rand ? 2 : 1) : 188 | 1; 189 | audio.play('coin-' + num, jingle.bind(null, num)); 190 | }.bind(this); 191 | jingle(); 192 | startCounting($scores.last(), $(), function() { done = true; }); // Total 193 | }; 194 | -------------------------------------------------------------------------------- /app/lib/finish-screen/header-template.html: -------------------------------------------------------------------------------- 1 | 2 |
{{outcome}}
3 | -------------------------------------------------------------------------------- /app/lib/finish-screen/template.html: -------------------------------------------------------------------------------- 1 | {{#is_victory}} 2 |
4 | {{/is_victory}} 5 | 6 | 24 | 25 | {{#is_victory}} 26 |
27 | {{#lives}} 28 | 29 | {{/lives}} 30 | 31 |
32 | 33 |
34 | {{/is_victory}} 35 | 36 | {{#is_victory}} 37 |
38 | {{#achievements}} 39 |
40 | 41 | {{name}} 42 |
43 | {{/achievements}} 44 |
45 | {{/is_victory}} 46 | 47 |
48 | {{^is_victory}}
Retry
{{/is_victory}} 49 |
Continue
50 |
51 | -------------------------------------------------------------------------------- /app/lib/hearts/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hearts", 3 | "description": "How many mistakes left", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "twitter/hogan.js": "*", 7 | "ianstormtaylor/animate": "*" 8 | }, 9 | "development": {}, 10 | "main": "hearts.js", 11 | "scripts": [ 12 | "hearts.js", 13 | "template.js" 14 | ], 15 | "styles": [ 16 | "hearts.css" 17 | ] 18 | } -------------------------------------------------------------------------------- /app/lib/hearts/hearts.css: -------------------------------------------------------------------------------- 1 | 2 | .hearts { 3 | font-size: 18px; 4 | color: #CA1C04; 5 | margin-top: 17px; 6 | text-align: center; 7 | } 8 | 9 | .hearts .fa-heart { 10 | margin-left: 3px; 11 | } 12 | 13 | .hearts .fa-heart:first-child { 14 | margin-left: 0; 15 | } -------------------------------------------------------------------------------- /app/lib/hearts/hearts.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var template = require('./template'); 3 | var animate = require('animate'); 4 | 5 | module.exports = function (levelProgress) { 6 | var $el = $('
', { class: 'hearts' }); 7 | 8 | function update (mistakesLeft) { 9 | if (mistakesLeft < 0) { 10 | return; 11 | } 12 | var hearts = $el.find('.fa-heart').toArray(); 13 | if (hearts.length < mistakesLeft) { 14 | while (mistakesLeft-- > 0) { 15 | $el.append(template); 16 | } 17 | } else { 18 | while (hearts.length > mistakesLeft) { 19 | var heart = hearts.pop(); 20 | animate.out(heart, 'rotate-up-right', false, function () { 21 | $(heart).css('visibility', 'hidden'); 22 | }); 23 | } 24 | } 25 | } 26 | 27 | levelProgress.on('change mistakes_left', update); 28 | update(levelProgress.mistakes_left()); 29 | 30 | return $el; 31 | }; 32 | -------------------------------------------------------------------------------- /app/lib/hearts/template.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/lib/level-hub/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "level-hub", 3 | "description": "a menu of available levels", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "twitter/hogan.js": "*" 7 | }, 8 | "development": {}, 9 | "main": "level-hub.js", 10 | "scripts": [ 11 | "level-hub.js", 12 | "template.js", 13 | "level-template.js" 14 | ], 15 | "styles": [ 16 | "level-hub.css" 17 | ], 18 | "local": [ 19 | "audio" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /app/lib/level-hub/level-hub.css: -------------------------------------------------------------------------------- 1 | .level-hub { 2 | text-align: center; 3 | line-height: 30px; 4 | width: 90%; 5 | margin: 50px auto; 6 | font-family: Ubuntu, sans-serif; 7 | } 8 | 9 | .level-hub .fast-levels { 10 | float: left; 11 | } 12 | 13 | .level-hub .hard-levels { 14 | float: right; 15 | } 16 | 17 | .level-hub .hard-levels { 18 | float: right; 19 | } 20 | 21 | .level-hub .level { 22 | clear: both; 23 | font-weight: bold; 24 | width: 160px; 25 | margin: auto; 26 | margin-top: 20px; 27 | white-space: nowrap; 28 | line-height: 35px; 29 | border-radius: 10px; 30 | position: relative; 31 | } 32 | 33 | .level-hub .level .icon { 34 | font-size: 15px; 35 | vertical-align: middle; 36 | padding: 10px; 37 | position: absolute; 38 | } 39 | 40 | .level-hub .level .icon.left { 41 | left: 0; 42 | } 43 | 44 | .level-hub .level .icon.right { 45 | right: 0; 46 | } 47 | 48 | .level-hub .level.unlocked { 49 | background: #D69201; /* Yellow */ 50 | cursor: pointer; 51 | } 52 | 53 | .level-hub .level.unlocked:hover, 54 | .level-hub .level.unlocked:hover { 55 | background: #E4B703; 56 | } 57 | 58 | .level-hub .level.locked { 59 | background: #AF4127; /* Red */ 60 | } 61 | 62 | .level-hub .level.completed { 63 | background: #6BA81D; /* Green */ 64 | } 65 | 66 | .level-hub .level.completed:hover, 67 | .level-hub .level.completed:hover { 68 | background: #7ECC1B; 69 | } 70 | 71 | .levels-branch { 72 | clear: both; 73 | } -------------------------------------------------------------------------------- /app/lib/level-hub/level-hub.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var audio = require('audio'); 3 | var Hogan = require('hogan.js'); 4 | var template = Hogan.compile(require('./template')); 5 | var levelTemplate = Hogan.compile(require('./level-template')); 6 | 7 | // Renders the level hub for a given campaign, calling back when a level is 8 | // selected passing the Level object. 9 | module.exports = function (campaign, user, callback) { 10 | var $el = $('
', { class: 'level-hub' }); 11 | $el.html( 12 | template.render( 13 | campaign.toJSONWithUserProgress(user), { levelTemplate: levelTemplate } 14 | ) 15 | ); 16 | 17 | $el.on('click', '.level', function () { 18 | var id = parseInt($(this).data('id'), 10); 19 | if (campaign.isUnlocked(id, user.completed_level_ids())) { 20 | callback(campaign.getLevelById(id)); 21 | } 22 | }).on('mouseenter', '.level.unlocked', function () { 23 | audio.play('click'); 24 | }); 25 | 26 | return $el; 27 | }; 28 | -------------------------------------------------------------------------------- /app/lib/level-hub/level-template.html: -------------------------------------------------------------------------------- 1 |
4 | {{^is_unlocked}}{{/is_unlocked}} 5 | {{name}} 6 | {{^is_unlocked}}{{/is_unlocked}} 7 |
8 | -------------------------------------------------------------------------------- /app/lib/level-hub/template.html: -------------------------------------------------------------------------------- 1 |
2 | {{#intro_levels}} 3 | {{> levelTemplate}} 4 | {{/intro_levels}} 5 |
6 |
7 |
8 | {{#fast_levels}} 9 | {{> levelTemplate}} 10 | {{/fast_levels}} 11 |
12 |
13 | {{#hard_levels}} 14 | {{> levelTemplate}} 15 | {{/hard_levels}} 16 |
17 |
18 | {{#final_level}} 19 | {{> levelTemplate}} 20 | {{/final_level}} 21 | 22 | {{#survival_level}} 23 | {{> levelTemplate}} 24 | {{/survival_level}} 25 | -------------------------------------------------------------------------------- /app/lib/level-stats/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "level-stats", 3 | "description": "Misses & Guesses", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "twitter/hogan.js": "*" 7 | }, 8 | "development": {}, 9 | "main": "level-stats.js", 10 | "scripts": [ 11 | "level-stats.js", 12 | "template.js" 13 | ], 14 | "styles": [ 15 | "level-stats.css" 16 | ] 17 | } -------------------------------------------------------------------------------- /app/lib/level-stats/level-stats.css: -------------------------------------------------------------------------------- 1 | #level-stats .remaining, 2 | #level-stats .total { 3 | font-size: 45px; 4 | height: 92px; 5 | font-weight: bold; 6 | font-family: Ubuntu, sans-serif; 7 | line-height: 41px; 8 | } 9 | 10 | #level-stats .bar { 11 | width: 76px; 12 | height: 8px; 13 | position: absolute; 14 | background: white; 15 | border-radius: 4px; 16 | top: 65px; 17 | left: 38px; 18 | } 19 | -------------------------------------------------------------------------------- /app/lib/level-stats/level-stats.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var Hogan = require('hogan.js') 3 | var template = Hogan.compile(require('./template')); 4 | 5 | // TODO: Use a flashy animation on each change. 6 | 7 | module.exports = function (levelProgress) { 8 | return $('
') 9 | .html(template.render({ 10 | remaining: levelProgress.completed_round(), 11 | total: levelProgress.rounds() 12 | })); 13 | }; 14 | -------------------------------------------------------------------------------- /app/lib/level-stats/template.html: -------------------------------------------------------------------------------- 1 |
{{remaining}}
2 |
3 |
{{total}}
4 | -------------------------------------------------------------------------------- /app/lib/models/campaign.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var model = require('model'); 3 | var Level = require('./level'); 4 | var plugins = require('./plugins'); 5 | 6 | // Immutable collection of levels. 7 | // TODO: Add bonus levels. 8 | var Campaign = plugins(model('Campaign')) 9 | .attr('intro_levels') 10 | .attr('fast_levels') 11 | .attr('hard_levels') 12 | .attr('final_level') 13 | .attr('survival_level') 14 | ; 15 | 16 | Campaign.on('construct', function (model) { 17 | model._flattenLevels(); 18 | }); 19 | 20 | Campaign.prototype._flattenLevels = function () { 21 | var levels = this._levels = []; 22 | function store (level) { 23 | levels.push(level); 24 | } 25 | this.intro_levels().forEach(store); 26 | this.fast_levels().forEach(store); 27 | this.hard_levels().forEach(store); 28 | store(this.final_level()); 29 | store(this.survival_level()); 30 | }; 31 | 32 | Campaign.prototype.getLevelById = function (id) { 33 | var level = this._levels.filter(function (l) { 34 | return l.id() === id; 35 | })[0]; 36 | console.assert(level, 'Not expecting unknown ids'); 37 | return level; 38 | }; 39 | 40 | Campaign.prototype.isUnlocked = function(levelId, completedLevelIds) { 41 | var requires = this.getLevelById(levelId).requires(); 42 | // Clone. 43 | requires = requires.slice(); 44 | 45 | return requires.length === 0 || 46 | completedLevelIds.some(function (id) { 47 | var completedLevel = this.getLevelById(id); 48 | var idx = requires.indexOf(id); 49 | if (idx > -1) { 50 | requires.splice(idx, 1); 51 | } 52 | if (requires.length === 0) { 53 | return true; 54 | } 55 | }, this); 56 | }; 57 | 58 | Campaign.prototype.toJSONWithUserProgress = function (user) { 59 | var json = this.toJSON(); 60 | var completedLevelIds = user.completed_level_ids(); 61 | var that = this; 62 | function computeIsUnlocked (levelJSON) { 63 | levelJSON.is_completed = completedLevelIds.indexOf(levelJSON.id) != -1; 64 | levelJSON.is_unlocked = that.isUnlocked(levelJSON.id, completedLevelIds); 65 | if (!levelJSON.is_unlocked) { 66 | levelJSON.name = levelJSON.name.replace(/\S/g, '?'); 67 | } 68 | } 69 | json.intro_levels.forEach(computeIsUnlocked); 70 | json.fast_levels.forEach(computeIsUnlocked); 71 | json.hard_levels.forEach(computeIsUnlocked); 72 | computeIsUnlocked(json.final_level); 73 | computeIsUnlocked(json.survival_level); 74 | return json; 75 | }; 76 | 77 | Campaign.MAIN = new Campaign({ 78 | intro_levels: [ 79 | new Level({ 80 | id: 1, 81 | name: 'intro', 82 | min_grade: 10, 83 | max_grade: 30, 84 | num_mistakes_allowed: 4, 85 | timer: 60, 86 | requires: [] 87 | }), 88 | new Level({ 89 | id: 2, 90 | name: 'warmup', 91 | min_grade: 30, 92 | max_grade: 60, 93 | num_mistakes_allowed: 3, 94 | timer: 45, 95 | requires: [1] 96 | }) 97 | ], 98 | fast_levels: [ 99 | new Level({ 100 | id: 3, 101 | name: 'fast', 102 | min_grade: 10, 103 | max_grade: 60, 104 | num_mistakes_allowed: 2, 105 | timer: 15, 106 | requires: [2] 107 | }), 108 | new Level({ 109 | id: 4, 110 | name: 'faster', 111 | min_grade: 10, 112 | max_grade: 60, 113 | num_mistakes_allowed: 2, 114 | timer: 10, 115 | requires: [3] 116 | }), 117 | new Level({ 118 | id: 5, 119 | name: 'fastest', 120 | min_grade: 10, 121 | max_grade: 60, 122 | num_mistakes_allowed: 1, 123 | timer: 5, 124 | requires: [4] 125 | }) 126 | ], 127 | hard_levels: [ 128 | new Level({ 129 | id: 6, 130 | name: 'hard', 131 | min_grade: 45, 132 | max_grade: 70, 133 | num_mistakes_allowed: 2, 134 | timer: 30, 135 | requires: [2] 136 | }), 137 | new Level({ 138 | id: 7, 139 | name: 'harder', 140 | min_grade: 60, 141 | max_grade: 80, 142 | num_mistakes_allowed: 2, 143 | timer: 30, 144 | requires: [6] 145 | }), 146 | new Level({ 147 | id: 8, 148 | name: 'hardest', 149 | min_grade: 75, 150 | max_grade: 100, 151 | num_mistakes_allowed: 1, 152 | timer: 30, 153 | requires: [7] 154 | }) 155 | ], 156 | final_level: new Level({ 157 | id: 9, 158 | name: 'final trial', 159 | num_rounds: 25, 160 | min_grade: 15, 161 | max_grade: 100, 162 | num_mistakes_allowed: 1, 163 | requires: [5, 8] 164 | }), 165 | survival_level: new Level({ 166 | id: 10, 167 | name: 'survival', 168 | num_rounds: 256, 169 | min_grade: 0, 170 | max_grade: 100, 171 | num_mistakes_allowed: 3, 172 | requires: [9] 173 | }) 174 | }); 175 | 176 | module.exports = Campaign; 177 | -------------------------------------------------------------------------------- /app/lib/models/commit.js: -------------------------------------------------------------------------------- 1 | var model = require('model'); 2 | var plugins = require('./plugins'); 3 | 4 | module.exports = plugins(model('Commit')) 5 | .attr('sha') 6 | .attr('patch_number') 7 | .attr('message') 8 | .attr('author_login') 9 | .attr('author_avatar_url') 10 | .attr('author_name') 11 | .attr('repository') 12 | .attr('filename') 13 | .attr('additions') 14 | .attr('deletions') 15 | .attr('old_start_line') 16 | .attr('new_start_line') 17 | .attr('block_name') 18 | .attr('diff_lines') 19 | .attr('grade') 20 | ; 21 | -------------------------------------------------------------------------------- /app/lib/models/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "models", 3 | "description": "Game models", 4 | "dependencies": { 5 | "component/model": "*", 6 | "segmentio/model-defaults": "*", 7 | "component/jquery": "*" 8 | }, 9 | "development": {}, 10 | "main": "models.js", 11 | "scripts": [ 12 | "models.js", 13 | "user-level-progress.js", 14 | "repo.js", 15 | "commit.js", 16 | "plugins.js", 17 | "level.js", 18 | "user.js", 19 | "campaign.js", 20 | "round.js", 21 | "power.js", 22 | "shuffle.js" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /app/lib/models/level.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var model = require('model'); 3 | var Repo = require('./repo'); 4 | var Commit = require('./commit'); 5 | var Round = require('./round'); 6 | var plugins = require('./plugins'); 7 | var shuffle = require('./shuffle'); 8 | 9 | // Immutable level descriptions, declared as part of a Campaign. 10 | var Level = plugins(model('Level')) 11 | .attr('id') 12 | .attr('name') 13 | .attr('num_rounds', { default: 10 }) 14 | .attr('min_grade') 15 | .attr('max_grade') 16 | .attr('num_mistakes_allowed') 17 | .attr('timer') // null: per-Round, based on grade. 18 | .attr('requires') 19 | ; 20 | 21 | Level.prototype.fetchRounds = function (callback) { 22 | var url = '/' + [ 23 | 'level', 24 | this.num_rounds(), 25 | this.min_grade(), 26 | this.max_grade() 27 | ].join('/'); 28 | var defaultTimer = this.timer(); 29 | // TODO: Retry with exponential backoff on errors. 30 | var level = this; 31 | $.getJSON(url, function (commits_json) { 32 | var rounds = commits_json.rounds.map(function(c) { 33 | return new Round({ 34 | commit: new Commit(c.commit), 35 | repos: c.repos.map(Repo), 36 | constant_timer: defaultTimer 37 | }); 38 | }); 39 | 40 | callback(shuffle(rounds, function (r) { 41 | return r.commit().repository(); 42 | })); 43 | }); 44 | }; 45 | 46 | module.exports = Level; 47 | -------------------------------------------------------------------------------- /app/lib/models/models.js: -------------------------------------------------------------------------------- 1 | exports.Campaign = require('./campaign'); 2 | exports.Level = require('./level'); 3 | exports.Round = require('./round') 4 | exports.Commit = require('./commit'); 5 | exports.Repo = require('./repo'); 6 | exports.Power = require('./power'); 7 | 8 | exports.User = require('./user'); 9 | exports.UserLevelProgress = require('./user-level-progress'); 10 | -------------------------------------------------------------------------------- /app/lib/models/plugins.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var defaults = require('model-defaults'); 3 | 4 | /** Patches Model.toJSON() to clone the object and recurse into arrays. */ 5 | module.exports = function (Model) { 6 | return Model 7 | .use(function (m) { 8 | m.prototype.toJSON = function () { 9 | return toJSON(this); 10 | }; 11 | }) 12 | .use(defaults) 13 | ; 14 | } 15 | 16 | function toJSON(val) { 17 | if (val && val.toJSON && val.attrs) { 18 | var result = {}; 19 | for (var key in val.attrs) { 20 | result[key] = toJSON(val.attrs[key]); 21 | } 22 | return result; 23 | } else if (val instanceof Array) { 24 | return $.map(val, toJSON); 25 | } else { 26 | return val; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/lib/models/power.js: -------------------------------------------------------------------------------- 1 | var model = require('model'); 2 | var plugins = require('./plugins'); 3 | 4 | // Immutable power descriptions. 5 | var Power = plugins(model('Level')) 6 | .attr('id') 7 | .attr('price') 8 | .attr('tooltip') 9 | .attr('icon') 10 | ; 11 | 12 | Power.ALL = { 13 | time: new Power({ 14 | id: 'time', 15 | price: 400, 16 | tooltip: 'Rewind \xa0the timer', // \xa0 to force wrapping 17 | icon: 'clock-o' 18 | }), 19 | repo: new Power({ 20 | id: 'repo', 21 | price: 1000, 22 | tooltip: 'Reveal repo details', 23 | icon: 'folder-open' 24 | }), 25 | commit: new Power({ 26 | id: 'commit', 27 | price: 1600, 28 | tooltip: 'Reveal commit details', 29 | icon: 'tags' 30 | }), 31 | half: new Power({ 32 | id: 'half', 33 | price: 2400, 34 | tooltip: 'Reduce wrong choices', 35 | icon: 'adjust' 36 | }) 37 | }; 38 | 39 | module.exports = Power; 40 | -------------------------------------------------------------------------------- /app/lib/models/repo.js: -------------------------------------------------------------------------------- 1 | var model = require('model'); 2 | var plugins = require('./plugins'); 3 | 4 | var Repo = plugins(model('Repo')) 5 | .attr('id') 6 | .attr('name') 7 | .attr('author') 8 | .attr('author_avatar_url') 9 | .attr('description') 10 | .attr('watcher_count') 11 | .attr('star_count') 12 | .attr('hidden'); 13 | 14 | Repo.on('construct', function (m) { 15 | if (!m.description()) { 16 | m.description('No description :('); 17 | } 18 | }); 19 | 20 | module.exports = Repo; -------------------------------------------------------------------------------- /app/lib/models/round.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var model = require('model'); 3 | var Repo = require('./repo'); 4 | var Commit = require('./commit'); 5 | var plugins = require('./plugins'); 6 | 7 | // Immutable round descriptions, fetched from the server by Level.fetchRounds(). 8 | var Round = plugins(model('Round')) 9 | .attr('commit') 10 | .attr('repos') 11 | .attr('constant_timer'); 12 | 13 | Round.prototype.timer = function () { 14 | if (this.constant_timer()) { 15 | return this.constant_timer(); 16 | } else { 17 | // Based on grade. 18 | return parseInt(5 + this.commit().grade() / 2); 19 | } 20 | }; 21 | 22 | module.exports = Round; 23 | -------------------------------------------------------------------------------- /app/lib/models/shuffle.js: -------------------------------------------------------------------------------- 1 | // Given an array return as little as possible consequitive items. 2 | // Note: doesn't care about maintaining order. 3 | function shuffle(arr, id) { 4 | 5 | // Given an array return a unique array and the dups. 6 | function unique(arr) { 7 | var m = {}; 8 | var dups = []; 9 | var ret = []; 10 | 11 | for (var i = 0, item; item = arr[i]; i++) { 12 | if (!m[id(item)]) { 13 | ret.push(item); 14 | m[id(item)] = true; 15 | } else { 16 | dups.push(item); 17 | } 18 | } 19 | return { arr: ret, dups: dups }; 20 | } 21 | 22 | function isEqual(a, b) { 23 | // Don't care about nulls to simplify loop. 24 | if (a == null || b == null) { 25 | return false; 26 | } else { 27 | return id(a) === id(b); 28 | } 29 | } 30 | 31 | var tmp = unique(arr); 32 | arr = tmp.arr; 33 | dups = tmp.dups; 34 | var unshifted = 0; 35 | while (dups.length) { 36 | var item = dups.pop(); 37 | var broke = false; 38 | for (var i = 0; i < arr.length; i++) { 39 | if (!isEqual(item, arr[i]) && !isEqual(item, arr[i - 1])) { 40 | arr.splice(i, 0, item); 41 | broke = true; 42 | break; 43 | } 44 | } 45 | if (!broke) { 46 | // If we couldn't find a place for this dup in the return array 47 | // then unshift in the orignal dups maybe in the future we could 48 | // find a place, however, make sure we don't get stuck. 49 | if (unshifted < dups.length) { 50 | dups.unshift(item); 51 | unshifted++; 52 | } else { 53 | arr.push(item); 54 | } 55 | } 56 | } 57 | 58 | return arr; 59 | } 60 | 61 | module.exports = shuffle; 62 | 63 | // Uncomment and run in node for test. 64 | // if (typeof window === 'undefined') { 65 | // var assert = require('assert'); 66 | // function id(a) { 67 | // return a; 68 | // } 69 | // assert.deepEqual(shuffle([1, 1, 2, 2], id), [1, 2, 1, 2]); 70 | // assert.deepEqual(shuffle([1, 1, 2, 2, 2], id), [2, 1, 2, 1, 2]); 71 | // assert.deepEqual(shuffle([1, 1, 2, 2, 2, 3, 3], id), [1, 2, 3, 2, 1, 2, 3]); 72 | // assert.deepEqual(shuffle([1, 1, 1, 1, 1], id), [1, 1, 1, 1, 1]); 73 | // assert.deepEqual(shuffle([1, 1, 1, 1, 2], id), [1, 2, 1, 1, 1]); 74 | // } 75 | -------------------------------------------------------------------------------- /app/lib/models/user-level-progress.js: -------------------------------------------------------------------------------- 1 | var model = require('model'); 2 | var plugins = require('./plugins'); 3 | 4 | var UserLevelProgress = plugins(model('UserLevelProgress')) 5 | .attr('rounds') 6 | .attr('guessed') 7 | .attr('missed') 8 | .attr('score_earned') // One entry per round, 0 for misses. 9 | .attr('mistakes_left') // < 0 means the level is lost. 10 | ; 11 | 12 | UserLevelProgress.create = function (level) { 13 | return new UserLevelProgress({ 14 | rounds: level.num_rounds(), 15 | guessed: 0, 16 | missed: 0, 17 | score_earned: [], 18 | mistakes_left: level.num_mistakes_allowed() 19 | }); 20 | }; 21 | 22 | UserLevelProgress.prototype.completed_round = function () { 23 | return this.guessed() + this.missed(); 24 | }; 25 | 26 | UserLevelProgress.prototype.recordRoundMissed = function () { 27 | this.missed(this.missed() + 1); 28 | this.mistakes_left(this.mistakes_left() - 1); 29 | this.score_earned().push(0); 30 | }; 31 | 32 | UserLevelProgress.prototype.recordRoundGuessed = function (scoreEarned) { 33 | this.guessed(this.guessed() + 1); 34 | this.score_earned().push(scoreEarned); 35 | }; 36 | 37 | UserLevelProgress.prototype.commmitScore = function () { 38 | return this.score_earned().reduce(function(left, right) { 39 | return left + right; 40 | }); 41 | }; 42 | 43 | UserLevelProgress.prototype.totalScore = function () { 44 | return this.commmitScore() 45 | + this.mistakes_left() * UserLevelProgress.SCORE_PER_LIFE; 46 | }; 47 | 48 | UserLevelProgress.SCORE_PER_LIFE = 50; 49 | 50 | module.exports = UserLevelProgress; 51 | -------------------------------------------------------------------------------- /app/lib/models/user.js: -------------------------------------------------------------------------------- 1 | var model = require('model'); 2 | var plugins = require('./plugins'); 3 | 4 | var User = plugins(model('User')) 5 | .attr('seen_tutorial', { default: false }) 6 | .attr('seen_power_hint', { default: false }) 7 | .attr('score', { default: 0 }) 8 | .attr('powers', { default: {time: 0, repo: 0, commit: 0, half: 0} }) 9 | .attr('completed_level_ids', { default: [] }) 10 | ; 11 | 12 | User.MAX_POWERS = 5; 13 | 14 | User.loadOrCreate = function () { 15 | var user = localStorage.getItem('user'); 16 | return new User(user ? JSON.parse(user) : undefined); 17 | }; 18 | 19 | User.prototype.addScore = function (points) { 20 | this.score(this.score() + points); 21 | }; 22 | 23 | User.prototype.subtractScore = function (value) { 24 | if (this.score() < value) { 25 | throw Error('Not enough score to subtract.'); 26 | } 27 | this.score(this.score() - value); 28 | }; 29 | 30 | User.prototype.addPower = function (power) { 31 | var powers = this.powers(); 32 | if (!this.canStorePower(power)) { 33 | throw Error('No space for power "' + power.id() + '".'); 34 | } 35 | powers[power.id()]++; 36 | this.powers(powers); 37 | }; 38 | 39 | User.prototype.removePower = function (power) { 40 | var powers = this.powers(); 41 | if (powers[power.id()] <= 0) { 42 | throw Error('No power "' + power.id() + '" to remove.'); 43 | } 44 | powers[power.id()]--; 45 | this.powers(powers); 46 | }; 47 | 48 | User.prototype.powerCount = function (power) { 49 | return this.powers()[power.id()]; 50 | }; 51 | 52 | User.prototype.canStorePower = function (power) { 53 | return this.powerCount(power) < User.MAX_POWERS; 54 | }; 55 | 56 | User.prototype.canAffordPower = function (power) { 57 | return this.score() >= power.price(); 58 | }; 59 | 60 | User.prototype.canUsePower = function (power) { 61 | return this.powerCount(power) > 0; 62 | }; 63 | 64 | User.prototype.completeLevel = function (level) { 65 | if (this.completed_level_ids().indexOf(level.id()) === -1) { 66 | this.completed_level_ids().push(level.id()); 67 | } 68 | }; 69 | 70 | User.prototype.isLevelComplete = function (level) { 71 | return this.completed_level_ids().indexOf(level.id()) !== -1; 72 | }; 73 | 74 | User.prototype.persist = function(first_argument) { 75 | localStorage.setItem('user', JSON.stringify(this)); 76 | }; 77 | 78 | module.exports = User; 79 | -------------------------------------------------------------------------------- /app/lib/power-list/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "power-list", 3 | "description": "a list of purchaseable, activatable powers", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "twitter/hogan.js": "*", 7 | "component/humanize-number": "*", 8 | "ianstormtaylor/animate": "*" 9 | }, 10 | "development": {}, 11 | "main": "power-list.js", 12 | "scripts": [ 13 | "power-list.js", 14 | "template.js" 15 | ], 16 | "local": [ 17 | "audio" 18 | ], 19 | "styles": [ 20 | "power-list.css" 21 | ] 22 | } -------------------------------------------------------------------------------- /app/lib/power-list/power-list.css: -------------------------------------------------------------------------------- 1 | .power-list .power { 2 | text-align: center; 3 | font-weight: bold; 4 | width: 150px; 5 | height: 92px; 6 | display: inline-block; 7 | position: absolute; 8 | } 9 | 10 | .power-list .power:nth-child(1) { 11 | left: 0; 12 | top: 0; 13 | } 14 | 15 | .power-list .power:nth-child(2) { 16 | right: 0; 17 | top: 0; 18 | } 19 | 20 | .power-list .power:nth-child(3) { 21 | left: 0; 22 | bottom: 0; 23 | } 24 | 25 | .power-list .power:nth-child(4) { 26 | right: 0; 27 | bottom: 0; 28 | } 29 | 30 | .power-list .power.available { 31 | opacity: 1.0; 32 | cursor: pointer; 33 | } 34 | 35 | .power-list .power:not(.available) { 36 | opacity: 0.3; 37 | } 38 | 39 | .power-list .power.hidden { 40 | visibility: hidden; 41 | } 42 | 43 | .power-list .power.available:hover, 44 | .power-list .power.available:focus, 45 | .power-list .power.available:active { 46 | color: yellow; 47 | } 48 | 49 | .power-list .power .count { 50 | height: 16px; 51 | position: absolute; 52 | left: 97px; 53 | top: 32px; 54 | } 55 | 56 | .power-list .power .icon { 57 | font-size: 50px; 58 | } 59 | 60 | .power-list .power .icon.active { 61 | display: none; 62 | } 63 | 64 | .power-list .tooltip { 65 | position: absolute; 66 | top: 1px; 67 | left: -37px; 68 | display: none; 69 | text-align: right; 70 | color: #fff; 71 | width: 75px; 72 | z-index: 200; 73 | letter-spacing: 1px; 74 | } 75 | 76 | .power-list .power:hover .tooltip { 77 | display: block; 78 | } 79 | 80 | .power-list .power.available:hover .tooltip { 81 | color: yellow; 82 | } 83 | 84 | .power-list .price { 85 | font-family: Ubuntu, sans-serif; 86 | } 87 | 88 | .power-list .price .currency { 89 | color: #F1CE2A; 90 | } 91 | 92 | .power-list .power.available:hover .currency, 93 | .power-list .power.available:focus .currency, 94 | .power-list .power.available:active .currency { 95 | color: yellow !important; 96 | } 97 | -------------------------------------------------------------------------------- /app/lib/power-list/power-list.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var audio = require('audio'); 3 | var humanize = require('humanize-number'); 4 | var animate = require('animate'); 5 | var Hogan = require('hogan.js'); 6 | var template = Hogan.compile(require('./template')); 7 | 8 | 9 | module.exports = function (powers, user, mode, callback) { 10 | var $el = $('
', { class: 'power-list' }); 11 | function update() { 12 | $el.html(template.render({ powers: getPowers(powers, user, mode) })); 13 | } 14 | user.on('change', update); 15 | update(); 16 | 17 | // TODO: Make choices respond to QWER keyboards keys. 18 | $el.on('click', '.power.available', function () { 19 | var type = $(this).attr('data-power-type'); 20 | (callback || $.noop)(powers[type]); 21 | if (mode == 'buy') { 22 | // Hack: animate after next render. 23 | setTimeout(function() { 24 | animate($('[data-power-type=' + type + ']', $el)[0], 'tada'); 25 | }, 1); 26 | } 27 | }) 28 | .on('mouseenter', '.power.available', function () { 29 | audio.play('click'); 30 | }); 31 | 32 | return $el; 33 | }; 34 | 35 | function getPowers(powers, user, mode) { 36 | return $.map(powers, function (power) { 37 | var isAvailable, isVisible, priceDisplay, priceHasIcon; 38 | if (mode == 'buy') { 39 | var canStore = user.canStorePower(power); 40 | isAvailable = user.canAffordPower(power) && canStore; 41 | priceHasIcon = canStore; 42 | priceDisplay = canStore ? humanize(power.price()) : 'FULL'; 43 | isVisible = true; 44 | } else if (mode == 'use') { 45 | isVisible = isAvailable = user.canUsePower(power); 46 | priceDisplay = null; 47 | } else if (mode == 'inactive') { 48 | isVisible = true; 49 | isAvailable = false; 50 | priceDisplay = null; 51 | } else { 52 | throw Error('Invalid mode: ' + mode); 53 | } 54 | return { 55 | type: power.id(), 56 | tooltip: power.tooltip(), 57 | count: user.powerCount(power), 58 | available: isAvailable, 59 | visible: isVisible, 60 | price: priceDisplay, 61 | priceHasIcon: priceHasIcon, 62 | icon: power.icon() 63 | }; 64 | }); 65 | } -------------------------------------------------------------------------------- /app/lib/power-list/template.html: -------------------------------------------------------------------------------- 1 | {{#powers}} 2 |
6 | 7 |
8 | {{#count}}{{count}}{{/count}} 9 |
10 | {{#price}} 11 |
12 | {{price}} {{#priceHasIcon}}G{{/priceHasIcon}} 13 |
14 | {{/price}} 15 |
{{tooltip}}
16 |
17 | {{/powers}} 18 | -------------------------------------------------------------------------------- /app/lib/repo-list/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repo-list", 3 | "description": "List of repos to guess from", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "twitter/hogan.js": "*", 7 | "ianstormtaylor/animate": "*" 8 | }, 9 | "development": {}, 10 | "main": "repo-list.js", 11 | "scripts": [ 12 | "repo-list.js", 13 | "template.js" 14 | ], 15 | "local": [ 16 | "audio" 17 | ], 18 | "styles": [ 19 | "repo-list.css" 20 | ] 21 | } -------------------------------------------------------------------------------- /app/lib/repo-list/repo-list.css: -------------------------------------------------------------------------------- 1 | .repo-list { 2 | padding: 10px 0px 20px 0px; 3 | font-family: Ubuntu, sans-serif; 4 | font-weight: normal; 5 | font-size: 18px; 6 | } 7 | 8 | .repo-list .repo-item { 9 | display: inline-block; 10 | width: 190px; 11 | margin-right: 36px; 12 | } 13 | 14 | .repo-list .repo-item:nth-child(4) { 15 | margin-right: 0; 16 | } 17 | 18 | .repo-list .repo-item.hide { 19 | visibility: hidden; 20 | } 21 | 22 | .repo-list .repo-item .repo-button { 23 | display: inline-block; 24 | width: 190px; 25 | padding: 10px; 26 | cursor: pointer; 27 | text-align: center; 28 | line-height: 21px; 29 | border-top-width: 4px; 30 | border-radius: 10px; 31 | color: white; 32 | } 33 | 34 | .repo-list .label { 35 | white-space: nowrap; 36 | overflow-x: hidden; 37 | text-overflow: ellipsis; 38 | } 39 | 40 | /* Colors */ 41 | .repo-list .repo-item:nth-child(1) .repo-button { 42 | background: #D69201; /* Yellow */ 43 | } 44 | .repo-list .repo-item:nth-child(1) .repo-button:hover, 45 | .repo-list .repo-item:nth-child(1) .repo-button:focus, 46 | .repo-list .repo-item:nth-child(1) .repo-button.hover{ 47 | background: #E4B703; 48 | } 49 | 50 | .repo-list .repo-item:nth-child(2) .repo-button { 51 | background: #AF4127; /* Red */ 52 | } 53 | .repo-list .repo-item:nth-child(2) .repo-button:hover, 54 | .repo-list .repo-item:nth-child(2) .repo-button:focus, 55 | .repo-list .repo-item:nth-child(2) .repo-button.hover { 56 | background: #D14929; 57 | } 58 | 59 | .repo-list .repo-item:nth-child(3) .repo-button { 60 | background: #6BA81D; /* Green */ 61 | } 62 | .repo-list .repo-item:nth-child(3) .repo-button:hover, 63 | .repo-list .repo-item:nth-child(3) .repo-button:focus, 64 | .repo-list .repo-item:nth-child(3) .repo-button.hover { 65 | background: #7ECC1B; 66 | } 67 | 68 | .repo-list .repo-item:nth-child(4) .repo-button { 69 | background: #2451B1; /* Blue */ 70 | } 71 | .repo-list .repo-item:nth-child(4) .repo-button:hover, 72 | .repo-list .repo-item:nth-child(4) .repo-button:focus, 73 | .repo-list .repo-item:nth-child(4) .repo-button.hover { 74 | background: #3C65FC; 75 | } 76 | 77 | .repo-description { 78 | position: absolute; 79 | background-color: #777; 80 | max-width: 450px; 81 | top: 275px; 82 | padding: 20px; 83 | border-radius: 10px; 84 | left:50%; 85 | } 86 | -------------------------------------------------------------------------------- /app/lib/repo-list/repo-list.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var audio = require('audio'); 3 | var Hogan = require('hogan.js'); 4 | var template = Hogan.compile(require('./template')); 5 | var animate = require('animate'); 6 | 7 | module.exports = RepoList; 8 | 9 | function RepoList (repos, onSelect) { 10 | this.repos = repos; 11 | this.onSelect = onSelect; 12 | this.render(); 13 | } 14 | 15 | RepoList.prototype.render = function() { 16 | var repos = this.repos; 17 | 18 | // Extract owner and repo name from the full repo name. 19 | var model = { 20 | repos: repos.map(function (repo) { 21 | var result = repo.toJSON(); 22 | var parts = result.name.split('/'); 23 | result.owner = parts[0]; 24 | result.name = parts[1]; 25 | return result; 26 | }) 27 | }; 28 | 29 | // Render the list and listen to clicks. 30 | // TODO: Make choices respond to 1-4 keyboards keys. 31 | var callback = this.onSelect; 32 | this.$el = $(template.render(model)) 33 | .on('click', '.repo-button', function () { 34 | callback(repos[$(this).closest('.repo-item').index()]); 35 | }) 36 | .on('mouseenter', '.repo-button', function () { 37 | audio.play('click'); 38 | }); 39 | }; 40 | 41 | RepoList.prototype.$getRepoElement = function(repo) { 42 | return this.$el.find('[data-id=' + repo.id() + ']'); 43 | }; 44 | 45 | RepoList.prototype.hideRepos = function(repoToLeave) { 46 | if (this._reposHidden) { 47 | return; 48 | } 49 | this._reposHidden = true; 50 | 51 | var hidden = 0; 52 | this.repos.slice().sort(function () { 53 | return 0.5 - Math.random(); 54 | }).forEach(function (repo) { 55 | if (hidden < 2 && repo.name() != repoToLeave) { 56 | var $repo = this.$getRepoElement(repo); 57 | if (!$repo.hasClass('hide')) { 58 | animate.out($repo[0], 'bounce-down', false, function () { 59 | $repo.addClass('hide'); 60 | }); 61 | hidden++; 62 | } 63 | } 64 | }, this); 65 | }; 66 | 67 | var FADE_SPEED = 250; 68 | 69 | RepoList.prototype.showDescription = function() { 70 | if (this._descShowed) { 71 | this._animateIntro(); 72 | return false; 73 | } 74 | this._descShowed = true; 75 | 76 | var $descs = this.$descs = this.repos.map(function (repo) { 77 | var $repo = this.$getRepoElement(repo); 78 | var $div = $('
', { class: 'repo-description' }) 79 | .text(repo.description()) 80 | .hide() 81 | .appendTo('#content'); 82 | $div.css('margin-left', -1 * ($div.outerWidth() / 2)); 83 | return $div; 84 | }, this); 85 | 86 | // Bind events. 87 | $descs.forEach(function ($desc, i) { 88 | var $button = this.$getRepoElement(this.repos[i]).find('.repo-button'); 89 | $button.mouseenter(function () { 90 | this._cancelAnimation = true; 91 | $desc.fadeIn(FADE_SPEED); 92 | }.bind(this)).mouseleave(function () { 93 | $desc.fadeOut(FADE_SPEED); 94 | }); 95 | }, this); 96 | 97 | this._animateIntro(); 98 | 99 | return true; 100 | }; 101 | 102 | RepoList.prototype._animateIntro = function () { 103 | this._cancelAnimation = false; 104 | 105 | var $descs = this.$descs; 106 | 107 | var animateNext = function (i) { 108 | if (i === $descs.length || this._cancelAnimation) { 109 | return; 110 | } 111 | 112 | if (this.$getRepoElement(this.repos[i]).is('.hide')) { 113 | animateNext(i + 1); 114 | return; 115 | } 116 | 117 | var $button = this.$getRepoElement(this.repos[i]).find('.repo-button'); 118 | audio.play('power-repo'); 119 | animate($button[0], 'pulse'); 120 | $descs[i].fadeIn(FADE_SPEED, function () { 121 | setTimeout(function () { 122 | $descs[i].fadeOut(FADE_SPEED, animateNext.bind(null, i + 1)); 123 | }, 300); 124 | }); 125 | }.bind(this); 126 | 127 | animateNext(0); 128 | }; 129 | 130 | RepoList.prototype.destroy = function () { 131 | if (this.$descs) { 132 | this.$descs.forEach(function ($d) { 133 | $d.remove(); 134 | }); 135 | } 136 | }; 137 | -------------------------------------------------------------------------------- /app/lib/repo-list/template.html: -------------------------------------------------------------------------------- 1 |
2 | {{#repos}} 3 |
4 |
5 |
{{owner}}
{{name}}
6 |
7 |
8 | {{/repos}} 9 |
-------------------------------------------------------------------------------- /app/lib/score-card/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "score-card", 3 | "description": "shows the user score", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "twitter/hogan.js": "*", 7 | "component/humanize-number": "*" 8 | }, 9 | "development": {}, 10 | "main": "score-card.js", 11 | "scripts": [ 12 | "score-card.js", 13 | "template.js" 14 | ], 15 | "styles": [ 16 | "score-card.css" 17 | ] 18 | } -------------------------------------------------------------------------------- /app/lib/score-card/score-card.css: -------------------------------------------------------------------------------- 1 | .score-card-container .score-icon { 2 | font-size: 250%; 3 | } 4 | 5 | .score-card-container .score { 6 | font-weight: bold; 7 | font-family: Ubuntu, sans-serif; 8 | white-space: nowrap; 9 | text-align: center; 10 | } 11 | 12 | .score-card-container .currency { 13 | color: #F1CE2A; 14 | } 15 | 16 | #score-card { 17 | font-size: 20px; 18 | } 19 | 20 | #score-card.animate { 21 | -webkit-animation: score-pulsate 0.5s ease-out; 22 | animation: score-pulsate 0.5s ease-out; 23 | -webkit-animation-iteration-count: 1; 24 | animation-iteration-count: 1; 25 | } 26 | 27 | @keyframes score-pulsate { 28 | 0% { 29 | font-size: 20px; 30 | color: white; 31 | } 32 | 50% { 33 | font-size: 23px; 34 | color: #F1CE2A; 35 | } 36 | 100% { 37 | font-size: 20px; 38 | color: white; 39 | } 40 | } 41 | 42 | @-webkit-keyframes score-pulsate { 43 | 0% { 44 | font-size: 20px; 45 | color: white; 46 | } 47 | 50% { 48 | font-size: 23px; 49 | color: #F1CE2A; 50 | } 51 | 100% { 52 | font-size: 20px; 53 | color: white; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/lib/score-card/score-card.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var humanize = require('humanize-number'); 3 | var Hogan = require('hogan.js'); 4 | var template = Hogan.compile(require('./template')); 5 | 6 | module.exports = function (score) { 7 | return $('
', { class: 'score-card-container' }) 8 | .html(template.render({score: humanize(score)})); 9 | }; 10 | -------------------------------------------------------------------------------- /app/lib/score-card/template.html: -------------------------------------------------------------------------------- 1 | 2 |
{{score}} G
3 | -------------------------------------------------------------------------------- /app/lib/share-buttons/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "share-buttons", 3 | "description": "", 4 | "dependencies": { 5 | "component/jquery": "*" 6 | }, 7 | "development": {}, 8 | "main": "share-buttons.js", 9 | "scripts": [ 10 | "share-buttons.js" 11 | ], 12 | "styles": [ 13 | "share-buttons.css" 14 | ] 15 | } -------------------------------------------------------------------------------- /app/lib/share-buttons/share-buttons.css: -------------------------------------------------------------------------------- 1 | 2 | #share-buttons { 3 | position: fixed; 4 | bottom: 0; 5 | right: 0; 6 | } 7 | 8 | #share-buttons a { 9 | display: inline-block; 10 | font-size: 40px; 11 | width: 55px; 12 | height: 55px; 13 | color: #777; 14 | cursor: pointer; 15 | } 16 | 17 | #share-buttons a:hover { 18 | color: #bbb; 19 | } 20 | 21 | #share-buttons .social-media { 22 | position: absolute; 23 | bottom: 46px; 24 | text-align: center; 25 | right: 14px; 26 | z-index: 1; 27 | display: none; 28 | } 29 | 30 | #share-buttons .social-media a { 31 | display: block; 32 | } 33 | 34 | #share-buttons .social-media.show { 35 | display: block; 36 | 37 | } 38 | 39 | #share-buttons .social-media.show a { 40 | -webkit-animation: show 250ms ease-out; 41 | -moz-animation: show 250ms ease-out; 42 | animation: show 250ms ease-out; 43 | 44 | -webkit-animation-iteration-count: 1; 45 | -moz-animation-iteration-count: 1; 46 | animation-iteration-count: 1; 47 | 48 | -webkit-animation-fill-mode: backwards; 49 | -moz-animation-fill-mode: backwards; 50 | animation-fill-mode: backwards; 51 | } 52 | .share-menu-button { 53 | z-index: 10; 54 | margin-right: 5px; 55 | position: relative; 56 | } 57 | 58 | #share-buttons .social-media a:nth-child(1) { 59 | -webkit-animation-delay: 20ms; 60 | -moz-animation-delay: 20ms; 61 | animation-delay: 20ms; 62 | } 63 | 64 | #share-buttons .social-media a:nth-child(2) { 65 | -webkit-animation-delay: 40ms; 66 | -moz-animation-delay: 40ms; 67 | animation-delay: 40ms; 68 | } 69 | #share-buttons .social-media a:nth-child(3) { 70 | -webkit-animation-delay: 60ms; 71 | -moz-animation-delay: 60ms; 72 | animation-delay: 60ms; 73 | } 74 | 75 | @-webkit-keyframes show { 76 | 0% { 77 | visibility: hidden; 78 | -webkit-transform: translate3d(0, 150px, 0px); 79 | } 80 | 20% { 81 | visibility: hidden; 82 | -webkit-transform: translate3d(0, 100px, 0px); 83 | } 84 | 100% { 85 | -webkit-transform: translate3d(0, 0, 0px); 86 | } 87 | } 88 | 89 | @-moz-keyframes show { 90 | 0% { 91 | visibility: hidden; 92 | -moz-transform: translate3d(0, 150px, 0px); 93 | } 94 | 20% { 95 | visibility: hidden; 96 | -moz-transform: translate3d(0, 100px, 0px); 97 | } 98 | 100% { 99 | -moz-transform: translate3d(0, 0, 0px); 100 | } 101 | } 102 | 103 | @keyframes show { 104 | 0% { 105 | visibility: hidden; 106 | transform: translate3d(0, 150px, 0px); 107 | } 108 | 20% { 109 | visibility: hidden; 110 | transform: translate3d(0, 100px, 0px); 111 | } 112 | 100% { 113 | transform: translate3d(0, 0, 0px); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /app/lib/share-buttons/share-buttons.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | 3 | module.exports = function () { 4 | var inMenu = false; 5 | 6 | $('.social-media').mouseenter(function () { 7 | inMenu = true; 8 | }).mouseleave(function () { 9 | inMenu = false; 10 | $('.social-media').removeClass('show'); 11 | }); 12 | 13 | $('.share-menu-button').on('mouseenter click', function () { 14 | $('.social-media').addClass('show'); 15 | }).mouseleave(function () { 16 | setTimeout(function () { 17 | if (!inMenu) { 18 | $('.social-media').removeClass('show').addClass('hide'); 19 | } 20 | }, 300); 21 | }); 22 | 23 | }; -------------------------------------------------------------------------------- /app/lib/timer/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timer", 3 | "description": "countdown with svg graphics", 4 | "dependencies": { 5 | "mbostock/d3": "*", 6 | "component/jquery": "*", 7 | "ianstormtaylor/animate": "*" 8 | }, 9 | "development": {}, 10 | "main": "timer.js", 11 | "scripts": [ 12 | "timer.js", 13 | "template.js" 14 | ], 15 | "styles": [ 16 | "timer.css" 17 | ], 18 | "local": [ 19 | "audio" 20 | ], 21 | "remotes": [] 22 | } -------------------------------------------------------------------------------- /app/lib/timer/template.html: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /app/lib/timer/timer.css: -------------------------------------------------------------------------------- 1 | .timer { 2 | margin: 0 auto; 3 | width: 160px; 4 | height: 160px; 5 | } 6 | 7 | .timer circle { 8 | fill: transparent; 9 | } 10 | 11 | .timer path { 12 | fill: white; 13 | stroke: white; 14 | stroke-width: 0px; 15 | } 16 | 17 | .timer.panic path { 18 | fill: red; 19 | stroke: red; 20 | stroke-width: 0px; 21 | -webkit-animation: timer-path-pulsate 1s ease-out; 22 | animation: timer-path-pulsate 1s ease-out; 23 | -webkit-animation-iteration-count: infinite; 24 | animation-iteration-count: infinite; 25 | } 26 | 27 | .timer text { 28 | fill: white; 29 | font-weight: bold; 30 | font-family: Ubuntu, sans-serif; 31 | font-size: 50px; 32 | text-anchor: middle; 33 | } 34 | 35 | .timer.panic text { 36 | fill: red; 37 | -webkit-animation: timer-text-pulsate 1s ease-out; 38 | animation: timer-text-pulsate 1s ease-out; 39 | -webkit-animation-iteration-count: infinite; 40 | animation-iteration-count: infinite; 41 | } 42 | 43 | @-webkit-keyframes timer-text-pulsate { 44 | 0% { 45 | font-size: 45px; 46 | opacity: 0.4; 47 | } 48 | 100% { 49 | font-size: 55px; 50 | opacity: 1.0; 51 | } 52 | } 53 | 54 | @keyframes timer-text-pulsate { 55 | 0% { 56 | font-size: 45px; 57 | opacity: 0.4; 58 | } 59 | 100% { 60 | font-size: 55px; 61 | opacity: 1.0; 62 | } 63 | } 64 | 65 | @-webkit-keyframes timer-path-pulsate { 66 | 0% { 67 | stroke-width: 0px; 68 | opacity: 0.4; 69 | } 70 | 100% { 71 | stroke-width: 3px; 72 | opacity: 1; 73 | } 74 | } 75 | 76 | @keyframes timer-path-pulsate { 77 | 0% { 78 | stroke-width: 0px; 79 | opacity: 0.4; 80 | } 81 | 100% { 82 | stroke-width: 3px; 83 | opacity: 1; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/lib/timer/timer.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | var d3 = require('d3'); 3 | var audio = require('audio'); 4 | var template = require('./template'); 5 | var animate = require('animate'); 6 | 7 | var STROKE_WIDTH = 3; 8 | 9 | function Timer (options) { 10 | if (!options.interval) { 11 | throw new Error('Please set an interval'); 12 | } 13 | 14 | this.$el = $(template); 15 | this.interval = options.interval; 16 | this.timeLeft = options.interval; 17 | this.progressWidth = options.progressWidth || 5; 18 | this.outerRadius = options.outerRadius || this.$el.height() / 2; 19 | this.innerRadius = this.outerRadius - this.progressWidth; 20 | this.d3Container = d3.select(this.$el[0]); 21 | this.svg = this.d3Container.append('svg') 22 | .style('width', ((this.outerRadius + STROKE_WIDTH) * 2) + 'px') 23 | .style('height', ((this.outerRadius + STROKE_WIDTH) * 2) + 'px'); 24 | this._completeCallback = options.onComplete || function () {}; 25 | this.timeout = null; 26 | 27 | this._initialDraw(); 28 | } 29 | 30 | Timer.prototype._initialDraw = function () { 31 | var offset = this.outerRadius + STROKE_WIDTH; 32 | this.group = this.svg.append('g').attr( 33 | 'transform', 34 | 'translate(' + offset + ',' + offset +')' 35 | ); 36 | 37 | this.group.append('path'); 38 | this._updatePath(1); 39 | 40 | this.group.append('circle') 41 | .attr('r', this.innerRadius); 42 | 43 | this.group.append('text') 44 | .text(this.timeLeft) 45 | .attr('y', '15px'); 46 | }; 47 | 48 | var SEC = 1000; 49 | 50 | Timer.prototype.start = function() { 51 | this.stop(); 52 | this.timeout = setTimeout(function () { 53 | this.timeLeft--; 54 | if (this.timeLeft === 0) { 55 | this._completeCallback(); 56 | } else { 57 | this.start(); 58 | } 59 | this._update(); 60 | if (this.timeLeft > 0) { 61 | if (this.$el.is('.panic')) { 62 | audio.play('timer-beep'); 63 | } else { 64 | audio.play('timer-tick'); 65 | } 66 | } 67 | }.bind(this), SEC); 68 | }; 69 | 70 | Timer.prototype.stop = function () { 71 | if (this.timeout !== null) { 72 | clearTimeout(this.timeout); 73 | this.timeout = null; 74 | } 75 | }; 76 | 77 | Timer.prototype._update = function() { 78 | this.$el.toggleClass('panic', this.timeLeft <= this.interval * 0.25); 79 | this.group.select('text').text(this.timeLeft); 80 | this._updatePath(this.timeLeft / this.interval); 81 | }; 82 | 83 | var TWOPI = 2 * Math.PI; 84 | 85 | Timer.prototype._updatePath = function (ratioLeft) { 86 | this.group.select('path').attr( 87 | 'd', 88 | d3.svg.arc() 89 | .startAngle(TWOPI) 90 | .endAngle(TWOPI - (ratioLeft * TWOPI)) 91 | .innerRadius(this.innerRadius) 92 | .outerRadius(this.outerRadius) 93 | ); 94 | }; 95 | 96 | Timer.prototype.rewind = function (fraction) { 97 | animate(this.$el[0], 'pulse'); 98 | this.stop(); 99 | 100 | var bonusTime = fraction * this.interval; 101 | this.timeLeft = Math.round( 102 | Math.min(this.interval, this.timeLeft + bonusTime) 103 | ); 104 | 105 | this._update(); 106 | this.start(); 107 | }; 108 | 109 | module.exports = Timer; 110 | -------------------------------------------------------------------------------- /app/lib/track/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "track", 3 | "description": "Event tracking using Google Analytics.", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "amasad/howler.js": "*" 7 | }, 8 | "development": {}, 9 | "main": "track.js", 10 | "scripts": ["track.js"] 11 | } 12 | -------------------------------------------------------------------------------- /app/lib/track/track.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | 3 | var Track = {}; 4 | 5 | Track.initialize = function () { 6 | window.GoogleAnalyticsObject = 'ga'; 7 | window.ga = function () { 8 | window.ga.q.push(arguments); 9 | } 10 | window.ga.q = []; 11 | window.ga.l = Date.now(); 12 | 13 | $.getScript('//www.google-analytics.com/analytics.js'); 14 | 15 | ga('create', 'UA-46058020-1', 'guesshub.io'); 16 | }; 17 | 18 | Track.visit = function () { 19 | ga('send', 'pageview'); 20 | }; 21 | 22 | Track.event = function (category, action, label, value) { 23 | ga('send', 'event', category, action, label, value); 24 | }; 25 | 26 | module.exports = Track; 27 | 28 | // Events tracked so far: 29 | // level 30 | // power 31 | // score 32 | // round 33 | -------------------------------------------------------------------------------- /app/lib/tutorial/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tutorial", 3 | "description": "An intro tutorial to the game.", 4 | "dependencies": { 5 | "component/jquery": "*", 6 | "component/overlay": "*", 7 | "ianstormtaylor/animate": "*", 8 | "component/tip": "*", 9 | "component/aurora-tip": "*" 10 | }, 11 | "development": {}, 12 | "main": "tutorial.js", 13 | "scripts": [ 14 | "tutorial.js" 15 | ], 16 | "styles": [ 17 | "tutorial.css" 18 | ], 19 | "remotes": [], 20 | "local": [ 21 | "models", 22 | "audio" 23 | ] 24 | } -------------------------------------------------------------------------------- /app/lib/tutorial/tutorial.css: -------------------------------------------------------------------------------- 1 | .tip { 2 | font-size: 18px; 3 | -webkit-animation-duration: 0.25s; 4 | animation-duration: 0.25s; 5 | } 6 | 7 | .tip-inner { 8 | border: 1px solid white; 9 | border-radius: 5px; 10 | padding: 15px; 11 | background: black; 12 | } 13 | 14 | .tip-arrow { 15 | border-color: white; 16 | } 17 | 18 | .unshade { 19 | z-index: 501; 20 | position: relative; 21 | } 22 | 23 | .unshade .power { 24 | opacity: 1 !important; 25 | } 26 | 27 | .click-overlay { 28 | z-index: 1001 !important; 29 | opacity: 0 !important; 30 | } 31 | 32 | #intro-top, #intro-bottom { 33 | text-align: center; 34 | font-family: Ubuntu, sans-serif; 35 | font-weight: bold; 36 | font-size: 85px; 37 | line-height: 100%; 38 | z-index: 1002; 39 | position: fixed; 40 | width: 100%; 41 | display: none; 42 | } 43 | 44 | #intro-top { 45 | top: 30%; 46 | } 47 | 48 | #intro-bottom { 49 | top: 40%; 50 | } 51 | -------------------------------------------------------------------------------- /app/lib/tutorial/tutorial.js: -------------------------------------------------------------------------------- 1 | var $ = require('jquery'); 2 | 3 | var models = require('models'); 4 | var Commit = models.Commit; 5 | var Repo = models.Repo; 6 | var Level = models.Level; 7 | var Round = models.Round; 8 | var Campaign = models.Campaign; 9 | 10 | var Overlay = require('overlay'); 11 | var animate = require('animate'); 12 | var Tip = require('tip'); 13 | var audio = require('audio'); 14 | 15 | module.exports = Tutorial; 16 | 17 | function Tutorial (game) { 18 | this.game = game; 19 | this._unshaded = []; 20 | this._overlay = Overlay(); 21 | this._click_overlay = Overlay(); 22 | this.$_click_overlay = $(this._click_overlay.el.els[0]); 23 | this.$_click_overlay.addClass('click-overlay'); 24 | } 25 | 26 | Tutorial.prototype.start = function () { 27 | this._showIntroStep(); 28 | }; 29 | 30 | Tutorial.prototype._unshade = function (var_args) { 31 | for (var i = 0; i < arguments.length; i++) { 32 | var $el = arguments[i]; 33 | $el.addClass('unshade'); 34 | this._unshaded.push($el); 35 | } 36 | }; 37 | 38 | Tutorial.prototype._showIntroStep = function() { 39 | this._overlay.show(); 40 | this.game.clear(); 41 | 42 | // Set up a functio wait for both the intro and the level load. 43 | var level = Campaign.MAIN.getLevelById(1); 44 | var readyCount = 2; 45 | var savedRounds = null; 46 | var ready = function () { 47 | readyCount--; 48 | if (readyCount == 0) { 49 | // Once we're ready, start the game. 50 | this.game.initLevel(level, savedRounds); 51 | this._showRepoSelectStep(); 52 | } 53 | }.bind(this); 54 | 55 | // Start loading the level. 56 | this.game.loadLevel(level, function (rounds) { 57 | savedRounds = rounds; 58 | ready(); 59 | }, true); 60 | 61 | // Play the animation. 62 | var top = $('
').attr('id', 'intro-top').text('Guess') 63 | .appendTo('body')[0]; 64 | var bottom = $('
').attr('id', 'intro-bottom').text('Hub') 65 | .appendTo('body')[0]; 66 | 67 | $(top, bottom).show(); 68 | animate.in(top, 'bounce-down'); 69 | setTimeout(animate.in.bind(animate, bottom, 'bounce-up', function () { 70 | animate.out(top, 'bounce-up', function() { $(top).remove() }); 71 | setTimeout(animate.out.bind(animate, bottom, 'bounce-down', function () { 72 | $(bottom).remove(); 73 | ready(); 74 | }.bind(this)), 100); 75 | }.bind(this)), 150); 76 | }; 77 | 78 | Tutorial.prototype._showRepoSelectStep = function() { 79 | this._click_overlay.show(); 80 | this.game.timer.stop(); 81 | 82 | this._unshade(this.game.$repos, this.game.$commitDisplay); 83 | 84 | var tip = new Tip('Select which repository this commit comes from.'); 85 | tip.position('south'); 86 | tip.show(this.game.$repos[0]); 87 | animate.in(tip.el, 'fade-up'); 88 | 89 | this.$_click_overlay.click(function () { 90 | animate.out(tip.el, 'fade-down', false, function () { 91 | $(tip.el).remove(); 92 | this._showTimerStep(); 93 | }.bind(this)); 94 | }.bind(this)); 95 | 96 | audio.play('tutorial-tip'); 97 | }; 98 | 99 | Tutorial.prototype._showTimerStep = function () { 100 | this._unshade(this.game.$timer); 101 | 102 | var tip = new Tip('Quick! Choose before the time runs out!'); 103 | tip.position('west'); 104 | tip.show(this.game.$timer[0]); 105 | animate.in(tip.el, 'fade-left'); 106 | 107 | this.$_click_overlay.click(function () { 108 | animate.out(tip.el, 'fade-right', function () { 109 | $(tip.el).remove(); 110 | this._unshadeAll(); 111 | this._overlay.hide(); 112 | this._click_overlay.hide(); 113 | this.game.startTime = Date.now(); 114 | this.game.timer.start(); 115 | this.game.user.seen_tutorial(true); 116 | }.bind(this)); 117 | }.bind(this)); 118 | 119 | audio.play('tutorial-tip'); 120 | }; 121 | 122 | Tutorial.prototype._unshadeAll = function () { 123 | this._unshaded.forEach(function ($el) { 124 | $el.removeClass('unshade'); 125 | }); 126 | }; 127 | 128 | Tutorial.prototype.showPowerHint = function() { 129 | this._overlay.show(); 130 | this._click_overlay.show(); 131 | this._unshade(this.game.$powerList); 132 | this._unshade(this.game.$scoreCard); 133 | 134 | var tip = new Tip('Purchase power-ups with score gained during levels.'); 135 | tip.position('south'); 136 | tip.show(this.game.$powerList[0]); 137 | animate.in(tip.el, 'fade-down'); 138 | 139 | this.$_click_overlay.click(function () { 140 | animate.out(tip.el, 'fade-down', function () { 141 | $(tip.el).remove(); 142 | this._unshadeAll(); 143 | this._overlay.hide(); 144 | this._click_overlay.hide(); 145 | this.game.user.seen_power_hint(true); 146 | }.bind(this)); 147 | }.bind(this)); 148 | 149 | audio.play('tutorial-tip'); 150 | }; 151 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.0.0", 4 | "description": "Install [component](https://github.com/component)", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "BSD-2-Clause", 11 | "devDependencies": { 12 | "jake": "~0.7.3", 13 | "glob": "~3.2.6", 14 | "component-builder": "~0.10.0", 15 | "component-minify": "~1.1.1", 16 | "mkdirp": "~0.3.5", 17 | "rimraf": "~2.2.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | model.json 3 | commit_index.pickle 4 | -------------------------------------------------------------------------------- /backend/app.wsgi: -------------------------------------------------------------------------------- 1 | import os, sys 2 | 3 | # Override import path. 4 | ROOT = os.path.dirname(os.path.realpath(__file__)) 5 | sys.path.insert(0, ROOT) 6 | 7 | from server import APP as application 8 | -------------------------------------------------------------------------------- /backend/config.py: -------------------------------------------------------------------------------- 1 | # GitHub auth. 2 | GITHUB_CLIENT_ID = '6e91d029d2eeca74bf24' 3 | GITHUB_CLIENT_SECRET = '' 4 | 5 | # The user agent to use for GitHub requests. 6 | CLIENT_USER_AGENT = 'max99x/guesshub' 7 | 8 | # DB auth. 9 | DB_HOST = 'localhost' 10 | DB_USER = 'root' 11 | DB_PASSWORD = 'root' 12 | DB_NAME = 'commit_game' 13 | -------------------------------------------------------------------------------- /backend/crawl.py: -------------------------------------------------------------------------------- 1 | import config 2 | import github 3 | import model 4 | import MySQLdb as mysql 5 | 6 | # How many pages of commits to look at (100 per page). 7 | PAGES = 1 8 | 9 | # The maximum number of rows to insert at a time. 10 | BATCH_SIZE = 10 11 | 12 | 13 | def encode(row): 14 | """UTF8-encodes all strings in an iterable, returning a tuple.""" 15 | return tuple([i.encode('utf8') if isinstance(i, unicode) 16 | else i for i in row]) 17 | 18 | 19 | def insert_repository(cursor, repo): 20 | """Inserts a Repository object into the database using a given cursor.""" 21 | sql = ('REPLACE INTO repository VALUES ' + 22 | '(DEFAULT, %s, %s, %s, %s, %s, %s, %s, %s, %s)') 23 | row = (repo.id, 24 | repo.name, 25 | repo.author, 26 | repo.author_avatar_url, 27 | repo.description.encode('utf8'), 28 | repo.is_private, 29 | repo.is_fork, 30 | repo.watcher_count, 31 | repo.star_count) 32 | cursor.execute(sql, encode(row)) 33 | 34 | 35 | def insert_commit(cursor, commits): 36 | """Inserts a Commit object into the database using a given cursor.""" 37 | sql = ('REPLACE INTO commit VALUES' 38 | '(DEFAULT, %s, %s, %s, %s, %s, %s,' 39 | ' %s, %s, %s, %s, %s, %s, %s, %s)') 40 | rows = [] 41 | 42 | def flush(): 43 | try: 44 | cursor.executemany(sql, rows) 45 | except Exception as e: 46 | print commits[0].repository, [commit.sha for commit in commits] 47 | raise e 48 | 49 | for commit in commits: 50 | rows.append(encode((commit.sha, 51 | commit.patch_number, 52 | commit.message, 53 | commit.author_login, 54 | commit.author_name, 55 | commit.author_avatar_url, 56 | commit.repository, 57 | commit.filename, 58 | commit.additions, 59 | commit.deletions, 60 | commit.old_start_line, 61 | commit.new_start_line, 62 | commit.block_name, 63 | u'\n'.join(commit.diff_lines)))) 64 | if len(rows) >= BATCH_SIZE: 65 | flush() 66 | rows = [] 67 | if len(rows) > 0: 68 | flush() 69 | 70 | 71 | def commit_exists(cursor, sha): 72 | """Returns whether at least one commit with a given exists in the database.""" 73 | cursor.execute('SELECT COUNT(*) FROM commit WHERE sha = %s', sha) 74 | return cursor.fetchone()[0] > 0 75 | 76 | 77 | def crawl(): 78 | """Crawls the top repositories, collecting commits.""" 79 | db = mysql.connect( 80 | host=config.DB_HOST, 81 | user=config.DB_USER, 82 | passwd=config.DB_PASSWORD, 83 | db=config.DB_NAME) 84 | cursor = db.cursor() 85 | gh = github.GitHub( 86 | config.GITHUB_CLIENT_ID, 87 | config.GITHUB_CLIENT_SECRET, 88 | config.CLIENT_USER_AGENT) 89 | 90 | try: 91 | for repo in gh.GetTopRepositories(): 92 | print 'Starting repo ', repo.name 93 | insert_repository(cursor, repo) 94 | for commit_sha in gh.GetCommitsList(repo.name, PAGES): 95 | if commit_exists(cursor, commit_sha): 96 | print ' Skipping SHA ' + commit_sha 97 | else: 98 | # TODO(max99x): Log and skip errors. 99 | print ' Getting SHA ' + commit_sha, '->', 100 | commits = list(gh.GetCommits(repo.name, commit_sha)) 101 | print '%d commit(s)' % len(commits) 102 | insert_commit(cursor, commits) 103 | yield 104 | finally: 105 | cursor.close() 106 | db.close() 107 | 108 | 109 | def main(): 110 | """Crawls the top repositories, collecting commits.""" 111 | crawler = crawl() 112 | while True: 113 | crawler.next() 114 | 115 | 116 | if __name__ == '__main__': 117 | main() 118 | -------------------------------------------------------------------------------- /backend/equalize.py: -------------------------------------------------------------------------------- 1 | import config 2 | import MySQLdb as mysql 3 | 4 | MAX_ROWS = 20000 5 | 6 | def main(): 7 | db = mysql.connect( 8 | host=config.DB_HOST, 9 | user=config.DB_USER, 10 | passwd=config.DB_PASSWORD, 11 | db=config.DB_NAME) 12 | cursor = db.cursor() 13 | cursor.execute('SELECT repository, COUNT(*) as count ' 14 | 'FROM commit ' 15 | 'WHERE grade >= 0 ' 16 | 'GROUP BY repository ' 17 | 'HAVING count >= %s ' 18 | 'ORDER BY count DESC', 19 | (MAX_ROWS,)) 20 | 21 | for repo, count in cursor.fetchall(): 22 | cursor.execute('UPDATE commit ' 23 | 'SET grade=-grade ' 24 | 'WHERE grade >= 0 AND repository = %s ' 25 | 'LIMIT %s', 26 | (repo, count - MAX_ROWS)) 27 | 28 | 29 | if __name__ == '__main__': 30 | main() 31 | -------------------------------------------------------------------------------- /backend/github.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import re 3 | import requests 4 | import time 5 | 6 | import config 7 | import model 8 | 9 | # The maximum number of entries that can be requested per page. 10 | MAX_PAGE_SIZE = 100 11 | 12 | # Implementation constants. 13 | NEXT_PAGE_REGEX = re.compile(r'<([^<>]+)>; rel="next"') 14 | TOTAL_PAGES_REGEX = re.compile(r'<[^<>]+=(\d+)>; rel="last"') 15 | 16 | 17 | class GitHub(object): 18 | def __init__(self, client_id, client_secret, user_agent): 19 | self.client_id = client_id 20 | self.client_secret = client_secret 21 | self.user_agent = user_agent 22 | 23 | def Fetch(self, url, **params): 24 | """ 25 | Performs an authenticated GitHub request, retrying on server errors and 26 | sleeping if the rate limit is exceeded. 27 | 28 | Args: 29 | url: The URL to request. An absolute path or full URL. Can include a 30 | query string. 31 | prams: The query parameters to send. 32 | 33 | Returns: 34 | A requests.Response object. 35 | """ 36 | full_params = params.copy() 37 | full_params['client_id'] = self.client_id 38 | full_params['client_secret'] = self.client_secret 39 | request_headers = {'User-Agent': self.user_agent} 40 | if not url.startswith('https://api.github.com/'): 41 | url = 'https://api.github.com/' + url 42 | result = requests.get(url, params=full_params, headers=request_headers) 43 | 44 | # Explode on client errors and retry on server error. 45 | status = result.status_code 46 | if status >= 500: 47 | return self.Fetch(url, **params) 48 | elif status == 403: 49 | # Sleep and retry when we hit the rate limit. 50 | print 'Hit rate limit.' 51 | response_headers = result.headers 52 | reset_time = int(response_headers['X-RateLimit-Reset']) 53 | delay_time = int(reset_time - time.time()) + 1 54 | if delay_time > 0: # Time sync issues may result in negative delay. 55 | print 'Sleeping for', delay_time, 'seconds...' 56 | time.sleep(delay_time) 57 | return self.Fetch(url, **params) 58 | elif status >= 400 and status < 500: 59 | raise RuntimeError('Client error, HTTP %s.\n' 60 | 'Path: %s\nParams: %s\nResponse: %s' % 61 | (status, url, params, result.json())) 62 | 63 | # All's well that ends well. 64 | return result 65 | 66 | def List(self, url, pages=None, **params): 67 | """Fetches and yields each item from a GH listing.""" 68 | if pages is None: 69 | page_range = itertools.repeat(1) 70 | else: 71 | page_range = range(pages) 72 | 73 | for page in page_range: 74 | # Fetch current page. 75 | response = self.Fetch(url, per_page=MAX_PAGE_SIZE, **params) 76 | json = response.json() 77 | if isinstance(json, dict): 78 | assert 'items' in json, json 79 | json = json['items'] 80 | for item_json in json: 81 | yield item_json 82 | 83 | # Get next page. 84 | if 'Link' in response.headers: 85 | next_page_links = NEXT_PAGE_REGEX.findall(response.headers['Link']) 86 | if next_page_links: 87 | assert len(next_page_links) == 1, response.headers 88 | url = next_page_links[0] 89 | 90 | def GetCommitsList(self, repo, pages_count): 91 | """Yields the SHAs of recent commits given a repo name.""" 92 | for commit_json in self.List('repos/%s/commits' % repo, pages_count): 93 | yield commit_json['sha'] 94 | 95 | def GetCommits(self, repo, sha): 96 | """Yields Commit objects for each patch in a given commit. 97 | 98 | The commit is specified by a repo name and a SHA hash. 99 | """ 100 | commit_json = self.Fetch('repos/%s/commits/%s' % (repo, sha)).json() 101 | for commit in model.Commit.split_from_json(commit_json): 102 | yield commit 103 | 104 | def GetStarCount(self, repo): 105 | """Returns the number stars for a repository given its name.""" 106 | response = self.Fetch('repos/%s/stargazers' % repo) 107 | first_page_count = len(response.json()) 108 | 109 | if 'Link' in response.headers: 110 | # Multiple pages. Estimate. 111 | matches = TOTAL_PAGES_REGEX.findall(response.headers['Link']) 112 | assert matches and len(matches) == 1, response.headers 113 | total_pages = int(matches[0]) 114 | return (total_pages - 1) * first_page_count + first_page_count / 2 115 | else: 116 | # One page. Exact number. 117 | return first_page_count 118 | 119 | def GetUserRespositories(self, username): 120 | """Yields the repositories of a given user as Repository objects.""" 121 | for repository_json in self.List('users/%s/repos' % username): 122 | yield model.Repository(repository_json, 123 | self.GetStarCount(repository_json['full_name'])) 124 | 125 | def GetTopUsers(self): 126 | """Yields the usernames of the top 10,000 users.""" 127 | search_url = 'search/users?q=followers%3A%3E%3D0&sort=followers' 128 | for user_json in self.List(search_url): 129 | yield user_json['login'] 130 | 131 | def GetTopRepositories(self): 132 | """Yields the top 10,000 repositories as Repository objects.""" 133 | search_url = 'search/repositories?q=stars%3A>%3D0&sort=stars' 134 | for repository_json in self.List(search_url): 135 | yield model.Repository(repository_json, 136 | self.GetStarCount(repository_json['full_name'])) 137 | -------------------------------------------------------------------------------- /backend/grade.py: -------------------------------------------------------------------------------- 1 | import config 2 | import collections 3 | import math 4 | import json 5 | import re 6 | import sys 7 | import MySQLdb as mysql 8 | import MySQLdb.cursors as mysql_cursors 9 | from os import path 10 | 11 | COUNT_SQL = 'SELECT MAX(order_id) FROM commit' 12 | BATCH_SQL = 'SELECT * FROM commit WHERE %s <= order_id AND order_id < %s' 13 | UPDATE_SQL = 'UPDATE commit SET grade = %s WHERE order_id = %s' 14 | BATCH_SIZE = 1000 15 | WORD_REGEX = re.compile(r'\w+') 16 | SNAKE_REGEX = re.compile(r'[a-zA-Z]{3,}') 17 | CAMEL_REGEX = re.compile(r'(?:[A-Z]|\b)[a-zA-Z]{2,}') 18 | TOKEN_REGEX = re.compile(r'[\x21-\x2f\x3a-\x40\x5B-\x60]+|' 19 | r'(?:[A-Z]|\b)[a-zA-Z]{2,}|' 20 | r'[a-z]{3,}') 21 | 22 | MIN_LINE_COUNT = 3 23 | MAX_LINE_COUNT = 25 24 | MIN_DIFF_COUNT = 2 25 | MAX_LINE_LENGTH = 80 26 | MIN_SPACE_RATIO = 0.01 27 | MIN_WORDS_COUNT = 10 28 | MIN_CONTEXT_LINE_COUNT = 1 29 | MAX_KEYWORDS_COUNT = 12 30 | 31 | SMALL_LINE_COUNT = 5 32 | LARGE_LINE_COUNT = 20 33 | 34 | ALLOWED_TYPES = { 35 | 'c', 'h', 36 | 'cc', 'cpp', 'cxx', 'hh', 'hpp', 'hxx', 37 | 'coffee', 'coffeescript', 'litcoffee', 38 | 'cs' 39 | 'd', 40 | 'go', 41 | 'hs', 'lhs', 42 | 'java', 43 | 'scala', 44 | 'js', 'ts', 45 | 'lua', 46 | 'php', 'phtml', 47 | 'py', 'pyw', 48 | 'r', 49 | 'rb', 50 | 'scm', 51 | 'sh', 'bash', 'zsh', 52 | 'sql', 53 | 'groovy', 'gvy', 'gy', 54 | 'gsh', 'vsh', 'fsh', 'shader', 55 | } 56 | STOP_WORDS = { 57 | "a", "about", "above", "after", "again", "against", "all", "am", "an", "and", 58 | "any", "are", "aren\'t", "as", "at", "be", "because", "been", "before", 59 | "being", "below", "between", "both", "but", "by", "can\'t", "cannot", 60 | "could", "couldn\'t", "did", "didn\'t", "do", "does", "doesn\'t", "doing", 61 | "don\'t", "down", "during", "each", "few", "for", "from", "further", "had", 62 | "hadn\'t", "has", "hasn\'t", "have", "haven\'t", "having", "he", "he\'d", 63 | "he\'ll", "he\'s", "her", "here", "here\'s", "hers", "herself", "him", 64 | "himself", "his", "how", "how\'s", "i", "i\'d", "i\'ll", "i\'m", "i\'ve", 65 | "if", "in", "into", "is", "isn\'t", "it", "it\'s", "its", "itself", "let\'s", 66 | "me", "more", "most", "mustn\'t", "my", "myself", "no", "nor", "not", "of", 67 | "off", "on", "once", "only", "or", "other", "ought", "our", "ours ", 68 | "ourselves", "out", "over", "own", "same", "shan\'t", "she", "she\'d", 69 | "she\'ll", "she\'s", "should", "shouldn\'t", "so", "some", "such", "than", 70 | "that", "that\'s", "the", "their", "theirs", "them", "themselves", "then", 71 | "there", "there\'s", "these", "they", "they\'d", "they\'ll", "they\'re", 72 | "they\'ve", "this", "those", "through", "to", "too", "under", "until", "up", 73 | "very", "was", "wasn\'t", "we", "we\'d", "we\'ll", "we\'re", "we\'ve", "were", 74 | "weren\'t", "what", "what\'s", "when", "when\'s", "where", "where\'s", 75 | "which", "while", "who", "who\'s", "whom", "why", "why\'s", "with", "won\'t", 76 | "would", "wouldn\'t", "you", "you\'d", "you\'ll", "you\'re", "you\'ve", 77 | "your", "yours", "yourself", "yourselves" 78 | } 79 | 80 | DB = mysql.connect( 81 | host=config.DB_HOST, 82 | user=config.DB_USER, 83 | passwd=config.DB_PASSWORD, 84 | db=config.DB_NAME, 85 | cursorclass=mysql_cursors.DictCursor) 86 | 87 | 88 | class Model(object): 89 | """A naive Bayesian text classifier.""" 90 | 91 | def __init__(self): 92 | self.samples = collections.defaultdict( 93 | lambda: collections.defaultdict(lambda: 1)) 94 | self.label_counts = collections.defaultdict(lambda: 0) 95 | 96 | def probability(self, features, label): 97 | """Returns the probability of a feature set being labeled with a given label. 98 | 99 | The features a list of strings. The returned value is between 0 and 1. 100 | """ 101 | total_labels = sum(self.label_counts.values()) 102 | log_prob = self._scaled_log_probability(features, label, total_labels) 103 | total_log_prob = log_prob 104 | for other_label in self.label_counts: 105 | if other_label != label: 106 | other_log_prob = self._scaled_log_probability( 107 | features, other_label, total_labels) 108 | total_log_prob = self._log_add(total_log_prob, other_log_prob) 109 | return math.exp(log_prob - total_log_prob) 110 | 111 | def classify(self, features): 112 | """Returns the most likely label for a given feature set (string list).""" 113 | total_labels = sum(self.label_counts.values()) 114 | max_prob = -1 115 | max_val = None 116 | for label in self.label_counts: 117 | prob = self._scaled_log_probability(features, label, total_labels) 118 | if prob > max_prob: 119 | max_prob = prob 120 | max_val = label 121 | return max_val 122 | 123 | def dump(self, filename): 124 | """Saves the model to a file in JSON format.""" 125 | f = open(filename, 'w') 126 | json.dump([m.samples, m.label_counts], f) 127 | f.close() 128 | 129 | @staticmethod 130 | def load(filename): 131 | """Loads a model previously saved by Model.dump().""" 132 | data = json.load(open(filename)) 133 | m = Model() 134 | m.samples = data[0] 135 | m.label_counts = data[1] 136 | return m 137 | 138 | @staticmethod 139 | def build(): 140 | """Generates a classifier based on all the commits in the DB.""" 141 | m = Model() 142 | for batch in fetch_commits(): 143 | for commit in batch: 144 | tokens = tokenize( 145 | commit['diff_lines'] + ' ' + (commit['block_name'] or '')) 146 | tokens = [i.lower() for i in tokens] 147 | counts = collections.defaultdict(int) 148 | for token in tokens: 149 | if token not in STOP_WORDS: 150 | counts[token] += 1 151 | top_tokens = sorted(counts.iteritems(), key=lambda x:-x[1])[:20] 152 | features = [i[0] for i in top_tokens] 153 | features.append(path.splitext(commit['filename'])[1]) 154 | m._add_sample(features, commit['repository']) 155 | 156 | return m 157 | 158 | def _add_sample(self, features, label): 159 | """Adds a feature set (string list) and its label to the samples.""" 160 | for feature in features: 161 | self.samples[feature][label] += 1 162 | self.label_counts[label] += 1 163 | 164 | def _scaled_log_probability(self, features, label, total_labels): 165 | """Returns the likelihood of features to be labeled with the given label. 166 | 167 | The returned value is scaled by the probability of the features, so 168 | it is only meaningful when compared to values returned from calls to 169 | this method that pass the same features (but a different label). 170 | 171 | The value is in log space because it may exceed the range of a double 172 | precision floating point number. 173 | """ 174 | label_count = float(self.label_counts[label]) 175 | log_class_probability = math.log(label_count / total_labels) 176 | log_correlation_probability = 0 177 | default_log_prob = math.log(1 / label_count) 178 | for feature in features: 179 | log_prob = default_log_prob 180 | feature_counts = self.samples.get(feature) 181 | if feature_counts is not None: 182 | count = feature_counts.get(label, 1) 183 | if count != 1: 184 | log_prob = count / label_count 185 | log_correlation_probability += log_prob 186 | return log_class_probability + log_correlation_probability 187 | 188 | @staticmethod 189 | def _log_add(log_x, log_y): 190 | """Returns log(x + y) given log(x) and log(y).""" 191 | if log_y > log_x: 192 | log_x, log_y = log_y, log_x 193 | if log_x == float('-inf'): 194 | return log_x 195 | neg_diff = log_y - log_x 196 | if neg_diff < -20: 197 | return log_x 198 | return log_x + math.log(1.0 + math.exp(neg_diff)) 199 | 200 | 201 | def compute_grade(commit, classifier): 202 | """Computes a grade for a given commit, either -1 or between 0 and 100. 203 | 204 | -1 signifies invalid commits. Otherwise harder commits have higher grades. 205 | """ 206 | 207 | # Skip non code diffs. 208 | (_, ext) = path.splitext(commit['filename']) 209 | if ext[1:] not in ALLOWED_TYPES: 210 | return -1 211 | 212 | lines = commit['diff_lines'].split('\n') 213 | 214 | # Skip diffs with too few lines. 215 | if len(lines) < MIN_LINE_COUNT: 216 | return -1 217 | 218 | # Skip diffs with too many lines. 219 | if len(lines) > MAX_LINE_COUNT: 220 | return -1 221 | 222 | # Skip diffs with long lines. 223 | if any([len(l) > MAX_LINE_LENGTH for l in lines]): 224 | return -1 225 | 226 | # Skip diffs with too little spacing. 227 | space_count = commit['diff_lines'].replace('\t', ' ').count(' ') 228 | if space_count / float(len(commit['diff_lines'])) < MIN_SPACE_RATIO: 229 | return -1 230 | 231 | # Skip diffs with too few diff lines. 232 | diff_count = len([i for i in lines if i[:1] in ('+', '-')]) 233 | if diff_count < MIN_DIFF_COUNT: 234 | return -1 235 | 236 | # Skip diffs with too few context lines. 237 | context_count = len([i for i in lines if i[:1] not in ('+', '-', '\\')]) 238 | if context_count < MIN_CONTEXT_LINE_COUNT: 239 | return -1 240 | 241 | # Skip diffs with too few words. 242 | words = extract_words(commit['diff_lines'], commit['block_name']) 243 | if len(words) < MIN_WORDS_COUNT: 244 | return -1 245 | 246 | # How many keywords do we have that reference the repo name? 247 | repo_keywords = set(extract_words(commit['repository'])) 248 | keywords_count = len([i for i in words if i in repo_keywords]) 249 | 250 | # Skip diffs with too many keywords. 251 | if keywords_count > MAX_KEYWORDS_COUNT: 252 | return -1 253 | 254 | # How many keywords do we have that reference the metadata? 255 | metadata = (commit['filename'], commit['author_login'], commit['author_name']) 256 | metadata_keywords = set(extract_words(*metadata)) 257 | metadata_keyword_matches = [i for i in words if i in metadata_keywords] 258 | metadata_keywords_count = len(metadata_keyword_matches) 259 | 260 | # Calculate the grade, starting from perfectly hard. 261 | if keywords_count > 0: 262 | # Has repo keywords. Difficulty <= 50. 263 | grade = 42 - min(keywords_count, 6) * 7 264 | grade += min(len(lines) - MIN_LINE_COUNT, 8) 265 | else: 266 | # No repo keywords. Difficulty > 50. 267 | grade = 100 268 | 269 | # Maybe we have metadata keywords? 270 | grade -= min(metadata_keywords_count, 5) * 2 271 | 272 | # Classify. 273 | tokens = tokenize(commit['diff_lines'] + ' ' + (commit['block_name'] or '')) 274 | probability = classifier.probability(tokens, commit['repository']) 275 | if probability < 0.001 and probability != 0: 276 | # Switch to log scaling here. 277 | grade -= min(-math.log(probability) / 15, 40) 278 | else: 279 | grade -= (1 - probability) * 40 280 | 281 | return int(round(grade)) 282 | 283 | 284 | def tokenize(s): 285 | """Extracts tokesn from a string.""" 286 | return TOKEN_REGEX.findall(s) 287 | 288 | 289 | def extract_words(*strings): 290 | """Extracts words from all strings in a list.""" 291 | text = ' '.join([i or '' for i in strings]) 292 | words = WORD_REGEX.findall(text) 293 | 294 | tokens = [] 295 | for word in words: 296 | word_lower = word.lower() 297 | tokens.append(word_lower) 298 | # Extract snake- and camel-case words. 299 | snakes = SNAKE_REGEX.findall(word) 300 | camels = CAMEL_REGEX.findall(word) 301 | for subword in set(snakes + camels): 302 | subword_lower = subword.lower() 303 | if subword_lower != word_lower: 304 | tokens.append(subword_lower) 305 | 306 | return tokens 307 | 308 | 309 | def fetch_commits(): 310 | """Yields batches of commits from the DB.""" 311 | count_cursor = DB.cursor() 312 | count_cursor.execute(COUNT_SQL) 313 | count = count_cursor.fetchone()['MAX(order_id)'] 314 | 315 | read_cursor = DB.cursor() 316 | for start in range(0, count + BATCH_SIZE, BATCH_SIZE): 317 | print 'Starting at', start 318 | read_cursor.execute(BATCH_SQL, (start, start + BATCH_SIZE)) 319 | yield read_cursor.fetchall() 320 | 321 | 322 | def update_grades(classifier): 323 | """Updates all commits with newly-computed grades.""" 324 | write_cursor = DB.cursor() 325 | for batch in fetch_commits(): 326 | ids = [c['order_id'] for c in batch] 327 | grades = map(lambda c: compute_grade(c, classifier), batch) 328 | write_cursor.executemany(UPDATE_SQL, zip(grades, ids)) 329 | pos_grades = [i for i in grades if i >= 0] 330 | print ' invalid=%d, min=%s, max=%s, mean=%s, median=%s' % ( 331 | len(grades) - len(pos_grades), 332 | min(pos_grades) if pos_grades else None, 333 | max(pos_grades) if pos_grades else None, 334 | sum(pos_grades) / float(len(pos_grades)) if pos_grades else None, 335 | sorted(pos_grades)[len(pos_grades) / 2] if pos_grades else None) 336 | 337 | 338 | def show_grade_histogram(): 339 | """Prints a histogram of positive grades.""" 340 | read_cursor = DB.cursor() 341 | read_cursor.execute('SELECT grade, COUNT(grade) FROM commit ' 342 | 'WHERE grade >= 0 GROUP BY grade') 343 | histogram = dict([(i['grade'], i['COUNT(grade)']) 344 | for i in read_cursor.fetchall()]) 345 | print 'Histogram:' 346 | for n in range(0, max(histogram.keys()) + 1): 347 | print '%-2d: %d' % (n, histogram.get(n, 0)) 348 | print 'Total:', sum(histogram.values()) 349 | 350 | 351 | if __name__ == '__main__': 352 | mode = sys.argv[1] 353 | if mode == 'build': 354 | m = Model.build() 355 | m.dump('model.json') 356 | elif mode == 'grade': 357 | m = Model.load('model.json') 358 | update_grades(m) 359 | show_grade_histogram() 360 | else: 361 | print 'Invalid mode. Call "grade.py build" or "grade.py grade".' 362 | -------------------------------------------------------------------------------- /backend/model.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | # A regex to pull the repository name from a commit URL. 4 | REPOSITORY_REGEX = re.compile( 5 | r'^https://api.github.com/repos/([^/]+/[^/]+)/commits/[a-fA-F0-9]+$') 6 | 7 | # A regex to pull the repository name from a commit URL. 8 | PATCH_HEADER_REGEX = re.compile( 9 | r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(?: (\S.*))?$') 10 | 11 | 12 | class Commit(object): 13 | """A single patch hunk linked to a GitHub commit.""" 14 | 15 | MAX_LINES_PER_PATCH = 25 16 | 17 | def __init__(self, commit_json, 18 | patch_number, patch_filename, 19 | patch_start_old, patch_start_new, 20 | patch_header, patch_lines): 21 | self.sha = commit_json['sha'] 22 | self.patch_number = patch_number 23 | self.message = commit_json['commit']['message'] 24 | if commit_json['author'] is not None and 'login' in commit_json['author']: 25 | self.author_login = commit_json['author']['login'] 26 | self.author_avatar_url = commit_json['author']['avatar_url'] 27 | else: 28 | self.author_login = None 29 | self.author_avatar_url = None 30 | self.author_name = commit_json['commit']['author']['name'] 31 | 32 | repository_matches = REPOSITORY_REGEX.findall(commit_json['url']) 33 | assert repository_matches and len(repository_matches) == 1, commit_json 34 | self.repository = repository_matches[0] 35 | 36 | self.filename = patch_filename 37 | self.additions = len([i for i in patch_lines if i.startswith('+')]) 38 | self.deletions = len([i for i in patch_lines if i.startswith('-')]) 39 | self.old_start_line = patch_start_old 40 | self.new_start_line = patch_start_new 41 | self.block_name = patch_header or None 42 | self.diff_lines = [i.replace('\t', ' ') for i in patch_lines] 43 | 44 | @staticmethod 45 | def split_from_json(json): 46 | """Given a GitHub commit JSON, yield Commit objects for all patch hunks.""" 47 | assert json 48 | patch_number = 0 49 | if 'files' in json: 50 | for patch_json in json['files']: 51 | if 'patch' in patch_json: 52 | for hunk in Commit.split_patch(patch_json['patch']): 53 | for patch_block in Commit.split_hunk(hunk): 54 | yield Commit(json, 55 | patch_number, 56 | patch_json['filename'], 57 | *patch_block) 58 | patch_number += 1 59 | 60 | @staticmethod 61 | def split_patch(patch): 62 | """Given a full patch, yield all hunks. 63 | 64 | The hunks are tuples of: 65 | old_start_line, new_start_line, optional_header, list_of_lines 66 | """ 67 | current_header = None 68 | current_lines = [] 69 | def assemble(): 70 | matches = PATCH_HEADER_REGEX.findall(current_header) 71 | assert matches and len(matches) == 1, current_header 72 | old_start, new_start, header = matches[0] 73 | return old_start, new_start, header, current_lines 74 | 75 | for line in patch.splitlines(): 76 | if line.startswith('@@'): 77 | # Header line. 78 | if current_header is not None: 79 | # Flush current block. 80 | yield assemble() 81 | current_header = line 82 | else: 83 | # Regular line. 84 | current_lines.append(line) 85 | 86 | assert current_header is not None, patch 87 | yield assemble() 88 | 89 | @staticmethod 90 | def split_hunk(input): 91 | """Given a patch hunk, split it into pieces. 92 | 93 | The yielded results are hunks in the same format as the input. 94 | """ 95 | old_start_line, new_start_line, optional_header, lines = input 96 | if len(lines) <= Commit.MAX_LINES_PER_PATCH: 97 | yield input 98 | else: 99 | def is_context_line(i): 100 | return lines[i].startswith(' ') 101 | 102 | last = 0 103 | cur_old_start_line = old_start_line 104 | cur_new_start_line = new_start_line 105 | seen_a_diff = False 106 | for i in range(0, len(lines)): 107 | if lines[i].startswith('-'): 108 | cur_old_start_line += 1 109 | seen_a_diff = True 110 | elif lines[i].startswith('+'): 111 | cur_new_start_line += 1 112 | seen_a_diff = True 113 | elif lines[i].startswith(' '): 114 | cur_old_start_line += 1 115 | cur_new_start_line += 1 116 | 117 | if (i != len(lines) - 1 and 118 | is_context_line(i - 1) and 119 | is_context_line(i) and 120 | is_context_line(i + 1) and 121 | seen_a_diff): 122 | yield old_start_line, new_start_line, optional_header, lines[last:i] 123 | seen_a_diff = False 124 | last = i 125 | old_start_line = cur_old_start_line 126 | new_start_line = cur_new_start_line 127 | 128 | if last != i: 129 | yield old_start_line, new_start_line, optional_header, lines[last:i] 130 | 131 | 132 | class Repository(object): 133 | """A GitHub repository.""" 134 | 135 | def __init__(self, repository_json, star_count): 136 | self.id = repository_json['id'] 137 | self.name = repository_json['full_name'] 138 | self.author = repository_json['owner']['login'] 139 | self.author_avatar_url = repository_json['owner']['avatar_url'] 140 | self.description = repository_json['description'] 141 | self.is_private = repository_json['private'] 142 | self.is_fork = repository_json['fork'] 143 | self.watcher_count = repository_json['watchers'] 144 | self.star_count = star_count 145 | -------------------------------------------------------------------------------- /backend/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA IF NOT EXISTS `commit_game`; 2 | USE `commit_game`; 3 | 4 | CREATE TABLE `commit`( 5 | `order_id` INT(11) NOT NULL AUTO_INCREMENT, 6 | `sha` CHAR(40) NOT NULL, 7 | `patch_number` INT NOT NULL, 8 | `message` TEXT NOT NULL, 9 | `author_login` VARCHAR(255), 10 | `author_name` VARCHAR(255), 11 | `author_avatar_url` TEXT, 12 | `repository` VARCHAR(127) NOT NULL, 13 | `filename` VARCHAR(255) NOT NULL, 14 | `additions` INT UNSIGNED NOT NULL, 15 | `deletions` INT UNSIGNED NOT NULL, 16 | `old_start_line` INT UNSIGNED NOT NULL, 17 | `new_start_line` INT UNSIGNED NOT NULL, 18 | `block_name` VARCHAR(255), 19 | `diff_lines` LONGTEXT NOT NULL, 20 | `grade` INT NOT NULL DEFAULT -100, 21 | PRIMARY KEY (`order_id`), 22 | UNIQUE KEY `sha_patch_number_index` (`sha`,`patch_number`), 23 | KEY `grade_index` (`grade`) 24 | ) ENGINE = MyISAM; 25 | 26 | CREATE TABLE `repository`( 27 | `order_id` INT(11) NOT NULL AUTO_INCREMENT, 28 | `id` BIGINT NOT NULL, 29 | `name` VARCHAR(255) NOT NULL, 30 | `author` VARCHAR(255) NOT NULL, 31 | `author_avatar_url` TEXT NOT NULL, 32 | `description` TEXT NOT NULL, 33 | `is_private` BIT(1) NOT NULL, 34 | `is_fork` BIT(1) NOT NULL, 35 | `watcher_count` INT NOT NULL, 36 | `star_count` INT NOT NULL, 37 | PRIMARY KEY(`name`), 38 | UNIQUE INDEX `name_UNIQUE`(`name` ASC), 39 | UNIQUE INDEX `order_id_UNIQUE` (`order_id` ASC) 40 | ) ENGINE = MyISAM; 41 | -------------------------------------------------------------------------------- /backend/server.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import config 3 | import flask 4 | import json 5 | import gc 6 | import array 7 | import random 8 | import os 9 | import mimetypes 10 | import MySQLdb as mysql 11 | import MySQLdb.cursors 12 | import cPickle as pickle 13 | import platform 14 | 15 | APP = flask.Flask(__name__, 16 | static_folder='../app' if os.environ.get('ENV') == 'DEV' else '../app/dist', 17 | static_url_path='') 18 | 19 | ROOT = os.path.dirname(os.path.realpath(__file__)) 20 | INDEX_CACHE_FILENAME = 'commit_index.pickle' 21 | 22 | 23 | def connect_to_db(): 24 | return mysql.connect( 25 | host=config.DB_HOST, 26 | user=config.DB_USER, 27 | passwd=config.DB_PASSWORD, 28 | db=config.DB_NAME, 29 | cursorclass=MySQLdb.cursors.DictCursor) 30 | 31 | 32 | def initialize(): 33 | print 'Initializing...' 34 | with connect_to_db() as cursor: 35 | # Get all repositories. 36 | cursor.execute('SELECT * FROM repository') 37 | first_row = cursor.fetchone() 38 | repo_class = collections.namedtuple('Repository', 39 | ' '.join(first_row.keys())) 40 | repos = {} 41 | repos[first_row['name']] = repo_class(**first_row) 42 | for row in cursor.fetchall(): 43 | repos[row['name']] = repo_class(**row) 44 | 45 | # Get a commit index. 46 | index_class = collections.namedtuple('Index', 'grades ids') 47 | cache_path = os.path.join(ROOT, INDEX_CACHE_FILENAME) 48 | if os.path.exists(cache_path): 49 | print ' ...from cache.' 50 | cache_file = open(cache_path, 'r') 51 | commit_index_raw = pickle.load(cache_file) 52 | commit_index = index_class(*commit_index_raw) 53 | cache_file.close() 54 | else: 55 | print ' ...from DB.' 56 | cursor.execute('SELECT grade, order_id ' 57 | 'FROM commit WHERE grade >= 0 ' 58 | 'ORDER BY grade ASC') 59 | grades = [] 60 | ids = array.array('i') 61 | last_grade = -1 62 | for _ in xrange(0, cursor.rowcount / 1000): 63 | for commit in cursor.fetchmany(1000): 64 | grade = commit['grade'] 65 | if last_grade != grade: 66 | for _ in xrange(last_grade, grade): 67 | grades.append(len(ids)) 68 | last_grade = grade 69 | ids.append(commit['order_id']) 70 | commit_index = index_class(tuple(grades), tuple(ids)) 71 | 72 | cache_file = open(cache_path, 'w') 73 | pickle.dump([tuple(grades), tuple(ids)], cache_file) 74 | cache_file.close() 75 | 76 | # Try to GC, since iterating over the table uses up a lot of RAM. 77 | gc.collect() 78 | 79 | print 'Initialization done.' 80 | return repos, commit_index 81 | 82 | 83 | # Assume we have enough memory to keep all repositories and a commit index loaded. 84 | REPOS, COMMIT_INDEX = initialize() 85 | REPO_NAMES = REPOS.keys() 86 | 87 | 88 | @APP.route("/") 89 | @APP.route("/level/") 90 | def homepage(level_id=None): 91 | return open(os.path.join(ROOT, '../app/index.html'), 'r').read() 92 | 93 | 94 | @APP.route("/level///") 95 | def level(length, min_grade, max_grade): 96 | length = min(int(length), 256) 97 | max_grade = int(max_grade) 98 | min_grade = int(min_grade) 99 | 100 | start = COMMIT_INDEX.grades[min_grade] 101 | end = COMMIT_INDEX.grades[max_grade] 102 | ids = [COMMIT_INDEX.ids[i] for i in random.sample(xrange(start, end), length)] 103 | 104 | sql = 'SELECT * FROM commit WHERE order_id IN (%s)' % ','.join(map(str, ids)) 105 | with connect_to_db() as cursor: 106 | cursor.execute(sql) 107 | commits = cursor.fetchall() 108 | 109 | levels = [] 110 | for commit in commits: 111 | repo_names = random.sample(REPO_NAMES, 4) 112 | if commit['repository'] not in repo_names: 113 | repo_names = repo_names[:3] + [commit['repository']] 114 | repos = [REPOS[i]._asdict() for i in repo_names] 115 | random.shuffle(repos) 116 | levels.append({'commit': commit, 'repos': repos}) 117 | 118 | return flask.Response(json.dumps({'rounds': levels}), mimetype='text/json') 119 | 120 | if platform.system() == 'Windows': 121 | # FontAwesome files cause a weird bug on Windows. Hack around it. 122 | @APP.route('/build/FortAwesome-Font-Awesome/fonts/') 123 | @APP.route('/build//FortAwesome-Font-Awesome/fonts/') 124 | def custom_static(path): 125 | path = path.replace('../', '') # Make sure we don't try to reach outside. 126 | fs_path = os.path.join( 127 | ROOT, '../app/build/FortAwesome-Font-Awesome/fonts/', path) 128 | print fs_path 129 | if os.path.exists(fs_path): 130 | mime = mimetypes.guess_type(fs_path)[0] 131 | return flask.Response(open(fs_path, 'rb'), mimetype=mime) 132 | else: 133 | return 'Not Found', 404 134 | 135 | 136 | if __name__ == "__main__": 137 | APP.run(debug=True if os.environ.get('ENV') == 'DEV' else False) 138 | -------------------------------------------------------------------------------- /mocks/game-screen-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/mocks/game-screen-1.png -------------------------------------------------------------------------------- /mocks/game-screen-1.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/mocks/game-screen-1.xcf -------------------------------------------------------------------------------- /mocks/game-screen-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/mocks/game-screen-2.png -------------------------------------------------------------------------------- /mocks/game-screen-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/mocks/game-screen-3.png -------------------------------------------------------------------------------- /mocks/hub-with-powers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/mocks/hub-with-powers.png -------------------------------------------------------------------------------- /mocks/hub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/mocks/hub.png -------------------------------------------------------------------------------- /mocks/intro.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/mocks/intro.gif -------------------------------------------------------------------------------- /mocks/progress-bar-mock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/mocks/progress-bar-mock.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max99x/guesshub/74f0160d27dc02ff8e411bd07fd5c5448fd513e0/screenshot.png -------------------------------------------------------------------------------- /todo-priorities: -------------------------------------------------------------------------------- 1 | Nice to have 2 | Add animation on level unlock. 3 | ./power-list/power-list.js:16: // TODO: Make choices respond to QWER keyboards keys. 4 | ./repo-list/repo-list.js:30: // TODO: Make choices respond to 1-4 keyboards keys. 5 | Include commits from the game repository itself. 6 | 7 | Post-launch 8 | Achievements 9 | Keep track of achievements. 10 | Show a lists of achievements when the user clicks on the trophy icon. 11 | Show achievements on the finish page. 12 | User Statistics 13 | Keep track of all players' statistics. 14 | Show the user's statistics in the achievements UI. 15 | Show a graph comparison against other players on the finish screen. 16 | Bonus levels. 17 | Background music. 18 | 19 | Misc 20 | ./boot/game.js:131: // TODO: Maybe move these into Power.use()? 21 | ./models/level.js:28: // TODO: Retry with exponential backoff on errors. 22 | ./crawl.py:98: # TODO(max99x): Log and skip errors. 23 | ./boot/game.js:67: // TODO: Properly destroy widgets? 24 | --------------------------------------------------------------------------------