벽 세우기
저번글에 이어 작업을 계속해보자. PlayerStick은 위아래로 움직이기 시작했지만, 화면 넘어까지 넘어갔었다. 해결 방법은 여러가지가 있겠지만, 나중에 공도 튕겨내야 하기 때문에 위 아래로 벽을 세워보자. Pong Level 씬을 열고 KinematicBody2D를 추가한다. 앞에서 다뤘다시피, CollisionShape2D가 필요하다. 이름을 WallTop으로 바꿔준 후, CollisionShape2D를 자식 노드로 추가한다. CollisionShape2D를 선택한 후, Inspector > Shape 항목에서 RectangleShape2D를 선택해준다. 여기까지 하면, 노드트리는 다음과 같이 보일 것이다.

이제, 뷰포트에서 CollisionShape2D를 변형하여 게임 화면의 위쪽을 막아준다.

뷰포트에서 흐릿하지만 파란색 라인이 실제 게임화면 크기이다. 넉넉하게 게임화면을 넘어가도록 길게 만들어준다. 위치는 정확히 탑 라인에 일치하게 만들어 줬는데, 쉽게 한다고 Grid Snap을 사용하게 되면 탑 라인에 붙지를 않는다. Grid Snap의 간격이 디폴트로 8px로 되어 있기 때문이다. 이걸 조정하고 싶다면, 5px로 바꿔주면 된다. Grid Snap은 뷰포트 툴바에 있으며, 옆에 3개의 점을 클릭하면 설정변경도 가능하다.

또는 Inspector에서 정확한 값을 입력해도 된다.

주의할 점은, Shape > Extends는 전체 길이가 아니고 양방향으로 늘어나는 길이를 말한다. 즉, 700이면 총 길이는 1400이 된다.
위쪽에 벽을 세웠으니, 아래쪽도 세우자. 노드트리에서 WallTop을 선택한 후, 마우스 우클릭을 해보면 Duplicate 메뉴가 보인다. 이걸 선택해서 WallTop을 복제해 생성하자. 단축키는 Ctrl+D이다. WallTop2 노드가 생성될텐데, 이름을 WallBottom으로 바꿔준다. 자깃노드인 CollisionShape2D를 선택한 후, 화면의 아래쪽을 막도록 이동한다. 간단하게 Inspector에서 Transform > Position > y 값을 바꿔주면 된다.

이동시키면 뷰포트에 다음과 같이 보일것이다.

좌표계
아, 내가 좌표계 얘길 했던가? Godot에서는 화면의 좌측 상단이 (0, 0)이고 오른쪽이 +X 축, 아래쪽이 +Y축이다. 2D 좌표계는 공식 문서에서 확인할 수 있다.

얘기가 나온김에 3D도 살펴보자. 3D는 오른손 좌표계와 왼손 좌표계로 나뉜다. Godot은 오른손 좌표계를 사용한다. 오른손 좌표계에서도 프로그램 뷰포트에서 위쪽을 어떤 좌표로 두느냐에 따라 Y-up, Z-up으로 구분하는데, Godot은 Y-up 을 사용한다. 즉, 하늘로 가는 방향이 Y방향이다.

혼란을 가중시키는 부분은 각종 3D 프로그램들이 다 제각각의 좌표계를 사용하고 있다는 사실이다. 다음 표에 정리되어 있다.

예를들어, Blender에서 모델을 그대로 export해서 import 해오면, 모델이 누워있을 것이다. 그래도 같은 오른손 좌표계는 회전만 시켜주면 되므로 export시 간단한 체크 하나만 해주면 쉽게 정상적으로 가져올 수 있다.
Ball의 움직임을 스크립트로 구현
이제 공을 움직여 보자. Pong Level 노드트리에서 PongBall 노드에 attach script를 이용하여 스크립트 파일을 추가한다. 다음과 같은 다이얼로그가 뜨는데, 그대로 생성.

공을 움직이는 함수는 KinematicBody2D에 있는 move_and_collide()를 사용한다. 앞에서 얘길 못했는데, KinematicBody2D에 대해 어떻게 사용하는지 공식 문서에 자세한 설명이 있다. 또한 오브젝트간 충돌과 이에 대한 처리는 Physics introduction 항목을 참조하면 도움이 될 것이다.
move_and_collide()는 충돌시, KinematicCollision2D 오브젝트를 리턴해준다. 여기에는 충돌에 대한 여러 정보가 담겨있는데, 그 중에 충돌지점에 대한 nomal vector도 들어있다. 이를 이용해, Vector2의 bounce(Vector2 normal) 을 이용하면 벽에서 튕겨져 나오는 방향을 구할 수 있고, 이를 이용해 벽과의 충돌을 구현할 수 있다. 주의할 점은, Vector2에 reflect()라는 메소드도 있으나, mirrored 또는 symmetric이란 의미로 벡터의 수학적인 변화일 뿐, 우리가 원하는 물리적인 반사가 아니다. 이는 아래 그림을 참조하자.

이를 이용하여 PongBall의 스크립트를 작성하면 다음과 같다.
var speed := 600
var direction := Vector2(-1, -1).normalized()
func _physics_process(delta):
var collision_object = move_and_collide(direction * speed * delta)
if collision_object:
direction = direction.bounce(collision_object.normal)
우선, 공의 속도를 정의하고 있다. 테스트를 위해, 공의 진행 방향은 45도 방향으로 움직이도록 설정했다. 앞서 KinematicBody의 경우 물리적 계산이 들어가므로 _process()대신 _physics_process()에서 처리하라는 가이드를 줬었다. 콜백 함수 내부에서는 말한대로 move_and_collide()를 이용해 충돌시 normal vector를 잉용하고 있으며, direction 벡터의 bounce()를 이용해 충돌 후 진행방향을 새로 설정하고 있다. 주의할점은, move_and_slide()는 delta값을 함수 내부적으로 계산해서 적용해 주지만, move_and_collide()는 delta를 사용하지 않는다. 그러므로 delta값을 직접 계산하여 인자로 넘겨주고 있다. 일관성이 없는 부분은 좀 의아하지만, 일단은 그렇게 동작하니 헷갈리지 말자. 테스트를 위해 실행해보자. 잘 따라왔다면, 벽에 튕기고 막대를 움직여 공을 받아낼 수도 있을 것이다.
랜덤 넘버 생성
이제 공은 원하는대로 움직인다. 다만, 시작시 방향이 고정되어 있다. 이걸 랜덤한 방향으로 시작하도록 구현해보자.
랜덤넘버 생성은 게임제작에서 필수적인 요소지만, 게임이 아니더라도 모든 프로그래밍 언어에서 거의 동일하게 구현하여 API를 제공한다. 랜덤값을 생성하는 것은 이론적으로 매우 어려운 일이지만, 제공되는 API의 사용은 간단하다. 랜덤은 특수한 수식을 이용해서 호출할 때마다 새로운 랜덤값을 생성해 돌려주는데, 최초 생성시 seed값을 필요로 한다. 같은 seed값을 주면 매번 똑같은 순서대로 같은 값을 돌려주므로, 랜덤값을 얻기 위해선 이 seed값을 다르게 줘야한다. 이 seed값으로 가장 유효한 것은 현재 시간의 밀리세컨드값이다. 계속 변화하는 값으로 랜덤의 seed로는 최적이다.
GDScript에서의 랜덤넘버 생성은 공식 문서에 잘 소개되어 있다. 보통 _ready() 콜백에서 randomize()를 호출하여 seed를 초기화한다. seed값을 따로 안주는 이유는 randomize()함수 내부에서 현재 시간의 틱값을 seed로 이용하여 랜덤 넘버 생성기를 초기화 해주기 때문이다. seed값을 직접 주고 싶다면, seed() 함수를 이용해도 된다. 일단, randomize()가 한번 실행 됐다면, 랜덤 넘버를 얻어오는 함수를 사용할 수 있다. randi()는 32비트 정수값(0~4,294,967,295)을 돌려준다. 여기에 나머지 연산을 사용하면, 원하는 정수범위 값을 얻을 수 있다. randf()는 0~1사이의 실수값을 돌려준다. 원하는 범위의 실수값을 얻고자 한다면, randf()로부터 직접 계산을 해도 되지만, rand_range(from, to)를 이용해도 된다.
이제 다시 공의 스크립트 파일로 돌아가보자. 우선 _ready() 콜백함수에서 randomize()로 초기화한 뒤에, 랜덤값을 얻어와 공의 방향을 정해보자. 생각을 조금 해보자면, 공이 수직으로 위아래로 이동할 경우, 게임이 불가능할 것이다. 마찬가지로 수평으로 이동할 경우, 공을 피하지 않는 이상 게임이 끝나지 않을 것이다. 우리가 원하는건 적당한 대각선 방향으로 움직이는 것이다.

그림과 같은 범위내에서만 방향을 정하려면 저 범위내 각도를 랜덤으로 생성하면 될 것이다. rand_range(30, 70)정도를 주면, 30도에서 70도 사이 값을 얻어올 수 있다. 다음은 4사분면의 방향을 정해줘야 하는데, (1, 1), (1, -1), (-1, 1), (-1, -1) 중에서 하나를 랜덤으로 골라 곱해주면된다. 이걸 코드로 구현해보면 다음과 같다.
func _ready():
randomize()
init_random_direction()
...
func init_random_direction():
var angle = deg2rad(rand_range(30, 70))
direction.x = cos(angle) * [-1, 1][randi() % 2]
direction.y = sin(angle) * [-1, 1][randi() % 2]
코드를 보면, 랜덤 방향을 구하는 init_random_direction() 함수를 만들었다. 30~70도 값을 rand_range()로 얻어오는데 이값은 degree이므로, 계산을 위해 deg2rad()를 이용해 radian값으로 변환해줬다. 이 angle값의 코사인값이 X, 사인값이 Y인데, 여기에 랜덤하게 1 또는 -1을 곱해줘서 방향을 랜덤하게 생성하고 있다. [-1, 1][randi() %2] 표현을 해석하면 다음과 같다. [-1, 1] 배열에서 배열 인덱스를 randi() % 2로 0 또는 1을 주게 된다. 결과적으로 [-1, 1][0] 이되면 배열의 첫번째 인덱스 이므로 -1값이 리턴되고, [-1,1][1]이 되면 배열의 두번째 인덱스 이므로 1이 리턴된다. 이제 실행해서 테스트 해보면 적당히 잘 동작하는거 같다.
가장 기초적인 상대 알고리즘의 구현
플레이어의 스틱도 움직이고 공도 움직인다. 이제, 상대방 컴퓨터 스틱도 자동으로 움직이게 만들어보자. 상대방 컴퓨터를 AI라고 말하려다가 요즘 AI는 진짜 너무 고도화되서 이런 표현을 쓰기가 꺼려진다. 여기서는 대신 간단한 알고리즘이라고 하겠다.
가장 기본적인 전제는 적당히 볼을 받아내서 랠리가 가능하게 해야겠지만, 완벽하면 안되고 볼을 흘려서 패배가 가능해야 한다. 파고든다면 쉬운일은 아닌데, 여기서는 Learn Godot by creating Pong 영상에서 나온 구현을 그대로 사용하겠다. 기초적인 알고리즘이며, 이 부분을 어떻게 구현하느냐에 따라 Pong게임이 달라질 수도 있을 것이다.
Pong Level 노드트리에서 OpponentStick에 Attach Script를 이용해 Script파일을 생성해준다.

컴퓨터 상대의 알고리즘은 기본적으로 볼을 따라 움직이는 것이다. 그럴려면 PongBall의 위치정보가 필요하다. 다른 노드를 참조하는 방법은 Node에서 제공하는 함수들을 이용하면 된다. 여기에선 get_node(“node path”)를 이용할 것이다. 이 함수는 마치 디렉토리를 탐색하듯 경로를 인자로 넘겨 해당 노드를 가져올 수 있다. 이를 이용하여 먼저 _ready() 콜백함수에서 PongBall의 참조를 구한다. 코드는 다음과 같다.
export var speed = 300
var pongBall : KinematicBody2D
func _ready():
#pongBall = get_node("../PongBall")
pongBall = $"../PongBall"
코드를 살펴보면, 먼저 speed 변수를 정의했다. 300은 공이나 플레이어 스틱보다 느린 속도이다. 몇번의 테스트를 통해 적절한 수치라 생각되어 설정한 값이다. export로 선언해서 디버깅 모드에서 값을 변화하며 테스트가 가능하도록 하였지만, 현재는 공이 나가면 게임이 진행이 안되므로 큰 의미는 없다. 공이 나가면 게임이 다시 시작되도록 하는걸 나중에 구현해야 한다. 다음, pongBall은 PongBall노드의 참조를 저장할 변수이다. 게임 시작시, _ready() 콜백함수 내에서 get_node()로 참조를 가져온다. get_node()는 ‘$'(dollar sign)으로 대체해서 사용가능하다. 이 사용법을 설명하기 위해 의도적으로 사용해봤다. ‘$’표시방법은 노드를 참조하는 일은 자주 있기 때문에 편의상 제공하는 기능이다. 부모로 올라가서 참조했기 때문에 “../PongBall”로 사용했지만, 바로 자식노드에 대해선 따옴표 없이 사용이 가능하다.
이제, 얻어온 PongBall의 참조를 이용하여 공의 실시간 위치정보를 얻어와 따라가준다. 레퍼런스 영상에서 소개된 알고리즘을 써보면 다음과 같다.
func _physics_process(delta):
move_and_slide(Vector2(0, get_opponent_direction()) * speed)
func get_opponent_direction():
if abs(pongBall.position.y - position.y) > 25:
if pongBall.position.y > position.y: return 1
else: return -1
else: return 0
공을 따라가는 위치를 계산하기위해 get_opponent_direction() 함수를 만들었다. 먼저, 25보다 차이가 크지 않으면 움직이지 않게 해놨다. 이것은 공의 작은 움직임에 반응하지 않게하고, 따라가기 시작하는데 딜레이를 만들어준다. 차이가 25 이상으로 벌어지게 되면, 공이 위쪽인지 아래쪽인지에 따라 +Y방향(1), -Y방향(-1)을 리턴해준다. 이 방향값을 가지고, _physics_process()내에서 move_and_slide()를 이용하여 움직여준다. 완성된 스크립트는 다음과 같다.
extends KinematicBody2D
export var speed = 300
var pongBall : KinematicBody2D
func _ready():
#pongBall = get_node("../PongBall")
pongBall = $"../PongBall"
func _physics_process(delta):
move_and_slide(Vector2(0, get_opponent_direction()) * speed)
func get_opponent_direction():
if abs(pongBall.position.y - position.y) > 25:
if pongBall.position.y > position.y: return 1
else: return -1
else: return 0
이 단순한 알고리즘에서, 컴퓨터의 난이도를 정하는 수치는 speed 값과 get_opponent_direction()에 있는 위치 차이 25에 따라갈 것이다. 대충 동작하게 만든게 speed = 300인데, 상용 게임이라면 수많은 테스트를 거치며 시험하고 구현하며 조정해야 할 것이다. 여기서는 적당히 동작만 하는 수준에서 만족하자.
이 다음에 해야할 일은?
게임의 기본적인 동작은 이렇게 구현이 되었다. 이제 남은 것은 UI를 추가하고 게임을 폴리싱할 단계이기 때문이다. 보통, 이정도 시점이 고비다. 기능은 구현한거 같고, 폴리싱 작업은 생각보다 훨씬 오래걸리기 때문에 정말 지루하며 하기 싫어 미루는 경우가 많다. 어떻게 보면 가장 중요한 단계이기 때문에, 마지막 완성까지 포기하지 말자. 이건 내 스스로에게 하는 말이기도 하다.
폴리싱 작업은 원래 힘들지만, 이건 코딱지만한 튜토리얼이기 때문에 해야할 일이 많지 않으니 걱정하지 말자. 일단, 득점을 하는 경우, 스코어를 표시해야 하며, 공의 위치를 초기화해서 다시 게임이 시작되도록 해야 한다. 이 단계에서 타이머를 사용해 볼 것이다. 그리고 준비된 작은 사운드 파일을 추가해 완성하자. 마지막 단계는 다음 포스팅으로~
1 thought on “처음 접하는 Godot: Pong 게임을 만들어 보자 #3”