Перейти к основному содержимому

Kivy — Snake

Разработчику Начальный уровень

О практикуме

Snake (змейка) — классика аркад: змейка движется по сетке, съедает еду, растёт; столкновение со стеной или собой — конец игры. Поле 16×24 клетки; управление — свайп по полю и D-pad (кнопки-стрелки) внизу — как в реальных мобильных портах.

Чему учит практикум

  • дискретный тик — змейка шагает по клеткам, не по пикселям (в отличие от Pong);
  • два канала ввода — жест и виртуальные кнопки (Мобильные игры);
  • ускорение — интервал Clock уменьшается с ростом счёта;
  • отрисовка на canvas без отдельного спрайта на клетку — один Widget рисует всю сетку.

Краткий однофайловый вариант на Pygame — Lab — змейка. Здесь акцент на мобильный UX и Kivy API.

Оценка времени — 2–3 часа.

Карта этапов

ЭтапФокусРезультат
0КаркасЭкран с кнопкой Start
1КонстантыDIRECTIONS, цвета, запрет разворота
2Отрисовка сеткиSnakeBoard._redraw
3Движение_tick, список snake
4Еда_spawn_food, рост
5Game overСтены и самопересечение
6Свайпset_direction
7D-pad и UIGameScreen, счёт, статус
8РевизияУскорение с ростом счёта
Два канала ввода

Свайп по полю и D-pad внизу — типичный мобильный паттерн. Тот же свайп, что в 2048; дискретный тик по сетке — как в Lab — змейка на Pygame.


Как проходить практикум

  1. Папка kivysnake/, venv, pip install "kivy>=2.3.0".
  2. Этапы 0–8 по порядку; после каждого — python main.py.
  3. На этапе 7 собирается полный GameScreen — до этого можно тестировать SnakeBoard отдельно.
  4. Финал — этап 8.

Архитектура

Змейка сочетает дискретную сетку (как 2048) и тик по таймеру (как Pong):

SnakeApp
└── GameScreen (BoxLayout)
├── score_label, status_label
├── SnakeBoard (Widget) ← canvas: сетка, snake[], food
└── D-pad GridLayout + Start / Restart
Структура данныхСмысл
snakeСписок (x, y); голова — snake[0]
foodОдна свободная клетка (x, y)
direction / next_directionТекущий ход и буфер ввода между тиками
tick_eventClock.schedule_interval; интервал уменьшается с очками

Определение. Дискретный тик — за один вызов _tick змейка сдвигается ровно на одну клетку. Плавная анимация между клетками здесь не нужна — так проще логика и отладка.

Зависимости

mkdir kivysnake && cd kivysnake
python -m venv .venv
pip install "kivy>=2.3.0"

Один main.py. Поле 16×24 клетки, cell_size = dp(18) — подбирается под вертикальный экран телефона (мобильные игры).


Этап 0 — каркас

Цель. Вертикальный layout — задел под заголовок, поле и панель управления.

Теория

На этапе 0 только каркас GameScreen: позже сюда добавятся SnakeBoard (центр, size_hint=(1,1)) и D-pad (низ). Так проще отлаживать отрисовку и тик по отдельности — см. отладку.

mkdir kivysnake && cd kivysnake
pip install "kivy>=2.3.0"
from kivy.app import App
from kivy.metrics import dp
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.label import Label


class GameScreen(BoxLayout):
def __init__(self, **kwargs):
super().__init__(orientation="vertical", padding=dp(8), spacing=dp(8), **kwargs)
self.add_widget(Label(text="Snake", font_size=dp(24)))
self.add_widget(Button(text="Start"))


class SnakeApp(App):
def build(self):
return GameScreen()


if __name__ == "__main__":
SnakeApp().run()

Разбор.

  • BoxLayout(orientation="vertical") — типичный каркас мобильного экрана: сверху инфо, в центре игра, снизу кнопки.
  • dp(8) — отступы, не зависящие от DPI (единицы в Kivy).

Самопроверка

  • Вертикальный layout с кнопкой Start отображается.

Этап 1 — направления и цвета

Цель. словарь направлений и запрет мгновенного разворота на 180°.

DIRECTIONS = {
"up": (0, 1),
"down": (0, -1),
"left": (-1, 0),
"right": (1, 0),
}

OPPOSITE = {
"up": "down", "down": "up",
"left": "right", "right": "left",
}

Логические координатыx вправо, y вверх (математическая сетка). Экран Kivy — ось Y снизу вверх, поэтому при отрисовке клетки (sx, sy):

screen_y = offset_y + (self.rows - 1 - sy) * self.cell_size

Разбор.

  • next_direction принимает ввод; в _tick копируется в direction — за один тик змейка не разворачивается дважды.
  • set_direction отклоняет направление, противоположное текущему direction, если игра уже идёт.

Самопроверка

  • Ключи в DIRECTIONS и OPPOSITE согласованы.

Этап 2 — SnakeBoard, отрисовка

Цель. виджет поля рисует сетку, змейку и еду на одном canvas.

Класс SnakeBoard(Widget) с cols=16, rows=24, cell_size = dp(18).

Алгоритм _redraw:

  1. self.canvas.clear() — стереть прошлый кадр.
  2. Вычислить board_w, board_h и offset_x, offset_y для центрирования сетки в виджете.
  3. Фон — большой Rectangle.
  4. Линии сетки — тонкие Rectangle 1×N пикселей.
  5. Если self.snake не пуст — еда (красный квадрат) и сегменты змейки (голова ярче).

Разбор.

  • Один виджет вместо 16×24 дочерних Tile — легче для змейки, чем для 2048 (практикум 2048 использует 16 виджетов — там нужны анимации плиток).
  • bind(size=self._redraw, pos=self._redraw) — при ресайзе окна поле перерисовывается.

Самопроверка

  • Пустое поле с сеткой видно по центру виджета.

Этап 3 — тик и движение

Цель. змейка из трёх клеток ползёт вправо по таймеру.

def start_game(self):
mid_y = self.rows // 2
self.snake = [(4, mid_y), (3, mid_y), (2, mid_y)]
self.direction = "right"
self.next_direction = "right"
self.running = True
self._schedule_tick()

def _tick(self, _dt):
self.direction = self.next_direction
dx, dy = DIRECTIONS[self.direction]
head_x, head_y = self.snake[0]
new_head = (head_x + dx, head_y + dy)
self.snake.insert(0, new_head)
self.snake.pop() # без еды — хвост укорачивается
self._redraw()

def _schedule_tick(self):
if self.tick_event:
self.tick_event.cancel()
self.tick_event = Clock.schedule_interval(self._tick, 0.15)

Разбор.

  • snake — список координат; головаsnake[0].
  • insert(0, new_head) + pop() — сдвиг без роста.
  • 0.15 с — ~6–7 шагов в секунду на старте.

Сравните с 2048 — там ход по событию (свайп), здесь — по таймеру (Clock).

Самопроверка

  • После Start змейка из трёх клеток движется вправо.

Этап 4 — еда и рост

Цель. случайная еда; при поедании змейка не укорачивает хвост.

def _spawn_food(self):
occupied = set(self.snake)
free = [
(x, y)
for x in range(self.cols)
for y in range(self.rows)
if (x, y) not in occupied
]
if not free:
self.running = False # поле заполнено — победа
return
self.food = free[randint(0, len(free) - 1)]

В _tick, если new_head == self.food:

  • не вызывать pop() — хвост остаётся, длина +1;
  • score += 10;
  • _spawn_food();
  • _schedule_tick() — пересчитать скорость (этап 8).

Самопроверка

  • После поедания змейка длиннее на одну клетку.
  • Счёт увеличивается (если уже подключён update_score у родителя).

Этап 5 — game over

Цель. остановка при вылете за сетку или укусе хвоста.

if not (0 <= new_head[0] < self.cols and 0 <= new_head[1] < self.rows):
self._game_over()
return
if new_head in self.snake:
self._game_over()
return
def _game_over(self):
self.stop_game()
if hasattr(self.parent, "on_game_over"):
self.parent.on_game_over(self.score)

Разбор.

  • Проверка new_head in self.snakeдо insert, иначе голова совпадёт с бывшим хвостом на клетку.
  • stop_game() отменяет tick_event — змейка замирает.

Самопроверка

  • Врез в стену останавливает игру.
  • Врез в хвост останавливает игру.

Этап 6 — свайп

Цель. смена направления жестом — как в 2048.

_touch_start в on_touch_down; в on_touch_updx, dy, порог dp(20):

def set_direction(self, direction):
if direction not in DIRECTIONS:
return
if self.running and direction == OPPOSITE.get(self.direction):
return
self.next_direction = direction

Разбор.

  • Ввод меняет только next_direction — безопасно между тиками.
  • Короткий тап без смещения не меняет направление (return True поглощает touch).

Самопроверка

  • Свайп вверх задаёт движение вверх со следующего тика.
  • Разворот на 180° за один тик невозможен.

Этап 7 — GameScreen и D-pad

Цель. полный экран — счёт, статус, поле, виртуальный крестовик.

Структура GameScreen(BoxLayout):

  • верхscore_label ("Score: 0") + status_label ("Tap Start");
  • центрSnakeBoard(size_hint=(1, 1)) растягивается;
  • низGridLayout 3×3 с кнопками ▲ ◀ ▶ ▼ и колонка Start / Restart.
def update_score(self, score):
self.score_label.text = f"Score: {score}"

def on_game_over(self, score):
self.status_label.text = f"Game Over! {score} pts"
self.start_btn.text = "Play Again"

Разбор.

  • D-pad дублирует свайп — игрок выбирает удобный способ (виртуальные элементы управления).
  • Restart сбрасывает поле без автостарта; Start / Play Again вызывает board.start_game().
  • Кнопки через _make_btn и lambda — один обработчик на направление.

Самопроверка

  • Кнопки ▲▼◀▶ меняют направление во время игры.
  • Restart обнуляет счёт и останавливает тик.

Этап 8 — ускорение и ревизия

Цель. игра усложняется с ростом счёта; финальная проверка.

speed = max(0.08, 0.18 - self.score * 0.004)
self.tick_event = Clock.schedule_interval(self._tick, speed)

Разбор.

  • Каждые 10 очков интервал уменьшается на 0.04 с; минимум 0.08 с (~12 шагов/с).
  • Пересоздание schedule_interval в _schedule_tick — старый таймер отменяют через cancel().

Сверка с листингами этапов 0–7 или повторный прогон всех шагов подряд.

Управление

ВводДействие
Start / Play AgainНачать партию
Свайп по полюСмена направления
D-padСмена направления
RestartСброс без немедленного старта

Итоговая самопроверка

  • +10 очков за каждую еду.
  • Игра ускоряется с ростом счёта.
  • Заполнение всего поля останавливает running (опциональная "победа").
  • Пройдены все три практикума Kivy — обзор раздела.

Дальшесборка APK или сравнение с Flutter для нативного UI.