这一期活动主角是“2022寒假在家练”pico_game_kit,它的MCU是树莓派的RP2040,相对于esp32主流的原型,这是一款比较新的产品。目前已经有多个平台的支持,如micropython、circuitpython、Arduino、pico_sdk等环境的支持,用户群也在慢慢壮大起来,但是这些平台也是刚刚建立起来,还需要时间去不断完善和熟化。据悉主办方在筹备这期活动也遇到了一些困难,例如全球“缺芯”的问题,最终这款游戏板的显示屏采用了国产的替代品。苏老师在群上感叹到“但凡以后有渠道能买到欧美的器件,尽量不用国产的,耽误太多时间,而且有潜在的风险,尽量远离自己不熟悉的品牌”,而“风险”就是本期项目的一个挑战,也为本次活动增添了不少乐趣。
下面请欣赏苏老师语录:
“任何新产品的发展都有一个过程,如果你选择体验新品,那就要自己手动改装不少还不完善的地方,当然你也可以等待几年,不用动脑就可以使用成熟的产品。”
“我们活动的目的就是让大家尽可能遇到坑,然后自己学会从坑里爬出来,并且知道坑是如何造成的,遇到类似的坑如何更快地爬出来。”
---苏公雨
本项目的主要内容
重点介绍game_kit的Arduino环境下st7789屏幕的驱动问题及利用该屏幕驱动移植了一款经典小游戏,供大家借鉴参考。
(一)Arduino环境下的st7789屏幕驱动
前面说过了Pico支持的平台很多,有非常容易上手的micropython,强大支持库的circuitpython,功能强大的pico_sdk 环境,Arduino 介于中间位置。但相对于本期项目内容Arduino还是具有一定优势,因为Arduino项目具有很高的可移植性,如果得到强大的图形库支持,就可以拥有广阔得发展空间。按原本计划只要能够用Arduino驱动ST7789屏幕,项目就可以很顺利的开展了,可是该款产品用的屏幕是国产替代品,进展并没有想象中那么顺利。在收到板子前面几天一直尝试用不同的图形驱动库来驱动屏幕,均以失败告终。后来在微信群上得到了“中量大-叶开”、“笛子”老师两位大神的深入分析研究,终于取得重大突破,完成了采用TFT_eSPI库驱动ST7789,为项目后期的工作奠定了坚实的基础,为后面的小程序移植开发带來了极大的便利。驱动这款屏幕是本项目终极难点,当大神成功在屏幕上成功打出“Hello World”时,标志这该板全面支持Arduino,假设没有学习群,光靠自己单兵作战,可能就要弃坑了。
下面介绍TFT_eSPI图形库具体设置要点。
口诀是:
“1. 0xff要发, 发完hard reset, 2. cpol=1, cpha=1 ”
--- 笛子老师
(1)修改spi数据发送模式:
参考“https://github.com/Bodmer/TFT_eSPI/pull/1547”中对TFT_eSPI的修理方法。
(叶开同学提供)
(2)在复位时出发送0xff
参考下图红色标记:
(笛子老师图片)
(3)St7789屏幕用户参数设置。
按照这三步走就可以驱动屏幕了,由于具体设置过程相对复杂,细节较多,为了快速上手的同学可以直接下载文末的TFT_eSPI驱动包,替代”C:\Users\Administrator\Documents\Arduino\librariesArduino” 目录下的TFT_eSPI,前提是已经在Arduino安装了TFT_eSPI库,然后该库是专门针对game_pico专用的驱动其他的屏幕正常来说是不适用的,所以建议使用新产品的时候记得删除该库重装。
(二)Flappy bird小游戏的移植
移植的思路是只要是用TFT_eSPI驱动的小游戏都能够移植到game_kit上面来,例如现有的在esp32上成功移植的游戏都能转换过来,遵循这一点同学们可以做更多的尝试。但是在移植过程总会遇到些大大小小的问题。目前成功移植的Flappy bird来自于M5Stack上的开源项目。由于原项目是基于M5Stack的二次开发的TFT_eSPI驱动的,里面有些功能的也基于esp32特性实现的,暂时在pico 上面没有现成办法实现,例如用esp32读写eeprom的方法断电保存数据的方法,目前折中的办法是只要不关机本轮的最佳成绩是在的,过去的事情就当没发生过好了。
游戏的移植具体实现如下,代码中全面的英文注释和中文对关键函数的理解。相信机智的同学都能够轻松理解,不理解也没关系,下载下来玩耍一下,新年期间或者疫情隔离期间都可以拿出来玩玩,也是极好的。
#include <Arduino.h>
#include <TFT_eSPI.h>
#include "Button.h"
TFT_eSPI tft;
// Button API
#define DEBOUNCE_MS 10
Button BtnB = Button(5, true, DEBOUNCE_MS); //实例化按键,可以使用waspressed功能,起到消抖作用。
#include <SPI.h>
#define TFTW 240 // screen width
#define TFTH 240 // screen height
#define TFTW2 120 // half screen width
#define TFTH2 120 // half screen height
// game constant
#define SPEED 1 //加快往前的速度难度更大。
#define GRAVITY 9.8 //正常重力加速度是9.8,下降速度比较快,减少加速度,降低游戏难度。
#define JUMP_FORCE 2.15 //小鸟起跳力度,可以调节到自己适合的数值。
#define SKIP_TICKS 20.0 // 1000 / 50fps
#define MAX_FRAMESKIP 5
...省略若干变量定义
static int maxScore = 0; //存储最高分值。
static unsigned int birdcol[] = //这个愤怒的小鸟长得有点丑。16列*8行。
{ C0, C0, C1, C1, C1, C1, C1, C0, C0, C0, C1, C1, C1, C1, C1, C0,
C0, C1, C2, C2, C2, C1, C3, C1, C0, C1, C2, C2, C2, C1, C3, C1,
C0, C2, C2, C2, C2, C1, C3, C1, C0, C2, C2, C2, C2, C1, C3, C1,
C1, C1, C1, C2, C2, C3, C1, C1, C1, C1, C1, C2, C2, C3, C1, C1,
C1, C2, C2, C2, C2, C2, C4, C4, C1, C2, C2, C2, C2, C2, C4, C4,
C1, C2, C2, C2, C1, C5, C4, C0, C1, C2, C2, C2, C1, C5, C4, C0,
C0, C1, C2, C1, C5, C5, C5, C0, C0, C1, C2, C1, C5, C5, C5, C0,
C0, C0, C1, C5, C5, C5, C0, C0, C0, C0, C1, C5, C5, C5, C0, C0};
// bird structure
static struct BIRD {
long x, y, old_y; //小鸟的位置信息
long col;
float vel_y;
} bird;
// pipe structure
static struct PIPES {
long x, gap_y;
long col;
} pipes;
// score
int score;
// temporary x and y var
static short tmpx, tmpy;
// ---------------
// draw pixel
// ---------------
#define drawMPixel(a, b, c) tft.setAddrWindow(a, b, a, b); tft.pushColor(c) //加速图形绘画,但在do循环中不能使用,有bug.
void setup() {
// put your setup code here, to run once:
tft.begin();
}
void loop() {
// put your main code here, to run repeatedly:
game_start();
game_loop();
game_over();
}
// ---------------
// game loop
// ---------------
void game_loop() {
// ===============
// prepare game variables
// draw floor
// ===============
// instead of calculating the distance of the floor from the screen height each time store it in a variable
unsigned char GAMEH = TFTH - FLOORH;
// draw the floor once, we will not overwrite on this area in-game
// black line
tft.drawFastHLine(0, GAMEH, TFTW, TFT_BLACK);
// grass and stripe
tft.fillRect(0, GAMEH+1, TFTW2, GRASSH, GRASSCOL);
tft.fillRect(TFTW2, GAMEH+1, TFTW2, GRASSH, GRASSCOL2);
// black line
tft.drawFastHLine(0, GAMEH+GRASSH, TFTW, TFT_BLACK);
// mud
tft.fillRect(0, GAMEH+GRASSH+1, TFTW, FLOORH-GRASSH, FLOORCOL);
// grass x position (for stripe animation)
long grassx = TFTW;
// game loop time variables
double delta, old_time, next_game_tick, current_time;
next_game_tick = current_time = millis();
int loops;
// passed pipe flag to count score
bool passed_pipe = false;
// temp var for setAddrWindow //void TFT_eSPI::setAddrWindow(int32_t x0, int32_t y0, int32_t w, int32_t h)
unsigned char px;
while (1) {
loops = 0;
while( millis() > next_game_tick && loops < MAX_FRAMESKIP) {
// ===============
// input
// ===============
if (BtnB.wasPressed()) {
// if the bird is not too close to the top of the screen apply jump force
if (bird.y > BIRDH2*0.5) bird.vel_y = -JUMP_FORCE;
// else zero velocity
else bird.vel_y = 0;
}
BtnB.read();
// ===============
// update
// ===============
// calculate delta time
// ---------------
old_time = current_time;
current_time = millis();
delta = (current_time-old_time)/1000;
// bird
// ---------------
bird.vel_y += GRAVITY * delta;
bird.y += bird.vel_y;
// pipe
// ---------------
pipes.x -= SPEED;
// if pipe reached edge of the screen reset its position and gap
if (pipes.x < -PIPEW) { //-PIPEW 柱子完全消失。
pipes.x = TFTW;
pipes.gap_y = random(10, GAMEH-(10+GAPHEIGHT));//柱子缺口的位置,y刻度10开始,这是顶部距离,底部 GAMEH-(10+GAPHEIGHT), GAMEH游戏区间高度、10底部占用,GAPHEIGHT缺口高度)
}
// ---------------
next_game_tick += SKIP_TICKS;
loops++;
}
// ===============
// draw
// ===============
// pipe
// ---------------
// we save cycles if we avoid drawing the pipe when outside the screen
if (pipes.x >= 0 && pipes.x < TFTW) {
// pipe color
tft.drawFastVLine(pipes.x+3, 0, pipes.gap_y, PIPECOL);
tft.drawFastVLine(pipes.x+3, pipes.gap_y+GAPHEIGHT+1, GAMEH-(pipes.gap_y+GAPHEIGHT+1), PIPECOL);
// highlight
tft.drawFastVLine(pipes.x, 0, pipes.gap_y, PIPEHIGHCOL);
tft.drawFastVLine(pipes.x, pipes.gap_y+GAPHEIGHT+1, GAMEH-(pipes.gap_y+GAPHEIGHT+1), PIPEHIGHCOL);
// bottom and top border of pipe
drawMPixel(pipes.x, pipes.gap_y, PIPESEAMCOL);
drawMPixel(pipes.x, pipes.gap_y+GAPHEIGHT, PIPESEAMCOL);
// pipe seam
drawMPixel(pipes.x, pipes.gap_y-6, PIPESEAMCOL);
drawMPixel(pipes.x, pipes.gap_y+GAPHEIGHT+6, PIPESEAMCOL);
drawMPixel(pipes.x+3, pipes.gap_y-6, PIPESEAMCOL);
drawMPixel(pipes.x+3, pipes.gap_y+GAPHEIGHT+6, PIPESEAMCOL);
}
// erase behind pipe
if (pipes.x <= TFTW) tft.drawFastVLine(pipes.x+PIPEW, 0, GAMEH, BCKGRDCOL);
// bird
// --------------- //本游戏的核心关键技术
tmpx = BIRDW-1; //妥善处理bird的绘制不留残影,更新不晃眼。
do {
px = bird.x+tmpx+BIRDW;
// clear bird at previous position stored in old_y
// we can't just erase the pixels before and after current position
// because of the non-linear bird movement (it would leave 'dirty' pixels)
tmpy = BIRDH - 1;
do {
tft.drawPixel(px, bird.old_y + tmpy, BCKGRDCOL);//先纵向用背景色填充原来小鸟的位置,tmpy--意思是从底部往上洗。
} while (tmpy--);
// draw bird sprite at new position
tmpy = BIRDH - 1;
do {//birdcol[tmpx + (tmpy * BIRDW) //最为烧脑的地方,小鸟图形颜色,用二维数组可能好理解些。
tft.drawPixel(px, bird.y + tmpy, birdcol[tmpx + (tmpy * BIRDW)]);//bird.y + tmpy 新位置的底部,
//tft.drawPixel(px, bird.y + tmpy, birdcol[1000]);//数组超出界限了也没报错。
//[tmpx + (tmpy * BIRDW)]//颜色只给了16列8行,但鸟的大小是16*16,看到小鸟是下半部分是黑色的。小鸟长得丑的原因找到了。
} while (tmpy--);//tmpy--同样采用从底部往上填充。
} while (tmpx--);
// save position to erase bird on next draw
bird.old_y = bird.y; //小鸟的y\高度起始位置,左顶角位置。
// grass stripes
// ---------------
grassx -= SPEED;
if (grassx < 0) grassx = TFTW;
tft.drawFastVLine( grassx %TFTW, GAMEH+1, GRASSH-1, GRASSCOL);
tft.drawFastVLine((grassx+64)%TFTW, GAMEH+1, GRASSH-1, GRASSCOL2);
// ===============
// collision
// ===============
// if the bird hit the ground game over
if (bird.y > GAMEH-BIRDH) break;
// checking for bird collision with pipe
if (bird.x+BIRDW >= pipes.x-BIRDW2 && bird.x <= pipes.x+PIPEW-BIRDW) {//进入管道缺口区间的判断。
// bird entered a pipe, check for collision
if (bird.y < pipes.gap_y || bird.y+BIRDH > pipes.gap_y+GAPHEIGHT) break;//跟管道缺口上下接触的判断。
else passed_pipe = true;
}
// if bird has passed the pipe increase score
else if (bird.x > pipes.x+PIPEW-BIRDW && passed_pipe) {
passed_pipe = false; //复位
// erase score with background color
tft.setTextColor(BCKGRDCOL);
tft.setCursor( TFTW2, 4);
tft.print(score);
// set text color back to white for new score
tft.setTextColor(TFT_WHITE);
// increase score since we successfully passed a pipe
score++;
}
// update score
// ---------------
tft.setCursor( TFTW2, 4);
tft.print(score);
}
// add a small delay to show how the player lost
delay(1200);
}
// ---------------
// game start
// ---------------
void game_start() {
tft.fillScreen(TFT_BLACK);
tft.fillRect(10, TFTH2 - 20, TFTW-20, 1, TFT_WHITE);
tft.fillRect(10, TFTH2 + 32, TFTW-20, 1, TFT_WHITE);
tft.setTextColor(TFT_WHITE);
tft.setTextSize(3);
// half width - num char * char width in pixels
tft.setCursor( TFTW2-(6*9), TFTH2 - 16);
tft.println("FLAPPY");
tft.setTextSize(3);
tft.setCursor( TFTW2-(6*9), TFTH2 + 8);
tft.println("-BIRD-");
tft.setTextSize(2);
tft.setCursor( 10, TFTH2 - 36);
tft.println("Pico Game Kit");
tft.setCursor( TFTW2 - (10*9), TFTH2 + 36);
tft.println("GenVex_updated");
while (1) {
// wait for push button
if(BtnB.wasPressed()) {
break;
}
BtnB.read(); //更新按键数据。
}
// init game settings
game_init();
}
void game_init() {
// clear screen
tft.fillScreen(BCKGRDCOL);
// reset score
score = 0;
// init bird
bird.x = 144;
bird.y = bird.old_y = TFTH2 - BIRDH;
bird.vel_y = -JUMP_FORCE;
tmpx = tmpy = 0;
// generate new random seed for the pipe gape
//randomSeed(analogRead(4));
// init pipe
pipes.x = 0;
pipes.gap_y = random(20, TFTH-60);
//pipes.gap_y = 40;
}
// ---------------
// game over
// ---------------
void game_over() {
tft.fillScreen(TFT_BLACK);
// EEPROM_Read(&maxScore,0);//pico还没看到读写EEPROM的方法,断电不消失数据保存的方法,记录本机的历史最高分。
//
if(score>maxScore)
{
maxScore = score;
tft.setTextColor(TFT_RED);
tft.setTextSize(2);
tft.setCursor( TFTW2 - (13*6), TFTH2 - 26);
tft.println("NEW HIGHSCORE");
}
tft.setTextColor(TFT_WHITE);
tft.setTextSize(3);
// half width - num char * char width in pixels
tft.setCursor( TFTW2 - (9*9), TFTH2 - 6);
tft.println("GAME OVER");
tft.setTextSize(2);
tft.setCursor( 10, 10);
tft.print("score: ");
tft.print(score);
tft.setCursor( TFTW2 - (12*6), TFTH2 + 18);
tft.println("press button");
tft.setCursor( 10, 28);
tft.print("Max Score:");
tft.print(maxScore);
while (1) {
// wait for push button
if(BtnB.wasPressed()) {
break;
}
BtnB.read();
}
}
(这游戏难度也太大了吧,最好成绩猜到5分)
心得总结
(1)Pico_game_kit是基于树莓派RP2040核心开发的新产品,屏幕是国产替代品,增加了项目的挑战性。再次出现苏老师语录:“对于我们来讲,能用起来,有的玩就已经谢天谢地,本身这个过程就是遇坑填坑,而不是寻找迅速做出来得到满足感的过程”。在把这个屏幕驱动的坑填上之后,我们就可以开心地玩耍了。目前的驱动只用TFT_eSPI完成了驱动,实现的办法好像还有点笨拙,应该还有提升的空间。尝试加深对该屏幕驱动的理解或许能够完成LovyanGFX的驱动支持。基于Arduino庞大的用户群和海量的成功案例,相信利用这块板子玩出更多精彩内容,让我们拭目以待期待小伙伴们带来更多精彩的项目。
(2)游戏移植过程需要对代码的逐句理解,才能充分理解作者的意图,学习他们核心关键技术,然后就可以尝试对游戏进行改良创新。例如,本游戏中小鸟的动态绘制技术就比较时实用,不过代码比较复杂,不好理解。目前已经尝试将小鸟提前绘制好,放在图层(sprite)里,只需知道小鸟的更新位置,用简单两行代码就可以替代原有复杂的循环,小鸟就可以随意飞翔,再也用考虑残影问题。
然而,移植别人的游戏只是站在别人肩膀之上轻易的摘到果子,那么能不能自己尝试开发一个新的游戏呢!
在此再次对本项目提供帮助的小伙伴致敬!!!
祝大家玩的愉快,学业猛进!