【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做連接的功能

程式碼與場景結合

  • 然後將程式碼與場景做結合,不然是不會有動作的…
  • 細節就不多說了,程式碼如下:
    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()
    

新增節點 - 開始選單

設定文字 / 位置 / 字型

  • 首先設定「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解半年未果,還故意寫錯裝不會,造成同事的工作量增加…
  • 但摸魚的確是個人的本事,而且薪水又不是我發的,八小時躺著睡也沒關係,但影響到其他人就不行了…
  • 想想以後還是領多少錢,做多少事吧,既沒升遷,也沒加薪的,健康最重要…