8086汇编推箱子:地图建模、键盘输入与显存渲染全解析
8086汇编做推箱子算是把计算机基础课里那点存货全用上了。项目本身不复杂一张地图、四个方向键、一个玩家、若干个箱子和目标点但用16位汇编实现一遍之后很多课本上讲不透的东西比如段寄存器、显存映射、扫描码读取都会被迫真正吃透。这个项目很适合拿来当汇编课程的期末设计也适合想验证自己到底懂不懂实模式编程的人——如果你能不看任何参考资料用TASM或MASM把推箱子跑起来那你对8086的理解基本就过关了。我最初做这个项目的时候犯过不少很低级的错误比如把位移量当成段地址用、在循环里被中断改掉了CX导致死循环、方向键扫描码取值取错等等。这篇文章把这些坑从头到尾捋一遍从地图建模、数据结构设计、键盘输入处理、推箱逻辑判定到文本模式下的渲染方案全部用可复现的代码和思路写出来。1. 项目整体设计与地图建模1.1 为什么用8086汇编做推箱子推箱子这个游戏逻辑上无非是“人推箱子到目标点”玩法规则足够简单所以核心难点不在算法而在怎么用最朴素的指令集去组织数据和控制流。8086汇编没有高级语言里的数组边界检查、没有结构体、没有面向对象所有东西都要自己用内存、寄存器、段地址去拼。这就逼着你把问题拆到最底层去思考。选择推箱子而不是贪吃蛇、俄罗斯方块还有一个原因推箱子的地图是静态的不需要实时物理运算也不需要大量随机数整个游戏的状态变化完全由玩家按键驱动。这种“事件驱动”模型在汇编里实现起来非常干净等待按键、解析按键、更新状态、刷新屏幕循环往复。代码量不会膨胀到失控同时又覆盖了输入输出、数组寻址、条件分支、循环控制等着重考点。另外推箱子天然适合用二维数组内存中的连续字节来建模。8086汇编对一维数组的寻址非常方便二维数组只要手动计算行偏移也不复杂。这个特点让地图数据可以在数据段里直接定义不需要运行时动态分配非常适合实模式下的编程模型。1.2 地图的空间编码方案先把地图类型定义清楚。我用文本方式设计地图每个字符对应一种地形或物体#墙壁空格空地$箱子.目标点玩家*箱子在目标点上玩家站在目标点上这个编码方案其实是Sokoban领域通用的字符映射好处有两个第一地图可以直接用文本编辑肉眼一眼就能看出关卡布局第二转换到汇编里只要把ASCII码映射成内部编号就行不会出现“地图长什么样全靠脑补”的问题。在汇编的数据段里地图就是一个纯字节数组。举个例子; 地图尺寸 MAP_W EQU 16 MAP_H EQU 12 ; 地图数据 —— 用DB逐行定义 MAP DB ################ DB # # DB # . $ . # DB # # # DB # . $ . # DB # # DB ################ MAP_END有人可能会问为什么不直接在汇编里用专门的编号比如0空地、1墙、2箱子而是先用ASCII存再转换我的做法是直接用ASCII码做内部表示省掉转换这一步。因为地图数据里每个字节的值就是字符的ASCII码比如#是0x23$是0x24。移动判定时直接拿这些ASCII码做比较逻辑上也清晰代码量更少。缺点是比较时数字不连续无法直接用范围判断但推箱子地图元素种类少每个都写一个CMP加JE也不算负担。1.3 关键数据结构在汇编里的落位地图数组是核心数据结构但光有地图还不够。整个程序需要维护的状态如下地图数组存放在数据段字节为单位按行优先排列。当前玩家位置可以用PLAYER_X和PLAYER_Y两个变量保存单位是列和行。箱子数量用于初始化时记录总共需要归位的箱子个数便于胜利检测。已到位箱子数每推一个箱子到目标点就加一归零就减一当这个值等于箱子总数时宣布通关。这些变量在汇编里就是简单的DB或DWPLAYER_X DW 7 PLAYER_Y DW 4 TOTAL_BOX DW 4 DONE_BOX DW 0为什么用DW而不是DB因为地图寻址时要用到行偏移 Y * MAP_W这个乘法结果可能超过255尤其地图宽度大于16时用8位存会溢出。稳妥起见坐标和计数器全部用16位。地图寻址的公式是; 入口BX 行号YAX 列号X ; 出口SI 对应地图数组的偏移地址 MOV AX, BX MOV BL, MAP_W MUL BL ; AX Y * MAP_W ADD AX, 列号 MOV SI, OFFSET MAP ADD SI, AX这里有个8086汇编新手常踩的坑MUL对操作数位数很敏感如果用MUL BL乘法结果是16位的存放在AX里高8位可能不为零导致后续ADD AX, 列号时把列号加到了错误的位置。解决办法是先把列号也装进某个寄存器再整体相加或者干脆用ADD AX, DX之类的方式处理。我习惯把坐标计算封装成一个子过程CALC_ADDR所有需要访问地图的地方都调用它避免每次写重复代码。2. 键盘输入处理与核心移动逻辑2.1 读取方向键的完整路径8086系统里读键盘按键最常用的两种方式INT 16H, AH00H阻塞式读键没有按键时会一直等待。INT 16H, AH01H探测是否有按键有则返回无则置零标志。推箱子是回合制游戏玩家不按方向键时画面不需要任何变化所以直接用阻塞式读键最舒服。调用INT 16H后你会在两个寄存器里拿到信息ALASCII码普通字符按键盘会得到对应的ASCII值。AH扫描码每个物理按键对应一个固定的扫描码方向键的扫描码分别是上48H下50H左4BH右4DH注意方向键在INT 16H里并不会返回普通的ASCII码它返回的是扩展键通常AL0真正的按键身份在AH里。如果你只判断AL方向键等于什么都没按。我第一次做的时候就是盯着AL看结果发现上下左右全都没反应。读键循环大概长这样WAIT_KEY: MOV AH, 00H INT 16H CMP AH, 48H JE KEY_UP CMP AH, 50H JE KEY_DOWN CMP AH, 4BH JE KEY_LEFT CMP AH, 4DH JE KEY_RIGHT JMP WAIT_KEY ; 其他键忽略继续等记得在读键之前把AX拿来用之前先保存因为INT 16H返回时AX会被改写。如果你在别处还存了重要数据在AX里最好先PUSH AX再读键读完POP回来。2.2 移动判定先“看”再“走”移动处理的核心思路可以拆成三步计算目标位置玩家前方一格。看目标位置是什么。分情况处理。假设玩家当前在(PX, PY)向上移动时目标位置是(PX, PY-1)。我们把目标位置的地图字节取出来; 取目标位置的地图元素 ; 假设目标地址已存入 SI MOV AL, [SI]分四种情况AL 空地玩家直接走过去原位置变为空地。AL .目标点玩家直接走过去原位置变为空地新位置变为。AL $或AL *箱子/到位箱子进入推箱逻辑。AL #或 其他撞墙什么都不做。这里有一个很重要的设计细节玩家踩过的位置要恢复成什么很多初学者直接把它变成空地 但这会丢掉目标点的信息。比如玩家原本站在目标点上的玩家他走开后那个位置应该恢复成.而不是 。所以每次移动完成后原位置的恢复逻辑必须基于“玩家离开前站在什么地形上”。解决方法是维护一个额外的状态量我在玩家位置变化时总是先记住“脚下的地形类型”。简单粗暴的做法是检查当前玩家所在位置如果地图上的玩家是说明脚下是目标点走开后写.如果是说明脚下是空地走开后写 。; 清除玩家旧位置 MOV AL, [SI] ; SI 指向旧玩家位置 CMP AL, ; 玩家站在目标点上 JE RESTORE_TARGET MOV BYTE PTR [SI], JMP MOVE_DONE RESTORE_TARGET: MOV BYTE PTR [SI], . MOVE_DONE:这个细节看似小实际影响很大。如果不处理玩两下地图上就会冒出很多“幽灵目标点”胜利判定也会出错。2.3 推箱逻辑箱子前方的地形决定一切当玩家前方是箱子$或*时能不能推动取决于箱子前方的位置也就是箱子再往前一格是什么。设玩家在(PX, PY)箱子在(BX, BY)箱子前方在(FX, FY)。对箱子的前一个位置取地图值如果是空地 箱子可以移动玩家前进一格箱子到前方空地。如果是目标点.箱子可以移动并将该位置标记为到位箱子*。如果是墙壁#、另一个箱子$或*推不动。如果是地图边界外推不动。先看一个完整的推箱子方向处理以向右推为例; 玩家向右移动 ; (PX, PY) - (PX1, PY) ; 如果 (PX1, PY) 是箱子再看 (PX2, PY) MOV AX, PLAYER_X INC AX ; AX 箱子候选位置列号 CALL CALC_ADDR MOV AL, [SI] CMP AL, $ JE TRY_PUSH_BOX CMP AL, * JE TRY_PUSH_BOX ; 不是箱子走其他移动逻辑 JMP NORMAL_MOVE TRY_PUSH_BOX: ; 计算箱子前方 (PX2, PY) MOV AX, PLAYER_X ADD AX, 2 MOV BX, PLAYER_Y CALL CALC_ADDR MOV AL, [SI] CMP AL, # JE BLOCKED CMP AL, $ JE BLOCKED CMP AL, * JE BLOCKED ; 箱子可以推 ; 1. 箱子前方写入箱子 ; 2. 箱子原来位置写入玩家或目标点上的玩家 ; 3. 玩家原来位置恢复空地/目标点推完后有一个账要算清楚箱子到达的位置如果是普通空地写$如果原来是目标点写*。这块逻辑可以用“先读箱子前方原本是什么再决定写入什么”的方式统一处理。还有一个需要小心的地方是如果箱子本来在*状态已经在目标点上把它推走之后曾经的目标点不能消失所以箱子原位置要恢复成.而不是 。这部分的代码比较琐碎但逻辑并不复杂。我个人的经验是所有位置更新必须遵循“先算后改”的原则。先在一个临时缓冲区里把所有新旧位置的值都算清楚再一次性写入地图避免改到一半发现后面判定需要旧值却已经被覆盖的情况。2.4 胜利检测与死局判断胜利检测的常规做法每推一次箱子就遍历一遍整个地图统计当前*的数量是否等于箱子总数。CHECK_WIN: MOV CX, MAP_W * MAP_H MOV SI, OFFSET MAP XOR BX, BX ; BX 用作计数器 SCAN_LOOP: MOV AL, [SI] CMP AL, * ; 箱子在目标点上 JNE NEXT_CELL INC BX NEXT_CELL: INC SI LOOP SCAN_LOOP MOV AX, TOTAL_BOX CMP BX, AX JE GAME_WIN这个办法简单可靠缺点是每次推箱子都全图扫描在地图不大的情况下完全够用。如果地图做到几百格才需要改用“增量计数”方案每次推箱子只判断目标位置是否是目标点来决定DONE_BOX加一或减一。死局判断比如箱子被推到墙角再也拉不出来在高级语言里都有点麻烦在纯汇编里实现更费事需要检测所有箱子是否都处在“不可恢复的角落”。我不想做也没做。和很多经典移植版一样推箱子玩的是“自己发现走错了就重来”允许玩家按R键重置关卡。重置只需要把初始地图从备份区复制回工作区就行了。RESET_LEVEL: MOV CX, MAP_W * MAP_H LEA SI, MAP_BACKUP ; 备份地图 LEA DI, MAP ; 当前工作地图 CLD REP MOVSB这个R键功能虽然简单但实际玩起来会极大降低挫败感。我个人强烈建议加上。3. 画面渲染与刷新方案3.1 文本模式还是图形模式8086汇编做显示有两条主流路线文本模式80x25或40x25VGA文本模式直接往显存的文本缓冲区B800段写字符和颜色属性操作简单刷新快。图形模式如13H模式320x200x256色需要操作像素适合画格子、画小人视觉效果更好但代码量明显增加。推箱子本身用文本模式做就非常合适。每个格子可以用不同字符代表不同物体墙壁用#空地用空格箱子用$目标点用.玩家用。在80x25的屏幕上40x25网格的地图都能放下我做的16x12小地图毫无压力。文本模式还有个好处刷新时只需要直接修改B800:0000处的字符字节不需要画线画矩形代码量少一个量级。但要提醒一点在纯DOS或模拟器环境下文本模式和中断调用都很好使。如果你跑在Windows的命令窗口里行为会有差异最好用DOSBox或虚拟机来测试。3.2 字符与颜色属性一个字节的含金量VGA文本模式的显存布局是每一个显示字符占用两个字节第一个字节是ASCII码第二个字节是属性前景色和背景色各占4位。比如要在屏幕坐标行Y列X显示一个字符; 显存段地址 0B800H MOV AX, 0B800H MOV ES, AX ; 屏幕位置计算(Y * 80 X) * 2 MOV AX, Y MOV BL, 80 MUL BL ADD AX, X SHL AX, 1 ; 乘以2 MOV DI, AX MOV BYTE PTR ES:[DI], # ; 字符 MOV BYTE PTR ES:[DI1], 07H ; 属性白字黑底我这版程序对每个物体都配了专属颜色方便一眼分辨墙壁#亮灰色属性07H空地空格属性07H基本看不出变化目标点., 红色属性04H箱子$, 棕色属性06H到位箱子*, 亮绿色属性0AH玩家, 亮青色属性0BH玩家在目标点上, 亮黄属性0EH进一步优化的话可以不只画字符连背景色一起改。比如目标点格子背景用暗红色箱子到位后背景变亮视觉效果很直观。只不过文本模式的背景色只有8种变化有限。3.3 整屏重绘与局部刷新一个常见的初级错误是每次按键后把整屏全部重画一遍。其实16x12的地图每次全量重绘也就192个字符的写入操作在实模式下慢不到哪里去。但80x25整屏就是2000个字符如果地图再大一点肉眼可见地会有闪烁感。稳妥的做法是维护一个“脏矩形”或“脏区域”的概念。推箱子每次移动只影响三个格子玩家原来所在的格子。玩家现在所在的格子。箱子移动前和移动后的格子如果发生了推动。所以在移动更新地图后只需要把这三四个格子同步到显存即可。我封装了一个子过程REFRESH_CELL传入地图坐标按前文公式算显存地址写入字符和属性; 入口BL 行号BH 列号AL 字符DL 属性 REFRESH_CELL: MOV AX, 0B800H MOV ES, AX MOV AX, BX ; 注意这里BH中含列号、BL中含行号 MOV BL, 80 MUL BL ADD AX, BX SHL AX, 1 MOV DI, AX MOV BYTE PTR ES:[DI], AL MOV BYTE PTR ES:[DI1], DL RET当然如果不想精细控制显存也可以用DOS中断INT 21H的09号功能输出字符串或者用INT 10H的02H号功能先设置光标位置再输出。这些方法在交互性要求不高的场合足够用但光标位置管理会稍微麻烦。直接操作显存虽然绕过了系统API但换来的是极快的刷新速度和完全可控的屏幕布局这点代价是值得的。4. 调试方法论与常见问题排查实录4.1 把状态机“画”出来让代码照着走推箱子的移动逻辑本质上是一个小状态机。玩家在移动前所处的状态有四种在空地在目标点前方是空地/目标点前方是箱子/到位箱子如果调试的时候发现“玩家走到目标点后消失”或者“箱子推不动”十有八九是某个分支没有按状态机处理。我的建议是先在一张纸上画出状态转换表把每种“当前地形/目标地形/箱子前方地形”的组合列出来对应的结果写清楚再去写汇编代码。这比在代码里瞎试高效得多。4.2 寄存器冲突最容易爆炸的问题8086只有那几个寄存器在复杂逻辑里很快就捉襟见肘。我至少踩过三次这种坑用CX做循环计数结果循环体里调用了子程序子程序又把CX拿来用了导致外层循环死循环。SI既当地图指针又被子程序计算地址时改掉了下一次访问就指到错误位置。AH在INT 16H读键返回后拿的是扫描码但紧接着调用MUL又把AH忽略了导致扫描码丢失。解决思路很简单但执行起来要自律任何子程序入口处先声明自己会破坏哪些寄存器再统一用PUSH/POP保护。至少保护AX、BX、CX、DX、SI、DI如果用到段寄存器“顺手”也保护一下。我在整个程序里加了几个“公共子程序”比如CALC_ADDR、REFRESH_CELL、DELAY每个都在开头结尾做好寄存器现场保存。虽说PUSH/POP会带来一点性能开销但推箱子这帧率根本不在乎这点开销换来的是调试期的巨大安心感。4.3 我实际踩过的五个具体问题整理成表方便后来人直接对照排查问题现象原因解决办法按方向键没反应只判断了AL的ASCII码方向键的扫描码在AH判断AH中的扫描码值玩家穿过墙壁边界检查缺失计算目标地址时地图外读到垃圾数据先检查目标行列是否在0..MAP_W-1和0..MAP_H-1内推箱子后目标点消失箱子原来在*状态推走后原位置直接写成空格先判断原位置是否是目标点恢复成.玩家站在目标点上时走出边界状态下玩家移动后恢复逻辑只写了 单独判断恢复成.画面闪烁严重每次全屏重绘改成局部刷新重开关卡后箱子还在原位工作地图被改了备份地图忘做恢复增加MAP_BACKUP按R时REP MOVSB恢复这六个问题里有四个是“地图状态恢复”相关的说明推箱子这种小游戏最考验的不是复杂的AI而是对地图格子状态的维护能力。虽然每个问题看起来都很简单但真实场景下它们会在不同方向、不同状态下交叉出现排查起来比想象中慢。4.4 用DOSBox和调试器的组合拳开发环境我用的是DOSBox加TASM调试时配合DEBUG工具单步跟踪。对于汇编程序单步执行价值极高你可以观察寄存器每一条指令前后的变化能清楚看到哪个值被计算错了哪个标志位被意外设置。我调试移动逻辑时的典型流程在WAIT_KEY回圈里设断点按一次方向键。单步执行移动逻辑每一条指令执行后看地图数组的内容。发现地图数组某个格子值不对反推是哪一步产生的。这个方法很笨但在指令规模只有几百行的项目里非常有效。TASM自带的TDTurbo Debugger也可以界面友好一点原理一样。另外一个技巧在地图数组区域设一个“内存观察点”TD支持在指定内存地址变化时自动暂停这比一步步强跳高效得多。我在调推箱逻辑时就是这么抓到一个“箱子被推到墙里”的bug的——内存断点一响位置一目了然。5. 从单关到多关的扩展设计5.1 关卡数据的组织方式做单关很简单做多关也不难。我建议把每个关卡的地图定义在数据段中连续存放在每张地图结束后加一个结束标记。比如LEVEL1: DB ################ DB # .......... # ... DB ################ DB 0 ; 关卡结束标记 LEVEL2: DB ################ ... DB 0载入下一关时从LEVEL1开始通过关卡编号偏移到对应地图的起始地址然后把数据复制到工作区。关卡编号存在内存变量里切换时递增。复制地图用REP MOVSB很顺手但要特别注意CX的值是地图大小别把结束标记也复制进去。地图备份同理。5.2 关卡间的“小技巧”设计多关版本里我加了不少提升体验的小功能步数统计每次移动加一存成十进制数显示在屏幕底部。箱子归位进度显示DONE/TOTAL形式的分数。快捷键R重置N下一关Q退出。这些功能在汇编里实现都不复杂。步数统计需要把二进制数转成十进制显示我封装了一个“数值转字符串”的小函数原理是反复除以10取余数然后把余数转成ASCII。这个函数在汇编课程里很经典建议自己手写一遍。关卡数据如果很多我建议用工具生成汇编的.asm文件。写一个简单的脚本把文本地图转换成DB定义能省掉大量手工排版错误。我实际做过一次15关的地图集手写了两关就放弃了后面全部靠脚本生成。6. 对新手和老手的几点实在建议先说给新手听的。如果你正在学汇编想通过这个项目练手请一定不要先看教程再抄代码。你需要的顺序是先自己在纸面上画出地图编码方案和移动状态表。用你熟悉的语言哪怕是Python先把推箱子逻辑跑通确保规则理解正确。再用汇编把相同的逻辑实现一遍。遇到不会的指令查书、查资料主动记录。这个流程能保证学到的不是“抄会了”而是“真会了”。我在带人的时候见过太多把代码背下来却不知道每个寄存器为什么这样用的情况那这个项目就白做了。对老手或者想做扩展的进阶玩家我建议考虑这几个方向加上死局检测写一个“箱子是否卡死在角落”的判断函数在玩家尝试推箱子时预警。支持撤销操作每次移动前把地图快照压栈按U键恢复上一步。实现很快但栈空间要注意控制。做图形化版本切到13H图形模式用16色像素画格子地图用更大的瓦片表示。加音效用PC喇叭INT 61H或直接操作端口61H做简单的按键反馈音。这个做起来很有意思也非常“复古”。推箱子这个项目最珍贵的地方在于它很小小到一个人用一个周末就能从头写到跑又很完整完整到覆盖了汇编编程几乎所有的基本功——寻址、循环、分支、子程序、I/O、显存操作、中断调用。说实话写完这个项目之后我再回头看书上那些“段地址和偏移地址”的理论一下子全通了。最后再分享一个小心得汇编代码里命名和注释的力量远比你想的大。我当时给所有子程序都取了功能明确的名字给所有魔法数字比如扫描码48H都写了注释两周后再打开代码依然能很快回忆起每一块在干什么。这种代码习惯在高级语言里叫可维护性在汇编里它就是你的命。