尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用Python和curses实现命令行俄罗斯方块:终端游戏开发全解析

用Python和curses实现命令行俄罗斯方块:终端游戏开发全解析 做命令行俄罗斯方块最开始真的只是想证明一件事在没有图形界面的环境里我们照样能让一个经典游戏跑得又顺又好看。你想想平时我们登上一台服务器、连上一台网络设备手指头能碰到的就是那个字符界面没有浏览器、没有桌面、没有触摸屏想放松一下都只能对着命令行发呆。后来我干脆动手写了一个俄罗斯方块用字符当积木用光标移动当刷新硬是把那块黑底白字的终端变成了一块能玩十分钟的游戏屏。做完这个项目以后我有一种很强烈的感受它表面上是个“摸鱼产物”实际上像是一次命令行底层能力的综合体检。你以为是写游戏实际踩进去的全是终端控制序列、非阻塞输入、屏幕重绘、坐标系转换、碰撞检测、状态机设计这些平时很少被单独讲透的东西。所以这篇文章想完整记录我这次实现命令行俄罗斯方块的思路、选型、核心算法和踩坑过程给那些刚开始接触终端、想找点“既能练手又不会太无聊”项目的人一个可复现的路线。1. 为什么要在命令行里写俄罗斯方块——它不只是一个玩具1.1 这个项目到底在练什么很多人会把“命令行俄罗斯方块”理解成一个纯娱乐项目但只要你真在终端里做过一次就会明白它其实是一台“终端能力测试仪”。图形界面的游戏写起来其实很省心窗口系统帮你处理了鼠标点击、键盘事件、画面重绘、控件布局命令行游戏则把这些遮羞布全揭掉了你必须直接面对输入流和输出流面对“字符怎么在屏幕上移动”这种最底层的命题。俄罗斯方块本身的规则也不像看起来那么简单。一个标准的俄罗斯方块有 7 种方块每种方块需要定义旋转后的形状方块的移动范围是 10 列 × 20 行的井意味着任何移动、旋转都得做边界检查和重叠检查方块落定之后还要判断是否有整行可以消除并且每消除一行都可能触发得分和速度变化。这些规则放在图形界面里不难放在纯字符终端里难点就变成了“如何高效地刷新画面”“如何不遗漏地处理按键”。换句话说这个项目让我完整练习了三块技能一是数据结构设计怎么用最简洁的方式表示一个方块和整个游戏场地二是实时交互编程怎么让程序在等待输入的同时还能按固定节奏向下落方块三是终端控制怎么在同一个画面区域做局部更新避免闪烁和内容错乱。这三块内容放到任何命令行工具的开发过程中都用得上。1.2 技术选型我为什么选了 Python 加 curses第一次考虑实现方案的时候我脑海里冒出来的候选不少C 语言加 ncurses、Rust 加 crossterm、Python 加 curses、甚至直接用 Bash 脚本硬写。后来我选择了 Python 加 curses原因非常实际Python 解释器在绝大多数 Linux 服务器和 macOS 机器上已经预装不用单独安装编译链拷贝一份脚本就能跑。curses 库把终端初始化和底层控制封装得比较干净能快速进入“隐藏光标、关闭回显、非阻塞读键”的状态。俄罗斯方块逻辑本身不复杂用 Python 表达坐标变换和碰撞判断非常直观调试成本低。我需要在开发过程中频繁验证“旋转是否越界”“方块是否重叠”这类逻辑Python 的交互式环境可以让我逐条试验。C 语言当然很经典很多老牌终端游戏都是用 C 配合 ncurses 写的性能也更好但它对初学者不够友好处理字符串和数组越界时会花掉大量时间。Rust 的安全性很好但引入所有权和生命周期概念以后反而会把重心从游戏逻辑转移到语言特性上。Bash 写俄罗斯方块更像炫技维护性很差我不建议把精力耗进去。如果在 Windows 环境下做同样的项目Python 标准库里的 curses 默认不可用可以用pip install windows-curses装一个兼容版本再配合 Windows Terminal 运行。我实际测试过只要终端模拟器支持基本的 ANSI 转义序列游戏体验和 Linux 下面差别不大。2. 先把方块放进坐标系数据结构、旋转与碰撞判定2.1 方块本质上是坐标点集合我第一次动手前下意识地想把俄罗斯方块当成一张张小图片来处理后来发现终端里没有现成的贴图概念最自然的做法是把方块拆成坐标点的集合。就像在格子纸上画画一样一个方块占据哪几个格子就用坐标记下来。以 4×4 的二维数组来定义方块是常见方案比如 L 形方块可以写成L_BLOCK [ [1, 0, 0], [1, 0, 0], [1, 1, 0], ]这里的1表示方块占据的位置0表示空位。写的时候我并不真的把这当成一张二维图片而是当成一个可以推导旋转结果的矩阵。为了统一实现我把四种经典方块和四种特殊方块的旋转基准点都放在同一个参考系里这样旋转代码可以共用一套公式。另一种更省内存但稍微抽象一些的做法是直接用一组(x, y)坐标表示方块。比如 I 形方块可以写成[(0, 0), (1, 0), (2, 0), (3, 0)]旋转时逐点变换坐标。这个方案在旋转计算时挺方便但在碰撞检测时需要额外考虑每个方块的“包围盒”。我最后选择的是坐标点方案因为俄罗斯方块的方块种类有限坐标数不会超过 4 个计算开销完全可以忽略。核心经验是场地的坐标轴也最好固定下来。我在代码里把屏幕左上角作为坐标原点x 轴向右、y 轴向下这样打印字符的时候可以直接把坐标映射到 curses 的行和列省去一层“屏幕上第几行第几列”和“游戏逻辑坐标”之间的来回转换。2.2 旋转就是一次坐标变换靠墙时要留一步“墙踢”俄罗斯方块的旋转是一个经典的坐标变换题。如果以方块的中心为参考点顺时针旋转 90 度坐标变化公式是new_x -old_y new_y old_x这个公式很多人可能已经看过但真正实现的时候容易忽略一个问题方块中心点一旦选得不好旋转后就会整体偏移表现起来像方块在“漂移”。俄罗斯方块圈子里有一种比较成熟的参考做法是把方块放在一个固定大小的矩阵里旋转矩阵法虽然逻辑简单但四格长条方块在竖躺和横躺时需要的空间差异很大如果矩阵始终是 4×4就会浪费空间但简单。我用的是先旋转再修正的路线。旋转完成后逐个检查每个坐标点是否越界或者和场上已有方块重叠如果出现问题就尝试把整个方块往左平移一格、往右平移一格或者向上平移一格直到找到一个合法位置。如果尝试完所有候选偏移量仍然没有合法位置就判定这次旋转不成立。这个过程很像现实里把一件家具在墙角转个方向转不动时总要前后挪一挪才知道行不行这就是方块游戏里的“墙踢”Wall Kick。实际代码里我维护了一个候选偏移列表def try_rotate(piece, board, kicks((0, 0), (-1, 0), (1, 0), (0, -1))): rotated rotate_coords(piece.coords) for dx, dy in kicks: moved [(x dx, y dy) for x, y in rotated] if not collides(moved, board): return moved return piece.coords # 旋转失败保持原样这里的技巧是偏移顺序很重要。优先试(0, 0)也就是原地旋转如果不行再试向左偏移或向右偏移。大多数时候方块只是紧贴墙壁转不进去左右偏移 1 格就能解决。至于向上偏移我只在极少数底部已锁定方块很多的情况下才用如果偏移后仍然不能通过就保持原来的方向不变避免游戏画面里出现方块瞬间瞬移的奇怪手感。2.3 碰撞检测、消行与计分把规则做成参数碰撞检测是整个游戏里最容易写出隐性 Bug 的地方。我最初的实现方法是每次移动或者旋转之后把方块的新坐标和场上已经锁定的格子做一次遍历一旦发现重叠就拒绝进入这个状态。这套逻辑听上去简单但要注意“边界外”也算碰撞而且计算时一定要以新状态为准不能拿旧位置去判断否则方块会“穿墙”。俄罗斯方块的原版规则中场地是 10 列宽、20 行高我就直接按照这个标准初始化一个二维数组。落定判断同样要小心方块垂直下落时如果正下方紧邻的一格已经有方块或者已经到达底部那么当前方块就必须锁定在场地上并且马上切换到下一个方块。消行我采用的是最朴素的扫描法每次方块锁定之后从场地底部往上检查如果一整行都是非空值就把它移除然后在上方插入一行空白。这个算法远谈不上高效但对一个 10×20 的小场地来说性能完全不是问题。我更看重的是它逻辑清晰不容易出错。计分规则我参考了 NES 版本常见的档位消除行数得分单行消除100双行消除300三行消除500四行消除800这个档位不是随便定的它会明显鼓励玩家积累方块制造四行同时消除的“Tetris”这也是俄罗斯方块传统计分体系的核心乐趣。我把底分、消除行数对应的分值、初始下落间隔、每次消除后下落间隔衰减的步长都放到了配置字典里只改参数就能调整难度。SCORE_TABLE {1: 100, 2: 300, 3: 500, 4: 800} DROP_INITIAL_MS 800 DROP_MIN_MS 100 DROP_DECREMENT_STEP 50难度曲线的经验是下落间隔从 800ms 开始每消满 5 行减少 50ms降到 100ms 后不再变化。这个曲线是经过很多尝试后才满意的如果一开始就太快新手根本没时间反应如果太慢老玩家又会觉得无聊。3. 让字符真正“动起来”渲染、输入与主循环3.1 终端其实是一块老式点阵屏很多人第一次接触命令行时会觉得终端无非就是一块显示文字的黑色区域但真正深入进去会发现终端本质上是一块“字符网格 打印头”的老式点阵屏。光标在哪里打印就从哪里开始控制字符可以移动光标、清除区域、改变字体颜色。古老的 ANSI 转义序列直到今天依然有效比如\x1b[2J是清屏\x1b[行;列H是把光标移动到指定位置。curses 库做的其实是在这些底层转义序列之上提供一套相对友好的接口它把“把字符画到坐标为(y, x)的位置”封装成addstr(y, x, text)。但理解底层原理依然有用。因为只有一个“打印头”你要更新画面里的不同部分就得先移动光标再输出这和现代图形界面的“部分窗口重绘”逻辑是一样的。我处理游戏场地的时候把屏幕划分成了几个区域左侧是游戏主场地右侧是下一个方块预览、当前得分、当前等级和操作提示。每次刷新时我不会清除整个终端那样会让光标跳来跳去并产生闪烁而是只更新上一帧以来发生变化的部分。方块位置变了就重画旧的空白区域和新的方块区域分数变了就只重画分数那一行。这样即使游戏跑得再快画面也始终干净。有一点经验很重要字符高度不是正方形。同一个宽度的“方块”在终端里显示出来通常比值约 1:2所以我绘制方块时没有用单个字符而是用两个半角空格拼出一个视觉上更接近正方形的色块。这样场地看起来不像被横向拉伸过观感会舒服很多。3.2 主循环和按键监听决定游戏是否流畅命令行程序普遍是“逐行执行、读到什么处理什么”的模型但俄罗斯方块需要同时满足两个要求程序持续接收键盘输入同时方块会按照固定时间间隔自动下落。在普通终端模式里input()这样的输入函数会阻塞住直到用户按下回车才返回这样显然没法做成实时游戏所以我需要借助 curses 进入一种特殊状态让终端把按键事件即时送到程序里而不是等待回车键。curses 里对应的关键操作是import curses def main(stdscr): curses.curs_set(0) # 隐藏光标 stdscr.nodelay(True) # getch 变成非阻塞 stdscr.keypad(True) # 支持方向键等特殊按键 while True: key stdscr.getch() # 没有按键时返回 -1 # 根据 key 处理移动、旋转、退出 # 更新逻辑状态并重新绘制这个代码骨架解释了一个很核心的问题在非阻塞模式下主循环每一轮都会执行一次画面刷新按键事件只是“在某一轮循环中被顺手读到”。我跑得有多快取决于我在循环里做了多少工作和是否调用time.sleep进行主动限速。对于俄罗斯方块这类简单游戏每秒钟循环 30 到 60 次就足够刷新太频繁反而容易让终端模拟器处理不过来。按键处理也需要额外小心尤其是函数键和方向键。在终端里方向键不是单个字符而是一串以转义字符开头的字节序列按下光标右键程序可能会收到三个字节\x1b[C。curses 会把它解析成KEY_RIGHT我只要在代码里判断key curses.KEY_RIGHT就能处理。如果你不用 curses 而选择自己在纯 Python 里读取终端那就要自己做字节解析这是另一个复杂度层级。关于游戏手感我补充一点按住方向键时程序会持续接收到按键事件如果我每收到一次就移动一格方块会快得像瞬移一样。为了手感更接近街机效果我设置了一个“重复延迟”计数按下方向键后先等 0.1 秒然后再以每 0.05 秒一格的速度连续移动。这个说法和现代游戏里的 DASDelayed Auto Shift机制相同别小看这几个数字它几乎决定了一个游戏“舒不舒服”。3.3 手感调校下落速度、锁定延迟和随机策略游戏逻辑能跑通和“好玩”是两回事我在这部分花的时间反而比写核心算法多得多。俄罗斯方块下落速度不能简单理解成“越快越难”。如果游玩过程只有“快速下落”一个维度玩家很容易觉得沮丧因为几乎没有喘息空间。我当时设置的难度曲线参数就是基础分数 / 消除行数 / 下落间隔三者联动让挑战变得可预期。所谓锁定延迟是指方块落到底部以后并不会立刻牢牢焊死而是会停留一小段短暂时间允许玩家在这个窗口内做最后一次移动和旋转。很多初版俄罗斯方块没做这个设计导致玩家会觉得“明明按了左键但方块没反应”这其实是因为方块已经完成碰撞判定和锁定输入来得太晚。加上 300ms 左右的锁定延迟之后游戏宽容度明显提升。随机策略也值得一提。如果每次下一个方块都是纯随机从 7 种里抽取经常会出现连续出同一个方块的情况体验极不稳定。我在代码里实现了很常见的“7-bag 随机”策略先把 7 种方块各放一个进池子随机打乱后逐个取出取完后再生成下一组。这样做能保证每 7 个方块里一定包含所有形状玩家可以用更合理的思路来规划堆叠。import random bag [] def next_piece(): global bag if not bag: bag list(range(7)) random.shuffle(bag) return bag.pop()代码很简单但对游戏体验的提升非常明显。如果有空还可以加入“下一块预览”直接显示下一个方块的形状这能显著降低玩家的认知负担也是判断一个俄罗斯方块版本是否考虑用户体验的重要标志。4. 从“命令行游戏”到日常终端实战几个能直接复用的经验4.1 游戏能在远程会话里跑和命令行程序的本质有关做这个项目时我常常是在本机用终端写代码然后通过远程会话登录到另一台 Linux 发展服务器上运行测试。第一次成功跑起来的时候我发现这个游戏在远程终端里和本地终端里表现几乎完全一致心里突然觉得很奇妙明明程序执行在不知道哪个机房里但键盘按键和画面回传却像本地程序一样自然。这个体验背后藏着一个很核心的事实命令行程序根本不关心用户的终端是本地窗口还是远程会话它只从标准输入读取字节向标准输出写入字节。当我按下方向键时终端模拟器把按键转成字节流如果是远程会话这个字节流会通过网络传到对方机器程序的输出同样通过这个通道传回本地屏幕。正因为这套模型如此简洁命令行工具才能成为服务器远程管理的基石我才能在只敲键盘的情况下完成设备配置、服务启动和日志查看这类日常操作。同样是在这里我开始理解为什么tmux或screen这类终端复用工具那么有用。它们允许你在同一个会话里创建多个窗口把游戏放在某个窗口继续跑然后切到另一个窗口继续敲命令过一会儿再切回来看看游戏情况。虽然我玩命令行俄罗斯方块时很少真这么做但是“程序不依赖图形界面所以可以一直在后台稳定运行”这个认知对我后来管理长耗时任务非常有帮助。4.2 把命令行程序当作自动化积木玩法才真正打开俄罗斯方块这个项目本身玩到最后其实会让我产生一个反向的思考命令行游戏依赖的直接输入输出模型在更大世界里对应的就是“命令行工具”这个概念。为什么很多后台服务、数据库、编译器都保留一套命令行接口因为只有命令行接口才适合自动化。举几个真实场景用 Maven 构建 Java 项目时大家会敲mvn clean install这条命令可以通过脚本自动执行用 SVN 做版本管理时提交和更新也都有对应的命令行指令用达梦数据库做备份命令行下可以执行特定导出命令生成 DMP 文件然后交给计划任务定期运行。这些任务如果只依赖图形界面人就只能手动点击很难拼成一条自动化流水线。一旦它们都有清晰的命令行入口就可以用脚本把多个工具串起来像搭积木一样完成一个完整流程。我调试命令行游戏时养成的习惯是凡是一个工具提供了命令行接口我一定会优先尝试通过脚本去调用它。只要接口允许非交互式运行并能在结束时返回明确的状态码我就能在脚本里判断它是否成功进而决定下一步是否继续执行。例如#!/usr/bin/env bash set -euo pipefail cd /opt/server mvn clean install if [ $? -eq 0 ]; then echo build success ./deploy.sh else echo build failed exit 1 fiset -euo pipefail的作用是让脚本在任一步出错时立刻退出避免一个失败的步骤污染后面整条链路。命令行程序的退出码其实是它和前一个程序之间最朴素的通信协议写自动化脚本时一定要重视。4.3 命令行不是化石它是通往系统能力的入口借着做这个游戏我还顺手试过很多“命令行新玩法”比如在 Windows 的命令行窗口里调用流媒体处理工具做直播推流用命令行查看硬盘状态在 Debian 系统里通过命令行安装字体甚至用一些 OCR 工具在纯命令行环境下完成文字识别。一开始我总觉得这些任务应该发生在图形界面里后来才发现命令行往往能更干净地完成它们尤其当你需要批量处理或定时执行的时候。但我也想说一句冷静的话命令行不是万能的它甚至有很明显的学习曲线入门时记住一堆英文命令和参数很痛苦。可是命令行之所以值得投入是因为它给了你一种“系统入口”的感觉。图形界面里很多功能是隐藏的、被美化过的而命令行把系统能做的事以参数的形式摆在面前。那些参数一眼看上去像是语法本质上是一次和操作系统对话的机会。俄罗斯方块这个项目没有直接帮我学会任何一个运维命令但它让我形成了“任何黑窗口程序都有输入输出任何功能都可以用参数控制和脚本驱动”的思维方式这个思维方式随后帮我在自动化部署、日志分析、数据备份这些完全没有图形界面的任务里接连踩通了路。5. 那些踩过的坑命令行游戏排查实录5.1 方向键没有反应终端按键不是一个字节我在开发时遇到最典型的“幽灵”问题就是按方向键偶尔无效偶尔像卡住一样连续移动。最初我以为是自己判断按键的常量写错了后来排查下来才发现终端的方向键发送的是一串带转义前缀的字节当程序读取不及时可能把前一个按键的后缀字节和后一个按键的前缀字节拼在一起被错误解析。比如KEY_LEFT对应的原始字节流可能是ESC[Dcurses 能识别但如果你自己实现输入读取就必须解析三字节序列ESC [ A - 上 ESC [ B - 下 ESC [ C - 右 ESC [ D - 左另外别小看“回车键”的问题。在默认终端模式里回车会显示成\r换行是\n这两者并不相同。如果在非 curses 模式下自己写行输入很容易因为混用换行符导致界面错位。我在代码的调试模式里加了一个“打开按键回显”的隐藏开关按下F1后会把最近一次读到的原始字节值显示在屏幕底部排查输入问题会高效很多。5.2 乱码、闪烁与窗口太小另一个频繁遇到的问题和字符宽度相关。我最初直接用中文提示和全角括号绘制界面结果发现方块位置和文字经常错位因为很多终端默认按双倍宽度渲染全角字符导致本该整齐的字符网格被撑乱。后来我把所有界面文字统一为英文和半角字符只保留一个 ASCII 风格的方框问题立刻消失。屏幕闪烁多半是因为每次刷新都用clear()把整个终端擦掉重画。curses 优化了许多重绘操作但如果你频繁调用refresh还是会看到明显的闪烁。我调整后的方案是只更新变化区域也就是“脏矩形”刷新。方块移动时先擦除旧坐标位置的两个空格再在新坐标位置画上新色块这样画面稳定很多。窗口太小时游戏也容易崩溃因为场地高度可能不够 20 行。我在初始化时检查了终端大小如果行数或列数低于最低要求会在屏幕中央显示一句提示然后主动降级成暂停模式等待用户把终端拉大或按q键退出。这个保护看起来不起眼但避免了大量因终端尺寸变化导致的段错误或显示错乱。5.3 “命令行过长”和其他反直觉的报错随着代码体积变大我开始把程序打包成命令行工具并接入一些参数解析逻辑。此时遇到了一个让我印象深刻的报错直接在命令行里写满整个启动命令时被告知命令行过长程序没法启动。这个问题听起来不像是现代系统还会遇到但真实场景中当命令行参数里包含一大串类路径、脚本参数或者含空格的路径时就有可能出现超过系统上限的情况。排查时有一个经验值得分享不要把所有内容都塞到命令行里。我后来把游戏配置和运行参数拆到了两个层面常用的交互参数保持简洁完整参数则通过配置文件传入。遇到个别工具支持“参数文件”模式的时候就把参数列表写入文件在命令行里只引用文件路径这样不仅绕开了长度限制也让参数便于复用和版本管理。像那些常见构建工具执行时也建议尽量使用配置文件或环境变量完成长参数传递而不是硬把它写在启动命令里。5.4 一个调试心得日志和可视化状态如果只靠观察屏幕上的方块很难判断“旋转判定错在哪个坐标点”我在程序里加了一个 debug 视图可以随时暂停并打印出场地上所有格子的坐标值以及当前方块的包围盒信息。这个视图对排查越界和重叠问题帮助极大也让我理解了“游戏状态可视化”的重要性。以后在做其他命令行项目时不妨也考虑预留一个--debug参数把关键状态输出为简洁文本。这比单步调试更直观也更适合在远程终端这种没法打开调试器的场景下使用。最后一点个人体会做完整个命令行俄罗斯方块最大的收获并不是我写出了一款可玩的游戏而是我终于敢说“我理解命令行是怎么工作的了”。我们平时每天都在敲指令却很少停下来想想终端在背后做了什么也很少意识到自己可以把一个完全交互式的任务跑在一个没有窗口的字符界面里。俄罗斯方块是一个特别合适的小项目它规则经典、逻辑封闭、画面上限很低但操作上限很高你不需要画任何一张美术素材就能把一整套实时交互系统做出来。如果你也想找个项目折腾一下命令行我非常推荐你亲自写一个哪怕只是最简陋的黑白方块版本跑通那一刻的感觉和看别人演示一百篇文章都不一样。
返回列表