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