大家好,很高興再次見到各位。能夠再次見面代表上一課的難度沒有把你難倒~(笑),實在是太好了~
如果各位在課後自行上網搜索相關資料,你應該會發現大部分的NES教程都是使用匯編語言(Assembly)來進行。
其中最知名的NES教程式叫做Nerdy Nights(科技宅之夜),這篇經典教程就是使用匯編來教的。
而美國知名的卡內基美隆大學,曾經在2004年的春天開設過一次NES編程的正式課程。那堂課程使用的是講師Bob Rost自製的N-Basic語言來開發,算是比匯編要好用那麼一點點...
傅老師在參考了大量的教程之後,最終選擇了shiru大神所開發的工具,搭配CC65這套C編譯器來進行教學。
一個原因是因為shiru做工具的真的好用,第二個原因是因為這麼好用的工具竟然搜遍網路也沒看到半篇教程。所以我希望為shiru以及他的作品做些事情,也能夠讓這些知識傳遞下去。
希望這堂課有朝一日能夠踏進國內大學的講台,為遊戲教學一次奠定歷史與編程能力兩大基礎。
任天堂硬件原理概述
若您對硬件興趣不大,或是發現閱讀以下這段出現了昏迷、昏睡、甚至嘴角已經露出口水的狀況,
請現在、立刻、馬上跳到本段小結部分(但如果你今天不巧失眠,那就...繼續看下去吧~~這段應該可以讓你睡得很安穩)。
以下是任天堂硬件系統架構簡圖,以及NES版本之主板照片(both from Nerdy Nights):
(那個...右邊是純粹賣弄用的,我們看左邊就好,呵呵...)。
上半部是主機(標為NES),下半部是卡匣(標為CART,意指Cartridge)。
我們先從上半部中央的CPU開始,逐漸繞向周圍認識各個模塊:
- 任天堂的CPU是一顆叫做6502的超級明星CPU,使用這顆CPU的機種要請大家注意一下:Apple II(蘋果電腦鎮店首作)、Atari 2600(不認識?看他的搖桿)、Commordore PET、還有就是我們的主角Nintendo Entertainment System(NES)。6502的登場就是整個家用遊戲機歷史的開端。
而任天堂的6502使用的則是一個特別版本,他與一顆音效處理晶片(Audio Processing Unit,APU)直接整在一起,整顆包起來叫做2A03。這也是為什麼我們在玩8-bit音樂tracker之時,任天堂的音效被稱為2A03之故。Alright重點就是,NES的CPU與APU是整合在一起的。 - 從CPU向左看,有一顆圖形處理器(Picture Processing Unit,PPU),這顆晶片專門負責將圖形畫到電視機上面去。CPU與PPU像是兩個好兄弟,合力將整個遊戲運算出來呈現給玩家。PPU擁有自己的暫存器與記憶體,他會依照自己暫存器與記憶體的內容去做事情,是紅白機的第二個大腦。雖說是第二大腦,但請各位注意,第二個大腦PPU是不允許使用程序來直接存取的---我們只能透過第一大腦CPU去存取第二大腦PPU,而且存取的步驟會有點繁瑣。所以在傳統的NES教程中,你會很容易學PPU相關操作學到睡著。。
- 承上,兩大大腦CPU與PPU內都有RAM記憶體可以使用。CPU有2KB(在板上),PPU有256B(晶片上)及2KB(板上),以上這些RAM在系統架構簡圖中均未被畫出。
- CPU的2K:儲存程序變量。從今天看起來,2K這個量真的...不多啊。所以要省著點用。
- PPU的兩組記憶體:晶片內的256B儲存圖精靈設置,板上的2K儲存瓦片地圖設置。為什麼存圖精靈的2K要做在晶片上?很簡單,因為圖精靈時常變動所以反應要快!要快的話,RAM就得靠近運算電路。所以你猜,我們第一課的hello world那些瓦片地圖設置是存在晶片還是板上?答案是:板上。(用任天堂術語來說,這塊板上存瓦片地圖的vram叫做nametable,我們後面還會再提到)
- 沿著CPU向下,會對接到卡匣的程式碼ROM(PRG ROM),這部分是出廠時即燒死的程式碼。我們所謂自製NES程式,就是要做出放進這塊PRG ROM內的程式。
- 沿著PPU向下,會對接到卡匣的瓦片素材ROM/RAM(CHR ROM/RAM)。這部分在早期簡單遊戲卡匣內是塊燒死的ROM,但在後期關卡較多的遊戲上我們必須不停地依關卡抽換瓦片素材,所以後期的卡匣上99%放的都是RAM(用ROM做就通通燒死了,甚麼都換不了)。我們所謂自製NES遊戲,就是要做放進這塊CHR ROM/RAM的美術素材。
- 再往下看到wRAM,這塊RAM多半用於存檔,所以看到系統架構圖中他綁上了一顆電池,用以提供維持數據的電力。小時候你一定也有碰過存檔不見得經驗吧?!就是這顆電池耗盡電力害你存檔消失的。
- 最後,Lockout是防偽機制。主機與卡匣會互相丟信號,用以驗證對方是否是正牌原廠貨。由於利益太龐大,所以這部份很早就被破解掉了...在我們製作ROM的過程中是不用理會這一塊的。
======我是分隔線=======
小結:所以我們倒底在做什麼?做簡單的NES遊戲之時,我們做程式就是一塊PRG ROM,畫的瓦片素材就是一塊CHR ROM。把兩塊ROM包在一起再加上少量額外的資訊,就變成了我們常見的.nes檔案。
話說在NES技術中,符合以上所謂<一塊PRG ROM + 一塊CHR ROM>,最便宜、最大眾化的卡匣組態就是NROM組態了。
對了,shiru提供了6套符合以上組合的設置檔,你還有印象我們第一課編譯的時候用的是哪一組設置檔嗎?
答對了~~我們第一節課編譯指令的最後一個參數,說明了我們上次其實是假設我們在做一個NROM組態的卡匣。
這樣明白了上次指令的意思了嗎?
> _compile example1 nrom_128_vert
讀者可以試試看改用nrom_256_vert組態檔再次編譯一遍,便可觀察到產生的.nes檔案變大。
圖精靈(Sprite)與瓦片地圖(tilemap)
在第一節中我們透過vram_put()指令去填充顯示記憶體(vram),而透過練習之後我們發現一件事:
這樣貼上去的圖都只能長在格線上啊啊啊~~~~~~ ><
只能把瓦片貼在格線上...這會導致甚麼樣的問題呢?請看下圖:
傅老師的助手蛋蛋老師有一天設計了一幅美美的地圖,想要用在NES遊戲裡面。
她想要讓自己的影像在遊戲簡介時,平順地走過米黃色的道路,問題就這麼發生了:
--辦不到
如果她只能使用瓦片地圖、只能使用vram_put()指令,那麼她的影像是不可能平滑地走過那條道路的。
要平滑地走過去勢必要讓瓦片能夠出現在非格線交錯點的座標位置上,就像下圖中的這個位置:
於是,Sprite(圖精靈)這種能夠在任意位置出現的物件就產生了!而解決這個問題的方式就是改用Sprite來繪製蛋蛋老師。
=====我是分隔線======
若我們使用任天堂的術語來描述Sprite(圖精靈)與Tilemap(瓦片地圖),那個我們應該這樣子說:
- 瓦片地圖具體數據存放在nametable結構中,nametable位於主板上2KB的顯示記憶體(vram)
- 圖精靈具體數據存放在OAM結構中(Object Attribute memory),OAM是位於晶片內的256Byte記憶體
稍後我們今天要講的Shiru第二課,就是要你怎麼輕鬆地使用shiru函式庫操作OAM,讓你盡情暢快地使用Sprite!!
Sprite的使用須知
年輕人~~有些事,不講你都不知道,講了讓你嚇一跳。
底下這裡有一些使用Sprite前一定要瞭解的使用須知,希望這些訊息不會嚇到你:
- 每個Sprite都是8像素 x 8像素大(這~有什麼鳥不起?我愛像素風)
- 再來,每個Sprite只能使用4種顏色(嗯?這...有點嚴格啊...為什麼捏?)
- 承上,剛剛說的4種顏色,其中一定要犧牲一色做為透明色。所以能塗塗抹抹的實際上只有3色(What the heck?!...)
- 還有還有~ 紅白機的PPU只能支持64個Sprite(油煤油搞錯啊?!?!)
- 最後,每條電視機的掃描線上不能超過8個Sprite,超過8個會來不及顯示。(........)
講到這裡,各位現在知道什麼叫做資源缺乏了嗎? ---用紅白機做遊戲就叫做資源缺乏啊!! ><
做NES遊戲就像去闖關少林寺18銅人陣,闖過了之後就海闊天空,自信比天高啊~~~
調色盤(Palette)
好了,這是最後一件要交待的事情了,這件事懂了就要開始解析第二課了~(那個...坐後面睡著的同學可以起來了)
是這樣的,常常有人會問我一個答不上來的問題:
老師請問:紅白機的屏幕分辨率是多少?
天啊~~~~~~你把我給問倒了~
我被問倒不是因為我講不出數據,而是...我不知道你能不能接受這個殘酷的事實...
好,首先我們先來做一則簡單的數學運算,用數字來看看這有多殘酷。
在網路上他們都說,紅白機的分辨率是256*240*16色。 那好,假設有台遊戲機,他屏幕取點數目就是256*240,每點就是16色,請問這台遊戲機儲存一張場景需要多少記憶體? Ans: 256 * 240 * log2(16) / 8 = 30,720Byte
30KB?我簡直不敢相信我的眼睛!!我們剛剛說紅白機的板上vram只有多大??
只有2KB啊啊啊啊~~~~~~~~
我的老天爺,這實在是太殘酷了,256x240*16色,這分辨率已經低到不行了。
而系統只給了我必需資源的1/15叫我去幹活,這這這...該腫模辦哩?別急,紅白機真的就妥妥當當地把這個問題給處理掉了,而且還真的用了1/15的資源創造了人類電子遊戲史的奇蹟。
解決這個問題的方法 就。。是。。
1. 將16色拆解為:4色 * 4個調色盤(Palette) 2. 每個Sprite只能選1個調色盤去發色 3. 每四塊田字型相鄰瓦片地圖,只能選1個調色盤去發色
是的,看來調色盤舒緩了發色數的問題。這樣一來每點只需要log2(4)也就是2bit即可。
現在我們再重新算一次這個數學
Ans: 256 * 240 * log2(4) / 8 = 15,900KB
。。。還是不夠啊!!!別急,以上的運算是假設每一點都可以塗塗抹抹顏色上去。
如果我們硬性要求遊戲背景不能顯示bmp、png、gif這樣的檔(紅白機的年代gif都還沒誕生哩...),而是只能顯示既定的那16x16枚瓦片,那麼再重算一次:
瓦片素材:16 * 16 * 8 *8 *log2(4) / 8 = 4K 瓦片背景:32 * 30 * log2(16*16) / 8 = 960Byte 總計:4K + 960B
。。。呼。。。我們已經盡力了,在做出最大犧牲後,一張背景還是需要4K+960B來儲存。
好,最重要的觀念來了。紅白機兩手一攤說:對不起,我板上vram就是只有2KB,現在你要怎麼辦?
恩,我們最終的大智慧決定就是:
把4K做進卡匣內,乾脆出廠的時候就燒死。反正這4K是素材,不會因為玩家操作而改變內容。 另外的960B就放在板上吧!這樣板上容量還可以放兩組。 這960B會隨著玩家的關卡而一直改變,不是死素材,所以得放在vram中,依玩家操作及遊戲進度來變更。
這960B,就叫做nametable。那個4K,就叫做CHR ROM。
Bravo!我們終於撐住了這個殘酷的困境!接下來讓我們開始解析第二課源碼吧~
源碼解說
在shiru的第二課中,我們將會學到如何操作OAM,將Sprite顯示到螢幕上。
我們會產生64顆球,讓其朝向不同的方向彈射。若球撞到螢幕邊緣則反彈回畫面中。
//匯入shiru函式庫 #include "neslib.h" //宣告一般用途的變數i,j //宣告產生sprite時用於儲存sprite索引值的spr變數 static unsigned char i,j; static unsigned char spr; //BALLS_MAX定義球的上限個數 #define BALLS_MAX 64 //宣告四個數組,每個數組大小與球數相同 //ball_x: 每個球的x座標;ball_y:每個球的y座標 //ball_dx:每個球的x方向移動間距;ball_dy:每個球的y方向移動間距。 static unsigned char ball_x[BALLS_MAX]; static unsigned char ball_y[BALLS_MAX]; static unsigned char ball_dx[BALLS_MAX]; static unsigned char ball_dy[BALLS_MAX]; //宣告4組調色盤,每組4色 //紅白機的色號請參考 http://orig02.deviantart.net/571b/f/2008/221/3/c/the_nes_palette_by_erik_red.png const unsigned char palSprites[16]={ 0x0f,0x17,0x27,0x37, 0x0f,0x11,0x21,0x31, 0x0f,0x15,0x25,0x35, 0x0f,0x19,0x29,0x39 };
接下來是main函數:
void main(void) { //pal_spr()是shiru函數,可設置sprite的調色盤。他的兄弟函數pal_bg()則可設置瓦片地圖的調色盤。 pal_spr(palSprites);//set palette for sprites //開啟PPU,所有vram與oam內的數據都會繪製到螢幕上 ppu_on_all();//enable rendering //將64顆小球數據逐一初始化 for(i=0;i<BALLS_MAX;++i) { //rand8()是shiru函數,可取得一個8位元的亂數值。 //此處設置每顆小球的x,y初始座標 ball_x[i]=rand8(); ball_y[i]=rand8(); //取8位元亂數放入變量j中,稍後會使用j的第0位作為小球的方向 j=rand8(); //取8位元亂數經處理後放入變量spr中,spr會是1,2,3其中一值 spr=1+(rand8()%3); //視變量j的二進位值第0位數設置小球的x方向移動間距dx //若j二進位值第0位為1,則將dx設為-spr;反之則設為+spr。 ball_dx[i]=j&1?-spr:spr; //使用同樣的方式設置小球的y方向移動間距dy。 spr=1+(rand8()%3); ball_dy[i]=j&2?-spr:spr; } //以下為無限循環的gameloop while(1) { //ppu_wait_frame()是shiru函數,用來偵測目前螢幕幀是否繪製完畢 //這個函數會基於每秒50次的頻率來更新螢幕幀 //若螢幕還沒開始繪製到右下角最後一點,則函數會以迴圈鎖死;若右下角最後一點繪製完畢,則此函數結束。 //繪製結束後的時間在術語上稱為VBlank time,進入VBlank後應當全力衝刺準備下一幀數據。 ppu_wait_frame();//wait for next TV frame //初始化spr索引值。請注意剛剛這個變量被拿去用做小球速度的初始設置,那是為了節省變量而複用。 //現在這裡才是用於原訂功能上。 spr=0; //更新每個小球的數據 for(i=0;i<BALLS_MAX;++i) { //oam_spr()是shiru函數,也是本節課最重要的函數。 //語句格式是oam_spr(x, y, tile, palette, spr),可以對oam中第spr個sprite進行設置 // x: x座標; // y: y座標; // tile: 瓦片編號,代表該取哪一片瓦片來繪製本sprite; // palette: sprite的調色盤號碼, // spr: sprite的索引值 spr=oam_spr(ball_x[i],ball_y[i],0x40,i&3,spr); //0x40是瓦片編號, i&3是將索引值的#1,#0位取出,來選調色盤 //移動小球。我們只需將小球的移動間距值dx, dy,加至小球的x, y座標即可。 ball_x[i]+=ball_dx[i]; ball_y[i]+=ball_dy[i]; //遇牆反彈。若小球的x座標大於256-8,代表小球已經撞上螢幕右緣,或是超出了左緣(!),此時我們將小球的dx反向。 //同理,若小球的y座標大於240-8,亦將dy反向。 if(ball_x[i]>=(256-8)) ball_dx[i]=-ball_dx[i]; if(ball_y[i]>=(240-8)) ball_dy[i]=-ball_dy[i]; } } }
以YYCHR打開tileset.chr檔,我們可以看到編號0x40的瓦片就是一顆小球。
這顆小球主體使用3色繪成,背景則塗上了透明色。
編譯example2,運行,即可看到64個小球Sprite在畫面上彈跳囉~~
在本節課中,我們學到了如何使用shiru函數操作OAM,因而掌握了使用Sprite的方式。
下節課我們將會教大家如何使用搖桿輸入,操縱畫面上的sprite。
其實若掌握了瓦片地圖、圖精靈、與搖桿,就已經有能力做入門的簡單遊戲了。
期待嗎?我們下次見!
回家作業
請試試看進行以下修改:
- 換用字元"#"代替小球
- 換掉palSprites的顏色,觀察變化。
- 如何提高小球的最大速度?
赞~到时会分享如何做成实体卡带吗?
@RetroDaddy:其實一開始是想以弄上raspberry pi(上Retro Pie)做為課程終點的,弄成實體卡帶當然也辦得到...但就要嚴格遵守硬件限制了。其中最怕的就是邏輯寫太複雜,導致運算能力不足(特別是我們是以C寫ROM編譯出來的代碼效能不如直接碼Assembly)。
我會找時間先研究一下,看看是否能做出實體卡帶課程~
教程很有用。关于楼上提到的实卡的问题,我记得有提供rom帮忙做实卡的网店,不知道对自制rom是否适用。