遊戲中的苦力怕行走機制是當玩家進入它的警戒範圍後,苦力怕會追著玩家跑,今天就讓我們利用 Tween.js 來實做這個部分,目標是做到能讓苦力怕追蹤鏡頭移動及轉身。
Photo by Denise Jans on Unsplash
這是本系列第 12 篇,如果還沒看過第 11 篇可以點以下連結前往:
用 Three.js 來當個創世神 (11):專案實作#6 - 人物基本動畫
今天要來讓苦力怕可以做「追蹤鏡頭做有位移的走動」及「轉身」的動作,這是為了之後在採用第一人稱視角後,當玩家接近苦力怕進入它的警戒範圍時,它會朝玩家追過來的前置作業,完成圖如下(有點頓建議直接點下方 demo):
主要的行為流程是用 dat.GUI 打開追蹤的開關後,苦力怕會從原點出發,朝著相機的方向走動一段距離,之後會回到原點,並朝下一個相機的位置轉身並往新的方向前進,如此反覆循環,可以透過左鍵拖移旋轉相機位置來測試。
而要做到反覆「從某一點移動至某一點」這個動畫效果,直接實作的話可能需要寫很多判斷式,所以為了讓動畫流程的開發簡單化,這邊考慮使用 Tween.js
這個函式庫來輔助。
Tween.js 是一個輕量的 Javascript 函式庫,可以輕易地做到補間動畫(tween),只要給它起始值、結束值、過程需花費多少時間,其他的中間值會自動被計算出來。
這邊舉個官方的簡單例子:
var coords = { x: 0, y: 0 }; // 起點為 (0, 0)
var tween = new TWEEN.Tween(coords) // 建立新的 Tween 物件來改變 coords
.to({ x: 300, y: 200 }, 1000) // 在 1000ms 內移動至(300, 200)
.easing(TWEEN.Easing.Quadratic.Out) // 補間動畫效果
.onUpdate(function() { // 在 coords 被改變時會執行的 callback function
...
})
.start(); // 立即啟動 tween 動畫
上面範例是指將一個 box 的座標從(0, 0)在一秒內移動至(300, 200),並且在過程中使用 onUpdate
函式來執行當座標值有改變時需要做的相對應修改,另外也能注意到 Tween.js
的函式寫法可以鏈式使用,會比直接寫一堆判斷式的寫法可讀性更高。
對 Tween.js
有興趣的讀者可以參考以下資源:
在原始碼中今天要實作的內容會寫在 tweenHandler
這個 function 裡,以下分別解析程式邏輯。
https://cdnjs.cloudflare.com/ajax/libs/tween.js/17.2.0/Tween.min.js
首先記得要引入 Tween.js
的函式庫。
let offset = { x: 0, z: 0, rotateY: 0 }
let target = { x: 20, z: 20, rotateY: 0.7853981633974484 } // 目標值
由於苦力怕是在與 x-z 平面平行的地板上走動,所以以下在移動過程中的座標都視為(x, z)。
這裡的 offset 代表的是目前苦力怕的位置及旋轉角度,一開始座標會是從(0, 0)出發;而起始的目標值是根據下面實作的規則算出當相機仍維持在預設位置時的數值,之後的目標值會根據相機位置改變。
// 朝相機移動
tween = new TWEEN.Tween(offset)
.to(target, 3000)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(onUpdate)
.onComplete(() => {
tweenBack.start()
})
// 回原點
tweenBack = new TWEEN.Tween(offset)
.to({ x: 0, z: 0, rotateY: 0 }, 3000)
.easing(TWEEN.Easing.Quadratic.Out)
.onUpdate(onUpdate)
.onComplete(() => {
handleNewTarget() // 計算新的目標值
tween.start()
})
這邊分別定義了兩個 tween 物件,tween
負責進行「朝相機移動」的工作,而 tweenBack
負責進行「回原點」的工作,也就是 .to()
裡不一樣的目標值。
兩者的過場動畫效果都使用 TWEEN.Easing.Quadratic.Out
(其他效果請參考連結)
在 .onComplete()
中是當動畫完成後要做的處理,在 tween
中的比較簡單,只要觸發 tweenBack
即可;而在 tweenBack
中則要去準備下一階段要從原點出發的目標值再觸發 tween
一直循環下去。
// 計算新的目標值
const handleNewTarget = () => {
// 限制苦力怕走路邊界
if (camera.position.x > 30) target.x = 20
else if (camera.position.x < -30) target.x = -20
else target.x = camera.position.x
if (camera.position.z > 30) target.z = 20
else if (camera.position.z < -30) target.z = -20
else target.z = camera.position.z
const v1 = new THREE.Vector2(0, 1) // 原點面向方向
const v2 = new THREE.Vector2(target.x, target.z) // 苦力怕面向新相機方向
// 內積除以純量得兩向量 cos 值
let cosValue = v1.dot(v2) / (v1.length() * v2.length())
// 防呆,cos 值區間為(-1, 1)
if (cosValue > 1) cosValue = 1
else if (cosValue < -1) cosValue = -1
// cos 值求轉身角度
target.rotateY = Math.acos(cosValue)
}
首先限制苦力怕只能在地板內走動,因此根據新的相機位置設定了它活動的邊界。
然後比較複雜的是轉身, v1
與 v2
示意如下圖:
這裡指的是要從原點轉身到下一個相機面向的方向,也就是說這裡 v1
是指原點的面向方向,而 v2
是轉身朝向相機時的面向方向。
有了兩個向量後要怎麼求夾角呢?沒錯,也許當年在高中你曾經學過有個公式叫做「內積」:
套用這個公式可以寫出以下的程式:
// 內積除以純量得兩向量 cosθ
let cosValue = v1.dot(v2) / (v1.length() * v2.length())
// 防呆,cosθ 區間為(-1, 1)
if (cosValue > 1) cosValue = 1
else if (cosValue < -1) cosValue = -1
// cosθ 求轉身角度
target.rotateY = Math.acos(cosValue)
從上面程式碼可以看到,我們先求得 cosθ 的值,就可以根據這個值求得需轉身多少角度了,需注意的是,這邊遇到一個 bug,在某些情況下 cosValue
會算出超過(-1, 1)的區間,所以需做個防呆。
在上面的 .onUpdate()
中我們傳入了 onUpdate
這個 function 如下:
// 苦力怕走動及轉身補間動畫
const onUpdate = () => {
// 移動
creeperObj.feet.position.x = offset.x
creeperObj.feet.position.z = offset.z
creeperObj.head.position.x = offset.x
creeperObj.head.position.z = offset.z
creeperObj.body.position.x = offset.x
creeperObj.body.position.z = offset.z
// 轉身
if (target.x > 0) {
creeperObj.feet.rotation.y = offset.rotateY
creeperObj.head.rotation.y = offset.rotateY
creeperObj.body.rotation.y = offset.rotateY
} else {
creeperObj.feet.rotation.y = -offset.rotateY
creeperObj.head.rotation.y = -offset.rotateY
creeperObj.body.rotation.y = -offset.rotateY
}
}
用來處理在動畫過程中苦力怕隨著 offset 的變化修改位置及旋轉角度,這邊特別注意的是,在轉身時,需根據相機現在的 x 位置,來判斷是沿著 y 軸順時針或逆時針轉。
前面在函式中寫了那麼多關於動畫處理的內容,在建立苦力怕物件時記得帶上這個效果:
function createCreeper() {
creeperObj = new Creeper()
tweenHandler() // 追蹤相機動畫
scene.add(creeperObj.creeper)
}
而前面有提到的 tween.start()
的設定,因為我們是用 dat.GUI
開關動畫,所以有如下設定:
gui.add(datGUIControls, 'startTracking').onChange(function(e) {
startTracking = e
if (invert > 0) { // invert = 1, tween
if (startTracking) {
tween.start()
} else {
tween.stop()
}
} else { // invert = -1, tweenBack
if (startTracking) {
tweenBack.start()
} else {
tweenBack.stop()
}
}
})
這裡用了一個 flag 值 invert
來判斷暫停時的動畫過程是停在 tween
還是 tweenBack
。
最後一樣要記得在 render()
處做 update:
function render() {
...
TWEEN.update() // update
...
requestAnimationFrame(render)
renderer.render(scene, camera)
}
其實還有一些美中不足的地方,像是動畫不夠自然流暢,因為目前都沒有設 delay,實際上像是轉身到走動之間應要有一些停頓,而且實際上之後的行為不會是回到原點,而是當玩家逃離警戒範圍後,苦力怕會停下來,隨後在附近隨機移動,因為細節實在不少,所以暫時先做到這裡,之後其他功能完成再來慢慢優化。
另外做到動畫這部分,才發現當年大學時應該好好學線性代數,可能在演算法設計上不會一直因為公式都忘光了卡關。而實際在開發遊戲中人物動畫,應該也不會是像這樣慢慢刻,可以考慮使用 Blender 之類的建模軟體來製作再載入即可,不過筆者暫時沒有這項技能。
如果能利用建模軟體製作動畫的話,應該可以像官方範例做出各種精緻的骨骼動畫示範(例一、例二),看起來真是學無止盡呢。
今天完成了苦力怕能追蹤相機走動及轉身的動畫,真的是千呼喚喚使出來,人物動畫的部分也暫時先做到這邊,明天開始來學習新東西 —— 粒子系統,為後面的爆炸及遊戲場景做準備,我們明天見!
Dez大大有用過three.js/editor和Nova Skin嗎?
看過一些教程,他們都是用類似blender和3ds Max的軟體來制作遊戲中的人物和物件,然後用相關的exporter輸出做json檔,之後用three.js editor載入和佈局,再用script加上動畫效果。
之前看官網文件時有看到有 Three.js 的編輯器,但我一開始學都直接用 VSCode 在做,後來就一直沒去學它的 editor 怎麼用,剛稍微試著操作一下好像還蠻方便的,之後可以玩玩看。
原來有 Nova Skin 這麼方便的貼圖!看來可以找機會來幫苦力怕弄得更像一點了。
原來如此,果然是需要更厲害一些的工具來幫忙,有打算後面來試著載入一些現成的場景或物件,感謝 marlin 大大提供資訊,很有幫助。
謝謝你的分享
我有發現這邊只要對creeper操作就可以了
因為父階層變動,底下的階層都會被影響
// 苦力怕走動及轉身補間動畫
const onUpdate = () => {
// 移動
creeperObj.creeper.position.x = offset.x
creeperObj.creeper.position.z = offset.z
// 轉身
if (target.x > 0) {
creeperObj.creeper.rotation.y = offset.rotateY
} else {
creeperObj.creeper.rotation.y = -offset.rotateY
}
}