Godot入門到棄坑——光槍射擊


3樓貓 發佈時間:2024-12-23 14:33:50 作者:cameLcAsE Language

部分讀者可能不知道這裡的光槍是什麼。下面這個遊戲就是一個光槍遊戲:
FPS的shooter就是指射擊要素。現在我們有了一個角色,但是他除了在場景中到處晃之外什麼也沒法做。

準星

實際上很多偏真實的FPS的準星都是動態的,但是不管怎麼花哨,基本的道理都是要在畫面中間顯示一個東西,這樣我們才能知道我們要打哪裡。
我們先做一個簡單的靜態準星,一方面可以讓手上的遊戲更像樣一點,一方面也能夠更方便地知道我們把攝像機對準到哪兒了。
像往常一樣,作為UI元素,我們需要在場景中放一個CanvasLayer來容納它,按照慣例,可以叫它HUD。我們可以把它放在玩家角色的場景中,後續我們可能要給他添加更多的和玩家相關的UI元素。
然後加入一個CenterContainer。顧名思義,這個容器會讓其子節點保持在正中。接下來就是簡單地放一個準星紋理到裡面就算有模有樣了。
提醒一下:由於這裡是在一個3D場景中放入了一個CanvasLayer然後編輯一些非3D的節點,所以如果想在編輯器中看到它或者直接在視口中編輯,需要點擊工具欄中間的2D和3D來切換。
現在啟動遊戲就能看到一個在畫面中間的準星了。

我到底瞄準沒有

我怎麼知道我對著哪裡?我怎麼知道我朝這個方向開槍能不能打到敵人?
實際的情況可能會更復雜,但是這個問題最簡單的抽象就是,從一個點(比如屏幕中心)發出一條射線,能否和空間中的某個幾何體相交。
沒錯,這就是一個數學問題。但是遊戲引擎的功能就是為了不讓我們從頭來解決這些問題。
主流的遊戲引擎中都有相應的API來處理這個問題。比如Unity叫它Raycast(射線投射),Unreal叫它LineTrace(“直線”追蹤,幸好它沒叫RayTrace)。當然不管怎麼取名字,做的事情都是差不多的,只是總得取個名字。
毫無疑問Godot也有相應的API,Godot在文檔中選擇叫它Raycast

手動擋

Godot提供了一個相對較複雜的API來和物理系統交互來查詢射線和具體可碰撞對象的相交情況。
因為要和物理系統交互,這裡自然而然地要在_physics_process中寫一些代碼。我們定義一個fire方法,在_physics_process中適時調用。在先簡述一下實現流程。
首先需要通過3D節點的get_world_3d方法獲得當前的World3D的引用,這個World3D總之就是一個包羅萬象的抽象資源,我們需要從它的direct_space_state屬性中拿到當前物理系統狀態。
實際進行射線投射的方法是space_state.intersect_ray,它的唯一參數是一個代表射線查詢參數的PhysicsRayQueryParameters3D對象。這個函數的作用顧名思義,intersect就是“相交”,ray就是“射線”。
要調用這個方法,我們自然要填寫一個PhysicsRayQueryParameters3D。對於一些參數比較複雜的函數,你會經常看到這種把一堆參數打包成一個單獨的類的做法。一方面是方便配置,另一方面也是因為用一堆參數去調用一個函數的時候很難看,也不好排版。這個參數類最重要的自然是射線的起點和終點。起點,應該是屏幕中心,我們先試著從屏幕中心位置……
真的是屏幕中心嗎?我們的屏幕並不是一個存在於遊戲的3D世界中的一個物件,何來的“從屏幕中心引一條射線通過3D場景中的指定位置”呢?
一般來說我們的屏幕是2D的,就相當於把一系列像素排列好就得到了我們看到的畫面。實際上,3D渲染引擎中的攝像機可以說是由一些參數定義的一個矩陣(或者說一系列矩陣),矩陣是一個明確的數學概念,它可以用來操作向量。3D場景中的東西,需要滿足一定要求才會被渲染,它們首先會被變換(這實際上也是一個數學概念)到攝像機面前的一個平面上(就像是“被看到”),最後再變換到我們的屏幕上。
因此“屏幕中心”指的是透過攝像機“所看到”的這個畫面的中心。它並不一定總是和我們所在的真實世界的屏幕中心在同一個位置上。這背後更多的數學知識,(可能)會在後面講到。
Camera3D節點提供了一些方法來方便構造這種射線。比如這個名字就很直白的方法project_ray_origin,其參數是屏幕上的一個位置。這個方法會把屏幕上的一個位置轉換(專業點說是變換)到3D空間中去。
project_ray_origin的唯一參數就是一個二維向量。它代表視口座標
,因為顯然我們遊戲的畫面並不總是等於整個屏幕,甚至於觀察某個場景的視口也不完全等於整個窗口。所以剛才說的屏幕準確地說應該是視口。要獲得視口信息,使用get_viewport即可拿到容納當前節點最近的Viewport。然後通過其size屬性即可拿到尺寸進而求出中心座標。這樣我們就求得了射線起點。
func fire(): var viewport_center = get_viewport().size / 2.0 var origin = camera.project_ray_origin(viewport_center) # TODO
理論上應該這樣寫沒錯。但是實際上在我們的情況下origin就始終等於攝像機的global_position。你可以試著給project_ray_origin傳入任意值,只要攝像機不動,這個結果就應該是不變的。
前面講到Godot(以及很多遊戲引擎)的3D攝像機都有透視和正交兩種模式,它們會按不同的規則將3D空間中要渲染的物體的各個頂點投影到這個虛擬攝像機面前的一個平面(近裁剪平面,圖中的near)上。對於透視攝像機,這些射線的起點實際上都在同一個位置,想象一下從眼睛處發出無數條穿過並覆蓋整個近裁剪平面的射線。3D空間中原本的點,和它經過透視投影投到近裁剪平面上的點連起來之後都會匯聚到同一個點上,但是方向各不相同。
而對於正交模式的攝像機,3D空間中的點和投影之後得到的點的連線都是平行的——但是方向相同。
因此在我們的情況中,這個origin換成camera.position也是一樣的。
拿到這個點之後,我們就把它作為起點,然後確定一個終點。至少在FPS的情況下,這條射線應該朝著視線方向射出去。如何確定這個方向呢?正如剛才所說,透視情況下射線的起點不變,但是需要用終點確定方向。我們這裡希望能夠從屏幕中心射出去,那麼自然就考慮想要“攝像機的前方”。如何獲得這個前方呢?
先說結論,如果要真正地獲得這個前方,可以這樣寫:
parameters.to = origin + (-camera.global_basis.z) * 1000
好吧,這裡確實跳過了太多說明。主要還是背後涉及不少數學知識,這裡不得不再次搬出那句話,後面可能會補充講解。但是還是要簡單說明一下。
當然最主要的就是這裡的global_basisglobal_basis等價於global_transform.basis。前面提到過transform屬性保存了節點位置、旋轉、縮放等屬性的數據。但是呢,transform的主要數據其實就是一個origin和一個basis。
實際上每個(Node2DNode3D)節點的transform都分transformglobal_transform。前者實際上是局部(local)transform。為什麼要分這個東西呢?
因為一個物體的位置、朝向信息在不同座標系中有不同的值。
全局(或者說世界)座標系就相當於一個最上層的根節點,在它之外就沒有其它東西了,它自己也不會動。這樣場景中每個東西都可以用這個座標系的座標來描述。可以說一定程度上,全局座標可以理解為一種“絕對座標”。它的原點就是(0, 0, 0),座標軸和Godot的座標系定義始終相一致。
假設我們的玩家角色在原點,然後使用rotate_y逆時針(從上往下看)轉了90度。然後再讓攝像機順時針旋轉90度。此時攝像機還是朝向前方(-z方向)。那麼這個過程中什麼變了什麼沒變呢?
rotate_*方法是對局部transform的basis進行修改(實際上Transform和Basis基本上是不可變類型,很多操作都會直接得到一個新的實例)。上述操作都是修改局部的朝向,和在檢視面板中直接改數字是一樣的。
局部transform在一定程度上可以解釋為“相對transform”。試著在_ready中觀察輸出:
print(camera.global_rotation) print(camera.rotation) rotate_y(deg_to_rad(90)) print(camera.global_rotation) print(camera.rotation) camera.rotate_y(deg_to_rad(-90)) print(camera.global_rotation) print(camera.rotation)
攝像機作為玩家角色的子節點,玩家場景整個旋轉時,其子節點也跟著旋轉,進而攝像機和他的父節點的相對transform是不變的。
攝像機作為玩家角色的子節點,玩家場景整個旋轉時,其子節點也跟著旋轉,進而攝像機和他的父節點的相對transform是不變的。
但是作為觀察者,我們跳出玩家角色的場景,從外部觀察,此時攝像機的朝向已經發生變化,這個變化就體現在global_rotation上。
隨後,我們又單獨把攝像機轉回去,此時它和整個玩家場景的根節點相對來說就不在同一個方向上,因此其rotation必然是不為0的(當然這取決於初始狀態,我們這裡的玩家場景的根節點和攝像機節點初始狀態transform的各個數值都是默認值0)。
那麼Basis到底是什麼呢?Basis基本上對應著數學中的基(也稱“基底”)概念。說嚴謹點它可以確定一個向量空間。說具體點但可能不那麼嚴謹的話,就是它可以確定一個具體的座標系。“座標”實際上就是對組成基的向量進行線性組合(啥是線性組合?就是倍乘和相加)得到的一個向量。我們很多時候都是談的一種特殊情況,那就是原點在(0, 0, 0),基為{i=(1, 0, 0), j=(0,1, 0), k=(0, 0, 1)}的座標系。例如座標為(3, 4, 5)的點意思就是它的座標是3i+4j+5k。但實際上“座標系”有無數個,甚至它的座標軸也可以不相互正交(垂直)。
basis就是三個向量,分別表示這一個“座標系”的x,y,z座標軸到底是哪個方向。當然這裡肯定是世界的、絕對的座標來表示,畢竟我們總要有一個基準來表示其它非基準的東西。
總之,-camera.global_basis.z總是表示攝像機(在全局座標系的)前方。這裡的負號自然還是因為這是Godot規定的攝像機的“前方”。
這個前方總是表示攝像機真正的前方,因為它是“絕對”的,不管玩家節點本身朝哪一方。你可以試一下把global_basis換成basis是什麼效果。
當然,這裡其實也不用這麼複雜:
parameters.to = origin + camera.project_ray_normal(viewport_center) * 1000
Camera3D
還提供了一個和project_ray_origin成對的方法project_ray_normal。normal在這裡不是“正常”的意思,它表示數學概念“法線”(法向量)。這裡指的就是從透過這個攝像機觀察的視口上的某一點投射出來的射線的方向。實際上法線在很多時候指的是在曲面上某一點上和曲面垂直的直線,而通過法向量就可以確定“內外”或者這一點上曲面的“朝向”。
但是,為什麼還要把origin加在前面呢?這一堆查詢參數的to屬性定義為射線檢測的終點。把這個問題一般化,就是給定一個點,一個方向,那麼從這個點沿某個方向引一條線,最後得到的點的座標為多少?
例如起點為A點,射線方向為u(1, 0)。那麼你就要求出B。把A點的座標視為向量,那麼向量a(對應B的座標)就應該是A+u。這個可以按照中學講過的所謂平行四邊形法則來想,也可以簡單地想成,把u的起點挪到我們的起點A處就得到了終點B。還是再次提醒一句,在用向量、座標討論這種幾何問題時,我們很多時候不區分點和向量。但是,在具體的問題中,我們又可能要加以區分以便於理解。
但是,依然由於我們這裡是從一個特殊的起點開始進行射線檢測,因此不加上origin也是一樣的。因為我們的origin本身就在這個方向上。
你的最後一個問題是為什麼還給射線方向乘了個1000?首先,這個1000是我隨便寫的,它不一定要是1000。project_ray_normal返回的是一個長度為1的規範化之後的向量,所以它相當於只代表方向,不關心長度。但是這裡所謂的射線檢測的終點是由射線檢測的範圍(射線的“長度”)確定的,所以我們要乘上一個長度。這裡你可以自己export一個變量,但應當是一個較大的數字。想象一下武器的射程。
最後,處理這個問題比較通用的寫法是這樣的:
var viewport_center = get_viewport().size / 2.0 var origin = camera.project_ray_origin(viewport_center) var parameters = PhysicsRayQueryParameters3D.new() parameters.from = origin parameters.to = origin + camera.project_ray_normal(viewport_center) * 1000 var result = get_world_3d().direct_space_state.intersect_ray(parameters)
一些遊戲可能需要“從屏幕上點到場景中”,比如點擊選中,或者各種類型遊戲中的點擊移動到指針位置,都沒那麼特殊,所以需要更通用的寫法。但是我們這裡足夠特殊,所以可以簡化成:
var parameters = PhysicsRayQueryParameters3D.new() parameters.from = camera.global_position parameters.to = camera.project_ray_normal(get_viewport().size / 2.0) * 1000 var result = get_world_3d().direct_space_state.intersect_ray(parameters)
說了這麼多終於說到獲得結果了。比較奇葩的是intersect_ray返回的是一個字典,具體包含哪些內容在文檔中有說。當然我們目前主要關心兩個東西,一個是collider,即誰和這條射線發生了碰撞;另一個是position,即在哪裡發生了碰撞。
最後再提醒一句,射線檢測需要被檢測物能夠進行碰撞檢測才行。一般的對象需要你自己配置CollisionShape等節點,CSG的話就簡單啟用一下碰撞就可以了。

自動擋

其實上面手動擋的代碼也不復雜,只是要解釋原理的話要說很多。
Godot實際上還提供了一個更簡單的節點專門用來進行射線檢測。這個節點就叫RayCast3D(2D場景中為RayCast2D)。
RayCast3D節點最主要的屬性就是target_position,也就相當於我們剛才求的to參數。它的射線由RayCast3d的位置target_position確定。
設置了這個屬性之後基本上就可以工作了。我們可以通過它的get_colliderget_collision_point來獲得相交的對象和點。
那麼和手動擋區別在哪兒呢?區別就在於RayCast3D會在每個物理幀_physics_process)裡進行檢查,而手動擋的處理時機由我們控制。當然毫無疑問,我們在手動擋裡能夠實現更細粒度的控制。但是RayCast3D對於本身就需要持續檢測的情況會更方便。

方便調試的擊中指示物

現在就算打中了一個東西,我們最多在終端輸出一下位置,我們沒法在畫面上看到任何東西。
為了方便開發過程中的調試,我們可以寫一些簡單的代碼來表示我們擊中了某個位置。這個功能對於現在的我們來說太簡單了。我們都已經可以拿到擊中的位置了,基本上也就沒什麼困難了。
當然,你可以單獨建一個場景專門來表示這個指示物方便後續調整。不過這裡我就簡單地直接寫代碼了。
擊中後,我在position處生成一個新的節點。這裡我就直接構造一個CSGSphere,也就是一個簡單的球體。
var indicator = CSGSphere3D.new() indicator.radius = 0.25 indicator.position = result.position var timer = get_tree().create_timer(3.0) timer.timeout.connect(func(): indicator.queue_free()) get_tree().root.add_child(indicator)
不過說到這裡要注意一下。基本上只有Godot內置節點直接用構造函數(new)才能直接構造一個對應類型的節點。如果你有一個自己寫的腳本然後試圖用構造函數構造整個場景的話實際上是不行的。要構造自己的場景只能通過對場景的引用PackedScene調用instantiate來構造場景。
不過為了避免不小心或者故意在場景中生成了一大堆指示物,這裡用個計時器定時銷燬它。
很簡單,現在啟動遊戲並射擊就可以看到了。
一不留神,我們又實現了射擊遊戲中基本的射擊功能。

© 2022 3樓貓 下載APP 站點地圖 廣告合作:asmrly666@gmail.com