【Pyxel】Python向けレトロゲームエンジンをRustで動かす

# はじめに

こんにちは！　中2のAwashAmityOakです！　今回は、<font color="red">**PythonのレトロゲームエンジンであるPyxelを、Rustで動かす**</font>方法をまとめます。

# Pyxelとは？

|　|
|:-:|
|![pyxel_message.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3742876/89443d30-0263-de5c-021f-9ba13148b849.png "Pyxel")|
|<font color="gray">https://github.com/kitao/pyxel/blob/main/docs/images/pyxel_message.png より</font>|

[Pyxel](https://github.com/kitao/pyxel)は、<font color="red">**Python向けのレトロゲームエンジン**</font>です。日本人の[kitao](https://twitter.com/kitao)さんという方が作られたので、<font color="red">**日本語ドキュメントが完備**</font>されています。2023年12月に、バージョン2.0.0を迎え、その後もアップデートが続いています。

Pyxelのライブラリのコア部分は、<font color="red">**Rust**</font>で書かれていて、[crates.io](https://crates.io/search?q=pyxel)にも公開されているので、`Cargo.toml`に依存関係を書き込むことで、<font color="red">**Rustで使用することができます**</font>。

# 動作環境

- OS: macOS 10.15.7
- CPU: Intel Core i7
- メモリ: 8GB
- `Homebrew 4.2.11`
- `SDL2 2.30.1`
- `rustc 1.76.0`
- `cargo 1.76.0`

# 手順

今回は、Rustで、Pyxelのexamplesの[`Hello, Pyxel!`](https://kitao.github.io/pyxel/wasm/examples/01_hello_pyxel.html)を動かすことを目標にします。(Pythonのソースコードは[こちら](https://github.com/kitao/pyxel/blob/main/python/pyxel/examples/01_hello_pyxel.py)です。)

:::note warn
RustはPCにインストール済みであることを前提とします。インストールしていない場合は、[公式ページ](https://www.rust-lang.org/)から、インストールしてください。(ググればインストールについての情報はたくさん出てきます。)
:::

:::note warn
(2024年3月18日追記)
macOS 11より古いバージョンをお使いの方は、記事後半の「[動かない場合は](#動かない場合は2024年3月18日追記)」を一度お読みください。
:::


## 1. プロジェクト作成

普通のRustプロジェクトと同じように、`cargo new`を使用して、<font color="red">**プロジェクトを作成**</font>します。

```shell
$ cargo new hello_pyxel
$ cd hello_pyxel
```

ディレクトリ構成は以下のようになります。

```
hello_pyxel
├── .gitignore
├── Cargo.toml
└── src
    └── main.rs
```

## 2. 依存関係の解決

:::note warn
Pyxelのコア部分は、`v1.8.2`から`v1.9.18`は[`pyxel-core`](https://crates.io/crates/pyxel-core)というcrateで管理されてます。また、`v1.5.1`から`v1.8.2`、そして`v2.0.0`以降は[`pyxel-engine`](https://crates.io/crates/pyxel-engine)というcrateで管理されています。

私の環境では、最新の`pyxel-engine`をコンパイルするときに、
```
ld: symbol(s) not found for architecture x86_64
```
というエラーが発生してしまったので、この記事では旧バージョンのコアである`pyxel-core`を使っていきます。
:::

### 1. SDL2のインストール

macOSで、`pyxel-core`を使う場合には、[SDL2](https://www.libsdl.org/)が必要となります。今回は、[Homebrew](https://brew.sh/ja/)を用いて、インストールしていきます。(macOS以外の環境については、[Rust-SDL2公式ドキュメント](https://github.com/Rust-SDL2/rust-sdl2)を参照してください。)

```shell
$ brew install sdl2
```

を実行し終わった後、

```shell
$ brew info sdl2
```

と実行して、出力の最初の行に

```
==> sdl2: stable 2.30.1, HEAD
```

などと表示されていればOKです。

### 2. `Cargo.toml`の編集

`Cargo.toml`に、

```Cargo.toml
pyxel-core = "1.9.18"
```

と追記します。

もしくは、`cargo add`を使用して、

```shell
$ cargo add pyxel-core
```

とCLIで実行しても良いです。

## 3. 画像ファイルの用意

PyxelのGitHubにある[ロゴ画像](https://github.com/kitao/pyxel/blob/main/python/pyxel/examples/assets/pyxel_logo_38x16.png)をダウンロードします。ダウンロードが完了したら、

```
hello_pyxel
├── .gitignore
├── Cargo.toml
├── assets
│   └── pyxel_logo_38x16.png
└── src
    └── main.rs
```

となるように、`./assets/pyxel_logo_38x16.png`に配置してください。

## 4. プログラムの記述

### 1. 必要なAPIのuse宣言

`pyxel::PyxelCallback`というトレイトをuseします。

```rust:main.rs
use pyxel::PyxelCallback;
```

### 2. `App`構造体の作成

[Pythonコード](https://github.com/kitao/pyxel/blob/main/python/pyxel/examples/01_hello_pyxel.py)での`App`クラスにあたるものです。

```rust:main.rs
struct App {}
```

今回は特に作成すべきフィールドがないので、空の構造体を作ります。

### 3. `init()`メソッドの実装

`impl`を使って、`App`構造体に`init()`メソッドを実装していきます。

```rust:main.rs
impl App {
    fn init() {
        pyxel::init(
            160,
            120,
            Some("Hello Pyxel"),
            None,
            None,
            None,
            None,
            None,
        );

        pyxel::image(0).lock().load(0, 0, "assets/pyxel_logo_38x16.png");

        let app = App {};
        pyxel::run(app);
    }
}
```

Pythonでの`pyxel.init()`関数には、<font color="red">**デフォルト引数**</font>があり、特に指定しない場合は省略できました。しかし、Rustの`pyxel::init()`関数では、ちゃんと<font color="red">**すべての引数に`None`を渡して**</font>あげる必要があります。

イメージのロードについても、Pythonと少し異なる点があります。Rustでの`pyxel::image()`の戻り値は、<font color="red">**ミュータブルな値を指す、参照カウント形式のスマートポインタ**</font>です。そのため、Rustの特徴の一つである<font color="red">**スレッド安全性**</font>を確保するために、`lock()`メソッドを実行する必要があります。これは、他のタイルマップやサウンド、ミュージックについても同様です。(難しい話なので、わからない人は、「イメージやサウンドを扱うときは、<font color="red">**`lock()`をつけなければいけない**</font>んだな」くらいに思っておいてください。)

### 4. `update()`メソッドの実装

`update()`メソッドは、[先ほど](#1-必要なapiのuse宣言)`use`宣言した`PyxelCallback`トレイトを用いて実装します。

```rust:main.rs
impl PyxelCallback for App {
    fn update(&mut self) {
        if pyxel::btnp(pyxel::KEY_Q, None, None) {
            pyxel::quit();
        }
    }
    // ここには`draw()`メソッドの実装が入ります。
)
```

`pyxel::btnp()`関数にも、`None`を渡してあげなければいけない引数があります。

### 5. `draw()`メソッドの実装

`draw()`メソッドも、`update()`メソッドと同様に、`PyxelCallback`トレイトを用いて実装します。

```rust:main.rs
impl PyxelCallback for App {
    // ここには`update()`メソッドの実装が入ります。
    fn draw(&mut self) {
        pyxel::cls(0);

        pyxel::text(55.0, 41.0, "Hello, Pyxel!", (pyxel::frame_count() % 16).try_into().unwrap());

        pyxel::blt(0.0, 0.0, 0, 0.0, 0.0, 8.0, 8.0, None);
    }
)
```

`pyxel::text()`に渡す引数の型変換に気をつけてください。

### 6. `main()`関数の実装

[Pythonコード](https://github.com/kitao/pyxel/blob/main/python/pyxel/examples/01_hello_pyxel.py)での最後の`App()`というところに対応します。

```rust:main.rs
fn main() {
    App::init();
}
```

<details><summary>ソースコードすべて</summary>

```rust:main.rs
use pyxel::PyxelCallback;

pub struct App {}

impl App {
    fn init() {
        pyxel::init(
            160,
            120,
            Some("Hello Pyxel"),
            None,
            None,
            None,
            None,
            None,
        );

        pyxel::image(0).lock().load(0, 0, "assets/pyxel_logo_38x16.png");

        let app = App {};
        pyxel::run(app);
    }
}

impl PyxelCallback for App {
    fn update(&mut self) {
        if pyxel::btnp(pyxel::KEY_Q, None, None) {
            pyxel::quit();
        }
    }

    fn draw(&mut self) {
        pyxel::cls(0);

        pyxel::text(55.0, 41.0, "Hello, Pyxel!", (pyxel::frame_count() % 16).try_into().unwrap());

        pyxel::blt(61.0, 66.0, 0, 0.0, 0.0, 38.0, 16.0, None);
    }
}

fn main() {
    App::init();
}

```

</details>

## 5. コンパイルと実行

```shell
$ cargo run
```

を走らせて、プログラムをコンパイル＆実行します！

こんな`Hello, Pyxel!`が表示されましたか？

|　|
|:-:|
|![hello_pyxel.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3742876/defbbfea-7fc7-adf7-b324-de02b86ce66c.gif "実行結果")|
|<font color="gray">`Hello, Pyxel!`の実行結果</font>|

### 動かない場合は(2024年3月18日追記)

古いバージョンのmacOSだと、リンクが上手く働かずに、コンパイルできない場合があるみたいです。ここでは、若干ゴリ押し気味ではありますが、対処法をお伝えしようと思います。

<details><summary>長いので折り畳みました</summary>

#### 1. Pyxel v1.9.18をクローン

適当なディレクトリ内で、

```shell
$ git clone https://github.com/kitao/pyxel.git -b v1.9.18
```

「ブランチ作ってないよ〜」みたいな警告が出るので、気になる人は`git switch -c [ブランチ名]`で作ってあげてください。(ここからコミットするつもりはないので、私は特に気にしてません。)

#### 2. `./crates/pyxel-core/Cargo.toml`を書き換える

`pyxel-core`crateの`Cargo.toml`を編集します。具体的には、

```diff_toml:./crates/pyxel-core/Cargo.toml(一部)
  [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
  sdl2 = { version = "0.35", default-features = false, features = [
      "bundled",
-     "static-link",
+     # "static-link",
      "unsafe_textures",
  ] }
```

のように、`"static-link",`というfeatureを無効にします。

コマンドでやる場合は、`pyxel`ディレクトリに移動した後、こちらを実行してください。

```shell
$ sed 's/"static-link",/# "static-link",/' crates/pyxel-core/Cargo.toml > crates/pyxel-core/Cargo.toml.tmp && mv crates/pyxel-core/Cargo.toml.tmp crates/pyxel-core/Cargo.toml
```

#### 3. 元の`hello_world`プロジェクトの`Cargo.tomlも書き換える`

**元のプロジェクトに戻って**、`Cargo.toml`の`pyxel-core`を、ローカルのパスで指定します。

```diff_toml:Cargo.toml
  [package]
  name = "hello_pyxel"
  version = "0.1.0"
  edition = "2021"

  [dependencies]
- pyxel-core = "1.9.18"
+ pyxel-core = { path = "../pyxel/crates/pyxel-core" }
```

:::note warn
`../pyxel/crates/pyxel-core`の部分は、クローンした`pyxel-core`があるパスに、適宜書き換えてください。
:::

これで完成です！　すでに手順5まで進めている場合は、`cargo run`を実行してみてください！

</details>

# おわりに

Pyxelは、公式でRustに対応しているわけではありませんが、コア部分が使える状態で公開されているので、この記事のように<font color="red">**Rustでプログラミング**</font>することができてしまいます。

Pyxelを触ったことがある＋Rust勉強中の人は、この機会に、自分が過去にPyxelで作ったゲームのソースコードを、<font color="red">**Rustで書き直してみる**</font>のはいかがですか？

この記事が好評であれば、RustでPyxelを使うときのチートシートのような記事も書いてみようと思います。

---

誤字・脱字などあれば、コメントか編集リクエストで教えていただけるとありがたいです。

この記事が良いと思ったら、ぜひいいねとフォローをよろしくお願いします！　[Xアカウント](https://twitter.com/awashamityoak)のフォローなどもしてくれると泣いて喜びます！

最後まで読んでくださり、ありがとうございました！