说句实话,第一次想到“用纯 Bash 写俄罗斯方块”的时候,我自己都觉得有点荒谬。Shell 脚本在大多数人眼里就是拿来部署、处理日志、写点自动化命令的,谁能让它像游戏一样实时渲染、响应键盘、滚动画布?但真正把代码跑起来、看到彩色方块一格一格落下的时候,那种“用最朴素工具折腾出像样作品”的满足感,确实不是写个 Python 小游戏能比的。
这个“纯 Bash Shell 脚本实现俄罗斯方块终端小游戏”的项目,指的是不依赖 ncurses、Python、gcc,只用 Bash 语法和少量系统命令完成完整的游戏循环:网格维护、方块生成、旋转、移动、碰撞检测、消行、计分、终端绘制和按键响应。它适合 Linux 初学者用来进阶 Shell 编程,也适合任何想在终端里找点乐子的人。文章会把整个设计和源码拆开讲清楚,包括我最开始为什么选这种架构、中间踩过的坑,以及最后可以怎么扩展它。
1. 项目定位与整体设计思路
1.1 纯 Bash 到底能做什么
很多人听到“纯 Bash”会以为完全不能调用外部命令,其实这个说法并不严谨。一个脚本要运行,本身就需要依赖 coreutils、bash 解释器这些基础环境。这里说的“纯 Bash”,准确含义是:不引入 Python、Node、gcc 这类高级语言运行时,也不使用 ncurses、tput 这类专门的终端图形库,整个游戏逻辑都用 Bash 的内建语法、内部变量、算术展开和基本命令协作完成。
这样做有两个很实际的好处。第一是移植性极强,几乎所有 Linux 发行版都默认带 Bash 和一堆基础命令,把这个脚本拷过去,不需要安装任何额外依赖,直接bash tetris.sh就能跑。第二是强迫你回归编程本质,Bash 没有像样的数组嵌套、没有多线程、没有高精度定时器,也没有现成的游戏框架,你只能用最原始的方式处理网格、状态机、输入循环,这反而能把“游戏是怎么转起来的”这件事看得更清楚。
当然,限制也很多。Bash 的整数范围有限,浮点运算要借道外部命令;read的阻塞行为需要特殊处理;终端转义序列在不同模拟器上有细微差别。所以我一开始就把目标定得很明确:不追求华丽的特效,先把一个“能玩、能计分、能消行、能结束”的闭环跑通。
1.2 游戏逻辑的最小闭环
俄罗斯方块无论用什么语言写,核心都是同一个状态循环。我把它拆成了四个环节:
- 生成:随机选一种方块,出现在战场顶部。
- 下落:每隔固定时间方块自动向下移动一格。
- 用户操作:左右移动、旋转、加速下落。
- 锁定:方块落到底后固定到网格,检查是否有满行,消行后生成下一个方块。
所有逻辑中,最难处理的不是消行,而是实时渲染和键盘输入。普通命令行程序是一个接一个地执行命令,游戏却需要在“没有输入的时候自动做事情”,同时在“有输入的时候立即响应”。Bash 没有事件驱动机制,所以我采用了最朴素的方案:用read -t的读超时实现时间片轮询。每一帧先尝试读一个字符,如果有按键就立刻处理;如果超时没有按键,就执行一次自动下落。这个方案不需要额外开进程,也不需要复杂锁,配合stty就能达到可玩的交互效果。
在设计数据结构时,我刻意避免过度抽象。因为 Bash 的性能不强,方块数量也很少,完全可以用直观的数组和字符串表示,不必为了炫技引入复杂的结构。后面所有源码都是在“能读懂、能改、能跑”的前提下写的。
2. 数据结构和碰撞检测,先让方块在数组里转起来
2.1 用一维数组模拟 10x20 战场
俄罗斯方块的经典战场是 10 列 20 行。很多语言里有二维数组,但 Bash 数组只有一维,所以最实用的做法是把它展平成一维数组BOARD,长度是WIDTH * HEIGHT = 200,然后通过行号 * WIDTH + 列号这个公式访问指定格子。
比如第三行第二列的索引是2 * 10 + 1 = 21。这个换算必须写在所有网格操作里,包括碰撞检测、合并、绘制、消行。一旦写乱了,就会出现“方块卡在半空”或者“明明看着没碰到却报碰壁”的怪问题。我封装了check_cells、merge_piece、draw这些函数,让每个函数内部独立完成换算,外部只传逻辑坐标,这样思路会清楚很多。
网格里的值,我用 0 表示空格,用 1 到 7 表示不同种类的方块。比如碰到类型 0 的 I 方块,写入的值是 1;类型 1 的 O 方块,写入的值是 2。这样绘制的时候可以直接根据值取颜色,不需要再额外记录“这块是哪个形状”。
2.2 方块形状怎么存:用字符串模拟 4x4 矩阵
俄罗斯方块有 7 种标准方块:I、O、T、S、Z、J、L。每种方块占 4x4 的矩阵,整个方块就是由一个 4x4 的小矩阵在网格里移动。
Bash 不好直接声明二维数组,所以我用了字符串方案。每个旋转形态存成一个字符串,字符串内部用空格分隔成 4 个小组,每个小组正好 4 个字符,代表一行。如果某个位置是#就表示有方块,是.就表示空。例如 I 方块的一种形态是:
".... #### .... ...."这代表 4 行分别为....、####、....、....。解码时用read -ra rows <<< "$shape_str"把它拆成数组,再逐行逐字符判断。
为什么用字符串而不是位掩码?因为位掩码虽然高效,但可读性差,调试的时候你根本看不出这一长串二进制数对应什么图案。字符串方案虽然占一点内存,但肉眼检查非常方便,出错了也能一眼定位。我把 7 种方块各自 4 个旋转形态都提前用手写进SHAPES数组,一共 28 个字符串,代码多一点,但完全不用写矩阵旋转算法,稳定性更高。
2.3 碰撞检测里的三条边界
碰撞检测是游戏正确性的核心。它的任务是:当我打算把当前方块移动到某个新位置时,计算移动后方块占据的所有网格坐标,判断是否越界或和已有格子重叠。三条边界分别是:
- 左右越界:方块任一格的列坐标小于 0 或大于等于 10。
- 底部越界:方块任一格的行坐标大于等于 20。
- 与已有块重叠:在网格范围内,目标格子的值不是 0。
需要注意一个容易被忽略的点:方块在刚生成时可能有一部分在网格顶部之外。比如有的旋转形态第一行是空行,方块实体从第二格开始;如果我把初始行设置成CUR_Y=0,通常没事,但在旋转时可能出现部分格子位于CUR_Y < 0。碰撞检测里要专门跳过gy < 0的格子,只要不越界、不重叠即可,不能因为在“战场外面”就直接判失败,否则方块还没进场就被卡死了。
碰撞检测函数的退出状态很关键:Bash 函数用return 0表示成功,return 1表示失败,之后在主循环里用if move 0 1; then ...来判断下落是否成功。我一开始容易写成“能移动返回1”,结果整个逻辑全部反掉,调试了很久才反应过来。
2.4 消行逻辑:从下往上扫,还要做一个 r++
消行的标准逻辑是从战场最底行往上扫描,如果某一行所有格子都不为 0,就说明满了,应该消掉。
消行不是简单地清空那一行,而是要把该行上面的所有行整体往下移一行,然后在顶部补一行空白。这里最容易出错的是下移顺序:必须从上往下复制,也就是说先处理第 9 行复制到第 10 行,再处理第 8 行复制到第 9 行,依次类推,直到第 0 行复制到第 1 行。如果反过来从下往上复制,上面的值还没保存就被覆盖了,整个网格直接乱掉。
消完一行之后,有一个我亲测必须加的步骤:对当前r做一次r=$((r+1)),让循环重新检查当前这一行。因为你消掉一行后,原来上面的行整体下移,完全有可能又是满的。如果不回退,这一行就被跳过去了,连续满行会被漏掉。这个细节很长一段时间没人提醒,我看很多教程也没写,只能靠实战踩坑踩出来。
3. 终端绘画和实时按键,这才是纯 Shell 版的灵魂
3.1 用 ANSI 转义序列当画笔
终端之所以能显示游戏界面,是因为它支持 ANSI 转义序列。其中最关键的是光标定位和颜色控制。
每次刷新画面时,先用printf "\033[H"把光标移到终端左上角,然后从第一行开始重新绘制整个网格。为了不残留上一帧的影像,我选择先printf "\033[2J"清屏再定位。这个方式最稳,代价是稍微有点闪烁,但在终端小游戏的可接受范围内。
颜色是用\033[背景色m和\033[0m组合出来的。我定义了一个COLORS数组,存 41 到 47 的背景色号,分别代表红、绿、黄、蓝、紫、青、白。绘制某个格子时,打印\033[41m \033[0m就是一个红色背景的空格,把两个背景空格连起来看就成了一个彩色方块。这样完全不依赖终端的方块字体,也不需要额外判断 UTF-8 是否支持,只要是有颜色能力的终端都能正常显示。
还要处理光标闪烁。玩的时候光标一直闪很烦,渲染前执行printf "\033[?25l"隐藏光标,退出时再用printf "\033[?25h"恢复。别小看这两行,忘了恢复,用户退出后终端的光标会一直不显示,非常影响使用体验。
3.2 用 read -t 和 stty 组合实现实时按键
这是整个项目里最折腾的部分。默认终端是“整行缓冲”模式,必须按下回车后程序才能读到输入,这对游戏来说完全不可用。所以第一步是调用stty -icanon -echo关闭行缓冲和键盘回显,让按键立即传给程序。这是所有终端交互程序的基础操作。
读按键我用的是read -n1 -s -t "$DELAY" key。-n1表示读一个字符,-s表示静默读入,-t表示超时时间。主循环里每帧先读一次,如果超时返回非零,就执行自动下落;如果读到字符,就根据字符内容判断是左移、右移、旋转、加速还是退出。
这里有个常见的误区:方向键在终端里不是单个字符,而是ESC [ A这种三字节转义序列。如果只做read -n1,程序每次只能读到ESC,后面的[ A会被当成两个按键。为了规避这个麻烦,我最终的源码里选择了w/a/s/d方向控制键,每个键都对应一个普通字符,实现起来干净可靠。文章后面的源码就是基于这套按键的,想要方向键的可以自己扩展,思路是在读到ESC后再尝试读两个字节并解析。
read -t支持小数超时,这是 Bash 4.3 以后才有的特性。现代 Linux 基本都没问题,但如果你在很老的系统上跑,可以把DELAY=0.3改成1,只是节奏会慢很多。
3.3 终端恢复和防呆设计
在开发过程中,我无数次因为按Ctrl+C中断程序,导致终端一直处于-icanon -echo状态:输入什么都不会回显,敲回车也没有反应,整个终端像死掉一样。这是写终端程序最容易踩的坑。
解决办法是加一个trap:
trap 'stty sane; printf "\033[?25h\033[0m\033[2J\033[H"; exit 0' INT TERM EXIT只要脚本退出,不管是通过正常结束还是被信号打断,都会先恢复终端设置、恢复光标显示。如果遇到已经乱掉的终端,手动执行reset或者stty sane也能救回来。
我还建议在脚本开头和结尾都用printf "\033[?25h"确保光标恢复。你可以理解成“进门先记下门锁状态,出门一定要还原”,这是所有终端程序都应该有的职业素养。
4. 完整源码与操作指南
4.1 源码全文
下面就是我整理好的完整版本。保存成tetris.sh,然后在终端里执行bash tetris.sh即可运行。控制键为a左移、d右移、w旋转、s硬降、q退出。
#!/usr/bin/env bash # 纯 Bash 俄罗斯方块 # 控制: A = 左移, D = 右移, W = 旋转, S = 硬降, Q = 退出 WIDTH=10 HEIGHT=20 SCORE=0 CUR_TYPE=0 CUR_ROT=0 CUR_X=0 CUR_Y=0 DELAY=0.3 LANDED=0 # 网格数组 for ((_i=0; _i<WIDTH*HEIGHT; _i++)); do BOARD[_i]=0 done # 七种方块,每种四个旋转形态,用字符串模拟 4x4 矩阵 # 空格分隔出四行,一行四个字符,# 表示实体,. 表示空 SHAPES=( ".... #### .... ...." "..#. ..#. ..#. ..#." ".... #### .... ...." "..#. ..#. ..#. ..#." ".... .##. .##. ...." ".... .##. .##. ...." ".... .##. .##. ...." ".... .##. .##. ...." ".... .#.. ###. ...." ".#.. .##. .#.. ...." ".... ###. .#.. ...." ".#.. ##.. .#.. ...." ".... .##. ##.. ...." ".#.. .##. ..#. ...." ".... .##. ##.. ...." ".#.. .##. ..#. ...." ".... ##.. .##. ...." "..#. .##. .#.. ...." ".... ##.. .##. ...." "..#. .##. .#.. ...." ".... #... ###. ...." ".##. .#.. .#.. ...." ".... ###. ...# ...." "..#. ..#. .##. ...." ".... ...# ###. ...." ".##. ..#. ..#. ...." ".... ###. #... ...." ".#.. .#.. .##. ...." ) # 背景色 COLORS=(41 42 43 44 45 46 47) check_cells() { local x=$1 y=$2 rr cc gx gy row local shape_str="${SHAPES[$((CUR_TYPE*4+CUR_ROT))]}" local -a rows=() read -ra rows <<< "$shape_str" for rr in 0 1 2 3; do row="${rows[$rr]}" for ((cc=0; cc<4; cc++)); do if [[ "${row:cc:1}" == "#" ]]; then gx=$((x+cc)) gy=$((y+rr)) if (( gx < 0 || gx >= WIDTH || gy >= HEIGHT )); then return 1 fi if (( gy >= 0 )); then if (( BOARD[gy*WIDTH+gx] != 0 )); then return 1 fi fi fi done done return 0 } move() { local dx=$1 dy=$2 if check_cells $((CUR_X+dx)) $((CUR_Y+dy)); then CUR_X=$((CUR_X+dx)) CUR_Y=$((CUR_Y+dy)) LANDED=0 return 0 fi return 1 } rotate() { local nr=$(( (CUR_ROT+1)%4 )) local old=$CUR_ROT CUR_ROT=$nr if ! check_cells "$CUR_X" "$CUR_Y"; then CUR_ROT=$old return 1 fi return 0 } merge_piece() { local shape_str="${SHAPES[$((CUR_TYPE*4+CUR_ROT))]}" local -a rows=() local rr cc gx gy row read -ra rows <<< "$shape_str" for rr in 0 1 2 3; do row="${rows[$rr]}" for ((cc=0; cc<4; cc++)); do if [[ "${row:cc:1}" == "#" ]]; then gx=$((CUR_X+cc)) gy=$((CUR_Y+rr)) if (( gy >= 0 && gy < HEIGHT )); then BOARD[gy*WIDTH+gx]=$((CUR_TYPE+1)) fi fi done done } remove_full_rows() { local r c full for ((r=HEIGHT-1; r>=0; r--)); do full=1 for ((c=0; c<WIDTH; c++)); do if (( BOARD[r*WIDTH+c] == 0 )); then full=0 break fi done if (( full == 1 )); then for ((r2=r-1; r2>=0; r2--)); do for ((c=0; c<WIDTH; c++)); do BOARD[(r2+1)*WIDTH+c]=${BOARD[r2*WIDTH+c]} done done for ((c=0; c<WIDTH; c++)); do BOARD[c]=0 done SCORE=$((SCORE+100)) r=$((r+1)) fi done } spawn_piece() { CUR_TYPE=$((RANDOM%7)) CUR_ROT=0 CUR_X=3 CUR_Y=0 check_cells "$CUR_X" "$CUR_Y" } lock_piece() { merge_piece remove_full_rows spawn_piece } draw() { local -a disp=("${BOARD[@]}") local shape_str="${SHAPES[$((CUR_TYPE*4+CUR_ROT))]}" local -a rows=() local rr cc gx gy row read -ra rows <<< "$shape_str" for rr in 0 1 2 3; do row="${rows[$rr]}" for ((cc=0; cc<4; cc++)); do if [[ "${row:cc:1}" == "#" ]]; then gx=$((CUR_X+cc)) gy=$((CUR_Y+rr)) if (( gy>=0 && gy<HEIGHT )); then disp[gy*WIDTH+gx]=$((CUR_TYPE+1)) fi fi done done printf "\033[2J\033[H" printf "Score: %s\n" "$SCORE" printf "Key: a/d=move w=rotate s=drop q=quit\n" for ((rr=0; rr<HEIGHT; rr++)); do printf "|" for ((cc=0; cc<WIDTH; cc++)); do local v=${disp[rr*WIDTH+cc]} if (( v == 0 )); then printf " " else printf "\033[%dm \033[0m" "${COLORS[v-1]}" fi done printf "|\n" done } # 初始化终端状态 stty -icanon -echo trap 'stty sane; printf "\033[?25h\033[0m\033[2J\033[H"; exit 0' INT TERM EXIT printf "\033[?25l" if ! spawn_piece; then draw echo "Game Over" stty sane printf "\033[?25h\033[0m\033[2J\033[H" exit 0 fi while :; do key="" if read -t "$DELAY" -n1 -s key; then case "$key" in a|A) move -1 0 ;; d|D) move 1 0 ;; w|W) rotate ;; s|S) while move 0 1; do :; done lock_piece || break LANDED=0 ;; q|Q) break ;; esac else if move 0 1; then LANDED=0 else if (( LANDED == 1 )); then lock_piece || break LANDED=0 else LANDED=1 fi fi fi draw done stty sane printf "\033[?25h\033[0m\033[2J\033[H" echo "Game Over, Score: $SCORE"4.2 核心函数导读
check_cells是碰撞检测函数,负责判断指定坐标下方块是否能合法放置。它从当前方块形状字符串里逐个找#,换算成网格坐标,边界检查和已有格子检查都在这里完成。
move函数调用一次check_cells,如果能移动就更新CUR_X和CUR_Y,同时把LANDED标志清零。rotate先尝试旋转,如果新形态与已有格子冲突就回滚到旧形态。
lock_piece在方块触底后调用,先调用merge_piece把方块数据写进BOARD,再调用remove_full_rows消行,最后调用spawn_piece生成下一个方块。如果生成时立即碰撞,说明战场已经堆到顶部,游戏结束。
draw函数先把BOARD拷贝到disp,再把当前移动中的方块叠加到disp上,最后一次性重绘整个画面。这样移动方块不会污染网格数据,落底前BOARD始终只保存已经固定的方块。
4.3 运行方式与操作说明
运行前确认你的 Bash 版本支持小数超时,一般 4.3 以上没问题。把源码保存后执行:
chmod +x tetris.sh ./tetris.sh也可以直接bash tetris.sh。脚本开始会自动清屏、隐藏光标,进入游戏。按键对照如下:
| 按键 | 功能 |
|---|---|
| a / A | 左移一格 |
| d / D | 右移一格 |
| w / W | 顺时针旋转 |
| s / S | 硬降到底 |
| q / Q | 退出并恢复终端 |
如果画面显示错乱,请确认终端窗口宽度足够大,建议至少 40x24。SSH 连接时关闭“自动换行”功能,否则边框会错位。
5. 调试实录:那些坑我替你踩过了
5.1 方块边缘会漏出去
初版写完,我发现在往左快速移动时,方块偶尔会卡在半空,或者在贴近墙壁时旋转导致三分之一漏到墙外。问题在于碰撞检测一开始只检查了方块的中心点或者只检查了部分格子。我以为是移动逻辑错了,后来才意识到,碰撞检测必须对当前形状矩阵里的每一个实体格子独立检查。
正确的做法是把 4x4 矩阵里的每个#都换算成目标坐标,任何一个格子越界或撞到旧方块,整个移动就被判失败。别搞什么“取整”“容错”,那只会让问题更隐蔽。严格逐格检查之后,这个问题再没出现过。
5.2 按一次方向键却移动两次
有段时间我明明只按了一下方向键,方块却直接移动了两格,或者旋转后画面又闪回原位。排查了半天,发现是因为stty没有设置成功,导致存在键盘回显,程序读到了两遍字符。后来在脚本开头明确执行stty -icanon -echo,并在读字符时加上-s静默选项,才算稳定。
如果你用的是旧系统,我建议在脚本里加一句判断,确认stty命令存在,并且执行后能生效。万一起作用失败,至少还能通过 Ctrl+C 退出恢复终端,不至于把会话搞死。
5.3 退出后终端乱成麻花
这是纯 Bash 终端程序最容易遇到的问题。游戏运行中把终端改成了原始模式,如果退出时没有恢复,你敲命令时什么都看不见,按回车也不正常。解决办法就是前面说的trap,把恢复逻辑写进信号处理里。除此之外,我还习惯在开发阶段随时准备好reset命令,万一代码崩了、trap 没来得及执行,手动敲一个reset就能救回来。
还有个细节:在脚本结尾的echo "Game Over, Score: $SCORE"之前,一定要先stty sane和恢复光标。顺序反了,最后的比分显示也会带着异常的终端状态。
5.4 下落节奏固定太死,怎么改手感
现在的源码里DELAY=0.3是固定值。实际玩的时候你会发现,前期局面空旷,0.3 秒还算合适;后期堆得高了,还是这个速度就非常紧张。想要进阶难度,可以把DELAY改成随着SCORE动态变化的表达式,比如:
DELAY=$(awk "BEGIN{print 0.4 - $SCORE/2000}")但这句话用到了外部命令awk,如果想严格保持纯 Bash,可以用整数迭代代替:每消一行记一个数,累计到多少次就让下落循环多一次“空转”,间接降低速度。我个人的体会是,游戏手感这个东西,必须自己在终端里跑起来一点点调,参考别人的参数永远不如自己试。
最后再分享一个小技巧
这个脚本虽然名字里带“纯 Bash”,但它的核心思想完全可以迁移到其他场景。比如终端菜单选择器、一份带实时状态的监控面板、交互式命令行工具,都可以沿用这套stty + read -t + ANSI 转义的组合。我后来写内部小工具时,经常回来翻这个俄罗斯方块的源码,不是为了抄代码,而是为了记起“终端程序怎么保持状态、怎么响应按键、怎么优雅退出”那套基本套路。
如果你想继续扩展,我最建议的起点是加上方向键支持和墙踢逻辑。方向键只需要在读到ESC之后补读两个字符,墙踢则需要在旋转失败时尝试左右平移一到两格。把这两个功能做完,这个终端游戏就非常接近正式商业版本的手感了。我个人在后续尝试中,深有体会:纯用 Bash 做游戏,限制多反而是好事,逼你把每一行代码都理解得明明白白。