从头开始做 Arduino 电子游戏机

前段时间和朋友约好玩交换礼物,我利用 Arduino 和 OLED 萤幕制作了一个简单的电子游戏机拿来交换。这篇文章将设计过程分享给大家,希望对大家有帮助!

目标

实作一个简单电子游戏机,包含以下功能:

  • 菜单介面可以选择游戏
  • 几款小游戏,包含贪吃蛇、打砖块与弹幕等

开发

硬体

第一步是购买元件。决定好要买的东西后,打开购物网站随便挑一间看得顺眼的店家进行採购即可。因为我很懒惰,所以希望能尽量在同一间店把所有元件买齐,虽然事后发现有点买贵,但差价也不大就算了。

选择萤幕时,我原本想挑 LCD 显示器,但意外看到 OLED 萤幕,觉得听起来更高端,于是选择了 OLED。后来才发现虽然价格差不多,但 OLED 的萤幕有点小。本来想换的,但因为时间临近,就没时间重新购买更大的萤幕了。

选择主机时,我最后选用了非原厂版本的 Arduino Uno,因为它比较常见,价格也相对亲民。这次我发现原厂与副厂的差异主要在于用料的扎实度,尤其体现在适配性上。副厂的 USB 控制晶片比较差,导致我在刚拿到的时候,macbook pro 和扩充埠完全侦测不到端口,换到 windows 主机后也还是无法侦测。

之后经过研究,发现因为我的设备都比较新,多数接口属于 USB3.0 以上的协议,因此不适配副厂的 USB 控制晶片。改用 windows 主机的 USB2.0 接口后,终于正确连接上了。

最后,我的游戏手把选用了 PS2 的蘑菇头,包含上下左右和中心按钮。当时觉得这样的手把可以让游戏玩的更顺畅。

其它就是随便买一些电子零件,包含面包版、杜邦线、电源之类的,最终的价格如下表:

部件

价格
OLED 萤幕 1 185 元
Arduino Uno 开发版 1 325 元
电源线 1 80 元
PS2 蘑菇头 1 50 元
其他电子零件 1 60 元

总计:185 + 325 + 80 + 50 + 60 = 700 元。

拿到零件后,可以开始参考网路上的文件一个洞一个洞插。这边我也遇到一个坑,因为这些零件来自各间副厂,他插的孔刚好跟我参考的网路文件相反,所以讯号传递错误。我后来是透过查询商家建置的教学网站才得知这一细节。所以结论是在接线时需要仔细检查实际硬体上的说明,并且比对最适合的网路教学再开始尝试,务必抱持开放的态度,面对不同副厂零件之间的差异性。

以下几个连结是我个人喜欢的参考文件,实际上该如何连还是要按照实际硬体状况:

  • SSD1306
  • joystick

最后是接线完成后的参考图:

软件

流程设计

在设计这类 OS-less 的 single core 嵌入式系统时,可以参考的其中一种设计方法是有限状态机(Finite State Machine, FSM)。可以把整个韧体想像成一个巨大循环,循环内存在着多台可以多层嵌套的状态机。每次循环就是在每一台状态机中依据当时的状态完成一次运算并转移到下一项状态。此外,实务上每个状态机彼此也可以透过某种方法互相沟通,因此每次 FSM 执行所需输入可以是自己内部的状态加上别台状态机提供的状态。

以下可以用 pseudo code 表达一下这个设计方法:

// claiming global variable for state machine array
global stateMachine[numStateMachines]
// communication channel for each state machine
global communicationChannel

int main() {
// init all state machines
for (int i = 0; i < numStateMachines; i++) {
stateMachine[i] = new StateMachine();
}

// infinite loop
while(true) {
// call action function of each state machine based on communication channel
for (int i = 0; i < numStateMachines; i++) {
stateMachine[i]->Serve(communicationChannel);
}
}
}

接着,按照真实案例,我想像中的游戏机使用流程如下:

  • 使用者开机,看到开机画面
  • 开机后有选单可以选择不同款游戏,点击确认后进入游戏模式
  • 在游戏模式中玩游戏,游戏结束后显示得分
  • 玩家确认得分后回到游戏选单,可以重新选择游戏
  • 至此可以画出整体流程所需的状态机如下:

    其中 Welcome、Menu、Game 和 Result 是状态机的状态。各自的定义如下:

    • Welcome: 开机初始画面。
    • Menu: 游戏选单,显示不同款游戏供用户选择。
    • Game: 游戏画面,玩家可以进行操作,例如按键控制角色移动等。
    • Result: 游戏结束,显示得分。

    流程实作

    关于 arduino 的开发环境等设置,请自行参考其他文件,也可以参考本专案的 README.md。

    假设已经完成环境配置,打开 Arduino IDE 的 sample code 后应该可以看到以下模板:

    void setup() {
    // put your setup code here, to run once when the board is powered up
    }
    void loop() {
    // put your main code here, to run repeatedly
    }

    这两行就是 Arduino 的生命周期函数,setup() 函数在板子启动时执行一次,而 loop() 函数则会不断重复执行。

    因此按照上一节的设计理念,我们可以设计一个 FSM,在 setup() 函数中初始化,并在 loop() 函数中重复调用该 FSM 的行为函数。这样就可以完整的实作出目标系统了。

    以下就是最终我的 Arduino 专案主程式入口:

    Controller *controller = nullptr;

    void setup()
    {
    controller = new Controller();
    }

    void loop()
    {
    controller->Serve();
    }

    下一步就是按照设计中的状态表实作状态机 "controller" 的行为函数与内部状态。

    enum ControllerState
    {
    Welcome,
    Menu,
    Game,
    Result
    };

    class Controller
    {
    private:
    ControllerState state;

    public:
    Controller(/* args */);
    ~Controller();

    void Serve(void);
    };

    void Controller::Serve(void)
    {
    ControllerState curState = this->state;
    switch (curState)
    {
    case Welcome:
    this->state = Menu;
    break;
    case Menu:
    this->state = Game;
    break;
    case Game:
    this->state = Result;
    break;
    case Result:
    this->state = Menu;
    break;
    }
    }

    至此,一个可以空转的流程实作完毕,可以利用 Serial Monitor 或其他工具来观察状态机的动作。

    分层架构

    有了基本的状态机后,下一步是让状态机可以在行为函数中调用外部 IO 完成输入输出,如此一来摇桿可以开始操作状态机,状态机也可以透过 OLED 屏幕展示画面。

    想要在系统设计中完成这一步,常见的作法是使用分层架构(Layered Architecture),其思想可以被简化为以下几点:

    • 权责分配:将系统功能分成不同的层级,每个层级都有自己的任务和责任。
    • 解耦:避免不同层级之间的直接依赖,而是通过接口或函数来实现通信。
    • 可扩展性:当需要增加新的功能时,只需要修改特定的层级即可,而不需要影响其他层级。

    在这个例子中,我们可以将系统功能分成以下几层:

  • Controller Layer:负责控制系统的生命周期,包括初始化、运行和停止等操作。
  • Service Layer:负责处理系统的核心业务逻辑,例如游戏本体的运行、玩家输入的处理、游戏画面的渲染等。
  • Hardware Layer:负责与 I/O 硬体通讯,聚焦在纯粹硬体操作 API 的串接。
  • 因此,在我的设计中,我会在 Hardware Layer 中实现以下两个模组:

    • Arduino 读取摇桿动作的功能。
    • Arduino 操作 OLED 视窗的渲染功能。

    这样一来,游戏本体和系统流程就可以和外部 IO 装置解耦,可以更顺利的完成开发和测试。

    硬体层实作

    在这个层次,主要价值是串接硬体与核心业务逻辑。因此应该尽可能的简单而明确,不需要额外添加太多功能或复杂性。

    在 sketch\\ps2btn.cpp 中

    • 透过 Arduino 开发版直接读取 pin 上关于摇桿硬体的类比与数位信号,并回传表示在那个时刻摇桿所检测到的资讯。
    • 利用 Arduino 开发版的 interrupt 机制完成按钮触发与摇桿的校正方案等两点。

    使用 analogRead 可以读取对应 pin 的类比讯号,按照教学书中的说明,这是一个 0 到 1023 的数值,可以表示摇桿在该方向上的位置。

    • VERT_PIN 表示摇桿的垂直方向。
    • HORZ_PIN 表示摇桿的水平方向。

    void PS2Button::updateRawBtnDir(void)
    {
    int vert = analogRead(VERT_PIN);
    int horz = analogRead(HORZ_PIN);
    this->controlRawX = vert;
    this->controlRawY = horz;
    }

    我定义了 enum 来表示摇桿的指向,当别层调用函数 getDir 时可以收到不同的方向。

    enum BUTTON_DIRECTORY
    {
    UP,
    DOWN,
    LEFT,
    RIGHT,
    MID,
    };

    BUTTON_DIRECTORY PS2Button::getDir(void)
    {
    this->updateRawBtnDir();

    int newX = this->controlRawX - naturalMiddle_x;
    int newY = this->controlRawY - naturalMiddle_y;

    if (ABS(newX) < XY_MIDDLE_RANGE && ABS(newY) < XY_MIDDLE_RANGE)
    {
    return MID;
    }

    if (ABS(newX) > ABS(newY))
    {
    if (newX > 0)
    {
    return RIGHT;
    }
    else
    {
    return LEFT;
    }
    }
    else
    {
    if (newY > 0)
    {
    return DOWN;
    }
    else
    {
    return UP;
    }
    }
    }

    上面的 naturalMiddle_x 与 naturalMiddle_y 存放了摇桿的自然偏移量,可以在下面的 adjust 函数被更新,该函数应该尽可能在确定使用者没有移动摇桿的情境下使用。原理是把未移动的摇桿回传座标视为中心点。举例来说,理想的摇桿中心座标应该是 (512,512) ,但今天实际的摇桿中心座标却是 (400,412) 因此如果没有校正座标,最后感测的方向会产生错误,所以要改成把 (400,412) 设定为中心点,这样就可以避免这种问题了。

    void PS2Button::adjust(void)
    {
    delay(1000);
    updateRawBtnDir();
    naturalMiddle_x = this->controlRawX;
    naturalMiddle_y = this->controlRawY;
    }

    最后,因为硬体控制摇桿具有按钮,但是使用者对按钮的想像是按下后就会触发,不用一直压着,因此不可能是在 isClickBtn 函数触发的当下直接读取数位信号。而应该是在使用者按下后在下一帧画面触发效果。举例来说,如果直接读取数位信号,会造成如果使用者压下按钮的时间在画面帧与帧之间,那么就会读取不到,因为在新的一帧渲染画面时,读取不到对应数位信号。我的做法是使用 Arduino 内建的 interrupt 功能,要求 CPU 及时处理 IO 事件,当收到按钮点击事件后举起点击按钮事件的 Flag,在下一帧画面渲染时,若发现点击按钮事件的 Flag 举起,主动产生对应效果,并且放下 Flag。

    bool PS2Button::isClickBtn(void)
    {
    return PS2SelPress;
    }

    void PS2Button::resetBtn(void)
    {
    PS2SelPress = false;
    }

    static void button_interrupt_handler()
    {
    PS2SelPress = true;
    }

    void PS2Button::init(void)
    {
    // interrupt 所需设置
    pinMode(SEL_PIN, INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(SEL_PIN),
    button_interrupt_handler,
    INTERRUPT_TRIGGER_TYPE);
    this->resetBtn();
    }

    下一帧画面渲染前透过isClickBtn确认 Flag 是否举起,而resetBtn可以在用完 Flag 后重置 Flag。button_interrupt_handler在init中被attachInterrupt方法注册到 CPU 的监听范畴,在事件发生时主动执行该函数把 Flag 举起。

    接着关于 OLED 屏幕的硬体层较为简单,因为有成熟的 OLED 萤幕套件Adafruit_SSD1306,所以我们只需要简单封装该套件,并且加入通用性程式码加快后续开发即可。

    class OLED
    {
    private:
    public:
    Adafruit_SSD1306 *display = nullptr;

    OLED();
    ~OLED();

    void init();
    void printText(String str);
    };

    变数display作为套件操作的介面,init则完成 SSD 萤幕的初始化。接着是printText,用于在 OLED 上显示文字,在很多地方都有用到,放在这层作为通用功能。

    服务层

    按照上方分层架构的设计,服务层应该提供游戏功能的核心逻辑。我想像中的游戏功能应该符合以下宣告。

    class Game
    {
    public:
    Game();

    void runGame();
    void initGame();
    };

    其中

    • 调用initGame可以初始化游戏
    • 调用runGame可以在萤幕上产生下一帧画面

    至此,终于可以开始开发游戏。有两个需要思考的设计要点:

    • 有限元素机(FSM)
    • 设计模式(Design Pattern)

    FSM 用在runGame中,我们可以把整个游戏视作有以下几个阶段的有限元素机,内嵌进Controller这个大 FSM 的Game状态,所以我前面才提到 FSM 这个韧体设计方法是把整个韧体视做多台可以多层嵌套的有限状态机。

    • INIT: 初始化
    • PLAYING: 游戏中
    • END: 游戏结束

    至此我们定义游戏的基础物件如下

    enum GAME_STATE
    {
    GAME_STATE_INIT,
    GAME_STATE_PLAYING,
    GAME_STATE_END,
    };

    class Game
    {
    public:
    GAME_STATE state;

    Game();

    void runGame();
    void initGame();
    };

    void initGame() {}

    void runGame()
    {
    switch (state)
    {
    case GAME_STATE_INIT:
    this->state = GAME_STATE_PLAYING;
    break;
    case GAME_STATE_PLAYING:
    this->state = GAME_STATE_END;
    break;
    case GAME_STATE_END:
    break;
    }
    }

    下一步中,考虑到系统要支援多款游戏,甚至游戏的更新。我希望游戏的设计本身可以扩充,并且和系统运作解耦,所以引用设计模式中的工厂模式(Factory Method)如下。

    enum MenuItem
    {
    SNAKE,
    BALL,
    AIRPLANE,
    };

    class GameBase
    {
    public:
    GAME_STATE state;

    GameBase();

    virtual void runGame() = 0;
    virtual void initGame() = 0;
    };

    class XXXGame : public GameBase
    {
    private:
    // custom function

    public:
    using GameBase::GameBase;
    void runGame() override;
    void initGame() override;
    };

    GameBase *gameFactory(MenuItem item)
    {
    switch (item)
    {
    case SNAKE:
    return new SnakeGame();
    case BALL:
    return new WallBallGame();
    case AIRPLANE:
    return new AirplaneGame();
    default:
    return nullptr;
    }
    }

    MenuItem中定义了我打算实作哪几款游戏,GameBase是所有游戏的介面,可以继承GameBase实作对应游戏,最后透过gameFactory可以产生对应类别的游戏。

    接着顺便把比较简单的游戏选单做出来如下:

    const char *menuItems[] = {"Snake", "Ball", "Airplane"};
    const int menuItemsCount = MENU_ITEM_COUNT;
    MenuItem currentSelection = SNAKE;

    void moveSelection(PS2Button &btn)
    {
    BUTTON_DIRECTORY dir = btn.getDir();
    switch (dir)
    {
    case LEFT:
    currentSelection = (currentSelection - 1 + menuItemsCount) % menuItemsCount;
    delay(500);
    break;
    case RIGHT:
    currentSelection = (currentSelection + 1) % menuItemsCount;
    delay(500);
    break;
    default:
    break;
    }
    }

    void drawMenu(OLED &oled)
    {
    oled.printMenuItem(menuItems[currentSelection], 2);
    }

    void OLED::printMenuItem(String str, int size)
    {
    // 计算框框的大小
    int boxWidth = str.length() * 12 + 8; // 每个字母约佔 6 像素宽,额外加上边框 // 每行的高度
    int boxX = 0; // 框框的 X 坐标
    int boxY = 20; // 框框的 Y 坐标

    this->display->clearDisplay();
    this->display->setTextSize(1);
    this->display->setTextColor(1);
    this->display->setCursor(20, 0);
    this->display->print(" click button ");
    this->drawArrow(0, 0, true); // 左箭头
    this->drawArrow(SCREEN_WIDTH - 8, 0, false); // 右箭头
    this->display->drawRect(boxX, boxY, boxWidth, 25, 1); // 绘制选中框框
    this->display->setTextSize(size); // 设定文字大小
    this->display->setTextColor(1); // 1:OLED预设的颜色(这个会依该OLED的颜色来决定)
    this->display->setCursor(5, 25); // 设定起始座标
    this->display->print(str); // 要显示的字串
    this->display->display(); // 要有这行才会把文字显示出来
    }

    void OLED::drawArrow(int x, int y, bool isLeft)
    {
    if (isLeft)
    {
    // 绘制左箭头
    this->display->drawLine(x, y + 4, x + 6, y, SSD1306_WHITE); // 上边
    this->display->drawLine(x, y + 4, x + 6, y + 8, SSD1306_WHITE); // 下边
    this->display->drawLine(x + 6, y, x + 6, y + 8, SSD1306_WHITE); // 直线部分
    }
    else
    {
    // 绘制右箭头
    this->display->drawLine(x + 2, y + 4, x - 4, y, SSD1306_WHITE); // 上边
    this->display->drawLine(x + 2, y + 4, x - 4, y + 8, SSD1306_WHITE); // 下边
    this->display->drawLine(x - 4, y, x - 4, y + 8, SSD1306_WHITE); // 直线部分
    }
    }

    currentSelection在全域变数中存放状态,内容是 Menu 的选项。drawMenu可以按照状态绘制游戏选择页面,printMenuItem则是细节实作。

    至此,服务层的架构完成,之后只要妥善的设计每一款游戏即可。

    控制层

    在控制层中,我们要完成系统的运作流程,因此可以按照以下方式实作:

    void Controller::Serve(void)
    {
    ControllerState curState = this->state;
    switch (curState)
    {
    case Welcome:
    delay(500);
    this->state = Menu;
    btn.resetBtn();
    btn.adjust();
    break;
    case Menu:
    moveSelection(this->btn);
    drawMenu(this->oled);
    if (btn.isClickBtn())
    {
    btn.resetBtn();
    curGame = gameFactory(currentSelection, this->oled, this->btn);
    if (curGame == nullptr)
    {
    oled.printText("null game");
    while (true)
    {
    }
    }
    curGame->initGame();
    this->state = Game;
    }
    break;
    case Game:
    // render to game
    curGame->runGame();
    if (curGame->state == GAME_STATE_END)
    {
    btn.resetBtn();
    this->state = Result;
    }
    break;
    case Result:
    curGame->runGame();
    if (btn.isClickBtn())
    {
    btn.resetBtn();
    delete curGame;
    curGame = nullptr;
    this->state = Menu;
    }
    break;
    }
    }

    基于 FSM 的设计原则,进入函数时要依据当前状态决定行为,Welcome表示初始状态,要完成按钮的校正与重设,接着直接转换到Menu游戏选单画面。使用者在游戏选单可以透过左右移动选择不同游戏,选中游戏后透过gameFactory动态配置游戏物件,完成初始化(initGame)后进入游戏状态(Game)。在游戏中只要不断地在执行到时用curGame->runGame()来渲染下一帧画面即可,可以透过检查内部 FSM(Game 物件)的状态进入GAME_STATE_END即游戏结束,来进入游戏结果(Result)状态。在结果(Result)状态中使用者可以按确认回到游戏列表(Menu)状态。

    后记

    以上主要是想描述大方向的设计思路,细节可以参考原始码:https://github.com/leon123858/Arduino-exercise/tree/main/small-gameboy

    小弟多年来一直觉得缺乏可以完整描述系统从发想到完成的教学文件,但实际一写确实困难,毕竟牵涉到方方面面。只能草草收尾直接发文了,希望未来能有所进步!

    若是内容有误或有相关建议,欢迎留言,谢谢。