📋 专案概述

每周日下午,我和三个朋友在板桥的桌游咖啡馆下棋——國際象棋为主,偶尔五子棋加赛。上个月的「时钟危机」彻底毁掉了一盘好棋:我们用手机的倒数台,轮流用一根食指悬在画面上方轻点(怕点错,姿势像在钢琴盖上练音阶)。第 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 毫米波午休哨兵 →