[JavaScript] 用 setTimeout 製作遊戲 buff
最近做小遊戲,碰到了一個以前學網頁時沒碰過的需求,player 吃到加速物件時,可獲得一個加速 buff 持續一個固定時間。這個在遊戲中很常見的玩意,沒想到一下去寫看看就撞壁,所以這邊稍微研究記錄一下。
最直覺的想法是,當吃到加速物件後,依設計不同,看是改變 role 的速度或是 background animation 的速度,然後下一個 setTimeout 在一定時間之後把速度還原回去。(這邊不討論吃到物件的碰撞判定)
細節忽略掉,大致會是這樣的感覺:
onHit(() => {
if (isSpeedUpObj) {
role.setSpeedQuick() // 3sec 後還原速度
setTimeout(() => {
role.setSpeedNormal()
},3000)
}
})
但是這樣會無法處理「多次觸發」的情況。如果在 3 秒內連續吃到 2 次以上加速物件時,時間計算上就會出現問題。
這邊為了方便測試,在 Web 上寫了一個簡單的頁面來模擬上述行為。
頁面上另外做一個計時器,來觀察時間。按下 action 按鈕代表吃到 buff,右手邊的方塊會從藍色變成紅色,表示處在「加速狀態」,並在 3 秒後變回來。
測試一下可以發現,單純的在 3 秒內只 click 一次,是沒什麼問題,但如果連續觸發的話就會壞掉。
這是由於 setTimeout 被登錄了數次,在單位時間過後那些被註冊上去的 callback 依序觸發所以導致。
可更新的 setTimeout
要解決這個問題,最理想的是每次事件觸發時,就更新 setTimeout 的 duration,而不是建立一個新的。但這個操作目前 JavaScript 是無法做到的,已經發生的事情就是發生了,沒有辦法對其進行 update 的操作。
但是砍掉重建是可以做的,我們有 clearTimeout 這個 API 可以用。
所以我們可以檢查當前是否有尚未執行的 setTimeout,如果有就砍掉他重建一個新的,如此就能解決重複觸發的問題。
而建立一個新的,duration 要如何設定呢? 這邊又會區分出兩種情況了。
1. 覆蓋
2. 疊加 (延長)
覆蓋 setTimeout
覆蓋的情況,比較符合一般遊戲中的 buff / debuff 設計,例如一個攻擊力加乘的 buff 持續 8 秒,在 8 秒的持續期間內重複觸發的話,通常都會採用「覆蓋」的操作,使其重新計算 8 秒,而不是疊加。
這種在處理上相對容易許多,我們不需要去檢查前一個 duration 跑多少,只需要把前一個 timeout 給無腦清掉,重建一個即可。
為了記錄當前是否存在 timeout,就需要用一個變數去儲存它。由於結構相對單純,可以用閉包來處理,創建一個會自己覆蓋的 setTimeout 函式。
function createUniqueSetTimeout() {
// 用來記錄 timeout
let timer = null return (cb, ms) => {
// 如果已存在,就 clear
if (timer) { clearTimeout(timer) } timer = setTimeout(() => {
// cb 觸發時清空
timer = null
cb()
}, ms)
}
}
在使用上只要用此函式來取代 setTimeout 即可。
const uniqueSetTimeout = createUniqueSetTimeout()onHit(() => {
if (isSpeedUpObj) {
role.setSpeedQuick() // 3sec 後還原速度
uniqueSetTimeout(() => {
role.setSpeedNormal()
}, 3000)
}
})
然後跟前面一樣,改寫原本的那個 Web Page 來做測試。
疊加 setTimeout
疊加的情況就比較麻煩,實際寫起來不是很好測試,腦袋不清醒的話也容易撞 bug。跟覆蓋一樣,需要去檢查當前是否存在尚未執行的 timeout,如果存在,除了清掉它之外,還得要去檢查 duration 跑多少了,然後建一個新的,並把時間疊加上去。
例如說,一個 8 秒的 buff,在第 5 秒的時候重複觸發,那清 buff 的時間就應該是 (8 - 5) + 8,也就是要重建一個為期 11 秒的 setTimeout。
為了做到這個操作,除了得紀錄 timeout 是否存在之外,還得紀錄 total time與 start time,才能完成疊加的公式。
(原本的總時長 - 經過時間) + 持續時間 = 新的總時長
要做的事情比較多,所以這邊用 class 來處理。
class TimeTool {
#cb = null
#totalTime = 0
#startTime = 0
#timer = null setTimeoutOrAddTime(cb, ms) {
this.#totalTime = this.#totalTime || ms
this.#cb = cb if (this.#timer) return this.#addTime(ms)
this.#setTimeout(cb, ms)
} #setTimeout(cb, ms) {
this.#timer = setTimeout(() => {
this.#timer = null
this.#totalTime = 0
this.#startTime = 0
cb()
}, ms); this.#startTime = this.#startTime || Date.now()
} #addTime(ms) {
clearTimeout(this.#timer) // add time
const pastTime = Date.now() - this.#startTime
const duration = (this.#totalTime - pastTime) + ms // update totalTime
this.#totalTime += ms this.#setTimeout(this.#cb, duration)
}
}
這個 class 儲存了計算 durtation 所需的屬性,以及 timeout 本身,並有 3 個 method。
setTimeoutOrAddTime 是主要使用的 method,如字面意思如果當前不存在讀秒中的 timeout 就直接建立新的,如果已存在就進行疊加的計算。
#setTimeout 則是不開放的私有 method,除了設置 setTimeout 之外,同時記錄下開始時間,callback 觸發後清空所有屬性。
#addTime 就是用來計算 duration 的,一進來先 clear 舊的 timeout,計算完新的時長之後,扔給 #setTimeout 建立一個新的。
透過 class 的包裝,使用上也非常的簡單方便。
const timeTool = new TimeTool()onHit(() => {
if (isSpeedUpObj) {
role.setSpeedQuick() // 3sec 後還原速度
timeTool.setTimeoutOrAddTime(() => {
role.setSpeedNormal()
}, 3000)
}
})
當然,如果不希望 new 出一個 instance,也可以改寫成靜態的,或是直接寫成 JS Object 都是可以的。
最後拿回那個 Web Page 進行改寫 / 測試,結果如下。
單次 buff 的持續時間目前設定為 3 秒。可以看到,如果點了兩下就會結束在 6 秒,點了三下就會結束在 9 秒,疊加順利的運作。
註記:
setTimeout 本身就會有誤差,所以有時候結束時間會稍微偏差一點。