ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用Python和curses实现终端同化棋:从棋盘逻辑到AI搜索完整指南

用Python和curses实现终端同化棋:从棋盘逻辑到AI搜索完整指南 简介一份面向高校计算机相关专业学生的C课程设计资源源自北京大学计算概论A期末大作业实现终端交互式同化棋游戏。资源包共2个文件包含同化棋.cpp源文件与README.md说明文档压缩后仅4KB结构精简、用途明确源码与文档一一对应。同化棋作为双人策略棋类通过终端交互运行覆盖棋盘逻辑、胜负判定等核心代码适合用于面向对象编程、算法设计的学习巩固也可作为课程设计、期末项目或立项演示的参考。该项目代码已通过运行验证当前已有212人学习下载可直接在此基础上扩展图形界面或人机对战功能。文档部分对项目背景和使用方式做了必要说明方便快速上手与二次开发。1. 终端交互同化棋最难的不是同化规则每年计算概论A的大作业列表里“终端交互同化棋”都是刷人率最高的那档。原因不在规则同化棋的处理逻辑用三十行代码就能写完真正卡住大多数人的是“终端交互”这三个字——光标怎么定位、按键怎么不回车就响应、窗口尺寸一变棋盘怎么才不会散架。这门课只教了 Python 和算法很少教你怎么把一个状态机稳稳地跑在终端里。这篇以同化棋为主题从数据结构、终端渲染、输入循环到 AI 参数和期末文档给出一套能直接落地、也禁得起追问的实现方案。你不需要有 curses 经验每个函数我都会拆开讲。如果你的目标是交一个能跑、能演示、答辩时有话可说的作品照着这套结构从零写比去网上找一个读不懂的完整源码要稳得多。2. 同化棋的棋盘状态从二维矩阵到能吃子的规则2.1 棋盘用二维矩阵还是用一维数组加位棋盘课程作业阶段不选位棋盘。位棋盘用两个 64 位整数表示双方棋子检索和同化操作在 C 语言里会非常快但你现在写的是 Python终端渲染需要逐个格子定位二维 list 的直观程度完胜。用一维数组当然也能做可每多一层坐标换算答辩时就要多解释一句不合适。状态编码要在一开始就定死0 代表空位1 代表玩家 12 代表玩家 2。初始化时双方各占棋盘上下两个半区这是同化棋最常见的开局布局能让双方在十回合内就有接触也方便后续测试吃子逻辑。# board.py EMPTY, P1, P2 0, 1, 2 def create_board(size: int 8) - list[list[int]]: board [[EMPTY for _ in range(size)] for _ in range(size)] # 后手方占据顶端两行先手方占据底端两行形成对称局面 for row in range(2): for col in range(size): board[row][col] P2 for row in range(size - 2, size): for col in range(size): board[row][col] P1 return boardsize默认 8也可以改成 6 做快速自测。用0/1/2做状态值而不是字符串在判等和计数时都会快一些后续写极小化极大搜索时这个优势会更明显。初始化时没有把棋子交错摆放是因为同化棋强调“同化相邻”而不是“跳过吃子”紧凑的初始布阵才能在有限回合内制造足够多的冲突。2.2 移动与同化封装把规则锁进一个函数同化棋的核心动作是选择己方一枚棋子移动到相邻空位移动落定后检查该棋子四方向上是否有敌方棋子有则将其同化为己方颜色。这里选择四连通而不是八连通能显著减缓棋盘上的势力扩张速度避免出现“走一步、染半盘”的问题。八方向会让 AI 评估变得极不稳定也不利于做搜索剪枝。DIRS [(-1, 0), (1, 0), (0, -1), (0, 1)] def move(board: list[list[int]], r: int, c: int, tr: int, tc: int) - bool: n len(board) # 目标位置越界或非空直接拒绝 if not (0 tr n and 0 tc n): return False if board[tr][tc] ! EMPTY: return False me board[r][c] board[tr][tc] me board[r][c] EMPTY # 四方向同化3 - me 利用 123 反转颜色 for dr, dc in DIRS: nr, nc tr dr, tc dc if 0 nr n and 0 nc n and board[nr][nc] 3 - me: board[nr][nc] me return True这里的关键点是先校验目标位置再执行移动而不是在校验起点上花太多功夫。3 - me是一个常见的小技巧等价于P2 if me P1 else P1但写成算术形式后整个函数少一个分支。代码里保留注释否则助教看代码时容易愣一下。移动函数直接原地修改棋盘返回值是布尔值便于外层输入循环判断“这一步有没有走成功”。如果你的版本想支持悔棋需要在这个函数外先复制整个棋盘快照再调用它不建议把快照逻辑混进move里。2.3 终局判断不只是“子数为零”一个棋子为零就结束这是最直观的规则。但同化棋实际对局里双方都可能进入无子可动的僵局所有己方棋子被困在自己半区或者棋盘被完全堵死。所以终局条件通常是两条组合某一方棋子归零或者连续若干回合没有任何合法移动。def legal_moves(board: list[list[int]], player: int) - list[tuple[int, int, int, int]]: moves [] n len(board) for r, row in enumerate(board): for c, v in enumerate(row): if v ! player: continue for dr, dc in DIRS: nr, nc r dr, c dc if 0 nr n and 0 nc n and board[nr][nc] EMPTY: moves.append((r, c, nr, nc)) return moves def game_over(board: list[list[int]], no_move_count: int) - bool: c1 sum(row.count(P1) for row in board) c2 sum(row.count(P2) for row in board) if c1 0 or c2 0: return True if no_move_count 2: return True return Falseno_move_count每回合自增任何一方走出合法移动就重置为 0。legal_moves返回的元组结构是(起点的行, 起点的列, 终点的行, 终点的列)这个顺序要始终保持一致后续终端交互、AI、回放模块都会依赖它。3. 终端交互实现用 curses 让方向键驱动光标3.1 为什么不用 input() 做输入用input()加坐标输入比如“输入当前棋子的行和列”写起来确实快但体验非常差每次输入都要按回车方向键会显示成^[[A之类的转义序列而且无法做到光标实时移动这不叫终端交互。工程上标准的做法是curses。它是 Python 标准库的一部分在 Linux 和 macOS 上直接可用Windows 上通过windows-curses包补齐。如果你的比赛环境不允许安装额外依赖也可以退回到termios直接读取按键但代码复杂度会高很多。import curses def main(stdscr): curses.curs_set(0) # 隐藏系统光标 curses.use_default_colors() # 允许使用终端默认背景色 stdscr.timeout(50) # getch 阻塞 50ms用于响应窗口变化 board create_board() turn P1 cursor [4, 4] selected None while True: draw(stdscr, board, cursor, selected, turn) key stdscr.getch() if key ord(q): break # 方向键与确认键随后处理 curses.wrapper(main)curses.wrapper(main)负责初始化终端、在异常时恢复终端状态这是最省心的启动方式不用自己手动curses.initscr()。stdscr.timeout(50)的意义在于限制键盘读取阻塞时间让主循环能周期性地重新绘制画面处理用户终端窗口缩放的情况如果设置成阻塞模式窗口变化时会卡住界面不刷新。3.2 绘制函数拆成“网格计算”与“字符输出”把绘制逻辑拆成独立函数最大的好处是能在脚本测试里直接调用它把棋盘内容重定向到字符串变量方便做单元测试。终端里的坐标轴是行列行在前、列在后和矩阵下标保持一致不会出现“x, y 反了”的经典事故。def draw(stdscr, board, cursor, selected, turn): stdscr.erase() n len(board) h, w stdscr.getmaxyx() if h n 6 or w n * 4 12: stdscr.addstr(0, 0, terminal too small, resize to at least 14x32) stdscr.refresh() return for r in range(n): for c in range(n): ch . if board[r][c] P1: ch X elif board[r][c] P2: ch O if (r, c) tuple(cursor): attr curses.A_REVERSE elif selected and (r, c) selected: attr curses.A_BOLD else: attr 0 stdscr.addstr(r 1, c * 4 1, f {ch} , attr) stdscr.addstr(n 1, 0, fturn: {P1 if turn P1 else P2}) stdscr.refresh()棋盘间距固定为 4 个字符宽度保证每个格子占位一致。attr参数用来高亮当前光标所在格和已选中棋子光标格用反色选中格用粗体。这一点对答辩演示很重要肉眼能直接看到当前操作对象不需要刻意解说。3.3 输入循环选中与移动的两段式状态终端交互的核心是一个“选中-再确认”的两段式状态循环。第一次按空格选中己方棋子第二次按空格确认目标位置并执行移动。方向键只负责挪光标不参与逻辑判定这样的设计让输入部分保持简单。按键动作↑↓←→光标向对应方向移动一格空格选中棋子或确认移动b取消选中状态r重置棋盘q退出游戏if key curses.KEY_UP: cursor[0] max(0, cursor[0] - 1) elif key curses.KEY_DOWN: cursor[0] min(n - 1, cursor[0] 1) elif key curses.KEY_LEFT: cursor[1] max(0, cursor[1] - 1) elif key curses.KEY_RIGHT: cursor[1] min(n - 1, cursor[1] 1) elif key ord( ): r, c cursor if selected is None: if board[r][c] turn: selected (r, c) else: sr, sc selected if move(board, sr, sc, r, c): turn 3 - turn no_move_count 0 selected None elif key ord(b): selected None elif key ord(r): board create_board() turn, selected, no_move_count P1, None, 0光标移动用max/min做边界收敛而不是越界后取模防止光标在棋盘两侧循环跳动。move返回False时只清除选中状态回合不切换玩家可以立刻重新选择。b的存在必要在于你选中一个棋子后发现路径不通需要一个显式的取消键避免被迫乱走一步。4. 给同化棋加一个对手极小化极大搜索的两个调参点4.1 评估函数从棋子数到位置权重人机对弈是同化棋大作业最常加的加分项。最简单的 AI 只数棋子数量但这样会导致 AI 只盯眼前利益被玩家用边角布局反复牵制。工程上更稳的评估函数是“棋子数 位置加权”边角的棋子更难被同化价值更高中心棋子容易被围攻适当降低权重。WEIGHT [ [8, 4, 5, 5, 5, 5, 4, 8], [4, 1, 2, 2, 2, 2, 1, 4], [5, 2, 3, 3, 3, 3, 2, 5], [5, 2, 3, 3, 3, 3, 2, 5], [5, 2, 3, 3, 3, 3, 2, 5], [5, 2, 3, 3, 3, 3, 2, 5], [4, 1, 2, 2, 2, 2, 1, 4], [8, 4, 5, 5, 5, 5, 4, 8], ] def evaluate(board: list[list[int]]) - float: score 0 for r in range(8): for c in range(8): if board[r][c] P1: score 10 WEIGHT[r][c] elif board[r][c] P2: score - 10 WEIGHT[r][c] return score权重的量级要和棋子基础分匹配。基础分给 10角给 8相当于比一个普通棋子重要不到一倍如果角权重给 50AI 会掰着手指头只抢四角忽略中部战线反而容易在被同化后连片崩溃。权重表在实际竞赛局里不需要太精细这个量级的启发式足够打败只会随机走的初级人类玩家。4.2 搜索深度与剪枝参数不是越深越好极小化极大搜索里最容易贪的是深度。深度 6 跑一步要好幾秒终端交互卡死体验全毁。作业阶段控制在深度 3 到 4 即可配合 Alpha-Beta 剪枝单步等待时间能压到 300ms 以内。参数推荐值说明max_depth3 或 4超过 4 后 Python 搜索耗时成倍增长moves_limit12每层只考察前 12 个候选移动降低分支因子time_budget0.8s超时后返回当前最优结果保证交互流畅def search(board, depth, alpha, beta, turn, moves_limit12): if depth 0: return evaluate(board) moves legal_moves(board, turn)[:moves_limit] if not moves: return evaluate(board) if turn P1: best -float(inf) for r, c, tr, tc in moves: b2 [row[:] for row in board] move(b2, r, c, tr, tc) val search(b2, depth - 1, alpha, beta, P2, moves_limit) best max(best, val) alpha max(alpha, best) if beta alpha: break return best else: best float(inf) for r, c, tr, tc in moves: b2 [row[:] for row in board] move(b2, r, c, tr, tc) val search(b2, depth - 1, alpha, beta, P1, moves_limit) best min(best, val) beta min(beta, best) if beta alpha: break return best在search外层套一层choose_move遍历根节点的所有移动依次调用search并记录每步的分数。排序剪枝是 Python 搜索的命门先用legal_moves的返回值去评估一个粗略分数再按从高到低排序剪枝命中率能提高 20% 以上。不加排序时剪枝只会切断最理想情况下的分支收益有限。4.3 复盘时看什么走子日志与局面权重AI 参数调优最怕瞎试。建议在每次 AI 落子前打印一行到标准错误流ai: depth4 alpha_beta1 eval_score6.5 best_move(5,2)-(5,3)这里eval_score是评估函数返回的局面分数正数表示 AIP1占优。把每步打印结果存到文件里几局过后就能看出 AI 在哪些局面下评估失误比如明明棋子数领先分数却是负数那就是位置权重设置出了问题。用这个日志配合游戏回放文件比在终端里肉眼盯棋盘有效得多。5. 期末文档说明怎么写从需求到测试覆盖助教提问列表5.1 文档大纲四个必须出现的部分“源代码文档说明”的大作业文档不是代码的复述而是让助教和老师不用读代码也能判分。文档结构可以固定在四块需求分析、总体设计、测试记录、用户手册。设计部分画一个简单的模块调用关系即可不必生成复杂的 UML 图。文档章节应包含内容需求分析游戏规则、运行环境、依赖库、支持的操作系统总体设计主循环流程图、模块划分、函数职责表测试记录单元测试用例、边界场景、手动测试步骤用户手册启动命令、按键表、AI 难度切换方式函数职责表这一项最容易被忽略。我一般建议用 Markdown 表格列出每个函数的输入、输出、副作用函数名输入输出副作用create_board棋盘大小二维 list无move棋盘、起点、终点bool修改棋盘内容drawstdscr、棋盘、光标None向终端写内容5.2 测试样例覆盖合法、非法、边界棋盘写测试样例时把程序当成一个黑盒。常见错误集中在三类移动到非空格子、移出棋盘边界、同化时修改了棋盘外的区域。下面是三个手测用例可以直接写进测试文档。用例输入操作预期结果合法移动选中(5,0)P1移动到(5,1)空格移动成功回合切换越界移动选中(5,0)P1移动到(5,-1)移动失败turn 不变同化相邻移动后目标点旁有 P2P2 变为 P1计数更新这些用例不需要引入额外的测试框架用python -m unittest就能跑。重点是在文档里写清楚“为什么这个用例重要”比如越界用例是为了验证输入循环对光标的边界收拢是否生效。自动化测试之外文档里最好附一段 asciinema 录制的终端操作视频。录制命令是asciinema rec结束后生成一个.cast文件把它连同代码一起压缩提交。老师可以直接在终端里用asciinema play查看不需要安装任何解码播放器这比贴一张截图要有说服力。6. 免解释的回放技巧把每一手落子导出成 log答辩现场最尴尬的一刻是老师问“这把棋怎么输的”而你只能从头重新下一遍。更稳的做法是让程序支持把整局记录导出成文本然后用同一套渲染代码把记录重放出来。这个功能实现起来非常简单却常常是加分项。log [] # 在输入循环外初始化 ... if move(board, sr, sc, r, c): log_line f{turn} {sr} {sc} {r} {c} log.append(log_line)文件格式不依赖 JSON 或 pickle直接用空格分隔的文本行每行五列当前回合玩家、起点的行和列、终点的行和列。运行结束时把这些行写进replay.log。重放时只需要一个循环python game.py --replay replay.log --speed 0.5--replay让程序跳过键盘输入改为按固定时间间隔读取日志中的每一行并调用move更新棋盘--speed 0.5表示每秒放 0.5 步也就是每步停留 2 秒。时间内核在真实对局中也记录存档和分析都能复用同一份数据。调试策略上可以用极小的棋盘规格跑回放比如create_board(4)是简化模式日志里多写两条replay: turn1 move...。改动只在创建棋盘和写日志两个位置代价极低收益却直接体现在答辩现场。本文还有配套的精品资源点击获取
返回列表