당신의 첫 게임

개요

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

주석

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

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

../../_images/dodge_preview.gif

Why 2D? 3D games are much more complex than 2D ones. You should stick to 2D until you have a good understanding of the game development process and how to use Godot.

프로젝트 설정하기

Launch Godot and create a new project. Then, download dodge_assets.zip. This contains the images and sounds you'll be using to make the game. Unzip these files in your project folder.

주석

For this tutorial, we will assume you are familiar with the Godot editor. If you haven't read 씬과 노드(Scenes and nodes), do so now for an explanation of setting up a project and using the editor.

This game is designed for portrait mode, so we need to adjust the size of the game window. Click on Project -> Project Settings -> Display -> Window and set "Width" to 480 and "Height" to 720.

Also in this section, under the "Stretch" options, set Mode to "2d" and Aspect to "keep". This ensures that the game scales consistently on different sized screens.

프로젝트 조직하기

In this project, we will make 3 independent scenes: Player, Mob, and HUD, which we will combine into the game's Main scene. In a larger project, it might be useful to create folders to hold the various scenes and their scripts, but for this relatively small game, you can save your scenes and scripts in the project's root folder, identified by res://. You can see your project folders in the FileSystem Dock in the lower left corner:

../../_images/filesystem_dock.png

Player(플레이어) 씬

The first scene will define the Player object. One of the benefits of creating a separate Player scene is that we can test it separately, even before we've created other parts of the game.

노드 구조

To begin, we need to choose a root node for the player object. As a general rule, a scene's root node should reflect the object's desired functionality - what the object is. Click the "Other Node" button and add an Area2D node to the scene.

../../_images/add_node.png

Godot는 씬 트리에서 노드 옆에 경고 아이콘을 보여줍니다. 지금은 무시해도 됩니다. 나중에 다룰 것입니다.

With Area2D we can detect objects that overlap or run into the player. Change the node's name to Player by double-clicking on it. Now that we've set the scene's root node, we can add additional nodes to give it more functionality.

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

../../_images/lock_children.png

Save the scene. Click Scene -> Save, or press Ctrl + S on Windows/Linux or Cmd + S on macOS.

주석

이 프로젝트에서, 우리는 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". Click again to open the "SpriteFrames" panel:

../../_images/spriteframes_panel.png

On the left is a list of animations. Click the "default" one and rename it to "walk". Then click the "New Animation" button to create a second animation named "up". Find the player images in the "FileSystem" tab - they're in the art folder you unzipped earlier. Drag the two images for each animation, named playerGrey_up[1/2] and playerGrey_walk[1/2], into the "Animation Frames" side of the panel for the corresponding animation:

../../_images/spriteframes_panel2.png

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

../../_images/player_scale.png

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

../../_images/player_coll_shape.png

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

../../_images/player_scene_nodes.png

Make sure to save the scene again after these changes.

플레이어 움직이기

Now we need to add some functionality that we can't get from a built-in node, so we'll add a script. Click the Player node and click the "Attach Script" button:

../../_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 키워드를 사용하면 인스펙터(Inspecter) 내에서 값을 설정할 수 있습니다. 편리한 점으로는 인스펙터(Inspecter)에서도 원하는 값으로 조정할 수 있는 것입니다. Player 노드를 선택하면 인스펙터(Inspecter)의 "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().Size;
}

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

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

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

You can detect whether a key is pressed using Input.is_action_pressed(), which returns true if it's pressed or false if it isn't.

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();
    }
}

We start by setting the velocity to (0, 0) - by default, the player should not be moving. Then we check each input and add/subtract from the velocity to obtain a total direction. For example, if you hold right and down at the same time, the resulting velocity vector will be (1, 1). In this case, since we're adding a horizontal and a vertical movement, the player would move faster diagonally than if it just moved horizontally.

We can prevent that if we normalize the velocity, which means we set its length to 1, then multiply by the desired speed. This means no more fast diagonal movement.

참고

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

We also check whether the player is moving so we can call play() or stop() on the AnimatedSprite.

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

참고

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

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 (make sure it's not indented under the else):

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)
);

참고

_process()`함수에 있는 매개변수 `delta 는 "frame length" (이전 프레임이 완료되는데 걸린 시간) 를 참조 합니다. 이 값을 사용하면 프레임 레이트가 변경되어도 당신의 이동속도를 항상 일정하게 유지 할 수 있습니다.

"씬 실행" (F6) 을 누르고 플레이어가 화면 내에서 모든 방향으로 움직일 수 있는지 확인하세요.

경고

If you get an error in the "Debugger" panel that says

Attempt to call function 'play' in base 'null instance' on a null instance

this likely means you spelled the name of the AnimatedSprite node wrong. Node names are case-sensitive and $NodeName must match the name you see in the scene tree.

애니메이션 고르기

Now that the player can move, we need to change which animation the AnimatedSprite is playing based on its direction. We have the "walk" animation, which shows the player walking to the right. This animation should be flipped horizontally using the flip_h property for left movement. We also have the "up" animation, which should be flipped vertically with flip_v for downward movement. Let's place this code at the end of the _process() function:

if velocity.x != 0:
    $AnimatedSprite.animation = "walk"
    $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 = "walk";
    animatedSprite.FlipV = false;
    // See the note below about boolean assignment
    animatedSprite.FlipH = velocity.x < 0;
}
else if (velocity.y != 0)
{
    animatedSprite.Animation = "up";
    animatedSprite.FlipV = velocity.y > 0;
}

주석

The boolean assignments in the code above are a common shorthand for programmers. Since we're doing a comparison test (boolean) and also assigning a boolean value, we can do both at the same time. Consider this code versus the one-line boolean assignment above:

if velocity.x < 0:
    $AnimatedSprite.flip_h = true
else:
    $AnimatedSprite.flip_h = false
if (velocity.x < 0)
{
    animatedSprite.FlipH = true;
}
else
{
    animatedSprite.FlipH = false;
}

Play the scene again and check that the animations are correct in each of the directions.

참고

A common mistake here is to type the names of the animations wrong. The animation names in the SpriteFrames panel must match what you type in the code. If you named the animation "Walk", you must also use a capital "W" in the code.

When you're sure the movement is working correctly, add this line to _ready(), so the player will be hidden when the game starts:

hide()
Hide();

충돌 준비하기

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

Add the following at the top of the script, after 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 노드를 선택하고 인스펙터(Inspecter) 탭 옆의 "노드" 탭을 클릭하고 플레이어가 방출할 수 있는 시그널들을 확인해 보세요:

../../_images/player_signals.png

Notice our custom "hit" signal is there as well! Since our enemies are going to be RigidBody2D nodes, we want the body_entered(body: Node) signal. This signal will be emitted when a body contacts the player. Click "Connect.." and the "Connect a Signal" window appears. We don't need to change any of these settings so click "Connect" again. Godot will automatically create a function in your player's script.

../../_images/player_signal_connection.png

Note the green icon indicating that a signal is connected to this function. Add this code to the function:

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 시그널이 발동되지 않도록 플레이어의 충돌을 비활성화 해야 합니다.

주석

Disabling the area's collision shape can cause an error if it happens in the middle of the engine's collision processing. Using set_deferred() tells Godot to wait to disable the shape until it's safe to do so.

The last piece is to add a function we can call to reset the player when starting a new game.

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

적 씬

Now it's time to make the enemies our player will have to dodge. Their behavior will not be very complex: mobs will spawn randomly at the edges of the screen, choose a random direction, and move in a straight line.

We'll create a Mob scene, which we can then instance to create any number of independent mobs in the game.

주석

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

노드 설정하기

씬 -> 새 씬을 클릭 후 다음 노드들을 추가합니다:

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

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

../../_images/set_collision_mask.png

Set up the AnimatedSprite like you did for the player. This time, we have 3 animations: fly, swim, and walk. There are two images for each animation in the art folder.

Adjust the "Speed (FPS)" to 3 for all animations.

../../_images/mob_animations.gif

Set the Playing property in the Inspector to “On”.

We'll select one of these animations randomly so that the mobs will have some variety.

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

As in the Player scene, add a CapsuleShape2D for the collision. To align the shape with the image, you'll need to set the Rotation Degrees property to 90 (under "Transform" in the Inspector).

씬을 저장합니다.

적 스크립트

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

extends RigidBody2D

export var min_speed = 150  # Minimum speed range.
export var max_speed = 250  # Maximum speed range.
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.

}

When we spawn a mob, we'll pick a random value between min_speed and max_speed for how fast each mob will move (it would be boring if they were all moving at the same speed).

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

func _ready():
    var mob_types = $AnimatedSprite.frames.get_animation_names()
    $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()
{
    var animSprite = GetNode<AnimatedSprite>("AnimatedSprite");
    var mobTypes = animSprite.Frames.GetAnimationNames();
    animSprite.Animation = mobTypes[_random.Next(0, mobTypes.Length)];
}

First, we get the list of animation names from the AnimatedSprite's frames property. This returns an Array containing all three animation names: ["walk", "swim", "fly"].

We then need to pick a random number between 0 and 2 to select one of these names from the list (array indices start at 0). randi() % n selects a random integer between 0 and n-1.

주석

You must use randomize() if you want your sequence of "random" numbers to be different every time you run the scene. We're going to use randomize() in our Main scene, so we won't need it here.

The last piece is to make the mobs delete themselves when they leave the screen. Connect the screen_exited() signal of the VisibilityNotifier2D node and add this code:

func _on_VisibilityNotifier2D_screen_exited():
    queue_free()
public void OnVisibilityNotifier2DScreenExited()
{
    QueueFree();
}

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

메인 씬

Now it's time to bring it all together. Create a new scene and add a Node named Main. Ensure you create a Node, not a Node2D. Click the "Instance" button and select your saved Player.tscn.

../../_images/instance_scene.png

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

Select the middle one ("Add Point") and draw the path by clicking to add the points at the corners shown. To have the points snap to the grid, make sure "Use Grid Snap" and "Use Snap" are both selected. These options can be found to the left of the "Lock" button, appearing as a magnet next to some dots and intersecting lines, respectively.

../../_images/grid_snap_button.png

중요

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

../../_images/draw_path2d.gif

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

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

Your scene should look like this:

../../_images/main_scene_nodes.png

메인 스크립트

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;
    }
}

Click the Main node and you will see the Mob property in the Inspector under "Script Variables".

You can assign this property's value in two ways:

  • Drag Mob.tscn from the "FileSystem" panel and drop it in the Mob property .
  • Click the down arrow next to "[empty]" and choose "Load". Select Mob.tscn.

Next, select the Player node in the Scene dock, and access the Node dock on the sidebar. Make sure to have the Signals tab selected in the Node dock.

You should see a list of the signals for the Player node. Find and double-click the hit signal in the list (or right-click it and select "Connect..."). This will open the signal connection dialog. We want to make a new function named game_over, which will handle what needs to happen when a game ends. Type "game_over" in the "Receiver Method" box at the bottom of the signal connection dialog and click "Connect". Add the following code to the new function, as well as a new_game function that will set everything up for a 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();
}

Now connect the timeout() signal of each of the Timer nodes (StartTimer, ScoreTimer , and MobTimer) to the main script. StartTimer will start the other two timers. ScoreTimer will increment the score by 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.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.Offset = _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.LinearVelocity = new Vector2(RandRange(150f, 250f), 0).Rotated(direction);
}

중요

Why PI? In functions requiring angles, GDScript uses radians, not degrees. If you're more comfortable working with degrees, you'll need to use the deg2rad() and rad2deg() functions to convert between the two.

Testing the scene

Let's test the scene to make sure everything is working. Add this to _ready():

func _ready():
    randomize()
    new_game()
    public override void _Ready()
    {
        NewGame();
    }
}

Let's also assign Main as our "Main Scene" - the one that runs automatically when the game launches. Press the "Play" button and select Main.tscn when prompted.

You should be able to move the player around, see mobs spawning, and see the player disappear when hit by a mob.

When you're sure everything is working, remove the call to new_game() from _ready().

HUD

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

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

The HUD needs to display the following information:

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

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

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

  • ScoreLabel 로 이름지은 Label.
  • Label named Message.
  • 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:

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

Once you've done this on the ScoreLabel, you can click the down arrow next to the DynamicFont property and choose "Copy", then "Paste" it in the same place on the other two Control nodes.

주석

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

아래에 보이는 대로 노드를 정렬하세요. "레이아웃(Layout)" 버튼을 클릭해서 Control 노드의 레이아웃을 설정하세요:

../../_images/ui_anchor.png

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

ScoreLabel

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

Message

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

StartButton

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

On the MessageTimer, set the Wait Time to 2 and set the One Shot property to "On".

이제 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):
    $Message.text = text
    $Message.show()
    $MessageTimer.start()
public void ShowMessage(string text)
{
    var message = GetNode<Label>("Message");
    message.Text = text;
    message.Show();

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

This function is called when we want to display a message temporarily, such as "Get Ready".

func show_game_over():
    show_message("Game Over")
    # Wait until the MessageTimer has counted down.
    yield($MessageTimer, "timeout")

    $Message.text = "Dodge the\nCreeps!"
    $Message.show()
    # Make a one-shot timer and wait for it to finish.
    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 message = GetNode<Label>("Message");
    message.Text = "Dodge the\nCreeps!";
    message.Show();

    await ToSignal(GetTree().CreateTimer(1), "timeout");
    GetNode<Button>("StartButton").Show();
}

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

주석

When you need to pause for a brief time, an alternative to using a Timer node is to use the SceneTree's create_timer() function. This can be very useful to add delays such as in the above code, where we want to wait some time before showing the "Start" button.

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

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

Connect the timeout() signal of MessageTimer and the pressed() signal of StartButton and add the following code to the new functions:

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

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

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

메인에 HUD를 연결하기

Now that we're done creating the HUD scene, go back to Main. Instance the HUD scene in Main like you did the Player scene. The scene tree should look like this, so make sure you didn't miss anything:

../../_images/completed_main_scene.png

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

In the Node tab, connect the HUD's start_game signal to the new_game() function of the Main node by typing "new_game" in the "Receiver Method" in the "Connect a Signal" window. Verify that the green connection icon now appears next to func new_game() in the script.

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 을 골라줍시다.

오래된 적들 제거하기

If you play until "Game Over" and then start a new game right away, the creeps from the previous game may still be on the screen. It would be better if they all disappeared at the start of a new game. We just need a way to tell all the mobs to remove themselves. We can do this with the "group" feature.

In the Mob scene, select the root node and click the "Node" tab next to the Inspector (the same place where you find the node's signals). Next to "Signals", click "Groups" and you can type a new group name and click "Add".

../../_images/group_tab.png

Now all mobs will be in the "mobs" group. We can then add the following line to the game_over() function in Main:

get_tree().call_group("mobs", "queue_free")
GetTree().CallGroup("mobs", "queue_free");

The call_group() function calls the named function on every node in a group - in this case we are telling every mob to delete itself.

마무리 작업

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

배경(Background)

The default gray background is not very appealing, so let's change its color. One way to do this is to use a ColorRect node. Make it the first node under Main so that it will be drawn behind the other nodes. ColorRect only has one property: Color. Choose a color you like and select "Layout" -> "Full Rect" so that it covers the screen.

You could also add a background image, if you have one, by using a TextureRect node instead.

음향 효과(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() 를 추가하세요.

Keyboard shortcut

Since the game is played with keyboard controls, it would be convenient if we could also start the game by pressing a key on the keyboard. We can do this with the "Shortcut" property of the Button node.

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 "InputEventAction". 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

Now when the start button appears, you can either click it or press Space to start the game.

프로젝트 파일

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