ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32双人五子棋课程设计:触摸屏校准、状态机与胜负判定

STM32双人五子棋课程设计:触摸屏校准、状态机与胜负判定 简介这套基于STM32的双人五子棋课程设计资源面向嵌入式、自动化、电子信息等专业的课程设计与毕设场景提供可运行并通过测试的完整工程。压缩包共259个文件大小6.42MB主要包含STM32标准外设库的h/c源码、Keil工程配置文件uvprojx、编译产物hex/axf以及README说明文档。工程基于标准外设库实现了LCD棋盘绘制、触摸输入与胜负判定双人对战流程完整代码结构清晰可直接打开工程烧录验证也便于二次修改实现计分、悔棋、人机对战等扩展功能。资源代码经过运行成功后才上传质量相对可靠可作为入门STM32图形界面与触摸交互开发的参考案例。目前已有1089人学习/下载适合需要快速完成嵌入式课设或初期立项演示的开发者学习使用。1. 先把“双人五子棋”当成一块完整的嵌入式课程设计拼图STM32 双人五子棋这个题目看起来只是把五子棋的逻辑跑在开发板上但实际做起来会发现它横跨了嵌入式开发里最常考的几条主线GPIO 与中断、定时器与 PWM、液晶屏驱动、触摸输入解析、状态机设计以及最基本的博弈逻辑。课程设计给的是“双人”意味着你不需要去写 AI 估值函数和搜索树真正的工作量都压在“人机交互”和“对局流程的稳定性”上。多数人做这个题目方案是 STM32F103 系列最小系统板、一块 SPI 接口的 TFT 液晶屏常用 2.4 寸或 2.8 寸分辨率 320×240、电阻触摸或四线电阻屏再用几个 GPIO 扩展按键做辅助操作。整体难度不高但要把触摸坐标换算、落子命中检测、胜利判定、悔棋和重开这些功能整理清楚代码量通常在一千五百行到两千五百行之间足够撑起一篇课程设计报告也适合作为蓝桥杯嵌入式赛项之外的练手项目。这篇文章不打算讲怎么把整个工程从零搭出来——那会很长而且 Keil 工程的配置属于基础能力。我打算把重点放在“你真正需要自己想清楚的那几个环节”触摸屏坐标怎么校正和映射、棋子绘制与胜负判定怎么写才不容易出 bug、对局状态机怎么设计才能让“双人轮流落子”这个核心流程不出乱子以及如何把代码拆成 UI 层和逻辑层方便测试。最后给出一个串口调试和 Modbus 扩展的思路方便你把它从课程设计升级成可演示的作品。整个过程用到的代码片段都按“能直接抄进工程”的标准来写但会标注清楚哪些参数需要根据你的屏幕和引脚重新调整。2. 硬件选型与接线从“能亮屏”到“能落子”的底层准备做 STM32 双人五子棋先要在硬件上决定三件事主控选谁、屏幕怎么接、触摸和按键怎么分配。这三件事直接决定了后面代码怎么写也决定了你焊板子的时候会不会跳线跳到头大。2.1 主控与屏幕驱动的选择原则SPI 屏优先FSMC 是加分项对于课程设计级别主控我推荐 STM32F103C8T6 或者 STM32F103RCT6。前者便宜、资料多适合最小系统板 独立 TFT 屏后者多出来的引脚和 Flash 容量能让你从容地加功能比如存棋谱、加音效。如果你用的是正点原子或野火的开发板板上已经带了 TFT-LCD 接口那直接用 FSMC 方式驱动会非常流畅因为 FSMC 写屏幕像写内存一样不需要软件模拟时序。如果是自己买的最小系统板 独立模块SPI 接口的屏更现实因为引脚占用少——完整驱动一块 2.4 寸 SPI 屏只需要 SCK、MOSI、CS、DC数据/命令选择、RST、背光一共 6 根信号线加上电源和地8 根就够。比较稳妥的组合是STM32F103C8T6 2.4 寸 SPI TFTILI9341 或 ST7789 控制器 XPT2046 电阻触摸芯片。ILI9341 的驱动代码网上能找到很多版本但我的建议是你自己把初始化序列精简一遍去掉不用的 Gamma 校正和电源序列只保留必要的 sleep out、display on 和像素格式设置这样屏幕唤醒更快代码也更清爽。SPI 速率先设到 18MHz 左右如果显示有花屏再往下调。不要一上来就开 36MHz有些杜邦线质量扛不住那么高的速率。2.2 触摸屏接线与 GPIO 规划表这 8 个引脚别接错以 SPI 屏 XPT2046 触摸为例标准四线电阻触摸的 T_IRQ、T_DO、T_DIN、T_CS、T_CLK 要各占一个 GPIO。如果你买的屏模块是“SPI 接口 触摸合体”的那种触摸控制器的引脚通常和屏幕的 SPI 共用时钟和数据线只是 CS 分开。这种接法省引脚但驱动时要小心写完屏幕数据之后不能立刻去读触摸需要把片选切到触摸芯片中间加一点延时否则两个外设会互相干扰。下面是我在课程设计里常用的一组引脚规划你可以直接用也可以按自己板子的丝印调整功能引脚说明SPI1_SCKPA5接屏幕与触摸共用时钟SPI1_MOSIPA7共用数据线屏幕写数据SPI1_MISOPA6触摸读数据屏幕不用TFT_CSPA4屏幕片选低有效TFT_DCPA3数据/命令选择TFT_RSTPA2复位低有效TCH_CSPA1触摸片选低有效TCH_IRQPA0触摸中断按下时低电平按键方面如果屏幕触摸够灵敏可以只保留一个“确认/落子”按键和一个“悔棋”按键分别接 PB0 和 PB1外部加上拉电阻按下为低电平用 EXTI 外部中断读取。如果触摸屏在课程设计答辩现场偶尔抽风这两个物理按键就是你的保底方案——至少能让评委看到棋能下下去。提示PA0 和 PA1 在 F103 上可以复用为 ADC 和 TIM2 的通道但在这个项目里优先给触摸用。编引脚表的时候先把所有功能列出来再分配别等焊完线才发现某个引脚已经被占用。2.3 触摸校正的最小算法四点校准比单点准得多电阻触摸屏的痛点在于坐标漂移你点屏幕左上角读回来的可能是 (120, 80) 而不是 (0, 0)。原因是触摸屏的 AD 值范围和液晶屏的分辨率之间是线性映射关系但这两个坐标系的原点、比例尺、甚至旋转方向都不一致。如果不做校正直接拿触摸 AD 值去和屏幕像素坐标比对落子位置会有半个格子的偏差对五子棋这种需要精确点格的场景来说完全不可用。最常用的做法是四点校正。在屏幕的四个角落附近显示四个十字标记让用户依次点上去拿到四组 (触摸原始坐标, 屏幕目标坐标)然后套用仿射变换公式X_screen A * X_touch B * Y_touch C Y_screen D * X_touch E * Y_touch F六个未知数四个点列出八个方程用最小二乘求近似解。实际做的时候不用真的去解矩阵取左上和右下两个点先算缩放比例和偏移就够了四点校准是为了把屏幕旋转和轻微梯形失真也修正掉。完整的最小二乘解算代码比较长课程设计里我通常用简化的两段式映射——先根据触摸坐标落在屏幕的哪个象限判断方向再线性映射——效果也很稳定。下面这段代码示意了简化映射的核心逻辑实际使用时要替换成你自己读到的触摸 AD 上下限/* 触摸坐标到屏幕像素坐标的线性映射 */ uint16_t touch_to_screen_x(uint16_t touch_x, uint16_t touch_y) { /* 假设触摸原始坐标范围: x 0-4000, y 0-4000 */ /* 屏幕分辨率: 240 宽 (x), 320 高 (y) */ /* 注意: 电阻触摸屏的 y 方向和屏幕的 y 方向通常相反 */ uint16_t screen_x (touch_y - TOUCH_X_MIN) * 240 / (TOUCH_X_MAX - TOUCH_X_MIN); uint16_t screen_y 320 - (touch_x - TOUCH_Y_MIN) * 320 / (TOUCH_Y_MAX - TOUCH_Y_MIN); if (screen_x 240) screen_x 239; if (screen_y 320) screen_y 319; return screen_x; }这段代码的核心在于交叉映射——读取的 touch_y 映射到屏幕的 x 方向touch_x 映射到屏幕的 y 方向同时取反。这是四线电阻屏的典型特性X 轴和 Y 轴的物理方向和像素坐标轴的对应关系取决于你的屏幕安装方向。如果你的屏幕读出来方向不对先把这一步调对再往下做否则后面的所有命中检测都是错的。提示触摸校正参数最好存入 STM32 的 Flash 内部比如占据最后两个扇区避免每次开机都要重新校准。掉电保存只需要调用一次 HAL_FLASH_Program 和 HAL_FLASH_Unlock 相关的接口代码量不大但非常实用。3. 棋盘建模与绘制用 15×15 的二维数组撑起全部对局逻辑五子棋的核心数据结构特别简单一个 15×15 的二维数组。每个元素的值表示该位置的状态——0 表示空1 表示黑子2 表示白子。但简单不意味着可以随便写因为围绕这个数组的所有操作——绘制、落子、判定胜负、悔棋——如果不在设计阶段统一约定好后面去耦合会非常痛苦。3.1 棋盘点位到像素坐标的换算公式一格 20 像素是安全值五子棋棋盘画在 320×240 的横屏还是 240×320 的竖屏直接决定格子大小。我建议你用横屏 320×240棋盘预留左侧 10 像素边距顶部留出 20 像素显示当前轮到谁剩下的区域画 15×15 的网格。这样算下来水平方向 300 像素分 14 个间隔每个格子大约 21 像素垂直方向 220 像素分 14 个间隔约 15.7 像素。为了画棋子方便格子大小最好取整数。我常用的方案是格子大小 20 像素棋盘起点 (10, 30)这样水平方向 15 个点从 10 到 290垂直方向从 30 到 310——超过 320×240 了所以这个方案只适合竖屏。如果你用横屏就把棋盘旋转 90 度重新计算。这里给出一组在竖屏 240×320 下的可靠参数棋盘起点: (20, 40) 格子大小: 20 像素 15 条横线 y 坐标: 40, 60, 80, ..., 320 (共 15 条最后一条在 320 处) 15 条竖线 x 坐标: 20, 40, 60, ..., 300 (共 15 条最后一条在 300 处) 棋子直径: 18 像素 (要比格子略小视觉上留出间隙)对应的坐标换算公式为#define GRID_SIZE 20 #define BOARD_X0 20 #define BOARD_Y0 40 /* 棋盘下标(row, col) - 像素坐标 */ uint16_t get_pixel_x(uint8_t col) { return BOARD_X0 col * GRID_SIZE; } uint16_t get_pixel_y(uint8_t row) { return BOARD_Y0 row * GRID_SIZE; } /* 像素坐标 - 棋盘下标 */ /* 注意先做边界判断再转换防止越界写数组 */ uint8_t get_board_col(uint16_t px) { if (px BOARD_X0 - GRID_SIZE/2) return 0xFF; /* 无效值 */ if (px BOARD_X0 14 * GRID_SIZE GRID_SIZE/2) return 0xFF; return (px - BOARD_X0 GRID_SIZE/2) / GRID_SIZE; } uint8_t get_board_row(uint16_t py) { if (py BOARD_Y0 - GRID_SIZE/2) return 0xFF; if (py BOARD_Y0 14 * GRID_SIZE GRID_SIZE/2) return 0xFF; return (py - BOARD_Y0 GRID_SIZE/2) / GRID_SIZE; }这套换算的核心逻辑是“无条件的四舍五入太危险应该先判断范围再取整”。get_board_col 函数里先检查触摸坐标是否落在棋盘区域向外扩大半个格子的范围内如果不在就直接返回 0xFF 表示无效这样触摸到边框外时不会算出负数下标。提示很多人在这一步直接写(px - 20) / 20取整导致点在两个格子正中间时容易落到错误的格子上。加一个GRID_SIZE/2的偏移再做整数除法就相当于四舍五入了这是最常见的修正手段。3.2 棋子绘制的两种方式全屏重绘和增量绘制绘制棋子最简单粗暴的方式是每次落子后全屏重绘整个棋盘——代码只有几行但刷新一次 320×240 的 SPI 屏大约需要 80 到 150 毫秒如果还叠加了触摸读取和胜负判定体感就是“卡一下”。更合理的做法是增量绘制只画刚落下的那一个棋子。增量绘制很简单根据棋盘下标的行列号算出像素坐标在那个 18×18 的圆形区域内先填充背景色再画圆形。但重复落子或悔棋后原先位置的棋子被擦除时底下如果残留之前棋子的边缘会出现“鬼影”。我的解决办法是维护一个 dirty 标志位每次改变棋盘状态后记录受影响的格子坐标在下一次刷新回调里统一重绘这些格子。typedef struct { uint8_t row; uint8_t col; uint8_t dirty; /* 1 表示需要重绘 */ } dirty_cell_t; dirty_cell_t dirty_cells[32]; uint8_t dirty_count 0; void mark_dirty(uint8_t row, uint8_t col) { if (dirty_count 32) { dirty_cells[dirty_count].row row; dirty_cells[dirty_count].col col; dirty_cells[dirty_count].dirty 1; dirty_count; } } void redraw_dirty_cells(void) { for (uint8_t i 0; i dirty_count; i) { uint8_t row dirty_cells[i].row; uint8_t col dirty_cells[i].col; draw_single_grid(row, col); /* 重绘底色和十字交叉点 */ if (board[row][col] 1) { draw_piece(row, col, BLACK_PIECE); } else if (board[row][col] 2) { draw_piece(row, col, WHITE_PIECE); } } dirty_count 0; }draw_single_grid 函数负责在指定格子坐标上先画底色矩形再画网格线最后调用 draw_piece 画棋子。这样悔棋时只需要把对应格子的状态改成 0 然后标记 dirty重绘后旧棋子就会被底色覆盖。如果屏幕刷新速度特别慢还可以把背景色、网格、棋子的绘制全部改为 DMA 传输——但 STM32F103 的 SPI DMA 需要处理 NSS 片选和 DC 引脚的时序配合复杂度提升明显课程设计不推荐除非你是为了答辩加分。3.3 落子命中检测先判断棋子是否已存在再落子触摸输入最容易被忽视的问题是“落子时手指还压在屏幕上”。电阻屏的物理特性决定了手指没有离开时触摸坐标读出来会持续抖动如果你不判断触摸释放一次按压可能触发两三次落子。所以落子的完整流程是检测到触摸按下读取并校正坐标换算成棋盘行列先检查该位置是否已有棋子如果没有就落子、切换玩家、等待触摸释放最后才进入下一次触摸检测。void handle_touch_input(void) { uint16_t tx, ty; uint8_t row, col; if (touch_pressed() 0) return; /* 无触摸按下 */ read_touch_raw(tx, ty); /* 读 AD 原始值 */ uint16_t px touch_to_screen_x(tx, ty); /* 校正 映射 */ uint16_t py touch_to_screen_y(tx, ty); row get_board_row(py); col get_board_col(px); if (row 0xFF || col 0xFF) return; /* 点击在棋盘外 */ if (board[row][col] ! 0) return; /* 已有棋子拒绝落子 */ board[row][col] (current_player PLAYER_BLACK) ? 1 : 2; mark_dirty(row, col); redraw_dirty_cells(); if (check_win(row, col)) { show_winner(current_player); game_state GAME_OVER; return; } current_player (current_player PLAYER_BLACK) ? PLAYER_WHITE : PLAYER_BLACK; update_status_bar(); /* 显示当前轮到谁 */ while (touch_pressed()); /* 等待手指释放 */ }这个函数的调用时机放在主循环里配合触摸中断置位的一个全局标志位来触发。要注意while (touch_pressed())这一句——如果是纯轮询模式这里卡住会导致整个系统短暂阻塞但考虑到后面本来就要等待用户操作这个阻塞是可以接受的。如果你要在等待释放期间刷新屏幕动画就把这个 while 改成状态机的一个状态效果更好。4. 胜利判定与状态机双人轮流对局里最容易被测试击穿的逻辑五子棋的胜利判定算法本身不复杂但放在嵌入式环境里因为要考虑资源占用和代码可维护性写法上还是有讲究的。4.1 四方向遍历的胜利判定从落子点扩散而不是全盘扫描最简单也最不容易出错的胜利判定方法是从最后一颗落子的位置出发沿着水平、垂直、主对角线、副对角线四个方向分别向两个方向延伸统计连续同色棋子的数量如果某个方向连续数达到 5 就判胜。这个方法比全盘扫描 15×15 的数组要高效得多——最坏情况只需要检查 4 个方向各 4 个格子计算量几乎可以忽略。uint8_t check_win(uint8_t row, uint8_t col) { int8_t dirs[4][2] {{1,0},{0,1},{1,1},{1,-1}}; uint8_t player board[row][col]; if (player 0) return 0; for (uint8_t d 0; d 4; d) { uint8_t count 1; int8_t dr dirs[d][0]; int8_t dc dirs[d][1]; /* 正方向延伸 */ for (int8_t step 1; step 4; step) { int8_t r row dr * step; int8_t c col dc * step; if (r 0 || r 15 || c 0 || c 15) break; if (board[r][c] player) count; else break; } /* 反方向延伸 */ for (int8_t step 1; step 4; step) { int8_t r row - dr * step; int8_t c col - dc * step; if (r 0 || r 15 || c 0 || c 15) break; if (board[r][c] player) count; else break; } if (count 5) return 1; } return 0; }这里的边界检查用r 0 || r 15 || c 0 || c 15完成四个方向分别向正负延伸最多 4 步避免数组越界。注意 dirs 数组里第三组元素是 {1,1}代表主对角线第四组是 {1,-1}代表副对角线方向已经包含了正负两个方向的情况。一个常见误判是把胜利判定写成“遍历整个棋盘检查是否有五连”这在 15×15 的小棋盘上功能没问题但无法复用到一个更通用的接口上——比如将来你给 AI 博弈写搜索函数时需要的是“某个点落子后是否获胜”而不是“全局是否有人获胜”。从这个角度讲从落子点扩散的写法是更合理的基础设施。4.2 游戏状态机空闲、对局、结束、悔棋四个状态的流转双人五子棋不能只有一个“下棋”状态如果不在代码里区分当前处于什么阶段悔棋、重开、回合切换这些功能都会出 bug。我常用的状态定义如下typedef enum { GAME_IDLE 0, /* 初始状态显示主菜单或等待开始 */ GAME_PLAYING, /* 对局中允许落子 */ GAME_OVER, /* 分出胜负等待确认 */ GAME_UNDO_CONFIRM, /* 悔棋确认弹窗 */ } game_state_t; game_state_t game_state GAME_IDLE;整个状态流转的逻辑是开机进入 IDLE按“开始”按键进入 PLAYING对局中每次成功落子后检查是否胜利——胜利则进入 OVER 状态显示胜者并停止落子如果没胜利玩家可以选择悔棋、也可以继续落子。悔棋流程走一个 CONFIRM 状态弹出“是否悔棋”确认后弹出棋盘上最后两步黑方和白方各退一步回到 PLAYING 并切换回正确玩家。状态机实现对课程设计来说不需要引入复杂框架直接在 main loop 里用 switch-case 就行void game_loop(void) { switch (game_state) { case GAME_IDLE: if (button_start_pressed()) { reset_board(); game_state GAME_PLAYING; update_status_bar(); } break; case GAME_PLAYING: handle_touch_input(); handle_button_input(); break; case GAME_OVER: /* 触摸或按键进入重新开始确认 */ if (button_restart_pressed()) { reset_board(); game_state GAME_PLAYING; } break; case GAME_UNDO_CONFIRM: handle_undo_confirm(); break; default: break; } }每个 case 内部的处理函数要保持简短不要在 case 里写大段逻辑。特别是 GAME_PLAYING 里的 handle_touch_input前面已经演示过它的职责非常单一——只处理“触摸落在合法位置”这件事。所有 UI 绘制和状态迁移都通过调用独立的函数完成这样后面测试时把触摸和按键的输入源替换成串口命令也能跑通同样的流程。提示悔棋功能有一个隐蔽的坑——如果一方已经获胜进入 GAME_OVER悔棋应该被禁止因为胜负已定回退到最后一手棋会让胜负状态失效。处理方式是在进入 GAME_OVER 的时候把悔棋按钮置灰或直接屏蔽。4.3 回合控制不是简单的 current_player 翻转双人轮流落子的回合控制表面上只是黑棋下完切白棋、白棋下完切黑棋但实际要考虑几个问题第一如果触碰的是棋盘外的区域不能切换玩家第二如果落子的位置已经有棋子不能切换玩家第三悔棋之后回到的回合是什么要回退到“被悔棋那一步的玩家”而不是简单翻转一次。处理第一和第二种情况只需要在 handle_touch_input 里把玩家切换的代码放在所有合法性检查之后。这个顺序很多人会写错——先切换了 current_player 再检查棋盘状态结果就是空点落子变成了白棋落子。处理悔棋的回合切换需要在悔棋函数里维护一个“最后一步棋的玩家记录”uint8_t last_move_player 0; /* 0: 无记录, 1: 黑, 2: 白 */ void undo_last_move(void) { if (move_history_count 0) return; move_record_t *last move_history[move_history_count - 1]; board[last-row][last-col] 0; mark_dirty(last-row, last-col); last_move_player last-player; move_history_count--; /* 回退后轮到上一步落子的玩家再次落子 */ current_player last_move_player; redraw_dirty_cells(); } void record_move(uint8_t row, uint8_t col, uint8_t player) { if (move_history_count MAX_MOVES) { move_history[move_history_count].row row; move_history[move_history_count].col col; move_history[move_history_count].player player; move_history_count; } }move_history 是一个数组每次落子时记录坐标和玩家悔棋时弹栈。这个设计比“记录完所有棋盘状态再整体回滚”要轻量得多而且天然支持连续悔棋——只要循环调用 undo_last_move 就能回退任意多步。对于课程设计的答辩场景能在评委面前连续悔两步棋并且界面不花属于非常加分的演示。5. UI 设计与人机交互减少误触的三种细节处理五子棋的 UI 不需要炫酷但必须让人一眼看清“现在轮到谁”“我上次点到哪了”“刚才那步棋在哪”。这三个信息对应三个 UI 元素顶部状态栏、最近落子高亮、棋盘网格交叉点。5.1 状态栏信息布局轮次、步数、胜负提示在同一行竖屏 240×320 下顶部 40 像素已经留给了状态栏。布局建议是左侧 8 像素字体显示“黑方/白方”中间显示步数阿拉伯数字即可右侧留空给特殊状态提示比如“黑胜”或者“悔棋?”。状态栏底色用深灰色文字用白色这样在阳光直射下也还能辨认。刷新状态栏时不要全屏重绘只刷新变了的部分。可以画一个矩形覆盖旧文字区域然后重新绘制文本或者更高效地写一个 draw_text_at 函数支持直接覆盖。清空区域内残留像素最稳妥的方法是先 fillRect 再 drawString两步操作 20 毫秒内完成。5.2 最近落子高亮与坐标指示小方框提示当前位置落子后在刚落下的棋子上画一个小方框用于标识“上一步棋在哪”。这个看似简单但必须处理“上一步棋被悔棋后高亮应该消失或移动”的情况。实现上维护一个全局变量uint8_t last_move_row 0xFF; uint8_t last_move_col 0xFF; void clear_last_move_highlight(void) { if (last_move_row ! 0xFF last_move_col ! 0xFF) { draw_single_grid(last_move_row, last_move_col); if (board[last_move_row][last_move_col] ! 0) { draw_piece(last_move_row, last_move_col, board[last_move_row][last_move_col]); } } last_move_row 0xFF; last_move_col 0xFF; }在每次落子后先调用 clear_last_move_highlight 清掉旧的高亮再给新落子标记高亮。绘制高亮的方法是在棋子外圈画一个 2 像素宽的方框颜色用亮绿色或黄色和黑子白子都区分得开。5.3 触摸消抖与误触过滤连续读取、阈值判定、区域屏蔽电阻触摸的抖动问题比电容屏严重得多尤其是在快速点击时。常用的消抖手段有三种连续读取 3 次触摸坐标如果最大差值小于阈值比如 10 个 AD 值才认为是有效点击。从触摸按下到触摸释放之间忽略所有新触发的落子逻辑也就是前面提到的“等待释放”逻辑。为按钮区域设置最小有效点击间隔比如两次落子间隔必须在 200 毫秒以上防止快速双击导致意外落子。第二种手段效果最好因为它从物理层面杜绝了一次按压触发多次落子的情况。第三种手段适合在触摸屏反应迟钝导致用户“用力多点几次”的场景下使用能防止积压的触摸事件在释放后连续触发。实际代码可以在 handle_touch_input 里加一个时间戳判断uint32_t last_move_time 0; if (HAL_GetTick() - last_move_time 200) return; last_move_time HAL_GetTick();这个判断放在触摸读数和坐标换算之后确保落子是有效的才更新时间戳。如果点在棋盘外或已有棋子的位置不更新时间戳这样用户快速重试不会受到间隔限制。6. 串口调试与 Modbus 扩展把课程设计从“能玩”做到“能讲”课程设计答辩时真正能把分数拉开的是演示流程和功能完整性而不是代码里某个算法写得有多漂亮。我的建议是至少加一个“串口调试”能力让评委能通过 PC 端串口助手直接看到当前棋盘状态和胜负判定结果而不是只看屏幕。进一步如果你还有余力把对局扩展成 Modbus 暂存指令控制在“嵌入式”这个标签上加分明显。6.1 串口协议设计三行命令覆盖调试全场景一个简单的文本协议就够了不需要二进制帧。我用过一套很轻的协议规则是串口发送show回显当前棋盘用数字矩阵打印 15×150/1/2 分别表示空/黑/白。串口发送move r c模拟在 (r, c) 落子走和触摸落子完全相同的逻辑入口。串口发送undo触发悔棋。串口发送reset重置棋盘。void uart_command_handler(char *cmd) { if (strncmp(cmd, move, 4) 0) { uint8_t row, col; sscanf(cmd 4, %hhu %hhu, row, col); if (row 15 col 15 board[row][col] 0) { board[row][col] (current_player PLAYER_BLACK) ? 1 : 2; record_move(row, col, board[row][col]); mark_dirty(row, col); redraw_dirty_cells(); if (check_win(row, col)) { show_winner(current_player); game_state GAME_OVER; } else { current_player (current_player PLAYER_BLACK) ? PLAYER_WHITE : PLAYER_BLACK; update_status_bar(); } } } else if (strncmp(cmd, undo, 4) 0) { undo_last_move(); } else if (strncmp(cmd, reset, 5) 0) { reset_board(); } }串口命令直接复用线程状态机里的逻辑而不是重新写一套落子代码这样保证“屏幕上看到的”和“串口打印的”永远一致。调试时如果发现触摸坐标换算有偏差可以直接用move命令精确落子排除触摸硬件干扰单独验证逻辑层。6.2 Modbus 扩展一张映射表解释全部寄存器如果你拿的是 485 接口的开发板或者想展示自己在通信协议上的能力可以把双人五子棋改造成“上位机通过 Modbus 控制落子”的演示。Modbus 寄存器映射可以这样设计寄存器地址读写属性含义0x0000只读当前棋盘状态位 0-14 表示第 0 行位 15 恒 00x0010只写落子命令低字节为列号高字节为行号0x0011只写0x01 表示悔棋0x02 表示重置0x0020只读当前玩家1黑2白0x0021只读步数计数映射表的意义不只是给上位机用——它还倒逼你把代码拆成“协议层-逻辑层-驱动层”。协议层只负责解析 Modbus 帧逻辑层暴露函数接口驱动层管屏幕和触摸。这个架构在面试时能直接回答“你如何保证代码可测试”这个问题。6.3 验证技巧用串口脚本做回归测试课程设计不需要完整的自动化测试但你可以写一个简单的 Python 脚本通过串口向 STM32 循环发送固定棋谱验证胜负判定是否正确。这个脚本本质上是把你的 move 命令依次发给板子每次发完等待 100 毫秒读取回显确认落子成功。如果某个棋谱该判胜但没判说明 check_win 逻辑有边界问题。我最常用的验证棋谱有两组一组是横连五子模拟赢棋一组是斜连五子但只连到四确认不会误判。前者验证正向功能后者验证边界条件。如果这两组都过了胜负判定基本不会出大问题。棋谱本身很简单比如 “move 7 7” 到 “move 7 11” 五步就能把黑棋横连。最后一手落完回显里应该出现玩家胜负标记。本文还有配套的精品资源点击获取
返回列表