本成果还包括以下链接内容。
Let'sDo第2期任务-《贪吃蛇》-游戏背景音乐(个性化)
项目概述:
开发一个基于vscode-PlatformIO的贪吃蛇游戏,使用 TFT_eSPI 显示屏进行画面显示,通过按钮控制蛇的移动方向,同时具备不同难度等级和音乐效果。
硬件设计:- 使用了 rp2040开发板作为控制核心。 连接了四个按钮,分别对应不同的操作:X按键(15 引脚)、Y按键(14 引脚)、BtnA(12 引脚)、BtnB(13 引脚)式。TFT_eSPI 显示屏用于游戏画面的输出。
软件调试:在调试过程中,重点关注了蛇的移动逻辑,确保其在边界和自身碰撞时能正确判定游戏结束。 对随机生成食物的位置进行多次测试,以避免食物出现在蛇身上。 调试按钮的消抖处理,保证按键响应的准确性和稳定性。
程序设计流程图:
项目代码:
#include <Arduino.h> #include <TFT_eSPI.h> #include "bg_up.h" #include "bg_down.h" #include "gameOver.h" #include "newGame.h" #include "Image.h" #include "Button.h" #include "MidiPlayer.h" #include "song.h" MidiPlayer midiplayer; TFT_eSPI tft = TFT_eSPI(); TFT_eSprite sprite = TFT_eSprite(&tft); #define left 15 #define right 14 #define BODYLENGTH 10 #define DEBOUNCE_MS 10 Button BtnY = Button(left, true, DEBOUNCE_MS); // 实例化按键,可以使用waspressed功能,起到消抖作用。 Button BtnX = Button(right, true, DEBOUNCE_MS); // Button BtnA = Button(12, true, DEBOUNCE_MS); // Button BtnB = Button(13, true, DEBOUNCE_MS); // /*是先做一个基本的游戏框架,然后慢慢添加功能,这样不容易翻车,先有个保底的东西 下一步 ,添加 button库来处理按键事件。 */ typedef struct { uint8_t x; uint8_t y; } Food; typedef struct { uint8_t x; uint8_t y; } Snake; int size = 1; unsigned long currentTime, readyTime; int period = 200; int dirX, dirY; unsigned short colors[2] = {0x48ED, 0x590F}; // terain colors unsigned short snakeColor[2] = {0x9FD3, 0x38C9}; bool taken, gOver, ready; Food food; Snake snake[100]; int difficulty = 0; String diff[3] = {"EASY", "NORMAL", "HARD"}; void setFood();//.....................getFood -get new position of food void gameInit(); void checkGameOver() ;//..,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,check game over void run(); //...............................run function void handleDirectionChange(bool isClockwise) ;// 第三代极简优化。 void BtnUpdate(); void setup() { //.......................setup Serial.begin(115200); midiplayer.init(); pinMode(left, INPUT_PULLUP); pinMode(right, INPUT_PULLUP); tft.init(); sprite.createSprite(240, 170); sprite.setSwapBytes(true); gameInit(); tft.setTextSize(3); // tft.setTextDatum(4); tft.drawString(String(size), 44, 250); tft.drawString(String(period), 174, 250); delay(400); } void loop() { //...............................................................loop midiplayer.musicupdate(); // Buzzer_BSP.play(80, 1, my_people_my_country); BtnUpdate(); if (millis() > currentTime + period) // 速度会越来越快。 { run(); currentTime = millis(); } if (millis() > readyTime + 100 && ready == 0) // 延时100ms后,ready置1 非阻塞式更新。 { ready = 1; } if (BtnY.wasPressed()) handleDirectionChange(false); // 逆时针转向 if (BtnX.wasPressed()) handleDirectionChange(true); // 顺时针转向 if (BtnA.wasPressed()) midiplayer.pause(); if (BtnB.wasPressed()) midiplayer.setMusicIndex(); } void BtnUpdate() { BtnY.read(); BtnX.read(); BtnA.read(); BtnB.read(); } /** * @brief This function sets the initial position of the food within the game area. * * @details This function generates a random position for the food within the game area and ensures that the food does not overlap with the snake. The function also initializes the `taken` flag to 0, indicating that the food has not yet been consumed by the snake. * * @param None * * @return void This function does not return any value. * * @note This function should be called regularly in the game loop to ensure that the food position is updated correctly. */ void setFood() { food.x = random(0, 24); food.y = random(0, 17); taken = 0; for (int i = 0; i < size; i++) if (food.x == snake[i].x && food.y == snake[i].y) taken = 1; if (taken == 1) setFood(); } /** * @brief This function initializes the game by setting the initial direction, speed, and size of the snake, as well as the initial position of the snake and the food. * * @details This function sets the initial direction of the snake to right (1) and down (0), the initial speed of the game to 200 milliseconds, and the initial size of the snake to 1. It also generates a random position for the snake and the food within the game area. The game area is initialized with background images and the difficulty level is displayed on the screen. * * @param None * * @return void This function does not return any value. * * @note This function should be called once at the beginning of the game to set up the initial game state. */ void gameInit() { dirX = 1; dirY = 0; ready = 0; gOver = 0; period = 200; size = 1; snake[0].y = random(5, 13); snake[0].x = random(5, 20); // 食物不能太靠边,不然一出来就死了。 tft.fillScreen(TFT_BLACK); tft.setSwapBytes(true); tft.pushImage(0, 0, 240, 30, bg_up); tft.pushImage(0, 200, 240, 120, image_data_bg_down); tft.pushImage(35, 30, 170, 170, newGame); tft.setTextSize(2); tft.setTextColor(TFT_PURPLE, 0x7DFD); tft.fillSmoothCircle(28 + 35, 102 + (difficulty * 24), 5, TFT_RED, TFT_BLACK); tft.drawString("DIFF: " + diff[difficulty], 50, 260); while (!BtnX.wasPressed()) // use button library. { BtnY.read(); BtnX.read(); currentTime = millis(); if (BtnY.wasPressed()) { tft.fillCircle(28 + 35, 102 + (difficulty * 24), 6, TFT_BLACK); difficulty++; if (difficulty == 3) difficulty = 0; tft.fillSmoothCircle(28 + 35, 102 + (difficulty * 24), 5, TFT_RED, TFT_BLACK); tft.pushImage(0, 200, 240, 120, image_data_bg_down); tft.drawString("DIFF: " + diff[difficulty], 50, 260); period = 200 - difficulty * 20; // 不同的难度对应不同的刷新时间。 delay(200); } } midiplayer.resume(); tft.pushImage(0, 200, 240, 120, image_data_bg_down); setFood(); tft.setTextSize(3); readyTime = millis(); tft.drawString(String(size), 44, 250); period = period - 1; tft.drawString(String(period), 174, 250); } /** * @brief This function checks if the game is over based on the position of the snake. * * @details This function checks if the snake has collided with the boundaries of the game area or if it has collided with itself. If either condition is true, the function sets the `gOver` flag to true, indicating that the game is over. * * @param None * * @return void This function does not return any value. * * @note This function should be called regularly in the game loop to ensure that the game state is updated correctly. */ void checkGameOver() { gOver = false; if (snake[0].x < 0 || snake[0].x >= 24 || snake[0].y < 0 || snake[0].y >= 17) gOver = true; for (int i = 1; i < size; i++) if (snake[i].x == snake[0].x && snake[i].y == snake[0].y) gOver = true; } /** * @brief This function handles the game logic and updates the game state. * * @details This function is responsible for updating the position of the snake, checking for collisions, and updating the game state accordingly. * * @param None * * @return void This function does not return any value. * * @note This function should be called in the game loop to ensure that the game state is updated regularly. */ void run() //...............................run function { // for (int i = size; i > 0; i--) // { // snake[i].x = snake[i - 1].x; // snake[i].y = snake[i - 1].y; // } // memmove(&snake[1], &snake[0], size * sizeof(snake[0])); //使用memmove函数相比for循环效率更高。 memmove(snake + 1, snake, size * sizeof(snake[0])); //............move the snake body snake[0].x += dirX; snake[0].y += dirY; if (snake[0].x == food.x && snake[0].y == food.y) { size++; // 只是增加size,然後如果有食物零位新加一截,最后一个位置不动,因为原来的位置是有东西的。 setFood(); // 立即生产新的食物 tft.drawString(String(size), 44, 250); period = period - 1; tft.drawString(String(period), 174, 250); } checkGameOver(); if (gOver == 0) { sprite.fillSprite(TFT_BLACK); sprite.drawRect(0, 0, 240, 170, 0x02F3); // 画个游戏区域的边框。 for (int i = 0; i < size; i++) { sprite.fillRoundRect(snake[i].x * 10, snake[i].y * BODYLENGTH, BODYLENGTH, BODYLENGTH, 2, snakeColor[0]); sprite.fillRoundRect(2 + snake[i].x * BODYLENGTH, 2 + snake[i].y * BODYLENGTH, 6, 6, 2, snakeColor[1]); } sprite.fillRoundRect(food.x * BODYLENGTH + 1, food.y * BODYLENGTH + 1, 8, 8, 1, TFT_RED); sprite.fillRoundRect(food.x * BODYLENGTH + 3, food.y * BODYLENGTH + 3, 4, 4, 1, 0xFE18); sprite.pushSprite(0, 30); } else { midiplayer.pause(); for (int i = 0; i < sprite.width() / 2; i++) { sprite.fillRect(0, 0, i, sprite.height(), TFT_BLACK); sprite.drawLine(i, 0, i, sprite.height(), TFT_WHITE); sprite.fillRect(sprite.width() - i, 0, sprite.width(), sprite.height(), TFT_BLACK); sprite.drawLine(sprite.width() - i, 0, sprite.width() - i, sprite.height(), TFT_WHITE); sprite.pushSprite(0, 30); } tft.drawLine(tft.width() / 2, 30, tft.width() / 2, 199, TFT_BLACK); sprite.pushImage(35, 0, 170, 170, gameOver); sprite.pushSprite(0, 30); delay(2000); gameInit(); } } /** * @brief This function handles the direction change of the snake based on the rotation direction. * * @param isClockwise A boolean indicating the rotation direction. True for clockwise, false for counterclockwise. * * @return void This function does not return any value. * * The function updates the direction of the snake based on the rotation direction. * If the rotation is clockwise, the snake's direction is updated to the right of its current direction. * If the rotation is counterclockwise, the snake's direction is updated to the left of its current direction. * The function also ensures that the direction change is only processed when the snake is ready to change direction. */ void handleDirectionChange(bool isClockwise) // 第三代极简优化。 { if (ready) { int temp = dirX; if (isClockwise) { dirX = -dirY; dirY = temp; } else { dirX = dirY; dirY = -temp; } ready = false; } }
效果演示:
游戏启动后,显示初始的游戏界面,包括背景、难度选择等。蛇在屏幕上移动,吃到食物后身体变长,速度加快。当蛇碰到边界或自身时,显示游戏结束画面,并可重新开始。
转向控制心得:
在这个贪吃蛇游戏项目中,蛇转向控制的实现经过了多次优化,用简单的代码实现高效的处理:
首先,考虑到蛇当前的移动方向以及用户的输入,以准确地计算出新的移动方向。这需要对方向的表示和转换有清晰的理解,使用`dirX`和`dirY`来表示水平和垂直方向的移动,通过改变它们的值来实现转向。 在处理转向时,还需要考虑转向的时机和限制。为了避免蛇瞬间反向导致游戏逻辑错误,设置了一些条件和延迟,比如通过`ready`标志和`readyTime`来控制转向的时机,确保转向操作不会过于频繁或不合理。 在最初的实现中,可能会出现蛇转向异常、碰撞检测不准确等问题。通过逐步调试,打印关键变量的值,观察蛇的移动轨迹,才能发现并解决这些潜在的问题。 另外,对于不同的转向方式(如顺时针和逆时针)的处理,需要仔细思考和准确编程。在实现过程中,要确保两种转向方式都能正确工作,并且不会导致游戏出现异常情况。蛇转向控制虽然只是游戏中的一个小部分,但它对于游戏的可玩性和稳定性却有着至关重要的影响。通过不断地优化和完善转向控制逻辑,提高了游戏的体验和质量。
(1)基本实现
最初的版本逻辑相对较为复杂,使用了多个条件判断来处理不同方向的转向情况,并且引入了额外的变量`change`来控制转向状态。这使得代码的可读性和可维护性受到一定影响。
void handleDirectionChange(bool isClockwise) //区分逆时针和顺时针。时针方向。 { if (dirX == 1 && change == 0) { if (isClockwise) dirY = dirX * 1; else dirY = dirX * -1; dirX = 0; change = 1; } if (dirX == -1 && change == 0) { if (isClockwise) { dirY = dirX * 1; } else { dirY = dirX * -1; } dirX = 0; change = 1; } if (dirY == 1 && change == 0) { if (isClockwise) { dirX = dirY * -1; } else { dirX = dirY * 1; } dirY = 0; change = 1; } if (dirY == -1 && change == 0) { if (isClockwise) { dirX = dirY * -1; } else { dirX = dirY * 1; } dirY = 0; change = 1; } change = 0; ready = 0; readyTime = millis(); }
(2)第二版升级优化
第二版开始引入了顺时针和逆时针的判断,但仍然不够好。
void handleDirectionChange(bool isClockwise) { //第二版优化 if (dirX == 1 && change == 0) { dirY = isClockwise ? dirX : -dirX; dirX = 0; change = 1; } else if (dirX == -1 && change == 0) { dirY = isClockwise ? dirX : -dirX; dirX = 0; change = 1; } else if (dirY == 1 && change == 0) { dirX = isClockwise ? -dirY : dirY; dirY = 0; change = 1; } else if (dirY == -1 && change == 0) { dirX = isClockwise ? -dirY : dirY; dirY = 0; change = 1; } change = 0; ready = 0; readyTime = millis(); }
(3)最终版本
随着优化的进行,我们可以看到代码逐渐变得简洁和清晰。第三代的极简优化版本,通过巧妙地利用临时变量`temp`,仅在满足`ready`条件时进行方向的转换计算,大大简化了逻辑。不仅减少了代码量,还降低了出错的可能性。更易于理解和后续的维护。另外,这也让我深刻认识到在编写代码时,应该先确保功能的实现,然后再不断地思考如何优化和改进代码的质量,使其更加高效、简洁和易于理解。
void handleDirectionChange(bool isClockwise) // 第三代极简优化。 { if (ready) { int temp = dirX; if (isClockwise) { dirX = -dirY; dirY = temp; } else { dirX = dirY; dirY = -temp; } ready = false; } }
(4)原理解析
方向变换的原理基于二维平面中的旋转变换,在二维平面上,旋转一个向量可以通过矩阵乘法来实现。对于一个向量 (x, y),绕原点旋转 90° ∘
逆时针,结果可以通过以下方式得到:
顺时针,结果可以通过以下方式得到:
举例说明
假设当前向量 (x, y) 表示蛇的移动方向:
1. 顺时针旋转:
- 原向量 (1, 0),表示蛇向右移动。
- 旋转 90° 顺时针后,得到向量 (0, 1),表示蛇向下移动。
2. 逆时针旋转:
- 原向量 (1, 0),表示蛇向右移动。
- 旋转 90° 逆时针后,得到向量 (0, -1),表示蛇向上移动。
代码中的方向变换原理基于二维平面上的矢量旋转。通过数学上的旋转矩阵,将蛇的当前方向向量按顺时针或逆时针方向旋转90°,得到新的方向向量,从而实现蛇在游戏中的方向变化。
小结
基于 vscode - PlatformIO 开发贪吃蛇游戏的详细过程。使用 rp2040 开发板、TFT_eSPI 显示屏和四个按钮,具备不同难度等级和音乐效果。硬件设计包括开发板、按钮和显示屏的连接,软件调试关注蛇的移动逻辑、食物位置和按钮消抖。程序设计中,通过相关函数实现游戏功能,如设置食物位置、初始化游戏、检查游戏结束等。游戏效果为启动后显示初始界面,蛇吃食物后变长、加速,触边或自身则游戏结束并可重新开始。转向控制经过多次优化,最终版本简洁且基于二维平面的旋转变换原理。
全套代码及相关关键从以下链接获取:
https://gitee.com/genvex/letsdo_snake
游戏验证估计:https://gitee.com/genvex/letsdo_snake/blob/master/firmware.uf2
本站下载链接 下载后修改成.uf2文件,然后发送到pico盘符上。