纯Bash写俄罗斯方块:终端里的Shell编程实战 这就是我为什么一直给身边朋友安利用纯 Bash 写俄罗斯方块的原因它表面上是“用一台 Linux 终端玩游戏”实际上是对 Shell 编程基本功的一次高强度体检。这里不涉及 Curses、不依赖 Python也不装任何游戏引擎唯一的原料就是你天天在敲的那个 Shell。只要机器上有 Bash终端一开脚本一跑俄罗斯方块就能在字符界面里转起来。这个项目适合谁参考如果你是刚把 Bash 语法过了一遍、想找点真正有挑战性的练手选题它比一百道 awk 练习题都更能夯实数组、循环、函数、输入处理这些基本功。如果你已经写了多年部署脚本再用它来查漏补缺也完全不亏——我写完之后才发现自己以前对read -n、终端转义序列、数组下标的理解其实都停留在“会用”的层面离“玩明白”差得远。下面我会把核心设计、关键实现、完整可拼装的源码思路、以及我踩过的坑全部摊开讲。1. 整体设计为什么用纯 Bash 做游戏1.1 零依赖带来的底气很多人会先入为主地觉得“Bash 根本不适合写游戏”。严格说这句话对一半Bash 确实不适合做高帧率、复杂碰撞的 3D 游戏但俄罗斯方块这种低频刷新、逻辑规则明确、画面网格化的游戏恰好落在 Bash 的能力范围内。更有意思的是这个项目最大的底气就是“零依赖”。你不需要ncurses不需要额外的运行时不需要编译。在任何一台预装 Bash 的 Linux 机器上把脚本丢进去就能跑。我甚至在一台精简得连桌面环境都没有的服务器上跑过照样能玩。这一点在实际工作中也很有意义当你在排查问题、只能在终端里操作时一个自包含的 Bash 脚本往往比“还得装个 Python 包”的方案省心得多。1.2 模块拆解与数据流写这种小游戏最容易犯的错是一上来就写主循环然后所有逻辑全堆在一坨代码里。我自己的习惯是先用笨办法把数据流梳理清楚输入按键 → 改变游戏状态 → 把游戏状态渲染成画面 → 输出到终端。由此自然分成四个模块输入层读取单个按键不回车直接响应。逻辑层处理移动、旋转、下落、碰撞、消行。数据层记录棋盘、当前方块、分数、等级。渲染层把棋盘和方块转成字符串输出到终端。每一层都不复杂但合起来就构成了一个完整的游戏循环。模块之间不要互相渗透例如渲染函数只管拼接字符串绝不改游戏状态按键处理函数只修改状态绝不直接输出画面。这样拆开之后后面想加功能、修 Bug 都非常好办。1.3 渲染方式选择ANSI 转义序列终端渲染通常有两条路一是用tput配合 terminfo 能力二是直接用 ANSI 转义序列。我最终选择了 ANSI 序列原因是tput依赖 terminfo 数据库在精简容器或某些裁剪过的发行版里可能缺失而 ANSI 控制码只要终端模拟器支持现在基本都支持就能稳定工作。这里涉及一组最基础的序列\033[H把光标移到左上角。\033[y;xH把光标定位到第 y 行、第 x 列。\033[2J清空屏幕。\033[?25l/\033[?25h隐藏 / 显示光标。\033[0m重置颜色和样式。Bash 里用printf %b 字符串就能输出这些转义序列。注意不要直接printf $str因为字符串里的%可能被当成格式符解析这是个很容易踩的细节。2. 核心细节解析与实操要点2.1 画面刷新光标复位与输出缓冲终端游戏的画面刷新方式跟 GUI 完全不同。我不用clear命令清屏因为每次清屏再重绘会造成明显的闪烁而且clear依赖外部命令。更好的做法是把光标复位到左上角再整幅覆盖输出。这种方法叫“覆盖式刷新”旧的画面不会主动消失新画面会直接盖上去。由于每次输出的内容长度一致都是固定的棋盘 分数行视觉上就很干净。还有一个提升流畅度的关键不要每画一个格子就调用一次printf。终端 I/O 的开销远比想象中高一个个打印字符会让脚本慢得没法玩。正确做法是先用字符串变量把整幅画面拼好最后一次性用printf %b输出。我第一次写时就是一个个echo结果下落一个格子都像幻灯片。2.2 棋盘存储一维数组模拟二维Bash 的数组是一维的但棋盘是一个 10 × 20 的矩形。很多人会尝试写board[$y][$x]这在 Bash 里并不是你以为的二维数组实际会把$y,$x当作奇怪的算术表达式结果完全不可控。我采用的方法是用一维数组并用下标y * cols x来定位。比如棋盘宽度是 10那么第 2 行第 3 列就是board[2 * 10 3]。值 0 表示空1 表示已经有方块。这种做法的好处很直接遍历整行时只需要从y * cols连续取cols个元素判断一个格子是否越界也只需要同时检查行列边界。相比真正的“二维模拟”代码反而更短也不容易出隐蔽错误。2.3 方块表示与旋转公式七种方块如果按 4 × 4 网格存成一串串字符串旋转时要重新映射代码会很冗长。我用的是坐标点方案每个方块就是四个坐标对每个坐标对表示相对于 4 × 4 盒子的行和列偏移。比如 I 形方块初始状态是横着的坐标就是(0,1) (1,1) (2,1) (3,1)。在 Bash 里可以把它写成字符串0,1 1,1 2,1 3,1读取时用IFS,拆开。旋转的核心是屏幕坐标系下的顺时针 90 度公式新列号 3 - 原行号 新行号 原列号用代码表示就是nx$(( 3 - y )) ny$(( x ))至于为什么是“3 - y”是因为我们的盒子是 4 × 4坐标范围是 0 到 3。旋转本质上是一个矩阵变换这个公式等价于把整张 4 × 4 格子顺时针转 90 度。理解这一点后以后如果想把盒子改成 5 × 5只需要把公式里的 3 换成 4。2.4 碰撞检测、消行与游戏结束判断碰撞检测的逻辑可以用一句话概括尝试把当前方块的每个坐标点移动到目标位置只要有一个点越界或者映射到棋盘上的格子已经被占用就不能移动。这里要特别注意检测和落子要分开。移动前先“试探”是否允许允许了才真正修改坐标而不是先把方块挪过去发现撞了再挪回来。后者在 Bash 里容易造成状态残留一旦某个分支没恢复现场游戏就会莫名卡死。消行是从下往上扫描每一行如果整行都是 1就把这一行上面的所有行整体下移一行再补一个新空行到顶部。注意消完一行后要继续检查同一行因为上面的行掉下来之后这一行的状态变了可能又凑成完整一行。游戏结束的判断就一条新方块生成时如果它的出生位置已经和其他方块重叠说明棋盘满了。3. 实战编码拼出一个能玩的脚本这里我直接给出一套核心版的实现思路。下面这些代码块按顺序保存到一个文件里比如tetris.sh再执行bash tetris.sh就能玩。操作键是a左移d右移s加速下落w旋转p暂停q退出。3.1 全局变量与方块库定义脚本开头是变量初始化和方块库。我特意没有用 Bash 4 才支持的关联数组而是用普通数组这样可以兼容更多环境。每个元素对应一个方块类型的四个坐标点。#!/usr/bin/env bash # tetris.sh 纯Bash俄罗斯方块核心版 rows20 cols10 score0 level1 game_over0 tick0 interval10 # 棋盘一维数组模拟二维下标 y * cols x board() # 方块库每个元素对应一种方块内容为四个坐标点 x,y x,y x,y x,y base_shapes( 0,1 1,1 2,1 3,1 # I 1,1 2,1 1,2 2,2 # O 1,0 0,1 1,1 2,1 # T 1,0 2,0 0,1 1,1 # S 0,0 1,0 1,1 2,1 # Z 0,0 0,1 1,1 2,1 # J 2,0 0,1 1,1 2,1 # L ) cur_kind0 cur_x0 cur_y0 cur_cells()初始化棋盘时把所有格子置 0init_board() { local idx for (( idx 0; idx rows * cols; idx )); do board[$idx]0 done }3.2 核心函数can_place、move、rotatecan_place是整个游戏正确性的基石。它接收横向增量dx和纵向增量dy在“不真正移动方块”的前提下判断如果移动到目标位置会不会碰撞。can_place() { local dx$1 dy$2 x y fx fy for cell in ${cur_cells[]}; do x${cell%,*} y${cell#*,} fx$(( cur_x x dx )) fy$(( cur_y y dy )) if (( fx 0 || fx cols || fy 0 || fy rows )); then return 1 fi if (( board[fy * cols fx] 1 )); then return 1 fi done return 0 }注意这里用${cell%,*}取行号、${cell#*,}取列号是 Bash 字符串截取的经典用法避免再调用cut等外部命令。移动函数就比较简单了先试探能走再改坐标。move_left() { if can_place -1 0; then (( cur_x-- )) fi } move_right() { if can_place 1 0; then (( cur_x )) fi } move_down() { if can_place 0 1; then (( cur_y )) else place_block clear_full_rows spawn_block fi }旋转函数要先备份当前形状旋转后试探是否碰撞碰撞了就恢复。这个“先备份再回滚”的思路在处理任何带状态的逻辑时都值得复用。rotate() { local backup(${cur_cells[]}) local new_cells() x y nx ny for cell in ${cur_cells[]}; do x${cell%,*} y${cell#*,} nx$(( 3 - y )) ny$(( x )) new_cells($nx,$ny) done cur_cells(${new_cells[]}) if ! can_place 0 0; then cur_cells(${backup[]}) fi }把方块固定到棋盘上是俄罗斯方块的“落定”操作place_block() { local x y fx fy for cell in ${cur_cells[]}; do x${cell%,*} y${cell#*,} fx$(( cur_x x )) fy$(( cur_y y )) board[fy * cols fx]1 done }消行的实现用 while 循环从下往上扫消完一行后不递减行号因为上面的行刚下移下来必须重新检查这一行。clear_full_rows() { local y x full y$(( rows - 1 )) while (( y 0 )); do full1 for (( x 0; x cols; x )); do if (( board[y * cols x] 0 )); then full0 break fi done if (( full )); then score$(( score level * 100 )) # 将上面的所有行整体下移一行 for (( yy y; yy 0; yy-- )); do for (( x 0; x cols; x )); do board[yy * cols x]${board[(yy - 1) * cols x]} done done # 顶行补空 for (( x 0; x cols; x )); do board[x]0 done else (( y-- )) fi done }3.3 渲染函数与主循环渲染函数最关键的一点是先把整幅画面拼接成一个字符串再一次输出。我用printf %b而不是直接printf $var因为画面字符串里包含 ANSI 转义序列和%号%b能让转义序列正确生效。draw_board() { local out out\033[?25l\033[H # 隐藏光标光标回左上角 outScore: $score Level: $level\n # 顶部边框 out for (( x 0; x cols; x )); do out-- done out\n for (( y 0; y rows; y )); do out| for (( x 0; x cols; x )); do if (( board[y * cols x] 1 )); then out[] else out fi done out|\n done # 底部边框 out for (( x 0; x cols; x )); do out-- done out\n # 当前操作方块也画进画面 for cell in ${cur_cells[]}; do local x${cell%,*} y${cell#*,} local fx$(( cur_x x )) fy$(( cur_y y )) if (( fx 0 fx cols fy 0 fy rows )); then board[fy * cols fx]2 out${out:0:${#out}-2} # 粗暴方式不建议直接这样改这里仅示意 fi done out\033[?25h printf %b $out }坦白说上面把当前方块也画进画面那部分我写得有点别扭直接修改board里的值 2 再重画会更可靠但要注意画完后把值 2 还原成 0否则会影响碰撞检测。实际我推荐的写法是在输出循环里判断当前坐标是否属于cur_cells而不是改棋盘。下面这个版本更清晰is_cur_cell() { local px$1 py$2 x y fx fy for cell in ${cur_cells[]}; do x${cell%,*} y${cell#*,} fx$(( cur_x x )) fy$(( cur_y y )) if (( px fx py fy )); then return 0 fi done return 1 } draw_board() { local out y x out\033[?25l\033[H outScore: $score Level: $level\n out for (( x 0; x cols; x )); do out-- done out\n for (( y 0; y rows; y )); do out| for (( x 0; x cols; x )); do if (( board[y * cols x] 1 )); then out[] elif is_cur_cell $x $y; then out## else out fi done out|\n done out for (( x 0; x cols; x )); do out-- done out\n out\033[?25h printf %b $out }这里#表示活动方块[]表示已落定方块空格表示空位。虽然不华丽但逻辑清楚也不会污染棋盘数据。生成新方块的函数如下。RANDOM是 Bash 内建的随机数变量不需要外部依赖。spawn_block() { cur_kind$(( RANDOM % 7 )) cur_cells() local cell x y for cell in ${base_shapes[$cur_kind]}; do x${cell%,*} y${cell#*,} cur_cells($x,$y) done cur_x3 cur_y0 if ! can_place 0 0; then game_over1 fi }主循环是纯 Bash 游戏的心脏。我不使用sleep来控制下落节奏因为sleep会阻塞整个循环导致按键响应迟钝。取而代之的是一个计数器每轮循环让tick自增当它达到interval时执行一次自动下落。main_loop() { local key while (( game_over 0 )); do draw_board # 非阻塞读取按键-t 表示超时时间秒 if read -t 0.05 -n 1 key; then case $key in a|A) move_left ;; d|D) move_right ;; s|S) move_down ;; w|W) rotate ;; p|P) pause_game ;; q|Q) game_over1 ;; esac fi (( tick )) if (( tick interval )); then tick0 move_down fi done }暂停函数是一个内层循环等待玩家再次按ppause_game() { local k while true; do if read -t 0.2 -n 1 k; then if [[ $k p || $k P ]]; then return fi fi done }入口则负责初始化、启动主循环、退出时恢复终端cleanup() { printf %b \033[?25h\033[0m\n } trap cleanup EXIT INT init_board spawn_block main_loop echo Game Over, score: $score3.4 组装运行与操作说明把上面这些函数按顺序放进同一个脚本文件里再在最后加上入口调用就能直接运行。如果你是第一次玩可以先把interval调大一点比如 14让方块下落得慢一些熟悉操作后再慢慢调小。运行方式bash tetris.sh如果你希望直接执行要加可执行权限chmod x tetris.sh ./tetris.sh注意脚本内部没有用到clear命令所以即使在没有额外工具的精简环境里也能运行。它只依赖 Bash 内建能力唯一可能要留意的是终端类型。4. 常见问题与排查技巧实录4.1 read 按键阻塞导致卡死最容易遇到的问题是脚本跑起来后完全不动按什么键都没反应。这多半是因为read在等待输入时把整个循环阻塞了。解决办法就是给read加上超时时间比如read -t 0.05 -n 1 key。这样每轮循环最多等 0.05 秒没按键就继续执行逻辑。另一个相关问题是方向键。终端方向键并不是单字节而是ESC [ A这样的转义序列。read -n 1每次只能读到一个字节所以如果你写的是方向键控制就只会读到ESC然后逻辑完全错乱。我对这个项目的建议是用字母键w a s d代替方向键省心又高效。如果非要用方向键需要先读到ESC后再连续读后面两个字节并处理超时复杂度会上一个台阶。还有一个细节read -t 0.05的小数超时在绝大多数 Linux Bash 上都支持但在某些很老的版本或特殊环境下可能表现异常。如果你发现超时不生效可以把超时改成整数1然后用计数器的方式减少轮询频率效果类似。4.2 画面闪烁与 printf 格式问题画面闪烁基本是渲染方式不对。记住两个原则不要用clear清屏。覆盖式刷新比清屏重绘更平滑。不要每个格子一次输出。整幅画面拼好后再一次性输出。printf的坑则体现在格式符上。我一开始写的是printf $out然后发现带%的画面会随机输出怪异内容甚至报错。原因就是$out里的%被当成了格式符。改为printf %b $out后一切正常%b会正确解释反斜杠转义序列。4.3 终端残留退出后光标消失游戏运行中要隐藏光标否则光标会在画面上闪来闪去。但如果退出时忘记恢复光标终端就会一直处于“光标看不到”的状态对新手来说简直是灾难。解决办法是使用traptrap cleanup EXIT INTcleanup里恢复光标显示并重置颜色cleanup() { printf %b \033[?25h\033[0m\n }trap EXIT会在脚本正常结束时执行trap INT则能处理 CtrlC 中断的场景。两个都挂上能保证绝大多数退出路径都会清理终端。4.4 兼容性老版本 Bash 的差异我在一台只装了 Bash 3.2 的机器上跑过这个脚本整体没问题但前提是避开几个新特性。比如local -n别名引用是 Bash 4.3 才支持的关联数组declare -A是 Bash 4 才有的。为了让脚本在更多环境直接跑我刻意避开了这些功能。如果你在写自己的版本时尽量保持“普通数组 局部变量 字符串截取”这套组合兼容性会非常好。等确认目标环境没问题再考虑用关联数组或新语法优化也不迟。5. 扩展思路让这个游戏更像样5.1 彩色渲染与方块阴影基础的[]和##虽然能玩但不够好看。要给方块上色可以用 ANSI 背景色序列# 46 是青色背景0m 是重置 out\033[46m \033[0m不同方块类型使用不同颜色需要在draw_board里根据方块类型动态选择颜色码。这里有个小技巧把颜色码也做成数组用方块类型下标索引比如colors( 41 42 43 44 45 46 47 )渲染时\033[${colors[$cur_kind]}m就能让每个方块有独立颜色。另外可以加一个“阴影落点”也就是当前方块下落到最低位置时的预览位置。计算方式是从当前高度开始不断尝试can_place 0 1直到碰撞为止再把落点位置用半亮字符画出来。5.2 下一个方块预览与难度加速预览下一个方块是俄罗斯方块的标配功能实现也不复杂。维护一个next_kind变量spawn_block时把cur_kind替换成next_kind再重新随机一个next_kind。渲染时在旁边单独画一个 4 × 4 的小区域来显示它。难度加速可以用消除行数驱动。每消 10 行让level加 1再把interval减小。比如level$(( score / 1000 1 )) interval$(( 10 - level 1 )) if (( interval 3 )); then interval3 fi这个逻辑放在clear_full_rows末尾即可。5.3 最高分存档与更多玩法最高分存档也很简单只要在启动时读取一个文件游戏结束时写入highscore_file/tmp/bash_tetris_highscore.txt highscore0 if [[ -f $highscore_file ]]; then highscore$(cat $highscore_file) fi # 结束时 if (( score highscore )); then echo $score $highscore_file fi存储位置建议放在临时目录或用户目录不要写到脚本所在目录避免把源码目录搞得乱七八糟。更多玩法方面可以试试双人对战各管半屏、共用一套主循环、每消一个方块播放终端响铃\a、或者塞进一个“回放”机制——把整个游戏过程的按键序列记录下来结束后重新播放。这些扩展能让你把 Bash 的字符串处理和逻辑控制练到更强的水平。我实际写这个项目的过程中最大的收获反而不是“做出来一个游戏”而是明白了 Bash 脚本的运行模型跟普通程序很不一样没有事件循环、没有图形库、一切输入输出都是字节流。你必须顺着它的脾气设计程序而不是硬掰着它去模仿其他语言的套路。也只有在这种限制下写点“不该用 Shell 做的东西”才能真正体会到 Shell 的边界在哪里。如果你也想找一个能把 Linux 终端玩出花来的练手项目这个俄罗斯方块是很好的选择。