Godot 4 做安卓像素小游戏,我踩的 20 个坑 说明这个小游戏是和 AI 编程助手协作开发的本文也由 AI 协助整理成文。代码摘自项目源码有删节环境是 Godot 4.7、macOS、安卓 10 到 16 的几台手机和模拟器。前言上一篇讲了《林间小事》一个没有台词的点一点解谜小游戏的设计思路。这一篇只讲技术从第一天的原型到第三版用 Godot 4 做安卓 2D 像素游戏时踩过的坑。每一条都按“现象 → 原因 → 解决”写。很多坑在电脑上根本看不出来到了模拟器或真手机上才露出来所以最后单独说一下“怎么发现的”。一、渲染和显示1. 安卓模拟器上一片黑现象游戏在电脑上正常装到安卓模拟器里一片黑。日志里是Couldnt present to Vulkan queue (VkResult error 5)设备名是llvmpipe也就是软件模拟的 Vulkan。原因Godot 4 默认的 Mobile 渲染走 Vulkan模拟器给不出能用的 Vulkan。解决渲染方式改成兼容模式OpenGL ES 3两个设置都要改带.mobile的是手机上专用的覆盖项只改第一个手机上照样走 Vulkan[rendering] renderer/rendering_methodgl_compatibility renderer/rendering_method.mobilegl_compatibility2D 像素游戏本来就用不上 Vulkan 的那些特性兼容模式对老手机也更友好。2. 先画小图再放大字就糊了现象画面按 180×320 的基准分辨率画好再整体放大stretch 模式viewport像素画很清楚但“关于”页的许可声明糊成一片根本没法看。解决stretch 模式改成canvas_items。window/size/viewport_width180 window/size/viewport_height320 window/stretch/modecanvas_items window/stretch/aspectkeep_width rendering/textures/canvas_textures/default_texture_filter0 # 最近邻像素画不糊canvas_items下贴图照样按最近邻放大像素画一样清楚字按屏幕实际分辨率画清楚了动起来也更顺。3. 竖屏的长画面宽度要锁住手机有 16:9 的也有 20:9 的。aspectkeep_width让宽度永远是 180 个“像素”高出来的部分往上多露一截天空。游戏内容是底部对齐的所以不管多长的屏幕能点的东西都在同一个位置。二、点击和输入4. 点哪里都没反应现象模拟器上点哪里都没反应。原因铺满全屏的ColorRect天空底色、标题页的遮罩是界面控件mouse_filter默认是STOP点击被它吃掉了_unhandled_input根本收不到。为什么之前没发现电脑上一直用“自动玩”测脚本直接调用规则从来没经过真正的点击。到模拟器上用adb shell input tap点才露出来。解决输入改在_input里收纯装饰的底色一律不接点击fill.mouse_filter Control.MOUSE_FILTER_IGNORE5. 轻点按钮没反应现象“再玩一次”按钮点了没反应。原因等按钮的循环里每一帧去查Input.is_mouse_button_pressed。轻点时“按下”和“松开”在同一帧里就完成了查的那一刻已经松开了。解决所有按钮都走点击事件信号事件里只置一个标志循环去看这个标志。不要轮询按键状态来判断“点了没有”。6. 安卓返回键按一次收到两次现象在故事里按一次返回键直接跳过地图退到了标题页再按一次游戏退出了。原因一次返回键Godot 有时会收到两次NOTIFICATION_WM_GO_BACK_REQUEST。电脑上没有返回键测不出来模拟器上用adb shell input keyevent 4才看到。解决400 毫秒内的第二次不算func _notification(what: int) - void: if what NOTIFICATION_WM_GO_BACK_REQUEST: # 安卓的返回键按一次可能到两次只算一次 if Time.get_ticks_msec() - last_back 400: return last_back Time.get_ticks_msec() ...7. 收集品抢了别的东西的点击现象瓢虫藏在木棍旁边时点木棍会先捡到瓢虫。原因收集品的点击判定优先两样东西挨得太近手指一点先落到了瓢虫上。解决在测试里加一条“藏东西的候选位置离可以点的物件至少 8 像素”再写了个小脚本自动列出符合条件的空位摆位置时直接从里面挑。三、动画8. 报 “Infinite loop detected”现象从标题页进游戏后控制台刷Infinite loop detected。原因标题页关掉节点释放以后挂在主节点上的循环补间动画还在跑它的目标已经没了每一步都瞬间完成就成了死循环。解决循环动画用目标节点自己创建var t : n.create_tween() # 不是 get_tree().create_tween()也不是主节点的 create_tween() t.set_loops()节点释放时它的补间动画跟着一起没。9. “发抖”的效果把蚂蚁钉在了原地现象护送蚂蚁的小游戏里蚂蚁一步也走不动。原因“发抖”是用补间动画来回改position做的起点、终点在创建动画时就算死了。后来小游戏每一帧去移动蚂蚁下一帧又被发抖动画拉回原处。为什么测试没抓到规则测试只管状态不管画面照样全过。只有让游戏自己在真实场景里玩一遍才发现卡住了。解决发抖只晃图片的offset不碰position。教训装饰性的循环动画别碰会被别处改动的属性。四、数据驱动的故事10. 参数叫 “hide”整个小游戏被吞了现象拉绳子的小游戏根本没出现蛋直接回到了窝里没有任何报错。原因故事 json 里规则层看到效果里有hide这个键就当成“藏起物件”处理了而小游戏恰好有一个参数也叫hide。画面层也照这个理解去播小游戏就被跳过了。解决参数改名conceal测试里加一条检查动画步骤里不许出现give、take、show、hide、set这几个保留字。教训用 json 写逻辑就等于自己发明了一门小语言。保留字要有测试守着。11. GDScript 的:推断不出类型从字典、没有类型的数组里取出来的值:推断不出类型直接是编译错误不是警告var c d[want] var x : c # 编译错误 var x: String c # 写明类型12. 规则测试看不到画面脚本里的错现象扫落叶的小游戏脚本有一处拼写错误从来没编译通过可规则测试一直是全绿的。原因规则测试只加载规则层画面层的脚本根本没被加载过。解决在打包前的检查脚本里加一步“让每个故事在真实场景里自己玩一遍”无窗口、固定帧率、快进日志里出现脚本错误就算失败。godot--headless--audio-driver Dummy --fixed-fps30--quit-after5000\----autoplay--story$st--seed1然后在输出里找SCRIPT ERROR、Parse Error、Compile Error并确认它真的做了动作。现在打包前先跑这一个脚本规则测试 每个故事自动玩一遍。五、打包和装机13. Gradle 报Unsupported class file major version 69原因Android Studio 自带的 Java 已经是 25Godot 4.7 用的 Gradle 8.11 只认到 Java 23。解决单独装一个 Java 17编辑器设置里的export/android/java_sdk_path和构建脚本里的JAVA_HOME都指过去brewinstallopenjdk17exportJAVA_HOME/opt/homebrew/opt/openjdk17/libexec/openjdk.jdk/Contents/Home14. 每导出一次adb 就断一次现象每打一次安卓包adb就重启daemon not running; starting now同时连着的别的设备也跟着断开重连。解决导出和装机之间加adb wait-for-device连着好几台设备时用ANDROID_SERIAL或-s emulator-5554指定目标别误装到别的设备上。zsh 里还有个小坑把带空格的命令放进变量里执行时不会自动拆开要写成函数。15. 安装失败被自己藏起来了现象改了半天模拟器上看到的还是旧画面。原因脚本把adb install的输出丢掉了那一刻模拟器离线装的是旧包。解决安装结果一定打出来失败就停。16. USB 拉大文件就断真手机上录的 117 MB 屏幕录像一拉就掉线。后来改成在手机上只截图录像都在模拟器里录模拟器在本机几十兆一秒就拉完。六、录像和截图商店截图、演示视频都是用 Godot 自己的录制功能--write-movie一帧一帧录的不靠录屏软件。17. 窗口被挡住录出来的画面不动现象录了一段画面一直停在同一帧可日志显示游戏一直在走。用 AVI 格式时几秒后还会彻底冻住。原因macOS 对被别的窗口完全盖住的窗口不再重画Godot 照样一帧帧写文件写的全是同一张旧画面。解决录图片序列声音另存一个.wav并把窗口放到屏幕外面godot --write-movie out/frame.png--position6000,6000...放在屏幕外的窗口不算“被挡住”画面正常也不会挡住正在用电脑的人。18.--resolution和canvas_items不对付stretch 模式还是viewport的时候录像只录基准分辨率180×320改成canvas_items以后加--resolution反而只录到左上角四分之一。最后的办法不加这个参数按项目里设的窗口大小360×760录要 16:9 的截图就从下面截 360×640游戏底部对齐正好是 16:9 手机看到的那一块再整倍放大。七、声音19. 配乐太小声循环有接缝配乐是用代码写谱、转成 MIDI、再用 FluidSynth 加开源音色库渲染出来的。太小声一开始平均只有 −35 dB木琴那一轨更小−53 dB。FluidSynth 默认增益低音色库里各种乐器的响度也差很多。解决几轨一起算峰值统一放大到 −2 dB木琴单独再乘 3.5。循环有接缝曲子结尾的混响尾巴在循环回开头时被一刀切掉了。解决每一段渲染两遍、取第二遍这时候开头里已经带着上一遍的尾巴接上就没缝了。每个故事的配乐拆成几条同样长度的分轨用AudioStreamSynchronized一起播按故事进度淡入sync AudioStreamSynchronized.new() sync.stream_count stems.size() for i in range(stems.size()): var s: AudioStreamOggVorbis load(res://audio/%s_%s.ogg % [track, stems[i]]) s.loop true sync.set_sync_stream(i, s) sync.set_sync_stream_volume(i, 0.0 if layers[i] else SILENT_DB)几条分轨长度必须完全一样不然循环几圈以后就对不齐了。八、合规20. 许可声明直接从引擎里取游戏里唯一有字的地方是“关于”页而且它是必须的Godot 是 MIT 许可它带的第三方库、用的音色库都要求随作品附上版权声明。不用自己去抄引擎自己就能给t.append(Engine.get_license_text()) for c in Engine.get_copyright_info(): for part in c[parts]: t.append(%s — %s — %s % [c[name], , .join(part[copyright]), part[license]]) var info : Engine.get_license_info() for name in info: t.append(— %s — % name) t.append(info[name])顺带一个上架时的小坑商店文案里一开始写了“小朋友和老人都能玩”可目标受众按默认只勾了 18 岁以上。审核可能认为这是“吸引儿童”却没按家庭政策申报最后把文案改成了“说什么语言的人都能玩”。怎么发现的回头看这 20 个坑大部分有个共同点电脑上测不出来。点击被吃、轻点没反应、返回键两次都是用adb shell input tap、adb shell input keyevent 4在模拟器上模拟真实操作才看到的。小游戏被吞、动画钉住、脚本没编译规则测试全过让游戏在真实场景里自己玩一遍才发现。字糊、人物太小在真手机上才看出来。所以现在每次打包前的固定动作是规则测试每种随机组合都穷举一遍→ 每个故事在真实场景里自动玩一遍 → 模拟器上用 adb 点一遍 → 真手机上从头玩一遍。小结2D 像素游戏渲染用兼容模式stretch 用canvas_items。输入走事件不要轮询全屏的装饰控件别接点击安卓返回键要去重。循环动画由目标节点自己创建并且只碰没人会改的属性。用 json 写逻辑等于发明了一门小语言保留字要有测试守着规则测试之外还要让画面层真的跑一遍。装机和录像的脚本失败一定要打出来。这个游戏还在 Google Play 封闭测试里想帮忙测试的朋友可以看第一篇结尾的三步。下一篇讲这个游戏从写代码到上架是怎么让几个 AI 助手分工完成的。