CMD 里用批处理写一个俄罗斯方块,这事听起来像段子,做起来却是实打实的硬活。命令行窗口没有图形接口,没有键盘事件回调,没有帧同步,连个能画矩形的地方都没有,你手上只有 echo、set 和 for 这几样东西。我第一次动这个念头是在一个只能开命令行的环境里,闲得发慌,就想验证一下:纯 .bat 文件,不加任何第三方程序、不调任何外部库,能不能跑出一个能玩、能消行、能计分的俄罗斯方块。结论是能,但过程比想象中粗糙,也比想象中有意思。这篇文章就是我踩完坑之后整理出来的完整思路:从字符画渲染、键盘轮询、方块旋转表,到碰撞判定和消行重排,每一块都给出可落地的写法、参数依据和避坑经验。适合已经会写基本批处理、想摸清 CMD 性能边界的人,也适合完全没写过游戏、想从一个极简案例入门的人——它把游戏循环、状态管理、渲染刷新这些通用概念压缩到了几百行脚本里,反而看得更清楚。
1. 选题拆解:批处理写游戏到底图什么
1.1 一个命令行窗口能提供的全部“图形能力”
先把幻觉打掉。命令提示符不是一个画布,它是一张字符网格。屏幕上每一个位置放的不是像素,而是一个字符单元;你能控制的只有“在哪个格子放哪个字符”和“用什么前景色背景色”。默认点阵字体下,一个半角字符约占 8×16 像素,一个全角字符占 16×16 像素,也就是说一个全角字符的宽高比大概是 1:1,一个半角字符是 1:2。这个比例关系是后面所有渲染决策的起点。
基于这一点,能用的图形手段其实只有三种。第一种是字符填充,用█、■、#、[]这类字符拼出形状,简单但受限于字体是否包含该字形。第二种是颜色反显,把背景色改成和前景色一样,用一个空格刷出一块有色矩形,视觉上更整齐,但需要 ANSI 转义序列支持,兼容性要单独测。第三种是光标定位,用ESC[H把光标打回左上角,实现原地覆盖重绘,这是避免闪烁的关键。
我用的是第一种加第三种:█画方块,ANSI 定位做刷新。理由很直接——█在简体中文代码页(936)下是标准字形,几乎所有默认字体都有,不用赌运气;而 ANSI 定位能让重绘不闪烁,代价只是要判断当前系统的 CMD 是否支持 VT 序列。这个取舍在后面第 5 节还会展开讲。
1.2 三条硬约束决定了整个架构
批处理做游戏,绕不开三个物理限制,这三条基本上把架构锁死了,任何“优雅设计”都得先向它们低头。
第一条,输入是阻塞的。set /p会一直挂着等你敲回车,pause干脆停住不动。整个批处理生态里,唯一能“等一段时间,要么拿到输入要么超时继续”的命令只有一个:choice。它带/t参数可以设定超时秒数,超时后返回/d指定的默认选项。这意味着主循环必须长成choice → 处理 → 渲染 → choice这个形状,没有第二种选择。
第二条,没有独立定时器。批处理不能起线程,不能注册回调,也没有sleep之外的异步机制。时间的唯一来源就是choice的超时。所以你的“帧率”不是设计出来的,是被choice /t的最小粒度绑死的——/t最小是 1 秒,没有小数。这条约束后面会引出很多麻烦,但也能被巧妙利用。
第三条,每次输出都有真实成本。echo不是往显存写,是往控制台缓冲区写,每次调用都有进程内的解析和写入开销。一帧里 echo 两百次和 echo 二十次,体感差异非常明显。所以渲染必须批量:先把一整行拼成一个变量,再一次性吐出去,而不是一个格子一个格子地 echo。
这三条合起来,等于告诉你:主循环 1 秒一拍,输入靠 choice,渲染必须攒批。认下这三点,后面的设计就顺了。
1.3 这个项目适合谁、能做到什么程度
先说清楚天花板,免得期待落空。用choice /t 1做时间源,下落速度的物理上限就是 1 格每秒(不按键的情况下)。想在高级别做出“方块刷刷往下掉”的效果,只能靠一次 tick 下落多格来模拟,这是后话。另外它做不了平滑动画、做不了粒子效果、做不了逐帧插值,任何需要 60fps 的东西都别想。
那它还有什么价值?三点。第一,它是理解“游戏循环”最干净的样本——没有引擎帮你隐藏状态,棋盘、当前方块、分数、等级全是你自己用变量摆出来的,看得见摸得着。第二,它是练习批处理功力的极限场景,变量延迟扩展、字符串切片、嵌套 for、errorlevel 判断这些平时用不上的东西,在这里全得用上。第三,它是真能跑的,你写完发给朋友,对方双击就能玩,不用装任何东西。
适合的人群也很明确:写过一点 bat、知道setlocal enabledelayedexpansion是干嘛的,想找个项目把批处理吃透的人;或者完全没碰过游戏开发、想从零理解“一个游戏到底由哪几块组成”的人。如果你只是想找个能玩的俄罗斯方块,那用现成软件就行,这篇不适合你。
2. 整体架构:把俄罗斯方块切成四个独立可调的模块
2.1 主循环与时间源:choice 的 /t 参数怎么当时钟用
主循环的骨架长这样:
:mainloop call :render choice /c WASD /n /t 1 /d S >nul if errorlevel 4 (set "act=D" & goto :apply) if errorlevel 3 (set "act=S" & goto :apply) if errorlevel 2 (set "act=A" & goto :apply) if errorlevel 1 (set "act=W" & goto :apply) :apply rem 根据 act 处理移动、旋转、下落 goto :mainloop几个细节必须说清楚。/c WASD定义了四个合法键,返回值的编号按书写顺序从 1 开始,也就是 W=1、A=2、S=3、D=4。/n关掉选项列表的输出,不然屏幕上会多一行[W,A,S,D]?。/t 1是超时 1 秒,/d S指定超时后默认返回 S 对应的编号 3,这个默认值正好被我们当成“重力”——一秒不按键,方块自动下落一格,逻辑自洽。
errorlevel的判断顺序绝对不能反。if errorlevel N的语义是“返回值大于等于 N”,所以必须从大到小判断,先判 4 再判 3 再判 2 再判 1。你要是从 1 开始判,那按 D 返回 4,第一个条件errorlevel 1就成立了,程序会以为你按了 W。这个坑我踩过,症状是“按右键方块往左跑”,排查了半天才发现是判断顺序写反了。
还有一个隐藏问题:choice不是所有系统版本都有。部分精简安装的系统里缺这个可执行文件。稳妥做法是在开头加一句检测:
where choice >nul 2>nul || (echo 缺少 choice 命令,无法运行 & pause & exit /b)这套主循环的实际节奏是这样的:玩家按键时choice立即返回,循环飞快转一圈;玩家不按键时,1 秒后超时返回,循环慢下来。也就是说游戏的“心跳”是不均匀的,按键时快、闲置时慢。听起来很糟,但实际玩起来反而有种“按一下动一下”的节奏感,俄罗斯方块本来也不是需要高频操作的品类。真正被这条限制住的是下落速度,2.4 节会专门讲怎么绕。
2.2 渲染层:先拼后吐,一帧只 echo 二十次
渲染的性能红线是 echo 的次数,不是字符的总量。一帧 200 个格子,如果逐格 echo,就是 200 次系统调用,屏幕会明显卡顿;如果先拼成 20 行、每行 echo 一次,总字符数不变但调用次数降到 20,流畅度立刻上一个台阶。
具体写法是这样:外层for /l遍历 20 行,内层for /l遍历 10 列,内层把每个格子翻译成一个显示字符并追加到行变量里,内层结束后 echo 这一整行。这里有个必须注意的点——内层循环里对行变量做追加,一定要用延迟扩展!line!而不是%line%,否则你在同一个括号块里拿到的永远是循环开始前的旧值。
格子翻译的规则是:棋盘数据里1表示已被固定方块占据,0表示空;再叠加当前活动方块的四个格子;最后1→█,0→ 两个半角空格。为什么要两个空格而不是一个?因为█是全角字符,占两格宽,如果空格只输出一个半角空格,整行会越走越窄,方块和背景错位。这个对齐问题在第 5 节还会详细讲。
还有一个小技巧:输出空行用echo(而不是echo.。后者在有同名文件存在时行为诡异,而且echo.在某些情况下会多输出一个点或者干脆报错,echo(是最稳的写法。拼行的时候如果某一行拼出来是空字符串,echo %line%会输出“ECHO 处于打开状态”,用echo(!line!就能避免。
2.3 数据层:棋盘用字符串,方块用坐标
棋盘表示我选了最土但最好用的方案:20 个变量b1到b20,每个变量是一个 10 位字符串,比如b20=0000110000表示最底行第 5、6 列被占据。为什么不用二维数组?因为批处理根本没有数组,所谓“数组”都是靠变量名拼出来的伪数组,既然如此,一维字符串反而更方便做整体操作——判断是否满行只需比较字符串是否等于1111111111,消行只需变量之间互相赋值,比按位修改快得多。
当前活动方块用 4 组坐标存:px1 py1到px4 py4,表示四个小方格在棋盘上的绝对位置。用绝对坐标而不是“方块中心 + 形状偏移”,是为了碰撞检测时直接拿坐标去比对,少一层换算。代价是旋转的时候要重新算四个坐标,但这个计算量很小,一次旋转四次赋值,忽略不计。
方块类型和旋转态用一张查找表存。七种方块,每种四个旋转态,一共 28 条记录。每条记录存四个相对坐标,格式是x,y x,y x,y x,y,用空格分隔。加载方块时用for遍历这串坐标,配for /f "tokens=1,2 delims=,"拆出 x 和 y,加上方块原点的偏移,写进px1..px4和py1..py4。这个方案的好处是扩展极其方便——想加一种新方块,照着格式加四条记录就行,不用动任何逻辑代码。
2.4 输入层:为什么只留四个键
俄罗斯方块的完整操作是左、右、旋转、软降、硬降,五个键。我只留了四个,把硬降砍掉了。原因是choice的/c参数一次最多接受有限个选项,虽然理论上能放 26 个字母,但几个键挤在一起,误触率会上升,而且五个键里硬降的使用频率其实不高。真要加,加个/c WASDQ就行,Q 对应 5,判断的时候多一层if errorlevel 5,成本很低,随时可以补。
更关键的问题是“下落速度”这件事。前面说过,choice /t 1决定了每秒最多一次 tick,也就是说最高速度是 1 格每秒。这个速度在 1 级的时候刚好(从顶到底 20 格,20 秒,够想),但到了 10 级还这么慢,游戏就没难度了。
解法不是改 tick 频率,而是改“每 tick 落几格”。等级每升一级,每 tick 下落格数加一,1 级落 1 格,5 级落 5 格。但这里不能直接让方块的 y 坐标加 5,因为中间可能会撞到东西,得逐格往下试:写一个 while 循环,一次落一格,撞了就停,计数到了就退出。这样既实现了“视觉上加速下落”,又不会穿模。
set /a fall=1 if %level% gtr 5 set /a fall=2 if %level% gtr 10 set /a fall=3 set /a n=0 :dropdown set /a n+=1 call :try_move 0 1 if errorlevel 1 goto :lock if %n% lss %fall% goto :dropdown这块逻辑是整个游戏里最容易写错的地方,因为它同时牵扯到移动、碰撞、锁定三个阶段,顺序错了就会出现“方块陷进地面”或者“方块悬空不动”的怪现象。
3. 核心细节:方块表、碰撞判定与消行算法
3.1 七种方块二十八个旋转态的编码方式
七种标准方块的形状大家都熟,但真正要写进代码,必须把每个旋转态拆成具体的格子坐标。以 I 型(长条)为例,横放的时候四个格子是(0,0) (1,0) (2,0) (3,0),竖放是(0,0) (0,1) (0,2) (0,3)。这里我用的是相对坐标,原点在方块的左上角。
查找表长这样:
set "S_I_0=0,0 1,0 2,0 3,0" set "S_I_1=0,0 0,1 0,2 0,3" set "S_I_2=0,0 1,0 2,0 3,0" set "S_I_3=0,0 0,1 0,2 0,3" set "S_O_0=0,0 1,0 0,1 1,1" set "S_T_0=0,0 1,0 2,0 1,1" set "S_T_1=1,0 1,1 1,2 0,1" rem 其余略,格式一致有几点值得说明。第一,I 型的横竖两个状态各重复了一次,是为了让四种旋转态数量统一,旋转逻辑不用做特殊判断,省掉一堆 if。第二,所有坐标都是非负的,这很重要——如果某个旋转态出现负数坐标,旋转后可能直接跑到棋盘外,得额外做边界钳制;把所有形状的原点选在左上角,能保证坐标恒非负。第三,S 型和 Z 型的四个旋转态其实是两两重复的(因为形状本身有 180 度对称),但为了代码统一,还是把四个都写上。
加载的时候:
set /a rnd=%random% %% 7 set "names=I O T S Z J L" for /f "tokens=%rnd%" %%n in ("%names%") do set "cur=%%n" for %%c in (!S_%cur%_0!) do ( for /f "tokens=1,2 delims=," %%x in ("%%c") do ( set /a idx+=1 set /a px!idx!=%%x+4 set /a py!idx!=%%y+1 ) )%random% %% 7取模是批处理里最常用的随机方案,%%在脚本里要写成双百分号,这是批处理的转义规则,写在命令行里就只写一个。for /f "tokens=%rnd%"从名字串里取第 n 个词,注意tokens是从 1 开始计数的,而%random% %% 7的结果是 0 到 6,所以这里其实少了一个 1,实际取的是第 0 个——这行是错的,正确写法是set /a rnd=%random% %% 7 + 1。这种“差一错误”在写这套东西的时候会反复出现,因为批处理的很多参数是从 1 开始,而取模是从 0 开始,脑子里必须一直绷着这根弦。
3.2 碰撞检测:一次判定要做的三次比较
碰撞检测是每一帧都要跑的核心函数,写得慢整个游戏就卡。它的逻辑其实很简单:给定一组目标坐标,判断这四个格子是否合法。合法的条件是同时满足三条——x 在 0 到 9 之间、y 在 0 到 19 之间、目标格子在棋盘数据里是0。
第三条是最容易写错的,因为从字符串里取单个字符这个操作,写法很绕。假设要判断第ty行第tx列,正确写法是三行:
set "row=!b%ty%!" set /a pos=tx set "cell=!row:~%pos%,1!"不能写成set "cell=!b%ty%:~%tx%,1!",批处理不支持这种嵌套的延迟扩展,会直接把整串当变量名去找,找不到就返回空,然后所有判定都变成“撞了”,方块一动就定住。这个坑非常隐蔽,因为语法上看起来完全合理,报错也不报,就是静默失效。
另外注意%ty%和%tx%在这里的展开时机。如果这段代码在括号块内部,%ty%会在块开始执行前就被替换成当时的值,可能不是你想要的。稳妥做法是在块内用for %%v in (!ty!) do中转一次,或者干脆把碰撞判定单独写成一个标签,用call调用,逃避变量展开的作用域问题。
写成子程序还有个好处,方便复用于不同类型的移动。检查左右移动时传dx=-1或dx=1,检查下落时传dy=1,检查旋转时传入旋转后的四个新坐标。同一套判定逻辑复用三次,代码量能砍掉一大半。
3.3 消行与重排:从下往上扫描的字符串操作
消行是这个项目里最有意思的一段算法,因为它完全依赖字符串操作的特性,用数组反而不方便。
扫描顺序必须从下往上。如果从上往下扫,消掉第 10 行之后下面的行整体下移,原来第 11 行变成第 10 行,但你的循环已经走到 11 了,等于跳过了新来的第 10 行,结果就是消不干净。从第 20 行往上走到第 1 行,每次处理完当前行,上面那部分数据没变,不会受影响。
判断满行的写法极简:
for /l %%r in (20,-1,1) do ( if "!b%%r!"=="1111111111" ( set /a lines+=1 for /l %%k in (%%r,1,20) do ( set /a up=%%k+1 for %%u in (!up!) do set "b%%k=!b%%u!" ) set "b20=" ) )for /l支持负数步长,(20,-1,1)就是从 20 递减到 1。内层把第 r+1 行到第 20 行的内容整体上移一行,然后把最后一行清空。注意这里用了一个小技巧:for %%u in (!up!),因为!b%%u!这种直接嵌套不合法,用一个临时的 for 变量把数值“洗”一遍,%%u会被替换成实际数字,再拿去做变量名拼接就合法了。
这里还有一个隐蔽的坑:内层for /l %%k的循环变量不能和外面的%%r重名,否则内层会把外层的值覆盖掉。批处理不像现代语言有块级作用域,所有 for 变量在同一个作用域里,重名就是灾难。
消完之后分数怎么算?标准规则是:一次消 1 行 100 分,2 行 300 分,3 行 500 分,4 行 800 分。写成公式就是“行数平方 × 100 + 额外奖励”,或者干脆用一张查找表:
if %lines%==1 set /a score+=100*%level% if %lines%==2 set /a score+=300*%level% if %lines%==3 set /a score+=500*%level% if %lines%==4 set /a score+=800*%level%乘上等级是经典设计——等级越高,同样消四行的收益越大,鼓励你在高等级阶段搏一把。这个系数不乘也行,全凭个人口味,但它对节奏的影响其实挺明显的。
3.4 计分与速度:等级提升到底改了哪个参数
等级的计算方式最常见的是“每消 10 行升一级”:set /a level = lines / 10 + 1。这个设计的好处是节奏可控,玩家大概每两分钟能升一级,二十级封顶的话一局能玩四十分钟左右,太长了,实际做的时候我把上限设在了 10 级,大概十五分钟一局。
等级影响的东西有两个:下落速度和计分倍率。速度部分前面讲过,靠“每 tick 落几格”来实现,1 到 5 级落 1 格,6 到 10 级落 2 格、3 格。这个映射用两个 if 就够,不需要更复杂的公式。
有个细节容易被忽略:等级提升的那一刻,如果当前方块正在下落过程中,速度变化会导致它突然多掉几格。看起来像 bug,其实符合预期——很多商业俄罗斯方块也是这么做的,玩家甚至会利用这个特性做战术。但如果你的实现里下落是“一口气改 y 坐标”,那就可能穿模,所以必须逐格试落,这就是 2.4 节那个 while 循环的意义。
分数显示的位置也有讲究。我在右侧画了一个固定宽度的侧栏,显示分数、等级、已消行数。批处理没有“覆盖部分区域”的便捷方法,只能整屏重绘,所以侧栏的绘制要和主棋盘合并在同一次渲染里,不能分开 echo,否则会看到两次闪烁。侧栏宽度设了 14 个半角字符,加上棋盘的 20 个半角宽,总宽 34,再加两边各 2 格边框,控制在 40 列以内。这个数字不是随便定的,是为了适配mode con cols=40这个常见的小窗口尺寸,超过 40 列在低分辨率屏上会出现横向滚动条,那个体验非常糟。
4. 动手实现:从空文件到能玩的版本
4.1 环境准备与编码设定
开头几行是整个脚本的地基,写错了后面全是玄学问题:
@echo off setlocal enabledelayedexpansion chcp 936 >nul mode con cols=40 lines=28 color 0A title BAT TETRISsetlocal enabledelayedexpansion必须在任何使用!变量!的代码之前,放在后面是不生效的。chcp 936切到简体中文代码页,这是让█正常显示的前提,如果不切或者切成 65001(UTF-8),在很多系统上█会变成乱码或者半个字。mode con固定窗口尺寸,这个很关键——如果不固定,玩家手动把窗口拉大,你的字符画就会歪掉,因为字符网格的宽度变了,原本对齐的列会错位。color 0A是黑底亮绿,纯属个人偏好,改0F是黑底白,改1E是蓝底黄字,随意。
文件保存的编码也要注意。用记事本写的话,另存为的时候编码选“ANSI”,不要选 UTF-8。用 VS Code 之类的编辑器,右下角编码切到 GB2312 或 GBK。如果你用的是别的代码页(比如繁体系统是 950),把所有 936 换成对应数字就行。
还有一个前置检查别忘了:确认系统里有choice。前面提过检测方法,这里补一句——如果确实没有,可以退而用set /p加timeout的组合,但那个方案不能捕获单键,必须敲回车,手感会差很多,属于最后的备选。
4.2 渲染模块的完整写法
渲染是整个脚本里调用最频繁的部分,写法直接决定游戏能不能玩。核心结构是“外层走行、内层走列、行尾一次性输出”:
:render cls echo( +--------------------+ for /l %%r in (1,1,20) do ( set "line= |" set "row=!b%%r!" for /l %%c in (0,1,9) do ( set "ch=!row:~%%c,1!" for %%i in (1 2 3 4) do ( if !py%%i!==%%r if !px%%i!==%%c set "ch=1" ) if "!ch!"=="1" (set "line=!line!█") else (set "line=!line! ") ) set "line=!line!| !score!" echo(!line! ) echo( +--------------------+这段代码里有两处值得琢磨。第一处是!py%%i!这个写法,%%i是 for 变量,在语句解析阶段就被替换成 1、2、3、4,所以实际展开是!py1!、!py2!,这是合法的。但如果写成!py!%%i!!就废了,永远别这么写。第二处是右侧的状态栏,我图省事直接拼在行尾,实际做的时候会做得更规整,用固定宽度的侧栏覆盖,但原理一样——所有内容必须在同一次 echo 里出去。
关于要不要cls,这是个需要实测的选择。cls会让整个窗口瞬间变黑再重绘,在 1 秒一帧的节奏下,肉眼能看到轻微闪烁。用 ANSI 的ESC[H定位到左上角覆盖,闪烁会小很多,但会有另一个问题——上一帧比这一帧内容多出来的部分不会被清掉,留下残影。稳妥方案是ESC[H加ESC[J(清除光标到屏幕末尾),两个转义序列连用,效果等价于 cls 但没有黑屏过渡。ANSI 序列在批处理里要先生成 ESC 字符:
for /f %%a in ('echo prompt $E^| cmd') do set "ESC=%%a"这行利用了prompt $E会输出 ESC 字符这个冷门特性,是批处理里生成控制字符的标准做法。
4.3 主循环与按键分发的拼接
把前面几块拼起来,主循环大概是这个形状:
:loop call :render choice /c WASD /n /t 1 /d S >nul set "act=" if errorlevel 4 set "act=D" if errorlevel 3 if not defined act set "act=S" if errorlevel 2 if not defined act set "act=A" if errorlevel 1 if not defined act set "act=W" if "%act%"=="A" call :try_move -1 0 if "%act%"=="D" call :try_move 1 0 if "%act%"=="W" call :rotate rem S 或者超时,走下落逻辑 call :gravity goto :loop注意这里判断 errorlevel 用了if not defined act的写法,而不是else if。原因是在批处理里嵌套 if-else 很容易写错,尤其是有多层的时候,括号配对一旦出错就是整个脚本静默退出。if not defined是更稳的写法,虽然代码长了点,但不会有括号地狱。
移动失败的处理也有讲究。call :try_move如果撞墙了,不应该报错退出,而是静默忽略这一帧的操作,方块保持原位。这在手感上很重要——玩家在边界一直按左键,应该什么都不发生,而不是卡一下或者闪一下。
方块锁定后要检查是否需要消行、更新分数、生成新方块,然后判断游戏是否结束。结束条件很简单:新方块生成时,如果初始位置的四个格子有任何一个已经被占据,就直接 game over。这个判断写在:spawn里,返回一个 errorlevel,主循环接住就跳出。
4.4 四步联调法:先跑通再谈手感
一次性写完 300 行然后运行,基本上一定会看到一堆问题。我的做法是分四步验证,每一步只测一个东西,出问题也容易定位。
第一步,只写渲染和静态棋盘。往b20里塞几个 1,跑起来看看能不能正确画出一行方块,边框对不对齐,█有没有变成乱码。这一步过不了,后面都是白搭。
第二步,加活动方块和下落。生成一个方块,让它每秒往下掉一格,看它会不会穿过已有方块、会不会掉出下边界。这一步最容易暴露坐标越界和碰撞失效的问题。
第三步,加移动和旋转。重点测三件事:在最左边一直按 A 会不会跑出去;在最底部旋转会不会穿到地板下面;I 型方块在墙边旋转会不会只显示一半。这三个是最典型的边界情况。
第四步,加消行和计分。故意用 I 型方块拼满几行,看能不能正确消掉、分数对不对、上面的方块有没有正确下移。这一步的问题往往出在扫描方向上。
四步都是小脚本单独跑,等全部跑通了再合并。合并的时候最怕的就是变量名冲突——比如渲染用的line和碰撞判定用的line撞了,就会出现莫名其妙的显示错误。我的习惯是给每个模块的临时变量加前缀,渲染用r_,碰撞用c_,方块用p_,虽然啰嗦但省事。
5. 踩坑实录:批处理写游戏最容易翻车的地方
5.1 闪烁、残影与光标跳动的处理
闪烁的来源有三个,得分开治。第一个是cls本身的黑屏过渡,用 ANSI 定位替代可以解决。第二个是逐行 echo 之间的时间差,20 行 echo 之间如果有延迟,肉眼能看出“从上往下刷”的感觉,解决办法是把整个画面拼成一个带换行的大字符串,一次性 echo 出去——但这会触发批处理的一个限制,单个变量最大只能存 8191 个字符,20 行 × 20 列的全角字符算下来大概 800 多个字符,还远没到上限,可以放心拼。
第三个是光标跳动。默认光标在每个 echo 之后会闪现,看起来像屏幕上有个小方块在乱窜。解决方法是渲染开始时用ESC[?25l隐藏光标,全部画完再ESC[?25h显示回来。这两行加上去,视觉上会干净非常多。
残影的问题前面提过,根源是用覆盖重绘而不是清屏重绘。上一帧的方块掉下来之后,它原来位置留下的█不会被自动清掉,除非你这一帧在那个位置画了空格。因为我们每帧都完整重画整个棋盘,理论上不会有残影,但如果渲染中途出错提前 return 了,就会留下一帧画到一半的残像。所以渲染函数里要保证不提前退出。
还有一种“残影”其实是颜色问题。如果侧栏的背景色和棋盘不一样,边界处的字符可能会有半格颜色残留,这跟控制台的字符属性有关,属于渲染层面的老问题,只能靠统一用同一种颜色、不用背景色反显来规避。
5.2 按键丢失与延迟的真正原因
很多人会觉得 choice 丢键,其实它不是丢,是吞。choice在返回时会把输入缓冲区里已经按下的键丢掉,只保留优先级最高的那个。如果玩家在半秒内连按了三下右键,choice 只返回一次,另外两次直接消失。
这个行为在俄罗斯方块里其实影响不大,因为游戏节奏本来就慢,一秒一次操作很正常。但如果你的实现里下落逻辑比较快(比如每 tick 落多格),玩家会感觉“我明明按了就是没反应”。缓解方法是把/t的值调小,让 choice 更快返回,但前面说过/t最小是 1,没法再小。
另一个“延迟”的来源是渲染时间。如果一帧渲染要花 200 毫秒,那玩家按键到画面响应就有 200 毫秒的滞后,叠加 choice 的 1 秒超时,体感上会非常迟钝。所以渲染优化不是为了好看,是为了手感。具体的优化点前面说过:减少 echo 次数、避免在循环里做字符串拼接的大操作、不要在渲染里调用call。
还有一个隐藏很深的问题:如果脚本里有pause或者set /p残留,整个循环就会卡在那里。调试的时候经常忘了删掉一行pause,然后对着屏幕发呆五分钟。
5.3 乱码与方块字符显示不全
乱码的表现形式有好几种,得对症下药。如果█显示成问号,说明代码页没切对,或者脚本文件本身的编码和代码页不匹配。前者改chcp,后者改文件保存格式。如果█显示成一个字的左半边加一个竖线,那是字体问题——某些等宽字体不包含 U+2588 这个字形,会退化显示。换成■(U+25A0)或者干脆用[]两个半角字符替代,都能解决