从一个尴尬的春节聚会说起:我用 Rokid AR 眼镜做了个聚会游戏助手

从一个尴尬的春节聚会说起:我用 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("com.google.android.material:material:1.11.0") implementation("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 } // 如果全部用完了,就从所有题目中随机选 returnif(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(ActivityCompat.checkSelfPermission(adapter.javaClass, 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("眼镜未连接")returnfalse} // 先激活提词器场景 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)returnfalsereturnif(cxrApi.sendTtsContent(text)== ValueUtil.CxrStatus.REQUEST_SUCCEED){ cxrApi.notifyTtsAudioFinished()true}elsefalse}

第五步:主界面逻辑

主界面 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 }returnif(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 的封装做得不错,让开发者可以专注于业务逻辑,而不用关心底层通信细节。提词器场景的设计也很巧妙,非常适合这类需要私密显示内容的应用。
如果你也有类似的想法,不妨动手试试。代码量不大,但成就感满满。
项目源码:PartyGameHelper/
相关资源:
CXR-M SDK 官方文档
Rokid 开发者论坛

Read more

解锁AIGC新时代:通义万相2.1与蓝耘智算平台的完美结合引领AI内容生成革命

解锁AIGC新时代:通义万相2.1与蓝耘智算平台的完美结合引领AI内容生成革命

前言 通义万相2.1作为一个开源的视频生成AI模型,在发布当天便荣登了VBench排行榜的榜首,超越了Sora和Runway等业内巨头,展现出惊人的潜力。模型不仅能够生成1080P分辨率的视频,而且没有时长限制,能够模拟自然动作,甚至还可以还原物理规律,这在AIGC领域中简直堪称革命性突破。通过蓝耘智算平台,我们能够轻松部署这个模型,创建属于自己的AI视频生成工具。今天,我将为大家深入探讨通义万相2.1的强大功能,并分享如何利用蓝耘智算平台快速入门。 蓝耘智算平台 1. 平台概述 蓝耘智算平台是一个为高性能计算需求设计的云计算平台,提供强大的计算能力与灵活服务。平台基于领先的基础设施和大规模GPU算力,采用现代化的Kubernetes架构,专为大规模GPU加速工作负载而设计,满足用户多样化的需求。 2. 核心优势 * 硬件层: 蓝耘智算平台支持多型号GPU,包括NVIDIA A100、V100、H100等高性能显卡,能够通过高速网络实现多机多卡并行计算,突破单机算力瓶颈。 * 软件层: 集成Kubernetes与Docker技术,便于任务迁移与隔离;支持PyTo

VSCode Github Copilot使用OpenAI兼容的自定义模型方法

VSCode Github Copilot使用OpenAI兼容的自定义模型方法

背景 VSCode 1.105.0发布了,但是用户最期待的Copilot功能却没更新!!! (Github Copilot Chat 中使用OpenAI兼容的自定义模型。) 🔥官方也关闭了Issue,并且做了回复,并表示未来也不会更新这个功能: “实际上,这个功能在可预见的未来只面向内部人员开放,作为一种“高级”实验功能。是否实现特定模型提供者的功能,我们交由扩展作者自行决定。仅限内部人员使用可以让我们快速推进,并提供一种可能并非始终百分之百完善,但能够持续改进并快速修复 bug 的体验。如果这个功能对你很重要,我建议切换到内部版本 insider。” 🤗 官方解决方案:安装VSCode扩展支持 你们完全不用担心只需要在 VS Code 中安装扩展:OAI Compatible Provider for Copilot 通过任何兼容 OpenAI 的提供商驱动的 GitHub Copilot Chat,使用前沿开源大模型,如 Kimi K2、DeepSeek

Whisper语音识别:零门槛打造个人专属语音转文字系统

Whisper语音识别:零门槛打造个人专属语音转文字系统 【免费下载链接】whisper-base.en 项目地址: https://ai.gitcode.com/hf_mirrors/openai/whisper-base.en 还在为繁琐的音频整理工作而头疼?OpenAI Whisper语音识别技术为您带来革命性的解决方案。这款强大的本地语音转文字工具能够将各类音频文件快速转换为可编辑文本,无论是会议记录、学习资料还是创作内容,都能轻松应对。 技术优势:为何选择Whisper语音识别 隐私安全第一 所有音频处理均在本地完成,无需上传云端,彻底保护您的数据隐私和商业机密。 多语言智能识别 支持99种语言的自动识别和转换,包括中文、英文、日语、法语等主流语言,还能实现语言间的智能翻译。 高精度转录效果 基于深度学习的先进算法,即使在复杂环境下也能保持出色的识别准确率。 快速部署:三分钟完成环境搭建 系统要求检查 确保您的设备满足以下条件: * Python 3.8或更高版本 * 至少4GB可用内存 * 支持的操作系统:Windows、macOS、Linux

Local Moondream2精彩案例分享:Stable Diffusion用户提示词优化前后对比

Local Moondream2精彩案例分享:Stable Diffusion用户提示词优化前后对比 让你的电脑拥有"眼睛",一键生成专业级绘画提示词 1. 引言:当AI绘画遇到"描述困难症" 很多Stable Diffusion用户都遇到过这样的困境:脑子里有很棒的创意画面,但就是不知道该怎么用文字描述出来。要么描述得太简单,生成效果不尽人意;要么描述得太复杂,AI反而理解偏差。 这就是Local Moondream2的价值所在——它就像一个专业的"视觉翻译官",能够看懂你的图片,然后用AI绘画最理解的语言,生成精准详细的英文提示词。 本文将通过多个真实案例,展示Local Moondream2如何将普通用户的简单描述,优化成专业级的绘画提示词,让你亲眼见证提示词优化前后的惊人差异。 2. 什么是Local Moondream2? 2.1 你的本地视觉助手 Local Moondream2是一个基于Moondream2构建的超轻量级视觉对话Web界面。简单来说,它能让你的电脑拥有"眼睛"