Как написать 2D игру «Змейка» на Flutter

Узнайте, как использовать фреймворк Flutter в качестве простого игрового движка для создания классической 2D игры «Змейка». В этой статье мы рассмотрим основы двухмерной игровой графики и контроль объектов.

Версии использованного программного обеспечения: Dart 2.10, Flutter, Android Studio 4.2.

Скачать материалы

Фреймворк Flutter позволяет создавать кроссплатформенные приложения – мобильные (для Android и iOS), браузерные и десктопные, – с использованием одного и того же кода. Крупные компании используют Flutter для популярных приложений – включая Google Pay и Alibaba Xianyu , – однако в разработке игр данный фреймворк практически не применяется. Поэтому в этом руководстве мы займемся именно разработкой игры.

Скорость отрисовки интерфейса в Flutter достигает 60 кадров в секунду – эта особенность дает нам возможность использовать потенциал этого фреймворка для создания простой 2D-игры «Змейка». В ходе данного руководства вы научитесь:

  • Использовать фреймворк Flutter в качестве игрового движка;
  • перемещать объекты;
  • управлять движением;
  • создавать игровой интерфейс;
  • добавлять игровые элементы.

Примечание: это руководство предполагает, что читатель уже имеет базовые навыки работы с Flutter, знает основные виджеты и их свойства. Также предполагается, что вы знакомы с языком программирования Dart и принятыми в нем соглашениями.

Теперь перейдем к стартовому проекту.

Приступаем к работе

Скачайте starter project (стартовый проект), нажав на кнопку «Скачать материалы». Распакуйте скачанный файл и откройте его в Android Studio версии 4.1 или выше. Вы также можете использовать Visual Studio Code, но некоторые действия в этой среде разработки отличаются от приведенных в данном руководстве.

Нажмите на кнопку «Открыть существующий проект» и выберите папку стартового проекта starter из распакованной загрузки. Находясь в директории starter, выполните команду flutter create для создания папок Android и iOS. После этого скачайте все необходимые компоненты (зависимости), кликнув по pubspec.yaml в левой панели, а затем – по Pub get вверху экрана. Чтобы не возникали проблемы, удалите папку test, которую Flutter создает автоматически во время выполнения команды flutter create.

И, наконец, запустите сборку и запуск. Вы получите такое окно:

Приступаем к работе

В стартовом проекте содержатся все настройки, необходимые для создания игрового поля. Постепенно мы добавим все нужные элементы пользовательского интерфейса. Стартовый проект включает в себя набор необходимых методов и функций, чтобы вы могли сосредоточиться на главном – написании исходного кода игры. Вот эти встроенные функции:

  1. roundToNearestTens(int num) – округляет переданное целое число до значения ближайшего шага сетки. Позволяет получить точную следующую позицию, удаленную на один шаг от текущей позиции на игровом поле.
  2. getRandomPositionWithinRange() – задает случайную позицию в пределах игрового поля. Эти значения будут использоваться для создания новой змейки и еды для нее.
  3. showGameOverDialog() – вывод окна завершения игры. Отображает финальный счет и кнопку для перезапуска игры. Выводится в том случае, если змея наткнулась на препятствие в игровом поле.
  4. getRandomDirection([String type]) –случайным образом возвращает одно из направлений: вверх, вниз, влево, вправо. Используется для управления змейкой. Опционально выбирает направление рандомного движения – по горизонтали или вертикали.
  5. getRandomPositionWithinRange() – возвращает случайную позицию на экране в пределах игрового поля. Обеспечивает нахождение рандомной позиции в пределах траектории движения змейки.
  6. getControls() – выводит виджет ControlPanel – панель управления змейкой с четырьмя круглыми кнопками.
  7. getPlayAreaBorder() – определяет границы игрового поля на экране мобильного устройства.

Использование Flutter в качестве игрового движка

Игровым движком принято называть комплект инструментов и сервисов, которые упрощают процесс разработки игровых приложений. Такие комплекты обеспечивают выполнение многочисленных задач – обработку графики, звука, использование искусственного интеллекта, работу с вводом данных, взаимодействие игроков по сети и так далее.

Фреймворк Flutter имеет встроенные инструменты для выполнения большинства из этих задач. У разработчиков, использующих Flutter, есть доступ к огромной базе плагинов, написанных на языке программирования Dart. А если нужного плагина еще нет, его всегда можно создать.

При использовании Flutter в качестве игрового движка мы будем применять возможности рендеринга, реализованные в этом фреймворке, для анимации игровых объектов: змейки, еды, подсчета очков. Мы будем использовать таймер для перерисовки интерфейса 60 раз в секунду и отображения движений змейки. Использование рендеринга с частотой 60 кадров в секунду позволяет получить очень реалистичные анимационные эффекты.

Профессиональные игровые движки, подобные Unity, работают по такому же принципу. Разница заключается лишь в том, что перегрузка фреймворка может привести к чрезмерному потреблению приложением системных ресурсов. Однако во время тестирования этой игры Flutter продемонстрировал поразительную производительность, которая при этом не привела к нагреву пользовательского устройства.

Рисуем змейку

Наша первая задача – создание змейки. Для этого мы будем использовать виджет Piece, находящийся в стартовом проекте. Данный виджет обеспечивает вывод на экран цветного круга. Используя комбинацию из цветных кругов, мы изобразим змею, а также еду для нее.

Перед началом рисования давайте посмотрим, как работает 2D-рендеринг в Flutter.

Основы 2D-рендеринга

Для отрисовки любого объекта, который постоянно меняет расположение на экране, используются виджеты Stackи Positioned. При передаче им координат х и y они могут выводить вложенные виджеты на любой позиции экрана.

При этом очень важно помнить то, что вы разрабатываете мобильное приложение, которое должно запускаться на устройствах с самыми разными размерами и пропорциями экрана. Это означает, что мы не можем использовать жестко установленные параметры игрового поля. Вместо этого мы воспользуемся MediaQuery для получения сведений о высоте и ширине экрана пользовательского устройства, и вычислим размеры игрового поля, исходя из этих данных.

Вот пример фрагмента кода, который объявляет переменные, а затем передает им высоту и ширину экрана пользовательского устройства:

final screenSize = MediaQuery.of(context).size;
final screenWidth = screenSize.width;
final screenHeight = screenSize.height;

В приведенном выше коде унаследованный MediaQuery виджет используется для получения текущей ширины и высоты экрана. Поскольку требуется передача параметров, этот код мы будем использовать внутри метода build().

Создаем змейку

При создании змейки мы используем виджет Piece. Сначала мы создадим модуль List (список) для сохранения всех координат змейки на игровом поле. Для этого мы используем функцию getPieces(), которая считывает позиции и возвращает список координат виджетов Piece.

Откройте файл lib/game.dart и замените getPieces() на следующий код:

List<Piece> getPieces() {
    final pieces = <Piece>[];
    draw();
    drawFood();

    // 1
    for (var i = 0; i < length; ++i) {
      // 2
      if (i >= positions.length) {
        continue;
      }

      // 3
      pieces.add(
        Piece(
          posX: positions[i].dx.toInt(),
          posY: positions[i].dy.toInt(),
          // 4
          size: step,
          color: Colors.red,
        ),
      );
    }

    return pieces;
}

Данный код выполняет следующие действия:

  1. Мы используем цикл for, который обрабатывает всю длину змейки.
  2. Блок if внутри цикла for обрабатывает условие, при котором длина змейки не соответствует координатам в списке. Это происходит всякий раз, когда змейка поглощает еду и удлиняется.
  3. При каждой итерации цикла, код создает виджет Piece на соответствующей позиции и добавляет координаты в список List.
  4. Вместе с позицией мы передаем размер и цвет виджетов Piece. Размер в данном случае соответствует шагу, с которым змейка двигается по сетке поля, где каждая ячейка имеет размер, равный одному шагу. Значение параметра «цвет» – вопрос личных предпочтений: используйте те цвета, которые вам нравятся.

Сохраните файл и выполните горячую перезагрузку. На этом этапе никаких видимых изменений на экране не видно.

Создаем змейку

Заполнение списка позиций

Для внесения позиций в список нам надо реализовать функцию draw(). В файле lib/game.dart замените draw() на следующий код:

void draw() async {
    // 1
    if (positions.length == 0) {
      positions.add(getRandomPositionWithinRange());
    }

    // 2
    while (length > positions.length) {
      positions.add(positions[positions.length - 1]);
    }

    // 3
    for (var i = positions.length - 1; i > 0; i--) {
      positions[i] = positions[i - 1];
    }

    // 4
    positions[0] = await getNextPosition(positions[0]);
  }

Рассмотрим более подробно, как именно работает эта функция:

  1. Если позиция пуста, getRandomPositionWithinRange() задает случайные координаты и запускает процесс.
  2. Если змейка только что съела свою еду, длина увеличивается. Цикл while добавляет новую позицию в список, чтобы длина и координаты синхронизировались.
  3. Затем функция проверяет длину змейки и пересчитывает координаты каждого кружка. Это создает иллюзию движения змейки.
  4. Наконец, getNextPosition() передвигает первый кружок, голову змейки, в новую позицию.

Последнее, что нам следует сделать для передвижения змейки – разработать функцию getNextPosition().

Передвигаем змейку на следующую позицию

Вставьте следующий код в файл lib/game.dart:

Future<Offset> getNextPosition(Offset position) async {
    Offset nextPosition;

    if (direction == Direction.right) {
      nextPosition = Offset(position.dx + step, position.dy);
    } else if (direction == Direction.left) {
      nextPosition = Offset(position.dx - step, position.dy);
    } else if (direction == Direction.up) {
      nextPosition = Offset(position.dx, position.dy - step);
    } else if (direction == Direction.down) {
      nextPosition = Offset(position.dx, position.dy + step);
    }

    return nextPosition;
  }

Посмотрим, что делает этот код:

  1. Создает новую позицию для объекта, основываясь на текущем положении объекта и направлении движения. Изменение направления заставляет объект перемещаться в другую сторону. Позже мы будем использовать для этого кнопки управления.
  2. Увеличивает значение координаты х, если выбрано движение вправо и уменьшает это значение, если выбрано направление влево.
  3. Таким же образом, увеличивает значение по координате y в случае направления вверх, и уменьшает, если выбрано движение вниз.

Теперь изменим [] на getPieces() внутри Stack():

  return Scaffold(
    body: Container(
      color: Color(0XFFF5BB00),
      child: Stack(
        children: [
          Stack(
            children: getPieces(),
          ),
        ],
      ),
    ),
  );

В приведенном выше фрагменте кода мы добавляем метод getPieces(), который возвращает виджет в Stack, чтобы мы смогли увидеть его на экране. Обратите внимание – если вы не добавите виджет в build(), никаких изменений на экране не произойдет.

Сохраните внесенные изменения и перезапустите приложение. Вы увидите следующее:

Передвигаем змейку на следующую позицию

Мы видим змейку, которая ничего не делает. Это потому, что мы еще не добавили код для рендеринга игрового поля. Однако если вы еще раз нажмете на «Сохранить», а затем на «Горячую перезагрузку», вы увидите, что змейка движется.

Добавляем движение и скорость

Все, что нужно сделать для анимации змейки – использовать метод перерисовки интерфейса. Каждый раз при вызове build, нам нужно вычислить новые позиции и вывести кружки из списка Piece на экран. Для реализации нам потребуется таймер – Timer.

Добавьте следующее определение к changeSpeed() в файле lib/game.dart:

void changeSpeed() {
    if (timer != null && timer.isActive) timer.cancel();

    timer = Timer.periodic(Duration(milliseconds: 200 ~/ speed), (timer) {
      setState(() {});
    });
  }

Этот код устанавливает таймер на продолжительность, которая учитывает текущую скорость. Скорость увеличивается каждый раз после того, как змейка поглощает еду. С каждым срабатыванием таймера вызывается функция setState(), которая запускает перерисовку экрана приложения. Частота перерисовки зависит от скорости змейки.

Теперь вызовем changeSpeed() из функции restart() в этом же файле:

  void restart() {
    changeSpeed();
  }

Данный код сбрасывает таймер каждый раз, когда пользователь перезапускает игру.

Сохраните файл и перезапустите приложение. Теперь змейка движется в случайном направлении после каждого перезапуска.

Добавляем кнопки управления

Теперь наша змейка двигается, и пришло время разработать несколько дополнительных опций для того, чтобы дать пользователю возможность управления направлением движения и скоростью змейки. Для этого нам нужно учесть следующее:

  1. Управление змейкой происходит с помощью панели ControlPanel.
  2. Скорость увеличивается каждый раз после того, как змейка съест еду.
  3. Функция restart() сбрасывает скорость, длину и направление, если змейка столкнется с границами игрового поля.
  4. После столкновения выводится уведомление «Игра окончена».

Изменение направления

В виджете ControlPanel есть все, что нужно для того, чтобы передать пользователю управление змейкой. Панель состоит из четырех круглых кнопок, каждая из которых вызывает соответствующее изменение направления движения змейки. Виджет расположен в lib/control_panel.dart – если интересно, загляните в код этого файла.

Вставьте следующий код в функцию getControls(), расположенную в файле lib/game.dart:

  Widget getControls() {
    return ControlPanel( // 1
      onTapped: (Direction newDirection) { // 2
        direction = newDirection; // 3
      },
    );
  }

Вот что делает этот код:

  1. Мы обращаемся к виджету ControlPanel, который уже включен в стартовый проект. Панель управления имеет четыре кнопки для управления движениями змейки.
  2. Используем метод onTapped, который принимает новое направление в качестве аргумента.
  3. Добавляем новую переменную newDirection для передачи направления движения – это заставит змейку двигаться в сторону, выбранную пользователем.

Кроме того, добавьте в начало файла команду импорта:

import 'control_panel.dart';

После этого добавьте getControls() в качестве второго вложенного виджета во внешний Stack в build():

@override
Widget build(BuildContext context) {
  // ...
  return Scaffold(
    body: Container(
      color: Color(0XFFF5BB00),
      child: Stack(
        children: [
          Stack(
            children: getPieces(),
          ),
          getControls(),
        ],
      ),
    ),
  );
}

Этот код выводит виджеты, возвращенные методом getControls, на экран – внутри Stack, но выше остальных элементов интерфейса.

Сохраните файлы, перезапустите приложение. Теперь вы видите набор кнопок, которые управляют движениями змейки.

Изменение направления

Нажатие на кнопки панели управления меняет направление змейки. Игра уже работает на базовом уровне, но нам надо добавить в нее какие-нибудь интересные задачи: просто так двигать змейку по игровому полю достаточно скучно. Поэтому на следующем этапе мы добавим в игру еду, поедание которой ускорит движения змейки.

Поглощение еды и увеличение скорости

Для визуализации еды на игровом поле мы вновь воспользуемся Piece, но на этот раз элементы виджета будут окрашены в другой цвет. Кроме того, мы не хотим, чтобы еда возникала на поле в одних и тех же областях. Вместо этого еда должна появляться в случайных позициях, и при этом находиться в пределах игровой сетки, по которой движется змейка.

Сейчас мы создадим функцию drawFood() для вывода на игровое поле еды с помощью виджета Piece. Вставьте предоставленный код в drawFood() этого же файла:

  void drawFood() {

    // 1
    if (foodPosition == null) {
      foodPosition = getRandomPositionWithinRange();
    }

    // 2
    food = Piece(
      posX: foodPosition.dx.toInt(),
      posY: foodPosition.dy.toInt(),
      size: step,
      color: Color(0XFF8EA604),
      isAnimated: true,
    );
  }

Разберем, что именно делает этот код:

Создает еду с помощью элемента Piece и сохраняет внутри модуля food.

Сохраняет координаты еды внутри объекта Offset. Вначале объект пуст, поэтому первая порция еды выводится на игровом поле случайным образом с помощью getRandomPositionWithinRange().

Отображение еды на поле

Для рендеринга съедобных объектов мы добавим food к виджету build внутри Stack.

@override
Widget build(BuildContext context) {
  //...
  return Scaffold(
    body: Container(
      color: Color(0XFFF5BB00),
      child: Stack(
        children: [
          Stack(
            children: getPieces(),
          ),
          getControls(),
          food,
        ],
      ),
    ),
  );
}

Сохраните и перезапустите проект, чтобы увидеть изменения в действии. Как только приложение запустится, вы заметите зеленую еду на экране.

Отображение еды на поле

Однако если вы попытаетесь направить змейку к еде, ничего не произойдет – змейка просто переползет через свое пропитание. Это потому, что у нас в коде еще нет ничего, чтобы научить змейку есть продукты. Мы исправим это на следующем этапе.

Поедание и восполнение еды

Прежде всего, нам надо проверить, находятся ли змейка и еда на одних и тех же координатах 2D-пространства. Если это так, надо внести некоторые изменения во внешний вид змейки и разместить очередную порцию еды в новой позиции.

Добавьте следующий код в функцию drawFood(), сразу под первым блоком:

  void drawFood() {

    // ...

    if (foodPosition == positions[0]) {
      length++;
      speed = speed + 0.25;
      score = score + 5;
      changeSpeed();

      foodPosition = getRandomPositionWithinRange();
    }

    // ...
  }

Этот код проверяет, совпадают ли позиции еды в foodPosition и координаты первого кружка Piece змейки. Если это так, мы увеличиваем длину змейки на 1 Piece, скорость на 0.25 секунды и счет на 5 очков. Затем мы вызываем changeSpeed() для установки новых параметров таймера.

И наконец, мы передаем foodPosition новую случайную позицию для вывода следующей порции еды на игровом поле.

Сохраните, перезапустите приложение и оцените произошедшие изменения.

Поедание и восполнение еды

Наша змейка теперь ест свою еду, после чего заметно прибавляет в длине.

Определение столкновений и вывод уведомления «Игра окончена»

Теперь змейка может свободно двигаться, куда захочет пользователь. Это вызывает проблему – ничто не удерживает змейку в границах игрового поля. Необходимо ограничить движения змейки так, чтобы она всегда оставалась в пределах игрового поля.

Прежде всего, нарисуем границы поля с помощью getPlayAreaBorder(). Для этого просто добавим getPlayAreaBorder() во внешний Stack в виджете build().

@override
Widget build(BuildContext context) {
  //...
  return Scaffold(
      body: Container(
        color: Color(0XFFF5BB00),
        child: Stack(
          children: [
            getPlayAreaBorder(),
            Stack(
              children: getPieces(),
            ),
            getControls(),
            food,
          ],
        ),
      ),
    );
}

Данный код добавляет виджет food в Stack внутри build() для вывода на экран. Теперь вставьте приведенный ниже код в функцию определения столкновения detectCollision():

  bool detectCollision(Offset position) {

    if (position.dx >= upperBoundX && direction == Direction.right) {
      return true;
    } else if (position.dx <= lowerBoundX && direction == Direction.left) {
      return true;
    } else if (position.dy >= upperBoundY && direction == Direction.down) {
      return true;
    } else if (position.dy <= lowerBoundY && direction == Direction.up) {
      return true;
    }

    return false;
  }

Функция определяет, достигла ли змейка одной из четырех границ игрового поля. Если это так, функция возвращает значение true, а в противном случае – значение false. Для установления факта нахождения змейки в пределах игрового поля используются переменные lowerBoundX, upperBoundX, lowerBoundY и upperBoundY.

Теперь нам надо использовать detectCollision() внутри getNextPosition() для того, чтобы избежать столкновения во время генерации новой позиции змейки. Вставьте следующий код в getNextPosition(), сразу после объявления следующей позиции nextPosition:

Future<Offset> getNextPosition(Offset position) async {
  //...
  if (detectCollision(position) == true) {
      if (timer != null && timer.isActive) timer.cancel();
      await Future.delayed(
          Duration(milliseconds: 500), () => showGameOverDialog());
      return position;
    }
  //...
}

Этот код обеспечивает проверку столкновений змейки с препятствиями. Если змейка натолкнется на границы игрового поля, данный код обнуляет таймер и показывает уведомление о завершении игры showGameOverDialog().

Определение столкновений и вывод уведомления «Игра окончена»

Сохраните внесенные изменения, выполните горячую перезагрузку.

Теперь вы будете получать уведомление о завершении игры всякий раз после того, как змейка столкнется с границами игрового поля. Уведомление должно показывать количество очков и опцию «Играть снова». При нажатии на «Играть снова» игра должна перезапускаться – мы займемся этим на следующем этапе.

Финальные штрихи

Игра почти готова. Нам осталось написать код для перезапуска игры и подсчета очков, чтобы придать игре соревновательный дух.

Перезапуск игры

Сейчас мы создадим функцию для перезапуска игры в том случае, если пользователь нажмет на «Играть снова». Замените существующий restart в файле lib/game.dart на следующий код:

  void restart() {

    score = 0;
    length = 5;
    positions = [];
    direction = getRandomDirection();
    speed = 1;

    changeSpeed();
  }

Этот код сбрасывает все параметры до начальных. Он также обнуляет все позиции, так что змейка теряет длину и возрождается в первоначальном виде.

Отображение очков

Теперь добавим код для вывода счета игры в верхнем правом углу экрана. Для этого создадим функцию getScore():

  Widget getScore() {
    return Positioned(
      top: 50.0,
      right: 40.0,
      child: Text(
        "Score: " + score.toString(),
        style: TextStyle(fontSize: 24.0),
      ),
    );
  }

Добавьте getScore() в build() в качестве последнего вложенного виджета Stack, сразу после функции food. В итоге build() должен выглядеть следующим образом:

@override
Widget build(BuildContext context) {
  //...
  return Scaffold(
    body: Container(
      color: Color(0XFFF5BB00),
      child: Stack(
        children: [
          getPlayAreaBorder(),
          Stack(
            children: getPieces(),
          ),
          getControls(),
          food,
          getScore(),
        ],
      ),
    ),
  );
}

Сохраните файлы, перезапустите проект.

Отображение очков

Теперь вы можете видеть обновление счета в реальном времени.

Теперь все готово – вы создали свою первую игру. Можно показывать друзьям.

Следующий шаг

Скачайте набор материалов для стартового проекта. Если вам захочется внести интересные дополнения в созданную игру, вот несколько идей:

  1. Измените функцию определения столкновений так, чтобы игра останавливалась, если змейка наткнется на саму себя. Поскольку позиции всех элементов змейки сохраняются, реализовать эту опцию несложно.
  2. Ограничьте движения змейки так, чтобы она не могла дважды отступать в одном и том же направлении.
  3. Предоставьте пользователю возможность поиграть в супер-сложном режиме, когда скорость движения возрастает быстрее, а начисление очков – медленнее. Это значительно усложняет игру.
  4. Можно сохранять предыдущие рекорды игроков – любому пользователю будет интересно побить предыдущее достижение.
  5. И, наконец, можно добавить звуковое оформление – с ним игра станет гораздо интереснее.

Надеюсь, это руководство вам понравилось. Если есть вопросы и замечания – расскажите о них комментариях.

Скачать материалы

Пожалуйста, оставляйте свои мнения по текущей теме статьи. За комментарии, подписки, лайки, дизлайки, отклики огромное вам спасибо!

Наталья Кайдаавтор-переводчик статьи «How to Create a 2D Snake Game in Flutter»