文章目录

  • 2048游戏规则
  • 简要思路
  • 游戏界面该如何显示
    • 开始界面
    • 游戏界面
    • 结算界面
  • 代码分析
    • 杂项
    • board类
      • 属性
      • 方法
        • summon方法
        • init方法
        • isOver方法
        • isWon方法
        • isChanged方法
        • getScore方法
        • LeftOne等方法
        • move方法
        • setNum方法
        • updateDisplay方法
    • getKey函数
    • setup:开机时的准备工作
    • loop:游戏主循环

源代码在上一篇文章里(传送门),此处不放出完整源代码。

本篇代码中用于测试的部分(例如CHEAT宏定义和TEST_MODE宏定义)被移除,以方便讲解。

2048游戏规则

2048游戏棋盘上共有4x4=16个格子,初始时存在两个初始数字,其中一个是2,另一个有30%的几率为4,70%的几率为2。

  • 手指向一个方向滑动,所有格子会向那个方向运动。

  • 相同数字的两个格子,相撞时数字会相加。

  • 每次有效滑动后,空白处会随机刷新出一个格子,有30%的几率为4,70%的几率为2。

  • 当界面不可运动时(当界面全部被数字填满时),游戏结束;当界面中最大数字是2048时,游戏胜利。

简要思路

使用4x4的二维数组模拟2048的游戏棋盘,从左上角(0,0)开始标号:

数组的每个元素就代表此处格子的数字。

用0表示此处没有数字。

游戏界面该如何显示

开始界面

“2048”字样选择了u8g2_font_maniac_tr字体。

“Start”字样选择了u8g2_font_7x14B_mr字体。

游戏界面

屏幕左侧的棋盘大概类似这样:

每个格子里显示一个数字。

可是,翻遍u8g2的字体库,仍找不到一款能显示得开四位数2048并且可读性令我满意的一款字体。

但是,为什么一定要用字体呢?

每个含有数字的格子放一张代表这个数字的位图不也行吗?

我另辟蹊径,用Ps为从2到2048的所有所需数字绘制了14x14大小的位图,通过取字模网站转换为字模,发现显示效果非常不错。

附:绘制的位图(已上传到[github](https://github.com/blackpancake/arduino2048/tree/main/Number model)):

结算界面

“Game Over”字样和“You Win!”仍然是u8g2_font_maniac_tr字体。

“Your score: xxx”字样是u8g2_font_crox4t_tr字体。

代码分析

杂项

首先是头文件引入,没什么好说的,time.h用于获取时间为随机数提供种子,U8g2lib.h是u8g2库的头文件,用于在LCD12864上绘图。

#include <time.h>
#include <U8g2lib.h>

然后是实例化一个u8g2绘图对象,这里由于搭建电路时LCD12864反向摆放,所以画面标志位由原本的U8G2_R0改为U8G2_R2

10号管脚是CS片选,12号管脚是显示屏的RST复位。(详见上一篇博客)

U8G2_ST7920_128X64_F_HW_SPI u8g2(U8G2_R2, /* CS=*/10, /* reset=*/12);

接下来是Number.h的引入,待会再讲。

#include "Number.h"

接下来是4个方向的轻触按钮所连管脚的宏定义:

#define Up 4
#define Down 5
#define Left 6
#define Right 7

这里定义了一个内联函数int getRand(int a, int b),作用是获取 [a,b][a,b][a,b] 之间的一个随机整数。

原理基于c++内置的rand()函数,具体请自行百度。

inline int getRand(int a, int b)
{return rand() % (b - a + 1) + a;
}

这里定义了四个方向的枚举变量,之后在move函数上会用到。

enum DIRECTION
{LEFT,RIGHT,UP,DOWN
};

board类

接下来就是重头戏了,棋盘类board的定义,待会会分开对每个方法进行分析:

class board
{private:int map[4][4];long score;using PtrToMemberFunc = bool (board::*)(int);PtrToMemberFunc Moves[4] = {&board::LeftOne, &board::RightOne, &board::UpOne, &board::DownOne};bool changed;public:void init();void summon(bool noFour = false);bool isOver();bool isWon();bool isChanged(){return changed;}int getScore(){return score;}bool LeftOne(int ro);bool RightOne(int ro);bool UpOne(int ro);bool DownOne(int ro);void move(DIRECTION di);void updateDisplay();void setNum(int a, int b);
};

属性

首先来看私有的一些属性:

模拟地图:

    int map[4][4];

存分数(这里保险起见,用了long存分数):

    long score;

下面的两行先跳过,待会在讲move函数时会讲。

棋盘是否在移动后被改变了的标志位:

    bool changed;

方法

summon方法

void board::summon(bool noFour)
{struct block{int x;int y;} blanks[16];memset(blanks, 0, sizeof(blanks));int ptr = 0;for (int i = 0; i < 4; ++i)for (int j = 0; j < 4; ++j)if (map[i][j] == 0)blanks[ptr++] = {i, j};block &sele = blanks[getRand(0, ptr - 1)];if (noFour)map[sele.x][sele.y] = 2;elsemap[sele.x][sele.y] = getRand(1, 10) > 3 ? 2 : 4;
}

该方法用于在棋盘上空白位置生成一个数字,在开局时和每次有效滑动后被调用。

在解释方法如何运行之前,先谈谈生成数字的思路吧!

这里采用的思路是,遍历整个棋盘,将所有空白位置记录下来到队列里,

然后从队列中随机选取一个空白位置,将棋盘的这个位置放上数字。

方法内部定义了表示一个空白位置的结构体block,它拥有两个成员,分别记录某个空白格子在棋盘上的坐标,并顺便创建了一个长16的(因为棋盘上最多只可能有16个空白位置)block类型的数组blanks

    struct block{int x;int y;} blanks[16];

之后利用memset将队列清零(初始化),并定义了一个用于自增的指针ptr,初始时指向队列首位元素。

    for (int i = 0; i < 4; ++i)for (int j = 0; j < 4; ++j)if (map[i][j] == 0)blanks[ptr++] = {i, j};

两个for循环遍历数组,if判断该格是否为空,是则加入队列。

注意,ptr++实际上是返回现在的ptr之后,再将ptr自增。赋值号右侧是花括号形式的结构体赋值。

显然,遍历完成之后,ptr指向队尾元素的下一个位置,不难看出,只需要在 [0,ptr−1][0,ptr-1][0,ptr−1] 中随机选择一个整数作为blanks数组的下标,就相当于随机选取了一个空白格。

于是便有了下面这行代码:

block &sele = blanks[getRand(0, ptr - 1)];

此处为了节省内存,创建了一个对被选择了的空白格的引用,方便后续从它身上获取数据。

if (noFour)map[sele.x][sele.y] = 2;elsemap[sele.x][sele.y] = getRand(1, 10) > 3 ? 2 : 4;

因为我们有“不许生成4”的需求(在开局时有一个初始数字只能为2),所以加入了noFour标志位作为函数的参数,它为true时就代表此时的summon方法不会生成4。

这里使用了“在1到10之间随机选择一个数看是否大于3”的方法来模拟三七分的生成概率。

init方法

void board::init()
{memset(map, 0, sizeof(map));summon();summon(true);changed = true;score = 0;
}

此函数对整个棋盘对象进行初始化,在开始游戏和重新游戏时被调用。

使用memset内置函数对棋盘数组清零后调用两次summon方法生成两个初始数字。

注意,第二次调用summon时,为了防止出现“双4开局”的情况,将noFour标志位置位。

changed被设为true,以便让游戏主循环调用updateDisplay方法,进行屏幕的首次刷新。

isOver方法

bool board::isOver()
{for (int i = 0; i < 4; ++i)for (int j = 0; j < 4; ++j){if (map[i][j] == 0)return false;if (j < 3 && map[i][j] == map[i][j + 1]){return false;}if (i < 3 && map[i][j] == map[i + 1][j]){return false;}}return true;
}

该方法返回游戏是否结束,每次移动后,游戏主循环就会调用它以检测是否因为这次移动而导致游戏的终结。

判断思路:

首先可以肯定的是,对于给定的一个2048游戏局面,只要棋盘上还存在空位,那就说明游戏还没有结束。

假如没有空位了呢?

那就检测是否有能够合并的。

从左上角(0,0)开始遍历,对于某一个格子,若它右侧或者下方有相同的格子,就代表它还可以合并,也代表着游戏还没结束。

为什么不检测左侧和上方?

事实上,如果向右滑动棋盘时某个格子会被合并,那么即使向左滑动棋盘,它仍然会被合并,两个滑动方向在这个问题上是等价的。向上向下滑也是同理。因此只检测右侧和下方就够了。

需要注意的是,为了防止数组越界(例如在棋盘最右边试图寻找更右侧的格子),加入了坐标上的限定。即对于最右侧的格子不检测其右侧,对于最下方的格子不检测其下方。

isWon方法

bool board::isWon()
{for (int i = 0; i < 4; ++i)for (int j = 0; j < 4; ++j){if (map[i][j] >= 2048)return true;}return false;
}

该方法返回游戏是否胜利,即棋盘中是否含有2048这个数字。

原理很简单,两个for循环遍历棋盘,查找是否存在2048即可。

map[i][j] >= 2048中的>=也可以换成==

isChanged方法

bool isChanged()
{return changed;
}

内联接口,用于从外部获取changed属性的值。

getScore方法

int getScore()
{return score;
}

内联接口,用于从外部获取score属性的值。

LeftOne等方法

RightOneUpOneDownOne等方法与LeftOne方法的代码极其相似,所以此处只放出LeftOne方法的代码。

bool board::LeftOne(int ro)
{bool fail = true;int c = 0;while (c < 4){int nextc = c + 1;while (nextc < 4 && map[ro][nextc] == 0)nextc++;if (nextc >= 4)break;if (map[ro][c] == 0){fail = false;map[ro][c] = map[ro][nextc];map[ro][nextc] = 0;continue;}else if (map[ro][c] == map[ro][nextc]){fail = false;map[ro][c] *= 2;score += map[ro][c];map[ro][nextc] = 0;}++c;}return fail;
}

这个方法是2048游戏的核心所在,它负责移动和合并一行(列)的数字。

在解释方法如何运行之前,先谈谈移动与合并的思路吧!

此处的逻辑思路参考了这篇博客

不难发现,在向某个方向滑动棋盘时,该方向上的行(列)是互不干扰的。即,对于每行 (列)所进行的操作是相同的。

因此我们可以把问题规模缩小,缩小到如何在一行(列)上实现移动+合并。

此处的思路如下(暂时以向左滑动为例):

  1. 首先将c指针指向最左边的元素
  2. 在c的左侧向右寻找第一个非0元素,将nextc指向它(这是第2步)
  3. 如果找到了
    1. 如果c指向的值是0

      1. 让nextc和c所指向的格子交换它们的值
    2. 如果c指向的值等于nextc指向的值
      1. 将c指向的值乘2
      2. 分数加上c指向的值
      3. 将nextc指向的值置0
    3. c指向下一个元素,若已经完成该行(列)上所有元素的遍历,否则回到第2步
  4. 否则
    1. 结束过程

这个过程结束后就能在一行(列)上同时完成移动和合并两大任务。

对四个行(列)各进行一遍这样的操作,一次棋盘的滑动就完成了。

讲完思路,再看代码就很容易看懂了。

while (c < 4)

这行代码让c在完成所有元素的遍历后自动退出循环。

        int nextc = c + 1;while (nextc < 4 && map[ro][nextc] == 0)nextc++;

这些代码让nextc从c的右侧开始寻找非零格,nextc < 4让循环在找不到非零格时自动退出寻找的循环。

        if (nextc >= 4)break;

这些代码让找不到非零格时退出移动-合并的过程。

        if (map[ro][c] == 0){fail = false;map[ro][c] = map[ro][nextc];map[ro][nextc] = 0;continue;}else if (map[ro][c] == map[ro][nextc]){fail = false;map[ro][c] *= 2;score += map[ro][c];map[ro][nextc] = 0;}

和上述的思路基本一一对应,就不解释了。

++c;

这行代码让c指向下一个元素。

这时你可能会注意,为什么代码里有一个变量fail

此时,我们要引入两个概念:有效移动有效滑动

对于一行(列)来说,如果在进行一次上述的移动-合并操作后,这一次移动-合并操作起了效果(即这一行(列)发生了变化,比如位置移动了,或者发生了合并),那么就称这次移动-合并操作是一次有效移动

对于一个棋盘来说,如果在对四行(列)分别进行四次上述的移动-合并操作后,棋盘的状态发生了变化,那么就称这四次移动-合并操作是一次有效滑动

那么,在LeftOne方法里,fail就代表着这次移动-合并操作是否不是一次有效移动,或者更通俗一点,这次移动-合并操作是否失败。

函数最开始时将fail设为true,在循环中,如果发生移动(if (map[ro][c] == 0))或合并(else if (map[ro][c] == map[ro][nextc])),就把fail设为false,最后将fail返回。

需要注意的是,函数的参数ro,表示对第ro行(列)进行移动-合并操作。

另外,虽然此处只展示了向左的移动-合并操作,但其实其他三个方向的原理也是相同的,此处就不放出代码了。

move方法

void board::move(DIRECTION di)
{bool fail = true;for (int i = 0; i < 4; ++i){bool tmp = (this->*Moves[di])(i);fail &= tmp;}changed = !fail;if (!fail)summon();
}

此方法的代码虽短,但也是代码中的关键所在。

先把fail相关的代码暂时去掉,此时该方法就只剩下两行代码了:

for (int i = 0; i < 4; ++i)(this->*Moves[di])(i);

没错,这两行就是关键。

在研究这两行之前,先给出move方法的意义吧:move(X)使棋盘向X方向滑动。这里的X是枚举类型DIRECTION的,这意味着它可以有四个取值:LEFTRIGHTUPDOWN,分别是0123的别名。

要想使棋盘向某个方向滑动,就对四个行(列)分别调用对应的XXXOne方法进行移动-合并操作。

例如想让整个棋盘向左滑动,就要执行以下代码:

LeftOne(0);//对第一行进行移动-合并操作。
LeftOne(1);//本行及以下同上
LeftOne(2);
LeftOne(3);

这太臃肿了!

你一定会想到用for循环简化:

for (int i = 0; i < 4; ++i)LeftOne(i);

好了,现在向左滑动只需要两行代码了。可是,向右呢,向上向下呢?也要写一遍这样的for循环然后用if根据di判断用哪个吗?

不行,这太臃肿了,不符合*DRY(Don’t Repeat Yourself)*原则!

怎样才能让di直接动态的与四个方法联系起来呢?

用函数指针!

更确切地,是用成员函数指针数组。

将四个指向各方向的移动-合并操作方法的指针组织成一个数组,用di作数组的下标就可以实现了。

于是便有了在前文中提到的:

using PtrToMemberFunc = bool (board::*)(int);
PtrToMemberFunc Moves[4] = {&board::LeftOne, &board::RightOne, &board::UpOne, &board::DownOne};

第一行使用了C++11独有的(没错,arduino部分支持C++11!)using type_New = type_Old的语法来给“指向各方向的移动-合并操作方法的指针”这一类型取了一个别名PtrToMemberFunc

此处在*号前加board::是因为成员函数指针前面要有类名的指定否则会编译不过。

第二行定义了一个长度为4的(因为是4个方向的成员函数嘛)元素类型为PtrToMemberFunc的数组,四个元素初始化为指向四个成员函数的函数指针。

其实第二行的&board::是可以去掉的,但是会有警告,为了满足强迫症,还是加上了。

定义完了成员函数指针数组,该怎么用它调用成员函数呢?

你可能会脱口而出:

Moves[di](i);//i是参数

很抱歉,编译失败。

然后,你可能会抱着试一试的心态敲下:

(*Moves[di])(i);//i是参数

很抱歉,编译失败。

经过一番尝试和查阅资料,真相终于大白:

(this->*Moves[di])(i);

为什么这里会有this->?说实话,我也不知道,但是加上就编译成功了(摊手)。

无论如何,move方法的核心两行代码终于理解了:

对于棋盘的每一行(列),根据传入的方向参数di,调用对应方向的移动-合并操作方法。

接下来就是move方法中的fail了。

这里的fail的意义和移动-合并操作方法中的fail意义差不多,但又有所区别,它代表着这次对棋盘的滑动是否不是一次棋盘上的有效滑动,或者更通俗一点,这次滑动是否失败。

再来想一下,已知四个行(列)上的移动-合并是否失败,能否得出整次滑动是否失败?

容易得出,除非四个行(列)上的移动-合并全部失败才算失败,只要有一行(列)成功,就算成功。

也就是说,将四次移动-合并的返回值全部进行&与操作,得到的结果就是整次滑动是否失败。

fail的初始值只能是1,毕竟如果是0的话,无论再&与多少个1,结果还是0,这肯定不是我们想要的。

另外,因为每次移动(调用move方法)都存在使棋盘局面改变的可能~~(这不废话吗?)~~,因此我们需要在滑动未失败时(fail==0)将changed置位。

if(fail==0) changed=1;更简洁的写法是changed=!fail;,所以这里采用了后者。

另外,根据游戏规则,我们需要在滑动未失败时(fail==0),在空白处生成新数字,这点需要注意。

setNum方法

void board::setNum(int a, int b)
{int num = map[a][b];u8g2.drawXBMP(1 + a * 15, 1 + b * 15, 14, 14, GET_NUM_DATA(num));
}

该方法用于将棋盘给定位置的数字在屏幕上绘制出来。

setNum(a,b)将会在屏幕上的对应位置绘制出map[a][b]里的数字。

在讲解该方法之前,我们需要先看一下Number.h的内容:

#ifndef _NUMBER_H
#define _NUMBER_H
// width: 14, height: 14
const unsigned char NUMs[][28] U8X8_PROGMEM = {{0xff, 0x3f, 0x03, 0x30, 0xff, 0x33, 0xff, 0x33, 0xff, 0x33, 0xff, 0x33, 0x03, 0x30, 0xf3, 0x3f, 0xf3, 0x3f, 0xf3, 0x3f, 0xf3, 0x3f, 0xf3, 0x3f, 0x03, 0x30, 0xff, 0x3f},{0xff, 0x3f, 0xf3, 0x33, 0xf3, 0x33, 0xf3, 0x33, 0xf3, 0x33, 0xf3, 0x33, 0x03, 0x30, 0xff, 0x33, 0xff, 0x33, 0xff, 0x33, 0xff, 0x33, 0xff, 0x33, 0xff, 0x33, 0xff, 0x3f},{0xff, 0x3f, 0x03, 0x30, 0xf3, 0x33, 0xf3, 0x33, 0xf3, 0x33, 0xf3, 0x33, 0x03, 0x30, 0xf3, 0x33, 0xf3, 0x33, 0xf3, 0x33, 0xf3, 0x33, 0xf3, 0x33, 0x03, 0x30, 0xff, 0x3f},{0xff, 0x3f, 0x33, 0x30, 0x33, 0x3f, 0x33, 0x3f, 0x33, 0x3f, 0x33, 0x3f, 0x33, 0x30, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x30, 0xff, 0x3f},{0xff, 0x3f, 0xc1, 0x20, 0xc1, 0x20, 0xdf, 0x2f, 0xdf, 0x2f, 0xdf, 0x2f, 0xc1, 0x20, 0xc1, 0x20, 0xdf, 0x3e, 0xdf, 0x3e, 0xdf, 0x3e, 0xc1, 0x20, 0xc1, 0x20, 0xff, 0x3f},{0xff, 0x3f, 0xc1, 0x2e, 0xc1, 0x2e, 0xfd, 0x2e, 0xfd, 0x2e, 0xfd, 0x2e, 0xc1, 0x20, 0xc1, 0x20, 0xdd, 0x2f, 0xdd, 0x2f, 0xdd, 0x2f, 0xc1, 0x2f, 0xc1, 0x2f, 0xff, 0x3f},{0xff, 0x3f, 0x19, 0x21, 0x19, 0x21, 0x7b, 0x2d, 0x7b, 0x2d, 0x7b, 0x2d, 0x1b, 0x21, 0x1b, 0x21, 0xdb, 0x2d, 0xdb, 0x2d, 0xdb, 0x2d, 0x11, 0x21, 0x11, 0x21, 0xff, 0x3f},{0xff, 0x3f, 0x11, 0x21, 0x11, 0x21, 0xd7, 0x3d, 0xd7, 0x3d, 0xd7, 0x3d, 0x11, 0x21, 0x11, 0x21, 0x7d, 0x2d, 0x7d, 0x2d, 0x7d, 0x2d, 0x11, 0x21, 0x11, 0x21, 0xff, 0x3f},{0xff, 0x3f, 0x31, 0x23, 0x31, 0x23, 0x7d, 0x2f, 0x7d, 0x2f, 0x7d, 0x2f, 0x71, 0x23, 0x71, 0x23, 0x77, 0x3b, 0x77, 0x3b, 0x77, 0x3b, 0x31, 0x22, 0x31, 0x22, 0xff, 0x3f},{0xff, 0x3f, 0xe3, 0x30, 0xe3, 0x36, 0xe7, 0x36, 0xe7, 0x36, 0xc3, 0x30, 0xff, 0x3f, 0xff, 0x3f, 0xc3, 0x36, 0xdf, 0x36, 0xc3, 0x30, 0xfb, 0x37, 0xc3, 0x37, 0xff, 0x3f},{0xff, 0x3f, 0xc3, 0x30, 0xdf, 0x36, 0xc3, 0x36, 0xfb, 0x36, 0xc3, 0x30, 0xff, 0x3f, 0xff, 0x3f, 0xdb, 0x30, 0xdb, 0x36, 0xc3, 0x30, 0xdf, 0x36, 0xdf, 0x30, 0xff, 0x3f}
};#define GET_NUM_DATA(num) (NUMs[__builtin_ctz(num) - 1])#endif // _NUMBER_H

Number.h定义了一个二维数组,用于存放从2到2048十一个游戏里会用到的数字的字模。

正如前文所提到的,程序采用绘制位图字模的方式来显示一个含有数字的格子。

为了方便动态地取出字模数据,需要使用二维数组存放字模数据。

这样,NUMs[0]就是指向存放2的字模数据数组的指针(没错,一个指向数组的指针)。

同理,NUMs[1]可以取出4的字模数据,NUMs[10]可以取出2048的字模数据……

但是,我们从棋盘上得知的是该格子包含的数字,例如216512这样的整数,怎么才能把它们转换成二维数组的第一维的下标呢?

换句话说,怎么才能把序列 2,4,8,16,⋯,1024,20482,4,8,16,\cdots,1024,20482,4,8,16,⋯,1024,2048 映射为 0,1,2,3,…,9,100,1,2,3,\ldots,9,100,1,2,3,…,9,10 这个序列呢?

显然可以用对数运算。假如我们想要绘制数字 xxx 的字模,则 log⁡2x−1\log_2x-1log2​x−1 就是它在NUMs数组第一维的下标。

事实上,有一个比对数运算更高效的方法:

avrgcc编译器有一个内建函数__built_in_ctz__built_in_ctz(p)返回p在二进制下尾随零的个数,如果p只取2的幂,那么它和 log⁡2x\log_2xlog2​x的结果是一样的。

所以就有了Number.h中的宏定义:

#define GET_NUM_DATA(num) (NUMs[__builtin_ctz(num) - 1])

GET_NUM_DATA(num)返回指向存放num这个数字对应的字模数据的数组的指针。

于是就有了以下的绘制语句:

u8g2.drawXBMP(1 + a * 15, 1 + b * 15, 14, 14, GET_NUM_DATA(num));

drawXBMP方法的前两个参数分别是要绘制出的位图的左上角的坐标,因为正如前文所提到的,棋盘上一个空格的大小是14x14,所以可以用map数组下标ab推算偏移值从而得知坐标。

之后的两个参数是位图的长宽,在这里是一个空格的大小14x14。

之后就是上文中的GET_NUM_DATA,根据num获取其字模数据。

需要注意的是,为了节省RAM,将存字模的二维数组用U8X8_PROGMEM(其实用PROGMEM宏也是一样的,因为U8X8_PROGMEMPROGMEM宏的别名)存入了PROGMEM里,所以绘制字模是使用的是支持PROGMEM的drawXBMP而不是drawXBM

updateDisplay方法

void board::updateDisplay()
{u8g2.clearBuffer();u8g2.drawFrame(0, 0, 61, 61);u8g2.drawHLine(0, 15, 61);u8g2.drawHLine(0, 30, 61);u8g2.drawHLine(0, 45, 61);u8g2.drawVLine(15, 0, 61);u8g2.drawVLine(30, 0, 61);u8g2.drawVLine(45, 0, 61);u8g2.setFont(u8g2_font_crox4t_tr);u8g2.drawStr(65, 28, "Score:");char score_str[6];itoa(score, score_str, 10);u8g2.drawStr(65, 48, score_str);for (int i = 0; i < 4; ++i)for (int j = 0; j < 4; ++j)if (map[i][j] != 0)setNum(i, j);u8g2.sendBuffer();
}

该方法负责按照map数组的内容在LCD12864上绘制(更新)棋盘,同时更新分数。

棋盘的边框是由一个空心矩形内部画上横三竖三的直线形成的。

    u8g2.drawStr(65, 28, "Score:");char score_str[6];itoa(score, score_str, 10);u8g2.drawStr(65, 48, score_str);

这是负责更新分数的部分,首先绘制“Score:”字样,然后创建了一个局部变量的数组用于缓存itoa方法转换出的字符串数据。

itoa内置函数将整数在给定进制下转换为字符串形式。

第一个参数是要转换的整数,在这里是分数。

第二个参数是转换结果的缓冲区。

第三个参数是整数的进制,这里当然需要使用10进制。

虽然itoa是非标准的,但arduino还是支持了。

for (int i = 0; i < 4; ++i)for (int j = 0; j < 4; ++j)if (map[i][j] != 0)setNum(i, j);

两个for循环用于遍历棋盘,对于非空的格子,调用setNum方法将其绘制出来。

需要注意的是,由于屏幕的绘制模式采用的是full_buffer模式,所以对屏幕的每一次更新都要放在两句代码之间,两句代码的意义从字面也不难看出:

u8g2.clearBuffer();
...
u8g2.sendBuffer();

getKey函数

char getKey()
{while (!(digitalRead(Up) || digitalRead(Down) || digitalRead(Left) || digitalRead(Right)));bool U = digitalRead(Up);bool D = digitalRead(Down);bool L = digitalRead(Left);bool R = digitalRead(Right);while (digitalRead(Up) || digitalRead(Down) || digitalRead(Left) || digitalRead(Right));if (U)return 'a';if (D)return 'd';if (L)return 'w';if (R)return 's';return 'x';
}

该函数独立于board类,用于阻塞地获取玩家点击的按钮方向。

    while (!(digitalRead(Up) || digitalRead(Down) || digitalRead(Left) || digitalRead(Right)));

这行代码意思是“持续等待,直到四个方向的按钮中任意一个被按下”。

因为是阻塞式地获取,所以在发生按按钮事件之前需要一直等待下去。

当按下按钮的事件发生,就用四个变量缓存一下当前四个按钮的按下与否。

切忌不能此时就返回结果,因为如果这样会造成玩家一直按着就会一直发生移动,而我们期望的行为是“在玩家松开按钮后再进行移动”,于是就有了下面的代码:

    while (digitalRead(Up) || digitalRead(Down) || digitalRead(Left) || digitalRead(Right));

这行代码意思是“持续等待,直到四个方向的按钮全部松开”。

当松开的事件发生后,就可以返回结果了。

因为LCD12864是反向摆放,所以需要进行反转和镜像,所以加入一个中间层(wasd)用来抹除屏幕旋转带来的方向改变。

需要注意的是,不同的按钮电路连接也会造成该层UDLR和wasd的对应关系不同,所以实际制作时需要在这个地方反复调试直到方向对应正确为止。

return 'x'是可以去掉的,只是保险起见加上,换成别的除wasd以外的字符也可以。

setup:开机时的准备工作

board Game;
void setup()
{srand((unsigned)time(NULL) + analogRead(A0));u8g2.begin();
}

在实例化了一个board类的对象Game后,接下来就是熟悉的、每个arduino程序都有的函数——setup函数了。

srand用于设定接下来随机数序列的种子,这里就用到了time.h里的time函数获取时间戳,同时为了增加随机数的随机性,把时间和analogRead读取一个未pinMode设置过的、悬空的模拟端口得到的数字(具体数字主要受环境中的电磁噪声影响)加在了一起。

然后调用了u8g2begin方法进行了绘图库的初始化。

loop:游戏主循环

void loop()
{u8g2.clearBuffer();u8g2.setFont(u8g2_font_maniac_tr);u8g2.drawStr(30, 24, "2048");u8g2.setFont(u8g2_font_7x14B_mr);u8g2.drawStr(48, 48, "Start");u8g2.drawFrame(45, 36, 40, 15);u8g2.sendBuffer();getKey();Game.init();while (!(Game.isOver() || Game.isWon())){if (Game.isChanged())Game.updateDisplay();char cmd = getKey();switch (cmd){case 'w':Game.move(UP);break;case 'a':Game.move(LEFT);break;case 's':Game.move(DOWN);break;case 'd':Game.move(RIGHT);break;default:break;}}Game.updateDisplay();delay(2000);u8g2.clearBuffer();u8g2.setFont(u8g2_font_maniac_tr);if (Game.isWon()){u8g2.drawStr(23, 24, "You");u8g2.drawStr(23, 53, "Win!");}else{u8g2.drawStr(23, 24, "Game");u8g2.drawStr(23, 53, "Over");}u8g2.sendBuffer();delay(1500);u8g2.clearBuffer();u8g2.setFont(u8g2_font_crox4t_tr);char score_str[6];itoa(Game.getScore(), score_str, 10);u8g2.drawStr(20, 28, "Your score:");u8g2.drawStr(20, 48, score_str);u8g2.sendBuffer();delay(2500);
}

众所周知,arduino的loop函数会不断地重复执行下去。在本程序中,loop的一次重复,就是一个完整的游戏生命周期,从开始界面到结算界面。

首先夹在u8g2.clearBuffer();u8g2.sendBuffer();中的是开始界面的绘制,没什么好说的,主要是边框的绘制一定要放在“Start”字样的绘制之后,否则“Start”字样字体的透明像素会覆盖掉边框的一部分,让边框出现一个豁口。

之后的一次丢弃返回值的getKey函数的调用是为了模拟“按任意键开始”的效果,它是阻塞的,也就是说在按下任意键之前将会一直停留在开始界面。

之后调用Game对象的init方法,进行棋盘和游戏数据的初始化。

接下来是一个较大的while循环:

    while (!(Game.isOver() || Game.isWon())){if (Game.isChanged())Game.updateDisplay();char cmd = getKey();switch (cmd){case 'w':Game.move(UP);break;case 'a':Game.move(LEFT);break;case 's':Game.move(DOWN);break;case 'd':Game.move(RIGHT);break;default:break;}}

每次运行循环中的内容前都会先检测是否终局或者获胜,游戏没有结束的话,就判断棋盘状态是否变更,若是,则更新棋盘的绘制。

之后阻塞地获取玩家按下的按钮,存入cmd变量中。

switch语句对于不同的中间层(wasd)数据对应地改变move方法的参数并调用。

等到while循环退出之后,就说明游戏结束了(终局或获胜),此时开始结算。

需要注意的是,在开始结算之前,需要再次更新一次棋盘绘制,否则玩家无法看见死亡时棋盘的状态。

2秒后进入结算页面,首先根据是否获胜显示对应的语句(“GameOver”或“YouWin!”)。

1.5秒之后显示分数,逻辑和更新棋盘绘制时的逻辑显示,就不赘述了。

再过2秒,loop函数返回,进入新的一次游戏生命周期。

至此,Arduino2048游戏的实现逻辑已全部讲述完毕。

arduino2048小游戏源代码解析(超详细)相关推荐

  1. 第一个小游戏“三子棋”超详细

    三子棋 我们先想一下游戏是怎么玩的??? 打开游戏,进去先看到一个菜单,然后选择是玩游戏还是退出,如果选择玩游戏,那进去是我和电脑玩游戏,我下一步棋,电脑下一步棋,如果我赢了那游戏就结束了,如果电脑赢 ...

  2. 火柴人逃脱小游戏(python 超详细)

    大家好,今天我们开始做一个新的游戏--火柴人逃脱. 以下是效果图: 温馨提示:代码里有图片资源,你可以自己用GIMP画,也可以去下载: 图片 我们来看第一部分代码: from tkinter impo ...

  3. TypeScript实现小游戏---贪吃蛇(超详细)

    目录 项目背景及简介 多模块需求分析 场景模块需求 食物类模块需求 记分牌模块需求 蛇类模块需求 控制模块需求 项目搭建 ts转译为js代码 package.json包配置文件 webpack.con ...

  4. java怎么跑游戏_RunGame java赛跑小游戏源代码和素材,适合新手入门练手。 Develop 272万源代码下载- www.pudn.com...

    文件名称: RunGame下载  收藏√  [ 5  4  3  2  1 ] 开发工具: Java 文件大小: 1650 KB 上传时间: 2017-03-11 下载次数: 0 提 供 者: kek ...

  5. 区间DP解析超详细版!!街边老奶奶也喜欢看的好博客

    区间DP解析超详细版!! 文章目录 区间DP解析超详细版!! 1. 概念入门 2. 线性石子归并 3. 环形石子归并 4. 奇怪的题题目目 5. 区间DP的优化 附录 在上章 背包 (<-点击传 ...

  6. python双手打字_Python打字练习小游戏源代码

    Python打字练习小游戏源代码 Python代码狂人 Python代码大全 Python打字练习小游戏源程序,随机产生一串字符,可对打字练习的正确率和时间进行统计,运行截图如下: from tkin ...

  7. Python我的世界小游戏源代码

    我的世界小游戏使用方法: 移动 前进:W,后退:S,向左:A,向右:D,环顾四周:鼠标,跳起:空格键,切换飞行模式:Tab; 选择建筑材料 砖:1,草:2,沙子:3,删除建筑:鼠标左键单击,创建建筑块 ...

  8. Python制作的赛车小游戏源代码,逆行飙车

    python制作的赛车小游戏,逆行飙车,通过键盘方向键控制 程序运行截图: 源代码 import pygame, sys, time, random# pygame 初始化 pygame.init() ...

  9. 2022年全国职业院校技能大赛中职组网络安全竞赛——隐写术应用解析(超详细)

    2022年全国职业院校技能大赛中职组网络安全竞赛--隐写术应用解析(超详细) B-8任务八:隐写术应用 *任务说明:仅能获取Server8的IP地址 环境需求私信博主 1.找出文件夹1中的文件,将文件 ...

最新文章

  1. Java8-Lambda表达式
  2. linux下安装监控网络流量工具Iptraf
  3. python工具安装教程_Python 开发工具PyCharm安装教程图文详解(新手必看)
  4. Spring-Boot 2.1.x和主要的bean定义
  5. java中抓阄_如何进行抓阄
  6. linux指定cpu运行程序,进程/线程绑定到特定CPU核的linux实现(有代码有实例)
  7. dism 分割镜像_UEFI安装win10 1809系统,拆分Windows 映像大于4G文件install.wim的官方教程(亲测完美)...
  8. windows本地安装PLSQL
  9. 小白量化彩票实战(1)彩票分析及爬虫双色球彩票开奖数据
  10. Aligning Domain-Specific Distribution and Classifier for Cross-Domain Classification from Multiple
  11. 操作系统名词中英文对照(一)
  12. python中pd series_pd.Series的基本操作
  13. 搜扣扣三大频道闹元宵 优惠券、聚划算、九块邮正式上线
  14. linux编译aborted,Ubuntu Linux上编译kernel出错__stack_chk_fail
  15. Android绘制地图
  16. 木马病毒:英国歌手Amy Winehouse临死前的最后影片,勿点
  17. PTA“值班安排”的题没有思路?速来,这里有附带详细注释的代码!
  18. Tableau 圆柱条形图的两种实现方法
  19. 最初计算机研制时间科学家,美科学家研制“时间晶体”超越宇宙寿命 类似永动机...
  20. k米评分容易得高分的歌_有品上架纯麦K歌无线麦克风,小米电视即插即用,客厅秒变KTV...

热门文章

  1. 复利计算公器 网页版 0600
  2. Docker:构造简单容器
  3. VMware workstation虚拟机安装与激活
  4. 12月10日科技资讯|朱啸虎回应“美团滴滴合并”;小米成世界第四大手机制造商;Ant Design 3.26.1 发布
  5. X86与海光(中科曙光+AMD)/兆芯(上海国资委+威盛)
  6. Redis多容器高并发场景 , 设置缓存的时候,要考虑多容器加锁的场景。(incr计数和redis分布式锁区别)
  7. selenium下载文件路径保存
  8. Vivado使用Tcl命令下载FPGA
  9. 程序员菜鸟工作第一个月总结(一)
  10. 用笔画输入法发现自己“象”字的拼写错误