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或DW:
PLAYER_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 = 行号(Y),AX = 列号(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, AH=00H:阻塞式读键,没有按键时会一直等待。INT 16H, AH=01H:探测是否有按键,有则返回,无则置零标志。
推箱子是回合制游戏,玩家不按方向键时画面不需要任何变化,所以直接用阻塞式读键最舒服。调用INT 16H后,你会在两个寄存器里拿到信息:
AL:ASCII码,普通字符按键盘会得到对应的ASCII值。AH:扫描码,每个物理按键对应一个固定的扫描码,方向键的扫描码分别是:- 上:
48H - 下:
50H - 左:
4BH - 右:
4DH
- 上:
注意,方向键在INT 16H里并不会返回普通的ASCII码,它返回的是扩展键,通常AL=0,真正的按键身份在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) -> (PX+1, PY) ; 如果 (PX+1, PY) 是箱子,再看 (PX+2, 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: ; 计算箱子前方 (PX+2, 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或40x25,VGA文本模式):直接往显存的文本缓冲区(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:[DI+1], 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:[DI+1], 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自带的TD(Turbo 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)都写了注释,两周后再打开代码,依然能很快回忆起每一块在干什么。这种代码习惯在高级语言里叫可维护性,在汇编里,它就是你的命。