Android 实践:基于双人实时视频的互动小游戏——拿头玩

在 RTC 2020 编程挑战赛春季赛中。我们还有一个获奖团队,思路新颖,开发了一款基于双人视频聊天场景的Android小游戏——“拿头玩”。在视频聊天过程中即可开启游戏。通过人脸识别算法识别转头方向,实现以“接锅”和“甩锅”为主题的玩法。目前实现了Android版本。

我们请“拿头玩”团队分享他们的开发思路与应用的功能实现:

项目介绍

《拿头玩》是一款基于双人视频聊天场景的小游戏,在视频聊天过程中即可开启游戏。通过人脸识别算法识别转头方向,实现以“接锅”和“甩锅”为主题的玩法。目前实现了Android版本。


image

项目初心

颈椎问题是困扰所有办公族的难题,大多数人工作中很难有机会能起身动一动,回到家里也会因为疲倦而放弃做一些颈椎康复的运动。所以我们想设计一款游戏,让大家在休息的时候可以通过游戏的形式活动颈椎,舒缓疼痛。我们选择了职场中的“甩锅”和“接锅”的场景,来作为游戏中的元素,希望能增加玩家的代入感。此外,我们还添加了截图分享模块,方便游戏进行传播。

主要功能

经过了5天的设计和开发,我们最终完成了《拿头玩》这个作品,下面来分享一下它的主要功能和其中的代码细节。

  • 视频聊天模块的搭建

    视频聊天模块主要是使用声网的音视频sdk,它可以快速的开发出一个基本的视频对话模块,核心代码如下:
//onCreate
val rtcEngine = RtcEngine.create(this, AppConfig.appKey,
            object : IRtcEngineEventHandler() {
                override fun onFirstRemoteVideoDecoded(uid: Int,width: Int,height: Int,elapsed: Int) {
                    setupRemoteVideo(uid)
                }
            }
//setup
private fun setupRemoteVideo(uid: Int) {
    val remoteView = RtcEngine.CreateRendererView(baseContext)
    remoteView.setZOrderMediaOverlay(true)
    container.addView(remoteView)
    rtcEngine.setupRemoteVideo(VideoCanvas(remoteView, VideoCanvas.RENDER_MODE_HIDDEN, uid))
}
  • 视频帧数据的获取和处理

    为了进行下一步的人脸识别,我们需要获取到视频帧数据,对帧数据进行预处理。在阅读声网提供的文档和demo后,我们搭建了一个简单的apm-plugin插件,通过这个插件,就可以得到视频聊天过程中的裸数据了。
    首先我们创建apm-plugin-packet-processing.cpp文件,然后通过CMakeLists.txt配置编译参数:

cmake_minimum_required(VERSION 3.4.1)

add_library(
        apm-plugin-packet-processing
        SHARED
        apm-plugin-packet-processing.cpp)

include_directories(../cpp/include) //这里需要导入sdk中的.h文件
...
target_link_libraries(
        apm-plugin-packet-processing
        ${log-lib})

然后我们定义两个jni方法来注册和反注册裸数据的回调:

JNIEXPORT void JNICALL Java_com_zero_game_utils_frame_VideoFrameHandler_doRegisterProcessing
        (JNIEnv *env, jobject obj) {
    if (!rtcEngine) {
        return;
    } else {
        agora::util::AutoPtr<agora::media::IMediaEngine> mediaEngine;
        mediaEngine.queryInterface(rtcEngine, agora::AGORA_IID_MEDIA_ENGINE);
        s_packetObserver = *new AgoraVideoFrameObserver(jvm, env, env->NewGlobalRef(obj));
        mediaEngine->registerVideoFrameObserver(&s_packetObserver);
    }
}

JNIEXPORT void JNICALL Java_com_zero_game_utils_frame_VideoFrameHandler_doUnregisterProcessing
        (JNIEnv *env, jobject obj) {
    if (!rtcEngine) {
        return;
    } else {
        agora::util::AutoPtr<agora::media::IMediaEngine> mediaEngine;
        mediaEngine.queryInterface(rtcEngine, agora::AGORA_IID_MEDIA_ENGINE);
        s_packetObserver.release();
        mediaEngine->registerVideoFrameObserver(nullptr);
    }
}

agora::media::IVideoFrameObserver这个接口就是声网sdk提供的视频帧回调,只要实现它即可:

class AgoraVideoFrameObserver : public agora::media::IVideoFrameObserver {
public:
    AgoraVideoFrameObserver() {
    }
    
    AgoraVideoFrameObserver(JavaVM *vm, JNIEnv *env, jobject jobj) {
       //...
    }
    
    // 获取本地摄像头采集到的视频帧
    virtual bool onCaptureVideoFrame(VideoFrame &videoFrame) override {
        //processVideoFrame(videoFrame)
        return true;
    }
    // 获取远端用户发送的视频帧
    virtual bool onRenderVideoFrame(unsigned int uid, VideoFrame &videoFrame) override {
        return true;
    }
    // 获取本地视频编码前的视频帧
    virtual bool onPreEncodeVideoFrame(VideoFrame &videoFrame) override {
        return true;
    }
    void release() {
        //...
    }
};

由于Android平台中摄像头返回的裸数据是YUV420编码,所以我们还要转换为提供给人脸识别模块的rgba格式才行,最后通过jni方法将数据传递到java层,进行后续的处理:

int width = videoFrame.width;
int height = videoFrame.height;
int index = 0;
char *rgba = new char[width * height * 4];
unsigned char *ybase = static_cast<unsigned char *>(videoFrame.yBuffer);
unsigned char *ubase = static_cast<unsigned char *>(videoFrame.uBuffer);;
unsigned char *vbase = static_cast<unsigned char *>(videoFrame.vBuffer);;
for (int y = 0; y < height; y++) {
    for (int x = 0; x < width; x++) {
    //YYYYYYYYUUVV
        u_char Y = ybase[x + y * width];
        u_char U = ubase[y / 2 * width / 2 + (x / 2)];
        u_char V = vbase[y / 2 * width / 2 + (x / 2)];
        int r = static_cast<int>(Y + 1.402 * (V - 128));
        if (r > 255) { r = 255; } if (r < 0) { r = 0; }
        int g = static_cast<int>(Y - 0.34413 * (U - 128) - 0.71414 * (V - 128));
        if (g > 255) { g = 255;} if (g < 0) { g = 0; }
        int b = static_cast<int>(Y + 1.772 * (U - 128));
        if (b > 255) { b = 255; } if (b < 0) { b = 0; }
        rgba[index++] = static_cast<char>(r); //R
        rgba[index++] = static_cast<char>(g); //G
        rgba[index++] = static_cast<char>(b); //B
        rgba[index++] = static_cast<char>(255);
    }
}

jbyte buf[width * height * 4];
int i = 0;
for (i = 0; i < width * height * 4; i++) {
    buf[i] = rgba[i];
}

jbyteArray jarrRV = env->NewByteArray(width * height * 4);
env->SetByteArrayRegion(jarrRV, 0, width * height * 4, buf);
env->CallVoidMethod(jobj, jSendMethodId, jarrRV, width, height, videoFrame.rotation);
env->DeleteLocalRef(jarrRV);
  • 人脸识别和方向检测

    人脸识别主要使用的是MLKit,通过Firebase即可简单配置使用,在上一个环节中,我们把源数据通过jni传到了java层,现在我们需要将它转化成bitmap对象然后传给MLKit中提供的VisionFaceDetector。
val bitmap = Bitmap.createBitmap(color,width,height,Bitmap.Config.ARGB_8888)
//裸数据还需要进行旋转和水平翻转
val matrix = Matrix()
matrix.postRotate(rotation.toFloat())
matrix.postScale(-1.0f, 1.0f)
val rotationBitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true)
val image = FirebaseVisionImage.fromBitmap(rotationBitmap)
val detect = FirebaseVision.getInstance().getVisionFaceDetector(highAccuracyOpts)
detect.detectInImage(image)
    .addOnSuccessListener {
        val leftEye = face.getLandmark(FirebaseVisionFaceLandmark.LEFT_EYE)
        val rightEye = face.getLandmark(FirebaseVisionFaceLandmark.RIGHT_EYE)
        val nose = face.getLandmark(FirebaseVisionFaceLandmark.NOSE_BASE)
        //获取到左眼、右眼和鼻子的位置
        val leftEyeNose = euclidean(leftEye,nose)//计算鼻子到左眼的距离
        val rightEyeNode = euclidean(rightEye,nose)//计算鼻子到右眼的距离
        val ratio = min(leftEyeNose,rightEyeNose) / max(leftEyeNose,rightEyeNose)
        if (ratio > 0.7 && ratio < 1) {
            //左右眼离鼻子的比例在0.7-1.0之间我们认为没有转头
            FaceState.FRONT
        } else {
            if (rightHalfFace > leftHalfFace) {
                //右边眼睛到鼻子距离大于左边的,我们认为转向了左边
                FaceState.LEFT
            } else {
                //反之右边
                FaceState.RIGHT
            }
        }
    }

实现了转头识别后,配合上UI和动画,我们就可以使游戏中的人偶跟随我们的转头方向运动了。

  • 游戏流程控制

    由于游戏是在两端同时进行的,所以我们需要进行端对端的数据传递,我们采用的是声网提供的消息传输方案。通过实时传递游戏过程中的指令,对双方游戏画面进行控制,传递的指令包括:游戏开始,游戏结束,向左转头,向右转头,没有转头以及实时分数等。
//发送方
streamId = rtcEngine.createDataStream(true, true)
rtcEngine.sendStreamMessage(streamId, "left".toByteArray())

//接收方 object : IRtcEngineEventHandler
override fun onStreamMessage(uid: Int, s: Int, data: ByteArray?) {
    data?.let {
        val string = String(it)
        when (string) {
            "left" -> {
                //处理游戏
            }
            "right"->{
                //处理游戏
            }
            .....
        }
}

尾声:未来展望

《拿头玩》这个项目是一个起点,基于它的框架,其实可以快速地添加到各种app中,形成一个额外的小游戏模块。将“接锅”“甩锅”的替换成“接优惠券”、“采集素材”等不同元素,可以扩展它的使用场景。通过提供更多有趣的包装,可以有效实现社交裂变引流。

开源链接

开源地址 : https://github.com/AgoraIO-Community/RTC-Hackathon/tree/master/SDKChallengeProject/Zero_PlayHead

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,284评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,115评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,614评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,671评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,699评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,562评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,309评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,223评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,668评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,859评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,981评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,705评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,310评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,904评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,023评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,146评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,933评论 2 355