Swift Digest
Blog | Swift.org Blog

Byte-sized Swift: Playdate 向けの小さなゲームを作る

Byte-sized Swift: Building Tiny Games for the Playdate

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

この記事の要点

背景: なぜ Embedded Swift なのか

Playdate のゲームは開発のしやすさから多くが Lua で書かれますが、性能上の問題から C の利用が必要になることがあります。Swift は高レベルな使い勝手と低レベルな性能を併せ持ち、C との相互運用も得意なため、Playdate と相性が良さそうに見えます。しかし、通常の Swift アプリとランタイムは、この機種の厳しいリソース制約を超えてしまいます。

そこで使うのが Embedded Swift です。Swift プロジェクトが制約の厳しいプラットフォーム向けに開発を進めている言語モードで、次の特徴により極小のバイナリを生成します。

これらにより Swift の中核機能を保ったまま、Playdate の制約に収まるサイズへ縮小できます。Embedded Swift は活発に進化しており、non-copyable types(SE-0390)typed throws(SE-0413) といった言語機能の開発も後押ししています。

作ったゲーム

Conway のライフゲーム

Playdate SDK のサンプルを Swift へ移植した 1 本目です。Swift ファイル 1 つで Playdate C API に対して直接ビルドでき、動的メモリ確保を必要としません。Playdate OS が提供するフレームバッファ上で直接動作し、ディスプレイ自体をゲーム状態として使うため、別途のデータ構造や動的確保が不要になっています。

C 版のビット操作・ポインタ操作には Swift の直接的な対応物があるため、移植は機械的に進みました。たとえば、あるビットが立っているかを調べる処理は次のように書けます。

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
  }
}

パッケージ後のサイズは 788 バイトで、C のサンプル(904 バイト)よりわずかに小さくなりました(どちらもサイズ最適化は追求していません)。

Swift Break

2 本目はパドルとボールのゲーム「Swift Break」です。スプラッシュ画面・ポーズメニュー・パドル位置に応じたバウンド物理・無限ステージ・ゲームオーバー画面を備え、十字キーまたはクランクでパドルを操作できます。

このゲームは、デスクトップやサーバーのアプリと同じ高レベル機能(関連値を持つ enum、ジェネリックな型・関数、自動メモリ管理)を活用してゲーム開発を簡潔にしつつ、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)
  }
}

深掘り: 新しいプラットフォームへ Swift を持ち込む

著者の方針は、Swift の相互運用性を活かして Playdate C SDK の上に構築することでした。必要な機能はツールチェインに既に揃っており、あとはそれらをどう組み合わせるかという問題でした。主なステップと、つまずいたポイントを順に見ていきます。

C API を import する

Playdate C SDK のヘッダを Swift から使うには、まず module map を書いてヘッダを import 可能なモジュールにまとめます。

module CPlaydate [system] {
  umbrella header "pd_api.h"
  export *
}

そのうえで、include 検索パス(-I)でヘッダの場所を、import 検索パス(-I)でモジュールの場所を Swift コンパイラに伝え、ヘッダのパースに必要な define(-DTARGET_EXTENSION)を渡します。Embedded Swift を有効にするには -enable-experimental-feature Embedded、サイズ最適化には -Osize -wmo を指定します。

シミュレータで動かす

シミュレータはホスト用ライブラリを動的にロードして動作するため、ホストのプラットフォーム・アーキテクチャ向けにオブジェクトファイルをビルドします(swiftc の既定の挙動)。ライフゲームを移植して pdc(Playdate コンパイラ)で pdx(Playdate 実行形式)にパッケージしたところ、最初はクラッシュしました。

原因は、ゲームを起動するためのシンボル _eventHandlerShim を含む setup.c(SDK 付属)をリンクし忘れていたことでした。このシンボルが無いとシミュレータは _eventHandler でフォールバック起動してしまい、重要な初期化がスキップされていました。setup.cclang でオブジェクトファイルにコンパイルしてリンクすると、無事に動作しました。

実機で動かす

実機向けには、まずデバイスのターゲットトリプル(armv7em-none-none-eabi)を指定します。すると C 標準ライブラリのヘッダ(stdlib.h など)が見つからずエラーになります。これはホスト向けビルドではホストのヘッダが使われていたためで、C のサンプルにならって Playdate SDK 同梱の gcc ツールチェインの libc ヘッダの include パスを追加して解決しました。

実機でもクラッシュしましたが、過去の Cortex-M7 への Swift 展開経験から 呼び出し規約(C calling convention)の不一致 だと見当が付き、-Xfrontend -experimental-platform-c-calling-convention=arm_aapcs_vfp を加えて Playdate OS の呼び出し規約に合わせると、ライフゲームが実機で動きました。最終的に、手作業のコンパイル手順を Playdate SDK の Makefile に統合し、make 一発でシミュレータと実機の両方に対応する pdx をビルドできるようにしています。

Swift で API を改善する

生の Playdate C API を Swift から直接使うのは扱いにくかったため、著者は API の使い勝手の改善に寄り道します。

1 つ目の課題は命名規則です。C の enum のケースは型名を接頭辞に付けがちですが、Swift では型チェックがあるため不要です。Swift には API notes(ヘッダを書き換えずに外付けでアノテーションを与える仕組み)があり、これを使ってケース名を Swift らしく改名できます。

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

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

2 つ目の課題はより深刻で、C API に nullability アノテーションが無いことでした。このため import されたコードに冗長な null チェックが入り、コードサイズと性能を悪化させます。通常なら API notes で補えますが、この C API は関数ポインタを持つ struct を「vtable」として使っており、これは現状 API notes で変更できません。そこで Optional.unsafelyUnwrapped を多用するという次善策を取らざるを得ず、可読性が大きく損なわれました。

// 冗長な null チェックが残る書き方
let spritePointer = playdate_api.pointee.sprite.pointee.newSprite()

// null チェックを取り除いた書き方(可読性が大きく低下)
let spritePointer = playdate_api.unsafelyUnwrapped.pointee.sprite.unsafelyUnwrapped.pointee.newSprite.unsafelyUnwrapped()

これを解決するため、C API の上に薄い Swift オーバーレイ を作りました。関数ポインタへのアクセスを Swift 型の静的/インスタンスメソッドに包み、get/set のペアをプロパティに変換することで、import された C 呼び出しと同等のままオーバーヘッドゼロで直感的に書けるようになります。

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

さらに、手動のメモリ管理が必要な API も自動化しました。たとえば C API の moveWithCollisions は、呼び出し側が解放すべき SpriteCollisionInfo のバッファを返しますが、オーバーレイ側でこれを隠蔽することで手動の解放が不要になります。

// オーバーレイなし
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 { ... }

// オーバーレイあり
sprite.moveWithCollisions(goalX: 10, goalY: 10) { actualX, actualY, collisions in
    for collision in collisions { ... }
}

著者は、Swift の ownership や non-copyable のサポートが進めば、言語側のオーバーヘッドなしにさらに使い勝手の良い C API 表現が可能になると見込んでいます。

実機で動かす(再び)

Swift Break を実機で動かそうとすると、再びクラッシュしました。前述の -Xfrontend フラグだけでは呼び出し規約の問題が完全には解決しておらず、マイクロコントローラの CPU と浮動小数点 ABI に合わせてコンパイラを構成する必要がありました(ライフゲームでは struct の値渡しも浮動小数点演算も使っていなかったため表面化しませんでした)。

最も厄介だったのは、Playdate OS から enum を返す API 呼び出しでのクラッシュです。原因は、gcc でビルドされたシステムと swiftc(内部の clang)でビルドしたゲームとの間で enum のメモリレイアウトが食い違っていた ことでした。gcc は既定で -fshort-enums を使う一方、armv7em-none-none-eabi トリプルでは swiftc 経由の clang-fno-short-enums を使っていたためです。-Xcc -fshort-enums をはじめ、CPU・FPU・浮動小数点 ABI を指定するフラグ群を加えて、ようやく Swift Break が実機で動作しました。

まとめ

呼び出し規約・CPU 設定・メモリレイアウトの食い違いといった移植時の難所は多くありましたが、いったん解決すれば、Playdate 向けゲームの Swift 開発は make 一発で済む快適なプロセスになります。本記事は、Swift がメモリ安全性と表現力を保ちながら、極めて制約の厳しい組み込み環境にも持ち込めることを示す実例です。コードと「Getting Started」ドキュメントは swift-playdate-examples で公開されています。

関連リンク