重玩 40 年前的經典遊戲小蜜蜂,這次通關了原始碼

削微寒發表於2021-12-01

本文適合有 C 語言基礎的朋友

這裡是 HelloGitHub 推出的《講解開源專案》系列,本期為您講解的是 80、90 後兒時的記憶,誕生於 1978 年的經典街機遊戲《太空侵略者》也叫“小蜜蜂”的 C 語言復刻版——si78c

專案:https://github.com/loadzero/si78c

這款遊戲在當時可謂是風靡一時,相信很多朋友小時候都玩過。現在長大了,不知道有多少朋友對它的原始碼感興趣呢!

原版的《太空侵略者》由大約 2k 行的 8080 彙編程式碼寫成,但組合語言太過底層不方便閱讀,今天講解的開源專案 si78c 是按照原版彙編程式碼用 C 語言重寫了一遍,並最大程度還原了原版街機硬體的中斷、協程邏輯,在執行時其記憶體狀態也幾乎與原始版本相同 幾乎達到了完美的復刻,著實讓我眼前一亮!

下面就請跟著 HelloGitHub 一起抽絲剝繭,執行這個開源專案、閱讀原始碼,穿越歷史感受 40 年前遊戲設計的精妙之處!

一、快速開始

本文的實驗環境為 Ubuntu 20.04 LTS,GCC 版本大於 GCC 3

1. 準備工作

首先 si78c 使用 SDL2 繪製遊戲視窗,所以需要安裝依賴:

$ sudo apt-get install libsdl2-dev

然後從倉庫下載原始碼:

$ git clone https://github.com/loadzero/si78c.git

此外,該專案會從原版的 ROM 中提取原版遊戲的圖片、字型,所以還需要下載原版的 ROM 檔案

《太空侵略者》原版 ROM 檔案:https://archive.org/download/MAME_078/invaders.zip

2. 檔案結構

在 si78c 原始碼資料夾中新建名為 inv1bin 的資料夾

$ cd si78c-master
$ mkdir inv1 bin

然後將 invaders.zip 中的內容解壓到 inv1 中,最後目錄結構如下:

si78c-master
├── bin
├── inv1
│   ├── invaders.e
│   ├── invaders.f
│   ├── invaders.g
│   └── invaders.h
├── Makefile
├── README.md
├── si78c.c
└── si78c_proto.h

3. 編譯與執行

使用 make 進行編譯:

$ make

之後會在 bin 資料夾中生成可執行檔案,執行即可啟動遊戲:

$ ./bin/si78c 

遊戲操控按鍵如下:

a   LEFT(左移)
d   RIGHT(右移)
1   1P(單人)
2   2P(雙人)
j   FIRE(射擊)
5   COIN(投幣)
t   TILT(結束遊戲)

二、 前置知識

2.1 簡介

《太空侵略者》原版程式碼執行在 8080 處理器之上,其內容全部由彙編程式碼寫成並涉及一些硬體操作,為了模擬原版街機程式碼邏輯以及效果,si78c 盡最大可能將彙編程式碼轉換為 C 語言並使用一個 Mem 的結構體模擬了原版街機的硬體,所以有些程式碼從純軟體的角度來講是比較奇怪甚至是匪夷所思的,但限於篇幅原因作者無法將程式碼全部貼進文章進行解釋,所以請讀者配合本人詳細註釋程式碼閱讀此文

2.2 什麼是協程

si78c 使用了 ucontex 庫的 協程 模擬原版街機的程式排程和中斷操作。

協程:協程更加輕便快捷、節省資源,協程 對於 執行緒 就相當於 執行緒 對於 程式。

其中 ucontext 提供了 getcontext()makecontext()swapcontext() 以及 setcontext() 函式實現協程的建立和切換,si78c 中的初始化函式為 init_thread。下面我們直接來看原始碼中的例子:

如果這裡不夠直觀可以看後面狀態轉移圖,圖文結合更加直觀。

程式碼 2-1

// 切換協程時用的中間變數
static ucontext_t frontend_ctx;
// 遊戲主要邏輯協程
static ucontext_t main_ctx;
// 遊戲中斷邏輯協程
static ucontext_t int_ctx;


// 用於切換兩個協程
static ucontext_t *prev_ctx;
static ucontext_t *curr_ctx;

// 初始化遊戲協程
static void init_threads(YieldReason entry_point)
{
    // 獲取當前上下文,儲存在 main_ctx 中
    int rc = 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

static void yield(YieldReason reason)
{
    // 排程原因
    yield_reason = reason;
    // 排程到另一個協程上
    switch_to(&frontend_ctx);
}

// 協程切換函式
static void switch_to(ucontext_t *to)
{
    // 給 co_switch 包裝了一層,簡化了程式碼量
    co_switch(curr_ctx, to);
}

// 協程切換函式
static void co_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-1FFF 8K ROM
  • 2000-23FF 1K RAM
  • 2400-3FFF 7K Video RAM
  • 4000- RAM mirror

可以看出當年機器的 RAM 只有可憐的 1kb 大小,每一個位元都彌足珍貴需要程式認真規劃。這裡有張 RAM 分配情況表,更多詳情

2.4 從模擬視訊記憶體到螢幕

在詳細解釋遊戲動畫顯示原理以前,我們需要先了解一下游戲的素材是怎麼儲存的:

圖 2-1

圖片來自於街機彙編程式碼解讀

在街機原版 ROM 中,遊戲素材直接以二進位制格式儲存在記憶體中,其中每一位二進位制表示當前位置畫素是黑還是白

比如 圖 2-1 中顯示 0x1BA0 位置的記憶體資料為 00 03 04 78 14 13 08 1A 3D 68 FC FC 68 3D 1A 00 八位一行 排列和出來就是一個外星人帶著一個顛倒字母 “Y” 的圖片(圖中的內容看起來像是旋轉了 90 度這是因為圖片是一列一列儲存的,每 8 bit 代表一列畫素)。

si78c 的作者在顯示圖片的時候直接將 X Y 軸進行了交換以達到旋轉圖片的效果。

我們可以找到名為 Mem 的結構體,其中的 m.vram0x24000x3FFF)模擬了街機的視訊記憶體,這裡面每一個 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

int main(int argc, char **argv)
{
    // 初始化 SDL 和 遊戲視窗
    init_renderer();
    // 初始化遊戲
    init_game();
    int credit = 0;
    size_t frame = -1;
    // 開始遊戲協程排程與模擬觸發中斷
    while (1)
    {
        frame++;
        // 處理按鍵輸入
        input();
        // 如果退出標誌置位推出迴圈清理遊戲記憶體
        if (exited)
            break;
        // preserves timing compatibility with MAME
        // 保留與 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();
    return 0;
}

啟動過程如圖所示:

圖 3-1

遊戲原版程式碼(8080 彙編)使用的是中斷驅動(這種程式設計方式和硬體有關,具體內容可以自行了解什麼是 中斷)配合協程多工操作。為了模擬原版遊戲邏輯作者以 main() 中大迴圈作為硬體行為模擬中心(實現中斷管理、協程切換、螢幕渲染)。遊戲大約三分之一的時間在執行 主執行緒主執行緒 會被 midscreenvblank 兩個中斷搶佔,程式碼 3-1 中兩個 irq() 就實現了對中斷的模擬(設定對應的變數作為標誌位)。

第一次 進入 loop_core() 時其流程如下:

圖 3-2

因為 yield_rason 這個變數是 static 型別其預設值為零

程式碼 3-2

// 根據遊戲狀態標誌切換到相應的上下文
static int execute(int allowed)
{
    int64_t start = ticks;
    ucontext_t *next = NULL;
    switch (yield_reason)
    {
    // 剛啟動時 yield_reason 是 0 表示 YIELD_INIT
    case YIELD_INIT:
    // 當需要延遲的時候會呼叫 timeslice() 將 yield_reason 切換為 YIELD_TIMESLICE
    // 模擬時間片輪轉,這個時候會切換回上一個執行的任務(統共就倆協程),實現時間片輪轉
    case YIELD_TIMESLICE:
        next = prev_ctx;
        break;
    case YIELD_INTFIN:
        // 處理完中斷後讓 int_ctx 休眠,重新執行 main_ctx
        next = &main_ctx;
        break;
    // 玩家死亡、等待開始、外星人入侵狀態
    case YIELD_PLAYER_DEATH:
    case YIELD_WAIT_FOR_START:
    case YIELD_INVADED:
        init_threads(yield_reason);
        enable_interrupts();
        next = &main_ctx;
        break;
    // 退出遊戲
    case YIELD_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);
    return ticks - 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_ctxfrontend_ctx 間的時間片輪轉操作,其過程如下:

圖 3-4

main_init() 中主要做了如下事情:

在玩家投幣前,遊戲會依靠 main_init() 迴圈播放動畫吸引玩家

如果只翻看 main_init() 中出現的函式我們會發現程式碼中並未涉及太多的遊戲邏輯,例如外星人移動、射擊,玩家投幣檢查等內容好像根本不存在一樣,更多的時候是在操縱記憶體、設定標誌位。那麼有關遊戲遊戲邏輯處理相關的函式又在哪裡呢?這部分內容將在下面揭祕。

四、模擬中斷

程式碼 3-1loop_core() 函式被兩個 irq() 分隔了開來。我們之前提到 main() 中的大迴圈本質上是在模擬街機的硬體行為,在真實的機器上中斷是隻有在觸發時才會執行,但在 si78c 上我們只能通過在 loop_core() 之間呼叫 irq()模擬產生中斷並在 execute() 中輪詢中斷狀態來判斷是不是進入中斷處理函式,過程如下:

這時它的協程狀態如下:

有兩種中斷:midscreen_int()vblank_int() 這兩種中斷會輪流出現。

程式碼 4-1

// 處理中斷的函式
static void run_int_ctx()
{
    while (1)
    {
        // 0xcf = RST 1 opcode (call 0x8)
        // 0xd7 = RST 2 opcode (call 0x16)
        if (irq_vector == 0xcf)
            midscreen_int();
        else if (irq_vector == 0xd7)
            vblank_int();
        // 使能中斷
        enable_interrupts();
        yield(YIELD_INTFIN);
    }
}

我們先來看 midscreen_int()

程式碼 4-2

/**
 * 在光將要擊中螢幕中間(應該是模擬老式街機的現實原理)時由中斷觸發
 * 主要處理遊戲物件的移動、開火、碰撞等等的檢測更新與繪製(具體看函式 GameObj0到4)
 * 以及確定下一個將要繪製哪個外星人,檢測外星人是不是入侵成功了
 */
static void midscreen_int()
{
    // 更新 vblank 標誌位
    m.vblankStatus = BEAM_MIDDLE;
    // 如果沒有運動的遊戲物件,返回
    if (m.gameTasksRunning == 0)
        return;
    // 在歡迎介面 且 沒有在演示模式,返回(只在遊戲模式 和 demo模式下繼續執行)
    if (!m.gameMode && !(m.isrSplashTask & 0x1))
        return;
    // 執行 game objects 但是略過第一個入口(玩家)
    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

/** 
 * 當光擊中螢幕最後一點(模擬老式街機原理)時觸發
 * 主要處理遊戲結束、投幣、遊戲中各種事件處理、播放演示動畫
 */
static void vblank_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)
    {
        // xref 005d
        if (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_DEATHYIELD_WAIT_FOR_STARTYIELD_INVADEDYIELD_TILT 這四種分支中都呼叫了 init_threads(yield_reason),在這個函式裡會重置 int_ctxmain_ctx 的堆疊並重新繫結呼叫 run_main_ctx 時的引數為 yield_reason,這樣在下一次執行的時候 run_main_ctx 就會根據中斷的指示跳轉到合適的分支去執行。

五、巧妙地節省 RAM

開篇的時候提到過,當年街機的 RAM 只有可憐的 1kb 大小,這樣小的地方必定無法讓我們儲存螢幕上每個物件的資訊,但是玩家的位置、外星人的位置以及它們的子彈、螢幕上的盾牌損壞情況都是會實時更新的,如何做到這一點呢?

我發現《太空侵略者》遊戲區域內容分佈還是很有規律的,特殊飛船(飛碟)只會出現在螢幕上端,盾牌和玩家的位置不會改變,只有子彈的位置不好把握,所以仔細研讀程式碼,從 DrawSpriteGeneric() 可以看出,遊戲對於碰撞的檢測只是簡單的判斷畫素塊是否重合,對於玩家子彈到底擊中了什麼在 PlayerShotHit() 函式進行判斷時,則只需要判斷子彈垂直方向座標(Y座標),如果 >= 216 則是撞到上頂,>=206 則是擊中神祕飛碟,其他則是擊中護盾或者外星人的子彈。且由於外星飛船的是成組一起運動,只需要記住其中一個的位置就能推算出整體每一個外星飛船的座標。

這樣算下來,程式只需要儲存外星飛船的存活狀態、當前艦隊的相對移動位置、玩家和外星人子彈資訊,在需要檢測碰撞時則去讀取視訊記憶體中的畫素資訊進行對比然後反推當前時哪兩樣物體發生了碰撞即可,這種方法相比儲存每一個物件的資訊節省了不少資源。

六、結語

si78c 不同於其他程式碼,它本質上是對硬體和彙編程式碼的模擬,希望通過本文的原始碼講解,讓更多人看到當年程式設計師們在有限資源下製作出優秀遊戲的困難,還有程式碼設計的精妙。

最後,感謝本專案作者所做的一切,沒有他的付出也就不會有這篇文章。如果您覺得這篇文章還不錯,歡迎分享給更多人。

相關文章