6
\$\begingroup\$

Continuation of this post

I wrote a program in pygame that basically acts as a physics engine for a ball. You can hit the ball around and your strokes are counted, as well as an extra stroke for going out of bounds. Recently, I added a few things like air resistance, and rewrote my movement physics to make it easier to bounce. I'm wondering if the physics approach is good. Also, is there any way to convert everything into SI units? Right now, my air drag is an arbitrary value.

import math
import pygame as pg


class Colors:

    BLACK = (0, 0, 0)
    WHITE = (255, 255, 255)
    RED = (255, 0, 0)
    GREEN = (0, 255, 0)
    BLUE = (0, 0, 255)

    YELLOW = (255, 255, 0)
    GOLD = (255, 215, 0)
    GRAY = (100, 100, 100)

    NIGHT =  (20, 24, 82)
    DAY = (135, 206, 235)
    MOON = (245, 243, 206)
    SMOKE = (96, 96, 96)



class Constants:

    SCREEN_WIDTH = 1500
    SCREEN_HEIGHT = 800
    WINDOW_COLOR = Colors.NIGHT

    TICKRATE = 60
    GAME_SPEED = .35

    LINE_COLOR = Colors.GOLD
    ALINE_COLOR = Colors.GOLD

    X_BOUNDS_BARRIER = 1
    Y_BOUNDS_BARRIER = 1
    BOUNCE_FUZZ = 0

    START_X = int(.5 * SCREEN_WIDTH)
    START_Y = int(.99 * SCREEN_HEIGHT)

    AIR_DRAG = .3
    GRAVITY = 9.80665


class Fonts:

    pg.font.init()
    strokeFont = pg.font.SysFont("monospace", 50)
    STROKECOLOR = Colors.YELLOW

    powerFont = pg.font.SysFont("arial", 15, bold=True)
    POWERCOLOR = Colors.GREEN

    angleFont = pg.font.SysFont("arial", 15, bold=True)
    ANGLECOLOR = Colors.GREEN

    penaltyFont = pg.font.SysFont("georgia", 40, bold=True)
    PENALTYCOLOR = Colors.RED

    toggleBoundsFont = pg.font.SysFont("geneva", 20)
    TOGGLEBOUNDSCOLOR = Colors.RED

    resistMultiplierFont = pg.font.SysFont("courier new", 13)
    RESISTMULTIPLIERCOLOR = Colors.RED

    powerMultiplierFont = pg.font.SysFont("courier new", 13)
    POWERMULTIPLIERCOLOR = Colors.RED


class Ball(object):
    def __init__(self, x, y, dx = 0, dy = 0, bounce = .8, radius = 10, color=Colors.SMOKE, outlinecolor=Colors.RED, density=1):
        self.color = color
        self.outlinecolor = outlinecolor
        self.x = x
        self.y = y
        self.dx = dx
        self.dy = dy
        self.ax = 0
        self.ay = Constants.GRAVITY
        self.dt = Constants.GAME_SPEED
        self.bounce = bounce
        self.radius = radius
        self.mass = 4/3 * math.pi * self.radius**3 * density

    def show(self, window):
        pg.draw.circle(window, self.outlinecolor, (int(self.x), int(self.y)), self.radius)
        pg.draw.circle(window, self.color, (int(self.x), int(self.y)), self.radius - int(.4 * self.radius))

    def update(self, update_frame):
        update_frame += 1

        self.vx += self.ax * self.dt
        self.vy += self.ay * self.dt

        if resist_multiplier:
            drag = 6*math.pi * self.radius * resist_multiplier * Constants.AIR_DRAG
            air_resist_x = -drag * self.vx / self.mass
            air_resist_y = -drag * self.vy / self.mass

            self.vx += air_resist_x/self.dt
            self.vy += air_resist_y/self.dt

        self.x += self.vx * self.dt
        self.y += self.vy * self.dt

        bounced, stop, shoot = False, False, True

        # Top & Bottom
        if self.y + self.radius > Constants.SCREEN_HEIGHT:
            self.y = Constants.SCREEN_HEIGHT - self.radius
            self.vy = -self.vy
            bounced = True
            print('    Bounce!')

        if self.y - self.radius < Constants.Y_BOUNDS_BARRIER:
            self.y = Constants.Y_BOUNDS_BARRIER + self.radius
            self.vy = -self.vy
            bounced = True
            print('    Bounce!')

        # Speed/Resistance Rectangles
        if (self.x >= .875*Constants.SCREEN_WIDTH + self.radius) and (self.y + self.radius >= .98*Constants.SCREEN_HEIGHT):
            self.x = .88*Constants.SCREEN_WIDTH + self.radius
            self.y = .98*Constants.SCREEN_HEIGHT - self.radius
            self.x = .87*Constants.SCREEN_WIDTH + self.radius
            self.vy, self.vx = -self.vy, -2 * abs(self.vx)
            bounced = True

        if (self.x <= .1175*Constants.SCREEN_WIDTH + self.radius) and (self.y + self.radius >= .98*Constants.SCREEN_HEIGHT):
            self.x = .118*Constants.SCREEN_WIDTH + self.radius
            self.y = .98*Constants.SCREEN_HEIGHT - self.radius
            self.x = .119*Constants.SCREEN_WIDTH + self.radius
            self.vy, self.vx = -self.vy, 2 * abs(self.vx)
            bounced = True

        if x_bounded:
            if (self.x - self.radius < Constants.X_BOUNDS_BARRIER):
                self.x = Constants.X_BOUNDS_BARRIER + self.radius
                self.vx = -self.vx
                bounced = True

            if (self.x + self.radius > Constants.SCREEN_WIDTH - Constants.X_BOUNDS_BARRIER):
                self.x = Constants.SCREEN_WIDTH - Constants.X_BOUNDS_BARRIER - self.radius
                self.vx = -self.vx
                bounced = True

        if self.vx > 1000:
            self.vx = 1000
            self.y = Constants.SCREEN_HEIGHT/4

        if bounced:
            self.vx *= self.bounce
            self.vy *= self.bounce

        print(f'\n    Update Frame: {update_frame}',
               '        x-pos: %spx' % round(self.x),
               '        y-pos: %spx' % round(self.y),
               '        x-vel: %spx/u' % round(self.vx),
               '        y-vel: %spx/u' % round(self.vy),
               sep='\n', end='\n\n')

        return update_frame, shoot, stop

    @staticmethod
    def quadrant(x, y, xm, ym):
        if ym < y and xm > x:
            return 1
        elif ym < y and xm < x:
            return 2
        elif ym > y and xm < x:
            return 3
        elif ym > y and xm > x:
            return 4
        else:
            return False


def draw_window():
    clock.tick(Constants.TICKRATE)

    window.fill(Constants.WINDOW_COLOR)

    resist_multiplier_text = 'Air Resistance: {:2.2f} m/s'.format(resist_multiplier)
    resist_multiplier_label = Fonts.resistMultiplierFont.render(resist_multiplier_text, 1, Fonts.RESISTMULTIPLIERCOLOR)
    pg.draw.rect(window, Colors.BLACK, (.8875*Constants.SCREEN_WIDTH, .98*Constants.SCREEN_HEIGHT, Constants.SCREEN_WIDTH, Constants.SCREEN_HEIGHT))
    pg.draw.arrow(window, Colors.MOON, Colors.GREEN, (.8875*Constants.SCREEN_WIDTH, .99*Constants.SCREEN_HEIGHT), (.88*Constants.SCREEN_WIDTH, .99*Constants.SCREEN_HEIGHT), 3, 3)
    pg.draw.arrow(window, Colors.MOON, Colors.GREEN, (Constants.SCREEN_WIDTH, .975*Constants.SCREEN_HEIGHT), (.88*Constants.SCREEN_WIDTH, .975*Constants.SCREEN_HEIGHT), 3)
    window.blit(resist_multiplier_label, (.8925*Constants.SCREEN_WIDTH, .98*Constants.SCREEN_HEIGHT))

    power_multiplier_text = f'Swing Strength: {int(power_multiplier*100)}%'
    power_multiplier_label = Fonts.powerMultiplierFont.render(power_multiplier_text, 1, Fonts.POWERMULTIPLIERCOLOR)
    pg.draw.rect(window, Colors.BLACK, (0, .98*Constants.SCREEN_HEIGHT, .1125*Constants.SCREEN_WIDTH, Constants.SCREEN_HEIGHT))
    pg.draw.arrow(window, Colors.MOON, Colors.GREEN, (.1125*Constants.SCREEN_WIDTH, .99*Constants.SCREEN_HEIGHT), (.12*Constants.SCREEN_WIDTH, .99*Constants.SCREEN_HEIGHT), 3, 3)
    pg.draw.arrow(window, Colors.MOON, Colors.GREEN, (0, .975*Constants.SCREEN_HEIGHT), (.12*Constants.SCREEN_WIDTH, .975*Constants.SCREEN_HEIGHT), 3)
    window.blit(power_multiplier_label, (.005*Constants.SCREEN_WIDTH, .98*Constants.SCREEN_HEIGHT))

    if not shoot:
        pg.draw.arrow(window, Constants.ALINE_COLOR, Constants.ALINE_COLOR, aline[0], aline[1], 5)
        pg.draw.arrow(window, Constants.LINE_COLOR, Constants.LINE_COLOR, line[0], line[1], 5)

    stroke_text = 'Strokes: %s' % strokes
    stroke_label = Fonts.strokeFont.render(stroke_text, 1, Fonts.STROKECOLOR)
    if not strokes:
        window.blit(stroke_label, (Constants.SCREEN_WIDTH - .21 * Constants.SCREEN_WIDTH, Constants.SCREEN_HEIGHT - .985 * Constants.SCREEN_HEIGHT))
    else:
        window.blit(stroke_label, (Constants.SCREEN_WIDTH - (.21+.02*math.floor(math.log10(strokes))) * Constants.SCREEN_WIDTH, Constants.SCREEN_HEIGHT - .985 * Constants.SCREEN_HEIGHT))

    power_text = 'Shot Strength: %sN' % power_display
    power_label = Fonts.powerFont.render(power_text, 1, Fonts.POWERCOLOR)
    if not shoot: window.blit(power_label, (cursor_pos[0] + .008 * Constants.SCREEN_WIDTH, cursor_pos[1]))

    angle_text = 'Angle: %s°' % angle_display
    angle_label = Fonts.angleFont.render(angle_text, 1, Fonts.ANGLECOLOR)
    if not shoot: window.blit(angle_label, (ball.x - .06 * Constants.SCREEN_WIDTH, ball.y - .01 * Constants.SCREEN_HEIGHT))

    if penalty:
        penalty_text = f'Out of Bounds! +1 Stroke'
        penalty_label = Fonts.penaltyFont.render(penalty_text, 1, Fonts.PENALTYCOLOR)
        penalty_rect = penalty_label.get_rect(center=(Constants.SCREEN_WIDTH/2, .225*Constants.SCREEN_HEIGHT))
        window.blit(penalty_label, penalty_rect)

        toggle_bounds_text = "Use [b] to toggle bounds"
        toggle_bounds_label = Fonts.toggleBoundsFont.render(toggle_bounds_text, 1, Fonts.TOGGLEBOUNDSCOLOR)
        toggle_bounds_rect = toggle_bounds_label.get_rect(center=(Constants.SCREEN_WIDTH/2, .275*Constants.SCREEN_HEIGHT))
        window.blit(toggle_bounds_label, toggle_bounds_rect)

    ball.show(window)

    pg.display.flip()


def angle(cursor_pos):
    x, y, xm, ym = ball.x, ball.y, cursor_pos[0], cursor_pos[1]
    if x-xm:
        angle = math.atan((y - ym) / (x - xm))
    elif y > ym:
        angle = math.pi/2
    else:
        angle = 3*math.pi/2

    q = ball.quadrant(x,y,xm,ym)
    if q: angle = math.pi*math.floor(q/2) - angle

    if round(angle*deg) == 360:
        angle = 0

    if x > xm and not round(angle*deg):
        angle = math.pi

    return angle


def arrow(screen, lcolor, tricolor, start, end, trirad, thickness=2):
    pg.draw.line(screen, lcolor, start, end, thickness)
    rotation = (math.atan2(start[1] - end[1], end[0] - start[0])) + math.pi/2
    pg.draw.polygon(screen, tricolor, ((end[0] + trirad * math.sin(rotation),
                                        end[1] + trirad * math.cos(rotation)),
                                       (end[0] + trirad * math.sin(rotation - 120*rad),
                                        end[1] + trirad * math.cos(rotation - 120*rad)),
                                       (end[0] + trirad * math.sin(rotation + 120*rad),
                                        end[1] + trirad * math.cos(rotation + 120*rad))))
setattr(pg.draw, 'arrow', arrow)


def distance(x, y):
    return math.sqrt(x**2 + y**2)


def update_values(quit, rkey, skey, shoot, xb, yb, strokes, x_bounded):
    for event in pg.event.get():
        if event.type == pg.QUIT:
            quit = True

        if event.type == pg.KEYDOWN:
            if event.key == pg.K_ESCAPE:
                quit = True

            if event.key == pg.K_RIGHT:
                if rkey != max(resist_dict):
                    rkey += 1

            if event.key == pg.K_LEFT:
                if rkey != min(resist_dict):
                    rkey -= 1

            if event.key == pg.K_UP:
                if skey != max(strength_dict):
                    skey += 1

            if event.key == pg.K_DOWN:
                if skey != min(strength_dict):
                    skey -= 1

            if event.key == pg.K_b:
                x_bounded = not x_bounded

            if event.key == pg.K_q:
                rkey = min(resist_dict)
                skey = max(strength_dict)
                x_bounded = True

            if event.key == pg.K_e:
                rkey = max(resist_dict)
                skey = max(strength_dict)
                x_bounded = False


        if event.type == pg.MOUSEBUTTONDOWN:
            if not shoot:
                shoot, stop = True, False
                strokes, xb, yb = hit_ball(strokes)

    return quit, rkey, skey, shoot, xb, yb, strokes, x_bounded


def hit_ball(strokes):
    x, y = ball.x, ball.y
    xb, yb = ball.x, ball.y
    power = power_multiplier/4 * distance(line_ball_x, line_ball_y)
    print('\n\nBall Hit!')
    print('\npower: %sN' % round(power, 2))
    ang = angle(cursor_pos)
    print('angle: %s°' % round(ang * deg, 2))
    print('cos(a): %s' % round(math.cos(ang), 2)), print('sin(a): %s' % round(math.sin(ang), 2))

    ball.vx, ball.vy = power * math.cos(ang), -power * math.sin(ang)

    strokes += 1

    return strokes, xb, yb


def initialize():
    pg.init()
    pg.display.set_caption('Golf')
    window = pg.display.set_mode((Constants.SCREEN_WIDTH, Constants.SCREEN_HEIGHT))
    pg.event.set_grab(True)
    pg.mouse.set_cursor((8, 8), (0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0))

    return window


rad, deg = math.pi/180, 180/math.pi
x, y, power, ang, strokes = [0]*5
xb, yb = None, None
shoot, penalty, stop, quit, x_bounded = [False]*5
p_ticks, update_frame = 0, 0

ball = Ball(Constants.START_X, Constants.START_Y)

clock = pg.time.Clock()

strength_dict = {0: .01, 1: .02, 2: .04, 3: .08, 4: .16, 5: .25, 6: .50, 7: .75, 8: 1}; skey = 6
resist_dict = {0: 0, 1: .01, 2: .02, 3: .03, 4: .04, 5: .05, 6: .1, 7: .2, 8: .3, 9: .4, 10: .5, 11: .6, 12: .7,
               13: .8, 14: .9, 15: 1, 16: 1.25, 17: 1.5, 18: 1.75, 19: 2, 20: 2.5, 21: 3, 22: 3.5, 23: 4, 24: 4.5,
               25: 5}; rkey = 7


if __name__ == '__main__':

    window = initialize()
    while not quit:
        power_multiplier = strength_dict[skey]
        resist_multiplier = resist_dict[rkey]

        seconds = (pg.time.get_ticks()-p_ticks)/1000
        if seconds > 1.2: penalty = False

        cursor_pos = pg.mouse.get_pos()
        line = [(ball.x, ball.y), cursor_pos]
        line_ball_x, line_ball_y = cursor_pos[0] - ball.x, cursor_pos[1] - ball.y

        aline = [(ball.x, ball.y), (ball.x + .015 * Constants.SCREEN_WIDTH, ball.y)]

        if not shoot:
            power_display = round(
                distance(line_ball_x, line_ball_y) * power_multiplier/5)

            angle_display = round(angle(cursor_pos) * deg)

        else:
            if stop or (abs(ball.vy) < 5 and abs(ball.vx) < 1 and abs(ball.y - (Constants.START_Y - 2)) <= Constants.BOUNCE_FUZZ):
                shoot = False
                #ball.y = Constants.START_Y
                print('\nThe ball has come to a rest!')
                update_frame = 0
            else:
                update_frame, shoot, stop = ball.update(update_frame)

            if not Constants.X_BOUNDS_BARRIER < ball.x < Constants.SCREEN_WIDTH:
                shoot = False
                print(f'\nOut of Bounds! Pos: {round(ball.x), round(ball.y)}')
                penalty = True
                p_ticks = pg.time.get_ticks()
                strokes += 1

                if Constants.X_BOUNDS_BARRIER < xb < Constants.SCREEN_WIDTH:
                    ball.x = xb
                else:
                    ball.x = Constants.START_X
                ball.y = yb

        quit, rkey, skey, shoot, xb, yb, strokes, x_bounded = update_values(quit, rkey, skey, shoot, xb, yb, strokes, x_bounded)

        draw_window()

    print("\nShutting down...")
    pg.quit()
\$\endgroup\$
  • \$\begingroup\$ You are using 5 different fonts. Does that really look good? And why monospace and courier new? Either be generic or specific. \$\endgroup\$ – Roland Illig Apr 17 at 3:19
  • \$\begingroup\$ I like the look, but you’re free to run it and tell me your opinion. \$\endgroup\$ – Alec A Apr 17 at 3:19
  • \$\begingroup\$ Why do you divide by self.dt for the air resistance? I would have expected multiplication \$\endgroup\$ – Hagen von Eitzen Apr 17 at 9:27
  • \$\begingroup\$ air_resist is acceleration, so I divide by time to get velocity \$\endgroup\$ – Alec A Apr 17 at 16:04
  • \$\begingroup\$ Not even a bounty is getting this post any attention lol... \$\endgroup\$ – Alec A Apr 21 at 0:07
4
+50
\$\begingroup\$

For the physics:

Given that you don't have bodies that are constantly interacting, updating position and velocity based only on the previous step is fine. Be wary of doing this with lots of interacting objects if you decide to add more balls. Because you can't reasonably update simultaneously, you get issues with divergence. Again though, for this game it should be fine.

Be aware that in most games, physics is tweaked; a simple example being bouncing not being parabolic (instead, objects tend to fall faster than they rise). Players are used to behaviour like this and games can feel weird if objects behave as they would in the real world.

For converting to SI units:

You would need to ensure that ALL properties are SI (or unitless). If, for example, your dimensions are based on pixels, not metres, you would need to scale by some pixel:metre conversion. I suspect having things like density just being 1 rather than the actual density of a golf ball are causing issues. This is probably why gravity feels a little "heavy".

Using purely SI units may be a bit of a stretch. Accurately mimicking properties like friction may be a little over the top for a simple game. I would suggest it's a good idea to use real physical formulae, then tweak some constants and add scale factors.

I won't comment on the code style as I can see people have done that in the past.

EDIT:

Thinking about it a bit more, you're currently storing position and velocity with a precision of 1 pixel. It's not unusual for the actual values to be near 1 pixel (e.g. vx = 1 +/- 1). This will create issues where velocity will decay far faster than it should when it is close to zero. I would change it so that your ball physics properties are stored separately to its display properties. Update the physics, then draw accordingly.

\$\endgroup\$
  • \$\begingroup\$ ball.x and ball.y are floats. They're not defined to 1px precision \$\endgroup\$ – Alec A Apr 23 at 22:57
  • \$\begingroup\$ ahh my mistake, I was reading the print readout in the terminal. \$\endgroup\$ – HoboProber Apr 24 at 8:24
  • \$\begingroup\$ how is physics tweaked in most games? \$\endgroup\$ – Alec A Apr 24 at 23: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.