傻孩子(GorgonMeducer) · 2022年02月24日

【玩转Arm-2D】不到4K RAM的自制FlappyBird 竟然快到我跟不上

首发:裸机思维
作者: FledgingSu 支离苏

image.png

今天我们用Arm-2D制作一款时下流行的小游戏flappy bird,由于版权问题,没有用原版素材,它的玩法也很简单,只需要一个按键控制拍翅膀就行。

先上最终游戏成品的视频演示:
image.png
(注:现有硬件不包含发声设备,背景音乐为后期添加)

软硬件配置如下:
image.png
image.png

【游戏简介】

这款floppy bird小游戏使用了Arm-2D图形引擎,使得制作很简单。为了祝愿玩Arm-2D的人越来越多——如星星之火燎原之势,势不可挡——我们游戏的主人公“小火星”就出来了,如下图所示:

image.png

  • 外围是Arm-2D小火星,中间是一个虫字,也预祝嵌入式小书虫公众号能够更好的写下去,实现破茧成蝶的蜕变。

是的,这个小游戏刚开始是把下面的一个个蜡烛点燃,走着走着就会变成一只蝴蝶,蝴蝶飞过,下面的玫瑰花会开放。背景是一个大满月,也是花好月圆,美好圆满的寓意。

  • 有了美好的寓意,那这款游戏的玩法也是很简单,小火星会自动前进,并在前进过程中自动下落,当按下按键后,会控制小火星向上移动,实现从障碍物中间的缝隙中穿过。

【界面设计】

首先设计障碍物,如下图:
image.png
从概念设计图可知,已知屏幕的宽度和高度都是240像素,障碍物的宽度为30,间距为140,因此只要确定了上边障碍物的(x,y)坐标和上下障碍物的间隙h就可以在屏幕中绘制障碍物了。

障碍物的位置确定了,可是随着y和h的不同,障碍物也会有长有短,这个怎么弄呢,不会都是图片吧?显然不是的,对障碍物的拆解如下图所示:
image.png
看到了吧,障碍物是由两张小图片和绘制一个矩形得到的。

这个是下边的障碍物,那上边的障碍物怎么绘制呢?是要用rotation功能把图片旋转180度吗?

这样做倒是也可以, 不过Arm-2D还提供了更简单的方法。

什么, 还有简单的方法?
image.png
是的,就是用“Y轴镜像拷贝”(Y-Mirroring)图片,效果和旋转180度是一样的,如下:

arm_2d_rgb16_tile_copy_with_colour_masking( &c_tileP, 
    ptTile, 
    &tBox, 
    GLCD_COLOR_BLACK,
    ARM_2D_CP_MODE_Y_MIRROR);

最后一个参数ARM_2D_CP_MODE_Y_MIRROR就是Y镜像拷贝。同样的,Arm-2D还提供了X镜像拷贝和XY镜像拷贝。接着就是绘制小火星了,这个很简单,就是一个图片拷贝,如下所示:

arm_2d_rgb16_tile_copy_with_colour_masking( &c_tileStar, 
    ptTile, 
    &tBox, 
    GLCD_COLOR_BLACK,
    ARM_2D_CP_MODE_COPY);

那那只蝴蝶煽动翅膀是怎么实现的呢?其实这个也简单,就是两张图片交替显示就可以了,如下所示:


static uint8_t num = 0;
if((num & 0x03) < 2){        
    arm_2d_rgb16_tile_copy_with_colour_masking( &c_tileButterfly, 
        ptTile, 
        &tBox, 
        GLCD_COLOR_BLACK,
        ARM_2D_CP_MODE_COPY);
} else {
    arm_2d_rgb16_tile_copy_with_colour_masking( &c_tileButterfly2, 
        ptTile, 
        &tBox, 
        GLCD_COLOR_BLACK,
        ARM_2D_CP_MODE_COPY);
}
if(bIsNewFrame){
    num++;
}

背景的制作我们要着重讲一下,因为他使用了一个Arm-2D的高级功能:横向单线渐变蒙版(horizontal-line-mask) 来实现一个渐变效果。如下图所示:

image.png

是不是很酷,其实用Arm-2D实现也很简单,只需要给 target 这一端提供一个 mask就行了。需要注意的是,target的这个mask 它的 height 和 width 必须能完整覆盖 target(或者height 为 1时,width 能覆盖整个 target的宽度)。下面我以height 为 1时的target mask程序举例,如下:

  • 定义一个mask Tile,iWidth为200,iHeight 为1
ARM_NOINIT static uint8_t s_bmpFadeMask[200];
const arm_2d_tile_t c_tileFadeMask = {
    .tRegion = {
        .tSize = {
            .iWidth = 200,
            .iHeight = 1,
        },
    },
    .tInfo = {
        .bIsRoot = true,
        .bHasEnforcedColour = true,
        .tColourInfo = {
            .chScheme = ARM_2D_COLOUR_8BIT,
        },
    },
    .pchBuffer = (uint8_t *)s_bmpFadeMask,
};
  • 初始化mask为透明度渐变
void init_line_mask(){
    //! generate line-fading template   
    do {
        memset(s_bmpFadeMask, 0, sizeof(s_bmpFadeMask));
        float fRatio = 255.0f / 200.f;
        for (int32_t n = 0; n < 200; n++) {
            //s_bmpFadeMask[n] = 255 - ((float)n * fRatio);
            s_bmpFadeMask[n] = ((float)n * fRatio);            
        }
    } while(0);
}
  • 绘制透明度渐变的圆,这个就是上面我们看到的透明度渐变的满月了。
void test_line_mask(const arm_2d_tile_t *ptFrameBuffer){
     arm_2d_region_t tRegion= {
        .tLocation = {30,10},
        .tSize = {200, 200},
    };
       
    arm_2d_tile_t tTempPanel;
    //!< generate a child tile for this screen
    arm_2d_tile_generate_child( ptFrameBuffer, 
          &tRegion, 
          &tTempPanel, 
          false);
                                    
    //!< set background colour
    arm_2d_rgb16_fill_colour(   &tTempPanel,
        NULL,
        GLCD_COLOR_NAVY);
    
    //绘制透明度渐变的满月  
    tRegion.tLocation.iX = 0;   
    tRegion.tLocation.iY = 0;
    arm_2d_rgb565_tile_copy_with_masks(
          &c_tilecircle,
          &c_tilecircleMask,
          &tTempPanel,
          &c_tileFadeMask,
          &tRegion,            
          ARM_2D_CP_MODE_COPY);              
}

有了这个target mask,我们很容易就做出百叶窗效果,啥都不用改,只需要改mask(即s_bmpFadeMask[]的值),百叶窗效果的程序如下:

memset(s_bmpFadeMask2, 0, sizeof(s_bmpFadeMask));
        
for (int32_t n = 0; n < 200; n++) {            
    if(n%4 < 2){
        s_bmpFadeMask[n] = 255;
    }else{
        s_bmpFadeMask[n] = 0;
    }
}

实现效果如下图:

image.png

简单吧,其实有了这个利器,我们还可以做出各种千奇百怪的擦除效果,比如渐渐出现,渐渐消失的效果,只要动态修改mask,图还是那个图,但是mask一直在变,就可以做出一些非常绚丽的效果。

【游戏核心数据结构】

下面我们讲一下障碍物的数据结构,如下:

typedef struct{
    uint32_t iX;
    uint8_t iHight;
    uint8_t iGap;
    uint8_t char_arm_2d;
    uint8_t game_level;
}play_obstacle_t;
  • 前3个是用来确定障碍物的位置坐标,char_arm_2d是用来保存要显示的字符,如下图所示:

image.png!

  • char_arm_2d = 'A',就显示字符A,会循环显示Arm-2D。
  • game_level是用来区分第几关,(即第一关为小火星,第二关为蝴蝶)。

由于我们的屏幕只能显示两个障碍物,所以只需要 定义一个长度为2 的数组,如下

play_obstacle_t play_obstacle[2] = {
    {30,40,80,'A',.game_level=1},
    {170,60,100,'r',.game_level=1},};

接下来就是游戏中物体的移动,需要一个坐标,结构也很简单,如下并定义了一个静态全局变量

typedef struct location_t {
    uint32_t iX;
    int32_t iY;
} location_t;

static location_t tplay_X_Y = {.iX = 0,  .iY = 0};

【程序实现】

有了上面的数据类型,我们就来看看他是怎么前进的。其实前进很简单,因为我们的小火星是横向向右移动,所以只需要把横向坐标累加就可以了,如下:


if((!stop_flag) && play_flag){
    if(bIsNewFrame){
        //tplay_X_Y.iX++;
        tplay_X_Y.iX += MOVING_SPEED_iX;
    }   
}
  • MOVING_SPEED_iX为移动速度,可以自己设定。
  • play_flag为游戏开始标志,初始化为False,当按下按键置为True,游戏开始。
  • stop_flag为小火星撞到障碍物置为True,小火星停止前进。

tplay_X_Y.iX增加之后,小火星是怎么移动的呢?其实小火星向右移动就相当于障碍物向左移动(即减去tplay_X_Y.iX的值),如下以绘制红色矩形为例:

// 绘制红色矩形
tBox.tLocation.iX = play_obstacle[i].iX - tplay_X_Y.iX;
tBox.tLocation.iY = 10;
tBox.tSize.iWidth = 30;
tBox.tSize.iHeight= play_obstacle[i].iHight-27;        
arm_2d_rgb16_fill_colour(ptTile, &tBox, GLCD_COLOR_RED);

提示:我们也可以根据tplay_X_Y.iX的值在小火星走过一定的距离后,在背景出现一个动画效果,也可以在走过一段距离后速度提升增加难度。

那障碍物移出屏幕之外怎么办呢?移出屏幕就给他重新赋值新的play_obstacle[i].iX,准备下一次出现,如下图所示:
image.png
当判断障碍物的iX < -30时,就可以修改play_obstacle[i].iX的值,然后重新出现,这样就可以一直循环下去,如下:

tBox.tLocation.iX = play_obstacle[i].iX - tplay_X_Y.iX;
    ...
if(tBox.tLocation.iX < -30){
    //新的障碍
    new_obstacle(i);
}

void new_obstacle(uint8_t i){
    static uint8_t char_Arm2D_num;
    play_obstacle[i].iX= play_obstacle[i].iX + 140*2;
    play_obstacle[i].iGap = 80 + myrand()%21;//80~100
    play_obstacle[i].game_level = play_game_mode;
    play_obstacle[i].char_arm_2d = char_Arm2D[char_Arm2D_num%6];
    char_Arm2D_num++;
}

play_obstacle[i].iGap使用了随机数,其他变量也可以是随机数,但要注意变量的范围。那小火星碰到障碍物怎么检测呢?其实也简单,就是判断两个矩形是否有重合,原理如下图:
image.png
程序如下:

bool hit_against_inspect(void)
{
    int32_t iY,iX ;
    for(uint8_t i= 0; i < 2; i++){
        iX = play_obstacle[i].iX - tplay_X_Y.iX;
        //先判断X是否在矩形区域
        if((iX < (tBird.tLocation.iX+tBird.tSize.iWidth))  && (iX+30) > (tBird.tLocation.iX+tBird.tSize.iWidth)){
            iY = tBird.tLocation.iY - tplay_X_Y.iY;
            //判断右上角的y是否在矩形区域
            if(iY < play_obstacle[i].iHight){
                return true;
            }
            //判断右下角的y是否在矩形区域
            else if((iY+tBird.tSize.iHeight) > ((play_obstacle[i].iHight + play_obstacle[i].iGap))){
                return true;
            }
        }
    }    
    return false;
}

那现在就剩下小火星的上下移动了,相信大家也已经想到了,其实就是 tplay_X_Y.iY增加或减小。有按键按下就向上移动,没有就自动向下移动。程序如下:

 void fly(uint8_t key)
 {
    static enum{
          FLY_INIT = 0,          
          FLY_DOWN,          
          FLY_UP,
     }e_fly_mod = FLY_INIT;
    static uint8_t number = 0;
    switch(e_fly_mod){
        case FLY_INIT:
            number = 0;
            e_fly_mod = FLY_DOWN;
        //break;
        case FLY_DOWN:
            number++;
            if((number % 5) == 0){
                tplay_X_Y.iY -= 3 * MOVING_SPEED_iX;
                if(tplay_X_Y.iY < -150){
                    tplay_X_Y.iY = -150;
                }
            }else if((number % 5) == 2){
                tplay_X_Y.iY += MOVING_SPEED_iX;
            }
            if(key){
                e_fly_mod = FLY_UP;
                number = 10;
            }
        break;
        case FLY_UP:
            if(number & 0x01 ){
                tplay_X_Y.iY += 4 * MOVING_SPEED_iX;
                if(tplay_X_Y.iY > 50){
                    tplay_X_Y.iY = 50;
                }
            }else{
                tplay_X_Y.iY -= MOVING_SPEED_iX;
            }
            number--;
            if(number == 0){
                e_fly_mod = FLY_INIT;
            }
        break;
    }
}

最后就是我们的play_game()函数,如下:

void play_game(void)
{   
    uint8_t key = 0;  
    // 获取按键值
    key = get_key();
    
    if(play_flag){
        fly(key);
        // 检测是否撞到障碍物
        stop_flag = hit_against_inspect();
        if(stop_flag == 1){
            // 撞到障碍物,游戏停止
            play_flag = 0;
        }
    }else{ 
        // 有按键按下,游戏开始       
        if(key){
            play_flag = 1;
        }
    }
}

还是很简单的,是吧。按键驱动函数我就不贴了。(* ̄︶ ̄)其中还有一些不足之处希望大家能够完善。期待大家实现自己的小游戏并添加更多好玩的功能。

【帧率优化】

上面我们讲了控制速度用了一个变量MOVING_SPEED_iX,设置不同的值移动速度就会不同,这样也可以实现速度的控制了。不过,Arm-2D还有一种特有的提速方法,那就是dirty List脏矩阵(即局部刷新),这也是我们可以使用小PFB的奥秘所在。

dirty List我们前面提到过,不知道大家还有印象没,他的使用就是几个宏,如下:

<1>  IMPL_ARM_2D_REGION_LIST

#define __IMPL_ARM_2D_REGION_LIST(__NAME, ...)                                  \
            enum {                                                              \
                __NAME##_offset = __COUNTER__,                                  \
            };                                                                  \
            __VA_ARGS__                                                         \
            arm_2d_region_list_item_t __NAME[] = {
            
            
#define IMPL_ARM_2D_REGION_LIST(__NAME, ...)                                    \
            __IMPL_ARM_2D_REGION_LIST(__NAME,##__VA_ARGS__)

IMPL_ARM_2D_REGION_LIST就是定义一个arm_2d_region_list_item_t的数组:

IMPL_ARM_2D_REGION_LIST(s_tDirtyRegions,  static)

宏展开为

static arm_2d_region_list_item_t s_tDirtyRegions[] = {

<2> ADD_REGION_TO_LIST


#define __ADD_REGION_TO_LIST(__NAME, ...)                                       \
            {                                                                   \
                .ptNext = (arm_2d_region_list_item_t *)                         \
                            &(__NAME[__COUNTER__ - __NAME##_offset]),           \
                .tRegion = {                                                    \
                __VA_ARGS__                                                     \
                },                                                              \
            }
            
#define ADD_REGION_TO_LIST(__NAME, ...)                                         \
            __ADD_REGION_TO_LIST(__NAME, ##__VA_ARGS__)

ADD_REGION_TO_LIST就是初始化数组元素:

ADD_REGION_TO_LIST(s_tDirtyRegions,
  .tLocation = {
  .iX = 30, 
  .iY = 0,
  },
),

宏展开为


{  .ptNext = (arm_2d_region_list_item_t *)  
  &(s_tDirtyRegions[__COUNTER__ - s_tDirtyRegions_offset]),
  .tRegion = {    .tLocation = {
      .iX = 30, 
      .iY = 0,
     },
   },},

<3>ADD_LAST_REGION_TO_LIST


#define __ADD_LAST_REGION_TO_LIST(__NAME, ...)                                  \
            {                                                                   \
                .ptNext = NULL,                                                 \
                .tRegion = {                                                    \
                __VA_ARGS__                                                     \
                },                                                              \
            }
            
#define ADD_LAST_REGION_TO_LIST(__NAME, ...)                                    \
            __ADD_LAST_REGION_TO_LIST(__NAME, ##__VA_ARGS__)

ADD_LAST_REGION_TO_LIST就是初始化最后一个元素

ADD_LAST_REGION_TO_LIST(s_tDirtyRegions,
  .tLocation = {23,30},
  .tSize = {.iWidth = 140,.iHeight = 60,},
),

宏展开为

{  
  .ptNext = NULL,
  .tRegion = {        .tLocation = {23,30},
    .tSize = {.iWidth = 140,.iHeight = 60,},
   },},

<4> END_IMPL_ARM_2D_REGION_LIST

#define END_IMPL_ARM_2D_REGION_LIST(...)                                        \
            };

END_IMPL_ARM_2D_REGION_LIST()就是数组初始化结束

<5>Dirty List脏矩阵

IMPL_ARM_2D_REGION_LIST(s_tDirtyRegions,  static) 
ADD_REGION_TO_LIST(s_tDirtyRegions,.tLocation = {.iX = 30, .iY = 0,},),                                                                                          
ADD_REGION_TO_LIST(s_tDirtyRegions,.tSize = {.iWidth = 30,.iHeight = 40,}, ),                                                                                 
ADD_LAST_REGION_TO_LIST(s_tDirtyRegions,.tLocation = {23,30},.tSize = {.iWidth = 140,.iHeight = 60,},),
END_IMPL_ARM_2D_REGION_LIST()

这几个宏就是定义一个arm_2d_region_list_item_t类型的数组,其中arm_2d_region_list_item_t 原型如下:

typedef struct arm_2d_region_list_item_t {
    struct arm_2d_region_list_item_t *ptNext;
    arm_2d_region_t tRegion;
}arm_2d_region_list_item_t;

看到这里我们就清楚了,dirty List就是用数组封装了一个链表。

好,知道了这个,下面才是我们要讲的重点:动态修改dirty List的刷新区域。dirty List虽然是一个链表,但他也是一个数组,我们就用数组的形式对他进行修改,如下:

for(uint8_t i = 0; i < 2; i++){
    s_tDirtyRegions[i_num].tRegion.tLocation.iX = play_obstacle[i].iX - tplay_X_Y.iX;
    s_tDirtyRegions[i_num].tRegion.tLocation.iY = 0;
    ||刷新区域在屏幕外,宽和高设置为0
    if((s_tDirtyRegions[i_num].tRegion.tLocation.iX < -31) || (s_tDirtyRegions[i_num].tRegion.tLocation.iX > 240)){
        s_tDirtyRegions[i_num].tRegion.tSize.iWidth = 0;
        s_tDirtyRegions[i_num].tRegion.tSize.iHeight= 0;    
    }else{
        s_tDirtyRegions[i_num].tRegion.tSize.iWidth = 30+MOVING_SPEED_iX;
        s_tDirtyRegions[i_num].tRegion.tSize.iHeight= 240;  
        i_num++;
    }          
}

这段代码的刷新区域如下图白色框所示:

image.png

由于我们移动了MOVING_SPEED_iX的距离,所以刷新宽度要加上。小火星的刷新区域也是一样的,我就不写了。对了,dirty List也可以用链表的形式进行动态添加和删除操作哦!

专栏推荐文章

如果你喜欢我的思维,欢迎订阅裸机思维
版权归裸机思维(傻孩子图书工作室旗下公众号)所有,
所有内容原创,严禁任何形式的转载。
推荐阅读
关注数
1479
内容数
117
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息