【Godot 4】踏出成功的第一步 - 2D遊戲初體驗
記得之前有接觸過幾個遊戲開發引擎,分別是:LÖVE / Unity / Unreal / Spritekit,這一次再介紹一個叫Godot的遊戲開發引擎,它是一個Open-Source的遊戲開發引擎,可以開發2D / 3D的遊戲,比較著名的遊戲有《魔導書幸存者》、《文字遊戲》,接下來我們就一步步帶領大家做個可愛又有趣的小遊戲吧…
做一個長得像這樣的東西
作業環境
項目 | 版本 |
---|---|
CPU | Apple M1 |
macOS | Sonoma 14.4.1 arm64 |
Godot | 4.2.2 arm64 |
GDScript | 1.0 |
背景
- 主要是Unity收費上的爭議,再加上在YouTube看到一篇相關文章,所以就來試用看看啦…
- 試用了之後,發現跟Unity大同小異,但是IDE好看多了,而且殺雞焉用牛刀啊,Godot真的是小巧又好用,決定以後都用它了…
- Godot支援兩種程式語言 - GDScript / C#,還可以加上C++來加強程式的流暢度…
實作
安裝 / 新增專案
- 安裝Godot有兩種方式,第一種當然是到官方下載安裝檔,第二種就是用Homebrew安裝…
- 因為本文是使用GDScript,所以安裝一般版本的就可以了…
brew install --cask godot
- 安裝好後,可以新增一個專案來使用,如果筆者有說明不足或漏掉之處,麻煩請再對照一下官方說明…
- 它的資料夾長這樣子,跟Xcode的專案滿像的…
- IDE的介面使用方式大約是這樣子,大家可以點點看看…
加入素材
- 這裡我們下載官方demo的素材,放到指定的地方…
新增節點 - 玩家
- 首先新增一個「Area2D」的主節點,把它改名為「Player」,它就是玩家的主體…
- 接下來再附加一個「AnimatedSprite2D」的節點,它是用來設定玩家動畫的顯示設定…
- 再來再附加一個「CollisionShape2D」的節點,它是用來設定玩家觸碰範圍的相關設定…
設定玩家動畫 - AnimatedSprite2D
- 設定玩家動畫,其實就是連續換圖片…
- 在這裡設定兩個動作 - 「up」、「walk」…
- 接下來就是「按圖施工,保證成功」,記得把fps設定成「3」…
設定玩家碰撞範圍 - CollisionShape2D
- 設定玩家碰撞範圍,就是兩個東西怎麼才算碰到?比如說:子彈、氣功、武器攻擊…等等
- 這裡我們選擇「CapsuleShape2D」,也就是橢圓型的,以符合玩家圖片外型…
設定主場景
- 先設定遊戲畫面的大小為(480, 720) / 拉伸模式為「canvas_items」
- 再把「player.tscn」設為主埸景,執行後是不是看到玩家了啊?
建立腳本
- 把玩家的設定完成之後,接下來就是要讓它會動,可以去控制它…
- 新增腳本「player.gd」
- 設定方向鍵的對應Key…
取得方向鍵
- 利用「Input.is_action_pressed()」取得方向鍵的按下去的動作…
Input.is_action_pressed("move_right")
設定動作
- 利用「$AnimatedSprite2D」取該名稱的節點…
- 利用「$AnimatedSprite2D.animation = “<動畫名稱>"」來設定動畫…
$AnimatedSprite2D.animation = "walk"
設定外部參數
- 利用@export關鍵字,產生在面板上可以直接設定的欄位,跟Swift的@IBInspectable有點像…
- 雖然GDScript可以不用寫類型的,但是為了快速顯示方便,還是加上比較好一點…
@export var speed: int = 400
跟其它的Script做連接的功能
- 這個後面會用到,有點像Swift的Notifications,到後面會再做說明…
signal hit
程式碼與場景結合
- 然後將程式碼與場景做結合,不然是不會有動作的…
- 細節就不多說了,程式碼如下:
extends Area2D @export var speed: int = 400 signal hit var screen_size: Vector2 # MARK: - 生命週期 func _ready(): _ReadyAction() func _process(delta: float): _ProcessAction(delta) # MARK: - Node函式 func _on_body_entered(body: Node2D): _OnBodyEntered(body) # MARK: - 公用函式 func start(position: Vector2): _StartAction(position) # MARK: - 主工具 # 初始化的動作 (取得畫面大小 / 隱藏玩家) func _ReadyAction(): screen_size = get_viewport_rect().size hide() # 畫面刷新時的動作 # - Parameters: # - delta: 畫面更新率 (fps) func _ProcessAction(delta: float): var velocity = _MoveVelocityAction() _PositionAction(delta, velocity, screen_size) _AnimationSetting(velocity) # 設定起始的位置 # - Parameters: # - position: 位置 func _StartAction(position: Vector2): self.position = position show() $CollisionShape2D.disabled = false # 敵人碰撞到玩家後都會送出訊號 => 執行hit() # - Parameters: # - body: Node2D func _OnBodyEntered(body: Node2D): hide() hit.emit() $CollisionShape2D.set_deferred("disabled", true) # MARK: - 小工具 # 按下方向鍵後,玩家移動的單位速度處理 # - Returns: Vector2 func _MoveVelocityAction() -> Vector2: var velocity = Vector2.ZERO 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 return velocity # 按下方向鍵後,玩家移動的速度處理 / 防止人物超出畫面 # - Parameters: # - delta: 畫面更新率 (fps) # - velocity: 單位速度 # - screenSize: 畫面大小 func _PositionAction(delta: float, velocity: Vector2, screenSize: Vector2): if velocity.length() > 0: velocity = velocity.normalized() * speed $AnimatedSprite2D.play() else: $AnimatedSprite2D.stop() position += velocity * delta position = position.clamp(Vector2.ZERO, screenSize) # 動畫設定 => 使用什麼動畫 / 什麼時候反轉 # - Parameters: # - velocity: Vector2 func _AnimationSetting(velocity: Vector2): if velocity.x != 0: $AnimatedSprite2D.animation = "walk" $AnimatedSprite2D.flip_v = false $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
設定動作連結
- 有點像Swift的@IBAction拉線的功能,應該算是Override吧…
新增節點 - 敵人
- 首先新增一個「RigidBody2D」的主節點,把它改名為「Mob」,它就是敵人的主體…
- 接下來再附加一個「AnimatedSprite2D」的節點,它是用來設定敵人動畫的顯示設定,動作為:fly / swim / walk,然後在屬性面板上設定大小為0.5倍…
- 再來再附加一個「CollisionShape2D」的節點,它是用來設定敵人觸碰範圍的相關設定,跟玩家一樣的設定…
- 最後再附加一個「VisibleOnScreenNotifier2D」的節點,這個比較特別,因為當敵人超出畫面後就要清除,所以要連結節點的動作訊號 (signal),有點像Swift的@IBAction…
- 當然script也要新增結合上去,這就不多說了…
- 最後來把mob.tscn設定成主畫面,要記得把「Gravity Scale」設定為0,不然它會自己往下掉喲…
- 細節就不多說了,程式碼如下:
extends RigidBody2D # MARK: - 生命週期 func _ready(): _ReadyAction() func _process(delta): pass # MARK: - Node函式 func _on_visible_on_screen_notifier_2d_screen_exited(): _OnVisibleOnScreenNotifier2dScreenExited() # MARK: - 主工具 # 初始化的動作 (隨機動畫) func _ReadyAction(): var mobTypes: PackedStringArray = $AnimatedSprite2D.sprite_frames.get_animation_names() var randomIndex: int = randi() % mobTypes.size() $AnimatedSprite2D.play(mobTypes[randomIndex]) # 超出畫面後清除 func _OnVisibleOnScreenNotifier2dScreenExited(): queue_free()
新增節點 - 開始選單
- 首先新增一個「CanvasLayer」的主節點,取名為「HUD」…
- 附加一個「Timer」的節點,取名為「MessageTimer」…
- 附加兩個「Label」的節點,取名為「ScoreLabel」、「Message」…
- 附加一個「Button」的節點,取名為「StartButton」…
設定文字 / 位置 / 字型
- 首先設定「StartButton」,文字為「Start」,位置在中下的地方,大小為(200, 100),字型大小為64px,細節如影片所示…
- 「ScoreLabel」、「Message」也是類似的設定方式,這裡就不再贅述…
開始選單程式碼
- 程式碼就不多做說明了,上面都有註解,就按圖施工吧…
extends CanvasLayer signal start_game # MARK: - 生命週期 func _ready(): pass func _process(delta: float): pass # MARK: - Node函式 func _on_start_button_pressed(): _OnStartButtonPressed() func _on_message_timer_timeout(): _OnMessageTimerTimeout() # MARK: - 公用函式 func show_game_over(): _ShowGameOverAction() func update_score(score: int): _UpdateScoreAction(score) func show_message(text: String): _ShowMessageAction(text) # MARK: - 主工具 # 按下開始鍵後 => 開始遊戲 func _OnStartButtonPressed(): $StartButton.hide() start_game.emit() # 隱藏$Message / $StartButton (One Shot) func _OnMessageTimerTimeout(): $Message.hide() $StartButton.hide() # 更新分數 # - Parameters: # - score: int func _UpdateScoreAction(score: int): $ScoreLabel.text = str(score) # 顯示提示訊息 # - Parameters: # - text: String func _ShowMessageAction(text: String): $Message.text = text $Message.show() $MessageTimer.start() # 顯示遊戲結束提示訊息 func _ShowGameOverAction(): _ShowMessageAction("Game Over") await $MessageTimer.timeout $Message.text = "Dodge the Creeps!" $Message.show() await get_tree().create_timer(1.0).timeout $StartButton.show()
- 完成後,也可以設定成主畫面跑一下看看長相…
新增節點 - 主畫面
- 接下來就是要把三個節點結合在一起了…
- 主節點「Node2D」- Main…
- 計時器節點「Timer」 - MobTimer (0.5s) / ScoreTimer (1s) / StartTimer (2s)…
- 背景節點「ColorRect」- 設定顏色為「#009999」,大小為(480,720)…
- 聲音節點「AudioStreamPlayer」 - Music (House In a Forest Loop.ogg) / DeathSound (gameover.wav)…
- 設定Mob出現的路徑…
- 一定要按順時針的方向去連,不然敵人就不會出現了…
- 相關的程式如下,主要是要去連接相關的功能…
extends Node2D @export var mob_scene: PackedScene var score: int # MARK: - 生命週期 func _ready(): new_game() func _process(delta: float): pass # MARK: - Node函式 func new_game(): _NewGameAction() func game_over(): _GameOverAction() func _on_score_timer_timeout(): _OnScoreTimerTimeout() func _on_start_timer_timeout(): _OnStartTimerTimeout() func _on_mob_timer_timeout(): _OnMobTimerTimeout() # MARK: - 主工具 # 產生新遊戲 => 該清的清一清 / 音樂 func _NewGameAction(): score = 0 get_tree().call_group("mobs", "queue_free") $Player.start($StartPosition.position) $StartTimer.start() $HUD.update_score(score) $HUD.show_message("Get Ready") $Music.play() # 遊戲結束 => 該停的停一停 / 音樂 func _GameOverAction(): $ScoreTimer.stop() $MobTimer.stop() $HUD.show_game_over() $Music.stop() $DeathSound.play() # 2秒後啟動$MobTimer / $ScoreTimer (One Shot) func _OnStartTimerTimeout(): $MobTimer.start() $ScoreTimer.start() # 更新分數 => 撐1秒加1分 func _OnScoreTimerTimeout(): score += 1 $HUD.update_score(score) # 產生隨機位置的敵人 => 根據$MobPath/MobSpawnLocation上的範圍 func _OnMobTimerTimeout(): var mob = mob_scene.instantiate() var mob_spawn_location = $MobPath/MobSpawnLocation mob_spawn_location.progress_ratio = randf() var direction = mob_spawn_location.rotation + PI / 2 mob.position = mob_spawn_location.position direction += randf_range(-PI / 4, PI / 4) mob.rotation = direction var velocity = Vector2(randf_range(150.0, 250.0), 0.0) mob.linear_velocity = velocity.rotated(direction) add_child(mob)
MainCrash
- 設定Main為主場景之後,是不是發現會當掉呢?
- 因為啊,還沒有跟Mob作連接,所以會當掉…所以呢,要將mob_scene變數跟mob.tscn做相連,然後在mob.tscn加上群組設定 (因為很多個嘛),讓在遊戲開始的時候把敵人通通清空…
- 最後再把玩家位置設定在中間 (240, 450)就完成了…
範例程式碼下載
後記
- 其實這個官方說明的範例,筆者做了約5次了吧?除了官方的文字說明之外,有些細節並沒有用圖來表示,也算是踏了很多坑啊,所以特做此文來補這些坑,也來記錄一下學習的過程,筆者也算是初學者的好朋友吧…
- 經過這次的大病之後,也了解了一些社會的怪事,認真的人永遠在幫摸魚的人的忙,社會大多都是在處罰認真的人;薪水小偷在廁所摸魚30分鐘 * 3,結果公司規定大家去上廁所最多10分鐘;薪水小偷解bug解半年未果,還故意寫錯裝不會,造成同事的工作量增加…
- 但摸魚的確是個人的本事,而且薪水又不是我發的,八小時躺著睡也沒關係,但影響到其他人就不行了…
- 想想以後還是領多少錢,做多少事吧,既沒升遷,也沒加薪的,健康最重要…