【Godot 4】不可能,我3D天下無敵 - 3D遊戲我來了
今天要分享的是3D遊戲篇,這倒是我第一次作3D的遊戲,本來之前只想要做做2D的版本,因為圖片還是比3D模型來得容易找,不過Godot官方有提供很完整的範例,我就來試用看看啦,不得不說Godot真的是小巧,再加上上週做2D的經驗,這次只做的三次就成功了… 上網查了一下,其實製作3D模型的軟體滿多的,像是Blender、Maya、3ds Max、Cinema 4D、ZBrush、Houdini、SketchUp、Modo、Tinkercad… 話不多說,我們就來試試看吧…
做一個長得像這樣的東西
作業環境
項目 | 版本 |
---|---|
CPU | Apple M1 |
macOS | Sonoma 14.4.1 arm64 |
Godot | 4.2.2 arm64 |
GDScript | 1.0 |
實作
安裝 / 新增專案
主角設定
- 首先新增主角為「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次都不一樣…
- 其實這篇文章的先後順序,是照自己的想法去編排的,而非照官方的文章說明順序,因為每個人的想法都不盡相同,記憶上的點也會有所不同,所以照著自己能最快理解的思緒是最好的,記的也快,正所謂「殊途同歸」嘛,能產生一樣的結果也就圓滿了…
- 或許有人會想說,寫得這麼細不就很累?把技巧告訴別人不就很笨嗎?其實,這文章是寫給筆者當成筆記在用的,而且筆者也不是高高手,只是個努力的打字工,也不是什麼名校出身,只是看不到排名的私立小學畢業而已,學程式是個人的夢想,有人看筆者的文章有受益就覺得很幸福了…