Creating a simple Ping-Pong game using Flutter CustomPainter and Explicit animations

Creating a simple Ping-Pong game using Flutter CustomPainter and Explicit animations

google-flutter-logo-white.png

CustomPaint is a widget that allows you to draw shapes on a canvas using a CustomPainter. The first step into creating our game would be to draw out a simple circle and a rectangle on our canvas

Screen_Recording_20201209-161030_001_1.gif

Create a new flutter project

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Ping Pong Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>{
Size canvasSize;

//ball specs
Offset ballPosition;
double ballRadius;

@override
 void initState() {
    super.initState();
    canvasSize = Size(MediaQuery.of(context).size.width,
         MediaQuery.of(context).size.height);

    //to place the ball at the center of the canvas
    ballPosition = Offset(canvasSize.width/2,canvasSize.height/2);
    ballRadius = 15;
   }

  @override
  Widget build(BuildContext context){
       return Scaffold(
         body: Container(
              color: Colors.white,
              child: ....... // The CustomPaint witdget goes here
          )
      );
   }
}

In the above snippet, we set the canvasSize variable to take up the size of the entire screen. This would give our game more space and a better feel

Creating our CustomPainter

We extend the CustomPainter class and override the following methods

paint : The paint method is where we draw our shapes

shouldRepaint: Constrols whether the painter should redraw. This should always return false if what is drawn never changes.

For more information about the CustomPaint widget click here

class GamePainter extends CustomPainter {
  double ballRadius;
  Offset ballPosition;

  GamePainter({this.ballRadius, this.ballPosition});

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(ballPosition, ballRadius, Paint()
      ..color = Colors.red);
  }

  @override
  bool shouldRepaint(GamePainter old) {
    return false;
  }
}

In the next step, we make use of the GamePainter

...
@override
  Widget build(BuildContext context){
       return Scaffold(
         body: Container(
              color: Colors.white,
              child: CustomPaint(
                  size: canvasSize,
                  painter: GamePainter(
                    ballRadius: ballRadius,
                    ballPosition: ballPosition,
                 )
             )
          )
      );
   }
...

On running the app, you would get an error

dependOnInheritedWidgetOfExactType() or dependOnInheritedElement() was called before _MyHomePageState.initState() completed.

This error occurs because the MediaQuery.of(context) gets executed before the super.initState(). The fix is quite simple

....
@override
  void initState() {
    super.initState();

   //delay execution by just a millisecond
    Future.delayed(Duration(milliseconds: 1),(){
      canvasSize = Size(MediaQuery.of(context).size.width,
          MediaQuery.of(context).size.height);

      //to place the ball at the center of the canvas
      ballPosition = Offset(canvasSize.width/2,canvasSize.height/2);
      ballRadius = 15;
    });
  }

  @override
  Widget build(BuildContext context){
    return Scaffold(
        body: Container(
            color: Colors.white,
            child: canvasSize==null?Container():CustomPaint(
                size: canvasSize,
                painter: GamePainter(
                  ballRadius: ballRadius,
                  ballPosition: ballPosition,
                )
            )
        )
    );
  }
....

Animating the ball

You can't have a game without things moving around. To add motion to our ball, we create an AnimationController.

The _MyHomePageState will use the SingleTickerProviderStateMixin to tell flutter there is some animation in this widget

...
class _MyHomePageState extends State<MyHomePage> with
           SingleTickerProviderStateMixin{
  ...
  Animation controller;
  @override
  void initState() {
    super.initState();
    Future.delayed(Duration(milliseconds: 1),(){
       ....
       controller = AnimationController(duration: Duration(hours: 1), vsync: this);
       controller.addListener((){
       setState((){
              //code that causes ball to move
         });
     });
    controller.repeat();
   });
  }
}
...

We want our animation to run infinitely, so we set our controller duration to any value. it doesn't matter how large it is, but you must call the controller.repeat() to make it run continiously.

....
//ball specs
Offset ballPosition;
double ballRadius;
double velocityX;
double velocityY;
....

To make the ball move either in the x or y direction, we simply increment the ballPosition offset in the direction we want it to move. velecotyX and velocityY variables represent the x and y components of the speed respectively

We then create the initBall() function (inside the _MyHomePageState class) that calculates the initial position, speed and direction of the ball

import 'package:flutter/material.dart';
import 'dart:math' as math;
... 

class _MyHomePageState extends State<MyHomePage> with
       SingleTickerProviderStateMixin{
   ...
   void initBall(){
        math.Random random = new math.Random();
        ballRadius = 15;
        int initialBallSpeed = 5; 
        double initialBallAngle = random.nextDouble() * (150 - 30 + 1) + 30;
        velocityX = initialBallSpeed * math.cos(initialBallAngle * math.pi / 180);
        velocityY = initialBallSpeed * math.sin(initialBallAngle * math.pi / 180);
        ballPosition = Offset(canvasSize.width / 2, 15);
   }
  ...
}

Note: A speed of 5 simply means we are shifting the position of the ball by 5 px per frame.

In the above code snippet, we use an initial speed of 5 (use any speed of your choice) and and we randomly select an angle between 30 -150 degress. This angle range was chosen because we want the ball to move downwards from the top of the screen. Screenshot (48).png We then used the initialBallSpeed and initialBallAngle to determine the x and y component of the speed

using, x=r*cos(θ) and y=r*sin(θ)

velocityX = initialBallSpeed * math.cos(initialBallAngle * math.pi / 180);

velocityY = initialBallSpeed * math.sin(initialBallAngle * math.pi / 180); ballPosition = Offset(canvasSize.width / 2, 15);

Note: The angle is converted to radians by multiplying by math.pi / 180

Call the initBall function. Replace this section of your code

Future.delayed(Duration(milliseconds: 1),(){
      canvasSize = Size(MediaQuery.of(context).size.width,
          MediaQuery.of(context).size.height);

      //initBall
      initBall();

      controller = AnimationController(duration: Duration(hours: 1), vsync: this);
      controller.addListener((){
        setState((){
          //code that causes ball to move
          ballPosition =
              Offset(ballPosition.dx + velocityX, ballPosition.dy + velocityY);
        });
      });
      controller.repeat();
    });

ballPosition = Offset(ballPosition.dx + velocityX, ballPosition.dy + velocityY);

This line of code is what causes the ball to move inside the setState method. The velocityX and velocityY is added to the ballPositon after each frame of the animation

We still have to make changes to the shouldRepaint method of our gamePainter because we now have components that would need to be redrawn in our CustomPainter

@override
  bool shouldRepaint(GamePainter old) {
    //return true if the ball position has changed otherwise return false
    if (old.ballPosition.dx != ballPosition.dx ||
        old.ballPosition.dy != ballPosition.dy) {
      return true;
    }
    return false;
  }

Run the program and the ball moves. Try changing the speed to a higher value and notice the changes.

Making the ball bounce off the edges

The ball currently just animates out of the screen into oblivion and never returns 😞

Inside the _MyHomePageState class, we define the following function that checks if the ball has hit any of the sides

bool didBallHitLeft() {
    if (ballPosition.dx >= canvasSize.width) {
      return true;
    }
    return false;
  }

  bool didBallHitRight() {
    if (ballPosition.dx <= 0) {
      return true;
    }
    return false;
  }

  bool didBallHitTop() {
    if (ballPosition.dy <= 0) {
      return true;
    }
    return false;
  }

  bool didBallHitBottom() {
    if (ballPosition.dy >= canvasSize.height) {
      return true;
    }
    return false;
  }

In the controller.addListener,

controller.addListener(() {
        setState(() {
          //code that causes ball to move
          ballPosition =
              Offset(ballPosition.dx + velocityX, ballPosition.dy + velocityY);
        });
        if (didBallHitTop()) {
          if (velocityY < 1) {

            velocityY = velocityY * -1;
          }
        }

        if (didBallHitBottom()) {
          //game over. reinitialize ball
          initBall();
        }

        if (didBallHitLeft()||didBallHitRight()) {
          velocityX = velocityX * -1;
        }
      });

In the above snippet, if the ball hits the top, the velocityY is multiplied by -1 to reverse its direction downwards.

If the ball hits the left or right frame, the velocityX is multiplied by -1 to reverse its direction towards the opposite side

if the ball hits the bottom, we simply just call initBall() to start afresh because the user has failed the game.

Drawing the rectangular paddle

//ball variables
  Offset ballPosition;
  int ballSpeed;
  double velocityY;
  double velocityX;
  double ballRadius;
  double angle;

  //paddle variable
  Offset paddlePosition;  //new line
...
@override
  Widget build(BuildContext context){
       return Scaffold(
         body: Container(
              color: Colors.white,
              child: CustomPaint(
                  size: canvasSize,
                  painter: GamePainter(
                    ballRadius: ballRadius,
                    ballPosition: ballPosition,
                    paddlePosition: paddlePosition, //new line
                 )
             )
          )
      );
   }
...
class GamePainter extends CustomPainter {
  double ballRadius;
  Offset ballPosition;
  Offset paddlePosition; //new line

  GamePainter({this.ballRadius, this.ballPosition,this.paddlePosition});

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(ballPosition, ballRadius, Paint()..color = Colors.red);

    //new line
    canvas.drawRect(Rect.fromCenter(center: paddlePosition, width: 200, height: 15), Paint()); 
  }

  @override
  bool shouldRepaint(GamePainter old) {
    ......
  }
}

Initialize the paddlePosion in the initState() function

    //init paddle variables
    paddlePosition = Offset(canvasSize.width / 2, canvasSize.height * 0.95);

Making the paddle respond to user gesture

It wouldn't be a game if users can't interact with it. To make the paddle responsive we have to Wrap the CustomPaint widget with a GestureDetector widget

@override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Container(
            color: Colors.white,
            child: canvasSize == null
                ? Container()
                : GestureDetector(
                    onPanDown: (details) {},
                    onPanUpdate: (details) {
                      paddlePosition =
                          Offset(details.globalPosition.dx, paddlePosition.dy);
                    },
                    child: CustomPaint(
                        size: canvasSize,
                        painter: GamePainter(
                            ballRadius: ballRadius,
                            ballPosition: ballPosition,
                            paddlePosition: paddlePosition)),
                  )));
  }

In the onPanUpdate callBack, we update the position of the paddle by updating the dx of its offset. The dy is left constant because we only want the paddle to move left or right not up or down.

Run the app and play around with the gestures.

Making the ball bounce off the paddle

We create a function that checks if the ball has hit the paddle just like we did for the sides.

...
bool didBallHitBottom() {
    if (ballPosition.dy >= canvasSize.height) {
      return true;
    }
    return false;
  }

bool didBallHitPaddle() {
    if (ballPosition.dy + ballRadius >= paddlePosition.dy &&
        ballPosition.dx >= paddlePosition.dx - 100 &&
        ballPosition.dx <= paddlePosition.dx + 100) {
      return true;
    }
    return false;
  }
...
if (didBallHitTop()) {
     if (velocityY < 1) {
         velocityY = velocityY * -1;
     }
}

//new line
if (didBallHitPaddle()) {
     velocityY = velocityY * -1;
}
...

Run the app, the ball should bounce off the paddles.

This completes the game. Below is the Complete Source Code

import 'package:flutter/material.dart';
import 'dart:math' as math;

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Ping Pong Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  Size canvasSize;

  Offset ballPosition;
  double ballRadius;
  double velocityX;
  double velocityY;

  //paddle variable
  Offset paddlePosition;

  AnimationController controller;

  void initBall() {
    math.Random random = new math.Random();
    ballRadius = 15;
    int initialBallSpeed = 10;
    double initialBallAngle = random.nextDouble() * (150 - 30 + 1) + 30;
    velocityX = initialBallSpeed * math.cos(initialBallAngle * math.pi / 180);
    velocityY = initialBallSpeed * math.sin(initialBallAngle * math.pi / 180);
    ballPosition = Offset(canvasSize.width / 2, 15);
  }

  bool didBallHitLeft() {
    if (ballPosition.dx >= canvasSize.width) {
      return true;
    }
    return false;
  }

  bool didBallHitRight() {
    if (ballPosition.dx <= 0) {
      return true;
    }
    return false;
  }

  bool didBallHitTop() {
    if (ballPosition.dy <= 0) {
      return true;
    }
    return false;
  }

  bool didBallHitBottom() {
    if (ballPosition.dy >= canvasSize.height) {
      return true;
    }
    return false;
  }

  bool didBallHitPaddle() {
    if (ballPosition.dy + ballRadius >= paddlePosition.dy &&
        ballPosition.dx >= paddlePosition.dx - 100 &&
        ballPosition.dx <= paddlePosition.dx + 100) {
      return true;
    }
    return false;
  }

  @override
  void initState() {
    super.initState();
    Future.delayed(Duration(milliseconds: 1), () {
      canvasSize = Size(MediaQuery.of(context).size.width,
          MediaQuery.of(context).size.height);

      //initBall
      initBall();

      //init paddle variables
      paddlePosition = Offset(canvasSize.width / 2, canvasSize.height * 0.95);

      controller =
          AnimationController(duration: Duration(hours: 1), vsync: this);
      controller.addListener(() {
        setState(() {
          //code that causes ball to move
          ballPosition =
              Offset(ballPosition.dx + velocityX, ballPosition.dy + velocityY);
        });
        if (didBallHitTop()) {
          if (velocityY < 1) {
            velocityY = velocityY * -1;
          }
        }

        if (didBallHitPaddle()) {
          velocityY = velocityY * -1;
        }

        if (didBallHitBottom()) {
          //game over. reinitialize ball
          initBall();
        }

        if (didBallHitLeft() || didBallHitRight()) {
          velocityX = velocityX * -1;
        }
      });
      controller.repeat();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Container(
            color: Colors.white,
            child: canvasSize == null
                ? Container()
                : GestureDetector(
                    onPanDown: (details) {},
                    onPanUpdate: (details) {
                      paddlePosition =
                          Offset(details.globalPosition.dx, paddlePosition.dy);
                    },
                    child: CustomPaint(
                        size: canvasSize,
                        painter: GamePainter(
                            ballRadius: ballRadius,
                            ballPosition: ballPosition,
                            paddlePosition: paddlePosition)),
                  )));
  }
}

class GamePainter extends CustomPainter {
  double ballRadius;
  Offset ballPosition;
  Offset paddlePosition; //new line

  GamePainter({this.ballRadius, this.ballPosition, this.paddlePosition});

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(ballPosition, ballRadius, Paint()..color = Colors.red);
    canvas.drawRect(
        Rect.fromCenter(center: paddlePosition, width: 200, height: 15),
        Paint());
  }

  @override
  bool shouldRepaint(GamePainter old) {
    //return true if the ball position has changed otherwise return false
    if (old.ballPosition.dx != ballPosition.dx ||
        old.ballPosition.dy != ballPosition.dy) {
      return true;
    }
    return false;
  }
}