移動玩家

在這一課中,我們將新增玩家的動作、動畫,並將其設定為偵測碰撞。

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

../../_images/add_script_button.webp

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

備註

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

../../_images/attach_node_window.webp

備註

如果這是你第一次接觸 GDScript,請在繼續之前閱讀 為場景編寫腳本

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

extends Area2D

@export var speed = 400 # How fast the player will move (pixels/sec).
var screen_size # Size of the game window.

在第一個變數 speed 前使用 export 關鍵字,可以讓我們在 Inspector(屬性檢視器)中設定它的值。對於您希望能夠像節點的內建屬性一樣調整的數值,這會非常方便。點擊 Player 節點,您會看到該屬性現在出現在 Inspector(屬性檢視器)中一個以腳本名稱命名的新區塊裡。請記住,如果您在這裡更改數值,它將會覆蓋腳本中寫入的數值。

警告

如果你使用 C#,每當你想看到新的匯出變數或訊號時,你需要(重新)建置專案組件。這個建置可以透過點擊編輯器右上角的 建置 按鈕手動觸發。

../../_images/build_dotnet.webp
../../_images/export_variable.webp

你的 player.gd 腳本應該已經包含 _ready()_process() 函式。如果你沒有選擇上方顯示的預設範本,請在遵循課程的同時建立這些函式。

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

func _ready():
    screen_size = get_viewport_rect().size

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

  • 檢查輸入。

  • 沿指定方向移動。

  • 播放適當的動畫。

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

點擊 專案 -> 專案設定 打開專案設定視窗,然後按一下頂部的 輸入對應 分頁。在頂部欄中鍵入“move_right”,然後按一下“新增”按鈕以新增該 move_right 動作。

../../_images/input-mapping-add-action.webp

我們需要為這個操作分配一個按鍵。按一下右側的“+”圖示,打開事件管理器視窗。

../../_images/input-mapping-add-key.webp

會自動選中“監聽輸入...”區域。按下鍵盤上的“右方向”鍵,選單應該像這樣。

../../_images/input-mapping-event-configuration.webp

選擇“確定”按鈕。現在“右方向”鍵與 move_right 動作關聯了。

重複這些步驟以再新增三個對應:

  1. move_left 對應到左方向鍵。

  2. move_up 對應到向上方向鍵。

  3. move_down 對應到向下方向鍵。

現在場景看起來會這樣:

../../_images/input-mapping-completed.webp

按一下“關閉”按鈕關閉專案設定。

備註

我們只將一個鍵對應到每個輸入動作,但你可以將多個鍵、操縱桿按鈕或滑鼠按鈕對應到同一個輸入動作。

可以用 Input.is_action_pressed() 來偵測按鍵是否被按下。這個函式會在按鍵被按下時回傳 true ,若沒按下則會回傳 false

func _process(delta):
    var velocity = Vector2.ZERO # The player's movement vector.
    if Input.is_action_pressed("move_right"):
        velocity.x += 1
    if Input.is_action_pressed("move_left"):
        velocity.x -= 1
    if Input.is_action_pressed("move_down"):
        velocity.y += 1
    if Input.is_action_pressed("move_up"):
        velocity.y -= 1

    if velocity.length() > 0:
        velocity = velocity.normalized() * speed
        $AnimatedSprite2D.play()
    else:
        $AnimatedSprite2D.stop()

我們先從設定 velocity (速率) 開始,設定成 (0, 0) ——這表示在預設情況下,玩家應該不會移動。接著依據不同的輸入來增加/減少 velocity 來往不同方向移動。例如,如果同時按住 方向鍵,最終的 velocity 向量值應該是 (1, 1) 。因為這個情況下我們同時增加了水平與垂直方向的移動距離,所以玩家的移動速度應該比只在水平方向上移動 還要快

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

小訣竅

如果你從來沒學習過向量數學運算或是需要複習的話,可以參考這篇 向量數學 瞭解 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 = position.clamp(Vector2.ZERO, screen_size)

小訣竅

_process() 函式的 delta 參數代表了 影格時長 ——即是自從上一個影格到現在這個影格所花費的時間。通過這個數值可以確保即使影格率發生變化,移動的距離也能保持不變。

點擊「執行目前場景」(F6,macOS 上為 Cmd + R) 並確認您可以朝所有方向在螢幕上移動玩家。

警告

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

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:
    $AnimatedSprite2D.animation = "walk"
    $AnimatedSprite2D.flip_v = false
    # See the note below about the following boolean assignment.
    $AnimatedSprite2D.flip_h = velocity.x < 0
elif velocity.y != 0:
    $AnimatedSprite2D.animation = "up"
    $AnimatedSprite2D.flip_v = velocity.y > 0

備註

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

if velocity.x < 0:
    $AnimatedSprite2D.flip_h = true
else:
    $AnimatedSprite2D.flip_h = false

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

小訣竅

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

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

hide()

準備碰撞

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

在腳本頂部新增以下內容。如果你使用的是 GDScript,請將其新增到 extends Area2D 之後。如果你使用 C#,請將其新增到 public partial class Player : Area2D 之後:

signal hit

這段程式碼定義了一個叫做「hit」的自定訊號,稍後我們會讓玩家在碰撞到敵人的時候送出這個訊號。我們會使用 Area2D 來偵測碰撞。先選擇 Player 節點,然後點擊屬性檢視器旁邊的 [節點] 分頁來看看所有 Player 能送出的訊號:

../../_images/player_signals.webp

請注意我們自訂的 "hit" 訊號也在那裡!由於我們的敵人將會是 RigidBody2D 節點,我們需要 body_entered(body: Node2D) 訊號。當一個物理物件接觸到玩家時,這個訊號就會發射。點擊「連接...」,接著會出現「連接訊號」視窗。

Godot 會直接在你的腳本中建立一個完全相同的函數名稱。你現在不需要更改預設設定。

警告

如果你使用外部文字編輯器(例如 Visual Studio Code),目前有個錯誤會導致 Godot 無法正確運作。你會被導向你的外部編輯器,但新的函式不會在那裡。

在這種情況下,你需要自己將這個函式寫進「玩家」的腳本檔案中。

../../_images/player_signal_connection.webp

請注意綠色圖示,它表示訊號已連接到此函數;這並不代表該函數存在,只是訊號將會嘗試連接到一個具有該名稱的函數,因此請務必仔細檢查函數名稱的拼寫是否完全一致!

接下來新增這段程式到函式中:

func _on_body_entered(_body):
    hide() # Player disappears after being hit.
    hit.emit()
    # Must be deferred as we can't change physics properties on a physics callback.
    $CollisionShape2D.set_deferred("disabled", true)

每當敵人碰撞到玩家後都會送出這個訊號。這時我們需要關閉玩家的碰撞偵測,這樣才不會觸發超過一次的 hit 訊號。

備註

如果在引擎處理碰撞過程中間禁用碰撞區域可能會發生錯誤。所以我們需要使用 set_deferred() 來讓 Godot 等到能安全禁用碰撞區域後再禁用。

最後我們要做的,就是新增一個函式,用來在開始新遊戲時重設玩家。

func start(pos):
    position = pos
    show()
    $CollisionShape2D.disabled = false

在玩家部分的工作完成後,我們將在下一課中研究敵人。