Are you enjoying “Ballz series“? I really wanted to add a predictive trajectory, but it wasn’t that easy. Unlike Trick Shot which uses Box2D allowing you to play with time steps, Arcade physics does not feature any time step so you can’t fast forward the simulation.
But I found another solution, given the simplicity of the physics world we are dealing with given that:
1 – we only have squares, balls included, because a tiny ball can be approximated with a square.
2 – None of the bodies rotate. There isn’t rotation at all.
With these two points in mind, it’s quite easy to compute a predictive trajectory, following these steps:
1 – Break down each square into segments. If we have twenty squares in game, we will have 20 squares * 4 segments each + 4 segments representing world bounding box = 84 segments. A really tiny number, for a really dangerous situation. With 20 squares in game, you’re next to game over.
2 – Break down ball square into vertices, so you will have four vertices.
3 – Starting from each vertex, build a line according to player angle of fire.
4 – Check for intersection between each vertext line and each segment.
5 – The intersection – if any – with the shortest distance from its vertex is the collision point
6 – Knowing the vertex which collided, it’s easy to predict where the ball will hit a wall or a block, as well as to calculate the rebound.
End of the boring theory, now have a look:
Tap/click and drag to the bottom to aim the ball, release to launch it.
Green line represents the shortest line from a vertex to a segment, and red square is the collision point.
Have a look at the completely commented source code, this is quite big:
let game; let gameOptions = { // ball size, compared to game width ballSize: 0.04, // ball speed, in pixels per second ballSpeed: 1000, // blocks per line, or block columns :) blocksPerLine: 7, // block lines blockLines: 8, // max amount of blocks per line maxBlocksPerLine: 4, // probability 0 -> 100 of having an extra ball in each line extraBallProbability: 60, // predictive trajectory length, in pixels trajectoryLength: 1200 } window.onload = function() { let gameConfig = { type: Phaser.AUTO, backgroundColor:0x444444, scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, parent: "thegame", width: 640, height: 960 }, physics: { default: "arcade" }, scene: playGame } game = new Phaser.Game(gameConfig); window.focus(); } // game states const WAITING_FOR_PLAYER_INPUT = 0; const PLAYER_IS_AIMING = 1; const BALLS_ARE_RUNNING = 2; const ARCADE_PHYSICS_IS_UPDATING = 3; const PREPARING_FOR_NEXT_MOVE = 4; class playGame extends Phaser.Scene { constructor() { super("PlayGame"); } preload() { this.load.image("ball", "ball.png"); this.load.image("panel", "panel.png"); this.load.image("block", "block.png"); } create() { // at the beginning of the game, we wait for player input this.gameState = WAITING_FOR_PLAYER_INPUT; // it's not game over... yet this.gameOver = false; // we start from level zero this.level = 0; // array used to recycle destroyed blocks this.recycledBlocks = []; // determine block size according to game width and the number of blocks for each line this.blockSize = game.config.width / gameOptions.blocksPerLine; // determine game field height according to block size and block lines this.gameFieldHeight = this.blockSize * gameOptions.blockLines; // empty space is the amount of the stage not covered by game field this.emptySpace = game.config.height - this.gameFieldHeight; // set bounds of the physics world this.physics.world.setBounds(0, this.emptySpace / 2, game.config.width, this.gameFieldHeight); // creation of physics groups where to place blocks, balls and extra balls this.blockGroup = this.physics.add.group(); this.ballGroup = this.physics.add.group(); this.extraBallGroup = this.physics.add.group(); // the upper panel is called scorePanel because probably you'll want to display player score here let scorePanel = this.add.sprite(game.config.width / 2, 0, "panel"); scorePanel.displayWidth = game.config.width; scorePanel.displayHeight = this.emptySpace / 2; scorePanel.setOrigin(0.5, 0); // bottom panel this.bottomPanel = this.add.sprite(game.config.width / 2, game.config.height, "panel"); this.bottomPanel.displayWidth = game.config.width; this.bottomPanel.displayHeight = this.emptySpace / 2; this.bottomPanel.setOrigin(0.5, 1); // determine actual ball size in pixels this.ballSize = game.config.width * gameOptions.ballSize; // add the first ball this.addBall(game.config.width / 2, game.config.height - this.bottomPanel.displayHeight - this.ballSize / 2, false); // add the trajectory graphics this.trajectoryGraphics = this.add.graphics(); // add a block line this.addBlockLine(); // input listeners this.input.on("pointerdown", this.startAiming, this); this.input.on("pointerup", this.shootBall, this); this.input.on("pointermove", this.adjustAim, this); // lister for collision with world bounds this.physics.world.on("worldbounds", this.checkBoundCollision, this); } // method to add the ball at a given position x, y. The third argument tells us if it's an extra ball addBall(x, y, isExtraBall) { // ball creation as a child of ballGroup or extraBallGroup let ball = isExtraBall ? this.extraBallGroup.create(x, y, "ball") : this.ballGroup.create(x, y, "ball"); // resize the ball ball.displayWidth = this.ballSize; ball.displayHeight = this.ballSize; // maximum bounce ball.body.setBounce(1, 1); // if it's an extra ball... if(isExtraBall) { // set a custom "row" property to 1 ball.row = 1; // set a custom "collected" property to false ball.collected = false; } // if it's not an extra ball... else { // ball collides with world bounds ball.body.collideWorldBounds = true; // ball fires a listener when colliding on world bounds ball.body.onWorldBounds = true; } } // method to add a block line addBlockLine() { // increase level number this.level ++; // array where to store placed blocks positions let placedBlocks = []; // will we place an extra ball too? let placeExtraBall = Phaser.Math.Between(0, 100) < gameOptions.extraBallProbability; // execute the block "gameOptions.maxBlocksPerLine" times for(let i = 0; i < gameOptions.maxBlocksPerLine; i ++) { // random block position let blockPosition = Phaser.Math.Between(0, gameOptions.blocksPerLine - 1); // is this block position empty? if(placedBlocks.indexOf(blockPosition) == -1) { // save this block position placedBlocks.push(blockPosition); // should we place an extra ball? if(placeExtraBall) { // no more extra balls placeExtraBall = false; // add the extra ball this.addBall(blockPosition * this.blockSize + this.blockSize / 2, this.blockSize / 2 + this.emptySpace / 2, true); } // this time we don't place an extra ball, but a block else { // if we don't have any block to recycle... if(this.recycledBlocks.length == 0) { // add a block this.addBlock(blockPosition * this.blockSize + this.blockSize / 2, this.blockSize / 2 + this.emptySpace / 2, false); } else{ // recycle a block this.addBlock(blockPosition * this.blockSize + this.blockSize / 2, this.blockSize / 2 + this.emptySpace / 2, true) } } } } // here we store all segments where to check for collisions this.fieldSegments = []; // get physics world bounds let boundRectangle = new Phaser.Geom.Rectangle(0, this.emptySpace / 2, game.config.width, this.gameFieldHeight); // turn world bounds into segments this.addTofieldSegments(boundRectangle); // iterate through all blocks Phaser.Actions.Call(this.blockGroup.getChildren(), function(block) { // get block bounding box let rectangle = block.getBounds(); // turn bounding box into segments this.addTofieldSegments(rectangle); }, this); } // method to add a block at a given x,y position. The third argument tells us if the block is recycled addBlock(x, y, isRecycled) { // block creation as a child of blockGroup let block = isRecycled ? this.recycledBlocks.shift() : this.blockGroup.create(x, y, "block"); // resize the block block.displayWidth = this.blockSize; block.displayHeight = this.blockSize; // custom property to save block value block.value = this.level; // custom property to save block row block.row = 1; // if the block is recycled... if(isRecycled) { block.x = x; block.y = y; block.text.setText(block.value); block.text.x = block.x; block.text.y = block.y; block.setVisible(true); block.text.setVisible(true); this.blockGroup.add(block); } // if the block is not recycled... else { // text object to show block value let text = this.add.text(block.x, block.y, block.value, { font: "bold 32px Arial", align: "center", color: "#000000" }); text.setOrigin(0.5); // text object is stored as a block custom property block.text = text; } // block is immovable, does not react to collisions block.body.immovable = true; } // given the center point, return ball vertices getBallVertices(p) { let halfBallSize = this.ballSize / 2; return [ new Phaser.Geom.Point(p.x - halfBallSize, p.y - halfBallSize), new Phaser.Geom.Point(p.x + halfBallSize, p.y - halfBallSize), new Phaser.Geom.Point(p.x + halfBallSize, p.y + halfBallSize), new Phaser.Geom.Point(p.x - halfBallSize, p.y + halfBallSize) ] } // method to get the ball position getBallPosition() { // select gallGroup children let children = this.ballGroup.getChildren(); // return x and y properties of first child return new Phaser.Geom.Point(children[0].x, children[0].y); } // method to start aiming startAiming() { // are we waiting for player input? if(this.gameState == WAITING_FOR_PLAYER_INPUT) { // the angle of fire is not legal at the moment this.legalAngleOfFire = false; // change game state because now the player is aiming this.gameState = PLAYER_IS_AIMING; } } // method to adjust the aim adjustAim(e) { // is the player aiming? if(this.gameState == PLAYER_IS_AIMING) { // determine x and y distance between current and initial input let distX = e.x - e.downX; let distY = e.y - e.downY; // is y distance greater than 10, that is: is the player dragging down? if(distY > 10) { // this is a legal agne of fire this.legalAngleOfFire = true; // determine dragging direction this.direction = Phaser.Math.Angle.Between(e.x, e.y, e.downX, e.downY); // trajectory direction at the moment is the same as future ball direction let trajectoryDirection = this.direction; // set trajectory length let trajectoryLength = gameOptions.trajectoryLength; // clear trajectory graphics this.trajectoryGraphics.clear(); // set trajectory graphics line style this.trajectoryGraphics.lineStyle(1, 0x00ff00); // get ball bounding box vertices this.ballVertices = this.getBallVertices(this.getBallPosition()); // predictive trajectory loop do { // here we will store all collision information, starting from the distance, initally set as a very high number let collisionObject = { collisionDistance: 10000 } // loop through all ball vertices this.ballVertices.forEach(function(vertex, index) { // determine trajectory line let trajectoryLine = new Phaser.Geom.Line(vertex.x, vertex.y, vertex.x + trajectoryLength * Math.cos(trajectoryDirection), vertex.y + trajectoryLength * Math.sin(trajectoryDirection)); // iterate through all field segments Phaser.Actions.Call(this.fieldSegments, function(line) { // create a new temp point outside game field let intersectionPoint = new Phaser.Geom.Point(-1, -1); // assign temp point the valie of the intersection point between trajectory and polygon line, if any Phaser.Geom.Intersects.LineToLine(trajectoryLine, line, intersectionPoint); // if the intersection point is inside the field... if(intersectionPoint.x != -1) { // determine distance between intersection point and vertex let distance = Phaser.Math.Distance.BetweenPoints(intersectionPoint, vertex); // if the distance is less than current collision object distance, but greater than 1, to avoid collision with the line we just checked... if(distance < collisionObject.collisionDistance && distance > 1) { // update collision object distance collisionObject.collisionDistance = distance; // save collision point collisionObject.collisionPoint = new Phaser.Geom.Point(intersectionPoint.x, intersectionPoint.y); // save collision angle collisionObject.collisionAngle = Phaser.Geom.Line.Angle(line); // save collision line collisionObject.collisionLine = Phaser.Geom.Line.Clone(line); // save vertex index collisionObject.vertexIndex = index; } } }, this); }.bind(this)); // if there was a collision point... if(collisionObject.collisionPoint) { // draw a line between the vertex and the collision point this.trajectoryGraphics.lineBetween(this.ballVertices[collisionObject.vertexIndex].x, this.ballVertices[collisionObject.vertexIndex].y, collisionObject.collisionPoint.x, collisionObject.collisionPoint.y); // set trajectoryGraphics fill style this.trajectoryGraphics.fillStyle(0xff0000, 0.5); // squareOrigin will contain the center of the ball, given the collision point let squareOrigin = new Phaser.Geom.Point(); // different actions to do according to vertex index switch(collisionObject.vertexIndex) { // top left case 0 : this.trajectoryGraphics.fillRect(collisionObject.collisionPoint.x, collisionObject.collisionPoint.y, this.ballSize, this.ballSize); squareOrigin.x = collisionObject.collisionPoint.x + this.ballSize / 2; squareOrigin.y = collisionObject.collisionPoint.y + this.ballSize / 2; break; // top right case 1 : this.trajectoryGraphics.fillRect(collisionObject.collisionPoint.x - this.ballSize, collisionObject.collisionPoint.y, this.ballSize, this.ballSize); squareOrigin.x = collisionObject.collisionPoint.x - this.ballSize / 2; squareOrigin.y = collisionObject.collisionPoint.y + this.ballSize / 2; break; // bottom right case 2 : this.trajectoryGraphics.fillRect(collisionObject.collisionPoint.x - this.ballSize, collisionObject.collisionPoint.y - this.ballSize, this.ballSize, this.ballSize); squareOrigin.x = collisionObject.collisionPoint.x - this.ballSize / 2; squareOrigin.y = collisionObject.collisionPoint.y - this.ballSize / 2; break; // bottom left case 3 : this.trajectoryGraphics.fillRect(collisionObject.collisionPoint.x, collisionObject.collisionPoint.y - this.ballSize, this.ballSize, this.ballSize); squareOrigin.x = collisionObject.collisionPoint.x + this.ballSize / 2; squareOrigin.y = collisionObject.collisionPoint.y - this.ballSize / 2; break; } // determine new trajectory direction according to surface angle if(Phaser.Math.RadToDeg(collisionObject.collisionAngle) % 180 == 0) { trajectoryDirection = 2 * Math.PI - trajectoryDirection; } else { trajectoryDirection = Math.PI - trajectoryDirection; } trajectoryDirection = Phaser.Math.Angle.Wrap(trajectoryDirection); // determine new ball vertices this.ballVertices = this.getBallVertices(squareOrigin); } // calculate the lenght of the remaining trajectory trajectoryLength -= collisionObject.collisionDistance; // keep looping while trajectory length is greater than zero } while (trajectoryLength > 0); } // y distance is smaller than 10, that is: player is not dragging down else{ // not a legal angle of fire this.legalAngleOfFire = false; // hide trajectory graphics this.trajectoryGraphics.clear(); } } } // method to turn a rectangle into 4 segments addTofieldSegments(rectangle) { this.fieldSegments.push(rectangle.getLineA()); this.fieldSegments.push(rectangle.getLineB()); this.fieldSegments.push(rectangle.getLineC()); this.fieldSegments.push(rectangle.getLineD()); } // method to shoot the ball shootBall() { // is the player aiming? if(this.gameState == PLAYER_IS_AIMING) { // hide trajectory graphics this.trajectoryGraphics.clear(); // do we have a legal angle of fire? if(this.legalAngleOfFire) { // change game state this.gameState = BALLS_ARE_RUNNING; // no balls have landed already this.landedBalls = 0; // iterate through all balls this.ballGroup.getChildren().forEach(function(ball, index) { // add a timer event which fires a ball every 0.1 seconds this.time.addEvent({ delay: 100 * index, callbackScope: this, callback: function() { // set ball velocity ball.body.setVelocity(gameOptions.ballSpeed * Math.cos(this.direction), gameOptions.ballSpeed * Math.sin(this.direction)); } }); }.bind(this)) } // we don't have a legal angle of fire else { // let's wait for player input again this.gameState = WAITING_FOR_PLAYER_INPUT; } } } // method to check collision between a ball and the bounds checkBoundCollision(ball, up, down, left, right) { // we only want to check lower bound and only if balls are running if(down && this.gameState == BALLS_ARE_RUNNING) { // stop the ball ball.setVelocity(0); // increase the amount of landed balls this.landedBalls ++; // if this is the first landed ball... if(this.landedBalls == 1) { // save the ball in firstBallToLand variable this.firstBallToLand = ball; } } } // method to be executed at each frame update() { // if Arcade physics is updating or balls are running and all balls have landed... if((this.gameState == ARCADE_PHYSICS_IS_UPDATING) || this.gameState == BALLS_ARE_RUNNING && this.landedBalls == this.ballGroup.getChildren().length) { // if the game state is still set to BALLS_ARE_RUNNING... if(this.gameState == BALLS_ARE_RUNNING) { // ... basically wait a frame to let Arcade physics update body positions this.gameState = ARCADE_PHYSICS_IS_UPDATING; } // if Arcade already updated body positions... else{ // time to prepare for next move this.gameState = PREPARING_FOR_NEXT_MOVE; // move the blocks this.moveBlocks(); // move the balls this.moveBalls(); // move the extra balls this.moveExtraBalls(); } } // if balls are running... if(this.gameState == BALLS_ARE_RUNNING) { // handle collisions between balls and blocks this.handleBallVsBlock(); // handle collisions between ball and extra balls this.handleBallVsExtra(); } } // method to move all blocks down a row moveBlocks() { // we will move blocks with a tween this.tweens.add({ // we set all blocks as tween target targets: this.blockGroup.getChildren(), // which properties are we going to tween? props: { // y property y: { // each block is moved down from its position by its display height getEnd: function(target) { return target.y + target.displayHeight; } }, }, // scope of callback function callbackScope: this, // each time the tween updates... onUpdate: function(tween, target) { // tween down the value text too target.text.y = target.y; }, // once the tween completes... onComplete: function() { // wait for player input again this.gameState = WAITING_FOR_PLAYER_INPUT; // execute an action on all blocks Phaser.Actions.Call(this.blockGroup.getChildren(), function(block) { // update row custom property block.row ++; // if a block reached the bottom of the game area... if(block.row == gameOptions.blockLines) { // ... it's game over this.gameOver = true; } }, this); // if it's not game over... if(!this.gameOver) { // add another block line this.addBlockLine(); } // if it's game over... else { // ...restart the game this.scene.start("PlayGame"); } }, // tween duration, 1/2 second duration: 500, // tween easing ease: "Cubic.easeInOut" }); } // method to move all balls to first landed ball position moveBalls() { // we will move balls with a tween this.tweens.add({ // we set all balls as tween target targets: this.ballGroup.getChildren(), // set x to match the horizontal position of the first landed ball x: this.firstBallToLand.gameObject.x, // tween duration, 1/2 second duration: 500, // tween easing ease: "Cubic.easeInOut" }); } // method to move all extra balls moveExtraBalls() { // execute an action on all extra balls Phaser.Actions.Call(this.extraBallGroup.getChildren(), function(ball) { // if a ball reached the bottom of the game field... if(ball.row == gameOptions.blockLines) { // set it as "collected" ball.collected = true; } }) // we will move balls with a tween this.tweens.add({ // we set all extra balls as tween target targets: this.extraBallGroup.getChildren(), // which properties are we going to tween? props: { // x property x: { getEnd: function(target) { // is the ball marked as collected? if(target.collected) { // set x to match the horizontal position of the first landed ball return target.scene.firstBallToLand.gameObject.x; } // ... or leave it in its place return target.x; } }, // same thing with y position y: { getEnd: function(target) { if(target.collected) { return target.scene.firstBallToLand.gameObject.y; } return target.y + target.scene.blockSize; } }, }, // scope of callback function callbackScope: this, // once the tween completes... onComplete: function() { // execute an action on all extra balls Phaser.Actions.Call(this.extraBallGroup.getChildren(), function(ball) { // if the ball is not collected... if(!ball.collected) { // ... increase its row property ball.row ++; } // if the ball has been collected... else { // remove the ball from extra ball group this.extraBallGroup.remove(ball); // add the ball to ball group this.ballGroup.add(ball); // set extra ball properties ball.body.collideWorldBounds = true; ball.body.onWorldBounds = true; ball.body.setBounce(1, 1); } }, this); }, // tween duration, 1/2 second duration: 500, // tween easing ease: "Cubic.easeInOut" }); } // method to handle collision between a ball and a block handleBallVsBlock() { // check collision between ballGroup and blockGroup members this.physics.world.collide(this.ballGroup, this.blockGroup, function(ball, block) { // decrease block value block.value --; // if block value reaches zero... if(block.value == 0) { // push block into recycledBlocks array this.recycledBlocks.push(block); // remove the block from blockGroup this.blockGroup.remove(block); // hide the block block.visible = false; // hide block text block.text.visible = false; } // if block value does not reach zero... else{ // update block text block.text.setText(block.value); } }, null, this); } // method to handle collision between a ball and an extra ball handleBallVsExtra() { // check overlap between ballGroup and extraBallGroup members this.physics.world.overlap(this.ballGroup, this.extraBallGroup, function(ball, extraBall) { // set extra ball as collected extraBall.collected = true; // add a tween to move the ball down this.tweens.add({ // the target is the extra ball targets: extraBall, // y destination position is the very bottom of game area y: game.config.height - this.bottomPanel.displayHeight - extraBall.displayHeight / 2, // tween duration, 0.2 seconds duration: 200, // tween easing ease: "Cubic.easeOut" }); }, null, this); } }
In some rare cases, it won’t be that accurate. Why? Probably because the way I determine the collision point, ready for continuous collision detection, is not the same way Arcade physics handle collisions, in a discrete environment.
With an easy and simple physics world like this one, we could even write the game without any physics engine, but this is an experiment I may consider later on, meanwhile download the source code.