1. 项目玩法拆解与第一版功能界定
五子棋大概是所有开发者接触的第一个“真正游戏”项目。规则一句话就能说完:黑白双方轮流在棋盘交叉点落子,横、竖、斜任意方向率先连成五子的一方获胜。但真要动手写代码,你会发现这个项目把你的数据结构、事件处理、坐标换算、算法基本功全串起来了,做完之后对整个编程体系的理解都会升一个台阶。
“五子棋游戏(一)”这个标题里的“(一)”其实很关键。这说明我们不是一上来就要做一个能跟职业选手过招的完美作品,而是要先搭一个结实、好扩展的基础版本。我的第一版规划很简单:做一个15×15标准棋盘的双人对战程序,鼠标点击落子,程序自动判断胜负,再顺手加一个基础的人机对战模式。把这三个能力跑通,后续再往里面加悔棋、禁手、进阶AI、联网对战都是水到渠成的事。
先把这个版本的功能边界定死,避免写着写着就失控:
- 支持标准 15×15 方格棋盘,棋子落在交叉点上。
- 双人模式:同一台电脑上两个玩家轮流用鼠标落子。
- 人机模式:玩家执黑先行,程序执白自动回应。
- 落子后立即检测胜负,胜利时在界面上弹出提示,并支持重新开局。
- 提供简单的菜单或键盘快捷键来切换模式、重置游戏。
为什么要先定这个边界?因为“五子棋”这三个字能延伸出去的东西太多了:禁手规则、三手交换、五手两打、复杂AI、悔棋、计时器……如果不控制范围,第一版就会变成一锅粥。我的做法是:先把核心闭环跑通——落子、判断、胜负反馈,这三个环节是五子棋的灵魂,其他东西都是锦上添花。
顺便补一个五子棋规则里常被新手忽略的术语,后面讲AI逻辑时也会用到。五子棋里有“活四”(两端都开放的四连)和“冲四”(只有一端开放或形成四四点位的四连)、“活三”(再走一步能变成活四的三连)、“眠三”(再走一步只能形成冲四的三连)这些概念。第一版判胜负只需要判断“是否连五”,但到后面写AI、写禁手逻辑,这些术语就是讨论的基础。
我还想强调:这个项目非常适合作为编程学习的中期里程碑。刚学完循环、数组、函数之后,很多人会陷入“学了语法但写不出完整程序”的迷茫。五子棋就是一个恰到好处的练习量——不大到让人放弃,不小到让人感觉无聊。
2. 技术选型与界面设计思路
第一版我选的技术方案是 Python 搭配 Pygame 图形库。Pygame 的入门门槛低、跨平台表现稳定、事件模型清晰,对于学习图形界面程序和游戏循环这个概念来说非常友好。而且五子棋的逻辑本身不依赖特定图形库,就算你用的是 C系语言配图形库,或者 JavaScript 配 Canvas,核心思路完全能迁移。
拿到这个项目,先别急着写代码,花十分钟想清楚“棋盘怎么画、棋子落在哪”这两个问题。棋盘是一个15×15的网格,每个交叉点就是一个可落子位置。窗口尺寸我定为640×720,左边60像素留作边距,下面60像素留作状态栏显示当前回合提示。格子边长设为40像素,这样棋盘最大可用范围就是15×40等于600像素。
坐标换算是这个项目第一个容易出错的点。像素坐标和棋盘逻辑坐标之间的映射关系是这样:逻辑坐标(row, col)对应像素坐标 (col * 40 + 60, row * 40 + 60)。反过来,鼠标点击位置加上偏移量之后除以40取整,就能得到对应的row和col。例如鼠标点在了像素点(80, 300)上,先减去边距得到(20, 240),再做整除运算,得到的逻辑坐标就是(6, 0)。注意这里容易出现整除边界问题,鼠标点在线段边缘时可能误判到相邻格子,所以一般还要做一次距离判断,只有点击位置距离交叉点的距离小于某个阈值(比如格子边长的四成)才允许落子。
界面方面,我习惯把状态信息放在窗口底部的一个固定区域。顶部区域显示当前轮到谁走,右侧留一小块区域做模式切换按钮。这种布局可以让玩的人一眼就看清楚当前游戏状态,不用靠猜。棋盘背景色我选了偏暖的米黄色,黑棋用黑色圆点,白棋用白色圆点加一圈深色描边——这个描边不是可有可无,纯白棋子在浅色背景上很容易看不清轮廓,加一个两像素的深色描边观感立刻上一个档次。
还有一个细节是棋子的半径。格子边长40像素的情况下,棋子半径设成16比较合适,视觉上大概占格子宽度的八成,稀疏得当。画棋子时要注意圆点不要超出格子边界,否则两个相邻棋子会叠在一起显得拥挤。
底部除了状态栏,我还会显示“按R重新开始、按Esc退出”这类快捷键提示。这个界面的设计思路很简单:别整花活,清楚、稳定、反馈即时,比什么都重要。很多初学者容易在这上面花太多时间调UI美化,结果核心逻辑一直没写,这就本末倒置了。
3. 核心逻辑实现:棋盘存储、落子与胜负判定
界面是皮,逻辑才是骨。这一步我按三层来做:棋盘状态的存储、落子的交互处理、胜负判定算法。每一层都很独立,也方便后面测试和扩展。
棋盘存储我用的是一个二维数组,15行15列,初始值全是0。0表示空位,1表示黑棋,2表示白棋。很多教程喜欢用字符串或者字典来存储棋盘,但我建议用二维数组,因为后面无论是判断胜负、扫描空位、还是做AI评估,二维数组都是最直观、性能也够用的结构。
board = [[0 for _ in range(15)] for _ in range(15)] current_player = 1 # 1=黑棋, 2=白棋落子的交互处理逻辑放在主循环里。Pygame 的主循环每一帧都在跑,我们需要监听鼠标事件,一旦发生点击,就做三步:把像素坐标换算成棋盘坐标、检查该位置是否为空、把棋子写入棋盘并刷新显示。
for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN: x, y = event.pos col = (x - 60) // 40 row = (y - 60) // 40 if 0 <= row < 15 and 0 <= col < 15 and board[row][col] == 0: board[row][col] = current_player draw_piece(row, col, current_player) # 网格越界检查和空位检查都放这里这里有两个很常见的坑。第一是坐标转换时没做边界检查。如果点在棋盘区域外,比如点到了右侧状态栏,减去60再整除40就会得到一个超出0到14范围的行列号,直接访问数组会报错。第二是没做格子归属容差,导致点两个格子交界处时落到相邻格子。我的处理方式是:拿到行列号后先检查有效性,再算该交叉点的精确像素坐标,计算点击点和交叉点的距离,大于某个阈值就忽略这次点击。
胜负判定是整个项目的核心,也是我这次想重点讲透的部分。判断五子棋胜负的方法有很多:落子后全盘扫描、对每个棋子朝四个方向统计连子数,等等。我采用的做法是“以最后一次落子为中心,检测四个方向”。
具体思路是这样:刚下的这颗棋子是当前回合的唯一变量,如果棋盘上形成了连五,这五颗子必然包含刚下的这颗。所以我只需要从这颗棋子出发,沿着四条线——水平方向、垂直方向、主对角线方向、副对角线方向——分别向两边数连续同色棋子的数量。每个方向两边的数量加起来,再减去当前棋子本身被重复计算一次,如果大于等于5,说明已经赢了。
def count_direction(board, row, col, dx, dy, player): count = 1 # 当前棋子自身 for step in range(1, 5): nr, nc = row + step * dx, col + step * dy if 0 <= nr < 15 and 0 <= nc < 15 and board[nr][nc] == player: count += 1 else: break for step in range(1, 5): nr, nc = row - step * dx, col - step * dy if 0 <= nr < 15 and 0 <= nc < 15 and board[nr][nc] == player: count += 1 else: break return count def check_win(row, col, player): directions = [(1, 0), (0, 1), (1, 1), (1, -1)] for dx, dy in directions: if count_direction(board, row, col, dx, dy, player) >= 5: return True return False有的读者会问:为什么向两个方向数的时候,每次循环只走4步?因为我只需要判断是否连成五子。连五不可能是从当前棋子出发数完了单边五颗还没有到达边界,所以单边最多数四步就够了。当然你也可以写成数到棋盘边界为止,我没那么做是为了让“差一步形成禁手的判断”在后续版本里更容易扩展。
这个胜负判定方案的优点在哪里?第一,时间复杂度低。最坏情况下只需检查四条线、每条线最多八步,远快于全盘扫描方案。第二,边界条件好控制。越界检查写在内部循环里,不会数组越界。第三,它天然支持后面要增加的禁手判断——只需把“数连续同色棋”的逻辑稍微改动,就能统计出活三、冲四这些棋形的数量。
我特别想提醒一个初学者容易踩的坑:很多人统计连续同色棋子时会用一个全局变量max_count,然后在循环里不断更新它,结果在双方向累加时把当前棋子重复计了两次。我在代码里使用局部变量count,先初始化为1,再分别向两边累加,有效避开了这个问题。写这类逻辑的时候,脑子里要有一幅图:你是站在当前棋子上,向左右各探出头去,而不是每走一步就从头数一遍。
4. 基础人机AI:逐点评分法的实现
很多人做完双人对战模式后最大的成就感来自“让我和电脑下一盘棋”。第一版的人机AI我用了逐点评分法,这是五子棋AI里最容易理解、也最能打出效果的算法之一。它不涉及搜索树,不涉及剪枝,但足以打败没怎么研究过五子棋的普通玩家。
原理非常直白:电脑拿到棋盘后,遍历每一个空位,对每个空位分别打分——假设黑棋落在这里有多大的威胁值,假设白棋(也就是电脑自己)落在这里有多大价值。然后把这两个维度合并成一个总分。最后电脑选择总分最高的那个空位落子。听起来像笨办法,但实际效果相当不错,因为五子棋的棋形评估天然适合这种“局部打分”策略。
打分规则我按棋形的重要程度来定,参考五子棋里棋形的基本价值排序:
| 棋形 | 进攻分 | 说明 |
|---|---|---|
| 连五 | 100000 | 直接获胜,最高优先级 |
| 活四 | 10000 | 两端都开放,对方无法阻挡 |
| 冲四 | 1000 | 差一步成五,对手必须堵 |
| 活三 | 1000 | 可发展成活四,威胁很大 |
| 眠三 | 100 | 只能形成冲四,威胁一般 |
| 活二 | 100 | 有发展潜力但需要时间 |
| 眠二 | 10 | 基本属于试探性棋形 |
这个是经典的“评分表”做法,实际分值可以微调,但相对次序别打乱。我在代码里用了一个函数:给定一个位置,模拟把这个棋子放下去,然后在四个方向统计连子数和开放程度,根据这两个指标去查表得到分值。注意这个过程中要区分“空位是正常空位”和“空位是边界外”,开放端的定义是“紧挨着棋形的那一格是空位”,如果那格是对方棋子或棋盘边界,就属于封闭端。
AI的最终打分公式是这样的:
def evaluate_point(board, row, col, ai_player): human_player = 3 - ai_player score = 0 # 进攻分:模拟AI自己落子 board[row][col] = ai_player score += evaluate_shape(board, row, col, ai_player) # 防守分:模拟对手落子 board[row][col] = human_player score += evaluate_shape(board, row, col, human_player) * 0.9 board[row][col] = 0 # 恢复空位 return score防守分乘以0.9是一个很实用的经验值。为什么不乘1或更高?因为你是在“替对方想一步”,不等于对方真的会下这里,如果防守权重过高,电脑会变得畏首畏尾,全程堵你但不进攻,棋局很容易下成僵局。乘以0.9会让电脑在威胁足够大时果断防守,同时在机会来临时敢于进攻。
然后AI遍历整个棋盘,把所有空位都评一遍分,找到最高分的那个位置落子:
best_score = -1 best_move = None for row in range(15): for col in range(15): if board[row][col] == 0: s = evaluate_point(board, row, col, ai_player) if s > best_score: best_score = s best_move = (row, col)这个算法最妙的地方在于“它完全知道应该堵谁的棋”。当你在棋盘上摆了个活三,电脑扫描空位时会发现:如果它不堵,你在下一步就能形成活四,这个位置对应极高的防守分,所以它会毫不犹豫地堵在关键位置。当它自己有机会形成连五时,进攻分更是直接爆表,这时它会果断下出制胜一手。
第一版AI我故意没做成更高级的Alpha-Beta剪枝搜索。原因有三:第一,逐点评分法已经满足第一版游戏的需求,棋力足够应付日常娱乐;第二,评分法好调试,出问题一眼能看出来是哪个方向漏算了,而搜索树一旦写错,问题很难定位;第三,后续文章里我会在上面扩展搜索深度,先把评分体系打扎实,后面的代码就能直接复用。
还有一个细节要处理:AI如果在开局阶段就按照评分表逐点扫描,会在最中央几个点随机选一个。这个行为虽然正确但略显呆板,我在实现时给AI加了一个小逻辑——开局前三步如果棋盘上没有子,那就先下到中心点附近稍微靠边一点的位置。这会让开局变化多一点,不容易一开局就走入重复定式。
5. 常见问题与调试技巧实录
我一开始写这个项目时,踩了不少坑,也帮一些朋友看过他们写的版本,总结出了几个高频问题。如果你实际运行游戏时碰到了意外,可以参考下面的排查思路。
问题一:Pygame窗口启动后立刻闪退。这个问题的原因几乎都是主循环没有正确运行。很多人写完界面初始化代码就直接开始画棋子,忘记写while running:这个主循环,或者循环条件一开始就是 False,窗口画完一帧就退出了。解决办法是确认代码结构大致是这样:初始化、进入主循环、事件处理、更新逻辑、重绘图像、刷新屏幕。一个简单的自查方法是在主循环里加一句打印,如果控制台没有周期性输出,就说明主循环根本没跑起来。
问题二:落子的棋子和网格线对不齐。这种情况几乎都是坐标换算公式写错了。你可以把公式单独拆出来测试:给定几个逻辑坐标,手动算出期望的像素坐标,然后和执行结果对比。我强烈建议在开发阶段给每个交互函数写一个简单的测试函数,输入固定坐标输出固定结果,出问题一眼就能看出来。坐标换算这类纯函数级的逻辑,既不容易上下文耦合,也适合单元测试。
问题三:胜负判定在四条线上“数多了”。一个常见的错误是在某个方向上数完连续同色棋子之后,又跑到另一个方向把所有同色棋子重新数一遍,最终数量被重复计算。比如你在水平方向上数到五颗子已经赢了,但代码没返回,继续去数垂直方向,把重叠的棋子又加了一次。解决办法是在check_win里一旦检测到某个方向返回的连续数大于等于5,立即返回True,不做多余的统计。
我在调试胜负判定时用过一个非常笨但非常有效的方法:强行在一个远离其他棋子的位置摆一个五连场景,然后让程序打印每一步检测的三个方向——左边数量、右边数量、总数量。如果某个数字明显不对,基本就能锁定是哪一段逻辑出错了。这种“人肉打印调试法”看起来很土,但在纯逻辑型代码里比什么抽象工具都好使。
问题四:AI全程只防守不进攻,棋局特别被动。这多半是防守权重设置过高造成的。你可以把防守分系数从0.9往0.8调,再测试几局。如果调到0.6以下AI又变得太激进、容易漏掉明显的威胁,那就在0.8到0.9区间内调整。这类参数没有标准答案,和你的棋形评分表数值搭配有关,调参时每次只改一个变量,改完马上对局验证。
问题五:白棋在浅色棋盘上看不清楚。解决办法前面提到过,给白棋加一圈深色描边。Pygame里画圆时可以先用深灰色画一个半径稍大的圆,再叠加一个白色小圆,视觉上就形成了一圈描边效果。这个优化对局体验提升非常明显,成本却几乎为零。
还有一条重要的工程建议:把棋盘状态实时打印出来。print函数在图形编程里的价值被严重低估。你可以在每次落子后把整个15×15的二维数组打印到控制台,1代表黑棋2代表白棋0代表空位。这样哪怕图形界面渲染出了bug,控制台里棋盘状态仍然一目了然。我第一版做完之后,后面调AI参数的效率一大部分就靠这个打印功能撑着。
在调试过程中另一个让我印象深刻的坑是:画棋子时没有调用pygame.display.flip(),导致棋子画上了屏幕却不显示,看起来就像落子失败。图形程序里“绘制”和“呈现”是两回事,绘制是把指令发给后备缓冲区,呈现才是把缓冲区内容真正显示到屏幕上。每次用pygame.draw画完东西,记得刷一次屏。这种低级问题排查起来特别费时间,最好的规避方式就是在开发初期写一个固定模板,把绘制、更新、呈现的顺序固化下来,别乱改。
6. 从第一版到后面的扩展:我的一些实操心得
做完这个“五子棋游戏(一)”的基础版本,整体的成就感其实比想象中更大。因为从零把一个能正常对战的游戏写出来,需要的不只是代码能力,还有拆解问题、控制范围、调试排错的综合功夫。下面分享几个我总结的个人心得,这些在教程里很难找到。
第一点是“先让版本能跑,再让版本好玩”。我在第一版里没有执意追求花哨的AI效果和复杂的界面动画,而是把双人对战、判胜、基本人机这三个功能做扎实。中途有几次我想给棋盘加背景音乐、加落子动画,考虑到那会让代码复杂不少,还是忍住了。事实告诉我,第一版越简洁,后续第二版、第三版加功能时就越有信心,因为你已经搭好了一个不会到处连环崩溃的地基。
第二点是“控制台是裸眼3D调试的利器”。这个项目图形界面和逻辑混合在一起,如果不把逻辑层数据可视化,出了问题很难定位。我养成了一个习惯:棋盘状态每次变化后都打印一遍,虽然打印输出看起来很原始,但它让我在五分钟内就能判断问题是出在“逻辑没写入数组”还是“数组有值但界面没画出来”。实操时你还会发现一个好处:这种打印内容可以直接复制下来,作为和同行讨论问题时的依据,比截图方便十倍。
第三点是评分法AI虽然不算高级,但可玩性比你想象得高。我测试时发现,只要防守和进攻权重设置得当,这个AI足以在低端对局中保持不败。它的极限在于没有前瞻性,看不清两三步之后的杀棋机会,但这恰恰是后续优化的切入点。后面我计划在评分法的基础上加一层深度搜索:让AI在选出得分最高的前几个位置后,再往前搜索后续两步,看哪条路线对自己更有利。到那时评分表本身不用重写,只需要加一个搜索壳而已。这就是逻辑分层的价值——核心评估函数稳定,你就能在其上不断叠加新能力而不动根基。
如果你也正在写自己的五子棋版本,我的建议是先完整跑通双人对战模式,再动AI。很多人一上来就写AI,结果棋盘逻辑本身还有各种隐藏bug,后期排查时根本分不清是AI的问题还是基础逻辑的问题,会非常痛苦。把基础逻辑验证扎实,AI只是在这个可靠基础上做一次选择,你调试起来会从容很多。
这个系列后续可以做的事还有很多:增加悔棋和重新开局按钮、实现不同难度级别的AI、加入落子音效和动画、把棋盘做成可缩放的窗口、甚至加入简单的网络对战功能。每一步都建立在这个第一版的基础之上。如果你对这个项目有任何想法或者卡住了,非常欢迎留言一起讨论。