렌파이에서 미니게임을 구현해야 해서...ㅋㅋㅋㅋㅋ
렌파이 쿡북을 뒤져서 공룡 달리기 게임을 찾아냈다.
기존에 pygame으로 구현된걸 렌파이로 마이그레이션 해놓음
정확히는 렌파이와 pygame을 통합한 프레임워크인 Renpygame
을 사용했다.
예전에 존재하던 Renpygame
이라는 프레임워크를 BlackRam-oss 라는 개발자가 개인적으로 관리하고 있는 듯 하다.
https://github.com/DRincs-Productions/Renpygame
https://github.com/BlackRam-oss/Chrome-Dino-Runner
아무튼 난 Renpygame
을 쓰는 대신, 그냥 제작자 정의 디스플레이어블로 구현하고 싶었다...
https://www.renpy.org/doc/html/cdd.html#renpy-displayable
http://baekansi.dothome.co.kr/doc/html/udd.html
렌파이 설치 경로 tutorial > game
폴더 하위의 indepth_minigame.rpy
라는 스네이크 게임 예제가 있다.
둘을 적절히 응용하면 될 듯 싶어서 바로 시작
미니게임에 사용할 이미지가 존재하는 경로를 작성해준다. 여기까진 평범한 렌파이 문법이다.
화면 너비랑 점수에 대한 전역변수도 선언 ㄱㄱ
# 공룡
image dino_run1 = "images/dino/dino_run1.png"
image dino_run2 = "images/dino/dino_run2.png"
image dino_down1 = "images/dino/dino_down1.png"
image dino_down2 = "images/dino/dino_down2.png"
image dino_jump = "images/dino/dino_jump.png"
image dino_hit = "images/dino/dino_dead.png"
# 배경
image bg = "images/other/background.png"
image track = "images/other/track.png"
image cloud = "images/other/cloud.png"
# 장애물
image large_cactus1 = "images/cactus/large_cactus1.png"
image large_cactus2 = "images/cactus/large_cactus2.png"
image large_cactus3 = "images/cactus/large_cactus3.png"
image small_cactus1 = "images/cactus/small_cactus1.png"
image small_cactus2 = "images/cactus/small_cactus2.png"
image small_cactus3 = "images/cactus/small_cactus3.png"
image bird1 = "images/bird/bird1.png"
image bird2 = "images/bird/bird2.png"
define SCREEN_WIDTH = 1800 # 화면 너비
define score = 0
공룡, 장애물, 구름 관련된 클래스를 생성한다.
파이썬 문법이므로 init python:
이후 사용한다.
init python:
import random, pygame, time
공룡 클래스이다.
공룡 이미지랑 상태를 변경하기 위한 멤버변수와 메서드를 만들어준다.
__
init__
공룡 생성자에 필요한 정보들을 설정해준다.
class Dino:
X_POS = 200 # 초기 X 위치
Y_POS = 600 # 초기 Y 위치
Y_POS_DOWN = 630 # DOWN시 Y위치
JUMP_VEL = 10 # 가속도
def __init__(self):
self.run_imgs = [Transform("dino_run1"), Transform("dino_run2")]
self.down_imgs = [Transform("dino_down1"), Transform("dino_down2")]
self.jump_img = Transform("dino_jump")
self.dead_img = Transform("dino_hit")
self.is_jump = False
self.is_down = False
self.is_run = True
self.step_idx = 0 # 걸을 때마다 이미지를 변경하기 위함
self.img = self.run_imgs[0]
self.jump_velocity = self.JUMP_VEL
self.x = self.X_POS
self.y = self.Y_POS
self.animation_timer = 0 # 자연스러운 움직임을 위함
이미지는 Transform("{image변수명}")
으로 사용 가능하다.
예)
image dino_run1 = "images/dino/dino_run1.png"
run_image1 = Transform("dino_run1")
dt(프레임 간 경과 시간)를 누적해서 0.1초가 넘어가면, 전달받은 이미지를 순회하면서 애니메이션 효과를 적용하는 메서드이다.
def animate(self,imgs,dt):
self.animation_timer += dt
if self.animation_timer > 0.1:
self.step_idx = (self.step_idx + 1) % len(imgs)
self.img = imgs[self.step_idx]
self.animation_timer = 0
step_idx
가 0 이고, 전달받은 이미지 리스트가 run_imgs
라고 했을 때 animation_timer
의 값이 0.1을 초과하는 경우dino_run2
이미지로 변경된다.아무런 키 입력도 없을 때, 기본으로 달리는 상태이다.
점프나 피하기 이후 x, y 좌표를 초기화 해주어 공룡이 원래 위치에서 잘 달리는 것처럼 보이게 해주자.
def run(self,dt):
self.animate(self.run_imgs,dt) # 애니메이션 적용
self.x = self.X_POS
self.y = self.Y_POS
공룡의 피하기 동작을 표현한다.
y 좌표를 Y_POS_DOWN
으로 변경한다.
def down(self,dt):
self.animate(self.down_imgs,dt)
self.x = self.X_POS
self.y = self.Y_POS_DOWN
점프 시, 가속도 * 3.6 값만큼 y좌표에서 빼서 높이를 위로 조정한다.
y좌표 조정 이후 가속도를 0.05 * game_speed * dt
만큼 빼서 감속시킨다.
game_speed * dt
는 게임 속도에 따라 가속도가 감소하는 비율을 조절하기 위한 값이다.
-1 * 초기 가속도(10)
보다 점프 가속도가 줄어든 경우 점프 상태를 종료하고 다시 달리기 상태로 설정한다.
def jump(self,game_speed,dt):
self.img = self.jump_img
if self.is_jump:
self.y -= self.jump_velocity * 3.6
self.jump_velocity -= 0.05 * (game_speed * dt)
if self.jump_velocity < - self.JUMP_VEL:
self.is_jump = False # 점프 종료
self.is_run = True # 다시 달리기
self.jump_velocity = self.JUMP_VEL # 가속도 초기화
self.y = self.Y_POS # y위치
장애물에 부딪혔을 때 dead_img
로 변경하고 충돌 시점의 x, y 좌표로 업데이트 한다.
def dead(self,x,y):
self.img = self.dead_img
self.x = x
self.y = y
공룡의 상태 플래그에 따른 적절한 행위 메서드를 호출한다.
def update(self,game_speed,dt): # 움직임 갱신
if self.is_run:
self.run(dt)
if self.is_jump:
self.jump(game_speed,dt)
if self.is_down:
self.down(dt)
장애물 클래스. 장애물의 위치를 조정하고, 장애물이 화면 밖으로 나갔는지 검사한다.
하단에 추가적으로 서술할 장애물 클래스들의 부모 클래스이다.
__
init__
장애물 생성 시 장애물의 이미지, x, y 좌표 설정
class Obstacle:
def __init__(self,img,y_pos):
self.img = img
self.x = SCREEN_WIDTH
self.y = y_pos
장애물의 x좌표를 game_speed * dt
한 값만큼 빼면서 조정
game_speed * dt
를 적용한 값을 빼서 이동거리를 계산하고 일정한 속도를 유지시킨다. 장애물이 자연스럽게 왼쪽으로 이동하는 것처럼 보이게 한다.
즉, game_speed * dt
는 프레임률과 상관없는 자연스러운 화면의 움직임을 구현하기 위해 사용한다.
def update(self,game_speed,dt):
self.x -= game_speed * dt # 게임 속도만큼 x좌표 감소
장애물의 x좌표가 -1 * 화면 너비
보다 작은지 검사한다.
def is_out_screen(self): # 장애물이 화면밖으로 나갔는지 검사
return self.x < - SCREEN_WIDTH
Obstacle
의 자식클래스로 small_cactus
이미지에 속하는 장애물에 대한 객체를 생성한다.
__
init__
small_cactus
이미지 3개중 임의로 1개를 선택한다.
class SmallCactus(Obstacle): # 작은 선인장
def __init__(self):
self.y = 620
self.img = Transform("small_cactus" + str(random.randint(1,3))) # 랜덤으로 작은 선인장 이미지 선택
super().__init__(self.img,self.y)
Obstacle
의 자식클래스로 large_cactus
이미지에 속하는 장애물에 대한 객체를 생성한다.
__
init__
large_cactus
이미지 3개중 임의로 1개를 선택한다.
class LargeCactus(Obstacle): # 큰 선인장
def __init__(self):
self.y = 600
self.img = Transform("large_cactus" + str(random.randint(1,3)))
super().__init__(self.img,self.y)
Obstacle
의 자식클래스이다. Bird
장애물의 경우, 날갯짓을 하는 애니메이션 동작이 필요하다.
__
init__
바닥에 고정된 장애물이 아니기 때문에 공중에 표기할 y좌표 리스트에서 임의의 y좌표를 선택하도록 한다.
class Bird(Obstacle): # 새
BIRD_Y = [540,560,610]
def __init__(self):
self.bird_imgs = [Transform("bird1"),Transform("bird2")]
self.img = self.bird_imgs[random.randint(0,1)]
self.y = random.choice(self.BIRD_Y)
self.animation_timer = 0
self.idx = 0
super().__init__(self.img,self.y)
Dino
의 animate
와 유사한 로직이다. x좌표를 감소시키면서 자연스러운 애니메이션 효과를 적용한다.
def fly(self,game_speed,dt): # 날갯짓
super().update(game_speed,dt)
self.animation_timer += dt
if self.animation_timer > 0.1: # 0.1초마다 이미지 변경
self.idx = (self.idx + 1) % len(self.bird_imgs)
self.img = self.bird_imgs[self.idx]
self.animation_timer = 0
배경의 구름을 표현하는 객체이다.
__
init__
class Cloud: # 구름
def __init__(self):
self.x = SCREEN_WIDTH - random.randint(800,1000)
self.y = random.randint(50,100)
self.img = Transform("cloud")
구름의 x좌표를 game_speed * dt
한 값만큼 빼면서 조정한다.
x좌표가 매개변수로 넘어온 -1 * width
값보다 작은 경우 x, y 좌표를 초기화한다.
def update(self,width,game_speed,dt): # 구름 움직임
self.x -= game_speed * dt
if self.x < - width:
self.x = SCREEN_WIDTH + random.randint(800,1000)
self.y = random.randint(50,100)
제작자 정의 디스플레이어블을 구현하기 위한 게임 클래스로 renpy.Displayable
클래스를 상속받는다.
이 경우, render 메서드는 반드시 오버라이드(재정의) 해야한다.
__
init__
게임 시작 시 필요한 정보들을 초기화한다.
여기선 굳이 생성자를 오버라이드 해 줄 필요가 없기 때문에 renpy.Displayable.__init__(self)
를 사용한다.
게임에 필요한 초기 정보들을 설정해준다.
st는 디스플레이어블이 처음 화면에 그려진 순간부터 측정되는 시간이다.
render
메서드가 재호출될때마다 이전의 st를 oldst
에 기록해두고, 현재 시점의 st와 oldst
를 빼서 dt를 구한다.
game_over_delay
는 게임 오버 이후 곧바로 다음 화면으로 jump하는 걸 방지하기 위해 추가한 변수이다.
class DinosaurGame(renpy.Displayable): # 게임
def __init__(self):
renpy.Displayable.__init__(self) # renpy의 Displayable을 상속받아 구현
self.game_speed = 1000
self.obstacles = []
self.score = 0
self.bg_x = 0
self.bg_y = 680
self.bg_img = Transform("track")
self.game_over = False
self.dino = Dino()
self.cloud = Cloud()
self.oldst = None
self.paused = False
self.game_over_delay = None
배경의 움직임을 나타내기 위한 메서드이다.
배경의 x좌표가 -1 * 화면 너비
보다 작아지는 경우 화면 밖으로 넘어간 것이므로 x좌표를 0으로 초기화한다.
def move_track(self,dt): # 배경 움직임
if self.bg_x <= - SCREEN_WIDTH:
self.bg_x = 0
self.bg_x -= self.game_speed * dt
임의의 장애물을 생성하여 obstacles
에 추가한다.
obstacles
의 장애물 개수가 3개 미만일 때, 가장 마지막 장애물의 x좌표가 200 미만인 경우 새로운 장애물을 추가한다.
def create_obstacle(self): # 랜덤 장애물 생성
if len(self.obstacles) < 3:
if not self.obstacles or (self.obstacles and self.obstacles[-1].x < 200):
obs_type = random.randint(0,2)
if obs_type == 0: # 작은 선인장
self.obstacles.append(SmallCactus())
if obs_type == 1: # 큰 선인장
self.obstacles.append(LargeCactus())
if obs_type == 2: # 새
self.obstacles.append(Bird())
obstacles
을 순회하면서 장애물의 위치를 조정하고 화면 밖으로 나간 장애물은 obstacles
에서 제거한다.
Bird
객체인 경우 날갯짓 표현을 위해 Obstacle
의 update
메서드 대신 Bird
의 fly
메서드를 호출하도록 한다.
def update_obstacle(self,dt): # 장애물 갱신
for obstacle in self.obstacles:
if isinstance(obstacle,Bird):
obstacle.fly(self.game_speed,dt) # 날갯짓
else:
obstacle.update(self.game_speed,dt) # 장애물 업데이트
if obstacle.is_out_screen():
self.obstacles.remove(obstacle) # 화면 밖으로 나간 장애물 제거
get_size()
는 render 객체의 크기를 구한다.(= 이미지 크기)
공룡과 장애물 이미지의 크기만큼 사각형 범위를 생성(pygame.Rect
)해준다.
colliderect
메서드를 사용하여 공룡과 장애물의 사각형 범위가 겹치는지 확인한다.
겹치면 충돌했다고 간주하고 충돌여부를 True
로 반환한다.
def check_collision(self,dino_render,obs_render,obs): # 충돌 체크
dino_width, dino_height = dino_render.get_size() # 공룡 크기
obs_width, obs_height = obs_render.get_size() # 장애물 크기
dino_rect = pygame.Rect(self.dino.x, self.dino.y, dino_width, dino_height) # 공룡 사각형범위 생성
obs_rect = pygame.Rect(obs.x, obs.y,obs_width,obs_height)
if dino_rect.colliderect(obs_rect): # 장애물 범위와 공룡 범위가 충돌했는지 확인
return True
return False
디스플레이어블에 하위 디스플레이어블이 존재하는 경우, 하위 디스플레이어블의 리스트를 반환한다.
말이 이해하기 어려운데... 그냥 디스플레이어블을 상속한 클래스 내부에 화면에 표시해야할 이미지가 존재하는 경우, 캐싱할 목록에 추가하는 것이다.
그러면 인터렉션(게임과 사용자 간의 상호작용)이 발생할 때 redraw가 호출되면서 화면을 다시 그린다.
여기선 공룡의 이미지, 배경 이미지, 구름 이미지, 장애물 이미지 등이 하위 디스플레이어블에 속한다.
def visit(self): # 사용하는 image 반환
return [self.dino.img,self.bg_img, self.cloud.img] + [obs.img for obs in self.obstacles]
재정의된 render
메서드에선 화면에 나타낼 정보를 결정하는 객체인 renpy.Render
를 반환한다.
즉, 화면에 나타나야 할 것들은 알아서 만들고, 그걸 렌파이가 알아들을 수 있는 렌더 객체로 반환하라는 것...
디스플레이어블이 화면에 처음 나타났을 때 호출된다.
renpy.redraw()
를 renpy.Displayable를 상속받은 자식 클래스 내부에서 호출하면, render
메서드를 재호출 할 수 있다.
1) renpy.Render 객체 생성
2) dtime(프레임 간 경과 시간) 구하기
3) 일시정지 상태거나 게임오버가 아닌 경우
4) renpy.render
로 하위 디스플레이어블에 대한 렌더 생성
5) blit
메서드를 호출하여 생성한 하위 디스플레이어블 렌더를 화면에 그림
6) 단, 장애물의 경우는 장애물 생성 -> obstacles
을 순회하며 4), 5) 수행하고 일시정지 상태거나 게임오버가 아닌 경우 충돌을 확인
7) 각 오브젝트들의 변경 사항을 화면에 반영하기 위해 renpy.redraw
호출
8) 게임 오버인 경우, 게임 오버 이후 0.3초가 지난 시점에서 game_over
페이지로 넘어가게 설정
def render(self,width,height,st,at): # 화면에 그림
render = renpy.Render(width,height)
if self.oldst is None:
self.oldst = st
dtime = st - self.oldst
self.oldst = st
if not self.paused and not self.game_over:
# 점수 누적
self.score += 1
if self.score % 100 == 0:
self.game_speed += 10 # 게임 속도 증가
self.update_obstacle(dtime) # 장애물 이동
self.dino.update(self.game_speed,dtime) # 공룡 이동
self.move_track(dtime) # 배경 이동
self.cloud.update(width,self.game_speed,dtime) # 구름 이동
# 공룡
dino_render = renpy.render(self.dino.img,width,height,st,at)
render.blit(dino_render,(self.dino.x,self.dino.y))
# 장애물
self.create_obstacle()
for obstacle in self.obstacles:
obs_render = renpy.render(obstacle.img,width,height,st,at)
render.blit(obs_render,(obstacle.x,obstacle.y))
if not self.paused and not self.game_over:
if self.check_collision(dino_render,obs_render,obstacle): # 충돌체크
self.dino.dead(self.dino.x,self.dino.y)
global score
self.game_over = True
self.game_over_delay = None
score = self.score
break
# 배경
bg_render = renpy.render(self.bg_img,width,height,st,at)
render.blit(bg_render,(self.bg_x,self.bg_y))
render.blit(bg_render,(self.bg_x + width,self.bg_y)) # 배경이 매끄럽게 이어지도록
# 구름
cloud_render = renpy.render(self.cloud.img,width,height,st,at)
render.blit(cloud_render,(self.cloud.x,self.cloud.y))
# 점수
score_text = Text(f"SCORE: {self.score}", color = "#FAFAFA")
score_render = renpy.render(score_text,width,height,st,at)
render.blit(score_render, ((width//1.15),40))
renpy.redraw(self,0)
if self.game_over:
if self.game_over_delay is None:
self.game_over_delay = time.time()
elif time.time() - self.game_over_delay > 0.3:
renpy.jump("game_over")
return render
인터렉션이 발생할 때, pygame의 이벤트를 제작자 정의 디스플레이어블에 전달할 수 있다.
각 키 입력 이벤트에 대해 공룡의 상태 플래그를 변경한다.
점프를 하는 상태에서 다시 점프하거나, 점프하는 동안 피하기를 할 수 없게끔 is_jump
가 False
인 경우까지 확인하여 점프와 피하기 상태 플래그를 변경한다.
키 입력이 끝나면 다시 달리기 상태로 설정한다.
def event(self,event,x,y,st): # 키 입력 처리
if not self.paused:
if event.type == pygame.KEYDOWN:
if (event.key == pygame.K_UP or event.key == pygame.K_SPACE) and not self.dino.is_jump:
# 윗 방향키나 스페이스바 누르면 점프
self.dino.is_down = False
self.dino.is_run = False
self.dino.is_jump = True
elif event.key == pygame.K_DOWN and not self.dino.is_jump:
# 아래 방향키 누르면 고개숙임
self.dino.is_down = True
self.dino.is_run = False
self.dino.is_jump = False
if event.type == pygame.KEYUP and not self.dino.is_jump:
# 키 입력이 끝나면 다시 달리기 상태
self.dino.is_down = False
self.dino.is_run = True
self.dino.is_jump = False
if event.type == pygame.KEYDOWN and (event.key == pygame.K_LALT or event.key == pygame.K_RALT): # ALT 일시정지
self.paused = not self.paused
renpy.redraw(self, 0.1)
if self.game_over: # 게임 종료 시 이벤트 무시
raise renpy.IgnoreEvent()
DinosaurGame
클래스 작성 이후, init python:
내부에 게임 객체를 생성해준다.
init python:
import random, pygame, time
... 생략 ...
dino_game = DinosaurGame() # 게임 객체 생성
구현한 사용자 정의 디스플레이어블을 호출할 screen과 label을 작성한다.
screen에서 add
를 통해 사용자 정의 디스플레이어블을 추가할 수 있다.
screen start_mini_game():
text "시작하려면 스페이스 바를 누르세요..." color "#ffffff" size 40 xalign 0.5 yalign 0.5
$ dino_game.__init__() # 게임 시작 전 객체 초기화
key "K_SPACE" action Jump("dinosaur_game")
label dinosaur_game:
scene bg
call screen dino_runner_game
screen dino_runner_game():
add dino_game
label game_over:
"게임 오버... 당신의 점수는 ... [score]점 입니다."
menu:
"다시하기":
call screen start_mini_game
"끝내기":
return
👍 재밌었다. 렌파이로 미연시 개발하면서 유용했던 팁 같은걸 추가적으로 정리해둘 생각이다.
올리신 게시물 잘 보고 있습니다!
근데 제가 렌파이는 초보여서 그런데 장애물 피하기 미니게임을 적용하려면 어떻게 해야하는 지 모르겠어요 ㅠㅠ
클래스는 다 작성하고 했는데 플레이 중간에 넣으려니 어떻게 하는지 몰라서 혹시 알려주실 수 있나요??