【Godot 4】不可能,我3D天下無敵 - 3D遊戲我來了

今天要分享的是3D遊戲篇,這倒是我第一次作3D的遊戲,本來之前只想要做做2D的版本,因為圖片還是比3D模型來得容易找,不過Godot官方有提供很完整的範例,我就來試用看看啦,不得不說Godot真的是小巧,再加上上週做2D的經驗,這次只做的三次就成功了… 上網查了一下,其實製作3D模型的軟體滿多的,像是BlenderMaya3ds MaxCinema 4DZBrushHoudiniSketchUpModoTinkercad… 話不多說,我們就來試試看吧…

做一個長得像這樣的東西

作業環境

項目 版本
CPU Apple M1
macOS Sonoma 14.4.1 arm64
Godot 4.2.2 arm64
GDScript 1.0

實作

安裝 / 新增專案

  • 先下載官方在github的範例,解壓縮後,開啟project.godot檔案…
  • 在這邊就不再重複說明了,可以參考2D的例子…
  • 在這主要會以「設定」的地方為主,一些跟2D同樣的東西就不細說了…

主角設定

  • 首先新增主角為「CharacterBody3D」的主節點,名為「Player」…
  • 再加入一為「Node3D」的節點,叫做「Pivot」,然後把主角的模型「player.glb」拉到裡面,改個名字叫「Character」…
  • 然後再增加「CollisionShape3D」的次節點,設定它的碰撞範圍的半徑為「0.8」…
  • 接下來設定Input (專案 -> 專案設定 -> 輸入映射),名稱動作如圖所示 (move_left / move_right / move_forward / move_back / jump)…
  • 新增主角的腳本「player.gd」,程式碼如下,細節就不多說了,都記錄在註解之上…
    extends CharacterBody3D
    
    @export var speed = 14				# 移動的速度 14m/s
    @export var fall_acceleration = 75	# 在空中下降的加速度 75m/s
    @export var jump_impulse = 20		# 跳起來的距離
    @export var bounce_impulse = 16		# 踏到Mob時再反彈一下
    
    signal hit							# 碰撞到的信號處理
    
    var target_velocity = Vector3.ZERO	# 目前的位置
    
    # MARK: - 生命週期
    func _physics_process(delta): _PhysicsProcessAction(delta)
    
    # MARK: - Node函式
    func _on_mob_detector_body_entered(_body): _DieAction()
    
    # MARK: - 主工具
    # 設定主角相關動作 / 圖形方向 / 跳動重力的反應
    # - Parameters:
    #   - delta: float
    func _PhysicsProcessAction(delta: float):
    
    	var direction = _MoveActionSetting()
    
    	_CharacterDirection(direction)
    	_OnFloorAction(delta, jump_impulse)
    	_CollisionAction(delta)
    	_MoveAndSlideAction(direction, speed)
    
    	$Pivot.rotation.x = PI / 6 * velocity.y / jump_impulse
    
    # 主角被碰撞到處理
    func _DieAction():
    	hit.emit()
    	queue_free()
    
    # MARK: - 小工具
    # 移動設定 (///) + 正規化處理 (半徑為1)
    # - Returns: Vector3
    func _MoveActionSetting() -> Vector3:
    
    	var direction = Vector3.ZERO
    
    	if Input.is_action_pressed("move_right"): direction.x += 1
    	if Input.is_action_pressed("move_left"): direction.x -= 1
    	if Input.is_action_pressed("move_back"): direction.z += 1
    	if Input.is_action_pressed("move_forward"): direction.z -= 1
    
    	direction = direction.normalized()
    
    	return direction
    
    # 彈跳的相關處理 (在空中就慢慢下降 / 在地上就,按跳就跳起來)
    # - Parameters:
    #   - delta: float
    #   - jumpImpulse: int
    func _OnFloorAction(delta: float, jumpImpulse: int):
    	if not is_on_floor(): target_velocity.y = target_velocity.y - (fall_acceleration * delta)
    	if is_on_floor() and Input.is_action_just_pressed("jump"): target_velocity.y = jumpImpulse
    
    # 移動角色
    # - Parameters:
    #   - direction: Vector3
    #   - speed: float
    func _MoveAndSlideAction(direction: Vector3, _speed: float):
    
    	target_velocity.x = direction.x * _speed
    	target_velocity.z = direction.z * _speed
    
    	velocity = target_velocity
    	move_and_slide()
    
    # 主角的圖形方向 (頭朝哪一邊)
    # - Parameters:
    #   - direction: Vector3
    func _CharacterDirection(direction: Vector3):
    	if direction != Vector3.ZERO: $Pivot.basis = Basis.looking_at(direction)
    
    # 主角跟敵人碰撞的反應
    # - Parameters:
    #   - _delta: float
    func _CollisionAction(_delta: float):
    
    	for index in range(get_slide_collision_count()):
    
    		var collision = get_slide_collision(index)
    
    		if collision.get_collider() == null: continue
    
    		if collision.get_collider().is_in_group("mob"):
    
    			var mob = collision.get_collider()
    			var upValue = Vector3.UP.dot(collision.get_normal())
    
    			if upValue > 0.1:
    				mob.squash()
    				target_velocity.y = bounce_impulse
    				break
    
  • 增加「Maker3D」節點,名為「CameraPivot」,在其上再加入「Camera3D」節點,這點非常的重要,因為在Run的時候,實際上只看得到2D的場景畫面,如果沒有Camera的話,3D場景是看不到的…
  • 在這裡我們選擇「Camera3D」的z軸位置為「19」,「CameraPivot」的x軸旋轉角度為「-45」,大家也可以試著調整「CameraPivot」、「Camera3D」的位置試試…
  • 接下來設定Layer圖層,也就是誰跟誰可以碰到的設定,我們在「專案 -> 專案設定 -> 一般 -> 圖層 -> 3D物理」中設定,由1至3層,分別取名為「player」、「enemies」、「world」…
  • 然後新增「Area3D」節點,改名為「MobDetector」,再附加「CollisionShape3D」節點,看名字就知道這個是被怪物碰撞的範圍,這邊取CapsuleShape3D - 圓柱體在主角的上方,碰到上方才會有反應,不然太容易GameOver了,這裡最主要的點是在「MobDetector」的Collision的Layer層要全關,Mask層要取「2 - enemies」,至於大小範圍可以依照自己的習慣去設定,細節就照著影片去做設定就可以了…
  • 再來就是動畫設計,先新增一個「AnimationPlayer」的節點,故名思義就是播放動畫的節點…
  • 在AnimationPlayer節點上,新增一個名為「float」的動畫,總長度為「1.2秒」,主要設定Character的「位置」與「旋轉角度」,讓主角看起來有在浮動的感覺,接下來數值的設定細節,就請大家看圖說故事吧…
  • 最後再將「MobDetector」的「body_exited(body:Node3D)」訊號,連接到「_on_mob_detector_body_entered()」之上…

敵人設定

  • 接下來就來設定敵人節點「Mob」,它的節點設計跟主角差不多,就不再細說了,就看影片去新增節點吧…
  • 程式碼如下,內容也不多做說明了,記得要在Mob節點加上群組「mob」…
    extends CharacterBody3D
    
    @export var min_speed = 10		# 移動的最小速度
    @export var max_speed = 18		# 移動的最大速度
    
    signal squashed					# 被壓扁的信號功能
    
    # MARK: - 生命週期
    func _physics_process(_delta): move_and_slide()
    
    # MARK: - Node函式
    func _on_visible_on_screen_enabler_3d_screen_exited(): _OnVisibleOnScreenEnabler3dScreenExited()
    
    # MARK: - 公用函式
    func initialize(start_position, player_position): _InitializeActiion(start_position, player_position)
    func squash(): _SquashAction()
    
    # MARK: - 主工具
    # 初始化腳色的位置
    # - Parameters:
    #   - startPosition: 開始的位置
    #   - playerPosition: 主角的位置
    func  _InitializeActiion(startPosition: Vector3, playerPosition: Vector3):
    
    	var random_speed = randi_range(min_speed, max_speed)
    
    	look_at_from_position(startPosition, playerPosition, Vector3.UP)
    	rotate_y(randf_range(-PI / 4, PI / 4))
    
    	velocity = Vector3.FORWARD * random_speed
    	velocity = velocity.rotated(Vector3.UP, rotation.y)
    
    # 到設定範圍外就清除
    func _OnVisibleOnScreenEnabler3dScreenExited():
    	queue_free()
    
    # 被壓到的反應 (清除)
    func _SquashAction():
    	squashed.emit()
    	queue_free()
    
  • 再來我們把Mob設定成「編號2」的Layer圖層,也就是對應一開始設定「專案 -> 專案設定 -> 一般 -> 圖層 -> 3D物理」中的那些數值…
  • 然後再設定「VisibleNotifier」的「screen_exited()」連接至方法「_on_visible_on_screen_enabler_3d_screen_exited()」…
  • 設定「BoxSharp3D」外型的碰撞範圍大小…
  • 最後,再設定畫面觸發事件「VisibleOnScreenEnabler3D」AABB - Axis Aligned Bounding Box數值…

背景音樂

  • 設定背景音樂,新增一個叫『AudioStreamPlayer』的場景,取名為『MusicPlayer』,然後在『專案 -> 專案設定 -> 自動載入』新增節點…
  • 然後再新增音樂,勾選『Autoplay』,細節在影片中,大家可以按圖施工…

主場景

  • 終於到了要把元件結合在一起的時刻了,新增一個「Node」場景,名為「Main」,然後把程式貼上,設定『mob_scene』場景為mob.tcsn…

    extends Node
    
    @export var mob_scene: PackedScene	# mob主角載入
    
    # MARK: - 生命週期
    func _ready(): _ReadyActoin()
    func _unhandled_input(event): _UnhandledInput(event)
    
    # MARK: - Node函式
    func _on_mob_timer_timeout(): _OnMobTimerTimeoutAction()
    func _on_player_hit(): _OnPlayerHitAction()
    
    # MARK: - 主工具
    # 根據路徑隨機產生Mob
    func _OnMobTimerTimeoutAction():
    
    	var mob = mob_scene.instantiate()
    	var mob_spawn_location = $"SpawnPath/SpawnLocation"
    	var player_position = $Player.position
    
    	mob_spawn_location.progress_ratio = randf()
    	mob.initialize(mob_spawn_location.position, player_position)
    	mob.squashed.connect($UserInterface/ScoreLabel._on_mob_squashed.bind())
    
    	add_child(mob)
    
    # 初始化設定
    func _ReadyActoin():
    	$UserInterface/Retry.hide()
    
    # Player被碰到的設定
    func _OnPlayerHitAction():
    	$MobTimer.stop()
    	$UserInterface/Retry.show()
    
    # 失去控制權的設定
    func _UnhandledInput(event):
    	if event.is_action_pressed("ui_accept") and $UserInterface/Retry.visible: 
    		get_tree().reload_current_scene()
    
  • 設定地板,新增「StaticBody3D」,名為「Ground」

  • 再新增「MeshInstance3D」,將它的Mesh設定為「BoxMesh」,尺寸為(60, 2, 60)…

  • 然後再新增碰撞範圍的節點「CollisionSharp3D」,尺寸同樣為(60, 2, 60)…

  • 最後再將「Ground」的Layer圖層設定為「3 - World」,Mask圖層全關,位置設定為(0, -2, 0)…

  • 連接Player場景,再將「hit()」訊號連接到「_on_player_hit()」…

  • 新增環境光源「DirectionalLight3D」,這個光源就類似太陽一樣,大家可以自己設定自己想要的位置…

  • 再來是設定Camera,這個動作跟前面的一樣,就不多說了…

  • 接下來設定「Timer」節點,名為「MobTimer」,這是用來設定Mob產生的速度,記得將「timeout()」訊號連接至「_on_mob_timer_timeout()」…

  • 而後就是設定Mob出現的範圍,跟之前說的2D是類似的,只是換成「Path3D」+「PathFollow3D」,這裡就用影片說明會比較清楚,因為動作滿多的…

  • 最後再設計2D的畫面,計分跟顯示的畫面,現在筆者才知道原來2D/3D畫面是分開的,3D一定要有相機才會顯示…

  • 先加入一個「Control」,名為「UserInterface」,加入一個節點「ColorRect」名叫「Retry」,這個就是黑色半透明的框,提示重玩用的,當然也是要有個提示「Label」名叫「RetryLabel」…

  • 再加上一個計分用的Label,名叫「ScoreLabel」,當然這是要寫程式碼的…

    extends Label
    
    var score = 0
    
    # MARK: - 公用函式
    func _on_mob_squashed(): _OnMobSquashedAction()
    
    # MARK: - 主工具
    # 壓到後計算分數
    func _OnMobSquashedAction():
    	score += 1
    	text = "Score: %s" % score
    
  • 完工…

範例程式碼下載

後記

  • 最後此文章可能有說明不清楚的地方,大家再看看Code去補一下,或者參考一下官方的說明,筆者自己也是一知半解的,做4次,4次都不一樣…
  • 其實這篇文章的先後順序,是照自己的想法去編排的,而非照官方的文章說明順序,因為每個人的想法都不盡相同,記憶上的點也會有所不同,所以照著自己能最快理解的思緒是最好的,記的也快,正所謂「殊途同歸」嘛,能產生一樣的結果也就圓滿了…
  • 或許有人會想說,寫得這麼細不就很累?把技巧告訴別人不就很笨嗎?其實,這文章是寫給筆者當成筆記在用的,而且筆者也不是高高手,只是個努力的打字工,也不是什麼名校出身,只是看不到排名的私立小學畢業而已,學程式是個人的夢想,有人看筆者的文章有受益就覺得很幸福了…