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

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

7yzb8th-goe6zfkwlgmgke53gss.png


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

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

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



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

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

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

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


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


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

cs9xmjxucgifuqwu_r-lmo8h1de.gif

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

Манифест


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



  
      
      
  


Полный 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> {
    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 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, в котором покажем эмоции через смайлы.

8jerrwopa7no1y3qqwwg1tqqpec.png

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

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


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

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

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

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


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

Игрок


f3jj236xakfg_9rcipqimhvhqhw.png

Наш персонаж — это квадрат. При инициализации зададим его размеры и установим положение слева, так как он будет находиться на месте. Позиция по оси 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 шага.
Чтобы игру было не стыдно показывать на презентации хакатона, добавим немного графония!

dsw-othjhgwhdsaco7olp6ecp6u.png

Картинки


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

xa5wn6_pojquahsmoxe9so0czwe.png

Анимация


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

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


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

2lwasaqgwkxrl3khdcg1dz5g7ty.png

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


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


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

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

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

Эту функциональность — определение контуров лица — можно применять не только для развлечений. Те, кто сами пытались вырезать фото на документы, оценят. Берём контур лица, автоматически вырезаем фото с нужным соотношением сторон и правильным положением головы. Определить правильный угол съёмки поможет датчик гироскопа.

© Habrahabr.ru