精简 Swift:为 Playdate 构建微型游戏

我很高兴分享 swift-playdate-examples,这是一个使用 Swift 为 Playdate 构建游戏的技术演示,Playdate 是 Panic 出品的一款掌上游戏机。

为什么选择 Swift?

Swift 广为人知,是 Apple 设备上应用程序开发的现代语言。然而,在其发展的第一个十年中,它已成长为一种通用的、多平台的语言,其目标用例通常会使用 C 或 C++。

就我个人而言,我开始欣赏 Swift 对内存安全和出色人体工程学的重视,并希望嵌入式系统也能拥有这些特性,因为在嵌入式系统中,可靠性和安全性至关重要。

但嵌入式系统不仅存在于任务关键型应用中。有些实际上充满了乐趣和游戏。

Panic 出品的 Playdate

在假期期间,我阅读了关于用 C 语言构建 Playdate 游戏的文章,并好奇是否可以用 Swift 实现同样的功能。对于那些不熟悉 Playdate 的人来说,它是由 Panic 打造的一款微型掌上游戏机,Panic 也是 “Transmit”、“Nova”、“Firewatch”、“Untitled Goose Game” 等流行应用和游戏的创造者。它搭载 Cortex M7 处理器、400x240 像素 1 位显示屏,并拥有一个用于托管游戏的小型运行时。此外,Panic 还提供了一个 SDK,用于使用 C 和 Lua 构建游戏。

虽然大多数 Playdate 游戏都是用 Lua 编写的,以便于开发,但它们可能会遇到性能问题,因此需要增加使用 C 语言的复杂性。Swift 将高级人体工程学与低级性能相结合,并对与 C 语言的互操作性提供强大支持,这使其看起来非常适合 Playdate。然而,典型的 Swift 应用程序和运行时超出了该设备严格的资源限制。

尽管如此,我仍然想用 Swift 创建一款游戏,并且我对方法有了一个好主意。

嵌入式语言模式

最近,Swift 项目开始开发一种新的嵌入式语言模式,以支持资源高度受限的平台。此模式利用泛型特化、内联和死代码消除来生成微小的二进制文件,同时保留 Swift 的核心功能。

注意:嵌入式语言模式正在积极发展,并正在推动语言功能(例如:不可复制类型类型化抛出等)的开发。它现在已在 每夜构建工具链中提供,如果你有兴趣了解更多信息,请查看 嵌入式 Swift 的愿景

这些定义性特征使嵌入式语言模式成为缩小 Swift 以适应 Playdate 限制的绝佳解决方案。

借助嵌入式 Swift 语言模式,我开始工作了。

游戏

我用 Swift 为 Playdate 写了两个小游戏。第一个游戏是将 Playdate SDK 中 康威生命游戏的示例移植到 Swift

A screenshot of the Playdate Simulator running Conway’s Game of Life.

这个游戏是一个 Swift 文件,它直接针对 Playdate C API 构建,并且不需要动态内存分配。打包后的游戏大小为 788 字节,略小于 C 示例的 904 字节。

$ wc -c < $REPO_ROOT/Examples/Life/Life.pdx/pdex.bin
     788

$ wc -c < $HOME/Developer/PlaydateSDK/C_API/Examples/GameOfLife.pdx/pdex.bin
     904

注意:两个版本可能都可以做得更小,但我没有尝试优化代码大小。

第二个游戏是一款名为 “Swift Break” 的挡板弹球类游戏。

A screenshot of the Playdate Simulator with the Swift Break splash screen.

Swift Break 使用了与你在桌面和服务器应用程序中找到的相同的高级语言特性,例如带关联值的枚举泛型类型和函数以及自动内存管理,以简化游戏开发,同时保持 C 级别的性能。

例如,这是处理球反弹的核心游戏逻辑

sprite.moveWithCollisions(goalX: newX, goalY: newY) { _, _, collisions in
  for collision in collisions {
    let otherSprite = Sprite(borrowing: collision.other)

    // If we hit a visible brick, remove it.
    if otherSprite.tag == .brick, otherSprite.isVisible {
      otherSprite.removeSprite()
      activeGame.bricksRemaining -= 1
    }

    var normal = Vector(collision.normal)

    if otherSprite.tag == .paddle {
      // Compute deflection angle (radians) for the normal in domain
      // -pi/6 to pi/6.
      let placement = placement(of: collision, along: otherSprite)
      let deflectionAngle = placement * (.pi / 6)
      normal.rotate(by: deflectionAngle)
    }

    activeGame.ballVelocity.reflect(along: normal)
  }
}

它调用 `moveWithCollisions` 方法来移动球,同时迭代球在移动时从其上反弹的对象集合。

Swift Break 具有启动画面、暂停菜单、基于挡板位置的反弹物理效果、无限关卡、游戏结束画面,并允许你使用方向键或曲柄控制挡板!

试用一下

如果你渴望在你的 Playdate 上使用 Swift,那么 swift-playdate-examples 仓库可以满足你的需求。它包含上述即用型示例,演示了如何为 Playdate 构建 Swift 游戏,包括模拟器和硬件。

此外,该仓库还包含详细的文档,指导你完成设置过程。无论你是经验丰富的开发人员还是刚入门,你都会找到将你的想法变为现实的必要资源。

但如果你有兴趣深入了解将 Swift 引入新平台所需的技术细节,请继续阅读!

深入探究:将 Swift 引入 Playdate

引入一个新平台总是充满挑战和令人恼火的错误。一切都支离破碎,经历了无数次错误的开始,直到你清除最后一个错误,然后一切才融为一体。让 Swift 游戏在 Playdate 上运行也不例外。

我的总体方法是利用 Swift 的互操作性,在 Playdate C SDK 之上构建。好消息是 Swift 工具链已经具备了我让它工作所需的所有功能。我只需要弄清楚如何将它们组合在一起。以下是我采取的路径的概述

废话不多说,让我们开始吧。

为 Playdate 模拟器构建目标文件

注意:下面提到的命令是在安装了 Swift 每夜构建工具链并设置了 TOOLCHAINS 环境变量为工具链名称的情况下运行的。

我的第一步是为 Playdate 模拟器编译一个目标文件。模拟器通过动态加载主机库来工作,因此我需要为主机平台和架构(编译器术语中所谓的 *triple*)构建目标文件,swiftc 默认会这样做。我唯一需要的其他标志是启用嵌入式 Swift 和代码优化。

$ cat test.swift
let value = 1

$ mkdir build

$ swiftc -c test.swift -o build/test.o \
    -Osize -wmo -enable-experimental-feature Embedded

$ file build/test.o
test.o: Mach-O 64-bit object arm64

导入 Playdate C API

下一步是从 Swift 编译 Playdate C API。由于 Playdate C 头文件的结构以及 Swift 对与 C 互操作的本机支持,这非常简单。

我首先找到 Playdate C 头文件

$ ls $HOME/Developer/PlaydateSDK/C_API/
Examples     buildsupport pd_api       pd_api.h

$ ls $HOME/Developer/PlaydateSDK/C_API/pd_api
pd_api_display.h     pd_api_gfx.h         pd_api_lua.h         pd_api_sound.h       pd_api_sys.h
pd_api_file.h        pd_api_json.h        pd_api_scoreboards.h pd_api_sprite.h

并使用 “include search path” (-I) 告诉 Swift 编译器的 C 互操作性功能在哪里找到它们。我还需要传递一个 “define” (-D) 来告诉编译器如何解析头文件

$ swiftc ... -Xcc -I -Xcc $HOME/Developer/PlaydateSDK/C_API/ -Xcc -DTARGET_EXTENSION

接下来,我创建了一个模块映射文件 将头文件包装成可从 Swift 导入的模块

$ cat $HOME/Developer/PlaydateSDK/C_API/module.modulemap
module CPlaydate [system] {
  umbrella header "pd_api.h"
  export *
}

并使用 “import search path” (-I) 告诉 Swift 编译器在哪里找到 CPlaydate 模块

$ swiftc ... -I $HOME/Developer/PlaydateSDK/C_API/

最后,我使用 Swift 中的 Playdate C API 创建了一个最小的 “库”,并使用上述标志进行了编译

$ cat test.swift
import CPlaydate
let pd: UnsafePointer<PlaydateAPI>? = nil

$ mkdir build

$ swiftc \
    -c test.swift \
    -o build/test.o \
    -Osize -wmo -enable-experimental-feature Embedded \
    -Xcc -I -Xcc $HOME/Developer/PlaydateSDK/C_API/ \
    -Xcc -DTARGET_EXTENSION \
    -I $HOME/Developer/PlaydateSDK/C_API/

$ file build/test.o
test.o: Mach-O 64-bit object arm64

在模拟器上运行

一旦我能够从 Swift 中使用 Playdate C API,我就将 Playdate SDK 中包含的康威生命游戏示例移植到 Swift,并经常参考 Inside Playdate with C 以熟悉 API。

康威生命游戏的 C 实现严格操作 Playdate OS 提供的帧缓冲区,并将显示器用作游戏状态,从而消除了对单独数据结构和动态分配的需求。因此,移植过程非常机械化,因为 C 示例中的位操作和指针操作在 Swift 中有直接的对应物

static inline int val(uint8_t* row, int x) {
    return 1 - ((row[x/8] >> (7 - (x%8))) & 1);
}

static inline int ison(uint8_t* row, int x) {
    return !(row[x/8] & (0x80 >> (x%8)));
}
struct Row {
  var buffer: UnsafeMutablePointer<UInt8>

  func value(at column: Int32) -> UInt8 {
    isOn(at: column) ? 1 : 0
  }

  func isOn(at column: Int32) -> Bool {
    let byte = buffer[Int(column / 8)]
    let bitPosition = 0x80 >> (column % 8)
    return (byte & bitPosition) == 0
  }
}

我将源代码构建成一个动态库,并使用 pdc(Playdate 编译器)将最终的 dylib 包装成 pdx(Playdate 可执行文件)。

$ swiftc \
    -emit-library test.swift \
    -o build/pdex.dylib \
    ...

$ file build/pdex.dylib
pdex.dylib: Mach-O 64-bit dynamically linked shared library arm64

$ $HOME/Developer/PlaydateSDK/bin/pdc build Test

$ ls Test.pdx
pdex.dylib pdxinfo

我使用 Playdate 模拟器打开了我的游戏文件 Test.pdx,正如你可能预料到的那样,它第一次就成功运行了……开玩笑的,它崩溃了!

经过一些调试后,我意识到用于编译 C 示例的 Makefile 包含 SDK 中的一个附加文件 setup.c,其中包含引导游戏所需的符号 _eventHandlerShim。如果二进制文件中不存在此符号,则模拟器会回退到使用符号 _eventHandler 来引导游戏,我的二进制文件确实包含该符号,但这意味着我的游戏跳过了一个重要的设置步骤。

因此,我使用 clangsetup.c 编译成一个目标文件,将其链接到我的动态库中,重新运行,瞧!我的用 Swift 编写的康威生命游戏在 Playdate 模拟器上运行起来了。

在硬件上运行

在模拟器上成功运行后,我想在真机硬件上运行游戏。一位同事慷慨地允许我借用他们的 Playdate,我开始着手研究。

我首先尝试匹配 C 示例用于设备的 triple,看看会发生什么。

$ swiftc ... -target armv7em-none-none-eabi
<module-includes>:1:10: note: in file included from <module-includes>:1:
#include "pd_api.h"
         ^
$HOME/Developer/PlaydateSDK/C_API/pd_api.h:13:10: error: 'stdlib.h' file not found
#include <stdlib.h>
         ^

这些错误以前没有发生,因为我的目标是主机,并使用了 C 标准库的主机头文件。我曾考虑为目标设备使用相同的主机头文件,但不想调试平台不兼容性问题。但我万万没想到,我最终还是不得不这样做。

相反,我决定遵循 C 示例程序使用的路线,即利用 Playdate SDK 安装的 gcc 工具链中的 libc 头文件。我复制了 C 示例使用的 include 路径,并重新运行了编译。

$ mkdir build

$ GCC_LIB=/usr/local/playdate/gcc-arm-none-eabi-9-2019-q4-major/lib

$ swiftc \
    -c test.swift \
    -o build/test.o \
    -target armv7em-none-none-eabi \
    -Osize -wmo -enable-experimental-feature Embedded \
    -I $HOME/Developer/PlaydateSDK/C_API/ \
    -Xcc -DTARGET_EXTENSION \
    -Xcc -I -Xcc $HOME/Developer/PlaydateSDK/C_API/ \
    -Xcc -I -Xcc $GCC_LIB/gcc/arm-none-eabi/9.2.1/include \
    -Xcc -I -Xcc $GCC_LIB/gcc/arm-none-eabi/9.2.1/include-fixed \
    -Xcc -I -Xcc $GCC_LIB/gcc/arm-none-eabi/9.2.1/../../../../arm-none-eabi/include

$ file build/test.o
test.o: ELF 32-bit LSB relocatable, ARM, EABI5 version 1 (SYSV), not stripped

编译成功了,我得到了一个用于真机硬件的目标文件。我经历了类似的步骤,使用 clang 作为链接器驱动程序,将目标文件链接和打包成 pdx

我将游戏部署到 Playdate 上,然后……它崩溃了。

出于某些原因,当帧更新函数指针被调用时,游戏会崩溃!起初,调试这个问题让人困惑,但由于过去在 Cortex M7 上部署 Swift 的经验,我意识到我可能遇到了调用约定不匹配的问题。我添加了一个编译器标志 -Xfrontend -experimental-platform-c-calling-convention=arm_aapcs_vfp,试图匹配 Playdate OS 使用的调用约定。

我再次将我的游戏部署到 Playdate,结果……它竟然真的可以运行了!您可以在下面看到游戏的实际运行效果

然后,我将手动编译步骤集成到 Playdate SDK 中的 Makefile 文件中,在最终确定 swift-playdate-examples 中的最终解决方案之前,我尝试了许多选项。这项努力的成果是一个单独的 make 命令,可以构建一个与模拟器和硬件都兼容的 pdx 文件!

使用 Swift 改进 API

在移植了康威生命游戏之后,我开始了一个更雄心勃勃的项目:一个名为 Swift Break 的打砖块风格游戏。然而,我很快发现直接在 Swift 中使用原始 Playdate C API 会遇到摩擦。按照典型的编程方式,我绕道而行,转而致力于 API 的人体工程学,而不是游戏本身!此时,我也引起了一些同事的兴趣,他们为进一步改进做出了贡献。

一个障碍是导入的 API 的命名约定。在 C 语言中,枚举用例通常以其类型名称为前缀,以防止程序员无意中混合不相关的枚举实例和用例常量。然而,在 Swift 中,这样的前缀是不必要的,因为编译器会对比较进行类型检查,以确保使用了正确的用例。

幸运的是,Swift 已经提供了解决这个精确问题的工具,称为 API 注解。我在 Playdate SDK 中添加了一个 API 注解文件,并将枚举用例重命名为更符合 Swift 习惯的名称

// Before
if event == kEventPause { ... }

// After
if event == .pause { ... }

然而,一个更大的问题是 C API 中缺少可空性注解。这意味着生成的代码到处都有冗余的空值检查,导致代码体积膨胀并损害性能。虽然我通常会使用 API 注解来添加缺失的注解,但这在这里是行不通的。C API 使用带有函数指针的结构体作为“虚函数表”,但不幸的是,这些目前无法通过 API 注解进行修改。由于这种不兼容性,我不得不采取一种次优的解决方案:在整个 Swift 代码中大量使用 Optional.unsafelyUnwrapped

虽然这种方法消除了空值检查,但它极大地损害了可读性

// C API in Swift with redundant null checks
let spritePointer = playdate_api.pointee.sprite.pointee.newSprite()

// C API in Swift without redundant null checks
let spritePointer = playdate_api.unsafelyUnwrapped.pointee.sprite.unsafelyUnwrapped.pointee.newSprite.unsafelyUnwrapped()

为了解决可读性问题,我在 C API 之上创建了一个精简的 Swift 覆盖层。我将函数指针访问包装到 Swift 类型的静态方法和实例方法中,并将函数 get/set 对转换为 Swift 属性。创建精灵变得更加直观,并且在等效的导入 C 调用之上引入了零开销。

var sprite = Sprite(bitmapPath: "background.png")
sprite.collisionsEnabled = false
sprite.zIndex = 0
sprite.addSprite()

同事们进一步改进了覆盖层,将需要手动内存管理的 Playdate API 抽象为自动处理。一个很好的例子是 C API 的 moveWithCollisions 函数,它返回一个 SpriteCollisionInfo 结构体的缓冲区,该缓冲区必须由调用者释放。使用覆盖层使我们能够避免手动释放缓冲区,并使 API 更易于使用

// moveWithCollisions without the overlay
var count: Int32 = 0
var actualX: Int32 = 0
var actualY: Int32 = 0
let collisionsStartAddress = playdate_api.pointee.sprite.pointee.moveWithCollisions(sprite, 10, 10, &actualX, &actualY, &count)
let collisions = UnsafeBufferPointer(start: collisionsStartAddress, count: count)
defer { collisions.deallocate() }
for collision in collisions { ... }

// moveWithCollisions with the overlay
sprite.moveWithCollisions(goalX: 10, goalY: 10) { actualX, actualY, collisions in
    for collision in collisions { ... }
}

这些改进极大地简化了为 Playdate 编写代码的过程。此外,随着 Swift 对所有权和不可复制类型的支持得到改进,我预计 C API 将会以更符合人体工程学的方式表示,而不会产生语言开销。

完成 Swift Break

有了经过改进的 Swift Playdate API,我回到了 Swift Break 的开发。

我很快就掌握了基本原理,但还是忍不住添加了额外的功能,只是为了好玩。其中一个亮点是实现了基于球和球拍碰撞位置来偏转球反弹的基本逻辑。

此功能需要计算相对于代表圆形球拍的假设曲线的法向量,然后将球的速度围绕法向量反射。以下是预期行为的可视化效果

注意:讽刺的是,为这篇文章制作动画帮助我找到了弹跳逻辑中的一个错误。在某些入射角和法线角的组合下,当前的设计可能会导致球向下弹到球拍中,而不是向上弹起。

为了将数学转化为算法,我必须执行以下步骤

  1. 检查球碰撞的对象是否是球拍
  2. 计算碰撞点在球拍上的位置,范围从 -1 到 +1
  3. 将位置映射到偏转角,范围从 -π/6 到 +π/6
  4. 将碰撞法向量旋转偏转角
  5. 沿旋转后的法向量反射球的速度

然后,我直接将此算法翻译成球碰撞回调中的代码

if otherSprite.tag == .paddle {                                // 1
  let placement = placement(of: collision, along: otherSprite) // 2
  let deflectionAngle = placement * (.pi / 6)                  // 3
  normal.rotate(by: deflectionAngle)                           // 4
}
ballVelocity.reflect(along: normal)                            // 5

再次在硬件上运行!

在“Swift Break”的整个开发过程中,我定期将游戏部署到 Playdate 模拟器。然而,当我决定在实际的 Playdate 硬件上运行游戏时,真正的挑战出现了。像往常一样,我加载了游戏,结果……又一次崩溃了,但这次很多地方都出了问题。

长话短说,我发现前面提到的 -Xfrontend 标志并没有完全解决调用约定问题。为了解决这个问题,我需要配置编译器以匹配 Playdate 中微控制器的 CPU 和浮点 ABI。当我移植康威生命游戏时,由于我碰巧既没有按值传递结构体,也没有使用浮点运算,因此忽略了这一方面。

最终也是最令人困惑的崩溃,源于一个特定的 Playdate C API 调用,该调用从 Playdate OS 返回一个枚举。经过彻底的调试过程,例如到处使用 printf,我发现使用 gcc 构建的系统和使用 swiftc 构建的游戏之间,枚举的内存布局存在差异。经过进一步研究,我发现差异源于 gcc 默认使用 -fshort-enums,而 swiftc 通过 clangarmv7em-none-none-eabi 三元组使用了 -fno-short-enums

我将这些新的和移除的标志收集到以下编译命令中

$ swiftc \
    -c test.swift \
    -o build/test.o \
    -target armv7em-none-none-eabi \
    -Osize -wmo -enable-experimental-feature Embedded \
    -I $HOME/Developer/PlaydateSDK/C_API \
    -Xcc -D__FPU_USED=1 \
    -Xcc -DTARGET_EXTENSION \
    -Xcc -falign-functions=16 \
    -Xcc -fshort-enums \
    -Xcc -mcpu=cortex-m7 \
    -Xcc -mfloat-abi=hard \
    -Xcc -mfpu=fpv5-sp-d16 \
    -Xcc -mthumb \
    -Xcc -I -Xcc $HOME/Developer/PlaydateSDK/C_API/ \
    -Xcc -I -Xcc $GCC_LIB/gcc/arm-none-eabi/9.2.1/include \
    -Xcc -I -Xcc $GCC_LIB/gcc/arm-none-eabi/9.2.1/include-fixed \
    -Xcc -I -Xcc $GCC_LIB/gcc/arm-none-eabi/9.2.1/../../../../arm-none-eabi/include \
    -Xfrontend -disable-stack-protector \
    -Xfrontend -experimental-platform-c-calling-convention=arm_aapcs_vfp \
    -Xfrontend -function-sections

通过这些调整,我再次尝试,最终“Swift Break” 成功地在 Playdate 硬件上运行了!我在下面附上了一段简短的视频,展示了这款游戏

总结

感谢您与我一起深入了解这次启动之旅。从改进 Swift Playdate API,到解决涉及调用约定、CPU 配置和内存布局差异的问题,挑战可谓层出不穷。

然而,随着问题现在得到解决,使用 Swift 创建 Playdate 游戏已成为一个简化的过程。只需运行 make,即可享受 Swift 带来的富有表现力高性能的开发体验。

您可以在 swift-playdate-examples 存储库中找到本文中提到的所有代码示例,并附带“入门”文档。

我希望这篇文章能鼓励您探索在非常规环境中使用 Swift 的可能性。欢迎在 Swift 论坛 上分享您的经验、问题或游戏想法!

祝您编码愉快! 🎮