CMU 15-112: Fundamentals of Programming and Computer Science
Class Notes: Event-Based Animations in Tkinter
Part 1: Getting Started with MVC


  1. Installing required modules (PIL/Pillow and Requests)
  2. Our First Example: A KeyPress Counter
  3. Model-View-Controller (MVC)
  4. Legal event.key values
  5. Moving a Dot with Key Presses
    1. Moving a Dot with Arrows
    2. Moving a Dot with Arrows and Bounds
    3. Moving a Dot with Arrows and Wraparound
    4. Moving a Dot in Two Dimensions
  6. Moving a Dot with Mouse Presses
  7. Moving a Dot with a Timer
  8. Pausing with a Timer
  9. MVC Violations
  10. Example: Grids (with modelToView and viewToModel)
  11. Example: Pong!

Notes:
  1. To run these examples, first download cmu_112_graphics.py and be sure it is in the same folder as the file you are running.
  2. That file has version numbers. As we release updates, be sure you are using the most-recent version!
  3. This is a relatively new animation framework (since Fall 2019), which means it may still have some bugs that we will fix as we go. Also, it is similar to previous semesters, but different in important ways. Be aware of this if you are reviewing previous semesters' materials (especially prior to Fall 2019)!
  4. As with Tkinter graphics, the examples here will not run using Brython in your browser.

  1. Installing required modules (PIL/Pillow and Requests)
    To use cmu_112_graphics.py, you need to have some modules installed. If they are not installed, you will see a message like this (or a similar one for "requests" instead of "PIL"):
    **********************************************************
    ** Cannot import PIL -- it seems you need to install pillow
    ** This may result in limited functionality or even a runtime error.
    **********************************************************
    
    You can try to use 'pip' to install the missing modules, but it can be complicated making sure you are installing these modules for the same version of Python that you are running. Here are some more-reliable steps that should work for you:

    Important Hint: in the steps below, you will use the terminal (on Mac) or command prompt (on Windows). In each case, this is not the terminal in VS Code!
    • To get a Command Prompt on Windows, hit the Windows key, and then type in 'command' (or just 'cmd').
    • To get a Terminal on a Mac, click on the Spotlight Search and type in 'terminal'.

    With that, here are the steps:

    • For Windows:
      1. Run this Python code block in your main Python file (it will print the commands you need to paste into your command prompt):
        import sys
        print(f'"{sys.executable}" -m pip install pillow')
        print(f'"{sys.executable}" -m pip install requests')
        
      2. Open Command Prompt as an administrator user (right click - run as administrator)
      3. Copy-paste each of the two commands printed in step 1 into the command prompt you opened in step 2.
      4. Close the command prompt and close Python.
      5. Re-open Python, and you're set (hopefully)!

    • For Mac or Linux:
      1. Run this Python code block in your main Python file (it will print the commands you need to paste into your command prompt):
        import sys
        print(f'sudo "{sys.executable}" -m pip install pillow')
        print(f'sudo "{sys.executable}" -m pip install requests')
        
      2. Open Terminal
      3. Copy-paste each of the two commands printed in step 1 into the command prompt you opened in step 2.
        • If you see a lock and a password is requested, type in the same password that you use to log into your computer.
      4. Close the terminal and close Python.
      5. Re-open Python, and you're set (hopefully)!

    If these steps do not work for you, please go to OH and we will be happy to assist.

  2. Our First Example: A KeyPress Counter
    from cmu_112_graphics import * def appStarted(app): app.counter = 0 def keyPressed(app, event): app.counter += 1 def redrawAll(app, canvas): canvas.create_text(app.width/2, app.height/2, text=f'{app.counter} keypresses', font='Arial 30 bold') runApp(width=400, height=400)

  3. Model-View-Controller (MVC)
    Note:
    1. We will write animations using the Model-View-Controller (MVC) paradigm.
    2. The model contains all the data we need for the animation. We can store the model in the app object's attributes.
      • In the example above, app.counter is our model.
    3. The view draws the app using the values in the model.
      • In the example above, redrawAll is our view.
    4. The controller responds to keyboard, mouse, timer and other events and updates the model.
      • In the example above, keyPressed is our controller.
    And...
    1. You never call the view or the controllers. The animation framework calls these for you.
      • In the example above, we never call redrawAll or keyPressed. They are called for us.
    2. Controllers can only update the model, they cannot update the view.
      • In the example above, keyPressed cannot call redrawAll.
    3. The view can never update the model.
      • In the example above, redrawAll cannot change app.counter or any other values in the model.
    4. If you violate these rules, it is called an MVC Violation. If that happens, your code will stop running and will display the runtime error for you.

  4. Legal event.key values
    # Note: Tkinter uses event.keysym for some keys, and event.char # for others, and it can be confusing how to use these properly. # Instead, cmu_112_graphics replaces both of these with event.key, # which simply works as expected in all cases. from cmu_112_graphics import * def appStarted(app): app.message = 'Press any key' def keyPressed(app, event): app.message = f"event.key == '{event.key}'" def redrawAll(app, canvas): canvas.create_text(app.width/2, 40, text=app.message, font='Arial 30 bold') keyNamesText = '''Here are the legal event.key names: * Keyboard key labels (letters, digits, punctuation) * Arrow directions ('Up', 'Down', 'Left', 'Right') * Whitespace ('Space', 'Enter', 'Tab', 'Backspace') * Other commands ('Delete', 'Escape')''' y = 80 for line in keyNamesText.splitlines(): canvas.create_text(app.width/2, y, text=line.strip(), font='Arial 20') y += 30 runApp(width=600, height=400)

  5. Moving a Dot with Key Presses

    1. Moving a Dot with Arrows
      from cmu_112_graphics import * def appStarted(app): app.cx = app.width/2 app.cy = app.height/2 app.r = 40 def keyPressed(app, event): if (event.key == 'Left'): app.cx -= 10 elif (event.key == 'Right'): app.cx += 10 def redrawAll(app, canvas): canvas.create_text(app.width/2, 20, text='Move dot with left and right arrows') canvas.create_oval(app.cx-app.r, app.cy-app.r, app.cx+app.r, app.cy+app.r, fill='darkGreen') runApp(width=400, height=400)

    2. Moving a Dot with Arrows and Bounds
      # This version bounds the dot to remain entirely on the canvas from cmu_112_graphics import * def appStarted(app): app.cx = app.width/2 app.cy = app.height/2 app.r = 40 def keyPressed(app, event): if (event.key == 'Left'): app.cx -= 10 if (app.cx - app.r < 0): app.cx = app.r elif (event.key == 'Right'): app.cx += 10 if (app.cx + app.r > app.width): app.cx = app.width - app.r def redrawAll(app, canvas): canvas.create_text(app.width/2, 20, text='Move dot with left and right arrows') canvas.create_text(app.width/2, 40, text='See how it is bounded by the canvas edges') canvas.create_oval(app.cx-app.r, app.cy-app.r, app.cx+app.r, app.cy+app.r, fill='darkGreen') runApp(width=400, height=400)

    3. Moving a Dot with Arrows and Wraparound
      # This version wraps around, so leaving one side enters the opposite side from cmu_112_graphics import * def appStarted(app): app.cx = app.width/2 app.cy = app.height/2 app.r = 40 def keyPressed(app, event): if (event.key == 'Left'): app.cx -= 10 if (app.cx + app.r <= 0): app.cx = app.width + app.r elif (event.key == 'Right'): app.cx += 10 if (app.cx - app.r >= app.width): app.cx = 0 - app.r def redrawAll(app, canvas): canvas.create_text(app.width/2, 20, text='Move dot with left and right arrows') canvas.create_text(app.width/2, 40, text='See how it uses wraparound on the edges') canvas.create_oval(app.cx-app.r, app.cy-app.r, app.cx+app.r, app.cy+app.r, fill='darkGreen') runApp(width=400, height=400)

    4. Moving a Dot in Two Dimensions
      # This version moves in both x and y dimensions. from cmu_112_graphics import * def appStarted(app): app.cx = app.width/2 app.cy = app.height/2 app.r = 40 def keyPressed(app, event): if (event.key == 'Left'): app.cx -= 10 elif (event.key == 'Right'): app.cx += 10 elif (event.key == 'Up'): app.cy -= 10 elif (event.key == 'Down'): app.cy += 10 def redrawAll(app, canvas): canvas.create_text(app.width/2, 20, text='Move dot with up, down, left, and right arrows') canvas.create_oval(app.cx-app.r, app.cy-app.r, app.cx+app.r, app.cy+app.r, fill='darkGreen') runApp(width=400, height=400)

  6. Moving a Dot with Mouse Presses
    from cmu_112_graphics import * def appStarted(app): app.cx = app.width/2 app.cy = app.height/2 app.r = 40 def mousePressed(app, event): app.cx = event.x app.cy = event.y def redrawAll(app, canvas): canvas.create_text(app.width/2, 20, text='Move dot with mouse presses') canvas.create_oval(app.cx-app.r, app.cy-app.r, app.cx+app.r, app.cy+app.r, fill='darkGreen') runApp(width=400, height=400)

  7. Moving a Dot with a Timer
    from cmu_112_graphics import * def appStarted(app): app.cx = app.width/2 app.cy = app.height/2 app.r = 40 def timerFired(app): app.cx -= 10 if (app.cx + app.r <= 0): app.cx = app.width + app.r def redrawAll(app, canvas): canvas.create_text(app.width/2, 20, text='Watch the dot move!') canvas.create_oval(app.cx-app.r, app.cy-app.r, app.cx+app.r, app.cy+app.r, fill='darkGreen') runApp(width=400, height=400)

  8. Pausing with a Timer
    Pausing and stepping are super helpful when debugging animations!
    from cmu_112_graphics import * def appStarted(app): app.cx = app.width/2 app.cy = app.height/2 app.r = 40 app.paused = False def timerFired(app): if (not app.paused): doStep(app) def doStep(app): app.cx -= 10 if (app.cx + app.r <= 0): app.cx = app.width + app.r def keyPressed(app, event): if (event.key == 'p'): app.paused = not app.paused elif (event.key == 's') and app.paused: doStep(app) def redrawAll(app, canvas): canvas.create_text(app.width/2, 20, text='Watch the dot move!') canvas.create_text(app.width/2, 40, text='Press p to pause or unpause') canvas.create_text(app.width/2, 60, text='Press s to step while paused') canvas.create_oval(app.cx-app.r, app.cy-app.r, app.cx+app.r, app.cy+app.r, fill='darkGreen') runApp(width=400, height=400)

  9. MVC Violations

    1. Cannot change the model while drawing the view
      from cmu_112_graphics import * def appStarted(app): app.x = 0 def redrawAll(app, canvas): canvas.create_text(app.width/2, 20, text='This has an MVC Violation!') app.x = 10 # This is an MVC Violation! # We cannot change the model from the view (redrawAll) runApp(width=400, height=400)

    2. Once again, but with a mutable value (such as a list)
      # Since this version modifies a mutable value in the model, # the exception does not occur immediately on the line of the change, # but only after redrawAll has entirely finished. from cmu_112_graphics import * def appStarted(app): app.L = [ ] def redrawAll(app, canvas): canvas.create_text(app.width/2, 20, text='This also has an MVC Violation!') app.L.append(42) # This is an MVC Violation! # We cannot change the model from the view (redrawAll) runApp(width=400, height=400)

  10. Example: Grids (with modelToView and viewToModel)
    from cmu_112_graphics import * def appStarted(app): app.rows = 4 app.cols = 8 app.margin = 5 # margin around grid app.selection = (-1, -1) # (row, col) of selection, (-1,-1) for none def pointInGrid(app, x, y): # return True if (x, y) is inside the grid defined by app. return ((app.margin <= x <= app.width-app.margin) and (app.margin <= y <= app.height-app.margin)) def getCell(app, x, y): # aka "viewToModel" # return (row, col) in which (x, y) occurred or (-1, -1) if outside grid. if (not pointInGrid(app, x, y)): return (-1, -1) gridWidth = app.width - 2*app.margin gridHeight = app.height - 2*app.margin cellWidth = gridWidth / app.cols cellHeight = gridHeight / app.rows # Note: we have to use int() here and not just // because # row and col cannot be floats and if any of x, y, app.margin, # cellWidth or cellHeight are floats, // would still produce floats. row = int((y - app.margin) / cellHeight) col = int((x - app.margin) / cellWidth) return (row, col) def getCellBounds(app, row, col): # aka "modelToView" # returns (x0, y0, x1, y1) corners/bounding box of given cell in grid gridWidth = app.width - 2*app.margin gridHeight = app.height - 2*app.margin cellWidth = gridWidth / app.cols cellHeight = gridHeight / app.rows x0 = app.margin + col * cellWidth x1 = app.margin + (col+1) * cellWidth y0 = app.margin + row * cellHeight y1 = app.margin + (row+1) * cellHeight return (x0, y0, x1, y1) def mousePressed(app, event): (row, col) = getCell(app, event.x, event.y) # select this (row, col) unless it is selected if (app.selection == (row, col)): app.selection = (-1, -1) else: app.selection = (row, col) def redrawAll(app, canvas): # draw grid of cells for row in range(app.rows): for col in range(app.cols): (x0, y0, x1, y1) = getCellBounds(app, row, col) fill = "orange" if (app.selection == (row, col)) else "cyan" canvas.create_rectangle(x0, y0, x1, y1, fill=fill) canvas.create_text(app.width/2, app.height/2 - 15, text="Click in cells!", font="Arial 26 bold", fill="darkBlue") runApp(width=400, height=400)

  11. Example: Pong!
    # 112_pong.py # This is a simplified version of Pong, one of the earliest # arcade games. We have kept it simple for learning purposes. from cmu_112_graphics import * def appStarted(app): # This is a Controller app.waitingForKeyPress = True resetApp(app) def resetApp(app): # This is a helper function for Controllers # This initializes most of our model (stored in app.xyz) # This is called when they start the app, and also after # the game is over when we restart the app. app.timerDelay = 50 # milliseconds app.dotsLeft = 2 app.score = 0 app.paddleX0 = 20 app.paddleX1 = 40 app.paddleY0 = 20 app.paddleY1 = 80 app.margin = 5 app.paddleSpeed = 10 app.dotR = 15 app.gameOver = False app.paused = False resetDot(app) def resetDot(app): # This is a helper function for Controllers # Get the dot ready for the next round. Move the dot to # the center of the screen and give it an initial velocity. app.dotCx = app.width//2 app.dotCy = app.height//2 app.dotDx = -10 app.dotDy = -3 def movePaddleDown(app): # This is a helper function for Controllers # Move the paddle down while keeping it inside the play area dy = min(app.paddleSpeed, app.height - app.margin - app.paddleY1) app.paddleY0 += dy app.paddleY1 += dy def movePaddleUp(app): # This is a helper function for Controllers # Move the paddle up while keeping it inside the play area dy = min(app.paddleSpeed, app.paddleY0 - app.margin) app.paddleY0 -= dy app.paddleY1 -= dy def keyPressed(app, event): # This is a Controller if app.gameOver: resetApp(app) elif app.waitingForKeyPress: app.waitingForKeyPress = False app.dotsLeft -= 1 elif (event.key == 'Down'): movePaddleDown(app) elif (event.key == 'Up'): movePaddleUp(app) elif (event.key == 'p'): app.paused = not app.paused elif (event.key == 's') and app.paused: doStep(app) def timerFired(app): # This is a Controller if (not app.paused): doStep(app) def doStep(app): # This is a helper function for Controllers # The dot should move only when we are not waiting for # a key press or in the game-over state if not app.waitingForKeyPress and not app.gameOver: moveDot(app) def dotWentOffLeftSide(app): # This is a helper function for Controllers # Called when the dot went off the left side of the screen, # so the round is over. If there are no dots left, then # the game is over. if app.dotsLeft == 0: app.gameOver = True else: app.waitingForKeyPress = True resetDot(app) def dotIntersectsPaddle(app): # This is a helper function for Controllers # Check if the dot intersects the paddle. To keep this # simple here, we will only test that the center of the dot # is inside the paddle. We could be more precise here # (that's an interesting exercise!). return ((app.paddleX0 <= app.dotCx <= app.paddleX1) and (app.paddleY0 <= app.dotCy <= app.paddleY1)) def moveDot(app): # This is a helper function for Controllers # Move the dot by the current velocity (dotDx and dotDy). # Then handle all the special cases: # * bounce the dot if it went off the top, right, or bottom # * bounce the dot if it went off the paddle # * lose the round (or the game) if it went off the left side app.dotCx += app.dotDx app.dotCy += app.dotDy if (app.dotCy + app.dotR >= app.height): # The dot went off the bottom! app.dotCy = app.height - app.dotR app.dotDy = -app.dotDy elif (app.dotCy - app.dotR <= 0): # The dot went off the top! app.dotCy = app.dotR app.dotDy = -app.dotDy if (app.dotCx + app.dotR >= app.width): # The dot went off the right! app.dotCx = app.width - app.dotR app.dotDx = -app.dotDx elif dotIntersectsPaddle(app): # The dot hit the paddle! app.score += 1 # hurray! app.dotDx = -app.dotDx app.dotCx = app.paddleX1 dToMiddleY = app.dotCy - (app.paddleY0 + app.paddleY1)/2 dampeningFactor = 3 # smaller = more extreme bounces app.dotDy = dToMiddleY / dampeningFactor elif (app.dotCx - app.dotR <= 0): # The dot went off the left side dotWentOffLeftSide(app) def drawAppInfo(app, canvas): # This is a helper function for the View # This draws the title, the score, and the dots left font = 'Arial 18 bold' title ='112 Pong!' canvas.create_text(app.width/2, 20, text=title, font=font) canvas.create_text(app.width-70, 20, text=f'Score: {app.score}', font=font) canvas.create_text(app.width-70, app.height-20, text=f'Dots Left: {app.dotsLeft}', font=font) def drawPaddle(app, canvas): # This is a helper function for the View canvas.create_rectangle(app.paddleX0, app.paddleY0, app.paddleX1, app.paddleY1, fill='black') def drawDot(app, canvas): # This is a helper function for the View cx, cy, r = app.dotCx, app.dotCy, app.dotR canvas.create_oval(cx-r, cy-r, cx+r, cy+r, fill='black') def drawGameOver(app, canvas): # This is a helper function for the View canvas.create_text(app.width/2, app.height/2, text='Game Over!', font='Arial 18 bold') canvas.create_text(app.width/2, app.height/2 + 50, text='Press any key to restart', font='Arial 16 bold') def drawPressAnyKey(app, canvas): # This is a helper function for the View canvas.create_text(app.width/2, app.height/2, text='Press any key to start!', font='Arial 18 bold') def redrawAll(app, canvas): # This is the View drawAppInfo(app, canvas) drawPaddle(app, canvas) if app.gameOver: drawGameOver(app, canvas) elif app.waitingForKeyPress: drawPressAnyKey(app, canvas) else: drawDot(app, canvas) def main(): # This runs the app runApp(width=400, height=300) if __name__ == '__main__': main()