iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
JavaScript

QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》系列 第 17 篇

Day17|計時器:地雷、定時炸彈,和我決定不用它的理由

  • 分享至 

  • xImage
  •  

今天的主題:計時器——第一次寫出「不需要人操作也會發生」的程式

前面十六天,我寫的程式全都在等人。按下按鈕才攻擊、輸入名字才出征——沒人動它,它就安靜地躺著。今天學的東西不一樣:它自己會動。

地雷和引線炸彈

學完之後我想到一個比喻,剛好把前後的差別講清楚:

誰決定觸發時機 對應
地雷 玩家(踩了才爆) addEventListener
定時炸彈 時鐘(時間到就爆,沒人踩也爆) setTimeout
連環炸彈 時鐘,而且會一直爆下去 setInterval

前十六天我埋的全是地雷。今天第一次出現「不需要任何人操作,自己就會發生的事」。

setTimeout(函式, 毫秒);    // 等一段時間,執行一次
setInterval(函式, 毫秒);   // 每隔一段時間,重複執行

裡面那個 () => { ... } 是箭頭函式,今天的新語法:

function () { console.log("嗨"); }   // 舊寫法
() => { console.log("嗨"); }         // 箭頭函式,同一件事

就是「沒有名字的函式」,用在只寫一次、懶得取名的場合。

第一個發現:計時器不會卡住程式

console.log("A");
setTimeout(() => { console.log("B(1 秒後)"); }, 1000);
console.log("C");

輸出是 A → C → B(1 秒後)。

插入 day17.png:A、C 先出現,B 一秒後才出現

setTimeout 不是「停在這裡等一秒」,而是預約一件事,然後程式立刻往下跑。我猜對了這題,因為前面幾天已經習慣「程式不會停下來等」的思路。

第二個發現:我猜錯了,而且錯得剛剛好

console.log("1");
setTimeout(() => { console.log("2(延遲 0 毫秒)"); }, 0);
console.log("3");

延遲設成 0 毫秒。我猜輸出會是 1 → 2 → 3——既然不用等,那它應該就在原地執行吧?

實際結果是 1 → 3 → 2。

插入 day17-2.png:輸出順序是 1、3、2

原因是 JavaScript 是單執行緒的(Day 7 那次無窮迴圈讓整個分頁卡死,就是這件事)。setTimeout 真正的意思不是「等 N 毫秒然後插隊」,而是:

把這件事放到待辦清單的最後面。等我把現在手上這段程式從頭跑完,再去看清單。

所以 0 毫秒的意思是「越快越好,但一定要等現在的工作做完」。它永遠不可能插進 1 和 3 中間。

用炸彈的比喻:setTimeout(fn, 0) 是一顆引線長度 0 的炸彈,但拆彈小組手上還有事,要等他們忙完才會過來點火。

這個機制叫事件迴圈(event loop),是 JavaScript 最核心的概念之一。我用三行程式和一個錯誤的猜測撞出了它。

而且它有實際用途:要做一件很重的計算時,可以用 setTimeout(..., 0) 把它丟到清單後面,讓畫面先更新完再開始算——不然畫面會卡住,就像 Day 7 那次。

setInterval 一定要有人剪線

let count = 3;
const timerId = setInterval(() => {
  console.log("倒數:", count);
  count = count - 1;
  if (count === 0) {
    clearInterval(timerId);
    console.log("時間到!");
  }
}, 1000);

插入 day17-3.png:倒數 3、2、1,然後時間到

我一開始以為這個炸彈會「自己炸完」,但其實不是——setInterval 沒有次數上限,不會自然結束。上面那個 if 做的事還是剪線,只是換成炸彈自己剪自己。

結局永遠只有一種:有人剪線。差別只在於是外面的人剪,還是它自己剪。

這個認知很重要,因為它對應一個真實的 bug:如果 count 因為某個原因跳過了 0(例如變成 -1),那個 if (count === 0) 永遠不成立,炸彈就永遠停不下來。寫成 count <= 0 會安全一點——Day 4 的邊界值問題又出現了。

timerId 是號碼牌,不是我想的那個 3

執行時我順手印了 timerId,結果是 3。當時倒數也剛好從 3 開始,我一度以為它們有關係。

其實完全是巧合。setInterval 回傳的是瀏覽器發的一張號碼牌,從 1 開始遞增。我前面做了兩個 setTimeout 練習,所以這個拿到 3 號。

用炸彈比喻:號碼牌就是拆彈時要報的編號,clearInterval(timerId) 等於說「請拆掉 3 號炸彈」。沒有號碼牌就拆不了,這也是為什麼一定要用變數把它接住。

還有一個細節:那個 3 是立刻印出來的,而「倒數: 3」是一秒後才出現。因為 setInterval 做的事只是登記炸彈、發號碼牌,然後馬上返回。

忘記剪線的後果,比當機更可怕

let n = 0;
setInterval(() => {
  n = n + 1;
  console.log("我停不下來:", n);
}, 1000);

插入 day17-4.png:數到 11 還在跑,畫面一切正常

數到 11 還在跑,而且畫面完全正常、沒有任何錯誤訊息。

這跟 Day 7 那次無窮迴圈完全不同。那次是當機:風扇狂轉、網頁卡死、瀏覽器跳出警告——大聲失敗,你一定會發現。今天這個是在背景安靜地永遠執行,沒有任何症狀。

又一個安靜失敗,而且是最陰險的一種。如果那個函式裡有耗資源的操作(打 API、重算畫面),使用者的電腦會慢慢變慢,而且找不到原因。

還有一個延伸的坑:如果玩家連按兩次「開始倒數」,而程式每次都呼叫一次 setInterval,你就埋了兩顆炸彈,但只記得住最後一顆的號碼牌。第一顆會永遠爆下去,沒人拆得掉。現象是「倒數突然變兩倍快」——這在真實世界很常見。

然後我決定:這個遊戲不用計時器(至少不用在規則上)

學完之後,骨架上留給我的問題是:這個遊戲要不要用計時器?

我列了幾個可能的用途,然後發現一個判斷標準:我做的是回合制遊戲,而回合制裡「時間」的單位是回合,不是秒。

想法 決定 理由
中毒狀態每秒扣血 ❌ 不做 這是即時制的機制,放進回合制會很怪
小怪反擊延遲 0.5 秒 ❌ 不做 同上,而且那 0.5 秒玩家能不能操作又是一個問題
限時挑戰(倒數計時) ❌ 改設計 見下一段
受擊震動、血量閃爍 ✅ 之後做 這是表現,不是規則

整理成一句話就是:

計時器用在「表現層」沒問題,用在「規則層」會破壞回合制。

震動、閃爍、淡出這些效果不影響遊戲規則,只是讓玩家看得更清楚;而中毒扣血、反擊延遲會讓「秒」變成遊戲的一部分,那就不是回合制了。

限時挑戰:我想做,但我自己先否決了它

我原本想做一個隱藏關卡:通關之後可以挑戰「最快通關時間」,倒數計時,時間到沒打完算失敗。

但寫下來的當下我就擔心一件事:這樣最後會不會變成比誰點得快?

想了一下,答案是會。而且解法就藏在我自己前面說的話裡——這是回合制遊戲,所以挑戰的單位不該是秒,而是回合:

設計 玩家實際在比什麼
最快通關時間 手速
最少回合通關 運氣 + 策略

「用最少回合打倒小怪」:傷害有浮動(Day 16 加的),所以每場的最少回合數天生不同;之後如果加了技能或道具,就變成策略選擇。而且點快點慢完全一樣,手速在這裡沒有意義。

這才是回合制遊戲該有的挑戰形式。存最高紀錄需要把資料留住,那是 Day 20 的 localStorage,所以這個隱藏關卡我留到那之後再做。

小結

今天搞懂的事:

  • setTimeout 預約一次、setInterval 重複執行;兩者都不會卡住程式
  • 箭頭函式 () => {} 是沒有名字的函式
  • setTimeout(fn, 0) 不會插隊,它排在待辦清單最後面——這就是事件迴圈
  • setInterval 不會自己停,一定要有人 clearInterval;它回傳的號碼牌要接住才拆得掉
  • 忘記清除的計時器是安靜失敗:沒有錯誤、沒有當機,只是在背景永遠跑
  • 重複呼叫 setInterval 會疊出多顆停不掉的炸彈
  • 計時器用在表現層可以,用在規則層會破壞回合制
  • 回合制遊戲的挑戰單位應該是回合數,不是秒數——不然比的是手速

這是第一次我學完一個東西,結論是「知道它能做什麼,然後決定不用」。感覺比硬塞進去踏實。

明天要學非同步——今天那個「排到清單最後面」的概念,明天會變成主角。


上一篇
Day16|亂數:一邊是六面骰,一邊是 AI,測試方法是同一套
下一篇
Day018|把散落的變數收進一個盒子
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言