本文適合有C語言基礎的朋友
這里是HelloGitHub推出的《講解開源項目》系列,本期為您講解的是80、90后的兒時記憶,誕生于1978年經典街機游戲《太空侵略者》也叫“小蜜蜂”的C語言復刻版——si78c。
這款游戲在當時可謂是風靡一時,相信很多朋友小時候都玩過。現在長大了,不知道有多少朋友對它的源碼感興趣呢!
原版的《太空侵略者》由大約2k行的8080匯編代碼寫成,但匯編語言太過底層不方便閱讀,今天講解的開源項目si78c是按照原版匯編代碼用C語言重寫了一遍,并最大程度還原了原版街機硬件的中斷、協程邏輯,在運行時其內存狀態也幾乎與原始版本相同幾乎達到了完美的復刻,著實讓我眼前一亮!
下面就請跟著HelloGitHub一起抽絲剝繭,運行這個開源項目、閱讀源碼,穿越歷史感受40年前游戲設計的精妙之處!
一、快速開始本文的實驗環境為Ubuntu20.04LTS,GCC版本大于GCC3
1.準備工作首先si78c使用SDL2繪制游戲窗口,所以需要安裝依賴:
$sudoapt-getinstalllibsdl2-dev然后從倉庫下載源碼:
$gitclonehttps://github.com/loadzero/si78c.git此外,該項目會從原版的ROM中提取原版游戲的圖片、字體,所以還需要下載原版的ROM文件
2.文件結構在si78c源碼文件夾中新建名為inv1和bin的文件夾
$cdsi78c-master$mkdirinv1bin然后將invaders.zip中的內容解壓到inv1中,最后目錄結構如下:
si78c-master├──bin├──inv1│├──invaders.e│├──invaders.f│├──invaders.g│└──invaders.h├──Makefile├──README.md├──si78c.c└──si78c_proto.h3.編譯與運行使用make進行編譯:
$make之后會在bin文件夾中生成可執行文件,運行即可啟動游戲:
$./bin/si78c游戲操控按鍵如下:
aLEFT(左移)dRIGHT(右移)11P(單人)22P(雙人)jFIRE(射擊)5COIN(投幣)tTILT(結束游戲)二、前置知識2.1簡介《太空侵略者》原版代碼運行在8080處理器之上,其內容全部由匯編代碼寫成并涉及一些硬件操作,為了模擬原版街機代碼邏輯以及效果,si78c盡最大可能將匯編代碼轉換為C語言并使用一個Mem的結構體模擬了原版街機的硬件,所以有些代碼從純軟件的角度來講是比較奇怪甚至是匪夷所思的,但限于篇幅原因作者無法將代碼全部貼進文章進行解釋,所以請讀者配合本人詳細注釋代碼閱讀此文。
2.2什么是協程si78c使用了ucontex庫的協程模擬原版街機的進程調度和中斷操作。
協程:協程更加輕便快捷、節省資源,協程對于線程就相當于線程對于進程。
其中ucontext提供了getcontext()、makecontext()、swapcontext()以及setcontext()函數實現協程的創建和切換,si78c中的初始化函數為init_thread。下面我們直接來看源碼中的例子:
如果這里不夠直觀可以看后面狀態轉移圖,圖文結合更加直觀。
代碼2-1
//切換協程時用的中間變量staticucontext_tfrontend_ctx;//游戲主要邏輯協程staticucontext_tmain_ctx;//游戲中斷邏輯協程staticucontext_tint_ctx;//用于切換兩個協程staticucontext_t*prev_ctx;staticucontext_t*curr_ctx;//初始化游戲協程staticvoidinit_threads(YieldReasonentry_point){//獲取當前上下文,存儲在main_ctx中intrc=getcontext(&main_ctx);assert(rc==0);//指定棧空間main_ctx.uc_stack.ss_sp=main_ctx_stack;//指定棧空間大小main_ctx.uc_stack.ss_size=STACK_SIZE;//設置后繼上下文main_ctx.uc_link=&frontend_ctx;//修改main_ctx上下文指向run_main_ctx函數makecontext(&main_ctx,(void(*)())run_main_ctx,1,entry_point);/**以上內容相當于新建了一個叫main_cxt的協程,運行run_main_ctx函數,frontend_ctx為后繼上下文*(run_main_ctx運行完畢之后會接著運行frontend_ctx記錄的上下文)*協程對于線程,就相當于線程對于進程*只是協程切換開銷更小,用起來更加輕便*///獲取當前上下文存儲在init_ctx中rc=getcontext(&int_ctx);//指定棧空間int_ctx.uc_stack.ss_sp=&int_ctx_stack;//指定棧空間大小int_ctx.uc_stack.ss_size=STACK_SIZE;//設置后繼上下文int_ctx.uc_link=&frontend_ctx;//修改上下文指向run_init_ctx函數makecontext(&int_ctx,run_int_ctx,0);/**以上內容相當于新建了一個叫int_ctx的協程,運行run_int_ctx函數,frontend_ctx為后繼上下文*(run_int_ctx運行完畢之后會接著運行frontend_ctx記錄的上下文)*協程對于線程,就相當于線程對于進程*只是協程切換開銷更小,用起來更加輕便*///給pre_ctx初始值,在第一次調用timeslice()時候能切換到main_ctx運行prev_ctx=&main_ctx;//給curr_ctx初始值,這時候frontend_ctx還是空的//frontend_ctx會在上下文切換的時候用于保存上一個協程的狀態curr_ctx=&frontend_ctx;}之后每次調用yield()都會使用swapcontext()進行兩個協程間切換:
代碼2-2
staticvoidyield(YieldReasonreason){//調度原因yield_reason=reason;//調度到另一個協程上switch_to(&frontend_ctx);}//協程切換函數staticvoidswitch_to(ucontext_t*to){//給co_switch包裝了一層,簡化了代碼量co_switch(curr_ctx,to);}//協程切換函數staticvoidco_switch(ucontext_t*prev,ucontext_t*next){prev_ctx=prev;curr_ctx=next;//切換到next指向的上下文,將當前上下文保存在prev中swapcontext(prev,next);}具體用法請見后文
由于文章篇幅有限,下面只展示的關鍵源碼部分。更詳細的源碼逐行中文注釋:
地址:https://github.com/AnthonySun256/easy_games
2.3模擬硬件前文講過,si78c是原版街機游戲像素級的復刻,甚至大部分的內存數據也是相等的,為了做到這一點si78c模擬了街機的一部分硬件:RAM、ROM和顯存,它們在代碼中被封裝成了一個名為Mem的大結構體,內存分配如下:
0000-1FFF8KROM2000-23FF1KRAM2400-3FFF7KVideoRAM4000-RAMmirror可以看出當年機器的RAM只有可憐的1kb大小,每一個比特都彌足珍貴需要程序認真規劃。這里有張RAM分配情況表,更多詳情
2.4從模擬顯存到屏幕在詳細解釋游戲動畫顯示原理以前,我們需要先了解一下游戲的素材是怎么存儲的:
圖2-1
圖片來自于街機匯編代碼解讀
在街機原版ROM中,游戲素材直接以二進制格式保存在內存中,其中每一位二進制表示當前位置像素是黑還是白
比如圖2-1中顯示0x1BA0位置的內存數據為000304781413081A3D68FCFC683D1A00八位一行排列和出來就是一個外星人帶著一個顛倒字母“Y”的圖片(圖中的內容看起來像是旋轉了90度這是因為圖片是一列一列存儲的,每8bit代表一列像素)。
si78c的作者在顯示圖片的時候直接將XY軸進行了交換以達到旋轉圖片的效果。
我們可以找到名為Mem的結構體,其中的m.vram(0x2400到0x3FFF)模擬了街機的顯存,這里面每一個bit代表一個像素的黑(0)白(1),從左下角向右上角進行渲染,其對應關系如圖2-2:
圖2-2
游戲中所有跟動畫繪制有關的代碼都是在修改這部分區域的數據,例如DrawChar()、ClearPlayField()、DrawSimpSprite()等等。那么怎么讓模擬現存的內容顯示到玩家的屏幕上呢?注意看代碼3-1中在循環的末尾調用了render()函數,它負責的就挨個讀取模擬顯存中的內容并在窗口上有像素塊的地方渲染一個像素塊。
仔細想想不難發現,這種先修改模擬顯存再統一繪制的***其實沒有多省事,甚至有些怪異。這是因為si78c模擬了街機硬件的顯示過程:修改相應的顯存然后硬件會自動將顯存中的內容顯示到屏幕上。
2.5按鍵檢測代碼3-1中的input()函數負責檢測并存儲用戶的按鍵信息,其底層依賴SDL庫。
三、首次啟動si78c和所有的C程序一樣,都是從main()函數開始運行:
代碼3-1
intmain(intargc,char**argv){//初始化SDL和游戲窗口init_renderer();//初始化游戲init_game();intcredit=0;size_tframe=-1;//開始游戲協程調度與模擬觸發中斷while(1){frame++;//處理按鍵輸入input();//如果退出標志置位推出循環清理游戲內存if(exited)break;//preservestimingcompatibilitywithMAME//保留與MAME(一種街機)的時序兼容性if(frame==1)credit--;/***執行其他進程大概CRED1的時間*(為什么是這個數我也不知道,應該是估計值)*(原作者也說這種定時***不是很準確但不影響游戲效果)*/credit+=CRED1;loop_core(&credit);//設置場中間中斷標志位,在下面的loop_core()中會切換到int_ctx執行一次,然后清除標志位irq(0xcf);//道理同上credit+=CRED2;loop_core(&credit);//設置垂直消隱中斷標志位,下個循環時候loop_core()中會切換到int_ctx執行一次,然后清除標志位irq(0xd7);//繪制游戲界面render();}fini_game();fini_renderer();return0;}啟動過程如圖所示:
圖3-1
游戲原版代碼(8080匯編)使用的是中斷驅動(這種編程方式和硬件有關,具體內容可以自行了解什么是中斷)配合協程多任務操作。為了模擬原版游戲邏輯作者以main()中大循環作為硬件行為模擬中心(實現中斷管理、協程切換、屏幕渲染)。游戲大約三分之一的時間在運行主線程,主線程會被midscreen和vblank兩個中斷搶占,代碼3-1中兩個irq()就實現了對中斷的模擬(設置對應的變量作為標志位)。
在第一次進入loop_core()時其流程如下:
圖3-2
因為yield_rason這個變量是static類型其默認值為零
代碼3-2
//根據游戲狀態標志切換到相應的上下文staticintexecute(intallowed){int64_tstart=ticks;ucontext_t*next=NULL;switch(yield_reason){//剛啟動時yield_reason是0表示YIELD_INITcaseYIELD_INIT://當需要延遲的時候會調用timeslice()將yield_reason切換為YIELD_TIMESLICE//模擬時間片輪轉,這個時候會切換回上一個運行的任務(統共就倆協程),實現時間片輪轉caseYIELD_TIMESLICE:next=prev_ctx;break;caseYIELD_INTFIN://處理完中斷后讓int_ctx休眠,重新運行main_ctxnext=&main_ctx;break;//玩家死亡、等待開始、外星人入侵狀態caseYIELD_PLAYER_DEATH:caseYIELD_WAIT_FOR_START:caseYIELD_INVADED:init_threads(yield_reason);enable_interrupts();next=&main_ctx;break;//退出游戲caseYIELD_TILT:init_threads(yield_reason);next=&main_ctx;break;default:assert(FALSE);}yield_reason=YIELD_UNKNOWN;//如果有中斷產生if(allowed&&interrupted()){next=&int_ctx;}switch_to(next);returnticks-start;}需要注意的是,在execute()中進行了協程的切換,這個時候execute()的運行狀態就被保存在了變量frontend_ctx之中,指針prev_ctx更新為指向frontend_ctx,指針curr_ctx更新為指向main_ctx,其過程如圖所示:
圖3-3
實現解釋請見代碼2-2
當execute()返回時他會按照正常的執行流程返回到loop_core(),就像它從未被暫停過一樣。
仔細觀察main_init中主循環我們可以發現其多次調用timeslice()函數(例如OneSecDelay()中),通過這個函數我們就可以實現main_ctx與frontend_ctx間的時間片輪轉操作,其過程如下:
圖3-4
在main_init()中主要做了如下事情:
在玩家投幣前,游戲會依靠main_init()循環播放動畫吸引玩家
如果只翻看main_init()中出現的函數我們會發現代碼中并未涉及太多的游戲邏輯,例如外星人移動、射擊,玩家投幣檢查等內容好像根本不存在一樣,更多的時候是在操縱內存、設置標志位。那么有關游戲游戲邏輯處理相關的函數又在哪里呢?這部分內容將在下面揭秘。
四、模擬中斷在代碼3-1中loop_core()函數被兩個irq()分隔了開來。我們之前提到main()中的大循環本質上是在模擬街機的硬件行為,在真實的機器上中斷是只有在觸發時才會執行,但在si78c上我們只能通過在loop_core()之間調用irq()來模擬產生中斷并在execute()中輪詢中斷狀態來判斷是不是進入中斷處理函數,過程如下:
這時它的協程狀態如下:
有兩種中斷:midscreen_int()與vblank_int()這兩種中斷會輪流出現。
代碼4-1
//處理中斷的函數staticvoidrun_int_ctx(){while(1){//0xcf=RST1opcode(call0x8)//0xd7=RST2opcode(call0x16)if(irq_vector==0xcf)midscreen_int();elseif(irq_vector==0xd7)vblank_int();//使能中斷enable_interrupts();yield(YIELD_INTFIN);}}我們先來看midscreen_int():
代碼4-2
/***在光將要擊中屏幕中間(應該是模擬老式街機的現實原理)時由中斷觸發*主要處理游戲對象的移動、開火、碰撞等等的檢測更新與繪制(具體看函數GameObj0到4)*以及確定下一個將要繪制哪個外星人,檢測外星人是不是入侵成功了*/staticvoidmidscreen_int(){//更新vblank標志位m.vblankStatus=BEAM_MIDDLE;//如果沒有運動的游戲對象,返回if(m.gameTasksRunning==0)return;//在歡迎界面且沒有在演示模式,返回(只在游戲模式和demo模式下繼續運行)if(!m.gameMode&&!(m.isrSplashTask&0x1))return;//運行gameobjects但是略過第一個入口(玩家)RunGameObjs(u16_to_ptr(PLAYER_SHOT_ADDR));//確定下一個將要繪制的外星人CursorNextAlien();}在這一部分中RunGameObjs()函數基本上包括了玩家的移動和繪制,玩家子彈和外星人子彈的移動、碰撞檢測、繪制等等所有游戲邏輯的處理,CursorNextAlien()則找到要繪制的下一個活著的外星人設置標志位等待繪制,并且檢測外星飛船是否碰到了屏幕底端。
運行結束后會返回到run_int_ctx()繼續運行直到yield(YIELD_INTFIN)表示協程切換回execute(),并在execute()中重新將next設定為main_ctx使main_init()能夠繼續運行(詳情見代碼3-2)。
接下來是vblank_int():
代碼4-3
/***當光擊中屏幕最后一點(模擬老式街機原理)時觸發*主要處理游戲結束、投幣、游戲中各種事件處理、播放演示動畫*/staticvoidvblank_int(){//更新標志位m.vblankStatus=BEAM_VBLANK;//計時器減少m.isrDelay--;//看看是不是結束游戲CheckHandleTilt();//看看是不是投幣了vblank_coins();//如果游戲任務沒有運行,返回if(m.gameTasksRunning==0)return;//如果在游戲中的話if(m.gameMode){TimeFleetSound();m.shotSync=m.rolShotHeader.TimerExtra;DrawAlien();RunGameObjs(u16_to_ptr(PLAYER_ADDR));TimeToSaucer();return;}//如果投幣過了if(m.numCoins!=0){//xref005dif(m.waitStartLoop)return;m.waitStartLoop=1;//切換協程到等待開始循環yield(YIELD_WAIT_FOR_START);assert(FALSE);//不會再返回了}//如果以上事情都沒發生,播放演示動畫ISRSplTasks();}其主要作用一是檢測玩家是否想要退出游戲或是進行了投幣操作,如果已經處于游戲模式中則依次播放艦隊聲音、繪制在midscreen_int()中標記出的外星人、運行RunGameObjs()處理玩家和外星人開火與移動事件、TimeToSaucer()隨機生成神秘飛碟。如果未在游戲模式中則進入ISRSplTasks()調整當前屏幕上應該播放的動畫。
我們可以注意到,如果玩家進行了投幣會進入if(m.numCoins!=0)里,并調用yield(YIELD_WAIT_FOR_START)后面會提示這個函數不會再返回。在si78c的代碼中許多地方都會有這樣的提示,這里并不是簡單的調用一個不會返回的函數進行套娃。
觀察代碼3-2可以發現在YIELD_PLAYER_DEATH、YIELD_WAIT_FOR_START、YIELD_INVADED、YIELD_TILT這四種分支中都調用了init_threads(yield_reason),在這個函數里會重置int_ctx與main_ctx的堆棧并重新綁定調用run_main_ctx時的參數為yield_reason,這樣在下一次執行的時候run_main_ctx就會根據中斷的指示跳轉到合適的分支去運行。
五、巧妙地節省RAM開篇的時候提到過,當年街機的RAM只有可憐的1kb大小,這樣小的地方必定無法讓我們存儲屏幕上每個對象的信息,但是玩家的位置、外星人的位置以及它們的子彈、屏幕上的盾牌損壞情況都是會實時更新的,如何做到這一點呢?
我發現《太空侵略者》游戲區域內容分布還是很有規律的,特殊飛船(飛碟)只會出現在屏幕上端,盾牌和玩家的位置不會改變,只有子彈的位置不好把握,所以仔細研讀代碼,從DrawSpriteGeneric()可以看出,游戲對于碰撞的檢測只是簡單的判斷像素塊是否重合,對于玩家子彈到底擊中了什么在PlayerShotHit()函數進行判斷時,則只需要判斷子彈垂直方向坐標(Y坐標),如果>=216則是撞到上頂,>=206則是擊中神秘飛碟,其他則是擊中護盾或者外星人的子彈。且由于外星飛船的是成組一起運動,只需要記住其中一個的位置就能推算出整體每一個外星飛船的坐標。
這樣算下來,程序只需要保存外星飛船的存活狀態、當前艦隊的相對移動位置、玩家和外星人子彈信息,在需要檢測碰撞時則去讀取顯存中的像素信息進行對比然后反推當前時哪兩樣物體發生了碰撞即可,這種***相比存儲每一個對象的信息節省了不少資源。
六、結語si78c不同于其他代碼,它本質上是對硬件和匯編代碼的仿真,希望通過本文的源碼講解,讓更多人看到當年程序員們在有限資源下***出優秀游戲的困難,還有代碼設計的精妙。
最后,感謝本項目作者所做的一切,沒有他的付出也就不會有這篇文章。如果您覺得這篇文章還不錯,歡迎分享給更多人。