打开网易新闻 查看精彩图片

一、引言

当你想做一个简单的手机游戏 , 比如 Flappy Bird、2048、贪吃蛇——你的第一反应可能是打开 Unity 或者 Godot。但你有没有想过:对于一个只需要画几个矩形和圆的游戏,你真的需要一个完整的游戏引擎吗?

引擎内部数十万行的 C++ 代码 带来的不只是便利, 或许还有冗余 。如果我们换一种思路 : 不用引擎,不依赖运行时,直接用一门现代语言编写游戏逻辑,编译为原生机器码,再搭配一个极简的图形库 , 结果会怎样?

这正是本文要探讨的命题 : 使用 MoonBit (一门编译到原生代码的现代语言)和 Raylib (一个仅提供最基本图形能力的 C 库),从零构建一个可以在 Android 手机上运行的 Flappy Bird。

在这个过程中,你会看到:一个完整的移动游戏,可以只有几百行代码、几 MB 的 APK,以及零引擎依赖。

Web 游戏试玩链接:

https://moonbit-community.github.io/tonyfettes-raylib-android-games

Android 游戏 APK 下载链接:

https://moonbit-community.github.io/tonyfettes-raylib-android-games/

二、移动游戏开发的技术选型 演进

2015年前后,手机游戏开发主要依赖三大技术路线:

  1. 引擎时代(Unity/Godot):提供一站式开发环境,大幅降低门槛,但存在包体臃肿、底层黑箱、版本更新风险等问题。Godot 虽开源,思路仍类似。

  2. 跨平台框架(React Native/Flutter):承诺一套代码多端运行,适合 UI 应用。但在游戏中暴露出额外抽象层、GC 停顿、非为高频渲染优化等性能瓶颈。

  3. 原生 NDK(C/C++):性能最优,无中间开销,但开发体验差,手动内存管理易引发段错误等 bug,对业余项目成本过高。

有没有一种方案,既能获得原生性能,又能享受现代语言的开发体验?

这正是 MoonBit 和 Raylib 的组合所提供的。

MoonBit 是一门为性能而设计的现代编程语言。它拥有强类型系统、模式匹配、类型推导,编写体验接近 Rust 或 OCaml,但编译目标是 C——这意味着它可以直接对接 Android NDK 的工具链,最终生成和手写 C 一样高效的原生代码。

Raylib 则是游戏图形库的极简主义代表。它不是引擎,不帮你管理场景,不提供编辑器——它只做四件事: 开窗口、画图形、读输入、放声音 。用户面对的核心 API 集中在一个头文件 raylib.h 中,没有复杂的依赖关系,没有状态机,没有回调地狱。

把它们组合在一起,你得到的是:

打开网易新闻 查看精彩图片

这不是说 MoonBit + Raylib 适合所有场景——如果你在做一款需要物理引擎、粒子系统、骨骼动画的大型游戏,Unity 仍然是更合理的选择。但如果你的目标是一款逻辑清晰的 2D 游戏,这套"极简主义"方案可能是最干净的路径。

让我们来看看它是怎么工作的。

三、理解构建链路:从源码到 APK

在动手写代码之前,有一个问题值得想清楚: 你写的 MoonBit 代码,是如何变成手机上可以运行的 APK 的?

理解构建链路不是为了背诵流程——而是为了在出问题时,知道该往哪里看。

1、 构建链路

整个过程可以用一条链来描述:

打开网易新闻 查看精彩图片

让我们逐步拆解。

第一步:MoonBit → C 。MoonBit 编译器将你的 `.mbt` 源文件编译为标准 C 代码。MoonBit 的强类型系统在编译期就排除了大量常见错误,生成的 C 代码是高效的、确定性的。你可以把它理解为:MoonBit 帮你写了人类不太愿意手写的那种高质量 C 代码。

第二步:C → .so 。Android NDK 中的交叉编译器(通常是 clang)接手,将 C 代码连同 Raylib 的源码一起编译为目标架构的共享库(`.so` 文件)。这一步和你用 NDK 编译任何 C/C++ 项目一样。

第三步:打包成 APK 。Gradle 构建系统将 .so 文件打包进 APK。同时,那个极轻量的 Kotlin 入口点(仅仅是加载库和隐藏系统 UI)会经过标准的 Android 编译流程:Kotlin 编译器将其编译为 JVM 字节码,再由 D8 工具转换为 Android 运行时使用的 classes.dex 。最终的 APK 结构非常简单:

APK
├── classes.dex ← 极小的 Kotlin 胶水代码
├── lib/
│ ├── arm64-v8a/
│ │ └── libflappybird.so ← 你的游戏 + Raylib
│ └── armeabi-v7a/
│ └── libflappybird.so
└── AndroidManifest.xml

如果用一个类比:传统引擎方案就像你写了一封信,然后把它装进一个带有自动翻译器、排版引擎和朗读功能的智能信封里寄出去。而 MoonBit + Raylib 的方案就像你直接把信折好,塞进一个普通信封——信的内容没有变,但信封轻了十倍。

2、 脚手架:一键搭建项目

理解了链路之后,实际操作反而很简单。MoonBit 生态提供了一个脚手架工具,可以一键生成上述所有构建配置:

moon install tonyfettes/create-moonbit-raylib-android-app
create-moonbit-raylib-android-app MyFlappyBird

生成的项目结构看起来像这样:

MyFlappyBird/
├── gradlew # Gradle 构建包装器
├── app/
│ ├── build.gradle.kts # Android 构建配置 (NDK, ABI 目标)
│ ├── src/main/
│ │ ├── AndroidManifest.xml # 应用清单 (NativeActivity)
│ │ ├── java/.../MainActivity.kt # 轻量 Kotlin 入口点
│ │ ├── moonbit/ # 你的游戏代码存放处
│ │ │ ├── main.mbt # 游戏代码
│ │ │ ├── moon.mod.json # MoonBit 模块配置
│ │ │ └── moon.pkg # 包声明
│ │ └── cpp/
│ │ └── CMakeLists.txt # 构建管道胶水代码
│ └── ...
└── gradle/

这里关键的只有一个目录: app/src/main/moonbit/ ——你的所有游戏逻辑都写在这里。其余的 Gradle 配置、CMake 文件、Kotlin 入口点,脚手架已经帮你处理好了。

模块配置( moon.mod.json )声明了对 Raylib 绑定的依赖:

{
"name": "username/myflappybird",
"version": "0.1.0",
"deps": {
"tonyfettes/raylib": "0.2.2"
},
"preferred-target": "native"
}

构建和部署也是一行命令:

cd MyFlappyBird
./gradlew assembleDebug --no-daemon

第一次构建需要几分钟(它会从源码编译 Raylib),之后的增量构建会快得多。你也可以在 Android Studio 中打开项目,点击 Run 按钮一键编译部署。

打开网易新闻 查看精彩图片

在运行时,轻量的 MainActivity 加载 .so 库,NDK 胶水代码启动原生端,Raylib 初始化 OpenGL ES 上下文,然后调用 main() ——也就是你用 MoonBit 写的那个 fn main 。

基础设施讲完了。现在让我们进入真正有趣的部分:游戏逻辑。

四、构建 Flappy Bird

1、游戏循环

从《超级马里奥》到《原神》,所有实时游戏在最底层都共享同一个结构——初始化(Init)、循环执行更新(Update)与绘制(Draw)、最后清理(Cleanup):

打开网易新闻 查看精彩图片

这就是 游戏循环(Game Loop) 。它揭示了实时游戏的本质: 游戏不是一系列事件的响应,而是一帧又一帧的持续模拟 。和 Web 应用的事件驱动模型不同,游戏代码每秒执行 60 次,无论用户是否操作——用户输入不是触发器,而是被每一帧"采样"的信号。

一个良好的游戏架构应该将 状态更新(update) 和 画面绘制(draw) 严格分离:update 只修改数据,draw 只读取数据,不存在交叉副作用。让我们用这个原则来构建 Flappy Bird。

2、定义游戏世界

首先,用结构体描述游戏中的所有对象:

///|
privstructBird {
muty : Float
mutvelocity : Float
}

///|
privstructPipe {
mutx : Float
mutgap_y : Float
mutscored : Bool
}

///|
privstructGame {
sw : Float
sh : Float
bird_x : Float
bird : Bird
bird_radius : Float
gravity : Float
jump_force : Float
pipes : Array[Pipe]
pipe_width : Float
gap_size : Float
pipe_speed : Float
pipe_spacing : Float
mut score : Int
mut game_over : Bool
}

Bird 只保存 每帧变化 的值(位置和速度),而 不变的属性 (水平位置、半径)由 Game 持有—— 可变状态越少,bug 越少 。每个 Pipe 记录水平位置 x 、空隙中心点 gap_y (开口的垂直中点)和计分标志 `scored`。

另一个值得关注的细节:所有大小都从屏幕尺寸( sw 、 sh )派生——鸟的半径是 sh / 25.0 ,重力加速度是 sh * 1.5 。这意味着游戏在任何分辨率的设备上都能保持相同的视觉比例和手感,不需要额外的适配逻辑。

3、游戏逻辑

update 函数处理所有状态变化——物理模拟、水管移动、碰撞检测和计分:

///|
fn update(game : Game, dt : Float) -> Unit {
if game.game_over {
if@raylib.is_gesture_detected(@raylib.GestureTap) {
reset(game)
}
return
}

if@raylib.is_gesture_detected(@raylib.GestureTap) {
game.bird.velocity = game.jump_force
}

game.bird.velocity += game.gravity * dt
game.bird.y += game.bird.velocity * dt

// 限制在屏幕边缘内
if game.bird.y < game.bird_radius {
game.bird.y = game.bird_radius
game.bird.velocity = 0.0
}
if game.bird.y > game.sh - game.bird_radius {
game.bird.y = game.sh - game.bird_radius
game.bird.velocity = 0.0
}

for pipe in game.pipes {
pipe.x -= game.pipe_speed * dt
// 水管滚出左边缘后回收到右侧
if pipe.x < -game.pipe_width {
pipe.x += Float::from_int(game.pipes.length()) * game.pipe_spacing
pipe.gap_y = random_gap_y(game)
pipe.scored = false
}

// AABB 碰撞检测
if game.bird_x + game.bird_radius > pipe.x &&
game.bird_x - game.bird_radius < pipe.x + game.pipe_width {
if game.bird.y - game.bird_radius < pipe.gap_y - game.gap_size / 2.0 ||
game.bird.y + game.bird_radius > pipe.gap_y + game.gap_size / 2.0 {
game.game_over = true
}
}

// 飞过水管时计分
if not(pipe.scored) && pipe.x + game.pipe_width < game.bird_x {
game.score += 1
pipe.scored = true
}
}
}

这段代码有几个值得注意的设计决策:

  • 帧率无关性 :所有涉及"随时间变化"的量都乘以 `dt`(自上一帧经过的秒数)。`game.bird.velocity += game.gravity * dt` 意味着"每秒增加 `gravity` 这么多速度"——无论设备是 60fps 还是 30fps,物理效果一致。

  • 对象回收 :整个游戏只有 4 个水管对象。当一根水管滚出左边缘,直接把 `x` 坐标加上偏移量"传送"到最右边,重新随机空隙位置。不需要对象池框架——一个 `if` 和一次坐标重置就够了。

  • AABB 碰撞检测 :将圆形小鸟近似为外接矩形,检测它与水管矩形是否重叠——先查水平方向重叠,再查小鸟是否在空隙之外。不是像素级精确,但对休闲游戏完全足够。

  • 游戏结束检查 :`update` 顶部的 `game_over` 检查拦截一切后续逻辑,让游戏"冻结"在撞击瞬间,只允许点击重启。

draw 函数只负责将当前状态绘制到屏幕:

///|
fn draw(game : Game) -> Unit {
@raylib.begin_drawing()
@raylib.clear_background(@raylib.skyblue)
for pipe in game.pipes {
let px = pipe.x.to_int()
let pw = game.pipe_width.to_int()
let gap_top = (pipe.gap_y - game.gap_size / 2.0).to_int()
let gap_bottom = (pipe.gap_y + game.gap_size / 2.0).to_int()
@raylib.draw_rectangle(px, 0, pw, gap_top, @raylib.darkgreen)
@raylib.draw_rectangle(
px,
gap_bottom,
pw,
@raylib.get_screen_height() - gap_bottom,
@raylib.darkgreen,
)
}
@raylib.draw_circle_v(
@raylib.Vector2::new(game.bird_x, game.bird.y),
game.bird_radius,
@raylib.yellow,
)
@raylib.end_drawing()
}

先画水管再画小鸟,确保小鸟总在最上层。所有绘图调用必须在 begin_drawing() 和 end_drawing() 之间。

辅助函数和初始化逻辑:

///|
fn random_gap_y(game : Game) -> Float {
Float::from_int(
@raylib.get_random_value(
(game.gap_size / 2.0 + 50.0).to_int(),
(game.sh - game.gap_size / 2.0 - 50.0).to_int(),
),
)
}


///|
fn reset(game : Game) -> Unit {
game.bird.y = game.sh / 2.0
game.bird.velocity = 0.0
game.score = 0
game.game_over = false
for i in 0..
game.pipes[i].x = game.sw + Float::from_int(i) * game.pipe_spacing
game.pipes[i].gap_y = random_gap_y(game)
game.pipes[i].scored = false
}
}

最后, main 将一切连接起来:

///|
fn main {
@raylib.init_window(0, 0, "Flappy Bird")
@raylib.set_target_fps(60)
@raylib.set_exit_key(0)
letsw = Float::from_int(@raylib.get_screen_width())
letsh = Float::from_int(@raylib.get_screen_height())

let game : Game = {
sw,
sh,
bird_x: sw * 0.2,
bird: { y: 0.0, velocity: 0.0 },
bird_radius:sh / 25.0,
gravity: sh * 1.5,
jump_force: sh * -0.65,
pipes: Array::make(4, fn() { { x: 0.0, gap_y: 0.0, scored: false } }),
pipe_width: sw / 8.0,
gap_size: sh / 4.0,
pipe_speed: sw * 0.4,
pipe_spacing:sw / 2.5,
score: 0,
game_over: false,
}
reset(game)

while not(@raylib.window_should_close()) {
let dt = @raylib.get_frame_time()
update(game, dt)
draw(game)
}
@raylib.close_window()
}

init_window(0, 0, ...) 表示使用屏幕全尺寸——在 Android 上就是全屏。游戏循环本身只有三行:获取 dt、更新、绘制。 is_gesture_detected(GestureTap) 同时响应触屏和鼠标点击,可以在桌面开发测试后无缝部署到手机。

构建并部署:

cd MyFlappyBird
./gradlew assembleDebug --no-daemon
adb install -r app/build/outputs/apk/debug/app-debug.apk

打开网易新闻 查看精彩图片

重力、水管、碰撞检测、计分、游戏结束和重启——全在一个文件、约 200 行代码里。没有引擎,没有运行时,没有框架。

五 、总结与展望

让我们回顾一下整个技术脉络。

我们从一个简单的问题出发: 一个休闲小游戏,真的需要一个完整的游戏引擎吗? 然后沿着"从重到轻"的路径,审视了移动游戏开发的几种技术选型——从引擎(Unity/Godot)到跨平台框架,再到原生 NDK,最终到达 MoonBit + Raylib 这个极简组合。

在构建链路层面,我们看到 MoonBit 编译到 C、C 通过 NDK 编译到 .so 、 .so 打包进 APK 的清晰路径——每一步都是确定性的,没有黑箱。

在游戏架构层面,我们理解了游戏循环这个"所有游戏的共同骨架",以及为什么 update/draw 分离、帧率无关的物理模拟是重要的设计原则。

在具体实现层面,我们用约 200 行代码构建了一个完整的 Flappy Bird,其中涉及了对象回收(穷人版对象池)和 AABB 碰撞检测等实用技巧。

这套方案不是万能的。如果你需要 3D 渲染管线、物理引擎、骨骼动画、热更新——使用 Unity 或 Godot 仍然是更务实的选择。但如果你的目标是一款轻量的 2D 游戏,追求的是 小包体、高性能、完全可控的代码 ,那么"做减法"的思路值得一试。

这里有一些可以继续探索的方向:

  • tonyfettes/raylib —— MoonBit 的 Raylib 绑定库,涵盖图形、纹理、音频、3D 模型、着色器等完整功能

  • selene —— 一个用 MoonBit 编写的实验性游戏引擎,支持 WebGPU 和 Raylib 后端,专为网页和原生游戏设计

  • MoonBit 文档 —— 语言详细文档

另外值得注意一点的是,本文中所有示例代码均由 AI 生成,甚至包括 Raylib 绑定库本身。我们利用 AI Agent 的 Subagent 并行化地在桌面、Web 和 Android 平台上产出了超过 150 款游戏。更多详情可参见 tonyfettes/raylib 下的 examples/ 目录。

从 Unity 的"给你一切"到 MoonBit + Raylib 的"只给你需要的",这不仅是技术选型的变化,更是一种开发哲学的转变—— 最好的代码不是写出来的,而是不需要写的 。