昨天找到彩蛋會跳出提示框、也不會重複觸發了,但「找到」本身沒有任何成績感。今天加三個小功能:
有兩種選擇:
這篇選第二種。如果你想改成第一種,把 startTimer() 從點擊事件裡搬到 loader.load 的 callback 最後一行就好。
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:讓每個數字都佔同樣寬度的格子,來解決上述問題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();
}
逐段說明:
為什麼不是「每秒加 1」,而是記錄起點時間再相減: 如果寫成 setInterval 每秒把秒數加 1,這個加法的準確度完全依賴計時器每次都準時觸發,一旦哪一次晚了,就永遠少算。記下起點的時間戳記,每次顯示時用「現在時間減起點」,計時器晚一點觸發只會讓畫面晚一點更新,數字本身永遠是對的。
setInterval(updateHud, 250) 設成每 0.25 秒更新一次,也就是 1 秒更新 4 次。
遊戲畫面只顯示到「秒」,一秒內文字根本不會變,更新得再頻繁(例如每 10 毫秒一次)只是白白浪費運算。但也不設成每秒一次,因為更新的時間點剛好落在整秒邊緣時,只要計時器和 Date.now() 差一點點(例如讀到 999 毫秒),Math.floor 之後還是上一秒,畫面就要再多等一整秒才換數字;設成 250,最多晚 0.25 秒就會修正,是為了讓秒數跳動不要因為更新時間點剛好卡在邊緣而延遲,不需要更緊密的更新,因為畫面只顯示到「秒」。
import { startTimer, updateEgg, markEggFound, resetTimer, isFinished } from './timer.js';
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}`);
}
}
});
兩個細節:
event.target !== renderer.domElement:按「重新開始」的那一下,點擊事件也會一路傳到 window。這道檢查讓「點在畫布以外的地方」都直接略過,不會被誤判成在畫面上找彩蛋,也不會在按按鈕的瞬間就開始計時。全部找到之後,要做到兩件事:
HTML(加在 <div id="hud"> 之後):
<div id="result">
<div class="result-card">
<h2>🎉 全部找到了!</h2>
<p id="result-time"></p>
<button id="restart-btn">重新開始</button>
</div>
</div>
#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;
}
內容見步驟三,這裡不重複貼。
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 上
減少程式碼佔了很大版面的情形。