당신의 첫 게임

개요

이 튜토리얼은 첫 Godot 프로젝트를 만드는 과정을 안내합니다. Godot 편집기가 어떻게 작동하는지, 프로젝트를 구성하는 방법, 2D 게임을 만드는 방법을 배우게 됩니다.

주석

이 프로젝트는 Godot 엔진에 대한 소개입니다. 이 튜토리얼에서 당신은 프로그래밍 경험이 조금 있다고 가정하겠습니다. 프로그래밍에 완전 처음이라면 여기서부터 시작하세요: 스크립팅(Scripting).

게임 이름은 "Dodge the Creeps!"입니다. 캐릭터는 가능한 오랫동안 움직이면서 적을 피해야 합니다. 다음은 앞으로 보게 될 최종 결과물입니다:

../../_images/dodge_preview.gif

왜 2D인가요? 3D 게임은 2D 게임보다 훨씬 더 복잡합니다. 게임 개발 과정을 잘 알 때까지는 2D 게임을 개발하는 것이 좋습니다.

프로젝트 설정하기

Godot를 실행하고 새 프로젝트를 만드세요. 그런 다음 dodge_assets.zip을 다운로드하세요. 여기에는 게임에 쓸 이미지와 소리가 있습니다. 프로젝트 폴더에서 압축을 풀어줍니다.

주석

이 튜토리얼에서는 당신이 편집기에 익숙하다고 가정하겠습니다. 아직 씬과 노드(Scenes and nodes)를 읽지 않았다면 당장 돌아가세요. 돌아가서 편집기를 사용해 프로젝트를 설정하는 법을 배우고 오세요.

이 게임은 세로 모드를 사용합니다. 따라서 게임 창의 크기를 조정해야 합니다. 프로젝트(Project) -> 프로젝트 설정(Project Settings) -> Display -> Window를 클릭하고 "Width"를 480으로, "Height"를 720으로 설정합니다.

프로젝트 조직하기

이 프로젝트에서 우리는 Player , Mob , HUD의 세 개의 개별 씬을 만들 것입니다. 이 씬들을 게임의 Main 씬으로 묶을 것입니다. 규모가 큰 프로젝트라면 다양한 씬과 스크립트를 저장하는 폴더를 만드는 것이 유용하겠지만, 상대적으로 규모가 작은 이 게임에서는 res://라고 하는 루트 폴더에 씬과 스크립트를 저장하겠습니다. 프로젝트 폴더는 왼쪽 아래에 있는 파일 시스템(FileSystem) 독에서 볼 수 있습니다:

../../_images/filesystem_dock.png

Player(플레이어) 씬

처음으로 정의할 씬은 Player 객체입니다. 별도의 Player 씬을 만들면 게임의 다른 부분을 만들기 전에 따로 실험해 볼 수 있다는 장점이 있습니다.

노드 구조

시작하려면 "새 노드 추가하기/만들기(Add/Create a New Node)" 버튼을 클릭하고 :ref:`Area2D <class_Area2D>`노드를 씬에 추가하세요.

../../_images/add_node.png

Area2D를 사용하면 Player와 겹치거나 부딪히는 물체를 감지할 수 있습니다. 노드 이름을 클릭하고 이름을 Player로 바꾸세요. 이 노드가 씬의 루트 노드입니다. Player에게 기능을 추가하기 위해 별도의 노드를 추가할 수 있습니다.

Player 노드에 자식을 추가하기 전에, 실수로 자식을 클릭해서 자식이 움직이거나 크기가 바뀌지 않도록 해야 합니다. 노드를 선택하고 자물쇠 오른쪽에 있는 아이콘을 클릭하세요. 노드의 툴 팁에는 "객체의 자식을 선택하지 않도록 해요." 라고 표시됩니다.

../../_images/lock_children.png

씬을 저장합니다. 씬 -> 씬 저장을 클릭하거나 Windows/Linux에서는 Ctrl+S 를, Mac에서는 Command+S 를 누릅니다.

주석

이 프로젝트에서, 우리는 Godot의 명명 규칙을 따를 것입니다.

  • GDScript: 클래스(노드)는 PascalCase 를 사용하고', 변수와 함수는 snake_case 를 사용하며, 상수는 ALL_CAPS 를 사용합니다.
  • C#: 클래스, export 변수 그리고 메쏘드는 PascalCase를 사용합니다. private 필드는 _camelCase를 사용합니다. 지역변수(local variables)와 파라미터는 camelCase를 사용합니다 (참고 C# 스타일 가이드). 시그널을 연결할 때는 메쏘드 이름을 정확하게 입력하도록 주의하세요.

스프라이트 애니메이션

Click on the Player node and add an AnimatedSprite node as a child. The AnimatedSprite will handle the appearance and animations for our player. Notice that there is a warning symbol next to the node. An AnimatedSprite requires a SpriteFrames resource, which is a list of the animations it can display. To create one, find the Frames property in the Inspector and click "[empty]" -> "New SpriteFrames". This should automatically open the SpriteFrames panel.

../../_images/spriteframes_panel.png

왼쪽에는 애니메이션 목록이 있습니다. "default"를 클릭하고 "right"로 이름을 바꾸세요. 그런 뒤 "추가" 버튼을 눌러 두 번째 애니메이션을 만들고 "up"이라 이름을 지읍시다. 각 애니메이션에 두 개의 이미지를 드래그 하고, "애니메이션 프레임" 패널 안에 하나는 playerGrey_up[1/2] 다른 하나는 playerGrey_walk[1/2] 라고 이름을 지읍니다:

../../_images/spriteframes_panel2.png

플레이어 이미지들이 게임 창에 비해 너무 크기 때문에, 크기를 줄여야 합니다. AnimatedSprite 노드를 클릭하고 Scale 속성을 (0.5, 0.5) 로 설정하세요. 인스펙터의 Node2D 에서 찾으실 수 있습니다.

../../_images/player_scale.png

마지막으로, CollisionShape2DPlayer의 자식으로 추가하세요. 이것이 플레이어의 "hitbox", 즉 충돌 영역의 범위를 결정합니다. 이 캐릭터에게는, CapsuleShape2D 노드가 가장 적합합니다, 그러므로 인스펙터의 "Shape" 옆에 "<비었음>"" -> "새 CapsuleShape2D"를 클릭하세요. 스프라이트를 덮도록 두 개의 크기 핸들로 크기를 조정하세요:

../../_images/player_coll_shape.png

다 되셨다면, 당신의 Player 씬은 이렇게 되어야 합니다:

../../_images/player_scene_nodes.png

플레이어 움직이기

이제 내장 노드 만으로는 얻을 수 없는 몇 가지 기능을 추가해야 합니다, 그래서 스크립트를 만들겁니다. Player 노드를 클릭하고 "스크립트 추가" 버튼을 누르세요:

../../_images/add_script_button.png

스크립트 설정 창에서는, 기본 설정으로 냅두어도 됩니다. 그냥 "생성"을 누르세요:

주석

만약 C# 스크립트나 다른 언어로 생성하려한다면, 생성을 누르기 전에 언어 메뉴에서 언어를 선택하세요.

../../_images/attach_node_window.png

주석

처음 GDScript를 보시는 거라면, 계속하기 전에 스크립팅(Scripting) 를 읽어주세요.

이 객체가 필요로 하는 멤버 변수를 선언함으로써 시작합시다:

extends Area2D

export var speed = 400  # How fast the player will move (pixels/sec).
var screen_size  # Size of the game window.
public class Player : Area2D
{
    [Export]
    public int Speed = 400; // How fast the player will move (pixels/sec).

    private Vector2 _screenSize; // Size of the game window.
}

첫 번째 변수인 speedexport 키워드를 사용하면 인스펙터 내에서 값을 설정할 수 있습니다. 편리한 점으로는 인스펙터에서도 원하는 값으로 조정할 수 있는 것입니다. Player 노드를 선택하면 인스펙터의 "Script Variables" 섹션에 나타납니다. 기억하세요, 여기서 값을 바꾸면 스크립트에 작성한 값도 재정의됩니다.

경고

당신이 C#을 사용한다면, 새로운 외부변수(export variables)나 시그널을 보기 위해서 프로젝트 구성물(assemblies)를 (재)컴파일(build)할 필요가 있습니다. 이 컴파일은 편집기 의 밑에 "Mono" 단어를 클릭하여 Mono 패널이 나타나게 한 후 "프로젝트 생성(Build)" 버튼을 눌러서 수동으로 진행됩니다.

../../_images/export_variable.png

_ready() 함수는 노드가 씬 트리에 들어올 때 호출됩니다, 이는 게임 창의 크기를 알아보기 좋은 순간입니다:

func _ready():
    screen_size = get_viewport_rect().size
public override void _Ready()
{
    _screenSize = GetViewport().GetSize();
}

이제 _process() 함수를 사용해서 플레이어가 무엇을 할 지 정의할 수 있습니다. _process()는 매 프레임마다 호출되므로, 게임에서 자주 변하는 요소들을 업데이트하기 위해서 사용할 수 있습니다. 플레이어에게는, 다음과 같은 작업이 필요합니다:

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

먼저, 입력을 확인해야 합니다 - 플레이어가 키를 누르는 것이라 할까요? 이 게임에서, 우리는 입력을 확인하기 위한 4개의 방향키를 갖고 있습니다. 입력 액션은 프로젝트 설정의 "Input Map"에서 정의할 수 있습니다. 사용자 지정 이벤트를 정의하고 이를 다른 키, 마우스 이벤트, 혹은 다른 입력으로 지정할 수도 있습니다. 이 데모에서는, 우리는 키보드에 있는 방향키가 지정된 기본 이벤트를 사용할 것입니다.

Input.is_action_pressed() 를 사용해서 키가 눌러졌는지를 감지할 수 있습니다, 눌러지면 true 값을 내보내고 그렇지 않으면 false 값을 내보냅니다.

func _process(delta):
    var velocity = Vector2()  # The player's movement vector.
    if Input.is_action_pressed("ui_right"):
        velocity.x += 1
    if Input.is_action_pressed("ui_left"):
        velocity.x -= 1
    if Input.is_action_pressed("ui_down"):
        velocity.y += 1
    if Input.is_action_pressed("ui_up"):
        velocity.y -= 1
    if velocity.length() > 0:
        velocity = velocity.normalized() * speed
        $AnimatedSprite.play()
    else:
        $AnimatedSprite.stop()
public override void _Process(float delta)
{
    var velocity = new Vector2(); // The player's movement vector.

    if (Input.IsActionPressed("ui_right"))
    {
        velocity.x += 1;
    }

    if (Input.IsActionPressed("ui_left"))
    {
        velocity.x -= 1;
    }

    if (Input.IsActionPressed("ui_down"))
    {
        velocity.y += 1;
    }

    if (Input.IsActionPressed("ui_up"))
    {
        velocity.y -= 1;
    }

    var animatedSprite = GetNode<AnimatedSprite>("AnimatedSprite");

    if (velocity.Length() > 0)
    {
        velocity = velocity.Normalized() * Speed;
        animatedSprite.Play();
    }
    else
    {
        animatedSprite.Stop();
    }
}

velocity(0, 0)으로 설정하는 것으로 시작합니다 - 기본 값으로 플레이어는 움직이지 않아야 합니다. 그 후 각 입력을 확인하고 velocity에서 더하기/빼기를 통해 전체 방향을 얻습니다. 예를 들어 당신이 오른쪽아래 를 동시에 누른다면, 결과 velocity 벡터는 (1, 1) 이 될 것입니다. 이런 경우에서, 상하좌우 움직임을 추가하는 것은, 플레이어는 단순히 수평으로 움직이는 것보다 더 빠르게 움직이게 됩니다.

우리는 이 속도를 노멀라이즈 해서 예방할 수 있습니다, 즉 우리는 이것의 범위1 로 설정하고, 원하는 속도로 곱해줍니다. 이렇게 하면 더 이상 대각선 이동속도가 빨라지지 않습니다.

참고

이제까지 벡터 수학을 안 써봤거나, 회복이 필요하다면, Godot에서 벡터 사용에 관한 설명을 벡터 에서 보실 수 있습니다. 보면 좋지만 나머지 튜토리얼을 위해 꼭 필수적인 것은 아닙니다.

우리는 또 AnimatedSprite 애니메이션을 재생하고 정지할 수 있도록 플레이어가 움직이고 있는지를 확인할 것입니다.

참고

GDScript에서, $는 현재 노드에서 상대적인 경로에 있는 노드를 반환하거나, 노드가 없다면 null 값을 반환합니다. AnimatedSprite가 현재 노드의 자식인 상태이므로, $AnimatedSprite를 사용할 수 있습니다.

$get_node() 의 줄임말입니다. 그래서 위의 코드인, $AnimatedSprite.play()get_node("AnimatedSprite").play() 와 같습니다.

Now that we have a movement direction, we can update the player's position. We can also use clamp() to prevent it from leaving the screen. Clamping a value means restricting it to a given range. Add the following to the bottom of the _process function:

position += velocity * delta
position.x = clamp(position.x, 0, screen_size.x)
position.y = clamp(position.y, 0, screen_size.y)
Position += velocity * delta;
Position = new Vector2(
    x: Mathf.Clamp(Position.x, 0, _screenSize.x),
    y: Mathf.Clamp(Position.y, 0, _screenSize.y)
);

참고

The delta parameter in the _process() function refers to the frame length - the amount of time that the previous frame took to complete. Using this value ensures that your movement will remain consistent even if the frame rate changes.

"씬 실행하기" (F6) 를 클릭하고 플레이어가 화면에서 전 방향으로 움직이는지 확인하세요. 씬을 실행할 때 열리는 콘솔 출력은 하단 패널의 왼쪽 하단에 출력 을 클릭해서 닫으실 수 있습니다 (파란색으로 강조되어 있을 것입니다).

경고

"디버거" 패널에서 "null instance"라고 하면서 에러가 뜬다면, 노드 이름의 철자를 틀렸다는 것을 의미합니다. 노드 이름은 대소문자를 구별하고 $NodeName 이나 get_node("NodeName") 이 씬 트리에서 보는 이름이 일치해야 합니다.

애니메이션 고르기

이제 캐릭터가 움직이니, 방향에 맞는 AnimatedSprite가 움직이도록 바꿔야 합니다. 우리는 "오른쪽" 애니메이션이 있고 이걸 왼쪽 움직임으로 쓰려면 가로로 뒤집어야 하므로 flip_h 속성을 써줍니다, 그리고 "위" 애니메이션도 아래 움직임으로 쓰려면 세로로 뒤집어야 하므로 flip_v 을 써줍니다. _process() 아래에 이 코드를 작성해주세요:

if velocity.x != 0:
    $AnimatedSprite.animation = "right"
    $AnimatedSprite.flip_v = false
    # See the note below about boolean assignment
    $AnimatedSprite.flip_h = velocity.x < 0
elif velocity.y != 0:
    $AnimatedSprite.animation = "up"
    $AnimatedSprite.flip_v = velocity.y > 0
if (velocity.x != 0)
{
    animatedSprite.Animation = "right";
    // See the note below about boolean assignment
    animatedSprite.FlipH = velocity.x < 0;
    animatedSprite.FlipV = false;
}
else if(velocity.y != 0)
{
    animatedSprite.Animation = "up";
    animatedSprite.FlipV = velocity.y > 0;
}

주석

위 코드 내의 불 할당은 프로그래머를 위한 일반적인 속기입니다. 위의 단축된 불 할당을 이 코드와 비교해서 생각해보세요:

if velocity.x < 0:
    $AnimatedSprite.flip_h = true
else:
    $AnimatedSprite.flip_h = false
if velocity.x < 0:
    animatedSprite.FlipH = true
else:
    animatedSprite.FlipH = false

씬을 다시 실행하고 애니메이션이 각 방향으로 잘 움직이는지 확인하세요. 잘 작동된다고 확신된다면, 플레이어가 게임이 시작할 때 안 보이도록 이 문장을 _ready() 에 추가하세요:

hide()
Hide();

충돌 준비하기

우리는 Player 가 적과 닿았다는 것을 감지하길 원합니다, 하지만 아직 적을 안 만들었습니다! 괜찮습니다, 왜냐하면 충돌이 작동하도록 우리는 Godot의 시그널(signal) 기능을 사용할 것이기 때문입니다.

extends Area2d 다음에 스크립트 상단에 다음을 추가하세요:

signal hit
// Don't forget to rebuild the project so the editor knows about the new signal.

[Signal]
public delegate void Hit();

이것은 "hit"라 불리는 사용자 지정 시그널을 정의해서 적과 충돌할 때 우리의 플레이어가 방출하도록 (내보내도록) 합니다. 우리는 Area2D 로 그 충돌을 감지할 겁니다. Player 노드를 선택하고 인스펙터 탭 옆의 "노드" 탭을 클릭하고 플레이어가 방출할 수 있는 시그널들을 확인해 보세요:

../../_images/player_signals.png

우리의 "hit" 시그널도 거기에 있다는 것에 주목하세요! 적들은 RigidBody2D 노드가 될 것이기 때문에, 우리는 body_entered( Object body ) 시그널이 필요하고, 이걸로 바디가 플레이어와 접촉할 때 방출하도록 할 것입니다. "연결하기.." 를 누르고 "시그널 연결하기" 창에서 "연결"을 누릅니다. 우리는 어떤 설정도 바꿀 필요가 없습니다 - Godot는 자동으로 플레이어 스크립트에 함수를 생성해줍니다. 이 함수는 시그널이 방출될 때마다 호출될 것입니다 - 시그널을 처리하는 것입니다.

참고

시그널이 연결될 때, Godot가 함수를 만들어 주는 대신, 당신이 직접 연결하고 싶은 시그널에 존재하는 함수의 이름을 줄 수 있습니다.

함수에 이 코드를 추가하세요:

func _on_Player_body_entered(body):
    hide()  # Player disappears after being hit.
    emit_signal("hit")
    $CollisionShape2D.set_deferred("disabled", true)
public void OnPlayerBodyEntered(PhysicsBody2D body)
{
    Hide(); // Player disappears after being hit.
    EmitSignal("Hit");
    GetNode<CollisionShape2D>("CollisionShape2D").SetDeferred("disabled", true);
}

매번 적이 플레이어를 때리면, 시그널이 방출됩니다. 우리는 두 번 이상 hit 시그널이 발동되지 않도록 플레이어의 충돌을 비활성화 해야 합니다.

주석

엔진의 충돌 처리 중에 영역의 충돌 모양을 비활성화 하는 것은 오류를 초래할 수 있습니다. set_deferred()를 사용하면 Godot가 모양을 비활성화해도 안전할 때까지 기다려줍니다.

우리 플레이어의 마지막 과제는 호출할 수 있는 함수를 추가해서 새 게임이 시작할 때 플레이어를 리셋할 수 있게 하는 겁니다.

func start(pos):
    position = pos
    show()
    $CollisionShape2D.disabled = false
public void Start(Vector2 pos)
{
    Position = pos;
    Show();
    GetNode<CollisionShape2D>("CollisionShape2D").Disabled = false;
}

적 씬

이제 우리의 플레이어가 피할 적을 만들 차례입니다. 이들의 행동은 그리 복잡하진 않을겁니다: 적이 아무렇게나 화면의 가장자리에서 나타나고 무작위 방향으로 직선으로 나아갑니다, 그리고 화면을 벗어나면 사라집니다.

그러면 Mob 씬에 이 작업을 해야 합니다, 이것을 인스턴스 해서 게임에선 정해진 수 만큼의 적들을 만들 것입니다.

노드 설정하기

씬 -> 새 씬을 클릭하고 거기에서 적을 만들겁니다.

적 씬은 다음 노드들을 사용할 것입니다:

  • RigidBody2D ( Mob 으로 이름지음)
    • AnimatedSprite
    • CollisionShape2D
    • VisibilityNotifier2D ( Visibility 으로 이름지음)

플레이어 씬에서 한 것과 마찬가지로, 자식이 선택되지 않도록 설정하는 것을 잊지마세요.

RigidBody2D 속성에서는, Gravity Scale0 으로 설정해 적이 아래로 떨어지지 않도록 합니다. 그리고, PhysicsBody2D 섹션의 Mask 속성을 클릭하고 첫 번째 상자의 체크를 푸세요. 이것은 적들끼리 충돌하지 않도록 만듭니다.

../../_images/set_collision_mask.png

AnimatedSprite 를 당신이 플레이어에서 한 것처럼 설정합니다. 이번엔, 3 개의 애니메이션이 있습니다: fly, swim, 그리고 walk 이죠. 인스펙터의 Playing 를 "On"으로 설정합니다. 그리고 "Speed (FPS)" 를 아래에 보이는대로 조정해줍시다. 우리는 이 애니메이션들 중 하나를 무작위로 선택해서, 적들이 다양하게 보이도록 할 겁니다.

../../_images/mob_animations.gif

fly 는 3 FPS로 설정하고, swimwalk 는 4 FPS로 설정합니다.

플레이어 이미지처럼, 이 적 이미지들도 크기를 줄일 필요가 있습니다. AnimatedSpriteScale 속성을 (0.75, 0.75) 으로 설정합니다.

Player 씬처럼, 충돌을 위한 CapsuleShape2D 을 추가합니다. 이미지와 모양이 같도록, Node2D 아래 Rotation Degrees 속성을 90 으로 설정해야 합니다.

적 스크립트

Mob 에게 스크립트를 추가해주고 다음의 멤버 변수를 추가해주세요:

extends RigidBody2D

export var min_speed = 150  # Minimum speed range.
export var max_speed = 250  # Maximum speed range.
var mob_types = ["walk", "swim", "fly"]
public class Mob : RigidBody2D
{
    // Don't forget to rebuild the project so the editor knows about the new export variables.

    [Export]
    public int MinSpeed = 150; // Minimum speed range.

    [Export]
    public int MaxSpeed = 250; // Maximum speed range.

    private String[] _mobTypes = {"walk", "swim", "fly"};
}

적이 스폰할 때, min_speedmax_speed사이 무작위 값으로 각 적들이 빠르게 움직이는 정도를 선택하도록 할 겁니다 (모두가 같은 속도로 움직이면 지루할 것입니다). 그리고 세 개의 애니메이션의 이름이 들어있는 배열이 있는데 이걸 무작위로 선택해서 사용할 겁니다. 스크립트와 SpriteFrames 리소스에서 철자가 같은지 확인하세요.

이제 스크립트의 나머지를 봅시다. _ready() 에서 우리는 무작위로 세 개의 애니메이션 유형들 중 하나를 고릅니다:

func _ready():
    $AnimatedSprite.animation = mob_types[randi() % mob_types.size()]
// C# doesn't implement GDScript's random methods, so we use 'System.Random' as an alternative.
static private Random _random = new Random();

public override void _Ready()
{
    GetNode<AnimatedSprite>("AnimatedSprite").Animation = _mobTypes[_random.Next(0, _mobTypes.Length)];
}

주석

씬을 실행할 때 매 순간마다 다른 "무작위" 숫자의 순서를 원한다면 randomize() 를 사용해야 합니다. randomize() 를 우리의 Main 씬에 사용할꺼니까, 더이상 여기 있으면 안됩니다. randi() % n0n-1 사이의 무작위 정수 값을 얻는 기본적인 방법입니다.

적이 나갈 때 스스로를 삭제하는 것이 적의 마지막 과제입니다. Visubility 노드의 screen_exited() 시그널을 연결하고 이 코드를 추가하세요:

func _on_Visibility_screen_exited():
    queue_free()
public void OnVisibilityScreenExited()
{
    QueueFree();
}

이걸로 씬이 완성되었습니다.

메인 씬

이제 이 모두를 모을 차례입니다. 새 씬을 만들고 Main 이라는 이름의 Node 를 만드세요. "인스턴스" 버튼을 누르고 저장한 Player.tscn 을 선택하세요.

../../_images/instance_scene.png

주석

인스턴스에 대해 더 알고 싶다면 인스턴스(Instance)하기 로 가세요.

Main 의 자식으로 다음의 노드들을 추가하고, 여기에서 설명하는 대로 이름을 지으세요 (값은 초 단위 입니다):

  • Timer (MobTimer 라고 이름지음) - 얼마나 자주 적이 스폰하는지를 조절함
  • Timer (ScoreTimer 라고 이름지음) - 매 초마다 점수를 증가시킴
  • Timer (StratTimer 라고 이름지음) - 시작하기 전에 지연시간을 줌
  • Position2D (StartPosition 이라고 이름지음) - 플레이어의 시작 위치를 표시함

Timer 마다 Wait Time 속성을 다음과 같이 설정하세요:

  • MobTimer: 0.5
  • ScoreTimer: 1
  • StartTimer: 2

그리고, StartTimer 속성의 One Shot 을 "On"으로 설정하고 StartPosition 노드의 Position(240, 450) 으로 설정하세요.

적 스폰하기

메인 노드는 새로운 적들을 스폰할 것이고 우리는 그들이 화면 모서리 아무 위치에서나 나타나도록 만들고 싶습니다. Path2D 노드를 Main 의 자식으로 추가하고 MobPath 라고 이름지으세요. Path2D 를 선택한다면, 당신은 편집기 위쪽에 새로운 버튼들이 보일 것입니다:

../../_images/path2d_buttons.png

가운데 것을 선택하고 ("포인트 추가") 클릭으로 모서리에 포인트를 추가하고 경로를 그립니다. 포인트가 그리드에 스냅되게 하려면, "그리드에 스냅"이 체크되어있어야 합니다. 이 설정은 "잠금" 버튼 왼쪽과 "스냅 설정" 버튼 옆의 세 개의 점에서 찾을 수 있습니다.

../../_images/draw_path2d.gif

중요

시계 방향 으로 그리세요, 그렇지 않으면 적들은 안쪽 이 아닌 바깥쪽 으로 향할 것입니다!

이미지에서 4 포인트를 찍고 난 후, "커브 닫기" 버튼을 누르면 커브가 완성됩니다.

이제 경로를 정의하기 위해, PathFollow2D 노드를 MobPath 의 자식으로 추가한 후, MobSpawnLocation 이라고 이름짓습니다. 이 노드는 자동으로 회전하고 이동하면서 경로를 따라갈 것입니다, 그래서 우리는 이걸로 경로를 따라 임의의 위치와 방향을 선택하기 위해 사용할 수 있습니다.

메인 스크립트

Main 에 스크립트를 추가합니다. 스크립트의 위에 export (PackedScene) 를 사용해서 우리가 인스턴스하길 원하는 적 씬을 고를 수 있도록 만듭니다.

extends Node

export (PackedScene) var Mob
var score

func _ready():
    randomize()
public class Main : Node
{
    // Don't forget to rebuild the project so the editor knows about the new export variable.

    [Export]
    public PackedScene Mob;

    private int _score;

    // We use 'System.Random' as an alternative to GDScript's random methods.
    private Random _random = new Random();

    public override void _Ready()
    {
    }

    // We'll use this later because C# doesn't support GDScript's randi().
    private float RandRange(float min, float max)
    {
        return (float)_random.NextDouble() * (max - min) + min;
    }
}

"파일 시스템" 패널에서 Mob.tscn 을 드래그하고 Main 노드의 스크립트 속성 아래 Mob 속성에 드랍하세요.

다음으로, 플레이어를 클릭하고 hit 시그널과 연결하세요. 우리는 game_over 라는 새로운 함수를 만들어서, 게임이 끝날 때 발생하도록 조절할 것입니다. "시그널 연결" 창 아래 "Method In Node" 박스에 "game_over" 라고 치세요. 다음의 코드를 추가하고 new_game 함수를 추가하여 새로운 게임을 위한 모든 것들을 설정하세요:

func game_over():
    $ScoreTimer.stop()
    $MobTimer.stop()

func new_game():
    score = 0
    $Player.start($StartPosition.position)
    $StartTimer.start()
public void GameOver()
{
    GetNode<Timer>("MobTimer").Stop();
    GetNode<Timer>("ScoreTimer").Stop();
}

public void NewGame()
{
    _score = 0;

    var player = GetNode<Player>("Player");
    var startPosition = GetNode<Position2D>("StartPosition");
    player.Start(startPosition.Position);

    GetNode<Timer>("StartTimer").Start();
}

이제 각 Timer 노드 (StartTimer, ScoreTimer ,그리고 MobTimer) 의 timeout() 시그널을 연결하세요. StartTimer 는 나머지 두 타이머를 시작하게 만들 것입니다. ScoreTimer 는 점수가 1씩 증가하게 합니다.

func _on_StartTimer_timeout():
    $MobTimer.start()
    $ScoreTimer.start()

func _on_ScoreTimer_timeout():
    score += 1
public void OnStartTimerTimeout()
{
    GetNode<Timer>("MobTimer").Start();
    GetNode<Timer>("ScoreTimer").Start();
}

public void OnScoreTimerTimeout()
{
    _score++;
}

_on_MobTimer_timeout() 에서 우리는 몹 인스턴스를 만들고, Path2D 에 따라 무작위 시작점을 결정하고, 적이 움직이도록 설정할 것입니다. PathFollow2D 노드는 자동으로 경로를 따라 돌고, 그래서 이걸로 적들의 위치와 방향을 선택하기 위해서 사용할 수 있습니다.

새로운 인스턴스는 add_child() 를 사용해야 추가할 수 있습니다.

func _on_MobTimer_timeout():
    # Choose a random location on Path2D.
    $MobPath/MobSpawnLocation.set_offset(randi())
    # Create a Mob instance and add it to the scene.
    var mob = Mob.instance()
    add_child(mob)
    # Set the mob's direction perpendicular to the path direction.
    var direction = $MobPath/MobSpawnLocation.rotation + PI / 2
    # Set the mob's position to a random location.
    mob.position = $MobPath/MobSpawnLocation.position
    # Add some randomness to the direction.
    direction += rand_range(-PI / 4, PI / 4)
    mob.rotation = direction
    # Set the velocity (speed & direction).
    mob.linear_velocity = Vector2(rand_range(mob.min_speed, mob.max_speed), 0)
    mob.linear_velocity = mob.linear_velocity.rotated(direction)
public void OnMobTimerTimeout()
{
    // Choose a random location on Path2D.
    var mobSpawnLocation = GetNode<PathFollow2D>("MobPath/MobSpawnLocation");
    mobSpawnLocation.SetOffset(_random.Next());

    // Create a Mob instance and add it to the scene.
    var mobInstance = (RigidBody2D)Mob.Instance();
    AddChild(mobInstance);

    // Set the mob's direction perpendicular to the path direction.
    float direction = mobSpawnLocation.Rotation + Mathf.Pi / 2;

    // Set the mob's position to a random location.
    mobInstance.Position = mobSpawnLocation.Position;

    // Add some randomness to the direction.
    direction += RandRange(-Mathf.Pi / 4, Mathf.Pi / 4);
    mobInstance.Rotation = direction;

    // Choose the velocity.
    mobInstance.SetLinearVelocity(new Vector2(RandRange(150f, 250f), 0).Rotated(direction));
}

중요

각도가 필요한 기능에서, GDScript는 각도가 아닌, 라디안 을 사용합니다. 각도로 좀 더 편한 작업을 하고싶다면, deg2rad()rad2deg() 함수로 이 둘을 전환할 수 있습니다.

HUD

게임에 마지막으로 필요한 것은 UI입니다: 인터페이스로 화면에 점수, "게임 오버" 메시지, 재시작 버튼을 보여줍니다. 새 씬을 만들고, CanvasLayer 노드를 추가하고 HUD 라고 이름지으세요. "HUD"는 "heads-up display"의 약자로, 정보가 게임 화면 앞에 덮어씌우는 방식입니다.

CanvasLayer 노드는 게임 위 레이어에 우리의 UI 요소들을 그릴수 있게 해주고, 이로써 보여지는 정보가 플레이어나 적과 같은 게임 요소들에 의해 가려지지 않게 만듭니다.

HUD는 다음의 정보들을 보여줍니다:

  • 점수, ScoreTimer 가 바꿈.
  • 메시지, "Game Over"나 "Get Ready!"
  • 게임을 시작하기 위한 "Start" 버튼.

UI 요소에 기초가 되는 노드는 Control 입니다. UI를 만들기 위해, 우리는 두 가지 형식의 Control 노드를 쓸겁니다: LabelButton 입니다.

다음에 맞춰 HUD 노드의 자식으로 추가하세요:

  • ScoreLabel 로 이름지은 Label.
  • MessageLabel 로 이름지은 Label.
  • StartButton 으로 이름지은 Button.
  • MessageTimer 로 이름지은 Timer.

Click on the ScoreLabel and type a number into the Text field in the Inspector. The default font for Control nodes is small and doesn't scale well. There is a font file included in the game assets called "Xolonium-Regular.ttf". To use this font, do the following for each of the three Control nodes:

  1. "Custom Fonts" 에서, "새 DynamicFont"를 선택하세요
../../_images/custom_font1.png
  1. 추가한 "DynamicFont"를 클릭하시고, "Font/Font Data" 아래에서, "불러오기"를 눌러서 "Xolonium-Regular.ttf" 파일을 선택하세요. 또한 폰트의 Size 도 설정해야 합니다. 64 정도가 좋습니다.
../../_images/custom_font2.png

주석

앵커(Anchors)와 마진(Margins): Control 노드는 위치와 크기를 가지고 있고, 앵커과 마진 또한 가지고 있습니다. 앵커는 중심은 정의합니다 - 노드의 모서리를 위한 참고 포인트. 마진은 컨트롤 노드를 움직이거나 크기를 조절할 때 자동으로 업데이트됩니다. 이것은 컨트롤 노드들의 모서리가 앵커로부터 얼마나 떨어져 있는 지를 나타냅니다. 더 자세한 설명은 컨트롤(Control) 노드로 인터페이스를 설계하기 를 참고하세요.

Arrange the nodes as shown below. Click the "Layout" button to set a Control node's layout:

../../_images/ui_anchor.png

당신은 노드들을 드래그해서 수동으로 놓을 수도 있습니다, 혹은 더 정확한 방법으로는, 다음의 설정을 사용하세요:

ScoreLabel

  • Text : 0
  • 레이아웃 : "Top Wide"
  • Align : "Center"

MessageLabel

  • Text : Dodge the Creeps!
  • 레이아웃 : "HCenter Wide"
  • Align : "Center"
  • Autowrap : "On"

StartButton

  • Text : Start
  • 레이아웃 : "Center Bottom"
  • Margin :
    • Top: -200
    • Bottom: -100

이제 HUD 에 이 스크립트를 추가하세요:

extends CanvasLayer

signal start_game
public class HUD : CanvasLayer
{
    // Don't forget to rebuild the project so the editor knows about the new signal.

    [Signal]
    public delegate void StartGame();
}

start_game 시그널은 Main 노드에게 버튼이 눌려졌음을 알립니다.

func show_message(text):
    $MessageLabel.text = text
    $MessageLabel.show()
    $MessageTimer.start()
public void ShowMessage(string text)
{
    var messageLabel = GetNode<Label>("MessageLabel");
    messageLabel.Text = text;
    messageLabel.Show();

    GetNode<Timer>("MessageTimer").Start();
}

이 함수는 우리가 원하는 메시지를 일시적으로 보여줄 때 호출됩니다, "Get Ready" 처럼 말이죠. MessageTimer 에서, WaitTime2 로 설정하고 OneShot 속성을 "On"으로 하세요.

func show_game_over():
    show_message("Game Over")

    yield($MessageTimer, "timeout")

    $MessageLabel.text = "Dodge the\nCreeps!"
    $MessageLabel.show()

    yield(get_tree().create_timer(1), "timeout")

    $StartButton.show()
async public void ShowGameOver()
{
    ShowMessage("Game Over");

    var messageTimer = GetNode<Timer>("MessageTimer");
    await ToSignal(messageTimer, "timeout");

    var messageLabel = GetNode<Label>("MessageLabel");
    messageLabel.Text = "Dodge the\nCreeps!";
    messageLabel.Show();

    GetNode<Button>("StartButton").Show();
}

이 함수는 플레이어가 패배했을 때 호출됩니다. 이것은 2초동안 "Game Over"를 보여주고, 타이틀 화면으로 돌아와서, 잠깐 일시정지한 후 "Start" 버튼을 보여줍니다.

주석

짧은 시간동안 일시 정지를 하고 싶을 때, Timer 노드를 사용하는 대체재로 SceneTree의 create_timer() 함수를 사용하는 것입니다. 위의 코드에서처럼 시간 지연에 매우 유용한데, "Start" 버튼을 보여주기 전에 약간의 시간을 기다려줍니다.

func update_score(score):
    $ScoreLabel.text = str(score)
public void UpdateScore(int score)
{
    GetNode<Label>("ScoreLabel").Text = score.ToString();
}

이 함수는 점수가 변경될 때마다 Main 에 의해서 호출됩니다.

MessageTimertimeout() 시그널과 StartButtonpressed() 를 연결하세요.

func _on_StartButton_pressed():
    $StartButton.hide()
    emit_signal("start_game")

func _on_MessageTimer_timeout():
    $MessageLabel.hide()
public void OnStartButtonPressed()
{
    GetNode<Button>("StartButton").Hide();
    EmitSignal("StartGame");
}

public void OnMessageTimerTimeout()
{
    GetNode<Label>("MessageLabel").Hide();
}

메인에 HUD를 연결하기

이제 HUD 씬 만들기가 끝났습니다, 저장하고 다시 Main 으로 돌아갑니다. Player 씬에서 했듯이 HUD 씬을 Main 에 인스턴스하고, 트리의 맨 아래에 위치시킵니다. 모든 트리는 이렇게 되어야 하므로, 놓친 것이 없는지 확인해보세요:

../../_images/completed_main_scene.png

이제 HUD 기능을 Main 스크립트에 연결할겁니다. 여기에는 Main 씬에 추가적인 것이 요구됩니다:

노드 탭에서, HUD의 start_game 시그널을 new_game() 함수에 연결하세요.

new_game() 에서, 점수 화면을 업데이트하고 "Get Ready" 메시지를 보이게 합니다:

$HUD.update_score(score)
$HUD.show_message("Get Ready")
var hud = GetNode<HUD>("HUD");
hud.UpdateScore(_score);
hud.ShowMessage("Get Ready!");

game_over() 에서는, 일치하는 HUD 함수를 호출해야 합니다:

$HUD.show_game_over()
GetNode<HUD>("HUD").ShowGameOver();

마지막으로, _on_ScoreTimer_timeout() 를 추가해서 점수가 바뀌는 것과 일치한 화면을 유지하도록 합니다:

$HUD.update_score(score)
GetNode<HUD>("HUD").UpdateScore(_score);

이제 실행할 준비가 끝났습니다! "프로젝트 실행" 버튼을 누르세요. 아마 메인 씬을 선택하라고 물어볼 겁니다, Main.tscn 을 골라줍시다.

오래된 적들 제거하기

"Game Over"까지 게임을 하고 새 게임을 시작해도, 전 판에서 있었던 적들이 아직 화면에 남아 있습니다.새 게임이 시작되면 적이 모두 사라지는 것이 좋을 것입니다.

이미 HUD 노드에 의해 방출되는 start_game 시그널을 사용해 남아있는 적들을 제거할 것입니다. 편집기를 사용해 적에게 시그널을 연결할 수는 없는데, 게임을 실행하기 전 까진 Mob 노드가 Main 씬 트리에 없기 때운입니다. 대신 코드를 사용합니다.

시작하려면 Mob.gd에 새 함수를 추가합니다. queue_free()는 현재 프레임이 끝나는 순간 현재 노드를 제거합니다.

func _on_start_game():
    queue_free()
public void OnStartGame()
{
    QueueFree();
}

그런 다음 Main.gd에서 _on_MobTimer_timeout()의 끝에 새로운 줄을 추가합니다.

$HUD.connect("start_game", mob, "_on_start_game")
GetNode("HUD").Connect("StartGame", mobInstance, "OnStartGame");

이 줄은 (mob 변수에 의해 참조된) 새로운 Mob 노드로, HUD 노드의 _on_start_game() 함수가 실행하여 방출하는 start_game 시그널에 반응합니다.

마무리 작업

우리는 게임의 모든 기능을 마친 상태입니다. 밑에 남아있는 단계는 게임의 경험을 향상시킬 "주스"를 추가하는 것입니다. 독창적인 아이디어로 자유롭게 게임 플레이를 확장시켜보세요.

배경(Background)

기본 회색 배경은 그렇게 멋지지는 않습니다, 그래서 색깔을 바꿔봅시다. 그 방법들 중 하나는 ColorRect 노드를 사용하는 것입니다. Main 바로 밑 첫 번째 노드로 놓아서 다른 노드들 뒤에 그려지도록 하세요. ColorRect 는 오직 하나의 속성만 가지고 있습니다: Color 입니다. 원하는 색깔을 고르고 ColorRect 의 크기를 조절해 화면을 덮도록 만듭시다.

또한 배경 이미지를 추가할 수도 있는데, 이미지가 있다면, Sprite 노드를 사용하면 됩니다.

음향 효과(Sound effects)

사운드와 음악은 게임 경험을 멋지게 만들어주는 가장 효과적인 물건입니다. 게임 애셋 폴더에서, 두 개의 사운드 파일을 찾으실 수 있습니다: "House In a Forest Loop.ogg"는 배경 음악으로, "gameover.wav"는 플레이어가 죽었을 때 쓰면 됩니다.

두 개의 AudioStreamPlayer 노드를 Main 의 자식으로 추가하세요. 하나는 Music 으로, 다른 하나는 DeathSound 로 이름 지으세요. 각각에서, Stream 속성을 클릭하시고 "Load"를 선택해서 이름에 맞는 오디오 파일을 선택하세요.

음악을 실행시키려면, new_game() 함수에 $Music.play() 를 추가하고 game_over() 함수에 $Music.stop() 을 추가하세요.

마지막으로, game_over() 함수에 $DeathSound.play() 를 추가하세요.

키보드 단축키

게임을 키보드로 조작하면, 키보드의 한 버튼을 눌러서 게임을 실행할 수 있도록 하는 게 편리할 것입니다. 이렇게 하는 방법들 중 하나로 Button 노드의 "Shortcut" 속성을 사용하는 것이 있습니다.

In the HUD scene, select the StartButton and find its Shortcut property in the Inspector. Select "New Shortcut" and click on the "Shortcut" item. A second Shortcut property will appear. Select "New InputEventAction" and click the new "InputEvent". Finally, in the Action property, type the name ui_select. This is the default input event associated with the spacebar.

../../_images/start_button_shortcut.png

이제 시작 버튼이 나타날 때, 버튼을 클릭하거나 스페이스바를 눌러서 게임을 시작할 수 있습니다.

프로젝트 파일

당신은 여기서 이 프로젝트의 완성 버전을 확인하실 수 있습니다: