Unity/Futile Pong Example (Part 9) – Adding some polish and calling it a game

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.

final_product

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.

balltrail

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.

7 thoughts on “Unity/Futile Pong Example (Part 9) – Adding some polish and calling it a game

  1. Pingback: Unity/Futile Pong Example (Part 8) – Adding powerups and introducing animated sprites | Game Development

  2. Why do all my sprites look extremely blurry? I have filter mode set to point, Max Size 1024 and Format Truecolor.

  3. Hey Brandon, thanks for the tutorial series, was helpful! I’m thinking about extended the code base to test a couple more things. Mainly deploying to iOS with touch controls and integrating TestFlight for beta testing. I’ll probably throw in a simple AI so one person can play the game on mobile.

    Do you mind if I write up a tutorial or two for what I do on my blog using the code base you left off with as my starting point? I’ll of course fully reference your tutorial series and give you credit for all you’ve done.

    Jason

    • I wouldn’t mind at all! I’d love to see another Futile tutorial, especially one geared towards iOS. I’ll check it out as soon as you publish it. Good luck.

  4. Pingback: Unity/Futile Tutorial – Bringing Pong to Mobile Part 3 – Adding Touch Controls and TestFlight | Jason Rendel

  5. I really thought a configurable trail would look better so I made this if anyone is interested.

    using UnityEngine;
    using System.Collections;

    public class PDBall : FSprite {
    public float xVelocity;
    public float yVelocity;
    public float defaultVelocity;
    public float currentVelocity;

    public int trailLength = 100;

    public FSprite[] ballTrail;

    public PDBall() : base(“ballB”) {
    defaultVelocity = 200.0f;
    currentVelocity = defaultVelocity;

    // Instantiate and customize our ball trail sprite
    ballTrail = new FSprite[trailLength];
    for (int x=0; x<trailLength;x++) {
    ballTrail[x] = new FSprite("ballB");
    // USE THIS Line FOR SOMETHING REALLY FUN instead of the line after it!
    //ballTrail[x].scale = Mathf.Max(trailLength* 1.0f/(x+0.00001f), 1.0f);
    ballTrail[x].scale = x / (float)trailLength;
    ballTrail[x].alpha = x / (float)trailLength* 0.8f;
    Futile.stage.AddChild(ballTrail[x]);
    }
    }

    override public void Redraw(bool shouldForceDirty, bool shouldUpdateDepth) {
    // set the ball position of the each ball to the position of the ball ahead of it
    for(int i=0;i<trailLength-1;i++) {
    ballTrail[i].SetPosition(ballTrail[i+1].GetPosition());
    }
    ballTrail[trailLength-1].SetPosition(this.GetPosition());

    base.Redraw(shouldForceDirty, shouldUpdateDepth);
    }
    }

Leave a reply to Jim Cancel reply