聊到做游戏练手,五子棋往往是很多人的第一个完整项目。逻辑清晰、规则简单、界面直观,看起来好像一晚上就能写完,但真到自己动手实现,才发现一个小小的棋盘背后藏着不少值得琢磨的细节。我这次开个系列,先把基础版完整跑通,后续再慢慢把AI对战、悔棋、联网对战这些花活加进去,也算是记录一个项目从零到一的过程。
“五子棋游戏(一)”计划实现的目标很简单:双人轮流落子、黑棋先手、任意方向连成五子即获胜、支持重新开局。适合刚学完编程语法想找一个完整项目练手的人,也适合想搞明白“游戏状态机”和“棋盘坐标换算”这类基础问题的开发者。麻雀虽小五脏俱全,这个小项目能让你把事件处理、逻辑判定、界面刷新整个闭环走一遍,收获比单纯刷题实在得多。
1. 项目立项与需求拆解
1.1 为什么第一个游戏选五子棋
很多人第一反应是写个贪吃蛇或者俄罗斯方块,但五子棋有一个其他小游戏没有的优点:它的胜负判定完全基于棋盘状态,不涉及物理引擎、碰撞检测、速度变化这些容易劝退新人的东西。你不需要处理一帧里面物体移动了多少像素,也不需要考虑加速度衰减,只需要关心“棋子落在哪里”和“棋盘上现在是什么情况”。
另一个原因是五子棋天然适合分阶段迭代。第一版做双人对战,第二版加简单AI,第三版再进阶更深层的搜索剪枝算法,每一步都是独立可交付的增量。不像某些项目一做就要把所有系统同时到位,中途卡住就彻底放弃了。这种“每篇都有产出”的节奏,正是适合长期维护项目的原因。
从技术角度看,五子棋覆盖了游戏开发的基本要素:二维网格建模、坐标与像素的相互转换、鼠标交互事件、回合制状态流转、胜利条件的检测。这些要素换到任何一个棋盘类游戏里都能复用,比如黑白棋、象棋、扫雷。所以这一篇写的东西,后面都派得上用场。
1.2 核心技术选型:为什么用二维数组加Pygame
我用的是 Python 加 Pygame 这套方案,没有引入任何重型框架。原因很简单:Pygame 的上手曲线平缓,核心接口稳定,处理窗口、鼠标事件、绘制基本图形绰绰有余。五子棋对画面效果的要求并不高,棋子画个圆就行,网格画几条线就行,这种需求用 Pygame 的绘图原语就够了,用不上复杂的渲染管线。
棋盘模型我选了最朴素的二维数组,15行15列,数组元素存储三种状态:空、黑子、白子。为什么不用对象数组?因为五子棋的每个格子只需要一个状态标志,没必要给每个格子封装一个类,二维数组访问快,逻辑直观,遍历方便。后续无论是做胜负判定还是AI评估,直接通过行列下标访问数组即可,性能瓶颈根本不在这里。
选型的时候我其实纠结过要不要直接用纯终端实现,用“X”和“O”打印棋盘。后来放弃了,因为棋类项目如果界面不直观,落子体验差,调试起来也憋屈。视觉反馈能帮你快速发现问题,比如坐标是否偏移、棋子是否对齐,这些在终端里都很难看出来。
1.3 模块划分:别把代码全怼在一个文件里
基础版虽然规模不大,但我还是把代码拆成了三个模块:棋盘逻辑、绘制渲染、主循环控制。三个文件各司其职,后面扩展AI或者悔棋功能时,不需要改动全局。
- 棋盘模型负责维护二维数组、判定落子合法性、检测胜负
- 渲染模块负责根据棋盘数据绘制网格和棋子
- 主循环负责监听鼠标事件、调用模型更新数据、调用渲染刷新画面
这样做的好处是,万一后面想做AI,只需要在“落子前”琢磨怎么选点,不需要碰渲染的事情。万一想换一套深色皮肤,也只改渲染模块就好了。逻辑和表现分离,是项目能从第一篇一直延续下去的前提。
2. 棋盘与核心数据结构设计
2.1 棋盘尺寸、格子大小与像素计算
标准五子棋棋盘是15×15,棋子落在网格交叉点上,而不是格子中心。我第一次做的时候就搞反了这个概念,导致棋子全部落在格子正中间,整个棋盘看起来非常别扭。真实对弈中棋子是压在横竖线交点上的,所以像素坐标也要按交叉点来算。
我设置了几个常量:
BOARD_SIZE = 15 CELL_SIZE = 30 MARGIN = 30 WIDTH = HEIGHT = MARGIN * 2 + (BOARD_SIZE - 1) * CELL_SIZE这里有个细节很容易忽略:棋盘不是15×15个格子填充整个窗口,而是15×15个交叉点。点与点之间的距离是CELL_SIZE,所以两端的点之间距离是(BOARD_SIZE - 1) * CELL_SIZE,也就是14×30=420像素,再加上左右各30像素的边距,窗口宽高是480像素。
如果把公式写成BOARD_SIZE * CELL_SIZE,算出来的就是450,最后会发现边缘交叉点紧贴着窗口边框,棋子一半切出画面,观感很差。边距不仅仅是装饰,它确保了边缘的棋子完整可见,也给了鼠标点击一定的缓冲空间。
2.2 棋盘状态与棋盘数组
棋盘数据用二维数组存储,配合三个常量来表示状态:
EMPTY = 0 BLACK = 1 WHITE = 2 board = [[EMPTY for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)]为什么用0、1、2而不是字符串“empty”“black”“white”?因为整数比较效率高,判断条件写起来简洁。更关键的是后面做AI评估时,经常要对连续同色棋子计数,用数值可以直接做四则运算,用字符串还得转换。
代码里有一个经典的坑:初始化棋盘时如果图省事写[[EMPTY] * BOARD_SIZE for _ in range(BOARD_SIZE)],看起来是对的,但实际上内层是同一个列表的引用,修改任意一个格子,整列都会跟着变。我一开始踩过这个坑,调试了半天才发现是“共享引用”的问题。所以一定要用列表推导式,每一行都单独创建。
# 正确写法 board = [[EMPTY for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)] # 错误写法(矩阵行是同一个引用,千万别用) board = [[EMPTY] * BOARD_SIZE] * BOARD_SIZE2.3 绘制网格与星位
绘制网格用Pygame的draw.line即可。遍历每一行和每一列,算出端点坐标画线:
for i in range(BOARD_SIZE): start = MARGIN + i * CELL_SIZE pygame.draw.line(screen, LINE_COLOR, (MARGIN, start), (WIDTH - MARGIN, start)) pygame.draw.line(screen, LINE_COLOR, (start, MARGIN), (start, HEIGHT - MARGIN))这样画出来的就是横竖交叉的网格。最后再补上五个星位,也就是棋盘上那五个黑色小点,分布位置是四角和正中,坐标是(3,3)、(3,11)、(7,7)、(11,3)、(11,11)。画星位的意义纯粹是让棋盘更接近真实对局的感觉,对逻辑没有影响,但视觉上一下就有内味了。
绘制棋子时要通过行列下标反向换算像素坐标。交叉点的坐标就是MARGIN + col * CELL_SIZE,以它为圆心画一个半径为13像素的圆。黑棋深色,白棋浅色,边缘加一圈稍暗的轮廓圆,看起来更有立体感。
3. 交互与落子逻辑实现
3.1 主循环结构与事件驱动
游戏程序本质上是一个无限循环,每一帧做三件事:处理事件、更新状态、刷新画面。Pygame的标准写法是:
running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: handle_click(event.pos) draw() pygame.display.flip()初学者最容易忽略的是pygame.display.flip()。很多人画完图发现窗口是黑的,就是因为没有调用刷新接口。可以把画布理解为一块黑板,你画的内容是写在缓冲区里,flip()负责把整个缓冲区一次性呈现到屏幕上。如果漏了这一步,画了等于白画。
主循环还有一个好处,就是方便后续扩展。比如加入AI思考动画,只需要在“更新状态”这一步插入一个延时;加入倒计时,只需要在循环里记录时间差。循环结构就像一个稳定的底盘,后续所有功能都是往里面挂组件。
3.2 鼠标坐标转换成棋盘下标
鼠标点击拿到的是像素坐标,需要转换成棋盘的行列下标。这一步是整个交互流程最容易出错的地方。我的做法是:
col = round((mouse_x - MARGIN) / CELL_SIZE) row = round((mouse_y - MARGIN) / CELL_SIZE)注意这里用的是round而不是int强转。假设用户点击了交叉点右侧偏移5个像素的位置,点距离左边最近的交叉点只有5像素,距离右边交叉点有25像素,那么此时应该判定为点在了左边这个交叉点上,round(5/30)=0刚好选择左边;如果点偏移了20像素,round(20/30)=1就选右边交叉点。用int的话,20/30=0,永远只能选左边,用户必须点得非常准才行,体验很差。
换算完之后还要做边界检查:
if 0 <= row < BOARD_SIZE and 0 <= col < BOARD_SIZE: pass如果不检查,点中窗口边缘的空白区域,下标可能会变成-1或者15,访问数组时直接报错或产生诡异行为。
3.3 落子、换手与回合状态
落子前先判断当前位置是否为空,如果已经有棋子就忽略这次点击。这个判断非常简单,但极其重要,否则后落的棋子会覆盖先落的,棋盘数据全乱套。
def place_piece(board, row, col, player): if board[row][col] != EMPTY: return False board[row][col] = player return True换手逻辑用一个变量表示当前玩家,落子成功之后就切换:
current_player = BLACK # 落子成功后 current_player = WHITE if current_player == BLACK else BLACK这一步我建议用“当前玩家与另一玩家的身份切换”写成一个独立函数,后面做AI对战的时候,这里就是人机和人对人的分岔路口。预计下一篇会在这里加一个判断:如果当前玩家是AI,则走AI逻辑;否则等待鼠标输入。
这里我还额外加了一个“玩家计数”概念,用黑白双方的手数记录走了几步。倒不是基础版必须的,但后面做“第几步时游戏结束”复盘时非常好用,加上去成本很低。
4. 胜负判定算法深度拆解
4.1 为什么不在落子后扫描整个棋盘
胜负判定的思路是每次落子后,检查以这个棋子为中心的四个方向,判断是否形成连续五个同色棋子。有人可能想偷懒,每次落子都全棋盘扫描一遍,看看有没有五连珠。这样做在15×15的棋盘上也许不觉得慢,但方向上很浪费,而且逻辑要写很多层嵌套。
我每落一子,只需要围绕落点检查四个方向:水平、垂直、左上到右下、右上到左下。因为新形成的五连珠必然包含刚刚落下的这颗子,如果检查所有旧棋子,有一大部分是冗余的。这种方式最大的好处是判定及时且代码量少,也为以后引入AI时减少重复计算打好基础。
4.2 方向数组与双向扫描
四个方向可以抽象成四个向量,水平是(1,0),垂直是(0,1),主对角线是(1,1),副对角线是(1,-1)。用一个方向数组统一处理,比写四段几乎一样的代码好维护得多:
DIRECTIONS = [(1,0), (0,1), (1,1), (1,-1)] def check_win(board, row, col, player): for dx, dy in DIRECTIONS: count = 1 # 正向延伸 r, c = row + dx, col + dy while 0 <= r < BOARD_SIZE and 0 <= c < BOARD_SIZE and board[r][c] == player: count += 1 r += dx c += dy # 反向延伸 r, c = row - dx, col - dy while 0 <= r < BOARD_SIZE and 0 <= c < BOARD_SIZE and board[r][c] == player: count += 1 r -= dx c -= dy if count >= 5: return True return False重点解释一下双向扫描的必要。假设黑子落在了五个棋子的中间位置,如果不向两边扫,只从落点往一个方向延伸,最多只能看到两三个子,永远凑不够五个,会漏判胜利。所以必须沿着一个方向的正负两侧都走一遍,把它们累计起来。
循环终止条件也要注意,除了判断棋盘边界之外,一旦遇到空格或者对方棋子就必须立刻停止。有人会下意识忽略“对方棋子”这个条件,导致扫描穿过敌方棋子继续数,结果明明是断开的棋路也被误判成五连珠。
4.3 胜负后的流程控制
检测到胜利之后,需要让游戏进入一个“结束态”,不再响应落子事件。我引入了一个简单的状态变量:
game_over = False winner = None落子成功后调用check_win,如果返回True,就把game_over设置为True,并更新winner。主循环里做一次判断:
elif game_over: # 不处理落子,只响应新游戏按钮 pass这里最容易忽略的是,如果不加结束态,玩家赢棋之后还能继续下,棋盘状态继续改变,那判定就完全失去意义。基础版我没做复杂的胜负弹窗,只是在棋盘下方显示一行文字说明“胜利者”。如果后续想要加强交互,这里可以扩展成模态弹窗,或者增加“再来一局”按钮。
4.4 平局判定
平局也是一个需要处理的边界情况。15×15的棋盘一共225个交叉点,双方交替落子,所有位置占满还没有分出胜负就是平局。每次落子之后维护一个计数器,等于BOARD_SIZE * BOARD_SIZE时宣布平局。实现上就是在place_piece里增加一步:
move_count += 1 if move_count == BOARD_SIZE * BOARD_SIZE: game_over = True winner = None # 表示平局虽然实战中很少下满全盘,但有了这个逻辑,程序就不至于在“无空位可落”时继续接受点击,数组也永远不会越界。
5. 踩坑实录与排查技巧
5.1 坐标偏了一个像素,到底哪里出了问题
有次我把画面缩放测试,发现棋子总是偏向交叉点的右下角一两个像素,肉眼看着很别扭。排查到最后,问题出在绘制圆的位置和网格线绘制位置用的坐标系不完全一致。网格线用的是整数坐标,棋子圆心也是整数坐标,按理说不应该偏。实际上是我误把整个窗口的尺寸设置成了450,而交叉点全部集中在420像素范围内,右侧和下侧多出来30像素的边距,显示时整体偏移了半个格子。
这里想提醒大家,如果发现画面整体偏移,先检查窗口尺寸计算,而不是急着调坐标。窗口的宽高必须是MARGIN * 2 + (BOARD_SIZE - 1) * CELL_SIZE,少交一个MARGIN或者把15乘进去都会导致整体错位。
5.2 数组下标越界的隐蔽触发条件
数组越界最容易发生在胜负判定的扩展循环和鼠标坐标换算里。胜负判定时,如果不在循环内部做边界判断,棋子落在棋盘边缘时,程序就会尝试访问行号15或列号-1,直接崩溃。这种崩溃往往不是每一次都出现,而是下到边角才触发,特别容易让人摸不着头脑。
调试技巧是,写一个边界用例,比如在四个角分别落子,故意走成五连,看程序会不会崩溃。这种单元测试级别的小脚本,花不了两分钟,但能把边界问题一次性暴露出来。
5.3 棋子“画不上去”是怎么回事
有一种很常见的现象是:点击后终端打印的状态显示棋子已经落到数组里了,但屏幕上没有显示,或者要等下一次点击才显示。这八成是绘制和刷新顺序出错。Pygame的绘制都在后台缓冲区,你虽然调用了pygame.draw.circle,但如果不执行pygame.display.flip()或者pygame.display.update(),画面不会更新。
我建议把主循环里的刷新机制统一起来,不要一个地方用flip,另一个地方又用update,混用很容易乱。基础版直接用flip就行,它会把整个窗口重新绘制一遍,简单直接。
5.4 游戏体验细节:重新开局功能
基础版我加了一个最朴素的重新开局方式:按R键立即重置棋盘,黑子先行。实现很短,但很实用:
keys = pygame.key.get_pressed() if keys[pygame.K_r]: board = [[EMPTY for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)] current_player = BLACK game_over = False winner = None move_count = 0不加这个功能的话,每次测试完胜负判定,都要重新运行程序,开发效率很低。对于这种小游戏项目,开发期的效率非常依赖这类快捷键。
5.5 一套百试百灵的调试口诀
万一后续代码改乱了,排查时不要瞎试,我按优先级整理一套顺序:先检查棋盘数组是否更新,再检查绘制函数是否被调用,最后检查flip()是否执行。这三个节点分别对应“逻辑层有没有错”“渲染层有没有错”“刷新层有没有错”,绝大多数问题都能靠这条链路定位。
6. 本篇实现成果与下一步扩展思路
完成这版基础五子棋之后,实际跑通的效果是:15×15标准棋盘,黑白双方轮流落子,点击位置基本准确,连成五子或棋盘下满能正确判定胜负,按R键可以随时重开。代码量不大,但整个交互闭环是完整的。
下一步我计划在第二篇里加入一个人机对战接口,让电脑先调用一个简单的评分函数,遍历所有空位,给每个位置打分,选分数最高的位置落子。单项打分可以从“此位置能形成几连”“能封堵对手几连”两个维度计算。打分逻辑本质上就是现在这个胜负判定的扩展,你已经熟悉了四方向扫描,再做评分会非常顺。
我的体验是,五子棋这个项目看似简单,但它把程序设计里的几个基本功都串起来了:数据结构设计、坐标系统转换、状态机控制、边界条件意识。完成基础版之后,我明显感觉到自己画界面和调交互的熟练度上升了一个台阶。如果你也卡过棋类项目,不妨从这篇的代码结构起步,按自己的节奏加功能,进度快慢不重要,关键是每个版本都能跑起来、看得见效果。