[JavaScript] 用 setTimeout 製作遊戲 buff

Lastor
Code 隨筆放置場
7 min readOct 19, 2020

最近做小遊戲,碰到了一個以前學網頁時沒碰過的需求,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 本身就會有誤差,所以有時候結束時間會稍微偏差一點。

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。