[파이썬 게임 프로그래밍 공부] 4. 벽돌깨기. 게임 물리 구현하기.

Alegruz
2017-09-03 23:52
조회수 9259

이제 좀 게임을 만들 준비가 된 것 같습니다. 그럼 이제 어떤 갓겜을 만들면 좋을까요? 벽돌깨기(Breakout)는 어때요?

BreakOut arcadeflyer.png

벽돌깨기는 1976년 5월 13일에 발매된 아타리에서 제작한 아케이드 게임입니다. 이 게임은 스티브 워즈니악과 스티브 잡스가 제작한 전설적인 아케이드 게임인 퐁에 많은 영향을 받았습니다. 벽돌깨기는 비디오 게임 역사에 있어 초창기 게임들 중 하나입니다. 오래전 게임인만큼, 우리도 만들 수 있을 것 같은데, 우리도 아타리의 직원이 되어 우리만의 게임을 만들어볼까요?


자, 이제 이 게임이 어떤 게임이며, 무엇이 필요한지 등을 생각해봅시다. 먼저 게임의 외관을 분석해보죠. 보니 세 가지의 대표적인 게임 오브젝트들이 있군요. 밑에 돌아다니는 판, 공, 그리고 벽돌이 있는 것 같습니다. 편의상 판때기, 공, 벽돌로 부르죠. 판때기는 플레이어가 직접 조종을 해서 공을 튕기는 용도로 쓰입니다. 공은 이러한 튕김을 통해 벽돌과 부딪혀 벽돌을 부수죠. 벽돌은 이러한 부숴짐을 통해 플레이어에게 점수를 부여합니다. 만약 공이 하단으로 떨어진다면, 일정 수치의 생명력이 깎입니다. 만약 생명력이 0이 된다면, 게임은 끝나고 플레이어는 패배합니다. 만약 모든 벽돌을 다 깼다면, 게임이 플레이어의 승리와 함께 끝이 나게 됩니다.

이제 대충 게임이 어떻게 돌아가는지 알았으니, 본격적으로 제작을 해볼까요? 우선 우리는 게임 오브젝트를 캔버스에 그릴 줄 알고, 키보드 입력을 특정 함수와 엮어 판때기를 움직이게 만들 수 있습니다. 이제 필요한건, 공이 여기저기 튕기게 하는 것과, 이 공이 벽돌과 닿으면, 벽돌이 사라지게 만드는 것입니다. 순서대로 해보죠.

우선, 판때기를 만들어봅시다.

Paddle 클래스는 반드시 GameObject 클래스를 부모로 가져야 합니다. canvas, x, y를 입력값으로 받아야 하구요..


class Paddle(GameObject):
    def __init__(self, canvas, x, y):
        self.width = 80
        self.height = 10
        item = canvas.create_rectangle(x - self.width / 2,
                                       y - self.height / 2,
                                       x + self.width / 2,
                                       y + self.height / 2,
                                       fill='blue')
        super(Paddle, self).__init__(canvas, item)


근데 문제가 한 가지 있습니다. 판때기가 캔버스에서 벗어나면 안돼요. 이 코드를 좀 바꿔봅시다.

논리의 흐름은 다음과 같습니다.

  1. 판때기의 현재 좌표 (x1, y1, x2, y2) 를 구한다.
  2. 0 <= x1 & x2 <= width 인지 확인한다.
  3. 그러하다면, 움직이게 만든다.

이 논리를 코드로 짜봅시다. 우선, get_position 이라는 새로운 메소드를 다른 오브젝트들도 좌표를 구할 일이 있을테니, GameObject 클래스 안에 생성해줍시다. 


def get_position(self):
    return self.canvas.coords(self.item)


이제 move 메소드를 판때기 클래스에 만듭시다.


def move(self, velocity):
    coords = self.get_position()
    width = self.canvas.winfo_width()
    if coords[0] >= 0 and coords[2] <= width: #coords == [x1, y1, x2, y2]
        super(Paddle, self).move(velocity, 0)


근데 문제가 있어요. 만약 x1이 0과 같을 때 왼쪽 방향키를 누르게 된다면, 왼쪽으로 움직일테고, 그 순간 x1이 0보다 작으므로 move 메소드는 작동하지 않을 겁니다. 그렇기에 velocity라는 변수를 만들어서 제한을 두죠. 이 변수를 x1과 x2에 더해주어 왼쪽인지 오른쪽인지 미리 판단하게 만들어 이를 방지할 수 있을 겁니다.



def move(self, velocity):
    coords = self.get_position()
    width = self.canvas.winfo_width()
    if coords[0] + velocity>= 0 and coords[2] + velocity<= width:
        super(Paddle, self).move(velocity, 0)


이제 판때기는 정상적으로 움직일겁니다.

판때기의 움직임을 구현했으니, 공의 튕김을 구현해봅시다. 우선 directionspeed라는 새로운 변수를 Ball 클래스에 생성해줍시다.


self.direction = [1, -1]
self.speed = 10


direction 리스트는 마치 직교좌표계처럼 (0, 0)가 좌상단 구석의 좌표고, x의 양의 방향은 오른쪽, y의 양의 방향은 아래쪽입니다. 즉, [=1, =1]는 좌상단 방향이고, [1, -1] 는 우상단 방향일겁니다.


마지막으로, 2편에서 했던 것처럼, 벽돌을 제작해봅시다.


class Brick(GameObject):
    def __init__(self, canvas, x, y):
        self.width = 75
        self.height = 20
        color = '#999999'
        item = canvas.create_rectangle(x - self.width / 2,
                                       y - self.height / 2,
                                       x + self.width / 2,
                                       y + self.height / 2,
                                       fill=color, tags='brick')
        super(Brick, self).__init__(canvas, item)


캔버스 위에 추가해줍시다.


self.brick = Brick(self.canvas, self.width / 2, 50)


공을 튕기게 만드려면, 우선 공이 어디에 닿았는지, 즉 충돌 여부를 먼저 판별해야 합니다. 한 번 차근차근해보죠.

오브젝트들의 충돌여부를 체크하는 방법은 간단합니다. 좌표를 비교해보면 돼요. 이거를 좀 더 간단하게 만든게 canvas위에 쓰일 find_overlapping이라는 메소드입니다. 이 메소드를 Game 클래스에 생성해봅시다.

논리의 흐름은 다음과 같습니다.

  1. 어떤 오브젝트들이 충돌하고 있는지 확인한다.
  2. 충돌이 일어난다면, 이 충돌 데이터를 items라는 변수에 저장한다.
  3. 이 충돌이 일어난 오브젝트들이 게임 오브젝트인지 확인한다.
  4. 그러하다면 충돌이 일어난 것으로 판단한다.

이 논리를 완성하기 위해, 게임 오브젝트인지 확인하는데에 도움이 될 items라는 딕셔너리 변수를 만들어서 여기에 게임 오브젝트들의 데이터를 저장해줍시다.


self.items = {}
self.items[self.paddle.item] = self.paddle
self.items[self.brick.item] = self.brick


이제 위의 논리의 흐름에 따라 check_collision이라는 새로운 메소드를 만들 수 있게 되었습니다.


def check_collisions(self):
    ball_coords = self.ball.get_position()
    items = self.canvas.find_overlapping(*ball_coords)
    objects = [self.items[x] for x in items if x in self.items]
    self.ball.collide(items)


이제 충돌 여부는 알았으니, 충돌 후 무엇이 일어나는지를 프로그래밍해봅시다. 우선 필요한 것은, 어디에서 충돌이 일어났는지를 알아야합니다. 위 아래에서 충돌이 일어났으면 좌우로 공이 튕길 것이고, 옆에서 충돌이 일어났으면 상하로 공이 튕길 것입니다. 이것은 판때기나 벽돌의 x1, x2 좌표와 공의 중심의 x좌표의 대소 비교를 통해 가능합니다. 만약 공의 x좌표가 x1과 x2 사이에 있다면 위 아래에서 충돌이 일어난 것이고, x좌표가 x1보다 작다면 왼쪽, x2보다 크다면 오른쪽에서 충돌이 일어난 것이다. 이는 다음과 같이 프로그래밍 가능하다.


def collide(self, game_objects):
    coords = self.get_position()
    x = (coords[0] + coords[2]) * 0.5
    if len(game_objects) >= 1:  # 충돌이 발생했을 때.
        game_object = game_objects[0]
        coords = game_object.get_position()
        if x > coords[2]:
            self.direction[0] = 1
        elif x < coords[0]:
            self.direction[0] = -1
        else:
            self.direction[1] *= -1


이 메소드를 게임이 실행되는 내내 사용해야 하기 때문에, 게임 루프를 생성할 필요가 있습니다. 그렇기에 Game 클래스 안에 game_loop라는 메소드를 제작해줍시다. 이 루프가 필요로 하는 것은, 어떤 메소드를 계속해서 실행시킬 것이고, 이 실행 빈도, 혹은 fps를 얼마로 설정하느냐입니다. 여기선 check_collision 메소드를 계속해서 실행시킬 것이고, 이 빈도는 50 밀리세컨드로 설정하겠습니다.


def game_loop(self):
    self.check_collisions()
    self.after(50, self.game_loop)


이제 벽돌과 판때기와의 충돌 프로그래밍은 끝났습니다. 이제 캔버스의 경계와의 충돌과 공의 지속적인 움직임을 프로그래밍할 차례군요.

공이 경계에 닿았을 때 튕기도록 하고 지속적으로 움직임을 유지하도록 논리의 흐름을 짜봅시다.

  1. 공의 좌표 (x1, y1, x2, y2) 를 구한다.
  2. 0 >= x1 or x2 >= width 인지 확인한다.
  3. 그러하다면, 공의 direction의 x성분의 부호를 바꿔준다.
  4. y1 <= 0 인지 확인한다.
  5. 그러하다면, 공의 direction의 y성분의 부호를 바꿔준다.
  6. speed를 각각 성분에 곱해주어 move 메소드에 사용한다.


이제 위의 논리에 따라 update라는 메소드를 Ball 클래스에 제작해봅시다.


def update(self):
    coords = self.get_position()
    width = self.canvas.winfo_width()
    if coords[0] <= 0 or coords[2] >= width:
        self.direction[0] *= -1
    if coords[1] <= 0:
        self.direction[1] *= -1
    x = self.direction[0] * self.speed
    y = self.direction[1] * self.speed
    self.move(x, y)


위의 다른 메소들처럼, 이 메소드도 계속해서 실행되어야하기에, 루프에 넣어줍시다.


self.ball.update()


이제 루프를 직접적으로 실행시켜야하므로, Game 클래스의 __init__ 메소드에 루프를 실행시켜줍시다.


self.game_loop()


이제 프로그램을 실행시켜보면, 공이 판때기나 벽돌이나 경계에 닿으면 튕길 것입니다.


마지막으로 남은 벽돌 깨는 현상을 제작해봅시다. 우선 Brick 클래스에 hits라는 입력값을 추가해봅시다.


def __init__(self, canvas, x, y, hits):
    self.width = 75
    self.height = 20
    self.hits = hits
    color = '#999999'
    item = canvas.create_rectangle(x - self.width / 2,
                                   y - self.height / 2,
                                   x + self.width / 2,
                                   y + self.height / 2,
                                   fill=color, tags='brick')
    super(Brick, self).__init__(canvas, item)


self.brick = Brick(self.canvas, self.width / 2, 50, 1)


이제 필요한 건 메소드 두 가지입니다. delete 메소드와 hit 메소드입니다. delete 메소드는 GameObject 클래스에서 작성합니다.


def delete(self):
    self.canvas.delete(self.item)


hit 메소드는 간단합니다. 충돌이 발생했을 때, hits 변수를 1만큼 줄입니다. hits가 0이 되면, 벽돌을 삭제합니다.


def hit(self):
    self.hits -= 1
    if self.hits == 0:
        self.delete()


충돌 발생을 확인해야하기 때문에, collide 메소드에 공이 벽돌과 충돌했는지 안했는지 확인을 합니다. 충돌을 했으면, hit 메소드가 실행이 되도록 합니다.


for game_object in game_objects:
    if isinstance(game_object, Brick):
        game_object.hit()


마지막으로 한 가지 더 할 일이 있습니다. 충돌은 여러 개의 벽돌과 일어날 수 있기 때문이죠. 이러한 충돌이 위 아래에서만 일어난다고 합시다. collide 메소드를 수정해봅시다.



if len(game_objects) > 1:
    self.direction[1] *= -1
elif len(game_objects) == 1:
    game_object = game_objects[0]
    coords = game_object.get_position()
    if x > coords[2]:
        self.direction[0] = 1
    elif x < coords[0]:
        self.direction[0] = -1
    else:
        self.direction[1] *= -1


이제 기본적인 게임의 메커니즘과 물리가 완성되었습니다.


import tkinter as tk

class Game(tk.Frame):
    def __init__(self, master):
        super(Game, self).__init__(master)
        self.width = 610
        self.height= 400
        self.canvas = tk.Canvas(self, bg = '#aaaaaa', width = self.width, height = self.height)
        self.canvas.pack()
        self.pack()
        self.ball = Ball(self.canvas, self.width/2, 310)

        self.items = {}
        self.paddle = Paddle(self.canvas, self.width/2, 326)
        self.items[self.paddle.item] = self.paddle
        self.brick = Brick(self.canvas, self.width / 2, 50, 1)
        self.items[self.brick.item] = self.brick


        self.game_loop()
        self.canvas.focus_set()
        self.canvas.bind('<Left>',
                         lambda _: self.paddle.move(-10))
        self.canvas.bind('<Right>',
                         lambda _: self.paddle.move(10))

    def game_loop(self):
        self.check_collisions()
        self.ball.update()
        self.after(50, self.game_loop)

    def check_collisions(self):
        ball_coords = self.ball.get_position()
        items = self.canvas.find_overlapping(*ball_coords)
        objects = [self.items[x] for x in items if x in self.items]
        self.ball.collide(objects)


class GameObject:
    def __init__(self, canvas, item):
        self.canvas = canvas
        self.item = item

    def get_position(self):
        return self.canvas.coords(self.item)

    def move(self, x, y):
        self.canvas.move(self.item, x, y)

    def delete(self):
        self.canvas.delete(self.item)


class Paddle(GameObject):
    def __init__(self, canvas, x, y):
        self.width = 80
        self.height = 10
        item = canvas.create_rectangle(x - self.width / 2,
                                       y - self.height / 2,
                                       x + self.width / 2,
                                       y + self.height / 2,
                                       fill='blue')
        super(Paddle, self).__init__(canvas, item)

    def move(self, velocity):
        coords = self.get_position()
        width = self.canvas.winfo_width()
        if coords[0] + velocity >= 0 and coords[2] + velocity <= width: #coords == [x1, y1, x2, y2]
            super(Paddle, self).move(velocity, 0)

class Ball(GameObject):
    def __init__(self, canvas, x, y):
        self.radius = 10
        self.direction = [1, -1]
        self.speed = 10
        item = canvas.create_oval(x-self.radius, y-self.radius,
                                  x+self.radius, y+self.radius,
                                  fill='white')
        super(Ball, self).__init__(canvas, item)

    def update(self):
        coords = self.get_position()
        width = self.canvas.winfo_width()
        if coords[0] <= 0 or coords[2] >= width:
            self.direction[0] *= -1
        if coords[1] <= 0:
            self.direction[1] *= -1
        x = self.direction[0] * self.speed
        y = self.direction[1] * self.speed
        self.move(x, y)


    def collide(self, game_objects):
        coords = self.get_position()
        x = (coords[0] + coords[2]) * 0.5
        if len(game_objects) > 1:
            self.direction[1] *= -1
        elif len(game_objects) == 1:
            game_object = game_objects[0]
            coords = game_object.get_position()
            if x > coords[2]:
                self.direction[0] = 1
            elif x < coords[0]:
                self.direction[0] = -1
            else:
                self.direction[1] *= -1

        for game_object in game_objects:
            if isinstance(game_object, Brick):
                game_object.hit()


class Brick(GameObject):
    def __init__(self, canvas, x, y, hits):
        self.width = 75
        self.height = 20
        self.hits = hits
        color = '#999999'
        item = canvas.create_rectangle(x - self.width / 2,
                                       y - self.height / 2,
                                       x + self.width / 2,
                                       y + self.height / 2,
                                       fill=color, tags='brick')
        super(Brick, self).__init__(canvas, item)

    def hit(self):
        self.hits -= 1
        if self.hits == 0:
            self.delete()

if __name__ == '__main__':
    root = tk.Tk()
    root.title('Game Title')
    game = Game(root)
    game.mainloop()



6 1
이 코드로 실행이 잘되던가요?