3
\$\begingroup\$

The goal of each frame of the animation to be the same length as the one before it, regardless of the amount of processing that takes place as the frame is being built.

This is a little animation demo that I wrote to show off the basics of a game loop in Python. With a newbies eyes in mind I have tried to keep to a limited tool kit; avoid pygame and threading and sticking to tkinter (included with most installations) to make the code as clear and as easy to understand as possible.

The end result works, but I am dissatisfied with the pacing on the game loop (onTimer). "After" isn't really to tool for the job.

In addition to being open to general comments, I am looking for suggestions to make the animation smoother and less at the whims of "after" and background event processing.

#Bounce

import tkinter
import time
import math
import random


#--- Initialize Globla Variables ----------------------------------------------
winWid = 640    #window size
winHei = 480
frameRate = 20  #length of a frame (include delay) in millesconds
frameDelay = 0  #the delay at the end of the current frame
frameStart = 0  #when the current frame started

balldiameter = 7
ballShape = []
ballX = []
ballY = []
spdX = []
spdY = []
ballCnt = 0
addTimer = time.perf_counter()

#--- Function/Tookkit list ----------------------------------------------------

def onTimer():
    global timerhandle, frameStart, frameDelay, addTimer
    
    #An animation frame is the work being done to draw/update a frame,
    #  plus the delay between the frames.  As the work to draw the
    #  frame goes up, the delay between the frames goes down
    elapsedTime = round((time.perf_counter() - frameStart)* 1000)  #time from start of frame until now
    frameDelay = frameRate - elapsedTime   #delay for this frame is the frame size, minus how long it took to process this frame
    if frameDelay < 1: frameDelay = 1  #bottom out with a single millesecond delay
    frameStart = time.perf_counter()   #start a new frame

    #if the frame delay hasn't bottomed out and a half second has passed
    if (time.perf_counter() - addTimer) > 0.25 and frameDelay > 1:
        addTimer = time.perf_counter()  #update the add time
        addBall()
        window.title("FD:" + str(frameDelay) + " - " + str(ballCnt))
        
    moveTheBalls()  #update the position of the balls
    
    timerhandle = window.after(frameDelay,onTimer) #fill update rest of this frame with a delay

    
def onShutdown():
    window.after_cancel(timerhandle)
    window.destroy()
    
def addBall():
    global ballCnt
    newX = random.randrange(0,winWid)
    newY = random.randrange(0,winHei)
    color = randomColor()
    ballShape.append(canvas.create_oval(newX,newY, newX+balldiameter,newY+balldiameter, outline=color, fill=color))
    ballX.append(newX)
    ballY.append(newY)
    spdX.append((random.random() * 2)-1)
    spdY.append((random.random() * 2)-1)
    ballCnt = ballCnt + 1

def moveTheBalls():
    for i in range(0,ballCnt):     #for each ball
        ballX[i] = ballX[i] + spdX[i]    #update its position
        ballY[i] = ballY[i] + spdY[i]
        for j in range(i+1,ballCnt):     #check for colision between other balls
            dist = math.sqrt(( (ballX[i]+(balldiameter/2)) - (ballX[j]+(balldiameter/2)))**2 + ( (ballY[i]+(balldiameter/2)) - (ballY[j]+(balldiameter/2)))**2) 
            if dist < balldiameter:      #if the balls are inside each other
                hold = spdX[i]           #swap their directions
                spdX[i] = spdX[j]
                spdX[j] = hold
                hold = spdY[i]
                spdY[i] = spdY[j]
                spdY[j] = hold
        if ballX[i] < 0 or ballX[i] > winWid-balldiameter: spdX[i] = spdX[i] * -1   #top or bottom? reverse directions
        if ballY[i] < 0 or ballY[i] > winHei-balldiameter: spdY[i] = spdY[i] * -1   #left or right? reverse directions
        canvas.coords(ballShape[i], (ballX[i], ballY[i], ballX[i]+balldiameter, ballY[i]+balldiameter))
    
#Random color - This is a helper function
#Returns a random color.
#Usage c4 = randomcolor()
def randomColor():
    hexDigits = ["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"]
    rclr = "#"
    for i in range(0,6):
        rclr = rclr + hexDigits[random.randrange(0,len(hexDigits))]
    return rclr


#--- Main Program - Executes as soon as you hit run  --------------------------
  
window = tkinter.Tk()   #Sets the window
canvas = tkinter.Canvas(window, width=winWid, height=winHei, bg="white")
canvas.pack()

addBall()

timerhandle = window.after(20,onTimer)  #start the game loop
window.protocol("WM_DELETE_WINDOW",onShutdown)  #provide a graceful exit
window.mainloop()   #Start the GUI
\$\endgroup\$
1
\$\begingroup\$

Typos

Globla -> Global

Tookkit -> Toolkit?

millesconds -> milliseconds

Nomenclature

By the PEP8 standard, your functions and variables would be called

win_width
win_height
ball_diameter
ball_count
on_timer

etc.

And this is minor, but a name like moveTheBalls by convention would not include an article, thus move_balls.

String interpolation

"FD:" + str(frameDelay) + " - " + str(ballCnt)

can be

f'FD: {frame_delay} - {ball_count}'

Range default

range(0,ballCnt)

does not need the 0 since that is default.

Swap via tuple unpacking

            hold = spdX[i]           #swap their directions
            spdX[i] = spdX[j]
            spdX[j] = hold

does not need a temporary variable:

speed_x[i], speed_x[j] = speed_x[j], speed_x[i]

Random hexadecimal value

randomColor needs some re-thinking. hexDigits needs to go away completely. Instead, calculate a single random number between 0 and 16**6, and then format it as a hex string.

In-place increment

ballCnt = ballCnt + 1

can be

ball_count += 1

Globals and entry points

Everything at Main Program and beyond needs to be in a method; and you should attempt to reduce your dependence on global variables. These constants:

winWid = 640    #window size
winHei = 480
frameRate = 20  #length of a frame (include delay) in millesconds

can stay there (and should be capitalized); but everything else should not. For example, onTimer can be a method on a class whose member variables give it context.

"Graceful" exit

Given the nature of the application I find it odd to call after_cancel. Typically it's only appropriate to do this if there's something the user could lose on exit, such as unsaved data; but that's not the case here so I'd get rid of it.

Factor-less times

I recommend that instead of storing everything in milliseconds, only store fractional seconds - since that's what your performance counters use - and only convert to a non-base-multiple of milliseconds when passing to window.after.

Encapsulation

There is a lot of work - particularly in moveTheBalls - being done to balls from the outside, with all of their attributes split into multiple variables. This is a perfect situation for an OOP representation of a ball with all of its attributes, and methods to do the physics.

Physics

Your swap their directions is not a correct model of two circular objects that undergo elastic collision. The correct model takes into account the normal vector formed by the alignment of the balls when they contact.

Also, your physics will be made easier by tracking centre coordinates and radii, instead of upper-left and lower-right coordinates.

Suggested

This is equivalent in some ways to your code, and a significant departure in others - it attempts to be more careful with collision and overlapping physics, and is object-oriented. I have also increased the ball diameter to 20 to better see the effect of the new collision model.

# Bounce
from math import sqrt
from tkinter import Tk, Canvas
from random import randrange, random
from time import perf_counter
from typing import List, Tuple

WIN_WIDTH = 640     # pixels
WIN_HEIGHT = 480
BALL_DIAMETER = 20   # pixels
BALL_RADIUS = BALL_DIAMETER/2

FRAME_RATE = 0.020  # length of a frame (including delay) in fractional seconds
ADD_DELAY = 0.250   # ball adding delay, fractional seconds
MIN_FRAME = 0.001   # fractional seconds


def random_colour() -> str:
    return f'#{randrange(0x1000000):06X}'


class Ball:
    def __init__(self, canvas: Canvas):
        self.x = randrange(WIN_WIDTH)
        self.y = randrange(WIN_HEIGHT)
        self.colour = random_colour()
        self.shape = canvas.create_oval(
            *self.coords, outline=self.colour, fill=self.colour,
        )
        self.speed_x = 2*random() - 1
        self.speed_y = 2*random() - 1

    @property
    def coords(self) -> Tuple[float, ...]:
        return (
            self.x - BALL_RADIUS,
            self.y - BALL_RADIUS,
            self.x + BALL_RADIUS,
            self.y + BALL_RADIUS,
        )

    def move_x(self) -> None:
        self.x += self.speed_x

        if self.x < BALL_RADIUS:
            self.x = BALL_RADIUS
        elif self.x > WIN_WIDTH - BALL_RADIUS:
            self.x = WIN_WIDTH - BALL_RADIUS
        else:
            return

        self.speed_x = -self.speed_x

    def move_y(self) -> None:
        self.y += self.speed_y

        if self.y < BALL_RADIUS:
            self.y = BALL_RADIUS
        elif self.y > WIN_HEIGHT - BALL_RADIUS:
            self.y = WIN_HEIGHT - BALL_RADIUS
        else:
            return

        self.speed_y = -self.speed_y

    def move(self):
        self.move_x()
        self.move_y()

    def distance2_from(self, other: 'Ball') -> float:
        return (
            (self.x - other.x) ** 2 +
            (self.y - other.y) ** 2
        )

    def collides_with(self, other: 'Ball') -> bool:
        return self.distance2_from(other) < BALL_DIAMETER**2

    def deoverlap(self, other: 'Ball', nx: float, ny: float) -> None:
        # Push objects away if they overlap, along the normal
        dist2 = nx*nx + ny*ny
        if dist2 < BALL_DIAMETER*BALL_DIAMETER:
            f = BALL_RADIUS / sqrt(dist2)
            xm, ym = (other.x + self.x)/2, (other.y + self.y)/2
            xd, yd = f*nx, f*ny
            self.x, other.x = xm - xd, xm + xd
            self.y, other.y = ym - yd, ym + yd

    def collide(self, other: 'Ball') -> None:
        # Assume a fully-elastic collision and equal ball masses. Follow
        # https://imada.sdu.dk/~rolf/Edu/DM815/E10/2dcollisions.pdf

        # Normal vector and magnitude. Magnitude assumed to be
        # BALL_DIAMETER and is enforced.
        nx, ny = other.x - self.x, other.y - self.y
        un = BALL_DIAMETER

        # Unit normal and tangent vectors
        unx, uny = nx/un, ny/un
        utx, uty = -uny, unx

        self.deoverlap(other, nx, ny)

        # Initial velocities
        v1x, v1y = self.speed_x, self.speed_y
        v2x, v2y = other.speed_x, other.speed_y

        # Projected to normal and tangential components
        v1n = v1x*unx + v1y*uny
        v2n = v2x*unx + v2y*uny
        v1t = v1x*utx + v1y*uty
        v2t = v2x*utx + v2y*uty

        # New tangential velocities are equal to the old ones;
        # New normal velocities swap
        v1n, v2n = v2n, v1n

        # Back to vectors
        v1nx, v1ny = v1n*unx, v1n*uny
        v2nx, v2ny = v2n*unx, v2n*uny
        v1tx, v1ty = v1t*utx, v1t*uty
        v2tx, v2ty = v2t*utx, v2t*uty

        # Convert from normal/tangential back to xy
        self.speed_x, self.speed_y = v1nx + v1tx, v1ny + v1ty
        other.speed_x, other.speed_y = v2nx + v2tx, v2ny + v2ty


class Game:
    def __init__(self):
        self.window = Tk()  # Sets the window
        self.canvas = Canvas(self.window, width=WIN_WIDTH, height=WIN_HEIGHT, bg="white")
        self.canvas.pack()

        self.balls: List[Ball] = []

        self.frame_delay: float = 0  # the delay at the end of the current frame; fractional seconds
        self.frame_start: float = 0  # when the current frame started; fractional seconds
        self.add_timer: float  # fractional seconds
        self.timer_handle: str

    def run(self) -> None:
        self.add_timer = perf_counter()
        self.timer_handle = self.window.after(20, self.on_timer)  # start the game loop
        self.window.mainloop()  # Start the GUI

    @property
    def ball_count(self) -> int:
        return len(self.balls)

    def on_timer(self) -> None:
        # An animation frame is the work being done to draw/update a frame,
        #  plus the delay between the frames.  As the work to draw the
        #  frame goes up, the delay between the frames goes down
        elapsed = perf_counter() - self.frame_start  # time from start of frame until now

        # delay for this frame is the frame size, minus how long it took to process this frame
        # bottom out with a single millisecond delay
        self.frame_delay = max(MIN_FRAME, FRAME_RATE - elapsed)
        self.frame_start = perf_counter()  # start a new frame

        # if the frame delay hasn't bottomed out and a half second has passed
        if (perf_counter() - self.add_timer) > ADD_DELAY and self.frame_delay > MIN_FRAME:
            self.add_timer = perf_counter()  # update the add time
            self.add_ball()
            self.window.title(f"FD: {1e3*self.frame_delay:.0f}ms - {self.ball_count}")

        self.move_balls()  # update the position of the balls

        # fill update rest of this frame with a delay
        self.timer_handle = self.window.after(
            round(1000*self.frame_delay),
            self.on_timer,
        )

    def add_ball(self) -> None:
        self.balls.append(Ball(self.canvas))

    def move_balls(self) -> None:
        for ball in self.balls:
            ball.move()

            for other in self.balls:
                if other is ball:
                    continue
                if other.collides_with(ball):
                    other.collide(ball)

            self.canvas.coords(ball.shape, ball.coords)


def main():
    Game().run()


if __name__ == '__main__':
    main()
\$\endgroup\$
1
  • \$\begingroup\$ Wow... that is a very in depth analysis. Thank you. Items are on point if this was production code, at the college level, or if the topic was OOP. For high school students in an intro course, things like class structure, underscores in variable names, and statements such as: f"FD: {1e3*self.frame_delay:.0f}ms - {self.ball_count}, will send them away screaming. This is also why I kept the physics simple. I am all for teaching best practices and developing good habits, but if you don't keep the basics, simple and easy to follow, students won't stick around long enough to develop any habits. \$\endgroup\$ – codingCat Apr 11 at 16:23

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.