본문 바로가기

프로그래밍/Love2D

Love2D(LÖVE)로 플랫포머 게임 개발해보자! (3) 클래스

.......

 

텅텅 비어버린 잔디

Q. 유기했나요?

A. 아닙니다.

Q. 그럼 글 왜 안 올려요?

A. 좀.... 바빴습니다... 첫 독립 시작해서 이삿짐 옮기고, 이것저것 적응 좀 하느라 개발은커녕 평소 작업하던 것도 전부 락이 걸렸습니다.

잡설은 이쯤 하고, 바로 내용정리 들어가자.

 

클래스 도입

클래스는 쉽게 설명하면 코드를 좀 더 깔끔하게 정리하기 위해 메소드와 변수들을 모아놓은 일종의 템플릿이라고 할 수 있다. 쉽게 비유하면 공구와 나사, 볼트가 담겨있는 상자 정도로 해석해도 괜찮을지도 모르겠다. 또한 제가 참고했던 강좌에서는 건물의 설계도면에 빗대어 설명하기도 했다. 설계도면을 참고하여 다양한 건축물을 만들 수 있기 때문이다.

 

하지만 lua는 기본적으로 클래스를 지원하지 않는다. 그래서 love2d 또한 클래스를 사용할 수는 없다.... 만! 클래스 비슷하게 흉내를 낼 수는 있다.

https://raw.githubusercontent.com/rxi/classic/master/classic.lua

위 링크의 코드 전문을 복사한 뒤, classic.lua의 형태로 프로젝트 폴더에 저장해 준다. 그리고 아래와 같이 코드를 작성하면 클래스를 사용할 수 있다.

-- main.lua
function love.load()
    Object=require "classic"
end

 

짠! 이제 본격적으로 클래스를 파보자.

 

클래스

먼저, 사각형을 만드는 클래스를 한번 만들어보자. 저는 StudyAboutClass 폴더에 rectangle.lua를 생성했다. 이후 rectangle.lua는 아래와 같이 코드를 작성해 줬다.

-- rectangle.lua
Rectangle=Object.extend(Object)

function Rectangle.new(self)
    self.test=math.random(1,1000)

코드 설명부터 하나하나 짚고 가자.

Rectangle=Object.extend(Object)를 선언했는데, 이건 main.lua에서 먼저 require 된 classic을 상속하겠다는 뜻이다. 상속에 대해서는 나중에 설명할 테니 일단 그렇고나 하고 넘어가자. 대충 이걸 써야만 클래스로 사용할 수 있다. 여기서 클래스명은 Rectangle이 된다.

 

다음으로 Rectangle.new(self)는 이 클래스가 객체로 선언됐을 때 초기화될 부분을 작성한다. 이 부분에 필요한 프로퍼티나 함수를 선언해 주면 된다. 기존 C#에서의 생성자 적는 거랑 비슷한 느낌인 듯.

 

그리고 self.test=math.random(1,1000)은 프로퍼티를 초기화해 주는 부분이다. 여기서는 1에서 1000 사이의 랜덤한 숫자를 저장해 줬다.

 

이렇게 Rectangle 클래스를 만들어냈다. 축하한다. 그럼 이걸 어떻게 사용하는가? main.lua에서 사용할 거면 아래와 같이 코드를 작성해 주자.

-- main.lua
function love.load()
    Object=require "classic"
    require "StudyAboutClass.rectangle"

    r1=Rectangle()
    r2=Rectangle()

    print(r1.test, r2.test)
end

그리고 이를 실행하면?

사진과 같이 랜덤한 두 숫자가 제대로 출력되는 모습을 확인할 수 있다.

개발자가 임의로 생성한 클래스는 별도의 초기화 없이 require "파일 경로"만 작성해 주면 어떻게든 클래스를 사용할 수 있게 된다. 객체도 객체랄 것도 없이 변수=클래스명()의 형태로 선언만 해주면 이후는 클래스의 코드수정 없이 클래스의 기능을 자유롭게 사용할 수 있다.

 

조금 더 응용을 해보자. 기왕 rectangle로 선언을 했으니, 도형을 만들 줄은 알아야 하지 않겠는가? rectangle.lua를 아래와 같이 수정하자.

--rectangle.lua
Rectangle=Object.extend(Object)

function Rectangle.new(self)
    self.test=math.random(1,1000)
    self.x=100
    self.y=100
    self.width=200
    self.height=50
    self.speed=100
end

function Rectangle.update(self, dt)
    self.x=self.x+self.speed*dt
end

function Rectangle.draw(self)
    love.graphics.rectangle("line", self.x, self.y, self.width, self.height)
end

Rectangle.new까지는 이전 설명의 응용이니 넘어가고, 아래에 Rectangle.update(self, dt)와 Rectangle.draw(self)라는 함수가 추가됐다. 이들은 각각 update문과 draw문에서 실행할 코드를 선언하고 있고, 둘 다 클래스명.함수명(self, 기타 매개변수)의 규칙으로 작성되었다. 이렇게 객체가 사용하면 좋겠다 싶은 함수들은 위의 규칙에 맞게 클래스 내에 함수를 선언하면 된다. 이러면 객체가 클래스 내 함수를 온전히 사용할 수 있을 것이다.

Q. 근데 왜 update와 draw를 선언했나요? 걍 love.update나 love.draw로 선언하면 바로 작동하지 않나요?

A. 작동하지 않는다.

늘 잊지 말 것. 클래스는 어디까지나 템플릿일 뿐, 실제 작동하는 코드는 아니다. 우리가 보증금 내고 월세 꼬박꼬박 내면서 원룸 설계도 위에 나앉아 살 수 있는 건 아니지 않은가?

 

다음으로 main.lua입니다.

--main.lua
function love.load()
    Object=require "classic"
    require "StudyAboutClass.rectangle"
    
    r1=Rectangle()
end

function love.update(dt)
    r1.update(r1, dt)
end

function love.draw()
    r1.draw(r1)
end

이러면 아마 실행결과는 아래와 같이 나올 거다.

위의 코드를 잘 살펴보시면 객체 r1에서 r1.update() 함수를 호출하면서 self 자리에 r1 객체를 다시 넣어준 것이 눈에 띈다. r1.draw()도 마찬가지다. 파이썬에서는 self에 딱히 뭘 넣어도 되지 않았는데, 여기서는 일일이 사용하고자 하는 객체를 다시 매개변수로 넘겨줘야 한다! 너무 불편하지 않은가? 그럴 땐 함수를 선언할 때 . 대신 :를 활용하면 해결된다. 아래와 같이 말이다.

--main.lua
function love.load()
    Object=require "classic"
    require "StudyAboutClass.rectangle"
    
    r1=Rectangle()
end

function love.update(dt)
    r1:update(dt)
end

function love.draw()
    r1:draw()
end

훨씬 보기 편해졌다. 하지만 이것도 상황에 따라 적절히 활용할 수 있으니 둘 다 알아두는 편이 좋을 듯싶겠다.

여담이지만 이렇게 self를 생략할 수 있게끔 코드를 수정하는걸 Syntactic Sugar라고 표현한다고 한다. 코드의 가독성을 높이도록 코드를 수정한다는 뜻인데, 참 만국 공통으로 코드 길이 줄이고 가독성 높이는데 혈안이 되어있는 듯하다. 물론 스파게티마냥 꼬여있고 어제 막 꾼 꿈마냥 이해하기 어려운 코드를 날림으로 쓰는 것보다야 낫지만.

 


이번에는 객체를 선언하면서 매개변수를 받을 수 있게 해 볼 것이다. 긴말 말고 바로 코드부터 작성해 보자.

-- rectangle.lua
Rectangle=Object.extend(Object)

function Rectangle:new(x, y, width, height)   
    self.x=x
    self.y=y
    self.width=width
    self.height=height
    self.speed=100
end

function Rectangle:update(dt)
    self.x=self.x+self.speed*dt
end

function Rectangle:draw()
    love.graphics.rectangle("line", self.x, self.y, self.width, self.height)
end

여기서도 .대신 :를 사용하여 self를 생략한 건 소소한 포인트. 당연하게도 클래스에서 매개변수를 받아내어 초기화를 진행할 수도 있다. 장점은 역시 코드의 가변성 향상.

이후 main.lua에서는 객체를 아래와 같이 선언해 주면 된다.

 

--main.lua
function love.load()
    Object=require "classic"
    require "StudyAboutClass.rectangle"
    
    r1=Rectangle(100, 100, 200, 50)
end

function love.update(dt)
    r1:update(dt)
end

function love.draw()
    r1:draw()
end

이러면 좀 더 자유롭게 사각형을 생성할 수 있을 것이다.

 

그럼 여기서 생각해 보자. 사각형만 만들어야 하는가? 원을 만들 수도 있지 않은가? 그럼 원을 만들려면 지금 힘들게 작성한 rectangle.lua를 다시 갈아엎어야 하는가?

어... 생각해 보니 rectangle.lua를 갈아엎긴 해야 할 것이다. 하지만 그걸 갈아엎고 원에 대한 내용으로 코드를 다시 꾸밀 바에는 차라리 공통된 정보를 클래스로 만들고, 이를 부모로서 끌어 쓸 수 있다면?

 

소개하겠다. 상속이다.

 

상속

상속은 객체 지향 프로그래밍에서 주로 활용되는 개념이다. 부모 클래스를 상속시켜, 자식 클래스에서 부모 클래스의 프로퍼티를 활용하는 것이다. 이건... 앞서 설명한 설계도의 예시를 빌리자면, 기존 건축물 설계도에 개집을 추가하든 마당을 추가하든 기본적인 집의 형태에 무언가 더 추가된 설계도를 새로 쪄내는 것이다. 그리고 그 새로운 설계도를 기반으로 집을 만들 수 있고, 아니면 순수히 집만 있는 설계도로도 집을 만들 수 있을 것이다. 그런 느낌?

필자는 사진과 같이 파일을 2개 더 생성해 줬다. 원에 대한 정보를 담을 클래스 circle.lua와 도형 자체의 정보를 담을 shape.lua를 말이다.

 

먼저 shape.lua부터 작성해 보자.

-- shape.lua
Shape=Object:extend()

function Shape:new(x, y)
    self.x=x
    self.y=y
    self.speed=100
end

function Shape:update(dt)
    self.x=self.x+self.speed*dt
end

여기까진 드라마틱한 변화는 없는 것 같다. 단순히 Shape 클래스를 생성했으니 말이다. 그럼 이제 이를 상속할 circle.lua를 아래와 같이 작성해 보자.

 

-- circle.lua
Circle=Shape:extend()

function Circle:new(x, y, radius)
    Circle.super.new(self, x, y)
    self.radius=radius
end

function Circle:draw()
    love.graphics.circle("line", self.x, self.y, self.radius)
end

좀 생소한 내용이 여럿 보인다. 핵심 위주로 하나하나 짚어보자.

 

Circle=Shape:extend() ==> Circle라는 클래스는 Shape 클래스의 상속을 받는다는 뜻이다.  이를 통해 Circle 클래스는 Shape클래스의 프로퍼티에도 접근할 수 있게 된다. 지금껏 클래스를 선언할 때 Object:extend()를 사용한 것도 이 때문. 클래스들은 모두 classic.lua의 상속을 받고 있는 것이다.

 

Circle.super.new(self, x, y) ==> 클래스명에 super가 붙으면 그 클래스가 상속받고 있는 부모에 접근하게 된다. 즉, 해당 코드는 Circle의 부모인 Shape의 new 함수를 선언하겠다는 뜻이 된다.

 

self.radius=radius ==> 원은 가로/세로 길이가 필요하지 않기 때문에 반지름 길이만 선언했다. rectangle.lua와 달리 상속을 톡톡히 받고 있음을 보여주는 부분이다.

 

circle.lua까지 작성했으면 거의 다 왔다. 이제 main.lua를 다음과 같이 작성해 주면 된다.

-- main.lua
function love.load()
    Object=require "classic"
    require "StudyAboutClass.rectangle"
    require "StudyAboutClass.shape"
    require "StudyAboutClass.circle"

    r1=Rectangle(100, 100, 200, 50)
    r2=Rectangle(350, 80, 25, 140)
    r3=Circle(350, 80, 40)

    print(r1.test, r2.test)
end

function love.update(dt)
    r1.update(r1, dt)
    r3:update(dt)
end

function love.draw()
    r1:draw()
    r2:draw()
    r3:draw()
end

실행 결과는 아래와 같다.

 


이쯤 되면 이제 게임 개발에 대한 준비는 충분히 마친 것 같다. 다음 강좌부터는 이미지 불러오기와 물리엔진부터 시작하여 플랫포머게임을 차근차근 만들어보고자 한다. 순수 코딩만으로 게임을 만든다니, 벌써부터 기대가 된다. 그럼 다음을 위해 아디오스-