ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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_destinations:

def 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 dests

steps_allowed=False表示"连跳中途"——此时只能继续跳,不能平移。这一个参数同时解决了走子规则和连跳限制。

胜负判定也简单到不需要解释:

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())

这有两个致命问题:

  1. TCP 是字节流,不保证一次recv就是一条完整消息——粘包/拆包

  2. eval执行任意代码,客户端可以构造恶意报文

改为 4 字节大端长度前缀 + JSON:

def send_msg(sock, obj): data = json.dumps(obj, ensure_ascii=False).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:贪心 + 连跳 DFS

AI 不需要很强,但必须合法:

  1. 枚举每颗棋子的所有合法走法(平移 + 跳跃)

  2. 对每个落点试走,用"到目标营地的距离和"评估

  3. 如果是跳跃,DFS 继续枚举连跳路径,每个中间落点都算一次可收手的选择

  4. 取最优路径

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 线程模型的问题,也可以在评论区交流。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进