實驗介紹
貪吃蛇是一個起源于1976年的街機游戲 Blockade。此類游戲在1990年代由于一些具有小型屏幕的移動電話的引入而再度流行起來,在現在的手機上基本都可安裝此小游戲。版本亦有所不同。 在游戲中,玩家操控一條細長的蛇,它會不停前進,玩家只能操控蛇的頭部朝向(上下左右),一路拾起觸碰到食物,并要避免觸碰到自身或者其他障礙物。每次貪吃蛇吃掉一件食物,它的身體便增長一些。
涉及知識點
OLED繪圖 按鍵事件
開發環境準備
硬件
開發用電腦一臺
HAAS EDU K1 開發板一塊
USB2TypeC 數據線一根
軟件
開發環境的搭建請參考《AliOS Things集成開發環境使用說明之搭建開發環境》,其中詳細的介紹了AliOS Things 3.3的IDE集成開發環境的搭建流程。
本案例的代碼下載請參考《AliOS Things集成開發環境使用說明之創建工程》,
> 選擇解決方案:“HaaS EDU K1教育開發案例合集”
> 選擇開發板:haaseduk1 board configure
-- 編譯固件可參考《AliOS Things集成開發環境使用說明之編譯固件》。
-- 燒錄固件可參考《AliOS Things集成開發環境使用說明之燒錄固件》。
設計思路
游戲空間映射到邏輯空間
當玩家在體驗游戲時,他們能操作的都是游戲空間,包括按鍵的上下左右,對象物體的運動等等。對于開發者而言,我們需要將這些設想的游戲空間映射到邏輯空間中,做好對用戶輸入的判斷,對象運動的處理,對象間交互的判定,游戲整體進程的把控,以及最終將邏輯空間再次映射回游戲空間,返回給玩家。
對象定義
這一步是將游戲空間中涉及到的對象抽象化。在C語言的實現中,我們將對象抽象為結構體,對象屬性抽象為結構體的成員。
蛇
typedef struct
{
uint8_t length; // 當前長度
int16_t *XPos; // 邏輯坐標x 數組
int16_t *YPos; // 邏輯坐標y 數組
uint8_t cur_dir; // 蛇頭的運行方向
uint8_t alive; // 存活狀態
} Snake;
食物
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;
}
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();
}
死亡判定
在蛇每次運動的過程中,都涉及到對整個游戲新的更新,包括上述過程中出現的 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);
}
}
}
實現效果
接下來請欣賞筆者的操作。