Seminar 16 Moon lander game
Today we will create a moon lander game. You job is simple: land your ship on the pad but do not crash it! Here is a brief video of my implementation of the game
We will program it using object-oriented approach from the beginning by defining two classes: the MoonLander
and the LandingPad
. Here is the general outline of how we will proceed:
- Create a basic PsychoPy window and main experimental loop.
- Define a basic
MoonLander
class with a static image and add its drawing to the main loop. - Randomize position of the lander.
- Add gravity pull.
- Add vertical thruster that counter-act gravity.
- Add horizontal thrusters, so you can maneuver around.
- Define
LandingPad
class. - Learn about virtual attributes and implement them for both classes.
- Implement landing / crashing checks.
- Add more runs.
- Limit the fuel.
This time most of the code will be in classes, so making versions of them will be quite cumbersome. Thus, submit only the final game. I would suggest calling files main.py (main script), moonlander.py (MoonLander
class), and landingpad.py (LandingPad
class).
16.1 Create window
Create our usual boilerplate code in exercise01.py (you will use the same files for classes but use versioning of the main file, it makes it easier for me to check your code). Create a PsychoPy window 640 × 480 in size. Add a main game loop with gameover
variable that can be exited by pressing escape.
Put your code into exercise01.py.
16.2 Create MoonLander class
In moonlander.py, create a new MoonLander
class. It should have an ImageStim
attribute (that will be the visuals of the ship) created using ufo.png image. For the moment, place it at its default location at the center. Also, implement draw()
method that should draw all visual elements of the lander (we have one now but there will be more later on).
Create an instance of MoonLander
class in the main script and draw it in the main game loop. You should see a static picture of the ship at the center of the screen.
Put updated code into exercise02.py. Create MoonLander
class and use it in the main game loop.
16.3 Randomize position
Implement a new method reset()
that resets the lander for the next round. At the moment, the only thing it should do is to randomize position of the image. Use range of -0.5..0.5 horizontally and 0.8..0.9 vertically. Call it in the constructor and test it in the main loop by calling it every time you press space button.
Put updated code into exercise03.py. Add reset()
method to MoonLander
class and use it in the main game loop.
16.4 Gravity
Next we need to create a gravitational force that will pull the lander down. Create a constant GRAVITY = 0.0001
34 and create a new attribute speed = [0, 0]
that is horizontal and vertical speed of the lander. Note that it should be reset to (0, 0)
in the reset()
method.
The position of the lander (self.image.pos
) must adjusted based on the the speed on every frame. But before that, speed itself must be adjusted based on the forces from gravity and thrusters that act upon the lander. Create a new method update()
where you first adjust vertical speed based on gravity alone (we will add the effect of thrusters later) and when adjust vertical position based on vertical speed (we will worry about the horizontal speed, once we start working on horizontal thrusters). Call update()
method before the draw()
in the main loop. Your lander should fall down at accelerated rate (you can play with GRAVITY
constant to see how it changes the speed of falling). Once it is off the screen, press space and see it go again.
Put updated code into exercise04.py. Update MoonLander
class for the effect of gravity and use this in the main loop.
16.5 Vertical thurster
PsychoPy allows you to get key presses or, using hardware.keyboard to get both press and release time. Unfortunately, you get both only after the key was released. But in our game, the thursters must be active for as long as the player presses the key. Thus, we need to know whether a key is currently pressed, not that it was pressed and released at some time in the past. For this, we will use pyglet library (that is a backend used by PsychoPy) directly. First, in your moonlander.py add import pyglet
and then include the following code inside the constructor of the class.
# setting up keyboard monitoring
self.key = pyglet.window.key
self.keyboard = self.key.KeyStateHandler()
self.keyboard) win.winHandle.push_handlers(
This installs a “handler” that monitors the state of the keyboard. Now, you can read out the state of, say, down arrow key asself.keyboard[self.key.DOWN]
(True
if pressed, False
otherwise). We will use DOWN
for the vertical thruster and LEFT
and RIGHT
for the horizontal ones.
Define a VERTICAL_ACC
to be twice the gravity (but you can use some other number, of course) and update the update()
35, so that the total vertical acceleration is VERTICAL_ACC + GRAVITY
if the the user is pressing down key (use self.keyboard
and self.key
to figure that out) but GRAVITY
alone, if not.
Test you that vertical thruster works!
Put updated code into exercise05.py. Update MoonLander
class for the counter-effect of a vertical thruster.
16.6 Horizontal thursters
Now implement the same logic, computing acceleration, speed, and position but for horizontal thrusters (define HORIZONTAL_ACC
, decide on its value yourself). Remember, the right thruster pushes the lander to the left and vice versa! Assume that only one of the keys, left or right, can be pressed at a time. Test it by flying around!
Put updated code into exercise06.py. Add horizontal thrusters to MoonLander
.
16.7 Landing pad: visuals
The purpose of the game is to land on a landing pad. A landing pad is just a rectangle with some additional methods and properties. So it stands to reason to make it a descendant of the visual.Rect
class. This way, the original class will do all the heavy lifting and provide properties like size
and pos
and we can concentrate on the added value. Unfortunately, for some technical reason I have not figured out yet, this does not work for shape classes like Rect
or Circle
. It does work for classes like ImageStim
and we will use this opportunity when we program out next game.
Create a new file landing_pad.py and a new class LandingPad
. In the constructor, create a rectangle and store it in attribute (you pick the name). It should be 0.5
units wide and located at the bottom of the window but at a random position within the window horizontally. Pick the fill and line colors that you like. The only other method the class needs is draw()
.
In the main code, create an object of class LandingPad
and draw it in the main loop, along with the lander itself.
Put updated code into exercise07.py. Create LandingPad
class and use it in the main loop.
16.8 Computing edges of game objects
The aim of the game is a soft touchdown on a landing pad. For this, we need to know where the top of the landing pad is, as well as where the bottom of the lander is and where left and right limits of each object are. Let us think about bottom of the lander first, as the rest are very similar.
We do not have information about it directly. We have the vertical position of the lander in self.image.pos[1]
(I assume here that the visuals attribute is called image) and its height in self.image.size[1]
. From this, it is easy to compute the bottom edge (but remember that position is about the center of the rectangle). Accordingly, you could create a method called bottom()
that would return the computed value when it is called (e.g., lander.bottom()
).
Implement bottom()
method for the MoonLander
class.
16.9 Virtual attributes via getters and setters
Our approach of using lander.bottom()
works but it is semantically inconsistent. Calling methods is about manipulating objects, e.g., drawing them, updating them, comparing them. However, bottom
is, effectively, an attribute of an object, like its position or size. We could create and compute a bottom
attribute inside the constructor, solving the problem of semantics, as now you could write lander.bottom
. Note that as the lander moves all of its edge attributes (bottom
, left
and right
) need to be recomputed after every update. This is unavoidable but a real attribute approach still creates another problem: What if someone changes it? In that case, its value will not be correct, as bottom
value depends on both position and size, so changing it without a corresponding change in those two attributes makes no sense! And it is really hard to decide whether change in the bottom
should mean a change in position, or size, or, perhaps, both? Thus, ideally, it should be a read-only attribute.
For cases like these, Python has special decorators36: @property
and @<name>.setter
. The former one decorates a method that allows you to get an attribute’s value and, typically, is called a “getter”. The latter one, is for a “setter” method that sets a new value to an attribute. The idea is to isolate an actual attribute value from the outside influence. It is particularly helpful, if you need to control whether a new value of an attribute is a valid one, needs to converted, etc. For example, color
attribute of the rectangle stimulus uses this approach, which is why it can take RGB triplets, hexadecimal codes, or plain color names as a value and set the color correctly.
Here is a sketch of how it could work but note that today, we will only use the getter bit. To have a virtual attribute for color
, one typically creates an internal attribute with almost the same name, e.g., _color
or __color
(see below for the difference). The value is stored and read from that internal attribute by getter and setter methods:
class ExampleClass:
def __init__(self):
self.__color = "red"
@property
def color(self):
"""
This is a getter method for virtual color
attribute.
"""
# Here, we simply return the value. But we could
# compute it from some other attribute(s) instead.
return self.__color
@color.setter # not the most elegant syntax, IMHO
def color(self, newvalue):
"""
Note that the setter name has THE SAME name as the getter!
It sets a new value and does not return anything.
"""
# Here, you can have checks, conversion,
# additional changes to other attributes, etc.
self.__color = newvalue
= ExampleClass()
example
# get the value, note the lack of () after color
print(example.color)
# set the value
= "blue" example.color
Note that there is no actual attribute color
37, yet, our code works as if it does exist.
There is another twist to the story. If you only define the getter @property
method but no setter method, your property is read-only38! And this exactly what we wanted. Turning our bottom()
method into a getter of an attribute is as easy as adding the @property
decorator above it. After that, we can use it as a read-only attribute lander.bottom
. Do this and also create similar read-only attributes for top
, left
, and right
of the pad and for left
, and right
of the lander class.
Implement virtual properties for MoonLander
and LandingPad
classes.
16.10 Access restrictions
In the example on getter/setter methods, I used __color
name with two leading underscores. This is a Python way to make things (almost) private, that is, invisible from outside. If you copy-paste the class code from above and try to access the attribute directly via example.__color
, you will get an error "‘ExampleClass’ object has no attribute ’__color’". However, as I wrote, it is almost private, so you still can access it! The code format is object._<ClassName><hidden attribute name>
, so in our case example._ExampleClass__color
39. However, this is a last resort sort of thing that you should use only if you absolutely must access that attribute or method and, hopefully, know what you doing.
You can also come across attributes with a single leading underscore in the name, e.g. _color
. These are not private and are fully visible. However, the leading underscore hints that this attribute or method should be considered private. So, if you see an attribute like _color
, you should pretend that you know not of its existence and, therefore, you never read or modify it directly. Of course, this is only an agreement, so you can always ignore it and work with that attribute directly40. However, this almost certainly will break the code in unexpected and hard-to-trace ways.
16.11 Landing
We should check for landing whenever the bottom edge of the lander is at or below the top edge of the landing pad. A successful landing must satisfy several conditions:
- The lander must be within the limits of the lander pad horizontally.
- The vertical speed must be zero or negative (otherwise, the lander flies up) but below a certain threshold that we will define as a constant
VERTICAL_SPEED_THRESHOLD = 0.05
. - The absolute horizontal speed must be below a certain threshold, also defined as a constant
HORIZONTAL_SPEED_THRESHOLD = 0.05
.
If any of these three conditions are false, the lander has crashed. Either way, the game is over, so you should record the outcome (whether the landing was successful) and set gameover
to False
. After the loop, inform the player about the outcome. Draw all game objects plus the message about the outcome (e.g., “You did it!” / “Oh, no!” or something else) and wait for a space key press.
The condition above will be quite long, so fitting it into a single line will make it hard to read. In Python, you can split the line by putting \
at the end of it. So here, a multiline if statement will look as follows:
if lander_is_within_horizontal_limits and \
and \
lander_vertical_speed_is_good
lander_horizontal_speed_is_good:
...else:
...
Put updated code into exercise08.py. Implement landing checks.
16.12 More rounds
Extend the game to have more than one round after the player either landed or crashed. Remember to reset the position of the lander before each new round. You can also add a reset()
method to the landing pad as well, randomizing it horizontal position. Importantly, escape key should quit the game, not just the round, and there should be no “success”/“failure” message in that case. Think how you would implement this.
Put updated code into exercise09.py. Add more rounds. Add reset()
method to the LandingPad
class.
16.13 Limited fuel
Let us add a fuel limit to make things more interesting, so that thrursters would work only if there is any fuel left. For this, define a new constant FULL_TANK
(I’ve picked it to be 100
but you can have more) and add a new attribute fuel
to the Lander
class (remember that you need to explicitly define all attributes in the constructor). The fuel
level should be set to FULL_TANK
whenever you reset the lander.
Every use of a thruster should reduce this by 1 and thrusters should work only if there is fuel. You need to take care of this in the update
method. Think about how you would do it for both vertical and horizontal thrusters.
We also need to tell the player how much the fuel is left. I’ve implemented it as a bar gauge but you can implement it as text stimulus as well. Create the appropriate visual attribute in the constructor and remember to update it every time the level of the fuel changes and to draw it whenever you draw the lander itself. As a nice touch, you can change the color to indicate how much of the fuel is left. I’ve used green for more than 2/3, yellow for more than 1/3, and red if less than that.
Put updated code into exercise10.py. Add fuel and fuel gauge to Lander
class.
16.14 Add to it!
We already have a functioning game but you can add so much more to it: visuals for the thrusters, sounds, background, etc. Experiment at will!
The constant itself does not mean anything, I adjusted it to be reasonable for the image and window size that we are using.↩︎
Pun intended.↩︎
These are functions that “decorate” you function and are called before your function is called. They are like gatekeepers or face control, so they can alter whether or how your function is executed. They are very useful in certain scenarios and we will meet them again later. However, we will not talk about them in detail here.↩︎
Just like there is no physical phenomenon called “color”.↩︎
Note that you cannot have write-only property, you must have either getter alone or both.↩︎
Try it and see that it works.↩︎
On a side note of doing crazy things that you shouldn’t: You can replace a class method without inheritance with your own at run time, this is called “monkey patching”!↩︎