纯Bash实现终端俄罗斯方块:原理、算法与工程实践
在 Linux 终端里泡久了总有那么几个瞬间会对着满屏的命令行发呆觉得ls、cd、grep这些常用命令虽然可靠但终归缺少一点“活物”的感觉。某天我翻到一份老旧的 Bash 脚本收藏突然萌生了一个念头能不能只用纯 Bash 写一个俄罗斯方块不需要 Python、C 这类“正经”语言也不需要 ncurses、SDL 任何图形库就靠 Shell 脚本内置的数组、字符串操作和终端转义码把方块下落、旋转、消行、计分这套经典玩法完整做出来。当时很多人告诉我这不现实但我偏要试试。结果就是这篇博文里要讲的终端小游戏一个大约 300 行左右的tetris.sh脚本放到任何带 Bash 4 以上的 Linux 环境里就能跑。对 Linux 新手来说它是个非常有意思的 Bash 练手项目对已经熟悉命令行的老手来说它也能带来一种“我居然能用 Bash 做这个”的奇特快感。1. 到底怎么做才能在终端里跑起俄罗斯方块1.1 这题能不能用 Bash 解先说结论能但前提是你要接受 Bash 的“脾气”。用 Bash 写游戏最大的争议在于它并不是为高性能实时计算设计的。但俄罗斯方块这种游戏有个很特殊的性质逻辑简单、数据量小、渲染频率低。一个 10 列 × 20 行的棋盘一共也就 200 个格子用字符串来表示每一行每个字符表示一个格子整个棋盘的内存开销可以忽略不计。每秒刷新几十次在终端上完全可行因为输出到终端本来就是文本流Bash 处理字符串的速度虽然比不上 C但处理 200 个字符的字符串数组绰绰有余。关键要认清 Bash 擅长什么、不擅长什么。下面这张表是我在动手前给自己理的底细能力在 Bash 中的体现本次项目的用处字符串处理切片、拼接、替换表示棋盘每一行的格子状态数组普通数组、关联数组存储棋盘行、存储方块坐标循环for、while、until主循环、遍历方块坐标条件判断[[ ]]、(( ))碰撞检测、行消除判定终端控制ANSI 转义码、tput光标定位、颜色输出非阻塞输入read -t读取按键、控制方块移动容易让人踩坑的部分在后面两块终端控制和输入读取。Bash 本身没有图形概念终端上的“画面”本质是一堆文本字符要让它们像游戏画面一样变化靠的是 ANSI 转义序列。比如\033[H把光标移到左上角\033[31m把文字变为红色。这些序列在网络上一搜一大把但真正用起来时会有很多细节问题比如隐藏光标、清屏闪烁、方向键的转义序列在不同终端里不一样等。这些我都会在第 6 节里展开说。1.2 为什么选纯 Bash 而不选 Python 或 C很多人不理解写个游戏用什么不好非要折磨 Bash我的理由其实很朴素。第一零依赖。不管是在本机的任意 Linux 发行版上还是在一个精简到不能再精简的容器里只要有/bin/bash就能跑不需要pip install不需要编译工具链。第二学习价值高。写这个脚本的过程等于把 Bash 的数组扩展、字符串切片、关联数组、read的用法、ANSI 转义这些知识点全部用了一遍比死记硬背文档有效得多。第三它有足够的挑战性。Bash 没有结构体、没有对象、没有指针甚至严格意义上没有真正的二维数组。你只能用字符串数组模拟二维网格用关联数组模拟自定义对象。这种“带着镣铐跳舞”的约束反而会逼着你把问题模型想得非常简单直接。比如一个方块遮罩在 Python 里你会加一个类来表示在 Bash 里一个坐标字符串0,0 0,1 0,2 0,3就够了。写完之后你会觉得原来很多项目里所谓的“复杂架构”其实是因为工具给了你过度设计的空间。从实际代码量看这个项目大约 300 行其中一半是注释和空行。对于想学 Shell 脚本的人来说把一个几百行的脚本从头到尾读一遍比看十篇“Linux 命令大全”的文章都有用因为它是真正的“综合实战”。2. 先拆结构游戏区域和方块的数字化2.1 把 10×20 的棋盘塞进数组俄罗斯方块的棋盘是 10 列 20 行。在 Bash 里我选择用一维数组board来存每个元素是 10 个字符的字符串代表一行。空格表示这个格子是空的非空格字符比如I、O、T表示这格已经被某个方块占据。定义大概长这样ROWS20 COLS10 declare -a board init_board() { local i for ((i0; iROWS; i)); do board[i] # 恰好10个空格 done }为什么不直接用两层数组或者(x,y)坐标因为 Bash 的数组元素本身也是字符串用一维字符串数组来保存整行天然方便做两件事一是渲染时直接printf %s ${board[i]}输出一行二是消行时用字符串拼接就能把上面所有行往下挪不需要 200 个格子逐个搬运。后面讲消行的时候你就能体会到这个设计的妙处。你可能会问为什么每个格子用一个字符而不是两个字符因为 10 个字符恰好是一行的显示长度方便后续渲染。不过在真正输出到终端时我会把每个格子的内容变成两个字符宽的方块比如用■或·来显示视觉效果会更好。这一步在渲染函数里做棋盘数据本身始终是一行一个字符串。2.2 方块的数据结构和旋转俄罗斯方块有 7 种基础形状I、O、T、S、Z、J、L。每种方块我只存它的基础坐标其它三个旋转态通过坐标变换实时算出来。这里用关联数组做“形状字典”键是形状字母值是一个坐标字符串格式是x,y空格分隔declare -A SHAPES SHAPES[I]0,0 0,1 0,2 0,3 SHAPES[O]0,0 0,1 1,0 1,1 SHAPES[T]1,0 0,1 1,1 2,1 SHAPES[S]1,0 2,0 0,1 1,1 SHAPES[Z]0,0 1,0 1,1 2,1 SHAPES[J]0,0 0,1 1,1 2,1 SHAPES[L]2,0 0,1 1,1 2,1注意这里坐标(x,y)中x表示列偏移y表示行偏移都是从 0 开始。I方块基础状态是一行四格O方块是 2×2 的一个正方形。方块在棋盘中的具体位置由当前方块的“锚点坐标”cur_x、cur_y决定实际占用的格子就是cur_x x、cur_y y。锚点我通常取方块坐标中的最左上位置附近方便做碰撞检测。有的实现会把每个方块的四个旋转状态直接写死在关联数组里比如T0、T1、T2、T3这样旋转时直接查表代码直观但表格很占篇幅。我选择“基础坐标 旋转函数”的方案一方面代码更短另一方面可以对读者展示真正的数学变换理解起来更有趣。2.3 方块旋转的数学原理与 Bash 实现旋转的核心是坐标变换。把方块看成平面上的点集围绕原点旋转 90 度坐标变化规则是(x, y) - (-y, x)这得到的是顺时针旋转。但问题在于旋转后坐标可能变成负数比如(0,1)旋转后会变成(-1,0)。同时每旋转一次方块的“包围盒”也可能移动如果不做归一化处理方块旋转后视觉上会“飘”。解决办法是旋转完以后把所有坐标平移让 x 和 y 的最小值回到 0。具体到 Bash 里我会把坐标字符串拆开逐点变换找到最小 x 和最小 y再统一减回去rotate_coords() { local coords$1 local pair x y nx ny minx999 miny999 local -a xs ys local i result for pair in $coords; do x${pair%,*} y${pair#*,} nx$(( -y )) ny$(( x )) if (( nx minx )); then minx$nx; fi if (( ny miny )); then miny$ny; fi xs($nx) ys($ny) done for ((i0; i${#xs[]}; i)); do result$(( ${xs[i]} - minx )),$(( ${ys[i]} - miny )) done echo $result }这个函数返回一个新的坐标字符串比如输入0,0 0,1 0,2 0,3输出就对应旋转后的竖直 I 型方块坐标。不要小看这十几行它至少用到了字符串拆分、参数展开、算术运算、数组追加、循环这些全是 Bash 脚本里的基本功。实际调用时我会把旋转后的结果尝试一下如果旋转变形后与已有方块或边界碰撞就再尝试向左或向右平移一步这就是俄罗斯方块里很常用的“墙踢”补偿机制。3. 让画面动起来终端渲染和键盘输入3.1 ANSI 转义码的基本功终端渲染原理特别简单先在脑海中生成一张“字符画”清掉屏幕把字符画整体输出。为了让方块看起来像是在移动而不是一行行往下滚需要把光标定位到固定位置。最常用的 ANSI 转义序列是# 把光标移动到第 row 行、第 col 列 printf \033[%d;%dH $row $col这里\033就是 ESC 键的 ASCII 码终端看到这个序列就会移动光标。除了定位还有几个我常用的序列\033[2J清空整个屏幕配合\033[H把光标移到左上角\033[?25l隐藏光标避免那个一直在闪的小方块破坏画面\033[?25h显示光标退出游戏时一定要恢复\033[0m重置所有颜色属性\033[31m到\033[37m设置文字颜色我写的渲染函数大概思路是这样先隐藏光标并把它移到左上角然后循环输出 20 行棋盘和边框最后输出分数信息。由于每次重绘都从左上角开始覆盖旧的画面根本不需要先clear再画这样能有效防止屏幕闪烁。整个棋盘渲染代码会非常短render_board() { local r c line result ch printf \033[?25l printf \033[H printf ╔════════════════════╗\n for ((r0; rROWS; r)); do line${board[r]} # 把当前下落的方块叠加到这一行 line$(overlay_piece $line $r) result║ for ((c0; cCOLS; c)); do ch${line:c:1} if [[ $ch ]]; then result■ else result\033[1;33m■ \033[0m fi done printf %s║\n $result done printf ╚════════════════════╝\n printf 得分: %-6d 等级: %d\n $score $level }为什么用║而不是普通的|因为全角框线在终端里视觉上更整齐。但有个小坑不同终端对全角字符的宽度处理不完全一致如果发现边框对不齐可以把边框字符换成半角|和-。我在测试时就遇到了某些终端下■显示宽度不一致的问题最后统一用■两个字符宽表示一个格子才彻底对齐。3.2 非阻塞键盘输入游戏绝对不能等玩家按完回车才动必须做到按键实时响应。Bash 里没有标准的多线程也没有真正的按键事件但有一个很管用的内置命令read可以配合-t超时参数实现“非阻塞”读取IFS read -t 0.2 -n 1 -s key解释一下参数-t 0.2表示如果 0.2 秒内没有输入就返回-n 1表示只读一个字符-s表示不回显输入内容。这样一个read返回之后要么key里有玩家按下的字符要么超时无人按键。超时恰恰可以当作“自然下落”的节拍器这样游戏循环就自然变成“等一小会没按键就下降一格有按键就处理操作”。方向键在终端里不是单个字符而是一串转义序列。最常见的是上 ESC [ A 下 ESC [ B 右 ESC [ C 左 ESC [ D也就是说当玩家按方向键时read -n 1先读到了 ESC 字符\033然后需要立刻再用read读后两个字符。所以处理逻辑要分支handle_key() { local key esc IFS read -t 0.01 -n 1 -s key if [[ -z $key ]]; then return 1 # 超时没有按键 fi case $key in q) exit 0 ;; ) hard_drop ;; # 空格硬降 $\033) IFS read -t 0.01 -n 2 -s esc case $esc in [A) rotate_piece ;; [B) move_piece 0 1 ;; [C) move_piece 1 0 ;; [D) move_piece -1 0 ;; esac ;; w) rotate_piece ;; a) move_piece -1 0 ;; s) move_piece 0 1 ;; d) move_piece 1 0 ;; esac return 0 }为了键盘操作更方便除了方向键我还加了 WASD 映射这样在远程终端或者部分不发送方向键的会话里也能玩。方向键读取有个老坑某些终端发出的是ESC O A而不是ESC [ A也就是第二位是字母O而不是[。如果要兼容匹配里要同时写[A|OA。实际项目中这点兼容性很有价值否则同一个脚本在部分终端上方向键完全不响应。4. 核心逻辑移动、消行、计分4.1 碰撞检测是游戏的裁判游戏所有动作能不能执行最终都要看碰撞检测。所谓碰撞检测就是假设一个动作执行后当前方块的每一个格子是否超出了棋盘边界或者撞到了棋盘里已有的方块。Bash 里实现这个函数非常直观can_place() { local dx$1 dy$2 coords$3 local pair x y nr nc for pair in $coords; do x${pair%,*} y${pair#*,} nr$(( cur_y y dy )) nc$(( cur_x x dx )) if (( nr 0 || nr ROWS )); then return 1; fi if (( nc 0 || nc COLS )); then return 1; fi if [[ ${board[nr]:nc:1} ! ]]; then return 1; fi done return 0 }这里dx、dy代表尝试移动的偏移量coords是当前方块的坐标字符串。你会在移动、旋转、硬降时反复调用这个函数。移动就调用can_place 1 0 $coords旋转则先把坐标旋转变换再调用can_place 0 0 $new_coords看看能不能放得下。注意 Bash 里取字符串某个位置的字符用${board[nr]:nc:1}这个切片语法。如果棋盘行字符串恰好是I开头的某个字符判断! 就能知道这里已经被占了。整体逻辑非常像一个简单的棋盘游戏裁决先检查后行动绝不先把方块放进去再后悔。4.2 行消除把“满行”整行干掉行消除是整个游戏里最有成就感的逻辑。每次方块落定、合并进棋盘之后就要扫描每一行看哪一行里没有任何空格一旦有就删除这一行并在棋盘顶部补一行新的空白行。实现了这个操作游戏的“掉落感”就出来了。我用字符串数组存棋盘的优点在这里爆发了。消除一行只需要两步把上面所有行往下移一行在最顶上补一个空行。比如要消除第r行clear_line() { local r i for ((rROWS-1; r0; r--)); do # 判断第 r 行是否全占满 if [[ ${board[r]} * * ]]; then continue fi # 满了消除把 r 行以上的行全部下移 for ((ir; i0; i--)); do board[i]${board[i-1]} done board[0] ((lines_cleared)) ((score 10 * level)) # 消除后可能上面又出现新的满行所以 r 需要重新检查 # 这里用一个小技巧判断完一行后 r 不减继续检查当前行 ((r)) done }这段代码里if [[ ${board[r]} * * ]]是判断这一行里是否包含空格如果不包含空格就说明满了。Bash 的模式匹配在这里非常顺手一行只要包含空格就还有空位反之则为满行。消除后的((r))是为了让这一行被替换成上一行之后扫描指针停下来继续检查它否则可能漏掉连续出现的满行。4.3 计分与难度曲线不单调才是好游戏计分规则我参考了经典版本消 1 行得 10 分消 2 行得 30 分消 3 行得 60 分消 4 行得 100 分再乘上当前等级。为了简单也可以用“每消一行都按等级乘一个基数”的方式。更重要的其实是难度曲线也就是下落速度。每消除一定行数就提升一级每级的下落间隔变短。Bash 里控制下落间隔最简单的办法就是控制read -t的等待时间level$(( lines_cleared / 10 1 )) case $level in 1) delay0.6 ;; # 每 0.6 秒下落一行 2) delay0.5 ;; 3) delay0.4 ;; *) delay0.2 ;; esac这个delay会用在游戏主循环的read -t $delay里。玩家按了键循环立刻处理没有按键read等待delay秒后超时就执行一次“自然下落”。所以理论上等级越高玩家按键后等来的下一次下落就越快压迫感也就越强。4.4 主循环把每一块拼起来所有模块最后都汇到主循环里。主循环的大致结构是while true; do handle_key if [[ -z $key ]]; then if ! move_piece 0 1; then merge_piece # 落下合并进棋盘 clear_line # 消行 spawn_piece # 生成新方块 if game_over; then break fi fi fi render_board done关键点在于“按键优先”还是“下落优先”的取舍。我的实现里如果玩家在等待时间内按键了本帧就不额外下落如果没有按键则本帧执行下落。这样手感比较顺不会出现“按了左键方块又同时往下掉了一格”的别扭感。你也完全可以在每次按键后都强制下落一帧但那样会让操作变得很急促我试过体验明显不好。合并方块到棋盘的操作其实就是把当前方块的每个格子坐标换算成棋盘坐标然后写入对应字符merge_piece() { local pair x y px py for pair in $cur_coords; do x${pair%,*} y${pair#*,} px$(( cur_x x )) py$(( cur_y y )) board[py]${board[py]:0:px}${cur_type}${board[py]:px1} done }这个board[py]的字符串替换写法是 Bash 里“改某个位置字符”的通用套路左边截取、中间替换、右边拼接。如果你看过别人写的 Bash 俄罗斯方块大概率会见到类似代码。5. 完整脚本的组装与运行5.1 如何把这些片段拼成一个可执行脚本上面讲到的所有功能把它按顺序放进一个tetris.sh里就能跑。我建议按下面的顺序组织这样读起来最舒服段位内容开头变量初始化、棋盘和形状字典工具函数旋转、移动、碰撞检测游戏函数生成方块、合并、消行、结束判定渲染函数绘制边框、棋盘、分数主循环按键处理、下落、渲染脚本开头一定要加上#!/bin/bash并建议这样执行chmod x tetris.sh ./tetris.sh如果你用的发行版默认 shell 是 sh 或者 zsh我建议直接用bash tetris.sh来运行因为关联数组和read -t这些特性在老式 sh 里是没有的。5.2 运行环境要求与兼容性问题这个脚本能跑的最低要求是 Bash 4.0因为关联数组declare -A从 Bash 4 才开始支持。你可以提前用bash --version检查。系统方面任何完整的 Linux 发行版都没问题如果是 Windows 的 WSL 或 macOS 的终端只要 bash 版本够了也能跑。需要注意的兼容点有两个第一个是终端类型。我在screen、tmux和图形终端里分别测过方向键的转义序列基本一致但某些老旧的 xterm 可能发的是ESC O A所以代码里做了两套匹配。第二个是全角字符宽度。如果你发现边框错位多半是终端字体把全角字符渲染成了不同宽度。最简单的解决办法是把所有■换成两个空格再加个背景色或者干脆用半角字符。另一个必须强调的点是脚本异常退出时的终端恢复。如果脚本在隐藏光标的状态下被强制结束比如直接关闭终端窗口光标可能一直不显示非常别扭。所以代码里不仅要注册退出时恢复光标的处理还要在脚本开头用trap机制把常态恢复操作挂好restore_terminal() { printf \033[?25h printf \033[0m } trap restore_terminal EXIT这个trap是 Shell 脚本里很容易被忽略但极其重要的细节。有了它不管脚本是正常退出还是中途exit 0都能把光标和颜色重置回来。我建议把这个习惯带到所有用到 ANSI 转义码的脚本里。6. 踩过的坑和优化心得6.1 常见问题速查表我把实际调脚本过程中遇到过的典型问题整理成了一张表按“现象—原因—对策”的方式记录方便你遇到同样问题时快速定位现象原因对策方向键没反应终端发的是ESC O A序列在 case 里同时匹配[A和OA两种方块下落速度不规律主循环里按键和下落逻辑互相干扰把超时当成下落节拍不要在下落循环里额外 sleep画面一直在闪每次用clear全屏刷新改成把光标移到左上角后整帧覆盖输出光标在游戏结束后消失隐藏光标后没有恢复用trap ... EXIT恢复光标显示边框对不齐全角字符在不同终端宽度不同统一每格两个字符宽用■这种固定宽度写法方块旋转后位置偏移旋转坐标没做归一化旋转函数里找最小坐标并整体平移连续满行消除不干净消行后指针直接跳过消除后不移动行指针继续检查当前行这里面最容易被新手忽略的是最后一条。消行代码如果不做“当前行继续检查”的处理就会出现两块同时满时只消一行的灵异现象。我在初版脚本里就踩过这个坑当时看棋盘明明两层都满了结果只消了一行 debug 了半天才发现是循环指针的问题。还有一个体验细节主循环里如果玩家一直按着方向键不放read -t会不断读到键值导致自然下落迟迟不触发。解决办法是在每次handle_key之后加一个全局计数器或者时间戳确保不管有没有按键下落动作都不会被无限推迟。我给脚本加的是“上一次次下落循环计数”实际效果已经很顺滑。6.2 体验优化从“能玩”到“好玩”脚本能跑只是第一步想让它玩起来像样还得打磨几个地方。第一是色彩。我给不同方块分配不同颜色I 型青色、O 型黄色、T 型紫色、S 型绿色、Z 型红色、J 型蓝色、L 型橙色。实现方式不复杂渲染时根据棋盘里存的那个形状字符选对应的 ANSI 颜色码输出。这一招让终端画面瞬间从“黑白字符”变成了“彩色像素块”视觉反馈强非常多。第二是硬降和软降。软降就是普通的每帧下落一行硬降则是按了空格后让方块直接落到底并立即锁定。硬降时我会一次性结算落到最底部的移动并且把得分略微提高。实现上只要用一个循环不断尝试向下移动直到不能再移动为止代码不过几行hard_drop() { while can_place 0 1 $cur_coords; do ((cur_y)) ((score 2)) done }第三是下一个方块预告。很多版本的游戏会在侧边显示下一个出现的方块。Bash 里实现这个很容易多生成一个随机的next_type渲染时在旁边打印它的坐标点阵。这算是把游戏完整性的一个标志玩家会感觉“这是个正经游戏”。第四是退出界面。游戏结束后不要直接黑屏一堆数字我习惯显示一行 “GAME OVER” 和最终得分然后才退出。这既是对玩家的交代也是视觉上的收尾。用 ANSI 颜色把最终得分刷成红色效果很带感。最后再分享一点我的实际体会这个项目我前前后后写了好几版第一版跑起来时方块旋转后经常卡进边界里我当时差点把坐标表手写到底后来发现用旋转函数加上墙踢补偿才是正解。对我个人来说写这个脚本最大的收获不是“我做了个游戏”而是把平常零散用的 Bash 知识全部串成了一条线。你会突然发现read的超时机制能当定时器用字符串切片能当二维数组用关联数组能当对象表用原来 Shell 比你想象中能做的事多得多。如果你也准备动手写我可以给你个非常实用的建议先别急着一步到位写完整版先把棋盘渲染出来让一个静态方块能上下左右移动再加旋转、消行、计分最后补颜色和音效。每一步跑通之后再继续调试成本会低非常多。把这个脚本跑起来后弹出终端放个经典方块曲调你会觉得整个命令行都变得亲切了不少。