iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
佛心分享-IT 人自學之術

從空拍到3D展示系列 第 20 篇

Day 20. 計時與進度顯示: 增添遊戲感

  • 分享至 

  • xImage
  •  

昨天找到彩蛋會跳出提示框、也不會重複觸發了,但「找到」本身沒有任何成績感。今天加三個小功能:

  1. 計時:畫面角落顯示經過的時間,找到彩蛋就停下來,提示框顯示「用了幾秒」
  2. 進度:顯示「已找到 / 總數」,目前場景只有 1 個彩蛋,所以是 0 / 1 變成 1 / 1
  3. 結算與重新開始:全部找到後,彈出結算畫面顯示用時,同時鎖住所有操作,直到按「重新開始」或重新整理頁面

決定計時從什麼時候開始

有兩種選擇:

  • 模型載入完成就開始:簡單,但玩家進入畫面後,沒有任何動作,時間都會算進去
  • 玩家第一次點擊畫面才開始:確認玩家一定有跟畫面互動後再開始

這篇選第二種。如果你想改成第一種,把 startTimer() 從點擊事件裡搬到 loader.load 的 callback 最後一行就好。

步驟一:計時與進度的顯示區(HTML + CSS)

HTML(加在 <body> 裡,放在 <div id="toast"> 之後):

<div id="hud">⏱ 00:00 🥚 0 / 0</div>

CSS(加進 style.css):

#hud {
  position: fixed;
  top: 16px;
  left: 16px;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  padding: 8px 14px;
  border-radius: 8px;
  font-size: 18px;
  font-variant-numeric: tabular-nums;
  pointer-events: none;
}
  • position: fixed 加 top、left:固定在視窗左上角,不會跟著頁面捲動
  • pointer-events: none:讓這個顯示區不擋住滑鼠,點到它下面的模型一樣有效
  • font-variant-numeric: tabular-nums:讓每個數字寬度一樣,詳見下一段

font-variant-numeric: tabular-nums 在做什麼

這行是外觀微調,不寫也不影響功能,他主要影響外觀;很多字型裡 1 比 0 窄,
可以看到像是 00:11 變成 00:12 的瞬間,整串文字的總寬度就差了一點點,顯示區的右邊緣會跟著微微左右移動。

依 MDN 的 font-variant-numeric 說明,這個屬性控制數字、分數、序數標記。它的 <numeric-spacing-values> 有兩個值:

  • proportional-nums:啟用「數字寬度不一定相同」的那組字形
  • tabular-nums:讓每個數字都佔同樣寬度的格子,來解決上述問題
    font-variant-numeric 的預設值是 normal,意思是不啟用這類替代字形,字形用原本的設計

步驟二:計時的邏輯

到目前為止,所有 JS 程式碼都寫在同一個檔案裡:場景、燈光、控制器、模型載入、視窗縮放、射線、提示框。今天的計時再加進去,這個檔案會更長。

所以今天特別把計時相關的程式碼拆出來,這樣未來要更動計時相關的功能,也可以更快知道在哪一段/份程式碼裡

timer.js 的內容

const hud = document.getElementById('hud');

let totalEggs = 0;
let foundEggs = 0;
let startTime = null;
let endTime = null;
let timerId = null;
let finished = false;   // ★ 是否已經全部找完

function elapsedMs() {
  if (startTime === null) return 0;
  return (endTime ?? Date.now()) - startTime;
}

function formatTime(ms) {
  const totalSec = Math.floor(ms / 1000);
  const m = String(Math.floor(totalSec / 60)).padStart(2, '0');
  const s = String(totalSec % 60).padStart(2, '0');
  return `${m}:${s}`;
}

function updateHud() {
  hud.textContent = `⏱ ${formatTime(elapsedMs())} 🥚 ${foundEggs} / ${totalEggs}`;
}

function stopTimer() {
  endTime = Date.now();
  clearInterval(timerId);
}

export function isFinished() {                 // ★ 讓主程式問「結束了嗎」
  return finished;
}

export function startTimer() {
  if (finished || startTime !== null) return;  // ★ 已結束或已開始,不重複開始
  startTime = Date.now();
  timerId = setInterval(updateHud, 250);
}

export function updateEgg() {
  totalEggs += 1;
  updateHud();
}

export function markEggFound() {
  if (finished || foundEggs >= totalEggs) return null;   // ★ 已結束或已達總數:什麼都不做
  foundEggs += 1;
  const allFound = foundEggs === totalEggs;
  if (allFound) {
    stopTimer();
    finished = true;                                     // ★ 全部找完,鎖住狀態
  }
  updateHud();
  return {
    foundEggs,
    totalEggs,
    allFound,
    seconds: (elapsedMs() / 1000).toFixed(1),
  };
}

export function resetTimer() {
  clearInterval(timerId);
  foundEggs = 0;
  startTime = null;
  endTime = null;
  timerId = null;
  finished = false;                                      // ★ 重新開始,解除鎖定
  updateHud();
}

逐段說明:

  • elapsedMs():算出經過了多少毫秒。還沒開始是 0;原則上會是現在的時間 - startTime;但如果發生結束過 (有了endTime),時間段就應該凍結在結束,因為遊戲已經結束,也就是時間段為 startTime ~ endTime
  • formatTime(ms):把毫秒轉成 mm:ss。padStart(2, '0') 是不足兩位數時,前面補 0,讓 5 變成 05。
  • updateHud():把時間和「已找到 / 總數」寫進畫面左上角。
  • stopTimer():記下結束時間、停掉重複更新的計時器,只在 timer.js 裡自己用,所以沒有 export。

為什麼不是「每秒加 1」,而是記錄起點時間再相減: 如果寫成 setInterval 每秒把秒數加 1,這個加法的準確度完全依賴計時器每次都準時觸發,一旦哪一次晚了,就永遠少算。記下起點的時間戳記,每次顯示時用「現在時間減起點」,計時器晚一點觸發只會讓畫面晚一點更新,數字本身永遠是對的。

setInterval(updateHud, 250) 設成每 0.25 秒更新一次,也就是 1 秒更新 4 次。
遊戲畫面只顯示到「秒」,一秒內文字根本不會變,更新得再頻繁(例如每 10 毫秒一次)只是白白浪費運算。但也不設成每秒一次,因為更新的時間點剛好落在整秒邊緣時,只要計時器和 Date.now() 差一點點(例如讀到 999 毫秒),Math.floor 之後還是上一秒,畫面就要再多等一整秒才換數字;設成 250,最多晚 0.25 秒就會修正,是為了讓秒數跳動不要因為更新時間點剛好卡在邊緣而延遲,不需要更緊密的更新,因為畫面只顯示到「秒」。

步驟三: import timer 的模組

import { startTimer, updateEgg, markEggFound, resetTimer, isFinished } from './timer.js';
  • 路徑要以 ./ 開頭、帶 .js 副檔名,是相對於主程式檔案的位置,MDN 的 JavaScript modules 說明的範例也是這種寫法
  • 同一份文件說明,import 和 export 只能用在 module 裡,所以 要有 type="module",注意 html 是否有設定 type="module"

步驟四:彩蛋總數的計算,在貼標籤時順便加

loader.load('blender_output.glb', (gltf) => {
  scene.add(gltf.scene);

  characterArmature = gltf.scene.getObjectByName('CharacterArmature');
  if (characterArmature) {
    characterArmature.userData.isEgg = true;
    characterArmature.userData.found = false;
    updateEgg();  // 貼一個標籤,總數就加 1,畫面顯示 0 / 1
  }

  // ...(Box3、相機定位、controls.target 都不動)
});

總數不寫死成 1,而是「每貼一個標籤就加 1」。之後如果在 Blender 多放幾個彩蛋,只要每個都貼標籤,顯示的總數就會自動跟著變。不過這次的 getObjectByName 只會找到名字相符的第一個物件,多個彩蛋時貼標籤的方式需要再調整,那是之後的事,今天只有一個。

步驟五:整合進點擊事件

window.addEventListener('click', (event) => {
  if (isFinished()) return;                           // 已經結束,整個點擊事件都不處理
  if (event.target !== renderer.domElement) return;   // 只處理點在畫布上的點擊

  startTimer();    // 第一次點擊才開始計時(之後再呼叫會直接跳過)

  // 1. 把滑鼠的像素座標,換算成 NDC
  pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
  pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;

  // 2. 從相機、往滑鼠指的方向,射出這條線
  raycaster.setFromCamera(pointer, camera);

  // 3. 問這條線,打中了 scene 裡的哪些東西
  const intersects = raycaster.intersectObjects(scene.children, true);

  if (intersects.length > 0) {
    const hit = intersects[0].object;
    const eggRoot = findEggRoot(hit);

    if (!eggRoot) return;                             // 點到公園本體

    if (eggRoot.userData.found) {
      console.log('這個彩蛋已經找過了');
      return;
    }

    const result = markEggFound();
    if (!result) return;

    eggRoot.userData.found = true;                    // 成功計入之後才標記

    if (result.allFound) {
      finishGame(result.seconds);                     // 全部找完:結算畫面
    } else {
      showToast(`🥚 已找到 ${result.foundEggs} / ${result.totalEggs}`);
    }
  }
});

兩個細節:

  1. event.target !== renderer.domElement:按「重新開始」的那一下,點擊事件也會一路傳到 window。這道檢查讓「點在畫布以外的地方」都直接略過,不會被誤判成在畫面上找彩蛋,也不會在按按鈕的瞬間就開始計時。
  2. marEggFound 在發現總數已達到 (遊戲結束) 回傳 null, 所以 result 為 null 時,直接 return;否則就會處理找到的流程

步驟六:結算畫面與重新開始

全部找到之後,要做到兩件事:

  1. 顯示總花費時間
  2. 並且鎖住所有操作,直到玩家按「重新開始」或重新整理頁面。

HTML(加在 <div id="hud"> 之後):

index.html

<div id="result">
  <div class="result-card">
    <h2>🎉 全部找到了!</h2>
    <p id="result-time"></p>
    <button id="restart-btn">重新開始</button>
  </div>
</div>

style.css

#result {
  position: fixed;
  inset: 0;                      /* 上下左右都貼齊,蓋滿整個視窗 */
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.6);
  opacity: 0;
  pointer-events: none;          /* 沒顯示時不擋住下面的畫布 */
  transition: opacity 0.4s ease;
}
#result.show {
  opacity: 1;
  pointer-events: auto;          /* 顯示時才接收滑鼠,按鈕才按得到 */
}
.result-card {
  background: #fff;
  color: #222;
  padding: 32px 48px;
  border-radius: 16px;
  text-align: center;
}
.result-card button {
  margin-top: 16px;
  padding: 10px 24px;
  font-size: 18px;
  border: none;
  border-radius: 8px;
  cursor: pointer;
}

timer.js(★ 今天新檔案)

內容見步驟三,這裡不重複貼。

model.js

const resultPanel = document.getElementById('result');
const resultTime = document.getElementById('result-time');
const restartBtn = document.getElementById('restart-btn');

function finishGame(seconds) {
  controls.enabled = false;                           // 鎖住控制器
  resultTime.textContent = `用時 ${seconds} 秒`;
  resultPanel.classList.add('show');
}

function restartGame() {
  scene.traverse((obj) => {
    if (obj.userData.isEgg) obj.userData.found = false;   // 所有彩蛋改回「還沒找到」
  });
  resetTimer();                                       // 計數、時間歸零,同時把 finished 改回 false
  resultPanel.classList.remove('show');
  controls.enabled = true;
  //controls.reset();    // 只針對 TrackballControls 做 reset,不過我實測起來,畫面會比一開始 zoom-in 很多 
  location.reload(); // 模型會整個重新載入
}

restartBtn.addEventListener('click', restartGame);    // 按鈕被點擊時,執行 restartGame

功能到這裡差不多完整了,目前很多功能都寫在同一個檔案的內容,
之後會想要多拆幾個檔案,提高可讀性和之後的維護性。然後也會把程式碼放到 GitHub 上
減少程式碼佔了很大版面的情形。


上一篇
Day19. 找到彩蛋的客製化視窗 & 防止重覆觸發
下一篇
Day21. 程式碼整理 (1/2)
系列文
從空拍到3D展示 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言