By the end of this tutorial, you should have something that looks like this. You can download the Assets folder for this tutorial here.
The last tutorial attempted to separate itself from the classic Pong game a bit by adding a powerup that the ball can collide with. I would like to wrap up the Pong tutorial series by adding some very minor details to the game that can make a big difference. I won’t be covering all of the changes here in depth, because they don’t introduce any new Futile-specific concepts, but I did want to add some shine and end the series with a bit of a flourish. All assets and code are available as a zip archive, just like in every other tutorial.
Here is a preview.
An incredibly easy way to give some life to our game is by adding a ball trail.
In the PDBall.cs file, replace the contents with the code below.
using UnityEngine; using System.Collections; public class PDBall : FSprite { public float xVelocity; public float yVelocity; public float defaultVelocity; public float currentVelocity; public FSprite[] ballTrail; public PDBall() : base("ball") { defaultVelocity = 300.0f; currentVelocity = defaultVelocity; // Instantiate and customize our ball trail sprites ballTrail = new FSprite[3]; for (int x=0; x < 3; x++) { ballTrail[x] = new FSprite("ball"); ballTrail[x].scale = 0.25f + (x * 0.25f); ballTrail[x].alpha = 0.25f + (x * 0.25f); Futile.stage.AddChild(ballTrail[x]); } } override public void Redraw(bool shouldForceDirty, bool shouldUpdateDepth) { // Set the position of each ball to the position of the ball ahead of it ballTrail[0].SetPosition(ballTrail[1].GetPosition()); ballTrail[1].SetPosition(ballTrail[2].GetPosition()); ballTrail[2].SetPosition(this.GetPosition()); base.Redraw(shouldForceDirty, shouldUpdateDepth); } }
In the code above, we are creating three instances of an FSprite from ball.png in the sprite atlas. All three will be scaled differently with different alphas to give the impression that we’re seeing a motion trail. We then override the Redraw function the same way we did when creating an animated sprite, and adjust the positions of each ball in our trail.
So simple, yet so effective, in my opinion. Execute the code and give it a look.
Next, I utilized GoKit, which is packaged with Futile, to create a countdown timer whenever the ball spawns. If you’re familiar with any Tweening library, you’ll be familiar with it. I won’t go into much detail about it, but you can replace your entire PDGame.cs file with the following code to replicate it.
using UnityEngine; using System.Collections; public class PDGame { public PDPaddle player1; public PDPaddle player2; public PDBall ball; public FLabel lblScore1; public FLabel lblScore2; public bool paused = true; // Is the game state paused? public bool roundRestart = true; public int maxScore = 5; // Points needed to win the game public PDPowerup powerup; public FLabel lblCountdown; public PDGame() { player1 = new PDPaddle("player1"); // Create player1 player2 = new PDPaddle("player2"); // Create player2 ResetPaddles(); // Reset the position of the paddles ball = new PDBall(); // Create our ball ResetBall(); // Reset the position of the ball to the center of the screen Futile.stage.AddChild(player1); // Add our elements to the stage, making them visible Futile.stage.AddChild(player2); Futile.stage.AddChild(ball); // Create player1's score label lblScore1 = new FLabel("arial", player1.name + ": " + player1.score); lblScore1.anchorX = 0; // Anchor the label at the left edge lblScore1.anchorY = 0; // Anchor the label at the bottom edge lblScore1.x = -Futile.screen.halfWidth; // Move the label to the far left hand side of the screen lblScore1.y = -Futile.screen.halfHeight; // Move the label to the bottom of the screen // Create player2's score label lblScore2 = new FLabel("arial", player2.name + ": " + player2.score); lblScore2.anchorX = 1.0f; // Anchor the label at the right edge lblScore2.anchorY = 0; // Anchor the label at the bottom edge lblScore2.x = Futile.screen.halfWidth; // Move the label to the far right hand side of the screen lblScore2.y = -Futile.screen.halfHeight; // Move the label to the bottom of the screen // Add the labels to the stage Futile.stage.AddChild(lblScore1); Futile.stage.AddChild(lblScore2); // Create the Countdown label lblCountdown = new FLabel("arial", ""); lblCountdown.scale = 0f; Futile.stage.AddChild(lblCountdown); // Position and render the powerup powerup = new PDPowerup(); ResetPowerup(); Futile.stage.AddChild(powerup); } public void ResetPowerup() { powerup.x = Futile.screen.halfWidth/2 - (Futile.screen.halfWidth * RXRandom.Float()); powerup.y = (Futile.screen.halfHeight - powerup.height/2) - ((Futile.screen.height - powerup.height) * RXRandom.Float()); } public void Update(float dt) { if (roundRestart) { lblCountdown.text = "3"; ball.isVisible = false; ball.ballTrail[0].isVisible = false; ball.ballTrail[1].isVisible = false; ball.ballTrail[2].isVisible = false; powerup.isVisible = false; Tween twThree = new Tween(lblCountdown, 0.35f, new TweenConfig().setIterations(2, LoopType.PingPong).floatProp("scale", 5.0f).onComplete(thisTween => lblCountdown.text="2" )); Tween twTwo = new Tween(lblCountdown, 0.35f, new TweenConfig().setIterations(2, LoopType.PingPong).floatProp("scale", 5.0f).onComplete(thisTween => lblCountdown.text="1" )); Tween twOne = new Tween(lblCountdown, 0.35f, new TweenConfig().setIterations(2, LoopType.PingPong).floatProp("scale", 5.0f).onComplete(thisTween => lblCountdown.text="GO!" )); Tween twGo = new Tween(lblCountdown, 0.35f, new TweenConfig().setIterations(2, LoopType.PingPong).floatProp("scale", 5.0f).onComplete(thisTween => { paused = false; ball.isVisible = true; ball.ballTrail[0].isVisible = true; ball.ballTrail[1].isVisible = true; ball.ballTrail[2].isVisible = true; powerup.isVisible = true; } )); TweenChain twChain = new TweenChain(); twChain.append(twThree).append(twTwo).append(twOne).append(twGo); twChain.play(); roundRestart = false; ResetBall(); ResetPaddles(); } if (!paused) { float newPlayer1Y = player1.y; float newPlayer2Y = player2.y; // Handle Input if (Input.GetKey("w")) { newPlayer1Y += dt * player1.currentVelocity; } if (Input.GetKey("s")) { newPlayer1Y -= dt * player1.currentVelocity; } if (Input.GetKey("up")) { newPlayer2Y += dt * player2.currentVelocity; } if (Input.GetKey("down")) { newPlayer2Y -= dt * player2.currentVelocity; } // Integrate to find the new x and y values for the ball float newBallX = ball.x + dt * ball.xVelocity; float newBallY = ball.y + dt * ball.yVelocity; // Check for ball-and-wall collisions if (newBallY + (ball.height/2) >= Futile.screen.halfHeight) { newBallY = Futile.screen.halfHeight - (ball.height/2) - Mathf.Abs((newBallY - Futile.screen.halfHeight)); ball.yVelocity = -ball.yVelocity; } else if (newBallY - ball.height/2 <= -Futile.screen.halfHeight) { newBallY = -Futile.screen.halfHeight + (ball.height/2) + Mathf.Abs((-Futile.screen.halfHeight - newBallY)); ball.yVelocity = -ball.yVelocity; } // Check for paddle-and-ball collisions Rect ballRect = ball.localRect.CloneAndOffset(newBallX, newBallY); Rect player1Rect = player1.localRect.CloneAndOffset(player1.x, newPlayer1Y); Rect player2Rect = player2.localRect.CloneAndOffset(player2.x, newPlayer2Y); if (ballRect.CheckIntersect(player1Rect) && ball.xVelocity < 0) { BallPaddleCollision(player1, newBallY, newPlayer1Y); } if (ballRect.CheckIntersect(player2Rect) && ball.xVelocity > 0) { BallPaddleCollision(player2, newBallY, newPlayer2Y); } // Check for ball-and-powerup collisions Rect powerupRect = powerup.localRect.CloneAndOffset(powerup.x, powerup.y); if (ballRect.CheckIntersect(powerupRect)) { ResetPowerup(); ball.currentVelocity+= 25.0f; } // Render the ball and paddles at their new locations ball.x = newBallX; ball.y = newBallY; player1.y = newPlayer1Y; player2.y = newPlayer2Y; // Scoring conditions PDPaddle scoringPlayer = null; // No one scored yet, but we need to create a reference if (newBallX - ball.width/2 < -Futile.screen.halfWidth) { // If the right side of the ball leaves the left side of the screen, player2 scored scoringPlayer = player2; } else if (newBallX + ball.width/2 > Futile.screen.halfWidth) { // If the left side of the ball leaves the right side of the screen, player1 scored scoringPlayer = player1; } // Reset the board if someone scores, and handle win/scoring conditions if (scoringPlayer != null) { scoringPlayer.score++; // Increment the scoring player's score lblScore1.text = player1.name + ": " + player1.score; // Update our labels, regardless of who scored lblScore2.text = player2.name + ": " + player2.score; // If the scoring player won if (scoringPlayer.score >= maxScore) { paused = true; // Pause our update loop Futile.stage.RemoveAllChildren(); // Remove all sprites from the screen FLabel lblWinner = new FLabel("arial", scoringPlayer.name + " WINS!"); // Create a label declaring the winner Futile.stage.AddChild(lblWinner); // Display the label } else { roundRestart = true; paused = true; } } } } public void BallPaddleCollision(PDPaddle player, float newBallY, float newPaddleY) { float localHitLoc = newBallY - newPaddleY; // Where did the ball hit, relative to the paddle's center? float angleMultiplier = Mathf.Abs(localHitLoc / (player.height/2)); // Express local hit loc as a percentage of half the paddle's height // Use the angle multiplier to determine the angle the ball should return at, from 0-65 degrees. Then use trig functions to determine new x/y velocities. Feel free to use a different angle limit if you think another one works better. float xVelocity = Mathf.Cos(65.0f * angleMultiplier * Mathf.Deg2Rad) * ball.currentVelocity; float yVelocity = Mathf.Sin(65.0f * angleMultiplier * Mathf.Deg2Rad) * ball.currentVelocity; // If the ball hit the paddle below the center, the yVelocity should be flipped so that the ball is returned at a downward angle if (localHitLoc < 0) { yVelocity = -yVelocity; } // If the ball came in at an xVelocity of more than 0, we know the ball was travelling right when it hit the paddle. It should now start going left. if (ball.xVelocity > 0) { xVelocity = -xVelocity; } // Set the ball's x and y velocities to the newly calculated values ball.xVelocity = xVelocity; ball.yVelocity = yVelocity; } public void ResetPaddles() { player1.x = -Futile.screen.halfWidth + player1.width; // Make sure player1 to the left side of screen player1.y = 0; // Recenter player1 vertically player2.x = Futile.screen.halfWidth - player2.width; // Make sure player2 is on the right side of the screen player2.y = 0; // Recenter player2 vertically } public void ResetBall() { ball.x = 0; // Place ball in the center of the screen ball.y = 0; // Reset ball speed to default ball.currentVelocity = ball.defaultVelocity; // Ensure that the ball starts at a random angle that is never greater than 45 degrees from 0 in either direction ball.yVelocity = (ball.defaultVelocity/2) - (RXRandom.Float() * ball.defaultVelocity); // Make sure that the defaultVelocity (hypotenuse) is honored by setting the xVelocity accordingly, then choose a random horizontal direction ball.xVelocity = Mathf.Sqrt((ball.defaultVelocity*ball.defaultVelocity) - (ball.yVelocity*ball.yVelocity)) * (RXRandom.Int(2) * 2 - 1); } }
To end my work with Pong, I touched up the ball, paddle, and powerup assets a bit to create the final product. You can view it by opening the webplayer demo.
I would like to thank everyone who took the time to give Futile a try. MattRix really did create a solid, effective product to assist developers with 2D game development in Unity. I may continue to update this blog with other Futile tutorials, such as introducing FContainers and other concepts, but they will be standalone tutorials.
Finally, I’ll also be updating this blog with information about a game I am working on.
Thanks for reading!
By the end of the tutorial you just read, you should have something that looks like this. You can download the Assets folder for this tutorial here.