Lab 3: User Interaction and 2D Games
Due Monday, 28 September 2020, before 7am
This is a partnered lab assignment. If you have not already done so, please set your Partner preferences through the TeamMaker service. Your can pick a specific partner, a random partner, or to work alone. I strongly encourage you to work with a partner when given the option. You may choose a partner from either lab section. If you pick a specific partner, your partner must also choose you before the application will create a team and create a git repository for you.
1. Lab Goals
Through this lab you will learn to
-
Build a projection transform to convert from a grid coordinate system suitable for a 2D game to webGL2 clip coordinates.
-
Work with the GLSL
mat4
and TWGLm4
types. -
Use event listeners to respond to user input in a webGL2 program.
-
Build a model of small 2D interactive game on the CPU
-
Use WebGL2 to render the game model in real time.
2. References
2.1. JavaScript
2.2. WebGL2/TWGL
3. Cloning Files
After the setup from last week, you should only need to move to your labs folder and clone your lab3
repo from the Github CS40-F20 org
cd
cd cs40/labs
pwd # should list /home/you/cs40/labs
git clone git@github.swarthmore.edu:CS40-F20/lab3-yourTeamName
Longer GitHub setup instructions are available off the Remote Tools pages if you need help. You will be assigned a random team name that is the concatenation of material property, e.g., translucent, and an animal, e.g., Porcupine.
3.1. Setting a Symbolic Link
Similar to prior labs, we will link to the cs40lib
of third party libraries. But this week, you should not need to create the primary link in your ~/cs40/labs
folder. And since I did not add a lib
file by default to your lab3
repo, there should be no need to remove a bad link. You just need to add one good link in your lab3
folder.
cd
cd cs40/labs #make sure you are in labs, not lab1
cd lab3-yourTeamName #use your team name
ln -s ../cs40lib ./lib #pay attention to the dots
4. Running and Viewing your Lab
You will use the same process as lab1 to start a webserver with python3
and tunnel with ngrok
.
To run the server, change to the directory you want to serve and run the following command using python3
. Note the &
at the end to run the server in the background.
cd
cd cs40/labs/lab3-yourTeamName
python3 -m http.server &
You should get a message saying
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/)
If instead you get a long error message ending in
OSError: [Errno 98] Address already in use
It may mean someone else is running a server on the same port, possibly you. If it is you, you can either continue using the existing server, or kill the old server using
pkill python3
You can also try running your server on a different port other than 8000
cd
cd cs40/labs/lab3-yourTeamName
python3 -m http.server 8080 &
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/)
As long as we eventually get a Serving HTTP
message, we are ready to proceed to the next step
4.1. ngrok http 8000
The address http://0.0.0.0:8080/
is only usable if you are on campus and logged in directly to the machine you started the server on.
The ngrok
tool will create a temporary public URL that you can use to access your webserver from anywhere. To run it, just provide the protocol and port number that your webserver is running on.
ngrok http 8000
This should pop up a small display in your terminal listing a Forwarding URL.
Forwarding https://b985958321b9.ngrok.io
The session will last at most 8 hours, or until you stop the process with CTRL-C
5. Overview
You should now be able to go to the link given by ngrok
in a browser on your personal computer at home. If everything works, you should see the lab3 start page, which is quite blank.
The final version will be an interactive version of a classic 2D snake game shown below.
Using the arrow keys to change the direction of the snake, you will attempt to eat the food pellets while avoiding the walls and avoiding biting your own tail. You will grow each time you eat a pellet and the game ends when you run into a wall or cross over yourself.
There is not much to draw in this lab. You are using the basic setup of lab2 to draw a scene consisting of many grid cells, where each cell can contain a wall, a food pellet, or part of the snake body.
Most of this lab will be creating an abstract model of the game logic on the CPU side using JavaScript classes, a variant over the simple object type we have been using the past two weeks. Additionally we will use event listeners to the WebGL2 canvas to listen for user input including key presses and mouse clicks and update our internal model.
We will then use WebGL2 to render and animate our model to make the game interactive. The one small WebGL2 component we will implement this week is a small change of frame that allows us to work in a convenient coordinate system for the game board and then convert the geometry to clip coordinates through the use of a 4x4 matrix transform. We will use the TWGL m4 library to build our matrix transform once on the CPU side and then pass it as a uniform to our vertex shader as a mat4
type in GLSL.
6. Getting Started
Despite nothing showing on the screen at first, some components of this lab have been started for you. Some of the basic setup is similar to lab2. You will see a skeleton of a drawScene
function, and the makeTriangle/drawTriangle
and makeSquare/drawSquare
functions have been included. The makeSquare
function is a bit different, as the function can now take a parameter d
as the side length and the upper left and lower right coordinates of the square are (0,0)
and (d,d)
, respectively.
By default, in init
, the vao is set to use a square of side length d=1
. If you tried to call drawSquare()
now in drawScene
, nothing would happen even though the coordinates of the square describe the upper right quadrant of clip space. This is because the square has the wrong orientation in clip space. If you comment out the line gl.enable(gl.CULL_FACE);
in init
, you should see a red square if you called drawSquare()
in drawScene
.
But we want to do something a little different with our space. First, we are going to divide the canvas (defined in pixels) into a 2D grid of cells
, each a given blockSize
number of pixels wide and tall. This 2D grid will form an internal representation of our game board. In init
, we make a new World
object with a blockSize=20
. For the default canvas size of 800x600, this will make a 40x30 grid of cells. We will render each cell in our grid as a single square in WebGL2.
Internally, it will be helpful to use a coordinate system where the coordinates can map easily onto the rows and columns of the grid. We will denote the coordinate (0,0) to be the upper left corner of the leftmost cell in the top row. In general, a cell at row and column (r, c)
will have an upper left coordinate of (c,r)
and lower right coordinate of (c+1, r+1)
. Note the natural tendency is to use row, column indexing and (x,y)
coordinates, but a column index is really an x
-coordinate.
6.1. The World Class
Lab3 provides a sketch of the 2D game board in the World
class. A JavaScript class is similar to the base object types we have been using in the prior labs, but it provides a way of easily creating multiple instances and grouping functions/methods. You’ll see the World
class begins with the class World {
definition, and has a special constructor
method. You can make a new instance of this World
class by using the new
keyword and the name of the class, then providing the arguments to the constructor. See in init
where we have world = new World(gl.canvas, 20);
.
Similar to the self
variable in python, the this
keyword is a reference to the current object inside the class. It is used to refer to member variables and methods within the class. Some basic state for the world class has been added for you along with some basic methods. After construction, you can use the properties/methods like you would on any other object.
One of the things this World
class does for you already is sets up the 2D array of cells, and assigns each one of them the label labels.open
(defined before main in the globals section).
The World
constructor also creates a snake object using a separate Snake
class, which is much less complete, and updates the game board to place the snake in the center of the grid.
6.2. Adjusting the coordinate system
Your first step in modifying the program should be adding support for changing the coordinate system. You will build a matrix proj
in init
that converts grid
coordinates to clip
coordinates. In grid
coordinates, (0,0) is in the upper left and (ncols,nrows)
is in the lower right, where ncols
and nrows
are the number of columns and rows in the game grid.
Look over the TWGL docs and the notes on change of frame to build a suitable transformation matrix. init
currently sets the final value of proj
to uniforms.u_projection
which is copied to the GPU and the vertex shader in render
.
Your projection matrix is correct when a simple drawSquare()
in drawScene()
renders a small red square in the upper left corner. Your square should draw even when culling is enabled.
6.3. Drawing the snake.
The snake by default is set in the middle row and column. You should now be able to set a u_shift
uniform in drawScene to move the square to the center before drawing it. Note you the u_shift
amount is specified in grid coordinates, so it should be easier to compute. You should be also be able to change the color of the snake. See the colors
global for some options, and feel free to change the defaults.
This hand coding of drawScene
is just for practice as you learn how the uniforms adjust the scene. Soon you will use loops to draw the entire grid.
7. Add Walls and Draw Grid
Once you have a basic understanding of how drawSquare
is influence by the uniforms
and your projection matrix is correct, you are ready to add multiple elements.
Your grid should have walls along the perimeter so the snake cannot escape the boundary of the canvas. Update World.makeGrid
to add some wall elements. You can use the label.wall
id to set a particular grid cell to a wall.
Once you have multiple non-open elements (many walls, one snake square), modify drawScene
to loop over the grid and draw each non empty cell in the correct location with the correct color.
7.1. Add food
Add support in your World
class for adding a food pellet at a random open grid location. Your drawScene
function should be able to render the food object without much modification.
8. Adding motion
The next step is to animate your scene and interact with user input. JavaScript can connect various interactive events on HTML objects to functions of your own design that run when an event is triggered. Some examples are started for you in setupListeners()
. We want the WebGL2 canvas to listen for key presses and notify us when the user presses the arrow keys.
By default, the canvas, won’t listen for keypresses until it has focus, which happens when a user clicks on the canvas. I have added a focus
listener that unpauses the game and listens for keypresses once the user focuses on the canvas. There is a bit of CSS setup required for this to work too, but this has already been done.
The setupListeners
function and World.togglePause
method also show how to display text in an overlay box using innerHTML
. This can be used to show status messages on top of the canvas (outside a WebGL2 context). togglePause
also shows how to use css
to show or hide this status text.
You probably do not need to modify the focus listener or modify togglePause
, but you will need to extend the keydown listener to listen to other keys and make other updates to the game state. Some of these updates can be done in the Snake
class, while others will be done in World
class.
8.1. Sketching a Snake class
The snake object starts in the center as a single pixel. The user can press the arrow keys to move the snake in different directions. A snake cannot make a 180 degree turn in a single step. If the user has not pressed a key recently the snake keeps moving in the current direction unless it hits a wall or itself, at which point the game ends. When a snake passes over a cell containing food, it grows by three additional cells.
The Snake
class should contain enough state to adequately update and inspect the snake properties. Some likely important properties:
-
The current direction the snake is traveling
-
If the snake is alive
-
A sequential list of all cells composing the snakes body.
To represent the snake’s body, I recommend an array of [row, col]
pairs, with the head of the snake being at the first index, e.g., body[0] = [row, col]
.
To update the snake’s body, imagine moving every grid cell in the body starting from the tail forward to the position one spot ahead in the array. The only cell that does not have a previous cell along the body is the head. For this cell, we move the old head location in the direction of the last key press and make this the new head. A way to implement this in JavaScript is with the pop
and unshift
array methods. pop
will remove and return the last element from an array, making the array one smaller. unshift
is a strange name for inserting a new element at the front of the array, making the array one bigger.
If the snake is not growing, this pop and unshift can be used to move the snake in the current direction. To grow, you can perform an unshift and omit the pop over several frames to add multiple cells to the current snake.
Updating the snake body in the snake class does not update the grid labels in the world however, so you might be updating the Snake state internally, but the grid still says the snake is in the center of the grid. You will need to do some careful updating of the Snake state and the World state in the right order to get both in a consistent state.
8.2. Animating
If you update the snake’s status and the corresponding gameboard’s status through the world
object each time you call drawScene
the snake should move across the screen and respond to keypresses. You should only update the snake and the world if the game is not over (the snake is still alive) and the game is not paused.
You may find the default speed too fast (maybe not, you are all young). One way to slow things down is to only update if a given amount of time has elapsed. You can add something like the following in your drawScene
. You will need to define lastUpdate
and waitTime
as globals, and pick a waitTime
in fractions of second.
if (time - lastUpdate > waitTime){
lastUpdate = time;
//update snake, world
}
//draw even if no update
Once you get your event listeners set up and your snake class developed, you should be able to move the single pixel around the screen.
9. Adding game logic
Once you can control the basic snake element, it is time to finish the game logic. This will mostly happen in the World
and Snake
classes as you are just now responding to events. drawScene
should know how to draw then scene and when to trigger an update of the game state, but the World
and Snake
classes will actually perform that update. The event listeners may modify the state too through pausing the game, or changing the direction of the snake. Again, the listeners will know when to trigger the update, but the classes will do the work.
9.1. Support for game ending
Your game should end when the snake runs into a wall or itself. At this point, you should stop animating and responding to arrow key presses or pause/unpause events. It is fine to restart the game through a page reload. Adding support through another way of restarting is an optional extension.
9.2. Support for growing
When you snake crosses over a food cell, grow the snake, remove the old food item, and drop a new food item in a random open location. This can be done by adding just a small amount of extra state to the Snake
class and stretching the growing over several animation frames. The default is to grow 3 cells on each food item, but feel free to add extensions that grow proportional to the snake’s current length or some other metric.
9.3. Ignore 180 degree turns
If a snake is going up, and the user presses the down key, this would normally result in the snake running into itself and the game ending. Detect and ignore these events and only allow turns that are 90 degrees to the current direction.
10. Extending the basic game
A small portion of your grade is reserved for extensions to the basic game. There are numerous possibilities, some of which are listed below.
-
Gradually increasing the speed over time
-
Adding and updating a Score field somewhere on the canvas
-
Adding support for multiple levels, with different wall configurations
-
Allowing open edges where a snake can go through a gap on say the left end and appear on the right side.
-
Changing the growth amount from eating a food pellet to be non-uniform.
-
Other ideas?
11. Viewing Changes
If you make modifications to the files on the CS server and save them, you should be able to refresh the browser and see your changes without needing to restart the servers or ngrok tunnel. If it appears your changes are not visible in your browser, you can try a hard refresh.
12. Working in Partnership
When working with a partner, you may want to edit in one copy of the repo perhaps via Zoom or Live Share. Only one partner needs to set up the python
and ngrok
links and then share the ngrok
url with the other partner.
Periodically, add
, commit
, and push
your changes. Then your partner can pull
your changes and you could switch roles as to who is hosting the server and ngrok
session.
13. Closing the Web Server
When you are finished with a session, be sure to close your ngrok
and python3
session. ngrok
is likely running in the foreground and can be stopped using Ctrl-C
.
You can stop all your python3
jobs using
pkill -u adanner python3
replacing adanner
with your username.
14. Summary of Requirements
Your project will be graded on the following components:
-
Support for coordinate transforms from grid coordinate to clip coordinates.
-
Correct rendering of 2D grid world
-
Game playable according to basic logic rules
-
Some creative extension of the basic components
-
Answer to concept questions in the
Readme.md
file. -
A small percentage of your grade will be based on style, and creativity. Have fun and explore.
You will not be graded on the lab survey questions in the Readme.md
file
Submit
Once you have edited the files, you should publish your changes using add, commit
and push
.
The git push
command sends your committed changes to the github server. If you do not run git push before the submission deadline, I will not see your changes, even if you have finished coding your solution in your local directory.
If you make changes to files after your push and want to share these changes, repeat the add, commit, push
loop again to update the github server.
If you want to commit changes to files that have already been committed to git once, you can combine the add and commit steps using
$ git commit -am "bug fix/updates"
The -a
flag will automatically add files that have been previously committed. It will not add new files. When in doubt, use git status
, and please do not use git add * ./
Please do not add your symlink to the cs40lib
folder. I have it set to be ignored, and it may create conflicts if partners are working on different personal computers.