📋 專案概述

每週日下午,我和三個朋友在板橋的桌遊咖啡館下棋——國際象棋爲主,偶爾五子棋加賽。上個月的「時鐘危機」徹底毀掉了一盤好棋:我們用手機的倒數臺,輪流用一根食指懸在畫面上方輕點(怕點錯,姿勢像在鋼琴蓋上練音階)。第 38 手,老張還剩四分半,起身去倒水,回來發現手機被服務生「幫忙歸零」了。裁判?這桌沒有裁判。我們盯着那個 0:00 面面相覷,最後用猜硬幣決定和棋——一盤下了兩小時半的棋,死在服務生的好心上面。

其實真正的問題是手機根本不是爲棋桌設計的:

比賽鍾查了一圈:DGT 3000 約 NT$1,290(正賽規格,但功能鎖定在我用不到的國際象棋規則裡)、LEAP PQ9000 約 NT$1,500(功能多到說明書 40 頁,我們這種假日棋社按錯鍵比按對多)。而我們要的其實很簡單:兩顆按下去「砰」一聲的大按鈕、兩組 25mm 大數字、會滴答的讀秒、超時鎖死——一臺會敲鐘的桌,不需要會聯網。

這臺自制比賽鍾,全部材料 不到 NT$220:

算一盤賬:DGT 3000 的 1,290 元裡,品牌與裁判認證約佔一半;本機的 220 元全部花在「按得爽的大按鈕」與「看得清的大數字」上——這兩樣恰恰是棋桌體驗的全部。而且它是我們自己規則的主場:五子棋「40 分+3 秒×3」這種比賽鐘上根本沒有的賽制,加一行預設就有了。

爲什麼選 ESP32-C3?

需求C3 的對應
GPIO 數量(2 數字模組+5 按鈕+蜂鳴=8 支腳)GPIO 充足,且引腳全部支援輸入中斷與內部上拉
成本SuperMini 板約 40 元,整機的錢要留給按鈕和數字管
供電USB-C 一線到底:棋社桌上就有充電頭,沒電插線三秒續賽
時序精度millis() 毫秒計數做鐘面誤差 < 1 秒/4 小時,遠超比賽需要的 ±30 秒/日
不需要的(Wi-Fi/BLE/深睡)棋桌不要聯網、不要 App、不要電池——所以不用 S3/C6 的矽片錢

選型紀律:這臺機器的工作是「數毫秒、響蜂鳴、看按鈕」,三樣全是 GPIO 層的事。C3 是能滿足的最小晶片,多一顆核都是浪費。環境:arduino-esp32 core ≥ 3.0,開發板選 ESP32-C3 Dev Board 或 ESP32C3 Super Mini,USB CDC On Boot: Enabled。

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C3 SuperMini1~40 元USB-C 燒錄與供電同口
數字顯示TM1637 4 位 0.36" 數碼模組(紅)2~30 元共陽極版本,K 開頭模組皆同料
主按鈕6U 街機按鈕 30mm(黑/紅各一)2~30 元帶橙點微動者佳;附螺母與防塵墊
功能鍵6×6 輕觸開關(側向)3~3 元SET/+/−;面板開 6mm 方孔
蜂鳴器12mm 被動蜂鳴器(帶板)1~10 元被動式才能變調做「滴答/長鳴」區別
電源USB-C 1.5m 編織線+5V 2A 頭1~25 元編織線耐纏,棋盒裡滾來滾去不脫皮
固定M3×10 螺絲螺母墊片6 組~8 元按鈕自鎖、殼體重 8 顆
列印料PLA 約 45g—~15 元底座淺灰+面板黑,雙色來自分色
合計~221 元約 DGT 3000 的 1/6

🔌 接線圖

ESP32-C3 SuperMini USB-C 供電 GPIO3 ● GPIO4 ● GPIO5 ● GPIO6 ● GPIO7 ● ● GPIO8 ● GPIO9 ● GPIO0 ● GPIO1 ● GPIO10 A 按鈕(黑方) B 按鈕(白方) SET + − 8:8:8:8 TM1637 ①(黑方) 8:8:8:8 TM1637 ②(白方) 被動蜂鳴器 DIO/CLK ①→GPIO8/9 DIO/CLK ②→GPIO0/1
▲ 按鈕全部接「GPIO → 按鈕 → GND」,韌體內開內部上拉,按下爲 LOW;數字模組 VCC 接 5V、GND 共地。

ASCII 對照表

ESP32-C3 SuperMini 周邊 ┌─────────────┐ │ GPIO3 ●────┼── A 按鈕(6U 黑)→ GND │ GPIO4 ●────┼── B 按鈕(6U 紅)→ GND │ GPIO5 ●────┼── SET 鍵 → GND │ GPIO6 ●────┼── + 鍵 → GND │ GPIO7 ●────┼── − 鍵 → GND │ GPIO8 ●────┼── TM1637 ① DIO (strapping 腳:模組上拉=HIGH,開機安全) │ GPIO9 ●────┼── TM1637 ① CLK (同上) │ GPIO0 ●────┼── TM1637 ② DIO │ GPIO1 ●────┼── TM1637 ② CLK │ GPIO10 ●────┼── 蜂鳴器 I/O(tone() 驅動) │ 5V ●────┼── TM1637 ①② VCC、蜂鳴器 VCC │ GND ●────┼── 全部 GND 共地 └─────────────┘
⚠️ GPIO8/GPIO9 是 C3 的 strapping 腳。本設計之所以能用,是因爲 TM1637 模組的 DIO/CLK 自帶 10kΩ 板上拉、待機爲 HIGH——正好是 SPI boot 的安全電位。切勿在這兩支腳另加下拉電阻。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
底座(含配重槽)1PLA淺灰~2.5 hr
斜坡面板(12°,按鈕+數字窗)1PLA黑~3 hr
數字窗框(附遮光裙)2PLA黑~20 min
名牌插卡(黑方/白方)2PLA任意~15 min
配重板(夾硬幣層或實心 100%)1PLA黑~40 min
USB 線夾(桌緣固定)1PETG黑~10 min

列印設定

💻 程式碼

📐 註解規範:區塊以框注分段;函式以框注說明用途/參數/回傳;行內註解一律解釋「爲什麼」。全系列文章採同一規範。無外部函式庫依賴,arduino-esp32 3.x 直接編譯。
/* ============================================================
 *  ESP32-C3 國際象棋比賽鐘  |  Tournament Chess Clock
 *  ------------------------------------------------------------
 *  功能:雙玩家倒計時;菲舍爾增量 / 遲滯 / 讀秒;末程鳴叫與
 *        超時鎖定;5 組預設 + SET/± 微調;對局日誌走序列埠
 *  硬體:ESP32-C3 SuperMini + TM1637×2 + 6U 按鈕×2
 *        + 輕觸鍵×3 + 被動蜂鳴器
 *  供電:USB-C 5V(棋桌現場用充電寶即可)
 *  相依:arduino-esp32 core >= 3.0(tone() 內建,無外部庫)
 *  ============================================================ */

/* ------------------------------------------------------------
 * 1. 引腳定義 —— 全機接線唯一定義處
 * ------------------------------------------------------------ */
const uint8_t BTN_A   = 3;   // 黑方 6U 按鈕(對 GND,內部上拉)
const uint8_t BTN_B   = 4;   // 白方 6U 按鈕
const uint8_t BTN_SET = 5;   // 預設切換 / 開始
const uint8_t BTN_UP  = 6;   // + 調整
const uint8_t BTN_DN  = 7;   // − 調整
const uint8_t BUZZER  = 10;  // 被動蜂鳴器(tone 驅動)

/* ------------------------------------------------------------
 * 2. 時限預設表 —— 棋社自定義賽制就加在這裡
 * ------------------------------------------------------------ */
struct TimeControl {
  const char*  name;     // 顯示名(序列埠日誌用)
  uint32_t     mainMs;   // 基本時限(毫秒)
  uint16_t     incMs;    // 菲舍爾增量:每手加時(毫秒,0=關閉)
  uint16_t     delayMs;  // 遲滯:每手先倒數不扣時(毫秒,0=關閉)
  uint16_t     byoMs;    // 讀秒:每期間秒數(毫秒,0=不用讀秒制)
  uint8_t      byoCnt;   // 讀秒:期間數
};
TimeControl presets[] = {
  { "BLITZ 5+0",      300UL*60000UL, 0,     0,    0,   0 },
  { "BLITZ 5+3",      300UL*60000UL, 3000,  0,    0,   0 },
  { "RAPID 15+10",    900UL*60000UL, 10000, 0,    0,   0 },
  { "CLASSIC 30+30", 1800UL*60000UL, 30000, 0,    0,   0 },
  { "GO/CHO 60+10x3",600UL*60000UL, 0,     0,    10000UL, 3 }, // 圍棋應氏制:60 分 + 10 秒×3 期間
};
const uint8_t PRESET_N = sizeof(presets) / sizeof(presets[0]);

/* ------------------------------------------------------------
 * 3. TM1637 驅動(bit-bang,無庫)
 *    協議:類 I2C 開漏二線上位機;DIO 空閒 HIGH
 * ------------------------------------------------------------ */
class TM1637 {
  uint8_t dio, clk;
  static constexpr uint8_t SEG[12] = {   // 0-9 與空白/負號的共陽段碼
    0x3F,0x06,0x5B,0x4F,0x66,0x6D,0x7D,0x07,0x7F,0x6F, 0x00, 0x40 };
  void sda(bool hi){ if (hi) pinMode(dio, INPUT_PULLUP);
                     else { pinMode(dio, OUTPUT); digitalWrite(dio, LOW); } }
  void scl(bool hi){ if (hi) pinMode(clk, INPUT_PULLUP);
                     else { pinMode(clk, OUTPUT); digitalWrite(clk, LOW); } }
  void xmit(uint8_t b){
    for (uint8_t i = 0; i < 8; i++) {
      scl(false); sda((b >> i) & 1);       // LSB first,TM1637 規定
      delayMicroseconds(3); scl(true); delayMicroseconds(3);
    }
    sda(true); pinMode(dio, INPUT);        // 放總線讀 ACK(ACK 內容不採用:
    scl(true); delayMicroseconds(5); scl(false);  // 模組偶有黏總線怪事,超時自清比死等穩)
  }
public:
  TM1637(uint8_t d, uint8_t c): dio(d), clk(c) {}
  void begin(){ sda(true); scl(true); send(0x00, 0x8f); }   // 先清屏再開最大亮度
  void send(uint8_t digits[4], uint8_t bright){
    sda(false); scl(false); xmit(0x44);                      // 固定地址寫顯存
    for (uint8_t i = 0; i < 4; i++) {
      sda(false); scl(false); xmit(0xC0 + i); xmit(digits[i]);
      scl(true); sda(true);
    }
    sda(false); scl(false); xmit(0x80 | (bright & 0x0f)); scl(true); sda(true);
  }
  void digits(uint8_t d0,uint8_t d1,uint8_t d2,uint8_t d3,uint8_t b=0xE){
    uint8_t arr[4] = {d0,d1,d2,d3}; send(arr, b);
  }
  static uint8_t segOf(char c){
    if (c >= '0' && c <= '9') return SEG[c - '0'];
    if (c == ' ') return SEG[10];
    return SEG[11];                                           // 其餘以 "-" 代
  }
};
TM1637 tmBlack(8, 9);   // 黑方:GPIO8/9(strapping,靠模組上拉保開機)
TM1637 tmWhite(0, 1);   // 白方:GPIO0/1

/* ------------------------------------------------------------
 * 4. 按鈕(去抖 + 邊緣檢測)
 * ------------------------------------------------------------ */
struct Btn {
  uint8_t pin; uint32_t lastMs = 0; bool prev = true;
  bool pressed(){                              // 按下瞬間回傳一次,50ms 去抖
    uint32_t now = millis();
    if (now - lastMs < 50) return false;       // 6U 微動彈跳約 3~8ms,50 是三倍安全邊際
    lastMs = now;
    bool raw = digitalRead(pin);
    bool hit = prev && !raw;                   // FALLING 邊緣
    prev = raw; return hit;
  }
};
Btn bA{BTN_A}, bB{BTN_B}, bSet{BTN_SET}, bUp{BTN_UP}, bDn{BTN_DN};

/* ------------------------------------------------------------
 * 5. 鐘面狀態機
 * ------------------------------------------------------------ */
enum Mode { M_PRESET, M_READY, M_PLAY, M_OVER };
struct SideClock {
  uint32_t ms;          // 主鍾剩餘(毫秒)
  uint32_t delayLeft;   // 遲滯剩餘(不扣主鍾)
  uint16_t byoPeriods;  // 讀秒剩餘期間數
  uint16_t byoMs;       // 當期剩餘
  bool     inByo;       // 已進入讀秒(主鍾歸零後置位)
  bool     timeout;     // 鎖死旗:超時後只有裁判按 SET 能解
  uint16_t moves;
  uint32_t turnStart;   // 本手開始時間(millis),給日誌
} clk[2];

Mode     mode = M_PRESET;
uint8_t  presetIdx = 1, turn = 0;          // 0=黑走, 1=白走;默認 5+3
uint32_t lastTick = 0, tickAccum = 0;      // 顯示用累積器
uint32_t lastBeepSec[2] = {0, 0};          // 末程滴答防重複

/* 把毫秒渲染到 TM1637:>=10:00 分:秒 / 1:00~9:59 分:秒 / <1:00 純秒 */
void showClock(TM1637& tm, uint32_t ms, bool blinkOn){
  uint32_t s = (ms + 999) / 1000;          // 向上取整:剩 9.4s 顯示 10 而非 9
  char buf[4] = {' ',' ',' ',' '};
  if (s >= 600)      { snprintf(buf,4,"%03lu", s/60 > 999 ? 999 : s/60); } // 三位分+秒
  else if (s >= 60)  { snprintf(buf,4,"%u%02u", s/60, s%60); }
  else if (blinkOn)  { snprintf(buf,4,"%2lu", s); }
  uint8_t colon = (s >= 60) ? 0x80 : 0x00; // 只有 mm:ss 形式點亮冒號
  tm.digits(TM1637::segOf(buf[0]),
            TM1637::segOf(buf[1]) | colon,
            TM1637::segOf(buf[2]),
            blinkOn ? TM1637::segOf(buf[3]) : 0x00);
}

void refreshDisplay(){
  uint32_t now = millis();
  bool blink = ((now / 500) % 2) == 0;               // 讀秒/超時 2Hz 閃
  showClock(tmBlack, clk[0].inByo ? clk[0].byoMs : clk[0].ms,
            !(clk[0].inByo || clk[0].timeout) || blink);
  showClock(tmWhite, clk[1].inByo ? clk[1].byoMs : clk[1].ms,
            !(clk[1].inByo || clk[1].timeout) || blink);
}

/* 主鍾 tick:每 100ms 調用一次,扣除當前回合的鐘 */
void tick100(){
  if (mode != M_PLAY) return;
  SideClock& c = clk[turn];
  if (c.timeout) return;                             // 超時鎖定後停表
  if (c.delayLeft > 0) { c.delayLeft -= 100; return; } // 遲滯期:主鍾靜止
  if (c.inByo) {
    c.byoMs -= 100;
    if (c.byoMs <= 0) {                              // 期間用盡:扣一格讀秒
      if (c.byoPeriods > 0) {
        c.byoPeriods--; c.byoMs = presets[presetIdx].byoMs;
      } else { c.timeout = true; mode = M_OVER; tone(BUZZER, 200, 1500);
               Serial.printf("[GAME] %s 超時!\n", turn ? "白方" : "黑方"); }
    }
    return;
  }
  c.ms = (c.ms > 100) ? c.ms - 100 : 0;
  if (c.ms == 0) {
    if (presets[presetIdx].byoMs) {                  // 有讀秒制:進入讀秒
      c.inByo = true; c.byoMs = presets[presetIdx].byoMs;
      Serial.printf("[BYO] %s 進入讀秒\n", turn ? "白方" : "黑方");
    } else { c.timeout = true; mode = M_OVER; tone(BUZZER, 200, 1500);
             Serial.printf("[GAME] %s 超時!\n", turn ? "白方" : "黑方"); }
  }
}

/* 末程鳴叫:主鍾最後 10 秒逐秒滴答;讀秒期逐秒短響 */
void beepSchedule(){
  for (uint8_t s = 0; s < 2; s++) {
    SideClock& c = clk[s];
    if (c.timeout || mode != M_PLAY) continue;
    uint32_t left = c.inByo ? c.byoMs : c.ms;
    if (c.inByo || (left > 0 && left <= 10500)) {
      uint32_t sec = (left + 999) / 1000;            // 進入「新秒」才響一次
      if (sec != lastBeepSec[s] && sec <= 10) {
        lastBeepSec[s] = sec;
        tone(BUZZER, c.inByo ? 1800 : 1400, 80);     // 讀秒音調更高:閉眼也分得清
      }
    }
  }
}

/* 玩家按鍾:只有「輪到自己」纔有效(規則由硬件執行) */
void pressClock(uint8_t side){
  if (mode == M_READY && side == turn) {             // 第一手:開鍾
    mode = M_PLAY; clk[side].turnStart = millis();
    Serial.printf("[GAME] %s 開始(%s)\n", side ? "白方" : "黑方", presets[presetIdx].name);
    return;
  }
  if (mode != M_PLAY || side != turn) return;        // 對方回合/未開局:靜默忽略
  SideClock& c = clk[turn];
  if (c.timeout) return;
  uint32_t used = millis() - c.turnStart;
  c.moves++;
  Serial.printf("[MOVE] %s #%u 用 %.1fs 剩 %lus\n", turn ? "白" : "黑",
                c.moves, used / 1000.0, (c.inByo ? c.byoMs : c.ms) / 1000UL);
  if (c.inByo) {                                     // 讀秒期按鍾:結算期間
    c.inByo = false; c.byoMs = presets[presetIdx].byoMs; // 期間內完成=續期
    if (c.byoPeriods == 3) c.byoPeriods--;           // 首期不扣(圍棋慣例:第一次讀秒免扣)
  } else {
    if (presets[presetIdx].incMs) c.ms += presets[presetIdx].incMs; // 菲舍爾加時
  }
  c.delayLeft = presets[presetIdx].delayMs;
  turn ^= 1;                                         // 交棒給對方
  clk[turn].turnStart = millis();
  lastBeepSec[turn] = 0;
}

/* 重置兩鍾到預設值(新局/換預設共用) */
void resetClocks(){
  for (uint8_t s = 0; s < 2; s++) {
    clk[s] = { presets[presetIdx].mainMs, presets[presetIdx].delayMs,
               presets[presetIdx].byoCnt, presets[presetIdx].byoMs,
               false, false, 0, 0 };
  }
  turn = 0;                                          // 國際象棋黑方先行約定(棋鍾放黑方左)
}

void setup(){
  Serial.begin(115200);
  delay(1000);                                       // 等 USB-CDC 枚舉
  for (uint8_t p : {BTN_A, BTN_B, BTN_SET, BTN_UP, BTN_DN}) pinMode(p, INPUT_PULLUP);
  tmBlack.begin(); tmWhite.begin();
  tone(BUZZER, 1000, 120);                           // 開機電性自檢(聲音+兩隻鍾)
  resetClocks(); mode = M_PRESET;
  Serial.println("Chess Clock ready. SET=cycle preset, SET hold=start");
}

void loop(){
  /* --- 1. 預設選單:SET 切換,長按 1.5s 開局 --- */
  if (mode == M_PRESET) {
    if (bSet.pressed()) {
      static uint32_t holdT = 0;                     // 簡易長按判定:記錄按下時刻
      holdT = millis();
      bool held = false;
      while (digitalRead(BTN_SET) == LOW) {
        if (millis() - holdT > 1500) { held = true; break; }
        delay(10);
      }
      if (held) { mode = M_READY; refreshDisplay(); tone(BUZZER, 880, 200); }
      else      { presetIdx = (presetIdx + 1) % PRESET_N; resetClocks();
                  Serial.printf("[SET] %s\n", presets[presetIdx].name); }
    }
    if (bUp.pressed())   { presets[presetIdx].mainMs += 60UL*60000UL; resetClocks(); }
    if (bDn.pressed() && presets[presetIdx].mainMs > 60UL*60000UL)
                         { presets[presetIdx].mainMs -= 60UL*60000UL; resetClocks(); }
    // 選單畫面:左鍾顯分鐘、右鍾顯序號(讀秒制則右鍾顯期間數)
    tmBlack.digits(TM1637::segOf('P'), 0, TM1637::segOf('1' + presetIdx), 0);
    tmWhite.digits(0, 0,
      TM1637::segOf('0' + (presets[presetIdx].byoCnt ? presets[presetIdx].byoCnt : 0)), 0);
  }

  /* --- 2. 就緒 / 對局中:雙鍵同按 = 和棋確認回預設畫面 --- */
  if (mode == M_READY || mode == M_PLAY) {
    bool aNow = digitalRead(BTN_A) == LOW, bNow = digitalRead(BTN_B) == LOW;
    if (aNow && bNow) {                              // 雙方同時拍鍾:和棋/誤拍回退
      if (bA.pressed() || bB.pressed()) { resetClocks(); mode = M_PRESET;
        Serial.println("[GAME] 雙鍵同按,回到預設"); }
    } else {
      if (bA.pressed()) pressClock(0);
      if (bB.pressed()) pressClock(1);
    }
  }

  /* --- 3. 超時鎖定:只有 SET(裁判權)能解鎖回預設 --- */
  if (mode == M_OVER) {
    if (bSet.pressed()) { resetClocks(); mode = M_PRESET; }
  }

  /* --- 4. 每 100ms 走一格鍾 + 250ms 刷一次畫面(防數位管閃爍) --- */
  uint32_t now = millis();
  while (now - lastTick >= 100) { tick100(); lastTick += 100; }
  tickAccum += 0;                                    // 保留累積器,接 RTC 時改整點對時
  if (now / 250 != (now - 1) / 250) { refreshDisplay(); beepSchedule(); }
  delay(1);                                          // 讓出 CPU(C3 單核,Wi-Fi 棧也在跑)
}

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作桌 → 開啓 HTML 組裝說明書
  1. 列印與試孔:面板豎印;30.2mm 按鈕孔先用遊標卡尺驗——小於 30mm 用圓銼擴,別硬塞(應力白化會沿孔裂)
  2. 裝數碼模組:TM1637 背面四角點熱熔膠入窗位,套窗框遮光裙;先接線點亮再合蓋——裝好才發現虛焊要拆整臺
  3. 鎖 6U 按鈕:面板內側穿入,防塵墊→面板→微動本體→防松螺母手緊+半圈;6×6 輕觸鍵卡進側孔
  4. 接線:依接線圖 8 支 GPIO+電源共地;所有線以 20cm 以上走線槽收納,按鈕線留 10cm 服務環——拍鐘的震動全吸在環上,不傳到焊點
  5. 燒機測試:燒入韌體 → 序列埠跑 BLITZ 5+3 自我對局 10 手,覈對每手用時與增量加時
  6. 合蓋與配重:配重板入底座(可夾兩枚 50 元硬幣),4 顆 M3 鎖面板;拍鍾測試——整臺不該在桌上位移超過 2mm

🔧 問題排除

問題原因解決方法
開機沒反應/一直進 bootloaderGPIO8/9 被外部電路拉低拔掉數字模組再開機;若正常,檢查 TM1637 板上拉電阻是否在
一隻鍾全亮亂閃CLK/DIO 接反兩線互換即可;模組不會壞(同爲開漏)
按一次跳兩三手去抖不足或微動氧化6U 微動滴幾滴接點清潔劑;仍跳就把去抖從 50 降到 80ms 區段再測
鍾走得比手機慢一分鐘tick 用 millis 累積、循環阻塞本機設計容差 ±30 秒/日;要更準可加 DS3231 RTC 整點對時(tickAccum 預留了接口)
蜂鳴器小聲買了主動式蜂鳴器主動式只能「嘟」不能變調,換被動式(背面無振盪晶片)
讀秒首期就扣期間讀秒慣例差異(棋類不同)圍棋應氏制首期免扣(本機做法);象棋讀秒每期間都扣,把 if (c.byoPeriods == 3) 那行改成直接扣

🎯 進階延伸

📦 檔案清單

chess-clock/
├── src/
│   └── chess-clock.ino          # 本頁完整韌體
├── stl/
│   ├── base.stl                 # 底座(含配重槽)
│   ├── slope-panel.stl          # 12° 斜坡面板(豎印)
│   ├── window-frame.stl         # 數字窗框 ×2
│   ├── nameplate.stl            # 名牌插卡
│   ├── weight-plate.stl         # 配重板
│   └── usb-clip.stl             # 桌緣線夾(PETG)
├── f3d/
│   └── chess-clock.f3d          # Fusion 360 原始檔
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32-C2 光誘捕蚊燈 📚 系列索引 下一篇:ESP32-C6 毫米波午休哨兵 →