在 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)表示这格已经被某个方块占据。定义大概长这样:
ROWS=20 COLS=10 declare -a board init_board() { local i for ((i=0; i<ROWS; 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 minx=999 miny=999 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 ((i=0; 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 ((r=0; r<ROWS; r++)); do line="${board[r]}" # 把当前下落的方块叠加到这一行 line="$(overlay_piece "$line" "$r")" result="║" for ((c=0; c<COLS; 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 ((r=ROWS-1; r>=0; r--)); do # 判断第 r 行是否全占满 if [[ "${board[r]}" == *" "* ]]; then continue fi # 满了,消除:把 r 行以上的行全部下移 for ((i=r; i>0; 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) delay=0.6 ;; # 每 0.6 秒下落一行 2) delay=0.5 ;; 3) delay=0.4 ;; *) delay=0.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]:px+1}" 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 比你想象中能做的事多得多。
如果你也准备动手写,我可以给你个非常实用的建议:先别急着一步到位写完整版,先把棋盘渲染出来,让一个静态方块能上下左右移动,再加旋转、消行、计分,最后补颜色和音效。每一步跑通之后再继续,调试成本会低非常多。把这个脚本跑起来后,弹出终端,放个经典方块曲调,你会觉得整个命令行都变得亲切了不少。