用 Python 寫個貪吃蛇,保姆級教程!

削微寒發表於2021-06-02

本文基於 Windows 環境開發,適合 Python 新手

本文作者:HelloGitHub-Anthony

HelloGitHub 推出的《講解開源專案》系列,本期介紹 Python 練手級專案——貪吃蛇!

原本想推薦一個貪吃蛇的開源專案:python-console-snake,但由於該專案最近一次更新是 8 年前,而且在執行的時候出現了諸多問題。索性我就動手用 Python 重新寫了一個貪吃蛇遊戲。

專案地址:https://github.com/AnthonySun256/easy_games

下面我們就一起用 Python 實現一個簡單有趣的命令列貪吃蛇小遊戲,啟動命令:

git clone https://github.com/AnthonySun256/easy_games
cd easy_games
python snake

本文包含設計和講解,整體分為兩個部分:第一部分是關於 Python 命令列圖形化庫 curses 接著是 snake 相關程式碼。

一、初識 curses

Python 已經內建了 curses 庫,但是對於 Windows 作業系統我們需要安裝一個補丁以進行適配。

Windows 下安裝補全包:

pip install windows-curses

curses 是一個應用廣泛的圖形函式庫,可以在終端內繪製簡單的使用者介面。

在這裡我們只進行簡單的介紹,只學習貪吃蛇需要的功能

如果您已經接觸過 curses,請跳過此部分內容。

1.1 簡單使用

Python 內建了 curses 庫,其使用方法非常簡單,以下指令碼可以顯示出當前按鍵對應編號:

# 匯入必須的庫
import curses
import time

# 初始化命令列介面,返回的 stdscr 為視窗物件,表示命令列介面
stdscr = curses.initscr()
# 使用 noecho 方法關閉命令列回顯
curses.noecho()
# 使用 nodelay(True) 方法讓 getch 為非阻塞等待(即使沒有輸入程式也能繼續執行)
stdscr.nodelay(True)
while True:
    # 清除 stdscr 視窗的內容(清除殘留的符號)
    stdscr.erase()
    # 獲取使用者輸入並放回對應按鍵的編號
    # 非阻塞等待模式下沒有輸入則返回 -1
    key = stdscr.getch()
    # 在 stdscr 的第一行第三列顯示文字
    stdscr.addstr(1, 3, "Hello GitHub.")
    # 在 stdscr 的第二行第三列顯示文字
    stdscr.addstr(2, 3, "Key: %d" % key)
    # 重新整理視窗,讓剛才的 addstr 生效
    stdscr.refresh()
    # 等待 0.1s 給使用者足夠反應時間檢視文字
    time.sleep(0.1)

您也可以嘗試把 nodelay(True) 改為 nodelay(False) 後再次執行,這時候程式會阻塞在 stdscr.getch() 只有當您按下按鍵後才會繼續執行。

1.2 整點花樣

您也許會覺得上面的例子太菜了,隨便用幾個 print 都能達到相同的效果,現在我們來整點花樣以實現一些使用普通輸出無法達到的效果。

1.2.1 新建一個子視窗

說再多的話也不如一張圖來的實際:

如果我們想要實現圖中 Game over! 視窗,可以使用 newwin 方法:

import curses
import time

stdscr = curses.initscr()
curses.noecho()
stdscr.addstr(1, 2, "HelloGitHub")
# 新建視窗,高為 5 寬為 25,在命令列視窗的 四行六列處
new_win = curses.newwin(5, 25, 4, 6)
# 使用阻塞等待模式
new_win.nodelay(False)
# 在新視窗的 2 行 3 列處新增文字
new_win.addstr(2, 3, "www.HelloGitHub.com")
# 給新視窗新增邊框,其中邊框符號可以這是,這裡使用預設字元
new_win.border()
# 重新整理視窗
stdscr.refresh()
# 等待字元輸入(這裡會一直等待輸入)
new_win.getch()
# 刪除新視窗物件
del new_win
# 清除所有內容(比 erase 更徹底)
stdscr.clear()
# 重新新增文字
stdscr.addstr(1, 2, "HelloGitHub")
# 重新整理視窗
stdscr.refresh()
# 等待兩秒鐘
time.sleep(2)
# 結束 curses 模式,恢復到正常命令列模式
curses.endwin()

除了 curses.newwin 新建一個獨立的視窗,我們還能在任意視窗上使用 subwin 或者 subpad 方法新建子視窗,例如 stdscr.subwinstdscr.subpadnew_win.subwinnew_win.subpad 等等,其使用方法與本節中建立的 new_win 或者 stdscr 沒有區別,只是新建視窗使用獨立的快取區,而子視窗和父視窗共享快取區。

如果某個視窗會在使用後刪除,最好使用 newwin 方法新建獨立視窗,以防止刪除子視窗造成父視窗的快取內容出現問題。

1.2.2 上點顏色

白與黑的搭配看久了也會顯得單調,curses 提供了內建顏色可以讓我們自定義前後背景。

在使用彩色模式之前我們需要先使用使用 curses.start_corlor() 進行初始化操作:

import curses
import time
stdscr = curses.initscr()
stdscr.nodelay(False)
curses.noecho()
# 初始化彩色模式
curses.start_color()
# 在1號位置新增前景色是綠色,背景色是黑色的彩色對兒
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
# 在一行一列處顯示文字,使用 1號 色彩搭配
stdscr.addstr(1, 1, "HelloGitHub!", curses.color_pair(1))
# 阻塞等待按鍵然後結束程式
stdscr.getch()
curses.endwin()

需要注意的是,0號 位置顏色是預設黑白配色,無法修改

1.2.3 給點細節

在此部分最後的最後,我們來說說如何給文字加一點文字效果:

import curses
import time
stdscr = curses.initscr()
stdscr.nodelay(False)
curses.noecho()
# 之後的文字都加上下劃線,直到呼叫 attroff為止
stdscr.attron(curses.A_UNDERLINE)
stdscr.addstr(1, 1, "www.HelloGitHub.com")
stdscr.getch()

二、貪吃蛇

前面說了這麼多,現在終於到了我們的主菜。在這部分,我將一步步教給大家如何從零開始做出一個簡單卻又不失細節的貪吃蛇。

2.1 設計

對於一個專案來講,相比於盡快動手寫下第一行程式碼不如先花點時間進行一些必要的設計,畢竟結構決定功能,一個專案沒有一個良好的結構是沒有前途的。

snake 將貪吃蛇這個遊戲分為了三大塊:

  1. 介面:負責顯示相關的所有工作
  2. 遊戲流程控制:判斷遊戲輸贏、遊戲初始化等
  3. 蛇和食物:移動自身、判斷是否死亡、是否被吃等

每一塊都被做成了單獨的物件,通過相互配合實現遊戲。下面讓我們來分別看看應該如何實現。

2.2 蛇語者

對於貪吃蛇遊戲裡面的蛇來講,它可以做的事情有三種:移動,死亡(吃到自己,撞牆)和吃東西

圍繞著這三個功能,我們可以首先寫出一個簡陋的蛇,其類圖如圖所示:

這個蛇可以檢查自己是不是死亡,是不是吃了東西,以及更新自己的位置資訊。

其中,bodylast_body 是列表,分別儲存當前蛇身座標和上一步蛇身座標,預設列表第一個元素是蛇頭。direction 是當前行進方向,window_size 是蛇可以活動的區域大小。

rest 方法用於重置蛇的狀態,它與 __init__ 共同負責蛇的初始化工作:

class Snake(object):
    def __init__(self) -> None:
        # Position 是我自定義的類,只有 x, y 兩個屬性,儲存一個座標點
        # 初始化蛇可以移動範圍的大小
        self.window_size = Position(game_config.game_sizes["width"], game_config.game_sizes["height"])
        # 初始化移動方向
        self.direction = game_config.D_Down
        # 重置身體列表
        self.body = []
        self.last_body = []
        # 生成新的身體,預設在左上角,頭朝下,長三個格子
        for i in range(3):
            self.body.append(Position(2, 3 - i))
	# rest 重置相關屬性
    def reset(self) -> None:
        self.direction = game_config.D_Down
        self.body = []
        self.last_body = []
        for i in range(3):
            self.body.append(Position(2, 3 - i))

Position 是我自定義的類,只有 x, y 兩個屬性,儲存一個座標點

在最開始我們可能只是模糊的感覺應該有這幾個屬性,但是對於其中的內容和初始化方法又不完全清楚,這是正常的。我們需要做的就是繼續實現需要的功能,在實踐中新增和完善最初的構想。

之後,我們從繼續上到下實現,對照類圖,我們接下來應該實現一下 update_snake_pos 即 更新蛇的位置,這部分非常簡單:

def update_snake_pos(self) -> None:
    # 這個函式在文章下方,獲得蛇在 x, y 方向上分別增加多少
    dis_increment_factor = self.get_dis_inc_factor()
    # 需要注意,這裡要用深拷貝(import copy)
    self.last_body = copy.deepcopy(self.body)
	# 先移動蛇頭,然後蛇身依次向前
    for index, item in enumerate(self.body):
        if index < 1:
            item.x += dis_increment_factor.x
            item.y += dis_increment_factor.y
        else:  # 剩下的部分要跟著前一部分走
            item.x = self.last_body[index - 1].x
            item.y = self.last_body[index - 1].y

其實 last_body 可以只記錄最後一次修改的身體,這裡我偷了個懶

在這裡有一個細節,如果我們是第一次寫這個函式,為了讓蛇頭能夠正確的按照玩家操作移動,我們需要知道蛇頭元素在 x, y 方向上各移動了多少。

最簡單的方法是直接一串 if-elif,判斷方向再相加:

if self.direction == LEFT:
    head.x -= 1
elif self.direction == RIGHT:
    head.x += 1
    ....

但是這樣的問題在於,如果我們的需求更改(比如我現在說蛇可以一次走兩個格子,或者吃了特殊道具 x, y 方向上走的距離不一樣等等)直接修改這樣的程式碼會讓人很痛苦。

所以在這裡更好的解決辦法是使用一個 dis_increment_factor 儲存蛇再 x 和 y 上各移動多少,並且新建一個函式 get_dis_inc_factor 進行判斷:

def get_dis_inc_factor(self) -> Position:
    # 初始化
    dis_increment_factor = Position(0, 0)

    # 修改每個方向上的速度
    if self.direction == game_config.D_Up:
        dis_increment_factor.y = -1
    elif self.direction == game_config.D_Down:
        dis_increment_factor.y = 1
    elif self.direction == game_config.D_Left:
        dis_increment_factor.x = -1
    elif self.direction == game_config.D_Right:
        dis_increment_factor.x = 1

    return dis_increment_factor

當然了,這麼做或許有點多餘,但是努力做到一個函式只做一件事情能幫助化簡我們的程式碼,降低寫出又臭又長還難除錯程式碼的可能性。

解決了移動問題,下一步就是考慮貪吃蛇如何吃到食物了,在這裡我們用 check_eat_foodeat_food 兩個函式完成:

def eat_food(self, food) -> None:
    self.body.append(self.last_body[-1])  # 長大一個元素

def check_eat_food(self, foods: list) -> int:  # 返回吃到了哪個食物
    # 遍歷食物,看看當前食物和蛇頭是不是重合,重合就是吃到
    for index, food in enumerate(foods):
        if food == self.body[0]:
            # 吃到食物則呼叫 eat_food 函式,處理蛇身長大等操作
            self.eat_food(food)
            # 彈出吃掉的食物
            foods.pop(index)
            # 返回吃掉食物的序號,沒吃則返回 -1
            return index
    return -1

在這裡,foods 是一個儲存著所有食物位置資訊的列表,每次蛇體移動後都會呼叫 check_eat_food 函式檢查是不是吃到了某一個食物。

可以發現,檢查是不是「吃到」和「吃下去」這兩個動作我分為了兩個函式,以做到每個函式「一心一意」方便後期修改。

現在,我們的蛇已經能跑能吃了。但是作為一隻能照顧自己的貪吃蛇,我們還需要能夠判斷當前自身狀態,比如最基本的我需要知道我剛剛是不是咬到自己了,只需要看看蛇頭是不是移動到了身體裡面:

def check_eat_self(self) -> bool:
    return self.body[0] in self.body[1:]  # 判斷蛇頭是不是和身體重合

或者我想知道是不是跑得太快而撞了牆:

def check_hit_wall(self) -> bool:
    # 是不是在上下邊框之間
    is_between_top_bottom = self.window_size.y - 1 > self.body[0].y > 0
    # 是不是在左右邊框之間
    is_between_left_right = self.window_size.x - 1 > self.body[0].x > 0
    # 返回 是 或者 不是 撞了牆
    return not (is_between_top_bottom and is_between_left_right)

這些功能都是簡單得不能再簡單了,但是要相信自己,就是這麼簡單的幾行程式碼就能實現一個聽你指揮能做出複雜動作的

完整程式碼:https://github.com/AnthonySun256/easy_games

2.3 命令列?畫板!

上一節中我們實現了遊戲裡的第一位角色:。為了將它顯示出來我們現在需要將我們的命令列改造成一塊「畫板」。

在動手之前我們同樣思考:我們需要畫哪些東西在我們的命令列上?直接上類圖:

是不是覺得有些眼花繚亂以至於感覺無從下手?其實 Graphic 類方法雖多但是大多數方法只是執行一個特定的功能而已,而且每次更新遊戲只需要呼叫 draw_game 方法即可:

def draw_game(self, snake: Snake, foods, lives, scores, highest_score) -> None:
    # 清理視窗字元
    self.window.erase()
    # 繪製幫助資訊
    self.draw_help()
    # 更新當前幀率
    self.update_fps()
    # 繪製幀率資訊
    self.draw_fps()
    # 繪製生命、得分資訊
    self.draw_lives_and_scores(lives, scores, highest_score)
    # 繪製邊框
    self.draw_border()
    # 繪製食物
    self.draw_foods(foods)
    # 繪製蛇身體
    self.draw_snake_body(snake)
    # 更新介面
    self.window.refresh()
    # 更新介面
    self.game_area.refresh()
    # 延遲一段時間,以控制幀率
    time.sleep(self.delay_time)

遵循從上到下設計,從下到上實現的原則

可以看出 draw_game 實際上已經完成了 Graphic 的所有功能。

再往下深入,我們可以發現類似 draw_foodsdraw_snake_body 實現基本一樣,都是遍歷座標列表然後直接在相應位置上新增字元即可:

def draw_snake_body(self, snake: Snake) -> None:
    for item in snake.body:
        self.game_area.addch(item.y, item.x,
                             game_config.game_themes["tiles"]["snake_body"],
                             self.C_snake)

def draw_foods(self, foods) -> None:
    for item in foods:
        self.game_area.addch(item.y, item.x,
                             game_config.game_themes["tiles"]["food"],
                             self.C_food)

將其分開實現也是為了保持程式碼乾淨易懂以及方便後期修改。draw_helpdraw_fpsdraw_lives_and_scores 也是分別列印了不同文字資訊,沒有任何新的花樣。

update_fps 實現了幀率的估算以及調節等待時間穩定幀率:

def esp_fps(self) -> bool:  # 返回是否更新了fps
    # 每 fps_update_interval 幀計算一次
    if self.frame_count < self.fps_update_interval:
        self.frame_count += 1
        return False
    # 計算時間花費
    time_span = time.time() - self.last_time
    # 重置開始時間
    self.last_time = time.time()
    # 估算幀率
    self.true_fps = 1.0 / (time_span / self.frame_count)
    # 重置計數
    self.frame_count = 0
    return True

def update_fps(self) -> None:
    # 如果重新估計了幀率
    if self.esp_fps():
        # 計算誤差
        err = self.true_fps - self.target_fps
        # 調節等待時間,穩定fps
        self.delay_time += 0.00001 * err

draw_message_window 則實現了繪製勝利、失敗的畫面:

def draw_message_window(self, texts: list) -> None:  # 接收一個 str 列表
    text1 = "Press any key to continue."
    nrows = 6 + len(texts)  # 留出行與行之間的空隙
    ncols = max(*[len(len_tex) for len_tex in texts], len(text1)) + 20
	# 居中顯示視窗
    x = (self.window.getmaxyx()[1] - ncols) / 2
    y = (self.window.getmaxyx()[0] - nrows) / 2
    pos = Position(int(x), int(y))
    # 新建獨立視窗
    message_win = curses.newwin(nrows, ncols, pos.y, pos.x)
    # 阻塞等待,實現任意鍵繼續效果
    message_win.nodelay(False)
    # 繪製文字提示
    # 底部文字居中
    pos.y = nrows - 2
    pos.x = self.get_middle(ncols, len(text1))
    message_win.addstr(pos.y, pos.x, text1, self.C_default)
	# 繪製其他資訊
    pos.y = 2
    for text in texts:
        pos.x = self.get_middle(ncols, len(text))
        message_win.addstr(pos.y, pos.x, text, self.C_default)
        pos.y += 1
	# 繪製邊框
    message_win.border()
	# 重新整理內容
    message_win.refresh()
    # 等待任意按鍵
    message_win.getch()
    # 恢復非阻塞模式
    message_win.nodelay(True)
    # 清空視窗
    message_win.clear()
    # 刪除視窗
    del message_win

這樣,我們就實現了遊戲動畫的顯示!

2.4 控制!

到目前為止,我們實現了遊戲內容繪製以及遊戲角色實現,本節我們來學習 snake 的最後一個內容:控制

老規矩,敲程式碼之前我們應該先想一想:如果要寫一個 control 類,他應該都包含哪些方法呢?

仔細思考也不難想到:應該有一個迴圈,只要沒輸或者沒贏就一直進行遊戲,每輪應該更新畫面、蛇移動方向等等。這就是我們的 start

def start(self) -> None:
    # 重置遊戲
    self.reset()
	# 遊戲執行標誌
    while self.game_flag:
		# 繪製遊戲
        self.graphic.draw_game(self.snake, self.foods, self.lives, self.scores, self.highest_score)
		# 讀取按鍵控制
        if not self.update_control():
            continue
        # 控制遊戲速度
        if time.time() - self.start_time < 1/game_config.snake_config["speed"]:
            continue
        self.start_time = time.time()
        # 更新蛇
        self.update_snake()

只要我們寫出了 start 對於剩下的結構也就能輕鬆的實現,比如讀取按鍵控制就是最基本的比較數字是不是一樣大:

def update_control(self) -> bool:
    key = self.graphic.game_area.getch()

    # 不允許 180度 轉彎
    if key == curses.KEY_UP and self.snake.direction != game_config.D_Down:
        self.snake.direction = game_config.D_Up
    elif key == curses.KEY_DOWN and self.snake.direction != game_config.D_Up:
        self.snake.direction = game_config.D_Down
    elif key == curses.KEY_LEFT and self.snake.direction != game_config.D_Right:
        self.snake.direction = game_config.D_Left
    elif key == curses.KEY_RIGHT and self.snake.direction != game_config.D_Left:
        self.snake.direction = game_config.D_Right
    # 判斷是不是退出
    elif key == game_config.keys['Q']:
        self.game_flag = False
        return False
    # 判斷是不是重開
    elif key == game_config.keys['R']:
        self.reset()
        return False

更新蛇的狀態時只需要判斷是不是死亡、勝利、吃到東西就可:

def update_snake(self) -> None:
    self.snake.update_snake_pos()
    index = self.snake.check_eat_food(self.foods)
    if index != -1:  # 如果吃到食物
        # 得分 +1
        self.scores += 1
        # 如果填滿了遊戲區域就勝利
        if len(self.snake.body) >= (self.snake.window_size.x - 2) * (self.snake.window_size.y - 2):  # 蛇身已經填滿遊戲區域
            self.win()
        else:
            # 再放置一個食物
            self.span_food()
	# 如果死了,就看看是不是遊戲結束
    if not self.snake.check_alive():
        self.game_over()

2.5 直接使用

為了讓這個包能夠直接使用 python snake 就能直接開始遊戲,我們來看一下 __main__.py

import game

g = game.Game()
g.start()
g.quit()

當我們嘗試直接執行一個包時,Python 從 __main__.py 中開始執行,對於我們寫好的程式碼,只需三行即可開始遊戲!

三、結尾

到這裡如何編寫一個貪吃蛇遊戲就結束啦!實際上編寫一個小遊戲不難,對於新手來講難點在於如何去組織程式的結構。我所實現的只是其中的一種方法,每個人對於遊戲結構理解不同所寫出的程式碼也會不同。但無論怎樣,我們都應該遵循一個目標:儘量遵循程式碼規範,養成良好的風格。這樣不僅利於別人閱讀你的程式碼,也利於自己排查 bug、增加新的功能。

最後,感謝您的閱讀。這裡是 HelloGitHub 分享 GitHub 上有趣、入門級的開源專案。您的每個點贊、留言、分享都是對我們最大的鼓勵!


關注 HelloGitHub 公眾號 第一時間收到更新。

還有更多開源專案的介紹和寶藏專案等待你的發現。

相關文章