개인적으로 가장 기대하던 파트다. 오늘은 메인 게임 씬을 만들어본다.
튜토리얼 링크 : https://docs.godotengine.org/en/stable/getting_started/first_2d_game/05.the_main_game_scene.html
1. Main scene 생성
새 씬을 만든 다음, "Node"라는 이름의 노드를 추가하고 Main으로 이름을 바꾼다.
Node2D 대신 Node를 사용하는 이유는, 단순히 이 Main 노드는 게임의 로직을 구성하는 데에만 사용될 것이기 때문이다. 즉, 기능적으로 2D까지 담아낼 필요가 없다.
이 상태에서 Instance 버튼을 클릭해, 이전 시간에 저장해 둔 player.tscn 파일을 불러와보자.
이후 다음 노드들을 Main의 자식 노드로 추가해주자. 이름은 괄호 안의 단어로 수정해 준다.
- Timer (MobTimer) - 몹이 얼마나 자주 생성될 것인지 컨트롤할 노드
- Timer (ScoreTimer) - 1초마다 점수가 몇 점 오를 것인지 컨트롤
- Timer (StartTimer) - 게임을 시작하기 직전에 딜레이를 주기 위한 노드
- Marker2D (StartPosition) - 플레이어가 시작할 위치를 표시할 노드
그다음, 각각의 Timer 노드의 Wait Time을 다음과 같이 설정해 준다. (이 값들은 모두 초 단위)
- MobTimer: 0.5
- ScoreTimer: 1
- StartTimer: 2
더불어, StartTimer에서 One Shot 속성을 켜주고, StartPosition 노드의 position 값을 (240, 450)으로 맞춰준다.
2. 몹 생성 (Spawning mobs)
몹들은 화면의 경계 부분에서 랜덤하게 등장할 예정이다.
Main의 자식 노드로 Path2D 노드를 추가한 뒤, 이름을 MobPath로 바꿔주자.
이후 해당 노드를 선택하면 당신은 에디터 위에 새 버튼이 생긴 걸 확인할 수 있을 것이다.
그중 가운데 버튼("Add Point")을 선택해서 화면의 경계를 Path로 그려주자. 포인트를 그리드에 스냅하려면 "Use Grid Snap", "Use Smart Snap" 기능을 적절히 사용하면 된다. (아래 사진 빨간 동그라미)
참고로 그리는 그리는 순서는 시계 방향으로, 다음 사진을 참고한다. (반시계 방향으로 그리면 몹들이 밖>안이 아니라 안>밖으로 튕겨나가게 된다는 거 같다?)
다 그리고 난 뒤엔 "Close Curve" 버튼으로 커브를 닫아 마무리한다.
path가 그려졌으니, 이제 MobPath의 자식으로 PathFollow2D 노드를 생성하고, 이것의 이름을 MobSpawnLocation으로 수정한다.
그러면 이 노드는 자동으로 해당 path에 붙어서 따라가거나, 회전하게 된다. (해당 노드의 Progress Ratio 속성을 0~1로 움직여보자.)
우린 이 노드를 통해 랜덤 위치와 방향을 설정해 줄 것이다.
이 즈음에서 Main 노드 구조 정리. 저장도 잊지 말자.
3. Main 스크립트 작성
몹을 생성하고, 플레이어를 배치하기 위해 이제 스크립팅을 할 시간이다.
Main 노드에 스크립트를 추가해 주자. 스크립트의 위쪽, extends Node 아래에 다음과 같이 코드를 추가해 준다.
extends Node
@export var mob_scene: PackedScene
var score
총 두 줄 추가되었다.
이제 다시 Main노드로 돌아가면, 우리가 export한 Mob Scene이 생성되어 있음을 확인할 수 있다.
여기에 우리는 mob.tscn을 등록해 주자. (참고로 에디터 내에서 드래그 앤 드랍으로도 지정해 줄 수 있다)
그다음으로, Main 노드의 자식으로 만들어뒀던 Player (instance) 노드로 가보자. 해당 노드를 선택한 뒤, 인스펙터에서 노드 탭으로 이동하면 여기서도 Signal을 선택할 수 있다.
player.gd의 시그널 중 hit()을 오른쪽 마우스로 클릭하고 "Connect..."를 선택한다. 우리는 해당 시그널을 game over 용으로 사용할 것이므로, Receiver Method를 game_over로 변경해 준 뒤 connect를 눌러 마무리한다.
이때 우리는 이 시그널을 Main에서 사용하려는 것이므로 Main 노드가 선택되어 있어야 한다.
즉, 다시 정리하자면, 우리는 Player에서 특정 조건 만족 시 발생하는 hit() 신호를 Main 노드에서 받아 사용하려는 것이다. 이 부분은 분명히 알아야 한다.
이제 다음의 코드를 입력해 보자. 그와 함께 new_game 함수도 새로 생성해 준다.
func game_over() -> void:
$ScoreTimer.stop()
$MobTimer.stop()
func game_start():
score = 0
$Player.start($StartPosition.position)
$StartTimer.start()
이제, 각 Timer 노드의 timeout() 시그널을 Main 스크립트에 연결해 줄 차례다.
우리가 만든 Timer 노드는 총 3개가 있었다. (StartTimer, ScoreTimer, MobTimer)
StartTimer가 끝나면 다른 두 타이머가 작동될 것이다.
ScoreTimer는 Main 안에 있는 score 변수를 1초에 1번 증가시켜 줄 것이다.
위 두 타이머의 timeout을 main에 연결해 준 뒤 아래 같은 코드를 넣어준다.
func _on_start_timer_timeout() -> void:
$MobTimer.start()
$ScoreTimer.start()
func _on_score_timer_timeout() -> void:
score += 1
또한, MobTimer는 timeout 시에 mob 인스턴스를 만들고, Path2D의 특정 위치를 시작지점으로 한 뒤, mob을 움직이는 것까지 작동하게끔 만들 것이다.
PathFollow2D 노드는 자동으로 path를 따라 이동하고, 회전한다. 그러므로 우리는 새로 생성될 몹의 위치와 방향을 이 노드의 위치, 방향을 복사해서 사용할 수 있을 것이다.
몹이 생성될 때, 우리는 150.0 에서 250.0 사이의 랜덤한 속도로 몹이 움직이게끔 해줄 것이다.
MobTimer 의 timeout() 시그널을 Main에 연결시켜 준 뒤 아래와 같은 코드를 작성해 보자.
func _on_mob_timer_timeout() -> void:
# instance로서 새로운 몹을 생성한다.
var mob = mob_scene.instantiate()
# MobSpawnLocation 객체 자체를 가져온 뒤 랜덤값을 대입한다. (이후 이 노드의 위치값을 뽑기 위해)
var mob_spawn_location = $MobPath/MobSpawnLocation
mob_spawn_location.progress_ratio = randf() #random float
# 위 객체에서 위치값을 가져온다.
mob.position = mob_spawn_location.position
# MobSpawnLocation 객체에서 패스 기준으로 수직 방향을 가져온다.
var direction = mob_spawn_location.rotation + PI / 2
# 수직방향에 랜덤한 각도를 더해준 뒤 방향값을 가져온다.
direction += randf_range(-PI / 4, PI / 4)
mob.rotation = direction
# 몹의 속도를 정해준다.
var velocity = Vector2(randf_range(150.0, 250.0), 0.0)
mob.linear_velocity = velocity.rotated(direction) #linear면 그냥 velocity도 있나?
# Main Scene의 자식으로 붙여준다. 즉, 오브젝트를 표시/생성 한다.
add_child(mob)
PI는 또 뭐냐?
Godot에서는 각도를 잴 때 degrees 대신 radians를 사용한다. 수학적 개념은 넘어가고 본론만 말하면 180 degree = π radian을 의미한다. π는 익히 잘 아는 3.1415... 의 그 숫자가 맞고, Godot 스크립팅 문법에서 이는 PI라고 표기할 뿐이다.
이 둘 사이를 변환하기 위해선 deg_to_rad() 나 rad_to_deg() 함수를 사용할 수도 있다.
add_child()로 mob을 더하는 이유?
이건 그냥 추측인데 godot이 노드 트리 구조로 이루어져 있고, Main 노드를 실행한 결과로써 생긴 것이 mob 객체이기 때문이 아닌가 싶다.
예를 들어 Main 노드가 집이고, 우리는 게임 화면에서 집 안에 있는 것만 볼 수 있다고 치자. 근데 mob_scene.instantiate()는 집 밖 허공에 mob 객체를 "생성"만 해주는 명령어다. 그러므로 mob 자체를 집안으로 다시 불러오는 명령어(구체적으로 말하면 Main 노드의 "자식"으로 갖고 오는 명령어)가 add_child() 같다.
4. 게임 테스트
마지막으로, 게임을 테스트하기 위해 _ready() 함수에 new_game()을 넣어보자.
func _ready():
game_start()
pass
또한, 우리의 Main 노드를 "Main Scene"으로 지정해 준다.
"Main Scene"으로 지정한다는 건 게임이 실행되었을 때 자동으로 이 씬을 동작시키겠다는 뜻이다.
고도 에디터에서 Play 버튼을 누른 뒤, 알림 창이 뜨면 main.tscn으로 설정해 주자.
덤) 만약 당신이 이미 다른 씬을 Main Scene으로 지정했다면, 고도 에디터>FileSystem에서 main.tscn을 찾은 뒤 오른쪽 클릭>"Set as main scene"을 해주면 된다. (아래 사진)
테스트까지 성공적으로 마쳤다면 _ready() 안에 넣어둔 new_game()을 지워주자.
(우리는 게임창을 켰을 때 새 게임이 바로 실행되지 않도록 한 뒤, 특정 버튼을 클릭해야 시작되도록 만들 예정이다.)
python 문법에 따라 _ready() 함수를 pass 없이 비워두면 에러가 뜬다는 사실을 잊지 말자.
이제야 좀 게임스럽긴 하지만, 아직은 뭔가 부족하다. 점수표가 안 보이니 성취감도 별로 안 생기는 거 같다.
다음 시간에 우리는 타이틀 스크린과 점수 표시 UI 요소를 만들어본다.