iT邦幫忙

2026 iThome 鐵人賽

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

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

Day21. 程式碼整理 (1/2)

  • 分享至 

  • xImage
  •  

目前遊戲的功能已經完整:載入模型、旋轉縮放、點彩蛋、計時、結算、重新開始。
但 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)

下層:四條執行流程。 這是圖最下面那四排,箭頭是呼叫順序,方塊的顏色和小字,代表這個函式在哪個檔案裡:

  1. 啟動:main.js 開始執行 → createScene() → enableResize() → startRenderLoop() → loadModel() → updateEgg()(每個彩蛋一次)→ frameObject()
  2. 點擊:click 事件 → startTimer() → pickObject() → findEggRoot() → markEggFound() → showToast()
  3. 全部找完:markEggFound() 回傳 allFound = true → finishGame() → 鎖住控制器 → showResult()
  4. 重新開始:按下按鈕 → 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

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 原始碼,有三個發現:

  1. 建立控制器時,建構函式會呼叫一次 handleResize()
  2. handleResize() 用 domElement.getBoundingClientRect() 量出畫布的位置和大小,存進 screen
  3. 旋轉和平移的滑鼠座標換算,要除以 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 的完整程式碼]

[待補:說明 loader.js 對外提供哪些函式、用到哪些外部套件、main.js 怎麼呼叫它]

[待補:確認實際的 loader.js,是否和上方圖、表裡的規畫一致,不一致的地方要回頭修改規畫]

四、ui.js:先完成 showToast()

ui.js 負責畫面上的提示框和結算面板。今天先完成第一個函式 showToast(),也就是 Day19 的提示框。

搬進 ui.js 之後的第一版

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 拿掉,即使第二則提示才剛出現沒多久。

ui.js 還沒做的部分

showResult()、hideResult()、onRestart() 這三個函式,以及結算面板用到的 #result、#result-time、#restart-btn,留到下一篇。

接下來

今天完成了規畫圖,以及 scene.js、loader.js 和 ui.js 的第一個函式。下一篇繼續:picking.js、ui.js 剩下的函式,以及整理 main.js

目前已經把程式碼放到 GitHub 上,今天的內容在 git sha: fab07012

參考連結


上一篇
Day 20. 計時與進度顯示: 增添遊戲感
下一篇
Day22. 程式碼整理 (2/2)
系列文
從空拍到3D展示 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言