📋 專案概述
每週日下午,我和三個朋友在板橋的桌遊咖啡館下棋——國際象棋爲主,偶爾五子棋加賽。上個月的「時鐘危機」徹底毀掉了一盤好棋:我們用手機的倒數臺,輪流用一根食指懸在畫面上方輕點(怕點錯,姿勢像在鋼琴蓋上練音階)。第 38 手,老張還剩四分半,起身去倒水,回來發現手機被服務生「幫忙歸零」了。裁判?這桌沒有裁判。我們盯着那個 0:00 面面相覷,最後用猜硬幣決定和棋——一盤下了兩小時半的棋,死在服務生的好心上面。
其實真正的問題是手機根本不是爲棋桌設計的:
- 計時與操作分離:按停自己的要盯屏幕找按鈕,對方永遠會問「你按了沒?」——讀秒階段一次爭執就是一次心搏。
- 沒有遲滯(delay)與增量(increment):FIDE 的 3 秒遲滯、菲舍爾的 +3 秒增量,手機臺是「扣到零就死」的原始設計。
- 讀秒要靠人念:我們曾四個人輪流念「二十、十九、十八……」,唸錯的人要買飲料。
比賽鍾查了一圈:DGT 3000 約 NT$1,290(正賽規格,但功能鎖定在我用不到的國際象棋規則裡)、LEAP PQ9000 約 NT$1,500(功能多到說明書 40 頁,我們這種假日棋社按錯鍵比按對多)。而我們要的其實很簡單:兩顆按下去「砰」一聲的大按鈕、兩組 25mm 大數字、會滴答的讀秒、超時鎖死——一臺會敲鐘的桌,不需要會聯網。
這臺自制比賽鍾,全部材料 不到 NT$220:
- 6U 街機大按鈕 ×2:黑白雙方各一顆,按下去有明確的「喀」聲與行程,點不用眼睛看
- 雙 TM1637 四位數字 ×2:25mm 紅字,兩米外看得清楚;剩餘 10 秒起每秒滴答、讀秒逐秒鳴叫、超時長鳴+閃爍鎖定
- 五組時限預設:5 分裸棋/5+3 菲舍爾/15+10 快棋/30+30 標準/60 分+10 秒×3 讀秒,SET 鍵切換、+/− 鍵微調
- 遲滯 0/3/5 秒可選:按鍾後有反應時間,慢手不再被 0.2 秒的手速殺死
- 防串鍾邏輯:雙鍵同按不計時、對方回合按無效(必須本人按停自己的鐘,規則從硬件層執行)
- 對局日誌:每手用時與剩餘時間走序列埠輸出——回家接電腦就能覆盤「我這盤每手平均 11 秒,輸在快棋節奏不適」
算一盤賬: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 SuperMini | 1 | ~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 |
🔌 接線圖
ASCII 對照表
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 底座(含配重槽) | 1 | PLA | 淺灰 | ~2.5 hr |
| 斜坡面板(12°,按鈕+數字窗) | 1 | PLA | 黑 | ~3 hr |
| 數字窗框(附遮光裙) | 2 | PLA | 黑 | ~20 min |
| 名牌插卡(黑方/白方) | 2 | PLA | 任意 | ~15 min |
| 配重板(夾硬幣層或實心 100%) | 1 | PLA | 黑 | ~40 min |
| USB 線夾(桌緣固定) | 1 | PETG | 黑 | ~10 min |
列印設定
- 面板務必豎印(Y 軸 90°):按鈕孔是 30.2mm 圓,橫印的層間接縫會被「拍鍾」沿紋裂開——這是第一代用摔壞一顆按鈕後才學到的
- 面板 4 壁厚/底座 3 壁:拍鐘的衝擊力比你想的大,壁厚就是壽命
- 按鈕孔 30.2mm+螺母卡槽:免工具用指尖鎖緊防松螺母
- 數字窗開孔 46×26mm:窗框壓入模組表面,遮光裙遮住數碼管邊緣的漏光,兩米視角才「只見數字不見管」
- 12° 斜坡:坐姿視角下兩塊數字面互相不擋,雙方看對方倒計時也不費脖子
💻 程式碼
/* ============================================================
* 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 棧也在跑)
}
🏗️ 組裝步驟
- 列印與試孔:面板豎印;30.2mm 按鈕孔先用遊標卡尺驗——小於 30mm 用圓銼擴,別硬塞(應力白化會沿孔裂)
- 裝數碼模組:TM1637 背面四角點熱熔膠入窗位,套窗框遮光裙;先接線點亮再合蓋——裝好才發現虛焊要拆整臺
- 鎖 6U 按鈕:面板內側穿入,防塵墊→面板→微動本體→防松螺母手緊+半圈;6×6 輕觸鍵卡進側孔
- 接線:依接線圖 8 支 GPIO+電源共地;所有線以 20cm 以上走線槽收納,按鈕線留 10cm 服務環——拍鐘的震動全吸在環上,不傳到焊點
- 燒機測試:燒入韌體 → 序列埠跑
BLITZ 5+3自我對局 10 手,覈對每手用時與增量加時 - 合蓋與配重:配重板入底座(可夾兩枚 50 元硬幣),4 顆 M3 鎖面板;拍鍾測試——整臺不該在桌上位移超過 2mm
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 開機沒反應/一直進 bootloader | GPIO8/9 被外部電路拉低 | 拔掉數字模組再開機;若正常,檢查 TM1637 板上拉電阻是否在 |
| 一隻鍾全亮亂閃 | CLK/DIO 接反 | 兩線互換即可;模組不會壞(同爲開漏) |
| 按一次跳兩三手 | 去抖不足或微動氧化 | 6U 微動滴幾滴接點清潔劑;仍跳就把去抖從 50 降到 80ms 區段再測 |
| 鍾走得比手機慢一分鐘 | tick 用 millis 累積、循環阻塞 | 本機設計容差 ±30 秒/日;要更準可加 DS3231 RTC 整點對時(tickAccum 預留了接口) |
| 蜂鳴器小聲 | 買了主動式蜂鳴器 | 主動式只能「嘟」不能變調,換被動式(背面無振盪晶片) |
| 讀秒首期就扣期間 | 讀秒慣例差異(棋類不同) | 圍棋應氏制首期免扣(本機做法);象棋讀秒每期間都扣,把 if (c.byoPeriods == 3) 那行改成直接扣 |
🎯 進階延伸
- 升壓獨立供電:18650+TP4056+升壓板,4000mAh 可下三整天約 30 小時
- DS3231 整點對時:比賽要「封棋暫停」時走真實時間,韌體裡
tickAccum已預留對時接口 - 延遲語音:DFPlayer Mini 播「二十、十九」人聲讀秒——棋社之夜的靈魂,朋友們的反應會告訴你這錢該花
- 對局上傳:加 10 行 ESPNow/Telegram,把序列埠日誌推給棋社群——每週排行榜自動更新
- 黑白雙色名版:面板開模時多一層換色,用多色列印做俱樂部 LOGO 面板
📦 檔案清單
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 # 說明文件