先日、Twitterで以下のチャリティー企画を知りました。
最低5ドルの寄付でインディーゲーム1000タイトルを遊べるキャンペーンが開始 反人種差別運動の一環として - ねとらぼ
PCゲームはあまりやらないけど、素材とかも入ってるし、寄付がてら買っておくか…と眺めていたらDragonRuby GTKの文字が。
Rubyでクロスプラットフォームなゲームが作れるやつのようです。
(一斉を風靡したRubyMotionも今はこのファミリーなのだとか)
これは面白そう、ということで即ポチって週末に少し遊んでみました。
以下、macOS版を試した記録です。
※ とりあえずどんなものか試したい人にはブラウザ版があります。
http://fiddle.dragonruby.org/
Hello World
まずは動かしてみます。
$ unzip dragonruby-gtk-macos.zip
$ cd dragonruby-macos/
$ ./dragonruby
# ./dragonruby mygame でも同じ
動きました。
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しておきます。
# DragonRuby
/dragonruby
/font.ttf
/logs
/tmp
/exceptions
console_history.txt
これで準備はできたので、四角形を描くソースコードを書きます。
def tick args
args.outputs.solids << [args.grid.center_x - 32, args.grid.h * 0.1, 64, 64]
end
実行します。
./dragonruby
描けました。
args.outputs.solids
に追加すると図形が描画されます。
また、args.grid
に画面サイズの情報などが入っているのでそちらを利用しています。
少し独特ですが、特に難しいところはないと思います。
基本的な仕様は以下のようになっているようです。
- 画面は1280x720ピクセル
- 画面サイズを変更すると縦横比を保って自動リサイズされる
- 左下が原点
- FPSは60固定
- 先程も書いた
tick
がフレームごとに呼び出される
- 先程も書いた
配列の順番覚えるのが辛い問題
公式的にはこの記法を推してる感じがしますが、正直なところ、初見殺しで辛い感じがします…。
ちゃんと他の書き方も用意されていました。
ハッシュ
これならまぁ初めて読んでも大丈夫そうです。
args.outputs.solids << { x: args.grid.center_x - 32, y: args.grid.h * 0.1, w: 64, h: 64 }
クラス
以下のようなクラスを定義するとOKのようです。
-
primitive_marker
メソッドで:solid
など種類を返す - ハッシュ記法のキーと同じ属性を持つ
僕は今のところこの方法を使っています。
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
から読み込みます。
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
を変数に入れています。
class Line < Primitive
@@attr_keys = %i[x y x2 y2 r g b a]
attr_accessor(*@@attr_keys)
end
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
四角が左右中央に描画できていることが確認できました。
ゲームもクラス化
tick
メソッドが複雑化する前に、ゲーム本体もクラス化しておきます。
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
に以下のような変更を加えました。
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
これで四角形を動かせるようになります。
敵を作る
左右に動くグレーのSolid
を追加します。
特に新しい要素は使っていません。
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
弾を撃つ
state.new_entity
でエンティティを追加できます。
エンティティに情報を持たせておいて、その情報を使ってSolid
などで画面表示する使い方になります。
(エンティティがなくてもできるとは思いますが、せっかく用意されているので使ってみます。)
押しっぱなしで撃ちまくれないようにkey_up
にを使っています。
また、弾が画面の端に到達したらdead
フラグを立てて削除しています。
(dead
フラグがあるものはreject
する)
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
弾が撃てるようになりました。
弾を消す(敵に当たったら)
当たり判定にはintersect_rect
を使えます。
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
少しわかりづらいですが、敵に当たると弾が消えています。
スコア
弾が敵に当たると+2点、当たらなかったら-1点とし、合計を右上に表示します。
テキストの表示にはLabel
を使います。
ドットっぽいフォントにするため、Press Start 2P - Google Fontsを利用させてもらいました。(ダウンロード&展開したものを、mygame/fonts
に配置
class Label < Primitive
@@attr_keys = %i[x y text size_enum alignment_enum font r g b a]
attr_accessor(*@@attr_keys)
end
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
スコアが出るようになったので(一応)遊べるようになりました。
画像を使う
四角形のままだと寂しいので画像を使います。
(画像は以前僕が描いたやつなのでクオリティはあれですが…)
mygame/sprites
にplayer.png
、enemy.png
、bullet.png
を置きます。
あとは、Solid
の代わりにSprite
を使うようにコードを変更します。
十字線はもう不要なので消しておきます。
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
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
最初に比べると、だいぶそれらしくなりました!
パッケージ化
メタデータを用意します。
iconはplayer.png
をコピーしておきます。
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に置いてみました。
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ライセンス!)