如果你最近在学 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(size=15): 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 |
| 活四 | 4 | 2 | 50000 |
| 冲四 | 4 | 1 | 10000 |
| 活三 | 3 | 2 | 5000 |
| 眠三 | 3 | 1 | 1000 |
| 活二 | 2 | 2 | 500 |
| 眠二 | 2 | 1 | 100 |
| 活一 | 1 | 2 | 50 |
| 眠一 | 1 | 1 | 10 |
这里的分值并不是唯一标准,但比例关系比较合理。连五给 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(f"AI 落子在:{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函数遍历棋盘,没有空位就返回 True:
def 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 各自落子后再判断才是正确的:开局棋盘永远不满,没必要一进来就查。
输入处理还有一个藏得很深的 bug:raw.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, filename="save.json"): data = { "size": SIZE, "board": board, "turn": "player", } with open(filename, "w", encoding="utf-8") as f: json.dump(data, f) def load_game(filename="save.json"): with open(filename, "r", encoding="utf-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,程序逻辑才算真正内化成你自己的东西。