Container Encapsulation and Inheritance Lab =========================================== Starter Code ------------ - `Inheritance with Pygame `_ Steps ----- 1. Check out the line ``my_box = Box(50, 50, 4) # x, y, max_capacity``. What should the ``__init__`` method look like? Code it in ``box.py``. 2. Add three default attributes for the ``Box`` class. ``contents: List[int] = []``, ``width: int = 75``, and ``height: int = 50``. 3. Check out the line ``my_box.draw(screen)``. Define a ``draw()`` method for this in ``box.py``. Don't forget self *and* the ability to take a screen object as an argument. 4. Check out the line ``if my_box.is_clicked(mouse_location)``. Create the method definition for this in ``box.py``. By this point, when you click the box, new contents should be added to it. 5. What happens when you click more than four times? What is the capacity? What is wrong with this? Identify the line of code that allowed this bug to happen. How can this be fixed? There are two possible solutions. The best solution envolves proper encapsulation of the ``contents`` list. 6. Create an ``add_item`` method in the ``Box`` class. It should be defined to take a single integer (which we willl deem as a single item). This method will add the integer to the ``contents`` list, but *only if the box isn't already full*. 7. Change the un-encapsulated ``my_box.contents.append(1)`` in ``main.py`` to make use of the handy new ``add_item`` method. By this point, items should be added to the box if it isn't full. So, the display text on the box shouldn't go passed ``4/4``. .. note:: **Optional (Advanced)** If you wanted to display some message saying something to effect of "you can't add anything else (because it's full), you could have the `Box` object raise an error. - In ``box.py`` create a new class called ``BoxFullError``. - Have this error class inherit from ``Exception``. - In your ``add_item`` method, if you try to add an item and the container is full, raise this ``BoxFullError``. Now if you try to add an item to a full box, the program will crash. This is fine, because we can have ``main.py`` catch that error. - Where ``main.py`` adds a new item, wrap that line of code in a ``try/except``. - When the ``BoxFullError`` happens, you can simply print out to the console ``"Box is full"``. In reality, you could display some text near the box in the game or play a sound. Why go through all this trouble when you can just print out the message in the ``Box`` class ``add_item`` method? The answer is in the form of a question: do you want *every single person* using your ``Box`` class to be forced to print the error message ``"Box is full"`` to the console, or do you want to give each programmer the opportunity to handle that particular error in whatever way that best fits their particular situation? Let's say we wanted the ability for some boxes to be lockable. Normal boxes would not have that capability, but those with locks should have the exact capabilities as a normal Box. 8. Create a new class (in ``box.py``) called `LockableBox` and inherit from the ``Box`` class. It should have all the functionality of a regular box, but with added functionality (don't worry about this yet). This is achieved through **inheritance**. 9. In ``main.py`` create a ``LockableBox`` along-side the other box. You will need to call the ``draw()`` method, as well as handle mouse clicks, just like the regular box. Confirm that the box functions exactly the same as the regular box. *hint: make sure you import ``LockableBox`` from ``box``, just like the normal box. I forgot. Oops.* 10. It might be best to store these two boxes in a list now, so you can loop through them when checking for clicks and drawing them. Do this and ensure both boxes are functioning properly. Now we add the extra functionality specific to ``LockableBox``. We want to: 11. Override the ``__init__`` method in ``LockableBox`` and a ``locked`` attribute. Make this private and default to ``True`` (just because). The method definition should be exactly the same as the original ``Box`` ``__init__`` method. If you wanted to, you could add a ``locked`` parameter to it. 12. If you have not copy-and-pasted the original method, chances are your game doesn't run and you get some ``AttributeError`` saying ``LockableBox has no attribute 'x'``. Make sure to copy-paste the original method that initializes ``self.x``, ``self.y``, ``self.contents`` etc. Verify that the boxes work. You might notice that even though we have a ``locked`` attribute, that doesn't prevent us from adding items. We will get to that in a minute. For ``LockableBox.__init__``, we need to make sure we don't copy and paste code from the parent class (``Box``). That would defeat the advantage of inheritance. If we simply get rid of that code we get an error. So how do we get the original ``Box.__init__`` method to run as well? We need to use ``super().__init__()``. This will manually call the parent-class's ``__init__`` method that will take care of the attributes ``x``, ``y``, `contents`, and ``max_capacity``. 13. In ``LockableBox.__init__``, before you set the ``locked`` attribute, make a call to ``super().__init__()``. Be sure to give that init method all the information it needs. *Hint: ``Box(x, y, max_capacity)``*. Verify that both boxes work. 14. Lockable boxes now need to reject items if the boxes are locked. You need to override the ``add_item`` method. You can simply copy and paste the method from `Box` and add the code you need, but why not make use of ``super()`` to call the original method? **optional, advanced**: create a ``BoxIsLockedError`` that you can raise if they try to add an item when the box is locked. If you do this, don't forget to import it into ``main.py`` and catch the specific error when you call ``add_item``. Let's display on the box if it is locked. 15. Override the ``draw()`` method, call ``super()``. Don't forget to pass along the ``screen`` argument. Then use the lines below to display ``"locked"`` on the box: .. code-block:: python text = FONT.render("Locked", True, (0, 0, 0)) screen.blit(text, (self.x, self.y + self.height//2)) 16. Let's add a way to unlock the box. Becasue I instructed to make the ``locked`` attribute private, create a method called ``toggle_lock``. This will lock when it is unlocked, and unlock when it is locked. The cool thing is our ``main.py`` code doesn't have to worry about how this happens, all you have to do is call ``lockable_box.toggle_lock()`` and the box itself will take care of it. You can also make more realistic ``lock`` and ``unlock`` methods that can take a key as an argument, and depending on if the key matches, it will unlock or not. 17. In ``main.py`` we need to modify the event section under ``MOUSEBUTTONDOWN``. We want to be able to distinguish between a mouse right-click and a left click. Left-click will add items and right-click will toggle the lock. Because this is Pygame specific, I'll give you the code to replace the ``MOUSEBUTTONDOWN`` section of the event loop. .. code-block:: python elif event.type == MOUSEBUTTONDOWN: mouse_location = event.pos for box in boxes: if box.is_clicked(mouse_location): if event.button == 1: # left-click try: box.add_item(1) except BoxFullError: print("The box is full!") except BoxIsLockedError: print("The box is locked!") elif event.button == 3: # right-click # TOGGLE THE LOCK Confirm this works on the lockable box. Note: if you call ``.toggle_lock()`` on the regular box, you will get an ``AttributeError``. You can use ``try/except`` to handle it, or use ``if isinstance(box, LockableBox):`` to only call the method on the appropriate boxes.