11 August

Делаем игру с управлением улыбкой

ВКонтакте corporate blogGame developmentDevelopment for AndroidHackathon
Tutorial
Привет! Меня зовут Иван Шафран, недавно я присоединился к команде видео ВКонтакте в роли программиста-разработчика для Android. Участвую в создании как продуктовых приложений, так и SDK. Время от времени я посещаю хакатоны, где можно реализовывать любые безумные идеи. Сегодня расскажу, как за пару часов сделать прототип мобильной игры с необычным управлением: персонаж будет реагировать на улыбку и подмигивание.



Как возникла идея


Мысль создать такую игру пришла как раз во время хакатона. Формат предполагал, что на разработку есть один рабочий день, то есть 8 часов. Чтобы успеть сделать прототип, я выбрал Android SDK. Возможно, лучше подошли бы игровые движки, но в них я не разбираюсь.

Концепцию управления с помощью эмоций подсказала другая игра: там движения персонажа можно было задавать, меняя громкость своего голоса. Может, и эмоции уже кто-то использовал в игровом управлении. Но я знаю мало таких примеров, поэтому остановился на этом формате.

Осторожно громкое видео!


Настраиваем окружение для разработки


Нам понадобится только Android Studio на компьютере. Если нет реального устройства на Android для запуска, можно воспользоваться эмулятором с включённой веб-камерой.

Создаём проект с ML Kit




ML Kit — отличный инструмент, который поможет впечатлить жюри хакатона: ведь вы используете AI в прототипе! А вообще он помогает встраивать в проекты решения на основе машинного обучения, например функциональность для определения объектов в кадре, перевода и распознавания текста.

Для нас важно, что у ML Kit есть бесплатный offline API для распознавания улыбки и открытых или закрытых глаз.

Раньше, чтобы создать любой проект с ML Kit, нужно было сначала зарегистрироваться в консоли Firebase. Теперь этот шаг можно пропустить для офлайн-функциональности.

Android-приложение


Удаляем лишнее


Чтобы не писать логику по работе с камерой с нуля, возьмём официальный семпл и уберём из него то, что нам не нужно.



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

Манифест


Начнём правки с AndroidManifest.xml. Удалим все теги activity, кроме первого. А на его место выставим CameraXLivePreviewActivity, чтобы сразу запускаться с камеры. В значении атрибута android:value оставляем только face, чтобы исключить из APK ненужные нам ресурсы.

<meta-data
 android:name="com.google.mlkit.vision.DEPENDENCIES"
  android:value="face"/>
<activity
  android:name=".CameraXLivePreviewActivity"
  android:exported="true"
  android:theme="@style/AppTheme">
  <intent-filter>
      <action android:name="android.intent.action.MAIN"/>
      <category android:name="android.intent.category.LAUNCHER"/>
  </intent-filter>
</activity>

Полный diff шага.

Камера


Сэкономим время — не будем удалять лишние файлы, вместо этого сконцентрируемся на элементах экрана CameraXLivePreviewActivity.

  • На строке 117 установим режим face detection:
    private String selectedModel = FACE_DETECTION;
  • На строке 118 включим фронтальную камеру:
    private int lensFacing = CameraSelector.LENS_FACING_FRONT;
  • В конце метода onCreate на строках 198–199 скроем настройки
    findViewById( R.id.settings_button ).setVisibility( View.GONE );
    findViewById( R.id.control ).setVisibility( View.GONE );

На этом можно остановиться. Но если отрисовка FPS и сетка лица визуально отвлекают, то выключить их можно так:

  • В файле VisionProcessorBase.java удаляем строки 213–215, чтобы скрыть FPS:
    graphicOverlay.add(
           new InferenceInfoGraphic(
              graphicOverlay, currentLatencyMs, shouldShowFps ? framesPerSecond : null));
  • В файле FaceDetectorProcessor.java удаляем строки 75–78, чтобы скрыть сетку лица:
    for (Face face : faces) {
        graphicOverlay.add(new FaceGraphic(graphicOverlay, face));
        logExtrasForTesting(face);
    }

Полный diff шага.

Распознаём эмоции


Распознавание улыбки по умолчанию выключено, но запустить его очень просто. Не зря же мы брали пример кода за основу! Выделим необходимые нам параметры в отдельный класс и объявим интерфейс слушателя:

FaceDetectorProcessor.java

// В классе FaceDetectorProcessor.java
public class FaceDetectorProcessor extends VisionProcessorBase<List<Face>> {
    public static class Emotion {
        public final float smileProbability;
        public final float leftEyeOpenProbability;
        public final float rightEyeOpenProbability;
        public Emotion(float smileProbability, float leftEyeOpenProbability, float rightEyeOpenProbability) {
           this.smileProbability = smileProbability;
            this.leftEyeOpenProbability = leftEyeOpenProbability;
           this.rightEyeOpenProbability = rightEyeOpenProbability;
        }
    }
    public interface EmotionListener {
        void onEmotion(Emotion emotion);
    }
    private EmotionListener listener;
    public void setListener(EmotionListener listener) {
       this.listener = listener;
    }
    
    @Override
    protected void onSuccess(@NonNull List<Face> faces, @NonNull GraphicOverlay graphicOverlay) {
        if (!faces.isEmpty() && listener != null) {
            Face face = faces.get(0);
            if (face.getSmilingProbability() != null &&
                    face.getLeftEyeOpenProbability() != null && face.getRightEyeOpenProbability() != null) {
                listener.onEmotion(new Emotion(
                        face.getSmilingProbability(),
                        face.getLeftEyeOpenProbability(),
                        face.getRightEyeOpenProbability()
                ));
            }
        }
    }
}

Чтобы включить классификацию эмоций, настроим FaceDetectorProcessor в классе CameraXLivePreviewActivity и подпишемся на получение состояния эмоций. Затем вероятности преобразуем в булевы флаги. Для тестирования в вёрстку добавим TextView, в котором покажем эмоции через смайлы.



Полный diff шага.

Разделяй и играй


Раз мы делаем игру, нужно место для рисования элементов. Будем считать, что она запускается на телефоне в портретном режиме. Значит, разделим экран на две части: камера сверху и игра снизу.

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

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

  • Guitar Hero / Just Dance — аналог, где под музыку нужно показывать определённую эмоцию;
  • гонка с преодолением препятствий, где нужно доехать до финиша за определённое время или не разбившись;
  • шутер, где подмигиванием игрок делает выстрел в противника.

Отображать игру будем в кастомном Android View — там в методе onDraw нарисуем персонажа на Canvas. В первом прототипе ограничимся геометрическими примитивами.

Игрок




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

private var playerSize = 0
private var playerRect = RectF()
// Инициализируем размеры в зависимости от высоты View
private fun initializePlayer() {
    playerSize = height / 4
    playerRect.left = playerSize / 2f
    playerRect.right = playerRect.left + playerSize
}
// Имеем в полях класса флаги эмоций
private var flags: EmotionFlags
// Устанавливаем положение в зависимости от улыбки
private fun movePlayer() {
    playerRect.top = getObjectYTopForLine(playerSize, isTopLine = flags.isSmile).toFloat()
    playerRect.bottom = playerRect.top + playerSize
}
// Получаем позицию top для объекта с высотой size,
// чтобы он был посередине верхней или нижней дорожки
private fun getObjectYTopForLine(size: Int, isTopLine: Boolean): Int {
    return if (isTopLine) {
        width / 2 - width / 4 - size / 2
    } else {
        width / 2 + width / 4 - size / 2
    }
}
// Храним paint в полях класса, так как создавать его на каждый кадр накладно
private val playerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    style = Paint.Style.FILL
    color = Color.BLUE
}
// Рисуем наш квадрат на Canvas
private fun drawPlayer(canvas: Canvas) {
    canvas.drawRect(playerRect, playerPaint)
}

Тортик


Наш персонаж «бежит» и пытается ловить тортики, чтобы набрать как можно больше очков. Мы используем стандартный приём с переходом в систему отсчёта относительно игрока: он будет стоять на месте, а тортики — лететь к нему. Если квадрат тортика пересекается с квадратом игрока, то засчитываем балл. А если при этом хотя бы один глаз у пользователя закрыт — два балла ¯ \ _ ( ツ ) _ / ¯

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

// При инициализации тортика сразу перемещаем его за экран
private fun initializeCake() {
    cakeSize = height / 8
    moveCakeToStartPoint()
}
private fun moveCakeToStartPoint() {
    // Выбираем случайную позицию справа за экраном
    cakeRect.left = width + width * Random.nextFloat()
    cakeRect.right = cakeRect.left + cakeSize
    // Случайно выбираем полосу сверху или снизу
    val isTopLine = Random.nextBoolean()
    cakeRect.top = getObjectYTopForLine(cakeSize, isTopLine).toFloat()
    cakeRect.bottom = cakeRect.top + cakeSize
}
// Двигаем тортик относительно прошедшего времени от прошлого кадра
private fun moveCake() {
    val currentTime = System.currentTimeMillis()
    val deltaTime = currentTime - previousTimestamp
    val deltaX = cakeSpeed * width * deltaTime
    cakeRect.left -= deltaX
    cakeRect.right = cakeRect.left + cakeSize
    previousTimestamp = currentTime
}
// Если тортик и игрок пересекаются, то прибавляем очки
private fun checkPlayerCaughtCake() {
    if (RectF.intersects(playerRect, cakeRect)) {
        score += if (flags.isLeftEyeOpen && flags.isRightEyeOpen) 1 else 2
        moveCakeToStartPoint()
    }
}
// Если игрок пропустил тортик, то возвращаем тортик на стартовую позицию
private fun checkCakeIsOutOfScreenStart() {
    if (cakeRect.right < 0) {
        moveCakeToStartPoint()
    }
}

Что получилось


Показ баллов сделаем очень простым. Будем выводить число в центре экрана. Нужно только учесть высоту текста и сделать отступ сверху для красоты.

private val scorePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    color = Color.GREEN
    textSize = context.resources.getDimension(R.dimen.score_size)
}
private var score: Int = 0
private var scorePoint = PointF()
private fun initializeScore() {
    val bounds = Rect()
    scorePaint.getTextBounds("0", 0, 1, bounds)
    val scoreMargin = resources.getDimension(R.dimen.score_margin)
    scorePoint = PointF(width / 2f, scoreMargin + bounds.height())
    score = 0
}

Смотрим, какую игрушку мы сделали:


Полный diff шага.

Графоний


Чтобы игру было не стыдно показывать на презентации хакатона, добавим немного графония!



Картинки


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



Анимация


Мы рисуем на Canvas, а значит, анимацию нужно реализовывать самим. Если есть картинки с анимацией, запрограммировать это будет легко. Вводим класс для объекта со сменяющимися изображениями.

class AnimatedGameObject(
        private val bitmaps: List<Bitmap>,
        private val duration: Long
) {
    fun getBitmap(timeInMillis: Long): Bitmap {
        val mod = timeInMillis % duration
        val index = (mod / duration.toFloat()) * bitmaps.size
        return bitmaps[index.toInt()]
    }
}

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



Полный diff шага.

Финальный результат


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


В заключение добавлю, что ML Kit Face Detection может пригодиться и для других сценариев.

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

Используя распознавание контуров лица из модуля Face Detection, реально повторить маски, которые сейчас популярны почти во всех приложениях с камерой. А если добавить интерактив — через определение улыбки и подмигивания, — то пользоваться ими будет вдвойне весело.

Эту функциональность — определение контуров лица — можно применять не только для развлечений. Те, кто сами пытались вырезать фото на документы, оценят. Берём контур лица, автоматически вырезаем фото с нужным соотношением сторон и правильным положением головы. Определить правильный угол съёмки поможет датчик гироскопа.
Tags:androidхакатонml kit
Hubs: ВКонтакте corporate blog Game development Development for Android Hackathon
+9
2k 24
Comments 5