Python For Kids (Part 32: Classes)
For a complete table of contents of all the lessons please click below as it will give you a brief of each lesson in addition to the topics it will cover. https://github.com/mytechnotalent/Python-For-Kids
Today we are going to learn about OOP or object-oriented programming with classes.
This is the lesson of all the lessons thus far where I REALLY want you to take your time! This lesson should be carefully worked between a 2 to 4 week period or until such time that all of the concepts really solidify.
Please take very deliberate care to take breaks and swap out variables and other input data to really get a feel for what is going on at each step of the lesson.
This is an advanced topic and will take patience and endurance. With that said, let's dive in!
Classes
When we write larger code to scale it is much easier to take advantage of object-oriented programming or OOP with classes which also make our code less prone to bugs.
In the OOP design structure everything we develop represents real-world items. Let's imagine we were designing software for a car, specifically a Ferrari.
If were were to use what we have learned so far we would use functions to handle all of the functionality of the car so as you can imagine we would literally have thousands of functions to handle everything from handling the engine start to interacting with the brakes and gas handling the navigation software interface, in addition to handling the interaction of literally tens of thousands of individual sensors.
Imagine if you needed to make sweeping changes in the design with functions. Not only would it be very challenging to accomplish as so many different functions rely on each other in a procedural way you are also more prone to making bugs.
If you had say 100 functions that handled the interaction with the steering interface with the engine and the Project Manager and team decided to change out the engine you might possibly have to change every single of those 100 functions!
OOP would be a better solution. The first thing we would do is think about a class that would represent what every car HAS and DOES.
Let's take a step back for a moment. Let's talk about YOU. YOU are a PERSON. Your neighbor is a PERSON. You and your neighbor both may have hair however your neighbor may have brown hair and you have red hair.
If we started off with a generic Person class it would represent all the attributes of what all people share or what each person HAS and what each person DOES.
In classes or OOP you have attributes which represent what each person HAS.
In classes or OOP you have methods which represent what each person DOES.
class Person: """ Base class to represent a generic person """ def __init__(self, name='Generic', has_hair=False, hair_color=None): """ Attrs: name: str has_hair: bool hair_color: str """ self.name = name self.has_hair = has_hair self.hair_color = hair_color def is_brushing_hair(self): """Method to handle a person brushing hair if has_hair Returns: str """ if self.has_hair: return '{0} has {1} hair they are brushing.'.format(self.name, self.hair_color) else: return '{0} does not have hair.'.format(self.name) def is_eating(self, food): """Method to handle a person eating Params: food: str Returns: str """ return '{0} is eating {1}!'.format(self.name, food) def is_walking(self): """Method to handle a person walking Returns: str """ return '{0} is walking.'.format(self.name) def is_sleeping(self): """Method to handle a person sleeping Returns: str """ return '{0} is sleeping.'.format(self.name)
Woah what is all that! What is this strange looking __init__ and self stuff all about?
Let's take our time and look at a couple of new and exciting concepts!
When we learned to walk as young children we did not simply enter a marathon on day one of taking our first steps did we?
In fact we had the help of others to stabilize us as we fell down and cried but we GOT BACK UP and tried again.
We are seeing a BRAND NEW design pattern above so let us very carefully take our first step together!
We start out by creating our class called Person and you notice it is not like a function or variable name. It starts with a capital letter. This is referred to as camel casing vs what we have been using for variables and function names which is referred to as snake casing.
If we were to have multiple words such as ChocolateMachine we would create a single class name with each new word capitalized like above.
Earlier we mentioned that classes represent real-world objects. We handle what each object HAS and DOES. We handle an objects attributes and methods. An attribute represents what an object HAS and a method (which is a function that is part of a class) which represents what an object DOES.
As we stabilize our first stand holding desperately on to our parent we begin to become wide-eyed and feel the POWER of this new capability! This is how we will feel when we understand a few of these rather advanced topics. Just as we did when we were little we fell and we fell OFTEN however we KEPT AT IT and today we don't even think about it consciously. This is how you will feel over time about these concepts.
We then see this very strange looking __init__ method (a method is a function that lives within a class). This is referred to as our constructor. A constructor constructs the very object we are breathing life into! It is where we create our attributes or what the object HAS.
The __init__ is a special method, otherwise referred to a magic method which has special powers in Python.
The very first parameter within the __init__ method is self. The self parameter represents the actual what we refer to as instantiation or initialization of the object we are creating. In our case we could have a Person class that we instantiate or create called katie for instance.
katie = Person('Katie', has_hair=True, hair_color='red')
We can take this Class blueprint and immediately make another Person called ted for instance.
ted = Person('Ted')
Take note that ted only has one param. If we look back at our constructor or __init__ method we notice that we have 3 named parameters next to self. We have name='Generic', has_hair=None and hair_color=None.
The self parameter in our case is katie or ted! Let's take this knowledge and now in our mind replace every instance of self with katie or ted and see if this makes more sense.
In our case here, katie and ted are referred to as an instance object and the Person class is a class object.
To illustrate we will use pseudo code which will NOT run but I want to show you what is going on behind the scenes.
def __init__(katie, name='Katie', has_hair=True, hair_color='red'): """ Attrs: name: str has_hair: bool hair_color: str """ katie.name = name katie.has_hair = has_hair katie.hair_color = hair_color
Let's take this pseudo code which will NOT run but illustrate for our ted object.
def __init__(ted, name='Ted', has_hair=False, hair_color=None): """ Attrs: name: str has_hair: bool hair_color: str """ ted.name = name ted.has_hair = has_hair ted.hair_color = hair_color
Oh boy we got one foot on the ground! WAY TO GO! A little shaky but with our first foot on the ground gripping on to our parent or guardian we feel a bit of new confidence! We may continue to slip and fall but we will remember this VERY MOMENT and we shall say, 'I CAN TRY AGAIN TILL I GET THIS RIGHT!' It is CRITICAL to understand that the word TILL means WHEN NOT IF!
We now have a Person object that HAS attributes which are a name which is a str, has_hair which is a bool and hair_color which is a NoneType.
We then see our first non-constructor method which is is_brushing_hair. It does not have any params other than our self which represents either katie or ted and we see that it returns a str.
We see the following code which contains some conditional logic.
if self.has_hair: return '{0} has {1} hair they are brushing.'.format(self.name, self.hair_color) else: return '{0} does not have hair.'.format(self.name)
Let's make some pseudo code which will NOT run but to help us better understand what is going on.
if katie.has_hair: return '{0} has {1} hair they are brushing.'.format(katie.name, katie.hair_color) else: return '{0} does not have hair.'.format(katie.name)
If katie, the instance object, has_hair in our instantiation katie = Person('Katie', has_hair=True, hair_color='red'), which she does, we will enter into the first if conditional and return '{0} has {1} hair they are brushing.'.format(self.name, self.hair_color). Keep in mind I put the self back in the return value as we now have a full understanding of what self is.
The next method, is_eating, simply takes a food param and returns a formatted str.
The is_walking method takes no params and simply returns a formatted str.
The is_sleeping method takes no params and returns a formatted str.
Let's create a main routine for our classes.
shakira = Person('Shakira', has_hair=True, hair_color='red') mike = Person('Mike') brushing_hair = shakira.is_brushing_hair() print(brushing_hair) does_not_have_hair = mike.is_brushing_hair() print(does_not_have_hair)
Let's run this code and examine the output.
Shakira has red hair they are brushing. Mike does not have hair.
We see that like our katie and ted examples above a similar outcome. In this case the shakira instance object was constructed or initalized with a 'Shakira' name argument, has_hair=True argument and hair_color='red' argument. In our mike instance object we only pass in a name arg which will cause the is_brushing_hair method to handle a different set of conditional logic and give a different return statement.
Let's look at what happens when we construct an bare instance object.
generic = Person() does_not_have_hair = generic.is_brushing_hair() print(does_not_have_hair) is_eating_food = generic.is_eating('pizza') print(is_eating_food) is_walking_around = generic.is_walking() print(is_walking_around) is_sleeping_ = generic.is_sleeping() print(is_sleeping_)
Let's run and see what happens.
Generic does not have hair. Generic is eating pizza! Generic is walking. Generic is sleeping.
We can see what is going on here. This is where I would like you to take at least an hour and try all of these above examples with different values so you have a good grasp on what is going on.
We have to take careful consideration when naming our variables and make sure that it does not collide with a method or attribute name space. If you noticed we used is_sleeping_ with an extra _ at the end in order to not collide with the is_sleeping method.
Now that we are standing on both feel with our parent or guardian we are going to take our next step by making a custom game engine from scratch using OOP!
Before we launch into our APP let's download an integrated development environment or IDE for our future projects in order to help us more easily design our work.
Watch this video from Telusko to explain the detailed steps of getting and installing this tool.
Now that we have PyCharm Community Edition installed.
STEP 1: Open PyCharm
STEP 2: Click New Project & Name Project 0007_escape_room & Click Create
STEP 3: Click File & New & Python File & Name It data.py & Press Enter
STEP 4: Repeat STEP 8 & Create Additional Files
EscapeRoomPlayer.py FileManager.py Game.py Grid.py Player.py
APP 1
We already created our first app and called 0014_escape_room:
Let's create our Escape Room Application Requirements document and call it 0014_escape_room_ar:
Escape Room Application Requirements ------------------------------------ 1. Define the purpose of the application. a. Create a game where we build an escape room style adventure game with our python with questions as we are placed inside a mountain cave and work our way through different places in the cave where we randomly stumble upon a question space and one of the questions spaces when answered correctly gives us a red key which we will need for the other randomly placed question. Once you answer the question which gives you the red key you can then answer the question that will let you out of the cave or room to the outside world. The console will show an outline of the cave and the player moving around a map. 2. Define the rules of the application. a. If the player answers a correct question and that question has the red key then a red key will be placed into a persistent inventory. b. If the player answers the final room question and they have the red key in their inventory then they escape to the outside and win. 3. Define the logical steps of the application. a. Edit our data.py and populate our database. b. Build out our Grid class logic. c. Build out our Player.py base class logic. d. Build out our EscapeRoomPlayer.py child class logic. e. Build out our FileManager.py class logic. f. Build out our Game.py class logic. g. Build out our main.py logic to properly account for the integration of all of the above classes and guarantee proper functionality.
Woah that's a lot to do! The key is we are taking our time. When Leonardo da Vinci pained the Mona Lisa he did not simply snap his fingers and it all appear. Very careful thought and planning took place. We are no different in this matter.
Let us start by populating our data.py below.
questions = { 'What year was the MicroBit educational foundation created?': [ '2016', '2014', '2017', 0 ], 'What year was the first computer invented?': [ '1954', '1943', '1961', 1 ], 'What year did Damien George create MicroPython?': [ '2015', '2012', '2014', 2 ], 'What year did the Commodore 64 get released?': [ '1983', '1984', '1982', 2 ], }
Here we have a dictionary with 4 items to which each key has a value that is a list. The first three elements (0 through 2) are the possible correct answers and the fourth element (3) is our correct answer.
Take a moment and review the Study Buddy application as it uses this technique in Step 17.
Now it is time to pour our foundation. When working with OOP we are creating an engine where the base classes can be used over an over in future applications. We are building a game engine as well as an escape room game so the best part about this journey is we will have an engine we can take and create a new game on top of when we are complete which you will do!
We begin by building out the Grid class as we first need to handle all of the environment logic. This class that can be re-used on other CPython applications!
Let's build out our Grid.py file and go over each line very carefully!
class Grid: """ Class to represent a generic grid """ def __init__(self, led_height=0, led_width=0, led_on='*', led_off=' '): """ Attrs: led_height: int led_width: int led_on: int led_off: int """ self.led_height = led_height self.led_width = led_width self.led_on = led_on self.led_off = led_off self.available_height = led_height - 2 self.available_width = led_width - 2 @staticmethod def clear_screen(): """ Method to clear terminal Returns: str """ return '\n' * 100 def __create(self): """ Private method to create a grid Returns: str, str, str """ top_wall = self.led_on * self.led_width + '\n' side_walls = '' for _ in range(self.available_height): side_walls += self.led_on + self.led_off * self.available_width + self.led_on + '\n' bottom_wall = self.led_on * self.led_width return top_wall, side_walls, bottom_wall def update(self, player): """ Method to handle update with each event where we re-draw grid with player's current position Params: player: object Returns: grid: str """ top_wall, side_walls, bottom_wall = self.__create() grid = top_wall + side_walls + bottom_wall + '\n' # Convert to a list so that the element can be mutable to add player char temp_grid = list(grid) # For each step in y, needs to increment by jumps of row width plus the \n separating rows y_adjustment = (player.dy - 1) * (self.led_width + 1) # The index position of player marker in the list-formatted grid position = self.led_width + 1 + player.dx + y_adjustment temp_grid[position] = self.led_on grid = '' grid = grid.join(temp_grid) return grid
We start by creating our __init__ constructor which is what our Grid class HAS or otherwise referred to as its attributes. The constructor takes four params which are led_height, led_width, led_on and led_off to which we create default values of 0 for the led_height and led_width and a '9' for the led_on value and '0' for our led_off value.
def __init__(self, led_height=0, led_width=0, led_on='*', led_off=' '):
We then assign the param values that will be passed in to self which again represents the actual object upon instantiation. We see an available_height which is 2 less than the total led_height for the grid and the same thing for the width values. The available_height and available_width are the spaces the player can actually walk.
self.led_height = led_height self.led_width = led_width self.led_on = led_on self.led_off = led_off self.available_height = led_height - 2 self.available_width = led_width - 2
We create a clear_screen method to clear terminal.
@staticmethod def clear_screen(): """ Method to clear terminal Returns: str """ return '\n' * 100
We then create a private method called __create that takes no params and returns a top_wall, side_walls and bottom_wall.
def __create(self): """ Private method to create a grid Returns: str, str, str """ top_wall = self.led_on * self.led_width + '\n' side_walls = '' for _ in range(self.available_height): side_walls += self.led_on + self.led_off * self.available_width + self.led_on + '\n' bottom_wall = self.led_on * self.led_width return top_wall, side_walls, bottom_wall
The above will create the top_wall and then iterate through a for loop to create the side_walls and then create the bottom_wall.
We then handle the update method for each player re-draw.
def update(self, player): """ Method to handle update with each event where we re-draw grid with player's current position Params: player: object Returns: grid: str """ top_wall, side_walls, bottom_wall = self.__create() grid = top_wall + side_walls + bottom_wall + '\n' # Convert to a list so that the element can be mutable to add player char temp_grid = list(grid) # For each step in y, needs to increment by jumps of row width plus the \n separating rows y_adjustment = (player.dy - 1) * (self.led_width + 1) # The index position of player marker in the list-formatted grid position = self.led_width + 1 + player.dx + y_adjustment temp_grid[position] = self.led_on grid = '' grid = grid.join(temp_grid
Now we have taken our first few steps and holding on tight to our parent or guardian and begin to make our way around the room!
We now move on to the Player.py base class to create a scalable and reusable Player blueprint for not only this game but for any future games we create!
from time import sleep class Player: """ Base class to represent a generic player """ def __init__(self, name='Generic', dx=0, dy=0, armour=None, inventory=None): """ Attrs: name: str dx: int dy: int armour = list inventory: list """ self.name = name self.dx = dx self.dy = dy if armour is None: armour = [] self.armour = armour if inventory is None: inventory = [] self.inventory = inventory def __move(self, dx, dy): """ Method to move a generic player based on their current x and y location Params: dx: int dy: int """ self.dx += dx self.dy += dy def __move_north(self): """ Method to move a generic player from their current position to one position north """ self.__move(dx=0, dy=-1) def __move_south(self): """ Method to move a generic player from their current position to one position south """ self.__move(dx=0, dy=1) def __move_east(self): """ Method to move a generic player from their current position to one position east """ self.__move(dx=1, dy=0) def __move_west(self): """ Method to move a generic player from their current position to one position west """ self.__move(dx=-1, dy=0) def move_east(self, grid): """ Method to move the player east one position Params: grid: object Returns: int, int """ if self.dx < grid.available_width: self.__move_east() sleep(0.25) return self.dx, self.dy def move_west(self, grid): """ Method to move the player east one position Params: grid: object Returns: int, int """ # If the player is against the left wall do NOT allow them to go through it if self.dx != 1 and self.dx <= grid.available_width: self.__move_west() sleep(0.25) return self.dx, self.dy def move_north(self, grid): """ Method to move the player north one position Params: grid: object Returns: int, int """ # If the player is against the top wall do NOT allow them to go through it if self.dy != 1 and self.dy <= grid.available_height: self.__move_north() sleep(0.25) return self.dx, self.dy def move_south(self, grid): """ Method to move the player south one position Params: grid: object Returns: int, int """ if self.dy < grid.available_height: self.__move_south() sleep(0.25) return self.dx, self.dy @staticmethod def get_inventory(file_manager): """ Method to get the player inventory from disk Params: file_manager: object Returns: str """ inventory = file_manager.read_inventory_file() return inventory
Let's take this very large structure and carefully examine all of the beauty! This structure we will write once and use often over and over throughout all of our amazing games we make!
We start by examining the constructor. We create a name param and set to 'Generic' and create a dx and dy values to represent the player movements along the x and y axis and make them private and set to 0. The dx and dy values simply represent the change of position of where the current x and y values are or where are player is on the grid.
Let's illustrate this by looking at an x, y coordinate system on our console.
In our generic base class our player is set to 0, 0 which is 0 on the x and 0 on the y. The x axis goes from left to right and the y axis goes from top to bottom therefore 0, 0 will be the top left led of the 25 total spaces.
To solidify this if we are at 0, 0 we CANNOT move north and we CANNOT move west as we are at the extreme northwest coordinate of our 5 x 5 grid.
As we get back to looking at our constructor we then see a armour param which we set to None as well as an inventory param which we set to None.
def __init__(self, name='Generic', dx=0, dy=0, armour=None, inventory=None):
We then assign our params into the self values that we have seen before however the armour and inventory are handled differently. Let's dive into this!
self.name = name self.dx = dx self.dy = dy if armour is None: armour = [] self.armour = armour if inventory is None: inventory = [] self.inventory = inventory
Why do we do this? Why can't se simply assign our list [] in the params?
Part of this journey is to use Google and use StackOverflow to answer questions. I want to take this time to take a StackOverflow post which I would like you to read to illustrate why we follow this pattern.
What we learned is that it is better to set the params to None when dealing with a mutable object like a list or dictionary and then assign it inside the body of the constructor to avoid conflicts.
We next see a private method that we do not want others having access to called __move which simply adds the current value of dx and dy to the values being passed in as params.
def __move(self, dx, dy): """ Method to move a generic player based on their current x and y location Params: dx: int dy: int """ self.dx += dx self.dy += dy
We can see that this private method simply takes the current value in self.dx and adds the parameter value of dx to it. The same with the y values. A private method is only available to this class and this class alone and prevents other classes or the main.py from using it to help eliminate bugs.
We see private methods __move_north, __move_south, __move_east and __move_west public methods that use the private __move method and pass in the changes from dx and dy as provided.
Woah what does all this mean!
If our player is currently on 1, 1 for example and we called the private __move_east method we see the following.
def __move_east(self): """ Method to move a generic player from their current position to one position east """ self.__move(dx=1, dy=0)
Here we call the private __move method and add one to dx and leave dy with no change. Therefore when this private __move_east method is called our player which was first at 1, 1 will be at 2, 1 or 2 on the x and 1 on the y it is called in the move_east public method.
You can extrapolate the same logic for the other three methods.
We then create the public methods to expose to main.
def move_east(self, grid): """ Method to move the player east one position Params: grid: object Returns: int, int """ if self.dx < grid.available_width: self.__move_east() sleep(0.25) return self.dx, self.dy
You can extrapolate the same logic for the other three methods.
Finally we have the get_inventory method which works takes the file_manager instance object and reads the contents of the inventory file and returns it.
@staticmethod def get_inventory(file_manager): """ Method to get the player inventory from disk Params: file_manager: object Returns: str """ inventory = file_manager.read_inventory_file() return inventory
Now that we have a robust Player base class we can create an EscapeRoomPlayer subclass or child class called EscapeRoomPlayer.py so let's build it out.
from Player import Player class EscapeRoomPlayer(Player): """ Child class to represent an escape room player inheriting from the Player base class """ def __init__(self, name=None, dx=1, dy=1, armour=None, inventory=None): """ Attrs: name: str dx: int dy: int armour = list inventory: list """ super().__init__(name, dx, dy, armour, inventory) @staticmethod def pick_up_red_key(file_manager): """ Method to handle picking up red key Params: file_manager: object Returns: str """ file_manager.write_inventory_file('Red Key') return 'You picked up the red key!' @staticmethod def without_red_key(): """ Method to handle not having the red key Returns: str """ return 'You do not have the red key to escape.'
We start by calling the super() on the parent constructor. We override and set dx and dy in our params to 1 as that is where the beginning bounds of our player movement will be and we will therefore set the player on init or power on to 1, 1. Calling super().__init__ and then passing in the attributes allows us to inherit all of those features or what it HAS for free from the parent class.
def __init__(self, name=None, dx=1, dy=1, armour=None, inventory=None): """ Attrs: name: str dx: int dy: int armour = list inventory: list """ super().__init__(name, dx, dy, armour, inventory)
We then have the pick_up_red_key method to write the str or 'Red Key' into our inventory file and return the value.
@staticmethod def pick_up_red_key(file_manager): """ Method to handle picking up red key Params: file_manager: object Returns: str """ file_manager.write_inventory_file('Red Key') return 'You picked up the red key!'
We then handle the without_red_key logic if the player does not have the key which simply returns a str.
@staticmethod def without_red_key(): """ Method to handle not having the red key Returns: str """ return 'You do not have the red key to escape.'
We then build out our FileManager.py class which will handle writing our inventory values into the micro:bit flash storage.
class FileManager: """ Class to implement file access to store inventory if power lost or reset to maintain persistence """ @staticmethod def write_inventory_file(inventory_item): """ Method to write inventory item to inventory file upon picking it up """ try: with open('inventory', 'w') as file: file.write(inventory_item) except OSError: pass @staticmethod def read_inventory_file(): """ Method to read inventory file and return its contents Return: str """ try: with open('inventory', 'r') as file: inventory = file.read() return inventory except OSError: pass @staticmethod def clear_inventory_file(): """ Method to clear inventory file after winning a game """ try: with open('inventory', 'w') as file: file.write('') except OSError: pass
We have three static methods with no constructor. Let us take a look at how to manage simple file access.
try: with open('inventory', 'r') as file: inventory = file.read() return inventory except OSError: pass
Inside our try/except block we make sure that handle the fact that the file does not exist yet and to simply ignore it. We then use a with keyword which is a context manager. What that means is all the instructions that are within the with block are local to that with block. This will allow you to properly close a file without you having to type additional code.
with open('inventory', 'r') as file:
The above will attempt to read a file called 'inventory', which at this point does not exist. If it did it will read the file in and create a file object called file.
inventory = file.read()
Here we read all the contents of the file and store the str into an inventory var and simply return it.
@staticmethod def write_inventory_file(inventory_item): """ Method to write inventory item to inventory file upon picking it up """ try: with open('inventory', 'w') as file: file.write(inventory_item) except OSError: pass
Here we pass in an inventory_item param and literally open a file called inventory and if it does not exist create it.
with open('inventory', 'w') as file:
Then we take the file object and write the value of the param inventory_file into it and close the file. Therefore when the inventory value is passed in, 'Red Key', a str, will be written to a file called inventory.
The final method simply overwrites the inventory file and puts in an empty string.
@staticmethod def clear_inventory_file(): """ Clear inventory file after winning a game """ try: with open('inventory', 'w') as file: file.write('') except OSError: pass
Let's build out the Game.py class for our question logic.
First we are going to work with our data.py so let's review that code first before diving into the Game glass to make sure we understand data structures!
questions = { 'What year was the MicroBit educational foundation created?': [ '2016', '2014', '2017', 0 ], 'What year was the first computer invented?': [ '1954', '1943', '1961', 1 ], 'What year did Damien George create MicroPython?': [ '2015', '2012', '2014', 2 ], 'What year did the Commodore 64 get released?': [ '1983', '1984', '1982', 2 ], }
Now let's examine our class below.
from random import randint, choice class Game: """ Class to handle game integration """ @staticmethod def generate_random_number(grid): """ Method to handle obtaining random number to seed the Red Key placement Params: grid: object Returns: int """ x = randint(1, grid.available_width) return x @staticmethod def generate_random_numbers(grid): """ Method to handle obtaining random number to place question Params: grid: object Returns: int, int """ x = randint(1, grid.available_width) y = randint(1, grid.available_height) while x == 1 and y == 1: x = randint(1, grid.available_width) y = randint(1, grid.available_width) return x, y @staticmethod def ask_random_question(d_questions): """Method to ask a random question from the database Params: d_questions: dict Returns: str, str, str, str, int, str """ random_question = choice(list(d_questions)) answer_1 = d_questions[random_question][0] answer_2 = d_questions[random_question][1] answer_3 = d_questions[random_question][2] correct_answer_index = d_questions[random_question][3] correct_answer = d_questions[random_question][correct_answer_index] return random_question, answer_1, answer_2, answer_3, correct_answer_index, correct_answer @staticmethod def correct_answer_response(): """ Method to handle correct answer response Returns: str """ return '\nCorrect!' @staticmethod def incorrect_answer_response(correct_answer): """ Method to handle incorrect answer logic Params: correct_answer: str Returns: str """ return '\nThe correct answer is {0}.'.format(correct_answer) @staticmethod def win(file_manager): """ Method to handle win game logic Params: file_manager: object Returns: str """ file_manager.clear_inventory_file() return '\nYou Escaped!'
This class does not have a constructor. The first thing we do is import the random module as well as our data and questions dictionary.
from random import randint, choice from microbit import display from data import questions
The get_random_number and get_random_numbers provide a methods to get a random number for key placement and a random series of numbers for question placement.
@staticmethod def generate_random_number(grid): """ Method to handle obtaining random number to seed the Red Key placement Params: grid: object Returns: int, int """ x = randint(1, grid.available_width) return x @staticmethod def generate_random_numbers(grid): """ Method to handle obtaining random number to place question Params: grid: object Returns: int, int """ x = randint(1, grid.available_width) y = randint(1, grid.available_height) while x == 1 and y == 1: x = randint(1, grid.available_width) y = randint(1, grid.available_width) return x, y
We then have an ask_random_question method that will ask a random question from our dictionary which is a database.
@staticmethod def ask_random_question(d_questions): """Method to ask a random question from the database Params: d_questions: dict Returns: str, str, str, str, int, str """ random_question = choice(list(d_questions)) answer_1 = d_questions[random_question][0] answer_2 = d_questions[random_question][1] answer_3 = d_questions[random_question][2] correct_answer_index = d_questions[random_question][3] correct_answer = d_questions[random_question][correct_answer_index] return random_question, answer_1, answer_2, answer_3, correct_answer_index, correct_answer
Let's take this opportunity to learn how to do some print debugging. This code will not run for you yet as you have not built out the engine however for the purposes of learning I will show you directly what random_question produces.
random_question = choice(list(d_questions)) print(random_question)
When we run this code and step on a space that has a question we get random_question printed which contains the value of a random question.
>>> What year did the Commodore 64 get released?
We then see answer_1, answer_2 and answer_3 so let's debug answer_1 and see what is inside answer_1.
>>> 1983
You can see how we can work with data as we see the above result.
The next thing we are going to look at is the correct_answer_index and correct_answer vars.
correct_answer_index = questions[random_question][3] correct_answer = questions[random_question][correct_answer_index]
Let's look at our data.py specific question.
'What year did the Commodore 64 get released?': [ '1983', '1984', '1982', 2 ],
Our correct_answer_index will hold the literal integer 2 and our correct_answer will hold the value at index 2 or '1982' the str.
THE MOST IMPORTANT CONCEPT IN SOFTWARE ENGINEERING IS MASTERING CONDITIONAL LOGIC!
THE SECOND MOST IMPORTANT CONCEPT IN SOFTWARE ENGINEERING IS MASTERING DATA!
Data is everywhere. Data is the new oil. Let's take a moment and really properly understand how dictionaries hold data by taking a second look above.
'What year did the Commodore 64 get released?': [ '1983', '1984', '1982', 2 ],
We have a key which is the question and a list of values of which 3 are str and the 4th is an int.
questions[random_question][3]
Here we have the questions database in our case or more simply the questions dictionary.
questions = { 'What year was the MicroBit educational foundation created?': [ '2016', '2014', '2017', 0 ], 'What year was the first computer invented?': [ '1954', '1943', '1961', 1 ], 'What year did Damien George create MicroPython?': [ '2015', '2012', '2014', 2 ], 'What year did the Commodore 64 get released?': [ '1983', '1984', '1982', 2 ], }
Therefore...
questions[random_question][3]
In this above, questions is the dictionary and we are reaching inside and getting the random_question value.
random_question = choice(list(questions))
This is how we get a random_question. We know that choice() is going to grab a random value.
list(questions)
This will get a single key from a dictionary. Therefore an example of a single key is the following.
'What year did Damien George create MicroPython?'
Let's re-examine the above statement and substitute in the value.
questions[random_question][3]
Then becomes the following.
questions['What year did Damien George create MicroPython?'][3]
Let's look at this specific key/value pair.
'What year did Damien George create MicroPython?': [ '2015', '2012', '2014', 2 ],
We see if we take the questions dictionary and get the 'What year did Damien George create MicroPython?', it will return a list.
questions['What year did Damien George create MicroPython?']
That alone will return the following.
[ '2015', '2012', '2014', 2 ],
We see if we then examine the following.
questions['What year did Damien George create MicroPython?'][3]
This will give us the fourth element, as our lists are 0 indexed, in the list or [3] which is 2 which is an int.
2
Therefore if you grab the following.
questions[random_question][correct_answer_index]
It will give you '2014'.
Mini data lesson complete! Finally, back to our method! We return all these strings.
return random_question, answer_1, answer_2, answer_3, correct_answer_index, correct_answer
The final methods all handle the responses which are pretty self explanatory.
@staticmethod def correct_answer_response(): """ Method to handle correct answer response Returns: str """ return '\nCorrect!' @staticmethod def incorrect_answer_response(correct_answer): """ Method to handle incorrect answer logic Params: correct_answer: str Returns: str """ return '\nThe correct answer is {0}.'.format(correct_answer) @staticmethod def win(file_manager): """ Method to handle win game logic Params: file_manager: object Returns: str """ file_manager.clear_inventory_file() return '\nYou Escaped!'
Finally we are ready to build our app! Let's look at main.py and break it down.
from time import sleep from Grid import Grid from EscapeRoomPlayer import EscapeRoomPlayer from FileManager import FileManager from Game import Game grid = Grid(5, 5) player = EscapeRoomPlayer() file_manager = FileManager() game = Game() if __name__ == '__main__': player_location = None response = None final_question = False while True: # To ensure we do not generate a question if the player is hitting a wall # or not entering a valid move previous_player_location = player_location clear_screen = grid.clear_screen() update_grid = grid.update(player) print(clear_screen) print(update_grid) key = input('Enter A, D, W, S: ') if key == 'a': player_location = player.move_west(grid) elif key == 'd': player_location = player.move_east(grid) elif key == 'w': player_location = player.move_north(grid) elif key == 's': player_location = player.move_south(grid) else: pass random_location = (x, y) = game.generate_random_numbers(grid) if random_location == player_location and random_location != previous_player_location: random_question, answer_1, answer_2, answer_3, correct_answer_index, correct_answer \ = game.ask_random_question() print(random_question) print('Press 1 for {0}.'.format(answer_1)) print('Press 2 for {0}.'.format(answer_2)) print('Press 3 for {0}.'.format(answer_3)) while True: try: response = int(input('ENTER: ')) break except ValueError: print('Enter ONLY 1, 2 or 3!') if response == correct_answer_index + 1: print(game.correct_answer_response()) inventory = player.get_inventory(file_manager) player.inventory.append(inventory) if 'Red Key' in player.inventory: final_question = True if 'Red Key' not in player.inventory and not final_question: receive_red_key = game.generate_random_number(grid) if receive_red_key == 2: print(player.pick_up_red_key(file_manager)) final_question = True else: print(player.without_red_key()) elif final_question: print(game.win(file_manager)) sleep(3) break else: print(game.incorrect_answer_response(correct_answer)) sleep(3)
Ok this is a good deal of logic.
- We first import everything we need.
- We then instantiate our instance methods.
- We see a strange new syntax which means if the __name__ == '__main__'. This instructs the Python Interpreter to run this file only if the file was run directly, and not imported . This will be necessary for our next lesson in unittesting.
- We then create our logic to place the player and draw the grid with the player using previous_player_location = player_location and display.show(Image(update_grid)).
- We then have our main while loop which first creates a previous_player_location = player_location to make sure we are not generating a question if we are hitting a wall or entering invalid input. We then show the grid and player. We then create a nested while loop to handle button presses. We then generate our random_location for a question and display the question if the player lands on it. We then nest another while loop to get the player input for their response and create logic to handle. We then handle the logic if the red key is present or not by checking on disk and adding to the player inventory and if they obtain it to write to disk and add to inventory. Finally we handle win and lose logic.
Now lets click the play button and play the game!
Project 2a - Design Your Own OOP Game!
This is part 1 of your capstone project for the course. You will simply take the engine that you built today and will now use the classes you wish to build your OWN game! Take several hours or days and really pour over what you learned here before you begin.
Call it 0008_BLANK_game and substitute the BLANK for the name of your game as that will be your folder name. Use the naming conventions that you have learned for your files.
Project 2b - Design Wonka Chocolate Machine firmware (OOP)
Take your functional design from the last lesson and start from scratch and make an OOP version!
Call it p_0002_wonka_chocolate_machine as that will be your folder name. Use the naming conventions that you have learned for your files.
You can find today as well as all the source code in the GitHub repo if you run into any issues.
I really admire you as you have stuck it out and made it to the end of your thirty-second step in the Python Journey! Today was long and likely took two to four weeks to complete. Great job!
In our next lesson we will cover Unittest!