diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..a15f051e5139fb8f86e8a8e5cc394660f9327037
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+FROM python:3.11-slim
+
+# Install system dependencies required by OpenCV headless and Pygame
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libglib2.0-0 \
+ libgl1-mesa-glx \
+ libgomp1 \
+ && rm -rf /var/lib/apt/lists/*
+
+# Set up environment variables
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ SDL_VIDEODRIVER=dummy \
+ SDL_AUDIODRIVER=dummy \
+ PORT=8000
+
+# Create working directory and copy dependencies
+WORKDIR /app
+COPY requirements.txt .
+
+# Install dependencies
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy application files
+COPY . .
+
+# Create directory for projects if it doesn't exist
+RUN mkdir -p /app/projects
+
+# Set permissions for Hugging Face Spaces compatibility (runs as UID 1000)
+RUN chmod -R 777 /app
+
+# Run server.py on port specified by environment
+CMD uvicorn server:app --host 0.0.0.0 --port $PORT
diff --git a/projects/.DS_Store b/projects/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..f243165f73d04cdc05874414cd3f5f0675611e41
Binary files /dev/null and b/projects/.DS_Store differ
diff --git a/projects/2026project_class/.DS_Store b/projects/2026project_class/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..d13b3e883e97ec8bedb0ddd7a7fd1b0a1c6d4f7a
Binary files /dev/null and b/projects/2026project_class/.DS_Store differ
diff --git "a/projects/2026project_class/doc/16\351\200\261\350\252\262\347\250\213\350\250\210\347\225\253.md" "b/projects/2026project_class/doc/16\351\200\261\350\252\262\347\250\213\350\250\210\347\225\253.md"
new file mode 100644
index 0000000000000000000000000000000000000000..62b71c732fa8e83ce28ba2cdd1658a685c442cf2
--- /dev/null
+++ "b/projects/2026project_class/doc/16\351\200\261\350\252\262\347\250\213\350\250\210\347\225\253.md"
@@ -0,0 +1,113 @@
+# **Python AI 智慧工具與網路互動專案:16週完整教學計畫**
+
+**適用對象**:國小六年級以上至國中(具備 Python 基礎與 Pygame 基本經驗)
+
+**教學時長**:每週 1 堂,每堂 120 分鐘
+
+**核心理念**:讓程式具備「連結世界」與「視覺感知」的能力,從單機遊戲轉向解決真實生活問題的智慧工具。
+
+## **第一階段:數據與介面 —— 實體數據看板 (第 1 \- 4 週)**
+
+**核心目標**:學習如何抓取網路上的真實數據,並將其視覺化呈現。
+
+### **第 1 週:引擎重啟 —— 從線性邏輯到即時循環系統**
+
+* **教學重點 (45 min)**:
+ * **線性 vs. 循環**:複習一般 Python 腳本(執行完畢即結束)與遊戲引擎(每秒 60 次不斷循環)的差異。
+ * **Pygame 四大架構**:深入解析「初始化、事件處理、邏輯運算、畫面重繪」的執行順序。
+* **實作任務 (60 min)**:製作一個「靈魂追隨者」,圓形會延遲追蹤滑鼠位置,並在點擊時產生顏色漸變。
+* **討論與總結 (15 min)**:為什麼遊戲如果不清空畫面 (screen.fill),物體移動會留下尾巴?
+
+### **第 2 週:數位服務生 —— requests 庫與網路 API**
+
+* **教學重點 (40 min)**:
+ * **什麼是 API?**:用餐廳點餐比喻 API (服務生)、伺服器 (廚房) 與客戶端 (你)。
+ * **JSON 格式解析**:學習如何從像字典一樣的 JSON 數據中提取我們需要的內容。
+* **實作任務 (65 min)**:使用 requests 抓取真實的 API (如天氣、冷笑話、寶可夢圖鑑、姓名猜測年齡),並在終端機輸出。
+* **進階任務 (15 min)**:學習處理錯誤代碼(例如 404 代表找不到,500 代表伺服器罷工)。
+
+### **第 3 週:數據視覺化 —— 打造個人智慧看板**
+
+* **教學重點 (40 min)**:
+ * **文字渲染技術**:如何在 Pygame 中加載中文字型並顯示動態變數。
+ * **數據與圖像聯動**:例如當天氣是「雨天」時,程式自動加載下雨的圖片。
+* **實作任務 (80 min)**:將上週抓取的數據顯示在畫面上,製作一個能顯示「今日氣溫、濕度、建議穿著」的視覺化視窗。
+
+### **第 4 週:專案實作 (一) —— 生活數據助理發表**
+
+* **專案開發 (100 min)**:學生自主選題(股市走勢、空氣品質、星座運勢),獨立開發一個具備實用功能的看板。
+* **成果分享 (20 min)**:演示自己的看板,並說明數據來源。
+
+## **第二階段:社交與競爭 —— 區域網路連線互動 (第 5 \- 8 週)**
+
+**核心目標**:理解網路通訊,讓兩台電腦不再孤立,實現同步互動。
+
+### **第 5 週:哈囉鄰居!Socket 通訊與 IP 基礎**
+
+* **教學重點 (45 min)**:
+ * **住址與門牌**:理解 IP 地址 (電腦的位置) 與 Port 端口 (程式的門牌)。
+ * **連線協定**:簡單說明 TCP (安全點名) 與 UDP (快速廣播) 的差別。
+* **實作任務 (60 min)**:編寫一個「班級秘密傳訊機」,讓 A 同學傳送文字,B 同學的視窗即時跳出訊息。
+
+### **第 6 週:數據的包裹 —— JSON 序列化與同步**
+
+* **教學重點 (45 min)**:
+ * **數據打包**:如何將座標 (x, y) 變成一個包裹 (JSON String) 寄給對方。
+ * **延遲與補償**:討論為什麼網路遊戲有時候會「閃現」或「Lag」。
+* **實作任務 (75 min)**:雙人同步移動練習——我在我的螢幕動,你的螢幕會同步出現我的影子。
+
+### **第 7 週:對戰邏輯 —— 誰打中了誰?**
+
+* **教學重點 (40 min)**:
+ * **碰撞判定同步**:學習如何在兩台電腦上達成共識(誰先被打中?)。
+ * **狀態管理**:分數與血量在網路間的即時更新。
+* **實作任務 (80 min)**:開發「網路版坦克對戰」雛形,實現發射子彈與受傷判定。
+
+### **第 8 週:專案實作 (二) —— 班級網路競技場**
+
+* **對戰賽事 (120 min)**:全班分組優化自己的遊戲,並進行連線測試與小組 PK,觀察網路通訊的穩定性。
+
+## **第三階段:智慧互動 —— AI 視覺核心應用 (第 9 \- 12 週)**
+
+**核心目標**:整合電腦視覺模型,實現「非接觸式」的新一代人機互動。
+
+### **第 9 週:數位眼睛 —— MediaPipe 視覺辨識入門**
+
+* **教學重點 (45 min)**:
+ * **座標的魔力**:介紹 AI 如何在 0.01 秒內找出手掌上的 21 個關鍵點。
+ * **影像與矩陣**:簡單說明攝影機畫面如何轉化為 Python 可以運算的數字。
+* **實作任務 (65 min)**:啟動鏡頭,並在視窗中將食指尖標示出一個「雷射紅點」。
+
+### **第 10 週:魔法手勢 —— 從座標到控制邏輯**
+
+* **教學重點 (45 min)**:
+ * **距離計算**:利用勾股定理計算大拇指與食指的距離,判斷「抓取」動作。
+ * **邏輯觸發**:將手勢與第一階段的看板結合,揮揮手就能翻頁。
+* **實作任務 (75 min)**:開發「虛擬畫板」,用手尖在空中畫畫,或用掌心控制視窗物件。
+
+### **第 11 週:訓練你的大腦 —— Teachable Machine 實戰**
+
+* **教學重點 (40 min)**:
+ * **機器學習流程**:蒐集數據、訓練模型、測試準確率。
+ * **模型匯出與匯入**:將訓練好的檔案放進 Python 中執行。
+* **實作任務 (80 min)**:訓練一個「資源回收分類器」,拿著空瓶給鏡頭看,程式會告訴你該丟哪一類。
+
+### **第 12 週:專案實作 (三) —— AI 生活工具箱**
+
+* **獨立開發 (120 min)**:製作一個具備 AI 功能的小工具(如:坐姿提醒器、聲控小管家、手勢操作的音量旋鈕)。
+
+## **第四階段:綜合整合 —— 終極專題發表 (第 13 \- 16 週)**
+
+**核心目標**:解決真實痛點,展示軟體工程的完整思維。
+
+### **第 13 週:創意發想與架構圖繪製**
+
+* **任務**:分組討論「生活中還有什麼不方便?」,繪製系統流程圖,決定要用到 API、網路還是 AI。
+
+### **第 14 \- 15 週:開發衝刺與問題排解**
+
+* **任務**:密集的程式開發。學習團隊分工(有人寫介面、有人調校 AI、有人處理數據)。
+
+### **第 16 週:Demo Day 成果發表**
+
+* **活動**:佈置展位,邀請其他班級或老師來試玩,學生練習說明「我的程式解決了什麼問題」。
\ No newline at end of file
diff --git a/projects/2026project_class/doc/w1.md b/projects/2026project_class/doc/w1.md
new file mode 100644
index 0000000000000000000000000000000000000000..ef1f1fefab8016a5270b47ecd6b3716108764f38
--- /dev/null
+++ b/projects/2026project_class/doc/w1.md
@@ -0,0 +1,79 @@
+# **第一週:引擎重啟——從線性邏輯到即時系統 (2小時完整教案)**
+
+## **課程目標**
+
+1. 了解未來 16 週的課程藍圖,看見 Python 結合網路與 AI 所能實現的專案威力。
+2. 理解「線性腳本」與「即時循環系統」的本質差異。
+3. 掌握 Pygame 的四大核心架構(初始化、事件、邏輯、繪製)。
+4. 成功建立開發環境並將程式拆解為多個步驟,執行第一個互動畫布。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[30 min] 歡迎與願景**:16 週藍圖介紹與未來學習里程碑 Demo 展示。
+2. **[20 min] 思維大改造**:線性執行 vs. 即時系統(互動討論)。
+3. **[25 min] 構造大拆解**:Pygame 四大支柱與程式碼結構解析。
+4. **[25 min] 第一手練習**:按部就班建立遊戲主迴圈與事件監聽。
+5. **[20 min] 挑戰任務**:變色追隨者(結合 Python 滑鼠與按鍵偵測邏輯)。
+
+## **課程詳解內容**
+
+### **第一部分:歡迎與願景 —— 16週藍圖與魔法展示 (30 min)**
+
+* **投影片:我們這學期要做什麼?**
+ * 介紹課程的四大階段:
+ * **階段一 (第1-4週):** 實體數據看板(抓取 API 網路真實數據並畫出來)。
+ * **階段二 (第5-8週):** 區域網路連線互動(讓兩台不同電腦的程式互相傳送子彈與座標)。
+ * **階段三 (第9-12週):** 最新 AI 視覺應用(啟動鏡頭,讓電腦看懂你的手勢和動作)。
+ * **階段四 (第13-16週):** 終極專題發表(解決真實痛點的軟體開發)。
+* **【重要實作】展示魔法時刻 (Demo Session)**
+ * 不要只用嘴巴說!老師請在此刻開啟預先寫好的三個小專案,展示給學生看(但不要秀出程式碼,保持神秘感):
+ 1. **Demo 1:天氣看板**。展示一個能根據真實網路天氣數據改變背景的視窗。
+ 2. **Demo 2:雙人連線方塊**。老師開兩個視窗互相送封包,展示「網路」的概念。
+ 3. **Demo 3:AI 魔術手**。老師開鏡頭,用食指在畫面上移動 Pygame 的方塊。
+ * **目的**:讓學生在第一堂課就對「16 週後我會變得多強」有具體、視覺化的強烈期待。
+
+### **第二部分:思維大改造 (20 min)**
+
+* **投影片:你的程式「活」著嗎?**
+ * **線性程式 (Linear)**:像自動販賣機。開始 -> 輸入 -> 運算 -> 輸出 -> 結束。
+ * **即時系統 (Real-time)**:遊戲核心是一個無限循環(心跳)!一秒鐘跑 60 次,不斷檢查有沒有人按鍵盤。
+* **互動實驗**:
+ * 讓學生想像:如果遊戲不是「無限循環」,當你不按按鍵時,遊戲畫面會發生什麼事?(畫面會靜止,甚至程式會結束)。
+ * **核心概念**:遊戲引擎是一秒鐘跑 60 次的 `while True` 迴圈。
+
+### **第三部分:構造大拆解 (25 min)**
+
+* **投影片:Pygame 的四大支柱 (骨架)**
+ 1. **初始化 (Init)**:準備工具箱(聲音、影像、字體)。告訴電腦我們要借用顯示卡。
+ 2. **事件監聽 (Events)**:感官系統。隨時檢查「排隊區」:有人按鍵嗎?滑鼠動了嗎?想關門了嗎?
+ 3. **邏輯更新 (Logic)**:大腦思考。計算座標 `x = x + 5`,判斷有沒有撞到牆。
+ 4. **畫面渲染 (Drawing)**:視覺呈現。把計算結果畫出來。
+* **重點比喻解析**:
+ * **為何要 `screen.fill()`?**:把舊畫面擦掉。就像白板如果沒擦掉就畫下一幀,會變成一團亂塗鴉。
+ * **為何要 `pygame.display.flip()`?**:畫畫在小房間畫好,才翻過來給觀眾看,避免玩家看到畫一半的圖。
+
+### **第四部分:第一手練習 (25 min)**
+
+* **實作:一步步打造畫布**
+ * 帶領學生打開編輯器,確認 `pip install pygame`。
+ * **步驟拆解**(不直接給整包 Boilerplate,而是帶著打):
+ 1. **建立畫布**:輸入 `pygame.init()` 與設定長寬。
+ 2. **建立心跳**:寫出 `while True:` 與 `clock.tick(60)`。
+ 3. **加入防禦**:寫出 `pygame.event.get()` 並且設定處理 `QUIT` 事件(告訴學生不寫這個視窗會死當)。
+ 4. **繪出實體**:在迴圈最後填滿背景色 `fill`、畫圓形 `draw.circle`、並 `flip` 更新畫面。
+ * 這 25 分鐘重點在於學生跟著老師「逐段」讓視窗從死當 $\rightarrow$ 黑畫面 $\rightarrow$ 出現紅色圓形。
+
+### **第五部分:挑戰任務:變色追隨者 (20 min)**
+
+* **題目:製作一個「聽話的圈圈」**
+ * **基本要求 (Level 1)**:
+ 1. 使用 `pygame.mouse.get_pos()` 讓圓形座標隨著滑鼠移動。
+ 2. 當按下鍵盤 SPACE 鍵時,利用 `random` 模組讓圓形隨機變成另一種顏色。
+ * **進階要求 (PRO)**:
+ 1. 讓圓形不要「瞬間」移動到滑鼠位置,而是緩慢地「追隨」滑鼠(計算距離並除以速度系數)。
+ 2. 限制圓形不能跑出螢幕邊界。
+
+## **教師筆記 (Teacher's Note)**
+
+* **第一堂課的節奏**:這堂課的 120 分鐘非常緊湊。30 分鐘的 Demo 非常關鍵,這是往後 15 週學生遇到 Bug 願意撐下去的動力來源。
+* **避免一次梭哈**:在「第一手練習」中,強烈建議老師在台上「刻意犯錯」。例如:不要寫 `event.get()` 就執行,讓全班看你的電腦當機;不要寫 `screen.fill()` 讓圓形畫出貪食蛇般的詭異軌跡。這比直接給他們完美程式碼的教學效果好上十倍。
\ No newline at end of file
diff --git a/projects/2026project_class/doc/w10.md b/projects/2026project_class/doc/w10.md
new file mode 100644
index 0000000000000000000000000000000000000000..d259ca001fbaa11dd89d0d2cd59ba570fa5085af
--- /dev/null
+++ b/projects/2026project_class/doc/w10.md
@@ -0,0 +1,83 @@
+# **第十週:魔法手勢——從座標到控制邏輯 (2小時完整教案)**
+
+## **課程目標**
+
+1. 將 MediaPipe 的各個指尖點位數據,利用幾何學的「距離運算」轉化為手指的絕對互動邏輯(如:掐合動作)。
+2. 在 Pygame 遊戲主迴圈中穩定整合串接辨識模型所吐出的運算結果。
+3. 實作「手掌游標」與基本的人機互動功能。
+4. **【解決運算延遲問題】**:建立 Frame Skip (跳幀) 的概念以避免 AI 運算卡住畫面更新。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[20 min] 魔法的代價**:勾股定理與兩點距離。
+2. **[40 min] 手印與勾結**:寫出抓取手勢判定 (Pinch Detection)。
+3. **[40 min] 觸發!連動第一階段的介面**:手勢翻頁,甚至控制方塊與物件。
+4. **[20 min] 挑戰任務**:設定動作冷卻機制 (Cooldown Time),解決重複觸發的問題。
+
+## **課程詳解內容**
+
+### **第一部分:魔法的代價 (20 min)**
+
+* **投影片:數學是魔法界的通用語**
+ * 在 2D 螢幕上,要怎麼知道兩根手指(比如大拇指 4 和 食指 8)是不是靠在一起?
+ * 複習 / 預習國中數學「勾股定理」:$\sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}$。
+* **實戰程式法**:介紹 Python 的 `math.hypot(x2 - x1, y2 - y1)` 一行解。數學其實不可怕,因為有內建函數。
+
+### **第二部分:手印與勾結 (40 min)**
+
+* **尋找距離的設定值 (Threshold)**
+ * 回到上週的迴圈中,獲取食指 `(cx8, cy8)` 和大拇指 `(cx4, cy4)` 的視窗座標。
+ * **寫出距離公式印在終端機**:
+ ```python
+ import math
+ dist = math.hypot(cx8 - cx4, cy8 - cy4)
+ print(dist)
+ ```
+ * **觀察與實驗**:請同學張開手,再把兩指捏起來,紀錄終端機的數字變化,並設定一個 Threshold(比如距離小於 40)。
+ * 邏輯撰寫:
+ ```python
+ if dist < 40:
+ is_pinching = True
+ pygame.draw.circle(screen, (0,255,0), (cx8, cy8), 15) # 捏起顯示綠色
+ else:
+ is_pinching = False
+ pygame.draw.circle(screen, (255,0,0), (cx8, cy8), 15) # 放開顯示紅色
+ ```
+
+### **第三部分:觸發!連動第一階段的介面 (40 min)**
+
+* **動作與物件碰撞的交響樂**
+ * 還記得 Pygame 的 `colliderect` 嗎?這次我們不用發射出來的子彈,我們用「手」!
+ * 結合第一階段的天氣看板,或者在畫面上畫出一個方形按鈕。
+ * **複合條件:**
+ ```python
+ finger_rect = pygame.Rect(cx8, cy8, 10, 10)
+ # 如果手指在按鈕上,而且「捏起」了!
+ if finger_rect.colliderect(button_rect) and is_pinching:
+ print("魔法點擊按鈕成功!")
+ # 觸發天氣變更、背景顏色轉變等事件
+ ```
+
+### **第四部分:挑戰任務與防呆冷卻 (20 min)**
+
+* **題目:魔法冷卻時間**
+ * **問題**:如果在 `while` 迴圈中一旦「捏」下去,程式會在一秒內印出 60 次「魔法點擊」。
+ * **防呆解決**:教導如何使用 `pygame.time.get_ticks()` 或設立一個「開關 Boolean (Flag)」:
+ - 必需是「前一幀沒捏」且「這一幀捏了」才算是點擊一次。
+ 這叫做**邊緣觸發判定 (Edge Detection)**,對未來開發遊戲和 UI 控制極為重要。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Threshold (閾值)**:觸發某個狀態判定的一條「界線數值」,如超過多少距離才算分開。
+* **Hypotenuse (斜邊)**:直角三角形的斜邊,用來推演平面上任意兩點的直線距離。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:我的手越靠近鏡頭,稍微伸出一點就觸發了,但手伸遠卻要捏緊才行?**
+ A:因為你在算的是「畫面上的絕對長度(Pixel)」!如果你的手掌離鏡頭近,那 `(cx8, cy8)` 隨便動就有很多像素;離得遠就很少。未來的進階作法是:以你手掌的寬度(點 0 到點 5 的長度)當作基準尺!
+
+## **教師筆記 (Teacher's Note)**
+
+* **【優化防呆再次強烈建議】**:這堂課如果使用視訊鏡頭而導致 FPS 嚴重降低至 5 或直接當掉畫面。請直接開大牌,在 `cv2.imshow` 和 `cap.read()` 這部分進行跳幀處理(例如:每 Pygame 三次 Update 畫面才請鏡頭抓一次),保護好同學好不容易開發出來的 Pygame 主畫布。
diff --git a/projects/2026project_class/doc/w11.md b/projects/2026project_class/doc/w11.md
new file mode 100644
index 0000000000000000000000000000000000000000..158742b8917867166962ffbdc1700d98d322351b
--- /dev/null
+++ b/projects/2026project_class/doc/w11.md
@@ -0,0 +1,86 @@
+# **第十一週:訓練你的大腦——Teachable Machine 實戰 (2小時完整教案)**
+
+## **課程目標**
+
+1. 了解機器學習 (Machine Learning) 的三大核心步驟:資料蒐集、神經網路訓練、模型預測。
+2. 認識以圖搜圖與圖像分類。
+3. 把模型打包 (Keras .h5 / TFLite),匯入至 Python 中,使自己的 Python 看懂萬物。
+4. 體驗「準備優質訓練資料」的重要性。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[20 min] 白痴與天才**:機器學習如何靠「看很多張圖」來歸納世界。
+2. **[40 min] Teachable Machine 遊樂場**:實作專屬你的三個分類模型。
+3. **[40 min] 接通神經網路**:把訓練成果放進 Python。
+4. **[20 min] 挑戰任務**:剪刀、石頭、布!或者猜猜看誰在喝水。
+
+## **課程詳解內容**
+
+### **第一部分:白痴與天才 (20 min)**
+
+* **投影片:一隻貓是怎麼被定義的?**
+ * 如果叫我們寫 if-else 來判斷影像中是不是貓(if 有兩隻尖尖的耳朵 and 有鬍鬚...)。幾乎是不可能寫出來的,因為不同光源和角度千變萬化。
+ * **現代解法:倒因為果**。我不給你 if-else,我給你十萬張印著貓的圖片,五十萬張不是貓的,然後你自己去裡面找出那該死的數學規律。這就是 Deep Learning!
+
+### **第二部分:Teachable Machine 遊樂場 (40 min)**
+
+* **線上神裝使用**
+ * 登入 Google Teachable Machine 網站選擇 `Image Project` > `Standard Image Model`。
+* **搜集高品質資料 (Data Collection)**
+ * **Class 1 (背景或無意義動作)**:告訴學生,一定要有一個類別是「沒在做事」的,這超級重要!沒有教它什麼是背景,它看見空白牆壁會以為是一隻貓的近拍!
+ * **Class 2 (你要辨識的動作)**:手拿水瓶喝水、剪刀手、比讚。
+ * 拍攝每類超過 200 張。
+* **按一鍵就完成訓練 (Train) 與輸出 (Export)**:選擇匯出為 TensorFlow (Keras) `.h5` 檔案。
+
+### **第三部分:接通神經網路 (40 min)**
+
+* **檔案安置與庫載入**
+ * 將下載下來的 `keras_model.h5` 和 `labels.txt` 放進今天專案的根目錄。
+ * 備註:如果是較舊的電腦,可教導匯出 OpenCV 較好處理的 TFLite。
+* **讀取與推論 (Inference)**:
+ ```python
+ from keras.models import load_model # (或是其它匯入方式依當下環境)
+ import cv2
+ import numpy as np
+
+ model = load_model("keras_model.h5", compile=False)
+ labels = open("labels.txt", "r").readlines()
+
+ # ... 省略攝影機啟動程式 (cap.read) ...
+
+ # Keras 要求特定的影像處理 (Resize to 224x224 & Normalize)
+ img_resized = cv2.resize(img, (224, 224))
+ img_array = np.asarray(img_resized, dtype=np.float32).reshape(1, 224, 224, 3)
+ img_array = (img_array / 127.5) - 1
+
+ # 前饋推論,得到有每一個類別機率的數字陣列
+ prediction = model.predict(img_array)
+ index = np.argmax(prediction) # 找最大機率的那一項標籤!
+ print(labels[index])
+ ```
+
+### **第四部分:挑戰任務 (20 min)**
+
+* **題目:AI 聲音播放器**
+ * 當類別 1 "喝水中" 的機率大於 90% 的時候,立刻觸發 Pygame 寫好的音效播放,放出氣泡咕嚕聲。
+ * 問題思考:如果在 `while` 的連續迴圈下,一偵測到喝水就播音效,音軌會被不斷覆蓋而鬼畜。
+ * **引導修復**:利用上週學到的 Boolean 狀態標記,讓音效「只在狀態剛變成喝水的那一瞬間」播放,之後不管再高機率都不重新播,直到狀態變為背景再重置。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Model Training (模型訓練)**:提供正確答案與資料集,讓神經網路不斷修改內部幾百萬個變數以降低預測誤差。
+* **Inference (推論)**:將訓練好的「大腦迴路結果 (\.h5 等)」丟給未知的影像去「猜」出這是什麼。我們用 Python 就是在做這階段。
+* **Labels (標籤)**:這是水、那是杯子、那是背景。人為賦予的各類別名字。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:為什麼我的模型在網站上很準,進到 Python 裡就變白痴?**
+ A:有兩個可能。第一,你的 Python 影像(如矩形變正方型)變形度過大,或是 BGR 沒有轉 RGB!Keras 模型只接受訓練當時同樣的處理法。
+* **Q:為什麼只要一跑到 `model.predict` 我的畫面就會大卡?**
+ A:神經網路本來就不是給一般 CPU 玩的好嗎!不要每一幀都預測!你可以加一個 `if counter % 5 == 0:` 每五格影像,再把畫面丟去判斷一次就可以解決。
+
+## **教師筆記 (Teacher's Note)**
+
+* **降低環境挫折**:Keras 及 Tensorflow 動輒佔據好幾 GB 空間,很多防毒軟體甚至覺得 `load_model` 是木馬行徑。請事前統一叫同學裝好。並一定要幫學生將範本代碼與 `cv2.resize` 那三行死背的預處理程式碼準備好,否則他們花兩小時都生不出一行前饋。
diff --git a/projects/2026project_class/doc/w12.md b/projects/2026project_class/doc/w12.md
new file mode 100644
index 0000000000000000000000000000000000000000..56399c965eb19ef95d2deaf02307627da3f09441
--- /dev/null
+++ b/projects/2026project_class/doc/w12.md
@@ -0,0 +1,68 @@
+# **第十二週:專案實作 (三)——AI 生活工具箱 (2小時完整教案)**
+
+## **課程目標**
+
+1. 將 MediaPipe 或者 Teachable Machine 訓練好的 AI 技術,獨立連動並包裝進使用者的具體解決方案中。
+2. 認識生活與數位結合的創新點子。
+3. **【優化項目】深化 PBL 敏捷開發 Scrum 站會**:針對學生易產生的「點子過大與迷幻發散」狀況進行控管與限制。
+4. **【優化項目】跨電腦 QA 找碴**:訓練抗壓除錯能力與軟體穩定度。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[15 min] 專案啟動與進階 Scrum 站會**:生活痛點定義與 MVP 設定。
+2. **[65 min] 開發衝刺 (Sprint)**:實作工具箱的 AI 分析與介面回饋。
+3. **[20 min] 破壞者大會 (QA 測試)**:請同學坐在你位子上嘗試用奇葩動作破壞你的程式。
+4. **[20 min] Demo 分享會**:快速演練如何用三十秒推銷 AI 生活小道具。
+
+## **課程詳解內容**
+
+### **第一部分:進階 Scrum 站會 (15 min)**
+
+* **痛點收斂與技術決選**
+ * 第九至第十一週,我們學了 AI 是怎麼運作的。現在請全班分組。
+ * 每個人快速提出:「日常生活中,我都做不到什麼?或者是覺得有什麼很煩的例行公事可以被自動化的?」
+ * 例子A:我常常打電動打到坐姿很差或靠到電腦銀幕上。
+ * 例子B:音樂太大聲,手又髒髒的不想碰鍵盤去調整。
+* **技術抉擇:**
+ * 要選 MediaPipe(需要抓手點數)還是 Teachable Machine(直接餵畫面)?
+ * 定義好你的 MVP 到底要做出什麼!
+
+### **第二部分:開發衝刺 Sprint (65 min)**
+
+* **沉浸開發與老師巡迴打壓:**
+ * 同學 A:「我要用 Teachable Machine 做一個能夠偵測到我有沒有打掃房間,沒有的話它會叫的鬧鐘。」
+ * 老師:「很棒的點子!但你今天要怎麼拿出一個剛好能表現『沒打掃』跟『有打掃』的示範?而且只有 60 分鐘!請給我一個拿在你手上的水瓶有沒有蓋好蓋子分類器就好。」
+* **將「偵測到」化作「互動行為」**:
+ * AI 抓出結果後,要在 Pygame 原先建立好的畫面上,切換提示圖文字(如「危險!坐姿太近!」)。
+ * 若要播放警報音,要處理延遲與重複播放。
+
+### **第三部分:破壞者大會 (跨電腦 QA 防呆測試) (20 min)**
+
+* **【優化實踐】QA 找碴的軟體工程**
+ * 過去 AI 的辨識都是同學用自己特有的習慣與角度在拍攝。
+ * 「現在,請左邊的同學坐上你的位子,給你的 AI 看看他的手、他的臉,或是拿同樣的東西給他看!」
+ * 紀錄系統有沒有發瘋?同學把手伸到畫面外面時,會不會噴出 Index Out of Bounds 或者直接閃退?
+ * 修復他!加上 `try-except` 或更多的防呆判定。不要把未處理的異常 (Unhandled Exception) 留給使用者。
+
+### **第四部分:Demo 分享會 (20 min)**
+
+* **銷售你的 AI 道具**
+ * 「這款名為【護眼大師】的小怪獸,在你的臉離螢幕低於 XX 距離(由兩眼 Landmark 的大小決定),就會尖叫並暫停你聽的音樂。只要我們有電腦與鏡頭,這就是免費的硬體!」
+ * 教師引導台下同學思考:它的辨識有盲點嗎?什麼環境下會破功?
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Edge Case (邊緣案例)**:因為開發者沒想過而引發的錯誤,比如在手勢識別時,畫面中出現「兩隻手」,或者「根本沒有手」,會導致不正常的程式執行。
+* **False Positive (偽陽性)**:明明沒有做出壞坐姿,卻硬是報警,表示把不該抓的結果給誤抓了。對使用者體驗影響極大。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:別人一來玩我的工具,它的 Teachable Machine 就一直辨識錯類別?**
+ A:代表你上次訓練時,把「你專屬的衣服顏色、你的房間光線」也當成了判斷的特徵。你需要蒐集更多在不同房間不同人出現的豐富度資料,才能避免過度擬合 (Overfitting)。
+
+## **教師筆記 (Teacher's Note)**
+
+* **防禦性程式開發**:這週是驗證 AI 防呆最完美的體驗。由於 AI 輸出的數值隨時在無序的浮動(甚至突然消失成為 `None`),這是培養他們加上 `if results.multi_hand_landmarks is not None:` 最棒的時段。
+* **PBL 痛點**:幫助學生理解到,即使是最頂尖的工程師與模型,也會對「手在鏡頭外」這等常識無能為力。一切都建立在開發者是否足夠「防禦」。
diff --git a/projects/2026project_class/doc/w13.md b/projects/2026project_class/doc/w13.md
new file mode 100644
index 0000000000000000000000000000000000000000..4f53a0d20371c442cf39e1ef05d95912385712e2
--- /dev/null
+++ b/projects/2026project_class/doc/w13.md
@@ -0,0 +1,70 @@
+# **第十三週:創意發想與架構圖繪製——系統分析 (2小時完整教案)**
+
+## **課程目標**
+
+1. 從程式編寫員步入「軟體系統分析師 (System Analyst)」,面對一個模糊的社會生活痛點,構建能以軟體解決的可行方案。
+2. 透過繪製具體的架構與流程圖,訓練邏輯拆解與團隊分工能力。
+3. **【優化項目】MVP ( Minimum Viable Product) 收斂與防雷控管**:避免過度野心導致後三週發生災難。
+4. 複習前三階段的所有功能:生活 API、連線網路對戰、AI 電腦視覺。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[20 min] 重點回放與痛點挖掘**:複習兵器譜,發現社會問題。
+2. **[30 min] 專題 Ideas Pitch (點子審查會)**:發表點子與殘酷可行性評估。
+3. **[40 min] 系統拆分與架構繪製**:UI 長怎樣?資料怎麼流動?
+4. **[30 min] 專案資料夾建立與備份設定**:技術基底的初始建置。
+
+## **課程詳解內容**
+
+### **第一部分:重點回放與痛點挖掘 (20 min)**
+
+* **投影片:回顧我們的軍火庫**
+ * 階段一:API。我們可以拿到全台灣公車的現在位置。
+ * 階段二:Socket 區網。我們可以把 A 電腦發生的一點小事傳給 B 的螢幕顯示。
+ * 階段三:電腦視覺。我們可以讓電腦看懂一切我們塞給它的動作或圖形。
+* **分組腦力激盪**:
+ * 給每個小組三張白紙,寫下這世界(或學校裡)「很蠢」、「很麻煩」、「常常造成糾紛」的幾個問題。
+
+### **第二部分:專題 Ideas Pitch 點子審查 (30 min)**
+
+* **上台發表你的狂想**:
+ * "Pitch" 在矽谷是向投資人推銷的概念。告訴大家:「我們想解決中午買午餐大排長龍的問題,所以要設計一個教室座位點餐連線系統,附帶 AI 手勢決定便當口味。」
+* **【優化實踐】老師的殘酷 MVP 剃刀**:
+ * **野心過大**:「你要怎麼在學校裡連通所有教室的內網?而且你兩週內還要把菜單畫到畫面上?而且 AI 辨識雞腿排還是排骨根本是天方夜譚!」
+ * **收斂與打磨**:「你們的 MVP 應該是:用區網傳遞 JSON 到同一個螢幕上的 Pygame UI,AI 不要有,只要按鍵盤就是了。API 試著加一個冷笑話讓排隊的畫面不無聊就好。」
+
+### **第三部分:系統拆分與架構繪製 (40 min)**
+
+* **軟體工程的大圖紙**
+ * 給予全開壁報紙,請他們畫出這個期末專案的:
+ 1. **UI 簡圖**:包含所有的「主畫面」、「設定畫面」或「連線配對區」。有哪些按鈕?
+ 2. **數據流程流 (Data Flow)**:標示哪一塊要跟外部伺服器發 Request?哪一塊要用 Socket 發給隔壁對手?
+ 3. **AI 辨識迴圈**:有沒有專門處理 `cv2.read()` 與推論的地方?
+* **團隊職責分配**:
+ * 「誰當 UI 工程師?」(負責畫 Pygame 畫面)。
+ * 「誰當邏輯分析師?」(負責接收封包處理、修補防呆或 AI 模型訓練)。
+ * 組長負責整合所有的變數!
+
+### **第四部分:專案建立與版本控制宣導 (30 min)**
+
+* **【優化實踐】專案的起源資料夾**
+ * 大家在電腦桌面上建立一個名為 `FinalProject_Group1_v1` 的資料夾。
+ * 放進老師發放的最乾淨的 Pygame 樣板 `app.py`。
+ * **宣導版本控制鐵律**:「當你們要把某段寫好的模組 (例如網路通訊),複製貼進這個 `app.py` 之前,請務必先把資料夾複製改名成 `FinalProject_Group1_v2`!我不想在那天聽到哭聲。」
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **MVP (最小可行性產品)**:先用最低成本只做出最核心、最痛點解決功能的產品,用來測試大家會不會想用。
+* **Wireframe (線框圖)**:一種低保真度的 UI 設計草圖,不考慮顏色美觀,只規劃按鈕、數據和各種狀態字串的螢幕排版位置。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:老師,我們什麼都不會畫,畫面超級醜怎麼辦?**
+ A:不要忘了 Python 是拿來解決自動化與資料問題的!只要 UI 清楚整齊,能秀出你對程式脈絡的理解(如抓取 API 與 Socket 傳輸),那就是滿分。不用比美術大賽!
+
+## **教師筆記 (Teacher's Note)**
+
+* **這是一堂「文科 / 管理」重於「寫扣」的課**。很多國中/小學生對這節很不在乎,想直接打開 IDE 劈哩啪啦打起程式。
+* 老師要嚴格執行**「沒有畫出架構圖,沒有給老師確認過 MVP 範圍的組別,不准打開電腦寫程式」**。這才是 PBL (Project-Based Learning) 教導的「規劃」能力之精髓。
diff --git a/projects/2026project_class/doc/w14.md b/projects/2026project_class/doc/w14.md
new file mode 100644
index 0000000000000000000000000000000000000000..c82c66d9d549629abfab874f57537c5267f28ea5
--- /dev/null
+++ b/projects/2026project_class/doc/w14.md
@@ -0,0 +1,58 @@
+# **第十四週:開發衝刺 (一)——核心邏輯建構 (2小時完整教案)**
+
+## **課程目標**
+
+1. 建立專案第一版最小可行性產品 (MVP) 的核心技術打底。
+2. 進行模組化程式撰寫,學習將大專案切分為:純邏輯處理與純介面顯示的兩個層次。
+3. **【優化項目】PBL 敏捷開發 Scrum 站會**:培養專注力,設定當日具體產出目標。
+4. **【優化項目】版本控制與除錯心法**:養成隨時備份版本的習慣,避免程式改壞全毀的慘劇。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[10 min] Scrum 站立會議**:每組宣告今天的最高開發目標。
+2. **[90 min] 深潛開發 (Deep Work)**:無干擾的密集程式撰寫。
+3. **[20 min] 版本備份與進度盤點**:教導並執行檔案多重備份與版本命名法。
+
+## **課程詳解內容**
+
+### **第一部分:Scrum 站立會議 (10 min)**
+
+* **目標宣誓**
+ * 在開發開始前,所有人起立,由組長發言:
+ * 「我們這週的 MVP 是:不用 AI,只要按鍵盤能送出 API,並且畫面會動就好!」
+ * 老師在一旁再次擔任「把關者」,告訴他們「畫面不要畫、字體不要換,先讓它『動起來』!」
+
+### **第二部分:深潛開發 (Deep Work) (90 min)**
+
+* **本週的開發準則 (Golden Rules)**:
+ 1. **拒絕美觀**:這個禮拜如果有人在用 Photoshop 去背圖片,或是修改字體大小,請嚴厲喝止。這週是「邏輯」的週次。
+ 2. **除錯自救第一步**:遇到紅字 Error,第一時間不是叫老師,而是用 Google 或翻譯軟體將最後一句的 `TypeError` 或 `KeyError` 看懂。
+* **教師輔導策略**:
+ * 在教室走動,主要回答「架構性」問題(例如:我的 while 迴圈為什麼一直卡死?),而不幫忙修「打錯字」或「縮排錯誤」這種初階問題,培養獨立除錯能力。
+
+### **第三部分:版本控制與除錯心法 (20 min)**
+
+* **【優化實踐】專案的守護神——備份命名**
+ * 打鐘前 20 分鐘,強制全班停下手邊工作。
+ * **狀況發生與預防**:
+ * 「有沒有人剛剛改了一行想要加新功能,結果本來會動的突然不會動,而且你也按不了 Ctrl+Z 倒退回去了?」
+ * **強制實作:**
+ * 將剛才運作正常的 `app_v1.py` 另存新檔為 `app_v2_add_network.py`。
+ * 建立資料夾觀念:將所有失敗的舊程式集中在一個叫做 `Old_Versions` 的廢件區資料夾,專案根目錄永遠只有一個主程式 `app.py`。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Deep Work (深潛工作)**:在無干擾的情況下專注於高認知要求的工作狀態。這對於寫出不會有蟲的程式碼非常重要。
+* **Modularity (模組化)**:把複雜的系統拆解成互相獨立的小模塊(比如 A 同學寫 API 檔案,B 同學寫 Pygame 介面檔,然後匯入 `app.py` 組合,而非全部擠在同一個檔案內)。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:我的程式在自己的電腦上會跑,移到隔壁同學做測試的時候就一直報找不到檔案的錯?**
+ A:你的檔案路徑寫成了「絕對路徑(如:C槽/桌面)」。請一律使用「相對路徑(只寫 `background.png`)」,並確保圖片與 `app.py` 在同一個階層,讓資料夾具有「可移植性」。
+
+## **教師筆記 (Teacher's Note)**
+
+* 這是一段無聲但壓力極大的課堂。很多組別會因為「什麼都做不出來」而開始聊天放棄。老師必須在巡場時給予**「微小的成功體驗」**:只要他們能成功 `print` 出一個字串,就大力讚賞他們解決了核心的一大半,然後再鼓勵他們將字串接到畫面上。
+* 老師的心態也要穩住:不要有「一定要每組都做出完整功能」的執念。程式不是魔法,會動與不會動就是一行防呆的距離。陪伴他們度過這段陣痛期即可。
diff --git a/projects/2026project_class/doc/w15.md b/projects/2026project_class/doc/w15.md
new file mode 100644
index 0000000000000000000000000000000000000000..b930366b1e7541fba228752330ef5dad14f53603
--- /dev/null
+++ b/projects/2026project_class/doc/w15.md
@@ -0,0 +1,61 @@
+# **第十五週:開發衝刺 (二)——體驗優化與除錯 (2小時完整教案)**
+
+## **課程目標**
+
+1. 透過 UI/UX 打磨,將專案從「可以用」的原始人狀態,提升到「好用漂亮」的現代軟體。
+2. 完整應用之前所學之字體渲染、音樂/音效匯入與背景圖片縮放技術。
+3. **【優化項目】跨組 QA 測試 (交叉找碴)**:理解使用者永遠是最大的 Bug 製造機,訓練從開發者轉為品保者的視野。
+4. 完成最終除錯與封包(Package),使專案能一鍵獨立穩定運行。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[10 min] Scrum 站立會議**:最後衝刺的目標宣告。
+2. **[50 min] 介面優化開發**:放飛美感,加入所有華麗的視覺設計。
+3. **[40 min] 破壞性 QA 測試**:交換試玩並填寫 Bug 紀錄單。
+4. **[20 min] 最終除錯與封裝**:除蟲收尾,一鍵點開即玩設定。
+
+## **課程詳解內容**
+
+### **第一部分:Scrum 站立會議 (10 min)**
+
+* 這是最後一次的站會。組長報告:「我們已經可以動了,我們今天要把黑底白字變成精緻的介面,還要加入音效跟防呆跳出視窗!」
+
+### **第二部分:介面優化開發 (50 min)**
+
+* **UX/UI 體驗升級**:
+ * **美觀**:套用去背好的角色圖片、高解析度的 Pygame 背景 (`pygame.transform.scale`)、好看的 TTF 字型。
+ * **回饋感**:加入 `pygame.mixer`。按下任何按鈕都要有「滴」聲,AI 觸發時要有特定的特效音。
+ * **互動提示 (Tooltip / Indicator)**:如果正在抓取 API,畫面上不是死機,而是要有一串字閃爍:「網路讀取中...」。如果沒接上 AI 鏡頭,要會印出:「請接上攝影機」而非直接報錯閃退。
+
+### **第三部分:破壞性 QA 測試 (40 min)**
+
+* **【優化實踐】使用者是來搞砸你的程式的**:
+ * **跨組交換**:全班站起來,向右移動一組的座位。
+ * **賦予任務**:「請盡量用力弄壞別人的程式!包含:亂按按鍵、拔掉網路線、把手遮住鏡頭、把視窗縮小又放大、輸入框亂打中文字。」
+* **Bug 紀錄單 (Bug Report)**:
+ * 要求試玩者寫下一張小紙條:「當我把網路關掉時,發生了什麼事?(如:程式停住或彈出亂碼)」
+ * **為什麼要做這件事?**:自己寫的程式永遠有盲點。這也是業界最重要的 Quality Assurance 流程。
+
+### **第四部分:最終除錯與封裝 (20 min)**
+
+* **最後 20 分鐘防呆與除錯**:
+ * 拿著剛剛別人幫你寫的 Bug 紀錄單,透過 `try-except` 或更多的 `if` 判斷修復這些崩潰點。
+* **封裝獨立執行**:
+ * 確保能在終端機打 `python app.py` 就順利進入並零報錯離開。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **UX (User Experience / 使用者體驗)**:包含速度、聲音回饋、防呆提示等能讓使用者感到舒服的心理學設計。
+* **QA (Quality Assurance / 軟體品質保證)**:專門找程式毛病的一門學問。優秀的 QA 工程師價值不會低於負責開發的軟體工程師。
+* **Unhandled Exception (未處理異常)**:當防呆沒有寫好,一旦發生例外情況,程式直接中斷閃退,這是 UX 的災難。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:別人發現我的遊戲只要網路稍微不穩,人物就會瞬移到奇怪的地方?**
+ A:如果是使用之前提過的 UDP,掉包在所難免。你可以實作「畫面插值(如果收不到封包,就假設對方還在繼續往前走)」來欺騙眼睛。
+
+## **教師筆記 (Teacher's Note)**
+
+* **QA 文化的建立**:在別人程式找碴的時候,很容易引起兩組吵架(「你幹嘛亂按破壞我的程式!!」)。老師必須在過程中強力倡導這才是業界幫對方省下「產品上線直接倒閉」救命錢的偉大行為,將衝突轉化為專業技術評估的樂趣。
diff --git a/projects/2026project_class/doc/w16.md b/projects/2026project_class/doc/w16.md
new file mode 100644
index 0000000000000000000000000000000000000000..c1ddd114c00e127811850dbbe2cc447d88a94bf8
--- /dev/null
+++ b/projects/2026project_class/doc/w16.md
@@ -0,0 +1,65 @@
+# **第十六週:Demo Day 成果發表 (2小時完整教案)**
+
+## **課程目標**
+
+1. 完成 16 週的學習歷程,透過 Demo Day (軟體成果展) 體驗科技創作者向社會展示產品的成就感。
+2. 磨練面對大眾的技術表達、痛點分析與溝通說服能力。
+3. 建立對自己完成之全端(介面+網路+AI邏輯)軟體作品的自信,並回顧過去幾週的成長。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[20 min] 教室佈置與攤位最後確認**:如同小型的黑客松或展位準備。
+2. **[60 min] 博覽會時間 (Demo & Pitch)**:自由體驗與解說時段。
+3. **[25 min] 榮耀時刻**:頒獎典禮與評審(或同儕)講評反饋。
+4. **[15 min] 總結與未來展望**:帶著超能力離開教室。
+
+## **課程詳解內容**
+
+### **第一部分:教室佈置與攤位最後確認 (20 min)**
+
+* **環境設定**
+ * 各組將電腦轉向對外,打開程式為「全螢幕且已經順暢執行」的狀態。
+ * 桌上可擺放一張 A4 紙,寫有自己的專案名稱與簡單標語:「用 AI ___ 拯救你生活中的 ___ 痛點!」
+* **演練與深呼吸**:
+ * 給予最後 5 分鐘,請各組互相練習「30 秒產品電梯簡報 (Elevator Pitch)」。
+
+### **第二部分:博覽會時間 (Demo & Pitch) (60 min)**
+
+* **闖關與自由試玩體驗**
+ * **【優化實踐】外部觀眾邀約**:如果校方允許,強烈建議邀請其他班級學生、班導師甚至家長入場。陌生的觀眾更能激發開發者的腎上腺素與成就感。
+ * **作者講解**:當有人來到攤位前試玩,開發學生必須站在旁邊指導並講解以下三點:
+ 1. 「這是為了解決什麼問題而開發的?」
+ 2. 「這背後用到了什麼技術?(API 抓資料、AI 看手勢、區域網路連線)」
+ 3. 「如果未來還有時間,我最想加什麼功能進去?」
+
+### **第三部分:榮耀時刻與頒獎 (25 min)**
+
+* **同儕票選與老師講評**:
+ * 剛才互相走動體驗完畢後,發放三張貼紙給每個人,請他們貼在最欣賞的專案海報或黑板上。
+ * **頒發專屬獎項 (非名次制)**:鼓勵多元發展,頒發如「最強防呆 UI 獎」、「最具商業價值點子獎」、「最瘋狂 AI 技術獎」。不要只頒發第一二三名,以免抹煞不同特質的學生。
+* **心路歷程短講**:
+ * 抽點一位技術曾經最卡關,但最後撐過來的同學分享:「你這 16 週到底經歷了什麼可怕(好笑)的地獄?」
+
+### **第四部分:總結與未來展望 (15 min)**
+
+* **老師做最溫暖的閉幕演講**
+ * 「十六週前,你們認為 Python 就是拿來印字串或者解數學題的無聊考試。」
+ * 「今天,你們能從網路上抓全世界的數據(API)、能讓兩台電腦互相溝通(Socket)、還能讓自己的鏡頭看懂人類的手勢(AI Vision),最後做成一個精美的介面!」
+ * 「這不是魔法,這是邏輯加上你們一次又一次 Try-Except 修好 Bug 換來的『超能力』。」
+ * 「從這裡走出去後,如果遇到生活中覺得不順眼、不方便、很浪費時間的事,大聲說:『這個,可以用我的 Python 寫支程式搞定它!』」
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Demo Day (演示日)**:新創團隊或培訓營的結業日,向大眾或投資人展現自家軟體能如何運作、有什麼價值的高光時刻。
+* **Elevator Pitch (電梯簡報)**:假設你在電梯裡遇到大老闆,要在這 30 秒抵達樓層的時間內,把你的專案核心精華講出來並且吸引到他。
+
+### **常見問題解答 (FAQ)**
+
+* **無** (這週不找 Bug,只享受成果的榮耀)。
+
+## **教師筆記 (Teacher's Note)**
+
+* **錄下這一刻**:強烈建議老師在博覽會時間拿手機錄下學生**充滿自信向觀眾講解自己程式原理**的畫面。這部短短 30 秒的影片,將會成為他們未來推甄或升學時最具說服力的「專案實作能力」證明,遠比任何紙上的分數都有價值。
+* **情緒高點的散場**:這堂課請不要再講任何技術的原理與對錯,完全浸泡在「成就感、歡樂與未來希望」中結束,讓學生的學習曲線帶著最高昂的尾韻離開教室。
diff --git a/projects/2026project_class/doc/w2.md b/projects/2026project_class/doc/w2.md
new file mode 100644
index 0000000000000000000000000000000000000000..3e036a7e2dd40617b3a3eed03b54f6f1797af221
--- /dev/null
+++ b/projects/2026project_class/doc/w2.md
@@ -0,0 +1,95 @@
+# **第二週:數位服務生——requests 庫與網路 API (2小時完整教案)**
+
+## **課程目標**
+
+1. 理解「API (應用程式介面)」的概念與運作原理。
+2. 認識 JSON 資料格式,並能透過 Python 字典與列表提取所需數據。
+3. 學習使用 `requests` 庫向網路伺服器發送請求並獲取回應。
+4. **【優化項目】強化除錯與防呆機制**:學習 HTTP 狀態碼與基本的 `try-except` 錯誤處理,避免程式因網路問題崩潰。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[15 min] 概念比喻**:API 餐廳點餐理論與網路運作基礎。
+2. **[25 min] JSON 大解密**:認識 JSON 格式,解析字典(Dict)與列表(List)的混合體。
+3. **[30 min] 第一手練習**:安裝 `requests` 庫,寫出第一支網路抓取程式。
+4. **[35 min] 挑戰任務**:擷取趣味 API (冷笑話、寶可夢、找狗狗等),在終端機漂亮地印出。
+5. **[15 min] 防呆與除錯教學**:認識 404/500 錯誤代碼,實作連線失敗時的溫柔提示。
+
+## **課程詳解內容**
+
+### **第一部分:概念比喻 (15 min)**
+
+* **投影片:誰是網路世界的服務生?**
+ * **角色扮演**:你(客戶端/Python) $\rightarrow$ 服務生(API) $\rightarrow$ 廚房(伺服器)。
+ * **為什麼需要 API?**:我們不能直接衝進別人的資料庫(廚房)拿資料,必須透過規定好的菜單(API 文件)請服務生幫忙送出請求(Request)並拿回餐點(Response)。
+ * **生活實例**:手機上的天氣 APP、公車動態,背後全都是 API 在運作。
+
+### **第二部分:JSON 大解密 (25 min)**
+
+* **投影片:資料的通用語言——JSON**
+ * **長得像誰?**:JSON 在 Python 裡看起來就是「字典 (Dictionary)」跟「列表 (List)」的超級組合包。
+* **互動練習:俄羅斯娃娃找資料**
+ * 給予一段簡單的 JSON 字串,例如 `{"records": [{"city": "Taipei", "weather": "Rain"}]}`。
+ * 老師帶領拆解:要拿到 "Rain",得先開 `"records"` 的箱子(得到列表),拿出第 0 個元素,再開 `"weather"` 的箱子。也就是 `data["records"][0]["weather"]`。
+
+### **第三部分:第一手練習 (30 min)**
+
+* **實作:我的第一支爬蟲**
+ 1. **環境確認**:終端機執行 `pip install requests`。
+ 2. **基礎語法**:
+ ```python
+ import requests
+ url = "https://api.ipify.org?format=json" # 獲取自己IP的簡單API
+ response = requests.get(url)
+ data = response.json()
+ print("我的 IP 是:", data["ip"])
+ ```
+ 3. **觀察**:帶領學生理解 `requests.get()` 的運作,以及 `.json()` 方法如何把文字變成 Python 字典。
+
+### **第四部分:挑戰任務 (35 min)**
+
+* **題目:今日冷笑話產生器 / 天氣播報員**
+ * **基本要求**:老師提供一個免費且免金鑰的 API 網址(例如:官方冷笑話、Dog API 隨機小狗、PokeAPI 寶可夢、Agify 姓名年齡探測等)。學生需用 `requests` 抓取資料,並挑選出特定的欄位印在終端機上。
+ * **進階要求**:讓程式在 while 迴圈中,每次按下 Enter 鍵就抓取一個新的笑話。
+
+### **第五部分:防呆與除錯教學 (15 min)**
+
+* **投影片:如果服務生跌倒了怎麼辦?**
+ * **HTTP 狀態碼 (StatusCode)**:200 (成功)、404 (找不到網頁)、500 (伺服器當機)。
+* **實作防呆機制**:
+ * **優化前**:沒網路時,Python 直接噴出紅字錯誤然後閃退。
+ * **優化後 (try-except)**:
+ ```python
+ try:
+ response = requests.get(url, timeout=3)
+ if response.status_code == 200:
+ print(response.json())
+ else:
+ print(f"哎呀!伺服器回應錯誤碼:{response.status_code}")
+ except requests.exceptions.RequestException as e:
+ print("連線失敗!請檢查您的網路或等一下再試。")
+ ```
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **API (Application Programming Interface)**:應用程式介面,軟體之間溝通的橋樑。
+* **JSON (JavaScript Object Notation)**:輕量級的資料交換格式,易於人閱讀和編寫。
+* **Request / Response**:請求與回應,網頁通訊的基本一來一往模式。
+* **Exception Handling (例外處理)**:使用 `try-except` 提早攔截可能發生的錯誤,避免程式無心崩潰。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:為什麼我的 `data["name"]` 會報錯 `KeyError`?**
+ A:這代表你跟 API 點的菜(Key)不存在它的菜單裡。請先 `print(data)` 看一下服務生到底送來了什麼格式的東西!
+* **Q:程式卡住不動好久才報錯?**
+ A:因為預設 `requests` 會無止盡地等待回應。加個 `timeout=3` 可以讓它等 3 秒就放棄。
+
+## **教師筆記 (Teacher's Note)**
+
+* **重點觀察**:學生讀取 JSON 的「中括號索引」非常容易寫錯或少寫。建議在黑板上用顏色標記字典(大括號 `{}`)跟列表(中括號 `[]`)的差別。
+* **教學心法**:故意把網址打錯一個字,讓全班看看 `404` 長什麼樣子;或是請大家把電腦的 Wi-Fi 關掉,體驗程式閃退的挫折,再教 `try-except`,他們會更有感「防呆」的重要性。
+
+* **教學建議 (API 實作區塊)**:
+ * 透過讓學生在介面上修改變數(如輸入皮卡丘或座號),能幫助他們把「程式碼中的字串串接」與「網址運作機制」在腦袋中完美結合。
diff --git a/projects/2026project_class/doc/w3.md b/projects/2026project_class/doc/w3.md
new file mode 100644
index 0000000000000000000000000000000000000000..636db98decda8055406f23ba7c64e429038d0134
--- /dev/null
+++ b/projects/2026project_class/doc/w3.md
@@ -0,0 +1,87 @@
+# **第三週:數據視覺化——打造個人智慧看板 (2小時完整教案)**
+
+## **課程目標**
+
+1. 掌握 Pygame 中的文字渲染 (Text Rendering) 技術。
+2. 了解如何處理與載入中文字型 (`.ttf` 檔案) 以解決亂碼或方塊字問題。
+3. 將網路 API 獲取的動態數據,與 Pygame 的視覺元素(文字、圖像、背景)進行互動綁定。
+4. 培養 UI (使用者介面) 排版的基本美感與座標規劃能力。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[20 min] 文字變身術**:Pygame 字體加載機制與中文字體的處理。
+2. **[30 min] 動態數據綁定**:結合上週的 API 變數,將文字動態繪製至畫布。
+3. **[40 min] 圖像與邏輯聯動**:實作情境判斷,依據 API 狀態變更背景或圖片。
+4. **[30 min] 挑戰任務**:自動排版挑戰——打造專屬天氣/生活看板。
+
+## **課程詳解內容**
+
+### **第一部分:文字變身術 (20 min)**
+
+* **投影片:如何在遊戲裡印字?**
+ * 在終端機裡,印字只需要 `print()`。但在 Pygame 裡,文字其實是一張「圖片」!
+ * **三步成圖**:建立字型物件 (Font) $\rightarrow$ 渲染成圖片表面 (Render) $\rightarrow$ 貼到畫面上 (Blit)。
+* **實作:消滅中文字方塊**
+ * 預設字體不支援中文,印出中文會變 `[ ]`。
+ * 教師發放 `思源黑體.ttf` 或請學生下載字型檔放進專案資料夾。
+ * `font = pygame.font.Font("NotoSansTC-Regular.ttf", 36)`
+
+### **第二部分:動態數據綁定 (30 min)**
+
+* **投影片:讓文字活起來**
+ * 文字圖片一旦渲染就是死的,如果要讓數字跳動,必須在 `while` 迴圈中「每幀重新渲染」。
+* **實作:結合 API 數據**
+ * 複習第 2 週的 code,將抓到的溫度存進變數 `temp`。
+ * 在主迴圈中:
+ ```python
+ # 必須先擦乾淨黑板!
+ screen.fill((255, 255, 255))
+
+ # 1. 將變數變成字串,2. 渲染成圖,3. 貼到指定座標
+ text_surface = font.render(f"今日氣溫:{temp} 度", True, (0, 0, 0))
+ screen.blit(text_surface, (50, 100))
+ pygame.display.flip()
+ ```
+ * **重點討論**:少寫 `screen.fill()` 會怎樣?(字會疊加變成黑色的一團墨跡)。
+
+### **第三部分:圖像與邏輯聯動 (40 min)**
+
+* **投影片:如果下雨,請給我雨傘**
+ * If-Else 的視覺化應用。當變數改變時,畫面上的圖片也該跟著換。
+* **實作練習:狀態圖示切換**
+ * 準備兩三張小圖示:太陽、雨傘、雲朵 (`sun.png`, `rain.png`...)。
+ * 使用 `pygame.image.load()` 載入圖片。
+ * 加入邏輯判斷:
+ ```python
+ if weather == "Rain":
+ screen.blit(rain_img, (300, 100))
+ elif weather == "Clear":
+ screen.blit(sun_img, (300, 100))
+ ```
+ * 測試階段:可手動將 `weather` 變數改為不同值,測試圖片是否成功切換。
+
+### **第四部分:挑戰任務 (30 min)**
+
+* **題目:排版達人——個人智慧看板**
+ * **基本要求**:將 API 獲取的至少三項資訊(例如:溫度、濕度、地區 / 或是三個笑話),整齊排列在 Pygame 視窗中。
+ * **進階要求**:加入「顏色意義」。例如:氣溫高於 30 度時,溫度數字變成紅色;低於 20 度變成藍色。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Render (渲染)**:將抽象的資料(如字串)轉換為可見圖像的過程。
+* **Blit (位元塊傳輸)**:將一張圖片的像素資料「貼」到另一張圖片(或螢幕表面)上的動作。
+* **Anti-aliasing (反鋸齒)**:文字渲染時讓邊緣變得平滑的技術,在 `font.render()` 的第二個參數 (通常設為 True)。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:為什麼我的字破圖或是跑到視窗外面?**
+ A:你的座標 (x, y) 指的是文字的「左上角」。如果要置中,可以用 `text_surface.get_rect(center=(寬/2, 高/2))` 來對齊。
+* **Q:程式跑好慢,而且電腦風扇一直轉?**
+ A:你有沒有把 `pygame.image.load()` 或者是 `font.Font()` 寫在 While 迴圈裡面?這些是很消耗資源的動作,只需在「迴圈前」做一次就好,迴圈裡只負責畫 (blit)!
+
+## **教師筆記 (Teacher's Note)**
+
+* **資源準備**:強烈建議課前準備一個資源包 ZIP(內含字體檔、幾張天氣小圖示),統一發給學生,避免把時間浪費在網路上找圖與去背。
+* **效能觀念**:學生常犯的錯就是把初始化的載入方法放進 While True 也就是每秒讀取硬碟 60 次。這是一個很好的機會教育,讓他們了解 RAM (記憶體) 與硬碟讀取速度的差異。
diff --git a/projects/2026project_class/doc/w4.md b/projects/2026project_class/doc/w4.md
new file mode 100644
index 0000000000000000000000000000000000000000..ab61a5d24b089f852694ae7e0481f1992dcaf91f
--- /dev/null
+++ b/projects/2026project_class/doc/w4.md
@@ -0,0 +1,75 @@
+# **第四週:專案實作(一)——生活數據助理發表 (2小時完整教案)**
+
+## **課程目標**
+
+1. 整合階段一 (Week 1-3) 的知識,獨立完成一個兼具 API 數據與視覺化介面的 Pygame 專案。
+2. **【優化項目】導入敏捷開發 (Agile) 精神**:學習 Scrum 站立會議,掌握專案進度管理。
+3. **【優化項目】建立版本控制觀念**:學習備份重要程式碼,避免修改過程導致專案崩毀。
+4. 體驗產品展示 (Demo),訓練將技術邏輯轉化為使用者語言的表達能力。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[10 min] 專案啟動與 Scrum 站會**:規格說明與個人目標宣告。
+2. **[65 min] 開發衝刺 (Sprint)**:學生自主選題並實作功能。
+3. **[15 min] 版本控制防呆與除錯互相檢驗**:備份檔案練習與同儕測試。
+4. **[30 min] Demo 分享會**:每組/每人 2 分鐘展示看板。
+
+## **課程詳解內容**
+
+### **第一部分:專案啟動與 Scrum 站會 (10 min)**
+
+* **專案規格說明**:
+ * 必須要有 Pygame 視窗介面。
+ * 必須至少串接一個網路 API(如:星座、匯率、公車站牌、地震資訊)。
+ * 遇到沒有網路或 API 失效時,不能直接當機(需包含 Try-Except)。
+* **【優化實踐】微型 Scrum 站立會議**:
+ * 全班起立。
+ * 每個人用 15 秒鐘大聲說:「我今天要做的看板主題是 ______,我目前最大的挑戰可能會是 ______。」
+ * 幫助學生集中精神,確立今天 2 小時內的最高目標。
+
+### **第二部分:開發衝刺 Sprint (65 min)**
+
+* **自主實作階段**:
+ * **第一步**:從老師提供的「API 清單」中挑選一組,寫一段測試程式確定能抓到資料。
+ * **第二步**:建立 Pygame 視窗,將資料轉化為畫面上的文字。
+ * **第三步**:加入美化元素(背景圖、顏色判斷邏輯、排版)。
+* **老師的角色**:
+ * 不主動幫學生寫 Code。當學生報錯時,引導他們看終端機的最後一行紅字(Traceback),並問:「你知道這行紅字是什麼意思嗎?」
+
+### **第三部分:版本控制與除錯 (15 min)**
+
+* **【優化實踐】拯救崩潰的專案:版本備份**
+ * **情境**:「老師,我剛剛加了個新功能,結果本來會動的程式現在整個壞掉了,怎麼辦?」
+ * **教學**:在程式從「會動」要邁向「加新功能」前,教導學生將 `app.py` 複製並改名為 `app_v1_stable.py`。
+ * **觀念建立**:軟體工程師永遠不會只在一個檔案上改到死。「備份」就是給自己的後悔藥。(為日後高中學習 Git 鋪路)。
+* **QA 除錯互測**:
+ * 請右邊的同學來操作你的程式。
+ * 測試如果「斷網」(關閉 Wi-Fi),你的程式會閃退,還是溫馨提示?
+
+### **第四部分:Demo 分享會 (30 min)**
+
+* **成果展現**:
+ * 將畫面投影到大螢幕。
+ * **演講架構(強制規定)**:
+ 1. 「我的看板是做什麼用的?為誰設計的?」
+ 2. 「我抓了哪裡的 API 資料?」
+ 3. 「我在開發中遇到最大的 Bug 是什麼?我怎麼修好它的?」
+ * **回饋環節**:老師與台下同學給予掌聲與一項具體的正向回饋(例如:你的排版顏色配得很好看)。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Scrum**:一種敏捷軟體開發的架構,強調短週期、快速迭代與團隊溝通。
+* **備份 / Versioning (版本號)**:軟體開發中將穩定版本單獨儲存的行為,如 v1.0, v1.1。
+* **MVP (Minimum Viable Product)**:最小可行性產品。先把核心功能(抓資料印出來)做好,再去想華麗的額外功能(動畫、音效)。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:我的 API 突然抓不到東西了,是不是我程式壞了?**
+ A:免費 API 常常有「呼叫次數限制 (Rate Limit)」。如果你把它放在 while 迴圈裡每秒抓 60 次,你的 IP 可能被伺服器暫時封鎖了!記住,網路請求應該要「定時」或是「按鍵觸發」,而不是每幀狂抓。
+
+## **教師筆記 (Teacher's Note)**
+
+* **鷹架策略**:對於程式能力較弱的學生,老師應備好一份「半完成」的樣板檔案(已有 Pygame 架構和一個天氣 API 框架),讓他們只要填寫少數邏輯就能產出結果,建立自信心。
+* **慶祝里程碑**:這是課程第一次的成果發表。完成後可請學生截圖自己的畫面,建立學習歷程檔案。這是一個重要的情緒高點。
diff --git a/projects/2026project_class/doc/w5.md b/projects/2026project_class/doc/w5.md
new file mode 100644
index 0000000000000000000000000000000000000000..0720f010c953db26ba82e53e41d8e13384b7cd87
--- /dev/null
+++ b/projects/2026project_class/doc/w5.md
@@ -0,0 +1,94 @@
+# **第五週:哈囉鄰居!Socket 通訊與 IP 基礎 (2小時完整教案)**
+
+## **課程目標**
+
+1. 現實對照:理解 IP 地址(電腦的位置)與 Port 端口(程式的門牌)的運作關聯。
+2. 認識 UDP 通訊協定的特性,並了解為什麼遊戲偏好使用 UDP。
+3. 實作第一隻 Socket 網路傳輸程式,讓兩台不同電腦能互相發送訊息。
+4. **【優化項目】防呆機制擴展**:處理網路連線的潛在錯誤與例外狀況。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[20 min] 網路世界的地址**:IP、Port 與 Socket 的生活比喻。
+2. **[25 min] TCP vs UDP**:安全點名與廣播喇叭的差異。
+3. **[35 min] 第一手練習**:發送包裹 (Send) 與接收包裹 (Receive)。
+4. **[25 min] 挑戰任務**:班級匿名廣播電台(學生互相傳訊)。
+5. **[15 min] 網路除錯教學**:ipconfig / ifconfig 的使用,與連線失敗的防呆。
+
+## **課程詳解內容**
+
+### **第一部分:網路世界的地址 (20 min)**
+
+* **投影片:一封信是怎麼寄到的?**
+ * **IP Address (伺服器位址)**:就像「台北市信義區XX路XX號」。指出這台機器在網路上的位置(如 `192.168.1.100`)。
+ * **Port (端口號)**:打開大門後,指定的「收發室分機」。一台電腦同時有瀏覽器(Port 80)、遊戲(Port 25565)、你的程式(指定 Port 5000)。
+ * **Socket (通訊端點)**:IP + Port,一根專屬的連線水管。
+
+### **第二部分:TCP vs UDP (25 min)**
+
+* **投影片:遊戲為什麼要用 UDP?**
+ * **TCP (掛號信)**:送出 $\rightarrow$ 確認收到 $\rightarrow$ 再送下一個。絕對安全,但很慢(會卡頓)。適用於:傳檔案、載入網頁。
+ * **UDP (丟傳單)**:我一直瘋狂丟!你沒接到就算了!速度極快。適用於:射擊遊戲、視訊對話、FPS座標同步。
+* **互動問答**:「在傳送玩家 x, y 座標的時候,如果不小心漏失了第 50 幀的資料,我們需要停下來重傳嗎?」 $\rightarrow$ 不用,因為第 51 幀的資料馬上就來了!
+
+### **第三部分:第一手練習 (35 min)**
+
+* **實作:用 Python 打造無線電**
+ 1. 匯入內建模組:`import socket`。
+ 2. **建立 UDP Socket**:
+ ```python
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ ```
+ 3. **編碼與發送 (encode)**:網路水管只能傳輸位元組 `bytes`。
+ ```python
+ message = "Hello 前方同學!".encode('utf-8')
+ target_ip = "192.168.X.X" # 修改為同學的 IP
+ sock.sendto(message, (target_ip, 5000))
+ ```
+ 4. **建立接收端 (bind)**:另一台電腦必須監聽該 Port。
+ ```python
+ sock.bind(("0.0.0.0", 5000))
+ data, addr = sock.recvfrom(1024)
+ print(f"收到來自 {addr} 的訊息:", data.decode('utf-8'))
+ ```
+
+### **第四部分:挑戰任務 (25 min)**
+
+* **題目:班級廣播電台 / 座位傳訊機**
+ * 雙人一組,A 同學寫發送端程式,B 同學寫接收端程式。
+ * 成功後互相角色互換。
+ * 結合 Python `input()`,讓使用者可以在終端機打字,按下 Enter 傳給指定 IP 的同學。
+
+### **第五部分:網路除錯與防呆 (15 min)**
+
+* **實用技能:找自己的 IP**
+ * Windows:打開 cmd 輸入 `ipconfig`,尋找 IPv4 位址。
+ * Mac:打開終端機輸入 `ifconfig` 或查系統設定。
+* **【優化實踐】網路錯誤處理**:
+ * 當我們試圖傳給一個不存在的 IP,或是網路根本斷線時,加上 `try-except` 防止閃退:
+ ```python
+ try:
+ sock.sendto(msg, (ip, port))
+ except OSError as e:
+ print("網路傳輸失敗,請檢查 Wi-Fi 是否連線或 IP 格式是否正確!")
+ ```
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Localhost (127.0.0.1)**:本機地址。自己傳給自己測試時使用的安全 IP。
+* **Socket**:插座/通訊端點,是 OS 提供給程式設計師連接網路的 API 工具。
+* **Encode / Decode**:字串與位元組 (bytes) 之間的轉換,網路傳輸一律需要 encode 成二進位。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:為什麼我的程式一直卡在 `recvfrom()` 不會動?**
+ A:因為接收是「阻塞的 (Blocking)」。它在等信箱掉進信件前,會讓整個程式停下來發呆。我們下週會教如何把它變成「不等待(Non-blocking)」!
+* **Q:IP 打對了,但對方完全收不到?**
+ A:通常是被 Windows Defender 防火牆或防毒軟體擋住了。若跳出「是否允許存取網路」請務必選「允許」。如果還是不行,可能是學校區網禁止設備互連 (AP Isolation)。
+
+## **教師筆記 (Teacher's Note)**
+
+* **開課前必要確認!!!**:強烈建議課外時間親自使用教室的網路環境測試 UDP 連線。許多學校的無線網路為了安全,會開啟 "Client Isolation" 阻擋電腦互連。若現場遇到此狀況,請立即切換至備案:讓學生用 `127.0.0.1` 開兩個 VSCode 視窗假裝是兩台電腦互傳。
+* **分組策略**:網路連線很容易有「挫折感」,因為一個人錯,兩個人的程式都不會動。建議老師把配對 IP 的清單寫在黑板上,明確規定誰跟誰一組。
diff --git a/projects/2026project_class/doc/w6.md b/projects/2026project_class/doc/w6.md
new file mode 100644
index 0000000000000000000000000000000000000000..50481ca4895eea8bc7a84a861cb761d9290b8778
--- /dev/null
+++ b/projects/2026project_class/doc/w6.md
@@ -0,0 +1,84 @@
+# **第六週:數據的包裹——JSON 序列化與同步 (2小時完整教案)**
+
+## **課程目標**
+
+1. 了解如何將複雜狀態(如座標、分數、玩家名稱)打包為 `JSON` 格式的短字串。
+2. 理解遊戲主迴圈中的「阻塞 (Blocking)」與「非阻塞 (Non-blocking)」概念。
+3. 實作「即時座標同步」,讓自己的操作能在另一台電腦上顯示。
+4. **【核心難點克服】**:學習處理網路延遲造成的丟包異常,透過 `try-except (BlockingIOError, ConnectionResetError)` 讓程式保持順暢。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[20 min] 資料的任意門**:打包寄送 (dumps) 與拆箱讀取 (loads)。
+2. **[30 min] 處理空白時光**:Blocking vs Non-blocking 的觀念解析。
+3. **[40 min] 即時座標同步實作**:將玩家方塊 `(x, y)` 化為 JSON 封包互傳。
+4. **[30 min] 挑戰任務**:影子雙子星——A動B動的跟隨遊戲。
+
+## **課程詳解內容**
+
+### **第一部分:資料的任意門 (20 min)**
+
+* **投影片:遊戲伺服器怎麼知道你在哪裡?**
+ * **序列化 (Serialization)**:也就是打包行李。
+ * **反序列化 (Deserialization)**:拆開行李並把衣服拿出來。
+ * 我們不能直接把 Python 的 `[100, 200]` 用水管傳出去。必須把它變成通用語言 (JSON String) $\rightarrow$ 編碼 (Bytes) $\rightarrow$ 網路層 $\rightarrow$ 解碼 (String) $\rightarrow$ 解析 (Python 列表)。
+* **短暫練習**:
+ ```python
+ import json
+ package = {"id": "player1", "x": 100, "y": 200, "hp": 5}
+ string = json.dumps(package) # 打包成字串
+ original = json.loads(string) # 拆開來
+ ```
+
+### **第二部分:處理空白時光 (30 min)**
+
+* **核心問題探討**:
+ * 上週的程式碼會卡在 `recvfrom()`,直到收到信為止。但是遊戲每一秒要跑 60 次,如果這 1/60 秒我沒有收到信,畫面就不畫了嗎?
+* **解決方案:非阻塞模式 (Non-blocking)**:
+ ```python
+ sock.setblocking(False) # 告訴水管:不用等!沒信就跟我說沒信,不要讓我傻站著。
+ ```
+* **防呆機制**:
+ * 當沒有信件時,會跳出 `BlockingIOError` (Windows 下可能是 `ConnectionResetError`)。我們必須**優雅地忽略它**。
+ ```python
+ try:
+ data, addr = sock.recvfrom(1024)
+ # 處理收到資料的邏輯
+ except (BlockingIOError, ConnectionResetError):
+ pass # 沒信也無所謂,裝沒事!
+ ```
+
+### **第三部分:即時座標同步實作 (40 min)**
+
+* **步驟拆解與編程**:
+ 1. 初始化玩家 `x`, `y` 為 100, 100。對手 `ax`, `ay` 為 200, 200。
+ 2. 加入按鍵偵測,控制自己 `(x, y)` 移動。
+ 3. **送出資料**:每次 `while` 迴圈結束前,將 `{'x': x, 'y': y}` 的字串送給對方。
+ 4. **接收資料**:每次迴圈開頭,用 `try-except` 接收對手的封包。如果收到,更新對手 `(ax, ay)` 的座標。
+ 5. 畫出兩個不同顏色的方塊。
+
+### **第四部分:挑戰任務 (30 min)**
+
+* **題目:影子跟隨賽**
+ * 在同一個畫面中,藍色是自己,紅色是對手。
+ * 當自己按空白鍵,螢幕背景隨機變一個顏色;並且,對手螢幕的背景也要**同時**變成該顏色。
+ * **考驗**:你要怎麼把「顏色」的資訊也寫進打包好的 JSON 裡面?(e.g., `{'x': 100, 'y': 200, 'color': 'red'}`)。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Blocking / Non-blocking**:程式語言中是否無窮盡地等待某個 I/O (讀寫) 動作的屬性設定。
+* **Serialization (序列化)**:將記憶體中的複雜資料結構,轉換為可以儲存成檔案或在網路傳輸的位元組序列的過程。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:為什麼我的對手走起來一卡一卡(Lag)的?**
+ A:可能是一次發出去的資料包太多擠在緩衝區,或者網路的延遲。在高級遊戲開發中,通常會加入「平滑插值 (Interpolation)」算法來讓動作看起來更滑順。
+* **Q:我加了 `setblocking(False)` 結果還是閃退了?**
+ A:如果用錯例外名稱(如寫成一般的 `Exception` 或者沒寫 `pass`),你的程式還是會崩潰。這是極度精密的一行防護網。
+
+## **教師筆記 (Teacher's Note)**
+
+* **網路除錯**:同學常在這裡徹底搞混自己是發送方還是接收方。建議兩兩互助,讓 A 專心寫發送、B 專心寫接收。如果是雙向互傳,一定要將兩個 Python 檔案的送出 IP 正確對應,因為如果寫錯,很可能變成不小心傳給隔壁組的慘劇。
+* **物理具象化**:在講解「阻塞/非阻塞」時,可以請一個同學當郵差,如果在門口等主人(程式)開門拿信,郵差後面的工作都會停擺;如果主人設了一個信箱(非阻塞),郵差丟了就走,主人才不管有信沒信都繼續看電視。
diff --git a/projects/2026project_class/doc/w7.md b/projects/2026project_class/doc/w7.md
new file mode 100644
index 0000000000000000000000000000000000000000..5e555b049a0759bf203e6e69d80f8f8e253c464c
--- /dev/null
+++ b/projects/2026project_class/doc/w7.md
@@ -0,0 +1,75 @@
+# **第七週:對戰邏輯——誰打中了誰? (2小時完整教案)**
+
+## **課程目標**
+
+1. 了解網路遊戲中的「客戶端」與「伺服器端」的信任機制與狀態同步。
+2. 實作事件觸發的 JSON 封包傳輸(例如:按下空白鍵發射子彈)。
+3. 使用 `pygame.Rect.colliderect` 實現在分散式系統中的碰撞判定與分數增減。
+4. 培養「各自判定、更新結果」的基本連線對戰思維。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[20 min] 邏輯的衝突**:當兩邊同時開槍,誰先被爆頭?從 P2P 探討信任機制。
+2. **[30 min] 發射!不只是座標**:增加 "action" 事件型的網路傳遞。
+3. **[40 min] 碰撞判定**:用 Rects 來判斷子彈是否擊中對方方塊。
+4. **[30 min] 挑戰任務**:加入「重生成為新獵人」與血條系統。
+
+## **課程詳解內容**
+
+### **第一部分:邏輯的衝突 (20 min)**
+
+* **觀點問題**:「如果我的螢幕上顯示我打中你了,但你的螢幕上顯示你閃過了,到底聽誰的?」
+* **兩種網路遊戲架構科普**:
+ * **主從架構 (Client-Server)**:有一個權威伺服器(上帝)說了算。CS:GO, 英雄聯盟。防外掛能力強,但建置困難。
+ * **對等網路 (P2P)**:我們互相溝通,自己判斷自己有沒有受傷。我們這週要實作的版本。如果你覺得你被打中,你就扣血並通知我。
+
+### **第二部分:發射!不只是座標 (30 min)**
+
+* **核心改變:打包不同類型的包裹**
+ * 原本每幀狂送的封包是持續性資料:`{'type': 'pos', 'x': 100, 'y': 200}`
+ * 按下發射時,立刻送出單發事件封包:`{'type': 'shoot', 'x': 100, 'y': 200, 'dir': 'up'}`
+* **實作技巧**:
+ * 在接收端,讀出 `data['type']` 後,用 `if-elif` 進行不同的處理。如果是 `'pos'` 就覆蓋對方影像座標;如果是 `'shoot'` 就產生一顆對方的子彈畫在螢幕上。
+
+### **第三部分:碰撞判定 (40 min)**
+
+* **投影片:隱形的碰撞框 (Rect)**
+* **程式碼實戰:扣血與回報**
+ * 使用 pygame 強大的碰撞函數:
+ ```python
+ my_rect = pygame.Rect(x, y, 50, 50)
+ bullet_rect = pygame.Rect(bx, by, 10, 10)
+
+ # 【P2P 邏輯】:只有我能判斷我自己的身體有沒有被 對方 的子彈打到
+ if my_rect.colliderect(bullet_rect):
+ hp -= 1
+ print("我被打中了!目前血量:", hp)
+ # 通知對方:我少了一滴血
+ sock.sendto(json.dumps({'type': 'hit', 'hp': hp}).encode(), (ip, port))
+ ```
+ * 同步在畫面上畫出 `hp` 的數值字串。
+
+### **第四部分:挑戰任務 (30 min)**
+
+* **題目:網路復仇戰**
+ * **基本要求**:血量 (hp) 歸零時,自己的角色會瞬間移動到螢幕中央,血量恢復到 10。同時通知對方自己的血量。
+ * **進階要求**:在畫面的最上方用兩個變數 (`my_score`, `enemy_score`) 記錄雙方的得分擊殺數。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Client / Server 架構**:多數網路遊戲採取的設計模式,Server 是大腦與驗證中心,Client 只是螢幕顯示器與遙控器搖桿。
+* **Hitbox 碰撞箱**:包裹在角色外圍的透明隱形框框,用於純邏輯的重疊計算,可以大於或小於真實精美的角色圖片。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:我的子彈飛一飛突然消失了?**
+ A:你的子彈可能在每一幀都被重新定義,或是儲存子彈的串列 (`bullet_list`) 在重新接收封包時被清空了。將子彈設定為持續移動的實體物件!
+* **Q:對手送過來的血量 `hp` 是字串還是數字?**
+ A:JSON 原生支援數字(如果在 JSON 裡面沒有加雙引號)。所以 `data['hp'] - 1` 是可以直接計算的。不要跟 `pygame` 的字串搞混。
+
+## **教師筆記 (Teacher's Note)**
+
+* **除錯難度極高**:這節課是全期課程最痛的一節課,因為每個 Bug 都可能是「自己沒送出」、「自己送錯格式」、「對方沒收到」或「對方收到沒更新畫面」等組合問題。
+* **最佳實踐**:強烈建議提供一個「可運作的網路子彈模板」,讓學生專注在修改 `colliderect` 這部分的邏輯,而不是在那邊對水管丟封包浪費了 40 分鐘。在程式架構中先定義一個乾淨的 `update_network_events()` 函式將網路與遊戲繪圖剝離。
diff --git a/projects/2026project_class/doc/w8.md b/projects/2026project_class/doc/w8.md
new file mode 100644
index 0000000000000000000000000000000000000000..570a74ff59885f05d583d9b82c03bd5c77b43323
--- /dev/null
+++ b/projects/2026project_class/doc/w8.md
@@ -0,0 +1,68 @@
+# **第八週:專案實作(二)——班級網路競技場(2小時完整教案)**
+
+## **課程目標**
+
+1. 將第六、七週的散碎技巧整合為一款完整的雙人連線遊戲雛形。
+2. **【優化項目】PBL 敏捷開發 Scrum 站會**:運用站立會議追蹤衝刺進度,明確訂立最小達標規格 (MVP)。
+3. 在真實多設備連網環境下進行「壓力測試」,學習找尋網路異常的 Bug。
+4. **【優化項目】課前硬體建置**:在課堂的最後 15 分鐘,提早全班完成 Python `OpenCV` 及 `MediaPipe` 環境安裝,並拍攝/儲存確認攝影機能否抓取到畫面。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[10 min] 專案啟動與 Scrum 站會**:團隊分工與目標宣告。
+2. **[50 min] 遊戲打磨與除錯衝刺**:完善計分系統、音效與畫面。
+3. **[45 min] 班級競技互聯**:小組對調試玩,測試雙方的「延遲感」與防呆。
+4. **[15 min] 面對未來:AI 環境課前測試**:全班統一安裝並試跑 cv2 鏡頭畫面。
+
+## **課程詳解內容**
+
+### **第一部分:專案啟動與 Scrum 站會 (10 min)**
+
+* **MVP 規格**:
+ * 按下空白鍵時雙方能互射。
+ * 血量減少,一方死掉後會有簡單的新回合刷新機制或勝負介面。
+ * 不會因為沒收到網路封包而卡頓。
+* **Scrum 站會**:
+ * 以兩人一組為主體,站立回報:「我們目前的對戰遊戲還缺什麼?今天誰要寫血量,誰要寫網路傳遞?」
+ * 督促他們互相溝通傳送的資料格式(例如約定包裹長這樣:`{"t": "mv", "x": 10, "y": 20}` 越短越快)。
+
+### **第二部分:遊戲打磨與除錯衝刺 (50 min)**
+
+* **重點實作**:
+ 1. **背景佈置**:透過 Pygame 載入並縮放背景(太空、荒野等)。
+ 2. **加入音效**:使用 `pygame.mixer.Sound` 加入開槍「砰」、受傷「啊」的聲音。(音效放在本機端播放,不透過網路傳輸音檔!)
+ 3. **結算畫面**:當某方血量歸零時,`screen.fill()` 畫上全螢幕的黑色與大字「YOU DIED」。
+* **老師輔導重點**:專注巡視是否發生「Blocking」導致程式死當。一旦發現,引導加上 `try-except` 或 `setblocking(False)`。
+
+### **第三部分:班級競技大亂鬥 (45 min)**
+
+* **體驗與互測 (QA Testing)**:
+ * 「這款網路遊戲的開發已經完成!現在開放全場自由挑戰!」
+ * 鼓勵 A 組去連線挑戰 B 組的 IP。這考驗他們是否能快速在程式碼中修改 Target IP 並重新啟動(若程式包裝得好,一開場就能輸入欲連線的 IP)。
+ * 觀察與記錄:誰的連線特別順?誰的對戰會「瞬移」或「子彈消失」?原因分析討論。
+
+### **第四部分:【優化項目】AI 環境課前測試 (15 min)**
+
+* **為下週(Week 9)的神奇魔術做準備**:
+ * **安裝三項神器**:打開命令提示字元,輸入:
+ `pip install opencv-python mediapipe numpy`
+ * **測試腳本**:老師提供一份 10 行腳本,只做一件事:用 `cv2.VideoCapture(0)` 讀取畫面並顯示。
+ * **環境防呆盤點**:筆電鏡頭沒反應、權限被拒絕、桌機沒鏡頭。
+ * **硬體備案說明**:如果硬體有問題,請教師將「手部揮動預錄影片 (.mp4)」拷貝給學生備用,下週直接將其取代 `(0)` 即可繼續上課。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Stress Test (壓力測試)**:在極端或多人同時使用的情境下,測試系統會不會崩潰的過程。
+* **Quality Assurance (QA)**:確保軟體上線沒有大問題的品管階段,互相找 Bug 的樂趣所在。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:老師,我們不同組寫的遊戲可以互連嗎?**
+ A:如果兩組對封包的「約定格式」完全一樣,就可以!但只要有一組是用 `{"bullet_x": 10}`,另一組是用 `{"bx": 10}`,連線就會立刻引發 `KeyError` 崩潰崩潰。這就是為什麼制定共同的 API 標準這麼重要!
+
+## **教師筆記 (Teacher's Note)**
+
+* **心態指引**:網路對戰幾乎沒有一次能做完美的。學生很有可能會對 Bug 感到沮喪。此時提醒他們:「你們現在碰到的問題,包含英雄聯盟和暴雪等大公司也都每天在面對。這就是『分散式系統』的浪漫。」
+* **提早測試鏡頭非常重要**:這能把第 9 週當「維修工人」的時間轉移到這堂課最後。若沒做到這點,下週會是一場因為硬體阻礙導致教學進度全線崩潰的災難。
diff --git a/projects/2026project_class/doc/w9.md b/projects/2026project_class/doc/w9.md
new file mode 100644
index 0000000000000000000000000000000000000000..34a664a8d09a69e2fff57ac51d2c67061674a4e8
--- /dev/null
+++ b/projects/2026project_class/doc/w9.md
@@ -0,0 +1,93 @@
+# **第九週:數位眼睛——MediaPipe 視覺辨識入門 (2小時完整教案)**
+
+## **課程目標**
+
+1. 了解電腦視覺 (Computer Vision) 與機器學習模型將影像轉換成數學矩陣的概念。
+2. 學習讀取攝影機或影片來源 (OpenCV),並透過 MediaPipe 套件取得手部關鍵點資訊。
+3. 將抽象的 3D 手勢座標點 (Landmarks 0~20) 轉換並映射至 Pygame 的 2D 畫布。
+4. **【優化項目】環境備案整合**:確保每一位學生在無硬體鏡頭的情況下也能流暢跟隨教學。
+
+## **課程時間分配 (總計 120 分鐘)**
+
+1. **[15 min] 從矩陣到眼睛**:電腦怎麼看得見畫面的?
+2. **[20 min] OpenCV 第一步**:抓取影像影格 (Frame) 並顯示(或載入備用影片防呆)。
+3. **[45 min] MediaPipe 的魔幻洗禮**:尋找在 0.01 秒內捕捉手骨架的秘密,提取並列印食指座標。
+4. **[40 min] 挑戰任務:空中雷射筆**:將 MediaPipe 座標連動回 Pygame 的視窗繪製點位。
+
+## **課程詳解內容**
+
+### **第一部分:從矩陣到眼睛 (15 min)**
+
+* **投影片:一秒 30 張圖的魔法**
+ * **電腦不看臉,只看數字**:說明影像本質就是高度 $x$ 寬度 $x$ 3(RGB 色彩通道) 的立體數字陣列 (Numpy Array)。
+ * **強大的預錄模型 (Pre-trained Model)**:介紹 Google 如何餵給電腦數十萬張不同膚色、角度的手的圖片,讓這包名為 MediaPipe 的套件自動找出 21 個固定的骨節點。
+
+### **第二部分:OpenCV 第一步 (20 min)**
+
+* **實戰:抓取影像流**
+ * 上週已有安裝測試基礎,這次進行正式讀取並加上反轉 (鏡面)。
+ ```python
+ import cv2
+
+ # 【優化防呆】預錄影片方案:cap = cv2.VideoCapture("test_hand.mp4")
+ cap = cv2.VideoCapture(0)
+
+ while True:
+ success, img = cap.read()
+ if not success:
+ break # 或者在讀影片完畢時重設到開頭
+
+ img = cv2.flip(img, 1) # 變成鏡面,直覺才對
+ cv2.imshow("Hand Tracking", img)
+ if cv2.waitKey(1) & 0xFF == ord('q'):
+ break
+ ```
+
+### **第三部分:MediaPipe 的魔幻洗禮 (45 min)**
+
+* **引入魔法模組**:
+ ```python
+ import mediapipe as mp
+ mp_hands = mp.solutions.hands
+ hands = mp_hands.Hands(max_num_hands=1)
+ ```
+* **運算與提取:解析 Landmarks**
+ * 在迴圈中將 BGR 圖片轉換為 RGB 傳給 `hands.process(img_rgb)`。
+ * 教學重點:拿出一張寫著「0~20點」的手勢解剖圖圖示,告訴學生食指尖 (Index Finger Tip) 是第 8 點。
+ * **提取資料**:
+ ```python
+ if results.multi_hand_landmarks:
+ for hand_lms in results.multi_hand_landmarks:
+ lm8 = hand_lms.landmark[8]
+ # 痛點:MediaPipe 回傳的只是比例 (0~1),不是像素!
+ h, w, c = img.shape
+ cx, cy = int(lm8.x * w), int(lm8.y * h)
+ print(f"食指座標找到了:({cx}, {cy})")
+ ```
+
+### **第四部分:挑戰任務 - 空中雷射筆 (40 min)**
+
+* **題目:把 AI 裝進遊戲引擎裡**
+ * 跳脫純粹的黑底 OpenCV 視窗。我們要把 `cx`, `cy` 變成剛才教過了那麼多週的 Pygame 移動變數 `x, y`!
+ * **架構挑戰**:讓 Pygame 負責 `screen.fill()` 跟畫圈圈,而 OpenCV 的 `cap.read()` 放進 Pygame 的主迴圈裡。
+ * 當你的手指在鏡頭前移動時,螢幕上有個 Pygame 畫出的紅色圖形要像是「雷射筆」一樣黏在你的食指上。
+
+## **知識擴充 (NotebookLM 推薦素材)**
+
+### **關鍵術語表 (Glossary)**
+
+* **Computer Vision (CV)**:使電腦能從數位影像或影片的高維度資料中獲取高層級理解的科學。
+* **Landmarks (地標 / 關鍵點)**:在臉部或人體骨架辨識中,標記特定特徵(如鼻尖、指尖、手腕)的數字座標。
+* **BGR vs RGB**:OpenCV 歷史遺留的問題,預設讀出來的藍紅通道是相反的,丟給強迫症發作的 AI 之前一定要做 色彩空間轉換 (`cv2.cvtColor`)。
+
+### **常見問題解答 (FAQ)**
+
+* **Q:老師,我的影像讀出來完全是灰的或綠色的?**
+ A:這代表你很有可能是忘了做 BGR 轉 RGB 的動作,導致 MediaPipe 這個「外國人」看嘸你傳來的心情不好的藍綠色手!
+* **Q:畫面跑得超級無敵慢,連三秒鐘前揮的手現在才出來?**
+ A:你的電腦算力不足。請幫忙加上 `cv2.resize()` 把輸入影像等比例變小,比如 640x480 的一半,能大幅減輕 CPU 的地獄高溫負擔。
+
+## **教師筆記 (Teacher's Note)**
+
+* **【優化防呆實體化】**:請在教室分享資料夾或群組直接丟好一支叫做 `backup_hand_video.mp4` 的影片檔,並強調:「跑不動的、攝影機出奧步的同學,請把 0 換成檔名,學習是不會因為硬體而停下腳步的。」這不僅安撫學生心情,也傳達了程式開發具備「解耦輸入源測試」的軟體工程意涵。
+* **從抽象到具體**:當在終端機瘋狂印出 `cx, cy` 座標,再讓他們親自手動乘上視窗大小寬度時,學生會因為親手把這數學概念轉換為可以移動物體的神力而興奮不已。這是體驗 AI 威力的第一個「魔幻時刻」。
diff --git a/projects/2026project_class/examples/.DS_Store b/projects/2026project_class/examples/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..4d5e1937af5d5001facc541d87b8f16507870bca
Binary files /dev/null and b/projects/2026project_class/examples/.DS_Store differ
diff --git a/projects/2026project_class/examples/background.jpg b/projects/2026project_class/examples/background.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..7b98b815bc295e37f93ad72b68f7990170aa05d4
--- /dev/null
+++ b/projects/2026project_class/examples/background.jpg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b4550b2073dfd8267a1d2fc94620e82aada100ab95114d44a62eef8e40e3e7e
+size 220630
diff --git a/projects/2026project_class/examples/extra_apis/advice_slip_api.py b/projects/2026project_class/examples/extra_apis/advice_slip_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e58c20bc3855d43a730c1f1722ebe505616c8cc
--- /dev/null
+++ b/projects/2026project_class/examples/extra_apis/advice_slip_api.py
@@ -0,0 +1,31 @@
+import requests
+import time
+
+url = "https://api.adviceslip.com/advice"
+
+print("==============================")
+print(" 🔮 終端機人生解答書")
+print("==============================\n")
+
+while True:
+ user_input = input("心中默念一個問題,然後按 Enter 獲得指引 (輸入 q 退出):")
+
+ if user_input.lower() == 'q':
+ break
+
+ try:
+ print("宇宙正在為你尋找答案...\n")
+ time.sleep(1) # 製造一點神秘的懸疑感
+
+ response = requests.get(url, timeout=3)
+ if response.status_code == 200:
+ data = response.json()
+ # 這個 API 的結構是 {"slip": { "id": 1, "advice": "..." }}
+ advice = data["slip"]["advice"]
+
+ print("------------------------------")
+ print(f"✨ 宇宙的指引: {advice}")
+ print("------------------------------\n")
+
+ except Exception as e:
+ print("宇宙暫時沒有回應,請稍後再試。")
diff --git a/projects/2026project_class/examples/extra_apis/agify_api.py b/projects/2026project_class/examples/extra_apis/agify_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..2c76b286ae9a6368f1520c7f7e67d0922fce7d91
--- /dev/null
+++ b/projects/2026project_class/examples/extra_apis/agify_api.py
@@ -0,0 +1,34 @@
+import requests
+
+print("==============================")
+print(" 🕵️♂️ 姓名年齡探測器")
+print("==============================\n")
+print("這個神秘的系統分析了全球千萬筆戶政資料,能透過你的『英文名字』猜出你的年齡!\n")
+
+while True:
+ name = input("請輸入一個英文名字 (例如 Steve, Kevin, Emma,輸入 q 退出):")
+
+ if name.lower() == 'q':
+ break
+
+ url = f"https://api.agify.io?name={name}"
+
+ try:
+ response = requests.get(url, timeout=3)
+ if response.status_code == 200:
+ data = response.json()
+
+ estimated_age = data.get("age")
+ count = data.get("count")
+
+ print("\n------------------------------")
+ if estimated_age:
+ print(f"名字: {name.capitalize()}")
+ print(f"預估年齡: {estimated_age} 歲")
+ print(f"(根據全球 {count} 位名叫 {name.capitalize()} 的統計數據)")
+ else:
+ print(f"資料庫太小了,猜不出 {name} 的年齡。")
+ print("------------------------------\n")
+
+ except Exception as e:
+ print("連線錯誤,無法連接至大數據智庫。")
diff --git a/projects/2026project_class/examples/extra_apis/dog_api.py b/projects/2026project_class/examples/extra_apis/dog_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..3acf37677edd36d96c80016a2775519c49d4a374
--- /dev/null
+++ b/projects/2026project_class/examples/extra_apis/dog_api.py
@@ -0,0 +1,29 @@
+import requests
+
+url = "https://dog.ceo/api/breeds/image/random"
+
+print("==============================")
+print(" 🐶 隨機狗狗圖片網址產生器")
+print("==============================\n")
+
+while True:
+ user_input = input("按 Enter 看狗狗 (輸入 q 退出):")
+
+ if user_input.lower() == 'q':
+ break
+
+ try:
+ response = requests.get(url, timeout=3)
+ if response.status_code == 200:
+ data = response.json()
+ image_url = data["message"]
+ status = data["status"]
+
+ if status == "success":
+ print("\n------------------------------")
+ print("🐾 成功獲得狗狗!請按住 Ctrl (或 Cmd) 並用滑鼠點擊下方網址:")
+ print(f"🔗 {image_url}")
+ print("------------------------------\n")
+
+ except Exception as e:
+ print("狗狗在睡覺,網路連線失敗。")
diff --git a/projects/2026project_class/examples/extra_apis/numbers_api.py b/projects/2026project_class/examples/extra_apis/numbers_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3ce245f22c4deade696d08d3a0f4a32f2dd1073
--- /dev/null
+++ b/projects/2026project_class/examples/extra_apis/numbers_api.py
@@ -0,0 +1,34 @@
+import requests
+
+print("==============================")
+print(" 🔢 數字冷知識圖書館")
+print("==============================\n")
+
+while True:
+ number_str = input("請輸入任意一個數字 (例如 42 或 100,輸入 q 退出):")
+
+ if number_str.lower() == 'q':
+ break
+
+ # 檢查輸入的是不是數字
+ if not number_str.isdigit():
+ print("⚠️ 只能輸入數字喔!\n")
+ continue
+
+ # 要求 JSON 格式回傳 (?json)
+ url = f"http://numbersapi.com/{number_str}?json"
+
+ try:
+ response = requests.get(url, timeout=3)
+ if response.status_code == 200:
+ data = response.json()
+
+ fact = data["text"]
+
+ print("\n------------------------------")
+ print(f"📚 關於數字 {number_str} 的冷知識:")
+ print(f"英文: {fact}")
+ print("------------------------------\n")
+
+ except Exception as e:
+ print("無法連線至圖書館。")
diff --git a/projects/2026project_class/examples/extra_apis/poke_api.py b/projects/2026project_class/examples/extra_apis/poke_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..11eb8305e776048e97c24a4770dd8d94dcce16e4
--- /dev/null
+++ b/projects/2026project_class/examples/extra_apis/poke_api.py
@@ -0,0 +1,42 @@
+import requests
+
+# PokeAPI: 全球最知名的開源 Pokemon 資料庫
+print("==============================")
+print(" 🔴 寶可夢圖鑑查詢器")
+print("==============================\n")
+
+while True:
+ pokemon_name = input("請輸入英文寶可夢名稱或編號 (例如 pikachu 或 25,輸入 q 退出):").lower()
+
+ if pokemon_name == 'q':
+ break
+
+ url = f"https://pokeapi.co/api/v2/pokemon/{pokemon_name}"
+
+ try:
+ print(f"正在連線到洛托姆圖鑑獲取 {pokemon_name} 的資料...")
+ response = requests.get(url, timeout=3)
+
+ if response.status_code == 200:
+ data = response.json()
+
+ name = data["name"].capitalize()
+ height = data["height"] / 10 # API 回傳的單位是公寸
+ weight = data["weight"] / 10 # API 回傳的單位是百克
+
+ # 獲取屬性列表 (這是一個陣列,我們可以用 list comprehension 取出來)
+ types = [t["type"]["name"] for t in data["types"]]
+ type_str = ", ".join(types)
+
+ print("\n------------------------------")
+ print(f"名稱: {name}")
+ print(f"屬性: {type_str}")
+ print(f"身高: {height} m")
+ print(f"體重: {weight} kg")
+ print("------------------------------\n")
+
+ elif response.status_code == 404:
+ print(f"\n❌ 找不到名為 '{pokemon_name}' 的寶可夢,請檢查拼字!\n")
+
+ except Exception as e:
+ print("網路連線失敗,無法連接到圖鑑伺服器。")
diff --git a/projects/2026project_class/examples/extra_apis/random_user_api.py b/projects/2026project_class/examples/extra_apis/random_user_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..5fac3d518a443013bc11d6af2f2f3cd49cbf5115
--- /dev/null
+++ b/projects/2026project_class/examples/extra_apis/random_user_api.py
@@ -0,0 +1,41 @@
+import requests
+
+url = "https://randomuser.me/api/"
+
+print("==============================")
+print(" 🎭 假身分製造機")
+print("==============================\n")
+print("需要寫小說或是註冊不想用的網站嗎?按下 Enter 產生一個隨機的虛擬外國身分。\n")
+
+while True:
+ user_input = input("按 Enter 取得新身分 (輸入 q 退出):")
+
+ if user_input.lower() == 'q':
+ break
+
+ try:
+ # 發送網路請求,獲取假身分
+ response = requests.get(url, timeout=3)
+ if response.status_code == 200:
+ data = response.json()
+
+ # 這個 API 的資料藏得比較深,需要一層一層撥開
+ # 資料結構長這樣: {"results": [ {"name": {"first": "...", "last": "..."}}, ... ] }
+ person = data["results"][0]
+
+ first_name = person["name"]["first"]
+ last_name = person["name"]["last"]
+ gender = "男" if person["gender"] == "male" else "女"
+ country = person["location"]["country"]
+ email = person["email"]
+ age = person["dob"]["age"]
+
+ print("\n------------------------------")
+ print(f"👤 姓名: {first_name} {last_name} ({gender})")
+ print(f"🎂 年齡: {age} 歲")
+ print(f"🌍 國家: {country}")
+ print(f"📧 信箱: {email}")
+ print("------------------------------\n")
+
+ except Exception as e:
+ print("身分偽造局暫時下線,請稍後再試。")
diff --git a/projects/2026project_class/examples/face_landmarker.task b/projects/2026project_class/examples/face_landmarker.task
new file mode 100644
index 0000000000000000000000000000000000000000..fedb14de6d2b6708a56c04ae259783e23404c1aa
--- /dev/null
+++ b/projects/2026project_class/examples/face_landmarker.task
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff
+size 3758596
diff --git a/projects/2026project_class/examples/hand_landmarker.task b/projects/2026project_class/examples/hand_landmarker.task
new file mode 100644
index 0000000000000000000000000000000000000000..5ecab741879892d97c2f90bbf03bf55d7213db7c
--- /dev/null
+++ b/projects/2026project_class/examples/hand_landmarker.task
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1
+size 7819105
diff --git a/projects/2026project_class/examples/hit.wav b/projects/2026project_class/examples/hit.wav
new file mode 100644
index 0000000000000000000000000000000000000000..d138a2494493124e0200999b18a4c4315b860a72
--- /dev/null
+++ b/projects/2026project_class/examples/hit.wav
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:55440a99e00bb9de3a53bcaa709c83d412001f78dcf7103036ec02a5057c2fdd
+size 13274
diff --git a/projects/2026project_class/examples/shoot.wav b/projects/2026project_class/examples/shoot.wav
new file mode 100644
index 0000000000000000000000000000000000000000..e4b1fe39a70052df01006c8b89a352f6e18b3f3d
--- /dev/null
+++ b/projects/2026project_class/examples/shoot.wav
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:091481354d97c5469a2cd40f16d8fad63904eb4676992c34c1f208fc87a8173c
+size 17684
diff --git a/projects/2026project_class/examples/test_mp.py b/projects/2026project_class/examples/test_mp.py
new file mode 100644
index 0000000000000000000000000000000000000000..9b7f3c163579b1d21d568851b9ede2ffb2e2ec28
--- /dev/null
+++ b/projects/2026project_class/examples/test_mp.py
@@ -0,0 +1,14 @@
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import numpy as np
+
+base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'hand_landmarker.task'))
+options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+detector = vision.HandLandmarker.create_from_options(options)
+
+img = np.zeros((480, 640, 3), dtype=np.uint8)
+mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img)
+result = detector.detect(mp_image)
+print("Hand detected?", bool(result.hand_landmarks))
diff --git a/projects/2026project_class/examples/w10_pinch_complete.py b/projects/2026project_class/examples/w10_pinch_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd47093d988373db13306a41baa3a206c21cbc45
--- /dev/null
+++ b/projects/2026project_class/examples/w10_pinch_complete.py
@@ -0,0 +1,179 @@
+"""
+Week 10 - 魔法手勢:Pinch 偵測與手勢按鈕 (Complete 完整版)
+=============================================================
+本週目標:
+ 1. 用勾股定理計算大拇指 (4) 與食指 (8) 的距離
+ 2. 實作 Pinch (掐合) 偵測:距離 < Threshold 就是捏起來了
+ 3. 用手指碰觸 Pygame 畫面上的按鈕 + 掐合 = 魔法點擊
+ 4. 使用邊緣觸發 (Edge Detection) 防止重複觸發
+
+操作方式:
+ - 食指移動到按鈕上方 + 大拇指與食指捏合 = 點擊按鈕
+ - 顏色會隨點擊切換
+
+注意:
+ - 確保 hand_landmarker.task 檔案和這個腳本放在同一個資料夾。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import math
+import sys
+import os
+
+# ============================================
+# 1. 初始化
+# ============================================
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 10 - 魔法手勢按鈕 🖐️✨")
+clock = pygame.time.Clock()
+
+# 攝影機
+cap = cv2.VideoCapture(0)
+if not cap.isOpened():
+ print("❌ 無法開啟攝影機,請改用影片檔案")
+ pygame.quit()
+ sys.exit()
+
+# MediaPipe (Tasks API)
+try:
+ base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'hand_landmarker.task'))
+ options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+ detector = vision.HandLandmarker.create_from_options(options)
+except Exception as e:
+ print(f"❌ 錯誤:無法載入 MediaPipe 模型:{e}")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 2. 遊戲變數
+# ============================================
+# 手指座標
+finger_x, finger_y = 400, 300
+thumb_x, thumb_y = 400, 300
+
+# Pinch 變數
+is_pinching = False
+was_pinching = False # 邊緣觸發:紀錄上一幀的狀態
+PINCH_THRESHOLD = 40 # 距離小於此值就算「捏起來」
+dist = 999 # 兩指距離
+
+# 按鈕設定
+button_rect = pygame.Rect(300, 250, 200, 100)
+button_colors = [(100, 100, 220), (220, 100, 100), (100, 220, 100), (220, 180, 50)]
+current_color_index = 0
+
+# 點擊計數器
+click_count = 0
+hand_detected = False
+
+# ============================================
+# 3. 主迴圈
+# ============================================
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機 ---
+ success, img = cap.read()
+ if success:
+ img = cv2.flip(img, 1)
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ if results.hand_landmarks:
+ hand_detected = True
+ for hand_lms in results.hand_landmarks:
+ # 取得食指尖 (8) 座標
+ lm8 = hand_lms[8]
+ finger_x = int(lm8.x * WIDTH)
+ finger_y = int(lm8.y * HEIGHT)
+
+ # 取得大拇指尖 (4) 座標
+ lm4 = hand_lms[4]
+ thumb_x = int(lm4.x * WIDTH)
+ thumb_y = int(lm4.y * HEIGHT)
+
+ # 用 math.hypot 計算兩指之間的距離
+ dist = math.hypot(finger_x - thumb_x, finger_y - thumb_y)
+
+ # 判斷是否掐合
+ is_pinching = dist < PINCH_THRESHOLD
+ else:
+ hand_detected = False
+ is_pinching = False
+
+ # --- 邊緣觸發判定 ---
+ # 只有「前一幀沒捏 + 這一幀捏了」才算一次點擊
+ if is_pinching and not was_pinching:
+ # 檢查手指是否在按鈕範圍內
+ finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10)
+ if finger_rect.colliderect(button_rect):
+ click_count += 1
+ current_color_index = (current_color_index + 1) % len(button_colors)
+ print(f"✨ 魔法點擊!第 {click_count} 次")
+
+ # 更新上一幀的狀態
+ was_pinching = is_pinching
+
+ # --- 繪製畫面 ---
+ screen.fill((20, 20, 35))
+
+ # 畫按鈕
+ btn_color = button_colors[current_color_index]
+ pygame.draw.rect(screen, btn_color, button_rect, border_radius=16)
+
+ font = pygame.font.SysFont(None, 32)
+ btn_text = font.render(f"Clicks: {click_count}", True, (255, 255, 255))
+ screen.blit(btn_text, (button_rect.centerx - btn_text.get_width() // 2,
+ button_rect.centery - btn_text.get_height() // 2))
+
+ # 畫提示文字在按鈕下方
+ hint_font = pygame.font.SysFont(None, 22)
+ hint = hint_font.render("Pinch on button to click!", True, (120, 120, 150))
+ screen.blit(hint, (button_rect.centerx - hint.get_width() // 2, button_rect.bottom + 12))
+
+ if hand_detected:
+ # 畫大拇指位置(紫色小點)
+ pygame.draw.circle(screen, (139, 92, 246), (thumb_x, thumb_y), 8)
+
+ # 畫兩指之間的連線
+ line_color = (50, 255, 50) if is_pinching else (255, 50, 50)
+ pygame.draw.line(screen, line_color, (thumb_x, thumb_y), (finger_x, finger_y), 2)
+
+ # 畫手指游標(根據 Pinch 狀態改變顏色)
+ if is_pinching:
+ cursor_color = (50, 255, 50) # 捏起來 → 綠色
+ else:
+ cursor_color = (255, 50, 50) # 放開 → 紅色
+ pygame.draw.circle(screen, cursor_color, (finger_x, finger_y), 12)
+
+ # 狀態文字
+ small_font = pygame.font.SysFont(None, 24)
+ coord_text = small_font.render(f"Index(8): ({finger_x}, {finger_y}) Thumb(4): ({thumb_x}, {thumb_y})", True, (150, 150, 150))
+ screen.blit(coord_text, (10, 10))
+
+ dist_text = small_font.render(f"Distance: {dist:.0f} px | Threshold: {PINCH_THRESHOLD}", True, (150, 150, 200))
+ screen.blit(dist_text, (10, 35))
+
+ pinch_text = small_font.render(f"Pinching: {'YES ✅' if is_pinching else 'NO ❌'}", True,
+ (50, 255, 50) if is_pinching else (255, 100, 100))
+ screen.blit(pinch_text, (10, 60))
+ else:
+ no_hand = font.render("Show your hand to the camera!", True, (255, 200, 0))
+ screen.blit(no_hand, (WIDTH // 2 - no_hand.get_width() // 2, 50))
+
+ pygame.display.update()
+ clock.tick(30)
+
+cap.release()
+pygame.quit()
+sys.exit()
diff --git a/projects/2026project_class/examples/w10_pinch_template.py b/projects/2026project_class/examples/w10_pinch_template.py
new file mode 100644
index 0000000000000000000000000000000000000000..65c14826627e9ef6faede9b82325dc757b86fd76
--- /dev/null
+++ b/projects/2026project_class/examples/w10_pinch_template.py
@@ -0,0 +1,146 @@
+"""
+Week 10 - 魔法手勢:Pinch 偵測與手勢按鈕 (Template 填空版)
+=============================================================
+本週目標:
+ 1. 用勾股定理計算大拇指 (4) 與食指 (8) 的距離
+ 2. 實作 Pinch (掐合) 偵測:距離 < Threshold 就是捏起來了
+ 3. 用手指碰觸 Pygame 畫面上的按鈕 + 掐合 = 魔法點擊
+ 4. 使用邊緣觸發 (Edge Detection) 防止重複觸發
+
+操作方式:
+ - 食指移動到按鈕上方 + 大拇指與食指捏合 = 點擊按鈕
+ - 顏色會隨點擊切換
+
+注意:
+ - 確保 hand_landmarker.task 檔案和這個腳本放在同一個資料夾。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import math
+import sys
+import os
+
+# ============================================
+# 1. 初始化
+# ============================================
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 10 - 魔法手勢按鈕 🖐️✨")
+clock = pygame.time.Clock()
+
+# 攝影機
+cap = cv2.VideoCapture(0)
+# 沒有攝影機就改成影片:cap = cv2.VideoCapture("backup_hand_video.mp4")
+
+# MediaPipe (Tasks API)
+# base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'hand_landmarker.task'))
+# options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+# detector = vision.HandLandmarker.create_from_options(options)
+
+# ============================================
+# 2. 遊戲變數
+# ============================================
+# 手指座標
+finger_x, finger_y = 400, 300
+
+# 【TODO 1】宣告 Pinch 相關變數
+# is_pinching = False
+# was_pinching = False # 邊緣觸發:紀錄上一幀的狀態
+# PINCH_THRESHOLD = 40 # 距離小於此值就算「捏起來」
+
+# 按鈕設定
+button_rect = pygame.Rect(300, 250, 200, 100)
+button_colors = [(100, 100, 220), (220, 100, 100), (100, 220, 100), (220, 180, 50)]
+current_color_index = 0
+
+# 點擊計數器
+click_count = 0
+
+# ============================================
+# 3. 主迴圈
+# ============================================
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機 ---
+ success, img = cap.read()
+ if success:
+ img = cv2.flip(img, 1)
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+
+ # mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ # results = detector.detect(mp_image)
+
+ # if results.hand_landmarks:
+ # for hand_lms in results.hand_landmarks:
+ # # 取得食指尖 (8) 座標
+ # lm8 = hand_lms[8]
+ # finger_x = int(lm8.x * WIDTH)
+ # finger_y = int(lm8.y * HEIGHT)
+ #
+ # # 【TODO 2】取得大拇指尖 (4) 座標
+ # # lm4 = hand_lms[4]
+ # # thumb_x = int(lm4.x * WIDTH)
+ # # thumb_y = int(lm4.y * HEIGHT)
+ #
+ # # 【TODO 3】用 math.hypot 計算兩指之間的距離
+ # # dist = math.hypot(finger_x - thumb_x, finger_y - thumb_y)
+ #
+ # # 【TODO 4】判斷是否掐合(距離 < PINCH_THRESHOLD)
+ # # is_pinching = dist < PINCH_THRESHOLD
+ pass
+
+ # 【TODO 5】邊緣觸發:只有「前一幀沒捏 + 這一幀捏了」才算一次點擊
+ # if is_pinching and not was_pinching:
+ # # 檢查手指是否在按鈕範圍內
+ # finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10)
+ # if finger_rect.colliderect(button_rect):
+ # click_count += 1
+ # current_color_index = (current_color_index + 1) % len(button_colors)
+ # print(f"✨ 魔法點擊!第 {click_count} 次")
+
+ # 【TODO 6】更新 was_pinching
+ # was_pinching = is_pinching
+
+ # --- 繪製畫面 ---
+ screen.fill((20, 20, 35))
+
+ # 畫按鈕
+ btn_color = button_colors[current_color_index]
+ pygame.draw.rect(screen, btn_color, button_rect, border_radius=16)
+
+ font = pygame.font.SysFont(None, 32)
+ btn_text = font.render(f"Clicks: {click_count}", True, (255, 255, 255))
+ screen.blit(btn_text, (button_rect.centerx - btn_text.get_width() // 2,
+ button_rect.centery - btn_text.get_height() // 2))
+
+ # 畫手指游標
+ # 【TODO 7】根據 is_pinching 狀態改變顏色(捏起=綠色,放開=紅色)
+ cursor_color = (255, 50, 50) # 預設紅色
+ # if is_pinching:
+ # cursor_color = (50, 255, 50) # 捏起來變綠色
+
+ pygame.draw.circle(screen, cursor_color, (finger_x, finger_y), 12)
+
+ # 狀態文字
+ small_font = pygame.font.SysFont(None, 24)
+ status = small_font.render(f"Finger: ({finger_x}, {finger_y})", True, (150, 150, 150))
+ screen.blit(status, (10, 10))
+
+ # 【TODO 8】顯示距離數值(dist)在畫面上
+ # dist_text = small_font.render(f"Dist: {dist:.0f} px", True, (150, 150, 200))
+ # screen.blit(dist_text, (10, 35))
+
+ pygame.display.update()
+ clock.tick(30)
+
+# cap.release()
+pygame.quit()
+sys.exit()
diff --git a/projects/2026project_class/examples/w10_step1_distance.py b/projects/2026project_class/examples/w10_step1_distance.py
new file mode 100644
index 0000000000000000000000000000000000000000..6fbae31a9e219927feec8a27b35467c3ee861b60
--- /dev/null
+++ b/projects/2026project_class/examples/w10_step1_distance.py
@@ -0,0 +1,53 @@
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import math
+import os
+
+# ============================================
+# Week 10 - Step 1: 計算兩指距離
+# ============================================
+
+model_path = os.path.join(os.path.dirname(__file__), 'hand_landmarker.task')
+base_options = python.BaseOptions(model_asset_path=model_path)
+options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+detector = vision.HandLandmarker.create_from_options(options)
+
+cap = cv2.VideoCapture(0)
+
+while True:
+ success, img = cap.read()
+ if not success:
+ break
+
+ img = cv2.flip(img, 1)
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ if results.hand_landmarks:
+ for hand_lms in results.hand_landmarks:
+ lm4 = hand_lms[4] # 大拇指尖
+ lm8 = hand_lms[8] # 食指尖
+
+ h, w, c = img.shape
+ cx4, cy4 = int(lm4.x * w), int(lm4.y * h)
+ cx8, cy8 = int(lm8.x * w), int(lm8.y * h)
+
+ # 使用 math.hypot 計算兩點之間的直線距離
+ dist = math.hypot(cx8 - cx4, cy8 - cy4)
+ print(f"距離:{dist:.1f}")
+
+ # 在影格上畫線連接兩指
+ cv2.line(img, (cx4, cy4), (cx8, cy8), (255, 255, 255), 2)
+ cv2.circle(img, (cx4, cy4), 8, (255, 0, 255), -1)
+ cv2.circle(img, (cx8, cy8), 8, (255, 0, 255), -1)
+
+ cv2.imshow("Distance Calc", img)
+
+ if cv2.waitKey(1) & 0xFF == ord('q'):
+ break
+
+cap.release()
+cv2.destroyAllWindows()
diff --git a/projects/2026project_class/examples/w10_step2_threshold.py b/projects/2026project_class/examples/w10_step2_threshold.py
new file mode 100644
index 0000000000000000000000000000000000000000..2a86e5262aa7efef7b8a74ae8d88443cfc7d98ff
--- /dev/null
+++ b/projects/2026project_class/examples/w10_step2_threshold.py
@@ -0,0 +1,59 @@
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import math
+import os
+
+# ============================================
+# Week 10 - Step 2: Threshold 臨界值判斷
+# ============================================
+
+model_path = os.path.join(os.path.dirname(__file__), 'hand_landmarker.task')
+base_options = python.BaseOptions(model_asset_path=model_path)
+options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+detector = vision.HandLandmarker.create_from_options(options)
+
+cap = cv2.VideoCapture(0)
+
+while True:
+ success, img = cap.read()
+ if not success:
+ break
+
+ img = cv2.flip(img, 1)
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ if results.hand_landmarks:
+ for hand_lms in results.hand_landmarks:
+ lm4 = hand_lms[4]
+ lm8 = hand_lms[8]
+
+ h, w, c = img.shape
+ cx4, cy4 = int(lm4.x * w), int(lm4.y * h)
+ cx8, cy8 = int(lm8.x * w), int(lm8.y * h)
+
+ dist = math.hypot(cx8 - cx4, cy8 - cy4)
+
+ # Threshold 判斷:距離小於 40 視為「捏起來」
+ if dist < 40:
+ is_pinching = True
+ # 捏起來 → 顯示綠色
+ cv2.circle(img, (cx8, cy8), 20, (0, 255, 0), -1)
+ cv2.putText(img, "Pinching!", (cx8 - 40, cy8 - 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
+ else:
+ is_pinching = False
+ # 放開 → 顯示紅色
+ cv2.circle(img, (cx8, cy8), 20, (0, 0, 255), -1)
+
+ cv2.line(img, (cx4, cy4), (cx8, cy8), (255, 255, 255), 2)
+
+ cv2.imshow("Pinch Threshold", img)
+
+ if cv2.waitKey(1) & 0xFF == ord('q'):
+ break
+
+cap.release()
+cv2.destroyAllWindows()
diff --git a/projects/2026project_class/examples/w11_classifier_complete.py b/projects/2026project_class/examples/w11_classifier_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..169e69414c0c63a7d4f3dd68f63ab354229096f5
--- /dev/null
+++ b/projects/2026project_class/examples/w11_classifier_complete.py
@@ -0,0 +1,212 @@
+"""
+Week 11 - Teachable Machine 影像分類 + AI 音效觸發 (Complete 完整版)
+=====================================================================
+本週目標:
+ 1. 載入 Teachable Machine 訓練好的 Keras 模型 (.h5)
+ 2. 使用攝影機即時推論,辨識畫面屬於哪一個類別
+ 3. 當偵測到特定類別 (如「喝水中」) 時觸發 Pygame 音效
+ 4. 使用 Boolean Flag 防止音效重複播放(邊緣觸發)
+
+前置準備:
+ - 從 Teachable Machine 匯出 keras_model.h5 和 labels.txt
+ - 把兩個檔案放在跟這個 .py 檔案同一個資料夾裡
+ - pip install tensorflow opencv-python numpy pygame
+"""
+import pygame
+import cv2
+import numpy as np
+import sys
+
+from keras.models import load_model
+
+# ============================================
+# 1. 初始化 Pygame
+# ============================================
+pygame.init()
+pygame.mixer.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 11 - AI 影像分類器 🧠🔮")
+clock = pygame.time.Clock()
+
+font = pygame.font.SysFont(None, 48)
+small_font = pygame.font.SysFont(None, 28)
+bar_font = pygame.font.SysFont(None, 22)
+
+# ============================================
+# 2. 載入模型與標籤
+# ============================================
+try:
+ model = load_model("keras_model.h5", compile=False)
+ print("✅ 模型載入成功!")
+except Exception as e:
+ print(f"❌ 模型載入失敗:{e}")
+ print(" 請確認 keras_model.h5 是否在同一個資料夾中")
+ pygame.quit()
+ sys.exit()
+
+try:
+ labels = open("labels.txt", "r").readlines()
+ print(f"✅ 載入 {len(labels)} 個分類標籤:{[l.strip() for l in labels]}")
+except FileNotFoundError:
+ print("❌ 找不到 labels.txt!請確認檔案是否在同一個資料夾中")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 3. 攝影機
+# ============================================
+cap = cv2.VideoCapture(0)
+if not cap.isOpened():
+ print("❌ 無法開啟攝影機,請改用影片檔案")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 4. 遊戲變數
+# ============================================
+current_class = "等待分析..."
+confidence = 0.0
+prediction = None
+frame_counter = 0
+
+# 邊緣觸發:紀錄上一個狀態
+is_drinking = False # 根據你的類別修改
+
+# 載入音效
+try:
+ trigger_sound = pygame.mixer.Sound("bubble.wav")
+ print("✅ 音效載入成功")
+except FileNotFoundError:
+ trigger_sound = None
+ print("⚠️ 找不到音效檔案 bubble.wav,將不播放音效")
+
+# 背景顏色(根據辨識結果切換)
+bg_color = (20, 20, 35)
+trigger_flash = 0 # 觸發時的閃爍計數器
+
+# 信心度條形圖的顏色
+bar_colors = [
+ (34, 197, 94), # 綠色
+ (59, 130, 246), # 藍色
+ (245, 158, 11), # 黃色
+ (239, 68, 68), # 紅色
+ (139, 92, 246), # 紫色
+]
+
+# ============================================
+# 5. 主迴圈
+# ============================================
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機 ---
+ success, img = cap.read()
+ if not success:
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
+ continue
+
+ img = cv2.flip(img, 1)
+
+ # --- 每 5 幀做一次推論(避免畫面大卡)---
+ frame_counter += 1
+ if frame_counter % 5 == 0:
+ try:
+ # Keras 要求的影像預處理(這三行請死背!)
+ img_resized = cv2.resize(img, (224, 224))
+ img_array = np.asarray(img_resized, dtype=np.float32).reshape(1, 224, 224, 3)
+ img_array = (img_array / 127.5) - 1
+
+ # 前饋推論:取得每個類別的機率
+ prediction = model.predict(img_array, verbose=0)
+ index = np.argmax(prediction)
+ current_class = labels[index].strip()
+ confidence = prediction[0][index]
+ except Exception as e:
+ print(f"推論出錯:{e}")
+
+ # --- 邊緣觸發 + 動作回饋 ---
+ # 這裡假設第一個非背景的類別叫做「喝水中」
+ # 請根據你自己的 labels.txt 修改判斷條件!
+ if current_class != labels[0].strip() and confidence > 0.90:
+ if not is_drinking:
+ # 狀態剛從「背景」切換到「動作」的瞬間
+ if trigger_sound:
+ trigger_sound.play()
+ trigger_flash = 15 # 觸發閃爍 15 幀
+ print(f"🥤 偵測到動作:{current_class}!信心度: {confidence:.1%}")
+ is_drinking = True
+ bg_color = (20, 50, 35) # 深綠背景
+ else:
+ is_drinking = False
+ bg_color = (20, 20, 35) # 回到預設
+
+ # 閃爍效果倒數
+ if trigger_flash > 0:
+ trigger_flash -= 1
+
+ # --- 繪製畫面 ---
+ # 背景(觸發時閃爍)
+ if trigger_flash > 0 and trigger_flash % 2 == 0:
+ screen.fill((40, 80, 50))
+ else:
+ screen.fill(bg_color)
+
+ # 辨識結果大字
+ result_color = (50, 255, 50) if is_drinking else (255, 255, 255)
+ class_text = font.render(f"{current_class}", True, result_color)
+ screen.blit(class_text, (WIDTH // 2 - class_text.get_width() // 2, 60))
+
+ conf_text = small_font.render(f"Confidence: {confidence:.1%}", True, (100, 200, 255))
+ screen.blit(conf_text, (WIDTH // 2 - conf_text.get_width() // 2, 120))
+
+ # --- 信心度條形圖 ---
+ if prediction is not None:
+ bar_y_start = 180
+ bar_height = 30
+ bar_gap = 12
+ bar_max_width = 500
+ bar_x = 150
+
+ for i, prob in enumerate(prediction[0]):
+ y = bar_y_start + i * (bar_height + bar_gap)
+ label_name = labels[i].strip() if i < len(labels) else f"Class {i}"
+ color = bar_colors[i % len(bar_colors)]
+
+ # 標籤名稱
+ label_text = bar_font.render(label_name, True, (200, 200, 200))
+ screen.blit(label_text, (bar_x - label_text.get_width() - 10, y + 5))
+
+ # 背景軌道
+ pygame.draw.rect(screen, (40, 40, 55), (bar_x, y, bar_max_width, bar_height), border_radius=6)
+
+ # 填充條
+ fill_width = int(prob * bar_max_width)
+ if fill_width > 0:
+ pygame.draw.rect(screen, color, (bar_x, y, fill_width, bar_height), border_radius=6)
+
+ # 百分比文字
+ pct_text = bar_font.render(f"{prob:.1%}", True, (255, 255, 255))
+ text_x = bar_x + fill_width + 8 if fill_width < bar_max_width - 60 else bar_x + fill_width - 55
+ screen.blit(pct_text, (text_x, y + 5))
+
+ # 標記最高的那一條
+ if i == np.argmax(prediction):
+ pygame.draw.rect(screen, color, (bar_x - 4, y, 4, bar_height), border_radius=2)
+
+ # 底部狀態列
+ status_text = small_font.render(
+ f"Frame: {frame_counter} | Predict every 5 frames | FPS: {clock.get_fps():.0f}",
+ True, (80, 80, 100)
+ )
+ screen.blit(status_text, (WIDTH // 2 - status_text.get_width() // 2, HEIGHT - 35))
+
+ pygame.display.update()
+ clock.tick(30)
+
+cap.release()
+pygame.quit()
+sys.exit()
diff --git a/projects/2026project_class/examples/w11_classifier_template.py b/projects/2026project_class/examples/w11_classifier_template.py
new file mode 100644
index 0000000000000000000000000000000000000000..eedf614a0e259c533e4d69a5b8bdc8f78ff29841
--- /dev/null
+++ b/projects/2026project_class/examples/w11_classifier_template.py
@@ -0,0 +1,139 @@
+"""
+Week 11 - Teachable Machine 影像分類 + AI 音效觸發 (Template 填空版)
+=====================================================================
+本週目標:
+ 1. 載入 Teachable Machine 訓練好的 Keras 模型 (.h5)
+ 2. 使用攝影機即時推論,辨識畫面屬於哪一個類別
+ 3. 當偵測到特定類別 (如「喝水中」) 時觸發 Pygame 音效
+ 4. 使用 Boolean Flag 防止音效重複播放(邊緣觸發)
+
+前置準備:
+ - 從 Teachable Machine 匯出 keras_model.h5 和 labels.txt
+ - 把兩個檔案放在跟這個 .py 檔案同一個資料夾裡
+ - pip install tensorflow opencv-python numpy pygame
+"""
+import pygame
+import cv2
+import numpy as np
+import sys
+
+# 【TODO 1】匯入 Keras 模型載入工具
+# from keras.models import load_model
+
+# ============================================
+# 1. 初始化 Pygame
+# ============================================
+pygame.init()
+pygame.mixer.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 11 - AI 影像分類器 🧠🔮")
+clock = pygame.time.Clock()
+
+font = pygame.font.SysFont(None, 48)
+small_font = pygame.font.SysFont(None, 28)
+
+# ============================================
+# 2. 載入模型與標籤
+# ============================================
+# 【TODO 2】載入 Teachable Machine 訓練出的模型
+# model = load_model("keras_model.h5", compile=False)
+
+# 【TODO 3】讀取標籤文字檔(每一行是一個類別名稱)
+# labels = open("labels.txt", "r").readlines()
+
+# ============================================
+# 3. 攝影機
+# ============================================
+cap = cv2.VideoCapture(0)
+# 沒有攝影機就改成影片:cap = cv2.VideoCapture("backup_video.mp4")
+
+# ============================================
+# 4. 遊戲變數
+# ============================================
+current_class = "等待中..."
+confidence = 0.0
+frame_counter = 0
+
+# 【TODO 4】邊緣觸發:宣告「上一個狀態」的旗標
+# is_drinking = False # 目前是不是喝水中
+# 你可以根據自己的類別修改變數名稱
+
+# 載入音效(請準備你自己的音效檔案)
+# try:
+# trigger_sound = pygame.mixer.Sound("bubble.wav")
+# except FileNotFoundError:
+# trigger_sound = None
+# print("⚠️ 找不到音效檔案 bubble.wav,將不播放音效")
+
+# 背景顏色(根據辨識結果切換)
+bg_color = (20, 20, 35)
+
+# ============================================
+# 5. 主迴圈
+# ============================================
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機 ---
+ success, img = cap.read()
+ if not success:
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
+ continue
+
+ img = cv2.flip(img, 1)
+
+ # 【TODO 5】影像前處理 + 模型推論
+ # 注意:不要每一幀都推論!每 5 幀做一次就好,避免畫面大卡
+ # frame_counter += 1
+ # if frame_counter % 5 == 0:
+ # # Keras 要求的預處理(這三行請死背!)
+ # img_resized = cv2.resize(img, (224, 224))
+ # img_array = np.asarray(img_resized, dtype=np.float32).reshape(1, 224, 224, 3)
+ # img_array = (img_array / 127.5) - 1
+ #
+ # # 前饋推論
+ # prediction = model.predict(img_array)
+ # index = np.argmax(prediction)
+ # current_class = labels[index].strip()
+ # confidence = prediction[0][index]
+
+ # 【TODO 6】邊緣觸發 + 播放音效
+ # if current_class == "喝水中" and confidence > 0.90:
+ # if not is_drinking:
+ # if trigger_sound:
+ # trigger_sound.play()
+ # print(f"🥤 偵測到喝水!信心度: {confidence:.1%}")
+ # is_drinking = True
+ # bg_color = (20, 50, 35) # 切換背景為深綠
+ # else:
+ # is_drinking = False
+ # bg_color = (20, 20, 35) # 回到預設背景
+
+ # --- 繪製畫面 ---
+ screen.fill(bg_color)
+
+ # 顯示辨識結果
+ class_text = font.render(f"Class: {current_class}", True, (255, 255, 255))
+ screen.blit(class_text, (WIDTH // 2 - class_text.get_width() // 2, 200))
+
+ conf_text = font.render(f"Confidence: {confidence:.1%}", True, (100, 200, 255))
+ screen.blit(conf_text, (WIDTH // 2 - conf_text.get_width() // 2, 260))
+
+ # 【TODO 7】畫出每個類別的信心度條形圖
+ # 提示:用 for 迴圈遍歷 prediction[0],每個類別畫一條長條
+ # 用不同顏色標示最高機率的類別
+
+ # 底部提示
+ hint = small_font.render("AI is watching...", True, (80, 80, 100))
+ screen.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT - 40))
+
+ pygame.display.update()
+ clock.tick(30)
+
+cap.release()
+pygame.quit()
+sys.exit()
diff --git a/projects/2026project_class/examples/w12_eyeguard_complete.py b/projects/2026project_class/examples/w12_eyeguard_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..12e284ee816fb7ef317571ef0bc50f19e5d91cc8
--- /dev/null
+++ b/projects/2026project_class/examples/w12_eyeguard_complete.py
@@ -0,0 +1,260 @@
+"""
+Week 12 - AI 生活工具箱:護眼大師 (Complete 完整版)
+=====================================================
+本週目標:
+ 1. 將 AI 視覺技術整合進一個「解決生活痛點」的完整專案
+ 2. 使用 MediaPipe 臉部偵測兩眼距離,判斷是否離螢幕太近
+ 3. 在 Pygame 畫面上顯示即時狀態與警報
+ 4. 加入防呆機制(try-except、None 檢查)
+
+護眼大師 EyeGuard:
+ - 偵測臉部兩眼之間的距離(兩眼距離越大 = 離鏡頭越近)
+ - 超過安全閾值時發出警報文字 + 閃爍效果 + 音效
+ - 安全時累計計時器鼓勵良好坐姿
+
+注意:
+ - 確保 face_landmarker.task 檔案和這個腳本放在同一個資料夾。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import math
+import sys
+import os
+import time
+
+# ============================================
+# 1. 初始化
+# ============================================
+pygame.init()
+pygame.mixer.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 12 - 護眼大師 EyeGuard 👁️🛡️")
+clock = pygame.time.Clock()
+
+font = pygame.font.SysFont(None, 48)
+small_font = pygame.font.SysFont(None, 28)
+big_font = pygame.font.SysFont(None, 72)
+tiny_font = pygame.font.SysFont(None, 22)
+
+# ============================================
+# 2. 攝影機與 AI 模組
+# ============================================
+cap = cv2.VideoCapture(0)
+if not cap.isOpened():
+ print("❌ 無法開啟攝影機,請改用影片檔案")
+ pygame.quit()
+ sys.exit()
+
+# MediaPipe 臉部偵測 (Tasks API)
+try:
+ base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'face_landmarker.task'))
+ options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1)
+ detector = vision.FaceLandmarker.create_from_options(options)
+except Exception as e:
+ print(f"❌ 錯誤:無法載入 MediaPipe 模型:{e}")
+ pygame.quit()
+ sys.exit()
+
+# 關鍵 Landmark 索引
+LEFT_EYE = 33 # 左眼外角
+RIGHT_EYE = 263 # 右眼外角
+
+# ============================================
+# 3. 遊戲變數
+# ============================================
+eye_distance = 0
+lx, ly = 0, 0 # 左眼座標
+rx, ry = 0, 0 # 右眼座標
+face_detected = False
+
+DANGER_THRESHOLD = 120 # 兩眼像素距離超過此值 = 太近了!
+SAFE_THRESHOLD = 100 # 低於此值 = 安全(遲滯效應,避免臨界值抖動)
+
+# 警報狀態
+is_too_close = False
+was_too_close = False # 邊緣觸發
+warning_timer = 0 # 警告閃爍計時器
+
+# 統計數據
+total_warnings = 0
+safe_start_time = time.time()
+consecutive_safe_seconds = 0
+frame_counter = 0
+
+# 載入音效
+try:
+ alarm_sound = pygame.mixer.Sound("hit.wav") # 用現有的 hit.wav 代替警報音
+ print("✅ 警報音效載入成功")
+except FileNotFoundError:
+ alarm_sound = None
+ print("⚠️ 找不到警報音效,將不播放音效")
+
+# ============================================
+# 4. 主迴圈
+# ============================================
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機 ---
+ success, img = cap.read()
+ if not success:
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
+ continue
+
+ img = cv2.flip(img, 1)
+ frame_counter += 1
+
+ # --- 臉部偵測與兩眼距離計算 ---
+ try:
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ if results.face_landmarks:
+ face_detected = True
+ face_lms = results.face_landmarks[0]
+
+ # 取得左眼和右眼座標
+ left_eye = face_lms[LEFT_EYE]
+ right_eye = face_lms[RIGHT_EYE]
+
+ lx = int(left_eye.x * WIDTH)
+ ly = int(left_eye.y * HEIGHT)
+ rx = int(right_eye.x * WIDTH)
+ ry = int(right_eye.y * HEIGHT)
+
+ # 計算兩眼之間的像素距離
+ eye_distance = math.hypot(rx - lx, ry - ly)
+
+ # 使用遲滯效應判斷(避免臨界值抖動)
+ if eye_distance > DANGER_THRESHOLD:
+ is_too_close = True
+ elif eye_distance < SAFE_THRESHOLD:
+ is_too_close = False
+ else:
+ face_detected = False
+ is_too_close = False
+ except Exception as e:
+ # 防禦性程式碼:任何異常都不應讓程式閃退
+ print(f"偵測異常:{e}")
+ face_detected = False
+
+ # --- 邊緣觸發判定 ---
+ if is_too_close and not was_too_close:
+ # 剛進入危險區
+ total_warnings += 1
+ warning_timer = 90 # 閃爍 90 幀(約 3 秒)
+ if alarm_sound:
+ alarm_sound.play()
+ safe_start_time = time.time() # 重置安全計時
+ print(f"⚠️ 第 {total_warnings} 次警告:你離螢幕太近了!(distance={eye_distance:.0f})")
+
+ if not is_too_close and was_too_close:
+ # 剛離開危險區
+ safe_start_time = time.time()
+ print("✅ 已回到安全距離")
+
+ was_too_close = is_too_close
+
+ # 計算連續安全秒數
+ if not is_too_close and face_detected:
+ consecutive_safe_seconds = time.time() - safe_start_time
+ else:
+ consecutive_safe_seconds = 0
+
+ # 閃爍倒數
+ if warning_timer > 0:
+ warning_timer -= 1
+
+ # --- 繪製畫面 ---
+ # 背景
+ if is_too_close:
+ if warning_timer > 0 and warning_timer % 6 < 3:
+ screen.fill((90, 15, 15)) # 強烈閃爍紅
+ else:
+ screen.fill((50, 12, 12)) # 暗紅
+ else:
+ screen.fill((12, 30, 18)) # 安全深綠
+
+ if face_detected:
+ # --- 距離量表 ---
+ gauge_x, gauge_y = 50, 50
+ gauge_w, gauge_h = 700, 30
+
+ # 軌道背景
+ pygame.draw.rect(screen, (40, 40, 55), (gauge_x, gauge_y, gauge_w, gauge_h), border_radius=8)
+
+ # 填充條(根據距離改變顏色與長度)
+ fill_ratio = min(eye_distance / 200, 1.0)
+ fill_w = int(fill_ratio * gauge_w)
+ if eye_distance > DANGER_THRESHOLD:
+ fill_color = (239, 68, 68) # 紅色
+ elif eye_distance > SAFE_THRESHOLD:
+ fill_color = (245, 158, 11) # 黃色
+ else:
+ fill_color = (34, 197, 94) # 綠色
+ pygame.draw.rect(screen, fill_color, (gauge_x, gauge_y, fill_w, gauge_h), border_radius=8)
+
+ # 閾值標記線
+ threshold_x = gauge_x + int(DANGER_THRESHOLD / 200 * gauge_w)
+ pygame.draw.line(screen, (255, 255, 255), (threshold_x, gauge_y - 5), (threshold_x, gauge_y + gauge_h + 5), 2)
+ threshold_label = tiny_font.render(f"DANGER: {DANGER_THRESHOLD}", True, (200, 200, 200))
+ screen.blit(threshold_label, (threshold_x - threshold_label.get_width() // 2, gauge_y + gauge_h + 8))
+
+ # 距離數值
+ dist_text = small_font.render(f"Eye Distance: {eye_distance:.0f} px", True, (200, 200, 220))
+ screen.blit(dist_text, (gauge_x, gauge_y - 28))
+
+ # --- 眼睛位置標記 ---
+ pygame.draw.circle(screen, (59, 130, 246), (lx, ly), 8)
+ pygame.draw.circle(screen, (59, 130, 246), (rx, ry), 8)
+ pygame.draw.line(screen, (59, 130, 246, 100), (lx, ly), (rx, ry), 2)
+
+ # --- 主要狀態顯示 ---
+ if is_too_close:
+ warn_text = big_font.render("TOO CLOSE!", True, (255, 80, 80))
+ screen.blit(warn_text, (WIDTH // 2 - warn_text.get_width() // 2, HEIGHT // 2 - 60))
+
+ sub_text = font.render("Please lean back!", True, (255, 150, 150))
+ screen.blit(sub_text, (WIDTH // 2 - sub_text.get_width() // 2, HEIGHT // 2 + 20))
+ else:
+ safe_text = font.render("Safe Distance", True, (80, 220, 100))
+ screen.blit(safe_text, (WIDTH // 2 - safe_text.get_width() // 2, HEIGHT // 2 - 30))
+
+ # 連續安全時間
+ if consecutive_safe_seconds > 3:
+ streak_text = small_font.render(
+ f"Good posture for {consecutive_safe_seconds:.0f}s! Keep it up!",
+ True, (100, 200, 150)
+ )
+ screen.blit(streak_text, (WIDTH // 2 - streak_text.get_width() // 2, HEIGHT // 2 + 20))
+
+ # --- 統計資訊 ---
+ stats_y = HEIGHT - 80
+ stats1 = small_font.render(f"Total Warnings: {total_warnings}", True, (150, 150, 180))
+ screen.blit(stats1, (20, stats_y))
+
+ stats2 = small_font.render(f"Frame: {frame_counter} | FPS: {clock.get_fps():.0f}", True, (100, 100, 130))
+ screen.blit(stats2, (20, stats_y + 28))
+
+ else:
+ # 沒偵測到臉
+ no_face = font.render("No face detected", True, (255, 200, 0))
+ screen.blit(no_face, (WIDTH // 2 - no_face.get_width() // 2, HEIGHT // 2 - 20))
+
+ hint = small_font.render("Please face the camera", True, (180, 180, 100))
+ screen.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT // 2 + 30))
+
+ pygame.display.update()
+ clock.tick(30)
+
+cap.release()
+pygame.quit()
+sys.exit()
diff --git a/projects/2026project_class/examples/w12_eyeguard_template.py b/projects/2026project_class/examples/w12_eyeguard_template.py
new file mode 100644
index 0000000000000000000000000000000000000000..43b24eb717a70fce1237ce1317d8a881e89376b2
--- /dev/null
+++ b/projects/2026project_class/examples/w12_eyeguard_template.py
@@ -0,0 +1,153 @@
+"""
+Week 12 - AI 生活工具箱:護眼大師 (Template 填空版)
+=====================================================
+本週目標:
+ 1. 將 AI 視覺技術整合進一個「解決生活痛點」的完整專案
+ 2. 使用 MediaPipe 臉部偵測兩眼距離,判斷是否離螢幕太近
+ 3. 在 Pygame 畫面上顯示即時狀態與警報
+ 4. 加入防呆機制(try-except、None 檢查)
+
+本範例:護眼大師 EyeGuard
+ - 偵測臉部兩眼之間的距離(兩眼距離越大 = 離鏡頭越近)
+ - 超過安全閾值時發出警報文字 + 閃爍效果
+ - 適用場景:打電動、寫作業時防止坐姿太差靠太近
+
+注意:
+ - 確保 face_landmarker.task 檔案和這個腳本放在同一個資料夾。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import math
+import sys
+import os
+
+# ============================================
+# 1. 初始化
+# ============================================
+pygame.init()
+pygame.mixer.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 12 - 護眼大師 EyeGuard 👁️🛡️")
+clock = pygame.time.Clock()
+
+font = pygame.font.SysFont(None, 48)
+small_font = pygame.font.SysFont(None, 28)
+big_font = pygame.font.SysFont(None, 72)
+
+# ============================================
+# 2. 攝影機與 AI 模組
+# ============================================
+cap = cv2.VideoCapture(0)
+# 沒有攝影機就改成影片:cap = cv2.VideoCapture("backup_face_video.mp4")
+
+# 【TODO 1】初始化 MediaPipe 臉部偵測 (Face Landmarker)
+# base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'face_landmarker.task'))
+# options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1)
+# detector = vision.FaceLandmarker.create_from_options(options)
+
+# 提示:左眼的 Landmark 索引為 33,右眼為 263
+
+# ============================================
+# 3. 遊戲變數
+# ============================================
+eye_distance = 0
+DANGER_THRESHOLD = 120 # 兩眼像素距離超過此值 = 太近了!
+
+# 【TODO 2】警報狀態變數
+# is_too_close = False
+# was_too_close = False # 邊緣觸發
+# warning_timer = 0 # 警告閃爍計時器
+
+# 統計數據
+total_warnings = 0
+safe_seconds = 0
+frame_counter = 0
+
+# ============================================
+# 4. 主迴圈
+# ============================================
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機 ---
+ success, img = cap.read()
+ if not success:
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
+ continue
+
+ img = cv2.flip(img, 1)
+ frame_counter += 1
+
+ # 【TODO 3】臉部偵測與兩眼距離計算
+ # img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ # mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ # results = detector.detect(mp_image)
+ #
+ # if results.face_landmarks:
+ # face_lms = results.face_landmarks[0]
+ #
+ # # 取得左眼 (33) 和右眼 (263) 座標
+ # left_eye = face_lms[33]
+ # right_eye = face_lms[263]
+ #
+ # lx, ly = int(left_eye.x * WIDTH), int(left_eye.y * HEIGHT)
+ # rx, ry = int(right_eye.x * WIDTH), int(right_eye.y * HEIGHT)
+ #
+ # # 計算兩眼之間的像素距離
+ # eye_distance = math.hypot(rx - lx, ry - ly)
+ #
+ # # 【TODO 4】判斷是否太近
+ # # is_too_close = eye_distance > DANGER_THRESHOLD
+ #
+ # # 【TODO 5】邊緣觸發:剛進入危險區才播放警報
+ # # if is_too_close and not was_too_close:
+ # # total_warnings += 1
+ # # warning_timer = 60 # 閃爍 60 幀
+ # # print(f"⚠️ 第 {total_warnings} 次警告:你離螢幕太近了!")
+ # #
+ # # was_too_close = is_too_close
+
+ # --- 繪製畫面 ---
+ # 【TODO 6】根據狀態切換背景顏色
+ # if is_too_close:
+ # if warning_timer > 0 and warning_timer % 4 < 2:
+ # screen.fill((80, 20, 20)) # 閃爍紅色
+ # else:
+ # screen.fill((50, 15, 15)) # 暗紅色
+ # warning_timer = max(0, warning_timer - 1)
+ # else:
+ # screen.fill((15, 30, 20)) # 安全綠色
+ screen.fill((20, 20, 35)) # 預設背景
+
+ # 【TODO 7】繪製距離量表
+ # 提示:用 pygame.draw.rect 畫一個長條,長度 = eye_distance 的比例
+ # 安全時綠色,危險時紅色
+
+ # 【TODO 8】如果太近,顯示大字警告
+ # if is_too_close:
+ # warn_text = big_font.render("⚠️ TOO CLOSE!", True, (255, 80, 80))
+ # screen.blit(warn_text, (WIDTH//2 - warn_text.get_width()//2, HEIGHT//2 - 40))
+ # else:
+ # safe_text = font.render("✅ Safe Distance", True, (80, 200, 100))
+ # screen.blit(safe_text, (WIDTH//2 - safe_text.get_width()//2, HEIGHT//2 - 20))
+
+ # 顯示數值
+ info = small_font.render(f"Eye Distance: {eye_distance:.0f} px | Threshold: {DANGER_THRESHOLD}", True, (150, 150, 180))
+ screen.blit(info, (10, 10))
+
+ stats = small_font.render(f"Warnings: {total_warnings}", True, (150, 150, 180))
+ screen.blit(stats, (10, 40))
+
+ pygame.display.update()
+ clock.tick(30)
+
+# cap.release()
+pygame.quit()
+sys.exit()
diff --git a/projects/2026project_class/examples/w1_1_basic_window.py b/projects/2026project_class/examples/w1_1_basic_window.py
new file mode 100644
index 0000000000000000000000000000000000000000..65e057d6308af7cd60a297dcddd931513971c6be
--- /dev/null
+++ b/projects/2026project_class/examples/w1_1_basic_window.py
@@ -0,0 +1,35 @@
+import pygame
+import sys
+
+# 【支柱一】初始化
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("我的第一個 Pygame 視窗")
+clock = pygame.time.Clock() # 控制心跳的時鐘
+
+my_x = 400
+my_y = 300
+
+# ----- 遊戲主迴圈開始 -----
+while True:
+
+ # 【支柱二】事件監聽:收信與檢查
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT: # 如果玩家按了紅色的叉叉
+ pygame.quit()
+ sys.exit() # 結束整個程式
+
+ # 【支柱三】邏輯更新 (大腦思考)
+ # 在這裡寫入更新座標的數學邏輯
+
+ # 【支柱四】畫面渲染
+ screen.fill((30, 30, 30)) # 擦乾淨黑板!填滿深灰色 (R, G, B)
+
+ # 畫一個紅色的圓形 (畫布, 顏色, 中心座標, 半徑)
+ pygame.draw.circle(screen, (255, 50, 50), (my_x, my_y), 30)
+
+ pygame.display.flip() # 翻轉畫布,呈現給玩家看
+
+ clock.tick(60) # 限制每秒最多跑 60 幀 (FPS)
+# ----- 遊戲主迴圈結束 -----
diff --git a/projects/2026project_class/examples/w1_2_challenge_follower.py b/projects/2026project_class/examples/w1_2_challenge_follower.py
new file mode 100644
index 0000000000000000000000000000000000000000..58b91a115876cc40811f18a7ea6097c5993aefe4
--- /dev/null
+++ b/projects/2026project_class/examples/w1_2_challenge_follower.py
@@ -0,0 +1,49 @@
+import pygame
+import sys
+import random
+
+# 【支柱一】初始化
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("變色追隨者")
+clock = pygame.time.Clock()
+
+my_x = 400
+my_y = 300
+color = (255, 50, 50)
+
+# ----- 遊戲主迴圈開始 -----
+while True:
+
+ # 【支柱二】事件監聽
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 挑戰任務:偵測按鍵
+ if event.type == pygame.KEYDOWN:
+ if event.key == pygame.K_SPACE:
+ # 隨機產生新的 RGB 顏色
+ color = (random.randint(50, 255), random.randint(50, 255), random.randint(50, 255))
+
+ # 【支柱三】邏輯更新
+ # 挑戰任務:獲取滑鼠座標
+ mouse_x, mouse_y = pygame.mouse.get_pos()
+
+ # 基本要求:直接跟隨
+ # my_x = mouse_x
+ # my_y = mouse_y
+
+ # 進階要求:平滑跟隨 (Lerp)
+ my_x = my_x + (mouse_x - my_x) / 10
+ my_y = my_y + (mouse_y - my_y) / 10
+
+ # 【支柱四】畫面渲染
+ screen.fill((30, 30, 30))
+ pygame.draw.circle(screen, color, (int(my_x), int(my_y)), 30)
+ pygame.display.flip()
+
+ clock.tick(60)
+# ----- 遊戲主迴圈結束 -----
diff --git a/projects/2026project_class/examples/w2_1_basic_api.py b/projects/2026project_class/examples/w2_1_basic_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..c46ac69d73c38177fe4ddacf0107c298dd8d3a59
--- /dev/null
+++ b/projects/2026project_class/examples/w2_1_basic_api.py
@@ -0,0 +1,26 @@
+import requests
+
+# 1. 準備好我們的目標網址 (餐廳菜單上的某一頁)
+url = "https://api.ipify.org?format=json"
+
+try:
+ # 2. 派出服務生 (使用 get 請求)
+ print("正在發送網路請求...")
+ # timeout=3 意思是:如果服務生等超過 3 秒廚房都沒反應,就直接放棄
+ response = requests.get(url, timeout=3)
+
+ # 檢查狀態碼是不是 200 成功
+ if response.status_code == 200:
+ # 3. 把伺服器給的一團文字,自動轉換為 Python 字典
+ data = response.json()
+ print("成功獲取資料:", data)
+ print("-------------------")
+
+ # 4. 精準提取資料
+ print("我的 IP 號碼是:", data["ip"])
+ else:
+ print(f"哎呀!伺服器回應錯誤碼:{response.status_code}")
+
+# 如果發生網路斷線、超時等各式連線災難,通通會跌進這個 except 的緩衝墊裡:
+except requests.exceptions.RequestException as e:
+ print("【系統警告】連線失敗!請檢查您的網路或等一下再試。")
diff --git a/projects/2026project_class/examples/w2_2_challenge_joke_api.py b/projects/2026project_class/examples/w2_2_challenge_joke_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..077134f3938c3fc9e83710c1d7fc677db0eb78bd
--- /dev/null
+++ b/projects/2026project_class/examples/w2_2_challenge_joke_api.py
@@ -0,0 +1,42 @@
+import requests
+
+# 挑戰任務:官方隨機笑話庫 API
+url = "https://official-joke-api.appspot.com/random_joke"
+
+print("==============================")
+print(" 🤖 歡迎來到終端機冷笑話產生器")
+print("==============================\n")
+
+while True:
+ user_input = input("按 Enter 取得下一個笑話 (輸入 q 退出):")
+
+ if user_input.lower() == 'q':
+ print("掰掰!")
+ break
+
+ try:
+ # 發送 GET 請求,設定超時以防卡死
+ response = requests.get(url, timeout=3)
+
+ if response.status_code == 200:
+ # 成功取得資料,解析 JSON
+ data = response.json()
+
+ # 從 JSON 中萃取我們需要的欄位
+ setup = data["setup"]
+ punchline = data["punchline"]
+
+ print("\n------------------------------")
+ print(f"🤔 鋪陳: {setup}")
+ print(f"🤣 笑點: {punchline}")
+ print("------------------------------\n")
+
+ else:
+ print(f"\n伺服器不太舒服 (錯誤碼:{response.status_code})\n")
+
+ except requests.exceptions.Timeout:
+ print("\n⏳ 網路連線太慢了,服務生等不及了 (Timeout)\n")
+ except requests.exceptions.ConnectionError:
+ print("\n🔌 網路斷線!請檢查您的 Wi-Fi 是否正常連線。\n")
+ except Exception as e:
+ print(f"\n⚠️ 發生未知的錯誤:{e}\n")
diff --git a/projects/2026project_class/examples/w4_dashboard_template.py b/projects/2026project_class/examples/w4_dashboard_template.py
new file mode 100644
index 0000000000000000000000000000000000000000..9fb65e7f11a760bc1fde06684e27a5069a08e0e2
--- /dev/null
+++ b/projects/2026project_class/examples/w4_dashboard_template.py
@@ -0,0 +1,238 @@
+import pygame, sys, requests, math, random
+
+pygame.init()
+
+# ===== 全域常數與設定 =====
+WIDTH, HEIGHT = 900, 650
+NEON_BLUE = (0, 210, 255)
+NEON_GREEN = (0, 255, 128)
+NEON_ORANGE = (255, 150, 50)
+NEON_PINK = (255, 80, 180)
+TEXT_WHITE = (245, 245, 255)
+TEXT_DIM = (160, 160, 180)
+
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("數位助理儀表板 天氣版 (學生用模板)")
+clock = pygame.time.Clock()
+
+font_huge = pygame.font.SysFont("microsoftjhenghei", 76, bold=True)
+font_big = pygame.font.SysFont("microsoftjhenghei", 38, bold=True)
+font_mid = pygame.font.SysFont("microsoftjhenghei", 26, bold=True)
+font_small = pygame.font.SysFont("microsoftjhenghei", 20)
+
+# ===== TODO 0:客製化你的城市清單 =====
+# 請隨意保留或更改你想查詢的城市名稱。
+# 經緯度怎麼查?打開 Google Maps,對著你想查的地點按「滑鼠右鍵」,第一行出現的數字就是經緯度,點擊即可複製!
+CITIES = {
+ "台北": {"lat": 25.04, "lon": 121.56},
+ "你的夢想城市": {"lat": 0.00, "lon": 0.00}, # <- 查好後,把 0.00 換成真實的緯度與經度
+ "最想去玩的地方": {"lat": 0.00, "lon": 0.00},
+}
+
+# ===== UI 繪圖輔助函式 (已經為你寫好啦!) =====
+def draw_gradient_bg(surface, top_color, bottom_color):
+ rect = surface.get_rect()
+ color_surface = pygame.Surface((1, 2))
+ pygame.draw.line(color_surface, top_color, (0,0), (0,0))
+ pygame.draw.line(color_surface, bottom_color, (0,1), (0,1))
+ color_surface = pygame.transform.smoothscale(color_surface, (rect.width, rect.height))
+ surface.blit(color_surface, rect)
+
+def draw_glass_card(surface, x, y, w, h, radius=18):
+ card = pygame.Surface((w, h), pygame.SRCALPHA)
+ pygame.draw.rect(card, (30, 35, 55, 220), (0, 0, w, h), border_radius=radius)
+ pygame.draw.rect(card, (255, 255, 255, 40), (0, 0, w, h), width=2, border_radius=radius)
+ surface.blit(card, (x, y))
+
+def draw_glowing_circle(surface, color, center, radius):
+ for i in range(radius * 4, radius, -2):
+ alpha = int(120 * (radius / i))
+ glow = pygame.Surface((i*2, i*2), pygame.SRCALPHA)
+ pygame.draw.circle(glow, (*color, alpha), (i, i), i)
+ surface.blit(glow, (center[0]-i, center[1]-i))
+ pygame.draw.circle(surface, color, center, radius)
+
+def draw_glowing_rect(surface, color, rect, radius):
+ for i in range(15, 0, -3):
+ alpha = int(80 * (1 - i/15))
+ glow = pygame.Surface((rect.width + i*2, rect.height + i*2), pygame.SRCALPHA)
+ pygame.draw.rect(glow, (*color, alpha), (0, 0, rect.width + i*2, rect.height + i*2), border_radius=radius+i)
+ surface.blit(glow, (rect.x - i, rect.y - i))
+
+class NinjaButton:
+ def __init__(self, x, y, text, is_real=True):
+ self.w, self.h = 160, 45
+ self.rect = pygame.Rect(x, y, self.w, self.h)
+ self.text, self.is_real, self.visible, self.hovered = text, is_real, True, False
+
+ def draw(self, surface):
+ if not self.visible: return
+ color = NEON_PINK if self.hovered else (40, 50, 80)
+ if self.hovered: draw_glowing_rect(surface, NEON_PINK, self.rect, 12)
+ pygame.draw.rect(surface, color, self.rect, border_radius=12)
+ pygame.draw.rect(surface, TEXT_WHITE, self.rect, width=2, border_radius=12)
+ text_surf = font_small.render(self.text, True, TEXT_WHITE)
+ surface.blit(text_surf, text_surf.get_rect(center=self.rect.center))
+
+
+# ==========================================
+# ===== 🛠️ 以下是你的實作區域 (TODOs) 🛠️ =====
+# ==========================================
+
+def fetch_weather(city_name="台北"):
+ """抓取指定即時天氣"""
+ coords = CITIES.get(city_name, CITIES["台北"])
+ try:
+ # 【TODO 1-1】請使用 coords['lat'] 與 coords['lon'] 組合出正確的 Open-Meteo API 網址 (需加上 &timezone=Asia%2FTaipei)
+ url = "" # <- 替換為正確的字串
+
+ # 【TODO 1-2】使用 requests.get 發送請求,並設定 timeout=5
+ # r = ...
+
+ # 假設已經成功抓到了 (以下需要你把註解打開並完成填空)
+ # if r.status_code == 200:
+ # data = r.json()
+ #
+ # # 【TODO 1-3】從 data 中安全地取出 'current_weather' 的字典
+ # curr = {}
+ #
+ # return {
+ # "city": city_name,
+ # # 【TODO 1-4】從 curr 字典中分別抓出 'temperature', 'windspeed', 'time' 的數值 (用 get 比較安全)
+ # "temp": f"??°C", # <- 替換 ??
+ # "wind": f"?? km/h", # <- 替換 ??
+ # "time": "??".replace("T", " "), # <- 替換 ??
+ # "ok": True
+ # }
+ pass
+ except:
+ pass
+ return {"city": city_name, "temp": "N/A", "wind": "N/A", "time": "無法連線", "ok": False}
+
+
+def fetch_ip():
+ """抓取目前的 IP 位址"""
+ try:
+ # 【TODO 2】使用 requests 抓取 "https://api.ipify.org?format=json" 的資料
+ # 並從回傳的 JSON 字典中取出 "ip" 這個欄位的字串回傳
+ pass # <- 替換為你的程式碼
+ except:
+ pass
+ return "127.0.0.1 (Offline)"
+
+
+# 初始抓取(程式剛打開時的第一次呼叫)
+weather = fetch_weather()
+my_ip = fetch_ip()
+
+# 跑馬燈與狀態初始化
+marquee = font_mid.render("快點擊更新按鈕試試看影分身術吧!", True, NEON_ORANGE)
+text_width = marquee.get_width()
+scroll_x = WIDTH
+
+main_btn = NinjaButton(340, 280, "點我更新")
+ninja_buttons = [main_btn]
+ninja_mode = False
+
+# ===== 遊戲主迴圈 =====
+while True:
+ mouse_pos = pygame.mouse.get_pos()
+ for btn in ninja_buttons:
+ btn.hovered = btn.rect.collidepoint(mouse_pos)
+
+ for event in pygame.event.get():
+
+ # 【TODO 3-1:關閉視窗】如果收到 pygame.QUIT 事件,請結束程式
+ pass # <- 替換為你的程式碼
+
+ # 【TODO 3-2:滑鼠點擊】如果收到滑鼠點擊事件 (pygame.MOUSEBUTTONDOWN) 且按鍵是左鍵 (event.button == 1)
+ if False: # <- 將 False 替換為正確的 if 判斷式
+ for btn in reversed(ninja_buttons):
+ if btn.visible and btn.hovered:
+ if not ninja_mode:
+ ninja_mode = True
+ ninja_buttons.clear()
+ positions = [(random.randint(50, WIDTH - 200), random.randint(120, HEIGHT - 150)) for _ in range(6)]
+ real_idx = random.randint(0, 5)
+ for i, pos in enumerate(positions):
+ ninja_buttons.append(NinjaButton(pos[0], pos[1], "點我更新", is_real=(i == real_idx)))
+ marquee = font_mid.render("警告:按鈕已發動影分身術!找出真正的按鈕才能更新天氣!", True, NEON_PINK)
+ text_width = marquee.get_width()
+ scroll_x = WIDTH
+ else:
+ if btn.is_real:
+ # 【TODO 4:打贏分身術的獎勵】玩家點到真正的按鈕了!請實作以下功能:
+ # 1. 用 random.choice() 與 list(CITIES.keys()) 抽出一個隨機城市,存入變數 next_city
+ # 2. 呼叫 fetch_weather(next_city) 重新取得天氣資料,並寫入 weather 全域變數中
+
+ next_city = "台北" # <- 修改這行
+
+ ninja_mode = False
+ ninja_buttons = [main_btn]
+ main_btn.visible = True
+ marquee = font_mid.render(f"恭喜!成功識破影分身術,已為您切換至【{next_city}】的天氣!", True, NEON_GREEN)
+ text_width = marquee.get_width()
+ scroll_x = WIDTH
+ else:
+ btn.visible = False
+ break
+
+ scroll_x -= 3
+ if scroll_x < -text_width: scroll_x = WIDTH
+
+ draw_gradient_bg(screen, (9, 13, 30), (28, 15, 45))
+ float_y = math.sin(pygame.time.get_ticks() / 350.0) * 6
+
+ # 標題
+ title = font_big.render("我的天氣助理儀表板", True, NEON_BLUE)
+ screen.blit(title, (40, 30))
+ pygame.draw.line(screen, (255, 255, 255, 80), (40, 80), (WIDTH - 40, 80), 2)
+
+ # 天氣面板
+ # 【TODO 5:繪製卡片背景】請呼叫 draw_glass_card(screen, x, y, w, h),將天氣面板畫在 x=40, y=110,寬長 480x240 的位置
+
+ icon = font_mid.render(f"{weather.get('city', '台北')}即時天氣", True, NEON_ORANGE)
+ screen.blit(icon, (65, 135 + float_y))
+
+ # 【TODO 6:畫面字體渲染與蓋印】請用 font_huge 字體,將 weather['temp'] 渲染為白色 (TEXT_WHITE) 文字
+ # 並將產出的文字圖片繪製 (blit) 到畫面座標 (65, 185) 處
+ # temp_text = ...
+ # screen.blit(...)
+
+ wind = font_small.render(f"風速:{weather['wind']}", True, NEON_BLUE)
+ time_lbl = font_small.render(f"更新於:{weather['time']}", True, TEXT_DIM)
+ screen.blit(wind, (65, 275))
+ screen.blit(time_lbl, (65, 305))
+
+ # IP 面板
+ # 【TODO 5-2:繪製卡片背景】同樣地,把 IP 面板畫在 x=40, y=380,大小 480x120 的位置
+
+ ip_title = font_mid.render("你的網路位置 (IP)", True, NEON_PINK)
+ ip_text = font_big.render(my_ip, True, TEXT_WHITE)
+ screen.blit(ip_title, (65, 400))
+ screen.blit(ip_text, (65, 440))
+
+ # 狀態面板 (這張卡片先幫你寫好當參考)
+ draw_glass_card(screen, 550, 110, 310, 390)
+ sys_title = font_mid.render("系統狀態", True, TEXT_WHITE)
+ screen.blit(sys_title, (580, 135))
+
+ status_color = NEON_GREEN if weather["ok"] else (255, 50, 50)
+ draw_glowing_circle(screen, status_color, (600, 210), 4)
+ status_msg = "天氣 API 運作正常" if weather["ok"] else "天氣 API 離線中"
+ screen.blit(font_small.render(status_msg, True, TEXT_WHITE), (625, 198))
+
+ draw_glowing_circle(screen, NEON_GREEN, (600, 260), 4)
+ screen.blit(font_small.render("網際網路連線正常", True, TEXT_WHITE), (625, 248))
+
+ for btn in ninja_buttons:
+ btn.draw(screen)
+
+ # 跑馬燈
+ footer = pygame.Surface((WIDTH, 50), pygame.SRCALPHA)
+ pygame.draw.rect(footer, (0, 0, 0, 180), (0, 0, WIDTH, 50))
+ screen.blit(footer, (0, HEIGHT - 50))
+ screen.blit(marquee, (scroll_x, HEIGHT - 38))
+
+ pygame.display.flip()
+ clock.tick(60)
diff --git a/projects/2026project_class/examples/w4_dashboard_weather.py b/projects/2026project_class/examples/w4_dashboard_weather.py
new file mode 100644
index 0000000000000000000000000000000000000000..c46f1e3208fbd266ca0d2385ae5d784ef919ac90
--- /dev/null
+++ b/projects/2026project_class/examples/w4_dashboard_weather.py
@@ -0,0 +1,233 @@
+import pygame, sys, requests, math, random
+
+pygame.init()
+
+# ===== 全域常數 =====
+WIDTH, HEIGHT = 900, 650
+NEON_BLUE = (0, 210, 255)
+NEON_GREEN = (0, 255, 128)
+NEON_ORANGE = (255, 150, 50)
+NEON_PINK = (255, 80, 180)
+TEXT_WHITE = (245, 245, 255)
+TEXT_DIM = (160, 160, 180)
+
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("數位助理儀表板 天氣版")
+clock = pygame.time.Clock()
+
+# 字體設定(使用加粗提升質感)
+font_huge = pygame.font.SysFont("microsoftjhenghei", 76, bold=True)
+font_big = pygame.font.SysFont("microsoftjhenghei", 38, bold=True)
+font_mid = pygame.font.SysFont("microsoftjhenghei", 26, bold=True)
+font_small = pygame.font.SysFont("microsoftjhenghei", 20)
+
+def draw_gradient_bg(surface, top_color, bottom_color):
+ """繪製垂直漸層背景"""
+ rect = surface.get_rect()
+ color_surface = pygame.Surface((1, 2))
+ pygame.draw.line(color_surface, top_color, (0,0), (0,0))
+ pygame.draw.line(color_surface, bottom_color, (0,1), (0,1))
+ color_surface = pygame.transform.smoothscale(color_surface, (rect.width, rect.height))
+ surface.blit(color_surface, rect)
+
+def draw_glass_card(surface, x, y, w, h, radius=18):
+ """繪製具備毛玻璃質感的半透明卡片"""
+ card = pygame.Surface((w, h), pygame.SRCALPHA)
+ pygame.draw.rect(card, (30, 35, 55, 220), (0, 0, w, h), border_radius=radius)
+ pygame.draw.rect(card, (255, 255, 255, 40), (0, 0, w, h), width=2, border_radius=radius)
+ surface.blit(card, (x, y))
+
+def draw_glowing_circle(surface, color, center, radius):
+ """繪製會發光的霓虹圓點"""
+ for i in range(radius * 4, radius, -2):
+ alpha = int(120 * (radius / i))
+ glow = pygame.Surface((i*2, i*2), pygame.SRCALPHA)
+ pygame.draw.circle(glow, (*color, alpha), (i, i), i)
+ surface.blit(glow, (center[0]-i, center[1]-i))
+ pygame.draw.circle(surface, color, center, radius)
+
+def draw_glowing_rect(surface, color, rect, radius):
+ """繪製發光矩形"""
+ for i in range(15, 0, -3):
+ alpha = int(80 * (1 - i/15))
+ glow = pygame.Surface((rect.width + i*2, rect.height + i*2), pygame.SRCALPHA)
+ pygame.draw.rect(glow, (*color, alpha), (0, 0, rect.width + i*2, rect.height + i*2), border_radius=radius+i)
+ surface.blit(glow, (rect.x - i, rect.y - i))
+
+class NinjaButton:
+ """影分身按鈕類別"""
+ def __init__(self, x, y, text, is_real=True):
+ self.w = 160
+ self.h = 45
+ self.rect = pygame.Rect(x, y, self.w, self.h)
+ self.text = text
+ self.is_real = is_real
+ self.visible = True
+ self.hovered = False
+
+ def draw(self, surface):
+ if not self.visible: return
+
+ color = NEON_PINK if self.hovered else (40, 50, 80)
+
+ if self.hovered:
+ draw_glowing_rect(surface, NEON_PINK, self.rect, 12)
+
+ pygame.draw.rect(surface, color, self.rect, border_radius=12)
+ pygame.draw.rect(surface, TEXT_WHITE, self.rect, width=2, border_radius=12)
+
+ text_surf = font_small.render(self.text, True, TEXT_WHITE)
+ text_rect = text_surf.get_rect(center=self.rect.center)
+ surface.blit(text_surf, text_rect)
+
+
+# 城市座標字典
+CITIES = {
+ "台北": {"lat": 25.04, "lon": 121.56},
+ "東京": {"lat": 35.68, "lon": 139.75},
+ "倫敦": {"lat": 51.51, "lon": -0.13},
+ "雪梨": {"lat": -33.87, "lon": 151.21},
+ "紐約": {"lat": 40.71, "lon": -74.01}
+}
+
+def fetch_weather(city_name="台北"):
+ """抓取指定即時天氣"""
+ coords = CITIES.get(city_name, CITIES["台北"])
+ try:
+ r = requests.get(f"https://api.open-meteo.com/v1/forecast?latitude={coords['lat']}&longitude={coords['lon']}¤t_weather=true&timezone=Asia%2FTaipei", timeout=5)
+ if r.status_code == 200:
+ curr = r.json().get("current_weather", {})
+ return {
+ "city": city_name,
+ "temp": f"{curr.get('temperature', '--')}°C",
+ "wind": f"{curr.get('windspeed', '--')} km/h",
+ "time": curr.get("time", "").replace("T", " "),
+ "ok": True
+ }
+ except: ...
+ return {"city": city_name, "temp": "N/A", "wind": "N/A", "time": "無法連線", "ok": False}
+
+def fetch_ip():
+ try:
+ r = requests.get("https://api.ipify.org?format=json", timeout=3)
+ if r.status_code == 200: return r.json()["ip"]
+ except: ...
+ return "127.0.0.1 (Offline)"
+
+weather = fetch_weather()
+my_ip = fetch_ip()
+
+marquee = font_mid.render("最新消息:Pygame 好好玩!快點擊更新按鈕試試看影分身術吧!", True, NEON_ORANGE)
+text_width = marquee.get_width()
+scroll_x = WIDTH
+
+# --- 忍者按鈕初始化 ---
+main_btn = NinjaButton(340, 280, "點我更新")
+ninja_buttons = [main_btn]
+ninja_mode = False
+
+while True:
+ mouse_pos = pygame.mouse.get_pos()
+ for btn in ninja_buttons:
+ btn.hovered = btn.rect.collidepoint(mouse_pos)
+
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
+ for btn in reversed(ninja_buttons):
+ if btn.visible and btn.hovered:
+ if not ninja_mode:
+ # 觸發影分身
+ ninja_mode = True
+ ninja_buttons.clear()
+ # 產生 6 個分身按鈕,散佈在畫面各處
+ positions = []
+ for _ in range(6):
+ rx = random.randint(50, WIDTH - 200)
+ ry = random.randint(120, HEIGHT - 150)
+ positions.append((rx, ry))
+
+ real_idx = random.randint(0, 5)
+ for i, pos in enumerate(positions):
+ ninja_buttons.append(NinjaButton(pos[0], pos[1], "點我更新", is_real=(i == real_idx)))
+ # 改變跑馬燈文字
+ marquee = font_mid.render("警告:按鈕已發動影分身術!找出真正的按鈕才能更新天氣!", True, NEON_PINK)
+ text_width = marquee.get_width()
+ scroll_x = WIDTH
+ else:
+ if btn.is_real:
+ # 點到本尊,隨機選一個新城市
+ next_city = random.choice(list(CITIES.keys()))
+ weather = fetch_weather(next_city)
+ ninja_mode = False
+ ninja_buttons = [main_btn]
+ main_btn.visible = True
+ marquee = font_mid.render(f"恭喜!成功識破影分身術,已為您切換至【{next_city}】的天氣!", True, NEON_GREEN)
+ text_width = marquee.get_width()
+ scroll_x = WIDTH
+ else:
+ # 點到假身,假身消失
+ btn.visible = False
+ break
+
+ scroll_x -= 3
+ if scroll_x < -text_width: scroll_x = WIDTH
+
+ # 1. 繪製漸層背景
+ draw_gradient_bg(screen, (9, 13, 30), (28, 15, 45))
+
+ float_y = math.sin(pygame.time.get_ticks() / 350.0) * 6
+
+ # 2. 標題列
+ title = font_big.render("我的天氣助理儀表板", True, NEON_BLUE)
+ screen.blit(title, (40, 30))
+ pygame.draw.line(screen, (255, 255, 255, 80), (40, 80), (WIDTH - 40, 80), 2)
+
+ # 3. 天氣面板 (不含按鈕了)
+ draw_glass_card(screen, 40, 110, 480, 240)
+ icon = font_mid.render(f"{weather.get('city', '台北')}即時天氣", True, NEON_ORANGE)
+ screen.blit(icon, (65, 135 + float_y))
+ temp_text = font_huge.render(weather['temp'], True, TEXT_WHITE)
+ screen.blit(temp_text, (65, 185))
+ wind = font_small.render(f"風速:{weather['wind']}", True, NEON_BLUE)
+ time_lbl = font_small.render(f"更新於:{weather['time']}", True, TEXT_DIM)
+ screen.blit(wind, (65, 275))
+ screen.blit(time_lbl, (65, 305))
+
+ # 4. IP 面板
+ draw_glass_card(screen, 40, 380, 480, 120)
+ ip_title = font_mid.render("你的網路位置 (IP)", True, NEON_PINK)
+ ip_text = font_big.render(my_ip, True, TEXT_WHITE)
+ screen.blit(ip_title, (65, 400))
+ screen.blit(ip_text, (65, 440))
+
+ # 5. 右側狀態面板
+ draw_glass_card(screen, 550, 110, 310, 390)
+ sys_title = font_mid.render("系統狀態", True, TEXT_WHITE)
+ screen.blit(sys_title, (580, 135))
+
+ status_color = NEON_GREEN if weather["ok"] else (255, 50, 50)
+ draw_glowing_circle(screen, status_color, (600, 210), 4)
+ status_msg = "天氣 API 運作正常" if weather["ok"] else "天氣 API 離線中"
+ status_txt = font_small.render(status_msg, True, TEXT_WHITE)
+ screen.blit(status_txt, (625, 198))
+
+ draw_glowing_circle(screen, NEON_GREEN, (600, 260), 4)
+ net_txt = font_small.render("網際網路連線正常", True, TEXT_WHITE)
+ screen.blit(net_txt, (625, 248))
+
+ # 畫出所有的忍者按鈕(注意要確保畫在卡片上方,所以放最後)
+ for btn in ninja_buttons:
+ btn.draw(screen)
+
+ # 6. 底部跑馬燈
+ footer = pygame.Surface((WIDTH, 50), pygame.SRCALPHA)
+ pygame.draw.rect(footer, (0, 0, 0, 180), (0, 0, WIDTH, 50))
+ screen.blit(footer, (0, HEIGHT - 50))
+ screen.blit(marquee, (scroll_x, HEIGHT - 38))
+
+ pygame.display.flip()
+ clock.tick(60)
diff --git a/projects/2026project_class/examples/w5_udp_chat.py b/projects/2026project_class/examples/w5_udp_chat.py
new file mode 100644
index 0000000000000000000000000000000000000000..451f1f136331d98f0e72e19ef84750ef03cb2636
--- /dev/null
+++ b/projects/2026project_class/examples/w5_udp_chat.py
@@ -0,0 +1,48 @@
+import socket
+import threading
+import sys
+
+# 1. 建立 UDP Socket 並綁定大門
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+try:
+ sock.bind(("0.0.0.0", 5000))
+except OSError:
+ print("Port 5000 已經被佔用,請確定之前的接收端是否已關閉!")
+ sys.exit(1)
+
+# 2. 定義「背景收信」的工作內容
+def receive_messages():
+ while True:
+ try:
+ data, addr = sock.recvfrom(1024)
+ print(f"\n[收到來自 {addr[0]}]:{data.decode('utf-8')}", end="\n> ")
+ except Exception as e:
+ # Socket 關閉或錯誤時退出迴圈
+ break
+
+# 召喚小精靈去背景執行 (與主程式分工合作)
+threading.Thread(target=receive_messages, daemon=True).start()
+
+# 3. 前景發信主程式
+print("聊天室已啟動!自己的接收端運作於本機的 Port 5000")
+target_ip = input("請輸入對方 IP (直接按 Enter 可預設 127.0.0.1 測試): ").strip()
+if not target_ip:
+ target_ip = "127.0.0.1"
+
+print(f"準備傳送訊息給 {target_ip}:5000 (輸入 'exit' 可離開程式)")
+
+while True:
+ try:
+ msg = input("> ") # 這裡會卡住等你打字,但背景的 recvfrom() 依然在辛勤運作!
+ if msg.strip().lower() == "exit":
+ break
+ if msg.strip() != "":
+ sock.sendto(msg.encode('utf-8'), (target_ip, 5000))
+ except KeyboardInterrupt:
+ break
+ except OSError as e:
+ print("【系統】網路傳輸失敗,請檢查 Wi-Fi 是否連線,或 IP 格式是否正確!例外細節:", e)
+
+# 離開時關閉 socket
+sock.close()
+print("已離開聊天室。")
diff --git a/projects/2026project_class/examples/w5_udp_receiver.py b/projects/2026project_class/examples/w5_udp_receiver.py
new file mode 100644
index 0000000000000000000000000000000000000000..fdb0f2be4b1036c2af87343b52b34775d6552b44
--- /dev/null
+++ b/projects/2026project_class/examples/w5_udp_receiver.py
@@ -0,0 +1,16 @@
+import socket
+
+# 建立 UDP Socket 水管
+# AF_INET = IPv4 位址格式, SOCK_DGRAM = UDP 模式 (Datagram)
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+# "0.0.0.0" 代表此台電腦的任何網路介面都傾聽這個 5000 敲門聲
+sock.bind(("0.0.0.0", 5000))
+print("成功佔用 Port 5000,等待訊息中...")
+
+# 一次最多接 1024 bytes 的包裹。這裡會「卡住(阻塞)」直到收到東西為止。
+data, addr = sock.recvfrom(1024)
+
+# 收到的資料也是 bytes,解碼顯示給人類看
+text = data.decode('utf-8')
+print(f"收到來自 {addr} 的訊息:", text)
diff --git a/projects/2026project_class/examples/w5_udp_sender.py b/projects/2026project_class/examples/w5_udp_sender.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d9253e28ab90292bef436ed7daeabddb3c19e92
--- /dev/null
+++ b/projects/2026project_class/examples/w5_udp_sender.py
@@ -0,0 +1,20 @@
+import socket
+
+# 建立 UDP Socket 水管
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+# 把文字編成機器碼 bytes
+message = "Hello 前方同學!".encode('utf-8')
+
+# 發射!如果對方沒開接收端,用UDP傳送也不會閃退,信件會直接在虛空中消失。
+# 這裡預設為本機 IP 127.0.0.1 供測試
+target_info = ("127.0.0.1", 5000) # [請修改為同學的 IP]
+
+print(f"準備發送訊息至 {target_info}")
+
+try:
+ sock.sendto(message, target_info)
+ print("訊息已發送出去了!")
+except OSError as e:
+ # 網卡罷工或斷線時的防範
+ print("【系統】網路傳輸失敗,請檢查 Wi-Fi 是否連線,或 IP 格式是否正確!例外細節:", e)
diff --git a/projects/2026project_class/examples/w6_json_demo_complete.py b/projects/2026project_class/examples/w6_json_demo_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..66ec5ce116d876361d680144ffeeaac8380dbbf4
--- /dev/null
+++ b/projects/2026project_class/examples/w6_json_demo_complete.py
@@ -0,0 +1,27 @@
+import json
+
+# 1. 將 Python 字典打包成 JSON 字串 (Serialization)
+print("=== 把資料打包成字串 (dumps) ===")
+package = {
+ "id": "player1",
+ "x": 100,
+ "y": 200,
+ "hp": 5,
+ "is_alive": True
+}
+
+# json.dumps 可以把字典轉換為純文字字串
+json_string = json.dumps(package)
+print(f"原始字典型態: {type(package)}")
+print(f"打包後變數型態: {type(json_string)}")
+print(f"打包後的 JSON 字串內容:\n{json_string}\n")
+
+# 2. 將 JSON 字串還原為 Python 字典 (Deserialization)
+print("=== 將收到的字串拆解為字典 (loads) ===")
+# 假設我們從網路上收到了一段純文字
+received_string = '{"id": "enemy2", "x": 350, "y": 420, "hp": 3, "is_alive": true}'
+
+original_dict = json.loads(received_string)
+print(f"還原後變數型態: {type(original_dict)}")
+print(f"還原後的對手 x 座標為: {original_dict['x']}")
+print(f"還原後的對手血量為: {original_dict['hp']}")
diff --git a/projects/2026project_class/examples/w6_json_demo_student.py b/projects/2026project_class/examples/w6_json_demo_student.py
new file mode 100644
index 0000000000000000000000000000000000000000..73102e8978c4c9bbf6ceb87c43cdc51ceb26d858
--- /dev/null
+++ b/projects/2026project_class/examples/w6_json_demo_student.py
@@ -0,0 +1,38 @@
+import json
+
+# 1. 將 Python 字典打包成 JSON 字串 (Serialization)
+print("=== 把資料打包成字串 (dumps) ===")
+package = {
+ "id": "player1",
+ "x": 100,
+ "y": 200,
+ "hp": 5,
+ "is_alive": True
+}
+
+# ==========================================
+# TODO 1: 將上方 package 字典,打包成為 JSON 格式字串
+# 提示: json.dumps(...)
+# ==========================================
+json_string = "" # 請替換這裡的程式碼
+
+print(f"原始字典型態: {type(package)}")
+print(f"打包後變數型態: {type(json_string)}")
+print(f"打包後的 JSON 字串內容:\n{json_string}\n")
+
+# 2. 將 JSON 字串還原為 Python 字典 (Deserialization)
+print("=== 將收到的字串拆解為字典 (loads) ===")
+# 假設我們從網路上收到了一段純文字
+received_string = '{"id": "enemy2", "x": 350, "y": 420, "hp": 3, "is_alive": true}'
+
+# ==========================================
+# TODO 2: 將收到的 received_string 字串,還原成 Python 字典
+# 提示: json.loads(...)
+# ==========================================
+original_dict = {} # 請替換這裡的程式碼
+
+print(f"還原後變數型態: {type(original_dict)}")
+# 若上一題寫對,應該可以順利解開註解印出以下數值
+# print(f"還原後的對手 x 座標為: {original_dict['x']}")
+# print(f"還原後的對手血量為: {original_dict['hp']}")
+
diff --git a/projects/2026project_class/examples/w6_sync_player_complete.py b/projects/2026project_class/examples/w6_sync_player_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..995492160e7abf19e5b1abb0ec3755c8affb6183
--- /dev/null
+++ b/projects/2026project_class/examples/w6_sync_player_complete.py
@@ -0,0 +1,86 @@
+import socket
+import json
+import pygame
+import sys
+
+# === 1. 面板與網路初始化 ===
+pygame.init()
+screen = pygame.display.set_mode((800, 600))
+pygame.display.set_caption("Week 6 - 雙人座標同步")
+clock = pygame.time.Clock()
+
+my_x, my_y = 100, 100
+enemy_x, enemy_y = -100, -100 # 一開始對方在畫面外
+
+# 建立 UDP 連線
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.bind(("0.0.0.0", 5000))
+
+# 【關鍵】:設定為非阻塞模式 Non-blocking !!!
+# 這讓 recvfrom 就算沒收到信,也不會卡住整個遊戲的畫面更新!
+sock.setblocking(False)
+
+# 測試用:請輸入同學的 IP,如果沒有同學可改為 "127.0.0.1" 測試自己傳給自己
+TARGET_IP = input("請輸入對方 IP (Enter 預設 127.0.0.1): ")
+if not TARGET_IP:
+ TARGET_IP = "127.0.0.1"
+
+print(f"連線成功!準備將座標同步給 {TARGET_IP}")
+
+# === 2. 遊戲主迴圈 ===
+while True:
+ screen.fill((30, 30, 50)) # 深色背景
+
+ # --- A. 接收對方的資料 ---
+ try:
+ # 因為設了 setblocking(False),沒有信的時候會引發 BlockingIOError
+ data, addr = sock.recvfrom(1024)
+
+ # 拆開看對方傳了什麼
+ enemy_package = json.loads(data.decode())
+ enemy_x = enemy_package['x']
+ enemy_y = enemy_package['y']
+
+ except (BlockingIOError, ConnectionResetError):
+ # 非阻塞模式下,Linux 拋 BlockingIOError,Windows 拋 ConnectionResetError
+ # 沒有信?當作沒發生,程式繼續往下走!
+ pass
+ except json.JSONDecodeError:
+ # 防止有人亂傳不是 JSON 格式的垃圾訊息導致閃退
+ pass
+
+ # --- B. 處理遊戲事件與玩家輸入 ---
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 鍵盤控制自己移動
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_LEFT]: my_x -= 5
+ if keys[pygame.K_RIGHT]: my_x += 5
+ if keys[pygame.K_UP]: my_y -= 5
+ if keys[pygame.K_DOWN]: my_y += 5
+
+ # --- C. 繪圖階段 ---
+ # 畫自己 (藍色)
+ pygame.draw.rect(screen, (59, 130, 246), (my_x, my_y, 40, 40))
+ # 畫對手 (綠色)
+ pygame.draw.rect(screen, (16, 185, 129), (enemy_x, enemy_y, 40, 40))
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # --- D. 送出自己的資料給對方 ---
+ my_package = {
+ "x": my_x,
+ "y": my_y
+ }
+
+ # 打包 -> 轉 bytes -> 送出
+ json_str = json.dumps(my_package)
+ try:
+ sock.sendto(json_str.encode(), (TARGET_IP, 5000))
+ except OSError:
+ # 若發生網路短暫斷線引發的 OS 錯誤,我們也能忽略不處理以避免閃退
+ pass
diff --git a/projects/2026project_class/examples/w6_sync_player_student.py b/projects/2026project_class/examples/w6_sync_player_student.py
new file mode 100644
index 0000000000000000000000000000000000000000..6dadf61f934a072b294500b89dca2a19bba2088a
--- /dev/null
+++ b/projects/2026project_class/examples/w6_sync_player_student.py
@@ -0,0 +1,84 @@
+import socket
+import json
+import pygame
+import sys
+
+# === 1. 面板與網路初始化 ===
+pygame.init()
+screen = pygame.display.set_mode((800, 600))
+pygame.display.set_caption("Week 6 - 雙人座標同步 (學生版)")
+clock = pygame.time.Clock()
+
+my_x, my_y = 100, 100
+enemy_x, enemy_y = -100, -100 # 一開始對方在畫面外
+
+# 建立 UDP 連線
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.bind(("0.0.0.0", 5000))
+
+# ==========================================
+# TODO 1: 設定 socket 為非阻塞模式 (Non-blocking)
+# 提示: 使用 sock.setblocking(...)
+# ==========================================
+
+
+
+# 測試用:請輸入同學的 IP,如果沒有同學可改為 "127.0.0.1" 測試自己傳給自己
+TARGET_IP = input("請輸入對方 IP (Enter 預設 127.0.0.1): ")
+if not TARGET_IP:
+ TARGET_IP = "127.0.0.1"
+
+print(f"連線成功!準備將座標同步給 {TARGET_IP}")
+
+# === 2. 遊戲主迴圈 ===
+while True:
+ screen.fill((30, 30, 50)) # 深色背景
+
+ # --- A. 接收對方的資料 ---
+ # ==========================================
+ # TODO 2: 嘗試接收對方的資料,並更新 enemy_x 與 enemy_y
+ # 提示:
+ # try:
+ # data, addr = sock.recvfrom(1024)
+ # ... (反序列化解包 data.decode())
+ # except (BlockingIOError, ConnectionResetError):
+ # pass
+ # except json.JSONDecodeError:
+ # pass
+ # ==========================================
+
+
+ # --- B. 處理遊戲事件與玩家輸入 ---
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 鍵盤控制自己移動
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_LEFT]: my_x -= 5
+ if keys[pygame.K_RIGHT]: my_x += 5
+ if keys[pygame.K_UP]: my_y -= 5
+ if keys[pygame.K_DOWN]: my_y += 5
+
+ # --- C. 繪圖階段 ---
+ # 畫自己 (藍色)
+ pygame.draw.rect(screen, (59, 130, 246), (my_x, my_y, 40, 40))
+ # 畫對手 (綠色)
+ pygame.draw.rect(screen, (16, 185, 129), (enemy_x, enemy_y, 40, 40))
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # --- D. 送出自己的資料給對方 ---
+ # ==========================================
+ # TODO 3: 將自己的最新座標 my_x, my_y 打包成 JSON 並送出
+ # 提示:
+ # my_package = {"x": my_x, "y": my_y}
+ # json_str = ...
+ # try:
+ # sock.sendto(json_str.encode(), (TARGET_IP, 5000))
+ # except OSError:
+ # pass
+ # ==========================================
+
diff --git a/projects/2026project_class/examples/w7_hitbox_complete.py b/projects/2026project_class/examples/w7_hitbox_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..638357d8fd16d14ee6d33834aa3e83dc909fb9af
--- /dev/null
+++ b/projects/2026project_class/examples/w7_hitbox_complete.py
@@ -0,0 +1,141 @@
+import pygame
+import json
+import socket
+import sys
+
+# === 初始化 ===
+pygame.init()
+screen = pygame.display.set_mode((800, 600))
+clock = pygame.time.Clock()
+
+# ==== 【TODO 1】網路 Socket 設定(自動判斷 P1 / P2)====
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.setblocking(False)
+
+# 嘗試綁定 port 5000,若被佔用則綁定 5001
+# 先啟動的人 = P1 (左側),後啟動的人 = P2 (右側)
+try:
+ sock.bind(("0.0.0.0", 5000))
+ MY_PORT = 5000
+ TARGET_PORT = 5001
+ player_role = "P1"
+except OSError:
+ sock.bind(("0.0.0.0", 5001))
+ MY_PORT = 5001
+ TARGET_PORT = 5000
+ player_role = "P2"
+
+TARGET_IP = "127.0.0.1" # 測試用
+
+# 根據角色自動設定位置與子彈方向
+if player_role == "P1":
+ my_x, my_y = 100, 300 # P1 在左邊
+ enemy_x, enemy_y = 600, 300
+ my_bullet_dir = 10 # P1 子彈朝右飛
+ enemy_bullet_dir = -10
+else:
+ my_x, my_y = 600, 300 # P2 在右邊
+ enemy_x, enemy_y = 100, 300
+ my_bullet_dir = -10 # P2 子彈朝左飛
+ enemy_bullet_dir = 10
+
+my_hp = 10
+bullets = [] # 儲存畫面上所有的子彈
+bullet_id_counter = 0 # 子彈編號計數器,用來同步雙方畫面
+
+pygame.display.set_caption(f"Week 7 - 碰撞判定與血條爭奪戰 [{player_role} | Port {MY_PORT}]")
+print(f"遊戲啟動!你是 {player_role}(監聽 port {MY_PORT},發送至 port {TARGET_PORT})")
+
+while True:
+ screen.fill((30, 30, 50))
+
+ # ==== 【TODO 2】接收對手網路封包 ====
+ try:
+ data, addr = sock.recvfrom(1024)
+ package = json.loads(data.decode())
+ if package['type'] == 'pos':
+ # 這是座標信:就蓋過對方的舊照片座標
+ enemy_x = package['x']
+ enemy_y = package['y']
+ elif package['type'] == 'shoot':
+ # 這是射擊事件信:馬上在畫面上憑空「生成」一顆對方開火的子彈
+ bullets.append({'x': package['x'], 'y': package['y'], 'dir': enemy_bullet_dir, 'owner': 'enemy', 'id': package['id']})
+ elif package['type'] == 'hit':
+ # 這是對方被擊中的通知
+ print(f"對手受傷了!對方血量剩下: {package.get('hp', 0)}")
+ # 對方被我的子彈打中了 → 從我的畫面上移除那顆子彈
+ hit_bullet_id = package.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+ except (BlockingIOError, ConnectionResetError):
+ # 非阻塞模式下,Linux 拋 BlockingIOError,Windows 拋 ConnectionResetError
+ pass
+ except json.JSONDecodeError:
+ # 防止有人亂傳不是 JSON 格式的垃圾訊息導致閃退
+ pass
+
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 按下空白鍵發射子彈
+ if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
+ bullet_id_counter += 1
+ new_bullet = {'x': my_x, 'y': my_y, 'dir': my_bullet_dir, 'owner': 'me', 'id': bullet_id_counter}
+ bullets.append(new_bullet)
+
+ # ==== 【TODO 3】通知對方我開槍了 ====
+ shoot_package = {'type': 'shoot', 'x': my_x, 'y': my_y, 'id': bullet_id_counter}
+ # 將這個 package 用 dumps 打包並送出
+ sock.sendto(json.dumps(shoot_package).encode(), (TARGET_IP, TARGET_PORT))
+
+ # 控制移動
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_w]: my_y -= 5
+ if keys[pygame.K_s]: my_y += 5
+
+ # 繪製自己與對手 (使用 Rect 作為碰撞箱)
+ my_rect = pygame.Rect(my_x, my_y, 40, 40)
+ pygame.draw.rect(screen, (59, 130, 246), my_rect)
+
+ enemy_rect = pygame.Rect(enemy_x, enemy_y, 40, 40)
+ pygame.draw.rect(screen, (16, 185, 129), enemy_rect)
+
+ # 處理並繪製每一顆子彈
+ for b in bullets[:]:
+ # 使子彈移動
+ b['x'] += b['dir']
+
+ # 繪製子彈
+ bullet_color = (255, 200, 0) if b['owner'] == 'me' else (255, 50, 50)
+ bullet_rect = pygame.Rect(b['x'], b['y'], 10, 10)
+ pygame.draw.rect(screen, bullet_color, bullet_rect)
+
+ # ==== 【TODO 4: P2P 碰撞判定】 ====
+ # 若這是一顆「對方」射過來的子彈,且「打中我了」
+ if b['owner'] == 'enemy' and my_rect.colliderect(bullet_rect):
+ # 1. my_hp -= 1
+ my_hp -= 1
+ # 2. bullets.remove(b)
+ hit_bullet_id = b['id']
+ if b in bullets:
+ bullets.remove(b)
+ # 3. 傳送 {'type': 'hit', 'hp': my_hp, 'bullet_id': ...} 告訴對方我受傷了
+ # 帶上 bullet_id 讓對方也能從他的畫面移除這顆子彈
+ hit_msg = {'type': 'hit', 'hp': my_hp, 'bullet_id': hit_bullet_id}
+ sock.sendto(json.dumps(hit_msg).encode(), (TARGET_IP, TARGET_PORT))
+ print(f"我被擊中了!剩餘血量: {my_hp}")
+
+ # 飛出邊界則刪除
+ elif b['x'] < 0 or b['x'] > 800:
+ if b in bullets:
+ bullets.remove(b)
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # ==== 【TODO 5】傳送座標 ====
+ # 每回合結束,把自己的新座標透過封包 {'type': 'pos', 'x': xxx, 'y': xxx} 送過去
+ pos_package = {'type': 'pos', 'x': my_x, 'y': my_y}
+ sock.sendto(json.dumps(pos_package).encode(), (TARGET_IP, TARGET_PORT))
diff --git a/projects/2026project_class/examples/w7_hitbox_template.py b/projects/2026project_class/examples/w7_hitbox_template.py
new file mode 100644
index 0000000000000000000000000000000000000000..d83ee8946f0ebebe6edcc14b1af978ce88de7db0
--- /dev/null
+++ b/projects/2026project_class/examples/w7_hitbox_template.py
@@ -0,0 +1,120 @@
+import pygame
+import json
+import socket
+import sys
+
+# === 初始化 ===
+pygame.init()
+screen = pygame.display.set_mode((800, 600))
+clock = pygame.time.Clock()
+
+# ==== 【TODO 1】網路 Socket 設定(自動判斷 P1 / P2)====
+# 請在這裡建立 UDP Socket 並設定為非阻塞 (Non-blocking)
+# 程式會自動嘗試綁定 port 5000,若已被佔用則綁定 5001
+# 先啟動 = P1 (左側),後啟動 = P2 (右側)
+# sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+# sock.setblocking(False)
+# try:
+# sock.bind(("0.0.0.0", 5000))
+# MY_PORT = 5000; TARGET_PORT = 5001; player_role = "P1"
+# except OSError:
+# sock.bind(("0.0.0.0", 5001))
+# MY_PORT = 5001; TARGET_PORT = 5000; player_role = "P2"
+
+TARGET_IP = "127.0.0.1" # 測試用
+
+# 根據角色自動設定位置與子彈方向
+# if player_role == "P1":
+# my_x, my_y = 100, 300 # P1 在左邊
+# my_bullet_dir = 10 # 子彈朝右飛
+# enemy_bullet_dir = -10
+# else:
+# my_x, my_y = 600, 300 # P2 在右邊
+# my_bullet_dir = -10 # 子彈朝左飛
+# enemy_bullet_dir = 10
+
+my_x, my_y = 100, 300
+enemy_x, enemy_y = 600, 300
+my_hp = 10
+
+bullets = [] # 儲存畫面上所有的子彈
+bullet_id_counter = 0 # 子彈編號計數器,用來同步雙方畫面
+
+while True:
+ screen.fill((30, 30, 50))
+
+ # ==== 【TODO 2】接收對手網路封包 ====
+ # try:
+ # data, addr = sock.recvfrom(1024)
+ # package = json.loads(data.decode())
+ # if package['type'] == 'pos':
+ # 更新對方座標...
+ # elif package['type'] == 'shoot':
+ # 把對方的子彈加進 bullets 陣列裡(記得帶上 package['id'])...
+ # elif package['type'] == 'hit':
+ # 更新對方的血量數字...
+ # 利用 package['bullet_id'] 從 bullets 中移除我方已命中的子彈
+ # except (BlockingIOError, ConnectionResetError):
+ # # 非阻塞模式下,Linux 拋 BlockingIOError,Windows 拋 ConnectionResetError
+ # pass
+ # except json.JSONDecodeError:
+ # # 防止有人亂傳不是 JSON 格式的垃圾訊息導致閃退
+ # pass
+
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 按下空白鍵發射子彈
+ if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
+ bullet_id_counter += 1
+ new_bullet = {'x': my_x, 'y': my_y, 'dir': 10, 'owner': 'me', 'id': bullet_id_counter}
+ bullets.append(new_bullet)
+
+ # ==== 【TODO 3】通知對方我開槍了 ====
+ # shoot_package = {'type': 'shoot', 'x': my_x, 'y': my_y, 'id': bullet_id_counter}
+ # 將這個 package 用 dumps 打包並送出(記得送到 TARGET_PORT)
+
+ # 控制移動
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_w]: my_y -= 5
+ if keys[pygame.K_s]: my_y += 5
+
+ # 繪製自己與對手 (使用 Rect 作為碰撞箱)
+ my_rect = pygame.Rect(my_x, my_y, 40, 40)
+ pygame.draw.rect(screen, (59, 130, 246), my_rect)
+
+ enemy_rect = pygame.Rect(enemy_x, enemy_y, 40, 40)
+ pygame.draw.rect(screen, (16, 185, 129), enemy_rect)
+
+ # 處理並繪製每一顆子彈
+ for b in bullets[:]:
+ # 使子彈移動
+ b['x'] += b['dir']
+
+ # 繪製子彈
+ bullet_color = (255, 200, 0) if b['owner'] == 'me' else (255, 50, 50)
+ bullet_rect = pygame.Rect(b['x'], b['y'], 10, 10)
+ pygame.draw.rect(screen, bullet_color, bullet_rect)
+
+ # ==== 【TODO 4: P2P 碰撞判定】 ====
+ # 若這是一顆「對方」射過來的子彈,且「打中我了」
+ # if b['owner'] == 'enemy' and my_rect.colliderect(bullet_rect):
+ # 1. my_hp -= 1
+ # 2. hit_bullet_id = b['id']
+ # 3. bullets.remove(b)
+ # 4. 傳送 {'type': 'hit', 'hp': my_hp, 'bullet_id': hit_bullet_id}
+ # 帶上 bullet_id 讓對方也能從他的畫面移除這顆子彈
+
+ # 飛出邊界則刪除
+ if b['x'] < 0 or b['x'] > 800:
+ if b in bullets:
+ bullets.remove(b)
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # ==== 【TODO 5】傳送座標 ====
+ # 每回合結束,把自己的新座標透過封包 {'type': 'pos', 'x': xxx, 'y': xxx} 送過去
+ # 記得送到 TARGET_PORT
diff --git a/projects/2026project_class/examples/w8_arena_complete.py b/projects/2026project_class/examples/w8_arena_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..80e6097aab3d94e230731468e3783047206c2b06
--- /dev/null
+++ b/projects/2026project_class/examples/w8_arena_complete.py
@@ -0,0 +1,232 @@
+import pygame
+import socket
+import json
+import sys
+
+pygame.init()
+pygame.mixer.init()
+screen = pygame.display.set_mode((800, 600))
+clock = pygame.time.Clock()
+
+# --- 1. 網路設定(自動判斷 P1 / P2)---
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.setblocking(False)
+
+# 嘗試綁定 port 5000,若被佔用則綁定 5001
+# 先啟動的人 = P1 (左側),後啟動的人 = P2 (右側)
+try:
+ sock.bind(("0.0.0.0", 5000))
+ MY_PORT = 5000
+ TARGET_PORT = 5001
+ player_role = "P1"
+except OSError:
+ sock.bind(("0.0.0.0", 5001))
+ MY_PORT = 5001
+ TARGET_PORT = 5000
+ player_role = "P2"
+
+# 請修改為隔壁同學的 IP 準備互連!
+TARGET_IP = "127.0.0.1"
+
+# --- 2. 遊戲參數與各種變數(根據角色自動設定位置與方向)---
+if player_role == "P1":
+ my_pos = [100, 300] # P1 在左邊
+ enemy_pos = [600, 300] # 對手在右邊
+ my_bullet_dir = 10 # P1 子彈朝右飛
+ enemy_bullet_dir = -10 # 對手子彈朝左飛
+else:
+ my_pos = [600, 300] # P2 在右邊
+ enemy_pos = [100, 300] # 對手在左邊
+ my_bullet_dir = -10 # P2 子彈朝左飛
+ enemy_bullet_dir = 10 # 對手子彈朝右飛
+
+my_hp = 10
+enemy_hp = 10
+my_score = 0
+enemy_score = 0
+bullets = []
+bullet_id_counter = 0 # 子彈編號計數器,用來同步雙方畫面
+game_over = False
+opponent_connected = False # 追蹤對手是否已連線
+
+pygame.display.set_caption(f"Week 8 - 網路競技場最終戰 [{player_role} | Port {MY_PORT}]")
+print(f"遊戲啟動!你是 {player_role}(監聽 port {MY_PORT},發送至 port {TARGET_PORT})")
+print(f"位置:{'左側' if player_role == 'P1' else '右側'}")
+print("等待連線中...")
+
+# 字體與素材載入
+font = pygame.font.SysFont(None, 36)
+big_font = pygame.font.SysFont(None, 72)
+
+try:
+ bg_img = pygame.image.load("background.jpg")
+ bg_img = pygame.transform.scale(bg_img, (800, 600))
+except FileNotFoundError:
+ bg_img = None
+
+try:
+ shoot_sound = pygame.mixer.Sound("shoot.wav")
+except FileNotFoundError:
+ shoot_sound = None
+
+try:
+ hit_sound = pygame.mixer.Sound("hit.wav")
+except FileNotFoundError:
+ hit_sound = None
+
+# --- 3. 遊戲主迴圈 ---
+while True:
+ if bg_img:
+ screen.blit(bg_img, (0, 0))
+ else:
+ screen.fill((20, 20, 30))
+
+ # ======= [網路區間:接收最新消息] =======
+ # 【Bug修復】: 加入 while True 迴圈,一次清空所有堆積的網路封包,避免子彈穿透延遲!
+ while True:
+ try:
+ data, addr = sock.recvfrom(1024)
+ opponent_connected = True # 只要收到任何封包,就代表對手來了
+ pkg = json.loads(data.decode())
+
+ if pkg['type'] == 'pos':
+ enemy_pos[0] = pkg['x']
+ enemy_pos[1] = pkg['y']
+
+ elif pkg['type'] == 'shoot':
+ # 對手的子彈:用 enemy_bullet_dir 決定飛行方向,帶上對方給的 bullet_id
+ bullets.append({'x': pkg['x'], 'y': pkg['y'], 'dir': enemy_bullet_dir, 'owner': 'enemy', 'id': pkg['id']})
+ if shoot_sound:
+ shoot_sound.play()
+
+ elif pkg['type'] == 'hit':
+ enemy_hp = pkg.get('hp', 10)
+ # 對方被我的子彈打中了 → 從我的畫面上移除那顆子彈
+ hit_bullet_id = pkg.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+
+ elif pkg['type'] == 'im_dead':
+ # 對方宣告死亡
+ enemy_hp = pkg.get('hp', 0)
+ my_score += 1
+ hit_bullet_id = pkg.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+
+ except (BlockingIOError, ConnectionResetError):
+ # 非阻塞模式下,Linux 拋 BlockingIOError,Windows 拋 ConnectionResetError
+ break
+ except json.JSONDecodeError:
+ # 防止有人亂傳不是 JSON 格式的垃圾訊息導致閃退
+ pass
+
+ # ======= [事件區間:玩家輸入] =======
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit(); sys.exit()
+
+ if event.type == pygame.KEYDOWN:
+ # 只有活著且對手連線時才能開槍
+ if event.key == pygame.K_SPACE and not game_over and opponent_connected:
+ bullet_id_counter += 1
+ bullets.append({'x': my_pos[0], 'y': my_pos[1], 'dir': my_bullet_dir, 'owner': 'me', 'id': bullet_id_counter})
+ shoot_pkg = {'type': 'shoot', 'x': my_pos[0], 'y': my_pos[1], 'id': bullet_id_counter}
+ sock.sendto(json.dumps(shoot_pkg).encode(), (TARGET_IP, TARGET_PORT))
+ if shoot_sound:
+ shoot_sound.play()
+
+ # 死亡時可以按 R 鍵重生
+ if event.key == pygame.K_r and game_over:
+ game_over = False
+ my_hp = 10
+ my_pos = [100, 300] if player_role == "P1" else [600, 300]
+ respawn_pkg = {'type': 'hit', 'hp': my_hp}
+ sock.sendto(json.dumps(respawn_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+ # 處理 WASD 或方向鍵移動 (活著才能移動)
+ if not game_over:
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_w]: my_pos[1] -= 5
+ if keys[pygame.K_s]: my_pos[1] += 5
+ if keys[pygame.K_a]: my_pos[0] -= 5
+ if keys[pygame.K_d]: my_pos[0] += 5
+
+ # ======= [運算與繪圖區間] =======
+ my_rect = pygame.Rect(my_pos[0], my_pos[1], 40, 40)
+ enemy_rect = pygame.Rect(enemy_pos[0], enemy_pos[1], 40, 40)
+
+ # 畫出自己與對手的人物實體
+ if not game_over:
+ pygame.draw.rect(screen, (59, 130, 246), my_rect)
+
+ # 假設對手 > 0 血才畫對手 (而且必須已連線)
+ if opponent_connected and enemy_hp > 0:
+ pygame.draw.rect(screen, (16, 185, 129), enemy_rect)
+
+ # 畫計分板與血條
+ score_text = font.render(f"Score - You: {my_score} | Enemy: {enemy_score}", True, (255, 255, 255))
+ screen.blit(score_text, (800//2 - score_text.get_width()//2, 15))
+
+ if not game_over:
+ my_hp_text = font.render(f"HP: {my_hp}", True, (255, 255, 255))
+ screen.blit(my_hp_text, (my_pos[0], my_pos[1] - 30))
+
+ if opponent_connected and enemy_hp > 0:
+ enemy_hp_text = font.render(f"HP: {enemy_hp}", True, (255, 255, 255))
+ screen.blit(enemy_hp_text, (enemy_pos[0], enemy_pos[1] - 30))
+
+ # 處理子彈移動與繪製
+ for b in bullets[:]:
+ if not game_over:
+ b['x'] += b['dir']
+
+ color = (255, 200, 0) if b['owner'] == 'me' else (255, 50, 50)
+ bullet_rect = pygame.Rect(b['x'], b['y'], 10, 10)
+ pygame.draw.rect(screen, color, bullet_rect)
+
+ # 判斷自己的碰撞框是否被對方的子彈打中 (自己必須活著)
+ if b['owner'] == 'enemy' and not game_over and my_rect.colliderect(bullet_rect):
+ my_hp -= 1
+ if hit_sound:
+ hit_sound.play()
+
+ hit_bullet_id = b['id']
+ if b in bullets:
+ bullets.remove(b)
+
+ # 若被打死,進入結算狀態並宣告死亡
+ if my_hp <= 0:
+ game_over = True
+ enemy_score += 1
+ hit_pkg = {'type': 'im_dead', 'hp': 0, 'bullet_id': hit_bullet_id}
+ print("您已陣亡!等待按下 'R' 鍵重生。")
+ else:
+ hit_pkg = {'type': 'hit', 'hp': my_hp, 'bullet_id': hit_bullet_id}
+
+ sock.sendto(json.dumps(hit_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+ elif b['x'] < 0 or b['x'] > 800:
+ if b in bullets:
+ bullets.remove(b)
+
+ # 如果死亡,蓋上結算文字
+ if game_over:
+ dead_text = big_font.render("YOU DIED", True, (239, 68, 68))
+ screen.blit(dead_text, (800//2 - dead_text.get_width()//2, 600//2 - dead_text.get_height()//2 - 20))
+
+ hint_text = font.render("Press 'R' to Restart", True, (255, 255, 255))
+ screen.blit(hint_text, (800//2 - hint_text.get_width()//2, 600//2 + 40))
+
+ elif not opponent_connected:
+ # 對手還沒連線的提示畫面
+ wait_text = font.render("Waiting for opponent...", True, (255, 200, 0))
+ screen.blit(wait_text, (800//2 - wait_text.get_width()//2, 600//2 - wait_text.get_height()//2))
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # ======= [網路區間:發送最新座標] =======
+ if not game_over:
+ pos_pkg = {'type': 'pos', 'x': my_pos[0], 'y': my_pos[1]}
+ sock.sendto(json.dumps(pos_pkg).encode(), (TARGET_IP, TARGET_PORT))
diff --git a/projects/2026project_class/examples/w8_arena_cv_complete.py b/projects/2026project_class/examples/w8_arena_cv_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb5ab98cceb6d5ed5bb09cd894de67737e0e9b48
--- /dev/null
+++ b/projects/2026project_class/examples/w8_arena_cv_complete.py
@@ -0,0 +1,523 @@
+"""
+Week 8 + 9 + 10 - 串接 AI 視覺控制的網路競技場 (Complete 完整版)
+=============================================================
+本腳本為獨立測試版,將 Week 8 的 UDP 雙人對戰競技場與 Week 9-10 的 MediaPipe 手勢偵測進行完美結合:
+ 1. 使用食指尖 (Landmark 8) 移動方塊 (支援 WASD 鍵盤作為備用 fallback)。
+ 2. 使用大拇指 (Landmark 4) 與食指尖的 Pinch (捏合) 來發射子彈 (支援空白鍵作為備用)。
+ 3. 右下角顯示「AI 攝影機全息投影」預覽視窗。
+ 4. 死亡時,食指移至畫面中央的「Pinch to Respawn」虛擬按鈕並捏合,即可重生。
+
+操作方式:
+ - 同一台電腦測試:開兩個終端機分別執行此程式,先開的為 P1 (左),後開的為 P2 (右)。
+ - 將食指伸到鏡頭前即可控制方塊;捏合大拇指與食指即可射擊。
+ - 按鍵盤 Q 或點擊視窗關閉鈕即可結束。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import socket
+import json
+import sys
+import os
+import math
+
+# ============================================
+# 1. 初始化 Pygame & 視窗設定
+# ============================================
+pygame.init()
+pygame.mixer.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+clock = pygame.time.Clock()
+
+# ============================================
+# 2. 網路設定 (自動判斷 P1 / P2)
+# ============================================
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.setblocking(False)
+
+try:
+ sock.bind(("0.0.0.0", 5000))
+ MY_PORT = 5000
+ TARGET_PORT = 5001
+ player_role = "P1"
+except OSError:
+ sock.bind(("0.0.0.0", 5001))
+ MY_PORT = 5001
+ TARGET_PORT = 5000
+ player_role = "P2"
+
+TARGET_IP = "127.0.0.1" # 若要跨電腦連線,請修改為對方的 IP
+pygame.display.set_caption(f"AI 雙人對戰競技場 [{player_role} | Port {MY_PORT}]")
+
+# ============================================
+# 3. 初始化 OpenCV 攝影機與 MediaPipe 手勢偵測
+# ============================================
+print("正在啟動攝影機並載入 MediaPipe 模型...")
+cap = cv2.VideoCapture(0)
+has_camera = True
+if not cap.isOpened():
+ print("⚠️ 警告:無法開啟攝影機!將自動切換為鍵盤控制模式 (WASD + Space)。")
+ has_camera = False
+
+# 載入 MediaPipe 模型
+detector = None
+if has_camera:
+ try:
+ # 尋找與此程式同資料夾的 hand_landmarker.task
+ model_path = os.path.join(os.path.dirname(__file__), 'hand_landmarker.task')
+ if not os.path.exists(model_path):
+ # 若不在同資料夾,嘗試在上一層找 (或相對路徑)
+ model_path = 'hand_landmarker.task'
+
+ base_options = python.BaseOptions(model_asset_path=model_path)
+ options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+ detector = vision.HandLandmarker.create_from_options(options)
+ print("✅ MediaPipe 模型載入成功!")
+ except Exception as e:
+ print(f"⚠️ 警告:MediaPipe 模型載入失敗 ({e})。將切換為鍵盤控制模式。")
+ has_camera = False
+
+# ============================================
+# 4. 遊戲變數 & 視覺設計系統
+# ============================================
+# 科幻感霓虹色彩系統
+COLOR_BG = (11, 15, 25) # 深藍太空底色
+COLOR_P1 = (0, 240, 255) # 霓虹青 (P1)
+COLOR_P2 = (57, 255, 20) # 霓虹綠 (P2)
+COLOR_BULLET_ME = (255, 110, 0) # 烈焰橘 (我方子彈)
+COLOR_BULLET_ENEMY = (255, 0, 128) # 魅惑紅 (對方子彈)
+COLOR_WHITE = (255, 255, 255)
+COLOR_MUTED = (100, 110, 130)
+
+# 字型
+font_small = pygame.font.SysFont("monospace", 18)
+font_med = pygame.font.SysFont("monospace", 28)
+font_large = pygame.font.SysFont("monospace", 64)
+
+# 載入 Week 8 素材與音效
+try:
+ bg_img = pygame.image.load("background.jpg")
+ bg_img = pygame.transform.scale(bg_img, (WIDTH, HEIGHT))
+except FileNotFoundError:
+ bg_img = None
+
+try:
+ shoot_sound = pygame.mixer.Sound("shoot.wav")
+except FileNotFoundError:
+ shoot_sound = None
+
+try:
+ hit_sound = pygame.mixer.Sound("hit.wav")
+except FileNotFoundError:
+ hit_sound = None
+
+# 玩家狀態初始化
+if player_role == "P1":
+ my_pos = [100, 300]
+ enemy_pos = [600, 300]
+ my_bullet_dir = 10
+ enemy_bullet_dir = -10
+ my_color = COLOR_P1
+ enemy_color = COLOR_P2
+else:
+ my_pos = [600, 300]
+ enemy_pos = [100, 300]
+ my_bullet_dir = -10
+ enemy_bullet_dir = 10
+ my_color = COLOR_P2
+ enemy_color = COLOR_P1
+
+my_hp = 10
+enemy_hp = 10
+my_score = 0
+enemy_score = 0
+bullets = []
+bullet_id_counter = 0
+game_over = False
+opponent_connected = False
+
+# AI 視覺手勢控制變數
+finger_x, finger_y = my_pos[0] + 20, my_pos[1] + 20
+thumb_x, thumb_y = my_pos[0] + 20, my_pos[1] + 20
+is_pinching = False
+was_pinching = False
+PINCH_THRESHOLD = 35 # 兩指距離小於 35px 判定為 Pinch
+hand_detected = False
+last_valid_dist = 999
+
+# 虛擬按鈕設定 (用於遊戲結束時手勢點擊重生)
+restart_btn_rect = pygame.Rect(300, 380, 200, 50)
+
+# ============================================
+# 5. 遊戲主迴圈
+# ============================================
+print("\n--- 遊戲已啟動 ---")
+print("提示:若偵測到手掌,方塊會自動跟隨食指尖移動。")
+print("提示:捏合大拇指與食指即可射擊。")
+print("提示:鍵盤 WASD / 鍵盤 Space 依然可做為緊急手動控制。")
+print("----------------")
+
+running = True
+while running:
+ # 畫背景
+ if bg_img:
+ screen.blit(bg_img, (0, 0))
+ else:
+ screen.fill(COLOR_BG)
+ # 繪製裝飾性格線 (Grid),提升科幻感
+ for x in range(0, WIDTH, 40):
+ pygame.draw.line(screen, (20, 30, 50), (x, 0), (x, HEIGHT), 1)
+ for y in range(0, HEIGHT, 40):
+ pygame.draw.line(screen, (20, 30, 50), (0, y), (WIDTH, y), 1)
+
+ # ============================================
+ # 5A. 讀取攝影機並進行 MediaPipe 手勢分析
+ # ============================================
+ cv_preview_surf = None
+ if has_camera and detector:
+ success, img = cap.read()
+ if success:
+ img = cv2.flip(img, 1) # 鏡像翻轉,控制更直覺
+ img_h, img_w, _ = img.shape
+
+ # 轉換為 RGB 格式供 MediaPipe 偵測
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ if results.hand_landmarks:
+ hand_detected = True
+ for hand_lms in results.hand_landmarks:
+ # 取得食指尖 (Landmark 8)
+ lm8 = hand_lms[8]
+ finger_x = int(lm8.x * WIDTH)
+ finger_y = int(lm8.y * HEIGHT)
+
+ # 取得大拇指尖 (Landmark 4)
+ lm4 = hand_lms[4]
+ thumb_x = int(lm4.x * WIDTH)
+ thumb_y = int(lm4.y * HEIGHT)
+
+ # 計算食指與大拇指之距離
+ last_valid_dist = math.hypot(finger_x - thumb_x, finger_y - thumb_y)
+ is_pinching = last_valid_dist < PINCH_THRESHOLD
+
+ # 將方塊中心移動至食指尖位置 (只有在活著時才移動)
+ if not game_over:
+ my_pos[0] = max(0, min(WIDTH - 40, finger_x - 20))
+ my_pos[1] = max(0, min(HEIGHT - 40, finger_y - 20))
+
+ # 在相機影格上畫出關鍵點 (供角落 HUD 顯示)
+ # 食指尖
+ cv2.circle(img, (int(lm8.x * img_w), int(lm8.y * img_h)), 8, (255, 0, 0), -1)
+ # 大拇指
+ cv2.circle(img, (int(lm4.x * img_w), int(lm4.y * img_h)), 8, (0, 0, 255), -1)
+ # 連線
+ line_color = (0, 255, 0) if is_pinching else (0, 0, 255)
+ cv2.line(img, (int(lm4.x * img_w), int(lm4.y * img_h)),
+ (int(lm8.x * img_w), int(lm8.y * img_h)), line_color, 2)
+ else:
+ hand_detected = False
+ is_pinching = False
+
+ # 製作 HUD 迷你鏡頭預覽影像 (縮小至 160x120)
+ small_img = cv2.resize(img, (160, 120))
+ small_img_rgb = cv2.cvtColor(small_img, cv2.COLOR_BGR2RGB)
+ cv_preview_surf = pygame.image.frombuffer(small_img_rgb.tobytes(), (160, 120), "RGB")
+
+ # ============================================
+ # 5B. 接收 UDP 封包 (處理網路連線數據)
+ # ============================================
+ while True:
+ try:
+ data, addr = sock.recvfrom(1024)
+ opponent_connected = True
+ pkg = json.loads(data.decode())
+
+ if pkg['type'] == 'pos':
+ enemy_pos[0] = pkg['x']
+ enemy_pos[1] = pkg['y']
+
+ elif pkg['type'] == 'shoot':
+ bullets.append({
+ 'x': pkg['x'], 'y': pkg['y'],
+ 'dir': enemy_bullet_dir, 'owner': 'enemy', 'id': pkg['id']
+ })
+ if shoot_sound:
+ shoot_sound.play()
+
+ elif pkg['type'] == 'hit':
+ enemy_hp = pkg.get('hp', 10)
+ hit_bullet_id = pkg.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+
+ elif pkg['type'] == 'im_dead':
+ enemy_hp = pkg.get('hp', 0)
+ my_score += 1
+ hit_bullet_id = pkg.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+
+ except (BlockingIOError, ConnectionResetError):
+ break
+ except json.JSONDecodeError:
+ pass
+
+ # ============================================
+ # 5C. 處理事件與鍵盤輸入 (手鍵雙軌備用)
+ # ============================================
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ if event.type == pygame.KEYDOWN:
+ if event.key == pygame.K_q:
+ running = False
+
+ # 空白鍵手動射擊
+ if event.key == pygame.K_SPACE and not game_over and opponent_connected:
+ bullet_id_counter += 1
+ bullets.append({
+ 'x': my_pos[0] + 20, 'y': my_pos[1] + 20,
+ 'dir': my_bullet_dir, 'owner': 'me', 'id': bullet_id_counter
+ })
+ shoot_pkg = {'type': 'shoot', 'x': my_pos[0] + 20, 'y': my_pos[1] + 20, 'id': bullet_id_counter}
+ sock.sendto(json.dumps(shoot_pkg).encode(), (TARGET_IP, TARGET_PORT))
+ if shoot_sound:
+ shoot_sound.play()
+
+ # 按 R 鍵重生
+ if event.key == pygame.K_r and game_over:
+ game_over = False
+ my_hp = 10
+ my_pos = [100, 300] if player_role == "P1" else [600, 300]
+ respawn_pkg = {'type': 'hit', 'hp': my_hp}
+ sock.sendto(json.dumps(respawn_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+ # Fallback 鍵盤 WASD 移動
+ if not game_over:
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_w]: my_pos[1] -= 5
+ if keys[pygame.K_s]: my_pos[1] += 5
+ if keys[pygame.K_a]: my_pos[0] -= 5
+ if keys[pygame.K_d]: my_pos[0] += 5
+
+ # 邊界限制
+ my_pos[0] = max(0, min(WIDTH - 40, my_pos[0]))
+ my_pos[1] = max(0, min(HEIGHT - 40, my_pos[1]))
+
+ # ============================================
+ # 5D. 邊緣觸發 (手勢動作判定)
+ # ============================================
+ if is_pinching and not was_pinching:
+ # 狀態1:活著 + 對手已連線 = 捏合射擊
+ if not game_over and opponent_connected:
+ bullet_id_counter += 1
+ bullets.append({
+ 'x': my_pos[0] + 20, 'y': my_pos[1] + 20,
+ 'dir': my_bullet_dir, 'owner': 'me', 'id': bullet_id_counter
+ })
+ shoot_pkg = {'type': 'shoot', 'x': my_pos[0] + 20, 'y': my_pos[1] + 20, 'id': bullet_id_counter}
+ sock.sendto(json.dumps(shoot_pkg).encode(), (TARGET_IP, TARGET_PORT))
+ if shoot_sound:
+ shoot_sound.play()
+ print("💥 AI 手勢射擊!")
+
+ # 狀態2:已陣亡 = 捏合點擊畫面中央的重生按鈕
+ elif game_over:
+ finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10)
+ if finger_rect.colliderect(restart_btn_rect):
+ game_over = False
+ my_hp = 10
+ my_pos = [100, 300] if player_role == "P1" else [600, 300]
+ respawn_pkg = {'type': 'hit', 'hp': my_hp}
+ sock.sendto(json.dumps(respawn_pkg).encode(), (TARGET_IP, TARGET_PORT))
+ print("♻️ AI 虛擬按鈕重生成功!")
+
+ was_pinching = is_pinching
+
+ # ============================================
+ # 5E. 繪圖區間 (Pygame 繪圖與 HUD 設計)
+ # ============================================
+ my_rect = pygame.Rect(my_pos[0], my_pos[1], 40, 40)
+ enemy_rect = pygame.Rect(enemy_pos[0], enemy_pos[1], 40, 40)
+
+ # 1. 繪製雷射引導線 (科技感效果)
+ if hand_detected and not game_over:
+ # 雷射瞄準虛線 (玩家指向射擊前方)
+ laser_color = (my_color[0], my_color[1], my_color[2], 80)
+ laser_surface = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
+ start_pt = (my_pos[0] + 20, my_pos[1] + 20)
+ end_pt = (WIDTH if my_bullet_dir > 0 else 0, my_pos[1] + 20)
+
+ # 畫虛線
+ distance_line = abs(end_pt[0] - start_pt[0])
+ step = 15
+ for offset in range(0, int(distance_line), step * 2):
+ curr_x = start_pt[0] + (offset if my_bullet_dir > 0 else -offset)
+ pygame.draw.line(laser_surface, (my_color[0], my_color[1], my_color[2], 120),
+ (curr_x, start_pt[1]),
+ (curr_x + (step if my_bullet_dir > 0 else -step), start_pt[1]), 2)
+ screen.blit(laser_surface, (0, 0))
+
+ # 2. 畫自己的人物 (加外發光邊框)
+ if not game_over:
+ pygame.draw.rect(screen, my_color, my_rect)
+ pygame.draw.rect(screen, COLOR_WHITE, my_rect, 2) # 白色框
+
+ # 3. 畫對方的人物 (對手血量大於 0 且已連線)
+ if opponent_connected and enemy_hp > 0:
+ pygame.draw.rect(screen, enemy_color, enemy_rect)
+ pygame.draw.rect(screen, COLOR_WHITE, enemy_rect, 2)
+
+ # 4. 畫計分板
+ score_text = font_med.render(f"P1 (BLUE): {my_score if player_role == 'P1' else enemy_score} | P2 (GREEN): {enemy_score if player_role == 'P1' else my_score}", True, COLOR_WHITE)
+ screen.blit(score_text, (WIDTH // 2 - score_text.get_width() // 2, 20))
+
+ # 5. 畫血條
+ if not game_over:
+ # 我的血條 (綠色/紅色比例)
+ pygame.draw.rect(screen, (100, 0, 0), (my_pos[0] - 10, my_pos[1] - 15, 60, 6))
+ pygame.draw.rect(screen, (0, 255, 100), (my_pos[0] - 10, my_pos[1] - 15, 6 * my_hp, 6))
+ hp_lbl = font_small.render(f"HP {my_hp}", True, COLOR_WHITE)
+ screen.blit(hp_lbl, (my_pos[0] - 10, my_pos[1] - 32))
+
+ if opponent_connected and enemy_hp > 0:
+ # 對方的血條
+ pygame.draw.rect(screen, (100, 0, 0), (enemy_pos[0] - 10, enemy_pos[1] - 15, 60, 6))
+ pygame.draw.rect(screen, (0, 255, 100), (enemy_pos[0] - 10, enemy_pos[1] - 15, 6 * enemy_hp, 6))
+ ehp_lbl = font_small.render(f"HP {enemy_hp}", True, COLOR_WHITE)
+ screen.blit(ehp_lbl, (enemy_pos[0] - 10, enemy_pos[1] - 32))
+
+ # 6. 處理子彈移動、邊界與碰撞
+ for b in bullets[:]:
+ if not game_over:
+ b['x'] += b['dir']
+
+ # 繪製子彈
+ bullet_rect = pygame.Rect(b['x'] - 5, b['y'] - 5, 10, 10)
+ bullet_color = COLOR_BULLET_ME if b['owner'] == 'me' else COLOR_BULLET_ENEMY
+
+ # 繪製亮色核心
+ pygame.draw.circle(screen, bullet_color, (b['x'], b['y']), 5)
+ # 外層微光效果
+ glow_surf = pygame.Surface((16, 16), pygame.SRCALPHA)
+ pygame.draw.circle(glow_surf, (bullet_color[0], bullet_color[1], bullet_color[2], 80), (8, 8), 8)
+ screen.blit(glow_surf, (b['x'] - 8, b['y'] - 8))
+
+ # 偵測自己被對方的子彈打中
+ if b['owner'] == 'enemy' and not game_over and my_rect.colliderect(bullet_rect):
+ my_hp -= 1
+ if hit_sound:
+ hit_sound.play()
+
+ hit_bullet_id = b['id']
+ if b in bullets:
+ bullets.remove(b)
+
+ if my_hp <= 0:
+ game_over = True
+ enemy_score += 1
+ hit_pkg = {'type': 'im_dead', 'hp': 0, 'bullet_id': hit_bullet_id}
+ print("💥 您已陣亡!等待手勢或鍵盤 R 鍵重生。")
+ else:
+ hit_pkg = {'type': 'hit', 'hp': my_hp, 'bullet_id': hit_bullet_id}
+
+ sock.sendto(json.dumps(hit_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+ # 超出邊界刪除
+ elif b['x'] < 0 or b['x'] > WIDTH:
+ if b in bullets:
+ bullets.remove(b)
+
+ # 7. 繪製手勢雷射游標與連接線 (當偵測到手掌且未死亡時)
+ if hand_detected and not game_over:
+ # 大拇指紫色點
+ pygame.draw.circle(screen, (139, 92, 246), (thumb_x, thumb_y), 6)
+
+ # 連接線 (捏合變綠,放開變紅)
+ line_color = (57, 255, 20) if is_pinching else (255, 50, 50)
+ pygame.draw.line(screen, line_color, (thumb_x, thumb_y), (finger_x, finger_y), 2)
+
+ # 食指游標
+ pygame.draw.circle(screen, line_color, (finger_x, finger_y), 10, 2)
+ pygame.draw.circle(screen, COLOR_WHITE, (finger_x, finger_y), 3)
+
+ # 8. 繪製角落全息相機 HUD (Holographic Camera Feed)
+ if cv_preview_surf:
+ hud_x, hud_y = WIDTH - 180, HEIGHT - 140
+ # 繪製科技感發光外框
+ pygame.draw.rect(screen, (30, 80, 150), (hud_x - 4, hud_y - 4, 168, 128), 2)
+ pygame.draw.rect(screen, COLOR_BG, (hud_x - 2, hud_y - 2, 164, 124))
+ # 貼上影像
+ screen.blit(cv_preview_surf, (hud_x, hud_y))
+
+ # 相機狀態標籤
+ status_color = (57, 255, 20) if hand_detected else (255, 200, 0)
+ status_text = "AI FEED [ACTIVE]" if hand_detected else "NO HAND DETECTED"
+ hud_label = font_small.render(status_text, True, status_color)
+ screen.blit(hud_label, (hud_x, hud_y - 22))
+
+ # 9. 顯示死亡畫面與虛擬重生按鈕
+ if game_over:
+ # 遮罩半透明層
+ overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
+ overlay.fill((0, 0, 0, 180))
+ screen.blit(overlay, (0, 0))
+
+ dead_text = font_large.render("YOU DIED", True, (239, 68, 68))
+ screen.blit(dead_text, (WIDTH // 2 - dead_text.get_width() // 2, HEIGHT // 2 - 100))
+
+ # 繪製虛擬按鈕
+ btn_border_color = (0, 255, 100) if is_pinching else COLOR_WHITE
+ # 若食指滑過按鈕變色 (Hover效果)
+ finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10)
+ is_hover = finger_rect.colliderect(restart_btn_rect)
+ btn_bg_color = (30, 60, 40) if is_hover else (30, 30, 40)
+
+ pygame.draw.rect(screen, btn_bg_color, restart_btn_rect, border_radius=8)
+ pygame.draw.rect(screen, btn_border_color, restart_btn_rect, 2, border_radius=8)
+
+ btn_text = font_small.render("Pinch to Respawn", True, COLOR_WHITE)
+ screen.blit(btn_text, (restart_btn_rect.centerx - btn_text.get_width() // 2,
+ restart_btn_rect.centery - btn_text.get_height() // 2))
+
+ hint_text = font_small.render("or press keyboard 'R' to Respawn", True, COLOR_MUTED)
+ screen.blit(hint_text, (WIDTH // 2 - hint_text.get_width() // 2, HEIGHT // 2 + 100))
+
+ # 死亡時依然繪製手勢指引,讓玩家看得到自己能不能點擊按鈕
+ if hand_detected:
+ pygame.draw.circle(screen, (139, 92, 246), (thumb_x, thumb_y), 6)
+ line_color = (57, 255, 20) if is_pinching else (255, 50, 50)
+ pygame.draw.line(screen, line_color, (thumb_x, thumb_y), (finger_x, finger_y), 2)
+ pygame.draw.circle(screen, line_color, (finger_x, finger_y), 10, 2)
+ pygame.draw.circle(screen, COLOR_WHITE, (finger_x, finger_y), 3)
+
+ elif not opponent_connected:
+ # 等待對手連線提示
+ wait_text = font_med.render("WAITING FOR OPPONENT...", True, (255, 200, 0))
+ screen.blit(wait_text, (WIDTH // 2 - wait_text.get_width() // 2, HEIGHT // 2 - 20))
+
+ ip_info = font_small.render(f"My Port: {MY_PORT} | Targets: {TARGET_IP}:{TARGET_PORT}", True, COLOR_MUTED)
+ screen.blit(ip_info, (WIDTH // 2 - ip_info.get_width() // 2, HEIGHT // 2 + 20))
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # ============================================
+ # 5F. 傳送座標給對手 (每一影格同步)
+ # ============================================
+ if not game_over:
+ pos_pkg = {'type': 'pos', 'x': my_pos[0], 'y': my_pos[1]}
+ sock.sendto(json.dumps(pos_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+# 清理資源
+if has_camera:
+ cap.release()
+cv2.destroyAllWindows()
+pygame.quit()
+sys.exit()
diff --git a/projects/2026project_class/examples/w8_arena_template.py b/projects/2026project_class/examples/w8_arena_template.py
new file mode 100644
index 0000000000000000000000000000000000000000..4e42e4e7cbbfe33000d1d41631e14507e3964b99
--- /dev/null
+++ b/projects/2026project_class/examples/w8_arena_template.py
@@ -0,0 +1,147 @@
+import pygame
+import socket
+import json
+import sys
+
+pygame.init()
+pygame.mixer.init()
+screen = pygame.display.set_mode((800, 600))
+clock = pygame.time.Clock()
+
+# --- 1. 網路設定(自動判斷 P1 / P2)---
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.setblocking(False)
+
+# 嘗試綁定 port 5000,若被佔用則綁定 5001
+# 先啟動的人 = P1 (左側),後啟動的人 = P2 (右側)
+try:
+ sock.bind(("0.0.0.0", 5000))
+ MY_PORT = 5000
+ TARGET_PORT = 5001
+ player_role = "P1"
+except OSError:
+ sock.bind(("0.0.0.0", 5001))
+ MY_PORT = 5001
+ TARGET_PORT = 5000
+ player_role = "P2"
+
+# 請修改為隔壁同學的 IP 準備互連!
+TARGET_IP = "127.0.0.1"
+
+# --- 2. 遊戲參數與各種變數(根據角色自動設定位置與方向)---
+if player_role == "P1":
+ my_pos = [100, 300] # P1 在左邊
+ enemy_pos = [600, 300] # 對手在右邊
+ my_bullet_dir = 10 # P1 子彈朝右飛
+ enemy_bullet_dir = -10 # 對手子彈朝左飛
+else:
+ my_pos = [600, 300] # P2 在右邊
+ enemy_pos = [100, 300] # 對手在左邊
+ my_bullet_dir = -10 # P2 子彈朝左飛
+ enemy_bullet_dir = 10 # 對手子彈朝右飛
+
+my_hp = 10
+enemy_hp = 10
+my_score = 0
+enemy_score = 0
+bullets = []
+bullet_id_counter = 0 # 子彈編號計數器,用來同步雙方畫面
+game_over = False
+opponent_connected = False # 追蹤對手是否已連線
+
+pygame.display.set_caption(f"Week 8 - 網路競技場最終戰 [{player_role} | Port {MY_PORT}]")
+print(f"遊戲啟動!你是 {player_role}(監聽 port {MY_PORT},發送至 port {TARGET_PORT})")
+print(f"位置:{'左側' if player_role == 'P1' else '右側'}")
+print("等待連線中...")
+
+# 字體與素材載入
+font = pygame.font.SysFont(None, 36)
+big_font = pygame.font.SysFont(None, 72)
+
+try:
+ bg_img = pygame.image.load("background.jpg")
+ bg_img = pygame.transform.scale(bg_img, (800, 600))
+except FileNotFoundError:
+ bg_img = None
+
+try:
+ shoot_sound = pygame.mixer.Sound("shoot.wav")
+except FileNotFoundError:
+ shoot_sound = None
+
+try:
+ hit_sound = pygame.mixer.Sound("hit.wav")
+except FileNotFoundError:
+ hit_sound = None
+
+
+# --- 3. 遊戲主迴圈 ---
+while True:
+ if bg_img:
+ screen.blit(bg_img, (0, 0))
+ else:
+ screen.fill((20, 20, 30))
+
+ # ======= [網路區間:接收最新消息] =======
+ while True:
+ try:
+ data, addr = sock.recvfrom(1024)
+ opponent_connected = True # 只要收到任何封包,就代表對手來了
+ pkg = json.loads(data.decode())
+
+ # 【TODO 1: 根據 pkg['type'] 判斷是 'pos', 'shoot', 'hit' 還是 'im_dead'!更新對手數值】
+
+ except (BlockingIOError, ConnectionResetError):
+ # 非阻塞模式下,Linux 拋 BlockingIOError,Windows 拋 ConnectionResetError
+ break
+ except json.JSONDecodeError:
+ # 防止有人亂傳不是 JSON 格式的垃圾訊息導致閃退
+ pass
+
+ # ======= [事件區間:玩家輸入] =======
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit(); sys.exit()
+
+ if event.type == pygame.KEYDOWN:
+ # 只有活著且對手連線時才能開槍
+ if event.key == pygame.K_SPACE and not game_over and opponent_connected:
+ # 【TODO 2: 當玩家按下空白鍵:生成一顆屬於你的子彈,並送出 'shoot' 封包給對方】
+ # 提示:子彈方向請用 my_bullet_dir,發送後記得播放 shoot_sound.play()
+ pass
+
+ # 死亡時可以按 R 鍵重生
+ if event.key == pygame.K_r and game_over:
+ # 【TODO 3: 重新開始設定,並傳送滿血復活的封包給對手】
+ pass
+
+ # 【TODO 4: 處理 WASD 或方向鍵移動 (活著才能移動)】
+
+ # ======= [運算與繪圖區間] =======
+ # 【TODO 5: 畫出自己與對手的人物實體 (Rect)】
+
+ # 【TODO 6: 畫出計分板與血條】
+
+ # 【TODO 7: 根據子彈陣列移動並畫出每一顆子彈】
+
+ # 【TODO 8: 判斷自己的碰撞框是否被對方的子彈打中 (自己必須活著)】
+ # 若打中: 1) 自己的 my_hp 扣一滴並播放 hit_sound 2) 刪除該子彈 3) 送出去 'hit' 封包
+ # 若 my_hp 歸零,設定 game_over = True,改為發送 'im_dead' 封包
+
+ # 如果死亡,蓋上結算文字
+ if game_over:
+ dead_text = big_font.render("YOU DIED", True, (239, 68, 68))
+ screen.blit(dead_text, (800//2 - dead_text.get_width()//2, 600//2 - dead_text.get_height()//2 - 20))
+ hint_text = font.render("Press 'R' to Restart", True, (255, 255, 255))
+ screen.blit(hint_text, (800//2 - hint_text.get_width()//2, 600//2 + 40))
+
+ elif not opponent_connected:
+ # 對手還沒連線的提示畫面
+ wait_text = font.render("Waiting for opponent...", True, (255, 200, 0))
+ screen.blit(wait_text, (800//2 - wait_text.get_width()//2, 600//2 - wait_text.get_height()//2))
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # ======= [網路區間:發送最新座標] =======
+ # 【TODO 9: 若自己不僅存活而且移動了,每回合的最後將座標裝進 'pos' 封包發送出去】
diff --git a/projects/2026project_class/examples/w8_cv2_test.py b/projects/2026project_class/examples/w8_cv2_test.py
new file mode 100644
index 0000000000000000000000000000000000000000..432e7ba75af5c1bf9466c8ba8af265edaf61c31c
--- /dev/null
+++ b/projects/2026project_class/examples/w8_cv2_test.py
@@ -0,0 +1,25 @@
+import cv2
+
+print("準備開啟攝影機... 請對著鏡頭揮揮手!")
+print("提示:點擊彈出的影像視窗,並按下鍵盤的 'q' 鍵即可離開。")
+
+cap = cv2.VideoCapture(0)
+
+while True:
+ ret, frame = cap.read()
+ if not ret:
+ print("無法取得鏡頭畫面!請確認您的筆記型電腦鏡頭是否被遮擋、或是否有授權相機權限!")
+ break
+
+ # 水平翻轉畫面讓它像鏡子一樣
+ frame = cv2.flip(frame, 1)
+
+ cv2.imshow("Week 8 - Webcam Hardware Test (Press 'q' to quit)", frame)
+
+ # 每 1 毫秒等待按鍵,若按 'q' 則離開迴圈
+ if cv2.waitKey(1) & 0xFF == ord('q'):
+ break
+
+cap.release()
+cv2.destroyAllWindows()
+print("攝影機測試結束,準備進入下週的 AI 視覺辨識專案!")
diff --git a/projects/2026project_class/examples/w9_laser_complete.py b/projects/2026project_class/examples/w9_laser_complete.py
new file mode 100644
index 0000000000000000000000000000000000000000..c38f5c3fb4d6a36f18143ccc5e5e502db7ed9fb6
--- /dev/null
+++ b/projects/2026project_class/examples/w9_laser_complete.py
@@ -0,0 +1,143 @@
+"""
+Week 9 - 空中雷射筆 (Complete 完整版)
+===========================================
+本週目標:
+ 1. 用 OpenCV 開啟攝影機
+ 2. 用 MediaPipe 偵測手部 21 個關鍵點
+ 3. 取得食指尖 (Landmark 8) 的座標
+ 4. 把座標映射到 Pygame 畫布上,畫出一顆跟著手指移動的紅色雷射點
+
+操作方式:
+ - 在鏡頭前移動手指,Pygame 視窗的紅點會跟著移動
+ - 按鍵盤 Q 或關閉視窗即可結束
+
+注意:
+ - 確保 hand_landmarker.task 檔案和這個腳本放在同一個資料夾。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import sys
+import os
+
+# ============================================
+# 1. 初始化 Pygame
+# ============================================
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 9 - 空中雷射筆 ✋🔴")
+clock = pygame.time.Clock()
+
+# ============================================
+# 2. 初始化 OpenCV 攝影機
+# ============================================
+cap = cv2.VideoCapture(0)
+# 如果沒有攝影機,請改為:
+# cap = cv2.VideoCapture("backup_hand_video.mp4")
+
+if not cap.isOpened():
+ print("❌ 錯誤:無法開啟攝影機!")
+ print(" 請嘗試改用影片檔案:cap = cv2.VideoCapture('backup_hand_video.mp4')")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 3. 初始化 MediaPipe 手部偵測 (Tasks API)
+# ============================================
+try:
+ base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'hand_landmarker.task'))
+ options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+ detector = vision.HandLandmarker.create_from_options(options)
+except Exception as e:
+ print(f"❌ 錯誤:無法載入 MediaPipe 模型:{e}")
+ print(" 請確認 'hand_landmarker.task' 是否與這個程式碼在同一個資料夾!")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 4. 雷射筆座標
+# ============================================
+laser_x, laser_y = WIDTH // 2, HEIGHT // 2 # 初始位置在畫面中央
+hand_detected = False # 用來追蹤目前有沒有偵測到手
+
+# ============================================
+# 5. 主迴圈
+# ============================================
+running = True
+frame_count = 0 # 用來做 Frame Skip(選用進階功能)
+
+while running:
+ # --- 事件處理 ---
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機影格 ---
+ success, img = cap.read()
+ if not success:
+ # 如果是影片檔讀完了,就從頭開始
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
+ continue
+
+ img = cv2.flip(img, 1) # 鏡面翻轉,讓畫面像照鏡子一樣直覺
+
+ # --- BGR 轉 RGB 並丟給 MediaPipe 分析 ---
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ # --- 提取食指尖 (Landmark 8) 的座標 ---
+ if results.hand_landmarks:
+ hand_detected = True
+ for hand_lms in results.hand_landmarks:
+ lm8 = hand_lms[8] # Index Finger Tip
+
+ # MediaPipe 回傳 0~1 的比例值,乘上 Pygame 視窗大小
+ laser_x = int(lm8.x * WIDTH)
+ laser_y = int(lm8.y * HEIGHT)
+ else:
+ hand_detected = False
+
+ # --- 繪製畫面 ---
+ screen.fill((15, 15, 35)) # 深色背景
+
+ # 狀態文字
+ font = pygame.font.SysFont(None, 28)
+
+ if hand_detected:
+ # 畫出雷射光點(紅色圓點 + 外圈光暈)
+ # 外層光暈
+ glow_surf = pygame.Surface((60, 60), pygame.SRCALPHA)
+ pygame.draw.circle(glow_surf, (255, 50, 50, 40), (30, 30), 28)
+ screen.blit(glow_surf, (laser_x - 30, laser_y - 30))
+
+ # 中層
+ pygame.draw.circle(screen, (255, 80, 80), (laser_x, laser_y), 16)
+ # 內核(最亮)
+ pygame.draw.circle(screen, (255, 200, 200), (laser_x, laser_y), 6)
+
+ # 座標文字
+ coord_text = font.render(f"Index Tip (Landmark 8): ({laser_x}, {laser_y})", True, (100, 255, 100))
+ screen.blit(coord_text, (10, 10))
+ else:
+ # 沒偵測到手的提示
+ no_hand = font.render("No hand detected - please show your hand!", True, (255, 200, 0))
+ screen.blit(no_hand, (WIDTH // 2 - no_hand.get_width() // 2, HEIGHT // 2))
+
+ # 底部提示
+ hint = font.render("Move your index finger in front of the camera", True, (100, 100, 130))
+ screen.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT - 40))
+
+ pygame.display.update()
+ clock.tick(30)
+ frame_count += 1
+
+# ============================================
+# 6. 結束清理
+# ============================================
+cap.release()
+pygame.quit()
+sys.exit()
diff --git a/projects/2026project_class/examples/w9_laser_template.py b/projects/2026project_class/examples/w9_laser_template.py
new file mode 100644
index 0000000000000000000000000000000000000000..cb6ef1724687238f39173d03d6a88239a4c859c9
--- /dev/null
+++ b/projects/2026project_class/examples/w9_laser_template.py
@@ -0,0 +1,105 @@
+"""
+Week 9 - 空中雷射筆 (Template 填空版)
+===========================================
+本週目標:
+ 1. 用 OpenCV 開啟攝影機
+ 2. 用 MediaPipe 偵測手部 21 個關鍵點
+ 3. 取得食指尖 (Landmark 8) 的座標
+ 4. 把座標映射到 Pygame 畫布上,畫出一顆跟著手指移動的紅色雷射點
+
+操作方式:
+ - 在鏡頭前移動手指,Pygame 視窗的紅點會跟著移動
+ - 按鍵盤 Q 或關閉視窗即可結束
+
+注意:
+ - 確保 hand_landmarker.task 檔案和這個腳本放在同一個資料夾。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import sys
+import os
+
+# ============================================
+# 1. 初始化 Pygame
+# ============================================
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 9 - 空中雷射筆 ✋🔴")
+clock = pygame.time.Clock()
+
+# ============================================
+# 2. 初始化 OpenCV 攝影機
+# ============================================
+# 【TODO 1】開啟攝影機(若沒有攝影機,改為影片路徑)
+# cap = cv2.VideoCapture(0)
+# 如果沒有攝影機:cap = cv2.VideoCapture("backup_hand_video.mp4")
+
+# ============================================
+# 3. 初始化 MediaPipe 手部偵測 (Tasks API)
+# ============================================
+# 【TODO 2】設定 MediaPipe Hand Landmarker
+# base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'hand_landmarker.task'))
+# options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+# detector = vision.HandLandmarker.create_from_options(options)
+
+# ============================================
+# 4. 雷射筆座標
+# ============================================
+laser_x, laser_y = WIDTH // 2, HEIGHT // 2 # 初始位置在畫面中央
+
+# ============================================
+# 5. 主迴圈
+# ============================================
+running = True
+while running:
+ # --- 事件處理 ---
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 【TODO 3】讀取攝影機影格 ---
+ # success, img = cap.read()
+ # if success:
+ # img = cv2.flip(img, 1) # 鏡面翻轉
+ #
+ # # 【TODO 4】BGR 轉 RGB 並丟給 MediaPipe 分析
+ # # img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ # # mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ # # results = detector.detect(mp_image)
+ #
+ # # 【TODO 5】如果偵測到手,提取食指尖 (Landmark 8) 的座標
+ # # if results.hand_landmarks:
+ # # for hand_lms in results.hand_landmarks:
+ # # lm8 = hand_lms[8]
+ # #
+ # # # 注意!MediaPipe 回傳的是 0~1 之間的「比例」,不是像素!
+ # # # 必須乘上 Pygame 視窗的寬高才能得到畫布上的座標
+ # # laser_x = int(lm8.x * WIDTH)
+ # # laser_y = int(lm8.y * HEIGHT)
+
+ # --- 繪製畫面 ---
+ screen.fill((15, 15, 35)) # 深色背景
+
+ # 畫出雷射光點(紅色圓點 + 外圈光暈效果)
+ pygame.draw.circle(screen, (255, 80, 80, 50), (laser_x, laser_y), 25) # 外光暈
+ pygame.draw.circle(screen, (255, 50, 50), (laser_x, laser_y), 10) # 內核
+
+ # 在左上角顯示目前座標
+ font = pygame.font.SysFont(None, 28)
+ coord_text = font.render(f"Laser: ({laser_x}, {laser_y})", True, (200, 200, 200))
+ screen.blit(coord_text, (10, 10))
+
+ pygame.display.update()
+ clock.tick(30)
+
+# ============================================
+# 6. 結束清理
+# ============================================
+# 【TODO 6】釋放資源
+# cap.release()
+pygame.quit()
+sys.exit()
diff --git a/projects/2026project_class/examples/w9_step2_hand_tracking.py b/projects/2026project_class/examples/w9_step2_hand_tracking.py
new file mode 100644
index 0000000000000000000000000000000000000000..8846faa5292a8e43f82da685adf36938d138796a
--- /dev/null
+++ b/projects/2026project_class/examples/w9_step2_hand_tracking.py
@@ -0,0 +1,58 @@
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import os
+
+# ============================================
+# Week 9 - Step 2: MediaPipe 手部追蹤 (Tasks API)
+# ============================================
+
+# 初始化 MediaPipe Hand Landmarker
+model_path = os.path.join(os.path.dirname(__file__), 'hand_landmarker.task')
+base_options = python.BaseOptions(model_asset_path=model_path)
+options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+detector = vision.HandLandmarker.create_from_options(options)
+
+cap = cv2.VideoCapture(0)
+
+while True:
+ success, img = cap.read()
+ if not success:
+ break
+
+ img = cv2.flip(img, 1)
+
+ # 1. OpenCV 的 BGR 轉為 RGB
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+
+ # 2. 將 Numpy 陣列轉為 MediaPipe Image 物件
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+
+ # 3. 進行偵測
+ results = detector.detect(mp_image)
+
+ # 4. 如果有偵測到手,把 21 個關鍵點都畫出來
+ if results.hand_landmarks:
+ for hand_lms in results.hand_landmarks:
+ h, w, c = img.shape
+
+ # 迴圈畫出 21 個節點
+ for i, lm in enumerate(hand_lms):
+ cx, cy = int(lm.x * w), int(lm.y * h)
+
+ # 畫出小綠點標示每個關節
+ cv2.circle(img, (cx, cy), 5, (0, 255, 0), -1)
+
+ # 特別把食指尖 (Landmark 8) 標示為大紅點,並印出座標
+ if i == 8:
+ cv2.circle(img, (cx, cy), 12, (0, 0, 255), -1)
+ print(f"食指(8) 座標:({cx}, {cy})")
+
+ cv2.imshow("Hand Tracking", img)
+
+ if cv2.waitKey(1) & 0xFF == ord('q'):
+ break
+
+cap.release()
+cv2.destroyAllWindows()
diff --git a/projects/2026project_class/style.css b/projects/2026project_class/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..338ac54fc9cea3c7c082a6e117c8fb4da734a237
--- /dev/null
+++ b/projects/2026project_class/style.css
@@ -0,0 +1,942 @@
+/*
+ Global Stylesheet for Python AI Course
+ Theme: Clean Light Mode
+*/
+
+:root {
+ --bg-dark: #f8fafc;
+ --bg-panel: #ffffff;
+ --bg-card: #ffffff;
+ --bg-card-hover: #f1f5f9;
+ --text-main: #0f172a;
+ --text-muted: #475569;
+ --border: #e2e8f0;
+ --accent: #3b82f6;
+ --accent-glow: rgba(59, 130, 246, 0.2);
+ --success: #10b981;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --radius-md: 12px;
+ --radius-lg: 20px;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Noto Sans TC', sans-serif;
+ background-color: var(--bg-dark);
+ color: var(--text-main);
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+ background-image:
+ radial-gradient(circle at top right, rgba(59, 130, 246, 0.08), transparent 40%),
+ radial-gradient(circle at bottom left, rgba(16, 185, 129, 0.08), transparent 40%);
+}
+
+/* Sidebar Styles */
+.sidebar {
+ width: 280px;
+ background-color: var(--bg-panel);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ padding: 2rem 1.5rem;
+ flex-shrink: 0;
+ z-index: 10;
+ box-shadow: 2px 0 10px rgba(0, 0, 0, 0.02);
+ overflow-y: auto;
+}
+
+.brand {
+ font-weight: 900;
+ font-size: 1.25rem;
+ color: var(--text-main);
+ margin-bottom: 2.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+}
+
+.brand-icon {
+ width: 32px;
+ height: 32px;
+ background: linear-gradient(135deg, #10b981, #3b82f6);
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 10px rgba(16, 185, 129, 0.2);
+ color: white;
+}
+
+.nav-label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ font-weight: 700;
+ margin-bottom: 0.8rem;
+ margin-top: 1.5rem;
+}
+
+.nav-menu {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.nav-btn {
+ width: 100%;
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ text-align: left;
+ padding: 0.8rem 1rem;
+ border-radius: var(--radius-md);
+ font-size: 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ font-family: inherit;
+}
+
+.nav-btn:hover {
+ background: var(--bg-card-hover);
+ color: var(--text-main);
+}
+
+.nav-btn.active {
+ background: rgba(16, 185, 129, 0.1);
+ color: var(--success);
+ border-left: 3px solid var(--success);
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+/* W1 Specific nav-btn overrides for its accent color */
+#w1-sidebar .nav-btn.active {
+ background: rgba(59, 130, 246, 0.1);
+ color: var(--accent);
+ border-left: 3px solid var(--accent);
+}
+
+.nav-btn i {
+ width: 18px;
+ height: 18px;
+}
+
+/* Main Content Area */
+.main-content {
+ flex-grow: 1;
+ overflow-y: auto;
+ scroll-behavior: smooth;
+ position: relative;
+}
+
+.top-bar {
+ height: 70px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ padding: 0 3rem;
+ background: rgba(255, 255, 255, 0.85);
+ backdrop-filter: blur(10px);
+ position: sticky;
+ top: 0;
+ z-index: 5;
+}
+
+.breadcrumb {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+
+.breadcrumb span {
+ color: var(--text-main);
+ font-weight: 700;
+}
+
+.tab-content {
+ display: none;
+ padding: 3rem;
+ max-width: 900px;
+ margin: 0 auto;
+ animation: fadeIn 0.4s ease forwards;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+h1 {
+ font-size: 2.5rem;
+ font-weight: 900;
+ margin-bottom: 0.5rem;
+ line-height: 1.2;
+ background: linear-gradient(to right, #0f172a, #334155);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.subtitle {
+ font-size: 1.1rem;
+ color: var(--text-muted);
+ margin-bottom: 3rem;
+}
+
+h2 {
+ font-size: 1.8rem;
+ font-weight: 800;
+ margin-top: 2rem;
+ margin-bottom: 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.8rem;
+ color: var(--text-main);
+ border-bottom: 2px solid var(--border);
+ padding-bottom: 0.5rem;
+}
+
+p {
+ margin-bottom: 1.5rem;
+ color: #334155;
+}
+
+/* Cards & Grids */
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 1.5rem;
+ margin: 2rem 0;
+}
+
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 1.8rem;
+ transition: transform 0.2s, box-shadow 0.2s;
+ position: relative;
+ overflow: hidden;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
+}
+
+.card:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
+ border-color: #cbd5e1;
+}
+
+.card-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 1.2rem;
+}
+
+/* Code Blocks */
+pre {
+ background: #f1f5f9 !important;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md) !important;
+ padding: 1.5rem !important;
+ margin: 1.5rem 0 !important;
+ font-family: 'JetBrains Mono', monospace !important;
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02);
+}
+
+pre.line-numbers {
+ position: relative !important;
+ padding-left: 3.8rem !important;
+ overflow: auto !important;
+}
+
+pre.line-numbers code {
+ line-height: 1.6;
+}
+
+pre.line-numbers .ln-gutter {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 3rem;
+ padding: 1.5rem 0 !important;
+ margin: 0 !important;
+ background: #e8ecf1;
+ border-right: 1px solid #cbd5e1;
+ text-align: right;
+ user-select: none;
+ pointer-events: none;
+ overflow: hidden;
+ box-sizing: border-box;
+}
+
+pre.line-numbers .ln-gutter span {
+ display: block !important;
+ padding: 0 0.6rem 0 0 !important;
+ margin: 0 !important;
+ color: #94a3b8;
+ font-family: 'JetBrains Mono', monospace !important;
+ font-size: 0.82em;
+ line-height: 1.6;
+}
+
+code {
+ font-family: 'JetBrains Mono', monospace;
+ background: rgba(0, 0, 0, 0.05);
+ padding: 0.2rem 0.4rem;
+ border-radius: 4px;
+ color: #d97706;
+ /* amber-600 */
+ font-size: 0.9em;
+}
+
+/* Step Process */
+.step-container {
+ border-left: 2px solid var(--border);
+ margin-left: 1rem;
+ padding-left: 2rem;
+ position: relative;
+ margin-bottom: 3rem;
+}
+
+.step-marker {
+ position: absolute;
+ left: -17px;
+ top: 0;
+ width: 32px;
+ height: 32px;
+ background: #ffffff;
+ border: 2px solid var(--success);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+ color: var(--success);
+ font-family: 'JetBrains Mono', monospace;
+}
+
+/* W1 Accent specific */
+.step-marker.accent {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.step-title {
+ font-size: 1.2rem;
+ font-weight: 700;
+ margin-bottom: 0.5rem;
+ margin-top: 0;
+ border: none;
+}
+
+.step-title.success {
+ color: var(--success);
+}
+
+.step-title.accent {
+ color: var(--accent);
+}
+
+/* Callout */
+.callout {
+ background: #fffbdf;
+ border-left: 4px solid var(--warning);
+ padding: 1.2rem 1.5rem;
+ border-radius: 0 var(--radius-md) var(--radius-md) 0;
+ margin: 1.5rem 0;
+ color: #475569;
+}
+
+.callout.info {
+ background: #eff6ff;
+ border-left-color: var(--accent);
+}
+
+.callout-title {
+ font-weight: 700;
+ color: var(--warning);
+ margin-bottom: 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.callout.info .callout-title {
+ color: var(--accent);
+}
+
+/* Interactive Toggle */
+.accordion {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ margin-bottom: 1rem;
+ overflow: hidden;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.accordion-header {
+ padding: 1.2rem 1.5rem;
+ cursor: pointer;
+ font-weight: 700;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: #f8fafc;
+ transition: background 0.2s;
+ color: var(--text-main);
+}
+
+.accordion-header:hover {
+ background: #f1f5f9;
+}
+
+.accordion-body {
+ padding: 0 1.5rem;
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.3s ease, padding 0.3s ease;
+ color: var(--text-muted);
+}
+
+.accordion.open .accordion-body {
+ padding: 1.2rem 1.5rem;
+ max-height: 400px;
+ border-top: 1px solid var(--border);
+}
+
+.accordion.open .chevron {
+ transform: rotate(180deg);
+ transition: transform 0.3s;
+}
+
+.chevron {
+ transition: transform 0.3s;
+}
+
+
+/* ==================== MOCKUPS (From W1) ==================== */
+.mockups-pro-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 3rem;
+ margin-top: 3rem;
+}
+
+.mockup-pro {
+ background: #f8fafc;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 15px 35px -5px rgba(0, 0, 0, 0.1);
+}
+
+.mockup-header-pro {
+ background: #e2e8f0;
+ padding: 12px 20px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border-bottom: 1px solid #cbd5e1;
+}
+
+.mockup-dot-pro {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+}
+
+.md-red {
+ background: #ef4444;
+}
+
+.md-yel {
+ background: #eab308;
+}
+
+.md-grn {
+ background: #22c55e;
+}
+
+.mockup-title-pro {
+ margin-left: auto;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.85rem;
+ color: #475569;
+ background: #ffffff;
+ padding: 4px 12px;
+ border-radius: 6px;
+ border: 1px solid #cbd5e1;
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+.mockup-body-pro {
+ height: 350px;
+ position: relative;
+ display: flex;
+ overflow: hidden;
+ background: #ffffff;
+}
+
+/* 1. App Board (Weather) */
+.mb-dashboard {
+ background: linear-gradient(135deg, #e0e7ff, #f3e8ff);
+ padding: 2rem;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 2rem;
+ width: 100%;
+}
+
+.dash-widget {
+ background: rgba(255, 255, 255, 0.7);
+ border: 1px solid rgba(255, 255, 255, 0.5);
+ border-radius: 16px;
+ padding: 1.5rem;
+ backdrop-filter: blur(10px);
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
+}
+
+.dash-weather-main {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+}
+
+.dash-temp {
+ font-size: 4rem;
+ font-weight: 900;
+ color: #1e293b;
+ line-height: 1;
+}
+
+.dash-icon {
+ font-size: 3rem;
+ animation: bounce 2s infinite ease-in-out;
+}
+
+.dash-chart {
+ height: 80px;
+ margin-top: 1rem;
+ background: repeating-linear-gradient(0deg, transparent, transparent 19px, rgba(0, 0, 0, 0.05) 20px);
+ position: relative;
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+}
+
+.dash-bar {
+ flex: 1;
+ background: #8b5cf6;
+ border-radius: 4px 4px 0 0;
+ opacity: 0.9;
+ animation: growBar 1.5s ease-out forwards;
+ height: 0;
+}
+
+@keyframes growBar {
+ to {
+ height: var(--h);
+ }
+}
+
+@keyframes bounce {
+
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+
+ 50% {
+ transform: translateY(-10px);
+ }
+}
+
+/* 2. Multiplayer Arena */
+.mb-arena {
+ background: #dcfce7;
+ background-image:
+ linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
+ background-size: 40px 40px;
+ width: 100%;
+ position: relative;
+}
+
+.arena-hud {
+ position: absolute;
+ top: 20px;
+ left: 20px;
+ right: 20px;
+ display: flex;
+ justify-content: space-between;
+}
+
+.hud-score {
+ background: rgba(255, 255, 255, 0.8);
+ padding: 8px 16px;
+ border-radius: 8px;
+ border: 1px solid #cbd5e1;
+ font-family: monospace;
+ font-size: 1.2rem;
+ font-weight: bold;
+ color: #0284c7;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
+}
+
+.hud-p2 {
+ color: #e11d48;
+}
+
+.tank {
+ width: 40px;
+ height: 40px;
+ position: absolute;
+ border-radius: 8px;
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tank-barrel {
+ width: 20px;
+ height: 8px;
+ background: #fff;
+ position: absolute;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+}
+
+.tank-1 {
+ background: #0ea5e9;
+ left: 80px;
+ top: 150px;
+}
+
+.tank-1 .tank-barrel {
+ right: -15px;
+}
+
+.tank-2 {
+ background: #f43f5e;
+ right: 80px;
+ top: 110px;
+}
+
+.tank-2 .tank-barrel {
+ left: -15px;
+}
+
+.missile {
+ width: 12px;
+ height: 8px;
+ background: #fde047;
+ position: absolute;
+ border-radius: 4px;
+ box-shadow: 0 0 15px #fde047;
+ top: 166px;
+ animation: fire 1.5s infinite linear;
+ left: 120px;
+}
+
+@keyframes fire {
+ 0% {
+ left: 120px;
+ opacity: 1;
+ }
+
+ 70% {
+ left: calc(100% - 130px);
+ opacity: 1;
+ }
+
+ 80% {
+ left: calc(100% - 100px);
+ opacity: 0;
+ transform: scale(3);
+ background: #ef4444;
+ box-shadow: 0 0 30px #ef4444;
+ }
+
+ 100% {
+ left: calc(100% - 100px);
+ opacity: 0;
+ }
+}
+
+/* 3. Computer Vision */
+.mb-cv {
+ background: #f8fafc;
+ width: 100%;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.cv-frame {
+ border: 2px dashed #94a3b8;
+ width: 80%;
+ height: 80%;
+ position: relative;
+ background: #fff;
+ border-radius: 12px;
+}
+
+.cv-overlay {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ color: #16a34a;
+ font-family: monospace;
+ font-size: 0.9rem;
+ font-weight: bold;
+}
+
+.hand-wireframe {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 150px;
+ height: 180px;
+ border: 1px solid rgba(22, 163, 74, 0.3);
+ border-radius: 20px;
+ animation: hoverHand 3s infinite alternate ease-in-out;
+}
+
+.joint {
+ width: 10px;
+ height: 10px;
+ background: #22c55e;
+ border-radius: 50%;
+ position: absolute;
+ box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
+}
+
+.j-wrist {
+ bottom: 0;
+ left: 70px;
+}
+
+.j-thumb {
+ bottom: 60px;
+ left: 0px;
+}
+
+.j-index {
+ top: 0;
+ left: 40px;
+ background: #f59e0b;
+ box-shadow: 0 0 10px #f59e0b;
+ border: 2px solid #fff;
+}
+
+.j-middle {
+ top: -20px;
+ left: 75px;
+}
+
+.j-ring {
+ top: -10px;
+ left: 110px;
+}
+
+.j-pinky {
+ top: 20px;
+ right: 0;
+}
+
+.cv-console {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: 250px;
+ background: #1e293b;
+ border-left: 1px solid var(--border);
+ padding: 1rem;
+ font-family: monospace;
+ color: #cbd5e1;
+ font-size: 0.8rem;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ box-shadow: -5px 0 15px rgba(0, 0, 0, 0.05);
+}
+
+.log-line {
+ padding-bottom: 5px;
+ border-bottom: 1px dashed #334155;
+}
+
+.log-new {
+ color: #fbbf24;
+ animation: blink 1s infinite alternate;
+}
+
+@keyframes hoverHand {
+ to {
+ transform: translate(-40%, -45%) rotate(5deg);
+ }
+}
+
+@keyframes blink {
+ to {
+ opacity: 0.5;
+ }
+}
+
+
+/* ==================== MOCKUPS (From W2 API) ==================== */
+.api-mockup {
+ background: #ffffff;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 15px 35px -5px rgba(0, 0, 0, 0.1);
+ margin: 2rem 0;
+}
+
+.api-url-bar {
+ background: #f1f5f9;
+ padding: 12px 20px;
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ border-bottom: 1px solid var(--border);
+}
+
+.method-badge {
+ background: #10b981;
+ color: #fff;
+ font-weight: 800;
+ padding: 4px 10px;
+ border-radius: 6px;
+ font-size: 0.85rem;
+ letter-spacing: 1px;
+ box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2);
+}
+
+.url-text {
+ color: #334155;
+ font-family: 'JetBrains Mono', monospace;
+ flex-grow: 1;
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.status-badge {
+ background: #dcfce7;
+ color: #16a34a;
+ padding: 4px 12px;
+ border-radius: 6px;
+ font-size: 0.85rem;
+ font-weight: bold;
+ border: 1px solid #86efac;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.status-badge.error {
+ background: #fee2e2;
+ color: #dc2626;
+ border-color: #fca5a5;
+}
+
+.api-body {
+ display: flex;
+ height: 380px;
+}
+
+.api-sidebar {
+ width: 250px;
+ border-right: 1px solid var(--border);
+ padding: 1rem;
+ background: #f8fafc;
+}
+
+.api-menu-item {
+ padding: 10px;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ color: #475569;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 5px;
+}
+
+.api-menu-item.active {
+ background: #e2e8f0;
+ color: #0f172a;
+ font-weight: 600;
+}
+
+.api-content-area {
+ flex-grow: 1;
+ padding: 1.5rem;
+ overflow-y: auto;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.95rem;
+ background: #ffffff;
+ line-height: 1.7;
+ color: #1e293b;
+}
+
+.json-key {
+ color: #0284c7;
+ font-weight: bold;
+}
+
+.json-string {
+ color: #16a34a;
+}
+
+.json-number {
+ color: #db2777;
+}
+
+.json-boolean {
+ color: #d97706;
+}
+
+.json-syntax {
+ color: #64748b;
+}
\ No newline at end of file
diff --git a/projects/2026project_class/w1.html b/projects/2026project_class/w1.html
new file mode 100644
index 0000000000000000000000000000000000000000..28396434ec2de8988dd6b574dced841982aabeeb
--- /dev/null
+++ b/projects/2026project_class/w1.html
@@ -0,0 +1,490 @@
+
+
+
+
+
+
+ Week 01 | Python AI 智慧工具專案
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
16週課程藍圖
+
我們將跳脫終端機的純文字介面,讓程式具備網路通訊與視覺感知的能力。這段旅程分為四個大里程碑:
+
+
+
+
+
階段一:API 數據與介面 (W1-W4)
+
從零開始建立 Pygame 視窗,並學習向真正的網路發送請求,抓取包含天氣、匯率等 JSON
+ 格式的巨量資料庫,並製作成精美的「個人數位助理面板」。
+
+
+
+
階段二:區域網路互動 (W5-W8)
+
我們不依靠中央伺服器,直接利用 Socket 與 UDP/TCP
+ 網路通訊協定,讓全班不同電腦的程式可以即時連線,互傳座標,開發真正的多對多連線遊戲或聊天室。
+
+
+
+
階段三:AI 電腦視覺 (W9-W12)
+
引入最新業界使用的 MediaPipe 與 OpenCV
+ 機器學習開源庫,開啟筆電鏡頭進行骨架辨識,甚至訓練專屬神經網路,實現猶如科幻電影般的「凌空手勢操控」。
+
+
+
+
階段四:期末專案發表 (W13-W16)
+
採用敏捷開發(Agile)模式,分組尋找真實生活中的痛點,從規劃系統架構圖開始,整合前面所有雲端與 AI
+ 知識,共同完成最終軟體開發並上台演講。
+
+
+
+
跨領域專案預覽 (High-Fidelity
+ Mockups)
+
不再是憑空想像。在抵達第四階段前,我們每四週都會產出一個具備業界水準的強大執行檔,以下為動態模擬成果:
+
+
+
+
+
+
+
+
+
+
+
+
P1 [HP: 100]
+
+ SOCKET.UDP ENABLED
+
P2 [HP: 45]
+
+
+
+
+
+
+
+
+
+
+
+
+ [INFO] Camera initialized
+ [INFO] Loading mediapipe_hands.pb...
+ FPS: 60.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
> init.cv2.VideoCapture(0)
+
> mp_hands.Hands(stc=0.5)
+
> Processing Frame #4812
+
> HAND DETECTED [Right]
+
> Landmark 8: (0.34, 0.12)
+
Event: INDEX_PINCH() emitted
+
System Payload Ready
+
+
+
+
+
+
+
+
+
+
思惟大改造:程式運作機制
+
從「自動販賣機」到「跳動的心臟」,理解即時系統的本質。
+
+
+
+
線性腳本 (Linear)
+
過去在基礎課程中,Python 程式的執行就像自動販賣機 :投幣(輸入)👉 掉出飲料(輸出)👉 程式結束關閉。沒有輸入就不會動,執行到底部程式就宣告死亡。
+
+
+
+
即時系統 (Real-time)
+
互動式應用(如遊戲)的核心是一個無限循環(心跳) !一秒鐘狂奔 60 次,不斷檢查有沒有人按鍵盤、滑鼠在哪裡。程式永遠不死,直到你按下叉叉。
+
+
+
+
四大支柱 (Pygame 架構)
+
+
+ 初始化
+ (Init) :準備工具箱!告訴電腦我們要用音效卡、顯示卡還有字體。
+ 事件監聽
+ (Events) :隨時檢查作業系統信箱:有人按空白鍵嗎?滑鼠動了嗎?
+ 邏輯更新 (Logic) :核心大腦。計算座標改變
+ x = x + 5、判斷碰撞。
+
+ 畫面渲染 (Drawing) :視覺呈現。先把舊畫面擦掉
+ screen.fill(),畫上新內容,最後呈現給玩家看。
+
+
+
+
+
+
+
+
第一手練習:建立世界與畫布
+
一步一步跟著打字,打造你專屬的遊戲引擎架構。
+
+
+
環境確認
+ 請先打開 Visual Studio Code,開好一個資料夾與
app.py,並在終端機執行:
+
pip install pygame
+
+
+
+
+
+
1
+
初始化 - 準備畫布
+
告訴電腦我們要開始使用 Pygame,並且設定視窗的大小為長寬 800x600 像素。
+
import pygame
+import sys
+
+# 【支柱一】初始化
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("我的第一個 Pygame 視窗")
+clock = pygame.time.Clock() # 控制心跳的時鐘
+
+my_x = 400
+my_y = 300
+
+
+
+
2
+
大腦與心跳 - 無限迴圈
+
遊戲永遠不休息。在下方加入這個不停運轉的迴圈,並設定每秒 60 幀的刷新率。
+
# ----- 遊戲主迴圈開始 -----
+while True:
+
+ # ... 等等要在這裡加入事件與畫面
+
+ clock.tick(60) # 限制每秒最多跑 60 幀 (FPS)
+# ----- 遊戲主迴圈結束 -----
+
+
+
+
3
+
事件監聽 - 處理關閉視窗
+
如果不寫這段去清空作業系統發來的「訊息事件」,視窗會直接呈現無回應狀態(當機)。
+
while True:
+ # 【支柱二】事件監聽:收信與檢查
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT: # 如果玩家按了紅色的叉叉
+ pygame.quit()
+ sys.exit() # 結束整個程式
+
+ clock.tick(60)
+
+
+
+
4
+
畫面渲染 - 擦黑板與畫圖
+
先填滿背景顏色(擦黑板),畫上我們要的實體,最後呼叫 display.flip() 統一呈現畫面給觀眾看完。
+
while True:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 【支柱三】邏輯更新 (大腦思考)
+ # 在這裡寫入更新座標的數學邏輯
+
+ # 【支柱四】畫面渲染
+ screen.fill((30, 30, 30)) # 擦乾淨黑板!填滿深灰色 (R, G, B)
+
+ # 畫一個紅色的圓形 (畫布, 顏色, 中心座標, 半徑)
+ pygame.draw.circle(screen, (255, 50, 50), (my_x, my_y), 30)
+
+ pygame.display.flip() # 翻轉畫布,呈現給玩家看
+ clock.tick(60)
+
+
+
+
+
🛠️ 執行並測試
+
現在請存檔並按下執行。你應該會看到一個有著紅色正圓形的鐵灰色視窗。試著修改 screen.fill((R,G,B))
+ 的三個數字,改變視窗的背景色!
+
+
+
+
+
+
挑戰任務:變色追隨者
+
讓圓圈真正「活起來」,與你的滑鼠和鍵盤互動。
+
+
+
基本要求 (Level 1)
+
+ 利用 pygame.mouse.get_pos() 獲取滑鼠目前的 (x, y) 座標。
+ 在迴圈的「邏輯更新」區塊,將圓形的 my_x 和 my_y 更新為滑鼠座標,讓它跟著滑鼠走。
+ 結合 Python 內建的 import random,在「事件監聽」區塊偵測:當按下 SPACE 空白鍵時,隨機改變圓形的 RGB 顏色變數。
+
+
+
+
+
進階要求 (PRO)
+
+ 平滑跟隨 (Lerp): 不要讓圓圈「瞬間移動」。計算滑鼠坐標與目前坐標的距離差,除以一個速度系數 (例如 10),慢慢加回去,創造順滑牽引感。
+ 邊界碰撞: 如果不再跟隨滑鼠,試著讓圓形自己加上速度 (speed_x, speed_y),如果碰到螢幕長寬邊界,就將速度乘上 -1
+ 產生反彈效果。
+
+
+
+
+
+
+
常見問題 FAQ
+
遇到 Bug 不用慌,看看是不是踩到常見的初學者地雷。
+
+
+
+
+ 通常是因為你漏寫了 pygame.event.get() 這個 For 迴圈。Windows
+ 作業系統會不斷丟出無數的訊息(滑鼠移動、系統點擊)給你的視窗程序,如果你不去把它們收下來(清空信箱),系統就會以為你的程式卡死了,然後強制判定它是「無回應」。
+
+
+
+
+
+
+ 絕對大錯特錯! 這就像是在開車的高速公路上,每秒鐘都重複轉動鑰匙試圖「發動汽車引擎」60 次,這會導致立刻當機。所有的硬體初始化準備工作,都只能在迴圈「外面」做一次。
+
+
+
+
+
+
+ 所有邏輯都完全相通!角色的血量是不是變數?對!畫面上無數朝你飛過來的子彈是不是列表 (List)?對!子彈有沒有打中你是不是在做 If
+ 判斷式?沒錯!以前打在黑黑終端機上的抽象數字,現在通通變成了能在畫布上互動的具體圖形實體。
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/projects/2026project_class/w10.html b/projects/2026project_class/w10.html
new file mode 100644
index 0000000000000000000000000000000000000000..63b2fd44be3eb0a495d3b202f865dad67ea7de00
--- /dev/null
+++ b/projects/2026project_class/w10.html
@@ -0,0 +1,570 @@
+
+
+
+
+
+
+ Week 10 | 魔法手勢——從座標到控制邏輯
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
魔法的代價:勾股定理
+
數學是魔法界的通用語。在 2D 螢幕上,要怎麼知道兩根手指是不是靠在一起?
+
+
+
兩點距離公式
+
如果大拇指尖 (Landmark 4) 在座標 (x₁, y₁),食指尖 (Landmark 8) 在 (x₂, y₂),它們之間的距離就是:
+
+
+
+
數學其實不可怕,因為 Python 有內建函數 math.hypot()!你不用自己開根號,一行程式碼就能算出任意兩點的距離。
+
+
+
+
實驗:觀察距離的變化
+
回到上週的迴圈中,同時取得食指 (cx8, cy8) 和大拇指 (cx4, cy4) 的視窗座標,計算並印出它們的距離:
+
import math
+
+# 在 MediaPipe 迴圈中取得兩指座標後:
+lm4 = hand_lms[4] # 大拇指尖
+lm8 = hand_lms[8] # 食指尖
+
+h, w, c = img.shape
+cx4, cy4 = int(lm4.x * w), int(lm4.y * h)
+cx8, cy8 = int(lm8.x * w), int(lm8.y * h)
+
+dist = math.hypot(cx8 - cx4, cy8 - cy4)
+print(f"距離:{dist:.1f}")
+
請張開手,再把兩指捏起來,觀察終端機的數字變化!你會發現:張開時數值大(如 150),捏起來時數值小(如 20)。
+
+
+
+
+
+
手印與勾結:Pinch Detection
+
把數學距離轉化為布林判斷:距離小於門檻值 = 手指捏起來了!
+
+
+
+
+ dist = 142 px
+
+
+
OPEN ❌
+
+
+
+
1
+
設定閾值 (Threshold)
+
透過觀察實驗,設定一個合適的門檻值。通常距離小於 40 像素 就代表「捏起來」了。
+
+
+
+
2
+
寫出判定邏輯
+
if dist < 40:
+ is_pinching = True
+ # 捏起來 → 顯示綠色
+ pygame.draw.circle(screen, (0, 255, 0), (cx8, cy8), 15)
+else:
+ is_pinching = False
+ # 放開 → 顯示紅色
+ pygame.draw.circle(screen, (255, 0, 0), (cx8, cy8), 15)
+
+
+
+
小技巧
+ Threshold 值不是固定的!距離鏡頭越近,手指的像素越大,40 可能太小;離得遠則太大。未來進階作法是:以手掌寬度(Landmark 0 到 5 的長度)當作基準尺來歸一化!
+
+
+
+
+
+
觸發!連動第一階段的介面
+
還記得 Pygame 的 colliderect 嗎?這次我們不用子彈——我們用「手」!
+
+
+
+ 手指碰撞按鈕
+
+
我們在畫面上放一個方形按鈕,然後用手指的位置建立一個小矩形。只有手指在按鈕上 且正在捏起 的時候,才算「點擊」成功!
+
+
# 建立一個畫面上的按鈕
+button_rect = pygame.Rect(300, 200, 200, 80)
+pygame.draw.rect(screen, (100, 100, 200), button_rect, border_radius=12)
+
+# 建立手指的碰撞點
+finger_rect = pygame.Rect(cx8, cy8, 10, 10)
+
+# 複合條件:碰到按鈕 + 正在掐合
+if finger_rect.colliderect(button_rect) and is_pinching:
+ print("魔法點擊按鈕成功!")
+ # 觸發天氣變更、背景顏色轉變等事件
+
+
+
+
+
+ 範例 A:手勢開關
+
+
掐合一次開燈、再掐一次關燈。可以連動 Pygame 的背景顏色切換!
+
+
+
+ 範例 B:手勢翻頁
+
+
在畫面上放左右兩個區塊,手指移到左邊並掐合就往前翻,右邊就往後翻。
+
+
+
+
+
連動天氣看板
+
把第一階段做的天氣看板介面搬回來!用手指掐合按鈕切換不同城市的天氣資料顯示,取代原本的鍵盤操作。你正在用 AI 取代滑鼠和鍵盤 !
+
+
+
+
+
+
挑戰:魔法冷卻時間
+
一秒內印出 60 次「魔法點擊」?你需要學會「邊緣觸發判定」來防止重複觸發。
+
+
+
+ 問題:瘋狂重複觸發
+
+
如果在 while 迴圈中,一旦「捏」下去,程式會在一秒內印出 60 次 「魔法點擊」。因為每一幀都偵測到 dist < 40,每一幀都觸發一次!
+
+
+
+
+ 解法:邊緣觸發判定 (Edge Detection)
+
+
必需是「前一幀沒捏」且「這一幀捏了」 才算是點擊一次。這叫做邊緣觸發判定,對未來開發遊戲和 UI 控制極為重要。
+
+
+
+
時間軸:每格代表一幀 (1/60 秒)
+
+
放開
+
放開
+
放開
+
🎯 觸發!
+
捏住
+
捏住
+
捏住
+
放開
+
放開
+
🎯 觸發!
+
捏住
+
放開
+
+
↑ 只有在「放開→捏住」的瞬間 才觸發一次,持續捏住不重複觸發
+
+
+
# 在迴圈外先宣告「上一幀」的狀態
+was_pinching = False
+
+# 在迴圈中:
+is_pinching = dist < 40
+
+# 邊緣觸發:上一幀沒捏,這一幀才捏 → 才算一次點擊
+if is_pinching and not was_pinching:
+ print("✨ 魔法點擊!(只觸發這一次)")
+ # 在這裡執行你的動作
+
+# 更新上一幀狀態
+was_pinching = is_pinching
+
+
+
+
另一種防呆:時間冷卻
+ 如果你覺得 Boolean Flag 不夠保險,也可以用
pygame.time.get_ticks() 設定一個冷卻時間(如 500 毫秒),在冷卻期間內不重複觸發。
+
if current_time - last_click_time > 500:
+
+
+
+
+
+
常見問題 FAQ
+
關於手勢控制與距離計算的疑難雜症。
+
+
+
+
+ 因為你在算的是「畫面上的絕對長度(Pixel)」!如果你的手掌離鏡頭近,那 (cx8, cy8) 隨便動就有很多像素;離得遠就很少。
+ 未來的進階作法是:以你手掌的寬度(Landmark 0 到 Landmark 5 的長度)當作基準尺!把絕對距離除以手掌寬度,得到一個不受距離影響的相對比值。
+
+
+
+
+
+
+ MediaPipe 的座標本身就有微小的抖動 (Jitter)。當你的手指距離剛好在 Threshold 附近時,數值會在「觸發」與「不觸發」之間來回跳動。
+ 解法: 可以加上「遲滯效應 (Hysteresis)」——進入門檻設為 35,離開門檻設為 50。這樣中間有一個緩衝區,就不會頻繁切換了。
+
+
+
+
+
+
+ 當然可以!MediaPipe 給你 21 個點,你可以自由組合。例如:
+ • 大拇指 + 食指 → 選取動作
+ • 大拇指 + 中指 → 確認動作
+ • 五指全張 → 取消/重設
+ 關鍵是每一種組合都要有各自的 dist 計算和對應的 Threshold 判定!
+
+
+
+
+
關鍵術語表
+
+ Threshold (閾值) :觸發某個狀態判定的「界線數值」,如距離小於多少才算捏起。
+ Hypotenuse (斜邊) :直角三角形的斜邊,用來推演平面上兩點的直線距離。
+ Edge Detection (邊緣觸發) :僅在狀態「改變」的瞬間觸發一次,而非持續觸發。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/2026project_class/w11.html b/projects/2026project_class/w11.html
new file mode 100644
index 0000000000000000000000000000000000000000..78d1da38438c9bd552752b58a2e9c7bd8d0b2dd0
--- /dev/null
+++ b/projects/2026project_class/w11.html
@@ -0,0 +1,638 @@
+
+
+
+
+
+
+ Week 11 | 訓練你的大腦——Teachable Machine 實戰
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
白痴與天才:機器學習的核心
+
如果叫你用 if-else 來判斷一張圖是不是貓——幾乎不可能。現代解法:倒因為果!
+
+
+
一隻貓是怎麼被定義的?
+
如果要寫 if 有兩隻尖尖的耳朵 and 有鬍鬚 and 有尾巴... 幾乎是不可能完成的任務。不同光源和角度千變萬化。
+
現代解法 :我不給你規則,我給你十萬張 印著貓的圖片,再加上五十萬張 不是貓的。然後你自己去裡面找出那個數學規律。這就是 Deep Learning !
+
+
+
+
+
📸
+
資料蒐集
+
拍攝大量照片 分門別類標記
+
+
→
+
+
🧠
+
模型訓練
+
神經網路學習 找出數學規律
+
+
→
+
+
🔮
+
模型預測
+
餵入新圖片 AI 即時判斷
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
隱藏層 百萬個參數
+
+
+
+
+
+
+
↑ 簡化的神經網路示意圖:影像輸入後經過數百萬個參數運算,最終輸出每個類別的機率
+
+
+
+
+
Teachable Machine 遊樂場
+
Google 的免費線上工具,讓你不用寫一行程式碼就能訓練自己的影像分類 AI。
+
+
+
1
+
+
開啟 Teachable Machine
+
進入 teachablemachine.withgoogle.com ,選擇 Image Project → Standard Image Model。
+
+
+
+
+
2
+
+
蒐集高品質資料
+
建立你的分類類別,每類拍攝超過 200 張 以上:
+
+
+
⚠️ Class 1:背景
+
一定要有一個類別是「沒在做事」的!沒有教它什麼是背景,它看見空白牆壁會以為是某個動作的近拍!
+
+
+
✅ Class 2+:動作
+
你要辨識的動作:比讚、剪刀手、喝水... 拍攝時請從不同角度 和不同距離 拍!
+
+
+
+
+
+
+
3
+
+
一鍵訓練 → 一鍵匯出
+
按下 Train Model 等待訓練完成,然後匯出 (Export) 為 TensorFlow (Keras) .h5 檔案。你會得到兩個檔案:
+
+ keras_model.h5
+ labels.txt
+
+
+
+
+
+
資料品質 > 數量
+ 如果你的 200 張照片全都是同一個角度同一個背景,AI 學到的不是「手勢」,而是「你房間那面灰色的牆壁 + 那件藍色的袖子」!請從不同角度、距離、光線去拍攝,讓 AI 學會真正重要的特徵。
+
+
+
+
+
+
接通神經網路:Python 推論
+
把 Teachable Machine 訓練好的成果,匯入你的 Python 程式中即時辨識。
+
+
+
1
+
安置檔案
+
把下載的 keras_model.h5 和 labels.txt 放進你的專案根目錄(跟 app.py 同層)。
+
+
+
+
2
+
載入模型與標籤
+
from keras.models import load_model
+import cv2
+import numpy as np
+
+# 載入你在 Teachable Machine 訓練出的模型
+model = load_model("keras_model.h5", compile=False)
+
+# 讀取標籤文字檔
+labels = open("labels.txt", "r").readlines()
+
+
+
+
3
+
影像前處理與推論
+
Keras 模型要求特定格式的輸入:影像必須縮放至 224×224 ,並正規化到 -1 ~ 1 之間。
+
# 省略攝影機啟動 (cap = cv2.VideoCapture(0)) ...
+# 省略讀取影格 (success, img = cap.read()) ...
+
+# Keras 要求的影像預處理 (這三行請死背!)
+img_resized = cv2.resize(img, (224, 224))
+img_array = np.asarray(img_resized, dtype=np.float32).reshape(1, 224, 224, 3)
+img_array = (img_array / 127.5) - 1
+
+# 前饋推論:得到每一個類別的機率陣列
+prediction = model.predict(img_array)
+
+# 找出最大機率的那一項標籤
+index = np.argmax(prediction)
+class_name = labels[index].strip()
+confidence = prediction[0][index]
+
+print(f"辨識結果:{class_name} ({confidence:.1%})")
+
+
+
+
推論結果示意
+
+
↑ model.predict() 回傳的陣列就是 [0.92, 0.06, 0.02],分別對應 labels.txt 中的每一行類別名稱。
+
+
+
+
+
+
挑戰:AI 聲音播放器
+
偵測到「喝水中」就播出咕嚕聲!但小心——不能每一幀都重複播放。
+
+
+
+ 任務目標
+
+
當「喝水中」的機率大於 90% 的時候,立刻觸發 Pygame 音效播放,放出氣泡咕嚕聲。
+
+
# 載入音效
+pygame.mixer.init()
+bubble_sound = pygame.mixer.Sound("bubble.wav")
+
+# 在推論迴圈中:
+if class_name == "喝水中" and confidence > 0.90:
+ bubble_sound.play() # ← 但這樣會鬼畜!
+
+
+
+
+ 問題:鬼畜播放
+
+
在 while 的連續迴圈下,一偵測到喝水就播音效,音軌會被不斷覆蓋而產生恐怖的鬼畜聲音。每一幀都觸發一次 play()!
+
+
+
+
+ 解法:狀態標記 (Flag)
+
+
利用上週學到的 Boolean 狀態標記,讓音效「只在狀態剛變成喝水的那一瞬間」 播放,之後不管再高機率都不重新播,直到狀態變為背景再重置。
+
+
# 在迴圈外面宣告
+is_drinking = False
+
+# 在迴圈裡面
+if class_name == "喝水中" and confidence > 0.90:
+ if not is_drinking: # 剛剛才開始喝
+ bubble_sound.play()
+ print("🥤 偵測到喝水!播放音效(只播一次)")
+ is_drinking = True
+else:
+ is_drinking = False # 狀態回到背景,重置旗標
+
+
這個概念跟上週的「邊緣觸發」完全一致!只在狀態 從「未喝水」→「喝水中」 的瞬間觸發一次。
+
+
+
+
+
+
常見問題 FAQ
+
關於 Teachable Machine 和 Keras 模型的疑難雜症。
+
+
+
+
+ 有兩個可能:
+ 1. 影像變形 :你的 Python 影像(如矩形變正方形)變形度過大,模型根本認不出來。
+ 2. BGR 沒轉 RGB :Keras 模型只接受訓練當時同樣的處理法。如果 Teachable Machine 用的是 RGB,但 OpenCV 送出的是 BGR,顏色都是反的,AI 當然看不懂!
+ 記得加上:img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+
+
+
+
+
+
+ 神經網路本來就不是給一般 CPU 玩的!不要每一幀都預測!你可以加一個計數器:
+ if counter % 5 == 0:
+ 每五格影像才把畫面丟去判斷一次,其他幀沿用上一次的辨識結果就好。這可以讓畫面的 FPS 從 3 提升到 20 以上!
+
+
+
+
+
+
+ MediaPipe :Google 已經訓練好的模型,專門辨識手部 / 臉部 / 姿態的「骨架關鍵點」。你拿到的是座標數字 (x, y),需要自己寫邏輯判斷。
+ Teachable Machine :你自己訓練的模型,直接辨識「整張畫面屬於哪一個類別」。你拿到的是分類結果(如「喝水中 92%」),不需要自己寫距離計算。
+ 如果你的需求是精確抓手指位置 → 用 MediaPipe。
+ 如果你的需求是整體動作分類 → 用 Teachable Machine。
+
+
+
+
+
關鍵術語表
+
+ Model Training (模型訓練) :提供正確答案與資料集,讓神經網路修改內部參數以降低預測誤差。
+ Inference (推論) :將訓練好的模型丟給未知影像去「猜」出這是什麼。
+ Labels (標籤) :人為賦予的各類別名字,如「這是水」、「那是背景」。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/2026project_class/w12.html b/projects/2026project_class/w12.html
new file mode 100644
index 0000000000000000000000000000000000000000..f0d12bce2267697e201bbe02fef0b17546b81adf
--- /dev/null
+++ b/projects/2026project_class/w12.html
@@ -0,0 +1,612 @@
+
+
+
+
+
+
+ Week 12 | 專案實作(三)——AI 生活工具箱
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Week 12 / Scrum:痛點與 MVP
+
+
+
+
+
專案啟動:痛點定義與 MVP 設定
+
過去三週學了 AI 怎麼運作,現在是時候把它拿來解決真正的問題了。
+
+
+
痛點收斂與技術決選
+
每個人快速提出:「日常生活中,我做不到什麼?或者覺得有什麼很煩的例行公事可以被自動化的?」
+
+
+
+
🖥️
+
護眼大師
+
「我常常打電動打到坐姿很差或靠到電腦螢幕上。」
+
技術:MediaPipe 臉部距離偵測
+
+
+
🎵
+
隔空調音量
+
「音樂太大聲,手又髒髒的不想碰鍵盤去調整。」
+
技術:MediaPipe 手勢 + Pinch 距離
+
+
+
🧹
+
打掃偵測鬧鐘
+
「房間沒整理就偵測到然後叫我起來打掃。」
+
技術:Teachable Machine 影像分類
+
+
+
💧
+
喝水提醒員
+
「偵測我有沒有喝水,太久沒喝就發出提示音。」
+
技術:Teachable Machine + 計時器
+
+
+
+
+
+
技術抉擇
+
+
+
選 MediaPipe?
+
需要抓精確的座標數字 (如:手指距離、臉部關鍵點位置)才能做出互動邏輯的場景。
+
+
+
選 Teachable Machine?
+
只需要判斷整張畫面屬於哪類 (如:有沒有在喝水、坐姿好不好),直接餵畫面分類。
+
+
+
+
+
+
+
+
開發衝刺 Sprint
+
65 分鐘的沉浸式開發!把「偵測到」的結果化作「互動行為」。
+
+
+
+
+
+
+
AI
+
整合 MediaPipe / TM 推論迴圈
+
+
+
+
+
+
+
核心開發目標
+
AI 抓出偵測結果後,要在 Pygame 畫面上產生視覺回饋 :
+
+ 切換提示圖文字(如「⚠️ 危險!坐姿太近! 」)
+ 改變背景顏色或閃爍效果
+ 播放警報音效(記得處理延遲與重複播放!)
+ 更新數值顯示(如距離數值、計時器)
+
+
+
+
+
殘酷 MVP 剃刀
+ 「你想做一個偵測有沒有打掃房間的鬧鐘?很棒!但今天
只有 60 分鐘 !請先做一個『偵測手上有沒有拿水瓶蓋好蓋子』的分類器就好。
先做最小的、能動的版本 ,再慢慢加功能!」
+
+
+
+
+
+
破壞者大會:跨電腦 QA 測試
+
使用者永遠是最大的 Bug 製造機。讓其他人來摧毀你的程式吧!
+
+
+
+ QA 找碴的軟體工程
+
+
過去 AI 的辨識都是你自己用自己特有的習慣與角度在拍攝。現在:
+
+ 「請旁邊的朋友坐上你的位子,給你的 AI 看看他的手、他的臉!」
+
+
+
+
+
+
+
嚴重
+
把手伸到畫面外面時,程式噴出 IndexError 直接閃退
+
+
+
嚴重
+
換一個人來操作時,AI 辨識結果完全錯亂(一直顯示「喝水中」即使沒有水瓶)
+
+
+
中等
+
警報音效一直重複播放,關不掉,聲音疊加後變成鬼畜
+
+
+
輕微
+
文字顯示跑出畫面邊界,看不到完整提示訊息
+
+
+
+
+
+ 修復策略:防禦性程式碼
+
+
拿著 Bug 紀錄單,用以下武器修復崩潰點:
+
# 防禦武器 1:判空保護
+if results.hand_landmarks:
+ # 有偵測到手才執行
+ for hand_lms in results.hand_landmarks:
+ # ... 你的邏輯
+else:
+ # 沒手?那就不做事,不要讓它存取不存在的資料!
+ pass
+
+# 防禦武器 2:try-except 終極護盾
+try:
+ prediction = model.predict(img_array)
+ index = np.argmax(prediction)
+except Exception as e:
+ print(f"推論出錯了:{e}")
+ # 不要讓整個程式因為一幀的異常而閃退!
+
+
+
+
+
+
Demo 分享會:銷售你的 AI 道具
+
快速演練如何用三十秒推銷你的 AI 生活小道具。
+
+
+
🛡️ 護眼大師 EyeGuard
+
「這款名為護眼大師的小怪獸,在你的臉離螢幕低於安全距離時,就會尖叫並暫停你聽的音樂。只要我們有電腦與鏡頭,這就是免費的硬體!」
+
+
+
+
+
30 秒 Elevator Pitch 架構
+
+ 痛點 :「你有沒有遇過 _____ 很煩的事?」
+ 方案 :「我們用 AI 辨識 _____,當偵測到 _____,就自動 _____。」
+ Demo :「你看,現在我把手伸出來... (展示即時效果)」
+
+
+
+
+
觀眾思考題
+
請思考:
+
+ 它的辨識有盲點 嗎?什麼環境下會破功?
+ 如果換一個人來用,還會準嗎?
+ 如果沒有鏡頭怎麼辦?有備用方案嗎?
+
+
+
+
+
+
+
常見問題 FAQ
+
關於 AI 專案整合與 QA 測試的疑難雜症。
+
+
+
+
+ 代表你上次訓練時,把「你專屬的衣服顏色、你的房間光線」也當成了判斷的特徵。你需要蒐集更多在不同房間、不同人出現的豐富度資料,才能避免過度擬合 (Overfitting) 。
+ Overfitting 就像是只背考古題而不理解原理,換了一張新考卷就全部答錯。
+
+
+
+
+
+
+ 因為當畫面中沒有手時,results.multi_hand_landmarks 是 None。你如果直接用 for hand_lms in results.multi_hand_landmarks 去遍歷 None,就會觸發 TypeError。
+ 解法: 在存取之前,永遠先檢查 if results.multi_hand_landmarks is not None:
+
+
+
+
+
+
+ 常見原因:
+ 1. 絕對路徑 :你的檔案路徑寫成了 C:\Users\我\桌面\model.h5,但別人的電腦沒有這個路徑。請改用相對路徑 "keras_model.h5"。
+ 2. 缺少套件 :別人沒有安裝 mediapipe 或 keras。請給他一份安裝指令。
+ 3. 缺少檔案 :忘了把 .h5 模型檔或音效檔一起複製過去。
+
+
+
+
+
關鍵術語表
+
+ Edge Case (邊緣案例) :開發者沒想過的異常情境,如畫面中出現兩隻手、或者根本沒有手。
+ False Positive (偽陽性) :明明沒有做出壞坐姿,卻硬是報警。代表把不該抓的誤抓了。
+ Overfitting (過度擬合) :模型只學會了訓練資料的特定模式,對新資料完全無法泛化。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/2026project_class/w2.html b/projects/2026project_class/w2.html
new file mode 100644
index 0000000000000000000000000000000000000000..b507a3316f3420da65813db5d4c16f224770131a
--- /dev/null
+++ b/projects/2026project_class/w2.html
@@ -0,0 +1,855 @@
+
+
+
+
+
+
+ Week 02 | Python 數位服務生與網路 API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
誰是網路世界的服務生?
+
API (Application Programming Interface) 的生活化比喻。
+
+
+
+
+
你 (客戶端 / Client)
+
坐在餐廳裡的客人。你不知道廚房怎麼煮菜,你只想要點一份「台北的即時天氣」。在程式世界裡,這個角色就是你正在寫的
+ Python 程式碼 。
+
+
+
+
+
服務生 (API / 介面)
+
你不能直接衝進別人的廚房!必須透過服務生的「菜單」傳遞要求 (Request)。服務生確認訂單後,幫你去廚房拿菜(資料),然後端回你的桌上
+ (Response)。
+
+
+
+
+
廚房 (伺服器 / Server)
+
存放資料庫的超大型電腦。它負責處理訂單(例如查詢天氣資訊),然後把結果裝進特定的盤子裡(通常這個盤子叫做 JSON)。
+
+
+
+
+
生活中的 API 實例
+
+
試想你手機上的「天氣 APP」或是「公車動態 APP」。製作這些 APP 的工程師,並沒有在全球各地安裝溫度計,也沒有在每一台公車上裝 GPS。他們都是透過 呼叫 API
+ (叫服務生去拿資料) ,即時把遠端伺服器的精準數據呈現到你的畫面上!
+
+
+
+
+
+
資料的通用語言:JSON
+
當服務生端菜回來時,他用的是一個名為 JSON 的超級百寶箱。
+
+
+
Python 世界裡的俄羅斯娃娃
+ JSON 看起來很嚇人,但在 Python 裡面,它其實就只是
「字典 (Dictionary `{}` )」 與
「列表 (List `[]`
+ )」 的超級混血包。
+
+
+
+
+
GET
+
https://api.weather.com/v1/taipei
+
200 OK
+
+
+
+ {
+ "status" : "success" ,
+ "records" : [
+ {
+ "city" : "Taipei" ,
+ "weather" : "Rain" ,
+ "temperature" : 24.5
+ }
+ ]
+ }
+
+
+
+
+
如何把 "Rain" 萃取出來?
+
# 假設上面的資料存在 data 變數中:
+
+# 1. 拆開第一層(字典,用字串 Key):
+records_list = data["records"]
+
+# 2. 拆開第二層(列表,用中括號選第 0 個):
+first_item = records_list[0]
+
+# 3. 拆開第三層(字典,拿出 weather):
+weather = first_item["weather"]
+
+# --- 黑客極速寫法:連鎖開箱 ---
+print( data["records"][0]["weather"] ) # 輸出:Rain
+
+
+
+
+
實作:我的第一支爬蟲
+
讓 Python 替你自動上網,抓取隱藏在網頁背後的純淨數據。
+
+
+
1
+
安裝魔法工具箱
+
開啟終端機,安裝我們本週的主角 requests 庫(Python 最好用的網路工具)。
+
+ > pip install requests
+
+
+
+
+
2
+
寫出第一支 API 請求程式
+
我們將使用一個最簡單的真實 API:api.ipify.org,它的功能是告訴你,你現在上網的 IP 位址是什麼。
+
import requests
+
+# 1. 準備好我們的目標網址 (餐廳菜單上的某一頁)
+url = "https://api.ipify.org?format=json"
+
+# 2. 派出服務生 (使用 get 請求)
+print("正在發送網路請求...")
+response = requests.get(url)
+
+# 3. 把伺服器給的一團文字,自動轉換為 Python 字典
+data = response.json()
+
+# 4. 精準提取資料
+print("我的 IP 號碼是:", data["ip"])
+
+
+
+
+
+
防呆與除錯:如果服務生跌倒了?
+
世界上沒有完美的網路。斷線、伺服器當機、網址拼錯是家常便飯。你的程式不能因此就崩潰!
+
+
+
+
+
狀態碼
+ 200 (OK)
+
+
代表點餐順利,廚房成功出菜。這是我們最想看到的結果。
+
+
+
+
狀態碼
+ 404 (Not Found)
+
+
網址打錯了!服務生找不到那張桌子,或者菜單上根本沒這道菜。
+
+
+
+
狀態碼 500
+ (Internal Server Error)
+
+
廚房起火了!(伺服器端發生程式錯誤或當機)。雖然錯不在你,但如果你的程式沒做好保護,就會一起陪葬崩潰退場。
+
+
+
+
溫柔的終極護盾:Try-Except
+
import requests
+
+url = "https://api.ipify.org?format=json"
+
+try:
+ # timeout=3 意思是:如果服務生等超過 3 秒廚房都沒反應,就直接放棄,不要傻傻等一輩子。
+ response = requests.get(url, timeout=3)
+
+ # 檢查狀態碼是不是 200 成功
+ if response.status_code == 200:
+ data = response.json()
+ print("成功獲取資料:", data)
+ else:
+ print(f"哎呀!伺服器回應錯誤碼:{response.status_code}")
+
+# 如果發生網路斷線、超時等各式連線災難,通通會跌進這個 except 的緩衝墊裡:
+except requests.exceptions.RequestException as e:
+ print("【系統警告】連線失敗!請檢查您的網路或等一下再試。")
+ # 程式在這裡會安全地印出警告,而不會噴出可怕的紅字並且閃退!
+
+
+
+
+
+
趣味 API 展覽館
+
除了天氣和笑話,這個世界上還有無數好玩的 API 等著我們去探索!歡迎直接在這邊設定參數並點擊「Send Request」測試:
+
+
+
+
+
+
+
+
+
\n
+
+
挑戰任務:冷笑話/天氣播報員
+
將所學知識轉化為真實應用的微型終端機專案。
+
+
+
基本要求
+
+ 請先從「趣味 API 展覽館」挑選一個你最喜歡的 API 網址(例如:冷笑話庫、狗狗圖片)。
+
+ 請寫一段程式碼,向這個網址發送 GET 請求。
+ 解析回傳的 JSON,將笑話的「鋪陳 (setup)」與「笑點 (punchline)」萃取出來。
+ 在終端機將這兩句話漂亮地印出來。
+
+
+
+
+
進階要求 (PRO)
+
+ 將整套 API 請求包裝在一個 `while True:` 的無限迴圈中。
+ 加上 `input("按 Enter 取得下一個笑話,輸入 q 離開:")` 讓使用者可以無限看新笑話。
+ 必須加入 `try-except` 防呆網 。請在程式執行途中「趁機把你的電腦 Wi-Fi 關掉」,看看你的程式是會崩潰閃退,還是溫柔地提示網路錯誤並繼續等待?
+
+
+
+
+
+
+
+
常見問題 FAQ
+
遇到問題不用慌,看看是不是踩到常見的初學者盲點。
+
+
+
+
+ 這代表你跟 API 點的菜("name" 這個 Key)根本不存在它的菜單裡。服務生端出來的東西可能叫 "title" 或 "username"。在寫字典存取之前,請先隨時
+ `print(data)` 看一下服務生到底送來了什麼格式的 JSON 資料結構!
+
+
+
+
+
+
+ 因為在預設情況下,`requests.get()` 有著超人的耐心,它會幾乎無止盡地等待微弱的網路回應。強烈建議在每個請求中加入 timeout=3
+ (等待三秒),這樣能大幅提高終端用戶的體驗。
+
+
+
+
+
+
+ 秘訣:看到大括號 { },就是查字典找特定的英文字 (`"weather"`、`"ip"`)。 看到中括號
+ [ ],通常代表裡面排了一列隊伍,你需要給他明確的排隊號碼 (`0`, `1`, `2`...)。若是混合型,就像開俄羅斯娃娃,一層一層拆開就好!
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/projects/2026project_class/w3.html b/projects/2026project_class/w3.html
new file mode 100644
index 0000000000000000000000000000000000000000..e8b457804327546971887f7b403d6536f16ef0aa
--- /dev/null
+++ b/projects/2026project_class/w3.html
@@ -0,0 +1,552 @@
+
+
+
+
+
+
+ Week 03 | 數據視覺化:文字渲染與狀態管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
遊戲畫面的文字印刷廠
+
Pygame 的畫布上,文字不是「打」上去的,而是像蓋印章一樣「印刷」上去的。
+
+
+
+
終端機 print()
+
過去我們在黑色終端機裡使用 print("Hello"),文字會乖乖地一行一行往下排列。這是作業系統幫你處理好的「文字模式」。
+
+
+
Pygame 文字渲染
+
在 Pygame 的畫布上,每一個像素都由你掌控 。文字必須先選擇字體 → 渲染成圖片 → 然後貼到畫布的精確座標上,就像印章蓋在紙上!
+
+
+
+
文字渲染三步驟
+
+
+ 選字體
+ (Font) :就像在 Word 裡面選「微軟正黑體」或「Arial」,告訴 Pygame 你要用哪種字體、多大號。
+ 渲染文字
+ (Render) :把一段文字「印刷」成一張小圖片(Surface)。這時候你要指定文字內容、是否反鋸齒、以及文字顏色。
+ 貼上畫布
+ (Blit) :使用 screen.blit(文字圖片, (x, y))
+ 把這張印好的文字圖片,精準地貼到畫布上你想要的位置。
+
+
+
+
+
為什麼這麼麻煩?
+ 因為遊戲引擎需要極致的控制力!你可以讓文字在畫面上飛來飛去、隨時改變大小與顏色、甚至旋轉——這些都是終端機的
print() 做不到的事!
+
+
+
+
+
+
實作:把 API 數據搬上大螢幕
+
結合 W2 學的 API 請求與 W1 的 Pygame 視窗,讓資料活生生地呈現在畫布上。
+
+
+
環境確認
+ 這週需要同時使用
pygame 和
requests 兩個函式庫,請確保它們都已安裝:
+
pip install pygame requests
+
+
+
+
+
+
1
+
準備字體工廠
+
在 pygame.init() 之後,建立字體物件。我們用系統內建字體,設定不同大小以區分標題與內文。
+
import pygame
+import sys
+import requests
+
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("我的 API 數據面板")
+clock = pygame.time.Clock()
+
+# 建立字體工廠(None = 使用系統預設字體)
+font_big = pygame.font.SysFont(None, 48) # 大標題
+font_mid = pygame.font.SysFont(None, 32) # 中等文字
+font_small = pygame.font.SysFont(None, 24) # 小字說明
+
+
+
+
2
+
先抓資料,再開迴圈
+
在進入遊戲主迴圈「之前」,先利用 W2 學到的 requests.get() 把數據拉回來存進變數。
+
# --- 在迴圈之前先抓資料 ---
+api_text = "正在載入資料..."
+
+try:
+ response = requests.get(
+ "https://official-joke-api.appspot.com/random_joke",
+ timeout=5
+ )
+ if response.status_code == 200:
+ data = response.json()
+ api_text = data["setup"] + " — " + data["punchline"]
+ else:
+ api_text = f"伺服器錯誤:{response.status_code}"
+except requests.exceptions.RequestException:
+ api_text = "網路連線失敗,請檢查 Wi-Fi"
+
+
+
+
3
+
渲染 + 貼上:文字登上畫布
+
在迴圈的「畫面渲染」區塊,使用 font.render() 把文字變成圖片(Surface),再用 screen.blit() 貼上去。
+
+
# ----- 遊戲主迴圈 -----
+while True:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 【畫面渲染】
+ screen.fill((20, 20, 35)) # 深藍灰背景
+
+ # 標題文字
+ title_surface = font_big.render("API Data Viewer", True, (100, 200, 255))
+ screen.blit(title_surface, (50, 40))
+
+ # 分隔線
+ pygame.draw.line(screen, (60, 60, 80), (50, 100), (750, 100), 2)
+
+ # API 回傳的資料文字
+ data_surface = font_mid.render(api_text, True, (200, 255, 200))
+ screen.blit(data_surface, (50, 140))
+
+ # 底部提示
+ hint_surface = font_small.render("按下空白鍵重新抓取資料", True, (120, 120, 140))
+ screen.blit(hint_surface, (50, HEIGHT - 50))
+
+ pygame.display.flip()
+ clock.tick(60)
+
+
+
+
4
+
加入按鍵觸發重新抓取
+
在事件監聽區塊,偵測空白鍵的按下事件,並重新發送 API 請求來更新畫面文字。
+
for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 偵測按鍵:按下空白鍵就重新抓一則笑話
+ if event.type == pygame.KEYDOWN:
+ if event.key == pygame.K_SPACE:
+ api_text = "重新載入中..."
+ try:
+ r = requests.get(
+ "https://official-joke-api.appspot.com/random_joke",
+ timeout=5
+ )
+ if r.status_code == 200:
+ d = r.json()
+ api_text = d["setup"] + " — " + d["punchline"]
+ except:
+ api_text = "抓取失敗,請稍後再試"
+
+
+
+
+
🛠️ 執行並測試
+
存檔並執行!你應該會看到一個深色視窗,上方有藍色標題,中間顯示一段英文冷笑話。按下空白鍵,笑話會刷新——恭喜你完成了第一個「API +
+ 視覺化」的跨界結合!
+
+
+
+
+
+
核心觀念:網路延遲與狀態機
+
為什麼按下按鍵時,畫面會「凍結」一下?這背後藏著即時系統的致命陷阱。
+
+
+
+
+
+ 問題:阻塞式呼叫
+
+
當你在遊戲迴圈裡直接呼叫 requests.get(),整個程式就在那邊「傻等」伺服器回應。如果伺服器慢了 3 秒,畫面就凍結 3 秒,因為迴圈被卡住了!
+
+
+
+
+ 解法:狀態變數
+
+
我們使用一個 狀態變數 來追蹤程式的當前狀態:「閒置中」、「載入中」、「已完成」。畫面可以根據狀態顯示不同的內容,例如「Loading...」的動畫。
+
+
+
+
狀態機 (State Machine) 概念圖
+
+
+
+ 🟢 IDLE等待使用者指令
+
→
+
+ 🟡 LOADING正在等待伺服器
+
→
+
+ 🟢 DONE資料已就位
+
+
+
+
進階:併發 (Async) 與 同步 (Sync) 請求比較
+
傳統的同步 (Sync) 請求會一個接一個執行,當我們要抓取多個網路資料時,會花費大量時間等待。如果使用 Python 的 threading 模組將請求放入背景執行緒,我們就能達到併發 (Async) 的效果,一次發出多個請求!
+
+
1. 同步請求 (Sync) 寫法
+
一個接一個抓資料,總時間是所有請求的總和。
+
import requests
+import time
+
+api_urls = [
+ "https://official-joke-api.appspot.com/random_joke",
+ "https://dog.ceo/api/breeds/image/random",
+ "https://api.agify.io?name=steve",
+ "https://randomuser.me/api/",
+ "https://api.adviceslip.com/advice"
+]
+
+def fetchExternalApi(_url):
+ print(f"[fetchExternalApi] url = {_url}")
+ start_time = time.time()
+ try:
+ response = requests.get(
+ _url,
+ timeout=5
+ )
+
+ end_time = time.time()
+
+ if response.status_code == 200:
+ data = response.json()
+ print(f"data = {data}")
+ return (end_time - start_time)
+ else:
+ print(f"伺服器錯誤:{response.status_code}")
+ except:
+ print("請求失敗")
+ finally:
+ end_time = time.time()
+ print(f"\033[31m執行時間:{(end_time - start_time):.3f} 秒\033[0m") # 紅色顯示
+
+if __name__ == "__main__":
+ elapsed_time = 0
+ elapsed_time += fetchExternalApi(api_urls[0])
+ elapsed_time += fetchExternalApi(api_urls[1])
+ elapsed_time += fetchExternalApi(api_urls[2])
+ elapsed_time += fetchExternalApi(api_urls[3])
+ elapsed_time += fetchExternalApi(api_urls[4])
+ print(f"\033[32m總執行時間:{elapsed_time:.3f} 秒\033[0m") # 綠色顯示
+
+
2. 非同步請求 (Async) 寫法 (使用 threading)
+
將請求指派給多個執行緒 (Thread) 同時發出,總時間大幅縮短!
+
import threading
+import requests
+import time
+
+api_urls = [
+ "https://official-joke-api.appspot.com/random_joke",
+ "https://dog.ceo/api/breeds/image/random",
+ "https://api.agify.io?name=steve",
+ "https://randomuser.me/api/",
+ "https://api.adviceslip.com/advice"
+]
+
+def fetchExternalApi(_url):
+ print(f"[fetchExternalApi] url = {_url}")
+ start_time = time.time()
+ try:
+ response = requests.get(
+ _url,
+ timeout=5
+ )
+
+ end_time = time.time()
+
+ if response.status_code == 200:
+ data = response.json()
+ print(f"data = {data}")
+ return end_time - start_time
+ else:
+ print(f"伺服器錯誤:{response.status_code}")
+ except:
+ print("請求失敗")
+ finally:
+ end_time = time.time()
+ print(f"\033[31m執行時間:{(end_time - start_time):.3f} 秒\033[0m") # 紅色顯示
+
+if __name__ == "__main__":
+ elapsed_time = 0 # 計時開始
+
+ start_time = time.time()
+ th1 = threading.Thread(target=fetchExternalApi, args=(api_urls[0],)) # 逗號必要,才會是 tuple,否則只是字串
+ th2 = threading.Thread(target=fetchExternalApi, args=(api_urls[1],))
+ th3 = threading.Thread(target=fetchExternalApi, args=(api_urls[2],))
+ th4 = threading.Thread(target=fetchExternalApi, args=(api_urls[3],))
+ th5 = threading.Thread(target=fetchExternalApi, args=(api_urls[4],))
+
+ th1.start()
+ th2.start()
+ th3.start()
+ th4.start()
+ th5.start()
+
+ th1.join()
+ th2.join()
+ th3.join()
+ th4.join()
+ th5.join()
+
+ end_time = time.time() # 計時結束
+
+ print(f"\033[32m所有請求已發出,等待結果中...\033[0m") # 綠色顯示
+ print(f"\033[32m總執行時間:{(end_time - start_time):.3f} 秒\033[0m") # 綠色顯示
+
+
+
小提醒
+ threading 是非常進階的概念,這週只需要「理解為什麼等待網路回應會讓程式卡頓」以及「知道有解法 (Async) 存在」即可。目前階段在 Pygame 中先用最簡單的「在迴圈外抓資料」或「使用短 timeout」來處理,下一週的專案會再深入。
+
+
+
+
+
+
挑戰任務:寶可夢圖鑑機
+
結合鍵盤輸入、API 呼叫與 Pygame 渲染,打造一台迷你圖鑑查詢機。
+
+
+
基本要求 (Level 1)
+
+ 建立一個 Pygame 視窗,顯示標題「寶可夢圖鑑」。
+ 在程式啟動時,呼叫 https://pokeapi.co/api/v2/pokemon/pikachu 抓取皮卡丘的資料。
+ 使用 font.render() 把寶可夢的名字 (name) 、身高
+ (height) 、體重 (weight) 渲染到畫面上。
+ 加入 Try-Except 防呆機制,確保斷網不會讓程式崩潰。
+
+
+
+
+
進階要求 (PRO)
+
+ 隨機圖鑑: 按下空白鍵時,使用 import random 隨機產生 1~151 之間的數字,然後呼叫
+ https://pokeapi.co/api/v2/pokemon/{隨機數字} 切換寶可夢。
+
+ 載入圖片: PokeAPI 回傳的 JSON 中有 data["sprites"]["front_default"]
+ 這個圖片網址。嘗試使用以下技巧把圖片載入 Pygame:
+ import io
+# 先把圖片的原始位元組抓下來
+img_response = requests.get(圖片網址)
+# 用 io.BytesIO 把位元組流轉換成 Pygame 可讀取的檔案格式
+img_surface = pygame.image.load(io.BytesIO(img_response.content))
+# 放大圖片(原始只有 96x96,太小了)
+img_surface = pygame.transform.scale(img_surface, (200, 200))
+# 在遊戲迴圈中 blit 到畫布上
+screen.blit(img_surface, (300, 200))
+
+ 屬性顯示: 解析 data["types"] 列表,把寶可夢所有的屬性(如 electric, fire)也印到畫面上。
+
+
+
+
+
💡 JSON 結構提示
+
PokéAPI 回傳的 JSON 非常龐大!記住 W2 教的「先 print(data) 看結構」。關鍵路徑:
+ 名字 → data["name"]
+ 身高 → data["height"]
+ 體重 → data["weight"]
+ 圖片 → data["sprites"]["front_default"]
+ 屬性 → data["types"][0]["type"]["name"]
+
+
+
+
+
+
常見問題 FAQ
+
文字渲染與 API 整合常踩的坑。
+
+
+
+
+ Pygame 的 font.render() 確實不支援自動換行!它只會把整段文字畫成一行長長的圖片。解法是自己把文字用 split("\n")
+ 分割成多行,
+ 然後分別 render 每一行,並且手動調整每行的 Y 座標(例如每行 +30 px)。
+
+
+
+
+
+
+ 系統預設字體 SysFont(None, 32) 通常不包含中文字型。解法:指定一個支援中文的字體名稱,例如
+ pygame.font.SysFont("microsoftjhenghei", 32),
+ 或是下載一個 .ttf 字體檔案放在同一個資料夾,然後用 pygame.font.Font("NotoSansTC.ttf", 32) 載入。
+
+
+
+
+
+
+ 這是正常的!因為 requests.get() 是阻塞式操作 ——它在等待伺服器回應時,整個程式(包括畫面渲染)都會暫停。
+ 本週的「狀態機」分頁有介紹原因與解法概念(使用 threading)。在目前的學習階段,加上 timeout=3 減少最長等待時間即可。
+
+
+
+
+
+
+ Pygame 的 font.render() 只支援單色。做陰影的技巧是:先用深色 render 一次並往右下偏移幾個像素 blit,再用亮色 render 同樣的文字
+ blit 在原始位置,
+ 這樣就會產生「文字陰影」的視覺效果。漸變色則需要用更進階的像素操作(pixel-level)或外部工具生成圖片。
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/projects/2026project_class/w4.html b/projects/2026project_class/w4.html
new file mode 100644
index 0000000000000000000000000000000000000000..0650d58465d6e9f356b94e5bce71490e94de7d28
--- /dev/null
+++ b/projects/2026project_class/w4.html
@@ -0,0 +1,494 @@
+
+
+
+
+
+
+ Week 04 | 期中專案:數位助理儀表板
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
向設計師借腦袋:UI 排版與色彩
+
從「能用」到「好看」的跨越,讓你的程式看起來有業界水準。
+
+
+
+
+
+ ❌ 素人排版
+
+
所有文字從 (0, 0) 開始堆疊、顏色隨便給、大小一樣大、數據跟標題擠在一起。整個畫面雜亂無章,像是在 notepad 裡列印。
+
+
+
+
+ ✅ 專業排版
+
+
有一致的邊距 (margin)、清楚的層級(標題大、數據中、說明小)、統一的配色方案、以及用 pygame.draw.rect() 分區框出的「小卡片」。
+
+
+
+
四個黃金法則
+
+
+ 對齊
+ (Alignment) :所有元素的左邊界、上邊界要對齊,就像用尺畫線一樣。不要隨機亂放 x, y 座標。
+ 留白
+ (Spacing) :元素之間要留有呼吸空間。邊距 (padding) 通常設為 20~40 像素。不要讓文字貼著視窗邊緣。
+ 層級
+ (Hierarchy) :標題用大字體 + 亮色,數據用中字體,說明用小字體 + 暗色。讓讀者 0.5 秒內知道哪裡是重點。
+ 配色
+ (Colors) :選一個深色背景(如 (18, 18, 30)),搭配 2~3 種亮色點綴。避免使用純白
+ (255,255,255),用柔白 (200,200,210) 取代。
+
+
+
+
繪製 UI 元件的利器
+
+
+
draw.rect()
+
畫矩形(卡片背景、分區框線)。
+
# 畫一個圓角卡片背景
+pygame.draw.rect(
+ screen,
+ (30, 30, 50), # 深灰藍色
+ (20, 120, 360, 200), # (x, y, 寬, 高)
+ border_radius=12 # 圓角半徑
+)
+
+
+
draw.line()
+
畫分隔線,區分不同區塊。
+
# 水平分隔線
+pygame.draw.line(
+ screen,
+ (60, 60, 80), # 暗灰色
+ (20, 110), # 起點 (x, y)
+ (780, 110), # 終點 (x, y)
+ 2 # 線條粗細
+)
+
+
+
draw.circle()
+
畫圓形指示燈(API 連線狀態)。
+
# 綠色小圓點 = API 連線成功
+pygame.draw.circle(
+ screen,
+ (16, 185, 129), # 翡翠綠
+ (50, 500), # 中心座標
+ 6 # 半徑
+)
+
+
+
+
+
+
+
不讓程式碼變成義大利麵
+
當程式碼超過 100 行,如果全部擠在一個大區塊裡,你自己都看不懂。函式 (Function) 就是你的救星。
+
+
+
+
沒有函式的世界
+
所有程式碼擠在 while True: 裡面,往下滾了 300 行才找到「畫天氣」的段落。出了 Bug 完全不知道從哪裡開始找。
+
+
+
有函式的世界
+
把每一塊功能包裝成獨立的「工具箱」。迴圈裡只剩
+ draw_header()、draw_weather()、draw_footer()——一目瞭然!
+
+
+
+
+
函式封裝實戰
+
def draw_header(screen, font):
+ """在畫面最上方繪製標題區塊"""
+ title = font.render("☁️ 我的數位助理面板", True, (100, 180, 255))
+ screen.blit(title, (30, 20))
+ # 畫一條分隔線
+ pygame.draw.line(screen, (50, 50, 70), (30, 65), (770, 65), 2)
+
+
+def draw_weather_card(screen, font_title, font_data, weather_info, x, y):
+ """在指定位置畫一張天氣資訊卡片"""
+ # 卡片背景
+ pygame.draw.rect(screen, (30, 30, 50), (x, y, 350, 180), border_radius=12)
+
+ # 卡片標題
+ label = font_title.render("🌤️ 即時天氣", True, (147, 197, 253))
+ screen.blit(label, (x + 15, y + 15))
+
+ # 溫度大字
+ temp = font_data.render(f"{weather_info['temp']}°C", True, (255, 255, 255))
+ screen.blit(temp, (x + 15, y + 55))
+
+ # 天氣描述
+ desc = font_title.render(weather_info['desc'], True, (148, 163, 184))
+ screen.blit(desc, (x + 15, y + 130))
+
+
+def draw_status_bar(screen, font, is_connected):
+ """底部狀態列:API 連線指示燈"""
+ color = (16, 185, 129) if is_connected else (239, 68, 68)
+ label = "Online" if is_connected else "Offline"
+ pygame.draw.circle(screen, color, (40, 575), 5)
+ status = font.render(f"API: {label}", True, (120, 120, 140))
+ screen.blit(status, (55, 566))
+
+
主迴圈變得超乾淨
+
# ----- 迴圈大幅簡化 -----
+while True:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ screen.fill((18, 18, 30))
+
+ draw_header(screen, font_big)
+ draw_weather_card(screen, font_mid, font_huge, weather_data, 30, 90)
+ draw_status_bar(screen, font_small, api_connected)
+
+ pygame.display.flip()
+ clock.tick(60)
+
+
+
為什麼這很重要?
+ 在下週開始的「階段二:區域網路」專案中,程式碼量會爆增。如果你不養成函式封裝的好習慣,整個專案會變得完全無法維護。
+
養成「一個功能一個函式」的思維模式吧!
+
+
+
+
+
+
專案指南:天氣儀表板架構解密
+
把四週所學的知識整合成一個完整的「數位助理儀表板」。
+
+
專案架構全景圖
+
+
+
+
📦 初始化區
+
+ pygame.init()
+ 建立字體
+ 呼叫 API 抓取初始資料
+ 設定顏色常數
+
+
+
+
🔧 函式工具區
+
+ fetch_weather()
+ fetch_joke()
+ draw_header()
+ draw_card()
+ draw_status()
+
+
+
+
🔄 主迴圈
+
+ 事件監聽
+ 呼叫函式繪製 UI
+ display.flip()
+ clock.tick(60)
+
+
+
+
+
+
+
+
+
1
+
取得並觀察專案模板
+
這次的「天氣助理 Premium 版」因為 UI 視覺效果較進階(使用了毛玻璃卡片、發光特效與精緻漸層),我們已經為你準備好了一份 GUI 填空模板 。專案內已經為你寫好了所有複雜的「繪圖函式」。
+
請開啟 w4_dashboard_template.py,你會看到程式碼裡面留有 TODO 1 ~ TODO 4 的空白區塊。你的任務就是把核心的 API 與資料處理邏輯填補上去!
+
+
+
+
2
+
TODO 1 & 2:實作 API 資料抓取與萃取
+
在 fetch_weather() 與 fetch_ip() 函式中,我們不僅需要親自組合 API 網址、發送請求,還要精準地從長長一串的 JSON 字典中「挖」出我們需要的欄位:
+
# 【TODO 1-1】組合出正確的 Open-Meteo API 網址,記得要放經緯度與時區參數!
+url = f"https://api.open-meteo.com/v1/forecast?latitude={coords['lat']}&longitude={coords['lon']}¤t_weather=true&timezone=Asia%2FTaipei"
+
+# 【TODO 1-2 & 1-3】發送請求並萃取出 current_weather 字典
+r = requests.get(url, timeout=5)
+if r.status_code == 200:
+ data = r.json()
+ curr = data.get("current_weather", {})
+
+# 【TODO 1-4】把抓到的溫度、風速與時間,組合成顯示用的字串
+"temp": f"{curr.get('temperature', '--')}°C"
+
+
+
+
3
+
TODO 3 & 4:處理 Pygame 系統事件與彩蛋互動
+
在遊戲的 while True: 主迴圈裡,你需要親自過濾 pygame.QUIT 關閉視窗,以及滑鼠點擊 pygame.MOUSEBUTTONDOWN 的訊號。 另外,當玩家點擊到真正的「更新按鈕」時,要負責觸發隨機城市抽籤系統:
+
# 【TODO 3-1】關閉視窗事件
+if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+# 【TODO 3-2】判斷是不是按下了滑鼠左鍵 (button 1)
+if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
+
+# 【TODO 4】玩家點到真正的按鈕了!抽出下一個隨機城市字串
+next_city = random.choice(list(CITIES.keys()))
+weather = fetch_weather(next_city)
+
+
+
+
4
+
TODO 5:善用現成函式繪製毛玻璃卡片
+
模板裡已經為你準備好了超美的 draw_glass_card(surface, x, y, width, height) 繪圖函式,請把它們呼叫出來畫在畫面上作為資料的底板:
+
# 【TODO 5-1】繪製上方天氣面板背景
+draw_glass_card(screen, 40, 110, 480, 240)
+
+# 【TODO 5-2】繪製下方 IP 面板背景
+draw_glass_card(screen, 40, 380, 480, 120)
+
+
+
+
5
+
TODO 6:將數字資料轉化為畫面視覺
+
我們已經獲得了整理好的 weather['temp'] 溫度變數,現在必須用大字體把它渲染 render 成真正的圖像表面 (Surface),並 blit 蓋印到畫面指定的座標上:
+
# 【TODO 6】畫面字體渲染與蓋印
+temp_text = font_huge.render(weather['temp'], True, TEXT_WHITE)
+screen.blit(temp_text, (65, 185))
+
+
+
+
+
🛠️ 執行並測試
+
當你完成這 4 個 TODO 後,執行程式!你會看到一個流暢又具備科技感的動態天氣儀表板。趕快點擊「點我更新」按鈕,體驗一下好玩的「影分身術」並解鎖環遊世界的隨機天氣吧!
+
+
+
+
+
+
挑戰任務:打造個人化儀表板
+
在基礎架構上擴充更多功能,讓這個面板真正成為「你的」數位助理。
+
+
+
基本要求 (Level 1)
+
+ 擴充專屬氣象局 :目前程式中 CITIES 字典只有台北的座標。請前往 Google Maps,隨意對著你最想去的 3 個國外城市按下「滑鼠右鍵」複製經緯度,並將它們的座標填入程式碼的字典中。讓影分身大車拚不再永遠只出現在台灣!
+ 所有的 API 抓取邏輯必須具備 try-except 防禦機制,不可以因為斷線就直接閃退。
+
+
+
+
+
進階要求 (PRO)
+
+ 改變視覺風格 (Vibe Check): 我們為你封裝好的 draw_gradient_bg 函式非常強大。嘗試去改變全域常數區的 NEON_ 裝飾顏色與漸層底色,例如把背景調整成「深紫到螢光粉紅」的 Vaporwave 復古蒸氣波風格,打造具有強烈個人特色的數位儀表板!
+
+ 客製化看板與跑馬燈: 將畫面上方的標題從「我的天氣助理儀表板」換成「[你專屬的名字] 的主控台」,並把最底下的跑馬燈文字也換成專屬於你的座右銘。你還可以嘗試改變變數 scroll_x -= 3 的數值來創造不同速度的跑馬燈!
+
+
+
+
+
🏆 加分彩蛋:結合其他 API
+
想不到還要擴充什麼?現在你已經會用 requests 了!請在畫面右下方的空白處(或者移除掉網路狀態面板),運用這裡的 draw_glass_card 畫出一個全新卡片,並把之前學過的「隨機冷笑話 API」或「隨機狗狗圖片 API」接上去,打造一個屬於你的超級無敵終極大面板!
+
+
+
+
+
+
常見問題 FAQ
+
期中專案最常見的困擾與解法。
+
+
+
+
+ 這就是「義大利麵程式碼」的症狀!請回頭看本週的「函式封裝術」分頁。把所有「畫 XX」的程式碼搬進 def draw_xx(): 函式裡,
+ 讓 while True: 裡面只剩下函式的呼叫名稱。這會大幅提高可讀性。
+
+
+
+
+
+
+ 在 Pygame 裡沒有 CSS 的 Flexbox!你需要自己計算 x 座標。例如畫面寬 900px,留邊距 30px,中間間距 20px,那每張卡片寬度 = (900 - 30 - 30 -
+ 20) / 2 = 410px。
+ 第一張 x = 30,第二張 x = 30 + 410 + 20 = 460。記住:Pygame 世界裡,你就是排版引擎。
+
+
+
+
+
+
+ Pygame 不會自動幫你裁切。最簡單的解法是限制文字長度:text = text[:40] + "...",在 render 之前先截斷。
+ 進階解法是使用 Surface.subsurface() 或 set_clip() 來建立裁切區域,但對於目前的專案來說,截斷文字就夠了。
+
+
+
+
+
+
+ 非常有關係!第一階段你學會了用 requests 從「遠端伺服器」抓資料。第二階段你會學
+ socket,讓兩台電腦直接對傳 資料——
+ 不再需要中間人(遠端伺服器)。你已經掌握的「Pygame 視窗 + 事件監聽 + 畫面渲染 + 函式封裝」全部都會直接沿用,只是資料來源從「雲端 API」變成「隔壁同學的電腦」!
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/projects/2026project_class/w5.html b/projects/2026project_class/w5.html
new file mode 100644
index 0000000000000000000000000000000000000000..4565dbd568b178553f52d9a8e90466529370e913
--- /dev/null
+++ b/projects/2026project_class/w5.html
@@ -0,0 +1,616 @@
+
+
+
+
+
+
+ Week 05 | 哈囉鄰居!Socket 通訊與 IP 基礎
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
世界的地址:一封信是怎麼寄到的?
+
要想讓兩台不同電腦互相連線,我們需要理解 IP、Port 與 Socket 的生活比喻。
+
+
+
+
IP Address (伺服器位址)
+
就像「台北市信義區XX路XX號」。 它精準指出這台機器在目前網路網路上的位置(例如 192.168.1.100 或是 142.250.204.46)。這能幫助訊息送達至特定電腦。
+
+
+
Port (端口號)
+
打開大門後,指定的「收發室/分機」。 一台電腦同時可以開著瀏覽器(Port 80)、連線Minecraft遊戲(Port 25565),你的程式也要指定一個沒被用過的分機,例如 5000。
+
+
+
Socket (通訊端點)
+
IP 加上 Port,組合起來構成一根專屬的「連線水管」。 它是作業系統 (OS) 提供給我們寫程式連接網路的工具箱。
+
+
+
+
+
+
網路通訊三元素的具象化
+
+
+
+
+
+
+ 你的電腦 (發信端)
+
+
+ IP: 192.168.1.100
+ 隨機 Port: 65018
+
+
+
+
+
+ Python Socket
+
+
「我要尋找 142.250.204.46 的 Port 80!」
+
+
+
+
+
+
+
+
+
+ 目標主機 (收信端)
+
+
+
+
+
+ 地址 (IP)
+ 142.250.204.46
+
+
+
+
內部收發室 (Ports)
+
+
+ 遊戲伺服器
+ 25565
+
+
+ 網站伺服器
+ 80
+
+
+ 你寫的程式
+ 5000
+
+
+
+
+
+
+
+
+
🗺️ 舉個例子來總結:
+ 如果你想連線上 Google 首頁,你必須對 142.250.204.46 的 80 號房間敲門。如果敲錯房間 (跑到 Port 5000),是不會有人理你的!
+ 我們今天寫接收端時寫下的 sock.bind(("0.0.0.0", 5000)),就是在宣告:「從現在起,我的電腦裡的 5000 號房由我的 Python 程式佔用來監聽訊息!」
+
+
+
+
+
關鍵術語表 (Glossary)
+
+
+ Localhost (127.0.0.1) :本機地址。就算沒網路,自己傳給自己測試時可以使用的安全預設 IP。
+ Socket :插座 / 通訊端點。開發者就是藉助 Socket API 才能把封包送進網卡。
+ Encode / Decode :因為網路傳輸「水管」只能聽得懂最原始的 bytes (位元組),任何文字在傳送前一律要 Encode 成二進位;接收後再 Decode 打包還原成可視文字。
+
+
+
+
+
+
+
遊戲為什麼要用 UDP?
+
同樣是傳資料,我們來認識兩種最底層的通訊協定差異。
+
+
+
+
+ TCP (傳輸控制協定)
+
+
特性: 「掛號信」。送出 → 確認對方收到且完整 → 再送下一個。絕對安全且保證順序。
+
缺點: 很慢!如果漏信,整個隊伍會停下來卡頓等它重傳。
+
適用: 傳檔案下載、載入網頁、通訊軟體的文字訊息。
+
+
+
+ UDP (使用者資料包協定)
+
+
特性: 「丟傳單」。資料瘋狂連續送出!你沒接到就算了,不保證抵達也不會重傳。
+
優點: 極度快速且即時,不需要花時間反覆確認。
+
適用: 射擊遊戲(FPS)座標同步、即時視訊/語音對話直播。
+
+
+
+
+
+
+
+
視覺化: 封包傳輸比較
+
+
+
+
+
+
注意:TCP 會等待綠色的 ACK (確認回執) 抵達才能送下一包;UDP 則是連珠炮式的發送,就算半路遺失 (紅色) 也不管。
+
+
+
+
互動思考:FPS 遊戲座標傳輸
+ 在傳送玩家 x, y 座標的時候,如果不小心漏失了第 50 幀的資料,我們需要停下來重傳嗎?
+
答案是不用! 因為第 51 幀的最新座標馬上就來了。停下來等只會讓畫面產生超級嚴重的延遲。這就是即時連線遊戲會選擇使用 UDP 的關鍵原因。
+
+
+
+
+
+
用 Python 打造無線電!(UDP)
+
我們將寫下第一手練習:發送包裹 (Send) 與 接收包裹 (Receive)。
+
+
+
+
+
發送端 (Sender)
+
socket()⬇️
sendto("訊息", IP和Port)
+
(直接把信交給郵局丟向對方地址)
+
+
+
+
+
+
接收端 (Receiver)
+
socket()⬇️
bind(本地Port)⬇️
recvfrom()
+
(預先佔用信箱,程式在此阻塞等待 )
+
+
+
+
+
+
+
1
+
建立 UDP Socket 水管
+
首先,我們需要載入內建的 socket 模組,並準備出 UDP 專用的通訊物件:
+
import socket
+
+# AF_INET = IPv4 位址格式, SOCK_DGRAM = UDP 模式 (Datagram)
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+
+
+
2
+
發送端:Encode 並射出 (Client)
+
網路水管只能傳輸「位元組 (bytes)」,所以在發送文字前記得加上 .encode('utf-8')。我們同時要準備好對方的 IP 與指定的 Port (例如 5000)。
+
# 把文字編成機器碼 bytes
+message = "Hello 前方同學!".encode('utf-8')
+
+# 發射!如果對方沒開接收端,用UDP傳送也不會閃退,信件會直接在虛空中消失。
+target_info = ("192.168.XXX.XXX", 5000) # [!請修改為同學的 IP]
+sock.sendto(message, target_info)
+print("訊息已發送出去了!")
+
+
+
+
3
+
接收端:綁定大門並監聽 (Server)
+
若要成為能收信接收資料的主機,需要使用 bind 佔用該電腦指定的 Port。而接收也是阻塞的 (Blocking) 動作,它會耐心等待直到新的包裹抵達。
+
import socket
+
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+# "0.0.0.0" 代表此台電腦的任何網路介面都傾聽這個 5000 敲門聲
+sock.bind(("0.0.0.0", 5000))
+print("成功佔用 Port 5000,等待訊息中...")
+
+# 一次最多接 1024 bytes 的包裹。這裡會「卡住(阻塞)」直到收到東西為止。
+data, addr = sock.recvfrom(1024)
+
+# 收到的資料也是 bytes,解碼顯示給人類看
+text = data.decode('utf-8')
+print(f"收到來自 {addr} 的訊息:", text)
+
+
+
+
💡 自己找 IP 的小技巧:
+
+ Windows :按下 Win 鍵輸入 cmd,於黑色終端機輸入 ipconfig,找到你的 IPv4 位址 複製給同學!
+ Mac :打開終端機輸入 ifconfig 或直接去進入系統「網路」設定查看 IP。
+
+
+
+
+
+
+
挑戰:終端機實境聊天室
+
讓終端機變成真正的即時通訊軟體!每個人都能同時收發訊息。
+
+
+
實作任務:同時收發的雙向通訊
+
因為 input() 與 recvfrom() 都會發生「阻塞 (卡住程式)」,我們必須結合 W3 學過的 threading (多執行緒) 技術,讓每位同學的程式都能「一心二用」:一邊在背景等收信、一邊在前景等你打字發信!
+
+
開發步驟:
+
+ 準備 Socket :建立 UDP Socket 並 bind(("0.0.0.0", 5000)),把家裡大門準備好。
+ 背景收信小精靈 (Thread) :
+ 寫一個專門收信的無窮迴圈函式 def receive_messages():,裡面使用 recvfrom 並把收到的字串 print 出來。
+ 接著,使用 threading.Thread(target=receive_messages, daemon=True).start() 把這個會「持續阻塞」的工作丟到背景執行!(daemon=True 代表主程式結束時執行緒將一併關閉)
+
+ 前景發信主程式 :
+ 主程式區塊寫一個 while True: 迴圈,不斷用 msg = input() 取得使用者輸入,並使用 sendto 傳送給隔壁同學。
+
+
+
+
import socket
+import threading
+
+# 1. 建立 UDP Socket 並綁定大門
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.bind(("0.0.0.0", 5000))
+
+# 2. 定義「背景收信」的工作內容
+def receive_messages():
+ while True:
+ data, addr = sock.recvfrom(1024)
+ print(f"\n[收到來自 {addr[0]}]:{data.decode('utf-8')}")
+
+# 召喚小精靈去背景執行 (與主程式分工合作)
+threading.Thread(target=receive_messages, daemon=True).start()
+
+# 3. 前景發信主程式
+print("聊天室已啟動!請輸入訊息 (直接按 Enter 傳送):")
+target_ip = "192.168.XXX.XXX" # [!請修改為同學的 IP]
+
+while True:
+ msg = input("") # 這裡會卡住等你打字,但背景的 recvfrom() 依然在辛勤運作!
+ sock.sendto(msg.encode('utf-8'), (target_ip, 5000))
+
+
+
+
優化:網路防呆機制大作戰
+
當我們試圖傳送資料,如果遇到「網路根本沒有連線 / IP 格式完全打錯」的情況下,程式會發生 OSError 的閃退。身為一個穩定的軟體,請用 try-except 防禦它!
+
try:
+ sock.sendto(message, (target_ip, port))
+except OSError as e:
+ # 網卡罷工或斷線時的防範
+ print("【系統】網路傳輸失敗,請檢查 Wi-Fi 是否連線,或 IP 格式是否正確!例外細節:", e)
+
+
+
+
+
+
常見問題 FAQ
+
第一次測試別人 IP 時的各種靈異現象排解與原因剖析。
+
+
+
+
+ 如果把 input() 和 recvfrom() 直接寫在同一個 while True: 迴圈裡,程式會永遠卡其中一個!
+ 因為這兩個函式都有「阻塞 (Blocking)」 特性。input() 會卡住等你按 Enter,此時收不到別人的信;而 recvfrom() 會卡住等別人寄信,此時你也沒辦法打字。這就是為什麼我們必須召喚 threading (多執行緒) 將它們分開處理!
+
+
+
+
+
+
+ 這非常常見!最大的兇手通常是 Windows Defender 防火牆 或防毒軟體!
+ 若執行 Python 時曾跳出「是否允許存取網路」的視窗,請務必點選「允許」。如果錯過了,只能進控制台防火牆將 Python 解除封鎖。
+ 另一個可能的原因是學校或咖啡廳的分享器設定了「防護隔離 (Client/AP Isolation)」模式,不允許設備互傳。如果是這個情況,那很可惜只能先改用 `127.0.0.1` 用自己的電腦互連了!
+
+
+
+
+
+
+ UDP 水管只認識 bytes (由字串 encode 而來)。如果你想傳送複雜結構像是 [10, 20, 30] 或 { "x":10, "y":20 },需要先借用 Python 的 json.dumps() 把字典轉成「純字串」,送達對岸後再用 json.loads() 把它復原回字典!這在後面的連線遊戲專案中會是非常重要的技巧!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/2026project_class/w6.html b/projects/2026project_class/w6.html
new file mode 100644
index 0000000000000000000000000000000000000000..56dc87ee5d20d2b97c34474ec31a397bdca2455a
--- /dev/null
+++ b/projects/2026project_class/w6.html
@@ -0,0 +1,354 @@
+
+
+
+
+
+
+ Week 06 | 數據的包裹——JSON 序列化與同步
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
資料的任意門:JSON 序列化
+
我們不能直接把 Python 的 `[100, 200]` 塞進網路水管,必須透過包裝來傳遞。
+
+
+
+
序列化 (Serialization)
+
也就是「打包行李」。它能將記憶體中的複雜資料結構(如 Python 字典或陣列),轉換為可以儲存或是傳輸的通用純字串格式。
+
+
+
反序列化 (Deserialization)
+
也就是「拆開行李並把衣服拿出來」。收到對方的字串後,將它還原為我們可以輕易存取的資料結構格式,例如字典物件。
+
+
+
+
+
📦 JSON 的打包與解包轉換
+
資料要能順利送出去且讓對方能讀懂,我們必須依賴 json 模組來達成:
+
+ 傳送端: 把 Python 型態 {'id': 'player1', 'x': 100} → 透過 json.dumps() → 變成通用字串 → 再編碼為二進位 `Bytes` 送進水管。
+ 接收端: 收到 `Bytes` 後 → 解碼還原純字串 → 透過 json.loads() → 變回 Python 字典。
+
+
+
+
短暫練習實作
+
import json
+
+package = {"id": "player1", "x": 100, "y": 200, "hp": 5}
+
+# 打包成字串
+string_data = json.dumps(package)
+print("轉換後的字串是:", string_data)
+
+# 拆開變成字典
+original_dict = json.loads(string_data)
+print("這是一個字典物件,我們可以直接拿裡面的值:", original_dict['x'])
+
+
+
+
+
處理空白時光:非阻塞模式
+
遊戲每一秒要跑 60 次畫面。如果我們停下來等人寄信,畫面就會徹底卡死!
+
+
+
+
+ 阻塞 (Blocking) 的夢魘
+
+
上週的程式中,data, addr = sock.recvfrom(1024) 是一個阻塞動作 。程式執行到這一行時,如果「水管裡面沒有信」,整個 Python 程式就會永遠停在那邊發呆,導致 Pygame 的畫面再也不會被更新。
+
+
+
+ 非阻塞 (Non-blocking)
+
+
我們可以在初始化網路連線的時候加入一句 sock.setblocking(False),這能告訴網路水管:「不用等!沒信就跟我說沒信,不要讓我傻站著!」
+
+
+
+
如果沒信來,怎麼辦?
+
設定成非阻塞之後,如果沒有收到信卻強行去撿信,系統會引發 BlockingIOError(Windows 下對手未開啟時會拋出 ConnectionResetError)錯誤警告!我們可以用 try-except 來「優雅地忽視它」。
+
+
# 把這行加在準備接收資料的遊戲迴圈中!
+try:
+ data, addr = sock.recvfrom(1024)
+ # 【成功】有收到信,馬上處理封包的內容!
+
+except (BlockingIOError, ConnectionResetError):
+ # 【沒收到信】沒信也無所謂,裝作沒事繼續看電視(畫遊戲畫面)!
+ pass
+
+
+
物理具象化:郵差與信箱
+ 在講解「阻塞/非阻塞」時,你可以想像自己是遊戲主程式:
+ 如果是「阻塞」,你就像一個痴情的郵務員,整天死守在門口等郵差拿信來,這段期間你什麼兼職都不能做。
+ 如果是「非阻塞」,你就在家門口加裝了一個信箱,然後你去打遊戲、看電視。每隔 1/60 秒你才看一眼信箱,有信就拿,沒信就繼續打遊戲!
+
+
+
+
+
+
即時座標同步實作
+
這就是所有連線遊戲的基石。讓雙方的方塊在彼此的畫面上順暢移動。
+
+
+
+
+
1
+
建立非阻塞 Socket
+
一樣初始化 UDP Socket,但別忘了加入我們剛學到的關鍵設定。
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.bind(("0.0.0.0", 5000))
+
+# 大絕招:設為非阻塞
+sock.setblocking(False)
+
+
+
+
2
+
迴圈開頭:接收對手資料
+
每次遊戲迴圈一開始,先用 `try-except` 看看對手有沒有最新消息。如果有的話,用 `json.loads` 抽出它的最新座標:
+
try:
+ data, addr = sock.recvfrom(1024)
+
+ # 將文字檔反變成字典,並取得對手的最新座標
+ enemy_package = json.loads(data.decode())
+ enemy_x = enemy_package['x']
+ enemy_y = enemy_package['y']
+
+except (BlockingIOError, ConnectionResetError):
+ # 沒有信?繼續走!
+ pass
+
+
+
+
3
+
迴圈結尾:送出自己的資料
+
當你在 # 繪圖階段 用 pygame.draw 搞定畫面後,在迴圈的最後,你要把自己這回合最新的移動結果回報給對方:
+
# 把自己的座標變成字典
+my_package = {
+ "x": my_x,
+ "y": my_y
+}
+
+# 打包成 json 字串,並轉換為 bytes
+json_str = json.dumps(my_package)
+
+try:
+ sock.sendto(json_str.encode(), (TARGET_IP, 5000))
+except OSError:
+ pass
+
+
+
+
+
💡 防呆小技巧
+
在使用 `json.loads()` 時可以再加上一個 `except json.JSONDecodeError:` 防治有人亂傳不是 JSON 格式的垃圾訊息導致你的遊戲無故閃退!
+
+
+
+
+
+
挑戰:影子跟隨賽
+
我們要在同一個畫面上,讓藍色是你自己,紅色是對手。A動B動,背景也要動!
+
+
+
+ 實作任務:背景變色同步
+
+
現在你們兩人已經能夠在螢幕上互推各自的方塊了,但這一切還不夠刺激。我們加上以下新機制:
+
+ 當自己按下 空白鍵 時,螢幕的背景會隨機抽取一個奇異的顏色(例如粉紅、亮黃等)。
+ 並且! 對手螢幕的背景也要在同一微秒 內變成一模一樣的顏色。
+
+
+
腦力激盪:
+
你要怎麼把「顏色」的資訊也寫進打包好的 JSON 裡面傳送過去?
+
+ {'x': 100, 'y': 200, 'bg_color': (255, 105, 180)}
+
+
+ 提示:JSON 對於 `tuple` `(255, 105, 180)` 會把它解構成 `list` `[255, 105, 180]` 來傳輸。收到之後一樣可以直接餵給 `screen.fill()` 喔!
+
+
+
+
+
+
+
常見問題 FAQ
+
開始實作非阻塞模式時各種神奇的問題總匯集。
+
+
+
+
+ 這可能是一次發出去的資料包太多擠在網卡的緩衝區,或者單純的網路硬體延遲。在高級商業遊戲開發中,很少會單純靠頻繁傳送座標同步,通常會加入「平滑插值 (Interpolation)」算法來讓動作看起來更滑順。
+
+
+
+
+
+
+ 如果你的 except 用錯例外名稱(如寫成了大範圍的 general Exception 或拼字拼錯),或是底下忘記寫 pass,你的程式還是會崩潰。BlockingIOError 與 ConnectionResetError 是特地設置的非阻塞模式專用信號。
+
+
+
+
+
+
+ JSON 是一種文字資料格式,預設只支援:數字、字串、布林值、陣列、字典。如果你需要傳真正的「圖片檔 (Surface)」,你必須對它進行很高階的 Base64 編碼,但考慮到封包大小限制跟傳輸浪費,通常我們只會在網路傳送「這張卡片要秀那張附圖的 ID」,由接收端自行從本地硬碟載入圖片!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/2026project_class/w7.html b/projects/2026project_class/w7.html
new file mode 100644
index 0000000000000000000000000000000000000000..e1397832536034d761e3423186628f0ea59af21f
--- /dev/null
+++ b/projects/2026project_class/w7.html
@@ -0,0 +1,471 @@
+
+
+
+
+
+
+ Week 07 | 對戰邏輯——誰打中了誰?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
如果同時開槍,誰先被爆頭?
+
不同的網路架構,決定了遊戲中遇到邏輯衝突時「要聽誰的」。
+
+
+
+
+
S
+
C1
+
C2
+
C3
+
+
+
+
+
+ 主從架構 (Client-Server)
+
+
有一個權威「伺服器大人」負責做終極裁判。你和對手都只是「螢幕」與「遙控器」。特性: 防外掛能力極強(因為你改不到伺服器的數值),但建置維護非常困難與昂貴。代表遊戲: CS:GO, 英雄聯盟 (LoL), 傳說對決。
+
+
+
+
+ 對等網路 (P2P)
+
+
沒有裁判,你和我互相溝通。遇到爭議時,**信任彼此的判斷**。特性: 容易實作,但如果其中一人偷偷修改數值(外掛),對方也只能默默接受。代表遊戲: 很多早期的區網遊戲,以及我們本節課所採用的開發架構!
+
+
+
+
+
對等網路的「君子協定」
+ 在接下來 P2P 的實作中,我們信奉一條鐵律:「只有我自己能判斷我自己的身體有沒有被害受傷。」
+ 如果你覺得被打中了,你的角色就扣血,並且
發送封包親自通知對方 你少了一滴血。不可以用「我覺得我打中對方了」去強制扣對方的血。
+
+
+
+
+
+
發射!不只是座標
+
我們需要傳遞的不只有 X 軸和 Y 軸,我們要開始透過 JSON 中的「屬性標籤」讓包裹多元化。
+
+
+
📦 兩種截然不同的訊息類型
+
以往我們每秒鐘瘋狂傳送 60 次的座標,這稱為「連續狀態」;但「按下空白鍵發射」是一瞬間的事情,它屬於「事件觸發」。
+
+ Type 1 - 連續廣播座標:
+ {'type': 'pos', 'x': 100, 'y': 200}
+ Type 2 - 單發事件觸發 (例如射擊):
+ {'type': 'shoot', 'x': 100, 'y': 200, 'id': 1}
+
+
+
+
+
+
Type 1: 連續廣播座標 (pos)
+
+
+
{'type': 'pos', 'x': 105, 'y': 200}
+
{'type': 'pos', 'x': 110, 'y': 200}
+
{'type': 'pos', 'x': 115, 'y': 200}
+
{'type': 'pos', 'x': 120, 'y': 200}
+
{'type': 'pos', 'x': 125, 'y': 200}
+
+
一秒鐘狂發 60 次,覆蓋最新狀態
+
+
+
Type 2: 單發事件觸發 (shoot)
+
+
+
{'type': 'shoot', 'dir': 'R'}
+
只有按下空白鍵的「瞬間」才發送一次
+
+
+
+
接收端要怎麼處理?
+
既然所有水管都混在一起傳,在收到對手的信件時,第一件要做的事情就是利用 if-elif 來作任務的分流!
+
+
# 當收到對方資料時
+data, addr = sock.recvfrom(1024)
+package = json.loads(data.decode())
+
+# 判斷這封信是屬於什麼類型?
+if package['type'] == 'pos':
+ # 這是座標信:就蓋過對方的舊照片座標
+ enemy_x = package['x']
+ enemy_y = package['y']
+
+elif package['type'] == 'shoot':
+ # 這是射擊事件信:馬上在畫面上憑空「生成」一顆對方開火的子彈
+ enemy_bullet = {'x': package['x'], 'y': package['y'], 'dir': package['dir'], 'owner': 'enemy', 'id': package['id']}
+ bullets.append(enemy_bullet)
+ print("警告!對方開槍了!")
+
+
+
+
+
+
核心技術:隱形的碰撞框
+
使用 pygame.Rect() 進行碰撞檢測,完成最核心的遊戲互動邏輯。
+
+
+
+
Hitbox 碰撞箱
+
我們在網路上傳遞的都是虛無縹緲的數學座標。若要判斷「我有沒有撞到你」,我們必須將這些數學座標化為 pygame.Rect 矩形空間,接著使用 colliderect 計算重疊!
+
+
+
screen (800x600)
+
+
+
HIT!
+
+
+ bullet_rect .colliderect(my_rect ) == True
+
+
+
+
+
+
+
+
+
1
+
建立實體碰撞框
+
在遊戲迴圈中,繪圖的同時建立自己角色與每一顆子彈的框框
+
# 把自己的座標變成一個 40x40 的物理體積
+my_rect = pygame.Rect(my_x, my_y, 40, 40)
+
+# 把場上飛來飛去的某顆子彈變成 10x10 的物理體積
+bullet_rect = pygame.Rect(bullet['x'], bullet['y'], 10, 10)
+
+
+
+
2
+
只有我能判斷我!
+
我們剛剛約定的 P2P 君子協定:當子彈主人是對方 enemy,且打中了我 my_rect,我才進行判定。
+
if bullet['owner'] == 'enemy' and my_rect.colliderect(bullet_rect):
+ # 第一步:記住這顆子彈的編號,並從畫面上刪除
+ hit_bullet_id = bullet['id']
+ bullets.remove(bullet)
+
+ # 第二步:自己的真氣 (HP) 受損
+ my_hp -= 1
+
+ # 第三步:利用網路廣播此消息,帶上 bullet_id!
+ # (對方收到後,除了更新血量,也能從他的畫面移除這顆子彈)
+ hit_msg = json.dumps({'type': 'hit', 'hp': my_hp, 'bullet_id': hit_bullet_id})
+ sock.sendto(hit_msg.encode(), target_address)
+
+
+
+
3
+
子彈同步:用編號追蹤每一顆子彈
+
你可能會發現:我射出的子彈打中對手後,對手的畫面上子彈消失了,但我的畫面上那顆子彈還在飛 !這是因為對手只移除了他自己畫面上 owner: 'enemy' 的子彈,而我這邊 owner: 'me' 的子彈完全不知道命中了。
+
解法:給每顆子彈一個 唯一編號 (ID) 。當對方被擊中時,回傳的 hit 封包帶上 bullet_id,射擊方就能根據編號找到並移除那顆子彈!
+
# 射擊時:給子彈一個獨特的編號
+bullet_id_counter += 1
+new_bullet = {'x': my_x, 'y': my_y, 'dir': 10,
+ 'owner': 'me', 'id': bullet_id_counter}
+bullets.append(new_bullet)
+
+# 發送封包時也帶上 id
+shoot_pkg = {'type': 'shoot', 'x': my_x, 'y': my_y,
+ 'dir': 10, 'id': bullet_id_counter}
+sock.sendto(json.dumps(shoot_pkg).encode(), target_address)
+
+# ----
+# 收到對方的 hit 封包時:根據 bullet_id 移除我方子彈
+if package['type'] == 'hit':
+ bid = package.get('bullet_id')
+ if bid is not None:
+ bullets[:] = [b for b in bullets
+ if not (b['owner'] == 'me' and b['id'] == bid)]
+
+
+
+
+
+
💡 Json 傳數字還是字串?
+
json 是非常聰明的資料包裹。如果傳的是沒有加雙引號的數字,它在解包後 data['hp'] - 1 是可以直接加減計算的,不用花時間用 int() 去轉型!
+
+
+
+
+
+
挑戰:網路復仇戰
+
當戰鬥機制完成,剩下就是遊戲機制的補完:血量歸零的處理與計分系統。
+
+
+
+ 實作任務:重生與回合制
+
+
+ 重生成為新獵人 :我們現在被子彈打中雖然會扣血,但扣到零程式可能就發瘋了或跑到負數去。請加入邏輯判斷:當 my_hp <= 0 時,角色立刻 瞬間移動到畫面的正中間 出生點,並把 my_hp 補滿回 10。
+ 向世界宣告我的復活 :記得要在同時發送一個 {'type': 'hit', 'hp': 10} 告訴對手「我又是一條好漢了!你的畫面趕緊幫我把血條畫滿!」
+
+
+
+
+
進階加分要求 (PRO)
+
在遊戲畫面最上方,設計一個計分板 my_score 與 enemy_score。如果對方把你的血量打歸零了,你應該要:
+
+ 自己默默承認失敗,幫 enemy_score += 1。
+ 利用 `json` 偷偷塞一個死亡證明給對方:{'type': 'im_dead'}。對手收到這封信的瞬間,他那邊的城市就可以直接讓 my_score += 1 普天同慶獲勝了!
+
+
+
+
+
+
+
常見問題 FAQ
+
這是整學期最痛的一節課,各種鬼影般不見的子彈與幽靈殺手都會在此浮現。
+
+
+
+
+ 你的子彈可能在每一幀的 while 迴圈中被重新覆蓋定義,或是儲存所有子彈的 bullet_list 陣列,在收到新的對手網包時不小心被清空了 = []。請確保子彈清單與網路接受邏輯不會打架!
+
+
+
+
+
+
+ 這代表你收到了「開槍事件」包裹,但你在畫面上生成他的子彈時,忘記綁定 `package['x']` 與 `package['y']` 作為起始點,而是讓它預設從 `(0, 0)` 飛出來了。
+
+
+
+
+
+
+ 如果兩個人都去判定畫面上的兩個方塊有沒有卡在一起,因為傳輸永遠有「延遲 0.1 秒」的落差,當 A 的螢幕顯示兩人撞在一起時,B 的螢幕可能才看到 A 才剛起步。如果兩個人都做判定,雙方的扣血基準點會錯亂,遊戲平衡會崩潰。
+
+
+
+
+
+
+ 因為 P2P 架構中,兩個人的 bullets 陣列是各自獨立的。對手被打中後只移除了他自己畫面上的那顆子彈(owner: 'enemy'),但你的畫面上那顆 owner: 'me' 的子彈還活得好好的。
+ 解法是使用子彈編號 (Bullet ID) 。每顆子彈都帶一個獨特的 id,被擊中時的 hit 封包帶上 bullet_id,這樣射擊方收到 hit 後就能根據編號找到那顆子彈並移除它。這就是分散式系統中的「狀態同步」概念!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/2026project_class/w8.html b/projects/2026project_class/w8.html
new file mode 100644
index 0000000000000000000000000000000000000000..d2e9022e6a40e9f87bada093f208f03fe73e526f
--- /dev/null
+++ b/projects/2026project_class/w8.html
@@ -0,0 +1,381 @@
+
+
+
+
+
+
+ Week 08 | 專案實作(二)——班級網路競技場
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Week 08 / 專案:Scrum 站會
+
+
+
+
+
啟動敏捷開發:Scrum 每日站會
+
真正的軟體工程師每天早上的第一件事,就是花 10 分鐘同步彼此的進度與障礙。
+
+
+graph LR
+ A[需求 Backlog] --> B(Sprint 規劃)
+ B --> C{每日站會}
+ C -- "24小時" --> C
+ C --> D[打磨功能 MVP]
+ D --> E(展示 Review)
+ style C fill:#3b82f6,stroke:#1d4ed8,stroke-width:2px,color:#fff
+ style D fill:#10b981,stroke:#047857,stroke-width:2px,color:#fff
+
+
+
+
👥 兩人小組對話時間
+
請和你的神隊友面對面站起來,快速確認以下三件事:
+
+ 昨天完成了什麼? 我們的連線封包現在能同時送出 `pos` 和 `shoot` 了嗎?
+ 今天預計做什麼? 誰負責處理血量歸零的畫面?誰負責處理碰撞 `colliderect`?
+ 目前遇到什麼困難? 是不是常常閃退(ConnectionResetError),或是人物瞬移(Buffer卡住)?是不是子彈打不中?
+
+
+
+
+
+
最小可行性產品 (MVP)
+
你們的首要任務不是把畫面弄得超級炫砲,而是達到最低要求:按下空白鍵,子彈能飛過去扣對面一滴血 。在確認這件事能 100% 在網路上觸發前,不要開始加音效或換背景。
+
+
+
+
+
+
+
遊戲打磨與除錯衝刺
+
將初具雛形的機制,包裝成有聲有色的遊戲體驗。
+
+
+
+
+ 1. 背景與素材
+
+
利用 pygame.image.load() 取代原本的 screen.fill() 顏色,將戰場改為外太空、廢墟或魔法學院。
+
記得將圖片配合螢幕縮放:pygame.transform.scale(bg, (800, 600))
+
+
+
+ 2. 音效打擊感
+
+
為遊戲加入開槍的「砰」與被擊中的「啊!」聲。
+
# 初始化音效系統後載入
+pygame.mixer.init()
+try:
+ shoot_sound = pygame.mixer.Sound("shoot.wav")
+except FileNotFoundError:
+ shoot_sound = None
+
+# 當按下發射鍵或收到對方射擊封包時:
+if shoot_sound:
+ shoot_sound.play()
+
+
+
+
+
3. 結算畫面與死亡
+
當血量歸零時,我們不再只是跳回中央,我們可以讓遊戲進入一個「YOU DIED」的畫面暫停階段,按 R 鍵重新開始回合。
+
+ 注意邏輯順序: 如果你的狀態變成 game_over = True,請用 if 把玩家的鍵盤移動以及子彈繪製功能全部鎖起來,只留下結算文字的繪出!
+
+
+
+
+
+
+
班級競技互聯大亂鬥
+
開發完畢後,最重要的步驟就是 QA (Quality Assurance) 品管壓力測試!
+
+
+
自動角色分配機制
+
過去我們需要維護兩份不同 port 的程式碼才能對戰,現在只要 同一份程式碼 ,程式會自動判斷誰是 P1、誰是 P2!
+
+graph LR
+ A["啟動遊戲"] --> B{"Port 5000 可用?"}
+ B -- "✅ 可用" --> C["綁定 5000 你是 P1 (左側)"]
+ B -- "❌ 已被佔用" --> D["綁定 5001 你是 P2 (右側)"]
+ C --> E["發送封包至 Port 5001"]
+ D --> F["發送封包至 Port 5000"]
+ style C fill:#3b82f6,stroke:#1d4ed8,stroke-width:2px,color:#fff
+ style D fill:#10b981,stroke:#047857,stroke-width:2px,color:#fff
+
+
# 嘗試綁定 port 5000,若被佔用則綁定 5001
+try:
+ sock.bind(("0.0.0.0", 5000))
+ MY_PORT = 5000
+ TARGET_PORT = 5001
+ player_role = "P1" # 先啟動 = P1 (左側)
+except OSError:
+ sock.bind(("0.0.0.0", 5001))
+ MY_PORT = 5001
+ TARGET_PORT = 5000
+ player_role = "P2" # 後啟動 = P2 (右側)
+
+# 根據角色設定位置與子彈方向
+if player_role == "P1":
+ my_pos = [100, 300] # 左邊
+ my_bullet_dir = 10 # 子彈朝右飛
+else:
+ my_pos = [600, 300] # 右邊
+ my_bullet_dir = -10 # 子彈朝左飛
+
+
+
+
跨區連線挑戰指南
+
+ 同一台電腦測試: 直接開兩個終端機視窗,各執行一次同一份 w8_arena_complete.py,先啟動的自動成為 P1(左側),後啟動的自動成為 P2(右側)。
+ 交換 IP: 去尋找班上尚未對戰過的小組,互換彼此的 IPv4 網址,將 TARGET_IP 改為對方的 IP。
+ 檢查 API 格式: 如果你們組別約定的子彈坐標叫做 bx,但對手叫做 bullet_x,遊戲會在連線瞬間引發 KeyError 當機。請互相討論並統一封包名稱!
+ 尋找漏洞: 在對戰過程中,測試是否有「子彈憑空消失」、「子彈穿過身體沒扣血」或「某一方走到畫面外不見了」的問題。這就是 QA 測試員的責任!
+
+
+
+
+
分散式系統的浪漫與無奈
+ 如果你的對手電腦很卡,或是網路訊號不佳,你會發現你的方塊非常順暢,但對方的角色一直在「瞬移」或「抖動」。
+
+
+
+
+
對手畫面 (封包遺失與延遲)
+
Enemy
+
+
+
+ 這就是分散式系統的真實現象!連線對戰絕對不可能達到單機遊戲的完美順暢度,身為工程師,我們只能透過預測算法與插值技術去「掩飾」這份延遲。不要沮喪,這正是網路遊戲最困難但也最迷人的技術挑戰!
+
+
+
+
+
+
為下半學期做準備:AI 環境建置
+
遊戲專案告一段落,接下來我們將進入全新的領域:影像辨識與人工智慧。
+
+
+
Step 1. 安裝三項神器
+
請打開你的終端機 (命令提示字元 / Terminal),在擁有網路連線的情況下,複製並執行以下指令,將 AI 模組下載到你的電腦中:
+
+ pip install opencv-python mediapipe numpy
+
+
這可能需要耗費 3~5 分鐘,請耐心等待直到全數出現 `Successfully installed`。
+
+
+
+
Step 2. 測試攝影機硬體
+
我們必須確認你的電腦攝影機沒有壞掉,且 Python 有權限存取它。請點擊側邊欄【📥 範例檔案與工具】中的 w8_cv2_test.py 並執行它。
+
+ 成功現象: 電腦的攝影機指示燈會亮起,然後出現一個像鏡子一樣可以直接映照出你的小視窗。請按鍵盤 `q` 鍵關閉。
+ 失敗現象: 視窗全黑、或者終端機顯示「無法取得鏡頭畫面」。這代表你的鏡頭被物理按鍵關閉、或是作業系統隱私設定阻擋了存取權!請立刻舉手讓老師協助!
+
+
+
+
+
+
+
常見問題 FAQ
+
關於專案整合與 OpenCV 鏡頭的疑難雜症。
+
+
+
+
+ 這是一個非常經典的網路堆積 (Buffer) 延遲!如果你的網路接收端只有寫一次 sock.recvfrom(),代表遊戲每一格畫面(1/60 秒)只會去信箱拿「一封信」。當兩台電腦同時每秒狂傳 60 個座標封包,再加上開槍的封包時,信箱馬上就會爆滿。
+ 解法: 你必須把接收封包的程式碼用一個 while True: 迴圈包起來,直到觸發 BlockingIOError 或 ConnectionResetError 才離開。這代表你在畫出下一格畫面之前,會「把信箱裡所有的信件一次讀完」,保證遊戲狀態永遠是最新的!
+
+
+
+
+
+
+ 雖然你們都用 Python 寫,但你們自己定義的 JSON 格式一定不一樣!如果 A 組把子彈送到 x 軸的標籤叫 package['bullet_x'],而 B 組叫 package['bx']。當 B 組送資料給 A 組時,A 組的程式找不到 bullet_x 這個字,就會直接觸發 KeyError 而閃退。這就是真實世界「系統要串接 API 必須有著極度嚴格的規格書」的原因。
+
+
+
+
+
+
+ 絕對不行!
+ 如果你真的把一張 5MB 的圖片轉成文字後每 1/60 秒傳送給對方,你們兩個的網路會瞬間癱瘓卡死。所有的遊戲圖片 (Assets) 都必須在一開始就各自存放在兩台電腦內。網路封包只傳遞:「我在 X=100 的地方發射了編號 2 的子彈」。由收到訊息的那方,自己去自己的硬碟畫出編號 2 的子彈圖片!
+
+
+
+
+
+
+ 程式啟動時會先嘗試綁定 port 5000。在同一台電腦上(或同一個網路中),一個 port 只能被一個程式佔用。所以先啟動的人 會成功拿到 5000,成為 P1(左側);後啟動的人 發現 5000 已經被佔用,就自動退而求其次綁定 5001,成為 P2(右側)。這就像排隊——先來的人站第一個位置!程式會根據角色自動設定起始座標和子彈方向,不需要手動修改任何程式碼。
+
+
+
+
+
+
+ OpenCV 顯示視窗必須依賴 cv2.waitKey(1) 來持續更新視窗介面。如果你去點擊了視窗的「X」關閉按鈕,或是你不小心讓它進入無窮迴圈,畫面就會死當。要離開請務必用鍵盤按 q 鍵讓它正常釋放 cap.release()。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/2026project_class/w9.html b/projects/2026project_class/w9.html
new file mode 100644
index 0000000000000000000000000000000000000000..f7b1841d682c11c917b8de97040e04694f4afbbd
--- /dev/null
+++ b/projects/2026project_class/w9.html
@@ -0,0 +1,692 @@
+
+
+
+
+
+
+ Week 09 | 數位眼睛——MediaPipe 視覺辨識入門
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
從矩陣到眼睛:電腦怎麼看見?
+
影像的本質不是圖片,而是一座三維數字陣列。每一個像素都是三個 0~255 的數字堆疊。
+
+
+
一秒 30 張圖的魔法
+
你看到的影片,其實就是電腦每秒鐘抓取 30 張照片 (Frame),然後像翻書一樣連續播放。每一張照片都是一個巨大的高度 × 寬度 × 3 (RGB) 的數字立方體。
+
+
+
+
↑ 每一格是一個像素的 RGB 值,組合在一起就成為了我們眼中的「圖片」
+
+
+
+
強大的預錄模型 (Pre-trained Model)
+
Google 餵給電腦數十萬張不同膚色、角度的手的圖片,讓機器自動找出規律。這些訓練的成果被打包在一個叫做 MediaPipe 的套件裡。
+
它能在 0.01 秒 之內,從一張影像中精確找出手掌的 21 個骨節點 (Landmarks 0~20) 。就像是幫你的手畫了一張即時的 X 光片!
+
+
+
📷 攝影機影像來源
+
→
+
🔧 OpenCV抓取影格
+
→
+
🧠 MediaPipeAI 辨識手部
+
→
+
🎮 Pygame繪製互動
+
+
+
+
+
+
+
OpenCV 第一步:抓取影像
+
使用 cv2 模組讓你的 Python 程式能直接讀取攝影機的即時畫面。
+
+
+
1
+
開啟攝影機
+
用 cv2.VideoCapture(0) 抓取你電腦的第一台鏡頭(索引 0)。如果沒有鏡頭,你也可以用預錄好的影片檔來取代!
+
import cv2
+
+# 如果沒有攝影機,可改用影片檔:
+# cap = cv2.VideoCapture("test_hand.mp4")
+cap = cv2.VideoCapture(0)
+
+
+
+
2
+
讀取並顯示影格
+
用 while True 不斷讀取每一幀畫面,並使用 cv2.flip() 做鏡面翻轉,才不會左右相反!
+
while True:
+ success, img = cap.read()
+ if not success:
+ break # 影片讀完了,或攝影機壞了
+
+ img = cv2.flip(img, 1) # 鏡面翻轉,直覺才對
+ cv2.imshow("Hand Tracking", img)
+
+ if cv2.waitKey(1) & 0xFF == ord('q'):
+ break
+
+cap.release()
+cv2.destroyAllWindows()
+
+
+
+
備用方案:沒有攝影機怎麼辦?
+ 跑不動的、攝影機出奧步,只要把
cv2.VideoCapture(0) 的
0 換成影片檔名,例如
"backup_hand_video.mp4",學習是不會因為硬體而停下腳步的!
+ 這不僅安撫你的心情,也是軟體工程中
「解耦輸入源測試」 的真正意涵。
+
+
+
+
+
+
MediaPipe:0.01 秒捕捉手骨架
+
讓 Google 的 AI 幫你在每一幀畫面中找出 21 個手部關鍵點座標。
+
+
+
+
+ MediaPipe Hands v0.8
+ Landmarks: 21 / 21
+ Confidence: 0.96
+
+
FPS: 30
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >>> lm8.x = 0.45 , lm8.y = 0.22
+ → 食指座標找到了:(288 , 105 )
+
+
+
+
+
1
+
引入魔法模組
+
import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+
+# 載入模型與設定選項
+base_options = python.BaseOptions(model_asset_path='hand_landmarker.task')
+options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+detector = vision.HandLandmarker.create_from_options(options)
+
+
+
+
2
+
BGR 轉 RGB 並打包成 Image 丟給 AI
+
OpenCV 讀出來的影像是 BGR 色彩順序,但 MediaPipe 要的是 RGB ,並且要包裝成專屬的 Image 格式!
+
# 在 while True 迴圈中:
+img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+
+# 開始偵測
+results = detector.detect(mp_image)
+
+
+
+
3
+
提取食指座標 (Landmark 8)
+
MediaPipe 回傳的座標是 0~1 的比例值 ,不是像素!你必須手動乘上影像的寬和高才能得到真正的螢幕座標。
+
if results.hand_landmarks:
+ for hand_lms in results.hand_landmarks:
+ lm8 = hand_lms[8]
+
+ # 痛點:MediaPipe 回傳的只是比例 (0~1),不是像素!
+ h, w, c = img.shape
+ cx, cy = int(lm8.x * w), int(lm8.y * h)
+
+ print(f"食指座標找到了:({cx}, {cy})")
+
+
+
+
魔幻時刻
+ 當你在終端機瘋狂看到
cx, cy 座標不斷變化時,親手用這些數字去控制 Pygame 的物件移動——這就是體驗 AI 威力的第一個
「魔幻時刻」 !
+
+
+
+
+
+
挑戰任務:空中雷射筆
+
把 AI 裝進遊戲引擎!讓手指在鏡頭前移動時,螢幕上的紅色圓點像雷射筆一樣黏在你的食指上。
+
+
+
+ 架構挑戰:兩個引擎合而為一
+
+
我們要跳脫純粹的黑底 OpenCV 視窗。把 cx, cy 變成 Pygame 的移動變數 x, y!
+
核心思路:讓 Pygame 負責 screen.fill() 和畫圈圈,而 OpenCV 的 cap.read() 被放進 Pygame 的主迴圈裡。
+
+
+graph TD
+ A["Pygame 主迴圈"] --> B["cap.read() 讀影格"]
+ B --> C["MediaPipe 分析手部"]
+ C --> D["取得 cx, cy"]
+ D --> E["screen.fill() 清空畫布"]
+ E --> F["pygame.draw.circle(cx, cy)"]
+ F --> G["pygame.display.update()"]
+ G --> A
+ style A fill:#8b5cf6,stroke:#6d28d9,stroke-width:2px,color:#fff
+ style C fill:#10b981,stroke:#047857,stroke-width:2px,color:#fff
+ style F fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff
+
+
+
+
+
完整程式骨架
+
import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+
+# === 初始化 ===
+pygame.init()
+screen = pygame.display.set_mode((800, 600))
+clock = pygame.time.Clock()
+
+cap = cv2.VideoCapture(0)
+base_options = python.BaseOptions(model_asset_path='hand_landmarker.task')
+options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+detector = vision.HandLandmarker.create_from_options(options)
+
+# 雷射筆的位置
+laser_x, laser_y = 400, 300
+
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # 1. 讀取影格
+ success, img = cap.read()
+ if success:
+ img = cv2.flip(img, 1)
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ results = hands.process(img_rgb)
+
+ # 2. 取得食指座標
+ if results.multi_hand_landmarks:
+ for hand_lms in results.multi_hand_landmarks:
+ lm8 = hand_lms.landmark[8]
+ h, w, c = img.shape
+ # 將比例座標映射至 Pygame 視窗大小
+ laser_x = int(lm8.x * 800)
+ laser_y = int(lm8.y * 600)
+
+ # 3. Pygame 繪製
+ screen.fill((15, 15, 35))
+ pygame.draw.circle(screen, (255, 50, 50), (laser_x, laser_y), 12)
+ pygame.display.update()
+ clock.tick(30)
+
+cap.release()
+pygame.quit()
+
+
+
+
進階挑戰
+ 如果畫面跑得太慢,試試看只每
3 幀 做一次 MediaPipe 分析!在其它幀保持上一次的座標就好。這叫做
跳幀處理 (Frame Skip) 。
+
+
+
+
+
+
常見問題 FAQ
+
關於 OpenCV 與 MediaPipe 的疑難雜症。
+
+
+
+
+ 這代表你很有可能是忘了做 BGR 轉 RGB 的動作,導致 MediaPipe 這個「外國人」看嘸你傳來的心情不好的藍綠色手!請確認你有使用 cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。
+
+
+
+
+
+
+ 你的電腦算力不足。請幫忙加上 cv2.resize() 把輸入影像等比例變小,比如 640×480 的一半,能大幅減輕 CPU 的地獄高溫負擔。
+ img = cv2.resize(img, (320, 240))
+
+
+
+
+
+
+ MediaPipe 為了讓你的程式能適應不同解析度的攝影機,它回傳的是 0~1 之間的比例值 。不管你的影像是 640×480 還是 1920×1080,比例 0.5 永遠代表「影像的正中間」。
+ 你只需要乘上你目標畫布的寬高就可以了:
+ cx = int(lm8.x * 視窗寬度)
+
+
+
+
+
+
+ 因為你忘了做 cv2.flip(img, 1) 鏡面翻轉!沒有翻轉的攝影機畫面就像在照鏡子——左右是顛倒的。加上 flip 後,你舉右手畫面就是右邊,直覺才對。
+
+
+
+
+
關鍵術語表
+
+ Computer Vision (CV) :使電腦能從數位影像中獲取高層級理解的科學。
+ Landmarks (地標/關鍵點) :在手部辨識中,標記特定特徵(如指尖、手腕)的數字座標。
+ BGR vs RGB :OpenCV 預設色彩通道順序是 BGR,而非一般的 RGB,丟給 AI 前一定要做色彩空間轉換。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\346\215\217\345\220\210\346\216\247\345\210\266/hand_landmarker.task" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\346\215\217\345\220\210\346\216\247\345\210\266/hand_landmarker.task"
new file mode 100644
index 0000000000000000000000000000000000000000..5ecab741879892d97c2f90bbf03bf55d7213db7c
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\346\215\217\345\220\210\346\216\247\345\210\266/hand_landmarker.task"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1
+size 7819105
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\346\215\217\345\220\210\346\216\247\345\210\266/main.py" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\346\215\217\345\220\210\346\216\247\345\210\266/main.py"
new file mode 100644
index 0000000000000000000000000000000000000000..fd47093d988373db13306a41baa3a206c21cbc45
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\346\215\217\345\220\210\346\216\247\345\210\266/main.py"
@@ -0,0 +1,179 @@
+"""
+Week 10 - 魔法手勢:Pinch 偵測與手勢按鈕 (Complete 完整版)
+=============================================================
+本週目標:
+ 1. 用勾股定理計算大拇指 (4) 與食指 (8) 的距離
+ 2. 實作 Pinch (掐合) 偵測:距離 < Threshold 就是捏起來了
+ 3. 用手指碰觸 Pygame 畫面上的按鈕 + 掐合 = 魔法點擊
+ 4. 使用邊緣觸發 (Edge Detection) 防止重複觸發
+
+操作方式:
+ - 食指移動到按鈕上方 + 大拇指與食指捏合 = 點擊按鈕
+ - 顏色會隨點擊切換
+
+注意:
+ - 確保 hand_landmarker.task 檔案和這個腳本放在同一個資料夾。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import math
+import sys
+import os
+
+# ============================================
+# 1. 初始化
+# ============================================
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 10 - 魔法手勢按鈕 🖐️✨")
+clock = pygame.time.Clock()
+
+# 攝影機
+cap = cv2.VideoCapture(0)
+if not cap.isOpened():
+ print("❌ 無法開啟攝影機,請改用影片檔案")
+ pygame.quit()
+ sys.exit()
+
+# MediaPipe (Tasks API)
+try:
+ base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'hand_landmarker.task'))
+ options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+ detector = vision.HandLandmarker.create_from_options(options)
+except Exception as e:
+ print(f"❌ 錯誤:無法載入 MediaPipe 模型:{e}")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 2. 遊戲變數
+# ============================================
+# 手指座標
+finger_x, finger_y = 400, 300
+thumb_x, thumb_y = 400, 300
+
+# Pinch 變數
+is_pinching = False
+was_pinching = False # 邊緣觸發:紀錄上一幀的狀態
+PINCH_THRESHOLD = 40 # 距離小於此值就算「捏起來」
+dist = 999 # 兩指距離
+
+# 按鈕設定
+button_rect = pygame.Rect(300, 250, 200, 100)
+button_colors = [(100, 100, 220), (220, 100, 100), (100, 220, 100), (220, 180, 50)]
+current_color_index = 0
+
+# 點擊計數器
+click_count = 0
+hand_detected = False
+
+# ============================================
+# 3. 主迴圈
+# ============================================
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機 ---
+ success, img = cap.read()
+ if success:
+ img = cv2.flip(img, 1)
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ if results.hand_landmarks:
+ hand_detected = True
+ for hand_lms in results.hand_landmarks:
+ # 取得食指尖 (8) 座標
+ lm8 = hand_lms[8]
+ finger_x = int(lm8.x * WIDTH)
+ finger_y = int(lm8.y * HEIGHT)
+
+ # 取得大拇指尖 (4) 座標
+ lm4 = hand_lms[4]
+ thumb_x = int(lm4.x * WIDTH)
+ thumb_y = int(lm4.y * HEIGHT)
+
+ # 用 math.hypot 計算兩指之間的距離
+ dist = math.hypot(finger_x - thumb_x, finger_y - thumb_y)
+
+ # 判斷是否掐合
+ is_pinching = dist < PINCH_THRESHOLD
+ else:
+ hand_detected = False
+ is_pinching = False
+
+ # --- 邊緣觸發判定 ---
+ # 只有「前一幀沒捏 + 這一幀捏了」才算一次點擊
+ if is_pinching and not was_pinching:
+ # 檢查手指是否在按鈕範圍內
+ finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10)
+ if finger_rect.colliderect(button_rect):
+ click_count += 1
+ current_color_index = (current_color_index + 1) % len(button_colors)
+ print(f"✨ 魔法點擊!第 {click_count} 次")
+
+ # 更新上一幀的狀態
+ was_pinching = is_pinching
+
+ # --- 繪製畫面 ---
+ screen.fill((20, 20, 35))
+
+ # 畫按鈕
+ btn_color = button_colors[current_color_index]
+ pygame.draw.rect(screen, btn_color, button_rect, border_radius=16)
+
+ font = pygame.font.SysFont(None, 32)
+ btn_text = font.render(f"Clicks: {click_count}", True, (255, 255, 255))
+ screen.blit(btn_text, (button_rect.centerx - btn_text.get_width() // 2,
+ button_rect.centery - btn_text.get_height() // 2))
+
+ # 畫提示文字在按鈕下方
+ hint_font = pygame.font.SysFont(None, 22)
+ hint = hint_font.render("Pinch on button to click!", True, (120, 120, 150))
+ screen.blit(hint, (button_rect.centerx - hint.get_width() // 2, button_rect.bottom + 12))
+
+ if hand_detected:
+ # 畫大拇指位置(紫色小點)
+ pygame.draw.circle(screen, (139, 92, 246), (thumb_x, thumb_y), 8)
+
+ # 畫兩指之間的連線
+ line_color = (50, 255, 50) if is_pinching else (255, 50, 50)
+ pygame.draw.line(screen, line_color, (thumb_x, thumb_y), (finger_x, finger_y), 2)
+
+ # 畫手指游標(根據 Pinch 狀態改變顏色)
+ if is_pinching:
+ cursor_color = (50, 255, 50) # 捏起來 → 綠色
+ else:
+ cursor_color = (255, 50, 50) # 放開 → 紅色
+ pygame.draw.circle(screen, cursor_color, (finger_x, finger_y), 12)
+
+ # 狀態文字
+ small_font = pygame.font.SysFont(None, 24)
+ coord_text = small_font.render(f"Index(8): ({finger_x}, {finger_y}) Thumb(4): ({thumb_x}, {thumb_y})", True, (150, 150, 150))
+ screen.blit(coord_text, (10, 10))
+
+ dist_text = small_font.render(f"Distance: {dist:.0f} px | Threshold: {PINCH_THRESHOLD}", True, (150, 150, 200))
+ screen.blit(dist_text, (10, 35))
+
+ pinch_text = small_font.render(f"Pinching: {'YES ✅' if is_pinching else 'NO ❌'}", True,
+ (50, 255, 50) if is_pinching else (255, 100, 100))
+ screen.blit(pinch_text, (10, 60))
+ else:
+ no_hand = font.render("Show your hand to the camera!", True, (255, 200, 0))
+ screen.blit(no_hand, (WIDTH // 2 - no_hand.get_width() // 2, 50))
+
+ pygame.display.update()
+ clock.tick(30)
+
+cap.release()
+pygame.quit()
+sys.exit()
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\347\211\271\345\276\265\345\210\206\351\241\236/hand_landmarker.task" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\347\211\271\345\276\265\345\210\206\351\241\236/hand_landmarker.task"
new file mode 100644
index 0000000000000000000000000000000000000000..5ecab741879892d97c2f90bbf03bf55d7213db7c
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\347\211\271\345\276\265\345\210\206\351\241\236/hand_landmarker.task"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1
+size 7819105
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\347\211\271\345\276\265\345\210\206\351\241\236/main.py" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\347\211\271\345\276\265\345\210\206\351\241\236/main.py"
new file mode 100644
index 0000000000000000000000000000000000000000..169e69414c0c63a7d4f3dd68f63ab354229096f5
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\345\274\265\345\220\233\351\233\205/\346\211\213\345\213\242\347\211\271\345\276\265\345\210\206\351\241\236/main.py"
@@ -0,0 +1,212 @@
+"""
+Week 11 - Teachable Machine 影像分類 + AI 音效觸發 (Complete 完整版)
+=====================================================================
+本週目標:
+ 1. 載入 Teachable Machine 訓練好的 Keras 模型 (.h5)
+ 2. 使用攝影機即時推論,辨識畫面屬於哪一個類別
+ 3. 當偵測到特定類別 (如「喝水中」) 時觸發 Pygame 音效
+ 4. 使用 Boolean Flag 防止音效重複播放(邊緣觸發)
+
+前置準備:
+ - 從 Teachable Machine 匯出 keras_model.h5 和 labels.txt
+ - 把兩個檔案放在跟這個 .py 檔案同一個資料夾裡
+ - pip install tensorflow opencv-python numpy pygame
+"""
+import pygame
+import cv2
+import numpy as np
+import sys
+
+from keras.models import load_model
+
+# ============================================
+# 1. 初始化 Pygame
+# ============================================
+pygame.init()
+pygame.mixer.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 11 - AI 影像分類器 🧠🔮")
+clock = pygame.time.Clock()
+
+font = pygame.font.SysFont(None, 48)
+small_font = pygame.font.SysFont(None, 28)
+bar_font = pygame.font.SysFont(None, 22)
+
+# ============================================
+# 2. 載入模型與標籤
+# ============================================
+try:
+ model = load_model("keras_model.h5", compile=False)
+ print("✅ 模型載入成功!")
+except Exception as e:
+ print(f"❌ 模型載入失敗:{e}")
+ print(" 請確認 keras_model.h5 是否在同一個資料夾中")
+ pygame.quit()
+ sys.exit()
+
+try:
+ labels = open("labels.txt", "r").readlines()
+ print(f"✅ 載入 {len(labels)} 個分類標籤:{[l.strip() for l in labels]}")
+except FileNotFoundError:
+ print("❌ 找不到 labels.txt!請確認檔案是否在同一個資料夾中")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 3. 攝影機
+# ============================================
+cap = cv2.VideoCapture(0)
+if not cap.isOpened():
+ print("❌ 無法開啟攝影機,請改用影片檔案")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 4. 遊戲變數
+# ============================================
+current_class = "等待分析..."
+confidence = 0.0
+prediction = None
+frame_counter = 0
+
+# 邊緣觸發:紀錄上一個狀態
+is_drinking = False # 根據你的類別修改
+
+# 載入音效
+try:
+ trigger_sound = pygame.mixer.Sound("bubble.wav")
+ print("✅ 音效載入成功")
+except FileNotFoundError:
+ trigger_sound = None
+ print("⚠️ 找不到音效檔案 bubble.wav,將不播放音效")
+
+# 背景顏色(根據辨識結果切換)
+bg_color = (20, 20, 35)
+trigger_flash = 0 # 觸發時的閃爍計數器
+
+# 信心度條形圖的顏色
+bar_colors = [
+ (34, 197, 94), # 綠色
+ (59, 130, 246), # 藍色
+ (245, 158, 11), # 黃色
+ (239, 68, 68), # 紅色
+ (139, 92, 246), # 紫色
+]
+
+# ============================================
+# 5. 主迴圈
+# ============================================
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機 ---
+ success, img = cap.read()
+ if not success:
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
+ continue
+
+ img = cv2.flip(img, 1)
+
+ # --- 每 5 幀做一次推論(避免畫面大卡)---
+ frame_counter += 1
+ if frame_counter % 5 == 0:
+ try:
+ # Keras 要求的影像預處理(這三行請死背!)
+ img_resized = cv2.resize(img, (224, 224))
+ img_array = np.asarray(img_resized, dtype=np.float32).reshape(1, 224, 224, 3)
+ img_array = (img_array / 127.5) - 1
+
+ # 前饋推論:取得每個類別的機率
+ prediction = model.predict(img_array, verbose=0)
+ index = np.argmax(prediction)
+ current_class = labels[index].strip()
+ confidence = prediction[0][index]
+ except Exception as e:
+ print(f"推論出錯:{e}")
+
+ # --- 邊緣觸發 + 動作回饋 ---
+ # 這裡假設第一個非背景的類別叫做「喝水中」
+ # 請根據你自己的 labels.txt 修改判斷條件!
+ if current_class != labels[0].strip() and confidence > 0.90:
+ if not is_drinking:
+ # 狀態剛從「背景」切換到「動作」的瞬間
+ if trigger_sound:
+ trigger_sound.play()
+ trigger_flash = 15 # 觸發閃爍 15 幀
+ print(f"🥤 偵測到動作:{current_class}!信心度: {confidence:.1%}")
+ is_drinking = True
+ bg_color = (20, 50, 35) # 深綠背景
+ else:
+ is_drinking = False
+ bg_color = (20, 20, 35) # 回到預設
+
+ # 閃爍效果倒數
+ if trigger_flash > 0:
+ trigger_flash -= 1
+
+ # --- 繪製畫面 ---
+ # 背景(觸發時閃爍)
+ if trigger_flash > 0 and trigger_flash % 2 == 0:
+ screen.fill((40, 80, 50))
+ else:
+ screen.fill(bg_color)
+
+ # 辨識結果大字
+ result_color = (50, 255, 50) if is_drinking else (255, 255, 255)
+ class_text = font.render(f"{current_class}", True, result_color)
+ screen.blit(class_text, (WIDTH // 2 - class_text.get_width() // 2, 60))
+
+ conf_text = small_font.render(f"Confidence: {confidence:.1%}", True, (100, 200, 255))
+ screen.blit(conf_text, (WIDTH // 2 - conf_text.get_width() // 2, 120))
+
+ # --- 信心度條形圖 ---
+ if prediction is not None:
+ bar_y_start = 180
+ bar_height = 30
+ bar_gap = 12
+ bar_max_width = 500
+ bar_x = 150
+
+ for i, prob in enumerate(prediction[0]):
+ y = bar_y_start + i * (bar_height + bar_gap)
+ label_name = labels[i].strip() if i < len(labels) else f"Class {i}"
+ color = bar_colors[i % len(bar_colors)]
+
+ # 標籤名稱
+ label_text = bar_font.render(label_name, True, (200, 200, 200))
+ screen.blit(label_text, (bar_x - label_text.get_width() - 10, y + 5))
+
+ # 背景軌道
+ pygame.draw.rect(screen, (40, 40, 55), (bar_x, y, bar_max_width, bar_height), border_radius=6)
+
+ # 填充條
+ fill_width = int(prob * bar_max_width)
+ if fill_width > 0:
+ pygame.draw.rect(screen, color, (bar_x, y, fill_width, bar_height), border_radius=6)
+
+ # 百分比文字
+ pct_text = bar_font.render(f"{prob:.1%}", True, (255, 255, 255))
+ text_x = bar_x + fill_width + 8 if fill_width < bar_max_width - 60 else bar_x + fill_width - 55
+ screen.blit(pct_text, (text_x, y + 5))
+
+ # 標記最高的那一條
+ if i == np.argmax(prediction):
+ pygame.draw.rect(screen, color, (bar_x - 4, y, 4, bar_height), border_radius=2)
+
+ # 底部狀態列
+ status_text = small_font.render(
+ f"Frame: {frame_counter} | Predict every 5 frames | FPS: {clock.get_fps():.0f}",
+ True, (80, 80, 100)
+ )
+ screen.blit(status_text, (WIDTH // 2 - status_text.get_width() // 2, HEIGHT - 35))
+
+ pygame.display.update()
+ clock.tick(30)
+
+cap.release()
+pygame.quit()
+sys.exit()
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/background.jpg" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/background.jpg"
new file mode 100644
index 0000000000000000000000000000000000000000..7b98b815bc295e37f93ad72b68f7990170aa05d4
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/background.jpg"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b4550b2073dfd8267a1d2fc94620e82aada100ab95114d44a62eef8e40e3e7e
+size 220630
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/hand_landmarker.task" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/hand_landmarker.task"
new file mode 100644
index 0000000000000000000000000000000000000000..5ecab741879892d97c2f90bbf03bf55d7213db7c
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/hand_landmarker.task"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1
+size 7819105
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/hit.wav" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/hit.wav"
new file mode 100644
index 0000000000000000000000000000000000000000..d138a2494493124e0200999b18a4c4315b860a72
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/hit.wav"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:55440a99e00bb9de3a53bcaa709c83d412001f78dcf7103036ec02a5057c2fdd
+size 13274
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/main.py" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/main.py"
new file mode 100644
index 0000000000000000000000000000000000000000..c38f5c3fb4d6a36f18143ccc5e5e502db7ed9fb6
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/main.py"
@@ -0,0 +1,143 @@
+"""
+Week 9 - 空中雷射筆 (Complete 完整版)
+===========================================
+本週目標:
+ 1. 用 OpenCV 開啟攝影機
+ 2. 用 MediaPipe 偵測手部 21 個關鍵點
+ 3. 取得食指尖 (Landmark 8) 的座標
+ 4. 把座標映射到 Pygame 畫布上,畫出一顆跟著手指移動的紅色雷射點
+
+操作方式:
+ - 在鏡頭前移動手指,Pygame 視窗的紅點會跟著移動
+ - 按鍵盤 Q 或關閉視窗即可結束
+
+注意:
+ - 確保 hand_landmarker.task 檔案和這個腳本放在同一個資料夾。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import sys
+import os
+
+# ============================================
+# 1. 初始化 Pygame
+# ============================================
+pygame.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 9 - 空中雷射筆 ✋🔴")
+clock = pygame.time.Clock()
+
+# ============================================
+# 2. 初始化 OpenCV 攝影機
+# ============================================
+cap = cv2.VideoCapture(0)
+# 如果沒有攝影機,請改為:
+# cap = cv2.VideoCapture("backup_hand_video.mp4")
+
+if not cap.isOpened():
+ print("❌ 錯誤:無法開啟攝影機!")
+ print(" 請嘗試改用影片檔案:cap = cv2.VideoCapture('backup_hand_video.mp4')")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 3. 初始化 MediaPipe 手部偵測 (Tasks API)
+# ============================================
+try:
+ base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'hand_landmarker.task'))
+ options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+ detector = vision.HandLandmarker.create_from_options(options)
+except Exception as e:
+ print(f"❌ 錯誤:無法載入 MediaPipe 模型:{e}")
+ print(" 請確認 'hand_landmarker.task' 是否與這個程式碼在同一個資料夾!")
+ pygame.quit()
+ sys.exit()
+
+# ============================================
+# 4. 雷射筆座標
+# ============================================
+laser_x, laser_y = WIDTH // 2, HEIGHT // 2 # 初始位置在畫面中央
+hand_detected = False # 用來追蹤目前有沒有偵測到手
+
+# ============================================
+# 5. 主迴圈
+# ============================================
+running = True
+frame_count = 0 # 用來做 Frame Skip(選用進階功能)
+
+while running:
+ # --- 事件處理 ---
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機影格 ---
+ success, img = cap.read()
+ if not success:
+ # 如果是影片檔讀完了,就從頭開始
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
+ continue
+
+ img = cv2.flip(img, 1) # 鏡面翻轉,讓畫面像照鏡子一樣直覺
+
+ # --- BGR 轉 RGB 並丟給 MediaPipe 分析 ---
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ # --- 提取食指尖 (Landmark 8) 的座標 ---
+ if results.hand_landmarks:
+ hand_detected = True
+ for hand_lms in results.hand_landmarks:
+ lm8 = hand_lms[8] # Index Finger Tip
+
+ # MediaPipe 回傳 0~1 的比例值,乘上 Pygame 視窗大小
+ laser_x = int(lm8.x * WIDTH)
+ laser_y = int(lm8.y * HEIGHT)
+ else:
+ hand_detected = False
+
+ # --- 繪製畫面 ---
+ screen.fill((15, 15, 35)) # 深色背景
+
+ # 狀態文字
+ font = pygame.font.SysFont(None, 28)
+
+ if hand_detected:
+ # 畫出雷射光點(紅色圓點 + 外圈光暈)
+ # 外層光暈
+ glow_surf = pygame.Surface((60, 60), pygame.SRCALPHA)
+ pygame.draw.circle(glow_surf, (255, 50, 50, 40), (30, 30), 28)
+ screen.blit(glow_surf, (laser_x - 30, laser_y - 30))
+
+ # 中層
+ pygame.draw.circle(screen, (255, 80, 80), (laser_x, laser_y), 16)
+ # 內核(最亮)
+ pygame.draw.circle(screen, (255, 200, 200), (laser_x, laser_y), 6)
+
+ # 座標文字
+ coord_text = font.render(f"Index Tip (Landmark 8): ({laser_x}, {laser_y})", True, (100, 255, 100))
+ screen.blit(coord_text, (10, 10))
+ else:
+ # 沒偵測到手的提示
+ no_hand = font.render("No hand detected - please show your hand!", True, (255, 200, 0))
+ screen.blit(no_hand, (WIDTH // 2 - no_hand.get_width() // 2, HEIGHT // 2))
+
+ # 底部提示
+ hint = font.render("Move your index finger in front of the camera", True, (100, 100, 130))
+ screen.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT - 40))
+
+ pygame.display.update()
+ clock.tick(30)
+ frame_count += 1
+
+# ============================================
+# 6. 結束清理
+# ============================================
+cap.release()
+pygame.quit()
+sys.exit()
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/shoot.wav" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/shoot.wav"
new file mode 100644
index 0000000000000000000000000000000000000000..e4b1fe39a70052df01006c8b89a352f6e18b3f3d
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/shoot.wav"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:091481354d97c5469a2cd40f16d8fad63904eb4676992c34c1f208fc87a8173c
+size 17684
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\347\265\202\347\253\257\346\251\237\346\211\223\345\255\227\346\270\254\350\251\246/main.py" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\347\265\202\347\253\257\346\251\237\346\211\223\345\255\227\346\270\254\350\251\246/main.py"
new file mode 100644
index 0000000000000000000000000000000000000000..3515087db2d4255df47335fde2a64ac880146fd7
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\347\265\202\347\253\257\346\251\237\346\211\223\345\255\227\346\270\254\350\251\246/main.py"
@@ -0,0 +1,16 @@
+import sys
+import time
+
+print("[測試] 互動式終端機輸入測試開始。")
+print("系統正在等待輸入...")
+
+try:
+ name = input("請輸入你的名字 (Name): ")
+ print(f"你好,{name}!")
+
+ age = input("請輸入你的年齡 (Age): ")
+ print(f"確認資訊:{name} 今年 {age} 歲。")
+except Exception as e:
+ print(f"執行時發生錯誤: {e}")
+
+print("[測試] 測試成功結束。")
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/background.jpg" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/background.jpg"
new file mode 100644
index 0000000000000000000000000000000000000000..7b98b815bc295e37f93ad72b68f7990170aa05d4
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/background.jpg"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b4550b2073dfd8267a1d2fc94620e82aada100ab95114d44a62eef8e40e3e7e
+size 220630
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/hit.wav" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/hit.wav"
new file mode 100644
index 0000000000000000000000000000000000000000..d138a2494493124e0200999b18a4c4315b860a72
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/hit.wav"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:55440a99e00bb9de3a53bcaa709c83d412001f78dcf7103036ec02a5057c2fdd
+size 13274
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/main.py" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/main.py"
new file mode 100644
index 0000000000000000000000000000000000000000..fb5ab98cceb6d5ed5bb09cd894de67737e0e9b48
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/main.py"
@@ -0,0 +1,523 @@
+"""
+Week 8 + 9 + 10 - 串接 AI 視覺控制的網路競技場 (Complete 完整版)
+=============================================================
+本腳本為獨立測試版,將 Week 8 的 UDP 雙人對戰競技場與 Week 9-10 的 MediaPipe 手勢偵測進行完美結合:
+ 1. 使用食指尖 (Landmark 8) 移動方塊 (支援 WASD 鍵盤作為備用 fallback)。
+ 2. 使用大拇指 (Landmark 4) 與食指尖的 Pinch (捏合) 來發射子彈 (支援空白鍵作為備用)。
+ 3. 右下角顯示「AI 攝影機全息投影」預覽視窗。
+ 4. 死亡時,食指移至畫面中央的「Pinch to Respawn」虛擬按鈕並捏合,即可重生。
+
+操作方式:
+ - 同一台電腦測試:開兩個終端機分別執行此程式,先開的為 P1 (左),後開的為 P2 (右)。
+ - 將食指伸到鏡頭前即可控制方塊;捏合大拇指與食指即可射擊。
+ - 按鍵盤 Q 或點擊視窗關閉鈕即可結束。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import socket
+import json
+import sys
+import os
+import math
+
+# ============================================
+# 1. 初始化 Pygame & 視窗設定
+# ============================================
+pygame.init()
+pygame.mixer.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+clock = pygame.time.Clock()
+
+# ============================================
+# 2. 網路設定 (自動判斷 P1 / P2)
+# ============================================
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.setblocking(False)
+
+try:
+ sock.bind(("0.0.0.0", 5000))
+ MY_PORT = 5000
+ TARGET_PORT = 5001
+ player_role = "P1"
+except OSError:
+ sock.bind(("0.0.0.0", 5001))
+ MY_PORT = 5001
+ TARGET_PORT = 5000
+ player_role = "P2"
+
+TARGET_IP = "127.0.0.1" # 若要跨電腦連線,請修改為對方的 IP
+pygame.display.set_caption(f"AI 雙人對戰競技場 [{player_role} | Port {MY_PORT}]")
+
+# ============================================
+# 3. 初始化 OpenCV 攝影機與 MediaPipe 手勢偵測
+# ============================================
+print("正在啟動攝影機並載入 MediaPipe 模型...")
+cap = cv2.VideoCapture(0)
+has_camera = True
+if not cap.isOpened():
+ print("⚠️ 警告:無法開啟攝影機!將自動切換為鍵盤控制模式 (WASD + Space)。")
+ has_camera = False
+
+# 載入 MediaPipe 模型
+detector = None
+if has_camera:
+ try:
+ # 尋找與此程式同資料夾的 hand_landmarker.task
+ model_path = os.path.join(os.path.dirname(__file__), 'hand_landmarker.task')
+ if not os.path.exists(model_path):
+ # 若不在同資料夾,嘗試在上一層找 (或相對路徑)
+ model_path = 'hand_landmarker.task'
+
+ base_options = python.BaseOptions(model_asset_path=model_path)
+ options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1)
+ detector = vision.HandLandmarker.create_from_options(options)
+ print("✅ MediaPipe 模型載入成功!")
+ except Exception as e:
+ print(f"⚠️ 警告:MediaPipe 模型載入失敗 ({e})。將切換為鍵盤控制模式。")
+ has_camera = False
+
+# ============================================
+# 4. 遊戲變數 & 視覺設計系統
+# ============================================
+# 科幻感霓虹色彩系統
+COLOR_BG = (11, 15, 25) # 深藍太空底色
+COLOR_P1 = (0, 240, 255) # 霓虹青 (P1)
+COLOR_P2 = (57, 255, 20) # 霓虹綠 (P2)
+COLOR_BULLET_ME = (255, 110, 0) # 烈焰橘 (我方子彈)
+COLOR_BULLET_ENEMY = (255, 0, 128) # 魅惑紅 (對方子彈)
+COLOR_WHITE = (255, 255, 255)
+COLOR_MUTED = (100, 110, 130)
+
+# 字型
+font_small = pygame.font.SysFont("monospace", 18)
+font_med = pygame.font.SysFont("monospace", 28)
+font_large = pygame.font.SysFont("monospace", 64)
+
+# 載入 Week 8 素材與音效
+try:
+ bg_img = pygame.image.load("background.jpg")
+ bg_img = pygame.transform.scale(bg_img, (WIDTH, HEIGHT))
+except FileNotFoundError:
+ bg_img = None
+
+try:
+ shoot_sound = pygame.mixer.Sound("shoot.wav")
+except FileNotFoundError:
+ shoot_sound = None
+
+try:
+ hit_sound = pygame.mixer.Sound("hit.wav")
+except FileNotFoundError:
+ hit_sound = None
+
+# 玩家狀態初始化
+if player_role == "P1":
+ my_pos = [100, 300]
+ enemy_pos = [600, 300]
+ my_bullet_dir = 10
+ enemy_bullet_dir = -10
+ my_color = COLOR_P1
+ enemy_color = COLOR_P2
+else:
+ my_pos = [600, 300]
+ enemy_pos = [100, 300]
+ my_bullet_dir = -10
+ enemy_bullet_dir = 10
+ my_color = COLOR_P2
+ enemy_color = COLOR_P1
+
+my_hp = 10
+enemy_hp = 10
+my_score = 0
+enemy_score = 0
+bullets = []
+bullet_id_counter = 0
+game_over = False
+opponent_connected = False
+
+# AI 視覺手勢控制變數
+finger_x, finger_y = my_pos[0] + 20, my_pos[1] + 20
+thumb_x, thumb_y = my_pos[0] + 20, my_pos[1] + 20
+is_pinching = False
+was_pinching = False
+PINCH_THRESHOLD = 35 # 兩指距離小於 35px 判定為 Pinch
+hand_detected = False
+last_valid_dist = 999
+
+# 虛擬按鈕設定 (用於遊戲結束時手勢點擊重生)
+restart_btn_rect = pygame.Rect(300, 380, 200, 50)
+
+# ============================================
+# 5. 遊戲主迴圈
+# ============================================
+print("\n--- 遊戲已啟動 ---")
+print("提示:若偵測到手掌,方塊會自動跟隨食指尖移動。")
+print("提示:捏合大拇指與食指即可射擊。")
+print("提示:鍵盤 WASD / 鍵盤 Space 依然可做為緊急手動控制。")
+print("----------------")
+
+running = True
+while running:
+ # 畫背景
+ if bg_img:
+ screen.blit(bg_img, (0, 0))
+ else:
+ screen.fill(COLOR_BG)
+ # 繪製裝飾性格線 (Grid),提升科幻感
+ for x in range(0, WIDTH, 40):
+ pygame.draw.line(screen, (20, 30, 50), (x, 0), (x, HEIGHT), 1)
+ for y in range(0, HEIGHT, 40):
+ pygame.draw.line(screen, (20, 30, 50), (0, y), (WIDTH, y), 1)
+
+ # ============================================
+ # 5A. 讀取攝影機並進行 MediaPipe 手勢分析
+ # ============================================
+ cv_preview_surf = None
+ if has_camera and detector:
+ success, img = cap.read()
+ if success:
+ img = cv2.flip(img, 1) # 鏡像翻轉,控制更直覺
+ img_h, img_w, _ = img.shape
+
+ # 轉換為 RGB 格式供 MediaPipe 偵測
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ if results.hand_landmarks:
+ hand_detected = True
+ for hand_lms in results.hand_landmarks:
+ # 取得食指尖 (Landmark 8)
+ lm8 = hand_lms[8]
+ finger_x = int(lm8.x * WIDTH)
+ finger_y = int(lm8.y * HEIGHT)
+
+ # 取得大拇指尖 (Landmark 4)
+ lm4 = hand_lms[4]
+ thumb_x = int(lm4.x * WIDTH)
+ thumb_y = int(lm4.y * HEIGHT)
+
+ # 計算食指與大拇指之距離
+ last_valid_dist = math.hypot(finger_x - thumb_x, finger_y - thumb_y)
+ is_pinching = last_valid_dist < PINCH_THRESHOLD
+
+ # 將方塊中心移動至食指尖位置 (只有在活著時才移動)
+ if not game_over:
+ my_pos[0] = max(0, min(WIDTH - 40, finger_x - 20))
+ my_pos[1] = max(0, min(HEIGHT - 40, finger_y - 20))
+
+ # 在相機影格上畫出關鍵點 (供角落 HUD 顯示)
+ # 食指尖
+ cv2.circle(img, (int(lm8.x * img_w), int(lm8.y * img_h)), 8, (255, 0, 0), -1)
+ # 大拇指
+ cv2.circle(img, (int(lm4.x * img_w), int(lm4.y * img_h)), 8, (0, 0, 255), -1)
+ # 連線
+ line_color = (0, 255, 0) if is_pinching else (0, 0, 255)
+ cv2.line(img, (int(lm4.x * img_w), int(lm4.y * img_h)),
+ (int(lm8.x * img_w), int(lm8.y * img_h)), line_color, 2)
+ else:
+ hand_detected = False
+ is_pinching = False
+
+ # 製作 HUD 迷你鏡頭預覽影像 (縮小至 160x120)
+ small_img = cv2.resize(img, (160, 120))
+ small_img_rgb = cv2.cvtColor(small_img, cv2.COLOR_BGR2RGB)
+ cv_preview_surf = pygame.image.frombuffer(small_img_rgb.tobytes(), (160, 120), "RGB")
+
+ # ============================================
+ # 5B. 接收 UDP 封包 (處理網路連線數據)
+ # ============================================
+ while True:
+ try:
+ data, addr = sock.recvfrom(1024)
+ opponent_connected = True
+ pkg = json.loads(data.decode())
+
+ if pkg['type'] == 'pos':
+ enemy_pos[0] = pkg['x']
+ enemy_pos[1] = pkg['y']
+
+ elif pkg['type'] == 'shoot':
+ bullets.append({
+ 'x': pkg['x'], 'y': pkg['y'],
+ 'dir': enemy_bullet_dir, 'owner': 'enemy', 'id': pkg['id']
+ })
+ if shoot_sound:
+ shoot_sound.play()
+
+ elif pkg['type'] == 'hit':
+ enemy_hp = pkg.get('hp', 10)
+ hit_bullet_id = pkg.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+
+ elif pkg['type'] == 'im_dead':
+ enemy_hp = pkg.get('hp', 0)
+ my_score += 1
+ hit_bullet_id = pkg.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+
+ except (BlockingIOError, ConnectionResetError):
+ break
+ except json.JSONDecodeError:
+ pass
+
+ # ============================================
+ # 5C. 處理事件與鍵盤輸入 (手鍵雙軌備用)
+ # ============================================
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ if event.type == pygame.KEYDOWN:
+ if event.key == pygame.K_q:
+ running = False
+
+ # 空白鍵手動射擊
+ if event.key == pygame.K_SPACE and not game_over and opponent_connected:
+ bullet_id_counter += 1
+ bullets.append({
+ 'x': my_pos[0] + 20, 'y': my_pos[1] + 20,
+ 'dir': my_bullet_dir, 'owner': 'me', 'id': bullet_id_counter
+ })
+ shoot_pkg = {'type': 'shoot', 'x': my_pos[0] + 20, 'y': my_pos[1] + 20, 'id': bullet_id_counter}
+ sock.sendto(json.dumps(shoot_pkg).encode(), (TARGET_IP, TARGET_PORT))
+ if shoot_sound:
+ shoot_sound.play()
+
+ # 按 R 鍵重生
+ if event.key == pygame.K_r and game_over:
+ game_over = False
+ my_hp = 10
+ my_pos = [100, 300] if player_role == "P1" else [600, 300]
+ respawn_pkg = {'type': 'hit', 'hp': my_hp}
+ sock.sendto(json.dumps(respawn_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+ # Fallback 鍵盤 WASD 移動
+ if not game_over:
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_w]: my_pos[1] -= 5
+ if keys[pygame.K_s]: my_pos[1] += 5
+ if keys[pygame.K_a]: my_pos[0] -= 5
+ if keys[pygame.K_d]: my_pos[0] += 5
+
+ # 邊界限制
+ my_pos[0] = max(0, min(WIDTH - 40, my_pos[0]))
+ my_pos[1] = max(0, min(HEIGHT - 40, my_pos[1]))
+
+ # ============================================
+ # 5D. 邊緣觸發 (手勢動作判定)
+ # ============================================
+ if is_pinching and not was_pinching:
+ # 狀態1:活著 + 對手已連線 = 捏合射擊
+ if not game_over and opponent_connected:
+ bullet_id_counter += 1
+ bullets.append({
+ 'x': my_pos[0] + 20, 'y': my_pos[1] + 20,
+ 'dir': my_bullet_dir, 'owner': 'me', 'id': bullet_id_counter
+ })
+ shoot_pkg = {'type': 'shoot', 'x': my_pos[0] + 20, 'y': my_pos[1] + 20, 'id': bullet_id_counter}
+ sock.sendto(json.dumps(shoot_pkg).encode(), (TARGET_IP, TARGET_PORT))
+ if shoot_sound:
+ shoot_sound.play()
+ print("💥 AI 手勢射擊!")
+
+ # 狀態2:已陣亡 = 捏合點擊畫面中央的重生按鈕
+ elif game_over:
+ finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10)
+ if finger_rect.colliderect(restart_btn_rect):
+ game_over = False
+ my_hp = 10
+ my_pos = [100, 300] if player_role == "P1" else [600, 300]
+ respawn_pkg = {'type': 'hit', 'hp': my_hp}
+ sock.sendto(json.dumps(respawn_pkg).encode(), (TARGET_IP, TARGET_PORT))
+ print("♻️ AI 虛擬按鈕重生成功!")
+
+ was_pinching = is_pinching
+
+ # ============================================
+ # 5E. 繪圖區間 (Pygame 繪圖與 HUD 設計)
+ # ============================================
+ my_rect = pygame.Rect(my_pos[0], my_pos[1], 40, 40)
+ enemy_rect = pygame.Rect(enemy_pos[0], enemy_pos[1], 40, 40)
+
+ # 1. 繪製雷射引導線 (科技感效果)
+ if hand_detected and not game_over:
+ # 雷射瞄準虛線 (玩家指向射擊前方)
+ laser_color = (my_color[0], my_color[1], my_color[2], 80)
+ laser_surface = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
+ start_pt = (my_pos[0] + 20, my_pos[1] + 20)
+ end_pt = (WIDTH if my_bullet_dir > 0 else 0, my_pos[1] + 20)
+
+ # 畫虛線
+ distance_line = abs(end_pt[0] - start_pt[0])
+ step = 15
+ for offset in range(0, int(distance_line), step * 2):
+ curr_x = start_pt[0] + (offset if my_bullet_dir > 0 else -offset)
+ pygame.draw.line(laser_surface, (my_color[0], my_color[1], my_color[2], 120),
+ (curr_x, start_pt[1]),
+ (curr_x + (step if my_bullet_dir > 0 else -step), start_pt[1]), 2)
+ screen.blit(laser_surface, (0, 0))
+
+ # 2. 畫自己的人物 (加外發光邊框)
+ if not game_over:
+ pygame.draw.rect(screen, my_color, my_rect)
+ pygame.draw.rect(screen, COLOR_WHITE, my_rect, 2) # 白色框
+
+ # 3. 畫對方的人物 (對手血量大於 0 且已連線)
+ if opponent_connected and enemy_hp > 0:
+ pygame.draw.rect(screen, enemy_color, enemy_rect)
+ pygame.draw.rect(screen, COLOR_WHITE, enemy_rect, 2)
+
+ # 4. 畫計分板
+ score_text = font_med.render(f"P1 (BLUE): {my_score if player_role == 'P1' else enemy_score} | P2 (GREEN): {enemy_score if player_role == 'P1' else my_score}", True, COLOR_WHITE)
+ screen.blit(score_text, (WIDTH // 2 - score_text.get_width() // 2, 20))
+
+ # 5. 畫血條
+ if not game_over:
+ # 我的血條 (綠色/紅色比例)
+ pygame.draw.rect(screen, (100, 0, 0), (my_pos[0] - 10, my_pos[1] - 15, 60, 6))
+ pygame.draw.rect(screen, (0, 255, 100), (my_pos[0] - 10, my_pos[1] - 15, 6 * my_hp, 6))
+ hp_lbl = font_small.render(f"HP {my_hp}", True, COLOR_WHITE)
+ screen.blit(hp_lbl, (my_pos[0] - 10, my_pos[1] - 32))
+
+ if opponent_connected and enemy_hp > 0:
+ # 對方的血條
+ pygame.draw.rect(screen, (100, 0, 0), (enemy_pos[0] - 10, enemy_pos[1] - 15, 60, 6))
+ pygame.draw.rect(screen, (0, 255, 100), (enemy_pos[0] - 10, enemy_pos[1] - 15, 6 * enemy_hp, 6))
+ ehp_lbl = font_small.render(f"HP {enemy_hp}", True, COLOR_WHITE)
+ screen.blit(ehp_lbl, (enemy_pos[0] - 10, enemy_pos[1] - 32))
+
+ # 6. 處理子彈移動、邊界與碰撞
+ for b in bullets[:]:
+ if not game_over:
+ b['x'] += b['dir']
+
+ # 繪製子彈
+ bullet_rect = pygame.Rect(b['x'] - 5, b['y'] - 5, 10, 10)
+ bullet_color = COLOR_BULLET_ME if b['owner'] == 'me' else COLOR_BULLET_ENEMY
+
+ # 繪製亮色核心
+ pygame.draw.circle(screen, bullet_color, (b['x'], b['y']), 5)
+ # 外層微光效果
+ glow_surf = pygame.Surface((16, 16), pygame.SRCALPHA)
+ pygame.draw.circle(glow_surf, (bullet_color[0], bullet_color[1], bullet_color[2], 80), (8, 8), 8)
+ screen.blit(glow_surf, (b['x'] - 8, b['y'] - 8))
+
+ # 偵測自己被對方的子彈打中
+ if b['owner'] == 'enemy' and not game_over and my_rect.colliderect(bullet_rect):
+ my_hp -= 1
+ if hit_sound:
+ hit_sound.play()
+
+ hit_bullet_id = b['id']
+ if b in bullets:
+ bullets.remove(b)
+
+ if my_hp <= 0:
+ game_over = True
+ enemy_score += 1
+ hit_pkg = {'type': 'im_dead', 'hp': 0, 'bullet_id': hit_bullet_id}
+ print("💥 您已陣亡!等待手勢或鍵盤 R 鍵重生。")
+ else:
+ hit_pkg = {'type': 'hit', 'hp': my_hp, 'bullet_id': hit_bullet_id}
+
+ sock.sendto(json.dumps(hit_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+ # 超出邊界刪除
+ elif b['x'] < 0 or b['x'] > WIDTH:
+ if b in bullets:
+ bullets.remove(b)
+
+ # 7. 繪製手勢雷射游標與連接線 (當偵測到手掌且未死亡時)
+ if hand_detected and not game_over:
+ # 大拇指紫色點
+ pygame.draw.circle(screen, (139, 92, 246), (thumb_x, thumb_y), 6)
+
+ # 連接線 (捏合變綠,放開變紅)
+ line_color = (57, 255, 20) if is_pinching else (255, 50, 50)
+ pygame.draw.line(screen, line_color, (thumb_x, thumb_y), (finger_x, finger_y), 2)
+
+ # 食指游標
+ pygame.draw.circle(screen, line_color, (finger_x, finger_y), 10, 2)
+ pygame.draw.circle(screen, COLOR_WHITE, (finger_x, finger_y), 3)
+
+ # 8. 繪製角落全息相機 HUD (Holographic Camera Feed)
+ if cv_preview_surf:
+ hud_x, hud_y = WIDTH - 180, HEIGHT - 140
+ # 繪製科技感發光外框
+ pygame.draw.rect(screen, (30, 80, 150), (hud_x - 4, hud_y - 4, 168, 128), 2)
+ pygame.draw.rect(screen, COLOR_BG, (hud_x - 2, hud_y - 2, 164, 124))
+ # 貼上影像
+ screen.blit(cv_preview_surf, (hud_x, hud_y))
+
+ # 相機狀態標籤
+ status_color = (57, 255, 20) if hand_detected else (255, 200, 0)
+ status_text = "AI FEED [ACTIVE]" if hand_detected else "NO HAND DETECTED"
+ hud_label = font_small.render(status_text, True, status_color)
+ screen.blit(hud_label, (hud_x, hud_y - 22))
+
+ # 9. 顯示死亡畫面與虛擬重生按鈕
+ if game_over:
+ # 遮罩半透明層
+ overlay = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
+ overlay.fill((0, 0, 0, 180))
+ screen.blit(overlay, (0, 0))
+
+ dead_text = font_large.render("YOU DIED", True, (239, 68, 68))
+ screen.blit(dead_text, (WIDTH // 2 - dead_text.get_width() // 2, HEIGHT // 2 - 100))
+
+ # 繪製虛擬按鈕
+ btn_border_color = (0, 255, 100) if is_pinching else COLOR_WHITE
+ # 若食指滑過按鈕變色 (Hover效果)
+ finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10)
+ is_hover = finger_rect.colliderect(restart_btn_rect)
+ btn_bg_color = (30, 60, 40) if is_hover else (30, 30, 40)
+
+ pygame.draw.rect(screen, btn_bg_color, restart_btn_rect, border_radius=8)
+ pygame.draw.rect(screen, btn_border_color, restart_btn_rect, 2, border_radius=8)
+
+ btn_text = font_small.render("Pinch to Respawn", True, COLOR_WHITE)
+ screen.blit(btn_text, (restart_btn_rect.centerx - btn_text.get_width() // 2,
+ restart_btn_rect.centery - btn_text.get_height() // 2))
+
+ hint_text = font_small.render("or press keyboard 'R' to Respawn", True, COLOR_MUTED)
+ screen.blit(hint_text, (WIDTH // 2 - hint_text.get_width() // 2, HEIGHT // 2 + 100))
+
+ # 死亡時依然繪製手勢指引,讓玩家看得到自己能不能點擊按鈕
+ if hand_detected:
+ pygame.draw.circle(screen, (139, 92, 246), (thumb_x, thumb_y), 6)
+ line_color = (57, 255, 20) if is_pinching else (255, 50, 50)
+ pygame.draw.line(screen, line_color, (thumb_x, thumb_y), (finger_x, finger_y), 2)
+ pygame.draw.circle(screen, line_color, (finger_x, finger_y), 10, 2)
+ pygame.draw.circle(screen, COLOR_WHITE, (finger_x, finger_y), 3)
+
+ elif not opponent_connected:
+ # 等待對手連線提示
+ wait_text = font_med.render("WAITING FOR OPPONENT...", True, (255, 200, 0))
+ screen.blit(wait_text, (WIDTH // 2 - wait_text.get_width() // 2, HEIGHT // 2 - 20))
+
+ ip_info = font_small.render(f"My Port: {MY_PORT} | Targets: {TARGET_IP}:{TARGET_PORT}", True, COLOR_MUTED)
+ screen.blit(ip_info, (WIDTH // 2 - ip_info.get_width() // 2, HEIGHT // 2 + 20))
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # ============================================
+ # 5F. 傳送座標給對手 (每一影格同步)
+ # ============================================
+ if not game_over:
+ pos_pkg = {'type': 'pos', 'x': my_pos[0], 'y': my_pos[1]}
+ sock.sendto(json.dumps(pos_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+# 清理資源
+if has_camera:
+ cap.release()
+cv2.destroyAllWindows()
+pygame.quit()
+sys.exit()
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/shoot.wav" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/shoot.wav"
new file mode 100644
index 0000000000000000000000000000000000000000..e4b1fe39a70052df01006c8b89a352f6e18b3f3d
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\346\235\216\345\260\217\350\217\257/\350\246\226\350\250\212\351\217\241\351\240\255\347\253\266\346\212\200\345\240\264/shoot.wav"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:091481354d97c5469a2cd40f16d8fad63904eb4676992c34c1f208fc87a8173c
+size 17684
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\347\242\260\346\222\236\347\256\261\346\204\237\346\207\211/main.py" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\347\242\260\346\222\236\347\256\261\346\204\237\346\207\211/main.py"
new file mode 100644
index 0000000000000000000000000000000000000000..638357d8fd16d14ee6d33834aa3e83dc909fb9af
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\347\242\260\346\222\236\347\256\261\346\204\237\346\207\211/main.py"
@@ -0,0 +1,141 @@
+import pygame
+import json
+import socket
+import sys
+
+# === 初始化 ===
+pygame.init()
+screen = pygame.display.set_mode((800, 600))
+clock = pygame.time.Clock()
+
+# ==== 【TODO 1】網路 Socket 設定(自動判斷 P1 / P2)====
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.setblocking(False)
+
+# 嘗試綁定 port 5000,若被佔用則綁定 5001
+# 先啟動的人 = P1 (左側),後啟動的人 = P2 (右側)
+try:
+ sock.bind(("0.0.0.0", 5000))
+ MY_PORT = 5000
+ TARGET_PORT = 5001
+ player_role = "P1"
+except OSError:
+ sock.bind(("0.0.0.0", 5001))
+ MY_PORT = 5001
+ TARGET_PORT = 5000
+ player_role = "P2"
+
+TARGET_IP = "127.0.0.1" # 測試用
+
+# 根據角色自動設定位置與子彈方向
+if player_role == "P1":
+ my_x, my_y = 100, 300 # P1 在左邊
+ enemy_x, enemy_y = 600, 300
+ my_bullet_dir = 10 # P1 子彈朝右飛
+ enemy_bullet_dir = -10
+else:
+ my_x, my_y = 600, 300 # P2 在右邊
+ enemy_x, enemy_y = 100, 300
+ my_bullet_dir = -10 # P2 子彈朝左飛
+ enemy_bullet_dir = 10
+
+my_hp = 10
+bullets = [] # 儲存畫面上所有的子彈
+bullet_id_counter = 0 # 子彈編號計數器,用來同步雙方畫面
+
+pygame.display.set_caption(f"Week 7 - 碰撞判定與血條爭奪戰 [{player_role} | Port {MY_PORT}]")
+print(f"遊戲啟動!你是 {player_role}(監聽 port {MY_PORT},發送至 port {TARGET_PORT})")
+
+while True:
+ screen.fill((30, 30, 50))
+
+ # ==== 【TODO 2】接收對手網路封包 ====
+ try:
+ data, addr = sock.recvfrom(1024)
+ package = json.loads(data.decode())
+ if package['type'] == 'pos':
+ # 這是座標信:就蓋過對方的舊照片座標
+ enemy_x = package['x']
+ enemy_y = package['y']
+ elif package['type'] == 'shoot':
+ # 這是射擊事件信:馬上在畫面上憑空「生成」一顆對方開火的子彈
+ bullets.append({'x': package['x'], 'y': package['y'], 'dir': enemy_bullet_dir, 'owner': 'enemy', 'id': package['id']})
+ elif package['type'] == 'hit':
+ # 這是對方被擊中的通知
+ print(f"對手受傷了!對方血量剩下: {package.get('hp', 0)}")
+ # 對方被我的子彈打中了 → 從我的畫面上移除那顆子彈
+ hit_bullet_id = package.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+ except (BlockingIOError, ConnectionResetError):
+ # 非阻塞模式下,Linux 拋 BlockingIOError,Windows 拋 ConnectionResetError
+ pass
+ except json.JSONDecodeError:
+ # 防止有人亂傳不是 JSON 格式的垃圾訊息導致閃退
+ pass
+
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 按下空白鍵發射子彈
+ if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
+ bullet_id_counter += 1
+ new_bullet = {'x': my_x, 'y': my_y, 'dir': my_bullet_dir, 'owner': 'me', 'id': bullet_id_counter}
+ bullets.append(new_bullet)
+
+ # ==== 【TODO 3】通知對方我開槍了 ====
+ shoot_package = {'type': 'shoot', 'x': my_x, 'y': my_y, 'id': bullet_id_counter}
+ # 將這個 package 用 dumps 打包並送出
+ sock.sendto(json.dumps(shoot_package).encode(), (TARGET_IP, TARGET_PORT))
+
+ # 控制移動
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_w]: my_y -= 5
+ if keys[pygame.K_s]: my_y += 5
+
+ # 繪製自己與對手 (使用 Rect 作為碰撞箱)
+ my_rect = pygame.Rect(my_x, my_y, 40, 40)
+ pygame.draw.rect(screen, (59, 130, 246), my_rect)
+
+ enemy_rect = pygame.Rect(enemy_x, enemy_y, 40, 40)
+ pygame.draw.rect(screen, (16, 185, 129), enemy_rect)
+
+ # 處理並繪製每一顆子彈
+ for b in bullets[:]:
+ # 使子彈移動
+ b['x'] += b['dir']
+
+ # 繪製子彈
+ bullet_color = (255, 200, 0) if b['owner'] == 'me' else (255, 50, 50)
+ bullet_rect = pygame.Rect(b['x'], b['y'], 10, 10)
+ pygame.draw.rect(screen, bullet_color, bullet_rect)
+
+ # ==== 【TODO 4: P2P 碰撞判定】 ====
+ # 若這是一顆「對方」射過來的子彈,且「打中我了」
+ if b['owner'] == 'enemy' and my_rect.colliderect(bullet_rect):
+ # 1. my_hp -= 1
+ my_hp -= 1
+ # 2. bullets.remove(b)
+ hit_bullet_id = b['id']
+ if b in bullets:
+ bullets.remove(b)
+ # 3. 傳送 {'type': 'hit', 'hp': my_hp, 'bullet_id': ...} 告訴對方我受傷了
+ # 帶上 bullet_id 讓對方也能從他的畫面移除這顆子彈
+ hit_msg = {'type': 'hit', 'hp': my_hp, 'bullet_id': hit_bullet_id}
+ sock.sendto(json.dumps(hit_msg).encode(), (TARGET_IP, TARGET_PORT))
+ print(f"我被擊中了!剩餘血量: {my_hp}")
+
+ # 飛出邊界則刪除
+ elif b['x'] < 0 or b['x'] > 800:
+ if b in bullets:
+ bullets.remove(b)
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # ==== 【TODO 5】傳送座標 ====
+ # 每回合結束,把自己的新座標透過封包 {'type': 'pos', 'x': xxx, 'y': xxx} 送過去
+ pos_package = {'type': 'pos', 'x': my_x, 'y': my_y}
+ sock.sendto(json.dumps(pos_package).encode(), (TARGET_IP, TARGET_PORT))
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/background.jpg" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/background.jpg"
new file mode 100644
index 0000000000000000000000000000000000000000..7b98b815bc295e37f93ad72b68f7990170aa05d4
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/background.jpg"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7b4550b2073dfd8267a1d2fc94620e82aada100ab95114d44a62eef8e40e3e7e
+size 220630
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/hit.wav" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/hit.wav"
new file mode 100644
index 0000000000000000000000000000000000000000..d138a2494493124e0200999b18a4c4315b860a72
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/hit.wav"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:55440a99e00bb9de3a53bcaa709c83d412001f78dcf7103036ec02a5057c2fdd
+size 13274
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/main.py" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/main.py"
new file mode 100644
index 0000000000000000000000000000000000000000..80e6097aab3d94e230731468e3783047206c2b06
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/main.py"
@@ -0,0 +1,232 @@
+import pygame
+import socket
+import json
+import sys
+
+pygame.init()
+pygame.mixer.init()
+screen = pygame.display.set_mode((800, 600))
+clock = pygame.time.Clock()
+
+# --- 1. 網路設定(自動判斷 P1 / P2)---
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.setblocking(False)
+
+# 嘗試綁定 port 5000,若被佔用則綁定 5001
+# 先啟動的人 = P1 (左側),後啟動的人 = P2 (右側)
+try:
+ sock.bind(("0.0.0.0", 5000))
+ MY_PORT = 5000
+ TARGET_PORT = 5001
+ player_role = "P1"
+except OSError:
+ sock.bind(("0.0.0.0", 5001))
+ MY_PORT = 5001
+ TARGET_PORT = 5000
+ player_role = "P2"
+
+# 請修改為隔壁同學的 IP 準備互連!
+TARGET_IP = "127.0.0.1"
+
+# --- 2. 遊戲參數與各種變數(根據角色自動設定位置與方向)---
+if player_role == "P1":
+ my_pos = [100, 300] # P1 在左邊
+ enemy_pos = [600, 300] # 對手在右邊
+ my_bullet_dir = 10 # P1 子彈朝右飛
+ enemy_bullet_dir = -10 # 對手子彈朝左飛
+else:
+ my_pos = [600, 300] # P2 在右邊
+ enemy_pos = [100, 300] # 對手在左邊
+ my_bullet_dir = -10 # P2 子彈朝左飛
+ enemy_bullet_dir = 10 # 對手子彈朝右飛
+
+my_hp = 10
+enemy_hp = 10
+my_score = 0
+enemy_score = 0
+bullets = []
+bullet_id_counter = 0 # 子彈編號計數器,用來同步雙方畫面
+game_over = False
+opponent_connected = False # 追蹤對手是否已連線
+
+pygame.display.set_caption(f"Week 8 - 網路競技場最終戰 [{player_role} | Port {MY_PORT}]")
+print(f"遊戲啟動!你是 {player_role}(監聽 port {MY_PORT},發送至 port {TARGET_PORT})")
+print(f"位置:{'左側' if player_role == 'P1' else '右側'}")
+print("等待連線中...")
+
+# 字體與素材載入
+font = pygame.font.SysFont(None, 36)
+big_font = pygame.font.SysFont(None, 72)
+
+try:
+ bg_img = pygame.image.load("background.jpg")
+ bg_img = pygame.transform.scale(bg_img, (800, 600))
+except FileNotFoundError:
+ bg_img = None
+
+try:
+ shoot_sound = pygame.mixer.Sound("shoot.wav")
+except FileNotFoundError:
+ shoot_sound = None
+
+try:
+ hit_sound = pygame.mixer.Sound("hit.wav")
+except FileNotFoundError:
+ hit_sound = None
+
+# --- 3. 遊戲主迴圈 ---
+while True:
+ if bg_img:
+ screen.blit(bg_img, (0, 0))
+ else:
+ screen.fill((20, 20, 30))
+
+ # ======= [網路區間:接收最新消息] =======
+ # 【Bug修復】: 加入 while True 迴圈,一次清空所有堆積的網路封包,避免子彈穿透延遲!
+ while True:
+ try:
+ data, addr = sock.recvfrom(1024)
+ opponent_connected = True # 只要收到任何封包,就代表對手來了
+ pkg = json.loads(data.decode())
+
+ if pkg['type'] == 'pos':
+ enemy_pos[0] = pkg['x']
+ enemy_pos[1] = pkg['y']
+
+ elif pkg['type'] == 'shoot':
+ # 對手的子彈:用 enemy_bullet_dir 決定飛行方向,帶上對方給的 bullet_id
+ bullets.append({'x': pkg['x'], 'y': pkg['y'], 'dir': enemy_bullet_dir, 'owner': 'enemy', 'id': pkg['id']})
+ if shoot_sound:
+ shoot_sound.play()
+
+ elif pkg['type'] == 'hit':
+ enemy_hp = pkg.get('hp', 10)
+ # 對方被我的子彈打中了 → 從我的畫面上移除那顆子彈
+ hit_bullet_id = pkg.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+
+ elif pkg['type'] == 'im_dead':
+ # 對方宣告死亡
+ enemy_hp = pkg.get('hp', 0)
+ my_score += 1
+ hit_bullet_id = pkg.get('bullet_id')
+ if hit_bullet_id is not None:
+ bullets[:] = [b for b in bullets if not (b['owner'] == 'me' and b['id'] == hit_bullet_id)]
+
+ except (BlockingIOError, ConnectionResetError):
+ # 非阻塞模式下,Linux 拋 BlockingIOError,Windows 拋 ConnectionResetError
+ break
+ except json.JSONDecodeError:
+ # 防止有人亂傳不是 JSON 格式的垃圾訊息導致閃退
+ pass
+
+ # ======= [事件區間:玩家輸入] =======
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit(); sys.exit()
+
+ if event.type == pygame.KEYDOWN:
+ # 只有活著且對手連線時才能開槍
+ if event.key == pygame.K_SPACE and not game_over and opponent_connected:
+ bullet_id_counter += 1
+ bullets.append({'x': my_pos[0], 'y': my_pos[1], 'dir': my_bullet_dir, 'owner': 'me', 'id': bullet_id_counter})
+ shoot_pkg = {'type': 'shoot', 'x': my_pos[0], 'y': my_pos[1], 'id': bullet_id_counter}
+ sock.sendto(json.dumps(shoot_pkg).encode(), (TARGET_IP, TARGET_PORT))
+ if shoot_sound:
+ shoot_sound.play()
+
+ # 死亡時可以按 R 鍵重生
+ if event.key == pygame.K_r and game_over:
+ game_over = False
+ my_hp = 10
+ my_pos = [100, 300] if player_role == "P1" else [600, 300]
+ respawn_pkg = {'type': 'hit', 'hp': my_hp}
+ sock.sendto(json.dumps(respawn_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+ # 處理 WASD 或方向鍵移動 (活著才能移動)
+ if not game_over:
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_w]: my_pos[1] -= 5
+ if keys[pygame.K_s]: my_pos[1] += 5
+ if keys[pygame.K_a]: my_pos[0] -= 5
+ if keys[pygame.K_d]: my_pos[0] += 5
+
+ # ======= [運算與繪圖區間] =======
+ my_rect = pygame.Rect(my_pos[0], my_pos[1], 40, 40)
+ enemy_rect = pygame.Rect(enemy_pos[0], enemy_pos[1], 40, 40)
+
+ # 畫出自己與對手的人物實體
+ if not game_over:
+ pygame.draw.rect(screen, (59, 130, 246), my_rect)
+
+ # 假設對手 > 0 血才畫對手 (而且必須已連線)
+ if opponent_connected and enemy_hp > 0:
+ pygame.draw.rect(screen, (16, 185, 129), enemy_rect)
+
+ # 畫計分板與血條
+ score_text = font.render(f"Score - You: {my_score} | Enemy: {enemy_score}", True, (255, 255, 255))
+ screen.blit(score_text, (800//2 - score_text.get_width()//2, 15))
+
+ if not game_over:
+ my_hp_text = font.render(f"HP: {my_hp}", True, (255, 255, 255))
+ screen.blit(my_hp_text, (my_pos[0], my_pos[1] - 30))
+
+ if opponent_connected and enemy_hp > 0:
+ enemy_hp_text = font.render(f"HP: {enemy_hp}", True, (255, 255, 255))
+ screen.blit(enemy_hp_text, (enemy_pos[0], enemy_pos[1] - 30))
+
+ # 處理子彈移動與繪製
+ for b in bullets[:]:
+ if not game_over:
+ b['x'] += b['dir']
+
+ color = (255, 200, 0) if b['owner'] == 'me' else (255, 50, 50)
+ bullet_rect = pygame.Rect(b['x'], b['y'], 10, 10)
+ pygame.draw.rect(screen, color, bullet_rect)
+
+ # 判斷自己的碰撞框是否被對方的子彈打中 (自己必須活著)
+ if b['owner'] == 'enemy' and not game_over and my_rect.colliderect(bullet_rect):
+ my_hp -= 1
+ if hit_sound:
+ hit_sound.play()
+
+ hit_bullet_id = b['id']
+ if b in bullets:
+ bullets.remove(b)
+
+ # 若被打死,進入結算狀態並宣告死亡
+ if my_hp <= 0:
+ game_over = True
+ enemy_score += 1
+ hit_pkg = {'type': 'im_dead', 'hp': 0, 'bullet_id': hit_bullet_id}
+ print("您已陣亡!等待按下 'R' 鍵重生。")
+ else:
+ hit_pkg = {'type': 'hit', 'hp': my_hp, 'bullet_id': hit_bullet_id}
+
+ sock.sendto(json.dumps(hit_pkg).encode(), (TARGET_IP, TARGET_PORT))
+
+ elif b['x'] < 0 or b['x'] > 800:
+ if b in bullets:
+ bullets.remove(b)
+
+ # 如果死亡,蓋上結算文字
+ if game_over:
+ dead_text = big_font.render("YOU DIED", True, (239, 68, 68))
+ screen.blit(dead_text, (800//2 - dead_text.get_width()//2, 600//2 - dead_text.get_height()//2 - 20))
+
+ hint_text = font.render("Press 'R' to Restart", True, (255, 255, 255))
+ screen.blit(hint_text, (800//2 - hint_text.get_width()//2, 600//2 + 40))
+
+ elif not opponent_connected:
+ # 對手還沒連線的提示畫面
+ wait_text = font.render("Waiting for opponent...", True, (255, 200, 0))
+ screen.blit(wait_text, (800//2 - wait_text.get_width()//2, 600//2 - wait_text.get_height()//2))
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # ======= [網路區間:發送最新座標] =======
+ if not game_over:
+ pos_pkg = {'type': 'pos', 'x': my_pos[0], 'y': my_pos[1]}
+ sock.sendto(json.dumps(pos_pkg).encode(), (TARGET_IP, TARGET_PORT))
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/shoot.wav" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/shoot.wav"
new file mode 100644
index 0000000000000000000000000000000000000000..e4b1fe39a70052df01006c8b89a352f6e18b3f3d
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\347\216\213\345\244\247\346\230\216/\351\233\231\344\272\272\347\253\266\346\212\200\345\240\264/shoot.wav"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:091481354d97c5469a2cd40f16d8fad63904eb4676992c34c1f208fc87a8173c
+size 17684
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\351\231\263\351\230\277\345\234\237/\350\246\226\345\212\233\350\255\267\347\234\274\350\241\233\345\243\253/face_landmarker.task" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\351\231\263\351\230\277\345\234\237/\350\246\226\345\212\233\350\255\267\347\234\274\350\241\233\345\243\253/face_landmarker.task"
new file mode 100644
index 0000000000000000000000000000000000000000..fedb14de6d2b6708a56c04ae259783e23404c1aa
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\351\231\263\351\230\277\345\234\237/\350\246\226\345\212\233\350\255\267\347\234\274\350\241\233\345\243\253/face_landmarker.task"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff
+size 3758596
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\351\231\263\351\230\277\345\234\237/\350\246\226\345\212\233\350\255\267\347\234\274\350\241\233\345\243\253/main.py" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\351\231\263\351\230\277\345\234\237/\350\246\226\345\212\233\350\255\267\347\234\274\350\241\233\345\243\253/main.py"
new file mode 100644
index 0000000000000000000000000000000000000000..12e284ee816fb7ef317571ef0bc50f19e5d91cc8
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\351\231\263\351\230\277\345\234\237/\350\246\226\345\212\233\350\255\267\347\234\274\350\241\233\345\243\253/main.py"
@@ -0,0 +1,260 @@
+"""
+Week 12 - AI 生活工具箱:護眼大師 (Complete 完整版)
+=====================================================
+本週目標:
+ 1. 將 AI 視覺技術整合進一個「解決生活痛點」的完整專案
+ 2. 使用 MediaPipe 臉部偵測兩眼距離,判斷是否離螢幕太近
+ 3. 在 Pygame 畫面上顯示即時狀態與警報
+ 4. 加入防呆機制(try-except、None 檢查)
+
+護眼大師 EyeGuard:
+ - 偵測臉部兩眼之間的距離(兩眼距離越大 = 離鏡頭越近)
+ - 超過安全閾值時發出警報文字 + 閃爍效果 + 音效
+ - 安全時累計計時器鼓勵良好坐姿
+
+注意:
+ - 確保 face_landmarker.task 檔案和這個腳本放在同一個資料夾。
+"""
+import pygame
+import cv2
+import mediapipe as mp
+from mediapipe.tasks import python
+from mediapipe.tasks.python import vision
+import math
+import sys
+import os
+import time
+
+# ============================================
+# 1. 初始化
+# ============================================
+pygame.init()
+pygame.mixer.init()
+WIDTH, HEIGHT = 800, 600
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+pygame.display.set_caption("Week 12 - 護眼大師 EyeGuard 👁️🛡️")
+clock = pygame.time.Clock()
+
+font = pygame.font.SysFont(None, 48)
+small_font = pygame.font.SysFont(None, 28)
+big_font = pygame.font.SysFont(None, 72)
+tiny_font = pygame.font.SysFont(None, 22)
+
+# ============================================
+# 2. 攝影機與 AI 模組
+# ============================================
+cap = cv2.VideoCapture(0)
+if not cap.isOpened():
+ print("❌ 無法開啟攝影機,請改用影片檔案")
+ pygame.quit()
+ sys.exit()
+
+# MediaPipe 臉部偵測 (Tasks API)
+try:
+ base_options = python.BaseOptions(model_asset_path=os.path.join(os.path.dirname(__file__), 'face_landmarker.task'))
+ options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1)
+ detector = vision.FaceLandmarker.create_from_options(options)
+except Exception as e:
+ print(f"❌ 錯誤:無法載入 MediaPipe 模型:{e}")
+ pygame.quit()
+ sys.exit()
+
+# 關鍵 Landmark 索引
+LEFT_EYE = 33 # 左眼外角
+RIGHT_EYE = 263 # 右眼外角
+
+# ============================================
+# 3. 遊戲變數
+# ============================================
+eye_distance = 0
+lx, ly = 0, 0 # 左眼座標
+rx, ry = 0, 0 # 右眼座標
+face_detected = False
+
+DANGER_THRESHOLD = 120 # 兩眼像素距離超過此值 = 太近了!
+SAFE_THRESHOLD = 100 # 低於此值 = 安全(遲滯效應,避免臨界值抖動)
+
+# 警報狀態
+is_too_close = False
+was_too_close = False # 邊緣觸發
+warning_timer = 0 # 警告閃爍計時器
+
+# 統計數據
+total_warnings = 0
+safe_start_time = time.time()
+consecutive_safe_seconds = 0
+frame_counter = 0
+
+# 載入音效
+try:
+ alarm_sound = pygame.mixer.Sound("hit.wav") # 用現有的 hit.wav 代替警報音
+ print("✅ 警報音效載入成功")
+except FileNotFoundError:
+ alarm_sound = None
+ print("⚠️ 找不到警報音效,將不播放音效")
+
+# ============================================
+# 4. 主迴圈
+# ============================================
+running = True
+while running:
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ running = False
+
+ # --- 讀取攝影機 ---
+ success, img = cap.read()
+ if not success:
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
+ continue
+
+ img = cv2.flip(img, 1)
+ frame_counter += 1
+
+ # --- 臉部偵測與兩眼距離計算 ---
+ try:
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb)
+ results = detector.detect(mp_image)
+
+ if results.face_landmarks:
+ face_detected = True
+ face_lms = results.face_landmarks[0]
+
+ # 取得左眼和右眼座標
+ left_eye = face_lms[LEFT_EYE]
+ right_eye = face_lms[RIGHT_EYE]
+
+ lx = int(left_eye.x * WIDTH)
+ ly = int(left_eye.y * HEIGHT)
+ rx = int(right_eye.x * WIDTH)
+ ry = int(right_eye.y * HEIGHT)
+
+ # 計算兩眼之間的像素距離
+ eye_distance = math.hypot(rx - lx, ry - ly)
+
+ # 使用遲滯效應判斷(避免臨界值抖動)
+ if eye_distance > DANGER_THRESHOLD:
+ is_too_close = True
+ elif eye_distance < SAFE_THRESHOLD:
+ is_too_close = False
+ else:
+ face_detected = False
+ is_too_close = False
+ except Exception as e:
+ # 防禦性程式碼:任何異常都不應讓程式閃退
+ print(f"偵測異常:{e}")
+ face_detected = False
+
+ # --- 邊緣觸發判定 ---
+ if is_too_close and not was_too_close:
+ # 剛進入危險區
+ total_warnings += 1
+ warning_timer = 90 # 閃爍 90 幀(約 3 秒)
+ if alarm_sound:
+ alarm_sound.play()
+ safe_start_time = time.time() # 重置安全計時
+ print(f"⚠️ 第 {total_warnings} 次警告:你離螢幕太近了!(distance={eye_distance:.0f})")
+
+ if not is_too_close and was_too_close:
+ # 剛離開危險區
+ safe_start_time = time.time()
+ print("✅ 已回到安全距離")
+
+ was_too_close = is_too_close
+
+ # 計算連續安全秒數
+ if not is_too_close and face_detected:
+ consecutive_safe_seconds = time.time() - safe_start_time
+ else:
+ consecutive_safe_seconds = 0
+
+ # 閃爍倒數
+ if warning_timer > 0:
+ warning_timer -= 1
+
+ # --- 繪製畫面 ---
+ # 背景
+ if is_too_close:
+ if warning_timer > 0 and warning_timer % 6 < 3:
+ screen.fill((90, 15, 15)) # 強烈閃爍紅
+ else:
+ screen.fill((50, 12, 12)) # 暗紅
+ else:
+ screen.fill((12, 30, 18)) # 安全深綠
+
+ if face_detected:
+ # --- 距離量表 ---
+ gauge_x, gauge_y = 50, 50
+ gauge_w, gauge_h = 700, 30
+
+ # 軌道背景
+ pygame.draw.rect(screen, (40, 40, 55), (gauge_x, gauge_y, gauge_w, gauge_h), border_radius=8)
+
+ # 填充條(根據距離改變顏色與長度)
+ fill_ratio = min(eye_distance / 200, 1.0)
+ fill_w = int(fill_ratio * gauge_w)
+ if eye_distance > DANGER_THRESHOLD:
+ fill_color = (239, 68, 68) # 紅色
+ elif eye_distance > SAFE_THRESHOLD:
+ fill_color = (245, 158, 11) # 黃色
+ else:
+ fill_color = (34, 197, 94) # 綠色
+ pygame.draw.rect(screen, fill_color, (gauge_x, gauge_y, fill_w, gauge_h), border_radius=8)
+
+ # 閾值標記線
+ threshold_x = gauge_x + int(DANGER_THRESHOLD / 200 * gauge_w)
+ pygame.draw.line(screen, (255, 255, 255), (threshold_x, gauge_y - 5), (threshold_x, gauge_y + gauge_h + 5), 2)
+ threshold_label = tiny_font.render(f"DANGER: {DANGER_THRESHOLD}", True, (200, 200, 200))
+ screen.blit(threshold_label, (threshold_x - threshold_label.get_width() // 2, gauge_y + gauge_h + 8))
+
+ # 距離數值
+ dist_text = small_font.render(f"Eye Distance: {eye_distance:.0f} px", True, (200, 200, 220))
+ screen.blit(dist_text, (gauge_x, gauge_y - 28))
+
+ # --- 眼睛位置標記 ---
+ pygame.draw.circle(screen, (59, 130, 246), (lx, ly), 8)
+ pygame.draw.circle(screen, (59, 130, 246), (rx, ry), 8)
+ pygame.draw.line(screen, (59, 130, 246, 100), (lx, ly), (rx, ry), 2)
+
+ # --- 主要狀態顯示 ---
+ if is_too_close:
+ warn_text = big_font.render("TOO CLOSE!", True, (255, 80, 80))
+ screen.blit(warn_text, (WIDTH // 2 - warn_text.get_width() // 2, HEIGHT // 2 - 60))
+
+ sub_text = font.render("Please lean back!", True, (255, 150, 150))
+ screen.blit(sub_text, (WIDTH // 2 - sub_text.get_width() // 2, HEIGHT // 2 + 20))
+ else:
+ safe_text = font.render("Safe Distance", True, (80, 220, 100))
+ screen.blit(safe_text, (WIDTH // 2 - safe_text.get_width() // 2, HEIGHT // 2 - 30))
+
+ # 連續安全時間
+ if consecutive_safe_seconds > 3:
+ streak_text = small_font.render(
+ f"Good posture for {consecutive_safe_seconds:.0f}s! Keep it up!",
+ True, (100, 200, 150)
+ )
+ screen.blit(streak_text, (WIDTH // 2 - streak_text.get_width() // 2, HEIGHT // 2 + 20))
+
+ # --- 統計資訊 ---
+ stats_y = HEIGHT - 80
+ stats1 = small_font.render(f"Total Warnings: {total_warnings}", True, (150, 150, 180))
+ screen.blit(stats1, (20, stats_y))
+
+ stats2 = small_font.render(f"Frame: {frame_counter} | FPS: {clock.get_fps():.0f}", True, (100, 100, 130))
+ screen.blit(stats2, (20, stats_y + 28))
+
+ else:
+ # 沒偵測到臉
+ no_face = font.render("No face detected", True, (255, 200, 0))
+ screen.blit(no_face, (WIDTH // 2 - no_face.get_width() // 2, HEIGHT // 2 - 20))
+
+ hint = small_font.render("Please face the camera", True, (180, 180, 100))
+ screen.blit(hint, (WIDTH // 2 - hint.get_width() // 2, HEIGHT // 2 + 30))
+
+ pygame.display.update()
+ clock.tick(30)
+
+cap.release()
+pygame.quit()
+sys.exit()
diff --git "a/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\351\231\263\351\230\277\345\234\237/\351\200\243\347\267\232\345\220\214\346\255\245\347\216\251\345\256\266/main.py" "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\351\231\263\351\230\277\345\234\237/\351\200\243\347\267\232\345\220\214\346\255\245\347\216\251\345\256\266/main.py"
new file mode 100644
index 0000000000000000000000000000000000000000..995492160e7abf19e5b1abb0ec3755c8affb6183
--- /dev/null
+++ "b/projects/\345\276\227\350\203\2752026\345\260\210\351\241\214\347\217\255/\351\231\263\351\230\277\345\234\237/\351\200\243\347\267\232\345\220\214\346\255\245\347\216\251\345\256\266/main.py"
@@ -0,0 +1,86 @@
+import socket
+import json
+import pygame
+import sys
+
+# === 1. 面板與網路初始化 ===
+pygame.init()
+screen = pygame.display.set_mode((800, 600))
+pygame.display.set_caption("Week 6 - 雙人座標同步")
+clock = pygame.time.Clock()
+
+my_x, my_y = 100, 100
+enemy_x, enemy_y = -100, -100 # 一開始對方在畫面外
+
+# 建立 UDP 連線
+sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+sock.bind(("0.0.0.0", 5000))
+
+# 【關鍵】:設定為非阻塞模式 Non-blocking !!!
+# 這讓 recvfrom 就算沒收到信,也不會卡住整個遊戲的畫面更新!
+sock.setblocking(False)
+
+# 測試用:請輸入同學的 IP,如果沒有同學可改為 "127.0.0.1" 測試自己傳給自己
+TARGET_IP = input("請輸入對方 IP (Enter 預設 127.0.0.1): ")
+if not TARGET_IP:
+ TARGET_IP = "127.0.0.1"
+
+print(f"連線成功!準備將座標同步給 {TARGET_IP}")
+
+# === 2. 遊戲主迴圈 ===
+while True:
+ screen.fill((30, 30, 50)) # 深色背景
+
+ # --- A. 接收對方的資料 ---
+ try:
+ # 因為設了 setblocking(False),沒有信的時候會引發 BlockingIOError
+ data, addr = sock.recvfrom(1024)
+
+ # 拆開看對方傳了什麼
+ enemy_package = json.loads(data.decode())
+ enemy_x = enemy_package['x']
+ enemy_y = enemy_package['y']
+
+ except (BlockingIOError, ConnectionResetError):
+ # 非阻塞模式下,Linux 拋 BlockingIOError,Windows 拋 ConnectionResetError
+ # 沒有信?當作沒發生,程式繼續往下走!
+ pass
+ except json.JSONDecodeError:
+ # 防止有人亂傳不是 JSON 格式的垃圾訊息導致閃退
+ pass
+
+ # --- B. 處理遊戲事件與玩家輸入 ---
+ for event in pygame.event.get():
+ if event.type == pygame.QUIT:
+ pygame.quit()
+ sys.exit()
+
+ # 鍵盤控制自己移動
+ keys = pygame.key.get_pressed()
+ if keys[pygame.K_LEFT]: my_x -= 5
+ if keys[pygame.K_RIGHT]: my_x += 5
+ if keys[pygame.K_UP]: my_y -= 5
+ if keys[pygame.K_DOWN]: my_y += 5
+
+ # --- C. 繪圖階段 ---
+ # 畫自己 (藍色)
+ pygame.draw.rect(screen, (59, 130, 246), (my_x, my_y, 40, 40))
+ # 畫對手 (綠色)
+ pygame.draw.rect(screen, (16, 185, 129), (enemy_x, enemy_y, 40, 40))
+
+ pygame.display.flip()
+ clock.tick(60)
+
+ # --- D. 送出自己的資料給對方 ---
+ my_package = {
+ "x": my_x,
+ "y": my_y
+ }
+
+ # 打包 -> 轉 bytes -> 送出
+ json_str = json.dumps(my_package)
+ try:
+ sock.sendto(json_str.encode(), (TARGET_IP, 5000))
+ except OSError:
+ # 若發生網路短暫斷線引發的 OS 錯誤,我們也能忽略不處理以避免閃退
+ pass
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9ec7b9f45c223496fa7c91b8dcf4caac1447200a
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,8 @@
+fastapi>=0.100.0
+uvicorn[standard]>=0.22.0
+starlette<1.0.0
+jinja2>=3.1.2
+websockets>=11.0.3
+numpy>=1.24.0
+opencv-python-headless>=4.7.0.0
+pygame-ce>=2.3.0
diff --git a/runner.py b/runner.py
new file mode 100644
index 0000000000000000000000000000000000000000..c9377bca7cc0fac888eaad14aa0f2da16b2090e9
--- /dev/null
+++ b/runner.py
@@ -0,0 +1,410 @@
+import os
+import sys
+import argparse
+import socket
+import threading
+import queue
+import time
+import json
+import numpy as np
+
+# We parse command-line arguments first before importing pygame/opencv
+parser = argparse.ArgumentParser()
+parser.add_argument("--project_path", required=True, help="Path to the student's main.py")
+parser.add_argument("--port", type=int, required=True, help="Internal TCP communication port")
+args = parser.parse_args()
+
+# Setup Headless environment for Pygame
+os.environ["SDL_VIDEODRIVER"] = "dummy"
+os.environ["SDL_AUDIODRIVER"] = "dummy"
+
+# ----------------- IPC Communication Channels -----------------
+send_queue = queue.Queue()
+cv2_keys = queue.Queue()
+stdin_queue = queue.Queue()
+
+shared_keys = [False] * 512
+shared_mouse_pos = (0, 0)
+shared_mouse_buttons = (False, False, False)
+shared_webcam_frame = None
+
+# Key map: JavaScript event.key name -> Pygame key constant
+import pygame # Import pygame now that SDL environment is set
+KEY_MAP = {
+ "ArrowLeft": pygame.K_LEFT,
+ "ArrowRight": pygame.K_RIGHT,
+ "ArrowUp": pygame.K_UP,
+ "ArrowDown": pygame.K_DOWN,
+ " ": pygame.K_SPACE,
+ "Spacebar": pygame.K_SPACE,
+ "Enter": pygame.K_RETURN,
+ "Escape": pygame.K_ESCAPE,
+ "Backspace": pygame.K_BACKSPACE,
+ "Tab": pygame.K_TAB,
+ "Shift": pygame.K_LSHIFT,
+ "Control": pygame.K_LCTRL,
+ "Alt": pygame.K_LALT,
+ "a": pygame.K_a, "b": pygame.K_b, "c": pygame.K_c, "d": pygame.K_d, "e": pygame.K_e,
+ "f": pygame.K_f, "g": pygame.K_g, "h": pygame.K_h, "i": pygame.K_i, "j": pygame.K_j,
+ "k": pygame.K_k, "l": pygame.K_l, "m": pygame.K_m, "n": pygame.K_n, "o": pygame.K_o,
+ "p": pygame.K_p, "q": pygame.K_q, "r": pygame.K_r, "s": pygame.K_s, "t": pygame.K_t,
+ "u": pygame.K_u, "v": pygame.K_v, "w": pygame.K_w, "x": pygame.K_x, "y": pygame.K_y,
+ "z": pygame.K_z,
+ "0": pygame.K_0, "1": pygame.K_1, "2": pygame.K_2, "3": pygame.K_3, "4": pygame.K_4,
+ "5": pygame.K_5, "6": pygame.K_6, "7": pygame.K_7, "8": pygame.K_8, "9": pygame.K_9,
+}
+
+# ----------------- TCP Network Threads -----------------
+def reader_thread_func(sock):
+ global shared_webcam_frame, shared_keys, shared_mouse_pos, shared_mouse_buttons
+ print("[Runner-TCP] Reader thread started.")
+ try:
+ while True:
+ # 1. Read message type (4 bytes)
+ type_bytes = sock.recv(4)
+ if not type_bytes or len(type_bytes) < 4:
+ break
+ msg_type = type_bytes.decode('utf-8', errors='ignore')
+
+ # 2. Read payload length (4 bytes)
+ len_bytes = sock.recv(4)
+ if not len_bytes or len(len_bytes) < 4:
+ break
+ length = int.from_bytes(len_bytes, byteorder='big')
+
+ # 3. Read payload
+ payload = bytearray()
+ while len(payload) < length:
+ chunk = sock.recv(length - len(payload))
+ if not chunk:
+ break
+ payload.extend(chunk)
+
+ if len(payload) < length:
+ break
+
+ # 4. Dispatch payload
+ if msg_type == 'KEYS':
+ # Pygame keys mapping
+ data = json.loads(payload.decode('utf-8'))
+ event_type = data.get('type') # 'keydown' or 'keyup'
+ key_name = data.get('key')
+
+ pg_key = KEY_MAP.get(key_name)
+ if pg_key is not None:
+ if event_type == 'keydown':
+ shared_keys[pg_key] = True
+ try:
+ pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=pg_key, unicode=data.get('unicode', '')))
+ except Exception:
+ pass
+ elif event_type == 'keyup':
+ shared_keys[pg_key] = False
+ try:
+ pygame.event.post(pygame.event.Event(pygame.KEYUP, key=pg_key))
+ except Exception:
+ pass
+
+ elif msg_type == 'MOUS':
+ # Pygame mouse mapping
+ data = json.loads(payload.decode('utf-8'))
+ # Mouse position scaling should be handled by client or here (we assume client coordinates match game coordinates)
+ shared_mouse_pos = (data.get('x', 0), data.get('y', 0))
+
+ # Update mouse button states
+ btn_left = data.get('btnLeft', False)
+ btn_middle = data.get('btnMiddle', False)
+ btn_right = data.get('btnRight', False)
+ shared_mouse_buttons = (btn_left, btn_middle, btn_right)
+
+ # Post mouse move / click events
+ try:
+ event_type = data.get('type')
+ if event_type == 'mousemove':
+ pygame.event.post(pygame.event.Event(pygame.MOUSEMOTION, pos=shared_mouse_pos, rel=(0, 0), buttons=shared_mouse_buttons))
+ elif event_type == 'mousedown':
+ button_idx = data.get('button', 0) + 1 # JS is 0-indexed, pygame is 1-indexed
+ pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=shared_mouse_pos, button=button_idx))
+ elif event_type == 'mouseup':
+ button_idx = data.get('button', 0) + 1
+ pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONUP, pos=shared_mouse_pos, button=button_idx))
+ except Exception:
+ pass
+
+ elif msg_type == 'CAMF':
+ # Webcam JPEG frame bytes
+ shared_webcam_frame = bytes(payload)
+
+ elif msg_type == 'CV2K':
+ # OpenCV keypress ASCII value
+ data = json.loads(payload.decode('utf-8'))
+ key_code = data.get('key_code', -1)
+ if key_code != -1:
+ cv2_keys.put(key_code)
+
+ elif msg_type == 'INPT':
+ # Stdin input from browser terminal
+ text = payload.decode('utf-8', errors='ignore')
+ stdin_queue.put(text)
+
+ except Exception as e:
+ print(f"[Runner-TCP] Reader error: {e}", file=sys.__stderr__)
+ finally:
+ print("[Runner-TCP] Reader thread finished.", file=sys.__stderr__)
+ sock.close()
+
+def writer_thread_func(sock):
+ print("[Runner-TCP] Writer thread started.")
+ try:
+ while True:
+ msg_type, payload = send_queue.get()
+ if msg_type == b'EXIT':
+ break
+
+ # Send: Type (4 bytes) + Length (4 bytes) + Payload
+ sock.sendall(msg_type)
+ sock.sendall(len(payload).to_bytes(4, byteorder='big'))
+ sock.sendall(payload)
+ except Exception as e:
+ print(f"[Runner-TCP] Writer error: {e}", file=sys.__stderr__)
+ finally:
+ print("[Runner-TCP] Writer thread finished.", file=sys.__stderr__)
+ sock.close()
+
+# Connect to Server TCP port
+sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+try:
+ sock.connect(("127.0.0.1", args.port))
+except Exception as e:
+ print(f"Error connecting to server on port {args.port}: {e}")
+ sys.exit(1)
+
+reader_thread = threading.Thread(target=reader_thread_func, args=(sock,), daemon=True)
+writer_thread = threading.Thread(target=writer_thread_func, args=(sock,), daemon=True)
+reader_thread.start()
+writer_thread.start()
+
+# ----------------- Redirecting stdout/stderr -----------------
+class StreamToTCP:
+ def __init__(self, original_stream):
+ self.original_stream = original_stream
+
+ def write(self, buf):
+ self.original_stream.write(buf)
+ self.original_stream.flush()
+ if buf:
+ send_queue.put((b'LOGS', buf.encode('utf-8')))
+
+ def flush(self):
+ self.original_stream.flush()
+
+sys.stdout = StreamToTCP(sys.stdout)
+sys.stderr = StreamToTCP(sys.stderr)
+
+# ----------------- Monkeypatching Stdin & input() -----------------
+import builtins
+
+class CustomStdin:
+ def readline(self, limit=-1):
+ return stdin_queue.get()
+ def read(self, size=-1):
+ return stdin_queue.get()
+ def fileno(self):
+ raise OSError("Not a real file descriptor")
+ def isatty(self):
+ return True
+
+sys.stdin = CustomStdin()
+
+original_input = builtins.input
+def custom_input(prompt=""):
+ if prompt:
+ sys.stdout.write(str(prompt))
+ sys.stdout.flush()
+ # Blocks until browser input is sent
+ line = stdin_queue.get()
+ if line.endswith('\r\n'):
+ return line[:-2]
+ elif line.endswith('\n') or line.endswith('\r'):
+ return line[:-1]
+ return line
+
+builtins.input = custom_input
+
+# ----------------- Monkeypatching Pygame -----------------
+# 1. Patch display set_mode
+original_set_mode = pygame.display.set_mode
+def custom_set_mode(size, flags=0, depth=0, display=0, vsync=0):
+ surf = original_set_mode(size, flags, depth, display, vsync)
+ print(f"[Pygame] 初始化視窗大小為: {size}")
+ return surf
+pygame.display.set_mode = custom_set_mode
+
+# 2. Patch flip and update to capture surface and stream it
+def send_pygame_frame():
+ surf = pygame.display.get_surface()
+ if surf:
+ import io
+ buffer = io.BytesIO()
+ try:
+ pygame.image.save(surf, buffer, "JPEG")
+ jpeg_bytes = buffer.getvalue()
+ # Send jpeg bytes to server
+ send_queue.put((b'FRME', jpeg_bytes))
+ except Exception as e:
+ # Silence internal pygame exceptions during shutdown
+ pass
+
+original_flip = pygame.display.flip
+original_update = pygame.display.update
+
+def custom_flip():
+ send_pygame_frame()
+ original_flip()
+
+def custom_update(*args, **kwargs):
+ send_pygame_frame()
+ original_update(*args, **kwargs)
+
+pygame.display.flip = custom_flip
+pygame.display.update = custom_update
+
+# 3. Patch key and mouse input states
+def custom_key_get_pressed():
+ return shared_keys
+pygame.key.get_pressed = custom_key_get_pressed
+
+def custom_mouse_get_pos():
+ return shared_mouse_pos
+pygame.mouse.get_pos = custom_mouse_get_pos
+
+def custom_mouse_get_pressed(num_buttons=3):
+ return shared_mouse_buttons
+pygame.mouse.get_pressed = custom_mouse_get_pressed
+
+
+# ----------------- Monkeypatching OpenCV (cv2) -----------------
+import cv2
+
+# 1. Patch imshow to capture frames
+def custom_imshow(winname, mat):
+ if mat is not None:
+ try:
+ ret, jpeg = cv2.imencode('.jpg', mat, [cv2.IMWRITE_JPEG_QUALITY, 70])
+ if ret:
+ send_queue.put((b'FRME', jpeg.tobytes()))
+ except Exception:
+ pass
+cv2.imshow = custom_imshow
+
+# 2. Patch waitKey to block on browser input queue
+def custom_wait_key(delay=0):
+ start_time = time.time()
+ timeout = (delay / 1000.0) if delay > 0 else float('inf')
+
+ while True:
+ if not cv2_keys.empty():
+ return cv2_keys.get()
+ elapsed = time.time() - start_time
+ if elapsed >= timeout:
+ break
+ time.sleep(0.005)
+ return -1
+cv2.waitKey = custom_wait_key
+
+# 3. Patch VideoCapture for Browser Webcam input
+class MockVideoCapture:
+ def __init__(self, source=0):
+ self.source = source
+ self.is_opened = True
+ self.frame_counter = 0
+
+ def isOpened(self):
+ return self.is_opened
+
+ def read(self):
+ global shared_webcam_frame
+ if not self.is_opened:
+ return False, None
+
+ # If webcam frame has been received from browser
+ if shared_webcam_frame is not None:
+ try:
+ img_np = np.frombuffer(shared_webcam_frame, dtype=np.uint8)
+ frame = cv2.imdecode(img_np, cv2.IMREAD_COLOR)
+ if frame is not None:
+ return True, frame
+ except Exception as e:
+ print(f"[OpenCV-Mock] 解碼 Webcam 畫面錯誤: {e}", file=sys.__stderr__)
+
+ # Fallback: if no browser webcam is streaming yet, generate a dummy test frame
+ # to prevent student code from blocking or crashing
+ width, height = 640, 480
+ frame = np.zeros((height, width, 3), dtype=np.uint8)
+ self.frame_counter += 1
+
+ # Draw bouncing circles as a placeholder
+ t = time.time()
+ cx = int(320 + 120 * np.sin(t * 3))
+ cy = int(240 + 80 * np.cos(t * 2))
+ cv2.circle(frame, (cx, cy), 40, (150, 80, 230), -1)
+ cv2.putText(frame, "Webcam Connecting...", (160, 240),
+ cv2.FONT_HERSHEY_SIMPLEX, 0.8, (240, 240, 240), 2)
+ cv2.putText(frame, "Please enable webcam in browser", (130, 280),
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
+ return True, frame
+
+ def release(self):
+ self.is_opened = False
+
+ def get(self, propId):
+ if propId == cv2.CAP_PROP_FRAME_WIDTH:
+ return 640
+ elif propId == cv2.CAP_PROP_FRAME_HEIGHT:
+ return 480
+ elif propId == cv2.CAP_PROP_FPS:
+ return 30
+ return 0
+
+ def set(self, propId, value):
+ return True
+
+original_VideoCapture = cv2.VideoCapture
+def custom_VideoCapture(source=0):
+ # Intercept index 0 (physical camera)
+ if isinstance(source, int) or source == '0' or source == 0:
+ print("[OpenCV] 攔截實體鏡頭 VideoCapture(0) 啟用瀏覽器 Webcam 串流。")
+ return MockVideoCapture(source)
+ else:
+ # File paths or other urls go through original video capture
+ print(f"[OpenCV] 載入影片檔案: {source}")
+ return original_VideoCapture(source)
+
+cv2.VideoCapture = custom_VideoCapture
+
+
+# ----------------- Load & Execute Student Script -----------------
+import runpy
+
+project_path = os.path.abspath(args.project_path)
+project_dir = os.path.dirname(project_path)
+
+print(f"[Runner] 切換工作目錄至: {project_dir}")
+os.chdir(project_dir)
+sys.path.insert(0, project_dir)
+
+try:
+ print(f"[Runner] 開始執行學生專案: {os.path.basename(project_path)}")
+ runpy.run_path(project_path, run_name='__main__')
+except SystemExit as e:
+ print(f"[Runner] 專案正常結束 (SystemExit: {e})")
+except Exception as e:
+ import traceback
+ print(f"[Runner] 專案執行出錯:")
+ traceback.print_exc(file=sys.stderr)
+finally:
+ # Cleanup network connections
+ print("[Runner] 清理資源,結束執行。")
+ send_queue.put((b'EXIT', b''))
+ # Wait briefly for writer thread to flush out
+ time.sleep(0.5)
diff --git a/server.py b/server.py
new file mode 100644
index 0000000000000000000000000000000000000000..9c503cec439c6822e34b41a0746ab51b3fb03c3d
--- /dev/null
+++ b/server.py
@@ -0,0 +1,330 @@
+import os
+import sys
+import json
+import asyncio
+import subprocess
+from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
+from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+
+app = FastAPI(title="Student Pygame/OpenCV Arcade Hub")
+
+# Directory setup
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+PROJECTS_DIR = os.path.join(BASE_DIR, "projects")
+os.makedirs(PROJECTS_DIR, exist_ok=True)
+
+# Templates setup
+TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
+os.makedirs(TEMPLATES_DIR, exist_ok=True)
+templates = Jinja2Templates(directory=TEMPLATES_DIR)
+
+# Static projects mount
+app.mount("/static_projects", StaticFiles(directory=PROJECTS_DIR), name="static_projects")
+
+# ----------------- Scanning Projects -----------------
+def scan_projects():
+ """
+ Scans the projects/ directory and returns a dictionary of:
+ { class_name: { student_name: [ {name, type, url}, ... ] } }
+ Supports Python, HTML5, and External Links.
+ """
+ tree = {}
+ if not os.path.exists(PROJECTS_DIR):
+ return tree
+
+ for class_name in sorted(os.listdir(PROJECTS_DIR)):
+ class_path = os.path.join(PROJECTS_DIR, class_name)
+ if not os.path.isdir(class_path) or class_name.startswith('.'):
+ continue
+
+ tree[class_name] = {}
+ for student_name in sorted(os.listdir(class_path)):
+ student_path = os.path.join(class_path, student_name)
+ if not os.path.isdir(student_path) or student_name.startswith('.'):
+ continue
+
+ projects = []
+ for project_name in sorted(os.listdir(student_path)):
+ project_dir = os.path.join(student_path, project_name)
+ if os.path.isdir(project_dir) and not project_name.startswith('.'):
+ # Check type priority: 1. Python, 2. HTML, 3. External Link
+ main_py = os.path.join(project_dir, "main.py")
+ index_html = os.path.join(project_dir, "index.html")
+ link_txt = os.path.join(project_dir, "link.txt")
+
+ if os.path.exists(main_py):
+ projects.append({"name": project_name, "type": "python", "url": ""})
+ elif os.path.exists(index_html):
+ projects.append({"name": project_name, "type": "html", "url": ""})
+ elif os.path.exists(link_txt):
+ try:
+ with open(link_txt, "r", encoding="utf-8") as f:
+ url = f.read().strip()
+ if url:
+ projects.append({"name": project_name, "type": "external", "url": url})
+ except Exception:
+ pass
+
+ if projects:
+ tree[class_name][student_name] = projects
+
+ # Clean up empty classes
+ if not tree[class_name]:
+ del tree[class_name]
+
+ return tree
+
+from fastapi.responses import FileResponse
+
+@app.get("/images/logo-light.png")
+@app.get("/logo.png")
+async def get_logo():
+ """Serves the official Telearn logo."""
+ return FileResponse(os.path.join(BASE_DIR, "templates", "logo-light.png"))
+
+# ----------------- HTTP Routes -----------------
+@app.get("/", response_class=HTMLResponse)
+async def read_index(request: Request):
+ """Renders the class/student/project catalog selection page."""
+ return templates.TemplateResponse("index.html", {
+ "request": request,
+ "restrict_class": "",
+ "restrict_student": ""
+ })
+
+@app.get("/class/{class_name}", response_class=HTMLResponse)
+async def read_class(request: Request, class_name: str):
+ """Renders the catalog restricted to a specific class."""
+ return templates.TemplateResponse("index.html", {
+ "request": request,
+ "restrict_class": class_name,
+ "restrict_student": ""
+ })
+
+@app.get("/student/{class_name}/{student_name}", response_class=HTMLResponse)
+async def read_student(request: Request, class_name: str, student_name: str):
+ """Renders the catalog restricted to a specific student."""
+ return templates.TemplateResponse("index.html", {
+ "request": request,
+ "restrict_class": class_name,
+ "restrict_student": student_name
+ })
+
+@app.get("/api/projects", response_class=JSONResponse)
+async def get_projects(class_name: str = None, student_name: str = None):
+ """API endpoint to get the class-student-project structure (with filtering)."""
+ all_projects = scan_projects()
+ if class_name:
+ if class_name in all_projects:
+ if student_name:
+ if student_name in all_projects[class_name]:
+ return {class_name: {student_name: all_projects[class_name][student_name]}}
+ else:
+ return {class_name: {}}
+ else:
+ return {class_name: all_projects[class_name]}
+ else:
+ return {}
+ return all_projects
+
+@app.get("/run/{class_name}/{student_name}/{project_name}", response_class=HTMLResponse)
+async def run_project_page(request: Request, class_name: str, student_name: str, project_name: str):
+ """Renders the player viewport wrapper."""
+ proj_dir = os.path.join(PROJECTS_DIR, class_name, student_name, project_name)
+ proj_type = "unknown"
+ external_url = ""
+
+ if os.path.exists(os.path.join(proj_dir, "main.py")):
+ proj_type = "python"
+ elif os.path.exists(os.path.join(proj_dir, "index.html")):
+ proj_type = "html"
+ elif os.path.exists(os.path.join(proj_dir, "link.txt")):
+ proj_type = "external"
+ try:
+ with open(os.path.join(proj_dir, "link.txt"), "r", encoding="utf-8") as f:
+ external_url = f.read().strip()
+ except Exception:
+ pass
+
+ return templates.TemplateResponse("player.html", {
+ "request": request,
+ "class_name": class_name,
+ "student_name": student_name,
+ "project_name": project_name,
+ "project_type": proj_type,
+ "external_url": external_url
+ })
+
+# ----------------- WebSocket Communication Proxy -----------------
+@app.websocket("/ws/play/{class_name}/{student_name}/{project_name}")
+async def play_project_ws(websocket: WebSocket, class_name: str, student_name: str, project_name: str):
+ await websocket.accept()
+
+ project_path = os.path.join(PROJECTS_DIR, class_name, student_name, project_name, "main.py")
+ if not os.path.exists(project_path):
+ await websocket.send_json({"type": "log", "data": "錯誤: 找不到該專案的 main.py 檔案!\n"})
+ await websocket.close()
+ return
+
+ # Start a local TCP Server with random port for the runner to connect back to
+ connected_event = asyncio.Event()
+ runner_reader = None
+ runner_writer = None
+
+ async def handle_runner_conn(reader, writer):
+ nonlocal runner_reader, runner_writer
+ runner_reader = reader
+ runner_writer = writer
+ connected_event.set()
+
+ tcp_server = await asyncio.start_server(handle_runner_conn, '127.0.0.1', 0)
+ tcp_port = tcp_server.sockets[0].getsockname()[1]
+
+ # Start runner.py as a subprocess
+ runner_script = os.path.join(BASE_DIR, "runner.py")
+ cmd = [sys.executable, runner_script, "--project_path", project_path, "--port", str(tcp_port)]
+
+ # We run the subprocess and redirect stdout/stderr so we don't pollute the main server logs,
+ # and to capture anything missed by monkeypatching
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL
+ )
+
+ print(f"[Server] 啟動執行器進程: {process.pid},監聽本地連接埠: {tcp_port}")
+
+ # Wait for the runner to connect to the TCP port (Timeout after 8 seconds)
+ try:
+ await asyncio.wait_for(connected_event.wait(), timeout=8.0)
+ print(f"[Server] 執行器進程 {process.pid} 已成功建立 TCP 通訊連結。")
+ except asyncio.TimeoutError:
+ print(f"[Server] 錯誤: 執行器進程 {process.pid} 建立 TCP 連結逾時。")
+ await websocket.send_json({"type": "log", "data": "伺服器錯誤: 執行模擬器連線超時!\n"})
+ process.terminate()
+ tcp_server.close()
+ await tcp_server.wait_closed()
+ await websocket.close()
+ return
+
+ # Tasks for bridging the connection
+ # 1. TCP -> WebSocket (Runner to Browser)
+ async def tcp_to_ws():
+ try:
+ while True:
+ # Read Type (4 bytes)
+ type_bytes = await runner_reader.readexactly(4)
+ msg_type = type_bytes.decode('utf-8')
+
+ # Read Length (4 bytes)
+ len_bytes = await runner_reader.readexactly(4)
+ length = int.from_bytes(len_bytes, byteorder='big')
+
+ # Read Payload
+ payload = await runner_reader.readexactly(length)
+
+ if msg_type == 'FRME':
+ # Video Frame JPEG bytes
+ await websocket.send_bytes(payload)
+ elif msg_type == 'LOGS':
+ # Log message UTF-8 text
+ log_text = payload.decode('utf-8', errors='ignore')
+ await websocket.send_json({"type": "log", "data": log_text})
+ elif msg_type == 'EXIT':
+ print(f"[Server] 執行器 {process.pid} 發送 EXIT 訊號,結束連線。")
+ await websocket.send_json({"type": "status", "data": "stopped"})
+ break
+ except asyncio.IncompleteReadError:
+ # Socket closed
+ print(f"[Server] 執行器 {process.pid} 連線中斷 (IncompleteReadError)")
+ except WebSocketDisconnect:
+ pass
+ except Exception as e:
+ print(f"[Server] tcp_to_ws 異常: {e}")
+
+ # 2. WebSocket -> TCP (Browser to Runner)
+ async def ws_to_tcp():
+ try:
+ while True:
+ data = await websocket.receive()
+ if "text" in data:
+ message = json.loads(data["text"])
+ mtype = message.get("type")
+
+ if mtype in ['keydown', 'keyup']:
+ # Forward keyboard inputs
+ payload = json.dumps(message).encode('utf-8')
+ runner_writer.write(b'KEYS' + len(payload).to_bytes(4, byteorder='big') + payload)
+ await runner_writer.drain()
+
+ elif mtype in ['mousemove', 'mousedown', 'mouseup']:
+ # Forward mouse inputs
+ payload = json.dumps(message).encode('utf-8')
+ runner_writer.write(b'MOUS' + len(payload).to_bytes(4, byteorder='big') + payload)
+ await runner_writer.drain()
+
+ elif mtype == 'cv2key':
+ # Forward cv2.waitKey keycode
+ payload = json.dumps({'key_code': message.get('keyCode')}).encode('utf-8')
+ runner_writer.write(b'CV2K' + len(payload).to_bytes(4, byteorder='big') + payload)
+ await runner_writer.drain()
+
+ elif mtype == 'terminal_input':
+ # Forward terminal stdin text
+ text = message.get("data", "")
+ if not text.endswith('\n'):
+ text += '\n'
+ payload = text.encode('utf-8')
+ runner_writer.write(b'INPT' + len(payload).to_bytes(4, byteorder='big') + payload)
+ await runner_writer.drain()
+
+ elif "bytes" in data:
+ # Binary data represents the browser webcam JPEG frame
+ payload = data["bytes"]
+ runner_writer.write(b'CAMF' + len(payload).to_bytes(4, byteorder='big') + payload)
+ await runner_writer.drain()
+ except WebSocketDisconnect:
+ print(f"[Server] 瀏覽器 WebSocket 連線已中斷。")
+ except Exception as e:
+ print(f"[Server] ws_to_tcp 異常: {e}")
+
+ try:
+ # Run both bridging tasks concurrently
+ await asyncio.gather(tcp_to_ws(), ws_to_tcp())
+ finally:
+ # --- Clean up resources ---
+ print(f"[Server] 清理專案執行資源 (Process ID: {process.pid})")
+ process.terminate()
+ try:
+ process.wait(timeout=1.0)
+ except subprocess.TimeoutExpired:
+ process.kill()
+
+ if runner_writer:
+ runner_writer.close()
+ try:
+ await runner_writer.wait_closed()
+ except Exception:
+ pass
+
+ tcp_server.close()
+ await tcp_server.wait_closed()
+
+ try:
+ await websocket.close()
+ except Exception:
+ pass
+
+# Main entry point to run server via python command directly if wanted
+if __name__ == "__main__":
+ import uvicorn
+ import argparse
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--host", default="127.0.0.1", help="Server host")
+ parser.add_argument("--port", type=int, default=8000, help="Server port")
+ args = parser.parse_args()
+
+ uvicorn.run("server:app", host=args.host, port=args.port, reload=True)
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..95383c18cb338ae78a1e047f3b9b57a90cd31ffd
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,895 @@
+
+
+
+
+
+ 得能教育專題展示場 - Telearn Project Showcase
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/logo-light.png b/templates/logo-light.png
new file mode 100644
index 0000000000000000000000000000000000000000..4d3e4ab135df82a2fff261e8452fc73a566ababa
--- /dev/null
+++ b/templates/logo-light.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:70c2212290aa67a591d6afcc9c153a055775a277e08df1a853d77184167761de
+size 25052
diff --git a/templates/player.html b/templates/player.html
new file mode 100644
index 0000000000000000000000000000000000000000..daf9408e3cced8ed0f6e9e2a165e279bb57919b4
--- /dev/null
+++ b/templates/player.html
@@ -0,0 +1,902 @@
+
+
+
+
+
+ Telearn Arcade Player - {{ project_name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Telearn 得能教育
+
+
+
+ 返回大廳
+
+
+ {{ class_name }} / {{ student_name }}
+ {{ project_name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
虛擬硬體設定
+
+
+ 啟用 Webcam 影像串流
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+