Class 4 - Objects and Pong Part 2
Play homework
Topics
- Objects
- Pong iteratively developed
Description
This week we continue our work on Pong in order to complete the game.
Objects
In our previous work with tables we used our tables as numbered lists, but we can also store values in a different way: With strings.
function love.load()
--rect is short for rectangle
rect = {}
rect["width"] = 100
end
"width"
in this case is what we call a key or a property. So the table rectangle
now has the property "width"
with a value of 100. We don’t need to use strings every time we want to create property. A dot (.) is the shorthand for table_name["property_name"]
.
function love.load()
rect = {}
-- These two are the same
rect["width"] = 100
rect.width = 100
end
Let’s add some more properties.
function love.load()
rect = {}
rect.x = 100
rect.y = 100
rect.width = 70
rect.height = 90
end
Now that we have our properties we can start drawing the rectangle.
function love.draw()
love.graphics.rectangle("line", rect.x, rect.y, rect.width, rect.height)
end
And let’s make it move!
function love.load()
rect = {}
rect.x = 100
rect.y = 100
rect.width = 70
rect.height = 90
--Add a speed property
rect.speed = 100
end
function love.update(dt)
-- Increase the value of x. Don't forget to use delta time.
rect.x = rect.x + rect.speed * dt
end
Now we have a moving rectangle again, but to show the power of tables I want to create multiple moving rectangles. For this we’re going to use a table as a list. We’ll make a list of rectangles. Move the code inside the love.load
to a new function, and create a new table in love.load
.
function love.load()
-- Remember: camelCasing!
listOfRectangles = {}
end
function createRect()
rect = {}
rect.x = 100
rect.y = 100
rect.width = 70
rect.height = 90
rect.speed = 100
-- Put the new rectangle in the list
table.insert(listOfRectangles, rect)
end
So now every time we call createRect
, a new rectangle object will be added to our list. That’s right, a table filled with tables. Let’s make it so that whenever we press space, we call createRect
. We can do this with the callback love.keypressed
.
function love.keypressed(key)
-- Remember, 2 equal signs (==) for comparing!
if key == "space" then
createRect()
end
end
Whenever we press a key, LÖVE will call love.keypressed, and pass the pressed key as argument. If that key is "space"
, it will call createRect
.
Last thing to do is to change our update and draw function. We have to iterate through our list of rectangles.
function love.update(dt)
for i,v in ipairs(listOfRectangles) do
v.x = v.x + v.speed * dt
end
end
function love.draw(dt)
for i,v in ipairs(listOfRectangles) do
love.graphics.rectangle("line", v.x, v.y, v.width, v.height)
end
end
And now when you run the game, a moving rectangle should appear every time you press space.
One more time?
That was a lot of code in a rather short tutorial. I can imagine it can be quite confusing so let’s go through the whole code one more time:
In love.load
we created a table called listOfRectangles
.
When we press space, LÖVE calls love.keypressed
, and inside that function we check if the pressed key is "space"
. If so, we call the function createRect
.
In createRect
we create a new table. We give this table properties, like x
and y
, and we store this new table inside our list listOfRectangles
.
In love.update
and love.draw
we iterate through this list of rectangles, to update and draw each rectangle.
Functions in objects
An object can also have functions. You create a function for an object like this:
tableName.functionName = function ()
end
-- Or the more common way
function tableName.functionName()
end
Summary
We can store values in tables not only with numbers but also with strings. We call these type of tables objects. Having objects saves us from creating a lot of variables.
pong-5 (“The Class Update”)
- pong-5 behaves exactly like pong-4. The biggest advantage we gain from this update is in the design of our code.
- Open up pong-5 to take a look at how we’ve reorganized the code using classes and objects.
What is a class?
- A class is essentially a container for attributes (i.e., values or fields) and methods (i.e., functions). You can think of it as a blueprint for creating bundles of data and code that are related to each other.
- Ex: A “Car” class can have “attributes” that describe its brand, model, color, miles, and anything else descriptive. Similarly, a “Car” class can also have “methods” that define its behavior, such as “accelerate”, “turn”, “honk”, and more, which take the form of functions.
- Objects are instantiated from these class blueprints, and it’s these concrete objects that are the physical “cars” you see on the road, as opposed to the blueprints that may exist in the factory.
- Our Paddles and Ball are perfect simple use cases for taking some of our code and bundling it together into classes and objects.
- In Lua, class filenames are capitalized by convention, which helps
you differentiate between any classes and libraries which you might
be including in the same directory as your
main.lua
file. - Also note that we
require
our class files inmain.lua
just as we do for libraries. Additionally, werequire
aclass
library which contains helpful functionality for object-oriented programming in Lua.
Important Code
- The main takeaway from this update is that we now have abstracted
away from
main.lua
the logic relevant to paddle and ball mechanics. These are now in their own classes, so you’ll see a few new files in the project directory.Ball.lua
contains all the logic specific to the ball, whilePaddle.lua
contains all the logic specific to each paddle. You’ll also findclass.lua
which is what allows us to do this. - This not only gives us greater flexibility moving forward, it also
makes our
main.lua
file cleaner and more readable.
pong-6 (“The FPS Update”)
- pong-6 adds a title to our screen and displays the FPS of our application on the screen as well
Important Functions
-
love.window.setTitle(title)
- This function sets the title of our application window, adding a slight level of polish.
-
love.timer.getFPS()
- Returns the current FPS (frames per second) of our application, making it easy to monitor when printed.
Important Code
-
Our first addition to the code is in
love.load()
:love.window.setTitle('Pong')
This quick and easy one-liner sets the title of our window.
-
Our second addition to the code is at the very bottom of
main.lua
. We define a helper function to display our FPS onto the screen:function displayFPS() love.graphics.setFont(smallFont) love.graphics.setColor(0, 255/255, 0, 255/255) love.graphics.print('FPS: ' .. tostring(love.timer.getFPS()), 10, 10) end
We then call this helper function in
love.draw()
.
pong-7 (“The Collision Update”)
- pong-7 allows for the Ball to bounce off the Paddles and window boundaries.
- Open up pong-7 to take a look at how we’ve incorporated AABB Collision Detection into our Pong program.
AABB Collision Detection
-
AABB Collision Detection relies on all colliding entities to have “axis-aligned bounding boxes”, which simply means their collision boxes contain no rotation in our world space, which allows us to use a simple math formula to test for collision:
if rect1.x is not > rect2.x + rect2.width and rect1.x + rect1.width is not < rect2.x and rect1.y is not > rect2.y + rect2.height and rect1.y + rect1.height is not < rect2.y: collision is true else collision is false
Essentially, the formula is merely checking if the two boxes are colliding in any way.
-
We can use AABB Collision Detection to detect whether our Ball is colliding with our Paddles and react accordingly.
-
We can apply similar logic to detect if the Ball collides with a window boundary.
Important Code
-
Notice how we’ve added a
collides
function to our Ball class. It uses the above algorithm to determine whether there has been a collision, returningtrue
if so andfalse
otherwise. -
We can use this function in
love.update()
to keep track of the ball’s changing position and velocity after each collision with a paddle:if ball:collides(player1) then ball.dx = -ball.dx * 1.03 ball.x = player1.x + 5 if ball.dy < 0 then ball.dy = -math.random(10, 150) else ball.dy = math.random(10, 150) end end if ball:collides(player2) then ball.dx = -ball.dx * 1.03 ball.x = player2.x - 4 if ball.dy < 0 then ball.dy = -math.random(10, 150) else ball.dy = math.random(10, 150) end end
Take special note of how we shift the ball away from the paddle first before reversing its direction if we detect a collision in which the ball and paddle’s edges overlap! This prevents an infinite collision loop between the ball and paddle.
-
We also implement similar logic for collisions with the window edges:
if ball.y <= 0 then ball.y = 0 ball.dy = -ball.dy end if ball.y >= VIRTUAL_HEIGHT - 4 then ball.y = VIRTUAL_HEIGHT - 4 ball.dy = -ball.dy end
pong-8 (“The Score Update”)
- pong-8 allows us to keep track of the score.
Important Code
-
Essentially, all we need to do is increment the score variables for each player whenever the ball collides with their goal boundary:
if ball.x < 0 then servingPlayer = 1 player2Score = player2Score + 1 ball:reset() gameState = 'start' end if ball.x > VIRTUAL_WIDTH then servingPlayer = 2 player1Score = player1Score + 1 ball:reset() gameState = 'start' end
pong-9 (“The Serve Update”)
- pong-9 introduces a new state, “serve”, to our game.
What is a State Machine?
- Currently in our Pong program we’ve only talked about state a little bit. We have our “start” state, which means the game is ready for us to press “enter” so that the ball will start moving, and our “play” state, which means the game is currently underway.
- A state machine concerns itself with monitoring what is the current state and what transitions take place between possible states, such that each individual state is produced by a specific transition and has its own logic.
- In pong-9, we allow a player to “serve” the ball by not having to defend during their first turn.
- We transition from the “play” state to the “serve” state by scoring,
and from the “serve” state to the “play” state by pressing
enter
. The game begins in the “start” state, and transitions to the serve state by pressingenter
.
Important Code
-
We can essentially add our new “serve” state by making an additional condition within our
love.update()
function:if gameState == 'serve' then ball.dy = math.random(-50, 50) if servingPlayer == 1 then ball.dx = math.random(140, 200) else ball.dx = -math.random(140, 200) end elseif ...
The idea is that when a player gets scored on, they should get to serve the ball, so as to not be immediately on defense. We do this by adjusting the ball velocity in the “serve” state based off which player is serving.
pong-10 (“The Victory Update”)
- pong-10 allows a player to win the game.
Important Code
-
We introduce a new state: “done”, and then we set a maximum score (in our case, 10). Within
love.update()
, we modify our code that checks whether a point has been scored as follows:if ball.x < 0 then servingPlayer = 1 player2Score = player2Score + 1 if player2Score == 10 then winningPlayer = 2 gameState = 'done' else gameState = 'serve' ball:reset() end end if ball.x > VIRTUAL_WIDTH then servingPlayer = 2 player1Score = player1Score + 1 if player1Score == 10 then winningPlayer = 1 gameState = 'done' else gameState = 'serve' ball:reset() end end
-
When a player reaches the maximum score, the game state transitions to “done” and we produce a victory screen in
love.draw()
:elseif gameState == 'done' then love.graphics.setFont(largeFont) love.graphics.printf('Player ' .. tostring(winningPlayer) .. ' wins!', 0, 10, VIRTUAL_WIDTH, 'center') love.graphics.setFont(smallFont) love.graphics.printf('Press Enter to restart!', 0, 30, VIRTUAL_WIDTH, 'center') end
-
Finally, we add some code to
love.keypressed(key)
to transition back to the “serve” state and reset the scores in case the player(s) would like to play again.elseif key == 'enter' or key == 'return' then ... elseif gameState == 'done' then gameState = 'serve' ball:reset() player1Score = 0 player2Score = 0 if winningPlayer == 1 then servingPlayer = 2 else servingPlayer = 1 end end end
pong-11 (“The Audio Update”)
- pong-11 adds sound to the game
Important Functions
-
love.audio.newSource(path, [type])
- This function creates a LÖVE2D Audio object that we can play back at any point in our program. It can also be given a “type” of “stream” or “static”; streamed assets will be streamed from disk as needed, whereas static assets will be preserved in memory. For larger sound effects and music tracks, streaming is more memory-effective; in our examples, audio assets are static, since they’re so small that they won’t take up much memory at all
- We will use this functionality to play a sound whenever there is a collision.
What is bfxr?
- bfxr is a simple program for generating random sounds, freely-available on all major Operating Systems.
- We will use it to generate all sound effects for our Pong example and most other examples going forward.
- bfxr.net
- We’ve created 3 sound files and stored them in a sub-directory within pong-11.
Important Code
-
You’ll notice in
love.load()
that we’ve created a table with references to the 3 sound files we’ve added to our project directory:sounds = { ['paddle_hit'] = love.audio.newSource('sounds/paddle_hit.wav', 'static'), ['score'] = love.audio.newSource('sounds/score.wav', 'static'), ['wall_hit'] = love.audio.newSource('sounds/wall_hit.wav', 'static') }
In this case, we are storing each sound as a “static” audio file because of how small they are. In the future, if using larger audio files, you might consider storing them as “stream” audio files so as to save on memory.
-
You should be able to find throughout the rest of
main.lua
function calls such assounds['paddle_hit']:play()
in locations corresponding to playing sound upon paddle collisions, wall collisions, and scoring points.
pong-12 (“The Resize Update”)
- pong-12 allows our Pong program to support resizing the window.
Important Functions
love.resize(width, height)
- This function is called by LÖVE every time we resize the
application; logic should go in here if anything in the game
(like a UI) is sized dynamically based on the window size.
push:resize()
needs to be called here for our use case so that it can dynamically rescale its internal canvas to fit our new window dimensions.
- This function is called by LÖVE every time we resize the
application; logic should go in here if anything in the game
(like a UI) is sized dynamically based on the window size.
Important Code
-
In order to support resizing the game window, we’ll first need to edit our window initialization in
love.load()
such thatresizable = true
:push:setupScreen(VIRTUAL_WIDTH, VIRTUAL_HEIGHT, WINDOW_WIDTH, WINDOW_HEIGHT, { fullscreen = false, resizable = true, vsync = true })
-
The next step is to overwrite
love.resize()
with its analog from thepush
library:function love.resize(w, h) push:resize(w, h) end
And with that, we have a fully functioning game of Pong!
Code homework
For homework, you will be completing your game of pong.
- Review our notes from class on tracking a bouncing ball and on collision detection
- Experiment with changing the size of the ball, the size of the paddles and the speed
- Test your game with another player. Revise your code so that the game is fully functional
- OPTIONAL: When a player misses the ball, their paddle decreases in size
- OPTIONAL: When a player misses 10 times, they lose the game
Credits
- Copyright (c) 2022 Sheepolution
- Pong 0: MIT Open Courseware Colton Ogden & David J. Malan 2018
- Frogger screenshot, Konami, Sega (Apple II)
- Paperboy illustration, robinstam