Remember our RPS game from the first part of this series?
Great! So now in part 2, I'd like to make it more dynamic. What does that mean? Well ..
Currently, our code evaluates who won using static string comparisons. That's great and all but if we keep adding elements to the game, it get's convoluted very fast. Back in my childhood there were all kinds of made up additional elements like the well - a rock would fall in, a paper would cover it up and I can't even remember what the scissors did.
There's also several other versions of the same game with additional things like lizard and spock or even more obscure variants. So the point of this tutorial is to make sure we can implement them if we want to.
There's a really simple solution for all of that: JSON
If you've never seen JSON, google it and realize that you're seen it before. The goal is to implement a way to initialize the elements of our game in a way where every element knows what it can beat and what it can't beat. In JSON this could look like this:
{
"name": "rock",
"win": [
"scissors"
],
"lose": [
"paper"
]
}
This is an example for the rock-element of our RPS-game. It's simply an object that contains a name to identify the element and two lists with other elements that result in either a win of the current element or a loss.
To implement this in Python, I recommend starting a new file for playing around. Add the following code, I'll explain it in a second:
import json
class RpsElement:
def __init__(self, name, win, lose):
self.name = name
self.win = win
self.lose = lose
def toJSON(self):
return json.dumps(self, default=lambda o: o.__dict__,
sort_keys=True, indent=4)
rps = RpsElement("rock", ["scissors"], ["paper"])
print(rps.toJSON())
We're creating a class called "RpsElement", which takes 3 arguments upon initialization. In our case, this is the identifying name, the list of win-elements and the list of lose-elements.
The function toJSON(self) ( credit ) is a really easy way to convert a class to JSON without having to deal with de/encoders. This will only work if every object in the class RpsElement has an attribute dict - thankfully, in our case it's working just fine.
This will work for mostly every variable, lists, dictionaries, etc but it won't work for datetime for example. I will probably get into more detail later in the series - for now, this is an easy copy-paste solution to use that will make the code less complicated.
Running the above will print the following output:
{
"lose": [
"paper"
],
"name": "rock",
"win": [
"scissors"
]
}
That's pretty much exactly what we wanted. So how do we proceed? Well, let's first make another class and move the toJSON() function there:
class RpsGame:
def __init__(self, filename):
self.filename = filename
self.elements = []
def save(self):
try:
f = open(self.filename, "w")
f.write(self.toJSON())
f.close()
except Exception as err:
print(err)
def toJSON(self):
return json.dumps(self, default=lambda o: o.__dict__,
sort_keys=True, indent=4)
The class RpsGame will contain our list of elements, which we now know are able to be saved as JSON. So, naturally, we'll be adding a save-function to write the entire class to a JSON file.
You can initialize our game elements, the game class and assign them like this:
rock = RpsElement("rock", ["scissors"], ["paper"])
paper = RpsElement("paper", ["rock"], ["scissors"])
scissors = RpsElement("scissors", ["paper"], ["rock"])
game = RpsGame("rps.json")
game.elements.append(rock)
game.elements.append(paper)
game.elements.append(scissors)
game.save()
If you run this code, a JSON file rps.json should appear in your working directory with the content:
{
"elements": [
{
"lose": [
"paper"
],
"name": "rock",
"win": [
"scissors"
]
},
{
"lose": [
"scissors"
],
"name": "paper",
"win": [
"rock"
]
},
{
"lose": [
"rock"
],
"name": "scissors",
"win": [
"paper"
]
}
],
"filename": "rps.json"
}
Neat! Also note that the filename was also dumped to JSON. How do we reverse the JSON saving? I'm glad you asked. Loading is note quite as easy but also not difficult:
def load(self):
try:
f = open(self.filename, "r")
data = json.load(f)
for el in data["elements"]:
element = RpsElement(el["name"], el["win"], el["lose"])
self.elements.append(element)
f.close()
except Exception as err:
print(err)
At this point we're just assuming that the RpsGame was initialized with a filename that is readable and actually exists - but for now this is fine. The line data = json.load(f) loads the entire JSON-file into the data element. If you simply print(data) you can see how we can access single elements.
Speaking of elements: data["elements"] is our object of type RpsElement - just in JSON format. So we know that there will be attributes within under the names of name, win and lose. We're treating it as a list and iterate through all elements within it.
Then we're creating new RpsElements and add the appropriate properties. Lastly (but not least - I always forget to append something to a list and wonder where it went ..), we append the created element to our own list and we're done.
If our saved rps.json file is still around, you can test it with the following code:
game = RpsGame("rps.json")
game.load()
for element in game.elements:
print(f"{element.name} wins against: {element.win}")
Which will output to the console:
rock wins against: ['scissors']
paper wins against: ['rock']
scissors wins against: ['paper']
Nice!
Ok, so now let's modify our code from part 1 and first copy the classes into the file right after the imports. Speaking of imports, don't forget to also import json. (I know, this is where we could starting working with different scripts, but for now let's keep it simple and all in one script)
Between root.title(..) and rps_choices = .. add the code for initialization and loading - while you're at it, you might as well re-assign rps_choices and run it to see if it's working:
root.title("Rock Paper Scissors")
# initialize the game
game = RpsGame("rps.json")
game.load()
# load possible choices
rps_choices = game.elements
When running:
It's working! Well .. mostly.. it's not crashing! Quick-Fix and also a fun thing to learn: str - add the following to our RpsElement class:
def __str__(self):
return self.name
This will tell our class to return the name-property when it's being asked to be represented in string-form. Running it again will show it's working but will also show a different problem:
We're losing! Even though we shouldn't. Which is obvious because we're not comparing the elements correctly anymore. So, let's do 2 things. First up, we'll add an eval() function to our RpsElement class:
def eval(self, o_element):
if(o_element.name in self.win):
return "win"
elif(o_element.name in self.lose):
return "lose"
else:
return "draw"
Sweet and short. We'll get passed an RpsElement o_element and check if it's name is within either our win-list or our lose-list. If it's not anywhere to be found, we'll just call it a draw.
The other issue is that in our fight()-function we've previously checked for "loss" and not for "lose" - so we'll need to adjust that:
elif(result == "lose"):
lbl_result["text"] = "You lose!"
It's a minor thing but these things will cost you precious hours of lifetime until you spot the stupid mistake. Which totally didn't happen to me by the way.
So .. to see if it's working, let's just add some more elements. Let's take lizard and spock since this is a pretty mainstream variant. All we have to do is modify our JSON file to include 2 more elements and add the other elements to the win/lose-list of the already existing elements.
Let's run the script again and voilà:
Not only can we now choose all the elements we added, but we can also play the game and win, draw or lose! Nice!
I can hear a thought in the back of your mind .. something like why do we need a win and a lose list? I like you already! See you in part 3.
Oh, and once again, if you're here for the copypaste or I explained something very confusingly without the entire context, here's the complete code for Python:
from tkinter import *
from tkinter import ttk
import random
import json
# classes
class RpsElement:
def __init__(self, name, win, lose):
self.name = name
self.win = win
self.lose = lose
def __str__(self):
return self.name
def eval(self, o_element):
if(o_element.name in self.win):
return "win"
elif(o_element.name in self.lose):
return "lose"
else:
return "draw"
class RpsGame:
def __init__(self, filename):
self.filename = filename
self.elements = []
def save(self):
try:
f = open(self.filename, "w")
f.write(self.toJSON())
f.close()
except Exception as err:
print(err)
def load(self):
try:
f = open(self.filename, "r")
data = json.load(f)
for el in data["elements"]:
element = RpsElement(el["name"], el["win"], el["lose"])
self.elements.append(element)
f.close()
except Exception as err:
print(err)
def toJSON(self):
return json.dumps(self, default=lambda o: o.__dict__,
sort_keys=True, indent=4)
# Tkinter initialization
root = Tk()
root.title("Rock Paper Scissors")
# initialize the game
game = RpsGame("rps.json")
game.load()
# load possible choices
rps_choices = game.elements
# functions
def fight():
# player
player_txt = box.get()
player = next(e for e in game.elements if e.name == player_txt)
opponent = random.choice(rps_choices)
# update opponent choice
lbl_op_selection["text"] = opponent
# get the result
result = player.eval(opponent)
# announce the result to the player
if(result == "win"):
lbl_result["text"] = "You win!"
lbl_result.configure(background="green")
elif(result == "lose"):
lbl_result["text"] = "You lose!"
lbl_result.configure(background="red")
elif(result == "draw"):
lbl_result["text"] = "Oh .. a draw .."
lbl_result.configure(background="grey")
# row 0
lbl_player = Label(root, text="Player:", width=16, foreground="black", background="green")
lbl_player.grid(row=0, column=0)
lbl_vs = Label(root, text=" .. VS .. ", width=16, foreground="black")
lbl_vs.grid(row=0, column=1)
lbl_opponent = Label(root, text="Opponent:", width=16, foreground="black", background="blue")
lbl_opponent.grid(row=0, column=2)
# row 1
box = ttk.Combobox(root, values=rps_choices, width=12, foreground="black", state="readonly")
box.grid(row=1, column=0)
box.current(0)
btn_fight = Button(root, command=fight, width=10, text="Fight!", foreground="black")
btn_fight.grid(row=1, column=1)
lbl_op_selection = Label(root, text=" .. ", width=16, foreground="black")
lbl_op_selection.grid(row=1, column=2)
# row 2
lbl_result = Label(root, text="", foreground="black")
lbl_result.grid(row=2, column=0, columnspan=3)
root.mainloop()
And because I'm nice, also the JSON for the modified RPS(LS):
{
"elements": [
{
"lose": [
"paper",
"spock"
],
"name": "rock",
"win": [
"scissors",
"lizard"
]
},
{
"lose": [
"rock",
"scissors"
],
"name": "lizard",
"win": [
"spock",
"paper"
]
},
{
"lose": [
"lizard",
"paper"
],
"name": "spock",
"win": [
"rock",
"scissors"
]
},
{
"lose": [
"scissors",
"lizard"
],
"name": "paper",
"win": [
"rock",
"spock"
]
},
{
"lose": [
"rock",
"spock"
],
"name": "scissors",
"win": [
"paper",
"lizard"
]
}
],
"filename": "rps.json"
}
(at this point you might be asking why I'm not simply putting it on GitHub but we're getting there)