코인 대시: 첫 2D 게임 만들기

가장 제작하기 간단한 2D게임을 만들어보면서 고도엔진에 대한 감을 잡아보자. 다음과 같은 주제를 다룬다.

  • 새 프로젝트 설정
  • 캐릭터 애니메이션 만들기
  • 캐릭터 이동
  • Area 2D를 사용해 오브젝트가 닿을 때 감지
  • Control노드를 사용해 정보를 표시
  • 시그널을 사용해 게임 오브젝트 간 통신

프로젝트 설정

Godot엔진을 설치한 경로 하위 폴더를 생성하고 해당 폴더에 프로젝트를 생성한다.

이 프로젝트에서는 3개의 독립적인 씬(플레이어 캐릭터, 동전, 점수와 시계)을 만들고, 이 모든 씬을 게임 ‘메인’씬에 결합하여 넣을 것이다. 규모가 큰 프로젝트에서는 각씬의 에셋과 스크립트를 별도의 폴더로 정리하는 편이 유용하겠지만, 비교적 작은 프로젝트에서는 모든 씬과 프로젝트를 res폴더라는 루트에 저장해도 된다.

앞서 다운로드 받은 에셋파일을 실제 파일시스템에 저장하여 관리한다.

이 게임은 portrait모드이므로 게임 창 설정해야 한다. 이런 설정은 프로젝트 -> 프로젝트 설정에서 볼 수 있다.

  • 뷰포트 너비: 480
  • 뷰포트 높이: 720
  • 스트레치/모드: canvas_items
  • 양상: keep

벡터와 2D 좌표계

2D에서 작업을 할 때는 데카르트 좌표를 사용하여 2D 평면에서 위치를 식별한다. 고도에서는 2D 공간에서 x축은 오른쪽으로, y축은 아래로 향한다. 이것은 대부분의 2D 그래픽 라이브러리와 유사하다.

벡터는 쉽게 원점으로부터 오프셋으로 생각할 수 있다. 원점에서 (3, 4)로 향하는 벡터는 x축으로 3만큼, y축으로 4만큼 이동하는 것이다. 이것은 3D 공간에서도 동일하게 적용된다.

1단계: 플레이어 씬

첫 번째 씬은 플레이어 오브젝트다. 플레이어용 씬을 별도로 만들면 게임의 다른 부분을 만들기 전에도 독립적으로 테스트할 수 있어서 좋다.

씬으로 오브젝트를 분리한다는 관점에서 각 컴포넌트마다 따로 테스트 씬을 구축한다는 느낌을 받았다.

이런 게임 오브젝트 분리는 프로젝트의 크기와 복잡성이 커짐에 따라 점점 더 유용해질 것이다. 개별 게임 오브젝트를 서로 분리해놓으면 게임의 다른 부분에는 영향을 주지 않으면서 문제를 해결하고, 수정하고, 완전히 교체하기가 더 쉬워진다. 이는 또한 한 번 만든 플레이어를 재사용할 수 있다는 뜻이기도 하다.

유니티의 프리팹과 같이 동작하는 개념으로 생각해도 좋을 듯 하다.

플레이어 씬에는 다음과 같은 내용이 필요하다.

  • 캐릭터와 애니메이션 표시
  • 사용자 입력에 응답해 캐릭터 이동
  • 동전이나 장애물 등 다른 게임 오브젝트와의 충돌 감지

씬 생성

자식 노드 추가 버튼을 클릭하여 Area2D를 선택하고, 노드 이름을 Player로 변경한다. 이후 -> 씬 저장을 클릭하여 Player.tscn으로 저장한다.

고도에서는 씬을 저장할 때 마다 .tscn확장자를 사용한다. 이는 고도의 씬용 파일 형식이다. 확장자에서 ‘t’는 텍스트 파일을 의미하고 실제로 텍스트 파일이다.

방금 만든 것이 씬의 root에 해당되는 최상위 노드다. 이 노드는 해당 오브젝트의 전반적인 기능을 정의한다. Area2D를 선택한 이유는 이것이 2D 노드라서 2D 공간에서 움직일 수 있고, 다른 노드의 중첩을 감지할 수 있으므로 동전 등의 게임 오브젝트 감지가 가능할 것이기 때문이다.

자식 노드를 추가하기 전에 항상 노드를 클릭해 실수로 이동하거나 크기를 조정하지 않았는지 확인하는 편이 좋다. Player노드를 선택하고 자물쇠 아이콘 옆의 선택한 노드 그룹화를 클릭하여 노드를 잠금한다.

새 씬을 생성하고 작업하기 전에 이렇게 하는 것을 권장한다. 예기치 못한 오류를 방지할 수 있기 때문이다.

스프라이트 애니메이션

Area2D 노드로는 다른 오브젝트가 플레이어와 겹치거나 부딪혔을 때를 감지할 수 있지만, Area2D자체에는 모양이 없다. 그러니 이미지를 표시할 수 있는 노드도 필요하다. Player노드를 선택하고 AnimatedSprite2D를 추가한다.

AnimatedSprite2D에는 SpriteFrames라는 리소스가 필요하다. 이 노드가 표시할 애니메이션이 여기에 담긴다. 이를 생성하기 위해 인스펙터 창에서 Animation/Sprite Frams속성을 찾고 새 SpriteFrames리소스를 만든다. 이후 해당 레이블를 클릭하면 하단에 SpriteFrames에 대한 에디터가 나타난다.

새로 만드는 애니메이션의 기본 속도 설정은 FPS값이 5다. 이 값은 애니메이션의 속도를 결정한다. 이 값이 높을수록 애니메이션이 빨라지고, 낮을수록 느려진다. 현재 프로젝트에 맞게 설정한다.

이후 AwakePlay와 같이 불러오면 시작할 기본 애니메이션도 지정할 수 있다. 애니메이션이 설정된 Sprite의 크기가 작다면 Scale 속성에서 값을 변경하면 된다.

콜리전 모양

Area2D등의 콜리전 오브젝트를 사용할 때는 고도에게 해당 오브젝트의 모양을 알려줄 필요가 있다. 콜리전 모양은 그 오브젝트가 차지하는 영역을 정의하며 중첩 및 충돌을 감지하는 데 사용된다. 모양은 다양한 Shape2D유형으로 정의되며 직사각형, 원, 다각형이 포함된다.

간단하게 영역이나 물리 바디에 도형을 추가해야 할 때 CollisionShape2D를 자식으로 추가할 수 있다. 그런 다음 에디터에서 원하는 모양의 유형을 선택하고 크기를 편집하면 된다.

CollisionShape2DPlayer 노드의 자식으로 추가하여 플레이어의 콜리전 모양을 정의한다. 생성된 콜리전의 위치는 현재 root기준의 위치이므로 스프라이트 기준으로 정렬하고 싶다면 스프라이트 속성의 오프셋을 조정하면 된다.

플레이어 스크립트 작성

이제 플레이어에 코드를 추가할 준비가 됐다. 노드에 스크립트를 붙이면 노드 자체만으로 제공하지 않는 기능을 추가할 수 있다. Player노드를 선택하고 새 스크립트버튼을 클릭한다.

노드 스크립트 붙이기창에서 기본 설정은 그대로 둬도 된다. 앞서 씬 저장을 잊지 않았다면 스크립트 이름이 자동으로 씬의 이름과 일치하게 지정된다.

모든 스크립트의 첫 줄은 어떤 노드를 상속하는지를 나타낸다. 그 바로 밑에서부터 변수 정의를 시작할 수 있다.

extends Area2D

@export var speed = 350
var velocity = Vector2.ZERO
var screensize = Vector2(480, 720)

speed변수에 @export어노테이션을 사용하면 기존 노드 속성처럼 인스펙터 창에서 값을 설정할 수 있다. 이 방법은 인스펙터를 통해 값을 조정할 수 있어 매우 유용하다. Player 노드를 선택하면 이제 인스펙터 창에 Speed 속성이 나타나는 것을 볼 수 있을 것이다.

유니티의 직렬화, 언리얼의 UPROPERTY와 비슷한 개념으로 보인다.

인스펙터 창에서 설정하면 모든 값은 스크립트에 작성한 속도값 350을 재정의한다. 변수 중 velocity값은 캐릭터의 이동 속도와 방향을 저장하고, screensize는 캐릭터의 이동 범위를 제한한다.

플레이어 이동

다음으로는 _process()함수를 사용해 플레이어가 무엇을 할 것인지 정의한다. _process()함수는 프레임마다 호출되는 업데이트/틱 함수로 게임 내에서 지속적인 입력, 변화가 필요한 게임 요소에 사용된다. 각 프레임마다 플레이어는 3가지 작업을 수행해야 한다.

  • 키보드 입력 확인
  • 주어진 방향으로 이동
  • 적절한 애니메이션 재생

먼저 입력부터 확인해야 한다. 이 게임에서는 4방향 입력을 받는다. 입력 동작은 프로젝트 설정의 입력 맵 탭에서 정의한다. 이 탭에서는 커스텀 이벤트를 정의하고 키, 마우스, 기타 입력을 할당할 수 있다.

유니티의 new InputSystem과 비슷한 개념으로 보인다. 언리얼의 enhanced input system과도 비슷한 개념으로 보인다.

입력 동작이 눌렸는지 아닌지는 Input.is_action_pressed()함수를 사용해 감지할 수 있다. 이 함수는 키가 눌린 상태라면 true를 반환하고, 눌리지 않았다면 false를 반환한다.

결과적으로 이동 방향을 알아내기 위해 화살표 키를 전부 검사하는 방식을 사용할 수도 있지만, 이동 방향을 알아내야 하는 경우가 매우 흔하기에 고도는 따로 이 일을 처리하는 Input.get_vector()함수를 제공한다.

이 함수에 사용할 입력 4가지만 알려주면 된다. 입력 동작이 나열되는 순서에 주목하자. get_vector()는 이 순서대로 처리한다. 이 함수의 결과는 방향 벡터로 눌린 입력에 따라 8가지 가능한 방향 중 하나를 반환한다.

func _process(delta):
	velocity = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	position += velocity * speed * delta

이렇게 해서 움직일 방향을 알아내 이후 위치를 실제로 업데이트 한다. 이상태로 씬을 실행하면 움직이긴 하지만 플레이어가 화면 밖으로 나가버리기 때문에 이를 제한해줘야 한다.

	position.x = clamp(position.x, 0, screensize.x)
	position.y = clamp(position.y, 0, screensize.y)
delta에 대해

_process()함수에는 delta라는 매개변수가 있고 여기에 velocity를 곱하는데 과연 delta는 무엇일까?

현재 게임엔진은 일정하게 초당 60프레임으로 실행하려고 한다. 그러나 고도에서든 컴퓨터에서 동시에 실행 중인 프로그램 때문이든 컴퓨터 속도가 느려지면 불가능할 수도 있다. 프레임율이 일정하지 않으면 게임 속 오브젝트의 움직임에 영향을 미친다.

예를 들어 프레임당 10픽셀씩 움직이길 바라는 오브젝트를 생각해보면 모든 것이 매끄럽게 돌아간다면 이 오브젝트는 1초에 600픽셀을 이동한다. 그러나 일부 프레임이 조금 더 오래 걸린다면 1초에 50프레임밖에 안 될 수 있으므로 오브젝트의 이동도 500픽셀밖에 안 될 수 있다.

고도뿐만 아니라 대다수의 게임 엔진은 delta라는 값으로 이 문제를 해결한다. 이 값은 이전 프레임 이후에 경과된 시간이다. 대부분의 경우 이 값은 약 0.016초에 매우 근접하며 이 예에서 원하는 속도인 초당 600픽셀에 이 delta를 곱하면 정확히 10픽셀의 움직임을 얻을 수 있다.

그러나 만일 delta가 0.03초로 증가했다면, 오브젝트는 18픽셀 이동한다. 덕분에 전반적으로 보면 이동속도가 프레임 속도와 상관없이 일정하게 유지된다.

또한 이동을 프레임당 픽셀이 아닌 초당 픽셀 단위로 표현할 수 있어 시각화하기 쉽다는 부가적인 장점도 있다.

애니메이션 선택

이제 플레이어 이동이 가능해졌으니, AnimatedSprite2D가 재생하는 애니메이션을 플레이어가 움직이는지 가만히 서 있는지에 따라 변경해야 한다. 또한 run 애니메이션의 아트는 오른쪽을 향하고 있음으로 Flipe H를 사용해 왼쪽을 향하게 만들어야 한다.

if velocity.length() > 0:
	$AnimatedSprite2D.animation = "run"
else:
	$AnimatedSprite2D.animation = "idle"
if velocity.x != 0:
	$AnimatedSprite2D.flip_h = velocity.x < 0
  • 노드 가져오기
    • $표기법을 사용할 때, 노드 이름은 스크립트를 실행하는 노드에 대해 상대적이다. 예를 들어 $Node1/Node2는 스크립트를 실행하는 노드의 자식 노드의 자식 노드인 Node2를 가리킨다. 고도의 자동완성 기능은 사용자가 입력할 노드이름을 제안해줄 것이다.
    • 노드 이름에 공백이 포함된다면 “ “로 감싸야 한다.

플레이어의 이동 시작 및 종료

메인 씬은 플레이어에게 게임이 언제 시작되고 종료됐는지를 알릴 필요가 있다. 이를 위해 플레이어에 start()함수를 추가한다. 이 함수는 플레이어의 시작 위치와 애니메이션을 설정할 것이다.

func start():
	set_process(true)
	position = screensize / 2
	$AnimatedSprite2D.animation = "idle"

그리고 장애물에 부딪히거나 시간이 부족할 때 호출되는 die()함수도 추가한다.

func die():
	$AnimatedSprite2D.animation = "hurt"
	set_process(false)

set_process()함수는 고도에 매 프레임 _process()함수를 호출하도록 지시한다. 이 함수를 true로 설정하면 _process()함수가 호출되고, false로 설정하면 호출되지 않는다.

충돌에 대비하기

플레이어가 동전이나 장애물에 부딪혔을 때의 기능은 고도의 시그널을 활용하여 구현할 수 있다. 시그널은 노드가 메시지를 보내서 다른 노드가 감지하고 반응할 수 있게 하는 방법이다.

대다수 노드에는 이벤트가 발생했을 때 이를 알려주는 시그널이 내장되어 있다. 커스텀도 가능하다. 시그널은 시그널을 수신하려는 노드에 연결해 사용한다. 연결은 인스펙터 창이나 코드를 통하여 할 수 있다. 이 프로젝트는 후반에 2가지 방법으로 시그널을 사용할 것이다.

signal picup ## 동전에 닿았을 때 발신할 시그널
signal hurt ## 장애물에 닿았을 때 발신할 시그널

이 코드는 플레이어가 장애물에 닿았을 때 발신할 커스텀 시그널을 선언한다. 닿았는지는 Area2D에서 감지한다. Player노드를 선택하고 인스펙터 탭 옆에 있는 노드 탭을 클릭하면 플레이어가 발신할 수 있는 시그널 목록을 확인할 수 있다.

이 때 연결할 노드를 선태하고 시그널 목록에서 사용할 시그널을 선택하여 연결하면 자동으로 스크립트에 시그널 핸들러가 추가된다.

1단계 마무리된 스크립트

extends Area2D

signal pickup ## 동전에 닿았을 때 발신할 시그널
signal hurt ## 장애물에 닿았을 때 발신할 시그널

@export var speed = 350

var velocity = Vector2.ZERO
var screensize = Vector2(480, 720)

# Called when the node enters the scene tree for the first time.
func _ready():
	pass # Replace with function body.

func start():
	set_process(true)
	position = screensize / 2
	$AnimatedSprite2D.animation = "idle"

func die():
	$AnimatedSprite2D.animation = "hurt"
	set_process(false)

# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
	velocity = Input.get_vector('ui_left', 'ui_right', 'ui_up', 'ui_down')
	position += velocity * speed * delta
	position.x = clamp(position.x, 0, screensize.x)
	position.y = clamp(position.y, 0, screensize.y)
	
	# Animation
	if velocity.length() > 0:
		$AnimatedSprite2D.animation = "run"
	else:
		$AnimatedSprite2D.animation = "idle"
	if velocity.x != 0:
		$AnimatedSprite2D.flip_h = velocity.x < 0

func _on_area_entered(area):
	if area.is_in_group("coins"):
		area.pickup()
		pickup.emit()
	if area.is_in_group("obstacles"):
		hurt.emit()
		die()

2단계: 동전 씬

이 절에서는 플레이어가 모을 동전을 만들 것이다. 이것은 별도의 씬이며, 동전 하나의 모든 속성과 동작을 묘사할 것이다. 다 만들고 저장하면 메인 씬이 이 씬을 로드해서 여러 개의 인스턴스를 생성할 것이다.

노드 설정

-> 새 씬을 클릭하고 다음 노드를 추가한다. 플레이어 씬에서 했던 것처럼 자식을 선택할 수 없게 설정한다.

동전 씬도 마찬가지로 다음과 같은 노드를 추가한다.

  • Area2D
    • CollisionShape2D
    • AnimatedSprite2D
그룹 사용

그룹이란 노드에 태그를 달아서 유사한 노드를 식별할 수 있게 하는 시스템이다. 한 노드는 동시에 여러 그룹에 속할 수 있다. 플레이어 스크립트가 동전을 올바르게 감지하려면 모든 동전이 coins그룹에 속해야 한다. Coin 노드를 선택하고 노드 탭에서 그룹을 추가하여 coins그룹에 추가한다.

동전 스크립트

다음 단계는 Coin 노드에 스크립트를 추가하는 것이다. Player 노드 때와 마찬가지로 노드를 선택하고 새 스크립트 버튼을 클릭한다. 템플릿 옵션을 선택 해제하면 주석이나 제안 사항이 없는 빈 스크립트가 표시된다.

extends Area2D

var screensize = Vector2.ZERO

func pickup():
	queue_free()

플레이어 스크립트에서 pickup() 함수가 호출된다는 점을 생각해보면 이 함수는 동전이 수집될 때 할 일을 정의한다. queue_free()는 고도에서 노드를 제거하는 메서드다. 트리에서 노드를 안전하게 제거하고 메모리에서 삭제한다.

  • 노드 제거
    • queue_free()는 오브젝트를 즉시 삭제하지 않고, 현재 프레임이 끝날 때 삭제한 대기열에 추가한다. 이편이 노드를 즉시 삭제하는 것보다 안전한데, 게임에서 실행 중인 다른 코드에서는 아직 해당 노드가 필요할 수 있기 때문이다. 프레임이 끝날 때까지 기다림으로써, 해당 노드에 접근할 수 있는 모든 코드가 완료되고 그 노드를 안전하게 제가 가능하다고 확신할 수 있다.

직접 메모리 해제 시점을 지정할 수 있다는 점에서 유니티보다 더 세밀한 메모리 관리가 가능해 보인다.

이제 2가지 오브젝트 중 두 번째도 완성했다. 화면에 무작위로 배치할 동전 오브젝트가 준비됐으며, 이제 메인 씬에 이 오브젝트를 추가할 차례다.

3단계: 메인 씬

Main씬은 게임의 모든 조각을 하나로 묶는 역할을 한다. 이 씬이 플레이어, 코인, 시계, 기타 모든 게임 요소를 관리하게 된다.

개인적으로 노드, 씬 이런 구조 자체가 깃허브 플로우와 유사하여 개발 과정 또한 좀 더 익숙하게 느껴진다.

노드 설정

마찬가지로 새 씬을 생서앟고 Main이라는 노드를 생성한다. 이 때 가장 간단한 유형인 Node로 생성하는데, 자체적인 별다른 기능은 없고 부모 오브젝트로서의 역할만 한다.

이후 플레이어를 Main의 자식 인스턴스로 추가하기 위해 자식 씬 인스턴스화 버튼을 눌러서 플레이어 씬을 추가한다.

이후 TextureRect노드를 추가하고 Background라는 이름을 붙인다. 이 노드는 게임의 배경을 표시할 것이다. Timer 노드를 추가하고 GameTimer라는 이름을 붙인다. 이 노드는 게임의 시간을 추적하고 게임이 끝나는 시점을 결정할 것이다.

이후 Background노드를 첫 번재 자식 노드가 되도록 플레이어 위로 드래그한다. 노드는 트리에 표시된 순서대로 그려지므로 Background가 첫 번째에 있으면 플레이어 뒤에 그려지는 것이 보장된다. 에셋 폴더에서 grass.png 이미지를 Background 노드의 Texture 영역으로 드래그해서 이미지를 추가한다.

  • Background노드의 Texture 영역으로 드래그앤 드롭하여 직접 png를 먹일 수 있다.
  • Stretch 모드를 tile로 변경하고 앵커 프리셋을 공간 전체로 설정한다.

메인 스크립트

extends Node

@export var coin_scene : PackedScene
@export var playtime = 30

var level = 1
var score = 0
var timer_left = 0
var screensize = Vector2.ZERO
var playing = false

이제 Main노드를 선택하면 인스펙터 창에 Coin Scene과 Playtime 속성이 나타난다. (export 어노테이션을 사용했기 때문) 파일 시스템 패널에서 Coin.tscn을 Main 노드의 Coin Scene 속성에 드래그앤 드롭하여 연결한다.

초기화

게임 시작을 위해 _ready() 함수를 추가한다.

func _ready():
	screensize = get_viewport().get_visible_rect().size
	$Player.screensize = screensize
	$Player.hide()

고도는 어느 노드든 추가될 때마다 _ready()를 자동으로 호출한다. 따라서 노드가 최초로 시작될 때 실행되어야 하는 코드를 넣기 좋은 곳이다.

$구문을 사용해 이름으로 Player 노드를 참조한다는 점에 주목하자. 이렇게 함으로써 게임 화면의 크기를 찾아 플레이어의 screensize 변수를 설정할 수 있다. hide()는 노드를 보이지 않게 만들어서, 게임이 시작되기 전에는 플레이어를 볼 수 없다.

새 게임 시작

new_game() 함수는 새 게임을 할 수 있게 모든 것을 초기화한다.

func new_game():
	playing = true;
	level = 1
	score = 0
	timer_left = playtime
	$Player.start()
	$Player.show()
	$GameTimer.start()
	spawn_coins()
func spawn_coins():
		for i in level + 4:
			var c = coin_scene.instantiate()
			add_child(c)
			c.screensize = screensize
			c.position = Vector2(randi_range(0, screensize.x), randi_range(0, screensize.y))
			

이 함수에서 Coin오브젝트의 인스턴스를 여러 개 생성하고 이를 Main 오브젝트의 자식으로 추가한다. (코드로 추가함) 새 노드를 인스턴스화할 때 마다 add_child()를 사용해 씬 트리에 추가한다. 마지막으로, 동전의 위치를 랜덤으로 선택하는데, screensize 변수를 사용해 화면 밖으로 나타나지 않게 한다. 매 레벨이 시작될 때마다 이 함수를 호출해 레벨이 올라갈수록 동전을 더 많이 생성한다.

최종적으로는 플레이어가 메뉴에서 시작버튼을 눌렀을 때 new_game()함수를 호출하면 게임이 시작된다.

남은 동전 확인

메인 스크립트는 플레이어가 모든 코인을 집었는지 아닌지를 감지할 필요가 있다. 동전은 모두 coins그룹에 포함되어 있으므로, 이 그룹의 크기를 확인하면 얼마나 남았는지 알 수 있다. 지속적으로 확인해야 하므로 _process()함수에 추가한다.

func _process(delta):
	if playing and get_tree().get_nodes_in_group("coins").size() == 0:
		level += 1
		time_left += 5
		spawn_coins()

4단계: 사용자 인터페이스

이 게임에 필요한 마지막 요소는 사용자 인터페이스다. UI는 플레이어가 게임 플레이중에 알아야 하는 정보를 표시해주는데, 게임 뷰 위에 오버레이로 표시되기 때문에 헤드업 디스플레이라고 할 때도 많다. 게임 오버 후 시작 버튼을 표시할 때도 이 씬을 사용할 것이다.

  • 점수
  • 남은 시간
  • 게임 오버 등의 메시지
  • 시작 버튼

노드 설정

새 씬을 생성하고 CanvasLayer 노드를 추가한 다음 이름을 HUD로 바꾼다. CanvasLayer 노드는 새 드로잉 레이러를 생성하는데, 여기에 그리는 UI 요소는 게임의 나머지 부분 위에 있어서 플레이어나 동전 같은 게임 오브젝트로 가려지지 않는다.

고도는 체력 바같은 표시기부터 인벤토리 같이 복잡한 인터페이스까지 뭐든지 만들어낼 수 있는 다양한 UI 요소를 제공한다. 사실 이 게임을 제작하는 데 사용하는 고도 에디터 자체도 고도 UI 요소를 사용해 만들어졌다.

UI용 기본 노드는 모두 Control에서 확장되며 노드 목록에서는 녹색 아이콘으로 구분된다. UI를 만들 때는 위치, 포맷, 정보 표시에 다양한 Control노드를 사용할 것이다.

메시지 레이블

씬에서 Label노드를 추가하고 원하는 글꼴과 정렬, 크기를 선택한다. 글꼴의 경우엔 Label Settings에서 새로운 라벨 세팅을 만들어서 사용할 수 있다.

점수 및 시간 표시 개요

HUD 상단에는 플레이어의 점수와 시계에 남은 시간이 표시된다. 둘 다 Label 노드이며, 게임 화면에서 맞은편에 배치된다. 둘의 위치를 별도로 잡는 대신 컨테이너(Container)노드를 사용해 위치를 관리한다.

컨테이너

고도의 Container노드는 자식 Control노드의 위치와 크기를 자동으로 정렬한다. 이를 사용해 요소 주위에 가장자리를 추가하거나, 중앙 정렬을 유지하거나, 행과 열로 정렬할 수 있다. 각 Container유형에는 자식의 정렬 방식을 제어하는 특수 속성이 있다.

컨테이너는 자동으로 자식을 정렬한다는 점을 기억하자. Container 노드 안에 있는 Control을 이동하거나 크기 조정을 하려고 하면 에디터에 경고가 표시될 것이다.

점수 및 시간 표시 구현

점수 및 레이블을 관리하기 위해 HUD에 MarginContainer 노드를 추가한다.

  • 앵커 프리셋에서 앵커를 위쪽 넓게로 설정한다.
  • 인스펙터 창의 Theme Overrides/Constant 섹션에서 Margin 속성 4개를 10으로 설정한다.
  • Label을 MarginContainer의 자식으로 추가한다. (score, time)
  • 각각 알맞은 위치로 정렬한다.

GDScript를 통한 UI 업데이트

HUD 노드에 스크립트를 추가한다. 이 스크립트는 동전을 모을 때마다 Score 텍스트를 업데이트 하는 등 속성을 변경해야 할 UI 요소를 업데이트한다.

extends CanvasLayer

signal start_game

func update_score(value):
	$MarginContainer/Score.text = str(value)
	
func update_timer(value):
	$MarginContainer/Time.text = str(value)

Main씬의 스크립트는 값이 변화할 때마다 이 두 함수를 호출해 표시된 내용을 업데이트할 것이다. Message 레이블이 조금 지나면 사라지도록 타이머도 필요하다.

Timer노드를 HUD자식으로 추가하고 Wait Time을 2초, One Shot을 사용으로 설정한다. 이렇게 하면 타이머가 시작될 때 한 번만 실행되고 반복되지 않을 것이다. 시그널 연결

func show_message(text):
	$Message.text = text
	$Message.show()
	$Timer.start()

func _on_timer_timeout():
	$Message.hide()
버튼 사용

Button 노드를 HUD에 추가하고 이름을 StartButton으로 바꾼다. 이 버튼은 게임이 시작되기 전에 표시되며, 클릭하면 사라지면서 Main씬에 게임을 시작하라는 시그널을 보낸다.

func _on_start_button_pressed():
	$StartButton.hide()
	$Message.hide()
	start_game.emit()

게임 오버

UI 스크립트의 마지막 작업은 게임 종료에 반응하는 것이다.

func show_game_over():
	show_message("Game Over")
	await $Timer.timeout
	$StartButton.show()
	$Message.text = "Coin Dash!"
	$Message.show()

메인에 HUD 추가

Main에 HUD 인스턴스를 추가하여 연결한다.

5단계: 마무리

게임 개발자는 게임을 플레이하는 느낌을 좋게 만드는 요소를 묘사할 때 주스라는 용어를 사용한다. 주스에는 사운드, 비주얼 이펙트, 그 밖에 게임 플레이의 본질을 바꾸지 않으면서 플레이의 즐거움을 더하는 온갖 추가 요소를 포함한다.

비주얼이펙트

트윈이란?

트윈은 특정 수학 함수를 사용해 어떤 값을 시간에 따라 보간하는 방법이다.

고도에선 트윈을 사용할 때는, 노드의 속성을 1개 이상 변경하게 할당할 수 있다. 이번 경우에는 동전의 크기를 키웠다가 Modulate속성을 사용해 동전을 페이드아웃할 것이다. 트윈이 역할을 다하면 동전은 삭제된다.

  • 조건중 동전을 즉시 제거하지 않으면 2번이상 트리거가 되기에 조건을 달아 방지해야 한다.
func pickup():
	$CollisionShape2D.set_deferred("disabled", true)
	var tw = create_tween().set_parallel().set_trans(Tween.TRANS_QUAD)
	tw.tween_property(self, "scale", scale * 3, 0.3)
	tw.tween_property(self, "modulate:a", 0.0, 0.3)
	await tw.finished
	queue_free()

이와 같이 수정되어야 한다.

먼저 CollisionShape2Ddisabled속성을 true로 변경해야 할 필요가 있지만, 바로 설정하면 고도엔진에서 경고를 준다. 이는 콜리전이 처리되는 동안 물리 속성을 변경할 수 없기 때문이다. 따라서 free_queue()와 같이 현재 프레임이 끝날 때 까지 기다려야 한다. 이 역할을 하는 것이 set_deferred()이다.

다음 줄의 create_tween()은 트윈 오브젝트를 생성하고, set_parallel()은 다음 트윈 순서를 순차적이 아니라 동시에 발생하게 하며, set_trans()는 전환 함수를 ‘2차 곡선`으로 설정한다.

다음에 오는 2줄은 속성의 트위닝을 설정한다. tween_property()는 매개변수를 4개 받는데, 각각 영향을 줄 오브젝트, 변경할 속성, 종료값, 지속 시간이다.

사운드

Main씬에 AudioStreamPlayer노드를 추가하고 이름을 각각 이펙트에 맞게 변경한 뒤 알맞는 사운드를 Stream속성에 넣는다. 이후 Play()함수를 호출하여 사운드를 재생한다.

파워업

코인과 비슷한 기능을 하는 파워업은 유니티의 프리팹과 같이 코인씬을 복사하여 활용할 수 있다.

느낀점

  • 런타임 도중에 노드의 상태를 시각적으로 볼 수 있으면 좋을 것 같다.

태그: ,

카테고리:

업데이트:

댓글남기기