[Ren'Py] 공룡 달리기(Chrome Dino Runner) 미니 게임 구현하기

dev asdf·2024년 8월 13일
0

Ren'Py

목록 보기
1/3

렌파이에서 미니게임을 구현해야 해서...ㅋㅋㅋㅋㅋ

렌파이 쿡북을 뒤져서 공룡 달리기 게임을 찾아냈다.

기존에 pygame으로 구현된걸 렌파이로 마이그레이션 해놓음

정확히는 렌파이와 pygame을 통합한 프레임워크인 Renpygame을 사용했다.

😲 근데 이제 지원은 중단된 ... ?

https://renpy.org/wiki/renpy/frameworks/Renpygame

예전에 존재하던 Renpygame이라는 프레임워크를 BlackRam-oss 라는 개발자가 개인적으로 관리하고 있는 듯 하다.

👾 GitHub

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 라는 스네이크 게임 예제가 있다.
둘을 적절히 응용하면 될 듯 싶어서 바로 시작



1. 이미지 로드

미니게임에 사용할 이미지가 존재하는 경로를 작성해준다. 여기까진 평범한 렌파이 문법이다.
화면 너비랑 점수에 대한 전역변수도 선언 ㄱㄱ

# 공룡
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

2. 게임 오브젝트 클래스

공룡, 장애물, 구름 관련된 클래스를 생성한다.
파이썬 문법이므로 init python: 이후 사용한다.

init python:
	import random, pygame, time 

1) Dino

공룡 클래스이다.

공룡 이미지랑 상태를 변경하기 위한 멤버변수와 메서드를 만들어준다.

__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")

animate

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을 초과하는 경우
    1) step_idx = 1
    2) img = run_imgs[1] 가 되어, 기존의 이미지가 dino_run2 이미지로 변경된다.

run

아무런 키 입력도 없을 때, 기본으로 달리는 상태이다.

점프나 피하기 이후 x, y 좌표를 초기화 해주어 공룡이 원래 위치에서 잘 달리는 것처럼 보이게 해주자.

        def run(self,dt):
            self.animate(self.run_imgs,dt) # 애니메이션 적용
            self.x = self.X_POS 
            self.y = self.Y_POS 

down

공룡의 피하기 동작을 표현한다.

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

jump

점프 시, 가속도 * 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

장애물에 부딪혔을 때 dead_img로 변경하고 충돌 시점의 x, y 좌표로 업데이트 한다.

        def dead(self,x,y): 
            self.img = self.dead_img
            self.x = x 
            self.y = y

update

공룡의 상태 플래그에 따른 적절한 행위 메서드를 호출한다.

  • 달리기
  • 피하기
  • 점프
        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)

2) Obstacle

장애물 클래스. 장애물의 위치를 조정하고, 장애물이 화면 밖으로 나갔는지 검사한다.

하단에 추가적으로 서술할 장애물 클래스들의 부모 클래스이다.

__init__

장애물 생성 시 장애물의 이미지, x, y 좌표 설정

class Obstacle:
        def __init__(self,img,y_pos):
            self.img = img 
            self.x = SCREEN_WIDTH
            self.y = y_pos

update

장애물의 x좌표를 game_speed * dt 한 값만큼 빼면서 조정

game_speed * dt를 적용한 값을 빼서 이동거리를 계산하고 일정한 속도를 유지시킨다. 장애물이 자연스럽게 왼쪽으로 이동하는 것처럼 보이게 한다.

즉, game_speed * dt는 프레임률과 상관없는 자연스러운 화면의 움직임을 구현하기 위해 사용한다.

        def update(self,game_speed,dt):
            self.x -= game_speed * dt # 게임 속도만큼 x좌표 감소

is_out_screen

장애물의 x좌표가 -1 * 화면 너비보다 작은지 검사한다.

        def is_out_screen(self): # 장애물이 화면밖으로 나갔는지 검사
            return self.x < - SCREEN_WIDTH

3) SmallCactus

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)

4) LargeCactus

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)

5) Bird

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)

fly

Dinoanimate와 유사한 로직이다. 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 

6) Cloud

배경의 구름을 표현하는 객체이다.

__init__

class Cloud: # 구름
        def __init__(self):
            self.x = SCREEN_WIDTH - random.randint(800,1000)
            self.y = random.randint(50,100)
            self.img = Transform("cloud")

update

구름의 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)

3. 게임 클래스

제작자 정의 디스플레이어블을 구현하기 위한 게임 클래스로 renpy.Displayable 클래스를 상속받는다.

이 경우, render 메서드는 반드시 오버라이드(재정의) 해야한다.

DinosaurGame

__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

move_track

배경의 움직임을 나타내기 위한 메서드이다.

배경의 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

create_obstacle

임의의 장애물을 생성하여 obstacles에 추가한다.

  • 0: 작은선인장
  • 1: 큰 선인장
  • 2: 새

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())

update_obstacle

obstacles을 순회하면서 장애물의 위치를 조정하고 화면 밖으로 나간 장애물은 obstacles에서 제거한다.

Bird 객체인 경우 날갯짓 표현을 위해 Obstacleupdate 메서드 대신 Birdfly 메서드를 호출하도록 한다.

        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)  # 화면 밖으로 나간 장애물 제거

check_collision

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 

visit

디스플레이어블에 하위 디스플레이어블이 존재하는 경우, 하위 디스플레이어블의 리스트를 반환한다.

말이 이해하기 어려운데... 그냥 디스플레이어블을 상속한 클래스 내부에 화면에 표시해야할 이미지가 존재하는 경우, 캐싱할 목록에 추가하는 것이다.

그러면 인터렉션(게임과 사용자 간의 상호작용)이 발생할 때 redraw가 호출되면서 화면을 다시 그린다.

여기선 공룡의 이미지, 배경 이미지, 구름 이미지, 장애물 이미지 등이 하위 디스플레이어블에 속한다.

        def visit(self): # 사용하는 image 반환
            return [self.dino.img,self.bg_img, self.cloud.img] + [obs.img for obs in self.obstacles]

render

재정의된 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 

event

인터렉션이 발생할 때, pygame의 이벤트를 제작자 정의 디스플레이어블에 전달할 수 있다.

각 키 입력 이벤트에 대해 공룡의 상태 플래그를 변경한다.

점프를 하는 상태에서 다시 점프하거나, 점프하는 동안 피하기를 할 수 없게끔 is_jumpFalse인 경우까지 확인하여 점프와 피하기 상태 플래그를 변경한다.

키 입력이 끝나면 다시 달리기 상태로 설정한다.

        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() # 게임 객체 생성

4. screen & label

구현한 사용자 정의 디스플레이어블을 호출할 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

결과


👍 재밌었다. 렌파이로 미연시 개발하면서 유용했던 팁 같은걸 추가적으로 정리해둘 생각이다.

https://github.com/DEV-asdf-516/renpy-dinosaur-game

2개의 댓글

comment-user-thumbnail
2025년 3월 3일

올리신 게시물 잘 보고 있습니다!
근데 제가 렌파이는 초보여서 그런데 장애물 피하기 미니게임을 적용하려면 어떻게 해야하는 지 모르겠어요 ㅠㅠ
클래스는 다 작성하고 했는데 플레이 중간에 넣으려니 어떻게 하는지 몰라서 혹시 알려주실 수 있나요??

1개의 답글

관련 채용 정보