Pong 게임에 대한 진짜 마지막 포스팅이다. 이번에 할 일은 스코어를 냈을 때, 바로 공이 발사되며 시작되는 문제를 고칠 것이다. 타이머를 추가하여 다음 라운드가 시작되기전에 준비할 시간을 주도록 할 것이다.
타이머(Timer)
게임에서 타이머도 정말 많이 사용하는 기능중 하나다. 하나의 스테이지를 클리어하는데 시간제한이 걸린 경우도 있고, RPG게임들은 각종 스킬들에 쿨타임이 걸려있기도 하다. 지금 만드는 Pong과 같은 아케이드 게임은 게임 시작전에 짧은 시간의 딜레이를 줘서 플레이어가 게임을 준비하도록 한다.
타이머는 타임아웃 시간을 주면 해당 시간이 지난 후, 타임아웃 이벤트를 발생시킨다. 이 타임아웃 이벤트 핸들러를 작성하면 땡이다. Pong 게임에 타이머를 달아보자.
타이머 노드(Timer Node)
Pong Level 씬을 열고, 타이머 노드를 추가해 보자.

이름을 ResetTimer로 바꿔준다.

이제 타이머를 선택하고 Inspector를 살펴보자.

먼저 Process Mode가 있다. Idle과 Physics가 선택가능한데, _process()를 이용할지, _physics_process()를 이용할지 선택하는 부분이다. Godot 게임엔진이 보통 30~60fps의 속도로 루프를 돌며 동작하기 때문에, 타이머가 어떻게 구현되는지 가늠할 수 있는 부분이기도 하다. 아마도 _process(delta)나 _physics_process(delta)에서 delta값을 누적해 나가며 타임아웃 여부를 검사하는 것으로 추정된다. 그러므로 타이머가 정확한 시간간격을 필요로 한다면, Physics를 선택하는게 도움이 될 것이다. 두번째로 Wait Time이 있는데, 타임아웃 시간을 설정한다. 스크롤바를 움직여보면, 소수점까지도 설정이 가능하다. 당연히 직접 입력도 되고. 다음, One Shot 항목은 타이머가 한 번 실행되고 끝날지, 반복해서 실행할지 결정하는 부분이다. 체크를 해줘야 한번만 실행된다. Autostart 항목은 타이머가 로딩 됐을 때, 타이머가 바로 동작하게 하는 항목이다.
우리는 스코어를 획득했을 때, 3초정도 딜레이를 주도록 하자. Wait Time을 3초로 설정하고, One Shot을 체크한다. Autostart는 체크되지 않은채로 남겨둔다.

이제, 코드에서 타이머를 동작해야 한다. 어느쪽이든 스코어를 획득 했을 때니까, Pong Level.gd 에서 _on_LeftArea_body_entered() 와 _on_RightArea_body_entered()에서 타이머를 시작해야 한다. 또한, 타이머가 동작하는 동안은 공을 움직이지 않다가, 타임아웃이 됐을 때 시작해야 한다. 우선 타이머를 동작하는 코드를 추가해보자. 타이머의 start() 메소드를 호출해주면 된다.
...
func _on_LeftArea_body_entered(body: PhysicsBody2D):
if body.name == "PongBall":
OpponentScore += 1
print("Opponent Score = %d" %OpponentScore)
emit_signal("opponent_score_updated", OpponentScore)
$PongBall.reset()
$ResetTimer.start() # <-- here
func _on_RightArea_body_entered(body: PhysicsBody2D):
if body.name == "PongBall":
PlayerScore += 1
print("Player Score = %d" %PlayerScore)
emit_signal("player_score_updated", PlayerScore)
$PongBall.reset()
$ResetTimer.start() # <-- here
타이머의 타임아웃은 시그널로 처리된다. 노드트리에서 ResetTimer를 선택한 후, timeout 시그널을 연결해보자. 연결 대상은 Pong Level.gd 스크립트로 한다.


이제 Pong Level.gd에 다음과 같이 timeout 핸들러가 추가됐다.
...
func _on_ResetTimer_timeout():
pass # Replace with function body.
이제 스코어를 획득시 공을 멈추고, 타임아웃이 되면 공을 다시 움직여야 한다. 이것도 커스텀 시그널을 정의해서 PongBall에게 커스텀 시그널을 처리하게 구현할 수 있으나, 여기서는 간단하게 PongBall에 메소드를 추가하고 직접 호출해 주도록 해보자. PongBall.gd 스크립트 파일을 열고 다음과 같이, start(), stop() 메소드를 추가하자.
...
func start():
speed = 600
func stop():
speed = 0
정말 간단하게 speed값을 설정해줘서 공을 멈추고 다시 움직이게 구현했다. 이제 Pong Level.gd에 타이머 코드를 추가한 부분에서 이 메소드들을 필요에 따라 호출하면 된다.
func _on_LeftArea_body_entered(body: PhysicsBody2D):
if body.name == "PongBall":
OpponentScore += 1
print("Opponent Score = %d" %OpponentScore)
emit_signal("opponent_score_updated", OpponentScore)
$PongBall.reset()
$PongBall.stop() # <-- here
$ResetTimer.start()
func _on_RightArea_body_entered(body: PhysicsBody2D):
if body.name == "PongBall":
PlayerScore += 1
print("Player Score = %d" %PlayerScore)
emit_signal("player_score_updated", PlayerScore)
$PongBall.reset()
$PongBall.stop() # <-- here
$ResetTimer.start()
이제 실행해보면, 스코어를 냈을 때 공이 3초간 멈췄다가 움직이는 것이 보일 것이다.
뭔가 허전한데, 한걸음만 더 나아가 타이머가 카운트 되는 시간을 화면 가운데 표시해보자. 레이블을 하나 추가하고, 이름을 TimerCount로 바꿔준다.

TimerCount 레이블을 선택한 후, 뷰포트의 Layout에서 Full Rect를 선택한다.

Inspector에서 Text항목에 더미값으로 아무 값이나 넣어주고, Align, V Align을 Center로 맞춰준다. 텍스트가 화면 가운데에 보일 것이다.

뷰포트를 보면, 공과 겹치는게 보일 텐데, 텍스트가 살짝만 위로 올라가면 좋겠다. Margin Top값을 -100으로 설정하자.

스코어랑 마찬가지로 글자가 너무 작을 것이다. 스코어에서 했던 것처럼 Theme Overrides 항목에서 폰트와 크기를 설정하자.

스코어랑 동일한 크기에 같은 폰트를 사용했는데, 구분을 해주고 싶다. 사용자의 주의를 끌어야 하는 타이머니까, 빨간색으로 변경해보자. Theme Overrides 항목에 Colors에서 Font Color를 체크해 변경이 가능하다.

여기까지 완료하면 뷰포트가 다음과 같이 보일 것이다.

이제, TimerCount 레이블에서 타이머 시간을 표시해야 한다. 이것도 시그널을 이용하는게 정답이겠지만… Pong Level.gd에서 처리하자.
꼭 짚고 넘어가야할 부분인데, 일반적으로는 이런 방식이 좋은 처리방법은 아니라고 언급해두고 싶다. 노드간 시그널을 이용하는게 일반적인 정답이며, 노드간 의존도를 낮춰서 복잡도를 없애준다. Pong Level.gd에서 처리하는게 쉽고 편하긴 하지만, 이런 방법은 글로벌 변수나 객체의 함정이기도 하다. 글로벌 객체나 변수를 사용하면, 어디에서나 접근이 쉽기 때문에 편리해 보이지만, 규모가 커지면 복잡도가 감당 못하게 증가할 것이다.
그러나 한편으로는, 지금 만드는 Pong게임 수준에서 게임이 더 확장될 가능성이 없다고 볼 수있는 상태이기 때문에, 굳이 시그널을 추가하며 작업량을 늘리는건 답이 아닐수도 있다. 필요한 만큼만 구현하는게 정답이라고 말한적이 있다. 여기서는 Pong Level.gd에서 동작하는 기능구현을 해놓는게 정답일 수 있고, 추가 작업이 발생하는 경우 그 시점에서 리팩토링을 통해 개선하는게 맞을 것이다.
Pong Level.gd에 다음과 같은 코드를 추가하자.
...
func _ready():
$TimerCount.text = "0"
func _process(delta):
$TimerCount.text = str(int(ceil($ResetTimer.time_left)))
...
먼저, _ready() 콜백함수에서 TimerCount 레이블의 텍스트를 “0”으로 초기화 했다. _process()에서 ResetTimer의 time_left값을 읽어와 사용한다. time_left값은 float값이다. ceil()함수는 올림 함수인데, 2~3 사이값은 3으로, 1~2 값은 2로 올림해준다. 반올림을 해주는 round()도 있으나, 사용해보면 0~0.5를 0으로 표시해서 0이 되었는데도 시작이 안되고 딜레이가 생기는걸 확인할 수 있다. 정확히 0이 되면서 시작하려면 올림함수인 ceil()을 사용해야 한다. ceil()로 올림을 했지만, 이대로 문자열로 변환하면 소수점 자리까지 표시가 된다. 이를 막기 위해 먼저 int()를 이용해 정수로 변환한다. 변환된 정수값을 str()을 이용해 스트링으로 변환해서 text 값에 넣어준다. 실행해보면, 잘 동작함을 알 수 있다.
아직 한가지 문제가 있다. 타이머가 새 라운드를 시작할 때만 보였다가 타이머가 다돌고 공이 움직이면 안보이는게 맞을 것이다. 다음과 같이 코드를 추가하자.
func _ready():
$TimerCount.text = "0"
$TimerCount.visible = false # <-- here
func _process(delta):
$TimerCount.text = str(int(ceil($ResetTimer.time_left)))
func _on_LeftArea_body_entered(body: PhysicsBody2D):
if body.name == "PongBall":
OpponentScore += 1
print("Opponent Score = %d" %OpponentScore)
emit_signal("opponent_score_updated", OpponentScore)
$PongBall.reset()
$PongBall.stop()
$TimerCount.visible = true # <-- here
$ResetTimer.start()
func _on_RightArea_body_entered(body: PhysicsBody2D):
if body.name == "PongBall":
PlayerScore += 1
print("Player Score = %d" %PlayerScore)
emit_signal("player_score_updated", PlayerScore)
$PongBall.reset()
$PongBall.stop()
$TimerCount.visible = true # <-- here
$ResetTimer.start()
func _on_ResetTimer_timeout():
$TimerCount.visible = false # <-- here
$PongBall.start()
코드를 보면, _ready()에서 시작시 $TimerCount.visible 값을 false로 주어서 안보이게한다. 누군가 스코어를 획득하면, visible을 true로 보여주고, 타이머가 다 돌고 타임아웃이 됐을 때, false로 변경하여 다시 가려준다.
이렇게 보니까, _on_LeftArea_body_entered()와 _on_RightArea_body_entered()에 코드 중복이 보인다. 중복 코드를 별개 함수로 빼주자.
func _on_LeftArea_body_entered(body: PhysicsBody2D):
if body.name == "PongBall":
OpponentScore += 1
print("Opponent Score = %d" %OpponentScore)
emit_signal("opponent_score_updated", OpponentScore)
restart_round()
func _on_RightArea_body_entered(body: PhysicsBody2D):
if body.name == "PongBall":
PlayerScore += 1
print("Player Score = %d" %PlayerScore)
emit_signal("player_score_updated", PlayerScore)
restart_round()
func restart_round():
$PongBall.reset()
$PongBall.stop()
$TimerCount.visible = true
$ResetTimer.start()
중복 코드를 restart_round() 함수로 빼서 중복을 제거했다.
사운드의 추가
이제 다 만들고 잘 동작하는걸 확인했다. 다된거 같은데 뭔가 허전함은… 사운드의 부재 때문이다. 게임에 사운드를 추가하는건 마치 화룡점정과도 같다. 눈을 찍어 그림에 생명을 불어넣듯, 사운드가 추가되면 게임이 완전히 달라보인다.
게임에서 사운드는 보통 두가지로 분류된다. BGM, 그러니까 배경음악이 존재하고 SFX, 사운드 효과음이 필요하다. 또한, 이 음원들을 공간에 위치시켜 플레이어의 위치에 따라 소리가 다르게 들리도록 하기도 한다. FPS게임의 경우, 이런 소리가 나는 방향과 크기를 듣고 플레이하는 일명 ‘사플’은 필수요소이기도 하다.
여기서는… 아주 단순한 효과음만 추가할 것이다. 공이 튕길 때 소리와 스코어를 획득할 때 소리이다. 처음에 소개한 유튜브 영상 에 주어진 리소스를 보면, 이 효과음에 대한 사운드 파일을 얻을 수 있다. BGM은 직접 구해서 추가해 볼 수도 있을 것이다.
먼저 PongBall 씬을 열고, 노드트리에서 AudioStreamPlayer 노드를 추가한다.

노드 목록에 보면, AudioStreamPlayer2D, 3D가 보이는데, 이것들은 위치정보를 포함한 것이다. 여기서는 위치 상관없이 소리를 낼 것이기 때문에 AudioStreamPlayer으로 충분하다.
추가한 AudioStreamPlayer의 이름을 CollisionSound로 바꿔준다. 그리고 Inspector에서 Stream 항목에 앞서 리소스로 받았던 ping_pong_8bit_beeep.ogg 파일을 드래그 해준다. 다른 방식으로 추가해줘도 된다.

다른 항목은 따로 수정할 필요가 없다. BGM을 추가하는 경우라면, Autoplay를 체크했어야 할 것이다. 이제 PongBall.gd 스크립트에서 bounce 되는 경우, 사운드파일을 재생해주면 된다. 다음과 같이 코드를 추가하자.
func _physics_process(delta):
var collision_object = move_and_collide(direction * speed * delta)
if collision_object:
direction = direction.bounce(collision_object.normal)
$CollisionSound.play()
collision이 발생해서 bounce()로 튕겨주는 경우에 AudioStreamPlayer의 play() 메소드를 불러서 사운드를 재생하고 있다.
이제, 스코어를 얻는 경우 효과음을 재생해보자. 작업은 동일하다.
Pong Level에 AudioStreamPlayer 노드를 추가하고 이름을 ScoreSound로 바꿔주자. Inspector에서 Stream에 ping_pong_8bit_plop.ogg 파일을 드래그 해서 설정한다. 다른 방법으로 설정해도 된다.
이제 스코어를 획든한 경우, 사운드를 플레이 해주면 된다. 공통 코드인 restart_round()에 다음과 같이 코드를 추가하자.
...
func restart_round():
$ScoreSound.play() # <-- here
$PongBall.reset()
$PongBall.stop()
$TimerCount.visible = true
$ResetTimer.start()
...
ScoreSound의 play() 메소드를 호출해서 효과음을 내주고 있다.
단순한 효과음들이지만, 이것만으로도 게임이 살아난다. 효과음들이 만족스럽지 않을 수도 있다. 다른 효과음을 써보거나, BGM을 추가하는등 변경을 해줘도 잼있을 것이다.
real_the_last_final_of_final.png… 아뭏튼 진짜 마지막임.
이렇게, Godot의 첫 게임 프로젝트인 Pong을 완성했다. 게임을 따라 만드는건 사실, 그렇게 오래 걸리지 않았는데, 이걸 문서로 정리하는게 20일은 사용한거 같다. 이렇게 공을 들인 이유는 Godot의 첫 사용이기 때문이다. 다시 찾아볼 수 있는 기본 개념들을 정리했으니, 점점 수월해 지겠지. 이게 누군가에게 도움이 될지는 모르겠지만, 적어도 나에겐 도움이 될 것이다. 그럼 리얼 더 파이널 오브 파이널 진짜 끝!