在终端里一边跑AI任务一边打砖块:curses与多线程实战
1. 这个标题到底在说什么第一次看到“Claude 干活的时候在终端里打砖块”这个标题我脑子里蹦出来的画面是这样的一边让 AI 助手在后台跑一个耗时任务一边在同一个终端窗口里摸鱼玩一局打砖块。听起来像是某种极客式的幽默但仔细一琢磨这背后其实藏着一个非常实用的技术需求——如何在等待 AI 处理任务的过程中让终端不只是干巴巴地转圈而是变成一个可交互的娱乐空间。说白了这个项目的核心就是把终端变成一个既能跑 AI 任务、又能玩小游戏的双栖环境。它解决的是“等待焦虑”这个真实痛点。你让 AI 帮你重构代码、生成文档、分析数据这些任务动辄几十秒到几分钟盯着光标闪烁纯属浪费时间。与其发呆不如在同一个终端里来一局打砖块任务完成了再切回来。这个内容适合谁看三类人最值得花时间读下去第一类是经常在终端里跟 AI 工具打交道的人比如用命令行调用大模型 API 的开发者第二类是对终端交互和 TUI 开发感兴趣的人想了解怎么在字符界面里做出流畅的动画和交互第三类是喜欢折腾效率工具和摸鱼神器的极客单纯觉得这个点子好玩想自己复现一个。我接下来会从设计思路、核心技术点、实操步骤、踩坑经验四个维度把这个项目彻底拆开讲清楚。不管你是想直接抄作业还是想理解背后的原理都能找到有用的东西。2. 整体设计思路与方案选型2.1 为什么要在终端里做这件事终端是开发者的主战场但它的交互能力一直被低估。大多数人觉得终端就是个敲命令的地方顶多加点颜色和进度条。但实际上终端可以做的事情远超想象——字符动画、实时交互、多窗口管理这些都不是新鲜事只是很少有人把它们和 AI 任务结合起来。这个项目的设计出发点很朴素AI 任务在后台跑的时候前台终端是空闲的。与其让用户干等不如把这个空闲时间利用起来。打砖块这个选择也很讲究——它足够简单不需要复杂的图形渲染又足够有趣能让人在几十秒的等待里获得一点放松而且它的交互逻辑清晰键盘控制、实时刷新、碰撞检测这些在终端里都能用字符实现。从技术选型上看这个项目有几个关键决策点。第一用什么语言写。Python 是最自然的选择因为大多数 AI 工具的 SDK 都是 Python 优先而且 Python 有curses这个标准库专门用来做终端界面。第二怎么管理任务和游戏的并行。这里有两种方案一种是多线程AI 任务跑在一个线程里游戏跑在主线程里另一种是多进程通过管道通信。我实测下来多线程更简单但要注意 GIL 的影响——不过对于 IO 密集型的 AI 任务来说GIL 基本不是问题。第三怎么处理终端状态。游戏需要实时刷新屏幕AI 任务需要输出日志这两者会打架。解决方案是给游戏分配一个独立的终端区域或者用curses的窗口机制做分屏。2.2 核心架构拆解整个系统的架构可以分成三层。最底层是终端控制层负责处理键盘输入、屏幕刷新、光标控制。这一层用curses库实现它提供了完整的终端 UI 能力包括窗口、颜色、键盘事件。中间层是任务调度层负责管理 AI 任务的执行状态包括启动、监控、完成回调。这一层用 Python 的threading模块实现每个 AI 任务跑在一个独立的线程里主线程负责游戏循环。最上层是游戏逻辑层包括挡板、球、砖块的物理模拟和碰撞检测。这三层之间的通信是关键。任务调度层需要把 AI 任务的进度实时传递给终端控制层让用户知道任务跑到哪了。游戏逻辑层需要从终端控制层获取键盘输入同时把游戏状态渲染到屏幕上。我采用的方式是共享状态 事件队列用一个全局字典存储任务状态游戏循环每帧读取一次键盘事件通过curses的getch()非阻塞获取然后分发给游戏逻辑或任务控制。这里有个细节值得展开怎么让游戏不阻塞任务执行。如果你用curses的getch()阻塞模式游戏会卡在等待按键上AI 任务的输出就没法实时刷新。解决方案是用nodelay(True)把输入设为非阻塞然后配合time.sleep(0.01)控制帧率。这样每帧只花 10 毫秒剩下的时间留给任务线程。实测下来60 帧的游戏循环对 AI 任务的性能影响几乎可以忽略。2.3 为什么不用现成的 TUI 框架有人可能会问为什么不用textual或rich这些现成的 TUI 框架它们确实更现代API 也更友好。但我选择curses有几个理由。第一依赖最少。curses是 Python 标准库不需要额外安装任何东西这在很多受限环境里很重要。第二控制粒度最细。打砖块需要精确控制每个字符的位置和刷新时机curses的窗口机制正好满足这个需求。第三性能足够。curses直接操作终端缓冲区渲染效率比rich的富文本渲染高得多对于需要 60 帧刷新的游戏来说这一点很关键。当然curses也有它的坑。最大的问题是跨平台兼容性。Windows 上需要额外安装windows-curses包而且某些终端模拟器对curses的支持不完整。我建议在 Linux 或 macOS 上开发Windows 用户可以用 WSL 或者 Windows Terminal 配合windows-curses使用。另一个坑是终端尺寸变化。用户调整窗口大小时curses会抛出异常需要捕获KEY_RESIZE事件并重新计算布局。这个我在后面的实操部分会详细讲。3. 核心细节解析与实操要点3.1 终端游戏循环的实现细节游戏循环是整个项目的心脏。它的基本结构是一个while循环每帧做四件事处理输入、更新状态、渲染画面、控制帧率。听起来简单但在终端里做这件事有很多细节要注意。首先是输入处理。curses的getch()默认是阻塞的这意味着如果没有按键程序会停在那里。对于游戏来说这不可接受。解决方案是调用stdscr.nodelay(True)把输入设为非阻塞模式。这样getch()会立即返回没有按键时返回-1。然后你可以根据返回值判断是否有输入。但这里有个陷阱非阻塞模式下getch()会疯狂占用 CPU因为它在不停地轮询。所以必须配合time.sleep()控制循环频率。其次是状态更新。打砖块的状态包括球的位置、速度、挡板位置、砖块矩阵。每帧需要根据当前速度更新球的位置然后检测碰撞。碰撞检测是重点球碰到挡板要反弹碰到砖块要消除砖块并反弹碰到墙壁要反弹掉到底部要扣命。这些逻辑用简单的几何判断就能实现但要注意边界条件。比如球同时碰到两个砖块时应该只消除一个还是两个我的做法是只消除一个然后根据碰撞方向决定反弹角度。然后是渲染。curses的渲染方式是先在一个缓冲区里画好所有内容然后调用refresh()一次性刷新到屏幕。这样做的好处是避免闪烁。具体操作是用stdscr.erase()清空缓冲区然后用addstr()在指定位置画字符最后refresh()。对于打砖块来说砖块用#表示球用O表示挡板用表示边界用|和-表示。颜色可以用curses.init_pair()定义比如砖块用红色球用白色挡板用绿色。最后是帧率控制。终端的刷新率有限一般 30 帧就足够流畅了。我用time.sleep(1/30)来控制实测下来 CPU 占用在 5% 以下。如果帧率太高终端会来不及渲染画面会撕裂如果太低球会看起来一顿一顿的。30 帧是个比较平衡的选择。3.2 AI 任务的集成方式这个项目的另一半是 AI 任务。怎么把 AI 任务集成进来让它和游戏和平共处是第二个核心问题。最直接的方式是用线程跑任务。Python 的threading.Thread可以轻松启动一个后台线程任务完成后通过回调通知主线程。但这里有个问题AI 任务通常需要输出日志比如“正在分析代码...”、“生成第 3 段...”。这些日志如果直接打印到终端会覆盖游戏画面。解决方案是把日志重定向到一个独立的区域。我采用的方式是在屏幕上方留出 5 行作为日志区游戏区在下方。日志区用curses的窗口机制单独管理任务线程往日志区写内容游戏线程往游戏区写内容互不干扰。另一个问题是任务完成的通知。当 AI 任务跑完时用户需要知道。最简单的做法是在日志区打印“任务完成”然后让游戏继续跑。但更好的做法是弹出一个提示框让用户选择是继续玩游戏还是查看结果。这个用curses的newwin()创建一个模态窗口就能实现。我实测下来模态窗口的体验最好因为它强制用户做出选择不会错过任务完成的通知。还有一个细节是任务的取消。如果用户不想等任务跑完想直接取消怎么办我的做法是监听CtrlC信号在信号处理函数里设置一个标志位任务线程定期检查这个标志位如果为真就提前退出。这里要注意线程安全标志位的读写要用threading.Event或者锁来保护。3.3 终端尺寸适配与布局管理终端尺寸是个容易被忽视但很关键的问题。不同用户的终端大小不一样有的是 80x24有的是 200x50。如果布局写死了在小终端上会显示不全在大终端上会留白太多。我的解决方案是动态计算布局。在程序启动时用curses的getmaxyx()获取终端宽高然后按比例分配区域。比如日志区占 20%游戏区占 80%。游戏区的宽度决定砖块的列数高度决定行数。如果终端太小比如宽度小于 40就提示用户调整窗口大小。这里有个坑终端尺寸变化事件。当用户拖动窗口边缘时curses会发送KEY_RESIZE事件。如果不处理这个事件程序会崩溃或者显示错乱。正确的做法是在主循环里检测KEY_RESIZE然后重新调用getmaxyx()获取新尺寸重新计算布局重新初始化窗口。这个过程要小心因为curses的窗口对象在尺寸变化后可能失效需要重新创建。另一个坑是中文字符的宽度。终端里中文字符占两个英文字符的宽度如果日志区有中文计算位置时要把这个考虑进去。我的做法是统一用英文输出日志避免宽度计算的复杂性。如果非要显示中文可以用wcwidth库来计算实际宽度。4. 完整实操过程与核心环节实现4.1 环境准备与依赖安装开始之前先把环境搭好。我假设你用的是 Linux 或 macOSWindows 用户建议用 WSL。第一步确认 Python 版本。这个项目需要 Python 3.8 以上因为用到了threading的一些新特性。用python3 --version检查一下。第二步安装必要的依赖。核心依赖只有两个curses和requests。curses是标准库Linux 和 macOS 自带Windows 需要pip install windows-curses。requests用来调用 AI 任务的 API如果你用的是其他 SDK可以替换成对应的包。pip install windows-curses # 仅 Windows 需要 pip install requests第三步准备一个 AI 任务。这个任务可以是任何耗时操作比如调用大模型 API 生成一段文本或者本地跑一个模型推理。为了演示方便我用一个模拟任务代替每隔一秒打印一条日志总共跑 10 秒。import time import threading def mock_ai_task(log_callback, done_callback): for i in range(10): time.sleep(1) log_callback(fAI 任务进度: {i1}/10) done_callback(任务完成)这个模拟任务接受两个回调log_callback用来输出日志done_callback用来通知完成。实际使用时把time.sleep换成真正的 AI 调用即可。4.2 游戏主循环的代码实现接下来是游戏主循环。我把它拆成几个函数方便理解和修改。首先是初始化函数。它负责设置curses环境、定义颜色、创建窗口。import curses def init_curses(): stdscr curses.initscr() curses.cbreak() curses.noecho() stdscr.nodelay(True) stdscr.keypad(True) curses.start_color() curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK) curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK) curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLACK) return stdscr这里的关键是nodelay(True)和keypad(True)。前者让输入非阻塞后者让方向键等特殊键能被正确识别。颜色方面我定义了三种红色给砖块绿色给挡板白色给球。然后是游戏状态初始化。砖块用一个二维列表表示1 表示有砖块0 表示没有。球的位置和速度用浮点数表示这样运动更平滑。def init_game_state(max_y, max_x): game_height int(max_y * 0.8) game_width max_x - 2 bricks [[1] * (game_width // 4) for _ in range(3)] ball {x: game_width // 2, y: game_height - 2, dx: 0.5, dy: -0.5} paddle {x: game_width // 2 - 3, width: 6} return bricks, ball, paddle, game_height, game_width砖块我设了 3 行每行宽度是游戏宽度的四分之一。球从底部中间出发速度是每帧移动 0.5 个字符。挡板宽度是 6 个字符初始位置在中间。接下来是游戏循环。它每帧处理输入、更新状态、渲染画面。def game_loop(stdscr, log_win, game_win, task_thread): bricks, ball, paddle, gh, gw init_game_state(*stdscr.getmaxyx()) while True: # 处理输入 key stdscr.getch() if key ord(q): break elif key curses.KEY_LEFT: paddle[x] max(0, paddle[x] - 1) elif key curses.KEY_RIGHT: paddle[x] min(gw - paddle[width], paddle[x] 1) # 更新球的位置 ball[x] ball[dx] ball[y] ball[dy] # 碰撞检测简化版 if ball[x] 0 or ball[x] gw - 1: ball[dx] -ball[dx] if ball[y] 0: ball[dy] -ball[dy] if ball[y] gh - 1: # 球掉到底部重置 ball[x] gw // 2 ball[y] gh - 2 ball[dx] 0.5 ball[dy] -0.5 # 渲染 game_win.erase() for i, row in enumerate(bricks): for j, val in enumerate(row): if val: game_win.addstr(i 1, j * 4 1, ####, curses.color_pair(1)) game_win.addstr(int(ball[y]), int(ball[x]), O, curses.color_pair(3)) game_win.addstr(gh - 1, paddle[x], * paddle[width], curses.color_pair(2)) game_win.refresh() # 控制帧率 curses.napms(33) # 约 30 帧这段代码是简化版省略了砖块碰撞检测和得分逻辑。实际使用时需要在球碰到砖块时消除砖块并反弹。碰撞检测的思路是计算球的位置对应的砖块坐标如果该位置有砖块就消除它并反转dy。4.3 任务与游戏的并行调度最后是把任务和游戏整合起来。核心思路是主线程跑游戏循环子线程跑 AI 任务。两者通过共享状态和回调通信。def main(stdscr): max_y, max_x stdscr.getmaxyx() log_height int(max_y * 0.2) game_height max_y - log_height log_win curses.newwin(log_height, max_x, 0, 0) game_win curses.newwin(game_height, max_x, log_height, 0) log_lines [] def log_callback(msg): log_lines.append(msg) if len(log_lines) log_height - 2: log_lines.pop(0) log_win.erase() for i, line in enumerate(log_lines): log_win.addstr(i 1, 1, line[:max_x-2]) log_win.refresh() def done_callback(msg): log_callback(msg) task_thread threading.Thread(targetmock_ai_task, args(log_callback, done_callback)) task_thread.daemon True task_thread.start() game_loop(stdscr, log_win, game_win, task_thread) curses.wrapper(main)这里用curses.wrapper来包裹主函数它会自动处理初始化和清理工作避免终端状态混乱。日志窗口在屏幕上方游戏窗口在下方。任务线程启动后日志会实时刷新到日志窗口游戏在主线程里跑。有个细节要注意log_callback是在子线程里调用的而curses的操作不是线程安全的。所以我在log_callback里只更新log_lines列表实际的渲染放在主线程的游戏循环里做。这样可以避免多线程同时操作curses导致的崩溃。5. 常见问题与排查技巧实录5.1 终端显示错乱怎么办这是最常见的问题。表现是画面闪烁、字符重叠、光标乱跳。原因通常有三个一是没有正确初始化curses二是多线程同时操作屏幕三是没有处理KEY_RESIZE事件。排查步骤首先检查是否用了curses.wrapper它会自动处理初始化和清理。如果没有确保在程序退出时调用curses.endwin()。其次检查是否有多个线程同时调用addstr()或refresh()如果有改成只在主线程渲染。最后检查是否处理了KEY_RESIZE如果没有在游戏循环里加一个判断if key curses.KEY_RESIZE: max_y, max_x stdscr.getmaxyx() # 重新计算布局重新创建窗口还有一个隐藏的坑终端类型。某些终端模拟器对curses的支持不完整比如某些 IDE 内置的终端。如果遇到奇怪的显示问题试试换一个终端比如 GNOME Terminal、iTerm2 或 Windows Terminal。5.2 游戏卡顿或 CPU 占用过高卡顿和 CPU 高占用通常是同一个问题的两面帧率控制不当。如果time.sleep()的时间太短循环跑得太快CPU 会飙高如果太长游戏会卡顿。我的经验值是30 帧对应time.sleep(1/30)约 33 毫秒。这个帧率在终端里看起来足够流畅CPU 占用也能控制在 5% 以下。如果还是觉得卡可以降到 20 帧对应 50 毫秒。但不要低于 15 帧否则球会看起来一顿一顿的。另一个可能导致卡顿的原因是日志输出太频繁。如果 AI 任务每秒输出几十条日志日志窗口的刷新会拖慢游戏循环。解决方案是限制日志刷新频率比如每 100 毫秒才刷新一次日志窗口而不是每条日志都刷新。5.3 AI 任务和游戏互相干扰这个问题的表现是游戏跑着跑着AI 任务的日志不更新了或者 AI 任务输出日志时游戏画面卡住。根本原因是资源竞争。AI 任务和游戏都在抢 CPU 和终端输出。解决方案是降低游戏的优先级。在游戏循环里每帧只做必要的渲染把复杂的计算比如碰撞检测放在单独的帧里做。另外日志输出用缓冲队列任务线程只往队列里放数据主线程定期从队列里取数据并渲染。还有一个技巧是用curses.napms()代替time.sleep()。napms()是curses提供的毫秒级睡眠函数它会在睡眠期间处理终端事件比time.sleep()更适合终端程序。5.4 常见问题速查表问题现象可能原因解决方法画面闪烁没有用erase()清屏每帧先erase()再refresh()字符重叠多线程同时渲染只在主线程渲染子线程只更新数据方向键无响应没有启用keypad调用stdscr.keypad(True)程序退出后终端乱码没有调用endwin()用curses.wrapper包裹主函数窗口大小变化后崩溃没有处理KEY_RESIZE捕获KEY_RESIZE并重新初始化CPU 占用过高帧率太高用napms(33)控制帧率日志不更新子线程直接操作curses子线程只更新数据主线程渲染球穿过砖块碰撞检测精度不够用浮点数位置每帧检测多次5.5 几个我踩过的坑第一个坑是颜色初始化顺序。curses.start_color()必须在init_pair()之前调用否则颜色不生效。而且init_pair()要在initscr()之后调用。这个顺序错了画面就是黑白的。第二个坑是窗口重叠。我用newwin()创建了两个窗口一个日志区一个游戏区。但newwin()创建的窗口默认是独立的不会自动处理重叠。如果两个窗口有重叠区域刷新时会互相覆盖。解决方案是确保两个窗口不重叠或者用curses.panel库来管理窗口层级。第三个坑是中文日志的宽度。我一开始在日志里输出中文结果位置全乱了。因为中文字符占两个英文字符宽度但addstr()按一个字符计算。后来改成全英文输出问题解决。如果非要中文可以用wcwidth库计算实际宽度然后手动调整位置。第四个坑是任务线程的异常处理。如果 AI 任务抛异常子线程会静默退出主线程完全不知道。解决方案是在任务函数里加try/except把异常信息通过回调传给主线程显示在日志区。6. 还能怎么玩扩展思路这个项目的基础版本已经能跑了但它的扩展空间很大。我分享几个我试过或想过的方向。第一个方向是多任务并行。现在只能跑一个 AI 任务如果同时跑三个呢每个任务分配一个日志区游戏区缩小一点。这样你可以一边等代码重构一边等文档生成一边打砖块。实现上需要把任务管理改成任务池每个任务有独立的状态和日志缓冲。第二个方向是游戏难度动态调整。球的速度可以根据 AI 任务的进度变化——任务快完成时球速加快任务卡住时球速减慢。这样游戏进度和任务进度有了关联玩起来更有意思。第三个方向是把游戏结果和任务结果关联。比如游戏得分越高AI 任务的输出越详细游戏输了AI 任务只给摘要。这个纯属好玩但实现起来需要把游戏状态传给任务回调。第四个方向是支持更多游戏。打砖块只是其中之一还可以加贪吃蛇、俄罗斯方块、2048。每个游戏实现一个统一的接口init()、update()、render()、handle_input()。然后用一个游戏管理器来切换。第五个方向是远程监控。把游戏和任务的状态通过一个简单的 HTTP 接口暴露出来这样你可以在另一台机器上查看任务进度甚至远程控制游戏。这个适合跑长时间任务的场景。我个人最喜欢的是第一个方向因为多任务并行是真实需求。我经常同时跑几个 AI 任务如果能在一个终端里管理它们同时还能摸鱼效率会高很多。实现上的难点是日志区的动态分配需要根据任务数量自动调整每个日志区的高度。这个用curses的newwin()可以做到但要注意窗口数量不能太多否则终端会显得很挤。最后分享一个小技巧如果你觉得 30 帧还是卡可以把渲染分成两部分——静态部分砖块、边界只在变化时重绘动态部分球、挡板每帧重绘。这样能显著降低渲染开销。我实测下来帧率能稳定在 60 帧CPU 占用反而更低。