☰
Godot 4 做安卓像素小游戏,我踩的 20 个坑
2026/10/22 2:20:56 网站建设 项目流程

说明:这个小游戏是和 AI 编程助手协作开发的,本文也由 AI 协助整理成文。代码摘自项目源码(有删节),环境是 Godot 4.7、macOS、安卓 10 到 16 的几台手机和模拟器。

前言

上一篇讲了《林间小事》(一个没有台词的点一点解谜小游戏)的设计思路。这一篇只讲技术:从第一天的原型到第三版,用 Godot 4 做安卓 2D 像素游戏时踩过的坑。

每一条都按“现象 → 原因 → 解决”写。很多坑在电脑上根本看不出来,到了模拟器或真手机上才露出来,所以最后单独说一下“怎么发现的”。

一、渲染和显示

1. 安卓模拟器上一片黑

现象:游戏在电脑上正常,装到安卓模拟器里一片黑。日志里是:

Couldn't present to Vulkan queue (VkResult error 5)

设备名是llvmpipe,也就是软件模拟的 Vulkan。

原因:Godot 4 默认的 Mobile 渲染走 Vulkan,模拟器给不出能用的 Vulkan。

解决:渲染方式改成兼容模式(OpenGL ES 3),两个设置都要改(带.mobile的是手机上专用的覆盖项,只改第一个,手机上照样走 Vulkan):

[rendering] renderer/rendering_method="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility"

2D 像素游戏本来就用不上 Vulkan 的那些特性,兼容模式对老手机也更友好。

2. 先画小图再放大,字就糊了

现象:画面按 180×320 的基准分辨率画好再整体放大(stretch 模式viewport),像素画很清楚,但“关于”页的许可声明糊成一片,根本没法看。

解决:stretch 模式改成canvas_items。

window/size/viewport_width=180 window/size/viewport_height=320 window/stretch/mode="canvas_items" window/stretch/aspect="keep_width" rendering/textures/canvas_textures/default_texture_filter=0 # 最近邻,像素画不糊

canvas_items下,贴图照样按最近邻放大,像素画一样清楚;字按屏幕实际分辨率画,清楚了,动起来也更顺。

3. 竖屏的长画面,宽度要锁住

手机有 16:9 的,也有 20:9 的。aspect="keep_width"让宽度永远是 180 个“像素”,高出来的部分往上多露一截天空。游戏内容是底部对齐的,所以不管多长的屏幕,能点的东西都在同一个位置。

二、点击和输入

4. 点哪里都没反应

现象:模拟器上点哪里都没反应。

原因:铺满全屏的ColorRect(天空底色、标题页的遮罩)是界面控件,mouse_filter默认是STOP,点击被它吃掉了,_unhandled_input根本收不到。

为什么之前没发现:电脑上一直用“自动玩”测,脚本直接调用规则,从来没经过真正的点击。到模拟器上用adb shell input tap点才露出来。

解决:输入改在_input里收;纯装饰的底色一律不接点击:

fill.mouse_filter = Control.MOUSE_FILTER_IGNORE

5. 轻点按钮没反应

现象:“再玩一次”按钮,点了没反应。

原因:等按钮的循环里每一帧去查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--seed=1

然后在输出里找SCRIPT ERROR、Parse Error、Compile Error,并确认它真的做了动作。现在打包前先跑这一个脚本:规则测试 + 每个故事自动玩一遍。

五、打包和装机

13. Gradle 报Unsupported class file major version 69

原因:Android Studio 自带的 Java 已经是 25,Godot 4.7 用的 Gradle 8.11 只认到 Java 23。

解决:单独装一个 Java 17,编辑器设置里的export/android/java_sdk_path和构建脚本里的JAVA_HOME都指过去:

brewinstallopenjdk@17exportJAVA_HOME="/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"

14. 每导出一次,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 助手分工完成的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询