资讯详情

资讯详情

Python控制台版五子棋实战:从棋盘数据结构到AI算法完整实现

如果你最近在学 Python想找一个“不大不小”的项目练手五子棋控制台版应该排在我的推荐列表前三位。它不需要装任何第三方库不需要碰图形界面一个标准库加一点算法就能跑起来却能完整覆盖二维数组操作、循环与函数拆分、输入校验、状态判断、基础AI设计这些 Python 核心基本功。我这周刚帮一个刚入门的朋友把整个流程理了一遍写这篇文章之前又自己从头实现了一次顺便把几个容易踩的细节都记下来了。很多人在这一步容易跑偏一听到“做游戏”就想着上 pygame、tkinter结果环境配置半小时界面折腾一下午核心逻辑却还是一团浆糊。我的看法是如果你的目标是搞懂“程序怎么从需求变成能跑的代码”控制台版才是真正的主菜图形界面只是一层皮。等到控制台版逻辑通透了再套什么界面都是顺手的事。这篇文章我就按自己做这个项目的完整思路来讲为什么选控制台版、棋盘数据结构怎么设计、胜负判定怎么写才不容易出错、AI 怎么做一个“够用”的对手以及主循环和输入处理里那些坑。代码我会完整给出来你照着敲一遍就能跑然后可以自己接着改。1. 为什么是控制台版先把游戏逻辑这层地基打牢我见过很多初学者写五子棋一上来就打开 pygame 的文档画完棋盘画棋子最后发现自己根本没在“做五子棋”而是在“做贴图”。这不是说图形界面不好而是它把注意力从真正重要的部分引开了。五子棋的核心逻辑和界面完全无关包括棋盘的状态如何存储、落子后如何判定胜负、双方回合如何流转、AI 如何选择落点。这些东西全部可以在控制台里实现清楚。控制台版还有两个被低估的好处。第一个是主循环极其直观。五子棋本质上是一个“打印棋盘 - 玩家输入 - 落子 - 判定 - 轮到对手”的无限循环控制台天然能把这个循环呈现得明明白白。第二个是方便自动调试。图形界面下你想连续跑一百局测试胜负判定得手动点鼠标控制台里直接写脚本调用判断函数就行我后面专门讲这个。当然控制台版也不是一点缺点没有。它无法直接体现鼠标点击交互、棋子的视觉效果也没有声音反馈。但在这个阶段这些恰恰是不需要的。我的建议很明确基础阶段做控制台版等项目逻辑稳定、你对代码结构有把握了再用 pygame 把同一套逻辑包上一层图形外壳。到那时候你会发现真正需要新增的代码比想象中少很多因为底层的东西都已经写好了。对了还要说一个意外收获。控制台版五子棋非常容易被“机器测试”这意味着你可以写一个小脚本每天跑几百局人机对战来调参。我在做 AI 的时候就是靠这种“批量自动对局”把几个不合理的评分权重调清醒的。图形界面版本做这种批量测试就麻烦得多。2. 棋盘的数据结构二维列表怎么选打印又怎么对齐2.1 棋盘尺寸与状态值的定义标准五子棋一般是 15×15也有用 19×19 的。我第一次做的时候纠结了很久后来总结出一个原则先把逻辑跑通再把规模调大。这个项目里所有代码都不依赖固定尺寸所以先用一个常量定义就好SIZE 15棋盘用二维列表来表示这是 Python 初学者最容易理解、也是最适合这种网格游戏的结构。每个位置存一个整数我习惯用 0、1、2 三个值0表示空位1表示黑子2表示白子为什么不用字符串或布尔值因为整数在判断时最快而且当你后面写 AI 的时候用 0 判断“能不能落子”非常顺手。初始化棋盘也简单board [[0 for _ in range(SIZE)] for _ in range(SIZE)]这里有个必须注意的细节我用加粗标出来不能写成[[0] * SIZE] * SIZE。这个写法看起来很聪明但实际上每一行都会指向同一个列表对象你改一个位置整列跟着变。这是入门阶段最容易踩的雷。我当年第一次写棋盘就是在这翻的车——下了一颗子整列都变成了黑色。用列表推导式[[0 for _ in range(SIZE)] for _ in range(SIZE)]才是每个子列表独立创建。2.2 打印棋盘控制台对不齐才是大问题控制台版五子棋最大的视觉困难是“对齐”。中文是双宽度字符英文和数字是单宽度混在一起很容易歪。我用的是全 ASCII 方案空位画.黑子用X白子用O。这样在各类终端下都能稳定对齐虽然没那么漂亮但调试的时候不会因错位产生误判。def print_board(board): print( , end) for col in range(SIZE): print(f{col:2}, end) print() for row in range(SIZE): print(f{row:2} , end) for col in range(SIZE): stone board[row][col] if stone 0: print( ., end) elif stone 1: print( X, end) else: print( O, end) print()注意我在格式上用了f{col:2}就是让每个列标都占两位宽度。棋盘每一格也统一用“空格加字符”的方式输出这样十位数和个位数能对上。实际跑起来坐标从 0 到 14行列一目了然。有朋友问过为什么不用●和○更接近真实棋子。我的回答是可以但不同操作系统的终端字体对全角符号的渲染宽度不一样容易出现竖线不齐的情况。你自己玩的时候喜欢可以换上但做教程或批量测试时ASCII 方案最稳。2.3 边界保护两种方案的取舍写棋盘相关的操作时一定会遇到“越界”的问题比如判断右边有没有棋子时最右边一列再往右就出界了。我见过两种处理办法。第一种是每次访问前都检查if 0 row SIZE and 0 col SIZE。优点是直观缺点是要写很多遍容易漏。第二种是“哨兵围墙”法在真实棋盘四周各加一圈无效标记比如用-1表示“墙”这样访问任意方向都不会真正出界判断代码会简洁一点。但代价是棋盘的实际尺寸、坐标映射都要偏移打印时也得处理对新手来说反而更容易乱。我给的建议是五子棋这种小项目直接用范围检查就行。到后面做 AI 评分时把范围判断封装进一个小的工具函数里就不会显得啰嗦。如果在更复杂的项目里遇到大量边界查询再考虑围墙方案也不迟。别为了炫技牺牲可读性。3. 连珠判定这段代码值得反复推敲3.1 为什么要从落点反向扫描五子棋的胜负标准非常简单横、竖、两条斜线方向任一方向出现连续五颗同色棋子。但“简单”的规则落到代码里特别容易写错。最常见的错误是落子后遍历整个棋盘检查每一行每一列有没有连五。这种写法也不是不行但效率低、逻辑分散还得同时考虑横竖斜四种情况代码很长。我采用的做法是“落点反向扫描”每次落下一颗子只需要从这颗子出发沿四个方向分别向两侧数同色棋子的数量。如果某个方向上同色棋子的总数包含刚落下的这枚大于等于 5就分出胜负了。因为新增棋子只可能改变包含它的连线不可能改变棋盘其他区域的连线。方向可以抽象成四组坐标增量# (行增量, 列增量) DIRECTIONS [ (0, 1), # 水平 (1, 0), # 垂直 (1, 1), # 主对角线 (1, -1), # 副对角线 ]3.2 判定函数完整实现我习惯把“数某方向连子”单独拆成一个函数def count_in_direction(board, row, col, dr, dc, color): count 0 r, c row dr, col dc while 0 r SIZE and 0 c SIZE and board[r][c] color: count 1 r dr c dc return count这个函数从落点的相邻一格开始沿某个方向一直数直到遇到不同颜色、空位或边界才停。然后是胜利判断def check_win(board, row, col): color board[row][col] if color 0: return False for dr, dc in DIRECTIONS: total 1 total count_in_direction(board, row, col, dr, dc, color) total count_in_direction(board, row, col, -dr, -dc, color) if total 5: return True return False这里最核心的思路是一个方向的正反两个方向都要数。有人会只往一个方向数五个结果明明是斜着的五连却因为只数了一侧而漏判。这个问题我第一次跑测试时就抓到了后面你会看到批量测试的重要性。3.3 三个容易忽略的细节第一个细节很多代码会把判定写成total 5必须改成total 5。因为棋盘上可能出现六个甚至七个连子也就是“长连”。规则上超过五子也算赢不能用等于。第二个细节检查的起点是当前落子位置本身。有人会把 count 初始化为 0然后再循环里从落点开始数反而把落点数漏了。第三个细节check_win的入参color不用额外传直接从board[row][col]读出来就好。这样可以避免调用方传错颜色导致误判。输入落子后必须确保该位置已经写入棋子再调用判定函数顺序错了也会出奇怪的问题。3.4 用自动化测试覆盖判定逻辑胜负判定是五子棋所有代码里最关键的一环我用一个简单脚本批量覆盖各种情况。核心思路是构造一个只有五个连子的小棋盘然后把五连所在的第一个位置传进去看是不是返回 True。def make_board(size15): return [[0 for _ in range(size)] for _ in range(size)] def test_horizontal(): b make_board() for col in range(5): b[7][col] 1 assert check_win(b, 7, 0) is True还可以测“四个连子不判定胜利”“斜向五连”“五子以上同样胜利”等用例。我甚至写过一个循环把所有可能的落点遍历一遍确认没有漏判。整个测试脚本也就几十行但这会儿省下的排查时间比写代码的时间多得多。建议你也养成交付前先跑自动测试的习惯。4. 给 AI 装一个简单的脑子打分制足够有趣4.1 从零开始设计评估函数人机对战是五子棋项目里最让人兴奋的部分。我不建议一上来就上蒙特卡洛树搜索、神经网络这种重型武器原因很简单控制台版的核心目标是理解逻辑一个打分制的 AI 已经能让普通玩家感受到“对手在思考”。打分制的基本思想是对棋盘上每个空位假设 AI 在这里落子按它能形成的棋形给一个“进攻分”再假设对手在这里落子按对手能形成的棋形给一个“防守分”。把这两个分数按权重合并选总分最高的位置落子。棋形怎么看还是用方向扫描。从某个空位出发沿四个方向分别统计如果放一颗某颜色的棋子能形成多长的连子两端有几个方向是开放的。例如两端都开放的四连就是“活四”下一步必赢分数极高一端堵死一端开放的四连是“冲四”也很危险两端都开放的“活三”已经产生了两个威胁点我把棋形的分数整理成一张表这个表也是后面调参的重心棋形描述连续子数开放端数量分数连五5任意100000活四4250000冲四4110000活三325000眠三311000活二22500眠二21100活一1250眠一1110这里的分值并不是唯一标准但比例关系比较合理。连五给 100000 是为了让 AI 一旦发现能赢就一定下那里活四给 50000是因为活四在下一步必然变成五连。4.2 评估函数的代码实现我封装了一个函数模拟“在某个空位放一枚指定颜色的棋子”然后统计四个方向的棋形分数总和def evaluate_point(board, row, col, color): total_score 0 for dr, dc in DIRECTIONS: count 1 open_ends 0 for sign in (1, -1): r row sign * dr c col sign * dc while 0 r SIZE and 0 c SIZE and board[r][c] color: count 1 r sign * dr c sign * dc if 0 r SIZE and 0 c SIZE and board[r][c] 0: open_ends 1 total_score shape_score(count, open_ends) return total_scoreshape_score函数就根据表格里对应的分值返回分数def shape_score(count, open_ends): if count 5: return 100000 if open_ends 0: return 0 if open_ends 1: if count 4: return 10000 if count 3: return 1000 if count 2: return 100 if count 1: return 10 if open_ends 2: if count 4: return 50000 if count 3: return 5000 if count 2: return 500 if count 1: return 50 return 04.3 AI 决策进攻分与防守分的取舍AI 每次落子时遍历所有空位分别计算 AI 颜色的进攻分和玩家颜色的防守分。然后采取一个很关键的策略防守权重略微大于进攻权重。def ai_move(board): best_score -1 best_move (SIZE // 2, SIZE // 2) for row in range(SIZE): for col in range(SIZE): if board[row][col] ! 0: continue attack_score evaluate_point(board, row, col, 1) defend_score evaluate_point(board, row, col, 2) total_score attack_score int(defend_score * 1.2) if total_score best_score: best_score total_score best_move (row, col) return best_move为什么防守权重要略高五子棋是先行者优势很大的游戏AI 作为后手时如果不等比地重视防守很容易在玩家形成活三冲四时才意识到要堵那时候已经晚了。1.2这个系数是我反复测试出来的经验值太高 AI 会过于保守全程被动太低又容易无视玩家的威胁。这里有一个明显的优化空间AI 只在“已被占位置周围”搜索空位而不是遍历整个 15×15 棋盘。因为隔得很远的空位在早期几乎不可能形成威胁遍历全棋盘纯属浪费。不过这个优化对控制台版来说效果不明显代码复杂度却会提高所以我保持了简单版。你可以把 AI 能力理解成一个“会堵棋、能补棋”的入门水平玩家对新手确实够用玩几局才会发现它在复杂定式上判断不够深。5. 游戏主循环与控制台输入如何把各部分串起来5.1 主循环结构设计五子棋的主流程不复杂但顺序错了就会出逻辑漏洞。我的设计是打印棋盘玩家输入坐标并落子判定玩家是否胜利、棋盘是否已满AI 计算落点并落子判定 AI 是否胜利、棋盘是否已满回到第一步代码骨架如下def main(): board [[0 for _ in range(SIZE)] for _ in range(SIZE)] print_board(board) while True: row, col get_user_move(board) board[row][col] 2 print_board(board) if check_win(board, row, col): print(恭喜你赢了) break if is_full(board): print(平局) break print(AI 思考中...) ai_row, ai_col ai_move(board) board[ai_row][ai_col] 1 print(fAI 落子在{ai_row 1} 行 {ai_col 1} 列) print_board(board) if check_win(board, ai_row, ai_col): print(AI 获胜) break if is_full(board): print(平局) break注意我在每次落子之后立即判定而不是等到下一轮开始才判定。这个细节很关键否则会出现“已经赢了但棋盘又打印了一次”的别扭体验。is_full函数遍历棋盘没有空位就返回 Truedef is_full(board): for row in range(SIZE): for col in range(SIZE): if board[row][col] 0: return False return True5.2 输入解析的边界问题控制台输入是整个项目里和真实使用体验最相关的一部分。用户大概率会输入7 5这种格式也就是“行 列”中间用空格隔开。但实际测试时会遇到各种奇怪的输入空字符串、单个数字、负数、字母、中文标点。我写了一个专门处理输入的函数把所有异常情况都兜住def get_user_move(board): while True: raw input(请输入落子坐标行 列例如7 5) parts raw.split() if len(parts) ! 2: print(格式错误需要两个数字用空格隔开) continue try: row int(parts[0]) - 1 col int(parts[1]) - 1 except ValueError: print(请输入数字) continue if row 0 or row SIZE or col 0 or col SIZE: print(坐标超出棋盘范围) continue if board[row][col] ! 0: print(这个位置已经有棋子了) continue return row, col很多新手会直接写row, col eval(input(...))我说句实话这个写法既危险又难用。eval会把用户输入的任意表达式都执行掉作为练习项目不该引入这种隐患。用split加int转换是最稳妥的解析方式。还有一点是关于坐标的起点。我在内部统一用 0 开始索引但用户输入时显示从 1 开始。这样可以避免新手玩家搞不清楚“第 0 行”在哪里也让输出坐标时更自然。这个 1 和 0 的转换在代码里一定要保持一致不然玩家在 15×15 棋盘上输入15 15程序却报越界非常打击信心。5.3 我实际调试时踩过的几个坑调试过程中我记录了好几个反直觉的问题。第一个是打印棋盘时方向写反。我一直觉得“第几行、第几列”这个顺序很自然但写打印函数时很容易先写列再写行结果棋盘直接转置了。解决办法是每次写完打印函数后手动在角落里下一颗子观察坐标是否符合预期。第二个坑是主循环里在判定玩家胜利后又让 AI 走了一步。表现出来就是玩家已经五连了AI 还在继续落子。解决方式就是把判定放在玩家落子后立刻执行并且用break跳出循环。我在代码骨架的缩进和逻辑顺序上花了不少时间你如果也跟着写一定要留意这一步。第三个坑是平局判断被放在最前面。一开始我写的是“进入循环先用is_full判断”结果每次轮到 AI 前都要多查一遍。放到玩家和 AI 各自落子后再判断才是正确的开局棋盘永远不满没必要一进来就查。输入处理还有一个藏得很深的 bugraw.split()如果用户输入7 5中间多个空格split()默认也处理得好因为 Python 的split()不带参数时会按任意空白字符切分多个空格或 Tab 都没关系。但如果我写raw.split( )多个空格就会出现空字符串元素导致转换报错。这种细节只有真实跑过输入的测试才会发现。6. 让项目更进一步参数化棋盘、悔棋与存档的扩展思路6.1 把棋盘尺寸抽成可配置参数现在代码里SIZE是常量改成参数化之后你可以方便地做 9×9 的快速对局、13×13 的亚运版本、15×15 的经典版本。我的做法是把所有函数都增加一个size参数或者用一个全局的Board类把尺寸和操作封装起来。如果你还没学过面向对象可以先定义一个字典来传递配置config { size: 15, player_color: 2, ai_color: 1, }这样做的价值是当你需要测试不同棋盘尺寸下的 AI 表现时不需要改函数内部逻辑只改配置即可。这个小改动能让“批量测试”这件事变得格外方便因为它把变量提升到了更高层级。6.2 悔棋功能看似简单其实考验栈结构悔棋本质上是“撤销最后一步”最自然的实现是用栈来保存历史落子。玩家悔棋时把最后两步AI 的一步和玩家自己的一步从栈里弹出对应棋盘位置改回 0。如果是双人对战模式就只弹一步。history [] def undo_move(board): if not history: print(没有可以撤销的步骤) return row, col history.pop() board[row][col] 0这里有一个细节悔棋之后如果玩家重新落子历史记录的后半部分就失效了。正规做法是维护一个“当前指针”而不是直接弹出栈顶否则悔棋再走新棋之后往前的步骤会错乱。但控制台练习版用直接弹出也够用只是你要清楚这个限制。6.3 存档读档让棋盘具备“会话”能力存档的实现思路更直接把棋盘二维数组序列化成文本每行用空格或逗号分隔。读取时按同样的规则还原。可以顺便把当前轮次、悔棋历史都存下来。用 JSON 是最省事的路径import json def save_game(board, filenamesave.json): data { size: SIZE, board: board, turn: player, } with open(filename, w, encodingutf-8) as f: json.dump(data, f) def load_game(filenamesave.json): with open(filename, r, encodingutf-8) as f: data json.load(f) return data[board]但要注意JSON 文件里的二维列表读取出来后要确保每一行都是独立列表。如果存档过程没问题这个返回结果可以直接塞进现有函数继续跑。我还建议把存档文件的格式信息比如尺寸也存进去这样换版本的时候不至于读不了旧存档。存档功能对控制台版的价值更多在于练习文件读写而不是真的有人会时不时存个五子棋进度。6.4 另一个扩展方向双人对战模式的抽象AI 版和双人版之间的差别其实只在“轮到谁落子”这一步。把“落子者”抽象出来做成一个函数参数代码复用率会高很多def game_loop(board, player_black, player_white): current player_black while True: row, col current.get_move(board) board[row][col] current.color ... current player_white if current player_black else player_black这里player_black可以是人类玩家也可以是 AI 玩家。只要大家都实现一个get_move方法主流程完全不用改。这就是“策略模式”在游戏里的雏形比直接堆 if else 要优雅一些。我后来把 AI 从弱到强实现了三个版本就是靠着这种抽象切换对手只改一行代码。最后再分享一点自己的体会这个项目做下来我最深的感受是五子棋看起来规则简单但真正实现一遍之后才会发现自己对“游戏程序”的理解提升了一大截。以前觉得游戏很神秘无非就是循环、状态、判断三件事只是状态多了、循环长了、判断复杂了而已。如果你也照着代码敲完建议你自己动手改三个地方一是把 AI 的防守权重从 1.2 改成 1.0 试试看看它是不是变“钝”了二是把total 5改成total 5跑一把六连验证下问题三是把棋盘尺寸改成 9感受下同样的代码在小棋盘上的节奏变化。这三个小试验比单纯抄代码有用得多。只有亲手制造过 bug、亲手修好 bug程序逻辑才算真正内化成你自己的东西。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →