
1. 为什么要在Rokid AIUI上复刻一个推箱子第一次拿到Rokid AIUI的开发权限时我脑子里冒出来的第一个念头不是做语音助手也不是搞什么智能家居中控而是想把我小学时候在文曲星上玩的那个推箱子给搬上来。原因很简单推箱子这个游戏规则足够简单但逻辑又足够严谨特别适合用来验证一个交互平台的输入输出能力。你想想一个游戏要跑起来至少得解决画面渲染、按键或语音输入、状态管理、关卡数据这几个核心问题而Rokid AIUI恰好提供了语音加触控的双通道交互还有一套还算完整的UI组件体系拿它来练手再合适不过。推箱子这个游戏英文叫Sokoban1981年由日本人今林宏行设计出来规则一句话就能说清楚把所有箱子推到目标点上就算过关。但玩过的人都知道这游戏烧脑程度一点不低有时候一步走错就得重来。我选它还有一个私心就是它的关卡数据可以用纯文本表示不需要什么复杂的资源文件对于我这种不想在素材上花太多时间的人来说简直是福音。一个字符代表一个格子井号是墙点是目标位美元符号是箱子星号是箱子已经在目标位上是玩家加号是玩家站在目标位上。就这么几个符号整个游戏世界就搭起来了。那为什么是Rokid AIUI而不是别的平台这里我得说几句实在话。Rokid AIUI这套东西本质上是一个面向智能眼镜和带屏语音设备的应用开发框架它跟普通的Android开发最大的区别在于它把语音交互提到了非常高的优先级。你可以在应用里定义各种语音指令用户说一句话就能触发对应的操作。对于推箱子这种需要频繁输入方向指令的游戏来说语音控制其实是一个很有意思的尝试。你想象一下戴着眼镜嘴里喊“上、下、左、右”箱子就跟着动这种体验在传统手机上还真做不到。当然语音输入有延迟和误识别的问题所以我也保留了触控和按键操作作为备选方案这个后面会详细说。另外一点Rokid AIUI的屏幕通常不大分辨率也跟手机不太一样这就逼着我在UI布局上做减法。推箱子的画面本身就很简洁不需要花里胡哨的特效正好契合这种小屏幕设备的显示特点。我试过在手机上玩一些复刻版的推箱子画面做得太精致反而分散注意力不如这种像素风格的来得纯粹。所以从平台适配的角度来看Rokid AIUI和推箱子这个组合算是互相成就。还有一个很现实的原因就是我想看看在一个以语音为主的平台上一个纯逻辑游戏能做到什么程度。市面上大部分AIUI应用都是工具类的查天气、设提醒、放音乐游戏类的少之又少。推箱子虽然简单但它涉及到状态机、碰撞检测、撤销回退这些游戏开发的基础概念如果能跑通后面再做更复杂的游戏就有底了。我甚至想过如果这个推箱子做成了下一步可以试试做数独或者华容道逻辑都是相通的。说回项目本身我给它定的目标很明确第一完整实现推箱子的核心玩法包括移动、推箱、过关判定、撤销第二接入Rokid AIUI的语音指令系统让用户可以用语音控制方向第三做好关卡数据的组织方便后续扩展第四在AIUI的屏幕上保证画面清晰可读不出现错位或者遮挡。这四个目标看起来简单但实际做下来每个环节都有坑。比如语音指令的唤醒词怎么设置才不会跟系统指令冲突比如撤销功能的数据结构怎么设计才能既省内存又快速回退比如关卡地图的坐标系怎么定义才能让碰撞检测不出错。这些细节我后面会一个一个拆开来讲。如果你也是一个对Rokid AIUI感兴趣或者想找个练手项目来熟悉这个平台的开发者我觉得推箱子是一个非常好的起点。它不像做一个完整的语音助手那么复杂但又能让你把AIUI的几大核心能力都摸一遍。而且推箱子这个游戏本身有几十年的历史网上的关卡资源一抓一大把你不用担心做完一关就没得玩了。接下来我就从整体设计思路开始把这个项目的来龙去脉讲清楚。2. 整体架构与核心设计思路拆解2.1 游戏状态机的设计逻辑推箱子这个游戏表面上看是玩家在推箱子实际上背后是一个状态机在运转。我一开始想得比较简单觉得不就是个二维数组嘛玩家移动就改数组里的值箱子移动就再改一次。但真正动手写的时候才发现如果没有一个清晰的状态管理机制代码很快就会变成一团乱麻。比如玩家推箱子的时候需要同时更新玩家位置、箱子位置、目标点状态还要判断是否过关这些操作如果散落在各个地方后面加撤销功能的时候就会非常痛苦。所以我做的第一件事就是把整个游戏的状态抽象成一个对象。这个对象里包含几个核心字段地图数据、玩家坐标、箱子坐标列表、目标点坐标列表、步数计数、历史记录栈。地图数据我用的是一个二维字符数组每个格子存一个字符跟前面说的文本表示法一一对应。玩家坐标和箱子坐标是单独存的不跟地图数据混在一起这样做的原因是地图上的墙和目标点是静态的而玩家和箱子的位置是动态的分开存储可以让碰撞检测更清晰。状态机的核心方法有三个移动、推箱、撤销。移动方法接收一个方向参数然后计算出玩家下一步的位置。如果那个位置是墙直接返回失败如果是空地或者目标点就更新玩家坐标如果是箱子就进入推箱逻辑。推箱逻辑会再往那个方向看一格如果是墙或者另一个箱子推不动返回失败如果是空地或者目标点就更新箱子坐标和玩家坐标。每次成功的操作都会往历史记录栈里压一条记录记录内容包括操作前的玩家坐标、操作前的箱子坐标、操作类型。撤销的时候就从栈顶弹出一条记录把状态恢复回去。这个设计的好处是所有状态变更都集中在一个地方调试的时候只需要看状态机的方法调用就能定位问题。我试过把状态散落在各个UI回调里结果就是改一个地方忘了改另一个地方箱子飞到墙外面去了。所以如果你也要做类似的项目我强烈建议先把状态机搭好再去做UI。2.2 关卡数据的组织与加载方式关卡数据这块我选择用纯文本文件来存储每个关卡一个文件文件名就是关卡编号。文件内容就是前面说的那种字符画比如第一关可能是这样的##### # # # $ # # # #####这种格式的好处是直观你打开文件就能看到关卡长什么样改起来也方便。加载的时候我写了一个解析函数逐行读取文件内容把每个字符映射到对应的游戏元素上。墙就是墙点就是目标点美元符号就是箱子就是玩家。解析完之后我会遍历一遍地图把玩家坐标、箱子坐标、目标点坐标分别提取出来存到状态对象里。这里有一个细节需要注意地图的坐标系。我一开始用的是行列坐标行是y轴列是x轴结果在写移动逻辑的时候老是搞混。后来我统一改成用x表示列y表示行跟数学上的笛卡尔坐标系保持一致代码就清晰多了。比如向上移动就是y减一向下就是y加一向左是x减一向右是x加一。这个看起来是小事但如果不统一后面写碰撞检测的时候会非常容易出错。另外关卡文件我建议放在assets目录下通过资源管理器读取。Rokid AIUI的资源加载机制跟标准Android差不多用AssetManager打开文件流然后逐行读取就行。如果你想让关卡可以动态更新也可以考虑放在外部存储目录但那样就需要处理权限问题对于一个小游戏来说有点得不偿失。我目前的做法是把关卡打包在应用里后续如果要加新关卡就更新应用版本。2.3 语音交互与触控操作的融合方案Rokid AIUI最吸引我的地方就是语音交互但推箱子这个游戏对操作的实时性要求比较高语音识别有延迟有时候你说完“上”箱子要等半秒才动体验上会打折扣。所以我采取了一个折中的方案语音和触控并行用户想用哪个就用哪个。语音指令我定义了八个分别是“上、下、左、右、撤销、重来、下一关、上一关”。触控方面我在屏幕下方放了一个虚拟方向键同时支持滑动手势往上滑就是上往下滑就是下以此类推。语音指令的注册在AIUI的配置文件中完成每个指令对应一个意图意图触发后调用状态机的对应方法。这里有一个坑就是语音指令的唤醒词不能跟系统指令冲突。我一开始用了“上”这个单字作为指令结果发现系统有时候会把它识别成其他指令的一部分。后来我改成了“向上移动”、“向下移动”这样的双字词识别准确率就上去了。如果你也要做语音控制建议指令词至少两个字而且尽量用不常见的组合避免跟系统指令撞车。触控操作这块方向键的布局我调整了好几次。最开始用的是十字键放在屏幕右下角但AIUI的屏幕比较小十字键占地方而且容易误触。后来改成了四个独立的按钮排成一排放在底部每个按钮的点击区域放大到至少48dp这样手指点起来比较准。滑动手势是后来加的因为有些用户可能觉得按按钮麻烦直接滑一下更自然。滑动手势的判定逻辑是记录手指按下和抬起时的坐标差如果横向位移大于纵向位移就判断为左右滑动反之就是上下滑动。阈值我设的是50像素太小了容易误判太大了又滑不动。语音和触控的融合还有一个问题就是状态同步。比如用户正在用语音控制突然又去点按钮这时候如果两个操作同时触发可能会导致状态错乱。我的做法是在状态机里加了一个操作锁同一时间只允许一个操作在执行执行完之后才释放锁。这样虽然会稍微增加一点延迟但能保证状态的一致性。实测下来这个锁对体验的影响几乎可以忽略不计因为推箱子的操作频率本来就不高。3. 核心细节解析与实操要点3.1 地图渲染的性能优化与适配技巧地图渲染这块我一开始用的是最笨的办法每个格子用一个TextView或者ImageView然后往GridLayout里塞。结果第一关只有五行五列还好到了后面十几行十几列的关卡屏幕上要放一两百个View滑动的时候明显卡顿。后来我换成了自定义View直接在Canvas上画性能一下子就上来了。自定义View的核心是重写onDraw方法在里面遍历地图数组根据每个格子的类型画不同的图形。墙我画的是深灰色方块目标点画的是空心圆箱子画的是棕色方块加一个叉玩家画的是蓝色圆形加两个眼睛。这些图形都是用Canvas的基本绘图方法画的不需要图片资源省去了加载图片的开销。实测下来即使地图扩大到三十行三十列帧率也能稳定在60帧。这里有一个细节需要注意地图的缩放和居中。AIUI的屏幕分辨率跟手机不一样如果直接按像素画地图可能会超出屏幕或者缩在角落里。我的做法是先计算屏幕的宽高然后根据地图的行列数算出每个格子的边长取宽高比中较小的那个作为格子边长这样能保证地图完整显示在屏幕内。然后计算地图的整体宽高跟屏幕宽高做差除以二就是偏移量把地图画在正中间。这个计算过程我封装成了一个方法每次屏幕尺寸变化或者地图切换的时候调用一次。还有一个优化点是脏矩形刷新。推箱子每次操作只影响玩家和箱子的位置不需要重绘整个地图。我一开始没注意这个每次操作都调用invalidate()重绘全部结果在低端设备上会有闪烁。后来改成了只重绘发生变化的区域用invalidate(Rect)指定刷新范围闪烁问题就解决了。具体做法是记录操作前后的玩家和箱子位置计算出最小包围矩形然后只刷新这个矩形区域。这个优化对于小地图来说效果不明显但大地图下提升很大。3.2 碰撞检测与推箱逻辑的精确实现碰撞检测是推箱子游戏的核心逻辑也是最容易出bug的地方。我总结下来碰撞检测需要处理三种情况玩家撞墙、玩家撞箱子、箱子撞墙或撞箱子。这三种情况的判断顺序很重要如果顺序错了就会出现玩家穿墙或者箱子重叠的问题。我的实现逻辑是这样的首先根据方向计算出玩家下一步的坐标然后判断这个坐标在地图上的字符是什么。如果是墙直接返回失败玩家不动。如果是空地或者目标点更新玩家坐标操作成功。如果是箱子进入推箱判断。推箱判断会再往同一个方向看一格如果那一格是墙或者另一个箱子推不动返回失败。如果是空地或者目标点更新箱子坐标和玩家坐标操作成功。这里有一个容易忽略的细节箱子推到目标点上之后地图上的字符要从美元符号变成星号表示箱子已经在目标位上。如果箱子从目标点上被推走星号要变回美元符号。这个状态切换如果不处理好过关判定就会出错。我的做法是在每次推箱之后检查箱子所在位置是否是目标点如果是就把箱子标记为已完成过关判定的时候只需要检查所有箱子是否都已完成。还有一个边界情况是玩家站在目标点上。这时候地图上的字符是加号移动的时候需要把加号变回点把新位置变成加号。这个逻辑跟箱子类似但更容易被忽略。我一开始就忘了处理这个结果玩家站在目标点上移动之后目标点就消失了导致关卡无法完成。后来加了一个判断如果玩家当前位置是目标点移动后要把当前位置恢复成点新位置如果是目标点就变成加号否则就是玩家符号。3.3 撤销功能的数据结构选择与实现撤销功能是推箱子游戏里非常重要的一个功能因为很多时候玩家需要试错没有撤销的话体验会很差。我一开始想用命令模式每个操作封装成一个命令对象撤销的时候执行反向操作。但后来发现推箱子的操作不是简单的可逆操作因为推箱会同时改变玩家和箱子的位置反向操作需要记录两个位置的变化用命令模式反而复杂了。最后我选择用快照栈的方式。每次操作之前把当前的玩家坐标和箱子坐标列表复制一份压入栈中。撤销的时候从栈顶弹出快照恢复玩家和箱子的位置。这种方式的优点是实现简单不容易出错缺点是内存占用会随着操作步数增加而增加。不过推箱子的操作步数一般不会太多一关下来也就几十步到几百步每个快照只存几个坐标内存占用完全可以接受。快照的数据结构我用的是一个简单的类里面包含玩家x坐标、玩家y坐标、箱子坐标列表。箱子坐标列表我用的是ArrayList每次复制的时候新建一个ArrayList把原来的坐标逐个复制进去。这里要注意深拷贝和浅拷贝的问题如果直接赋值两个列表会指向同一个对象撤销的时候就会出问题。我一开始就踩了这个坑撤销之后箱子位置没变排查了半天才发现是浅拷贝的问题。栈的容量我设了一个上限比如500步超过之后就把栈底的记录丢弃。这样做是为了防止极端情况下内存溢出虽然实际游戏中很少会超过这个数。另外我还加了一个重来功能直接把状态恢复到关卡初始状态清空历史栈。这个功能在玩家卡关的时候很有用比一步步撤销快多了。4. 实操过程与核心环节实现4.1 开发环境搭建与项目初始化Rokid AIUI的开发环境搭建跟标准Android开发差不多但有几个地方需要特别注意。首先你需要在Rokid的开发者网站上注册账号创建一个应用拿到AppKey和AppSecret。这两个东西在后面接入语音服务的时候会用到所以要保管好。然后下载Rokid AIUI的SDK解压之后把jar包和so库放到项目的libs目录下在build.gradle里配置好依赖。项目初始化的时候我建议直接用一个空的Activity模板不要用带Fragment的模板因为推箱子的界面很简单不需要复杂的导航结构。在AndroidManifest.xml里需要声明AIUI的权限和服务比如录音权限、网络权限还有AIUI的Service声明。这些在官方文档里都有说明照着配就行。有一点要注意的是AIUI的语音服务需要在应用启动时初始化我是在Application的onCreate里调的初始化方法这样能保证语音功能在Activity启动之前就准备好。编译的时候可能会遇到so库不兼容的问题因为AIUI的so库有多个架构版本你需要根据目标设备的CPU架构来选择。我一开始把所有的so库都打进去了结果APK体积大了不少后来只保留了armeabi-v7a和arm64-v8a两个版本体积就降下来了。如果你不确定设备架构可以用adb命令查一下或者干脆都保留反正现在手机存储空间都够大。4.2 关卡解析与状态初始化的代码实现关卡解析的代码我写了一个工具类核心方法是一个静态方法接收一个InputStream返回一个GameState对象。方法内部用BufferedReader逐行读取把每一行转成字符数组然后遍历字符数组识别出玩家、箱子、目标点的位置。这里有一个细节就是地图的宽高可能不一致有些行末尾有空格有些行没有。我的做法是取所有行中最长的作为地图宽度短的行用空格补齐这样能保证地图是一个规则的矩形。状态初始化的代码在GameState的构造函数里接收解析好的地图数据然后遍历一遍把玩家坐标、箱子坐标、目标点坐标分别提取出来。箱子坐标我用的是一个列表因为一关可能有多个箱子。目标点坐标也是列表过关判定的时候需要检查每个目标点上是否都有箱子。这里有一个优化点就是可以把目标点坐标存到一个Set里判断某个位置是否是目标点的时候直接用contains方法比遍历列表快。不过对于小地图来说这点性能差异可以忽略不计。初始化完成之后我会调用一次渲染方法把地图画到屏幕上。渲染方法接收Canvas和地图数据遍历每个格子根据字符类型画对应的图形。这里要注意坐标转换地图数组的索引是行和列而Canvas的坐标是x和y行对应y列对应x转换的时候不要搞反了。我一开始就搞反了结果地图画出来是转置的排查了好一会儿才发现问题。4.3 语音指令注册与意图处理流程语音指令的注册在AIUI的配置文件中完成配置文件是一个JSON格式的文件里面定义了各种意图和对应的指令词。我定义了八个意图分别是move_up、move_down、move_left、move_right、undo、restart、next_level、prev_level。每个意图下面配置指令词比如move_up的指令词是“向上移动”、“往上走”、“上移”。指令词可以配多个AIUI会自动做语义匹配用户说任何一个都能触发。意图处理是在Activity里实现的AIUI的SDK提供了一个回调接口当识别到意图时会回调onIntent方法参数里包含意图名称和槽位信息。我在onIntent方法里根据意图名称调用状态机的对应方法然后刷新界面。这里有一个坑就是语音识别的回调是在子线程里执行的而更新UI必须在主线程。我一开始直接在回调里调用了invalidate()结果程序崩溃了。后来用runOnUiThread包了一层问题就解决了。还有一个细节是语音指令的反馈。用户说完指令之后如果游戏没有反应会以为指令没识别到。所以我加了一个语音播报的反馈每次操作成功之后用TTS播报一个简短的提示音或者语音比如“向上移动成功”或者“推不动”。这样用户就能知道指令有没有生效。TTS的初始化也是在Application里做的跟AIUI的初始化放在一起。4.4 触控方向键与滑动手势的响应处理触控方向键我用的是自定义View在onTouchEvent里处理按下和抬起事件。按下的时候记录按下的按钮抬起的时候判断是否在同一个按钮内如果是就触发对应的移动操作。按钮的绘制是在onDraw里完成的用Canvas画四个圆角矩形里面画上箭头。按钮的颜色我用了半透明的灰色这样不会遮挡地图。滑动手势的判定逻辑是记录手指按下的坐标和抬起的坐标计算横向和纵向的位移差。如果横向位移的绝对值大于纵向位移的绝对值并且大于阈值就判断为左右滑动反之就是上下滑动。阈值我设的是50像素这个值是根据屏幕密度调整的在代码里用dp转px的方法动态计算。滑动的时候我加了一个视觉反馈就是在地图上画一个短暂的箭头指示让用户知道滑动被识别到了。触控和语音的冲突处理前面提过就是加一个操作锁。具体实现是在GameState里加一个boolean类型的isOperating字段操作开始的时候设为true操作结束的时候设为false。如果操作锁被占用新的操作请求就直接丢弃。这个锁的粒度是单次操作不是整个游戏过程所以不会影响连续操作。实测下来这个机制能有效防止语音和触控同时触发导致的状态错乱。5. 常见问题与排查技巧实录5.1 语音识别不准与误触发问题的排查语音识别不准是AIUI开发中最常见的问题之一。我遇到的情况主要有两种一种是指令词识别不出来另一种是识别成了别的指令。第一种情况通常是因为指令词太短或者太常见比如“上”这个字系统可能识别成“商”、“尚”等谐音字。解决办法是把指令词改成双字词或者三字词比如“向上移动”这样识别准确率会高很多。另外指令词最好避免跟系统指令重复比如“返回”、“确定”这些词系统已经占用了你再定义就会冲突。第二种情况是误触发就是用户没说话但系统识别到了指令。这个通常是因为环境噪音或者麦克风灵敏度太高。我试过在代码里加一个置信度阈值只有置信度高于某个值才执行操作。AIUI的识别结果里有一个confidence字段我设的阈值是0.7低于这个值就忽略。这个阈值可以根据实际环境调整安静环境下可以设低一点嘈杂环境下设高一点。还有一个排查技巧是看日志。AIUI的SDK会输出详细的识别日志包括识别到的文本、置信度、耗时等信息。你可以在Android Studio的Logcat里过滤AIUI的tag看看每次识别到底识别到了什么。我一开始不知道看日志瞎猜了半天后来看了日志才发现是识别成了谐音字。所以遇到语音问题第一件事就是看日志。5.2 地图渲染错位与闪烁的解决方案地图渲染错位通常是因为坐标系转换出了问题。我遇到过一次地图画出来之后玩家和箱子的位置都对但墙的位置偏了。排查了半天发现是地图数组的索引跟Canvas坐标的转换写反了。地图数组是map[row][col]row是行col是列而Canvas的坐标是(x, y)x是列y是行。所以画的时候应该是canvas.drawRect(col * cellSize, row * cellSize, ...)而不是反过来。这个错误很隐蔽因为如果地图是正方形转置之后看起来差不多只有长方形的地图才会明显错位。闪烁问题通常是因为重绘太频繁或者重绘区域太大。我前面提过用invalidate(Rect)指定刷新区域能有效减少闪烁。另外如果你的自定义View开启了硬件加速有时候也会导致闪烁可以尝试在View的构造函数里调用setLayerType(LAYER_TYPE_SOFTWARE, null)关闭硬件加速。不过关闭硬件加速会影响性能所以只在确实出现闪烁的时候才用。还有一个可能导致闪烁的原因是双缓冲没开。Android的View默认是开双缓冲的但如果你在onDraw里做了复杂的操作比如创建对象、加载图片可能会导致双缓冲失效。我的建议是在onDraw里只做绘图操作不要创建新对象所有需要的对象都在构造函数或者初始化方法里创建好。这样能保证onDraw的执行时间足够短双缓冲才能正常工作。5.3 撤销功能失效与状态不一致的修复撤销功能失效通常是因为快照没有正确保存或者恢复。我遇到过一次撤销之后玩家位置回去了但箱子位置没变。排查发现是快照里的箱子坐标列表是浅拷贝跟当前状态共享了同一个列表对象。操作的时候修改了当前状态的列表快照里的列表也跟着变了撤销的时候恢复的就是修改后的列表自然不对。解决办法是在保存快照的时候做深拷贝新建一个ArrayList把坐标逐个复制进去。还有一种情况是撤销之后状态不一致比如玩家站在了墙上或者箱子重叠了。这个通常是因为快照保存的时机不对。我的做法是在操作执行之前保存快照而不是操作之后。这样撤销的时候恢复的就是操作之前的状态逻辑上更清晰。如果操作失败了比如推不动就不保存快照也不压栈。这样能保证栈里的每个快照都对应一次成功的操作。另外重来功能也会影响撤销栈。我的做法是重来的时候清空撤销栈因为重来之后的状态跟之前的历史没有关系了保留历史反而会让用户困惑。如果你想让重来也可以撤销那就把重来也当成一次操作保存快照。但我觉得没必要重来本身就是一种放弃当前进度的操作再撤销回去意义不大。5.4 关卡数据格式错误的快速定位方法关卡数据格式错误是新手最容易遇到的问题。常见的错误包括地图不是矩形、玩家数量不对、箱子数量跟目标点数量不匹配、出现了非法字符等。我的做法是在解析关卡的时候加一系列校验如果校验不通过就抛出异常并在日志里打印出具体是哪一行哪一列出了问题。这样排查起来就很快。比如地图不是矩形这个问题我会在解析完所有行之后检查每行的长度是否一致如果不一致就报错并指出哪一行的长度不对。玩家数量不对的问题我会统计和的数量如果大于1就报错。箱子数量跟目标点数量不匹配的问题我会统计$和*的数量之和以及.和的数量之和如果两者不相等就报错。这些校验虽然简单但能拦截大部分格式错误。还有一个技巧是用一个可视化的关卡编辑器来生成关卡数据。网上有一些开源的推箱子关卡编辑器你可以用它们来画地图然后导出成文本格式。这样比手写字符画要准确得多也不容易出错。我自己是用一个简单的Python脚本读取文本文件然后在终端里用彩色字符打印出来检查地图是否正确。这个方法虽然土但很有效。5.5 常见问题速查表问题现象可能原因排查方法解决方案语音指令无响应指令词未注册或冲突查看AIUI日志中的意图识别结果修改指令词避免与系统指令重复语音识别成其他指令指令词太短或谐音查看日志中的识别文本和置信度改用双字词提高置信度阈值地图渲染错位坐标系转换错误检查行列与xy的对应关系统一用x表示列y表示行画面闪烁重绘区域过大或硬件加速用GPU渲染分析工具查看使用invalidate(Rect)局部刷新撤销后状态不对快照浅拷贝或保存时机错误检查快照对象的引用关系深拷贝坐标列表操作前保存快照关卡加载失败地图格式错误检查每行长度和字符类型加校验逻辑打印错误位置推箱逻辑错误碰撞检测顺序不对逐步调试打印每一步的坐标按墙、空地、箱子的顺序判断操作锁死操作锁未释放检查异常路径是否释放了锁用try-finally保证锁一定释放这个表格是我在实际开发过程中总结出来的基本上涵盖了推箱子游戏开发中会遇到的大部分问题。如果你遇到了表格里没有的问题我的建议是先在状态机里加日志把每一步操作的输入和输出都打印出来然后对照日志分析是哪一步出了问题。推箱子的逻辑是确定的只要输入正确输出一定正确所以问题通常出在输入或者状态保存上。6. 一些个人体会和后续扩展思路这个推箱子项目我断断续续做了大概两周大部分时间花在调试语音识别和渲染适配上。说实话Rokid AIUI这个平台在游戏开发方面并不是最顺手的选择它的强项在于语音交互和轻量级应用做游戏算是有点跨界。但正是这种跨界让我对这个平台有了更深入的理解。比如语音指令的设计不能照搬触控的思维要考虑用户说话的节奏和习惯。再比如屏幕适配不能假设用户拿着一个标准尺寸的手机要考虑到眼镜设备的小屏幕和特殊视角。如果让我给后来者提建议我会说先把核心逻辑跑通再去接语音和UI。我一开始就是急着接语音结果核心逻辑还没稳定语音识别一触发就崩溃排查起来非常痛苦。后来我把状态机单独抽出来写了一个命令行版本的推箱子在电脑上跑通了所有关卡确认逻辑没问题了再移植到AIUI上效率高了很多。这个经验我觉得对任何平台开发都适用就是先把业务逻辑跟平台解耦再去做平台适配。后续扩展的话我有几个想法。一个是加一个关卡编辑器让用户自己画地图然后分享给其他人。这个功能在AIUI上可能有点复杂因为屏幕小画地图不方便但可以做一个简化版的比如随机生成关卡。另一个是加一个排行榜记录每关的最少步数增加一点竞技性。这个需要联网和服务器支持工作量比较大但技术上不难。还有一个是加背景音乐和音效推箱子的音效其实很重要箱子归位的时候来一声清脆的提示音成就感会强很多。最后再说一个我在这个项目里学到的小技巧善用日志和断点。推箱子这种逻辑密集的游戏光靠看代码很难发现问题一定要在关键路径上加日志把每一步的状态都打出来。我后来养成了一个习惯每次操作之后都把玩家坐标、箱子坐标、步数打印出来这样一旦出现异常一眼就能看出是哪一步开始不对的。这个习惯帮我省了很多调试时间也推荐给你。