Bash脚本实战:纯终端开发俄罗斯方块游戏
很多人一提起 Bash 脚本第一反应就是“那不就是用来写点系统部署、日志清理的胶水命令吗”。我之前也这么想直到在一次纯 SSH 的 Linux 环境里闲着没事想玩玩小游戏发现图形界面压根不存在桌面应用更别想。于是我开始琢磨能不能用终端本身、用 Bash 自己搞一个能交互的俄罗斯方块出来这个念头听着像开玩笑但真的做成之后我反而觉得收获特别大。它不只是一个能用键盘控制的终端游戏更是一次把 Bash 的数组、字符串切片、终端转义序列、信号处理和按键读取全部揉在一起的实战练习。如果你平时写 Shell 脚本但总觉得只是“照着模板改命令”这个项目非常适合你拿来练手。它不依赖 Python、不依赖 C甚至不需要额外安装什么图形库唯一需要的就是 Bash、标准终端、一颗愿意折腾的心。整个项目做下来我对“Bash 到底能不能处理复杂状态”这件事有了全新的判断能但你得换个思路。接下来我会把整个制作过程拆开讲包括我为什么这么建模、哪些地方容易踩坑、代码每一段是怎么工作的。最后会给出一个可以直接跑起来的完整源码你复制到 Linux 终端里就能玩。1. 别小看 Bash这个玩具项目的真实价值1.1 为什么要用命令行做俄罗斯方块先说说动机。你在服务器上做运维、调服务的时候经常面对的是没有桌面的纯命令行环境。有时候你只是想放松两分钟但你不能随便装个图形游戏也可能没权限装软件。这种情况下一个纯 Bash 写的消消乐或俄罗斯方块就成了最“朴素”的玩具不装额外依赖直接把脚本扔进/usr/local/bin随时能玩。更重要的是俄罗斯方块这个游戏本身包含了一个“迷你系统”该有的所有东西一个会变化的二维状态、一个不断刷新的界面、一组异步的按键输入、一套判定规则。这些东西如果用 JavaScript 做可能也就几十行但放到 Bash 里你才会发现原来 Bash 的限制这么多也才会被迫去理解它的运行模型。正是这种“限制”让这个项目特别有教学价值。我见过有人把俄罗斯方块用 Python 写得很漂亮但用 Bash 写的版本反而更能逼你思考一个问题在没有指针、没有真正二维数组、没有事件循环的语言里怎么组织状态和流程等你想明白之后再回头写其他 Shell 脚本你会发现自己对变量作用域、数组展开、子 Shell 这些概念的理解完全不一样了。1.2 做这个项目前最好先掌握这几个 Bash 能力这不是一个零基础入门项目但你也不需要非常精通 Bash。只要下面几个点你大概知道跟着文章抄也能成功字符串按位置取子串${var:offset:length}这是操作游戏行的核心。数组的基本操作定义、追加、展开特别是${arr[]}和unset。终端的 ANSI 转义序列比如\033[H回到左上角、\033[4m设置背景色。stty基本用法怎么关掉终端的回显和缓冲让按键能实时被读取。trap信号处理在退出时恢复终端设置不然脚本被中断后终端会变得很怪。如果你现在还不太熟也没关系我在下面每一节都会把用到的地方单独讲清楚。你需要的不是“背命令”而是理解 Bash 在终端程序里是怎么工作的。2. 终端游戏的地基数据结构、渲染与按键读入任何游戏的第一步都是把“世界”变成数据。俄罗斯方块的世界很简单一块 10 行宽的纵向板子上面堆方块下面填满就消掉。但 Bash 没有一个像 C 语言那样真正的二维数组所以得用一个“变通”方案。2.1 游戏板我用字符串数组而不是真实二维数组我的做法是用一个一维数组数组的每个元素代表游戏板的一行每个元素是一个长度为 10 的字符串字符串中的每一个字符代表一个格子。字符是0表示空是非零数字表示这个方块的颜色编号。ROWS20 COLS10 empty_row0000000000 board() for ((i0; iROWS; i)); do board[$i]$empty_row done为什么要这么做因为 Bash 的一维数组本身已经很“贵”了真要去模拟二维比如用board[$row][$col]这种写法Bash 并不会自动创建多维结构你会被迫拼字符串键名写起来非常痛苦。而“一行字符串 字符串截取”的方式天然适合这类方格类游戏想读第 3 行第 5 列就取board[3]这个字符串的索引 5 位置。在对某一行的某个格子写入时用下面的替换手法# 把 board[$r] 里第 $c 个字符替换成 $ch board[$r]${board[$r]:0:$c}${ch}${board[$r]:$((c1))}这个操作我一开始老怀疑性能实际跑起来完全没问题因为 20 行 x 10 列的规模实在太小了。就算每秒刷新 10 次也远没到 Bash 的瓶颈。2.2 渲染用光标定位而不是反复 clear终端游戏的第二个基础问题怎么把数据画到屏幕上。很多新手会直接用clear每次清屏再画但你会发现终端会疯狂闪烁观感很差。更好的做法是用 ANSI 转义序列把光标移回左上角然后重新输出整帧画面。printf \033[H # 回到第一行第一列同时我还习惯隐藏光标让画面更干净printf \033[?25l # 隐藏光标 printf \033[?25h # 退出时显示光标渲染时我按行遍历 board遇到0就输出两个空格遇到数字就输出对应的 ANSI 背景色case $cell in 0) printf ;; *) printf \033[4%sm \033[0m $cell ;; esac这里\033[4Xm是设置背景色X是 1 到 7分别对应红、绿、黄、蓝、紫、青、白。方块颜色编号直接复用了这个数字所以既存状态又定颜色省了很多事。输出两个空格而不是一个字符是为了让格子看起来更接近正方形——终端里字符的高宽比大概是 2:1两个空格拼起来的宽度更自然。2.3 按键读入需要让终端进入 raw 模式默认终端下你按一个键会经过“行缓冲”也就是你得按一下回车程序才读得到输入。游戏显然不能这么干所以第一步是关掉回显和缓冲stty -icanon -echo-icanon表示关闭规范模式让按键立刻可以被读取-echo表示不要把按键显示到屏幕上。程序结束前记得恢复stty sane按键读取我用的是read -n1一次读一个字符配合-t实现“最多等多少秒”IFS read -r -t 0.2 -n1 key这段命令的意思是等 0.2 秒如果用户按了键就拿到一个字符如果没按read 返回非零状态key保持为空。这个特性是整个主循环能用“一帧一帧”方式跑起来的关键。3. 从移动一个格子到完整判定核心逻辑拆解数据结构准备好之后重点就来了。俄罗斯方块的规则不复杂但在 Bash 里实现时每一环都要想清楚否则 Bug 会一个接一个。3.1 形状的定义与旋转函数俄罗斯方块有七种标准形状我提前把所有形状的“4 x 4 矩阵”压成一个 16 字符的字符串。每 4 个字符代表一行0代表空非0代表有方块SHAPES( 0000111100000000 # I 0100111000000000 # T 1000111000000000 # J 0010111000000000 # L 0110110000000000 # S 1100011000000000 # Z 0110011000000000 # O )取新方块的时候随机挑一个字符串再用子串截取出 4 行sh( 0000 0000 0000 0000 ) s${SHAPES[$((RANDOM % 7))]} for ((i0; i4; i)); do sh[$i]${s:$((i*4)):4} done旋转函数是很多人头疼的地方。其实顺时针旋转 90 度的本质是先按列读一遍字符串再把每一行从右到左翻转一次。rotate() { local tmp( ${sh[]} ) local rotated( ) for ((i0; i4; i)); do for ((j0; j4; j)); do rotated[$j]${sh[$i]:$j:1} done done for ((i0; i4; i)); do sh[$i] for ((j0; j4; j)); do sh[$i]${rotated[$i]:$((3-j)):1} done done }简单解释一下第一步rotated[$j]...是在把第 i 行的第 j 列字符交给第 j 行相当于转置第二步从右往左拼等于水平翻转。转置加水平翻转正好就是顺时针旋转 90 度。这个思路在任意语言里都通用只是 Bash 写起来会觉得有点繁琐。3.2 碰撞检测要一次做全碰撞检测是所有“能不能移动 / 能不能落定”判断的总闸门。我写了一个can_place函数传入某个 4x4 方块的左上角坐标然后逐个检查四个格子是否超出边界、是否撞到已经堆在 board 里的方块。can_place() { local r$1 local c$2 for ((i0; i4; i)); do for ((j0; j4; j)); do ch${sh[$i]:$j:1} [[ $ch 0 ]] continue local br$((ri)) local bc$((cj)) if (( br 0 || br ROWS || bc 0 || bc COLS )); then return 1 fi if [[ ${board[$br]:$bc:1} ! 0 ]]; then return 1 fi done done return 0 }关键点是移动和旋转前都要先调用这个函数。能放就放不能放就什么都不做。lock_piece的时候也是一样先尝试往下放一格不能放了再真正写进 board。3.3 落定、消行与得分方块落定后要把它当前的位置“写死”到 board 数组里。写死不是直接把 4x4 矩阵覆盖上去而是要按每个非空格子把对应坐标的字符替换进去。我已经在 2.1 里写过单格替换方法这里循环调用即可。消行是整个游戏最让人舒爽的部分做法也不复杂从最下面一行往上扫如果某一行里找不到0字符就说明这一行全满了需要删除并且在数组头部补一个空行。remove_lines() { for ((iROWS-1; i0; i--)); do if [[ ${board[$i]} *0* ]]; then continue fi unset board[$i] board( $empty_row ${board[]} ) ((score 10)) i$((ROWS-1)) done }注意我在删除后把i重置到 ROWS-1因为头部插入了一行所有行的索引都变了必须重新从底部扫一遍否则可能会漏掉连续多行消除的情况。这里的unset和头部插入其实正好利用了 Bash 数组删除后会“压缩剩余元素再展开”的特性代码反而很简洁。4. 手感优化与终端安全细节光能跑起来远远不够。俄罗斯方块这个游戏对手感极其敏感下落节奏、按键响应、方块反馈都必须在线否则你玩起来会觉得“怎么这么难受”。4.1 主循环与下落节奏的取舍主循环我采用了一个很朴素的方案每次循环先画一帧然后read -t等待按键有按键就处理没按键就让方块自动下落一格然后进入下一帧。while :; do draw key drop0 if IFS read -r -t 0.2 -n1 key; then case $key in a) can_place $cur_row $((cur_col-1)) cur_col$((cur_col-1)) ;; d) can_place $cur_row $((cur_col1)) cur_col$((cur_col1)) ;; s) drop1 ;; w) rotate ;; ) while can_place $((cur_row1)) $cur_col; do ((cur_row)) done lock_piece continue ;; q|Q) restore_term; exit 0 ;; esac fi if (( drop )) || [[ -z $key ]]; then can_place $((cur_row1)) $cur_col ((cur_row)) || lock_piece fi done说明一下这个设计的取舍read -t 0.2让没按键时自动下落按s是软降立即下降一格空格是硬降直接落到最底a/d/w分别是左移、右移、旋转。有个小问题是当你连续按移动键时因为 read 立刻返回循环跑得很快方块会一直左右移动而暂时不下落。这是“单线程按键”模型带来的正常现象对于练手项目完全够用。如果你想要更标准的“移动不影响下落节奏”可以把下落计时改成基于date %s%N的绝对时间判断但复杂度会明显上去我建议先把当前版本跑通再优化。4.2 用颜色和双空格让方块更接近“方块”俄罗斯方块玩起来舒服不舒服很大程度取决于视觉效果。终端字符天然是长方形的一个普通字符的高是宽的两倍左右所以我每个格子输出两个空格整块板子的宽高比看起来更自然。颜色我用背景色填充也就是之前说的printf \033[4%sm \033[0m $cell这样实际效果就是一块带背景色的两个宽空格不同方块颜色不同堆叠起来层次感清晰。如果你用的是支持真彩色的终端可以再扩展成 24 位色但我觉得标准 16 色已经足够。4.3 退出时恢复终端的 trap 手法终端游戏最容易翻车的就是脚本被CtrlC中断后终端停留在无回显、无缓冲的诡异状态。你敲命令看不到字输密码也看不见非常吓人。解决办法是用trap在退出或中断时统一清理restore_term() { printf \033[?25h stty sane } trap restore_term INT TERM EXIT有了这行不管你是正常退出、按 q 退出还是被键盘中断终端都会恢复原状。这是所有纯终端程序都应该有的基础素养。5. 踩坑记录Bash 写游戏容易翻车的几个地方这个项目我写了不只一版中间踩了不少坑。有些坑你在网上搜教程很难看到我捡几个最重要的放在这里希望能帮你少走弯路。5.1 read -n1 的编码与方向键问题我在第一版里很想用方向键控制方块结果用read -n1读方向键时读到的根本不是“一个字母”而是一串转义序列的前缀\033。因为方向键发送的是ESC [ A这样的多字符序列而read -n1只读了一个字节后面的字符还残留在输入缓冲区里。如果你硬要处理方向键得用read -n3或者循环读取序列非常麻烦。后来我干脆放弃方向键使用a/d/w/s这套按键。对终端游戏来说字母键反而是最稳的跨终端方案不受终端类型影响。5.2 管道子 Shell 的变量隔离这是 Bash 新手最容易掉进去的坑printf ... | while read line这种写法管道右侧的 while 是在一个子 Shell 里执行的你在里面修改的任何变量循环结束后都带不出来。我早期写渲染循环时试过把绘制逻辑放到管道里结果分数一直显示 0。后来改成while循环在脚本主体里直接遍历数组不用管道传数据变量才能正常更新。记住一个原则在 Bash 里要修改外层变量就别用管道到 while尽量用函数、直接循环、或者${var$value}这类本进程语法。5.3 数组 unset 与重新插入时的索引变化消行时用unset board[$i]会让数组出现一个“空洞”而 Bash 数组展开时会把空洞跳过。这本身没问题但如果你再用${board[$i]}按索引访问就要小心索引是不是已经不连续了。比如你unset board[5]再取${board[6]}它还是原来的第 6 个元素但如果你用for i in ${!board[]}遍历索引就会看到 0、1、2、3、4、6、7……这种跳号。我消行后重新从底部扫描并且用“头部插入空行”的办法恢复成 0 到 19 的连续索引正是为了绕开这个问题。实际处理时记住对数组做删除操作后长度、索引都要重新评估。6. 完整源码与运行说明把前面所有逻辑拼在一起就是一个可以完整运行的脚本了。我这里的版本刻意保持“能跑、能玩、结构清晰”不做过度的花哨优化。你把下面的内容保存成tetris.sh然后bash tetris.sh就能玩。6.1 运行前的要求与按键说明运行环境只需要 Bash、stty、clear命令这在几乎所有 Linux 发行版上都默认安装。如果你在 Windows 上开了 WSL 或 Git Bash也大概率能跑但终端必须支持 ANSI 转义序列。按键对应关系如下按键功能a / d左移 / 右移s软降一格w顺时针旋转空格硬降到底q退出游戏6.2 完整脚本#!/usr/bin/env bash # tetris.sh —— 纯 Bash 终端俄罗斯方块 # 用法: bash tetris.sh ROWS20 COLS10 empty_row0000000000 score0 board() for ((i0; iROWS; i)); do board[$i]$empty_row done # 7 种方块每种用 16 字符表示 4x4 矩阵 SHAPES( 0000111100000000 # I 0100111000000000 # T 1000111000000000 # J 0010111000000000 # L 0110110000000000 # S 1100011000000000 # Z 0110011000000000 # O ) sh( 0000 0000 0000 0000 ) cur_row0 cur_col3 init_term() { stty -icanon -echo printf \033[?25l clear } restore_term() { printf \033[?25h stty sane } can_place() { local r$1 local c$2 local i j ch br bc for ((i0; i4; i)); do for ((j0; j4; j)); do ch${sh[$i]:$j:1} [[ $ch 0 ]] continue br$((ri)) bc$((cj)) if (( br 0 || br ROWS || bc 0 || bc COLS )); then return 1 fi if [[ ${board[$br]:$bc:1} ! 0 ]]; then return 1 fi done done return 0 } rotate() { local tmp( ${sh[]} ) local rotated( ) local i j for ((i0; i4; i)); do for ((j0; j4; j)); do rotated[$j]${sh[$i]:$j:1} done done for ((i0; i4; i)); do sh[$i] for ((j0; j4; j)); do sh[$i]${rotated[$i]:$((3-j)):1} done done if can_place $cur_row $cur_col; then return 0 fi # 简单墙踢旋转后先试着原地放不行就左右挪一格 if can_place $cur_row $((cur_col-1)); then cur_col$((cur_col-1)) return 0 fi if can_place $cur_row $((cur_col1)); then cur_col$((cur_col1)) return 0 fi sh( ${tmp[]} ) return 1 } remove_lines() { local i for ((iROWS-1; i0; i--)); do if [[ ${board[$i]} *0* ]]; then continue fi unset board[$i] board( $empty_row ${board[]} ) ((score 10)) i$((ROWS-1)) done } lock_piece() { local i j br bc ch for ((i0; i4; i)); do for ((j0; j4; j)); do ch${sh[$i]:$j:1} [[ $ch 0 ]] continue br$((cur_rowi)) bc$((cur_colj)) if (( br 0 br ROWS bc 0 bc COLS )); then board[$br]${board[$br]:0:$bc}${ch}${board[$br]:$((bc1))} fi done done remove_lines new_piece } new_piece() { local idx$((RANDOM % 7)) local s${SHAPES[$idx]} local i for ((i0; i4; i)); do sh[$i]${s:$((i*4)):4} done cur_row0 cur_col$(( (COLS-4)/2 )) if ! can_place $cur_row $cur_col; then printf Game Over, Score: %d\n $score exit 0 fi } draw() { printf \033[H printf Score: %-6d [a/d: move, s: soft drop, w: rotate, space: hard drop, q: quit]\n $score local r c ch orow ocol pch for ((r0; rROWS; r)); do printf | for ((c0; cCOLS; c)); do ch${board[$r]:$c:1} if (( r cur_row r cur_row4 c cur_col c cur_col4 )); then orow$((r-cur_row)) ocol$((c-cur_col)) pch${sh[$orow]:$ocol:1} [[ $pch ! 0 ]] ch$pch fi if [[ $ch 0 ]]; then printf else printf \033[4%sm \033[0m $ch fi done printf |\n done printf for ((c0; cCOLS; c)); do printf -- done printf \n } main() { init_term new_piece local key local drop while :; do draw key drop0 if IFS read -r -t 0.2 -n1 key; then case $key in a) can_place $cur_row $((cur_col-1)) cur_col$((cur_col-1)) ;; d) can_place $cur_row $((cur_col1)) cur_col$((cur_col1)) ;; s) drop1 ;; w) rotate ;; ) while can_place $((cur_row1)) $cur_col; do ((cur_row)) done lock_piece continue ;; q|Q) restore_term; exit 0 ;; esac fi if (( drop )) || [[ -z $key ]]; then if can_place $((cur_row1)) $cur_col; then ((cur_row)) else lock_piece fi fi done } trap restore_term INT TERM EXIT main6.3 还能往哪些方向继续扩展这个版本是我刻意保持的“最小可玩版”它已经具备完整游戏功能但离“手感优秀”还有距离。你完全可以继续加东西比如让下落速度随分数提高而加快、增加下一个方块的预览、实现真正的 7-Bag 随机策略、加入按旋转时的复杂墙踢规则、用更漂亮的 Unicode 方块字符替换两个空格。我记得自己在第一版跑通时看着方块稳稳落下、一行消掉、分数跳上去那种成就感完全不输给做复杂项目。这也正是用 Bash 折腾的意义越是不常规的挑战越能逼你想清楚底层逻辑。希望这份源码和踩坑记录也能让你在终端里找到属于自己的乐趣。