手把手教你使用ESP32+MicroPython制作贪吃蛇游戏
实现目标
在ESP32开发板上使用MicroPython编程实现一个贪吃蛇小游戏,游戏可以在ssd1306 OLED屏幕上游玩,使用四个按钮开关控制蛇的上下左右移动。
既然是手把手,就是让你不了解相关知识也能跟着流程运行起我们的项目,通过在线的仿真原件在线试玩。
项目所用工具介绍
- ESP32开发板:上海乐鑫出品的MCU,自带wifi和蓝牙,功能和配置可以说非常良心。如果你有开发板的话,相信你对如何在上面跑程序非常了解了。如果没有开发板那我们也可以使用在线仿真网站Wokwi运行项目。效果和使用开发板是相同的。(接下来的示例就是在仿真网站运行的)
- MicroPython:为微控制器设计的运行语言,语法和PC端运行的CPython几乎完全一致,不过库的功能可能有所削减。MicroPython让微控制器可以直接运行python这样的解释型语言,避免了使用C/C++等编译型语言开发时的编译、链接和上传步骤。我们只需要把要用的脚本放到目录下,将主程序所在的脚本命名为main.py就可以自动运行程序了。
- SSD1306 OLED显示屏,用来显示游戏界面。
- 按键开关4个:用来控制上下左右四个方向,控制蛇的移动。
操作步骤(使用在线仿真网站Wokwi演示)
- 进入Wokwi网站,在开发板处选择MicroPython with ESP32,进入项目开发页面:
- (不想动手连线的直接跳到第4步)在右侧的模拟器中添加我们使用的元器件,点击“+”,选择1个“SSD1306 OLED display”和4个“PushButton”:
- 添加元件后,按照下图连接引脚,连接时使用鼠标点击对应的引脚和想要连接的引脚就可以连线,点击导线还可以改变导线的颜色:
- 如果不想自己连线或怕连线出错,可以直接复制下面的代码,粘贴到左侧编辑窗口上方的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" ] ]
]
}
粘贴后,右侧模拟器窗口就会自动显示连好线的电路图。
- 添加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)
- 最后,添加我们的主程序: 在编辑栏中,选择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)
如果想了解代码的详细内容,可以自己阅读,代码都添加了注释,内容不算复杂,有Python基础的同学应该都可以看懂。如果有什么问题,欢迎下方评论区交流😁。
- OK,现在我们的项目就准备完成了,接下来,点击上方的“save”按钮保存程序,然后点击右侧模拟器的运行按钮,就可以运行我们的贪吃蛇小游戏了!
动图演示: