今天終於要來實作本專案的靈魂了!沒錯!就是射擊效果,一款射擊遊戲不能發射子彈完全是沒有靈魂啊,而除了完成射擊效果外,還要來把昨天沒做完的苦力怕剛體化及比例調整一下,另外也心血來潮做了疊磚的效果。
這是本系列第 24 篇,如果還沒看過第 23 篇可以點以下連結前往:
用 Three.js 來當個創世神 (23):專案實作#12 - 導入 Cannon.js 物理效果
請參考完整原始碼及成果展示,原本想讓它與玩家本體一比一,但突然覺得擊倒巨大苦力怕很有趣,那就 ... 讓它更大吧,讀者可以試試看能不能利用子彈將它擊倒。
筆者試玩了之後有發現一個秘技,這邊就先不點破了。
今天的實作有以下幾個目標:
那就讓我們開始吧!
其實這兩者是同一件事,因為一個是按左鍵發射球體,另一個則是從右鍵發射正方體,程式碼部分先從 index.js
中的 click
事件開始看:
// shooting related settings
const ballShape = new CANNON.Sphere(0.2)
const ballGeometry = new THREE.SphereGeometry(ballShape.radius, 32, 32)
let shootDirection = new THREE.Vector3()
const shootVelo = 15
let raycaster = new THREE.Raycaster() // create once
let mouse = new THREE.Vector2() // create once
// shooting event
window.addEventListener('click', function(e) {
if (controls.enabled == true) {
let ammoShape
let ammoGeometry
let ammoMass
let ammoColor
switch (e.which) {
case 1: // 左鍵射擊
ammoShape = ballShape
ammoGeometry = ballGeometry
ammoMass = 20
ammoColor = 0x93882f
break
case 3: // 右鍵疊磚
ammoShape = boxShape
ammoGeometry = boxGeometry
ammoMass = 50
ammoColor = 0x0f0201
default:
break
}
// 取得目前玩家位置
let x = playerBody.position.x
let y = playerBody.position.y
let z = playerBody.position.z
// 子彈剛體與網格
const ammoBody = new CANNON.Body({ mass: ammoMass })
ammoBody.addShape(ammoShape)
const ammoMaterial = new THREE.MeshStandardMaterial({ color: ammoColor })
const ammoMesh = new THREE.Mesh(ammoGeometry, ammoMaterial)
world.addBody(ammoBody)
scene.add(ammoMesh)
ammoMesh.castShadow = true
ammoMesh.receiveShadow = true
ammos.push(ammoBody)
ammoMeshes.push(ammoMesh)
getShootDir(e, shootDirection) // 取得射擊方向
ammoBody.velocity.set(
shootDirection.x * shootVelo,
shootDirection.y * shootVelo,
shootDirection.z * shootVelo
)
// Move the ball outside the player sphere
x += shootDirection.x * (sphereShape.radius * 1.02 + ballShape.radius)
y += shootDirection.y * (sphereShape.radius * 1.02 + ballShape.radius)
z += shootDirection.z * (sphereShape.radius * 1.02 + ballShape.radius)
ammoBody.position.set(x, y, z)
ammoMesh.position.set(x, y, z)
}
})
這邊先根據點擊左鍵或右鍵決定發射出的物體是子彈或磚塊,再建立發射物的剛體及網格模型,比較值得注意的是這邊有個 getShootDir(e, shootDirection)
,就是用來得知道發射物是朝什麼方向前進:
function getShootDir(event, targetVec) {
// 取得滑鼠在網頁上 (x, y) 位置
mouse.x = (event.clientX / window.innerWidth) * 2 - 1
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
// 透過 raycaster 取得目前玩家朝向方向
raycaster.setFromCamera(mouse, camera)
// 取得 raycaster 方向並決定發射方向
targetVec.copy(raycaster.ray.direction)
}
這邊官方範例中使用較早以前 Three.js 版本用的 THREE.Projector()
來取得發射方向,後來找了一些資料得到可以透過 raycaster 解決,當滑鼠及相機方向具備後,就可以用 raycaster.ray.direction
來取得子彈方向。
最後最重要的一步,就是要記得在 render()
中更新網格的位置與剛體同步了:
function render() {
requestAnimationFrame(render)
...
for (let i = 0; i < ammos.length; i++) {
ammoMeshes[i].position.copy(ammos[i].position)
ammoMeshes[i].quaternion.copy(ammos[i].quaternion)
}
...
renderer.render(scene, camera)
}
另外程式碼中建立剛體及網格模型可參考前面「第 20 篇:Cannon.js 基本練習」
由於苦力怕分別有頭、身體、四隻腳,共六個箱體,做法都差不多,這邊只示範頭的部分,首先看到 creeperModule.js
的程式碼:
const headShape = new CANNON.Box(
new CANNON.Vec3(2 * sizeScale, 2 * sizeScale, 2 * sizeScale)
)
this.headBody = new CANNON.Body({
mass: 5 * massScale,
position: new CANNON.Vec3(0, 12 * sizeScale, 0)
})
這邊要先說一下,由於昨天苦力怕變太大隻,在建立苦力怕物件時,改寫成可以動態傳入尺寸 sizeScale
跟體重 massScale
兩個參數,可以用來調整比例。
而箱型剛體(CANNON.Box
)一直沒仔細解釋,其實筆者一開始也一直好奇為什麼要用網格幾何體的一半長來宣告,看官網文件只寫請傳入「halfExtents」也沒什麼解釋,猜測 CANNON.Box
應該是以重心為標準去看待 shape 的長寬高。
然後在 index.js
中的 createCreeper()
初始化中做 world.addBody(creeperObj.headBody)
把剛體加到世界中,並且在動畫更新部分也要記得同步更新網格的位置:
creeperObj.head.position.copy(creeperObj.headBody.position)
creeperObj.head.quaternion.copy(creeperObj.headBody.quaternion)
而上面的剛體化效果對六塊物體依序做完,會發現苦力怕是有剛體化了,不過卻四分五裂散落一地:
有沒有想到昨天提到的一個概念?沒錯,「約束」在這邊就派上用場了,一樣舉頭部當範例,我們得將頭與身體的連接處,姑且稱它為脖子(雖然苦力怕沒有),來做一個頭與身體的約束效果:
creeperModule.js
:
// Neck joint
this.neckJoint = new CANNON.LockConstraint(this.headBody, this.bodyBody)
index.js
:
function createCreeper() {
...
world.addConstraint(creeperObj.neckJoint)
...
}
Cannon.js 中的約束有很多種,筆者測試到後來發現 LockConstraint
是可以順利完成我們要的效果的,加上去之後就會發現苦力怕成功地連結在一起剛體化了,可以透過子彈將它擊倒啦!
射擊部分主要的難點其實在於如何取得射擊的方向上,一開始只知道試著寫 raycater.direction
一直是 undefined
,查了半天才知道是要寫成 raycater.ray.direction
。
其實今天比較晚發文是因為這塊一直卡關,因為苦力怕有六塊,一直沒辦法組在一起,還有一些靈異現象像是頭會噴飛之類的,一度還覺得要不要乾脆做個長方體就好,但身為工程師就是不能放棄,持續的 try and error 才是王道,不然前面做了很久的苦力怕就不能用了!
而關於四分五裂的問題一開始也沒想法,後來想到可以參考範例,就找到了這個:
trace 了一下 code,發現原來是需要加上「約束」啊!就依樣畫葫蘆試著加上了:
this.neckJoint = new CANNON.ConeTwistConstraint(
this.headBody,
this.bodyBody,
{
pivotA: new CANNON.Vec3(0, 6, 0),
pivotB: new CANNON.Vec3(0, 4, 0),
axisA: CANNON.Vec3.UNIT_Y,
axisB: CANNON.Vec3.UNIT_Y,
angle: Math.PI / 4,
twistAngle: Math.PI / 8
}
)
你誰!?
後來就到官網文件查「Constraint」,改成另一個比較像的 LockConstraint
才成功。
最後剩下的一個 bug 是剛體跟網格的位置對不起來,就是網格模型生在(10, 0, 10),但實際上剛體位置似乎不在那而在大約(0, 0, 0)的位置,原本一直嘗試是不是 CANNON.Box
宣告形狀的定義搞錯了?還是 CANNON.Body
的 position 要自己平移?在各種試都無解的狀況下,還試著要加上 Cannon.js 的 debug tool,但卻一直噴 error(什麼巫術),後來追到各種 position 的問題才發現是在 index.js
的 createCreeper()
初始化時設了個 creeperObj.creeper.position.set(10, 0, 10)
,難怪一直有位置不對應的問題,才終於完成今天的所有進度。
可能解法像是「限制子彈數量」或「子彈過一段時間後需做 dispose」等等。
目前整個物理世界內的物體似乎都設成一樣的,但應該個別做調整,不然疊磚時很滑都疊不起來。
使用者體驗不太好,或許要在網頁上做個按鈕引導使用者點擊中心開始遊戲。
由於筆者是使用 Google Chrome 在 Macbook 上開發測試,所以使用桌面版的 Chrome 能擁有最佳體驗;剛才稍微試了一下 Safari 無法點右鍵,而 Firefox 還算正常,但左鍵連點會反白。
今天終於完成了射擊的效果,開始漸漸比較有遊戲性了,也順手加上了類似遊戲中的原始疊磚塊的玩法,不過既然是可以射擊又可以蓋地基,難道可以做成一款 Fortnite 了嗎?
剩下幾天要來處理子彈射到苦力怕爆炸、遊戲流程、整合各項功能、背景及射擊爆炸音效、場景及細節優化,雖然時間內可能不會全部都做到,但至少整個基本遊戲流程希望可以順利完成,我們明天見。