作者Bogay
從 https://godotengine.org/download 下載 Godot 4
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.
YT 介紹(二代):https://www.youtube.com/watch?v=1myElNV4MG0
關於更完整的介紹,建議參考官方文件: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!以《文字遊戲》為例 (張文瀚)。
jetpack-joyride,然後將 Project Path 修改成自己習慣的位置1280,Height: 720開啟 Godot 後,你會看到四個主要面板:
面板 | 用途 |
|---|---|
Scene(左上) | 目前場景的節點樹狀結構 |
Inspector(右側) | 被選取節點的屬性 |
FileSystem(左下) | 專案中的所有檔案 |
Viewport(中央) | 場景的視覺預覽 |
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 節點實現自動橫向捲動效果。
main -> Add Child Node -> Parallax2D,重新命名為 backgroundVector2(1024, 0)(水平方向重複拼接)Vector2(-64, 0)(每秒向左捲動 64 px)2background 下新增子節點 Sprite2Dicon.svg 做為佔位符Sprite2D 的 Position 與 Scale,使其覆蓋整個畫面(約 scale = (9.85, 7.15))Parallax2D 會讓背景圖無縫循環捲動,只需設定 Autoscroll 速度,Godot 便自動處理重複拼接,不需要任何程式碼。
main 下新增子節點 → CanvasLayer,重新命名為 UIUI 內新增子節點 Label32CanvasLayer 會讓 UI 固定在畫面上,即使遊戲世界的攝影機移動,UI 也不會跟著偏移。
遊戲場地需要上下兩道邊界,防止玩家飛出畫面:
地板(ground)
main.tscn 新增 StaticBody2D 節點,命名為 groundColorRect,用來顯示地板外觀。新增後設定成喜歡的顏色並調整 Anchors Preset 為 "Center",調整大小讓它可以覆蓋畫面左右兩側 Note: 調整大小時先按住 Alt 再拖動可以維持中心點CollisionShape2D,並在 Shape 欄位新增一個 RectangleShape2D,將大小調整到跟剛剛新增的 ColorRect 差不多天花板(ceil)
ground 複製一份,並重新命名為 ceil這時候兩個 node 裡面的 RectangleShape2D 其實是共用的,所以調整其中一邊的話也會影響到另外一個,如果希望可以讓兩邊的設定分開,可以選擇其中一邊 Shape 欄位的下拉選單,點選 "Make Unique",
官方文件參考
本專案已提供主角的美術素材,位於 sprite/ 資料夾,包含三種狀態:
檔案 | 用途 |
|---|---|
| 站在地面時 |
| 噴射飛行時 |
| 碰到障礙物時 |
玩家直接在 main.tscn 內建立,不需要獨立的場景檔案:
main.tscn,右鍵點擊 main → Add Child Node → CharacterBody2D,命名為 playerplayer 下新增兩組子節點:Sprite2D — 顯示角色圖片,Texture 拖入 sprite/meatball_walk.pngCollisionShape2D — 賦予玩家物理碰撞形狀,在 Shape 欄位新增 RectangleShape2D 並調整為合適的大小player 移動到合適的起始位置(畫面左下角,略高於地板)CharacterBody2D 是 Godot 用來製作可控制角色的節點。它處理沿地板與牆壁移動的邏輯,但物理計算由你的程式碼負責。
以下程式碼是一個簡單的 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 # 將經過時間累加到分數現在,我們要替玩家掛上腳本。右鍵點擊 player → Attach 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_processvs_process物理相關的更新要放在_physics_process,它的呼叫頻率固定(預設 60 Hz),不受畫面更新率影響,讓物理模擬更穩定。
move_and_slide()這是CharacterBody2D提供的方法,會依照velocity移動角色,並自動處理與靜態物體(如地板)的碰撞,不需要自己寫碰撞解析。
按下 F5 執行遊戲,此時角色應該會從起始位置自由落下,落到地板上停住。
在 Godot 中,我們會使用 Input Map 替輸入做抽象化——先替動作命名,再綁定實體按鍵,程式碼只需要詢問「動作有沒有被觸發」,不需要直接寫死按鍵名稱。 這樣的作法讓跨平台輸入變得容易,類似於 Unity 較新的 Input System。
fly → Addfly 旁的 + → 按下空白鍵 → 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_tex、fly_tex、hurt_tex 分別拖入對應的 png 檔案,執行遊戲確認貼圖會跟著狀態切換。
遊戲的主角是一顆貢丸,這是因為我在準備美術素材的時候玩到竹梅賽做的嘎拉給木
只切換貼圖已經有基本的視覺回饋,但我們可以再加一點「果凍感」——用縮放讓角色看起來更有重量與彈性:
狀態 | 效果 | 縮放值 |
|---|---|---|
噴射飛行 | 垂直拉伸(Stretch) |
|
站在地板 | 水平方向週期性擠壓(Squash) | 隨時間正弦波動 |
空中下墜 | 恢復原始比例 |
|
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.05Time.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,僅速度與外觀不同。
在開始撰寫腳本之前,先介紹一個即將用到的核心概念。
訊號是 Godot 內建的事件系統。節點在某事發生時**發出(emit)訊號;任何已連接(connected)**到該訊號的節點都會執行對應的函式。
VisibleOnScreenNotifier2D 偵測到節點離開畫面
│ 發出 "screen_exited"
└──► obstacle.gd 接收到 → 呼叫 queue_free 刪除自己
這讓每個節點只負責自己的事,不需要在外部寫輪詢邏輯來判斷「這個東西有沒有跑出畫面」。
先建立腳本,兩個場景都會使用它。右鍵根節點 → 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 子節點:
接著更新腳本,在 _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,可直接使用。
Node2D,命名為 Obstacle,儲存為 obstacle.tscnScale = (0.6, 0.6) 縮小整體大小Sprite2D — Texture 拖入 sprite/bus.pngArea2D — 偵測與玩家的重疊Area2D 內新增 CollisionPolygon2D,用來描繪不規則的公車外型碰撞區域VisibleOnScreenNotifier2D — 偵測節點何時離開畫面obstacle.gd 腳本(根節點右鍵 → Attach Script → 選擇現有的 obstacle.gd)Obstacle → Inspector → Node → Groups → 輸入 obstacle → Add如何編輯 CollisionPolygon2D
選取 CollisionPolygon2D 節點後,Viewport 上方工具列會出現多邊形編輯模式:
沿著公車的外輪廓依序點下頂點,圍成一個貼合車身的多邊形即可。不需要非常精確,略微內縮一點反而能讓遊戲判定更寬鬆、體驗更好。
為什麼要加入
obstacle群組?player.gd在偵測到Area2D重疊時,會用area.owner.is_in_group("obstacle")來判斷碰到的是不是障礙物——因為未來場景中可能有其他Area2D(如金幣),不能把所有重疊都當成死亡事件。群組(Groups)就是 Godot 裡最簡單的「標籤」系統。
辣椒醬是快速橫飛的第二種障礙物,使用 sprite/chili_sauce.png。
Node2D,命名為 Rocket,儲存為 rocket.tscnSprite2D — Texture 拖入 sprite/chili_sauce.png;設定根節點 Scale = (0.3, 0.3)Area2D — 偵測與玩家的重疊Area2D 內新增 CollisionShape2D,形狀選 New RectangleShape2D;調整覆蓋辣椒醬的主體(約 734×124,略向左偏移 x = -69)VisibleOnScreenNotifier2D — 偵測節點何時離開畫面,同樣調整 Rect 略大於 Spriteobstacle.gd 腳本speed 設為 400(比公車快很多,製造緊迫感)obstacle 群組辣椒醬形狀規則,直接用
CollisionShape2D+RectangleShape2D就夠了,不需要像公車一樣用多邊形。
上述兩個場景建立完成後,可以將它們拖入 main.tscn 測試,應該可以看到公車緩慢往左、辣椒醬快速橫掃,並在離開畫面後自動消失。
Node2D,命名為 Spawner,儲存為 spawner.tscnTimer 子節點5.0,Autostart = 開啟生成器同時管理公車與辣椒醬兩種障礙物,每次計時器到時以機率決定生成哪種(或不生成):
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 / 600main.tscn,將 spawner.tscn 拖曳到中 main 節點下Spawner 移動到畫面右側邊緣外,障礙物生成時的 x 位置由此決定obstacle.tscn 與 rocket.tscn 拖入對應欄位右鍵點擊 main → Attach 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" % scoreLambda 與 Callable func(): 是 GDScript 的**匿名函式(lambda)語法,可以在不另外宣告具名函式的情況下,直接把一段程式碼當作值傳入。 在 Godot 中,函式可以被當作一等公民(first-class value)**傳遞,這種「可被呼叫的值」稱為 Callable。connect() 的參數就是一個 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 可以減少具名函式的數量,讓程式碼更集中。
遊戲結束畫面是一個獨立場景,負責顯示訊息並等待玩家重新開始:
Control,命名為 GameOver,儲存為 game_over.tscnGameOver 的 Anchors Preset 設為 Full Rect(填滿畫面)Label:48Game Over!\nPress enter to retry.右鍵點擊 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 參數,不需要修改程式碼:
變數 | 所在節點 |
|---|---|
| Player |
| Player |
| Obstacle(公車) |
| Rocket(辣椒醬) |
| Spawner Timer |
AudioStreamPlayer 加入音效(噴射聲、碰撞聲)FileAccess 儲存並顯示歷史最高分