社團文章

GDC 20 Games Workshop | Jetpack Joyride

作者Bogay

用 Godot 4 製作 Jetpack Joyride

事前準備

https://godotengine.org/download 下載 Godot 4

Jetpack Joyride 簡介

Jetpack Joyride is a side-scrolling endless mobile game from 2011. It only requires a single input button to control the player. The game is fairly complex overall, though the basic premise is very simple. The game came from the same studio that made Fruit Ninja.

The game features a character with a machine-gun jetpack. When holding the input, the player will rise (and destroy everything below!) When the input is released, the character will fall. The character can run on the ground if they reach the bottom of the screen.

Jetpack Joyride | 20 Games Challenge

YT 介紹(二代):https://www.youtube.com/watch?v=1myElNV4MG0

Godot 簡介

關於更完整的介紹,建議參考官方文件:Intorduction to Godot

Godot 是一款開源的遊戲引擎,可以做 2D 或 3D 遊戲。編輯器本身支援許多平台,包含 PC / android / web / VR,並且相當輕量(相較於 Unity / UE),對於老舊裝置也很友善。 在 Godot 中,大部分腳本使用 GDScript 撰寫,語法與 Python 相似,專為 Godot 設計。但如果熟悉 Unity 的話,Godot 也支援使用 C# 當做腳本語言,甚至透過 GDExtension 的機制,你也可以使用其他的程式語言(門檻較高)。 比較多是獨立遊戲在使用,但也有一些大作,可參考官方 showcase 上面的列表。台灣近幾年比較有名的案例是文字遊戲,他們在 TGDF 有分享過相關敬驗:[2022 TGDF] 2D 遊戲開發,除了 Unity 你還可以考慮試試 Godot!以《文字遊戲》為例 (張文瀚)

建立第一個場景

建立新專案

  1. 開啟 Godot -> 點選左上角的 Create
  2. Project Name 輸入 jetpack-joyride,然後將 Project Path 修改成自己習慣的位置
  3. Rendere 選擇 Compatibility(適用於 2D 遊戲,相容性最廣,可以將遊戲發佈到網頁上)
  4. 點選 Create
  5. 前往 Project → Project Settings → Display → Window,設定:
    • Width: 1280,Height: 720

編輯器介面

Godot 編輯器介面總覽

開啟 Godot 後,你會看到四個主要面板:

面板

用途

Scene(左上)

目前場景的節點樹狀結構

Inspector(右側)

被選取節點的屬性

FileSystem(左下)

專案中的所有檔案

Viewport(中央)

場景的視覺預覽

節點(Node)與場景(Scene)

節點與場景

Godot 中的一切都是節點 (Node)。節點是遊戲的基本積木 --- 每個節點只負責一件事(e.g. 顯示圖片、播放音效、偵測碰撞...)。 我們可以將節點掛到另一個節點底下成為它的子節點,以該專案為例,玩家就會有顯示圖片 (Sprite2D) 跟碰撞 (Area2D, CollisionShape2D) 等功能,這些節點全部組裝在一起,就可以提供更複雜的功能。 這樣由多個節點組成的集合就可以被視為一顆樹,而場景 (Scene) 在 Godot 中就是一棵儲存起來的節點樹,可以重複使用,類似 Unity 的 prefab,而節點就類似 Unity 中的 component,負責提供特定功能。

在剛建立的 Godot 專案中,我們並沒有任何場景存在,因此我們可以先尋找編輯器左側的 Scene 面板,點選 "2D Scene" 創建一個空的 2D 場景,然後按下 Ctrl + s,將它另存為 main.tscn,做為我們遊戲的主要場景。 儲存後可以按下 F5,或是點選編輯器右上角的 "Run Project" 按鈕測試專案,Godot 會跳出視窗要求設定 main scene,也就是專案預設要載入的場景。選擇 Select Current 後就可以看到遊戲預設的空白畫面了。 如果之後有需要更改 main scene 的話,也可以在 FileSystem 面板中尋找想要修改的場景右鍵後選擇 "Set as Main Scene"。

背景與場景設定

加入捲動背景

Parallax2D 自動捲動背景

背景使用 Parallax2D 節點實現自動橫向捲動效果。

  1. 右鍵點擊 main -> Add Child Node -> Parallax2D,重新命名為 background
  2. 在 Inspector 設定:
    • Repeat SizeVector2(1024, 0)(水平方向重複拼接)
    • AutoscrollVector2(-64, 0)(每秒向左捲動 64 px)
    • Repeat Times2
  3. background 下新增子節點 Sprite2D
  4. 將背景貼圖拖入 Texture 欄位;若暫時沒有素材,可先使用專案內建的 icon.svg 做為佔位符
  5. 調整 Sprite2DPositionScale,使其覆蓋整個畫面(約 scale = (9.85, 7.15)

Parallax2D 會讓背景圖無縫循環捲動,只需設定 Autoscroll 速度,Godot 便自動處理重複拼接,不需要任何程式碼。

加入 UI 層

CanvasLayer 與 UI 渲染層Control 節點的 Anchor 系統

  1. main 下新增子節點 → CanvasLayer,重新命名為 UI
  2. UI 內新增子節點 Label
  3. 在 Inspector 將 Label 的 Anchors Preset 設為 Top Right(錨點在右上角)
  4. 調整 Offset 讓文字略微離開邊緣
  5. Theme Overrides → Font Sizes → Font Size32

CanvasLayer 會讓 UI 固定在畫面上,即使遊戲世界的攝影機移動,UI 也不會跟著偏移。

加入地板與天花板

遊戲場地需要上下兩道邊界,防止玩家飛出畫面:

地板(ground)

  1. main.tscn 新增 StaticBody2D 節點,命名為 ground
  2. 移動到畫面底部
  3. 在底下新增 ColorRect,用來顯示地板外觀。新增後設定成喜歡的顏色並調整 Anchors Preset 為 "Center",調整大小讓它可以覆蓋畫面左右兩側 Note: 調整大小時先按住 Alt 再拖動可以維持中心點
  4. 在底下新增 CollisionShape2D,並在 Shape 欄位新增一個 RectangleShape2D,將大小調整到跟剛剛新增的 ColorRect 差不多

天花板(ceil)

  1. ground 複製一份,並重新命名為 ceil
  2. 往上移動到畫面上方

這時候兩個 node 裡面的 RectangleShape2D 其實是共用的,所以調整其中一邊的話也會影響到另外一個,如果希望可以讓兩邊的設定分開,可以選擇其中一邊 Shape 欄位的下拉選單,點選 "Make Unique",

設定玩家節點

官方文件參考

在主場景中新增玩家

本專案已提供主角的美術素材,位於 sprite/ 資料夾,包含三種狀態:

檔案

用途

sprite/meatball_walk.png

站在地面時

sprite/meatball_fly.png

噴射飛行時

sprite/meatball_hurt.png

碰到障礙物時

玩家直接在 main.tscn 內建立,不需要獨立的場景檔案:

  1. 開啟 main.tscn,右鍵點擊 mainAdd Child NodeCharacterBody2D,命名為 player
  2. player 下新增兩組子節點:
    • Sprite2D — 顯示角色圖片,Texture 拖入 sprite/meatball_walk.png
    • CollisionShape2D — 賦予玩家物理碰撞形狀,在 Shape 欄位新增 RectangleShape2D 並調整為合適的大小
  3. player 移動到合適的起始位置(畫面左下角,略高於地板)

CharacterBody2D 是 Godot 用來製作可控制角色的節點。它處理沿地板與牆壁移動的邏輯,但物理計算由你的程式碼負責。

撰寫 player.gd

GDScript 基礎Learn GDScript from Zero

以下程式碼是一個簡單的 GDScript 範例:

extends Node2D          # 這個腳本掛載到哪種節點上

var score := 0.0        # 宣告變數(自動推斷型別)
var name: String = "Barry"  # 明確標注型別

# _ready 在節點第一次出現於場景時執行一次
func _ready() -> void:
    print("Hello, world!")

# _process 每個畫格執行一次;delta = 距離上一畫格的秒數
func _process(delta: float) -> void:
    score += delta      # 將經過時間累加到分數

現在,我們要替玩家掛上腳本。右鍵點擊 playerAttach Script → 儲存為 player.gd。 我們會分幾個階段逐步完成這個腳本。

階段一:讓玩家受到重力下墜

第一步先實作最基礎的物理:重力。玩家應該要自然往下掉,並且不能穿透地板。

class_name Player
extends CharacterBody2D

@export var gravity: float = 600   # 每秒向下加速的量(px/s²)

func _physics_process(delta: float) -> void:
    velocity.y += gravity * delta   # 重力每幀累積速度
    move_and_slide()                # 移動並自動處理與地板的碰撞

_physics_process vs _process 物理相關的更新要放在 _physics_process,它的呼叫頻率固定(預設 60 Hz),不受畫面更新率影響,讓物理模擬更穩定。

move_and_slide() 這是 CharacterBody2D 提供的方法,會依照 velocity 移動角色,並自動處理與靜態物體(如地板)的碰撞,不需要自己寫碰撞解析。

按下 F5 執行遊戲,此時角色應該會從起始位置自由落下,落到地板上停住。

階段二:設定輸入,讓玩家可以噴射飛行

Input Map 設定

在 Godot 中,我們會使用 Input Map 替輸入做抽象化——先替動作命名,再綁定實體按鍵,程式碼只需要詢問「動作有沒有被觸發」,不需要直接寫死按鍵名稱。 這樣的作法讓跨平台輸入變得容易,類似於 Unity 較新的 Input System。

  1. Project → Project Settings → Input Map
  2. 在「Add New Action」欄位輸入 flyAdd
  3. 點選 fly 旁的 + → 按下空白鍵OK

接著在腳本中加入輸入判斷:

class_name Player
extends CharacterBody2D

@export var gravity: float = 600
@export var acceleration: float = 800   # 噴射時的向上加速力

func _physics_process(delta: float) -> void:
    if Input.is_action_pressed("fly"):
        velocity.y -= acceleration * delta   # 向上加速(y 軸向下為正)
    else:
        velocity.y += gravity * delta

    move_and_slide()

執行遊戲,按住空白鍵應該可以讓角色上升,放開後下墜。

階段三:限制最大上升速度

目前持續按住空白鍵的話,角色速度會無限增加,飛出畫面。我們加入速度上限,讓手感更可控:

class_name Player
extends CharacterBody2D

const MAX_FLY_SPEED = 600   # 向上的速度上限(px/s)

@export var gravity: float = 600
@export var acceleration: float = 800

func _physics_process(delta: float) -> void:
    if Input.is_action_pressed("fly"):
        # move_toward(當前值, 目標值, 最大步進量):平滑趨近目標,不會超過
        velocity.y = move_toward(velocity.y, -MAX_FLY_SPEED, acceleration * delta)
    else:
        velocity.y += gravity * delta

    move_and_slide()

為什麼速度上限是負數? 在 Godot 的 2D 座標系中,y 軸向下為正。向上飛行的速度是負值,所以上限用 -MAX_FLY_SPEED 表示「向上最多這麼快」。

move_toward(from, to, delta) 讓數值從 from 平滑趨向 to,每次最多移動 delta,永遠不會超過目標值。這裡用來讓加速過程更流暢,而不是瞬間跳到上限。

階段四:依狀態切換貼圖

玩家有三種素材對應不同狀態。我們用 @export 讓素材可以在 Inspector 中直接指定,再根據目前的輸入切換 Sprite2D 的貼圖:

class_name Player
extends CharacterBody2D

const MAX_FLY_SPEED = 600

@export var gravity: float = 600
@export var acceleration: float = 800

@export var walk_tex: Texture2D   # 一般行走貼圖
@export var fly_tex: Texture2D    # 噴射飛行貼圖
@export var hurt_tex: Texture2D   # 碰到障礙物貼圖

@onready var sprite_2d = $Sprite2D   # 取得子節點的參考

func _physics_process(delta: float) -> void:
    if Input.is_action_pressed("fly"):
        velocity.y = move_toward(velocity.y, -MAX_FLY_SPEED, acceleration * delta)
        sprite_2d.texture = fly_tex
    else:
        velocity.y += gravity * delta
        sprite_2d.texture = walk_tex

    move_and_slide()

@export 加上這個標記的變數會出現在 Inspector 面板,可以直接在編輯器中拖入素材,不需要寫死路徑在程式碼裡。

@onready 確保在節點完全加入場景後才取得子節點的參考。如果省略,在 _ready() 執行之前存取子節點可能會得到 null

設定完成後,在 Inspector 中將 walk_texfly_texhurt_tex 分別拖入對應的 png 檔案,執行遊戲確認貼圖會跟著狀態切換。

遊戲的主角是一顆貢丸,這是因為我在準備美術素材的時候玩到竹梅賽做的嘎拉給木

階段五:縮放動畫(Squash & Stretch)

只切換貼圖已經有基本的視覺回饋,但我們可以再加一點「果凍感」——用縮放讓角色看起來更有重量與彈性:

狀態

效果

縮放值

噴射飛行

垂直拉伸(Stretch)

Vector2(0.9, 1.1)

站在地板

水平方向週期性擠壓(Squash)

隨時間正弦波動

空中下墜

恢復原始比例

Vector2(1.0, 1.0)

class_name Player
extends CharacterBody2D

const MAX_FLY_SPEED = 600

@export var gravity: float = 600
@export var acceleration: float = 800

@export var walk_tex: Texture2D
@export var fly_tex: Texture2D
@export var hurt_tex: Texture2D

@onready var sprite_2d = $Sprite2D

func _physics_process(delta: float) -> void:
    if Input.is_action_pressed("fly"):
        velocity.y = move_toward(velocity.y, -MAX_FLY_SPEED, acceleration * delta)
        sprite_2d.texture = fly_tex
        sprite_2d.scale = Vector2(0.9, 1.1)   # 飛行時:橫壓縱拉
    else:
        velocity.y += gravity * delta
        sprite_2d.texture = walk_tex
        if is_on_floor():
            # 站在地板時:用正弦波產生輕微的呼吸感擠壓
            var squash = sin(Time.get_ticks_msec() * 0.005) * 0.05
            sprite_2d.scale = Vector2(1.0 + squash, 1.0 - squash)
        else:
            sprite_2d.scale = Vector2(1.0, 1.0)   # 下墜時:恢復原始比例

    move_and_slide()

Squash & Stretch 這是傳統動畫的十二原則之一,由迪士尼動畫師 Ollie Johnston 與 Frank Thomas 在 1981 年的著作《The Illusion of Life》中提出。壓縮(squash)和拉伸(stretch)能讓物體看起來有質量與彈性,即使只是改變縮放比例,也能大幅提升遊戲的「手感」。

sin(Time.get_ticks_msec() * 0.005) * 0.05 Time.get_ticks_msec() 回傳遊戲啟動至今的毫秒數,乘上一個小係數後傳入 sin(),就得到一個在 -1 到 1 之間平滑往復的值。再乘上 0.05 縮小振幅,讓縮放變化維持在 ±5% 以內,細膩而不誇張。

is_on_floor() CharacterBody2D 在呼叫 move_and_slide() 後會自動更新這個狀態,可以直接判斷角色是否落地。

執行遊戲,觀察三種狀態下角色縮放的細微變化。

階段六:偵測與障礙物的碰撞

最後,我們要讓玩家能夠感知到自己碰到了障礙物。CharacterBody2D 的碰撞只能偵測到靜態物體(如地板),對於需要「重疊感知」的障礙物,我們改用 Area2D

先在 player 節點下新增子節點:

  • Area2D(內含一個 CollisionShape2D)— 形狀同樣用 RectangleShape2D,略小於外層的碰撞框即可

接著更新腳本,加入訊號宣告與碰撞處理:

class_name Player
extends CharacterBody2D

const MAX_FLY_SPEED = 600

@export var gravity: float = 600
@export var acceleration: float = 800

@export var walk_tex: Texture2D
@export var fly_tex: Texture2D
@export var hurt_tex: Texture2D

@onready var sprite_2d = $Sprite2D

signal player_died   # 玩家碰到障礙物時發出此訊號

func _ready() -> void:
    # 將 Area2D 的 area_entered 訊號連接到我們的處理函式
    $Area2D.area_entered.connect(_on_area_entered)

func _physics_process(delta: float) -> void:
    if Input.is_action_pressed("fly"):
        velocity.y = move_toward(velocity.y, -MAX_FLY_SPEED, acceleration * delta)
        sprite_2d.texture = fly_tex
    else:
        velocity.y += gravity * delta
        sprite_2d.texture = walk_tex

    move_and_slide()

func _on_area_entered(area: Area2D) -> void:
    if area.owner.is_in_group("obstacle"):
        player_died.emit()

訊號(Signal) signal player_died 宣告一個自訂事件。當玩家碰到障礙物時,用 player_died.emit() 發出訊號;其他節點(例如 main.gd)只需連接這個訊號,就能在適當時機做出回應(例如切換到遊戲結束畫面)。這讓 player.gd 不需要知道遊戲其他部分的存在。

以上都設定完成後,應該就可以看到角色正常移動了。

障礙物

官方文件參考

本遊戲有兩種障礙物:公車(obstacle.tscn)與辣椒醬(rocket.tscn)。兩者共用同一份腳本 obstacle.gd,僅速度與外觀不同。

訊號(Signals)是什麼?

訊號(Signals)使用教學

在開始撰寫腳本之前,先介紹一個即將用到的核心概念。

訊號是 Godot 內建的事件系統。節點在某事發生時**發出(emit)訊號;任何已連接(connected)**到該訊號的節點都會執行對應的函式。

VisibleOnScreenNotifier2D 偵測到節點離開畫面
        │ 發出 "screen_exited"
        └──► obstacle.gd 接收到 → 呼叫 queue_free 刪除自己

這讓每個節點只負責自己的事,不需要在外部寫輪詢邏輯來判斷「這個東西有沒有跑出畫面」。

撰寫 obstacle.gd(共用腳本)

先建立腳本,兩個場景都會使用它。右鍵根節點 → Attach Script → 儲存為 obstacle.gd

階段一:讓障礙物往左移動

extends Node2D

@export var speed: float = 64   # 每秒向左移動的像素數

func _process(delta: float) -> void:
    position += Vector2.LEFT * (speed * delta)

Vector2.LEFT 等同於 Vector2(-1, 0)。在 Godot 的 2D 座標系中,x 軸向右為正,所以向左移動要使用負 x。Vector2.LEFT / Vector2.RIGHT / Vector2.UP / Vector2.DOWN 是 Godot 提供的常數,讓方向語意更清晰。

此時可以將障礙物場景暫時拖入 main.tscn 測試,應該可以看到它緩緩往左移動。但有個問題:它會一直移動下去,永遠不會消失,長期下來會佔用越來越多記憶體。

階段二:離開畫面後自動刪除

先在場景中加入 VisibleOnScreenNotifier2D 子節點:

  • 選取它後,在 Viewport 中調整橘色的 Rect 邊框,使其略大於障礙物的 Sprite,確保障礙物完全移出畫面後才觸發。

接著更新腳本,在 _ready() 中連接訊號:

extends Node2D

@export var speed: float = 64

func _ready() -> void:
    # 障礙物完全離開畫面時,發出 screen_exited 訊號,觸發 queue_free 刪除自己
    $VisibleOnScreenNotifier2D.screen_exited.connect(queue_free)

func _process(delta: float) -> void:
    position += Vector2.LEFT * (speed * delta)

queue_free 將節點排入「待刪除」佇列,在當前幀結束後安全地從場景樹移除並釋放記憶體。不能直接呼叫 free() 是因為在物理或訊號處理期間節點可能仍在被參考,強制刪除會造成錯誤;queue_free 會等到安全時機再執行。

建立公車場景

本專案已提供公車的美術素材 sprite/bus.png,可直接使用。

  1. Scene → New Scene,根節點選 Node2D,命名為 Obstacle,儲存為 obstacle.tscn
  2. 在根節點設定 Scale = (0.6, 0.6) 縮小整體大小
  3. 新增以下子節點:
    • Sprite2DTexture 拖入 sprite/bus.png
    • Area2D — 偵測與玩家的重疊
      • Area2D 內新增 CollisionPolygon2D,用來描繪不規則的公車外型碰撞區域
    • VisibleOnScreenNotifier2D — 偵測節點何時離開畫面
  4. 掛上 obstacle.gd 腳本(根節點右鍵 → Attach Script → 選擇現有的 obstacle.gd
  5. 選取根節點 Obstacle → Inspector → Node → Groups → 輸入 obstacleAdd

如何編輯 CollisionPolygon2D

選取 CollisionPolygon2D 節點後,Viewport 上方工具列會出現多邊形編輯模式:

  • 左鍵點擊畫面上的空白處:新增頂點
  • 左鍵拖曳已存在的頂點:移動位置
  • 右鍵點擊已存在的頂點:刪除該點

沿著公車的外輪廓依序點下頂點,圍成一個貼合車身的多邊形即可。不需要非常精確,略微內縮一點反而能讓遊戲判定更寬鬆、體驗更好。

為什麼要加入 obstacle 群組? player.gd 在偵測到 Area2D 重疊時,會用 area.owner.is_in_group("obstacle") 來判斷碰到的是不是障礙物——因為未來場景中可能有其他 Area2D(如金幣),不能把所有重疊都當成死亡事件。群組(Groups)就是 Godot 裡最簡單的「標籤」系統。

建立東泉辣椒醬場景

辣椒醬是快速橫飛的第二種障礙物,使用 sprite/chili_sauce.png

  1. Scene → New Scene,根節點選 Node2D,命名為 Rocket,儲存為 rocket.tscn
  2. 新增以下子節點:
    • Sprite2DTexture 拖入 sprite/chili_sauce.png;設定根節點 Scale = (0.3, 0.3)
    • Area2D — 偵測與玩家的重疊
      • Area2D 內新增 CollisionShape2D,形狀選 New RectangleShape2D;調整覆蓋辣椒醬的主體(約 734×124,略向左偏移 x = -69
    • VisibleOnScreenNotifier2D — 偵測節點何時離開畫面,同樣調整 Rect 略大於 Sprite
  3. 掛上 obstacle.gd 腳本
  4. 在 Inspector 將 speed 設為 400(比公車快很多,製造緊迫感)
  5. 同樣加入 obstacle 群組

辣椒醬形狀規則,直接用 CollisionShape2D + RectangleShape2D 就夠了,不需要像公車一樣用多邊形。

上述兩個場景建立完成後,可以將它們拖入 main.tscn 測試,應該可以看到公車緩慢往左、辣椒醬快速橫掃,並在離開畫面後自動消失。

生成器

建立生成器場景

Timer 類別說明

  1. Scene → New Scene,根節點選 Node2D,命名為 Spawner,儲存為 spawner.tscn
  2. 新增 Timer 子節點
    • 在 Inspector 設定:Wait Time = 5.0Autostart = 開啟

撰寫 spawner.gd

PackedScene 與動態實體化

生成器同時管理公車與辣椒醬兩種障礙物,每次計時器到時以機率決定生成哪種(或不生成):

extends Node2D

@export var obstacle_scene: PackedScene   # 在 Inspector 中拖入 obstacle.tscn
@export var rocket_scene: PackedScene     # 在 Inspector 中拖入 rocket.tscn

func _ready() -> void:
    $Timer.timeout.connect(_spawn_obstacle)

func _spawn_obstacle() -> void:
    if randf() < 0.5:
        return           # 50% 機率跳過本次生成,保持節奏不規律
    if randf() < 0.4:
        _spawn_sauce()   # 剩餘中有 40% 機率改生成辣椒醬
        return
    _spawn_bus()

# 開發用:手動測試生成
func _input(event: InputEvent) -> void:
    if event.is_action_pressed("debug_spawn_bus"):
        _spawn_bus()
    if event.is_action_pressed("debug_spawn_sauce"):
        _spawn_sauce()

func _spawn_bus() -> void:
    var obstacle = obstacle_scene.instantiate()
    obstacle.position.y = _get_random_y()
    add_child(obstacle)

func _spawn_sauce() -> void:
    var rocket = rocket_scene.instantiate()
    rocket.position.y = _get_random_y()
    add_child(rocket)

func _get_random_y() -> int:
    return randi_range(1, 3) * 200   # 三個固定高度:200 / 400 / 600

連接場景

  1. 開啟 main.tscn,將 spawner.tscn 拖曳到中 main 節點下
  2. Spawner 移動到畫面右側邊緣外,障礙物生成時的 x 位置由此決定
  3. 從 FileSystem 將 obstacle.tscnrocket.tscn 拖入對應欄位

碰撞與遊戲結束

SceneTree.change_scene_to_file()

撰寫 main.gd

右鍵點擊 mainAttach Script → 儲存為 main.gd。 偵測到 player_died 後,直接切換到獨立的遊戲結束場景。

extends Node2D

var score := 0.0

func _ready() -> void:
    $player.player_died.connect(func():
        get_tree().change_scene_to_file("res://game_over.tscn")
    )

func _process(delta: float) -> void:
    score += delta
    $UI/Label.text = "Score: %d" % score

Lambda 與 Callable func(): 是 GDScript 的**匿名函式(lambda)語法,可以在不另外宣告具名函式的情況下,直接把一段程式碼當作值傳入。 在 Godot 中,函式可以被當作一等公民(first-class value)**傳遞,這種「可被呼叫的值」稱為 Callableconnect() 的參數就是一個 Callable——你可以傳入具名函式的參考,也可以傳入 lambda。 以下兩種寫法效果完全相同:

# 寫法一:具名函式
func _ready() -> void:
    $player.player_died.connect(_on_player_died)
func _on_player_died() -> void:
    get_tree().change_scene_to_file("res://game_over.tscn")
# 寫法二:lambda(程式碼只在一處使用時更簡潔)
func _ready() -> void:
    $player.player_died.connect(func(): get_tree().change_scene_to_file("res://game_over.tscn"))

當邏輯只有一兩行且不需要重複使用時,lambda 可以減少具名函式的數量,讓程式碼更集中。

建立遊戲結束場景(game_over.tscn)

遊戲結束畫面是一個獨立場景,負責顯示訊息並等待玩家重新開始:

  1. Scene → New Scene,根節點選 Control,命名為 GameOver,儲存為 game_over.tscn
  2. 在 Inspector 將 GameOverAnchors Preset 設為 Full Rect(填滿畫面)
  3. 在其下新增子節點 Label
    • Anchors Preset:Center(置中)
    • Horizontal Alignment:Center
    • Vertical Alignment:Center
    • Theme Overrides → Font Size48
    • TextGame Over!\nPress enter to retry.

撰寫 game_over.gd

右鍵點擊 GameOver 根節點 → Attach Script → 儲存為 game_over.gd

extends Control

func _process(delta: float) -> void:
    if Input.is_key_pressed(KEY_ENTER):
        get_tree().change_scene_to_file("res://main.tscn")

分數與細節調整

調整遊戲手感

在 Inspector 調整這些 @export 參數,不需要修改程式碼:

變數

所在節點

acceleration

Player

gravity

Player

speed

Obstacle(公車)

speed

Rocket(辣椒醬)

Wait Time

Spawner Timer

測試檢查清單

  • 按住空白鍵時玩家上升,放開後下墜
  • 玩家無法穿越地板或天花板
  • 公車從右往左緩慢移動,飛出畫面後消失
  • 辣椒醬從右往左快速橫掃,飛出畫面後消失
  • 每隔幾秒隨機生成公車或辣椒醬(偶爾不生成)
  • 碰到任何障礙物後切換到遊戲結束畫面
  • 按 Enter 後重新開始遊戲
  • 玩家存活期間分數持續增加,顯示在右上角
  • 按 B 鍵手動生成公車,按 S 鍵手動生成辣椒醬(開發測試用)

延伸挑戰

  • AudioStreamPlayer 加入音效(噴射聲、碰撞聲)
  • 新增金幣供玩家蒐集以獲得額外分數
  • FileAccess 儲存並顯示歷史最高分
  • 在遊戲結束畫面顯示本局得分