3D的CharacterBody
我們之前已經使用過
CharacterBody2D——一個便於創建2D場景角色的節點。
在3D場景中也有對等的CharacterBody3D。我們再一次新建一個Player場景,用CharacterBody3D作為其根節點。模板保持默認,如果默認不是Basic Movement請手動選擇,我們稍後在模板代碼的基礎上修改。
![](https://image.gcores.com/3b4f2fa354ebe360bef531b6cab6b207-930-201.png)
實際上從模板中可以看出,3D的CharacterBody很多屬性、方法和2D版本的名字是一樣的——只不過相關類型從Vector2變成了Vector3
。
總之模板給我們實現了移動和跳,我們暫時不看它是怎麼實現的。其實大部分代碼和2D版本差不多。模板默認把移動綁定到了箭頭上下左右,跳是空格。按照我的習慣,我選擇重新定義相關的輸入操作,然後綁定。
類似地,我們需要給它一個CollisionShape,自然,在3D場景中我們需要一個CollisionShape3D。一般來說對於類人形的角色,大家都愛選膠囊狀(capsule)的——就是類似於一個球中間拉開了的感覺。
別掉下去了
有可能,你現在啟動遊戲你的角色會穿過地板。如果你的地板或者其它場景是用CSG搭建的,那麼你只需要在檢視面板中找到Use Collision勾選啟用碰撞即可。
如果你用的是MeshInstance搭建的場景,那麼就需要給它套上一個StaticBody3D並配置CollisionShape。所以相比之下CSG確實方便。
攝像機
之前我們把攝像機放在了場景中。
當然,有不少3D遊戲的攝像機的位置確實(至少在部分場景中)是固定的。比如早期的《生化危機》和《潛龍諜影》。
不過在接一下來的一系列文章裡,我主要以FPS的典型形式來講解。畢竟FPS從3D遊戲誕生之初至今都是非常受歡迎的門類。
對於一個典型的第一人稱視角遊戲來說,攝像機通常放在直接受(真實世界的)玩家控制的東西上。這個東西作為玩家的化身,它身上的攝像機就相當於我們的眼睛。
因此一般來說我們就把攝像機放在接近這個膠囊頂部的位置。
不過……
前面是哪個前面
要注意Godot的攝像機的“前方”總是朝向z的負方向。你或許會覺得有點彆扭,但這樣做的原因之一是為了和OpenGL的選擇保持一致。
可以看到在往場景中加入Camera3D節點後,攝像機默認朝向-z方向。
![](https://image.gcores.com/baf00ca24e9978c781600f809c684105-556-778.png)
基於這樣的決定,很多和方向相關的概念都是圍繞它來確定的。在Vector3類型上定義了四個常量,FORWARD、BACK、LEFT、RIGHT分別代表前後左右四個方向(四個長度為1的向量)。其中代表前方的FORWARD就是(0, 0, -1),其他方向對應的值也就不言而喻了。
另外,Vector3上還有好幾個以MODEL(模型)開頭的和方向對應的常量。這是為導入的3D素材選用的座標系,其實就是和3D模型格式glTF相對應的方向。對於一些有涉及朝向的模型來說,需要注意這些座標系之間的區別。
旋轉的表示
接下來著手實現用鼠標調整攝像機的角度。
你會說OK,很簡單,就是調整一下rotation屬性唄。
確實沒錯。但是你很快就會發現事情比想象的複雜。
在2D中,Node2D的rotation屬性是一個標量、一個小數而已。因為在一個2D畫面中,我們可以想象觀察遊戲場景的方向(視線方向)始終是穿過屏幕的一個向量。所以物體自身的旋轉始終始終是繞這個方向來談的。
然而在3D空間中,你可以隨便點一個Node3D節點看看它的rotation,可以注意到它的類型是Vector3。
想象一下飛機——或者你可以拿一個飛機模型在手上。它自身在3D空間中的“旋轉”涉及三種動作:機頭抬頭低頭、機頭繞豎直方向左右旋轉、“機翼一邊抬高的同時另一邊降低”。
雖然上面我是用非常隨意的語言描述的,但是它們都分別對應著飛機繞不同的座標軸旋轉。用Godot的座標系來說,分別就對應著繞x軸、y軸、z軸旋轉。因此自然而然地,3D空間中的旋轉需要用Vector3來描述。
在這樣的場景中,我們有更專業的術語來指代這三種旋轉(角度)。Pitch(俯仰)、yaw(偏航)、roll(翻滾),這些術語在航空器相關的領域內應該有更專業的中文說法,歡迎相關行業的讀者補充。
![借用維基百科的圖](https://image.gcores.com/26b5c9bf315851d9075ab0f43e3bed05-1920-1444.png)
借用維基百科的圖
使用這三個單詞除了可以很好地對應約定熟成的術語之外,還有個好處就是它的含義是和具體的座標系無關的。例如Unreal的API中很多地方就是直接使用這三個詞。
控制攝像機
至少近十多二十年典型的FPS操作是用鼠標來控制攝像機以表現“四處看”的感覺。
我們要做的“很簡單”,無非是在前後移動鼠標時,讓攝像機也跟著調整俯仰角度。在左右移動鼠標時……
這裡值得一個停頓。如果不假思索地說在此時讓攝像機也調整圍繞y軸的旋轉角度,那麼就會得到一種可能不符合預期的行為——不叫它“問題”是因為在某些類型的遊戲中可能就是需要這種行為。
左右移動鼠標時如果只旋轉攝像機,那麼就會出現視線和玩家角色的前方不在同一直線的問題。在目前主流的FPS中並沒有採取這種做法,你肯定知道,我們左右移動鼠標時,整個玩家角色也會同時旋轉,然後帶動攝像機旋轉,進而保持視線前方和玩家角色的前方在同一方向上。另一方面,我們在移動過程中也可以保持移動方向的前方和攝像機的前方指的是同一個方向。
當然,也有一些遊戲並不是這樣。比如很多有坦克的遊戲,鼠標一般是控制炮塔的角度——也同時對應攝像機的角度。而移動是按照坦克底盤的前方來決定方向的。
不過話說回來,典型的FPS操控也是“很假的”。畢竟我們在現實生活中很多時候頭部的方向和移動方向都並不一致。但是在快節奏的FPS中,這種真實感很容易讓操作變得複雜而影響流暢度。
因此,這裡我們希望在左右移動鼠標時直接旋轉整個玩家角色。
抓老鼠
我的意思是“捕獲鼠標”(capture mouse)。
func _ready() -> void: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
喜報:我換了個軟件寫,代碼樣式可以直接複製粘貼到機核的編輯器了,不用發圖片了。機核的編輯器真的還有很多改善空間。
默認情況下在遊戲裡你是看得見鼠標指針,也可以把鼠標指針移動到遊戲畫面外的。將鼠標模式設置為captured就可以實現一般FPS遊戲的效果。
獲得鼠標移動
Godot對於鼠標移動的處理目前來看比較奇怪,它沒法直接映射到InputMap中。不過經常和鼠標移動對應的手柄搖桿倒是可以——我這裡還是以鼠標來講解。
為此我們需要實現/重寫節點的_input(event)方法。要用到這個方法,原因自然是因為鼠標移動沒法映射為一個action然後通過Input的靜態方法來處理。
但是聰明的你可能會問,如果兩者都可以處理輸入,那麼如何選擇呢?
_input方法會在任意輸入事件發生時,被調用。Input的相關方法則是我們主動調用的。因此兩者首先在執行頻率(時機)上有區別。前者和輸入事件保持同步,而後者是我們自行控制、主動查詢輸入事件的發生。
_input唯一的參數是類型為InputEvent的事件對象,其中包含了各種輸入事件的屬性。由於_input會在任意輸入事件發生時執行,所以我們必須對事件進行篩選,以便只在感興趣的輸入事件發生時運行相關代碼。
儘早返回是個好習慣,只要不感興趣就直接return:
func _input(event: InputEvent) -> void: if event is not InputEventMouseMotion: return var mouse_input = event as InputEventMouseMotion var motion = mouse_input.screen_relative # TODO
⚠注意:這段代碼中的is not必須要4.3+的版本才能夠正常運作。儘管is和not在之前的版本就已經存在,但是在進行類型檢查的時候連著這樣用在4.3才開始支持。在之前的版本中只能把not寫在最前面。
首先,只要不是鼠標移動我就不管(比如按下左鍵我們暫也時不管)。InputEvent是所有輸入事件的基類,鼠標相關事件是InputEventMouse,鼠標移動事件是更具體的InputEventMouseMotion。
InputEventMouseMotion中定義了很多和事件有關的屬性,其中screen_relative顧名思義是鼠標移動前後的座標差值,得到的向量就表示我們鼠標移動的方向和距離。
實際上還存在一個叫relative的屬性。不過如果你的遊戲畫面設置了某種拉伸(stretch,還記得我們在之前的文章中一開始為適應像素遊戲風格素材設置畫面拉伸模式的時候嗎),relative可能會被縮放,進而造成不同分辨率情況下移動鼠標給人的感覺不一樣。
基本實現
可以寫點控制攝像機的代碼了。
按照剛才說的,鼠標前後移動,攝像機也要跟著“俯仰”。因此定義一個look_up方法:
func _input(event: InputEvent) -> void: # ... look_up(motion.y * 0.01 * mouse_sensitivity) func look_up(value: float): camera.rotate_x(deg_to_rad(-value))
畢竟已經寫到這裡了,很多東西的定義我就不展示了,免得佔篇幅。
camera是對攝像機節點的引用。我們在_input中獲得的鼠標移動狀況會傳入到look_up中。
rotate_x方法的名稱已經很直白了,就是繞x軸旋轉,但是它的參數是以弧度為單位,所以需要轉換一下。或者你也可以採用另一種寫法:
camera.rotattion_degrees.x -= value
rotation_degrees和rotation是對應的,只不過是以角度表示的,對於人類來說可能方便一點。
mouse_sensitivity是一個export了的整數屬性,也就是鼠標靈敏度,我這裡默認值設置為50。鼠標移動得到的screen_relative
從一位數到三位數都有可能。如果直接把這個值當成度數傳給look_up,我覺得一般人類可能是無法接受那麼快的旋轉速度的。因此這裡乘以一個係數再乘上靈敏度便於調整。當然這裡的數字不是什麼魔法數,你可以根據自己的需要調整。
現在上下(前後)移動鼠標,可以看到我們的視野跟著在旋轉了。
你的問題可能是,value前面這個負號哪來的?
角度的正負
首先,你可以試一下不要這個符號是什麼效果。
角度在很多場合中有符號,如果我們把角看成是一條線段/射線/向量繞“起點”旋轉出來產生的,那麼它的符號體現了角度“是從哪個方向轉出來的”。習慣上,你可能接受逆時針為正,順時針為負。
有可能,這種習慣源自於上學的時候,2D座標系+x方向朝上,+y方向朝右,一條線段/直線和y軸正方向形成的夾角,其角度我們視作一個正的角度。Godot在2D中以逆時針為負,順時針為正——但是這和前面提到的習慣來說,也沒毛病,畢竟Godot的2D座標系原點在左上角,即+y方向朝下,+x方向朝右。
在3D中情況還要複雜一些,因為我們有更多的觀察角度。例如以圍繞x軸的角度為例,如果我們順著x軸正方向看,那麼逆時針為負;如果順著x軸負方向看,那麼逆時針為正。
你可以在場景中使用旋轉工具觀察一下這些角度的變化。
回到代碼的問題。我們的攝像機朝向-z方向。從右邊來看(順著-x方向),“抬頭”是逆時針旋轉,攝像機的rotation的x分量會加上一個正數。“那為什麼value要加負號啊?”,因為InputEventMouseMotion的screen_relative是一個2D向量,其座標的定義和Godot的2D座標系是一致的,也就是說鼠標向前是在朝-y方向移動,得到的是一個負數。
限制角度
目前如果持續前後移動鼠標的話,攝像機會360度旋轉。無論是從現實還是FPS的典型設計來說,我們都最多隻能轉一定角度。簡單,還是用clamp:
camera.rotation_degrees.x = \ clampf(camera.rotation_degrees.x, -70, 70)
這裡的70度是我自己隨便選的,你可以自己定義一個export屬性。
環顧四周
正如前面所說,鼠標左右移動時,我們不再旋轉攝像機,而是直接旋轉整個玩家角色,因此定義一個turn方法:
func trun(value: float): rotate_y(-value)
實現它要比俯仰簡單得多。並且它是不需要限制角度的,畢竟我們可以轉圈。類似地,這裡也要加個負號。這個函數放在look_up下方調用就行。
注意,正如前面所說,這裡是直接在自己身上調用rotate_y而不是在camera上面。我們是直接轉整個角色,而不是旋轉攝像機。
移動的實現
模板已經為我們實現了移動的功能。我們簡單看一下是如何實現的。
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_backward") var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
主要需要看的就是在_physics_process中這兩行代碼。
當然首先介紹一個之前在我的代碼中沒有用到的運算符:=。鑑於GDScript和Python的相似性,需要提醒一句它和Python中的“海象運算符”(walrus operator,橫著看)有不同的語義。
在GDScript中這個運算符意味著在變量初始化時,對變量的類型進行推斷,這樣就不用自己主動寫類型。
言歸正傳。這兩行代碼要求出想要移動的方向(最終的direction是一個長度為1的向量)。首先是求玩家輸入的方向,Input.get_vector根據傳入的四個輸入操作名稱來構造一個2D向量。四個參數分別是-x、+x、-y、+y方向。
實際上如果你接受了攝像機朝-z方向的設定,這裡反而就更有道理了。這裡的-x方向是左,+y方向是向後。實際上,如果我們從上往下看,讓-z為前方(正北),+z和+y方向剛好就和Godot的2D座標系的+y和+x對齊了!是不是一下就不覺得哪裡彆扭了。
![從上往下看](https://image.gcores.com/8b1c583429892711b15609302d4dab59-518-495.png)
從上往下看
求得輸入的方向後,進行“某種運算”求得角色要移動的方向direction並歸一化。這裡不得不賣個關子,因為沒有一些額外的解釋可能無法理解這個“某種運算”怎麼就讓從輸入得到的方向和玩家角色的朝向對齊了。
接下來的代碼就沒什麼新鮮的了。它會根據SPEED的定義來控制移動的速率,然後根據direction的值設置速度,最後調用和CharacterBody2D功能一樣的move_and_slide實現移動。
跳躍在模板中也實現了,和2D版基本上一樣,不多解釋。
至此,一個典型的可以在3D空間中漫遊的第一人稱角色就基本實現了。