Creating a simple Ping-Pong game using Flutter CustomPainter and Explicit animations
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
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.
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;
}
}