资讯详情

Python 实战:把一个“半成品“跳棋改造成单机 / 人机 / 联机三模式完整项目

📅 2026/10/29 2:18:59 | 华诺云谱 👁 阅读
Python 实战:把一个“半成品“跳棋改造成单机 / 人机 / 联机三模式完整项目
写在前面在学校随便做的中国跳棋课程设计。它能跑但问题不少棋盘画得出来点棋子却经常没反应联机靠手工拼 JSON还用eval解析报文只要有一个玩家断线整局游戏就卡死没有胜负判定没有 AI也没有规则说明于是我花了一点点时间把它从能演示改成了真的能玩单机热座、人机对战、联机对战三种模式共用一套规则引擎。这篇文章记录了完整的改造思路、关键代码和踩过的坑适合正在做 Python 课程设计 / 小游戏 / 网络编程练习的朋友参考。一、先说结论最终做成了什么能力说明单机热座2/3/4/6 人同屏轮流平移自动换人人机对战你执红先行AI 执绿联机对战房主选人数人满自动开局断线自动移交回合玩法教程5 页分步教程 游戏内规则速查账号系统注册 / 登录 / 找回密码口令加盐哈希技术上有一个关键决定把规则从界面里彻底剥离出来。这一步做对了后面三种模式才可能共用一个内核。二、架构四层一条主线┌──────────────────────────────┐ │ GamePanel.py 登录 / 菜单 / 侧栏 │ └──────────────┬───────────────┘ ┌───────┴────────┐ localGame.py gameClient.py 单机引擎 渲染器 联机客户端 │ │ └──────┬─────────┘ rules.py ←── aiPlayer.py 纯规则无 UI / 无网络 ​ hoppingGame.py服务端── rules.py │ netproto.py帧协议核心约束只有一句rules.py不 import tkinter、socket、turtle。它只接收一个board字典返回合法落点、执行走子、判断胜负。因此单机能用服务端能用AI 能用来推演写单元测试时不用起窗口数据模型棋盘用轴坐标表示121 个格子DIRS ((-1, 0), (-1, 1), (0, 1), (1, 0), (1, -1), (0, -1)) board {(x, y): 0 或 pid 1} # 0 表示空目标营地用一行公式解决def goal_camp(pid): return (pid 3) % 6 # 正对面不管 2 人还是 6 人每位玩家到目标的距离都相等规则天然公平。三、核心实现3.1 规则引擎把能不能走变成纯函数最核心的是legal_destinationsdef legal_destinations(board, pos, pid, steps_allowed): dests [] for dx, dy in DIRS: adj (pos[0] dx, pos[1] dy) if adj not in board: continue if board[adj]: # 相邻有子 → 尝试跳 land (adj[0] dx, adj[1] dy) if land in board and board[land] 0: dests.append(land) elif steps_allowed: # 相邻为空 → 平移 dests.append(adj) ​ goal camp_cells(goal_camp(pid)) if pos in goal: # 进营后不能出营 dests [d for d in dests if d in goal] return destssteps_allowedFalse表示连跳中途——此时只能继续跳不能平移。这一个参数同时解决了走子规则和连跳限制。胜负判定也简单到不需要解释def is_win(board, pid): goal camp_cells(goal_camp(pid)) return all(board.get(cell) pid 1 for cell in goal)3.2 单机热座连跳与收手单机模式最容易忽略的是连跳中途。连跳时只能继续跳同一颗棋子点别的棋子必须被拒绝。但如果拒绝是静默的玩家会以为程序卡死了——这是我在测试中真实遇到的反馈。所以现在if self.jumping: if cell self.cur_ball: self.end_turn() # 再点当前棋子 收手 elif cell in self.focus: self.do_move(cell) # 继续跳 else: self._hint(连跳中请跳往白色落点点击当前棋子或按右键结束回合) return任何无效操作都要有反馈这是交互设计里最容易被低估的一条。3.3 联机协议告别eval用长度前缀帧原项目的做法是手工拼字符串再用eval解析# 旧代码危险示例 data eval(sock.recv(1024).decode())这有两个致命问题TCP 是字节流不保证一次recv就是一条完整消息——粘包/拆包eval执行任意代码客户端可以构造恶意报文改为 4 字节大端长度前缀 JSONdef send_msg(sock, obj): data json.dumps(obj, ensure_asciiFalse).encode(utf-8) sock.sendall(struct.pack(!I, len(data)) data) ​ def recv_msg(sock): header _recv_exact(sock, 4) if header is None: return None # 对端关闭 length struct.unpack(!I, header)[0] if length 1 20: raise ValueError(message too large) return json.loads(_recv_exact(sock, length).decode(utf-8))_recv_exact循环读到指定字节数这是处理 TCP 拆包的标准写法。自测时我特意构造了5000 字节大报文 三条消息一次性发出的极端场景验证帧边界正确。3.4 服务端唯一权威 回合守卫联机模式下客户端发来的只是请求不是命令def process_new_point(self, new_pos): if not self._owner.is_started() or self._owner.cur_player() is not self: return False # 未开局 / 不是你的回合 → 完全忽略 ...这一行守卫拦掉了三类作弊/误操作观战者乱点、非当前玩家抢跑、开局前点击。右键也要守卫。原来的switch命令谁发都执行一条滞后的右键就能把别人的回合抢走尤其平移已经改成自动交棒后这种竞态更容易撞上elif cmd switch: if self._owner.is_started() and not self._owner.is_over() \ and self._owner.cur_player() is self: self._owner.switch_player()自动交棒落子后只要没有后续跳点就自动换人和单机保持一致。判断条件只有一句not self.next_pos()——平移一步时它必然为空跳跃时它等于还能不能继续跳正好覆盖两种情况if rules.is_win(board, self._pid): self._owner.game_over(self._pid) elif not self.next_pos(): self._owner.switch_player()于是联机的右键只剩下一个用途连跳中途想提前收手。3.5 断线处理finally里一定要收尾原来的实现里玩家掉线后服务端的回合永远停在那个人身上。修复方式很朴素finally: if self._owner is not None: self._owner.remove_player(self)remove_player负责把人从列表移除如果是房主且未开局 → 移交房主身份如果是当前行动玩家 → 回合移交给下一位测试时我真实断开了一个客户端确认另一方立刻收到新的回合焦点。3.6 tkinter turtle 的线程模型这是坑最深的一块。联机客户端需要一个接收线程但tkinter 不是线程安全的。在接收线程里直接调 turtle 绘制轻则花屏重则崩溃。正确姿势是单向流水线# 接收线程只入队 def _recv_loop(self): while True: msg netproto.recv_msg(self._conn) if msg is None: msg {msg: disconnected} self._inbox.put(msg) ​ # 主线程定时消费 def _poll_inbox(self): try: while True: self._handle_message(self._inbox.get_nowait()) except queue.Empty: pass canvas.after(50, self._poll_inbox)所有 UI 操作只在主线程发生接收线程只碰Queue。3.7 性能连点时为什么会卡有朋友反馈快速点几下画面就卡住。排查后不是死锁而是绘制策略tscreen.tracer(2, 10) # 每 2 次绘制刷新一次且每次刷新前等 10ms每点一次棋子要重绘十几到二十次10ms 的延迟层层叠加就出现了几百毫秒的视觉滞后。改成即时刷新并把清旧焦点 画新焦点合并为一次刷新class _BatchDraw: def __enter__(self): self._screen.tracer(0) def __exit__(self, *exc): self._screen.update() self._screen.tracer(1, 0)压测结果连点 30 次 0.24s 完成AI 走子期间连点 30 次几乎 0 耗时无效点击被守卫瞬间丢弃。3.8 AI贪心 连跳 DFSAI 不需要很强但必须合法枚举每颗棋子的所有合法走法平移 跳跃对每个落点试走用到目标营地的距离和评估如果是跳跃DFS 继续枚举连跳路径每个中间落点都算一次可收手的选择取最优路径def evaluate(board, pid): anchor _anchor(pid) return sum(dist(cell, anchor) for cell, v in board.items() if v pid 1)_anchor取目标营地里离己方营地最远的格子营地的尖顶这样 AI 会主动往深处钻而不是堆在门口。DFS 用visited防环避免原地来回跳导致无限递归。四、踩过的坑都是真的问题根因修复点棋子没反应绘制回调期望Position实际传入了元组异常被 tkinter 静默吞掉at()兼容两种入参AI 对战时点几次就卡住连跳中点击被静默拒绝误以为死机无效点击给出状态栏提示再点当前棋子可收手补位玩家棋子重叠用len(players)分配营地号中途有人离开后撞号分配最小空闲营地号房主离开后无法开局没有房主移交逻辑host消息 配置权移交快速连点画面卡tracer(2, 10)的 10ms 强制延迟tracer(1, 0) 批量绘制教程文字被截断Text的size按字符数截断中文改为只读Multiline自动换行重置密码改错账号l[:len(name)]前缀匹配ab会命中abc按空格切分后精确比对登录卡死找回密码窗口缺少退出分支存在死循环重写流程每个分支干净 return其中最值得说的是第一条GUI 回调里的异常会被 tkinter 吞掉。界面上表现为点了没反应但控制台可能什么都没有。所以后期我给界面操作都加了日志和显式反馈。五、测试18 项集成测试最终测试覆盖模块编译9 个文件rules/netproto/aiPlayer三个模块自带自测单机选子、平移、换人、右键、进营跳、连跳收手人机AI 完成回合并交还联机房主、人数协商、自动开局、抢跑拦截、走子同步、回合切换、满员拒绝、断线移交全部通过。联机测试用的是真实 socket双客户端跑完整流程不是 mock。六、怎么运行pip install PySimpleGUI ​ # 单机 / 人机 python GamePanel.py ​ # 联机先起服务器 python hoppingGame.py # 再在另一台/同一台机器上 python GamePanel.py → 联机模式 → 输入服务器 IP服务器监听0.0.0.0:12345同一局域网内输入服务器的内网 IP 即可。记得放行防火墙。七、最后一点感想这个项目最花时间的不是写代码而是判断哪些是看起来能跑、哪些是真的能用。能画棋盘 ≠ 点击链路正确能发消息 ≠ 协议可靠能连上 ≠ 断线不卡死有 AI ≠ AI 走得合法把规则抽成纯函数是我认为这次改造里回报最高的决定它让测试变得容易让三种模式共享同一套逻辑也让后来接手的人能先读懂rules.py再去看界面和网络。如果你也在改类似的老项目建议从抽出规则开始而不是先动 UI。项目结构Tiaoqi/ ├── GamePanel.py 入口与界面 ├── gameConfig.py 常量与配色 ├── rules.py 规则引擎无 UI/网络依赖 ├── localGame.py 单机热座 ├── aiPlayer.py 贪心 AI ├── gameClient.py 渲染器 联机客户端 ├── hoppingGame.py 联机服务端 ├── netproto.py 长度前缀帧协议 ├── ruleGuide.py 规则教程 └── docs/技术文档.md 完整技术文档如果这篇文章对你有帮助欢迎点赞收藏。关于跳棋规则、协议设计或 tkinter 线程模型的问题也可以在评论区交流。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑