Physics-based 2D game in Java, Pt 2
This post will demonstrate how the PhysicsSim game from part 1 can be rebuilt with a different structure. As I mentioned before, the structure of a program is a key factor in whether that program can be easily modified, understood, and reused for other projects.
If you are following along with a blank Project, download the image files and place them in the same folder where your code will go.
Download images onlyIf you don't want to follow along, you can get final finished code as well.
Download finished code and imagesAny program can be thought of as a system of mini-programs; chunks of code that act as little cogs to make the whole machine turn. Let's look at the web of interaction between the 4 classes that comprised the PhysicsSim V1...
GameController:
Displayed program window (inherits from JFrame)
Accepted Mouse interaction (implements MouseListener)
Processed Mouse interaction (modifies velocity of Ball)
Ran Game timer
Computed game physics
Owned / Displayed GameCanvas instance on-screen
Owned Ball / Target Instance
Determines game state (Playing / Game over)
GameCanvas:
Held static reference to GameController's Ball / Target
painted Ball and Target to screen
Ball / Target:
Held information about their own state (location, size, etc)
Let’s imagine now a slightly different structure. We’ll start by rethinking how the Ball and Target should relate to each other. In part 1, one was the child of the other. Since they both have virtually the same functionality, (having measures of velocity, size, and position that is used by the physicsCalculations method), it’s more logical to make them siblings (inheriting from a common parent).
A new class, Entity, can be that common parent.
Add 3 new classes to your project, Entity, Ball, and Target,
create Entity.java
public class Entity { double xPos = 0; double yPos = 0; double xVelocity = 0; double yVelocity = 0; int size; }
create Ball.java
public class Ball extends Entity{ double bounciness= 0.95; double friction = 0; }
create Target.java
public class Target extends Entity { double velocityModifier = 0.3; }
Now, let’s change how the game is rendered to screen. Instead of calling the repaint method on a single JComponent, such as the GameCanvas, we can use individual JComponents (JLabels with Images instead of Text) to represent the Ball and Target.
Old way: JFrame held one JComponent onto which all game objects were painted/rendered.
New way: JFrame holds a multiple JComponents, each of
represents a different game object.
Let’s also shift some of GameController’s Game Logic responsibilities to another class. This is because the GameController is now also responsible for owning and displaying the Game objects (Target and Ball). Choking a single class up with the entire program’s workload is rarely a good idea, so the LogicHandler class will be introduced to pick up the physics calculations and game-state checking.
GameController
First, add the GameController class to your project.
create GameController.java
import java.awt.Image; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; import java.net.URL; import java.util.TimerTask; import javax.imageio.ImageIO; import javax.swing.ImageIcon; import javax.swing.JFrame; import javax.swing.JLabel; public class GameController extends JFrame implements MouseListener { boolean runGame = true; int boardSizeX = 600; int boardSizeY = 600; final long cycleRate = 60; java.util.Timer timer; int gameAreaBufferSize = 40; int windowBarHeight = 20; ImageIcon ballImage; JLabel ballLabel; ImageIcon targetImage; JLabel targetLabel; int backgroundWidth = 800; int backgroundHeight = 800; ImageIcon backgroundImage; JLabel backgroundLabel; LogicHandler logicHandler = new LogicHandler(); GameController() { super(); setupJFrame(); createEntityLabels(); setLocationsOfEntityLabels(); loadImages(); createTimerTask(); } void setupJFrame() { int totalBufferSize = (2 * gameAreaBufferSize); setSize(boardSizeX + totalBufferSize, boardSizeY + totalBufferSize + windowBarHeight); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setVisible(true); this.setResizable(false); addMouseListener(this); } void createEntityLabels() { backgroundLabel = new JLabel(); this.add(backgroundLabel); targetLabel = new JLabel(); this.add(targetLabel); ballLabel = new JLabel(); this.add(ballLabel); } void setLocationsOfEntityLabels() { int mainBallDiameter = logicHandler.mainBall.size; int mainBallRadius = mainBallDiameter / 2; int mainBallX = (int) logicHandler.mainBall.xPos + gameAreaBufferSize - mainBallRadius + (int) logicHandler.screenShakeEntity.xPos; int mainBallY = (int) logicHandler.mainBall.yPos + gameAreaBufferSize - mainBallRadius + (int) logicHandler.screenShakeEntity.yPos; ballLabel.setBounds(mainBallX, mainBallY, mainBallDiameter, mainBallDiameter); int targetDiameter = logicHandler.target.size; int targetRadius = targetDiameter / 2; int targetX = (int) logicHandler.target.xPos + gameAreaBufferSize - targetRadius + (int) logicHandler.screenShakeEntity.xPos; int targetY = (int) logicHandler.target.yPos + gameAreaBufferSize - targetRadius + (int) logicHandler.screenShakeEntity.yPos; targetLabel.setBounds(targetX, targetY, targetDiameter, targetDiameter); int baseLocationX = -100; int baseLocationY = -100; int backgroundX = baseLocationX + (int) logicHandler.screenShakeEntity.xPos + gameAreaBufferSize; int backgroundY = baseLocationY + (int) logicHandler.screenShakeEntity.yPos + gameAreaBufferSize; backgroundLabel.setBounds(backgroundX, backgroundY, backgroundWidth, backgroundHeight); } void loadImages() { try { URL url; Image image; int mainBallDiameter = logicHandler.mainBall.size; url = this.getClass().getResource("Ball.png"); image = ImageIO.read(url).getScaledInstance(mainBallDiameter, mainBallDiameter, Image.SCALE_SMOOTH); ballImage = new ImageIcon(image); ballLabel.setIcon(ballImage); int targetDiameter = logicHandler.target.size; url = this.getClass().getResource("Target.png"); image = ImageIO.read(url).getScaledInstance(targetDiameter, targetDiameter, Image.SCALE_SMOOTH); targetImage = new ImageIcon(image); targetLabel.setIcon(targetImage); url = this.getClass().getResource("background.png"); image = ImageIO.read(url).getScaledInstance(backgroundWidth, backgroundHeight, Image.SCALE_SMOOTH); backgroundImage = new ImageIcon(image); backgroundLabel.setIcon(backgroundImage); } catch (Exception e) { System.out.println("Load failed"); System.err.println(e); } } void createTimerTask() { TimerTask timerTask = new TimerTask() { public void run() { if (runGame) { } } }; timer = new java.util.Timer(); timer.schedule(timerTask, 0, 1000 / cycleRate); } @Override public void mousePressed(MouseEvent e) { int clickX = e.getX(); int clickY = e.getY(); String clickLocation = clickX + ", " + clickY; System.out.println("Mouse Pressed at " + clickLocation); } @Override public void mouseClicked(MouseEvent e) { } @Override public void mouseReleased(MouseEvent e) { } @Override public void mouseEntered(MouseEvent e) { } @Override public void mouseExited(MouseEvent e) { } public static void main(String[] args) { GameController gameWindow = new GameController(); } }
Notice that most of the setup has already been taken care of: the class variables and timer are already present and the mouse interaction is already set up. (If you want an more in-depth look at what is happening inside the GameController, I explained it in better detail in part 1).
There are also some new variables in this version of GameController. There is an instance of the LogicHandler class, which I will cover in a moment. There are JLabels representing the Ball and Target, and an associated image reference for each. There is also another JLabel, the backgroundLabel, which is has no gameplay significance but is used for a cool visual effect later.
Also notice that I broke up the responsibilities of the
GameController's constructor. The constructor from part 1 was a
little messy. Now, it’s responsibilities have been have been moved to 5 separate methods that are more human-friendly to read.
setupJFrame() prepares a windowed application for viewing.
createEntityLabels() initializes the JLabel objects and adds them to the view heirarchy of the JFrame.
setLocationsOfEntityLabels() sets initial variables of the ball, target, and backgroundLabel, and sets their
position inside the JFrame.
loadImages() attemps to load images from disk and assign them to a JLabel object.
createTimerTask() kicks off the game timer, starting the game.
LogicHandler
create LogicHandler.java
import java.awt.Point; import static java.lang.Math.abs; public class LogicHandler { Ball mainBall; Target target; Entity screenShakeEntity; LogicHandler() { // Create the ball mainBall = new Ball(); mainBall.xPos = 200; mainBall.yPos = 200; mainBall.size = 50; // Create the Target target = new Target(); target.xPos = 40; target.yPos = 100; target.size = 80; // Create the ScreenShakeEntity screenShakeEntity = new Entity(); screenShakeEntity.xPos = 0; screenShakeEntity.yPos = 0; screenShakeEntity.size = 0; } void physics(int boardSizeX, int boardSizeY) { //================================================================ //Physics for the target //================================================================ // If on the left half of the board, push right if (target.xPos > boardSizeX / 2) { target.xVelocity -= target.velocityModifier; } // If on the left half of the board, push right else if (target.xPos < boardSizeX / 2) { target.xVelocity += target.velocityModifier; } target.xPos += target.xVelocity; //================================================================ //Physics for the Ball //================================================================ double gravityAcceleration = 0.5; // gravity mainBall.yVelocity += gravityAcceleration; // Air resistance mainBall.xVelocity *= 0.996; mainBall.yVelocity *= 0.996; // collision detection of the four walls int leftWall = 0; int rightWall = boardSizeX; int topWall = 0; int bottomWall = boardSizeY; int ballRadius = mainBall.size / 2; // Used for setting the screenShakeEntity velocity float velocityBumpX = 0; float velocityBumpY = 0; if (mainBall.xPos - ballRadius < leftWall) { mainBall.xVelocity = abs(mainBall.xVelocity) * mainBall.bounciness; mainBall.xPos = leftWall + ballRadius; velocityBumpX = (float) mainBall.xVelocity; } if (mainBall.xPos + ballRadius > rightWall) { mainBall.xVelocity = -abs(mainBall.xVelocity) * mainBall.bounciness; mainBall.xPos = rightWall - ballRadius; velocityBumpX = (float) mainBall.xVelocity; } if (mainBall.yPos - ballRadius < topWall) { mainBall.yVelocity = abs(mainBall.yVelocity) * mainBall.bounciness; mainBall.yPos = topWall + ballRadius; velocityBumpY = (float) mainBall.yVelocity; } if (mainBall.yPos + ballRadius > bottomWall) { mainBall.yVelocity = -abs(mainBall.yVelocity) * mainBall.bounciness; mainBall.yPos = bottomWall - ballRadius; velocityBumpY = (float) mainBall.yVelocity; } float bumpThreshold = 5; if (velocityBumpY > -bumpThreshold && velocityBumpY < bumpThreshold) { velocityBumpY = 0; } if (velocityBumpX > -bumpThreshold && velocityBumpX < bumpThreshold) { velocityBumpX = 0; } mainBall.xPos += mainBall.xVelocity; mainBall.yPos += mainBall.yVelocity; //================================================================ //Physics for the screenShakeEntity //================================================================ // Add the bump from the ball colliding with the wall screenShakeEntity.xVelocity += velocityBumpX / 3; screenShakeEntity.yVelocity += velocityBumpY / 3; // Add the spring effect that moves the screenShakeEntity back to (0, 0) screenShakeEntity.xVelocity -= screenShakeEntity.xPos * (0.5); screenShakeEntity.yVelocity -= screenShakeEntity.yPos * (0.5); screenShakeEntity.xVelocity *= 0.9; screenShakeEntity.yVelocity *= 0.9; screenShakeEntity.xPos += screenShakeEntity.xVelocity; screenShakeEntity.yPos += screenShakeEntity.yVelocity; } boolean gameIsOver(){ double distanceFromBallToGoal_X = mainBall.xPos - target.xPos; double distanceFromBallToGoal_Y = mainBall.yPos - target.yPos; double absoluteDistance = Math.sqrt((distanceFromBallToGoal_X * distanceFromBallToGoal_X) + (distanceFromBallToGoal_Y * distanceFromBallToGoal_Y)); double threshold = (target.size - mainBall.size) / 2; if (absoluteDistance <= threshold) return true; return false; } }
While the GameController owns the representing JLabels and associated Images, the LogicHandler owns the actual Entity objects of ball, target, and screenShakeEntity.
Right now, LogicHandler only has two methods: it’s constructor and the physics method. In the constructor, the 3 Entity objects are initialized. The physics method is very similar to the physicsCalculations method from lesson 1.
Your codebase should now look like this: 5 classes and 3 images. It doesn't matter what the name of the package is, as long as all java files start with the same package declaration
Test run
There is only one more thing to do before we give a test run- the GameController's timer must make a call to the LogicHandler’s physics method, and the JLabels representing the Game Objects must then be repositioned to the location of logicHandler.ball and logicHandler.target
Back inside the GameController, there is a method called createTimerTask that is called from the constructor. Go inside that method and replace the TimerTask’s run method with-
replace inside GameController.java
public void run() { if (runGame) { logicHandler.physics(boardSizeX, boardSizeY); setLocationsOfEntityLabels(); if (logicHandler.gameIsOver()) { runGame = false; } } }
This game loop is very similar to part 1’s Game loop. First, the physics are computed. Then, the visuals is updated with a call to setLocationsOfEntityLabels.
Run the program- it should look like this...
Your codebase should now look like this: 5 classes and 3 images. It doesn't matter what the name of the package is, as long as all java files start with the same package declaration
Notice that wobble every time the ball strikes the floor? That is the effect of the screenShakeEntity, defined in the LogicHandler. When the ball collides with a wall in logicHandler.physics, the screenShakeEntity's velocity is modified, which in turn changes the position. The offset of the screenShakeEntity is then added to the positons of the ball and target JLabels.
Adding interaction
The GameController is already to configured to listen to
mouse input (with the mousePressed method from MouseListener). But like I said earlier, the GameController is
shifting Game Logic-oriented tasks to the LogicHandler. The GameController is just catching mouse
input and sending it the logicHandler.throwBall method.
Add a method inside LogicHandler to translate the
mouseclick into an action, and a method to reset the game variables too.
add to LogicHandler.java
void throwBall(int clickX, int clickY){ double distanceX = (float)clickX - mainBall.xPos; double distanceY = (float)clickY - mainBall.yPos; double pushX = distanceX / 10; double pushY = distanceY / 10; mainBall.xVelocity += pushX; mainBall.yVelocity += pushY; } void resetAllGameObjects(){ mainBall.xPos = 300; mainBall.yPos = 300; mainBall.size = 50; screenShakeEntity.xPos = 0; screenShakeEntity.yPos = 0; screenShakeEntity.size = 0; }
Finally, update the mouseListener method to call the throwBall method
replace inside GameController.java
@Override public void mousePressed(MouseEvent e) { if (runGame) { int clickX = e.getX() - gameAreaBufferSize; int clickY = e.getY() - gameAreaBufferSize - windowBarHeight; logicHandler.throwBall(clickX, clickY); } else { logicHandler.resetAllGameObjects(); runGame = true; } }
...And that's it! run the program, and the game should function exactly like it did in part 1.
Posted Dec 26 2017