В этой статье мы напишем классическую «Змейку» на Python с помощью инструмента для создания GUI Kivy.
Знакомимся с Kivy
Kivy — это популярный инструмент для создания пользовательских интерфейсов, который отлично подходит для разработки приложений и несложных игр. Одним из основных достоинств Kivy является портируемость — возможность безболезненного переноса ваших проектов с одной платформы на другую. Наша «Змейка» будет работать на платформе Android.
Kivy эффективно использует Cython — язык программирования, сочетающий в себе оптимизированность C++ и синтаксис Python — что положительно сказывается на производительности. Также Kivy активно использует GPU для графических процессов, освобождая CPU для других вычислений.
Прим. перев. Код проверен на Ubuntu 16.04, Cython 0.25, Pygame 1.9.4.dev0, Buildozer 0.33, Kyvi 1.10.
Для правильной работы Kivy нам требуется три основных пакета: Cython, pygame и python-dev. Если вы используете Ubuntu, вам также может понадобиться библиотека gstreamer, которая используется для поддержки некоторых видеовозможностей фреймворка.
Прежде чем начать писать нашу «Змейку», давайте проверим, правильно ли у нас все установилось. Иначе в дальнейшем может обнаружиться, что проект не компилируется из-за какого-нибудь недостающего пакета.
Для проверки напишем старый добрый «Hello, world!».
Приступим к созданию проекта. Нужно перейти в рабочую папку и выполнить команду:
buildozer init
Теперь откроем файл с расширением .spec в любом текстовом редакторе и изменим следующие строки:
имя нашего приложения title = Hello World;
название пакета package.name = helloworldapp;
домен пакета (нужен для android/ios сборки) package.domain = org.helloworldapp;
закомментируйте эти строки, если они ещё не закомментированы:
строка version = 1.0.0 должна быть раскомментированной.
Создайте файл main.py и добавьте в него следующий код:
import kivy
kivy.require('1.8.0') # Ваша версия может отличаться
from kivy.app import App
from kivy.uix.button import Button
class DummyApp(App):
def build(self):
return Button(text="Hello World")
if __name__ == '__main__':
DummyApp().run()
Теперь все готово к сборке. Вернемся к терминалу.
buildozer android debug # Эта команда создает apk файл в папке ./bin
buildozer android debug deploy # Если вы хотите установить apk непосредственно на ваше устройство
Примечание В случае возникновения каких-либо ошибок установите значение log_level = 2 в файле buildozer.spec. Это даст более развернутое описание ошибки. Теперь мы точно готовы приступить к написанию «Змейки».
Прим. перев. Обратите внимание, что в проекте используется один файл и в статье разбираются лишь куски кода из этого файла. Весь исходный код вы можете просмотреть, перейдя по ссылке на страницу проекта в gitHub.
Цели
В этой части урока мы напишем игровой движок нашей «Змейки». И под созданием игрового движка подразумевается:
1. Написание классов, которые станут скелетом нашего приложения. 2. Предоставление им правильных методов и свойств, чтобы мы могли управлять ими по своему усмотрению. 3. Соединение всего в основном цикле приложения.
Классы
Теперь давайте разберем нашу игру на составные элементы: змея и игровое поле. Змея состоит из двух основных элементов: головы и хвоста. И надо не забыть, что змее нужно что-то есть.
Таким образом нам потребуется организовать следующую иерархию виджетов:
Игровое поле (Playground)
Фрукты (Fruit)
Змея (Snake)
Голова (SnakeHead)
Хвост (SnakeTail)
Мы объявим наши классы в файлах main.py и snake.kv, чтобы отделить дизайн от логики:
import kivy
kivy.require('1.8.0')
# Импортируем элементы Kivy, которые будем использовать в классах
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.properties import ObjectProperty
class Playground(Widget):
# Привязываем переменным элементы из .kv
fruit = ObjectProperty(None)
snake = ObjectProperty(None)
class Fruit(Widget):
pass
class Snake(Widget):
head = ObjectProperty(None)
tail = ObjectProperty(None)
class SnakeHead(Widget):
pass
class SnakeTail(Widget):
pass
class SnakeApp(App):
def build(self):
game = Playground()
return game
if __name__ == '__main__':
SnakeApp().run()
Теперь, когда мы реализовали классы, можно задуматься о содержимом.
Playground — это корневой виджет. Мы разделим его на сетку. Эта матрица поможет позиционировать и перемещать объекты по полю. Представление каждого дочернего виджета будет занимать одну клетку. Также нужно реализовать возможность сохранения счета и изменения частоты появления фруктов.
И последнее, но не менее важное: нужно реализовать управление вводом, но сделаем мы это в следующем разделе.
class Playground(Widget):
fruit = ObjectProperty(None)
snake = ObjectProperty(None)
# Задаем размер сетки
col_number = 16
row_number = 9
# Игровые переменные
score = NumericProperty(0)
turn_counter = NumericProperty(0)
fruit_rythme = NumericProperty(0)
# Одработка входных данных
touch_start_pos = ListProperty()
action_triggered = BooleanProperty(False)
Змея
Объект змеи не должен содержать ничего, кроме двух ее деталей: головы и хвоста.
Для головы мы должны знать текущее положение и направление движения для правильного графического представления: если змея движется направо — рисуем треугольник повернутый вправо, если змея движется влево — рисуем треугольник повернутый влево.
Позиция и направление будут соответствовать определенным инструкциям рисования. Для рисования треугольника нам нужно шесть точек (две координаты на одну вершину). Эти координаты будут не ячейками сетки, а определенными пикселями на холсте
Наконец, нам надо хранить информацию об объекте, нарисованном на холсте, чтобы удалить его позже (например, для перезапуска игры). Мы добавим логическую переменную, указывающую, должен ли объект быть нарисован на холсте:
class SnakeHead(Widget):
# Направление головы и ее позиция
direction = OptionProperty(
"Right", options=["Up", "Down", "Left", "Right"])
x_position = NumericProperty(0)
y_position = NumericProperty(0)
position = ReferenceListProperty(x_position, y_position)
# Представление на холсте
points = ListProperty([0]*6)
object_on_board = ObjectProperty(None)
state = BooleanProperty(False)
Теперь хвост. Он состоит из блоков (изначально трех), занимающих одну ячейку. Когда «Змейка» будет двигаться, мы будем убирать самый последний блок хвоста и добавлять новый на предыдущую позицию головы:
class SnakeTail(Widget):
# Длинна хвоста. Измеряется в количестве блоков
size = NumericProperty(3)
# Позицию каждого блока хвоста мы будем хранить здесь
blocks_positions = ListProperty()
# Обьекты (виджеты) хвоста будут находиться в этой переменной
tail_blocks_objects = ListProperty()
Фрукт
Поведение фрукта похоже на поведение змеиной головы. Нам понадобится отслеживать его позицию, состояние и интервал появления:
class Fruit(Widget):
# Эти значения будем использовать для определения частоты появления fruit_rhythme
duration = NumericProperty(10) # Продолжительность существования
interval = NumericProperty(3) # Продолжительность отсутствия
# Представление на поле
object_on_board = ObjectProperty(None)
state = BooleanProperty(False)
В классе SnakeApp будет происходить запуск нашего приложения:
Начнем с класса змеи. Мы хотим устанавливать ее в начальное положение и получать информацию о том, где она находится и как взаимодействует с объектами поля:
class Snake(Widget):
...
def move(self):
"""
Движение змеи будет происходить в 3 этапа:
- сохранить текущее положение головы.
- передвинуть голову на одну позицию вперед.
- переместить последний блок хвоста на предыдущие координаты головы .
"""
next_tail_pos = list(self.head.position)
self.head.move()
self.tail.add_block(next_tail_pos)
def remove(self):
"""
Здесь мы опишем, удаление элементов хвоста и головы
"""
self.head.remove()
self.tail.remove()
def set_position(self, position):
self.head.position = position
def get_position(self):
"""
Положение змеи равно положению ее головы на поле.
"""
return self.head.position
def get_full_position(self):
"""
Но иногда нам нужно будет узнавать, какое пространство занимает змея.
"""
return self.head.position + self.tail.blocks_positions
def set_direction(self, direction):
self.head.direction = direction
def get_direction(self):
return self.head.direction
Мы назвали ряд методов. Теперь давайте их реализуем. Начнем с remove() и add_block():
class SnakeTail(Widget):
...
def remove(self):
# Сбрасываем счетчик длины
self.size = 3
# Удаляем каждый блок хвоста
for block in self.tail_blocks_objects:
self.canvas.remove(block)
# Обнуляем списки с координатами блоков
# и их представления на холсте
self.blocks_positions = []
self.tail_blocks_objects = []
def add_block(self, pos):
"""
Здесь действуем в 3 этапа :
- Передаем позицию нового блока как аргумент и добавляем блок в список объектов.
- Проверяем равенство длины хвоста и количества блоков и изменяем, если требуется.
- Рисуем блоки на холсте, до тех пор, пока количество нарисованных блоков не станет равно длине хвоста.
"""
# Добавляем координаты блоков в список
self.blocks_positions.append(pos)
# Делаем проверку соответствия количеству блоков змеи на холсте и переменной отражающей длину
if len(self.blocks_positions) > self.size:
self.blocks_positions.pop(0)
with self.canvas:
# Рисуем блоки используя координаты из списка
for block_pos in self.blocks_positions:
x = (block_pos[0] - 1) * self.width
y = (block_pos[1] - 1) * self.height
coord = (x, y)
block = Rectangle(pos=coord, size=(self.width, self.height))
# Добавляем новый блок к списку объектов
self.tail_blocks_objects.append(block)
# Делаем проверку длины и удаляем лишние блоки с холста, если необходимо
if len(self.tail_blocks_objects) > self.size:
last_block = self.tail_blocks_objects.pop(0)
self.canvas.remove(last_block)
Теперь работаем с головой. Она будет иметь две функции: move() и remove():
class SnakeHead(Widget):
# Представление головы на "сетке"
direction = OptionProperty(
"Right", options=["Up", "Down", "Left", "Right"])
x_position = NumericProperty(0)
y_position = NumericProperty(0)
position = ReferenceListProperty(x_position, y_position)
# Представление головы на поле
points = ListProperty([0] * 6)
object_on_board = ObjectProperty(None)
state = BooleanProperty(False)
def is_on_board(self):
return self.state
def remove(self):
if self.is_on_board():
self.canvas.remove(self.object_on_board)
self.object_on_board = ObjectProperty(None)
self.state = False
def show(self):
"""
Размещаем голову на холсте.
"""
with self.canvas:
if not self.is_on_board():
self.object_on_board = Triangle(points=self.points)
self.state = True
else:
# Если объект должен быть на поле - удаляем старую голову
# и рисуем новую
self.canvas.remove(self.object_on_board)
self.object_on_board = Triangle(points=self.points)
def move(self):
"""
Не самое элегантное решение, но это работает.
Здесь мы описываем изображение треугольника для каждого положения головы.
"""
if self.direction == "Right":
# Обновляем позицию
self.position[0] += 1
# Вычисляем положения точек
x0 = self.position[0] * self.width
y0 = (self.position[1] - 0.5) * self.height
x1 = x0 - self.width
y1 = y0 + self.height / 2
x2 = x0 - self.width
y2 = y0 - self.height / 2
elif self.direction == "Left":
self.position[0] -= 1
x0 = (self.position[0] - 1) * self.width
y0 = (self.position[1] - 0.5) * self.height
x1 = x0 + self.width
y1 = y0 - self.height / 2
x2 = x0 + self.width
y2 = y0 + self.height / 2
elif self.direction == "Up":
self.position[1] += 1
x0 = (self.position[0] - 0.5) * self.width
y0 = self.position[1] * self.height
x1 = x0 - self.width / 2
y1 = y0 - self.height
x2 = x0 + self.width / 2
y2 = y0 - self.height
elif self.direction == "Down":
self.position[1] -= 1
x0 = (self.position[0] - 0.5) * self.width
y0 = (self.position[1] - 1) * self.height
x1 = x0 + self.width / 2
y1 = y0 + self.height
x2 = x0 - self.width / 2
y2 = y0 + self.height
# Записываем положения точек
self.points = [x0, y0, x1, y1, x2, y2]
# Рисуем голову
self.show()
А что там с фруктами? Мы должны уметь помещать их в заданные координаты и удалять, когда нам это понадобится:
class Fruit(Widget):
...
def is_on_board(self):
return self.state
def remove(self, *args):
# Удаляем объект с поля и указываем, что он сейчас стерт
if self.is_on_board():
self.canvas.remove(self.object_on_board)
self.object_on_board = ObjectProperty(None)
self.state = False
def pop(self, pos):
self.pos = pos # объявляем, что фрукт находится на поле
# Рисуем фрукт
with self.canvas:
x = (pos[0] - 1) * self.size[0]
y = (pos[1] - 1) * self.size[1]
coord = (x, y)
# Сохраняем представление и обновляем состояние объекта
self.object_on_board = Ellipse(pos=coord, size=self.size)
self.state = True
Почти готово, не сдавайтесь! Теперь нужно организовать весь игровой процесс, который будет происходить в классе Playground. Рассмотрим логику игры: она начинается с того, что змея помещается в случайные координаты. Игра обновляется при каждом перемещении змеи. Во время обновлений мы проверяем направление змеи и ее положение. Если змея сталкивается сама с собой или выходит за пределы поля – мы засчитываем поражение и игра начинается сначала.
Как будет осуществляться управление? Когда игрок касается экрана, мы сохраняем координаты касания. Когда палец будет перемещаться, мы будем сравнивать новое положение с исходным. Если позиция будет изменена на 10 % от размера экрана, мы будем определять это как инструкцию и обрабатывать ее:
class Playground(Widget):
...
def start(self):
# Добавляем змею на поле
self.new_snake()
# Начинаем основной цикл обновления игры
self.update()
def reset(self):
# Сбрасываем игровые переменные
self.turn_counter = 0
self.score = 0
# Удаляем образы змеи и фрукта с поля
self.snake.remove()
self.fruit.remove()
def new_snake(self):
# Генерируем случайные координаты
start_coord = (
randint(2, self.col_number - 2), randint(2, self.row_number - 2))
# Устанавливаем для змеи новые координаты
self.snake.set_position(start_coord)
# Генерируем случайное направление
rand_index = randint(0, 3)
start_direction = ["Up", "Down", "Left", "Right"][rand_index]
# Задаем змее случайное направление
self.snake.set_direction(start_direction)
def pop_fruit(self, *args):
# Генерируем случайные координаты для фрукта
random_coord = [
randint(1, self.col_number), randint(1, self.row_number)]
# получаем координаты всех клеток занимаемых змеей
snake_space = self.snake.get_full_position()
# Если координаты фрукта совпадают с координатами клеток змеи - генерируем
# новые координаты
while random_coord in snake_space:
random_coord = [
randint(1, self.col_number), randint(1, self.row_number)]
# Помещаем образ фрукта на поле
self.fruit.pop(random_coord)
def is_defeated(self):
"""
Проверяем, является ли позиция змеи проигрышной.
"""
snake_position = self.snake.get_position()
# Если змея кусает свой хвост - поражение
if snake_position in self.snake.tail.blocks_positions:
return True
# Если вышла за пределы поля - поражение
if snake_position[0] > self.col_number \
or snake_position[0] < 1 \
or snake_position[1] > self.row_number \
or snake_position[1] < 1:
return True
return False
def update(self, *args):
"""
Используется для смены игровых ходов.
"""
# Перемещаем змею на следующую позицию
self.snake.move()
# Проверяем на поражение
# Если поражение - сбрасываем игру
if self.is_defeated():
self.reset()
self.start()
return
# Проверяем, находится ли фрукт на поле
if self.fruit.is_on_board():
# Если змея съела фрукт - увеличиваем счет и длину змеи
if self.snake.get_position() == self.fruit.pos:
self.fruit.remove()
self.score += 1
self.snake.tail.size += 1
# Увеличиваем счетчик ходов
self.turn_counter += 1
def on_touch_down(self, touch):
self.touch_start_pos = touch.spos
def on_touch_move(self, touch):
# Вычисляем изменение позиции пальца
delta = Vector(*touch.spos) - Vector(*self.touch_start_pos)
# Проверяем, изменение > 10% от размера экрана:
if not self.action_triggered \
and (abs(delta[0]) > 0.1 or abs(delta[1]) > 0.1):
# Если да, задаем змее подходящее направление
if abs(delta[0]) > abs(delta[1]):
if delta[0] > 0:
self.snake.set_direction("Right")
else:
self.snake.set_direction("Left")
else:
if delta[1] > 0:
self.snake.set_direction("Up")
else:
self.snake.set_direction("Down")
# Здесь мы регистрируем, что действие закончено, для того, чтобы оно не # происходило более одного раза за ход
self.action_triggered = True
def on_touch_up(self, touch):
# Указываем, что мы готовы принять новые инструкции
self.action_triggered = False
Основной цикл
Здесь происходят процессы, устанавливающие положение фрукта, управляющие движением змеи и определяющие проигрыш:
def update(self, *args):
"""
Используется для смены игровых ходов.
"""
# Регистрация последовательности появления фруктов в планировщике событий
if self.turn_counter == 0:
self.fruit_rythme = self.fruit.interval + self.fruit.duration
Clock.schedule_interval(
self.fruit.remove, self.fruit_rythme)
elif self.turn_counter == self.fruit.interval:
self.pop_fruit()
Clock.schedule_interval(
self.pop_fruit, self.fruit_rythme)
...
# Каждое обновление будет происходить ежесекундно (1'')
Clock.schedule_once(self.update, 1)
Нужно добавить обработчик для события сброса игры:
Одна важная деталь. Чтобы приложение запустилось с правильным разрешением экрана, нужно сделать так:
class SnakeApp(App):
game_engine = ObjectProperty(None)
def on_start(self):
self.game_engine.start()
...
И вуаля! Теперь вы можете запустить приложение. Остается только упаковать его с помощью buildozer и загрузить на устройство.
Создаем экраны
В приложении будет два экрана: приветствия и игровой. Также будет всплывающее меню настроек. Сначала мы сделаем макеты наших виджетов в .kv-файле, а потом напишем соответствующие классы Python.
На экране приветствия требуется только метод show_popup(), который будет вызываться при нажатии кнопки настроек на главном экране. Нам не нужно определять что-либо еще для кнопки включения игры, потому что она будет использовать способность своего родителя обращаться к диспетчеру экрана:
class WelcomeScreen(Screen):
options_popup = ObjectProperty(None)
def show_popup(self):
# Создаем экземпляр всплывающего окна и отображаем на экране
self.options_popup = OptionsPopup()
self.options_popup.open()
Теперь нужно сделать так, чтоб экран приветствия показывался при запуске игры, а игра начиналась только тогда, когда будет показано игровое поле:
class PlaygroundScreen(Screen):
game_engine = ObjectProperty(None)
def on_enter(self):
# Показываем экран и начинаем игру
self.game_engine.start()
Также нам нужно подготовить класс окна настроек, иначе привязка будет работать не правильно:
class OptionsPopup(Popup):
pass
Теперь добавим ScreenManager в приложение и зарегистрируем два экрана:
class SnakeApp(App):
screen_manager = ObjectProperty(None)
def build(self):
# Объявление SkreenManager как свойства класса
SnakeApp.screen_manager = ScreenManager()
# Создание экземплров экранов
ws = WelcomeScreen(name="welcome_screen")
ps = PlaygroundScreen(name="playground_screen")
# Регистрация экранов в SkreenManager
self.screen_manager.add_widget(ws)
self.screen_manager.add_widget(ps)
return self.screen_manager
Теперь нужно сказать кнопкам, что делать, когда на них нажимают:
После проигрыша нужно возвращаться обратно на экран приветствия:
class Playground(Widget):
...
def update(self, *args):
...
# Проверяем проигрыш
# Если это произошло,
# показываем экран приветствия
if self.is_defeated():
self.reset()
SnakeApp.screen_manager.current = "welcome_screen"
return
…
Добавляем настройки
У нас будет всего два параметра:
Включение/отключение границ. Если границы включены, при выхождении змеи за пределы экрана засчитывается проигрыш. Если границы выключены, змея будет появляться на другой стороне, если выходит за пределы.
Скорость змеи.
Добавляем необходимые виджеты во всплывающее окно:
Теперь подготовим классы, чтобы они могли изменяться вместе с настройками. Если границы включены, будем рисовать очертание игрового поля. Также добавим возможность изменения частоты обновления:
class Playground(Widget):
...
# Пользовательские настройки
start_speed = NumericProperty(1)
border_option = BooleanProperty(False)
...
#Игровые переменные
...
start_time_coeff = NumericProperty(1)
running_time_coeff = NumericProperty(1)
...
def start(self):
# Если границы включены, рисуем прямоугольник вокруг поля
if self.border_option:
with self.canvas.before:
Line(width=3.,
rectangle=(self.x, self.y, self.width, self.height))
# Вычисляем коэффициент изменения частоты обновления игры
# (по умолчанию 1.1, максимальный 2)
self.start_time_coeff += (self.start_speed / 10)
self.running_time_coeff = self.start_time_coeff
...
def reset(self):
# Сбрасываем игровые переменные
...
self.running_time_coeff = self.start_time_coeff
...
def is_defeated(self):
...
# Если змея вышла за границы, которые были включены -- поражение
if self.border_option:
if snake_position[0] > self.col_number \
or snake_position[0] < 1 \
or snake_position[1] > self.row_number \
or snake_position[1] < 1:
return True
return False
def handle_outbound(self):
"""
Используется для перемещения змеи на противоположную сторону
(только если границы выключены)
"""
position = self.snake.get_position()
direction = self.snake.get_direction()
if position[0] == 1 and direction == "Left":
self.snake.tail.add_block(list(position))
self.snake.set_position([self.col_number + 1, position[1]])
elif position[0] == self.col_number and direction == "Right":
self.snake.tail.add_block(list(position))
self.snake.set_position([0, position[1]])
elif position[1] == 1 and direction == "Down":
self.snake.tail.add_block(list(position))
self.snake.set_position([position[0], self.row_number + 1])
elif position[1] == self.row_number and direction == "Up":
self.snake.tail.add_block(list(position))
self.snake.set_position([position[0], 0])
def update(self, *args):
...
# Изменяем частоту появления фрукта
if self.turn_counter == 0:
...
Clock.schedule_interval(
self.fruit.remove, self.fruit_rythme / self.running_time_coeff)
elif self.turn_counter == self.fruit.interval:
...
Clock.schedule_interval(
self.pop_fruit, self.fruit_rythme / self.running_time_coeff)
# Проверяем, пересечение змеей границ
# если пересекает -- переносим на противоположную сторону
if not self.border_option:
self.handle_outbound()
...
# Проверяем готовность фрукта
if self.fruit.is_on_board():
if self.snake.get_position() == self.fruit.pos:
...
self.running_time_coeff *= 1.05
...
Clock.schedule_once(self.update, 1 / self.running_time_coeff)
Изменим всплывающее окно так, чтобы оно могло передавать значения: