Easy 6502

原作者 Nick Morgan,采用 CC BY 4.0 许可

在 GitHub 上查看

介绍

这本小小的电子书会带你开始编写 6502 汇编语言。6502 处理器在 20 世纪七八十年代非常流行,许多著名计算机和游戏机都使用过它,例如 BBC MicroAtari 2600Commodore 64Apple IINintendo Entertainment System。 《飞出个未来》里的 Bender 脑子里也有一颗 6502甚至终结者也是用 6502 写的

那么,为什么还要学 6502?它不是已经过时了吗?嗯,拉丁语也过时了,但学校里仍然有人学。 证毕

事实上,我被可靠地告知,6502 系列处理器仍然由 Western Design Center 生产,并且仍然 卖给爱好者。所以 6502 显然并没有死掉。

认真地说,理解汇编语言很有价值。汇编语言是计算机中最低层、但仍然可读的抽象层。汇编会直接转换成处理器执行的字节。理解它之后,你基本上就像掌握了一点计算机 魔法

那为什么是 6502,而不是更“实用”的汇编语言,比如 x86?我并不认为学习 x86 对大多数人有多实用。你大概率不会在日常工作里手写汇编;这更像是一场扩展思维的学习练习。6502 诞生在另一个时代,当时许多开发者直接写汇编,而不是使用这些“新潮”的高级语言。因此它本来就是为人类书写而设计的。更现代的汇编语言通常是给编译器生成的,就把它们留给编译器吧。况且,6502 很有趣。没人会说 x86 很有趣。

第一个程序

开始吧!下面是一个小型 JavaScript 6502 汇编器和模拟器,我把它改造成了本书使用的版本。点击 汇编,再点击 运行,即可汇编并运行这段代码。

如果一切正常,右侧黑色区域的左上角现在应该出现了三个彩色“像素”。如果没有,请尝试换用较新的浏览器,例如 Chrome 或 Firefox。

这个程序到底做了什么?我们用调试器一步步看。点击 重置,勾选 调试器,然后点击一次 单步。如果你观察得很仔细,会发现 A=$00 变成了 $01PC=$0600 变成了 $0602

在 6502 汇编中,以 $ 开头的数字是十六进制数。本书也遵循这个约定。如果你不熟悉十六进制,可以先读一下 Wikipedia 的介绍。以 # 开头的是字面量数值。其他数字通常表示内存地址。

有了这些背景,你应该能看出 LDA #$01 会把十六进制值 $01 载入 A 寄存器。寄存器会在下一节详细介绍。

再次点击 单步 执行第二条指令。模拟器显示区域左上角的像素应该变成白色。这个模拟器使用 $0200$05ff 的内存地址来绘制屏幕像素。数值 $00$0f 代表 16 种颜色,其中 $00 是黑色,$01 是白色。因此,把 $01 存到 $0200 就是在左上角画一个白色像素。这比真实计算机输出视频的方式简单得多,但足够我们入门。

所以,STA $0200 会把 A 寄存器里的值存到内存地址 $0200。继续点击 单步 四次,执行剩下的指令,并观察 A 寄存器如何变化。

练习

  1. 修改这三个像素的颜色。
  2. 把其中一个像素改到右下角绘制,内存地址是 $05ff
  3. 添加更多指令,绘制额外的像素。

寄存器和标志位

我们已经简单看过处理器状态区域,也就是包含 APC 等内容的那一块。它们分别代表什么?

第一行显示 AXY 寄存器。A 常被称为“累加器”。每个寄存器保存一个字节,大多数操作都会处理这些寄存器里的内容。

SP 是栈指针。我们暂时不深入讲栈,但可以先这样理解:每当一个字节被压入栈中,这个寄存器就会递减;每当一个字节从栈中弹出,它就会递增。

PC 是程序计数器。处理器靠它知道当前执行到程序的哪个位置。它有点像正在运行的脚本里的当前行号。在这个 JavaScript 模拟器中,代码从内存地址 $0600 开始汇编,所以 PC 总是从那里开始。

最后一部分显示处理器标志位。每个标志占一位,因此这些标志会一起保存在一个字节中。处理器会根据上一条指令的结果设置这些标志。后面会继续讲。你也可以 在这里阅读更多寄存器和标志位资料

指令

汇编语言中的指令就像一组预定义的小函数。所有指令都接受零个或一个参数。下面这段带注释的源码介绍了几条不同的指令:

汇编代码,打开调试器,然后单步执行。注意观察 AX 寄存器。执行到 ADC #$c4 这一行时会发生一点奇怪的事。你可能以为 $c4$c0 会得到 $184,但处理器给出的结果是 $84。这是为什么?

问题在于 $184 太大,放不进一个字节中。一个字节的最大值是 $FF,而这些寄存器只能保存一个字节。不过处理器并不傻:如果你足够仔细,会发现这次操作之后进位标志被设置为 1。这就是它告诉你发生了进位的方式。

在下面的模拟器中,请手动输入以下代码,不要粘贴:

LDA #$80
STA $01
ADC $01
这里有一个重要区别:`ADC #$01` 和 `ADC $01` 并不一样。前者把数值 `$01` 加到 `A` 寄存器,后者把内存地址 `$01` 中保存的值加到 `A` 寄存器。 汇编代码,勾选 **监视器**,然后单步执行这三条指令。监视器会显示一段内存,有助于观察程序运行。`STA $01` 把 `A` 寄存器的值存到内存地址 `$01`,`ADC $01` 再把地址 `$01` 中的值加到 `A` 寄存器。`$80 + $80` 应该等于 `$100`,但它超过了一个字节,所以 `A` 寄存器会变成 `$00`,进位标志会被设置。同时,零标志也会被设置。所有结果为零的指令都会设置零标志。 6502 指令集的完整列表可以在 [这里](http://www.6502.org/tutorials/6502opcodes.html) 和 [这里](http://www.obelisk.me.uk/6502/reference.html) 找到。我通常会同时参考这两页,因为它们各有优缺点。这些页面说明了每条指令的参数、会使用哪些寄存器,以及会设置哪些标志位。它们就是你的说明书。 ### 练习 ### 1. 你已经见过 `TAX`。你大概能猜到 `TAY`、`TXA` 和 `TYA` 做什么,但请写点代码验证你的猜想。 2. 把本节第一个示例改写成使用 `Y` 寄存器,而不是 `X` 寄存器。 3. `ADC` 的反向操作是 `SBC`,也就是带进位减法。写一个使用它的程序。

分支

到目前为止,我们只能写没有分支逻辑的基础程序。现在来改变这一点。 6502 汇编有一组分支指令,它们都会根据某些标志位是否被设置来决定是否跳转。本例会使用 `BNE`,意思是“不相等时分支”。

首先把 $08 载入 X 寄存器。下一行是一个标签。标签用于标记程序中的某个位置,方便稍后跳回这里。标签之后,我们递减 X,把它存到 $0200,也就是左上角像素,然后把它和 $03 比较。 CPX 会把 X 寄存器中的值和另一个值进行比较。如果两个值相等,Z 标志会被设置为 1;否则会被设置为 0

下一行 BNE decrement 的含义是:如果 Z 标志为 0,也就是刚才 CPX 比较的两个值不相等,就跳转到 decrement 标签。否则它什么也不做,程序继续把 X 存到 $0201,然后结束。

在汇编语言中,分支指令通常会配合标签使用。汇编之后,标签会被转换成一个单字节的相对偏移量,也就是从下一条指令开始向前或向后移动多少个字节。因此分支指令只能在大约 256 字节范围内前后移动,适合在局部代码里跳转。如果要跳到更远的位置,需要使用跳转指令。

练习

  1. BNE 的反义指令是 BEQ。尝试写一个使用 BEQ 的程序。
  2. BCCBCS 分别表示“进位清除时分支”和“进位设置时分支”。写一个使用其中之一的程序。

寻址模式

6502 使用 16 位地址总线,意味着处理器可访问 65536 字节内存。记住,一个字节可以用两个十六进制字符表示,因此内存地址通常写成 $0000 - $ffff。引用这些内存地址有多种方式,下面逐一介绍。

看这些例子时,建议使用内存监视器观察内存变化。监视器需要一个起始内存地址和要显示的字节数,两者都用十六进制输入。例如,要从 $c000 开始显示 16 字节内存,就在 起始地址长度 中分别输入 c00010

绝对寻址:$c000

绝对寻址会把完整内存地址作为指令参数。例如:

STA $c000 ;把累加器中的值存到内存地址 $c000

零页寻址:$c0

所有支持绝对寻址的指令,除了跳转指令外,也可以选择使用单字节地址。这种寻址方式叫做“零页寻址”,因为它只能访问内存第一页,也就是前 256 字节。它速度更快,因为只需要查找一个字节;同时汇编后的代码也更小。

零页,X:$c0,X

这里开始变得有趣了。在这种模式中,先给出一个零页地址,然后把 X 寄存器的值加上去。例如:

LDX #$01   ;X 为 $01
LDA #$aa   ;A 为 $aa
STA $a0,X ;把 A 的值存到内存地址 $a1
INX        ;递增 X
STA $a0,X ;把 A 的值存到内存地址 $a2

如果加法结果超过一个字节,地址会回绕。例如:

LDX #$05
STA $ff,X ;把 A 的值存到内存地址 $04

零页,Y:$c0,Y

这相当于零页,X 的 Y 版本,但只能配合 LDXSTX 使用。

绝对,X 和绝对,Y:$c000,X$c000,Y

它们是零页,X 和零页,Y 的绝对寻址版本。例如:

LDX #$01
STA $0200,X ;把 A 的值存到内存地址 $0201

与零页,Y 不同,绝对,Y 不能用于 STX,但可以用于 LDASTA

立即数:#$c0

立即数寻址严格来说并不处理内存地址,而是直接使用实际数值。例如,LDX #$01 会把数值 $01 载入 X 寄存器。这和零页指令 LDX $01 完全不同,后者会把内存地址 $01 中的值载入 X

相对寻址:$c0 或标签

相对寻址用于分支指令。这类指令接受一个字节,并把它作为相对于下一条指令的偏移量。

汇编下面的代码,然后点击 十六进制转储 查看汇编后的代码。

十六进制结果大致如下:

a9 01 c9 02 d0 02 85 22 00

a9c9 分别是立即数寻址的 LDACMP 操作码。0102 是这些指令的参数。d0BNE 的操作码,它的参数是 02。这表示“跳过接下来的两个字节”,也就是 STA $22 汇编后的 85 22。试着把 STA 改为使用两字节绝对地址,而不是单字节零页地址,例如把 STA $22 改成 STA $2222。重新汇编并查看十六进制转储,BNE 的参数应该变成 03,因为处理器要跳过的那条指令现在长 3 个字节。

隐含寻址

有些指令不处理内存地址,例如 INX,它递增 X 寄存器。这些指令使用隐含寻址,因为参数由指令本身隐含。

间接寻址:($c000)

间接寻址使用一个绝对地址去查找另一个地址。第一个地址保存目标地址的低位字节,后一个字节保存高位字节。听起来有点绕,所以看个例子:

在这个例子中,$f0 包含 $01$f1 包含 $cc。指令 JMP ($f0) 会让处理器读取 $f0$f1 中的两个字节,也就是 $01$cc,并把它们组合成地址 $cc01,这个地址会成为新的程序计数器。汇编并单步执行上面的程序,看看会发生什么。关于 JMP,后面的 跳转 一节还会讲。

索引间接寻址:($c0,X)

这个模式有点奇特,像是零页,X 和间接寻址的混合体。基本做法是:取一个零页地址,加上 X 寄存器的值,然后用结果去查找一个两字节地址。例如:

内存地址 $01$02 分别包含 $05$07。可以把 ($00,X) 理解为 ($00 + X)。这里 X$01,所以它简化为 ($01)。接下来就像标准间接寻址一样:读取 $01$02 处的两个字节,即 $05$07,组成地址 $0705。前一条指令把 Y 寄存器存到了这个地址,所以 A 寄存器会通过这条绕远路得到和 Y 相同的值。这个模式你不会经常见到。

间接索引寻址:($c0),Y

间接索引寻址和索引间接寻址类似,但没那么离谱。它不是在解引用之前加上 X,而是先解引用零页地址,然后把 Y 寄存器加到得到的地址上。

这里,($01) 会读取 $01$02 中的两个字节:$03$07。它们组成地址 $0703。然后 Y 寄存器的值会加到这个地址上,得到最终地址 $0704

练习

  1. 尝试写一些代码片段,覆盖每一种 6502 寻址模式。记得可以用监视器观察一段内存。

6502 处理器中的栈和其他栈一样:值会被压入栈,也会从栈中弹出。在 6502 的术语里,弹出常叫做 “pull”。栈的当前深度由一个特殊寄存器,也就是栈指针来衡量。栈位于 $0100$01ff 之间。栈指针初始值是 $ff,指向内存地址 $01ff。当一个字节压栈时,栈指针变成 $fe,也就是内存地址 $01fe,依此类推。

两个栈相关指令是 PHAPLA,分别表示“压入累加器”和“拉出到累加器”。下面是一个示例:

X 保存像素颜色,Y 保存当前像素位置。第一个循环把当前颜色画成一个像素,通过 A 寄存器完成,然后把颜色压栈,再递增颜色和位置。第二个循环从栈中弹出颜色,画出弹出的颜色,再递增位置。不难预期,这会生成一个镜像图案。

跳转

跳转和分支类似,但有两个主要区别。第一,跳转不会根据条件执行;第二,它使用两字节绝对地址。对于小程序来说,第二点不太重要,因为你大多会使用标签,汇编器会根据标签算出正确的内存地址。但对于较大的程序,跳转是从一段代码移动到另一段代码的唯一方式。

JMP

JMP 是无条件跳转。下面是一个非常简单的例子:

JSR/RTS

JSRRTS 分别表示“跳转到子程序”和“从子程序返回”,它们经常成对出现。JSR 用于从当前位置跳到代码中的另一处,RTS 返回之前的位置。这基本上就像调用函数并返回。

处理器之所以知道要返回哪里,是因为 JSR 会在跳转前把下一条指令地址减一后的值压入栈。RTS 会弹出这个地址,加一,然后跳到那里。示例:

第一条指令会让执行跳到 init 标签。它设置 X,然后返回到下一条指令 JSR loop。这会跳到 loop 标签,并递增 X,直到它等于 $05。之后程序返回到下一条指令 JSR end,最终跳到文件末尾。这个例子展示了 JSRRTS 如何组合起来编写模块化代码。

制作一个游戏

现在,把这些知识用起来,做一个游戏!我们要制作一个非常简单的经典贪吃蛇游戏。

虽然这是一个简化版,但代码会比之前的例子大得多。我们需要用多个内存地址来记录游戏的各方面状态。像前面一样手动做所有记录当然也可以,但规模一大就会很繁琐,也容易出现难以发现的 bug。所以现在我们让汇编器帮我们做一些无聊的工作。

在这个汇编器中,我们可以定义描述性常量,也叫符号,用来代表数字。后续代码就可以直接使用常量,而不是写字面量数字,这会让代码表达的含义更明显。名称中可以使用字母、数字和下划线。

示例。注意,立即数操作数仍然要以 # 开头。

下面的模拟器窗口包含整个游戏的源代码。接下来的小节会解释它的工作方式。

Willem van der Jagt 制作了一个 完整注释版 gist,想看更多细节可以配合阅读。

{% include start.html %} ; ___ _ __ ___ __ ___ ; / __|_ _ __ _| |_____ / /| __|/ \_ ) ; \__ \ ' \/ _` | / / -_) _ \__ \ () / / ; |___/_||_\__,_|_\_\___\___/___/\__/___|

; 改变方向:W A S D

define appleL $00 ; 苹果屏幕位置,低位字节 define appleH $01 ; 苹果屏幕位置,高位字节 define snakeHeadL $10 ; 蛇头屏幕位置,低位字节 define snakeHeadH $11 ; 蛇头屏幕位置,高位字节 define snakeBodyStart $12 ; 蛇身字节对起点 define snakeDirection $02 ; 方向(可选值见下方) define snakeLength $03 ; 蛇身长度,单位为字节

; 方向(每个方向使用单独的位) define movingUp 1 define movingRight 2 define movingDown 4 define movingLeft 8

; 控制蛇的按键 ASCII 值 define ASCII_w $77 define ASCII_a $61 define ASCII_s $73 define ASCII_d $64

; 系统变量 define sysRandom $fe define sysLastKey $ff

jsr init jsr loop

init: jsr initSnake jsr generateApplePosition rts

initSnake: lda #movingRight ;初始方向 sta snakeDirection

lda #4 ;初始长度(2 段) sta snakeLength

lda #$11 sta snakeHeadL

lda #$10 sta snakeBodyStart

lda #$0f sta $14 ;身体段 1

lda #$04 sta snakeHeadH sta $13 ;身体段 1 sta $15 ;身体段 2 rts

generateApplePosition: ;把新的随机字节载入 $00 lda sysRandom sta appleL

;把 2 到 5 之间的新随机数载入 $01 lda sysRandom and #$03 ;屏蔽最低 2 位 clc adc #2 sta appleH

rts

loop: jsr readKeys jsr checkCollision jsr updateSnake jsr drawApple jsr drawSnake jsr spinWheels jmp loop

readKeys: lda sysLastKey cmp #ASCII_w beq upKey cmp #ASCII_d beq rightKey cmp #ASCII_s beq downKey cmp #ASCII_a beq leftKey rts upKey: lda #movingDown bit snakeDirection bne illegalMove

lda #movingUp sta snakeDirection rts rightKey: lda #movingLeft bit snakeDirection bne illegalMove

lda #movingRight sta snakeDirection rts downKey: lda #movingUp bit snakeDirection bne illegalMove

lda #movingDown sta snakeDirection rts leftKey: lda #movingRight bit snakeDirection bne illegalMove

lda #movingLeft sta snakeDirection rts illegalMove: rts

checkCollision: jsr checkAppleCollision jsr checkSnakeCollision rts

checkAppleCollision: lda appleL cmp snakeHeadL bne doneCheckingAppleCollision lda appleH cmp snakeHeadH bne doneCheckingAppleCollision

;吃掉苹果 inc snakeLength inc snakeLength ;增加长度 jsr generateApplePosition doneCheckingAppleCollision: rts

checkSnakeCollision: ldx #2 ;从第二段开始 snakeCollisionLoop: lda snakeHeadL,x cmp snakeHeadL bne continueCollisionLoop

maybeCollided: lda snakeHeadH,x cmp snakeHeadH beq didCollide

continueCollisionLoop: inx inx cpx snakeLength ;到达最后一段且没有碰撞 beq didntCollide jmp snakeCollisionLoop

didCollide: jmp gameOver didntCollide: rts

updateSnake: ldx snakeLength dex txa updateloop: lda snakeHeadL,x sta snakeBodyStart,x dex bpl updateloop

lda snakeDirection lsr bcs up lsr bcs right lsr bcs down lsr bcs left up: lda snakeHeadL sec sbc #$20 sta snakeHeadL bcc upup rts upup: dec snakeHeadH lda #$1 cmp snakeHeadH beq collision rts right: inc snakeHeadL lda #$1f bit snakeHeadL beq collision rts down: lda snakeHeadL clc adc #$20 sta snakeHeadL bcs downdown rts downdown: inc snakeHeadH lda #$6 cmp snakeHeadH beq collision rts left: dec snakeHeadL lda snakeHeadL and #$1f cmp #$1f beq collision rts collision: jmp gameOver

drawApple: ldy #0 lda sysRandom sta (appleL),y rts

drawSnake: ldx snakeLength lda #0 sta (snakeHeadL,x) ;擦除尾部末端

ldx #0 lda #1 sta (snakeHeadL,x) ;绘制蛇头 rts

spinWheels: ldx #0 spinloop: nop nop dex bne spinloop rts

gameOver: {% include end.html %}

整体结构

开头的注释块之后,前两行是:

jsr init
jsr loop

initloop 都是子程序。init 初始化游戏状态,loop 是主游戏循环。

loop 子程序本身只是依次调用多个子程序,然后跳回自身:

loop:
  jsr readkeys
  jsr checkCollision
  jsr updateSnake
  jsr drawApple
  jsr drawSnake
  jsr spinwheels
  jmp loop

首先,readkeys 检查方向键 W、A、S、D 是否被按下,如果有,就相应设置蛇的方向。然后 checkCollision 检查蛇是否撞到自己或苹果。updateSnake 根据当前方向更新蛇的内部表示。接着绘制苹果和蛇。最后,spinWheels 让处理器做一些空转,防止游戏运行得太快。可以把它理解成 sleep 命令。游戏会一直运行,直到蛇撞墙或撞到自己。

零页的使用

零页内存用于保存若干游戏状态变量,游戏开头的注释块里也列出了它们。$00$01 以及 $10 往后的内容都是成对字节,代表一个两字节内存地址,稍后会用间接寻址去查找。所有这些内存地址都会落在 $0200$05ff 之间,也就是模拟器显示区域对应的内存。例如,如果 $00$01 分别包含 $01$02,它们就指向显示区域的第二个像素:$0201。别忘了,间接寻址中低位字节在前。

前两个字节保存苹果的位置。每次蛇吃掉苹果后都会更新。字节 $02 保存当前方向:1 表示上,2 表示右,4 表示下,8 表示左。这些数字的原因稍后会变清楚。

最后,字节 $03 保存蛇的当前长度,单位是内存中的字节数。因此长度为 4 表示 2 个像素。

初始化

init 子程序会调用 initSnakegenerateApplePositioninitSnake 设置蛇的方向、长度,并载入蛇头和身体的初始内存地址。$10 处的字节对保存蛇头的屏幕位置,$12 处的字节对保存唯一身体段的位置,$14 保存尾部位置。尾部是身体的最后一段,会被画成黑色来制造移动效果。相关代码如下:

lda #$11
sta $10
lda #$10
sta $12
lda #$0f
sta $14
lda #$04
sta $11
sta $13
sta $15

这会把 $11 存到 $10,把 $10 存到 $12,把 $0f 存到 $14。接着把 $04 存到 $11$13$15。内存看起来会像这样:

0010: 11 04 10 04 0f 04

它表示间接寻址的内存地址 $0411$0410$040f,也就是屏幕中间的三个像素。这里说得有点细,但完全理解间接寻址很重要。

下一个子程序 generateApplePosition 会把苹果位置设置到显示区域中的一个随机位置。首先,它把一个随机字节载入累加器。在这个模拟器中,$fe 是随机数生成器。这个值会被存到 $00。接着再载入另一个随机字节,并与 $03AND 运算。这部分需要稍微绕一下。

十六进制值 $03 用二进制表示是 00000011AND 操作码会把参数和累加器进行按位与。例如,如果累加器中是二进制 10101010,和 00000011AND 后会得到 00000010

效果是只保留累加器最低的两位,把其他位清零。这样就把 0 到 255 范围内的数转换成了 0 到 3 范围内的数。

之后,程序把 2 加到累加器,得到一个 2 到 5 之间的随机数。

这个子程序最终会把一个随机字节载入 $00,把一个 2 到 5 之间的随机数载入 $01。由于间接寻址中低位字节在前,这会转换成 $0200$05ff 之间的内存地址,正好对应显示区域。

游戏循环

几乎所有游戏的核心都是游戏循环。游戏循环通常都遵循同一种形式:接受用户输入,更新游戏状态,然后渲染游戏状态。这个循环也一样。

读取输入

第一个子程序 readKeys 负责接受用户输入。在这个模拟器中,内存地址 $ff 保存最近一次按键的 ASCII 码。程序把这个值载入累加器,然后依次和 $77$64$73$61 比较,它们分别是 W、D、S、A 的十六进制编码。如果某次比较成功,程序就分支到对应代码段。每个代码段,例如 upKeyrightKey,都会先检查当前方向是否与新方向相反。这又需要一点解释。

如前所述,四个方向在内部用 1、2、4、8 表示。它们都是 2 的幂,因此二进制表示中都只有一个 1

1 => 0001(上)
2 => 0010(右)
4 => 0100(下)
8 => 1000(左)

BIT 操作码类似 AND,但计算结果只用于设置零标志,实际结果会被丢弃。只有当累加器和参数按位与的结果为零时,零标志才会被设置。对于 2 的幂来说,只有当两个数字不是同一个方向时,零标志才会被设置。例如,0001 AND 0001 不是零,但 0001 AND 0010 是零。

所以看 upKey:如果当前方向是下,也就是 4,位测试结果不会为零。BNE 表示“零标志清除时分支”,此时会跳到 illegalMove,它只是从子程序返回。否则,新方向 1 会被存到相应内存地址。

更新游戏状态

下一个子程序 checkCollision 会调用 checkAppleCollisioncheckSnakeCollisioncheckAppleCollision 只检查保存苹果位置的两个字节是否和保存蛇头位置的两个字节相同。如果相同,就增加长度并生成新的苹果位置。

checkSnakeCollision 会遍历蛇的身体段,把每个字节对和蛇头字节对比较。如果发现匹配,就游戏结束。

碰撞检测之后,我们更新蛇的位置。从高层看,它分三步:第一,把身体的每个字节对向后移动一个位置;第二,根据当前方向更新蛇头;第三,如果蛇头越界,就按碰撞处理。下面用 ASCII 图说明。为简单起见,每个括号里是 x,y 坐标,而不是字节对。

  0    1    2    3    4
头部                 尾部
[1,5][1,4][1,3][1,2][2,2]    起始位置
[1,5][1,4][1,3][1,2][1,2]    (3) 的值复制到 (4)
[1,5][1,4][1,3][1,3][1,2]    (2) 的值复制到 (3)
[1,5][1,4][1,4][1,3][1,2]    (1) 的值复制到 (2)
[1,5][1,5][1,4][1,3][1,2]    (0) 的值复制到 (1)
[0,5][1,5][1,4][1,3][1,2]    根据方向更新 (0)

在底层,这个子程序稍微复杂一点。首先把长度载入 X 寄存器,然后递减。下面的片段展示了蛇的初始内存:

内存地址: $10 $11 $12 $13 $14 $15
值:       $11 $04 $10 $04 $0f $04

长度初始化为 4,所以 X 一开始是 3LDA $10,x 会把 $13 的值载入 A,然后 STA $12,x 会把这个值存到 $15。接着 X 递减并继续循环。现在 X2,所以读取 $12 并存到 $14。只要 X 是正数,循环就会继续。BPL 表示“为正时分支”。

当蛇身中的值移动完毕后,就要决定蛇头怎么移动。程序先把方向载入 ALSR 表示逻辑右移,也就是把所有位向右移动一位。最低位会被移入进位标志,所以如果累加器是 1,执行 LSR 后它会变成 0,并设置进位标志。

为了判断方向是 124 还是 8,代码会不断右移,直到进位被设置。一次 LSR 表示上,两次表示右,依此类推。

接下来的一段代码根据方向更新蛇头。这可能是整段程序最复杂的部分,它完全依赖内存地址如何映射到屏幕,所以我们仔细看一下。

你可以把屏幕想象成四条横向区域,每条是 32 × 8 像素。这四条区域分别映射到 $0200-$02ff$0300-$03ff$0400-$04ff$0500-$05ff。第一行像素是 $0200-$021f,第二行是 $0220-$023f,第三行是 $0240-$025f,依此类推。

只要在同一条横向区域内移动,事情很简单。比如向右移动,只需要递增低位字节,$0200 会变成 $0201。向下移动就加 $20,例如 $0200 变成 $0220。向左和向上则相反。

跨区域移动会复杂一些,因为还要考虑高位字节。例如,从 $02e1 向下移动应该到 $0301。幸运的是这不难:把 $20 加到 $e1 会得到 $01 并设置进位位。如果进位位被设置,我们就知道还需要递增高位字节。

每次按方向移动后,还需要检查蛇头是否越界。每个方向的处理方式不同。对于左右移动,可以检查蛇头是否实际上“换行”了。比如从 $021f 向右移动,递增低位字节会得到 $0220,但这其实是从第一行最后一个像素跳到了第二行第一个像素。因此每次向右移动时,都要检查新的低位字节是否为 $20 的倍数。代码通过对掩码 $1f 做位检查来实现。下面的例子展示了为什么屏蔽最低 5 位可以判断一个数是否为 $20 的倍数。

$20: 0010 0000
$40: 0100 0000
$60: 0110 0000
$1f: 0001 1111

这里不再深入解释每个方向的全部实现,但上面的说明应该足够你通过阅读代码推导出来。

渲染游戏

由于游戏状态本身就是以像素位置保存的,渲染非常直接。第一个子程序 drawApple 极其简单:它把 Y 设置为零,把一个随机颜色载入累加器,然后把这个值存到 ($00),y$00 保存苹果的位置,所以 ($00),y 会解引用到对应内存地址。更多细节可以回看 寻址模式 中的“间接索引寻址”。

接下来是 drawSnake。它也很简单:先擦除尾部,再绘制头部。程序把 X 设置为蛇的长度,以便索引到正确的像素;把 A 设置为零,然后使用索引间接寻址写入。随后重新把 X 设置为头部索引,把 A 设置为一,并存到 ($10,x)$10 保存蛇头的两字节位置,因此这会在当前蛇头位置画一个白色像素。因为只有蛇头和蛇尾会移动,这就足以让蛇动起来。

最后一个子程序 spinWheels 只是为了让游戏不要跑得太快。它所做的就是让 X 从零开始倒数,直到再次回到零。第一次 dex 会回绕,让 X 变成 #$ff