跳到主要内容
极客日志极客日志
首页博客AI提示词GitHub精选代理工具
搜索
|注册
博客列表
Kotlin大前端java

基于 Rokid AR 眼镜的聚会游戏助手开发实战

利用 Rokid CXR-M SDK 和 Kotlin 开发聚会游戏助手,通过 AR 眼镜实现题目私密显示与倒计时同步,解决组织者需频繁查看手机的问题。核心步骤包括项目依赖配置、蓝牙权限管理、数据模型设计、SDK 交互封装及主界面逻辑实现。最终达成流畅的游戏节奏控制,提升聚会体验。

SecGuard发布于 2026/3/23更新于 2026/5/15 浏览
基于 Rokid AR 眼镜的聚会游戏助手开发实战

基于 Rokid AR 眼镜的聚会游戏助手开发实战

春节聚会时,作为组织者需要频繁查看手机题目,影响体验。为了解决这一问题,利用 Rokid CXR-M SDK 开发了游戏助手。本文记录使用这款 SDK 开发聚会游戏助手的完整过程。

图片

一、为什么是 AR 眼镜?一个产品思考

在动手写代码之前,思考了为什么不用手机 App 就够了。

场景手机方案AR 眼镜方案
组织者状态眼睛盯着手机屏幕抬头看向参与者
题目保密容易被旁人看到只有组织者可见
游戏氛围'等等,我看下题'流畅自然
时间把控需要看时钟倒计时直接显示

核心差异在于:手机方案把组织者变成了'管理员',而眼镜方案让组织者回归'参与者'。Rokid 的 CXR-M SDK 提供了「提词器场景」——这正是我需要的:将文字内容推送到眼镜屏幕显示。配合 TTS(语音合成)能力,还能在游戏开始或结束时播放提示。

二、项目架构:简单但不简陋

这个项目的核心原则是保持简单——毕竟只是一个聚会小工具。整个应用只有三个核心类:

com.rokid.game/
├── MainActivity.kt # 主界面,处理所有交互逻辑
├── data/
│   └── GameData.kt # 数据模型和预设题目
└── sdk/
    └── RokidGlassesManager.kt # SDK 封装层

为什么把 SDK 封装单独放一层?因为我想让业务代码与 SDK 实现解耦。如果将来 SDK 升级或者换成其他方案,只需要修改这一个文件。

三、Step by Step:从零开始的开发过程

第一步:配置项目依赖

首先是引入 CXR-M SDK。在 settings.gradle.kts 中配置仓库:

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://s01.oss.sonatype.org/content/repositories/releases/") }
        maven { url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") }
    }
}

然后在 app/build.gradle.kts 中添加依赖:

dependencies {
    implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2")
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.appcompat:appcompat:1.6.1")
    implementation()
    implementation()
}
"com.google.android.material:material:1.11.0"
"androidx.constraintlayout:constraintlayout:2.1.4"

踩坑提示:CXR-M SDK 需要 Android API 28+,记得在 defaultConfig 中设置 minSdk = 28。

第二步:配置蓝牙权限

眼镜通过蓝牙与手机连接,需要在 AndroidManifest.xml 中声明权限:

<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

踩坑提示:Android 12+ 需要动态申请 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 权限,在代码中要处理这个逻辑:

// MainActivity.kt
private fun checkPermissions() {
    val perms = mutableListOf<String>()
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        perms.add(Manifest.permission.BLUETOOTH_SCAN)
        perms.add(Manifest.permission.BLUETOOTH_CONNECT)
    }
    val notGranted = perms.filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }
    if (notGranted.isNotEmpty()) {
        ActivityCompat.requestPermissions(this, notGranted.toTypedArray(), 100)
    }
}
第三步:设计数据模型

我选择了三种经典聚会游戏:真心话大冒险、你比我猜、我是谁。数据模型的设计直接影响后续代码的复杂度,所以我在这里花了不少心思:

// GameData.kt
enum class GameType(val displayName: String) {
    TRUTH_OR_DARE("真心话大冒险"),
    CHARADES("你比我猜"),
    WHO_AM_I("我是谁"),
    COUNTDOWN("数数字")
}

data class GameQuestion(
    val id: Int,
    val gameType: GameType,
    val content: String,
    val answer: String? = null, // "我是谁"需要答案
    val isTruth: Boolean = true // 真心话大冒险需要区分真心话/大冒险
)

这里有一个设计细节:answer 字段是可空的,因为真心话大冒险和你比我猜不需要答案显示。而 isTruth 字段只对真心话大冒险有意义,用于在眼镜上显示「真心话」还是「大冒险」的标题。

预设数据我直接硬编码在 GameData 单例中:

// GameData.kt
object GameData {
    val questions: List<GameQuestion> = listOf(
        // 真心话
        GameQuestion(1, GameType.TRUTH_OR_DARE, "你最近一次哭是什么时候?", null, true),
        GameQuestion(2, GameType.TRUTH_OR_DARE, "你最尴尬的经历是什么?", null, true),
        GameQuestion(3, GameType.TRUTH_OR_DARE, "你有暗恋的人吗?", null, true),
        // 大冒险
        GameQuestion(6, GameType.TRUTH_OR_DARE, "给通讯录第 5 个人打电话说新年快乐", null, false),
        GameQuestion(7, GameType.TRUTH_OR_DARE, "模仿一个动物叫声", null, false),
        // 你比我猜
        GameQuestion(11, GameType.CHARADES, "包饺子", null),
        GameQuestion(12, GameType.CHARADES, "放鞭炮", null),
        // 我是谁
        GameQuestion(17, GameType.WHO_AM_I, "孙悟空", "西游记角色"),
        GameQuestion(18, GameType.WHO_AM_I, "奥特曼", "动漫角色"),
        // ... 更多题目
    )
}

随机选题要避免重复,我实现了一个简单但有效的方法:

// GameData.kt
fun getRandom(type: GameType, used: Set<Int>): GameQuestion? {
    val available = getByType(type).filter { it.id !in used }
    // 如果全部用完了,就从所有题目中随机选
    return if (available.isNotEmpty()) available.random()
    else getByType(type).random()
}
第四步:封装 SDK 交互

这是整个项目最核心的部分。我创建了一个 RokidGlassesManager 单例来封装所有与眼镜的交互。 首先定义回调接口,让调用方能够异步处理结果:

// RokidGlassesManager.kt
object RokidGlassesManager {
    private val cxrApi: CxrApi by lazy { CxrApi.getInstance() }
    private var connectionCallback: ConnectionCallback? = null

    interface ConnectionCallback {
        fun onConnecting()
        fun onConnected()
        fun onDisconnected()
        fun onFailed(errorMsg: String)
    }

    interface SendCallback {
        fun onSuccess()
        fun onFailed(errorMsg: String)
    }

    val isConnected: Boolean get() = cxrApi.isBluetoothConnected
}

连接眼镜的流程稍微复杂一些,需要先初始化蓝牙、获取连接信息、再建立连接:

// RokidGlassesManager.kt
fun connectGlasses(context: Context, device: BluetoothDevice) {
    connectionCallback?.onConnecting()
    cxrApi.initBluetooth(context, device, object : BluetoothStatusCallback() {
        override fun onConnectionInfo(uuid: String?, mac: String?, account: String?, type: Int) {
            if (!uuid.isNullOrEmpty() && !mac.isNullOrEmpty()) {
                cxrApi.connectBluetooth(context, uuid, mac, object : BluetoothStatusCallback() {
                    override fun onConnected() {
                        connectionCallback?.onConnected()
                    }
                    override fun onDisconnected() {
                        connectionCallback?.onDisconnected()
                    }
                    override fun onFailed(e: ValueUtil.CxrBluetoothErrorCode?) {
                        connectionCallback?.onFailed(e?.name ?: "连接失败")
                    }
                    // 需要空实现这个方法,即使我们不使用它
                    override fun onConnectionInfo(a: String?, b: String?, c: String?, d: Int) {}
                })
            } else {
                connectionCallback?.onFailed("获取连接信息失败")
            }
        }
        override fun onConnected() {
            connectionCallback?.onConnected()
        }
        override fun onDisconnected() {
            connectionCallback?.onDisconnected()
        }
        override fun onFailed(e: ValueUtil.CxrBluetoothErrorCode?) {
            connectionCallback?.onFailed(e?.name ?: "连接失败")
        }
    })
}

踩坑提示:connectBluetooth 的回调中,onConnectionInfo 方法必须实现,否则可能无法正常回调 onConnected。这个问题困扰了我好几个小时。

查找眼镜设备的逻辑很简单,就是遍历已配对的蓝牙设备:

// RokidGlassesManager.kt
fun findRokidGlasses(adapter: BluetoothAdapter): BluetoothDevice? {
    if (ContextCompat.checkSelfPermission(adapter.context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) return null
    return adapter.bondedDevices.find { it.name?.contains("Rokid", ignoreCase = true) == true }
}

发送内容到眼镜是核心功能。CXR-M SDK 的提词器场景通过 sendStream 方法发送文本:

// RokidGlassesManager.kt
fun sendGameContent(text: String, callback: SendCallback? = null): Boolean {
    if (!isConnected) {
        callback?.onFailed("眼镜未连接")
        return false
    }
    // 先激活提词器场景
    cxrApi.controlScene(ValueUtil.CxrSceneType.WORD_TIPS, true, null)
    // 再发送内容
    val status = cxrApi.sendStream(
        ValueUtil.CxrStreamType.WORD_TIPS,
        text.toByteArray(Charsets.UTF_8),
        "game.txt",
        object : SendStatusCallback() {
            override fun onSendSucceed() {
                callback?.onSuccess()
            }
            override fun onSendFailed(e: ValueUtil.CxrSendErrorCode?) {
                callback?.onFailed(e?.name ?: "发送失败")
            }
        }
    )
    return status == ValueUtil.CxrStatus.REQUEST_SUCCEED
}

TTS 语音播报是锦上添花的功能,可以在倒计时结束时播报'时间到':

// RokidGlassesManager.kt
fun sendTts(text: String): Boolean {
    if (!isConnected) return false
    return if (cxrApi.sendTtsContent(text) == ValueUtil.CxrStatus.REQUEST_SUCCEED) {
        cxrApi.notifyTtsAudioFinished()
        true
    } else false
}
第五步:主界面逻辑

主界面 MainActivity.kt 负责所有用户交互。我选择了简洁的设计:顶部显示游戏类型和当前题目,底部是操作按钮。

游戏类型切换的逻辑:

// MainActivity.kt
private fun selectGameType(type: GameType) {
    currentType = type
    usedQuestions.clear()
    // 切换游戏时清空已用题目
    binding.tvGameType.text = type.displayName
    nextQuestion()
    updateButtonStyles(type)
}

倒计时功能是你比我猜游戏的核心。我使用 Android 的 CountDownTimer,并在最后 10 秒同步更新眼镜显示:

// MainActivity.kt
private fun startCountdown() {
    countdownTimer?.cancel()
    binding.tvCountdown.text = "60"
    countdownTimer = object : CountDownTimer(60000, 1000) {
        override fun onTick(millis: Long) {
            binding.tvCountdown.text = "${millis / 1000}"
            // 最后 10 秒同步到眼镜
            if (millis / 1000 <= 10) {
                sendToGlasses("⏱ 倒计时:${millis / 1000}秒")
            }
        }
        override fun onFinish() {
            binding.tvCountdown.text = "0"
            RokidGlassesManager.sendTts("时间到!")
        }
    }.start()
}

发送到眼镜的内容格式需要精心设计,保证在眼镜上显示清晰易读:

// MainActivity.kt
private fun buildDisplayText(): String = buildString {
    val q = currentQuestion ?: return ""
    appendLine("🎮 ${currentType.displayName}")
    appendLine()
    if (currentType == GameType.TRUTH_OR_DARE) {
        appendLine("────── ${if (q.isTruth) "真心话" else "大冒险"} ──────")
    } else {
        appendLine("────── 题目 ──────")
    }
    appendLine()
    appendLine(q.content)
    appendLine()
    appendLine("👆 手机点击下一题")
}

四、实际使用体验

开发完成后,我在一次朋友聚会上测试了这个应用。使用流程是:

  1. 打开 APP,选择游戏类型
  2. 连接 Rokid 眼镜(首次需要配对)
  3. 点击「发送到眼镜」,题目出现在眼镜屏幕上
  4. 游戏进行中,用手机翻页或启动倒计时

眼镜端的显示效果:

┌──────────────────────────────┐
│ 🎮 你比我猜                  │
│                              │
│ ────── 题目 ──────           │
│                              │
│ 包饺子                       │
│                              │
│ 👆 手机点击下一题             │
└──────────────────────────────┘

实际效果:作为组织者,我终于可以抬头面对参与者,通过眼镜确认题目,而不用低头看手机。游戏节奏明显更流畅了,大家玩得也更尽兴。

五、遇到的问题与解决

问题一:题目全部用完后怎么办?

最初的实现会导致空指针异常。解决方案是在 getRandom 方法中,当没有可用题目时,重新从所有题目中随机选:

fun getRandom(type: GameType, used: Set<Int>): GameQuestion? {
    val available = getByType(type).filter { it.id !in used }
    return if (available.isNotEmpty()) available.random()
    else getByType(type).random()
}
问题二:倒计时精度问题

CountDownTimer 在某些设备上会有精度问题。对于聚会游戏这种场景,秒级精度足够了,但如果需要更精确的计时,建议使用 Handler + Runnable 的方式:

private val handler = Handler(Looper.getMainLooper())
private var remainingSeconds = 60
private val tickRunnable = object : Runnable {
    override fun run() {
        if (remainingSeconds > 0) {
            remainingSeconds--
            updateDisplay()
            handler.postDelayed(this, 1000)
        } else {
            onTimeUp()
        }
    }
}
问题三:屏幕常亮

游戏过程中屏幕不能熄灭,否则重新唤醒需要时间。解决方案是在 onCreate 中添加:

window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

六、功能清单

功能说明
四种游戏真心话大冒险/你比我猜/我是谁/数字
随机出题自动避免重复
60 秒倒计时你比我猜模式专用,最后 10 秒同步眼镜
眼镜同步题目实时推送到眼镜显示
TTS 语音倒计时结束播报'时间到'
蓝牙连接自动查找已配对的 Rokid 设备

七、不足与展望

当前版本的不足:

  • 题目数量有限,且硬编码在代码中
  • 不支持用户自定义添加题目
  • 没有积分和排行榜系统
  • 只支持单机模式

后续可以改进的方向:

  1. 云端题库:将题目存储在云端,支持实时更新
  2. 自定义题目:允许用户添加自己的题目
  3. 多人模式:通过局域网实现多设备同步
  4. 更多游戏:增加狼人杀、谁是卧底等游戏

八、结语

这个项目虽然规模不大,但让我深入理解了 AR 眼镜在日常生活中可能的应用场景。聚会游戏助手解决的不是一个技术难题,而是一个体验问题——让组织者从'管理员'回归'参与者'。

Rokid CXR-M SDK 的封装做得不错,让开发者可以专注于业务逻辑,而不用关心底层通信细节。提词器场景的设计也很巧妙,非常适合这类需要私密显示内容的应用。

如果你也有类似的想法,不妨动手试试。代码量不大,但成就感满满。

目录

  1. 基于 Rokid AR 眼镜的聚会游戏助手开发实战
  2. 一、为什么是 AR 眼镜?一个产品思考
  3. 二、项目架构:简单但不简陋
  4. 三、Step by Step:从零开始的开发过程
  5. 第一步:配置项目依赖
  6. 第二步:配置蓝牙权限
  7. 第三步:设计数据模型
  8. 第四步:封装 SDK 交互
  9. 第五步:主界面逻辑
  10. 四、实际使用体验
  11. 五、遇到的问题与解决
  12. 问题一:题目全部用完后怎么办?
  13. 问题二:倒计时精度问题
  14. 问题三:屏幕常亮
  15. 六、功能清单
  16. 七、不足与展望
  17. 八、结语
  • 💰 8折买阿里云服务器限时8折了解详情
  • GPT-5.5 超高智商模型1元抵1刀ChatGPT中转购买
  • 代充Chatgpt Plus/pro 帐号了解详情
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • Whisper 语音识别:零基础搭建个人 AI 语音助手
  • Claude Code Rules 配置实战:从基础到多语言管理
  • C++ STL 有序关联容器详解:set、map 及其变体用法
  • OpenClaw 开源 AI Agent 框架:架构解析与快速上手
  • 10款主流AI降重工具功能对比与使用指南
  • 使用 Trae IDE 将 Figma 设计稿转换为前端代码
  • CC-Switch 跨平台 AI 编码助手配置管理工具
  • 前端面试题精选:闭包、事件循环与 Vue 核心原理
  • 前端地图开发:主流 SDK 引入方式与初始化配置详解
  • AIGC 中的变分自编码器(VAE)代码与实现
  • Seedream 4.0 企业级图像生成能力与应用场景解析
  • Python 数据类实战:@dataclass 装饰器详解
  • Python 学习路线:从入门到精通的完整指南
  • Python 使用 FPDF 和 OpenPyXL 自动化生成 PDF 文档
  • 大模型 (LLMs) 面试准备指南:核心架构、训练微调与推理优化详解
  • DeepSeek 各版本详解:特性、优缺点与选型指南
  • OpenClaw 小白入门:定位、部署与核心场景
  • ESLint 实战指南:从原理到工程化落地
  • AI 大模型技术演进、应用生态与开发实践指南
  • Web 应用架构与安全漏洞学习框架

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online