手把手教你使用ESP32+MicroPython制作贪吃蛇游戏

实现目标

在ESP32开发板上使用MicroPython编程实现一个贪吃蛇小游戏,游戏可以在ssd1306 OLED屏幕上游玩,使用四个按钮开关控制蛇的上下左右移动。

既然是手把手,就是让你不了解相关知识也能跟着流程运行起我们的项目,通过在线的仿真原件在线试玩。

项目所用工具介绍

  • ESP32开发板:上海乐鑫出品的MCU,自带wifi和蓝牙,功能和配置可以说非常良心。如果你有开发板的话,相信你对如何在上面跑程序非常了解了。如果没有开发板那我们也可以使用在线仿真网站Wokwi运行项目。效果和使用开发板是相同的。(接下来的示例就是在仿真网站运行的)
  • MicroPython:为微控制器设计的运行语言,语法和PC端运行的CPython几乎完全一致,不过库的功能可能有所削减。MicroPython让微控制器可以直接运行python这样的解释型语言,避免了使用C/C++等编译型语言开发时的编译、链接和上传步骤。我们只需要把要用的脚本放到目录下,将主程序所在的脚本命名为main.py就可以自动运行程序了。
  • SSD1306 OLED显示屏,用来显示游戏界面。
  • 按键开关4个:用来控制上下左右四个方向,控制蛇的移动。

操作步骤(使用在线仿真网站Wokwi演示)

  1. 进入Wokwi网站,在开发板处选择MicroPython with ESP32,进入项目开发页面:
  2. esp32开源游戏机 esp32做游戏机_python

  3. (不想动手连线的直接跳到第4步)在右侧的模拟器中添加我们使用的元器件,点击“+”,选择1个“SSD1306 OLED display”和4个“PushButton”:
  4. esp32开源游戏机 esp32做游戏机_嵌入式硬件_02

  5. esp32开源游戏机 esp32做游戏机_python_03

  6. 添加元件后,按照下图连接引脚,连接时使用鼠标点击对应的引脚和想要连接的引脚就可以连线,点击导线还可以改变导线的颜色:
  7. esp32开源游戏机 esp32做游戏机_python_04

  8. 如果不想自己连线或怕连线出错,可以直接复制下面的代码,粘贴到左侧编辑窗口上方的diagram.json文件中替换原来的内容(使用该方法就不必执行2,3步骤了):
{
  "version": 1,
  "author": "Besharp",
  "editor": "wokwi",
  "parts": [
    { "type": "wokwi-esp32-devkit-v1", "id": "esp", "top": -194.67, "left": -74, "attrs": {} },
    {
      "type": "wokwi-pushbutton",
      "id": "btn1",
      "top": 170.61,
      "left": -52.71,
      "attrs": { "color": "green" }
    },
    {
      "type": "wokwi-pushbutton",
      "id": "btn2",
      "top": 303.75,
      "left": -56.56,
      "attrs": { "color": "green" }
    },
    {
      "type": "wokwi-pushbutton",
      "id": "btn3",
      "top": 232.46,
      "left": -139.65,
      "attrs": { "color": "red" }
    },
    {
      "type": "wokwi-pushbutton",
      "id": "btn4",
      "top": 236.22,
      "left": 39.83,
      "attrs": { "color": "red" }
    },
    { "type": "board-ssd1306", "id": "oled1", "top": 55.97, "left": -73.5, "attrs": {} }
  ],
  "connections": [
    [ "esp:TX0", "$serialMonitor:RX", "", [] ],
    [ "esp:RX0", "$serialMonitor:TX", "", [] ],
    [ "oled1:GND", "esp:GND.1", "black", [ "v0" ] ],
    [ "oled1:VCC", "esp:3V3", "red", [ "v0" ] ],
    [ "oled1:SCL", "esp:D18", "gold", [ "v0" ] ],
    [ "oled1:SDA", "esp:D19", "gold", [ "v0" ] ],
    [ "btn1:1.r", "esp:D15", "green", [ "v-0.84", "h32.39", "v-232.09" ] ],
    [ "btn2:1.r", "esp:D2", "green", [ "v0.62", "h133.1", "v-381.78" ] ],
    [ "btn3:1.r", "esp:D4", "green", [ "v-74.01", "h125.62", "v3.77" ] ],
    [ "btn4:1.r", "esp:D5", "green", [ "v0" ] ],
    [ "btn1:2.l", "esp:GND.2", "black", [ "h-51.79", "v-249.86" ] ],
    [ "btn4:2.l", "esp:GND.2", "black", [ "h-0.3", "v18.51", "h-207.56", "v-335.24" ] ],
    [ "btn3:2.l", "esp:GND.2", "black", [ "h-10.8", "v-41.22" ] ],
    [ "btn2:2.l", "esp:GND.2", "black", [ "h-100.39", "v-18.02" ] ]
  ]
}

esp32开源游戏机 esp32做游戏机_esp32开源游戏机_05

粘贴后,右侧模拟器窗口就会自动显示连好线的电路图。

  1. 添加ssd1306的库依赖: 和python程序一样MicroPython为各种硬件提供了方便用户使用的库,用户直接调用其中的方法就可以完成对硬件的操作,而不必去研究复杂的底层硬件操作。 这里我们需要为ssd1306 OLED显示器添加它的库文件,点击编辑栏上方的下拉按钮,选择“new file”创建一个新文件,命名为ssd1306.py: 然后将以下内容复制粘贴到文件中:
# MicroPython SSD1306 OLED driver, I2C and SPI interfaces

from micropython import const
import framebuf


# register definitions
SET_CONTRAST = const(0x81)
SET_ENTIRE_ON = const(0xA4)
SET_NORM_INV = const(0xA6)
SET_DISP = const(0xAE)
SET_MEM_ADDR = const(0x20)
SET_COL_ADDR = const(0x21)
SET_PAGE_ADDR = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP = const(0xA0)
SET_MUX_RATIO = const(0xA8)
SET_IREF_SELECT = const(0xAD)
SET_COM_OUT_DIR = const(0xC0)
SET_DISP_OFFSET = const(0xD3)
SET_COM_PIN_CFG = const(0xDA)
SET_DISP_CLK_DIV = const(0xD5)
SET_PRECHARGE = const(0xD9)
SET_VCOM_DESEL = const(0xDB)
SET_CHARGE_PUMP = const(0x8D)

# Subclassing FrameBuffer provides support for graphics primitives
# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html
class SSD1306(framebuf.FrameBuffer):
    def __init__(self, width, height, external_vcc):
        self.width = width
        self.height = height
        self.external_vcc = external_vcc
        self.pages = self.height // 8
        self.buffer = bytearray(self.pages * self.width)
        super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
        self.init_display()

    def init_display(self):
        for cmd in (
                SET_DISP,  # display off
                # address setting
                SET_MEM_ADDR,
                0x00,  # horizontal
                # resolution and layout
                SET_DISP_START_LINE,  # start at line 0
                SET_SEG_REMAP | 0x01,  # column addr 127 mapped to SEG0
                SET_MUX_RATIO,
                self.height - 1,
                SET_COM_OUT_DIR | 0x08,  # scan from COM[N] to COM0
                SET_DISP_OFFSET,
                0x00,
                SET_COM_PIN_CFG,
                0x02 if self.width > 2 * self.height else 0x12,
                # timing and driving scheme
                SET_DISP_CLK_DIV,
                0x80,
                SET_PRECHARGE,
                0x22 if self.external_vcc else 0xF1,
                SET_VCOM_DESEL,
                0x30,  # 0.83*Vcc
                # display
                SET_CONTRAST,
                0xFF,  # maximum
                SET_ENTIRE_ON,  # output follows RAM contents
                SET_NORM_INV,  # not inverted
                SET_IREF_SELECT,
                0x30,  # enable internal IREF during display on
                # charge pump
                SET_CHARGE_PUMP,
                0x10 if self.external_vcc else 0x14,
                SET_DISP | 0x01,  # display on
        ):  # on
            self.write_cmd(cmd)
        self.fill(0)
        self.show()

    def poweroff(self):
        self.write_cmd(SET_DISP)

    def poweron(self):
        self.write_cmd(SET_DISP | 0x01)

    def contrast(self, contrast):
        self.write_cmd(SET_CONTRAST)
        self.write_cmd(contrast)

    def invert(self, invert):
        self.write_cmd(SET_NORM_INV | (invert & 1))

    def rotate(self, rotate):
        self.write_cmd(SET_COM_OUT_DIR | ((rotate & 1) << 3))
        self.write_cmd(SET_SEG_REMAP | (rotate & 1))

    def show(self):
        x0 = 0
        x1 = self.width - 1
        if self.width != 128:
            # narrow displays use centred columns
            col_offset = (128 - self.width) // 2
            x0 += col_offset
            x1 += col_offset
        self.write_cmd(SET_COL_ADDR)
        self.write_cmd(x0)
        self.write_cmd(x1)
        self.write_cmd(SET_PAGE_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.pages - 1)
        self.write_data(self.buffer)


class SSD1306_I2C(SSD1306):
    def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
        self.i2c = i2c
        self.addr = addr
        self.temp = bytearray(2)
        self.write_list = [b"\x40", None]  # Co=0, D/C#=1
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.temp[0] = 0x80  # Co=1, D/C#=0
        self.temp[1] = cmd
        self.i2c.writeto(self.addr, self.temp)

    def write_data(self, buf):
        self.write_list[1] = buf
        self.i2c.writevto(self.addr, self.write_list)


class SSD1306_SPI(SSD1306):
    def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
        self.rate = 10 * 1024 * 1024
        dc.init(dc.OUT, value=0)
        res.init(res.OUT, value=0)
        cs.init(cs.OUT, value=1)
        self.spi = spi
        self.dc = dc
        self.res = res
        self.cs = cs
        import time

        self.res(1)
        time.sleep_ms(1)
        self.res(0)
        time.sleep_ms(10)
        self.res(1)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(0)
        self.cs(0)
        self.spi.write(bytearray([cmd]))
        self.cs(1)

    def write_data(self, buf):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(1)
        self.cs(0)
        self.spi.write(buf)
        self.cs(1)

esp32开源游戏机 esp32做游戏机_mcu_06

  1. 最后,添加我们的主程序: 在编辑栏中,选择main.py文件,填入我们的主程序:
"""
显示在ssd1306上的贪吃蛇游戏
运行后,可以通过按任意按钮启动游戏。
默认情况下,蛇初始时从左到右移动。游戏的目的是收集尽可能多的水果,水果将随机放置。
随着每吃一个水果,蛇就会变得更长。
当蛇撞到墙上或它自己时,游戏结束,显示Gameover。
此时可通过按任意按钮,将游戏还原为起始值,然后触摸按钮即可再次启动游戏。
"""
import random
import time

from machine import Pin, I2C

import ssd1306

SCREEN_WIDTH = 128
SCREEN_HEIGHT = 64

# 上下左右引脚, 通过上拉电阻设为高电平
UP_PIN = Pin(15, Pin.IN, Pin.PULL_UP)
DOWN_PIN = Pin(2, Pin.IN, Pin.PULL_UP)
LEFT_PIN = Pin(4, Pin.IN, Pin.PULL_UP)
RIGHT_PIN = Pin(5, Pin.IN, Pin.PULL_UP)

# snake config
SNAKE_PIECE_SIZE = 3  # 蛇的每一格占用3*3个像素
MAX_SNAKE_LENGTH = 150  # 蛇的最长长度
MAP_SIZE_X = 20  # 活动范围
MAP_SIZE_Y = 20
START_SNAKE_SIZE = 5  # 初始长度
SNAKE_MOVE_DELAY = 30  # 移动延时


# game config
class State(object):
    START = 0
    RUNNING = 1
    GAMEOVER = 2

    @classmethod
    def setter(cls, state):
        if state == cls.START:
            return cls.START
        elif state == cls.RUNNING:
            return cls.RUNNING
        elif state == cls.GAMEOVER:
            return cls.GAMEOVER


class Direction(object):
    # 注意顺序
    UP = 0
    LEFT = 1
    DOWN = 2
    RIGHT = 3

    @classmethod
    def setter(cls, dirc):
        if dirc == cls.UP:
            return cls.UP
        elif dirc == cls.DOWN:
            return cls.DOWN
        elif dirc == cls.LEFT:
            return cls.LEFT
        elif dirc == cls.RIGHT:
            return cls.RIGHT


i2c = I2C(0)
screen = ssd1306.SSD1306_I2C(SCREEN_WIDTH, SCREEN_HEIGHT, I2C(0))


################ Snake 功能实现 ###################
class Snake(object):
    def __init__(self):
        self.snake = []  # 初始位置[(x1,y1),(x2,y2),...]一个元组列表
        self.fruit = []  # 水果,[x,y]
        self.snake_length = START_SNAKE_SIZE
        self.direction = Direction.RIGHT  # 当前前进方向
        self.new_direction = Direction.RIGHT  # 用户按键后的前进方向
        self.game_state = None
        self.display = screen
        self.setup_game()

    def setup_game(self):
        """初始化游戏"""
        self.game_state = State.START
        direction = Direction.RIGHT
        new_direction = Direction.RIGHT
        self.reset_snake()
        self.generate_fruit()
        self.display.fill(0)
        self.draw_map()
        self.show_score()
        self.show_press_to_start()
        self.display.show()

    def reset_snake(self):
        """重设蛇的位置"""
        self.snake = []  # 重置
        self.snake_length = START_SNAKE_SIZE
        for i in range(self.snake_length):
            self.snake.append((MAP_SIZE_X // 2 - i, MAP_SIZE_Y // 2))

    def check_fruit(self):
        """检测蛇是否吃到水果,能否继续吃水果"""
        if self.snake[0][0] == self.fruit[0] and self.snake[0][1] == self.fruit[1]:
            if self.snake_length + 1 < MAX_SNAKE_LENGTH:
                self.snake_length += 1
                # 吃到水果后,将蛇增加一格
                self.snake.insert(0, (self.fruit[0], self.fruit[1]))
            self.generate_fruit()

    def generate_fruit(self):
        """随机生成水果位置,注意不能生成在蛇身上"""
        while True:
            self.fruit = [random.randint(1, MAP_SIZE_X - 1), random.randint(1, MAP_SIZE_Y - 1)]
            fruit = tuple(self.fruit)
            if fruit in self.snake:
                # 生成在蛇身上
                continue
            else:
                print('fruit: ', self.fruit)
                break

    @staticmethod
    def button_press():
        """是否有按键按下"""
        for pin in UP_PIN, DOWN_PIN, LEFT_PIN, RIGHT_PIN:
            if pin.value() == 0:  # 低电平表示按下
                return True
        return False

    def read_direction(self):
        """读取新的按键方向,不能与当前方向相反"""
        for direction, pin in enumerate((UP_PIN, LEFT_PIN, DOWN_PIN, RIGHT_PIN)):
            if pin.value() == 0 and not (direction == (self.direction + 2) % 4):
                self.new_direction = Direction.setter(direction)
                return

    def collection_check(self, x, y):
        """检查蛇社否撞到墙或者(x,y)位置"""
        for i in self.snake:
            if x == i[0] and y == i[1]:
                return True
        if x < 0 or y < 0 or x >= MAP_SIZE_X or y >= MAP_SIZE_Y:
            return True
        return False

    def move_snake(self):
        """按照方向键移动蛇,返回能否继续移动的布尔值"""
        x, y = self.snake[0]
        new_x, new_y = x, y

        if self.direction == Direction.UP:
            new_y -= 1
        elif self.direction == Direction.DOWN:
            new_y += 1
        elif self.direction == Direction.LEFT:
            new_x -= 1
        elif self.direction == Direction.RIGHT:
            new_x += 1

        if self.collection_check(new_x, new_y):  # 不能继续移动
            return False

        self.snake.pop()  # 去除最后一个位置
        self.snake.insert(0, (new_x, new_y))  # 在开头添加新位置
        return True  # 能继续移动

    def draw_map(self):
        """绘制地图区域: 蛇、水果、边界"""
        offset_map_x = SCREEN_WIDTH - SNAKE_PIECE_SIZE * MAP_SIZE_X - 2
        offset_map_y = 2

        # 绘制水果
        self.display.rect(self.fruit[0] * SNAKE_PIECE_SIZE + offset_map_x,
                          self.fruit[1] * SNAKE_PIECE_SIZE + offset_map_y,
                          SNAKE_PIECE_SIZE, SNAKE_PIECE_SIZE, 1)
        # 绘制地图边界, 边界占一个像素,但是绘制时在内侧留一个像素,当蛇头部到达内部一个像素时,即判定为碰撞
        self.display.rect(offset_map_x - 2,
                          0,
                          SNAKE_PIECE_SIZE * MAP_SIZE_X + 4,
                          SNAKE_PIECE_SIZE * MAP_SIZE_Y + 4, 1)
        # 绘制蛇
        for x, y in self.snake:
            self.display.fill_rect(x * SNAKE_PIECE_SIZE + offset_map_x,
                                   y * SNAKE_PIECE_SIZE + offset_map_y,
                                   SNAKE_PIECE_SIZE,
                                   SNAKE_PIECE_SIZE, 1)

    def show_score(self):
        """显示得分"""
        score = self.snake_length - START_SNAKE_SIZE
        self.display.text('Score:%d' % score, 0, 2, 1)

    def show_press_to_start(self):
        """提示按任意键开始游戏"""
        self.display.text('Press', 0, 16, 1)
        self.display.text('button', 0, 26, 1)
        self.display.text('start!', 0, 36, 1)

    def show_game_over(self):
        """显示游戏结束"""
        self.display.text('Game', 0, 30, 1)
        self.display.text('Over!', 0, 40, 1)


#################  循环运行程序  ##################
if __name__ == '__main__':
    # print('******** Start ********')
    snake = Snake()
    move_time = 0
    while True:
        if snake.game_state == State.START:
            if Snake.button_press():
                snake.game_state = State.RUNNING

        elif snake.game_state == State.RUNNING:
            move_time += 1
            snake.read_direction()
            if move_time >= SNAKE_MOVE_DELAY:
                snake.direction = snake.new_direction
                snake.display.fill(0)
                if not snake.move_snake():
                    snake.game_state = State.GAMEOVER
                    snake.show_game_over()
                    time.sleep(1)
                snake.draw_map()
                snake.show_score()
                snake.display.show()
                snake.check_fruit()
                move_time = 0

        elif snake.game_state == State.GAMEOVER:
            if Snake.button_press():
                time.sleep_ms(500)
                snake.setup_game()
                print('******** new game ********')
                snake.game_state = State.START

        time.sleep_ms(20)

esp32开源游戏机 esp32做游戏机_嵌入式硬件_07

如果想了解代码的详细内容,可以自己阅读,代码都添加了注释,内容不算复杂,有Python基础的同学应该都可以看懂。如果有什么问题,欢迎下方评论区交流😁。

  1. OK,现在我们的项目就准备完成了,接下来,点击上方的“save”按钮保存程序,然后点击右侧模拟器的运行按钮,就可以运行我们的贪吃蛇小游戏了!

动图演示:

esp32开源游戏机 esp32做游戏机_嵌入式硬件_08