實驗介紹
貪吃蛇是一個起源于1976年的街機遊戲 Blockade。此類遊戲在1990年代由于一些具有小型螢幕的行動電話的引入而再度流行起來,在現在的手機上基本都可安裝此小遊戲。版本亦有所不同。
在遊戲中,玩家操控一條細長的蛇,它會不停前進,玩家隻能操控蛇的頭部朝向(上下左右),一路拾起觸碰到食物,并要避免觸碰到自身或者其他障礙物。每次貪吃蛇吃掉一件食物,它的身體便增長一些。

涉及知識點
OLED繪圖
按鍵事件
開發環境準備
硬體
開發用電腦一台
HAAS EDU K1 開發闆一塊
USB2TypeC 資料線一根
- 1
- 2
- 3
軟體
AliOS Things開發環境搭建
開發環境的搭建請參考 @ref HaaS_EDU_K1_Quick_Start (搭建開發環境章節),其中詳細的介紹了AliOS Things 3.3的IDE內建開發環境的搭建流程。
HaaS EDU K1 DEMO 代碼下載下傳
HaaS EDU K1 DEMO 的代碼下載下傳請參考 @ref HaaS_EDU_K1_Quick_Start (建立工程章節),其中,
選擇解決方案: 基于教育開發闆的示例
選擇開發闆: haaseduk1 board configure
代碼編譯、燒錄
參考 @ref HaaS_EDU_K1_Quick_Start (3.1 編譯工程章節),點選 ✅ 即可完成編譯固件。
參考 @ref HaaS_EDU_K1_Quick_Start (3.2 燒錄鏡像章節),點選 "⚡️" 即可完成燒錄固件。
設計思路
遊戲空間映射到邏輯空間
當玩家在體驗遊戲時,他們能操作的都是遊戲空間,包括按鍵的上下左右,對象物體的運動等等。對于開發者而言,我們需要将這些設想的遊戲空間映射到邏輯空間中,做好對使用者輸入的判斷,對象運動的處理,對象間互動的判定,遊戲整體程序的把控,以及最終将邏輯空間再次映射回遊戲空間,傳回給玩家。
對象定義
這一步是将遊戲空間中涉及到的對象抽象化。在C語言的實作中,我們将對象抽象為結構體,對象屬性抽象為結構體的成員。
蛇
typedef struct
{
uint8_t length; // 目前長度
int16_t *XPos; // 邏輯坐标x 數組
int16_t *YPos; // 邏輯坐标y 數組
uint8_t cur_dir; // 蛇頭的運作方向
uint8_t alive; // 存活狀态
} Snake;
- 4
- 5
- 6
- 7
- 8
食物
typedef struct
{
int16_t x;
int16_t y; // 食物邏輯坐标
uint8_t eaten; // 食物是否被吃掉
} Food;
地圖
typedef struct
{
int16_t border_top;
int16_t border_right;
int16_t border_botton;
int16_t border_left; // 邊界像素坐标
int16_t block_size; // 網格大小 在本實驗的實作中 蛇身和食物的大小被統一限制進網格的大小中
} Map;
遊戲
typedef struct
{
int16_t score; // 遊戲記分
int16_t pos_x_max; // 邏輯最大x坐标 pos_x_max = (map.border_right - map.border_left) / map.block_size;
int16_t pos_y_max; // 邏輯最大y坐标 pos_y_max = (map.border_botton - map.border_top) / map.block_size;
} snake_game_t;
通過Map和snake_game_t的定義,我們将螢幕的 (border_left, border_top, border_bottom, border_right) 部分設定為遊戲區域,并且将其切分為 pos_x_max* pos_y_max 個大小為 block_size 的塊。繼而,我們可以在每個塊中繪制蛇、食物等對象。
對象初始化
在遊戲每一次開始時,我們需要給對象一些初始的屬性,例如蛇的長度、位置、存活狀态,食物的位置、狀态, 地圖的邊界、塊大小等等。
Food food = {-1, -1, 1};
Snake snake = {4, NULL, NULL, 0, 1};
Map map = {2, 128, 62, 12, 4};
snake_game_t snake_game = {0, 0, 0};
int greedySnake_init(void)
{
// 計算出遊戲的最大邏輯坐标 用于限制遊戲範圍
snake_game.pos_x_max = (map.border_right - map.border_left) / map.block_size;
snake_game.pos_y_max = (map.border_botton - map.border_top) / map.block_size;
// 為蛇的坐标數組配置設定空間 蛇的最大長度是填滿整個螢幕 即 pos_x_max* pos_y_max
snake.XPos = (int16_t *)malloc(snake_game.pos_x_max * snake_game.pos_y_max * sizeof(int16_t));
snake.YPos = (int16_t *)malloc(snake_game.pos_x_max * snake_game.pos_y_max * sizeof(int16_t));
// 蛇的初始長度設為4
snake.length = 4;
// 蛇的初始方向設為 右
snake.cur_dir = SNAKE_RIGHT;
// 生成蛇的身體 蛇頭在邏輯區域最中間的坐标上 即 (pos_x_max/2, pos_y_max/2)
for (uint8_t i = 0; i < snake.length; i++)
{
snake.XPos[i] = snake_game.pos_x_max / 2 + i;
snake.YPos[i] = snake_game.pos_y_max / 2;
}
// 複活這條蛇
snake.alive = 1;
// 将食物設定為被吃掉
food.eaten = 1;
// 生成食物 因為食物需要反複生成 是以封裝為函數
gen_food();
// 遊戲開始分數為0
snake_game.score = 0;
return 0;
}
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
void gen_food()
{
int i = 0;
// 如果食物被吃了
if (food.eaten == 1)
{
while (1)
{
// 随機生成一個坐标
food.x = rand() % snake_game.pos_x_max;
food.y = rand() % snake_game.pos_y_max;
// 開始周遊蛇身 檢查坐标是否重合
for (i = 0; i < snake.length; i++)
{
// 如果生成的食物坐标和蛇身重合 不合法 重新随機生成
if ((food.x == snake.XPos[i]) && (food.y == snake.YPos[i]))
break;
}
// 周遊完蛇身 并未發生重合
if (i == snake.length)
{
// 生成有效 終止循環
food.eaten = 0;
break;
}
}
}
}
對象繪畫
這一步其實是将邏輯空間重新映射到遊戲空間,理應是整個遊戲邏輯的最後一步,但是在我們開發過程中,也需要來自遊戲空間的回報,來驗證我們的實作是否符合預期。是以我們在這裡提前實作它。
static uint8_t icon_data_snake1_4_4[] = {0x0f, 0x0f, 0x0f, 0x0f}; // 純色方塊
static icon_t icon_snake1_4_4 = {icon_data_snake1_4_4, 4, 4, NULL};
static uint8_t icon_data_snake0_4_4[] = {0x09, 0x09, 0x03, 0x03}; // 紋理方塊
static icon_t icon_snake0_4_4 = {icon_data_snake0_4_4, 4, 4, NULL};
void draw_snake()
{
uint16_t i = 0;
OLED_Icon_Draw(
map.border_left + snake.XPos[i] * map.block_size,
map.border_top + snake.YPos[i] * map.block_size,
&icon_snake0_4_4,
0
); // 蛇尾一定使用紋理方塊
for (; i < snake.length - 2; i++)
{
OLED_Icon_Draw(
map.border_left + snake.XPos[i] * map.block_size,
map.border_top + snake.YPos[i] * map.block_size,
((i % 2) ? &icon_snake1_4_4 : &icon_snake0_4_4),
0);
} // 蛇身交替使用純色和紋理方塊 來模拟蛇的花紋
OLED_Icon_Draw(
map.border_left + snake.XPos[i] * map.block_size,
map.border_top + snake.YPos[i] * map.block_size,
&icon_snake1_4_4,
0
); // 蛇頭一定使用純色方塊
}
static uint8_t icon_data_food_4_4[] = {0x06, 0x09, 0x09, 0x06};
static icon_t icon_food_4_4 = {icon_data_food_4_4, 4, 4, NULL};
void draw_food()
{
if (food.eaten == 0) // 如果食物沒被吃掉
{
OLED_Icon_Draw(
map.border_left + food.x * map.block_size,
map.border_top + food.y * map.block_size,
&icon_food_4_4,
0);
}
}
對象行為
蛇的運動
在貪吃蛇中,對象蛇發生運動,有兩種情況,一是在使用者無操作的情況下,蛇按照目前的方向繼續運動,而是使用者按鍵觸發蛇的運動。總而言之,都是蛇的運動,隻是運動的方向不同,是以我們可以将蛇的行為抽象為
void Snake_Run(uint8_t dir)。
這裡以向上走為例。
void Snake_Run(uint8_t dir)
{
switch (dir)
{
// 對于右移
case SNAKE_UP:
// 如果目前方向是左則不響應 因為不能掉頭
if (snake.cur_dir != SNAKE_DOWN)
{
// 将蛇身數組向前移
// 值得注意的是,這裡采用數組起始(XPos[0],YPos[0])作為蛇尾,
// 而使用(XPos[snake.length - 1], YPos[snake.length - 1])作為蛇頭
// 這樣實作會較為友善
for (uint16_t i = 0; i < snake.length - 1; i++)
{
snake.XPos[i] = snake.XPos[i + 1];
snake.YPos[i] = snake.YPos[i + 1];
}
// 将蛇頭位置轉向右側 即 snake.XPos[snake.length - 2] + 1
snake.XPos[snake.length - 1] = snake.XPos[snake.length - 2];
snake.YPos[snake.length - 1] = snake.YPos[snake.length - 2] - 1;
snake.cur_dir = dir;
}
break;
case SNAKE_LEFT:
...
case SNAKE_DOWN:
...
case SNAKE_RIGHT:
...
break;
}
// 檢查蛇是否存活
check_snake_alive();
// 檢查食物狀态
check_food_eaten();
// 更新完所有狀态後繪制蛇和食物
draw_snake();
draw_food();
}
- 37
- 38
- 39
- 40
- 41
死亡判定
在蛇每次運動的過程中,都涉及到對整個遊戲新的更新,包括上述過程中出現的 check_snake_alive check_food_eaten 等。
對于 check_snake_alive, 分為兩種情況:蛇碰到地圖邊界/蛇吃到自己。
void check_snake_alive()
{
// 判斷蛇頭是否接觸邊界
if (snake.XPos[snake.length - 1] < 0 ||
snake.XPos[snake.length - 1] >= snake_game.pos_x_max ||
snake.YPos[snake.length - 1] < 0 ||
snake.YPos[snake.length - 1] >= snake_game.pos_y_max)
{
snake.alive = 0;
}
// 判斷蛇頭是否接觸自己
for (int i = 0; i < snake.length - 1; i++)
{
if (snake.XPos[snake.length - 1] == snake.XPos[i] && snake.YPos[snake.length - 1] == snake.YPos[i])
{
snake.alive = 0;
break;
}
}
}
吃食判定
在貪吃蛇中,食物除了被吃的份,還有就是随機生成。生成食物在上一節已經實作,是以這一節我們就來實作檢測食物是否被吃。
void check_food_eaten()
{
// 如果蛇頭與食物重合
if (snake.XPos[snake.length - 1] == food.x && snake.YPos[snake.length - 1] == food.y)
{
// 說明吃到了食物
food.eaten = 1;
// 增加蛇的長度
snake.length++;
// 長度增加表現為頭的方向延伸
snake.XPos[snake.length - 1] = food.x;
snake.YPos[snake.length - 1] = food.y;
// 遊戲得分增加
snake_game.score++;
// 重新生成食物
gen_food();
}
}
綁定使用者操作
在貪吃蛇中,唯一的使用者操作就是使用者按鍵觸發蛇的運動。好在我們已經對這個功能實作了良好的封裝,即void Snake_Run(uint8_t dir)
我們隻需要在按鍵回調函數中,接收來自底層上報的key_code即可。
#define SNAKE_UP EDK_KEY_2
#define SNAKE_LEFT EDK_KEY_1
#define SNAKE_RIGHT EDK_KEY_3
#define SNAKE_DOWN EDK_KEY_4
void greedySnake_key_handel(key_code_t key_code)
{
Snake_Run(key_code);
}
遊戲全局控制
在這個主循環裡,我們需要對遊戲整體進行重新整理、繪圖,對玩家的輸赢、得分進行判定,并提示玩家遊戲結果。
void greedySnake_task(void)
{
while (1)
{
if (snake.alive)
{
// 清除螢幕memory
OLED_Clear();
// 繪制地圖邊界
OLED_DrawRect(11, 1, 118, 62, 1);
// 繪制“SCORE”
OLED_Icon_Draw(3, 41, &icon_scores_5_21, 0);
// 繪制玩家目前分數
draw_score(snake_game.score);
// 讓蛇按目前方向運作
Snake_Run(snake.cur_dir);
// 将螢幕memory輸出
OLED_Refresh_GRAM();
// 間隔200ms
aos_msleep(200);
}
else
{
// 清除螢幕memory
OLED_Clear();
// 提示 GAME OVER
OLED_Show_String(30, 24, "GAME OVER", 16, 1);
// 将螢幕memory輸出
OLED_Refresh_GRAM();
// 間隔500ms
aos_msleep(500);
}
}
}
實作效果
接下來請欣賞筆者的操作。
開發者支援
HaaS官方:
https://haas.iot.aliyun.com/HaaS技術社群:
https://blog.csdn.net/HaaSTech開發者釘釘群和公衆号見下圖,開發者釘釘群每天都有技術支援同學值班。