第一個遊戲

概覽

本篇教學會帶領你製作出第一個 Godot 專案。在本篇教學中,會學習 Godot 是如何運作的、如何組織一個專案以及如何製作一個 2D 遊戲。

備註

這個專案是用來介紹 Godot 引擎的。在這裡我們會假設你已經有一些程式設計的經驗。如果完全沒有寫程式的經驗,請從 撰寫腳本 開始。

這個遊戲叫做「Dodge the Creeps! 」。玩家角色必須不斷移動,儘可能地一直閃躲敵人。最終成果會長這樣:

../../_images/dodge_preview.gif

為什麼是 2D? 3D 遊戲比 2D 來得複雜多了。直到完整掌握了遊戲開發流程以及 Godot 使用方法,都應該先以 2D 為主。

設定專案

開啟 Godot 並建立一個新專案。接著,下載 dodge_assets.zip 。這個檔案包含了接下來製作遊戲時會使用到的圖片與聲音檔。把這些檔案解壓縮至專案資料夾中。

備註

在這篇教學中,我們假設你已經熟悉 Godot 編輯器了。如果還沒讀過 場景與節點 ,請先閱讀,裡面有解釋如何設定專案以及編輯器使用方法。

這個遊戲的畫面設計成了垂直顯示,我們需要調整遊戲視窗的大小。點擊 [專案] -> [專案設定] -> [Display] (顯示) -> [Window] (視窗),接著把 [Width] (寬度) 設為 480 ,[Height] (高度) 設為 720

另外,在同一個地方,將 [Stretch] (拉伸) 選項中的 Mode (模式) 設為「2d」,將 [Aspect] (比例) 設為「keep」(保持)。這樣就能確保遊戲在不同大小的螢幕上會保持相同的比例。

組織專案

在這個專案中,我們會使用三個獨立的場景: Player (玩家)、 Mob (怪物) 與 HUD ,我們會把這三個場景組合進遊戲的 Main (主要) 場景。在大型專案中,或許開一些資料夾來儲存不同的場景與其對應的腳本會比較好,但對於相對小型的遊戲,可以把場景跟腳本都保存在專案的根目錄下,也就是 res:// 。可以在左下角的檔案系統 Dock 中看到專案資料夾:

../../_images/filesystem_dock.png

Player 場景

第一個場景,我們要來定義 Player 物件。把玩家做成獨立一個 Player 場景的好處是:就算遊戲其他部分還沒做好,也可以獨立測試單一場景。

節點結構

首先,我們需要選擇玩家物件的根節點。一般來說,場景的根節點通常對應了物件所需要的功能——也就是物件 是什麼。點擊 [其他節點] 按鈕,並新增 Area2D 節點至場景中。

../../_images/add_node.png

Godot 會在場景樹中的節點旁邊顯示一個警告圖示。可以暫時忽略,之後再來處理。

有了 Area2D 節點,我們就能偵測跑到了玩家範圍內物件。我們先點兩下節點,把節點名稱改成 Player 。現在,場景根節點已經設定好了,接著來設定更多節點,新增更多功能。

在我們給 Player 新增任何子節點前,需要先確保不會因為點到 Player 物件而移動到 Player 或改變到大小。先選擇 Player 節點,然後點擊鎖頭右邊的圖示,滑鼠移到圖示上方的時候會顯示提示「確保物件的子級項目無法被選擇。」

../../_images/lock_children.png

接著保存場景。選擇 [場景] -> [保存],或是在 Windows/Linux 上按鍵盤 Ctrl + S ,在 macOS 上則是 Cmd + S

備註

在這個專案中,我們會遵守 Godot 的命名慣例。

  • GDScript :類別 (節點) 使用大駝峰法 (PascalCase),變數與函式名稱使用蛇形法 (snake_case),常數則使用全大寫 (ALL_CAPS) (詳細請參考 GDScript 風格指南)。

  • C# :類別與匯出的變數與方法使用大駝峰法 (PascalCase),Private 欄位使用底線加小駝峰 (_camelCase),區域變數與參數使用小駝峰法 (camelCase) (請參考 C# 風格指南 )。在連接訊號的時候請特別注意不要打錯方法名稱。

Sprite 動畫

點擊 Player 節點然後新增一個 AnimatedSprite 子節點。這個 AnimatedSprite 會負責處理玩家的外觀與動畫效果。可以注意到節點旁邊有一個警告圖示。所有 AnimatedSprite 都必須要有 SpriteFrames 資源,裡面包含了所有能顯示的動畫。可以在屬性面板中 Frames (影格) 屬性上點選 [空] ->[新增 SpriteFrames] 來建立 SpriteFrames。再點擊一次就能打開 [SpriteFrames] 面板:

../../_images/spriteframes_panel.png

左側顯示的是動畫列表。點擊「default」(預設) 並重新命名為「walk」(走路)。接著點擊 [新增動畫] 按鈕來建立第二個動畫,將其命名為「up」(上移)。接著到 [檔案系統] 頁籤中找到玩家圖片——應該放在稍早解壓縮出來的 art 資料夾內。在動畫列表上選擇對應的動畫,並把圖片 playerGrey_up[1/2] 以及 playerGrey_walk[1/2] 拖移到 [動畫影格] 那裡 :

../../_images/spriteframes_panel2.png

對於遊戲視窗來說,這些玩家圖片看起來有點大,所以我們需要把圖片縮小。點擊 AnimatedSprite 節點,然後把 Scale (縮放) 屬性設為 (0.5, 0.5) 。這個屬性位在屬性面板中 Node2D 標題底下。

../../_images/player_scale.png

最後,在 Player 節點下新增一個 CollisionShape2D 子節點。這個節點是用來判斷玩家的「碰撞區域 (Hitbox 或 Collision Area)」。在這個角色上很適合使用 CapsuleShape2D 節點,所以我們可以到屬性面板中的 [Shape] (形狀),點擊 [空] -> [新增 CapsuleShape2D]。使用兩個控制尺寸的圓點來把大小調整至覆蓋 Sprite:

../../_images/player_coll_shape.png

完成之後, Player 看起來應該會像這樣:

../../_images/player_scene_nodes.png

修改完畢後記得保存場景。

移動玩家

現在我們需要一個內建節點沒有提供的功能,所以需要自己寫腳本了。點擊 Player 節點,然後點擊 [附加腳本] 按鈕:

../../_images/add_script_button.png

腳本設定視窗內的設定可以保持預設值。直接點擊 [建立] 就好:

備註

如果要建立 C# 腳本或其他語言的腳本,在點擊建立前記得在 [語言] 下拉選單內選擇語言。

../../_images/attach_node_window.png

備註

若是第一次接觸 GDScript,建議在繼續前先閱讀 撰寫腳本

首先我們先宣告這個物件所需要的成員變數:

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

在第一個變數 speed 上使用 export 關鍵字可以讓我們稍後在屬性面板中指定 speed 的值。當希望讓成員變數與節點內建屬性一樣可以在屬性面板中調整的話,就可以用這個功能。點擊 Player 節點,就可以在 [Script Variables] (腳本變數) 內看到這個屬性。但請記得,如果在屬性面板中修改了數值,會覆蓋掉腳本內所指定的值。

警告

使用 C# 時,若要看到新匯出的變數或訊號,就需要建置或重新建置專案。要重新建置專案可以手動點擊編輯器視窗底部的 [Mono] 文字來打開 Mono 面板,然後點擊 [建置專案] 按鈕。

../../_images/export_variable.png

_ready() 函式是當節點進入場景樹後會呼叫的函式,這裡正是取得遊戲視窗大小的好時機:

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

接著我們可以使用 _process() 函式來定義玩家要做的事。 _process() 在每一幀都會被呼叫,所以我們會用 _process() 來更新遊戲內需要常常變化的元素。而對於 Player,我們需要做這些事:

  • 檢查輸入。

  • 沿指定方向移動。

  • 播放適當的動畫。

首先,我們需要檢查輸入——玩家有按按鍵嗎?在這個遊戲中,我們需要檢查四個方向的輸入。輸入操作是在專案設定中的 [輸入映射] 裡設定的。可以在輸入映射中定義自定事件,並給事件指定不同的按鍵、滑鼠事件或其他輸入。在這個示範遊戲中,我們會使用預設事件,這些事件是被分配到鍵盤上的方向鍵。

可以用 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) 。因為這個情況下我們同時增加了水平與垂直方向的移動距離,所以玩家的移動速度應該比只在水平方向上移動 還要快

我們可以通過 正規化 (Normalize) 速率來防止這種情況,也就是要把移動的 距離 設定為 1 ,接著乘以需要的速度。這表示在對角線上移動不會比水平移動還要快了。

小訣竅

如果你從來沒學習過向量數學運算或是需要複習的話,可以參考這篇 Vector math 瞭解 Godot 中是如何使用向量的。雖然對於這篇本篇教學剩下的部分來說不必要,但若能瞭解 Godot 中的向量是最好不過的了。

同時也需要檢查玩家有沒有在移動,這樣才能呼叫 AnimatedSprite 上的 play()stop()

$get_node() 的簡寫。所以上面的程式碼中 $AnimatedSprite.play() 等同於 get_node("AnimatedSprite").play()

小訣竅

在 GDScript 中, $ 會回傳與目前節點相對路徑上的節點,若找不到則會回傳 null 。由於 AnimatedSprite 是目前節點的子節點,所以可以寫成 $AnimatedSprite

現在玩家會面向不同方向了,可以接著來更新玩家的位置。還可以使用 clamp() (箝制) 來防止 Player 物件跑出畫面外。箝制 一個值,就代表把值限制在一個給定的範圍內。將下列程式碼放到 _process 函式的末尾 (記得確定不要放到 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 參數代表了 影格時長 ——即是自從上一個影格到現在這個影格所花費的時間。通過這個數值可以確保即使幀率發生變化,移動的距離也能保持不變。

點擊 [執行場景] (F6) 並測試玩家能否夠移動到任何一個方向。

警告

若「除錯工具」中出現了這樣的錯誤

Attempt to call function 'play' in base 'null instance' on a null instance (嘗試在基礎型別為「null 實體」的 null 實體上呼叫「play」函式)

通常這代表程式中的 AnimatedSprite 節點名稱拼錯了。節點名稱有區分大小寫,而且 $NodeName 這種語法中的名稱必須與場景樹裡能看到的節點名稱一致。

選擇動畫

現在玩家可以移動了,現在讓我們來根據移動的方向改變播放的 AnimatedSprite。「walk」動畫會讓玩家看起來朝右邊行走,朝左邊行走時使用 flip_h 屬性來水平翻轉動畫。另一個動畫是「up」,朝下行走時使用 flip_v 來垂直翻轉。來把程式碼放到 _process() 函式的尾端:

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

備註

在這段程式碼中的布林賦值是程式設計師常用的一種簡寫方式。因為做了數值比較 (會得到布林值) 後也需要將這個布林值 賦值 到變數上,所以我們可以把這兩件事合在一起做。可以比較看看下面這段程式碼跟上方範例中單行布林賦值的程式:

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

再執行一次場景來看看各個方向的動畫是否正確。

小訣竅

很多人會在這裡打錯動畫名稱。SpriteFrames 面板上的動畫名稱必須與程式碼中輸入的名稱一致。如果動畫名稱為 「Walk」 ,則程式碼中也必須使用大寫的「W」。

確保能正常移動後,來把下面這行程式碼加到 _ready() 裡。如此一來在遊戲開始的時候玩家會隱藏起來:

hide()
Hide();

準備碰撞

我們希望 Player 被敵人撞到時能偵測到,但我們還沒做好敵人!沒關係,因為我們會用 Godot 的 訊號 來做這個功能。

將下列程式碼加在腳本頂部的 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 節點,然後點擊屬性面板旁邊的 [節點] 分頁來看看所有 Player 能送出的訊號:

../../_images/player_signals.png

可以看到我們自定的「hit」訊號出現在這裡了!由於敵人使用 RigidBody2D 節點,所以我們會需要 body_entered(body: Node) 訊號。這個訊號會在敵人的形體 (Body) 接觸到玩家時送出。點擊 [連接...] 後會出現「連接訊號」視窗。我們先不動這裡的設定,點擊 [連接]。Godot 會自動在玩家腳本內建立一個函式。

../../_images/player_signal_connection.png

可以注意到,出現了一個綠色圖示。這個圖示代表有訊號連到這個函式上。將這段程式碼加到函式中:

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

Enemy (敵人) 場景

是時候來做一些讓玩家閃躲的敵人了。這些敵人的行為不會很複雜:怪物會隨機在螢幕的邊緣產生,接著隨機選一個方向來直線移動。

我們來建立一個 Mob (怪物) 場景,我們利用這個場景來在遊戲中 實體化 多個獨立的怪物。

備註

請參考 實體化 以瞭解更多有關實體化的資訊。

設定節點

點擊 [場景] -> [新增場景],然後新增下列節點:

別忘了把子節點設定成無法選擇,跟剛剛在 Player 場景中一樣。

RigidBody2DGravity Scale (重力量級) 設為 0 ,讓怪物不會下墜。另外,在 PhysicsBody2D 中,點擊 Mask (遮罩) 屬性並取消選擇第一個勾選框,就可以確保怪物不會互相碰撞。

../../_images/set_collision_mask.png

接著像剛才設定玩家那樣,設定 AnimatedSprite 。這次有三種動畫: fly (飛行)、 swim (游泳) 與 walk (行走)。每個動畫都有兩個圖片,放在 art 資料夾中。

將所有動畫的「速度 (FPS)」調整為 3

../../_images/mob_animations.gif

在屬性面板中勾選 Playing (播放) 屬性為「開啟」。

接著我們會隨機選擇其中一個動畫來播放,這樣才不會讓所有怪物看起來都一樣。

就像玩家的圖片一樣,這些怪物的圖片也需要縮小。將 AnimatedSpriteScale (縮放) 屬性設為 (0.75, 0.75)

Player 場景中一樣,新增 CapsuleShape2D 來設定碰撞區域。你會需要將 Rotation Degrees (旋轉角度)屬性設定為 90 才能將形狀對齊圖片(這個屬性在屬性面板中的「Transform」(變形)底下)。

保存場景。

敵人腳本

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.

}

當產生怪物時,我們會在 min_speedmax_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)];
}

首先,我們需要從 AnimatedSprite 的 frames 屬性中取得所有動畫的名稱。這個屬性會回傳一個陣列 (Array),包含三個動畫的名稱: ["walk", "swim", "fly"]

接著我們需要選擇 02 間的一個隨機數,用來從這個陣列中選擇名稱(陣列的索引從 0 開始)。 使用 randi() % n 可以從 0n - 1 之間隨機選擇一個整數。

備註

要讓「隨機」產生的數字在每次執行場景的時候都不同的話,需要呼叫 randomize() 。我們之後會在 Main 場景中呼叫 randomize() 的,現在還不需要他。

最後我們來讓怪物在離開畫面後刪除自己。連接 VisibilityNotifier2D 節點的 screen_exited() 訊號,並新增這段程式碼:

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

這樣就完成了 Mob 場景。

Main(主要)場景

現在該把所有東西都組合在一起了。先建立一個新的場景,並新增一個名稱為 MainNode 。請確認一下建立的是 Node,而不是 Node2D。點擊 [實體化] 按鈕並選擇稍早保存的 Player.tscn

../../_images/instance_scene.png

現在,將下列節點新增為 Main 的子節點,並他們的名稱如下(數值單位為秒):

  • Timer (名稱為 MobTimer )——用以控制怪物產生的頻率

  • Timer (名稱為 ScoreTimer )——用以每秒增加成績

  • Timer (名稱為 StartTimer )——用以在開始前延遲

  • Position2D (名為 StartPosition )——用以判斷玩家的起始位置

將每個 Timer 節點的 Wait Time (等待時間)屬性按照如下設定:

  • MobTimer: 0.5

  • ScoreTimer: 1

  • StartTimer: 2

另外,將 StartTimerOne Shot 屬性設為「開啟」,並將 StartPosition 節點的 Position 設為 (240, 450)

產生怪物

Main 節點會產生新的怪物,而我們希望這些怪物出現在畫面邊緣上不同的位置。新增一個 Path2D 節點作為 Main 的子節點,設定名稱為 MobPath 。之後當選擇 Path2D 的時候,編輯器上方會出現一些新的按鈕:

../../_images/path2d_buttons.png

選擇中間的按鈕 (「新增控制點」),然後點擊顯示的轉角處來新增控制點並繪製路徑。若要將控制點吸附到網格上,請開啟「使用網格吸附」與「使用吸附」選項,這兩個選項在「鎖定」按鈕的左邊,顯示的圖示是一個磁鐵跟幾條相交的線。

../../_images/grid_snap_button.png

重要

請確定以 順時針 方向繪製路徑,不然的話怪物會 向外 產生而不是 向內 產生!

../../_images/draw_path2d.gif

在圖片內放置了點 4 以後,點擊「關閉曲線」按鈕,就可以完成繪製曲線。

現在路徑已經定義好了,接著我們新增一個 PathFollow2D 節點作為 MobPath 的子節點,並將其命名為 MobSpawnLocation 。這個節點會在移動的時候自動旋轉並跟著路徑移動,所以我們可以用 MobPath 來在路徑上隨機選擇位置與方向。

現在場景看起來會這樣:

../../_images/main_scene_nodes.png

Main(主要)腳本

Main 新增一個腳本。在腳本的頂部,我們會用 export (PackedScene) 來選擇要實體化的 Mob 場景。

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

點擊 Main 節點,接著可以在屬性面板中的「Script Variables」(腳本變數)中看到 Mob 屬性。

要為這個屬性賦值有兩個方法:

  • 從「檔案系統」面板中拖移 Mob.tscnMob 屬性來。

  • 點擊「[空]」旁邊的下拉箭頭,並選擇「載入」。接著選擇 Mob.tscn

接著,從場景 Dock 中選擇 Player 節點,然後前往側欄的節點 Dock 中。節選選擇節點 Dock 中的 [訊號] 分頁。

接著應該可以看到所有 Player 節點的訊號。點兩下列表中的 hit 訊號 (或是右鍵點擊然後選擇 [連接...])。這樣便可以打開訊號連接對話框。這裡我們要把新函式命名為 game_over ,這個函式會負責處理遊戲結束時要做的事。在 [連接訊號] 視窗中的 [Receiver 方法] 內輸入「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 節點( StartTimerScoreTimer 、與 MobTimer )的 timeout() 訊號連接到 Main 腳本中。 StartTimer 會啟動另外兩個 Timer。 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.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);
}

重要

為什麼要使用 PI (圓周率)呢?在需要角度的函式中,GDScript 會使用 弧度 而不是角度。如果你比較喜歡用角度的話,則需要使用 deg2rad() (角度轉弧度)與 rad2deg() (弧度轉角度)兩個函式來轉換弧度與角度。

測試場景

來測試一下場景,看看是不是所有東西都正常了。將下面這段程式碼新增至 _ready() 中:

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

同時,將 Main 設為「主要場景」——也就是遊戲啟動後會自動執行的場景。點擊「執行」按鈕,當提示框出現後選擇 Main.tscn

現在應該可以到處移動玩家了,並會看到怪物產生出來。玩家在被怪物撞到後會消失。

確定好了所有東西都正常後,將呼叫 new_game() 的程式碼從 _ready() 裡刪除。

HUD

最後,這個遊戲需要 UI:一個會顯示如分數、「Game Over」訊息、與重新開始遊戲按鈕的界面。建立一個新場景並新增一個名為 HUDCanvasLayer 節點。HUD 是覆蓋顯示於遊戲畫面上方,用於表示資訊的「擡頭顯示器 (Heads-Up Display)」的簡稱。

使用 CanvasLayer 節點可以讓我們在遊戲上方的圖層上繪製 UI 元素,這樣上面顯示的資訊就不會被其他遊戲元素如玩家或怪物給蓋住。

HUD 需要顯示如下資訊:

  • 分數,會通過 ScoreTimer 來更改。

  • 訊息,如「Gamer Over」或「請準備!」

  • 用來開始遊戲的「Start」按鈕。

UI 元素所使用的基本節點是 Control 。我們需要使用兩種類型的 Control 節點來給這個遊戲建立 UI: Label (標籤)與 Button (按鈕)。

HUD 節點建立下列子節點:

  • Label ,命名為 ScoreLabel

  • Label ,命名為 Message

  • Button ,命名為 StartButton

  • Timer ,命名為 MessageTimer

點擊 ScoreLabel 並在屬性面板中的 Text 輸入一個數字。 Control 節點的預設字體很小,而且也縮放得不是很好。遊戲素材裡有包含了一個字體檔,檔名為「Xolonium-Regular.ttf」。依照下列步驟來使用這個字體:

  1. 在「Custom Fonts」(自定義字體)中,選擇「New DynamicFont」(新增 DynamicFont 格式的字體)

../../_images/custom_font1.png
  1. 點擊新增的「DynamicFont」,並點擊「Font/Font Data」(字體與字體資料)中的「載入」,接著選擇「Xolonium-Regular.ttf」檔案。另外也必須設定字體的 Size (大小)。可以設定為 64

../../_images/custom_font2.png

完成 ScoreLabel 的設定後,點擊 DynamicFont 屬性旁邊的下拉箭頭,並選擇「Copy」(複製),接著在另外兩個 Control 節點上相同的地方「Paste」(貼上)。

備註

錨點與外邊距: Control 節點除了位置與大小之外,也可以設定錨點 (Anchor) 與外邊距 (Margin)。錨點定義原點(相對於節點邊緣的參考點)。外邊距則會在移動或縮放 Control 節點時自動更新。這兩個屬性的變化會反映出 Control 節點到錨點的距離。詳細說明請參考 利用 Control 節點設計界面

依照下圖排列節點。點擊「畫面配置」按鈕來設定 Control 節點的配置:

../../_images/ui_anchor.png

可以拖移節點來手動排列,或是使用下列設定來更精確地排放:

ScoreLabel

  • 畫面配置 :「上延伸」

  • Text0

  • Align :「Center」

Message

  • 畫面配置 :「水平中央延長」

  • TextDodge the Creeps!

  • Align :「Center」

  • Autostrap : 「開啟」

StartButton

  • TextStart

  • 畫面配置 :「中下」

  • Margin

    • Top: -200

    • Bottom: -100

MessageTimerWait Time 設為 2 ,將 One Shot 設為「開啟」。

現在將這個腳本新增至 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();
}

當我們想要暫時顯示訊息(如「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」按鈕。

備註

需要暫停一下下的時候,除了使用 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():
    $Message.hide()
public void OnStartButtonPressed()
{
    GetNode<Button>("StartButton").Hide();
    EmitSignal("StartGame");
}

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

將 HUD 場景連接至 Main 場景

我們現在做好 HUD 場景了。讓我們回到 Main。像剛才實體化 Player 場景一樣,在 Main 場景中實體化 HUD。場景樹會看起來像這樣,檢查一下有沒有漏了什麼:

../../_images/completed_main_scene.png

我們現在需要把 HUD 的功能連接到 Main 腳本。所以我們需要在 Main 場景內新增一點點東西:

在節點分頁中,設定 HUD 的 start_game 訊號的連接,在「連接訊號」視窗中將「Receiver 方法」設為「new_game」來將訊號連接到 Main 節點的 new_game() 函式。之後檢查看看腳本中的 ``func 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」並直接開始新遊戲的話,上一場遊戲的怪物可能還在畫面上。開始遊戲的時候如果能讓舊的怪物消失會更好。而我們只需要告訴 所有 怪物,刪除自己。我們可以在這裡使用「群組」功能。

Mob 場景中,先選擇根節點,然後點擊屬性面板旁邊的「節點」分頁(跟設定節點訊號的地方相同)。點擊「訊號」旁邊的「群組」,接著編輯器會讓你設定新群組名稱,最後點擊「新增」。

../../_images/group_tab.png

現在所有的怪物都會被放在「mobs」群組內了。我們可以接著在 Main 腳本的 game_over() 函式中增加下面這行的程式碼:

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

call_group() 函式會呼叫群組中所有節點的指定名稱的函式——在這裡我們用來讓所有怪物刪除自己。

最終調整

現在,這個遊戲的所有功能都已經完成了。接下來的這些步驟可以讓遊戲畫龍點睛、增進遊戲體驗。如果你有更多 Idea 的話,也可以自由地擴展遊戲。

背景

預設的灰色背景並不是很吸睛,所以來改個顏色。其中一種改背景的方式是新增一個 ColorRect 節點。將 ColorRect 新增為 Main 底下的第一個節點,這樣 ColorRect 才會被繪製在其他節點的下方。ColorRect 只有一個屬性: Color (色彩)。選擇一個你喜歡的顏色,並選擇「畫面配置」->「全矩形」,這樣就會覆蓋整個畫面。

如果你有適合的圖片的話,也可以用圖片作為背景。改用 TextureRect 節點即可。

音效

聲音與音樂可以是一種最有效能增加遊戲體驗吸引力的方法。遊戲素材資料夾中有兩個聲音檔案:背景音樂用的「House In A Forest Loop.ogg」與玩家 Game Over 時用的「gameover.wav」。

Main 中新增兩個 AudioStreamPlayer 節點作為子節點。將其中一個命名為 Music ,另一個則為 DeathSound 。在各個節點上點擊 Stream (串流)屬性,選擇「載入」,並選擇對應的音訊檔。

要播放音樂,在 new_game() 函式中新增 $Music.play() ,並在 game_over() 函式中新增 $Music.stop()

最後,在 game_over() 函式中新增 $DeathSound.play()

鍵盤快捷鍵

由於這個遊戲是用鍵盤控制來玩的,如果能用鍵盤上的按鍵也能開始新遊戲的話會很方便。我們可以使用 Button 節點的「Shortcut」(快捷鍵)屬性來做到。

HUD 場景中選擇 StartButton ,並在屬性面板中找到 Shortcut 屬性。選擇「新增 Shortcut」並點擊「Shortcut」項目。接著會出現第二個 Shortcut 屬性。選擇「新增 InputEventAction」,並點擊新的「InputEventAction」。最後,在 Action 屬性中輸入名稱 ui_select 。這個是預設關聯在空白鍵上的輸入事件。

../../_images/start_button_shortcut.png

現在當開始按鈕出現的時候,可以點擊按鈕或是使用按鍵 空白鍵 來開始新遊戲。

專案檔案

你可以在這幾個網址上找到這個專案的完成版: