diff --git a/.eslintrc b/.eslintrc index 9923934..7d1b29b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "extends": "airbnb", "globals": { "Phaser": true, - "document": + "document": true }, "env": { "browser": true diff --git a/gruntfile.js b/gruntfile.js index 19383ed..d28e898 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -1,240 +1,240 @@ -var properties = require('./src/js/game/properties.js'); +const properties = require('./src/js/game/properties.js'); module.exports = function (grunt) { + grunt.loadNpmTasks('grunt-browserify'); + grunt.loadNpmTasks('grunt-cache-bust'); + grunt.loadNpmTasks('grunt-contrib-clean'); + grunt.loadNpmTasks('grunt-contrib-compress'); + grunt.loadNpmTasks('grunt-contrib-connect'); + grunt.loadNpmTasks('grunt-contrib-copy'); + grunt.loadNpmTasks('grunt-contrib-jade'); + grunt.loadNpmTasks('grunt-contrib-jshint'); + grunt.loadNpmTasks('grunt-contrib-stylus'); + grunt.loadNpmTasks('grunt-contrib-uglify'); + grunt.loadNpmTasks('grunt-contrib-watch'); + grunt.loadNpmTasks('grunt-open'); + grunt.loadNpmTasks('grunt-pngmin'); + + const productionBuild = !!(grunt.cli.tasks.length && grunt.cli.tasks[0] === 'build'); + + grunt.initConfig({ + + pkg: grunt.file.readJSON('package.json'), + + properties, + + project: { + src: 'src/js', + js: '<%= project.src %>/game/{,*/}*.js', + dest: 'build/js', + bundle: 'build/js/app.min.js', + port: properties.port, + banner: + '/*\n' + + ' * <%= properties.title %>\n' + + ' * <%= pkg.description %>\n' + + ' *\n' + + ' * @author <%= pkg.author %>\n' + + ' * @version <%= pkg.version %>\n' + + ' * @copyright <%= pkg.author %>\n' + + ' * @license <%= pkg.license %> licensed\n' + + ' *\n' + + ' * Made using Phaser JS Boilerplate' + + ' <https://github.com/lukewilde/phaser-js-boilerplate>\n' + + ' */\n', + }, + + connect: { + dev: { + options: { + port: '<%= project.port %>', + base: './build', + }, + }, + }, + + jshint: { + files: [ + 'gruntfile.js', + '<%= project.js %>', + ], + options: { + jshintrc: '.jshintrc', + }, + }, + + watch: { + options: { + livereload: productionBuild ? false : properties.liveReloadPort, + }, + js: { + files: '<%= project.dest %>/**/*.js', + tasks: ['jade'], + }, + jade: { + files: 'src/templates/*.jade', + tasks: ['jade'], + }, + stylus: { + files: 'src/style/*.styl', + tasks: ['stylus'], + }, + images: { + files: 'src/images/**/*', + tasks: ['copy:images'], + }, + audio: { + files: 'src/audio/**/*', + tasks: ['copy:audio'], + }, + }, + + browserify: { + app: { + src: ['<%= project.src %>/game/app.js'], + dest: '<%= project.bundle %>', + options: { + transform: ['browserify-shim', ['babelify', { presets: ['es2015'] }]], + watch: true, + browserifyOptions: { + debug: !productionBuild, + }, + }, + }, + }, + + open: { + server: { + path: 'http://localhost:<%= project.port %>', + }, + }, + + cacheBust: { + options: { + assets: ['audio/**', 'images/**', 'js/**', 'style/**'], + baseDir: './build/', + deleteOriginals: true, + length: 5, + }, + files: { + src: ['./build/js/app.min.*', './build/index.html'], + }, + }, + + jade: { + compile: { + options: { + data: { + properties, + productionBuild, + }, + }, + files: { + 'build/index.html': ['src/templates/index.jade'], + }, + }, + }, + + stylus: { + compile: { + files: { 'build/style/index.css': ['src/style/index.styl'] }, + options: { + sourcemaps: !productionBuild, + }, + }, + }, + + clean: ['./build/'], + + pngmin: { + options: { + ext: '.png', + force: true, + }, + compile: { + files: [{ src: 'src/images/*.png', dest: 'src/images/' }] + }, + }, + + copy: { + images: { + files: [{ expand: true, cwd: 'src/images/', src: ['**'], dest: 'build/images/' }], + }, + audio: { + files: [{ expand: true, cwd: 'src/audio/', src: ['**'], dest: 'build/audio/' }], + }, + phaserArcade: { + files: [{ + src: ['node_modules/phaser/build/custom/phaser-arcade-physics.js'], + dest: 'build/js/phaser.js', + }], + }, + phaserArcadeMin: { + files: [{ + src: ['node_modules/phaser/build/custom/phaser-arcade-physics.min.js'], + dest: 'build/js/phaser.js', + }], + }, + phaserP2: { + files: [{ + src: ['node_modules/phaser/build/phaser.js'], + dest: 'build/js/phaser.js', + }], + }, + phaserP2Min: { + files: [{ + src: ['node_modules/phaser/build/phaser.min.js'], + dest: 'build/js/phaser.js', + }], + }, + }, + + uglify: { + options: { + banner: '<%= project.banner %>', + }, + dist: { + files: { '<%= project.bundle %>': '<%= project.bundle %>' }, + }, + }, - grunt.loadNpmTasks('grunt-browserify'); - grunt.loadNpmTasks('grunt-cache-bust'); - grunt.loadNpmTasks('grunt-contrib-clean'); - grunt.loadNpmTasks('grunt-contrib-compress'); - grunt.loadNpmTasks('grunt-contrib-connect'); - grunt.loadNpmTasks('grunt-contrib-copy'); - grunt.loadNpmTasks('grunt-contrib-jade'); - grunt.loadNpmTasks('grunt-contrib-jshint'); - grunt.loadNpmTasks('grunt-contrib-stylus'); - grunt.loadNpmTasks('grunt-contrib-uglify'); - grunt.loadNpmTasks('grunt-contrib-watch'); - grunt.loadNpmTasks('grunt-open'); - grunt.loadNpmTasks('grunt-pngmin'); - - var productionBuild = !!(grunt.cli.tasks.length && grunt.cli.tasks[0] === 'build'); - - grunt.initConfig({ - - pkg: grunt.file.readJSON('package.json'), - - properties: properties, - - project: { - src: 'src/js', - js: '<%= project.src %>/game/{,*/}*.js', - dest: 'build/js', - bundle: 'build/js/app.min.js', - port: properties.port, - banner: - '/*\n' + - ' * <%= properties.title %>\n' + - ' * <%= pkg.description %>\n' + - ' *\n' + - ' * @author <%= pkg.author %>\n' + - ' * @version <%= pkg.version %>\n' + - ' * @copyright <%= pkg.author %>\n' + - ' * @license <%= pkg.license %> licensed\n' + - ' *\n' + - ' * Made using Phaser JS Boilerplate' + - ' <https://github.com/lukewilde/phaser-js-boilerplate>\n' + - ' */\n' - }, - - connect: { - dev: { - options: { - port: '<%= project.port %>', - base: './build' - } - } - }, - - jshint: { - files: [ - 'gruntfile.js', - '<%= project.js %>' - ], - options: { - jshintrc: '.jshintrc' - } - }, - - watch: { - options: { - livereload: productionBuild ? false : properties.liveReloadPort - }, - js: { - files: '<%= project.dest %>/**/*.js', - tasks: ['jade'] - }, - jade: { - files: 'src/templates/*.jade', - tasks: ['jade'] - }, - stylus: { - files: 'src/style/*.styl', - tasks: ['stylus'] - }, - images: { - files: 'src/images/**/*', - tasks: ['copy:images'] - }, - audio:{ - files: 'src/audio/**/*', - tasks: ['copy:audio'] - } - }, - - browserify: { - app: { - src: ['<%= project.src %>/game/app.js'], - dest: '<%= project.bundle %>', - options: { - transform: ['browserify-shim', ['babelify', {presets: ['es2015']}]], - watch: true, - browserifyOptions: { - debug: !productionBuild - } - } - } - }, - - open: { - server: { - path: 'http://localhost:<%= project.port %>' - } - }, - - cacheBust: { - options: { - assets: ['audio/**', 'images/**', 'js/**', 'style/**'], - baseDir: './build/', - deleteOriginals: true, - length: 5 - }, - files: { - src: ['./build/js/app.min.*', './build/index.html'] - } - }, - - jade: { - compile: { - options: { - data: { - properties: properties, - productionBuild: productionBuild - } + compress: { + options: { archive: '<%= pkg.name %>.zip' }, + zip: { files: [{ expand: true, cwd: 'build/', src: ['**/*'], dest: '<%= pkg.name %>/' }] }, + cocoon: { files: [{ expand: true, cwd: 'build/', src: ['**/*'] }] }, }, - files: { - 'build/index.html': ['src/templates/index.jade'] - } - } - }, - - stylus: { - compile: { - files: { 'build/style/index.css': ['src/style/index.styl'] }, - options: { - sourcemaps: !productionBuild - } - } - }, - - clean: ['./build/'], - - pngmin: { - options: { - ext: '.png', - force: true - }, - compile: { - files: [ { src: 'src/images/*.png', dest: 'src/images/' } ] } - }, - - copy: { - images: { - files: [ { expand: true, cwd: 'src/images/', src: ['**'], dest: 'build/images/' } ] - }, - audio: { - files: [ { expand: true, cwd: 'src/audio/', src: ['**'], dest: 'build/audio/' } ] - }, - phaserArcade: { - files: [ { - src: ['node_modules/phaser/build/custom/phaser-arcade-physics.js'], - dest: 'build/js/phaser.js' - } ] - }, - phaserArcadeMin: { - files: [ { - src: ['node_modules/phaser/build/custom/phaser-arcade-physics.min.js'], - dest: 'build/js/phaser.js' - } ] - }, - phaserP2: { - files: [ { - src: ['node_modules/phaser/build/phaser.js'], - dest: 'build/js/phaser.js' - } ] - }, - phaserP2Min: { - files: [ { - src: ['node_modules/phaser/build/phaser.min.js'], - dest: 'build/js/phaser.js' - } ] - } - }, - - uglify: { - options: { - banner: '<%= project.banner %>' - }, - dist: { - files: { '<%= project.bundle %>': '<%= project.bundle %>' } - } - }, - - compress: { - options: { archive: '<%= pkg.name %>.zip' }, - zip: { files: [ { expand: true, cwd: 'build/', src: ['**/*'], dest: '<%= pkg.name %>/' } ] }, - cocoon: { files: [ { expand: true, cwd: 'build/', src: ['**/*'] } ] } - } - }); - - grunt.registerTask('default', [ - 'clean', - 'browserify', - 'jade', - 'stylus', - 'copy:images', - 'copy:audio', - 'copy:phaserArcade', - 'connect', - 'open', - 'watch' - ]); - - grunt.registerTask('build', [ - /*'jshint', - */'clean', - 'browserify', - 'jade', - 'stylus', - // 'uglify', - 'copy:images', - 'copy:audio', - 'copy:phaserArcadeMin', - 'cacheBust', - // 'connect', - // 'open', - // 'watch' - ]); - - grunt.registerTask('optimise', ['pngmin', 'copy:images']); - grunt.registerTask('cocoon', ['compress:cocoon']); - grunt.registerTask('zip', ['compress:zip']); + }); + + grunt.registerTask('default', [ + 'clean', + 'browserify', + 'jade', + 'stylus', + 'copy:images', + 'copy:audio', + 'copy:phaserArcade', + 'connect', + 'open', + 'watch', + ]); + + grunt.registerTask('build', [ + // 'jshint', + 'clean', + 'browserify', + 'jade', + 'stylus', + // 'uglify', + 'copy:images', + 'copy:audio', + 'copy:phaserArcadeMin', + // 'cacheBust', + // 'connect', + // 'open', + // 'watch' + ]); + + grunt.registerTask('optimise', ['pngmin', 'copy:images']); + grunt.registerTask('cocoon', ['compress:cocoon']); + grunt.registerTask('zip', ['compress:zip']); }; diff --git a/src/js/game/objects/Bouncer.js b/src/js/game/objects/Bouncer.js new file mode 100644 index 0000000..5f4d67b --- /dev/null +++ b/src/js/game/objects/Bouncer.js @@ -0,0 +1,93 @@ +/** + * A Bouncer handles the player's controls on an object's bounce + */ +function Bouncer(opts) { + Object.assign(this, { + actor: null, + timings: { + // TODO adjust the default timings + perfect: 0, + good: 2, + poor: 5, + }, + }, opts); + + this.reset(); +} + +Bouncer.create = (...args) => new Bouncer(...args); + +Bouncer.prototype = { + reset() { + Object.assign(this, { + frame: 0, + didBounce: false, + bouncedAt: 0, + didTrigger: false, + triggeredAt: 0, + }); + }, + + // True if too much time elapsed after the bounce + timedOut() { + return this.didBounce && this.frame - this.bouncedAt > this.timings.poor; + }, + + update(trigger, bounced) { + this.frame += 1; + + // Register trigger and bounce events + if (bounced && !this.didBounce) { + this.didBounce = true; + this.bouncedAt = this.frame; + } + + // Ignore input right after the actor bounce was resolved + const ignoreInput = !this.didBounce && this.actor.body.velocity.y <= 0; + // if (ignoreInput && trigger) { + // console.log('Ignore input'); + // } + + if (!ignoreInput && !this.didTrigger && ( + trigger || this.timedOut() // automatically trigger after timeout + )) { + this.didTrigger = true; + this.triggeredAt = this.frame; + } + + // Once both happened we can resolve the actor's new speed + if (this.didTrigger && this.didBounce) { + this.adjustBounce(); + this.killIfNeeded(); + this.reset(); + } + }, + + adjustBounce() { + const { perfect, good, poor } = this.timings; + const distance = Math.abs(this.bouncedAt - this.triggeredAt); + + let multiplier; + if (distance <= perfect) { + multiplier = 1.2; + } else if (distance <= good) { + multiplier = 1; + } else if (distance <= poor) { + multiplier = 0.8; + } else { + multiplier = 0.5; + } + + this.actor.body.velocity.y *= multiplier; + this.actor.body.velocity.x *= 1.05; + }, + + killIfNeeded() { + const { actor } = this; + if (Math.abs(actor.body.velocity.y) < 50) { + actor.kill(); + } + }, +}; + +module.exports = Bouncer; diff --git a/src/js/game/objects/Shadow.js b/src/js/game/objects/Shadow.js new file mode 100644 index 0000000..e0cecf8 --- /dev/null +++ b/src/js/game/objects/Shadow.js @@ -0,0 +1,43 @@ +/** + * A shadow for the disc + */ +function Shadow(game, opts) { + Object.assign(this, { + color: 0x000000, + ground: 0, + max_height: 0, + size: 0, + }, opts); + + this.graphics = game.add.graphics(0, this.ground); + this.graphics.visible = false; +} + +Shadow.create = (game, args) => new Shadow(game, args); + +Shadow.prototype = { + attachTo(actor) { + this.actor = actor; + this.graphics.beginFill(this.color); + this.graphics.drawCircle(this.size / 2, -(this.size + actor.height), this.size); + this.graphics.endFill(); + this.graphics.visible = this.actor.alive; + return this; + }, + + update() { + if (!this.actor.alive) { + this.graphics.kill(); + return; + } + + const gap = Math.abs(this.ground - this.actor.body.position.y); + const factor = 1 - (gap / this.max_height); + this.graphics.position.x = this.actor.body.position.x; + this.graphics.scale.y = factor / 2; + this.graphics.scale.x = factor; + this.graphics.alpha = factor ** 2; + }, +}; + +module.exports = Shadow; diff --git a/src/js/game/states/play.js b/src/js/game/states/play.js index 6693123..ec66009 100644 --- a/src/js/game/states/play.js +++ b/src/js/game/states/play.js @@ -1,5 +1,7 @@ const _ = require('lodash'); const properties = require('../properties'); +const Bouncer = require('../objects/Bouncer'); +const Shadow = require('../objects/Shadow'); const play = {}; @@ -17,19 +19,32 @@ function createWater(game, height) { } function createDisc(game) { - const disc = game.add.sprite(32, game.world.height * 0.7, 'disc'); + const disc = game.add.sprite(32, game.world.height * 0.5, 'disc'); const anim = disc.animations.add('rotate'); anim.play(40, true); disc.anchor.setTo(0.5, 0.5); game.physics.arcade.enable([disc]); - disc.body.bounce.y = 0.95; disc.body.collideWorldBounds = true; + disc.body.velocity.x = 250; + disc.body.gravity.y = 200; + disc.body.bounce.y = 1; // Higher will be less punitive for the player + disc.body.maxVelocity.y = 250; + disc.body.maxVelocity.x = 750; return disc; } +function createShadow(game) { + return Shadow.create(game, { + color: 0x11367a, + ground: game.height - 64, + max_height: game.height, + size: 16, + }); +} + function createSky(game, height) { const sky = game.add.tileSprite(0, height - 172, game.world.width, 88, 'sky'); sky.scale.setTo(2, 2); @@ -44,7 +59,7 @@ function makeSplash(game, disc) { } // Create the splash sprite below the disc - const splash = game.add.sprite(disc.centerX, disc.bottom, 'splash', 5); + const splash = game.add.sprite(disc.body.position.x + 24, disc.bottom, 'splash', 5); splash.anchor.setTo(0.5, 1); // Adapt animation to disc velocity @@ -68,42 +83,50 @@ function makeSplash(game, disc) { }); } -function handleInput(game) { - const key = game.input.keyboard; - if (key.isDown(Phaser.Keyboard.SPACE)) { - // TODO - } -} - play.create = function create() { this.game.world.setBounds(0, 0, properties.size.x * 30, properties.size.y); this.game.stage.backgroundColor = '#eeeeee'; this.game.physics.startSystem(Phaser.Physics.ARCADE); - this.game.physics.arcade.gravity.y = 200; this.water = createWater(this.game, this.game.height); this.sky = createSky(this.game, this.water.top); - this.disc = createDisc(this.game); - this.disc.body.velocity.x = 250; + const shadow = createShadow(this.game); // NOTE Disc has to be created after shadow (or help find a way to play with the z-indexes ^.^) + this.disc = createDisc(this.game); + this.disc.shadow = shadow.attachTo(this.disc); this.game.camera.follow(this.disc, Phaser.Camera.FOLLOW_LOCKON, 0.1, 0.1); + + this.bouncer = Bouncer.create({ actor: this.disc }); }; play.update = function update() { - const { disc, water, sky, game } = this; - game.physics.arcade.collide(disc, water, () => { - makeSplash(game, disc); - }); - - handleInput(game, sky, water); + const { disc, water, game } = this; + + disc.shadow.update(); + + if (disc.alive) { + // Handle bounce + const didCollide = game.physics.arcade.collide(disc, water); + if (didCollide) { + makeSplash(game, disc); + } + this.bouncer.update(game.input.keyboard.isDown(Phaser.Keyboard.SPACEBAR), didCollide); + + // The disc was too slow and killed + if (!disc.alive) { + // Reset the game later + setTimeout(() => { + this.game.state.clearCurrentState(); + this.game.state.restart(); + }, 750); + } + } }; + play.render = function render() { - // const { disc, water, game } = this; - // game.debug.body(disc); - // game.debug.bodyInfo(disc, 32, 72); - // game.debug.body(water); + // Empty }; module.exports = play;