12598 字
63 分钟
三子棋人机对弈机器人:2024 全国大学生电子设计竞赛 E 题实录

四天三夜,三个本科生,一堆舵机和 3D 打印件。

目标是做出一台能跟人下三子棋的装置——机械臂要自己从棋子堆里夹起棋子,放到棋盘上正确的格子里,还得能赢、至少不能输,甚至能发现你偷偷挪动了它下过的棋子。

最后我们拿到了 2024 年全国大学生电子设计竞赛天津赛区本科组三等奖。下面把整个过程拆开讲讲。

三子棋对弈装置整机

一、题目:三子棋游戏装置(E 题)#

这是 2024 年全国大学生电子设计竞赛赛区赛暨模拟电子系统设计专题赛初赛的本科组 E 题。

2024 全国大学生电子设计竞赛赛区赛 · 模拟电子系统设计专题赛初赛赛事与时间本科组 / 高职高专组可选组别第一天上报,之后不可更改7 月 29 日 8:00 开赛8 月 1 日 20:00 结束(四天三夜)结束时上交报告、实物与《登记表》参赛纪律每队严格限制 3 人开赛后不得中途更换队员可用图书资料与网络资源不得在指定场地外设计制作非参赛队员(含教师)必须回避E 题任务(本科组)设计并制作三子棋游戏装置控制机械臂或其他机构放置棋子人机对弈,先连成一线者获胜棋盘由黑色实线围成 9 个方格

任务:设计并制作三子棋游戏装置,能够控制机械臂或其他机构放置棋子,实现人机对弈。

棋盘由黑色实线围成 9 个方格,人和机器分别从各自的棋子放置处拾取棋子并放置到方格中,先将己方的 3 个棋子连成一线(横连、竖连、斜连皆可)即获胜——本质就是井字棋(Tic-Tac-Toe)。

9 个方格按题目给的编号排布,行优先:

列 1列 2列 3
行 1123
行 2456
行 3789

5 号格是正中心,也是评分表里第一个单独成项的考点——因为它是唯一一个”无论棋盘转到哪个角度都必须是中心”的位置。

几个硬性条件:

  • 棋子直径约 22±2mm,棋盘和棋子由选手自带
  • 黑、白棋子在放置处各摆成 1 列
  • 装置放置棋子的过程中不可触压黑实线,放置后不可脱离方格区域
  • 放置一颗棋子的时间不大于 15s
  • 装置应能适应正常室内照明环境,测试时不得有特殊照明条件要求

时间:7 月 29 日 8:00 正式开始,8 月 1 日 20:00 结束——四天三夜。

纪律:每队严格限制 3 人,开赛后不得中途更换队员;可以使用图书资料和网络资源,但不得在竞赛场地外设计制作,不得以任何方式与他人交流,包括教师在内的非参赛队员必须回避

二、评分点:题目真正难在哪#

E 题评分点合计 120 分基础放置能力 · 45 分1 颗黑棋放入 5 号方格 —— 5 分2 黑 2 白依次放入指定方格 —— 20 分棋盘绕中心 ±45° 旋转后仍能放置 —— 20 分博弈对弈能力 · 40 分执黑先行,人第 1 步出错则获胜 —— 20 分人执黑先行,正确放白棋保持不输 —— 20 分容错与附加 · 15 分棋子被变动,能发现并放回原处 —— 10 分其他 —— 5 分设计报告 · 20 分单独成册,与实物一同上交评审
#要求分值
1将任意 1 颗黑棋子放置到 5 号方格5
2将任意 2 黑 2 白依次放置到指定方格中20
3棋盘绕中心 ±45° 旋转后,仍能完成上述放置20
4装置执黑先行对弈,人第 1 步白棋出错则装置能获胜20
5人执黑先行,装置能正确放白棋保持不输棋20
6变动机器下过的棋子位置,装置能自动发现并放回原处10
7其他5
8设计报告20

合计 120 分。把这张表读透,题目的难点就清楚了——真正拉开差距的不是”会下棋”,而是”看得准、放得稳、能容错”

三个最狠的坑

① 棋盘会转(第 3 项,20 分)。 棋盘绕中心旋转 ±45°,意味着视觉系统拿到的是旋转后的格子坐标,机械臂的落子目标点必须跟着做坐标变换。如果代码里把 9 个格子的坐标写死,这一项直接零分。

② 棋子会被挪(第 6 项,10 分)。 人可以在不落新子的情况下,把机器下过的某颗棋子挪个位置,机器轮到时要自己发现并复位,限时 15s。这要求视觉系统不只”识别当前棋盘”,还得记住上一帧的状态并做差分对比

③ 不许压线。「不可触压黑实线」这条最磨人——它会惩罚任何”靠视觉估计落点”的做法。机械臂必须知道得足够准,而且要留安全余量。

另外第 4、5 项其实是两个方向的博弈任务:既要”执黑先行时能抓住人的失误获胜”,又要”人执黑先行时保证不输”。三子棋的先手方只要不犯错必不败,所以后手方(第 5 项)的正确策略是穷举搜索所有局面保证不输——这是个确定性算法问题,不是机器学习问题。

先别往下看,自己推一下:为什么”人执黑先行”时装置只要不输就能拿满分?

三子棋的状态空间极小——合法局面总共只有 5478 个(不含终局),完全可以在几毫秒内穷举完整棵博弈树

既然能穷举,后手方就不需要”聪明”,只需要”正确”:对每一个人的落子,搜索出一步让自己永远不输的应对。这个性质是数学保证的,不依赖对手强弱、不依赖运气。

反过来,“执黑先行时能获胜”(第 4 项)反而不能靠搜索保证——因为先手方最优策略是平局。所以那一项题目加了个前提:「若人应对的第 1 步白棋有错误,装置能获胜」。也就是说,装置要能识别并惩罚人的失误,而不是保证赢。两个考点的数学性质完全不同,这是这份题目设计得挺巧的地方。

三、赛题「说明」里的隐藏约束#

评分表下面还有 6 条「说明」,很多人会跳过,但它们其实直接决定了状态机怎么写

放置规则与约束原题「说明」摘录棋盘与棋子棋盘、棋子由选手自带棋子直径约 22±2mm,材质自定黑白棋子在放置处各摆成 1 列放置要求不可触压黑实线放置后不可脱离方格区域单颗放置时间 ≤ 15s,否则扣分对弈交互约定人下完按唯一指定按钮通知装置同时启动计时,装置落子后亮灯停表第 6 项复位时间同样 ≤ 15s环境要求适应正常室内照明测试时不得要求特殊照明

其中最容易吃亏的是第 4 条:

要求(4)(5)中,人下完 1 步棋后,通过按动装置上的某唯一指定按钮通知装置,同时启动计时;装置下完 1 步棋后,通过亮灯指示,计时停止。

这条把”人和机器的回合交接”变成了一个显式的握手协议:不是靠视觉去猜”人下完了没有”,而是等一个按钮中断。这意味着固件里必须有一个等待按键的状态,并且在这个状态下不能阻塞视觉采样(否则人一按按钮,装置还在读取上一次的图像)。

这条约束反而救了我们

刚开始我们想过”纯视觉判断人是否落子”,即检测棋盘是否发生变化。但这条路有两个坑:一是人移动棋子的过程有中间态,容易误判;二是第 6 项要求”人不动新子、只挪旧子”,纯视觉很难区分”人在下新棋”和”人在挪旧棋”。

题目给了按钮,等于给了我们一个明确的时序锚点——按下按钮那一刻起,棋盘状态就”定格”了,装置可以放心地采样、识别、决策,不用担心人还在动。把一个模糊的感知问题变成了确定的时序问题,这是题目给的便宜,不用白不用。

四、我们的方案#

实物

主控:ATmega328P(Arduino UNO 核心板)

开发工具:Arduino IDE、OpenMV IDE、VSCode、立创 EDA

核心思路是”视觉管感知、主控管决策、舵机管执行”,三块分工,用标准协议把彼此解耦:

三子棋对弈机器人ATmega328P 主控感知层OpenMV 机器视觉(STM32H7)MicroPython 做棋盘与棋子识别4×4 矩阵键盘:落子确认与参数输入决策与调度博弈决策:极小极大穷举搜索路径规划:解算各关节目标角度时序调度:15s 限时与状态机执行层多路 PWM 控制板(IIC 挂载)驱动机械臂各关节舵机3D 打印机械臂拾取与放置棋子交互与显示SPI OLED 显示状态与操作提示UART 把视觉与主控彻底解耦IIC / SPI / GPIO 三条外设链路
模块作用通信方式
OpenMV 机器视觉摄像头(STM32H7 主控)识别棋盘、棋子与落点UART 串口
ATmega328P 主控博弈决策、路径规划、时序调度
SPI OLED 屏幕显示当前状态、提示人操作SPI
多路 PWM 控制板驱动机械臂各关节舵机IIC
4×4 矩阵键盘落子确认、参数输入GPIO 扫描
3D 打印机械臂拾取与放置棋子舵机驱动

这个架构里有两个设计决定值得说:

为什么用 UART 把视觉和主控分开? 因为 OpenMV 上的图像处理是毫秒到几十毫秒级的耗时操作,如果塞进主控同一颗芯片,会跟舵机 PWM 的时序打架。分开之后,OpenMV 只负责输出结论(“5 号格有一颗白棋”这种结构化数据),主控拿到结论直接做决策,两边各跑各的。

为什么用 IIC 接 PWM 控制板而不是主控直接输出 PWM? 机械臂有多个关节,每个舵机一路 PWM。ATmega328P 的硬件定时器资源有限,直接驱动容易顾此失彼。用 IIC 挂一块多路 PWM 控制板,主控只要发”几号通道转到几度”的指令,剩下的交给控制板自己维持波形——把时序负担外包出去

OLED 走 SPI 是因为它刷新频繁、要快;键盘走 GPIO 扫描是因为 4×4 矩阵本来就只需要几个引脚。

4.1 棋盘旋转:一个必须先解决的坐标问题#

第 3 项(20 分)单独考”棋盘转 ±45° 后还能不能放准”,这题的本质是一句话:格子编号是棋盘自己的坐标系,而机械臂活在世界坐标系里,两者之间差一个刚体变换。

设棋盘中心在世界坐标系的位姿为 (cx,cy)(c_x, c_y)、旋转角为 θ\theta,格子边长 ss,那么 9 个格子中心的世界坐标是:

Pworld=R(θ)[(col2)s(row2)s]+[cxcy]P_{\text{world}} = R(\theta)\cdot\begin{bmatrix} (col-2)\,s \\ (row-2)\,s \end{bmatrix} + \begin{bmatrix} c_x \\ c_y \end{bmatrix}

其中 R(θ)R(\theta) 是二维旋转矩阵:

R(θ)=[cosθsinθsinθcosθ]R(\theta)=\begin{bmatrix}\cos\theta & -\sin\theta\\ \sin\theta & \cos\theta\end{bmatrix}

(row,col)(row, col) 是 1~3 的格号行列。代码里唯一允许出现 9 个格子坐标的地方,就是这个函数——其余任何地方要格子坐标,都得调它。我们在复盘时也把这条列为最大教训(见文末)。

旋转角 θ\theta 也不需要额外传感器,直接从视觉识别出的格点连线反解就行:

θ=atan2 ⁣(y3y12, x3x12)\theta = \operatorname{atan2}\!\left(\frac{y_3-y_1}{2},\ \frac{x_3-x_1}{2}\right)

取第 1 号与第 3 号方格中心的连线,它与水平轴的夹角就是 θ\theta。下面这段可以直接用 g++ -std=c++17 编译运行:

grid_to_world.cpp
// 依赖:g++ -std=c++17(无第三方库)
// 把「格子号 → 世界坐标」抽成唯一入口,棋盘旋转只影响 theta 与 center
#include <cmath>
#include <cstdio>
struct Vec2 { double x, y; };
// 棋盘几何:格子边长 s、中心 center、旋转角 theta(弧度)
10 collapsed lines
struct BoardFrame {
Vec2 center{0.0, 0.0};
double s = 30.0; // 题目给的格子边长量级
double theta = 0.0; // 棋盘绕中心旋转角,±45° 内的任意值
};
// 第 1 号与第 3 号方格中心的连线与水平轴夹角 → 棋盘旋转角
double solve_theta(Vec2 p1, Vec2 p3) {
return std::atan2(p3.y - p1.y, p3.x - p1.x);
}
// 唯一的坐标入口:row/col 取 1..3
Vec2 grid_to_world(const BoardFrame& f, int row, int col) {
const double lx = (col - 2) * f.s; // 棋盘自身坐标系下的偏移
const double ly = (row - 2) * f.s;
const double c = std::cos(f.theta), sn = std::sin(f.theta);
return Vec2{
f.center.x + c * lx - sn * ly,
f.center.y + sn * lx + c * ly,
};
}
// ⚠️ 早期版本(已废弃):把 9 个格子的世界坐标写死成一张表。
// 棋盘只要一转(theta ≠ 0),这张表整体失准,第 3 项直接零分。
// 保留在这里做对照——它就是下面正文里说的「1 号格被算到 (70, 50)」那个版本:
// const double GRID[9][2] = {
// { 70, 50}, {100, 50}, {130, 50},
// { 70, 80}, {100, 80}, {130, 80},
// { 70, 110}, {100, 110}, {130, 110},
// };
int main() {
BoardFrame f;
f.center = {100.0, 80.0};
// 不加旋转:5 号格应当正好落在棋盘中心
Vec2 a = grid_to_world(f, 2, 2);
std::printf("theta=0 5号格 -> (%.3f, %.3f)\n", a.x, a.y);
// 棋盘转 30°:中心仍应在中心,但 1 号格的坐标必须跟着转
f.theta = 30.0 * M_PI / 180.0;
Vec2 b = grid_to_world(f, 2, 2);
Vec2 c = grid_to_world(f, 1, 1);
std::printf("theta=30° 5号格 -> (%.3f, %.3f)\n", b.x, b.y);
std::printf("theta=30° 1号格 -> (%.3f, %.3f)\n", c.x, c.y);
// 自检:中心格在任意 theta 下都必须等于棋盘中心(容差 1e-9
const bool center_ok =
std::fabs(b.x - f.center.x) < 1e-9 && std::fabs(b.y - f.center.y) < 1e-9;
std::printf("中心格不随 theta 漂移: %s\n", center_ok ? "PASS" : "FAIL");
return center_ok ? 0 : 1;
}

跑出来是这样:

theta=0 5号格 -> (100.000, 80.000)
theta=30° 5号格 -> (100.000, 80.000)
theta=30° 1号格 -> (89.019, 39.019)
中心格不随 theta 漂移: PASS

这段代码看着平淡,但它就是第 3 项那 20 分的全部内容。如果坐标写死(即忽略 θ\theta),1 号格会被算到 (70,50)(70, 50),而正确位置是 (89.02,39.02)(89.02, 39.02)——差了约 22 个单位,相当于大半个格子宽,机械臂落子必压线。

4.2 博弈决策:三子棋能穷举,所以不要用启发式#

三子棋的局面数少到可以完全穷举:合法局面 5478 个,博弈树在没有剪枝的情况下也就 50 万量级。ATmega328P 是 16MHz 的 8 位机,但这个规模它完全跑得动

所以我们没有用任何启发式评分函数,直接上极小极大 + Alpha-Beta 剪枝

value(s)={+1s 机器胜1s 人胜  0s 平局\text{value}(s)=\begin{cases} +1 & s\ \text{机器胜}\\ -1 & s\ \text{人胜}\\ \ \ 0 & s\ \text{平局}\end{cases}

搜索策略就是标准的极小极大:机器层取最大值、人层取最小值,等价于假设对手永远走最优

best(s)=argmaxaA(s) minaA(s) value(s)\text{best}(s)=\arg\max_{a\in A(s)}\ \min_{a'\in A(s')}\ \text{value}(s'')

代码如下,同样是标准 C++、可直接编译:

tictactoe_ai.cpp
// 依赖:g++ -std=c++17
// 极小极大 + Alpha-Beta:三子棋局面数极小,穷举即最优,不需要启发式
#include <algorithm>
#include <array>
#include <cstdio>
#include <limits>
constexpr char EMPTY = '.';
// 平局打破用:仅在「多个走法分数相同」时生效,不参与胜负判定。
// 三子棋的最优结果是平局,所以「好几步分数都一样」是常态,
// 需要一个固定偏好让行为可复现(否则会永远选第一个空格)。
constexpr int PREF[9] = {2,1,2, 1,3,1, 2,1,2}; // 中心 3 > 角 2 > 边 1
6 collapsed lines
// 8 条连线:3 行 + 3 列 + 2 斜
constexpr int LINES[8][3] = {
{0,1,2},{3,4,5},{6,7,8},
{0,3,6},{1,4,7},{2,5,8},
{0,4,8},{2,4,6},
};
// 返回 +1 机器胜 / -1 人胜 / 0 未结束
int winner(const std::array<char,9>& b, char me, char human) {
for (auto& L : LINES) {
if (b[L[0]] != EMPTY && b[L[0]] == b[L[1]] && b[L[1]] == b[L[2]])
return b[L[0]] == me ? +1 : -1;
}
return 0;
}
// 带 Alpha-Beta 的极小极大。depth 只用于在本层内打破平局(越快赢越好)
int minimax(std::array<char,9>& b, char cur, char me, char human,
int depth, int alpha, int beta) {
const int w = winner(b, me, human);
if (w != 0) return w * (10 - depth); // 赢在浅层 = 分数更高
if (std::none_of(b.begin(), b.end(), [](char c){ return c == EMPTY; }))
return 0; // 平局
const bool maximizing = (cur == me);
int best = maximizing ? std::numeric_limits<int>::min()
: std::numeric_limits<int>::max();
for (int i = 0; i < 9; ++i) {
if (b[i] != EMPTY) continue;
b[i] = cur;
const int v = minimax(b, cur == me ? human : me, me, human,
depth + 1, alpha, beta);
b[i] = EMPTY;
if (maximizing) {
best = std::max(best, v);
alpha = std::max(alpha, v);
} else {
best = std::min(best, v);
beta = std::min(beta, v);
}
if (beta <= alpha) break; // 剪枝:这一支对手不会走进来
}
return best;
}
// 返回机器的最优落子格(0..8);棋盘已满返回 -1
int best_move(std::array<char,9> b, char me, char human) {
int best_score = std::numeric_limits<int>::min(), best_idx = -1;
for (int i = 0; i < 9; ++i) {
if (b[i] != EMPTY) continue;
b[i] = me;
const int v = minimax(b, human, me, human, 1,
std::numeric_limits<int>::min(),
std::numeric_limits<int>::max());
b[i] = EMPTY;
if (best_idx < 0 || v > best_score ||
(v == best_score && PREF[i] > PREF[best_idx])) {
best_score = v;
best_idx = i;
}
}
return best_idx;
}
int main() {
// 局面 1:机器(X)该走,威胁两条线 → 必须占中心
std::array<char,9> b1 = {
'X','.','.',
'.','.','.',
'.','.','X',
};
std::printf("局面1 机器最优落子: %d(应为中心 4)\n", best_move(b1, 'X', 'O'));
// 局面 2:人(O) 已有两子连线威胁第 1 行,机器自己没有立即取胜的走法 → 必须堵
std::array<char,9> b2 = {
'O','O','.',
'.','X','.',
'.','.','X',
};
std::printf("局面2 机器最优落子: %d(应为 2,堵住第 1 行)\n", best_move(b2, 'X', 'O'));
// 局面 3:空棋盘先手,所有走法都是平局 → 由 PREF 打破平局,落在中心
std::array<char,9> b3 = {
'.','.','.',
'.','.','.',
'.','.','.',
};
std::printf("局面3 空盘先手: %d(应为 4,中心)\n", best_move(b3, 'X', 'O'));
return 0;
}

实测输出:

局面1 机器最优落子: 4(应为中心 4)
局面2 机器最优落子: 2(应为 2,堵住第 1 行)
局面3 空盘先手: 4(应为 4,中心)
为什么不用”评分函数 + 权重表”

我们一开始写过一版”中心 3 分、角 2 分、边 1 分”的启发式,跑起来看着挺聪明,但在第 4 项(惩罚人的失误)上表现不稳定——因为启发式在对手失误时不一定能找到那条唯一的取胜线

穷举的价值就在这里:只要局面规模允许,穷举给出的不是”好棋”,而是”正确棋”。它的正确性不依赖权重调得好不好,也不依赖对手的强度。代价只是 CPU 时间,而三子棋的规模让这个代价几乎为零。

顺便,Alpha-Beta 的剪枝在这里不是可有可无的优化——它把实际搜索节点数砍掉了大约一个数量级,让 ATmega328P 能在几十毫秒内完成一步决策,远低于 15s 的限时。

4.3 「棋子被挪」:状态差分,不是重新识别#

第 6 项(10 分)的坑在于:人不动新子、只把机器下过的某颗棋子挪个位置。装置必须发现并复位

这不能靠”重新识别一遍棋盘”解决——重新识别只会得到”当前棋盘长这样”,而装置需要知道的是”哪一颗变了”。所以必须维护上一帧的状态快照:

board_diff.py
# 依赖:Python 3.10+,无第三方库
# 棋盘状态差分:找出被人挪动过的棋子,并给出应有的位置
from dataclasses import dataclass, field
@dataclass
class Board:
5 collapsed lines
"""cells[i] 为该格的棋子,'.' 表示空,'B'/'W' 为黑/白。"""
cells: list[str] = field(default_factory=lambda: ["."] * 9)
def board_key(self) -> str:
return "".join(self.cells)
def place(self, idx: int, stone: str) -> None:
if not 0 <= idx < 9:
raise IndexError(f"格号越界: {idx}")
self.cells[idx] = stone
def diff(prev: Board, now: Board) -> list[tuple[int, int, str]]:
"""对比两帧棋盘。
返回 [(from_idx, to_idx, stone)]:
from_idx 是上一帧它所在的位置,to_idx 是现在的位置。
棋子被挪走(原地变空)且别处多出来 → 一对 from/to。
"""
moved: list[tuple[int, int, str]] = []
# ① 哪些格子"少了子"
missing = [i for i in range(9)
if prev.cells[i] != "." and now.cells[i] == "."]
# ② 哪些格子"多了子"(原来空着,现在有子)
appeared = [i for i in range(9)
if prev.cells[i] == "." and now.cells[i] != "."]
# ③ 按棋子颜色配对:人的小动作通常是"把某颗子换个格子"
used: set[int] = set()
for m in missing:
for a in appeared:
if a in used:
continue
if prev.cells[m] == now.cells[a]:
moved.append((m, a, now.cells[a]))
used.add(a)
break
return moved
if __name__ == "__main__":
prev = Board()
# 机器(B)之前下在 4 和 8,人(W)在 0
prev.place(4, "B"); prev.place(8, "B"); prev.place(0, "W")
now = Board()
# 人把机器在 4 号格的棋子挪到了 6 号格(不落新子)
now.place(6, "B"); now.place(8, "B"); now.place(0, "W")
moved = diff(prev, now)
print("检测到被挪动的棋子:", moved)
for frm, to, stone in moved:
print(f" {stone}{frm} 号格被挪到 {to} 号格 → 应复位到 {frm} 号格")
assert moved == [(4, 6, "B")], "差分结果不符预期"
print("自检通过 PASS")

输出:

检测到被挪动的棋子: [(4, 6, 'B')]
B 从 4 号格被挪到 6 号格 → 应复位到 4 号格
自检通过 PASS

拿到 (4, 6, 'B') 之后,装置要做的就是把 6 号格的棋子夹起来放回 4 号格——复位动作和正常落子走的是同一套路径规划代码,只是目标坐标不同。这也是为什么我们把”格子号 → 世界坐标”抽成了单一入口(4.1 节)。

五、一次落子的完整流程#

把上面几块拼起来,一次完整的落子是这样一个闭环:

一次落子的完整流程① 人落子并通知按下唯一指定按钮启动 15s 计时② 视觉识别OpenMV 采集棋盘图像定位 9 个方格与现有棋子③ 坐标变换估计棋盘旋转角格子号 → 物理坐标④ 博弈决策极小极大搜索当前局面输出目标方格号⑤ 路径规划与执行逆运动学解算关节角IIC 下发多路 PWM⑥ 状态更新与校验记忆本帧棋盘状态与上一帧差分,发现被变动的棋子

固件里的主循环大致是这些状态,按优先级排(列表是可勾选的任务列表形式,方便对照实现进度):

  • IDLE:等待人按按钮,同时 OLED 提示”请落子”
  • CAPTURE:按键中断触发,OpenMV 拍一帧并输出 9 格占用情况
  • ALIGN:由格点连线反解棋盘旋转角 θ\theta 与中心 (cx,cy)(c_x,c_y)
  • DECIDE:极小极大搜索出目标格号
  • PLAN:逆运动学解算各关节目标角,检查是否会扫到已落棋子
  • EXECUTE:经 IIC 下发多路 PWM,依次完成”取子 → 移动 → 放下”
  • VERIFY:落下后再拍一帧,确认棋子确实在目标格内且未压线
  • RECOVER:若 VERIFY 失败,退回复位流程(我们的最终版只做到了报警提示,没做自动重试)

最后一个 RECOVER 没做完,是我们这届的一个遗憾——但它在赛题里其实没有单独分值,属于”能加分但不致命”的部分。

六、我在队里做的事#

我是项目队长,具体负责:

  • Arduino 核心板的 C++ 代码编写——博弈逻辑与时序调度这部分
  • 设计各个模块的通讯——上面那张表里的 UART / SPI / IIC 三条链路,都是我定的协议格式
  • 画 PCB 电路图(立创 EDA)
  • 基于 STM32 的 OpenMV 机器视觉模块的 MicroPython 代码编写
  • 机械臂的机械结构设计并 3D 打印(拓竹科技)

四天三夜最大的感受是”大部分时间边设计 C++ 框架边等 3D 打印机打印机械臂”——硬件迭代的周期真的是物理时间,改一版结构件就要等一轮打印,这跟写代码的节奏完全不是一回事。硬件项目里,等待本身就是工作的一部分。

七、结果#

获奖证书

2024 年全国大学生电子设计竞赛天津赛区竞赛,我们的《三子棋游戏装置(E 题)》作品荣获本科组三等奖

  • 证书编号:TJ-2024-DZSJJS-0460
  • 发证单位:天津市教育委员会

装置的运行实录(三个视角):

关于视频

这三段视频已随站点一起托管(public/videos/chess-robot-edc-2024/),不依赖任何外部站点。

注意 Astro 不会把文章目录里的视频文件打包进产物,所以视频必须放在 public/ 下、用 /blog/videos/xxx.mp4 这样的绝对路径引用;留在文章目录里引用是会 404 的。

另外 public/ 下的文件不会被 Astro 加哈希,路径不变的话读者浏览器会一直放旧片,所以 URL 上挂了一串 ?v=YYYYMMDDHHMM 做缓存击穿。各段视频的版本号互相独立——换哪一段就只推哪一段的号,避免让读者重下没动过的片子。

第一段是竖屏素材(源片 364×480),所以额外挂了一条 max-width:364px 的内联样式:<video width="100%"> 会把它拉到正文栏宽(约 703px)并按比例撑到 927px 高,既超出屏幕又把 364px 的源片放大近 2 倍而变糊。限到源片原生宽度后是 1:1 显示、不重采样,高度 480px 也和三段视频里的另外两段(约 395px)接近。

当时的制作过程我也发在了小红书上:

📕 2024 电赛 E 题象棋机器人 - 小红书

八、工程文件与 3D 模型#

这一节还在补

赛题作品的文件当时散在几台电脑里,正在逐步整理归档。计划补上这些,先列在这里免得忘:

  • 机械臂结构件 3D 模型(STEP / STL,含各关节装配关系)
  • PCB 原理图与 Gerber(立创 EDA 工程导出)
  • 主控固件(Arduino 工程,含博弈与时序调度)
  • OpenMV 视觉脚本(MicroPython)
  • BOM 物料清单(舵机型号、PWM 控制板、结构件耗材)

补齐之前,本文 4.1~4.3 节的核心算法(坐标变换、极小极大、状态差分)都是可直接编译/运行的独立文件,能先拿来跑通逻辑。

设计报告本身属于竞赛提交材料,按赛区规定不单独公开;上面这份清单是装置的可复现部分,不涉及报告的正文。

九、回过头看#

现在复盘,如果重做一次,我会改三件事:

  1. 先解决”放得准”,再解决”下得聪明”。 评分表里 65 分(第 1、2、3、7 项)都在考基础放置能力,只有 60 分跟博弈策略有关。我们前期花了不少时间在博弈算法上,其实基础放置的稳定性才是拿分的大头。
  2. 把坐标系统一抽象出来。 棋盘旋转那题本质是坐标变换,如果一开始就把”格子号 → 物理坐标”做成一个函数(就像 4.1 节那样),而不是散落在各处写死,第 3 项会轻松很多。
  3. 给”棋子被挪”留状态机。 第 6 项需要记忆上一帧棋盘状态,这在一开始就该作为数据模型的一部分设计进去——我们当时是快比赛结束才补的,补得很狼狈。
GitHub 上的提醒写法

上面这几条经验教训,如果是在 GitHub 的 issue 或 PR 里追进度,可以直接用原生语法写成任务列表:

> [!TIP]
> 这三条是"如果重做"的改进项,不是 bug。用复选框记进度最直观。
- [x] 优先保证基础放置稳定性
- [x] 坐标变换抽成单一函数
- [ ] 状态差分纳入数据模型

不过说到底,原文那句总结我至今认同——“学会更多,成长才是最好的成果。”

四天三夜教会我的不是把某个算法写得多漂亮,而是在时间、硬件、体力三重约束下,怎么排优先级、怎么在”能跑的丑方案”和”优雅的烂尾”之间选前者

十、参考与延伸#

这套装置用到的三个上游项目,都是各自领域里”最小可复现”的代表:

openmv
/
openmv
Waiting for api.github.com...
00K
0K
0K
Waiting...
adafruit
/
Adafruit-PWM-Servo-Driver-Library
Waiting for api.github.com...
00K
0K
0K
Waiting...
arduino
/
ArduinoCore-avr
Waiting for api.github.com...
00K
0K
0K
Waiting...
  • OpenMV 固件与 IDE——find_blobs / find_lines 那套视觉算子就是它提供的,方格的识别与定位直接建在上面
  • Adafruit PWM Servo Driver——多路 PWM 控制板的参考驱动,IIC 写寄存器控制各通道脉宽
  • ArduinoCore-avr——ATmega328P 的官方核心,硬件定时器与中断的用法都在里面

技术栈:ATmega328P(Arduino UNO)· OpenMV(STM32H7,MicroPython)· UART / SPI / IIC · OLED · 4×4 矩阵键盘 · 多路 PWM 舵机控制 · 3D 打印机械臂(拓竹)

三子棋人机对弈机器人:2024 全国大学生电子设计竞赛 E 题实录
https://ura2039.xyz/blog/posts/chess-robot-edc-2024/
作者
LEGEND热热
发布于
2024-07-27
许可协议
CC BY-NC-SA 4.0
评论