Делаем игру с управлением улыбкой
Привет! Меня зовут Иван Шафран, недавно я присоединился к команде видео ВКонтакте в роли программиста-разработчика для Android. Участвую в создании как продуктовых приложений, так и SDK. Время от времени я посещаю хакатоны, где можно реализовывать любые безумные идеи. Сегодня расскажу, как за пару часов сделать прототип мобильной игры с необычным управлением: персонаж будет реагировать на улыбку и подмигивание.
Мысль создать такую игру пришла как раз во время хакатона. Формат предполагал, что на разработку есть один рабочий день, то есть 8 часов. Чтобы успеть сделать прототип, я выбрал Android SDK. Возможно, лучше подошли бы игровые движки, но в них я не разбираюсь.
Концепцию управления с помощью эмоций подсказала другая игра: там движения персонажа можно было задавать, меняя громкость своего голоса. Может, и эмоции уже кто-то использовал в игровом управлении. Но я знаю мало таких примеров, поэтому остановился на этом формате.
Осторожно громкое видео!
Нам понадобится только Android Studio на компьютере. Если нет реального устройства на Android для запуска, можно воспользоваться эмулятором с включённой веб-камерой.
ML Kit — отличный инструмент, который поможет впечатлить жюри хакатона: ведь вы используете AI в прототипе! А вообще он помогает встраивать в проекты решения на основе машинного обучения, например функциональность для определения объектов в кадре, перевода и распознавания текста.
Для нас важно, что у ML Kit есть бесплатный offline API для распознавания улыбки и открытых или закрытых глаз.
Раньше, чтобы создать любой проект с ML Kit, нужно было сначала зарегистрироваться в консоли Firebase. Теперь этот шаг можно пропустить для офлайн-функциональности.
Удаляем лишнее
Чтобы не писать логику по работе с камерой с нуля, возьмём официальный семпл и уберём из него то, что нам не нужно.
Для начала скачайте пример и попробуйте запустить. Исследуйте режим 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
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, в котором покажем эмоции через смайлы.
Полный 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,
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, реально повторить маски, которые сейчас популярны почти во всех приложениях с камерой. А если добавить интерактив — через определение улыбки и подмигивания, — то пользоваться ими будет вдвойне весело.
Эту функциональность — определение контуров лица — можно применять не только для развлечений. Те, кто сами пытались вырезать фото на документы, оценят. Берём контур лица, автоматически вырезаем фото с нужным соотношением сторон и правильным положением головы. Определить правильный угол съёмки поможет датчик гироскопа.