DragonRuby GTK (Game Toolkit) で遊んでみた

先日、Twitterで以下のチャリティー企画を知りました。

[最低5ドルの寄付でインディーゲーム1000タイトルを遊べるキャンペーンが開始　反人種差別運動の一環として - ねとらぼ](https://nlab.itmedia.co.jp/nl/articles/2006/10/news135.html)

PCゲームはあまりやらないけど、素材とかも入ってるし、寄付がてら買っておくか…と眺めていたら**DragonRuby GTK**の文字が。

Rubyでクロスプラットフォームなゲームが作れるやつのようです。
（一斉を風靡したRubyMotionも今はこのファミリーなのだとか）

これは面白そう、ということで即ポチって週末に少し遊んでみました。

以下、macOS版を試した記録です。

※ とりあえずどんなものか試したい人にはブラウザ版があります。
　http://fiddle.dragonruby.org/

# Hello World

まずは動かしてみます。

```sh
$ unzip dragonruby-gtk-macos.zip 
$ cd dragonruby-macos/
$ ./dragonruby
# ./dragonruby mygame でも同じ
```

<img width="1348" alt="Screen Shot 2020-06-14 at 22.55.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2191/13e75075-5f9d-4ccd-c69c-1d1dc208a3ad.png">

動きました。
`mygame/app/main.rb` が実行されています。

僕の環境だと、しばらく動かしててもCPUファンが唸らなくて良い感じです。
（わりと本体が熱くはなる）

# 四角形を描く

先程はzipを展開したディレクトリの中で作業しましたが、別の場所で作業してみます。
（git管理などがしやすいように）

```
$ mkdir -p hello-dragonruby-gtk/mygame/app
$ cd hello-dragonruby-gtk/

$ cp ~/Downloads/dragonruby-macos/dragonruby .
$ cp ~/Downloads/dragonruby-macos/font.ttf .
```

`dragonruby`コマンドは、PATHを設定して他の場所から呼び出すようなことを想定されていなそうな感じがしたので、コピーしてきています。
`font.ttf`も必要でした。
（合わせて5.9MB程度）

実行するといろいろディレクトリやファイルが作られるので、それらと合わせてgitignoreしておきます。

```sh:.gitignore
# DragonRuby
/dragonruby
/font.ttf
/logs
/tmp
/exceptions
console_history.txt
```

これで準備はできたので、四角形を描くソースコードを書きます。

```rb:mygame/app/main.rb
def tick args
  args.outputs.solids << [args.grid.center_x - 32, args.grid.h * 0.1, 64, 64]
end
```

実行します。

```
./dragonruby
```

<img width="1348" alt="Screen Shot 2020-06-14 at 23.07.44.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2191/cdb3a2da-e0d0-e999-0325-5c30fe1f89b6.png">

描けました。

`args.outputs.solids`に追加すると図形が描画されます。
また、`args.grid`に画面サイズの情報などが入っているのでそちらを利用しています。

少し独特ですが、特に難しいところはないと思います。

基本的な仕様は以下のようになっているようです。

- 画面は1280x720ピクセル
  - 画面サイズを変更すると縦横比を保って自動リサイズされる
- 左下が原点
- FPSは60固定
  - 先程も書いた`tick`がフレームごとに呼び出される

# 配列の順番覚えるのが辛い問題

公式的にはこの記法を推してる感じがしますが、正直なところ、初見殺しで辛い感じがします…。

ちゃんと他の書き方も用意されていました。

## ハッシュ

これならまぁ初めて読んでも大丈夫そうです。

```rb:mygame/app/main.rb
  args.outputs.solids << { x: args.grid.center_x - 32, y: args.grid.h * 0.1, w: 64, h: 64 }
```

## クラス

以下のようなクラスを定義するとOKのようです。

- `primitive_marker`メソッドで`:solid`など種類を返す
- ハッシュ記法のキーと同じ属性を持つ

僕は今のところこの方法を使っています。

```rb:mygame/app/primitives.rb
class Primitive
  def initialize(attributes)
    attr_keys.each { |key| send("#{key}=", attributes[key]) }
  end

  def primitive_marker
    self.class.name.downcase
  end

  def attr_keys
    self.class.class_variable_get(:@@attr_keys)
  end

  def serialize
    attr_keys.map { |key| [key, send(key)] }.to_h
  end

  def inspect
    serialize.to_s
  end

  def to_s
    serialize.to_s
  end
end

class Solid < Primitive
  @@attr_keys = %i[x y w h r g b a]
  attr_accessor(*@@attr_keys)
end
```

`serialize`、`inspect`、`to_s`を定義していないと、エラー発生時にコンソールがその警告で埋め尽くされてしまうので定義しています。
（そのために`@@attr_keys`を持っている…）

あとはこれを`main.rb`から読み込みます。

```rb:mygame/app/main.rb
require 'app/primitives.rb'

def tick args
  args.outputs.solids << Solid.new(x: args.grid.center_x - 32, y: args.grid.h * 0.1, w: 64, h: 64)
end
```

# 線を引く

`Solid`以外の例として、`Line`を使って真ん中に十字線を線を描いてみます。
`args.outputs.lines`に追加することで描画されます。

`args`を毎回打つのが面倒なので、`outputs`と`grid`を変数に入れています。

```rb:mygame/app/primitives.rb
class Line < Primitive
  @@attr_keys = %i[x y x2 y2 r g b a]
  attr_accessor(*@@attr_keys)
end
```

```rb:mygame/app/main.rb
require 'app/primitives.rb'

def tick args
  outputs, grid = args.outputs, args.grid

  outputs.solids << Solid.new(x: grid.center_x - 32, y: grid.h * 0.1, w: 64, h: 64)

  outputs.lines << Line.new(x: 0, y: grid.center_y, x2: grid.w, y2: grid.center_y)
  outputs.lines << Line.new(x: grid.center_x, y: 0, x2: grid.center_x, y2: grid.h)
end
```

<img width="1348" alt="Screen Shot 2020-06-14 at 23.53.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2191/26ebcb52-8eea-3071-cf9d-35779ac8e732.png">

四角が左右中央に描画できていることが確認できました。

# ゲームもクラス化

`tick`メソッドが複雑化する前に、ゲーム本体もクラス化しておきます。

```rb:mygame/app/main.rb
require 'app/primitives.rb'

class Game
  attr_accessor :state, :outputs, :grid

  def tick
    output
  end

  def output
    outputs.solids << Solid.new(x: grid.center_x - 32, y: grid.h * 0.1, w: 64, h: 64)

    outputs.lines << Line.new(x: 0, y: grid.center_y, x2: grid.w, y2: grid.center_y)
    outputs.lines << Line.new(x: grid.center_x, y: 0, x2: grid.center_x, y2: grid.h)
  end

  def serialize
    {}
  end

  def inspect
    serialize.to_s
  end

  def to_s
    serialize.to_s
  end
end
 
$game = Game.new

def tick args
  $game.state = args.state
  $game.outputs = args.outputs
  $game.grid = args.grid
  $game.tick
end
```

`attr_gtk`というヘルパーが用意されていますが、`state`などに直接アクセスしたかったのでここでは`attr_accessor`を使っています。

# キーボードで動かす

左右のカーソルキーで四角（`player`）を動かします。

フレームをまたいで保持したい情報は`state`に持たせます。
キーボード入力は`inputs.keyboard.キー名`で検知できます。（`inputs.keyboard.key_held.キー名`で押しっぱなしを拾ったりもできるようですが、使っていません）

`main.rb`に以下のような変更を加えました。

```diff:mygame/app/main.rb
 require 'app/primitives.rb'
 
 class Game
-  attr_accessor :state, :outputs, :grid
+  attr_accessor :state, :outputs, :grid, :inputs
 
   def tick
+    set_defaults
+    handle_inputs
+    update_state
     output
   end
 
+  def set_defaults
+    state.player_x ||= grid.center_x - 32
+    state.player_dx ||= 0
+  end
+
+  def handle_inputs
+    if inputs.keyboard.right
+      state.player_dx = 5
+    elsif inputs.keyboard.left
+      state.player_dx = -5
+    else
+      state.player_dx = 0
+    end
+  end
+
+  def update_state
+    state.player_x += state.player_dx
+  end
+
   def output
-    outputs.solids << Solid.new(x: grid.center_x - 32, y: grid.h * 0.1, w: 64, h: 64)
+    outputs.solids << Solid.new(x: state.player_x, y: grid.h * 0.1, w: 64, h: 64)
 
     outputs.lines << Line.new(x: 0, y: grid.center_y, x2: grid.w, y2: grid.center_y)
     outputs.lines << Line.new(x: grid.center_x, y: 0, x2: grid.center_x, y2: grid.h)
@@ -26,12 +50,13 @@ class Game
     serialize.to_s
   end
 end

 $game = Game.new
 
 def tick args
   $game.state = args.state
   $game.outputs = args.outputs
   $game.grid = args.grid
+  $game.inputs = args.inputs
   $game.tick
 end
```

これで四角形を動かせるようになります。

![move.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2191/1bceda28-79c0-e954-0e3e-af65eef0b334.gif)

# 敵を作る

左右に動くグレーの`Solid`を追加します。
特に新しい要素は使っていません。

```diff:mygame/app/main.rb
   def set_defaults
     state.player_x ||= grid.center_x - 32
     state.player_dx ||= 0
+
+    state.enemy_x ||= grid.center_x - 32
+    state.enemy_dx ||= 5
   end
 
   def update_state
     state.player_x += state.player_dx
+
+    state.enemy_x += state.enemy_dx
+    if state.enemy_x < 0 || state.enemy_x > grid.w - 64
+      state.enemy_dx *= -1
+    end
   end
 
   def output
     outputs.solids << Solid.new(x: state.player_x, y: grid.h * 0.1, w: 64, h: 64)
+ 
+    outputs.solids << Solid.new(x: state.enemy_x, y: grid.h * 0.7, w: 64, h: 64, r: 150, g: 150, b: 150)

     outputs.lines << Line.new(x: 0, y: grid.center_y, x2: grid.w, y2: grid.center_y)
     outputs.lines << Line.new(x: grid.center_x, y: 0, x2: grid.center_x, y2: grid.h)
   end
```

![enemy.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2191/965dc7bb-ec75-d9f5-c6f2-f11fb48c0065.gif)

# 弾を撃つ

`state.new_entity`でエンティティを追加できます。
エンティティに情報を持たせておいて、その情報を使って`Solid`などで画面表示する使い方になります。
（エンティティがなくてもできるとは思いますが、せっかく用意されているので使ってみます。）

押しっぱなしで撃ちまくれないように`key_up`にを使っています。

また、弾が画面の端に到達したら`dead`フラグを立てて削除しています。
（`dead`フラグがあるものは`reject`する）

```diff:mygame/app/main.rb
   def set_defaults
     state.player_x ||= grid.center_x - 32
     state.player_dx ||= 0
 
     state.enemy_x ||= grid.center_x - 32
     state.enemy_dx ||= 5
+
+    state.bullets ||= []
   end
 
   def handle_inputs
     if inputs.keyboard.right
       state.player_dx = 5
     elsif inputs.keyboard.left
       state.player_dx = -5
     else
       state.player_dx = 0
     end
+
+    if inputs.keyboard.key_up.space
+      state.bullets << state.new_entity(:bullet) do |bullet|
+        bullet.y = player_rect[:y]
+        bullet.x = player_rect[:x] + 16
+        bullet.size = 32
+        bullet.dy = 10
+        bullet.solid = { x: bullet.x, y: bullet.y, w: bullet.size, h: bullet.size, r: 255, g: 100, b: 100 }
+      end
+    end
   end

   def update_state
     state.player_x += state.player_dx
 
     state.enemy_x += state.enemy_dx
     if state.enemy_x < 0 || state.enemy_x > grid.w - 64
       state.enemy_dx *= -1
     end
+
+    state.bullets.each do |bullet|
+      bullet.y += bullet.dy
+      bullet.solid[:y] = bullet.y
+
+      if bullet.y > grid.h
+        bullet.dead = true
+      end
+    end
+    state.bullets = state.bullets.reject(&:dead)
   end

   def output
-    outputs.solids << Solid.new(x: state.player_x, y: grid.h * 0.1, w: 64, h: 64)
+    outputs.solids << Solid.new(player_rect)
 
     outputs.solids << Solid.new(x: state.enemy_x, y: grid.h * 0.7, w: 64, h: 64, r: 150, g: 150, b: 150)
+ 
+    outputs.solids << state.bullets.map(&:solid)

     outputs.lines << Line.new(x: 0, y: grid.center_y, x2: grid.w, y2: grid.center_y)
     outputs.lines << Line.new(x: grid.center_x, y: 0, x2: grid.center_x, y2: grid.h)
   end
+
+  private
+
+  def player_rect
+    { x: state.player_x, y: grid.h * 0.1, w: 64, h: 64 }
+  end
```

![bullet.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2191/36c052be-3b33-9f04-a1c5-61231c5cc481.gif)

弾が撃てるようになりました。

# 弾を消す（敵に当たったら）

当たり判定には`intersect_rect`を使えます。

```diff:mygame/app/main.rb
   def update_state
     state.player_x += state.player_dx
 
     state.enemy_x += state.enemy_dx
     if state.enemy_x < 0 || state.enemy_x > grid.w - 64
       state.enemy_dx *= -1
     end
 
     state.bullets.each do |bullet|
       bullet.y += bullet.dy
       bullet.solid[:y] = bullet.y
 
       if bullet.y > grid.h
         bullet.dead = true
       end
+      if bullet.solid.intersect_rect?(enemy_rect)
+        bullet.dead = true
+      end
     end
     state.bullets = state.bullets.reject(&:dead)
   end
 
   def output
     outputs.solids << Solid.new(player_rect)
 
-    outputs.solids << Solid.new(x: state.enemy_x, y: grid.h * 0.7, w: 64, h: 64, r: 150, g: 150, b: 150)
+    outputs.solids << Solid.new(enemy_rect.merge(r: 150, g: 150, b: 150))
 
     outputs.solids << state.bullets.map(&:solid)
 
     outputs.lines << Line.new(x: 0, y: grid.center_y, x2: grid.w, y2: grid.center_y)
     outputs.lines << Line.new(x: grid.center_x, y: 0, x2: grid.center_x, y2: grid.h)
   end
+
+  def enemy_rect
+    { x: state.enemy_x, y: grid.h * 0.7, w: 64, h: 64 }
+  end
```

![collision.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2191/16cb1d01-8d55-97b8-8b19-a4f899fd10e1.gif)

少しわかりづらいですが、敵に当たると弾が消えています。

# スコア

弾が敵に当たると+2点、当たらなかったら-1点とし、合計を右上に表示します。

テキストの表示には`Label`を使います。
ドットっぽいフォントにするため、[Press Start 2P - Google Fonts](https://fonts.google.com/specimen/Press+Start+2P)を利用させてもらいました。（ダウンロード＆展開したものを、`mygame/fonts`に配置

```rb:mygame/app/primitives.rb
class Label < Primitive
  @@attr_keys = %i[x y text size_enum alignment_enum font r g b a]
  attr_accessor(*@@attr_keys)
end
```

```diff:mygame/app/main.rb
   def set_defaults
     state.player_x ||= grid.center_x - 32
     state.player_dx ||= 0
 
     state.enemy_x ||= grid.center_x - 32
     state.enemy_dx ||= 5
 
     state.bullets ||= []
+
+    state.score ||= 0
   end
  
   def update_state
     state.player_x += state.player_dx
 
     state.enemy_x += state.enemy_dx
     if state.enemy_x < 0 || state.enemy_x > grid.w - 64
       state.enemy_dx *= -1
     end
 
     state.bullets.each do |bullet|
       bullet.y += bullet.dy
       bullet.solid[:y] = bullet.y
 
       if bullet.y > grid.h
         bullet.dead = true
+        state.score -= 1
       end
       if bullet.solid.intersect_rect?(enemy_rect)
         bullet.dead = true
+        state.score += 2
       end
     end
     state.bullets = state.bullets.reject(&:dead)
   end
 
   def output
     outputs.solids << Solid.new(player_rect)
 
     outputs.solids << Solid.new(enemy_rect.merge(r: 150, g: 150, b: 150))
 
     outputs.solids << state.bullets.map(&:solid)
+
+    outputs.labels << Label.new(
+      x: grid.w * 0.99,
+      y: grid.h * 0.98,
+      text: state.score,
+      alignment_enum: 2,
+      font: 'fonts/Press_Start_2P/PressStart2P-Regular.ttf'
+    )
 
     outputs.lines << Line.new(x: 0, y: grid.center_y, x2: grid.w, y2: grid.center_y)
     outputs.lines << Line.new(x: grid.center_x, y: 0, x2: grid.center_x, y2: grid.h)
   end
```

![score.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2191/ccdc9745-4891-8c16-6691-d0fa1835ae88.gif)

スコアが出るようになったので（一応）遊べるようになりました。

# 画像を使う

四角形のままだと寂しいので画像を使います。
（画像は以前僕が描いたやつなのでクオリティはあれですが…）

`mygame/sprites`に`player.png`、`enemy.png`、`bullet.png`を置きます。

あとは、`Solid`の代わりに`Sprite`を使うようにコードを変更します。
十字線はもう不要なので消しておきます。

```rb:mygame/app/primitives.rb
class Sprite < Primitive
  @@attr_keys = %i[
    x y w h path angle a r g b
    source_x source_y source_w source_h
    flip_horizontally flip_vertically
    angle_anchor_x angle_anchor_y
    tile_x tile_y tile_w tile_h
  ]
  attr_accessor(*@@attr_keys)
end
```

```diff:mygame/app/main.rb
   def handle_inputs
     if inputs.keyboard.right
       state.player_dx = 5
     elsif inputs.keyboard.left
       state.player_dx = -5
     else
       state.player_dx = 0
     end
 
     if inputs.keyboard.key_up.space
       state.bullets << state.new_entity(:bullet) do |bullet|
         bullet.y = player_rect[:y]
         bullet.x = player_rect[:x] + 16
         bullet.size = 32
         bullet.dy = 10
-        bullet.solid = { x: bullet.x, y: bullet.y, w: bullet.size, h: bullet.size, r: 255, g: 100, b: 100 }
+        bullet.sprite = { x: bullet.x, y: bullet.y, w: bullet.size, h: bullet.size, r: 255, g: 100, b: 100, path: 'sprites/bullet.png' }
       end
     end
   end
 
   def update_state
     state.player_x += state.player_dx
 
     state.enemy_x += state.enemy_dx
     if state.enemy_x < 0 || state.enemy_x > grid.w - 64
       state.enemy_dx *= -1
     end
 
     state.bullets.each do |bullet|
       bullet.y += bullet.dy
-      bullet.solid[:y] = bullet.y
+      bullet.sprite[:y] = bullet.y
 
       if bullet.y > grid.h
         bullet.dead = true
         state.score -= 1
       end
-      if bullet.solid.intersect_rect?(enemy_rect)
+      if bullet.sprite.intersect_rect?(enemy_rect)
         bullet.dead = true
         state.score += 2
       end
     end
     state.bullets = state.bullets.reject(&:dead)
   end
 
   def output
-    outputs.solids << Solid.new(player_rect)
+    outputs.sprites << Sprite.new(player_rect.merge(path: 'sprites/player.png'))
 
-    outputs.solids << Solid.new(enemy_rect.merge(r: 150, g: 150, b: 150))
+    outputs.sprites << Sprite.new(enemy_rect.merge(r: 150, g: 150, b: 150, path: 'sprites/enemy.png'))
 
-    outputs.solids << state.bullets.map(&:solid)
+    outputs.sprites << state.bullets.map(&:sprite)
 
     outputs.labels << Label.new(
       x: grid.w * 0.99,
       y: grid.h * 0.98,
       text: state.score,
       alignment_enum: 2,
       font: 'fonts/Press_Start_2P/PressStart2P-Regular.ttf'
     )
-    outputs.lines << Line.new(x: 0, y: grid.center_y, x2: grid.w, y2: grid.center_y)
-    outputs.lines << Line.new(x: grid.center_x, y: 0, x2: grid.center_x, y2: grid.h)     
   end
```

![sprite.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2191/a01907ab-1318-f6b2-2867-25c03e7dafb3.gif)

最初に比べると、だいぶそれらしくなりました！

# パッケージ化

メタデータを用意します。
iconは`player.png`をコピーしておきます。

```:mygame/metadata/game_metadata.txt
devid=hello-dragonruby-gtk
devtitle=Hello DragonRuby GTK
gameid=hello-dragonruby-gtk
gametitle=Hello DragonRuby GTK
version=0.1
icon=metadata/icon.png
```

`dragonruby-publish` をコピーしてきてもなぜか動かなかったので、
DragonRubyをダウンロード＆展開したディレクトリの中でやります。
（コピーして動かす方法はこの後の追記参照）

```
$ cp mygame/sprites/player.png mygame/metadata/icon.png
$ cp -r mygame/ ~/Downloads/dragonruby-macos/hello-dragonruby-gtk
$ cd ~/Downloads/dragonruby-macos
$ ./dragonruby-publish --only-package hello-dragonruby-gtk
```

これで他のプラットフォーム用のファイルと共にHTML5版も出力されます。
（`builds/hello-dragonruby-gtk-html5-0.1`）

これを公開すればブラウザでプレイできます。
せっかくなので、GitHub Pagesに置いてみました。

https://tnantoka.github.io/hello-dragonruby-gtk/

### dragonruby-publishをコピーして動かす（追記）

`dragonruby-publish`以外にもいろいろとコピーしてきたら動きました。

````
$ cp ~/Downloads/dragonruby-macos/dragonruby-publish .
$ cp ~/Downloads/dragonruby-macos/.dragonruby .
$ cp ~/Downloads/dragonruby-macos/*.png .
$ cp ~/Downloads/dragonruby-macos/open-source-licenses.txt .

$ ./dragonruby-publish --only-package
```

`.gitignore`するものもいろいろと増えました。

```:.gitignore
# DragonRuby
/dragonruby*
/font.ttf
/logs
/tmp
/exceptions
/console_history.txt
/.dragonruby
/builds
/console-logo.png
/open-source-licenses.txt
```

# ソースコード

以下で公開しています。
（DragonRubyが別途必要です）

https://github.com/tnantoka/hello-dragonruby-gtk/

# 感想

特に大きなハマりどころはなく遊べました。

Productionで使えるのか？と言われたら、どうなのかわかりませんが、
ちょっとしたものを作るのには十分使えそうでした。

書き慣れたRubyでクリエイティブコーディング的なことはやりたかったので、そのツールとして使えないかな？などと考えています。
（そのためには、物理エンジン搭載＆図形描画を充実させてほしいところですが）

また時間を見つけて触ってみようと思います。

# 参考文献

APIリファレンスがない（？）ので、この辺りを見ながら探り探りやりました。

- README.md
    - まずはこちらを読む
- CHEATSHEET.md
    - こちらも読む
- [DragonRuby Game Toolkit by DragonRuby](https://dragonruby.itch.io/dragonruby-gtk)
    - EntityなどREADMEにはない記述あり
- mygame/documantion
    - Solid、Spriteなど要素ごとにそれなりに説明してくれる
- samples
    - 67個もサンプルがある（MITライセンス！）