HW 7:
Enchanted Garden

Submission Details

Due: 4/19 - Submit via Gradescope:

4 files in total

  • EnchantedMushroom.py

  • EnchantedLog.py

  • enchantedGarden.py

  • A PDF that contains your self-assessment & reflection

Self-assessment & Reflection (reproduced from the Syllabus for reference)

Homeworks are an opportunity to engage with the material and practice your programming skills. To support you, solutions will be available on moodle. It is your responsibility to engage with them in a way that strategically moves your learning forward. You should:

  • Attempt the homework without looking at the solutions.

  • Review the solutions; if you were stuck, you may revise your homework. You may find yourself iteratively reviewing and revising to best support your learning.

  • When you submit your homework, you will be required to complete a self-assessment and reflection:

  1. Give yourself an assessment mark on the initial attempt:

✘ if you did not complete it

✓- if you completed it, but were very far from the solution

✓ if you completed it and essentially had the solution

✓+ if you completed it correctly, including precise communication

? if you are unsure as to whether or not you completed it

  1. Provide a short reflection as to why you earned that mark, such as:

  • I did not understand how to get started, and it was too close to the deadline to visit support hours.

  • I got stumped by a bug where my program entered an infinite loop.

  1. If you used the solutions to revise your work, describe what you needed to change, as in:

    • I modified the style of my conditional when I saw a simpler approach in the solutions.

    • I was so stuck that the majority of my submission is based on the solutions.

  2. Ask specific questions on portions that are still confusing, such as:

    • Why does it cause an error when I returned "1" instead of 1?

[Optional] Provide one hint for the homework (to your past self or future student).

Learning goals

  • practice with OOP

  • gain comfort with the discomfort of working with and adapting sample code

Be strategic!

  • Start early!

  • Ask questions!

  • Leave time for questions by... starting early :)

  • When you're stuck, pause and evaluate what could be going on. Remember the self-regulated learning cycle!

    • Do you have a plan?

    • Are you evaluating your plan? How will you test your code as you go?

    • How will you revise? What will you do when you're confused? Stuck?

Overview

You've probably noticed that a crucial developer skill to practice is learning and adapting to new technologies and platforms. As developers, we do this regularly, often working with code that we won’t fully understand. It can be overwhelming at first, but there is a way to be systematic. First, start with simple sample code. Ideally it has behavior similar to our goal behavior. Then work to systematically adapt the code, changing only a small amount at a time. This often means not understanding every line of code and being comfortable with some discomfort.

For this homework, you’ll be creating a "GUI" for the first time. Probably, most of your previous programs have used the “command line” interface, allowing for text-only input and output. A GUI or Graphical User Interface is what we are more familiar with when we interact with devices – we expect to click different positions on the screen and have the interface (visually) respond, sometimes by changing the color or text of what we see.

Working with and programming GUIs in most languages takes quite a bit of getting used to. The code can sometimes feel clunky and mysterious, and that is part of what we’re practicing in this homework. First you’ll walk through some sample code. Then, based on what you've seen in the sample code and the introduction to defining your own classes, you'll create GUI application using OOP.

Part 1: sample GUI with Tkinter

Step 1: Set up the sample program

  1. Create a folder to organize your code called LightRoom

  2. Create a file for each of the following and copy/paste the code from below:

    1. LightBulb.py

    2. room.py

  3. Download the two image asset files from below by right-clicking them and saving/moving them to your LightRoom folder

  4. Confirm the directory and its contents by comparing with the image to the right

room.py

""" This is an example GUI using Tkinter that creates a "room"

that LightBulbs can be added to. It relies on the LightBulb class. """


from tkinter import *

from LightBulb import LightBulb


def main():

""" This function sets up the Tkinter GUI. """


# create the main tkinter window

window:Tk = Tk()


# set the background color

window.configure(background='black')


# this sets the title that shows at the top of the window

window.title( "Light Bulbs!" )


# this sets the width and height of the window (in pixels)

window.geometry("600x400")


# now that we have a window, we can initialize the PhotoImage elements

# that we want the LightBulb class to share

# to be in line with the assumptions of the LightBulb class

# we will first add the off image (at index 0)

LightBulb.displayElts.append( PhotoImage(file = r"LightBulbOff.png") )

# and then the on image (at index 1)

LightBulb.displayElts.append( PhotoImage(file = r"LightBulbOn.png" ) )


# add one light at the coordinates (100, 200)

# note that the upper left of the window is (0,0)

# x coordinates behave "as usual" with larger x values to the right

# y coordinates behave "flipped" with larger y values down

addLight( window, 100, 200 )


# ask the window to launch

window.mainloop()


def addLight( window:Tk, posX:int, posY:int ) -> None:

""" Create a LightBulb instance and initialize/add a display element for it. """


# create a Tkinter Label instance to display the LightBulb instance

# it requires the window for attachment and an optional background color

bulbDisplay:Label = Label( window, bg="black" )


# ask the Label instance to place itself on the window at the

# specified (posX,posY) coordinates by using the optional arguments x and y

bulbDisplay.place(x=posX,y=posY)


# create the LightBulb instance and pass it the GUI label for displaying

lightBulb:LightBulb = LightBulb( bulbDisplay )


# print out info about the LightBulb instance

print( f"Just added a LightBulb: {lightBulb}" )


if __name__ == "__main__":

main()

LightBulb.py

from tkinter import Label


class LightBulb( object ):

""" Represents a light bulb that can be on or off and maintains

a GUI element for display. """


# list to hold display elements

# because of how Tkinter is structured, this will need to be updated

# by an external class that has access to the main Tkinter window

# we expect that index 0 will hold the "off" image and index 1 the "on" image

displayElts:list = []


def __init__( self, guiElt:Label ):

""" Construct a new light bulb, initially off, that should use the

passed guiElt for display. """


# initialize the instance properties

self.__on:bool = False # to track if the light is on/off

self.__display:Label = guiElt # to maintain the GUI display


# set up the GUI to start by displaying the off image

# on the left, we access the Tkinter Label object's

# instance property named display

# it is a dictionary, and we want to update the entry for the "image" key

# on the right, we assign the "off" image which we expect to be stored at index 0

self.__display["image"] = LightBulb.displayElts[0]

# set up the GUI to respond to clicks by "binding" the click to the

# instance method processClick

# this works with Tkinter's "event-driven" infrastructure and

# is similar to signing up for an email list

# when we sign up for a mailing list, we have to give it the "address"

# for how to contact us

# here, we have the label sign up for click events using the argument "<Button>"

# and we give the "address" for how to contact us by passing the

# instance method "address" self.processClick

# OBSERVE that we are NOT invoking the instance method

self.__display.bind( "<Button>", self.processClick )


def processClick( self, event ) -> None:

""" This instance property exists as a way of allowing the Tkinter GUI

to "contact" us when a click has occurred. It requires the event parameter,

which we won't use (but contains information) -- this is like the body of

an email for a mailing list. """


# print info so we know a click occurred

print( f"Clicking: {self}" )


# change the state of the light bulb by invoking the instance methods setOn and isOn

self.setOn( not self.isOn() )


# print info now that the state has changed

print( f"... and now: {self}." )

def isOn( self ) -> bool:

""" A getter for accessing the state of the light bulb

returns True if it's on and False otherwise. """

return self.__on


def setOn( self, turnTheLightOn:bool ) -> None:

""" A setter for changing the state of the light bulb to

be on (True) or off (False). """


# update the instance property

self.__on = turnTheLightOn


# refresh the display

self.refreshDisplay()


def refreshDisplay( self ) -> None:

""" An instance method that refreshes the display to reflect

the current state of the light bulb."""

# invoke the instance property isOn to check if the bulb is on

if self.isOn():

# set the Tkinter Label data to display the on image (at index 1)

self.__display["image"] = LightBulb.displayElts[1]

else:

# set the Tkinter Label data to display the off image (at index 0)

self.__display["image"] = LightBulb.displayElts[0]

def __str__( self ) -> str:

""" Return a string representation of this light bulb. """

if self.isOn():

return "A light bulb that is on."

else:

return "A light bulb that is off."

Image asset files

LightBulbOff.png

LightBulbOn.png

Step 2: Run the sample program

  • In VSCode, make sure that you have the file room.py selected, as it contains the main code for launching the program.

  • Run the sample program. You should see a window with a black background and light bulb. Try clicking the light bulb -- it should "turn on" and show a different image.

click the bulb (it's a little hard to see, but is on the left side)

-->

Step 3: Investigate the code

When you get sample code, it's always good to try a few experiments to start to gain understanding of how it works. Here are a couple questions you might have when you first play with it.

  1. How did the light bulb get added to the window? How did its position get set?

  2. How would we add another light bulb? Will clicking it impact all the light bulbs or just the one that is clicked?

  3. How does the light bulb change its display when a click occurs?

Let's take each question and walk through "experiments" to gain understanding.

Question 1: How did the light bulb get added to the window? How did its position get set?

  1. Look through the code and see if you can find the line(s) of code for adding the light bulb.

  2. Run an experiment by commenting it out and re-running the program -- does the light bulb disappear?

Once you've tried, check out the solutions below.

Debrief

The GUI is mainly set up in room.py. There are a couple candidate lines that seem to add the light bulb.

  • line 31: addLight( window, 100, 200 )

  • line 45: bulbDisplay.place(x=posX,y=posY)

Run the experiment for each of these lines separately. They should both make the light bulb disappear!

As you consider the difference between the two lines, consider the roles of abstraction and reusability. Since we ultimately may want to add more light bulbs, line 31 seems more helpful. It invokes a function that encapsulates the behavior required to create and add the light bulb and is thus reusable. At this point, we may not care how that happens (though line 45 is certainly part of it), so let's abstract away the "implementation details."

Question 2: How would we add another light bulb? Will clicking it impact both light bulbs or just the one that is clicked?

  1. Based on the experiments from Question 1, add another line of code (by copy/pasting) to add another light bulb at the location (300, 200). Where do you expect that light bulb to show up?

  2. Run the program to see if you are correct.

  3. Do you think that clicking the new light bulb will impact both light bulbs or just the one that is clicked?

  4. Click the light bulb to see if you are correct.

Once you've tried, check out the debrief below.

Debrief

  1. After line 31, add a copy, then modify the second parameter to have the value 300:
    addLight( window, 300, 200 )

  2. When you run the program, you should see a second light bulb slightly the right of the center of the window.

  3. When you looked through the code, you should have noticed that an instance of the LightBulb class is created in line 48 of room.py.

# create the LightBulb instance and pass it the GUI label for displaying

lightBulb:LightBulb = LightBulb( bulbDisplay )

This should prompt you to expect that each light bulb (through the power of OOP) will maintain its own state. Clicking one light bulb should not impact the other one.

  1. When you run the program, you should be able to click the light bulbs on/off individually!

Question 3: How does the light bulb change its display when a click occurs?

  1. Look through the code to see if you can find which parts are related to the light bulb's display.

  2. Then, see if you could add a third image asset using the file to the right and modify the code to randomly display the original "on" asset or the newly added one if the light bulb is on.

Once you've tried, check out the debrief below.

LightBulbPink.png

Debrief

  1. As you look through the code, you should see a couple candidate lines that seem related to the light bulb's display:

    • in LightBulb.py, lines 7-11 seem to create a variable (this actually belongs to the LightBulb class and can be shared by all the instances) for maintaining the image assets:

# list to hold display elements

# because of how Tkinter is structured, this will need to be updated

# by an external class that has access to the main Tkinter window

# we expect that index 0 will hold the "off" image and index 1 the "on" image

displayElts:list = []

    • in LightBulb.py, lines 21-26 of the constructor seem to set the display to start with the off image:

# set up the GUI to start by displaying the off image

# on the left, we access the Tkinter Label object's

# instance property named display

# it is a dictionary, and we want to update the entry for the "image" key

# on the right, we assign the "off" image which we expect to be stored at index 0

self.__display["image"] = LightBulb.displayElts[0]

    • in LightBulb.py, lines 70-79 define the instance method for refreshing the display and contain code that is analogous to what is in the constructor -- depending on whether to display the on or off image, the code accesses a different index of the displayElts list:

def refreshDisplay( self ) -> None:

""" An instance method that refreshes the display to reflect

the current state of the light bulb."""

# invoke the instance property isOn to check if the bulb is on

if self.isOn():

# set the Tkinter Label data to display the on image (at index 1)

self.__display["image"] = LightBulb.displayElts[1]

else:

# set the Tkinter Label data to display the off image (at index 0)

self.__display["image"] = LightBulb.displayElts[0]

    • in room.py, lines 22-28 seem to set up the image assets for the LightBulb to use

# now that we have a window, we can initialize the PhotoImage elements

# that we want the LightBulb class to share

# to be in line with the assumptions of the LightBulb class

# we will first add the off image (at index 0)

LightBulb.displayElts.append( PhotoImage(file = r"LightBulbOff.png") )

# and then the on image (at index 1)

LightBulb.displayElts.append( PhotoImage(file = r"LightBulbOn.png" ) )

  1. To add an additional asset and randomly display it for a light bulb that is on, we can:

    • add the asset by appending it to the displayElts list -- insert this code after line 28 of room.py

# add a third image asset: an alternative pink image (at index 2)

LightBulb.displayElts.append( PhotoImage(file = r"LightBulbPink.png" ) )

    • modify the refreshDisplay instance method to randomly select index 1 or 2, as in:

def refreshDisplay( self ) -> None:

""" An instance method that refreshes the display to reflect

the current state of the light bulb."""

# invoke the instance property isOn to check if the bulb is on

if self.isOn():

# randomly choose a lit bulb to display (index 1 or 2)

litBulbIndex:int = random.randint(1,2)

# set the Tkinter Label data to display that image

self.__display["image"] = LightBulb.displayElts[litBulbIndex]

else:

# set the Tkinter Label data to display the off image (at index 0)

self.__display["image"] = LightBulb.displayElts[0]

    • run the program and see if the light bulb is pink sometimes!

Part 2: Enchanted Garden

Now you'll take what you've learned in Part 1 and synthesize it further by creating your own "Enchanted Garden" program!

Watch the video for a preview of how the final program should work.

Requirements

To create your Enchanted Garden, you must:

  1. Define an EnchantedMushroom class in a file named EnchantedMushroom.py

  2. Define an EnchantedLog class in a file named EnchantedLog.py

  3. Define a set of functions, including a main function, in a file named enchantedGarden.py

The specifications for each file are outlined below.

Developer tips

Develop a little, test a little...

  • Be thoughtful of your process; you may want to think about the design vs implementation phases

  • Resist the temptation to write the entire program all at once! Otherwise, the debugging process becomes so much more difficult and time-consuming!

    • First, create the skeleton of your program files by writing "stubs" (declarations of the classes, instance methods and functions with "placeholder" implementations).

    • Then implement one part at a time and test as you go.

  • Remember you have different types of debugging strategies:

    • Print statements help you peek at memory during program execution.

    • Tracing your program on paper helps you understand when the execution doesn't match your intended logic.

    • The Debugger can help you do both!

Be strategic!

Be intentional around your approach to working on the project:

  • Start early!

  • Ask questions!

  • Leave time for questions by... starting early :)

  • When you're stuck, pause and evaluate what could be going on. Consider the self-regulated learning cycle!

      • Do you have a plan?

      • Are you evaluating your plan? How will you test your code as you go?

      • How will you revise? What will you do when you're confused? Stuck?

EnchantedMushroom class in EnchantedMushroom.py

Define a class that represents an enchanted mushroom. Write your class definition in a file named EnchantedMushroom.py, adhering to the following specification:

  • The class should be named EnchantedMushroom.

  • Like the LightBulb class, there should be a shared "class variable" called displayImages. For the mushroom, this will be a list that is initialized to the empty list and will ultimately hold 4 images.
    (Due to how Tkinter handles image files, this will need to populated by the main program functions.)

  • There should be a two instance properties:

    • An int to track the enchantment level of the mushroom, with 0 indicating no enchantment

    • A Label to maintain the display for the mushroom (much like the LightBulb class)

  • There should be a constructor that:

    • Requires a single argument when invoked for the Label instance used as a display

    • Should initialize the instance properties

    • Should set up the display to show the image at index 0 of displayImages

    • Should "bind" mouse click events to the instance method processClick

  • There should be a getter for the enchantment level instance property called getEnchantment that:

    • Requires no arguments when invoked

    • Should return an int that is 0 for the initial enchantment level (not enchanted), 1 for the next level of enchantment, etc.

  • There should be a setter to change changing the level of enchantment called setEnchantment that:

    • Requires a single argument when invoked of type int

    • Checks if the given value is valid (between 0 and the # of levels)

      • If an invalid value is passed, it should assign the closest valid value. For a value < 0, set the enchantment to the initial level. For a value > the final level value, set the enchantment to the final level.

  • There should be an instance method called enchant that:

    • Requires no arguments when invoked

    • Should "advance" the level of enchantment by invoking the getEnchantment and setEnchantment methods

  • Like the LightBulb class, there should be an instance method for handling click events called processClick that:

    • Requires a single argument when invoked (we'll skip the type for this as it would require a little more GUI explanation than is within the scope of this course).

    • Should invoke the instance method enchant

  • Like the LightBulb class, there should be an instance method for refreshing the display called refreshDisplay that:

    • Requires no arguments when invoked

    • Should update the displayed image to be the corresponding image in the displayImages list.

  • There should be an instance method called __str__ that:

    • Requires no arguments when invoked

    • Should return a string representation of the mushroom. If the mushroom is enchanted at level 0, for example, it should return
      "A mushroom enchanted at level 0."

EnchantedLog class in EnchantedLog.py

Define a class that represents an enchanted log. Write your class definition in a file named EnchantedLog.py, adhering to the following specification:

  • The class should be named EnchantedLog.

  • Like the EnchantedMushroom class, there should be a shared "class variable" called displayImages. For the log, this will be a list that is initialized to the empty list and will ultimately hold 2 images.
    (Due to how Tkinter handles image files, this will need to populated by the main program functions.)

  • There should be a three instance properties:

    • A bool to track whether the log is enchanted (True) or not (False); it should initially not be enchanted

    • A list to track the EnchantedMushroom instances associated to the log; it should be initialized to the empty list

    • A Label to maintain the display for the log (much like the EnchantedMushroom class)

  • There should be a constructor that:

    • Requires a single argument when invoked for the Label instance used as a display

    • Should initialize the instance properties

    • Should set up the display to show the image at index 0 of displayImages

    • Should "bind" mouse click events to the instance method processClick

  • There should be a getter for the enchantment level instance property called isEnchanted that:

    • Requires no arguments when invoked

    • Should return an bool

  • There should be an instance method called enchant that:

    • Requires no arguments when invoked

    • Should have a return type of None

    • Should check if all the mushrooms associated to this log have some level of enchantment (>0). If so, should update the instance property to set the enchanted state to True and refresh the display.

  • There should be an instance method called addMushroom that:

    • Requires a single argument when invoked of type EnchantedMushroom

    • Should have a return type of None

    • Should add the passed mushroom to the list of mushrooms (maintained as an instance property)

  • Like the EnchantedMushroom class, there should be an instance method for handling click events called processClick that:

    • Requires a single argument when invoked (we'll skip the type for this as it would require a little more GUI explanation than is within the scope of this course).

    • Should invoke the instance method enchant

  • Like the EnchantedMushroom class, there should be an instance method for refreshing the display called refreshDisplay that:

    • Requires no arguments when invoked

    • Should update the displayed image to be the corresponding image in the displayImages list:

      • If the log is not enchanted, display the image at index 0

      • Otherwise, display the image at index 1

  • There should be an instance method called __str__ that:

    • Requires no arguments when invoked

    • Should return a string representation of the log, such as
      "A log that is not enchanted with 3 mushrooms."

or

"A log that is enchanted with 4 mushrooms."

main program functions in enchantedGarden.py

In a file enchantedGarden.py, using room.py as a guide, define the following functions:

  • A main function that:

    • Requires no parameters

    • Sets up the Tkinter window:

      • The background color should be navy

      • The dimensions should be 800 x 600

    • Initializes the art assets:

      • The image files are all in this directory

      • You'll notice they are named with the patter mushroomNUM.png and logNUM.py. This means you can use for loops to create the PhotoImage elements that the EnchantedMushroom and EnchantedLog classes each share.

    • Create and add two logs by invoking the addLogWithMushrooms (described next):

      • One log should be at coordinates (0,100) with 3 mushrooms

      • The other log should be at coordinates (300,350) with 4 mushrooms

    • Launch the Tkinter window

  • A addLogWithMushrooms function that:

    • Requires 4 parameters:

      • The Tkinter window

      • An int for the x coordinate

      • An int for the y coordinate

      • An int for the number of mushrooms

    • Should create a Tkinter Label instance to display the log

    • Should ask the created Label instance to place itself on the window at the specified x and y coordinates

    • Should create the EnchantedLog instance and pass it the GUI label for displaying

    • Should create the mushrooms using the createMushroom function (described next) and add them to the log using the log's instance method addMushroom

      • The coordinates of the mushroom mushroomNum (assuming mushroomNum takes on the values 0, 1, ...) should be:

        • (posX + 40 + mushroomNum*100, posY-100)

  • A createMushroom function that:

    • (Is almost the same as the addLightBulb function from Part 1)

    • Requires 3 parameters:

      • The Tkinter window

      • An int for the x coordinate

      • An int for the y coordinate

    • Should create a Tkinter Label instance to display the mushroom

    • Should ask the created Label instance to place itself on the window at the specified x and y coordinates

    • Should create the EnchantedMushroom instance and pass it the GUI label for displaying

    • Should return the EnchantedMushroom instance (so the log can add it)

IMPORTANT

  • Do NOT invoke the main function directly in your script, or you will fail the Gradescope autograder tests.

  • Remember to use this pattern to ensure it is only invoked when executed directly as a Python program:

if __name__ == "__main__":

main()

Solutions

As per the Syllabus: you should use these to support your learning. Do not look at solutions until you have attempted the work. It is up to you to best leverage this resource.