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
asked Apr 8, 2021 at 17:51
\$\endgroup\$

1 Answer 1

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()
answered Apr 10, 2021 at 23:06
\$\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\$ Commented Apr 11, 2021 at 16:23

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.