写在前面
在学校随便做的中国跳棋课程设计。它能跑,但问题不少:
棋盘画得出来,点棋子却经常没反应
联机靠手工拼 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())
这有两个致命问题:
TCP 是字节流,不保证一次
recv就是一条完整消息——粘包/拆包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 不需要很强,但必须合法:
枚举每颗棋子的所有合法走法(平移 + 跳跃)
对每个落点试走,用"到目标营地的距离和"评估
如果是跳跃,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 线程模型的问题,也可以在评论区交流。