起因

系列第四篇:上一篇把按键编码摸清了,这一篇让它"动起来"——用方向键控制一个精灵(先用星号占位)在屏幕上移动,顺便第一次接触 PPU(FC 的图像处理器)的寄存器。以后再把星号换成像游戏人物的图片,加上背景图。

需求

  • 上下左右操作一个星号精灵移动
  • 实时显示坐标,不出屏幕边界

技术实现原理

FC 图形系统:背景 + 64 个硬件精灵

PPU 分两层画图:背景层(nametable 里摆的图案)和精灵层。精灵有 64 个,每个在 OAM(Object Attribute Memory)里占 4 字节,顺序是 y 坐标、tile 编号、属性、x 坐标

代码里的 putSP 宏就是直接写 OAM:先向 0x2003 写 OAM 地址(n*4 即第 n 个精灵的起始字节),再往 0x2004 连续写 y、t、a、x 四个字节。改了 OAM 里的 x/y,PPU 下一帧就把精灵画到新位置——“星号会动而背景不动"的原理就在这:坐标在 OAM,背景在 nametable,两回事

镜像(mirroring):为什么看到的是"田字格"左上

NES 的显存只放得下两块物理 nametable,逻辑上却要排成四个(田字格),多出来的靠"镜像"复用:水平镜像=左右共享、垂直镜像=上下共享。镜像方式本该写在卡带 NES 头信息里;因为缺少nes头信息的配置,cc65会有默认生成nes的配置——默认情况下使用水平镜像,田字格的左上和左下是镜像,我们看到的就是田字格的左上角显示部分。

精灵为什么不出现在卷轴查看器里

调试器的卷轴查看器画的是 nametable(背景);精灵是 PPU 扫描每一行时实时叠上去的,不存放在 nametable 里。另外精灵不属于卷轴里固定显示的内容,右边的卷轴查看器不会看到精灵(也就是那个星号)。

PPU 控制寄存器:0x2000 与 0x2001

  • 0x2000(PPUCTRL)写 0x80 = 二进制 10000000:bit7 置 1,VBlank 中断使能。CC65 的 waitvsync() 就靠它提供每帧 1/60 秒的节拍;
  • 0x2001(PPUMASK)写 0x1E = 二进制 00011110:bit1~bit4 依次打开"背景左侧 8 像素显示、背景显示、精灵左侧 8 像素显示、精灵显示”——不开这些位,屏幕是黑的。

和(三)的关键差异:按住就连续动

(三)打印按键值用"值变了才处理"防闪烁;移动精灵要相反——按住就每帧走一格,所以每次循环都做位与检测,再 waitvsync() 等一次垂直消隐,一秒 60 帧就是一秒走 60 格。边沿触发还是电平触发,按业务选。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include "conio.h"
#include "nes.h"
#include "stdio.h"
#include "stdlib.h"
#include "string.h"
// 加载手柄驱动
#include <joystick.h>

typedef unsigned char u8;
#define address(add) (*(u8 *)(add))

// 定义读取手柄1
#define J1 joy_read (JOY_1);
#define J2 joy_read (JOY_2);

#define putSP(n,x,y,t,a)    address(0x2003)=n*4;\
                            address(0x2004)=y;\
                            address(0x2004)=t;\
                            address(0x2004)=a;\
                            address(0x2004)=x

// 初始位置坐标
u8 x=100,y=60;
unsigned int p1;

// 1P 坐标
void read_joy1(){
    p1 = J1;
    //第一个&是与运算,结果是二进制的 00010000 跟后面的 0x10 做and比较,相同结果表示按键按下了
    //因为屏幕是255x240的分辨率 所以y轴方向是0-239
    if((p1 & 0x10) && 0x10){if(y>239){y=239;}else if(y==0){y=239;}else{y--;};}
    if((p1 & 0x20) && 0x20){++y;if(y>239)y=0;}
    //左右方向有255个点,不会出现越界数字 x的范围是在 0-254 暂时没处理255
    if((p1 & 0x40) && 0x40){--x;}
    if((p1 & 0x80) && 0x80){++x;}
}

void main()
{
    // 清屏
    clrscr();
    // 初始化手柄
    joy_install (joy_static_stddrv);

    //这部分是开ppu显示屏幕,只在一个屏幕内显示
        //后面在研究透彻了这2个地址再回来补全注释
    address(0x2000) = 0x80;
    address(0x2001) = 0x1e;

    while(1){
        // 读取1P键位并处理坐标
        read_joy1();
        waitvsync();
        putSP(0,x,y,'*',0);
        gotoxy(0,0);
        cprintf("x:%-3d y:%-3d ",x,y);
    }
}

代码说明

  • 0x10 / 0x20 / 0x40 / 0x80 的编码看上一篇里面有介绍,对应 上下左右 按键;
  • (p1 & 0x10) && 0x10 后半个 && 0x10 其实是多余的:位与结果非零即为真,写 if (p1 & 0x10) 完全等价(保留原写法当对比没问题);
  • 2000 和 2001 地址的作用见上面"技术实现原理":0x80 是二进制的 10000000,VBlank 使能;0x1E 是二进制的 00011110,打开背景和精灵显示;
  • y 是 u8(0255)、屏幕高 240,所以对 239 做了封顶/回绕;x 方向 0254 暂时没处理 255(u8 溢出会回绕到 0)。

编译


使用cc65的命令生成 nes文件的命令

1
cl65 -t nes -o 简单精灵.nes 简单精灵.c

源文件是 简单精灵.c

-t nes 就是告诉cc65 生成文件对应的平台是nes

踩坑记录与注意事项

  • 屏幕全黑:先查 0x2001 的 0x1E 没写(显示没开),再查 0x2000 的 0x80——waitvsync 靠 VBlank 中断,没开启程序会卡死在等待里;
  • 渲染期间写 0x2004 可能花屏:PPU 正在画时改 OAM 有竞争,正规做法是等 VBlank(waitvsync 之后)再写,或用 OAMDMA(0x4014)整块搬运;本 demo 数据量小直接写没出问题;
  • 精灵实际显示位置比 OAM 的 y 值大 1 行:NES 的历史怪癖,做精确对位时记得补偿;
  • 一条扫描线最多 8 个精灵:超出会被 PPU 丢弃造成闪烁,以后精灵多了(人物+子弹)要错开 y;
  • conio 混用:gotoxy/cprintf 走背景层文本,改动镜像配置后定位可能乱。