1. 为什么要在Excel里折腾一个俄罗斯方块第一次跟朋友说我打算用Excel写个俄罗斯方块对方的反应基本都是一句话你是不是闲得慌。说实话这个念头最初也确实来自一次无聊——当时在整理一份几百行的数据表公式写得头昏脑涨突然就想Excel既然能画单元格、能响应键盘、能跑循环那它本质上不就是一块可以编程的像素屏吗既然有人用计算器弹音乐、用记事本写游戏那用表格软件做个方块下落的小游戏逻辑上完全说得通。真正动手之后才发现这件事的价值远不止好玩。它逼着我把VBA里几个平时用得不多、但极其关键的能力全部串了一遍单元格的批量着色与刷新、Application.OnKey的键盘事件接管、Timer驱动的定时循环、二维数组与游戏矩阵的映射以及最容易被忽略的屏幕闪烁与性能优化。这些东西在写业务宏的时候往往用不到可一旦你要做任何实时交互类的东西它们就是绕不过去的坎。这篇内容适合两类人看。一类是已经会写基本的VBA宏、想让自己的Excel技能再上一个台阶的人另一类是想找个具体项目把编程思维练起来的人——俄罗斯方块的规则足够简单但麻雀虽小五脏俱全状态机、碰撞检测、消行判定、随机生成一个都不少。我会把整个实现过程拆开讲包括我踩过的坑、试过的错误方案以及最后为什么选了现在这套写法。代码可以直接抄但更重要的是理解每一步为什么这么设计。需要提前说明的是Excel并不是为游戏而生的工具用它做实时交互天然有短板比如刷新机制、事件响应速度都受限于软件本身。所以本文的重点不是做一个媲美专业引擎的游戏而是在Excel的能力边界内把这件事做到能玩、流畅、不闪。理解了边界你才知道哪些优化是必要的哪些是白费力气。2. 动手之前必须想清楚的几件事2.1 游戏区域到底用单元格还是用图形这是第一个岔路口也是决定后续所有代码结构的关键选择。用Excel做游戏画面呈现有两条路一是直接操作单元格给格子填色二是插入一个图形对象比如矩形或图片在上面用API画点。我一开始试的是图形方案因为直觉上画布比表格更像游戏屏幕。但很快就放弃了。原因很实际在VBA里通过API往图形上画东西需要处理设备上下文、句柄、重绘消息代码量陡增而且一旦窗口被遮挡或最小化画面就乱了恢复起来很麻烦。对于俄罗斯方块这种由规则方块组成的画面用图形反而是杀鸡用牛刀。单元格方案就朴素多了。把游戏区域划成10列×20行的单元格网格每个格子代表一个像素。方块占用的格子填成实色空白的格子填成背景色。听起来很土但它有几个天然优势坐标系统直接就是行列号不需要任何换算单元格的边框、颜色、大小都能通过属性直接控制调试的时候肉眼就能看出哪一行哪一列出了问题。所以我最终选了单元格方案整个游戏区域就是一片Range。提示单元格方案的前提是把行高列宽调成正方形否则方块会变成扁的或长的视觉上很别扭。这一步在初始化阶段就要做掉。2.2 刷新方式决定了游戏会不会闪确定了用单元格紧接着就是怎么刷新画面。最直觉的写法是每次方块移动就把整个游戏区域清空再重画一遍。我第一版就是这么写的结果方块一动整个屏幕像老式日光灯一样疯狂闪烁根本没法看。闪烁的根源在于清空和重画是两个独立的操作Excel在两次操作之间会把中间状态渲染出来于是你看到的就是先全白、再全彩的频闪。解决办法有两个方向。一是只重画变化的部分也就是记录方块移动前后的位置把旧位置擦掉、新位置画上不动其他格子。二是关闭屏幕刷新用Application.ScreenUpdating False把整个绘制过程包起来画完再打开。我最后两个都用上了。局部重画减少工作量关闭刷新消除中间态。实测下来两者结合之后画面基本稳定方块下落很顺。这里有个细节ScreenUpdating的开关是有开销的不要每次画一个格子就开关一次而要在一整帧的绘制开始前关、结束后开。2.3 键盘事件为什么不能靠Worksheet事件俄罗斯方块需要响应方向键左右移动、下加速、上旋转。很多人第一反应是用Worksheet_SelectionChange或者Worksheet_KeyDown这类事件。但这两个都不行。SelectionChange是选中单元格变化时触发方向键在Excel里默认就是移动选中单元格你按一下方向键选中格就跳走了游戏逻辑和光标移动会打架。而工作表级的KeyDown事件在普通工作表模块里根本不触发它只在某些特定控件场景下有效。正确的做法是用Application.OnKey。它能在应用级别拦截按键把指定的键映射到你自己的宏上。比如把左方向键映射到MoveLeft过程那么无论当前选中哪个单元格按左键都会执行你的移动逻辑而不会去移动光标。这是Excel里做键盘交互最可靠的方式没有之一。注意OnKey是全局的它会覆盖Excel原本的按键行为。所以游戏开始时要接管按键游戏结束或退出时一定要用Application.OnKey {LEFT}这种形式把按键恢复原状否则用户关掉游戏后方向键就失灵了这是个很容易忘的坑。2.4 定时下落用什么驱动方块要自己往下掉就需要一个定时器。VBA里能用的定时手段主要有两个Application.OnTime和Windows API的SetTimer。Application.OnTime的好处是纯VBA、跨版本稳定它能在指定时间后调用一个宏。缺点是精度一般最小间隔受限于系统调度而且它是一次性的要实现循环下落得在每次回调里再安排下一次。SetTimer精度更高但需要声明API、处理回调地址代码复杂而且在某些环境下回调不稳定。考虑到俄罗斯方块对时间精度要求并不苛刻下落间隔在几百毫秒级别Application.OnTime完全够用。我选它图的就是稳定和可读。每次下落完成后重新安排下一次形成一个自我调度的循环。速度控制也简单改一下间隔参数就行等级越高间隔越短。3. 游戏矩阵与方块的数据结构设计3.1 用二维数组表示整个游戏场地游戏的核心是一个场地矩阵。我定义了一个10 × 20的二维整型数组每个元素代表一个格子0表示空非0表示已被固定的方块占据不同数字可以代表不同颜色方便后续扩展。方块下落、碰撞检测、消行全部围绕这个数组进行。为什么用数组而不是直接读单元格颜色因为数组在内存里读写极快而反复读写单元格属性是出了名的慢。游戏逻辑只在数组上跑只有当画面需要更新时才把数组的差异同步到单元格上。这种逻辑与显示分离的思路是所有游戏开发的基本功在Excel里同样适用。数组的坐标和单元格的坐标要建立明确映射。我约定数组的(row, col)对应工作表中第row行、第col列加上一个起始偏移量因为游戏区域不一定从A1开始。这个映射关系一旦定死后面所有代码都按它来就不会乱。3.2 七种方块怎么表示俄罗斯方块有七种基本形状I、O、T、S、Z、J、L。每种形状在不同旋转状态下占用的格子不同。表示方法有好几种我比较推荐的是用相对坐标列表。具体来说每种方块用一个坐标集合描述它当前占据的格子坐标是相对于方块锚点的偏移。比如O形方块就是(0,0)(0,1)(1,0)(1,1)四个偏移。旋转的时候对这些偏移做一次坐标变换顺时针旋转90度就是把(r,c)变成(c, -r)再归一化就得到新的形状。这种表示法干净、易扩展加新形状只要加一组坐标。另一种常见做法是用一个小的方阵比如4×4来存形状旋转就是矩阵转置加翻转。两种都行我选坐标列表是因为它更省内存而且碰撞检测时直接遍历坐标点即可不用扫描整个方阵。3.3 碰撞检测的判定逻辑碰撞检测是方块能不能移动、能不能旋转的核心。判定规则其实就三条方块移动后的每个格子不能超出左右边界、不能超出底部、不能落在已经被占据的格子上。任何一条不满足这次移动就非法要撤销。这里有个容易写错的地方旋转时的碰撞检测。旋转后形状变了占用的格子也变了必须对旋转后的新坐标重新做一遍完整检测而不是只检查锚点。我第一版就偷懒只检查了锚点结果方块贴着墙旋转时会穿墙或者和已有方块重叠。后来改成对旋转后的所有格子逐个检测问题就没了。还有一个边界情况方块在顶部生成时如果生成位置就已经和已有方块重叠说明游戏结束。这个判定要在生成时做而不是等它下落。4. 从零搭建游戏画面的实操步骤4.1 初始化游戏区域与视觉样式第一步是把游戏区域画出来。我选了一块固定区域比如从某个单元格开始划出10列宽、20行高的范围。然后统一设置行高和列宽让每个格子接近正方形。行高的单位是磅列宽的单位是字符数两者单位不同需要试几次才能调到视觉上接近正方形。我的经验是列宽设成2.5左右、行高设成18左右看起来比较舒服具体数值可以根据屏幕分辨率微调。接着给这片区域加边框让网格线清晰可见。边框用Borders集合统一设置线型选细实线颜色用浅灰这样既能看到格子又不会喧宾夺主。背景色统一填成深色比如深灰或黑方块用亮色对比强烈看起来更像游戏。提示初始化代码最好单独写成一个InitGame过程每次开始新游戏都调用它把场地清空、分数归零、方块重置。这样重开游戏不用重启Excel。4.2 把数组同步到单元格的绘制函数绘制函数是连接逻辑和画面的桥梁。它的职责很简单遍历场地数组把每个格子的值翻译成对应的单元格颜色。但直接每次全量重绘会很慢所以我做了优化——只重绘发生变化的格子。实现方式是维护一个上一帧的数组副本绘制时对比当前数组和上一帧只更新不同的格子然后更新副本。这样方块移动时实际重绘的格子只有几个速度飞快。对于方块本身因为它不在场地数组里场地数组只存已固定的方块所以绘制时要额外把当前活动方块的格子也画上。颜色映射我用了一个简单的规则0是背景色1到7分别对应七种方块的颜色。这样既区分了方块类型又方便调试时一眼看出是哪种方块。4.3 键盘接管的完整配置键盘接管在游戏开始时执行。用Application.OnKey把四个方向键分别映射到四个宏左移、右移、下移、旋转。映射的键名要用VBA规定的写法比如左方向键是{LEFT}上方向键是{UP}。这里有个细节值得说下方向键我映射成了加速下落而不是直接落底。有些实现把下键做成瞬间落底玩起来手感很硬。我改成按一下下移一格长按会连续触发因为键盘重复这样更接近经典手感。如果你想要硬降可以单独映射一个键比如空格。游戏结束时必须逐个恢复按键。我写了一个ReleaseKeys过程把每个被接管的键用Application.OnKey {LEFT}这种不带第二参数的形式恢复默认行为。这一步千万别省否则用户退出游戏后方向键就废了。4.4 定时下落循环的搭建下落循环用Application.OnTime实现。核心逻辑是安排一个在未来某时刻执行的过程这个过程执行一次下落然后再次安排自己。这样就形成了循环。下落过程要做几件事先尝试把方块下移一格如果下移合法就更新位置并重绘如果下移不合法说明触底或碰到其他方块就把方块固定到场地数组里然后检查有没有满行有就消行加分最后生成新方块。生成新方块时如果发现位置被占游戏结束。速度控制通过调整OnTime的间隔参数实现。初始间隔设大一点比如500毫秒随着消行数增加逐步减小最低到100毫秒左右。间隔太小会导致刷新跟不上反而卡顿所以别贪快。注意Application.OnTime安排的任务在游戏结束或用户中断时必须用Schedule:False取消掉否则它会一直尝试调用已经失效的过程可能报错或造成混乱。5. 那些让我卡了半天的坑与解法5.1 方块旋转后位置错乱的根因前面提过旋转碰撞检测的问题但还有一个更隐蔽的坑旋转中心的选择。如果旋转时以方块的锚点为圆心某些形状旋转后会发生明显的位移看起来像跳了一下。比如I形方块横着的时候占四个格子竖着的时候也占四个但如果旋转中心没选好旋转后它会偏到一边去。解决办法是旋转后做一次位置修正计算旋转后所有格子的最小行、最小列然后整体平移让方块回到一个合理的相对位置。更简单的做法是旋转后如果发现越界就尝试左右微调wall kick能塞进去就塞塞不进去才判定旋转失败。这个微调逻辑是很多经典实现里都有的加上之后旋转手感会好很多。5.2 屏幕闪烁的三种成因与对应处理闪烁这个问题我前后折腾了很久最后总结出三种成因对应三种处理。第一种是全量重绘导致的闪烁前面说的局部重绘加ScreenUpdating就是治它。第二种是单元格边框重绘引起的闪烁因为每次改颜色时边框也会跟着重画。我的处理是把边框和填充分开边框只在初始化时设一次之后只改填充色不动边框。第三种是选中单元格高亮造成的视觉干扰游戏区域里如果有个单元格被选中它会一直有个高亮框很碍眼。处理办法是游戏开始时把选中格移到一个不显眼的位置或者干脆用Application.Interactive配合其他手段弱化它。三种处理叠加之后画面才真正稳定下来。这个过程让我明白性能优化不是一步到位而是把一个个小问题逐个消掉。5.3 消行逻辑里最容易写错的下标消行是俄罗斯方块里逻辑最绕的部分。我的做法是从下往上扫描场地数组遇到满行就把它删掉上面的行整体下移。听起来简单但下标处理极易出错。最常见的错误是边扫描边删除导致漏行。比如你从下往上扫删掉第18行后原来的第17行下移变成了第18行但你的循环下标已经往上走了这一行就被跳过了。正确做法是删行之后把扫描下标回退一格或者干脆用重建数组的方式——把所有非满行按顺序收集起来再从底部往上填回数组。后者逻辑更清晰不容易错我最后用的就是重建法。消行之后还要加分。经典规则是消一行得基础分消多行有额外奖励。分数显示在游戏区域旁边的一个单元格里每次消行后更新。5.4 游戏结束与重开的边界处理游戏结束的判定点在生成新方块时。如果新方块生成的位置已经被占据就说明堆到顶了游戏结束。这时候要做几件事停止下落定时器、恢复键盘、弹出提示、把状态标记为已结束。重开游戏时要确保所有状态都被重置场地数组清零、分数归零、定时器重新启动、键盘重新接管。我踩过一个坑——重开时忘了取消上一次的定时器结果两个定时器同时在跑方块下落速度翻倍而且越开越多。所以重开的第一步永远是清理旧状态再初始化新状态。6. 让游戏更耐玩的几个进阶方向6.1 下一个方块预览与分数系统基础版本能玩之后我加了下一个方块预览。实现很简单在游戏区域旁边划一小块地方提前生成下一个方块并画在那里当前方块固定后就把它挪到场上再生成新的下一个。这个功能对游戏体验提升很大因为玩家能提前规划不会手忙脚乱。分数系统我做了两级消行得分和等级加速。消一行、两行、三行、四行的得分递增鼓励玩家攒着一次消多行。等级则根据累计消行数提升等级越高下落越快。这两个机制一加游戏就有了越玩越紧张的节奏感。6.2 用颜色区分方块类型的视觉优化七种方块如果都用同一种颜色玩起来眼睛很累也不容易分辨。我给每种方块分配了不同的颜色I是青色、O是黄色、T是紫色等等。颜色通过单元格的Interior.Color属性设置用RGB值指定。这里有个小技巧颜色不要选太饱和的因为Excel的单元格填充在大面积高饱和色下会显得刺眼。我选的都是稍微柔和一点的色调长时间看眼睛不累。另外方块和背景的对比度要够否则看不清边界。6.3 暂停、重开与难度调节暂停功能实现起来比想象中简单用一个布尔变量标记是否暂停下落过程每次执行前先检查这个标记如果暂停就直接返回、不安排下一次。恢复时重新安排即可。暂停键我映射到了某个不常用的键上避免和方向键冲突。难度调节我留了个入口可以手动改初始下落间隔和加速曲线。如果你想让游戏更简单就把初始间隔调大、加速调慢想挑战就反过来。这个参数放在代码顶部的一个常量区改起来方便。6.4 把游戏逻辑和界面彻底分离的重构思路写到后面我意识到游戏逻辑数组操作、碰撞、消行和界面操作画格子、读键盘混在一起代码越来越难维护。于是我做了次重构把逻辑部分抽成独立的过程它们只操作数组、不碰单元格界面部分只负责把数组画出来、把按键转成逻辑调用。重构之后代码清晰了很多也更好调试——逻辑出问题就单独测逻辑不用管画面。这个思路其实适用于任何VBA项目把算什么和显示什么分开是写出可维护代码的关键。哪怕你只是写个数据处理宏这个原则同样成立。7. 我在这个项目里真正学到的东西做完这个俄罗斯方块我最大的感受是Excel的边界比大多数人以为的要宽得多。平时我们把它当表格工具但它本质上是一个带网格界面、带事件系统、带编程语言的运行环境。只要理解了它的刷新机制和事件模型就能做出很多不像Excel能做出来的东西。另一个体会是关于性能。在业务场景里我们往往不太在意几百毫秒的延迟但做实时交互时每一毫秒都影响手感。这逼着我去理解ScreenUpdating、局部重绘、数组与单元格的读写成本差异。这些知识反过来用回日常的宏开发处理大数据量时也能明显提速。最后说个实操建议如果你也想动手做别一上来就追求功能完整。先把一个方块能下落、能左右移动、能堆叠这条最小链路跑通哪怕画面很丑、没有消行。跑通之后再逐步加旋转、加消行、加预览。每一步都验证比一口气写完再调试要省力得多。我第一版就是贪多结果一堆bug缠在一起排查起来非常痛苦。拆开做每个模块单独验证才是靠谱的路径。