# 光舞編輯器前端深度解析 – `index.html` × `main.js` × `dancer.js`
> **閱讀對象**:具備 HTML/CSS 與 JavaScript 基礎語法的高中生。
>
> **寫作風格**:第一人稱筆記,避開華麗詞藻,強調「…後」「…來」的流程連接詞;行文偏學生口吻,但保持條理。
---
## 0 先講我為什麼要拆三檔案
* **`index.html`** 是舞台外框,決定有哪些區塊、元素、熱鍵。
* **`main.js`** 像導演,統籌「音波 → 影像 → Python」。
* **`dancer.js`** 則是演員類別,每一個舞者都是它的實例。
把三者分開,改動時才不會互相牽拖。下面我一檔一檔解析。
---
## 1 `index.html` – HTML 架構 + CSS 造型
| 行數 | 重點片段 | 我怎麼讀 |
| --------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
| 1‑15 | `<!DOCTYPE html>` \~ `<meta charset>` | 先聲明 UTF‑8,避免中文亂碼。 |
| 6‑13 | `<script src="https://unpkg.com/wavesurfer.js@6.6.3">` 連同三個插件 | 我先換成 v6.6.3,因為 v7 marker API 變動太大。 |
| 20‑90 | `<style>` | 我用 CSS Grid 把版面切三欄:左工具列、中 Canvas、下波形;`--bg`、`--fg` 變數方便全站改色。 |
| \~110 | `<div id="waveform"></div>` | WaveSurfer 容器,一行就好,寬度交給 CSS。 |
| \~130 | `<canvas id="myCanvas" width="640" height="360"></canvas>` | 設 16:9 比例,後續 JavaScript 用 `ctx` 做 2D 繪圖。 |
| \~150 | `<input id="zoom-slider" type="range">` | 滑桿一開始隱藏;當使用者按 `Ctrl + 滾輪` 我再 `slider.style.display='block'`。 |
| \~160‑170 | `<script src="main.js"></script>` `<script src="dancer.js"></script>` | 載入順序要先 main.js,因為它會 new Dancer()。 |
### 1‑1 我在 CSS 做了哪些小技巧
1. **滑桿垂直翻轉** – `transform: rotate(-90deg);` 讓水平 `<input>` 變成上下拖拉。
2. **三欄自適應** – `grid-template-columns: 80px 1fr`,左側工具列固定 80px,中央跟著螢幕寬度伸縮。
3. **深色模式主題** – 用 `:root{--bg:#1e1e1e; --panel:#252525; --fg:#eee;}`,顏色集中管理。
> *如果只想改版面配色,動 CSS 就行,不用動 JS。*
---
## 2 `main.js` – 全局控制核心
我先列事件流程圖,再逐區塊拆碼。
```
使用者 → (鍵盤/滑鼠) → handleKeydown / handleWheel
↓ ↑
WaveSurfer --------------------> timeupdate
↓ ↑
QtWebChannel ← editor.py.receiveTime ← Python
```
### 2‑1 全域變數(1‑30 行)
```js
// main.js 行1‑12
export let N_DANCER = 0;
export let allLight = [];
export let frameTime = [];
export let posData = [];
export const colors = [];
let canvas = document.getElementById('myCanvas');
let ctx = canvas.getContext('2d');
```
* 我習慣先 `export` 以防其他檔案要引用(webpack 時最好用)。
* `canvas` 與 `ctx` 提前抓,之後 Dancer.draw 直接用。
### 2‑2 初始化流程
```js
// main.js 行40‑70
async function boot() {
await Promise.all([
initializeSettings(), // 抓 setting.json
initializeData(), // 抓 data.json
initializePosData() // 抓 pos.json
]);
createWaveSurfer(); // 波形+音樂
createDancers(); // 建立 Dancer 實例
startAnimation(); // 進入 60FPS loop
}
window.addEventListener('load', boot);
```
> *我用 `Promise.all` 確保三份 JSON 都到手,才開始畫面。* 一開始我沒等就 `createDancers()`,結果因為 `colors` 還沒填完導致 `undefined`,踩過一次坑。
### 2‑3 WaveSurfer 初始(80‑140 行)
```js
// WaveSurfer 核心
wavesurfer = WaveSurfer.create({
container: '#waveform',
height: 128,
backend: 'WebAudio',
waveColor: '#4fc3f7',
progressColor: '#039be5'
});
```
1. **plugins** – `regions.create({})`, `markers.create({})`, `minimap.create({})` 一次三個 plugin;`dragSelection` 記得設 `true` 才能拉區段。
2. **載音檔** – `wavesurfer.load('audio.mp3');` 路徑由 `setting.json.music` 指定。
3. **`timeupdate`** – 每幀 callback:
```js
wavesurfer.on('timeupdate', (sec)=>{
window.qt_handler.receiveTime(sec);
});
```
*我把秒數丟給 Qt*,讓 Python 知道現在在哪裡。
### 2‑4 動畫迴圈(150‑210 行)
```js
function loop() {
ctx.clearRect(0,0,canvas.width,canvas.height);
for (let i=0;i<N_DANCER;i++) dancers[i].draw(wavesurfer.getCurrentTime());
requestAnimationFrame(loop);
}
```
* 60 FPS 串成無限循環。
* `getCurrentTime()` 傳給每個 Dancer,讓他們自己決定該用哪個顏色幀。
### 2‑5 鍵盤與滑鼠互動(220‑310 行)
| 按鍵 | 行數 | 行為 |
| ------------------------ | --- | ----------------------------------------- |
| `Space` | 225 | play/pause toggle |
| `ArrowLeft`/`ArrowRight` | 235 | ±5 秒快退/快轉 |
| `<` `>` (`188` `190`) | 245 | ±0.1 秒微調 |
| `Ctrl + Wheel` | 260 | 更新 `zoom-slider` + 呼叫 `wavesurfer.zoom()` |
| 無修飾 Wheel | 275 | `wavesurfer.seekTo()` 左右捲動 |
> *這些熱鍵對照 editor.py `setup_shortcuts()`,保持跨端一致。*
---
## 3 `dancer.js` – 角色類別詳解
### 3‑1 類別雛形
```js
// dancer.js 行1‑40
export default class Dancer {
constructor(id, baseX, baseY) {
this.id = id;
this.x = baseX;
this.y = baseY;
this.width = 64;
this.height = 160;
}
// ...
}
```
### 3‑2 外部依賴
* `import { colors, allLight, frameTime, posData } from './main.js';`
* 直接共用 main.js 的全域陣列,避免重複 fetch。
### 3‑3 顏色取得流程
```js
getColor(segment, part) {
const colorIdx = allLight[this.id][segment][part];
return colors[colorIdx]; // colors = [[r,g,b], ...]
}
```
> *part 對照表:0/1 耳朵、2 頭、3‑6 上半身、…、12 鞋底,共 13 部。*
### 3‑4 `draw(time)` 渲染核心
1. **決定 segment**
```js
const seg = getTimeSegmentIndex(frameTime, time*1000);
```
2. **畫部位**
```js
ctx.fillStyle = this.rgbToCss(this.getColor(seg, 2)); // 頭
ctx.fillRect(this.x+20 , this.y ,24,24);
// ...對每部位重複
```
3. **若被選中 → 畫箭頭**
```js
if(selectedId === this.id) this.drawArrow();
```
### 3‑5 互動:被點擊與拖曳
```js
isClicked(mx,my){
return mx>this.x && mx<this.x+this.width && my>this.y && my<this.y+this.height;
}
move(dx,dy){ this.x+=dx; this.y+=dy; }
```
* 在 `main.js` 的 `canvas.onmousedown` 先檢查所有 Dancer 是否 `isClicked()`;若是,就鎖定 `draggingIndex`。
* `mousemove` 時呼叫 `dancers[draggingIndex].move(dx,dy)`,同時 `qt_handler.updatepos({...})` 回寫 Python。
---
## 4 三檔案聯合作業流程回顧
1. **載入** ‑ 瀏覽器開啟 → `index.html` 載 `main.js`、`dancer.js`。
2. **啟動** ‑ `boot()` 讀三個 JSON → 生成舞者 → `createWaveSurfer()`。
3. **播放** ‑ 使用者按 Space → WaveSurfer 播放音樂 → `timeupdate` 每 16ms 回報 → Qt 同步時間 → LED 服裝同步。
4. **編輯** ‑ 拖舞者 → `updatepos()` 寫 `pos.json` → Python 存檔 → 前端 `reloadDataAndRedraw()`。
5. **顏色改動** ‑ Python 側 ComboBox 改色 → `savejson()` → Firebase 觸發 → JS 拿到新 `data.json` → 畫面即時更新。
---
## 5 我踩過的兩個雷
1. **音檔 CORS 問題**
*第一次把 MP3 放 GitHub Pages 測,結果瀏覽器擋跨域。* 解法:同網域或在 headers 加 `Access-Control-Allow-Origin:*`。本地測試最穩。
2. **WaveSurfer v7 API 改動**
直接升 v7 會找不到 `markers.create()`,因為 plugin 改寫。若 marker 功能重要,先留在 v6。\*
---
## 6 實作建議
* **想讓音波左右捲動更滑順** → 把 `wheel` 事件改成 `requestAnimationFrame` 批次 seek,減少連續呼叫。
* **想支援 4K 螢幕** → `<canvas>` 用 `devicePixelRatio` 乘上寬高,再 `ctx.scale()` 回 1:1,避免模糊。
* **想加新版曲線走位** → 在 `dancer.js` 加貝茲曲線內插:`ctx.bezierCurveTo()` 畫軌跡,再丟控制點回 `pos.json`。
---
> 以上就是我對前端三檔案的完整拆解。若需要進一步 demo 或想要加上測試檔,留言給我,我再補。