给3DS写个贪吃蛇
3DS早已停产,eShop也于今年关闭。看起来,3DS已经不会再有新游戏了——除非,自己来写。
目录
先决条件
理想情况下,我们可以自由地使用3DS运行任何程序。可遗憾的是:想要跑自己的游戏,需要破解3DS并安装Homebrew Launcher(hbmenu)。
如果不想破解,也可以用模拟器玩。但是模拟器的性能要比实机强,所以最好还是安装hbmenu,来验证自己的游戏能不能在真实的3DS上正常运行。
安装游戏
如果用3DS实机+hbmenu的话,把3ds-snake.3dsx拖到SD卡中的3ds文件夹内。打开hbmenu即可看到下载链接旁的小蛇图标。
如果用模拟器的话,直接打开3ds-snake.3dsx所在目录即可。
安装工具链和库
简而言之,我们需要ARM11的编译工具,与可以利用屏幕、按键等外设的库。正规渠道是向任天堂申请开发者工具,但如果不想泄露个人信息,还是使用由devkitPro团队维护的devkitARM吧。devkitARM包含了ARM架构的GCC、基于Newlib的C标准库和用于3DS编程的库libctru。
devkitPro使用pacman来管理他们的软件,同时也提供包含devkitARM的Docker镜像。 我推荐直接用后者,省去一步步安装、污染环境的麻烦。
使用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.c的source子目录:
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,据为己有。
基本框架
简单的游戏可以被分成如下几步:
- 初始化各种资源;
- 主循环:
- 读取输入;
- 更新下一帧的状态;
- 绘制下一帧。
- 游戏结束,释放资源。
所以我们的初始代码也如下所示:
#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。所以我只能用libctru的console模式打印彩色字符来充当游戏画面。引用一下示例程序的注释:
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;
}