目前遊戲的功能已經完整:載入模型、旋轉縮放、點彩蛋、計時、結算、重新開始。
但 model.js 一個檔案裡包山包海:場景設定、模型載入、點擊判斷、提示框、結算面板,全部混在一起,要找某一段程式碼得往下翻很久。
接下來要做的是程式碼分離:把程式碼依功能拆成幾個檔案,讓它更好維護、更好閱讀。今天先把整體的拆法畫成一張圖,作為接下來幾天的規畫,並完成其中三個檔案。

這張圖是規畫,不是現況。實作過程中如果發現不合理,會調整圖,今天的進度在這一節最後面。
上層:入口。 index.html 用 <script type="module"> 載入 src/main.js,main.js 是整個程式的進入點,負責「組裝」:建立場景、載入模型、綁定點擊事件和重新開始按鈕,遊戲規則(點到彩蛋要做什麼、找完怎麼收尾、重新開始怎麼重設)也寫在這裡。
中層:五個模組。 main.js 往下呼叫五個模組,每個方框列出三件事:這個檔案負責什麼、對外提供哪些函式(export)、用到哪些外部東西。實線框是我們自己的檔案,虛線框是外部套件(three、TrackballControls、GLTFLoader),黃底是 HTML 裡的元素(#toast、#result、#hud 等)。
| 檔案 | 負責什麼 | 對外提供 | 依賴 |
|---|---|---|---|
main.js |
組裝,以及遊戲規則 | 無(程式進入點) | scene、loader、picking、ui、timer |
scene.js |
場景、相機、renderer、燈光、控制器、視窗縮放、動畫迴圈,以及模型載入後把相機對準模型 | createScene()(回傳 { scene, camera, renderer, controls })、enableResize()、startRenderLoop()、frameObject() |
three、TrackballControls |
loader.js |
載入 .glb、幫彩蛋貼標籤 |
loadModel() |
GLTFLoader |
picking.js |
射線拾取,並判斷點到的是不是彩蛋 | pickObject()、findEggRoot() |
three |
ui.js |
提示框、結算面板、重新開始按鈕的綁定 | showToast()、showResult()、hideResult()、onRestart() |
無(只操作 HTML 元素) |
timer.js |
計時、彩蛋計數、結束狀態 | startTimer()、updateEgg()、markEggFound()、resetTimer()、isFinished() |
無(只操作 #hud) |
下層:四條執行流程。 這是圖最下面那四排,箭頭是呼叫順序,方塊的顏色和小字,代表這個函式在哪個檔案裡:
main.js 開始執行 → createScene() → enableResize() → startRenderLoop() → loadModel() → updateEgg()(每個彩蛋一次)→ frameObject()
click 事件 → startTimer() → pickObject() → findEggRoot() → markEggFound() → showToast()
markEggFound() 回傳 allFound = true → finishGame() → 鎖住控制器 → showResult()
restartGame() → 重設彩蛋的 found → resetTimer() → hideResult() → 解鎖控制器規則一:只有 main.js 同時認識所有模組,其他模組之間不互相 import。 圖左上角寫的就是這條。「認識」的意思就是 import 它。
如果模組之間互相 import,很容易出現循環依賴:A 檔案 import B,B 又 import A,兩個檔案纏在一起,要單獨理解其中一個就很困難。所以需要跨模組協作的事,一律由 main.js 當中間人。舉例:結算時要鎖住控制器、又要顯示結算面板,如果讓 ui.js 去鎖控制器,它就得認識 scene.js;現在改成 main.js 的 finishGame() 先設定 controls.enabled = false,再呼叫 ui.js 的 showResult(),ui.js 完全不用知道控制器的存在。
規則二:兩種路徑,基準不同。
import 的路徑,是相對於寫這行 import 的檔案。路徑要以 ./ 開頭、帶 .js 副檔名,MDN 的 JavaScript modules 說明的範例是這種寫法。'blender_output.glb')是相對於 index.html,不是相對於 loader.js。依 web.dev 的說明,import 以外的動態請求(像 fetch)是相對於文件解析的,不是相對於 JS 檔;three r160 的 FileLoader 就是用 fetch 載入檔案。所以 JS 檔搬進 src/ 資料夾之後,模型路徑不用改。| 檔案 | 狀態 |
|---|---|
timer.js |
Day20 已完成 |
scene.js |
今天完成 |
loader.js |
今天完成 |
ui.js |
今天完成第一個函式 showToast(),showResult()、hideResult()、onRestart() 還沒做 |
picking.js |
還沒做 |
main.js |
還沒整理完 |
scene.js 負責所有「建立畫面」需要的東西。對外提供四個函式:
| 函式 | 做什麼 |
|---|---|
createScene() |
建立場景、相機、renderer、燈光、控制器,並回傳這四個物件 |
enableResize() |
視窗大小改變時,更新相機和畫布 |
startRenderLoop() |
啟動動畫迴圈:每一幀更新控制器,再把畫面畫出來 |
frameObject() |
模型載入完成後,把相機對準模型 |
createScene()scene、camera、renderer、controls 是在 createScene() 裡面用 const 宣告的。宣告在函式裡面的變數,只有那個函式裡面看得到,函式執行完,外面就拿不到了,這跟 Day15 提過的「controls 要建在載入 callback 的外面」是同一個概念。main.js 後面要用這四個物件(載入模型要 scene、射線要 camera、判斷點擊要 renderer、結算要鎖 controls),所以 createScene() 要用 return 把它們交出去:
return { scene, camera, renderer, controls };
main.js 用解構指派一次接住:
const { scene, camera, renderer, controls } = createScene();
大括號的意思是:把回傳物件裡「同名的屬性」取出來,各自變成一個變數。
依 MDN 的逗號運算子說明,逗號運算子會由左到右計算每個運算元,只回傳最後一個。所以這行實際只回傳 controls,main.js 解構之後,scene、camera、renderer 都會是 undefined。要回傳多個值,就要包成物件。
enableResize() 與 startRenderLoop():為什麼是獨立的函式視窗縮放和動畫迴圈,原本寫在 model.js 裡。拆檔時,我決定讓它們也放進 scene.js,但各自是獨立的函式,不寫在 createScene() 裡面:
createScene() 只負責「建立並回傳」,名字和它做的事一致main.js 決定因為這兩個函式要用到的 camera、renderer、controls,是 createScene() 的區域變數,所以改成用參數傳進去:
export function enableResize(camera, renderer, controls) { /* ... */ }
export function startRenderLoop(scene, camera, renderer, controls) { /* ... */ }
enableResize() 的第三個參數 controls 目前沒有用到,因為 controls.handleResize() 那行還是註解。依 TrackballControls 的官方文件,handleResize() 的說明是視窗縮放時必須呼叫(Must be called if the application window is resized),但 Day16 實測拿掉這行,操作上看不出差異,所以目前維持註解。先保留參數,是之後決定放回去時,函式的寫法不用再改。
frameObject():模型載入後的取景模型載入完成後,用 Box3 量出模型的中心和大小,把相機退到模型外面、對準中心,並讓控制器繞著中心旋轉。這就是 Day14、Day15 寫在 loader.load callback 裡的那一段,搬進 scene.js 包成函式,因為它操作的是 camera 和 controls,屬於 scene.js 管的東西。不寫死座標,是因為換一個模型,大小和中心就不一樣。
scene.js 寫好、換上去之後,畫面載入正常,但拖曳不能旋轉。
實驗: 在 main.js 的 createScene() 後面,印出控制器的 screen:
const { scene, camera, renderer, controls } = createScene();
console.log(controls.screen);
結果:
{left: 0, top: 0, width: 0, height: 0}
這個數字代表什麼? 依 three.js 官方文件,screen 記錄的是畫面的屬性,會在呼叫 handleResize() 時自動設定。查 r160 的 TrackballControls 原始碼,有三個發現:
handleResize()
handleResize() 用 domElement.getBoundingClientRect() 量出畫布的位置和大小,存進 screen
screen.width 和 screen.height
所以 screen 的寬高是 0,拖曳的座標換算就無法正常運作。
那為什麼量到的是 0? 依 MDN 的 getBoundingClientRect 說明,如果元素所有的邊框盒都是空的,回傳的矩形寬高為 0。我的 createScene() 是這個順序:
const renderer = new THREE.WebGLRenderer();
const controls = new TrackballControls(camera, renderer.domElement); // ① 先建立控制器
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement); // ② 才放進頁面
修法: 把 setSize 和 appendChild 搬到建立控制器之前:
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new TrackballControls(camera, renderer.domElement);
這個坑的特別之處是:程式碼的內容完全沒變,只是搬進函式之後順序變了,就壞了,而且沒有任何錯誤訊息,畫面只是轉不動。這也是 Day16 實測「拿掉 handleResize() 沒有差異」的另一個線索:screen 只在建立控制器、以及呼叫 handleResize() 時更新,只要建立時順序對,沒縮放視窗就看不出差別。
[待補:loader.js 的完整程式碼]
[待補:說明 loader.js 對外提供哪些函式、用到哪些外部套件、main.js 怎麼呼叫它]
[待補:確認實際的 loader.js,是否和上方圖、表裡的規畫一致,不一致的地方要回頭修改規畫]
ui.js 負責畫面上的提示框和結算面板。今天先完成第一個函式 showToast(),也就是 Day19 的提示框。
export function showToast(message, duration = 2500) {
const toast = document.getElementById('toast');
let toastTimer;
toast.textContent = message;
toast.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.classList.remove('show');
}, duration);
}
這一版放進去看起來沒問題,但它和 Day19 原本的寫法有一個差別:toastTimer 被宣告在函式裡面了。原本 toast 和 toastTimer 是寫在函式外面的。
宣告在函式裡面的變數,每次呼叫函式都會重新建立一個新的,上一次呼叫留下的值不會保留:
function counter() {
let n = 0;
n += 1;
return n;
}
console.log(counter(), counter()); // 1 1,不是 1 2
showToast() 的 clearTimeout(toastTimer) 想做的事是:如果提示框還在顯示,又被叫出來一次,就先取消上一次的倒數計時,避免上一次的計時器提早把提示框關掉。但 toastTimer 在函式裡,第二次呼叫時它又是一個全新的、沒有值的變數,clearTimeout 取消不到上一次的計時器。
結果是:第一則提示的倒數,照樣會在時間到的時候把 show 拿掉,即使第二則提示才剛出現沒多久。
showResult()、hideResult()、onRestart() 這三個函式,以及結算面板用到的 #result、#result-time、#restart-btn,留到下一篇。
今天完成了規畫圖,以及 scene.js、loader.js 和 ui.js 的第一個函式。下一篇繼續:picking.js、ui.js 剩下的函式,以及整理 main.js
目前已經把程式碼放到 GitHub 上,今天的內容在 git sha: fab07012