3DS写个贪吃蛇

3DS早已停产,eShop也于今年关闭。看起来,3DS已经不会再有新游戏了——除非,自己来写

小蛇咬着苹果的图标点我下载3ds-snake.3dsx

目录

先决条件

理想情况下,我们可以自由地使用3DS运行任何程序。可遗憾的是:想要跑自己的游戏,需要破解3DS并安装Homebrew Launcher(hbmenu

如果不想破解,也可以用模拟器玩。但是模拟器的性能要比实机强,所以最好还是安装hbmenu,来验证自己的游戏能不能在真实的3DS上正常运行。

安装游戏

如果用3DS实机+hbmenu的话,把3ds-snake.3dsx拖到SD卡中的3ds文件夹内。打开hbmenu即可看到下载链接旁的小蛇图标

如果用模拟器的话,直接打开3ds-snake.3dsx所在目录即可。

安装工具链和库

简而言之,我们需要ARM11的编译工具,与可以利用屏幕、按键等外设的库。正规渠道是向任天堂申请开发者工具,但如果不想泄露个人信息,还是使用由devkitPro团队维护的devkitARM吧。devkitARM包含了ARM架构的GCC、基于NewlibC标准库和用于3DS编程的库libctru

devkitPro使用pacman来管理他们的软件,同时也提供包含devkitARMDocker镜像我推荐直接用后者,省去一步步安装、污染环境的麻烦。

使用Docker下载devkitARM镜像的命令是(但其实不用手动下载,后面会提到

docker pull devkitpro/devkitarm

构建示例程序

devkitPro提供了大量的3DS示例程序,我们可以随意挑选一个试试我们的工具链是否安装成功了。比如,我想试试能不能编译read-controls这个示例,首先进入它所在的文件夹:

git clone https://github.com/devkitPro/3ds-examples.git
cd 3ds-examples/input/read-controls

此时当前路径下应该有一个Makefile,和带着main.csource子目录:

tree
# 下面是输出
.
├── Makefile
└── source
    └── main.c

1 directory, 2 files

然后使用来自Docker镜像的make来构建程序(如果本地没有devkitARM镜像,这一步会自动拉取

docker run --rm -v $PWD:/${PWD##*/} devkitpro/devkitarm make -C /${PWD##*/}

如果没有意外,会得到可以被hbmenu运行的read-controls.3dsx。

我们的目标也是要得到3dsx文件,而构建它的规则和示例程序的规则(写在Makefile里)无异。鉴于示例程序的代码采用了放弃一切权利的公共领域授权,我们可以直接修改它的Makefile,据为己有。

基本框架

简单的游戏可以被分成如下几步:

  1. 初始化各种资源;
  2. 主循环:
    1. 读取输入;
    2. 更新下一帧的状态;
    3. 绘制下一帧。
  3. 游戏结束,释放资源。

所以我们的初始代码也如下所示:

#include <3ds.h>

int main(int argc, char* argv[])
{
    setup(); // 1

    while (aptMainLoop()) { // 2
        input(); // 2.1
        update(); // 2.2
        draw(); // 2.3
    }

    cleanup(); // 3
    return 0;
}

其中setup、draw、cleanup所必须的样板代码,可以在上一小节的示例程序里找到。我就不再赘述了。

另外要注意的是更新频率,如果每个循环蛇都前进一格的话,那就太快了。有些游戏引擎的更新函数会提供“距离上次调用过去了多少时间”的参数,让开发者计算本次更新要更新到什么程度。不过,这种方法对我们的简单游戏有些过火:我们只要给主循环加个不到规定时间不更新的判断即可:

u64 reference = svcGetSystemTick();
bool enoughWait = now - reference > CPU_TICKS_PER_MSEC * 100;
if (enoughWait) {
    reference = now;

    update();
}

画地图

很抱歉,我完全不了解shader、gfx。所以我只能用libctruconsole模式打印彩色字符来充当游戏画面。引用一下示例程序的注释:

To move the cursor you have to print "\x1b[r;cH", where r and c are respectively the row and column where you want your cursor to move. The top screen has 30 rows and 50 columns. The bottom screen has 30 rows and 40 columns.

所以在屏幕四周打印一圈井号充当地图边界的代码就是:

using point = std::pair<unsigned, unsigned>;

PrintConsole topScreen;

void putStringAt(const char c[], point p)
{
    // 把光标移到(y + 1, x + 1),打印c
    // +1是因为libctru的行、列是从一开始数的
    printf("\x1b[%d;%dH%s", p.second + 1, p.first + 1, c);
}

void drawMap()
{
    consoleSelect(&topScreen);
    consoleClear();

    // 把光标移到左上角,打印50个#
    printf("\x1b[1;1H##################################################");
    for (auto i = 1u; i < TOP_SCREEN_HEIGHT - 1; i++) {
        // 从第二行到倒数第二行,在最左边和最右边各打印一个#
        putStringAt("#", { 0, i });
        putStringAt("#", { TOP_SCREEN_WIDTH - 1, i });
    }
    // 把光标移到左下角,打印50个#
    printf("\x1b[30;1H##################################################");
}

画苹果

console模式中,背景是黑色的,默认字符颜色是白色。想打印红色的苹果,同样需要\x开头的字符串:\x1b[31m@\x1b[0m。其意为:切换颜色为红色,打印@,恢复原来颜色。所以画苹果的代码是:

point apple;
// 生成苹果位置
putStringAt("\x1b[31m@\x1b[0m", apple);

画蛇

画蛇和画苹果类似,只是从打印一个@变成打印一个O表示头,再接着几个o表示身子。因为蛇不会只剩一个头,所以遍历身子时可以从1开始。

std::vector<point> snake;

putStringAt("\x1b[32mO\x1b[0m", snake[0]); // 头,绿色大写O
for (auto i = 1uz; i < snake.size(); i++)
    putStringAt("\x1b[32mo\x1b[0m", snake[i]); // 身,绿色小写o

但是在更新蛇时,我们不必清空屏幕再重新画直接在蛇头前一个画个新头,把原蛇头画成身子,再把蛇尾擦去(打印空格)即可。

putStringAt("\x1b[32mO\x1b[0m", newHead);
putStringAt("\x1b[32mo\x1b[0m", snake.front());
putStringAt(" ", snake.back());

不过这种画法不完全正确,因为蛇吃到苹果时身子会延长,所以在擦除尾巴之前应该先判定蛇头是否碰到苹果了:

if (newHead != apple)
    putStringAt(" ", snake.back());

碰撞检测及生成苹果

像这种字符画游戏的碰撞检测就是用或连起来一串相等:

bool hitWall(point p)
{
    return p.first == 0 || p.first == TOP_SCREEN_WIDTH - 1 || p.second == 0 || p.second == TOP_SCREEN_HEIGHT - 1;
}

生成苹果的方式就是随机,然后看有没有撞到蛇或者墙:

bool hitSnake(point p)
{
    return snake.end() != std::find(snake.begin(), snake.end(), p);
}

void createApple()
{
    do {
        apple = { rand() % TOP_SCREEN_WIDTH, rand() % TOP_SCREEN_HEIGHT };
    } while (hitWall(apple) || hitSnake(apple));
}

读取输入、生成新蛇头

libctru里,要先调用hidScanInput,再用hidKeysDown得到按下的按键(或hidKeysHeld得到按住的。后两者会返回u32类型的编码,和KEY_UP、KEY_DOWN等常量按位与后则可以判定按下的到底是哪些键:

u32 getInput()
{
    hidScanInput();
    return hidKeysDown() | hidKeysHeld();
}

point getNewHead(u32 key)
{
    if (key & KEY_UP)
        direction = up;
    else if (key & KEY_DOWN)
        direction = down;
    else if (key & KEY_LEFT)
        direction = left;
    else if (key & KEY_RIGHT)
        direction = right;

    point head = snake.front();
    switch (direction) {
    case up:
        return { head.first, head.second - 1 };
    case down:
        return { head.first, head.second + 1 };
    case left:
        return { head.first - 1, head.second };
    case right:
        return { head.first + 1, head.second };
    default: // direction is a global variable
        __builtin_unreachable();
    }
}

完整代码

把前面各小节的内容组合起来,就是完整的游戏了。184行,不多也不少。

点我展开main.cpp
#include <3ds.h>
#include <algorithm>
#include <cstdio>
#include <vector>

const unsigned TOP_SCREEN_WIDTH = 50;
const unsigned TOP_SCREEN_HEIGHT = 30;

bool gameOver = false;

enum Direction {
    up,
    down,
    left,
    right
};
Direction direction;

using point = std::pair<unsigned, unsigned>;
std::vector<point> snake;
point apple;

PrintConsole topScreen, bottomScreen;

void putStringAt(const char c[], point p)
{
    printf("\x1b[%d;%dH%s", p.second + 1, p.first + 1, c);
}

void gameStart()
{
    direction = down;

    snake.clear();
    snake.push_back({ 25, 4 });
    snake.push_back({ 25, 3 });
    snake.push_back({ 25, 2 });

    apple = { 25, 25 };

    consoleSelect(&topScreen);
    consoleClear();

    printf("\x1b[1;1H##################################################");
    for (auto i = 1u; i < TOP_SCREEN_HEIGHT - 1; i++) {
        putStringAt("#", { 0, i });
        putStringAt("#", { TOP_SCREEN_WIDTH - 1, i });
    }
    printf("\x1b[30;1H##################################################");

    putStringAt("\x1b[31m@\x1b[0m", apple);

    putStringAt("\x1b[32mO\x1b[0m", snake[0]);
    for (auto i = 1uz; i < snake.size(); i++)
        putStringAt("\x1b[32mo\x1b[0m", snake[i]);

    consoleSelect(&bottomScreen);
    consoleClear();
    printf("Use D-pad to move.\n");
    printf("Eat apple to grow.\n");
    printf("Don't bite your self!\n");
    printf("Don't hit wall!\n");
}

void init()
{
    gfxInitDefault();

    consoleInit(GFX_TOP, &topScreen);
    consoleInit(GFX_BOTTOM, &bottomScreen);

    srand(19260817);
}

u32 getInput()
{
    hidScanInput();
    return hidKeysDown() | hidKeysHeld();
}

bool hitWall(point p)
{
    return p.first == 0 || p.first == TOP_SCREEN_WIDTH - 1 || p.second == 0 || p.second == TOP_SCREEN_HEIGHT - 1;
}

bool hitSnake(point p)
{
    return snake.end() != std::find(snake.begin(), snake.end(), p);
}

void createApple()
{
    point newApple;
    do {
        newApple = { rand() % TOP_SCREEN_WIDTH, rand() % TOP_SCREEN_HEIGHT };
    } while (hitWall(newApple) || hitSnake(newApple));
    apple = newApple;
}

void update(point newHead)
{
    if (hitWall(newHead) || hitSnake(newHead)) {
        gameOver = true;
        consoleSelect(&bottomScreen);
        consoleClear();
        printf("YOU DIED!\n");
        printf("Press start to start again!");
        // Even the game is over, we keep updating,
        // to create the head-in-the-wall scene.
    }

    consoleSelect(&topScreen);
    if (newHead == apple) {
        createApple();
        putStringAt("\x1b[31m@\x1b[0m", apple);
    } else {
        // Clear the last
        putStringAt(" ", snake.back());
        snake.pop_back();
    }

    putStringAt("\x1b[32mO\x1b[0m", newHead);
    putStringAt("\x1b[32mo\x1b[0m", snake.front()); // Overwrites the old apple
    snake.insert(snake.begin(), newHead);
}

point getNewHead(u32 key)
{
    if (key & KEY_UP)
        direction = up;
    else if (key & KEY_DOWN)
        direction = down;
    else if (key & KEY_LEFT)
        direction = left;
    else if (key & KEY_RIGHT)
        direction = right;

    point head = snake.front();
    switch (direction) {
    case up:
        return { head.first, head.second - 1 };
    case down:
        return { head.first, head.second + 1 };
    case left:
        return { head.first - 1, head.second };
    case right:
        return { head.first + 1, head.second };
    default: // direction is a global variable
        __builtin_unreachable();
    }
}

int main(int argc, char* argv[])
{
    init();
    gameStart();

    u64 reference = svcGetSystemTick();
    while (aptMainLoop()) {
        u32 key = getInput();

        if (key & KEY_START) {
            gameOver = false; // For more rounds
            gameStart();
        } else if (key & KEY_SELECT)
            break;

        const u64 now = svcGetSystemTick();
        const bool enoughWait = now - reference > CPU_TICKS_PER_MSEC * 100;
        if (enoughWait && !gameOver) {
            reference = now;

            update(getNewHead(key));
        }

        // every cycle, or else Home won't work
        gfxFlushBuffers();
        gfxSwapBuffers();
        gspWaitForVBlank();
    }

    gfxExit();
    return 0;
}

复制以下链接,并粘贴到你的Mastodon、MisskeyGoToSocial等应用的搜索栏中,即可搜到对应本文的嘟文。对嘟文进行的点赞、转发、评论,都会出现在本文底部。快去试试吧!

链接:https://emptystack.top/note/3ds-snake


两人赞过:
  1. 冥王星爱丽
  2. 黑糖

两条评论:

注:点击昵称可以查看对评论的回复。

  1. 冥王星爱丽

    @actor 在3DS上用终端写贪吃蛇 很有意思,辛苦了www

  2. 茶栗·chariri

    @actor@emptystack.top
    非常cool的工作,原来3DS是ARM11架构啊
    就是用字符画场景有点残念