本稿ではDXOpalを使ってブラウザで動くゲームを作ってみます。Rubyでこんなこともできるんだ!と思ってもらえれば幸いです。
DXOpalは筆者が作っている、Rubyでブラウザ用ゲームを作るためのライブラリです。
DXOpalの「DX」はDXRubyから来ています。DXRubyはRubyでWindows用ゲームを作るためのライブラリです(このRubyist Magazineにも記事がありましたね)。
DXOpalの「Opal」はRubyのコードをJavaScriptに変換してくれるソフトウェアです。DXOpalは内部でOpalを利用しています。
DXOpalは、DXRubyの命令を「だいたいそのまま」ブラウザに移植したものです。そのため、DXRubyのリファレンスを見れば使い方はだいたい同じです。もしDXRubyの命令でDXOpalで動かないものがあったら、githubかtwitterで教えてください。
Rubyをインストール後、RubyGemsからDXOpalをインストールできます。
$ gem install dxopal
インストールするとdxopalコマンドが使えるようになります。dxopal initで、カレントディレクトリにファイルの雛形ができます。
$ mkdir mygame
$ cd mygame
$ dxopal init
initしたあと、dxopal serverコマンドを実行するとWebサーバが起動します。
$ dxopal server
DXOpal v1.1.0
Starting DXOpal Server
(Open http://localhost:7521/index.html in browser)
---
Puma starting in single mode...
* Version 3.11.0 (ruby 2.4.2-p198), codename: Love Song
* Min threads: 0, max threads: 16
* Environment: development
* Listening on tcp://0.0.0.0:7521
Use Ctrl-C to stop
ブラウザで http://localhost:7521/index.html を開くと、以下のように表示されるはずです。
本稿で使う画像と効果音をまとめたものを http://route477.net/files/rubima_dxopal.zip に置きました。これを展開するとimagesとsoundsというディレクトリができるので、index.htmlと同じところに移動させてください。以下のような感じです。
index.html
main.rb
dxopal.min.js
images/
apple.png
bomb.png
player.png
sounds/
explosion.wav
get.wav
では早速、初めてのDXOpalアプリケーションを書いてみましょう。
main.rb というファイルがあるので、これをテキストエディタで開き、中身をいったん全部消して、以下のように書き換えてください。
require "dxopal"
include DXOpal
Window.load_resources do
Window.loop do
end
end
ブラウザをリロードすると、真っ黒な四角だけの画面に変わったはずです。
変わらなかった場合は、ブラウザが古いデータをキャッシュしているかもしれません。以下の手順で、一時的にキャッシュさせないようにしましょう。
1. 「表示」→「開発/管理」→「デベロッパー ツール」で開発者コンソールを起動
2. 「Network」タブの「Disable cache」にチェック
3. これで、開発者コンソール起動中はキャッシュがオフになります
1. 「ツール」→「ウェブ開発」→「開発ツールを表示」で開発者コンソールを起動
2. 「ネットワーク」タブの「キャッシュを無効化」にチェック
3. これで、開発者コンソール起動中はキャッシュがオフになります
真っ黒なウィンドウを出すだけでは寂しいので、何か描いてみましょうか。
main.rb を以下のように書き換えて保存してください。 (以下では、前のスクリプトから変更する部分にはコメントを付けてあります。 スクリプトを写すときにはまず「#」を探してみてください。)
require "dxopal"
include DXOpal
# 地面のY座標
GROUND_Y = 400
Window.load_resources do
Window.loop do
# 背景を描画
Window.draw_box_fill(0, 0, Window.width, GROUND_Y, [128, 255, 255])
Window.draw_box_fill(0, GROUND_Y, Window.width, Window.height, [0, 128, 0])
end
end
空と大地が表示されましたか?:-)
ゲームには主人公が必要ですよね。画像を表示してみましょう。imagesというディレクトリにサンプル画像 (player.png) が入っていることを確認してください。
require "dxopal"
include DXOpal
GROUND_Y = 400
# 使いたい画像を宣言する
Image.register(:player, 'images/player.png')
Window.load_resources do
Window.loop do
Window.draw_box_fill(0, 0, Window.width, GROUND_Y, [128, 255, 255])
Window.draw_box_fill(0, GROUND_Y, Window.width, Window.height, [0, 128, 0])
# プレイヤーキャラを描画
Window.draw(240, GROUND_Y - Image[:player].height, Image[:player])
end
end
実行すると、地面の上にキャラクターが表示されます。少しゲームらしくなってきました。
次はビットマップ画像を動かしてみましょう。 キャラクターを少しずつ位置をずらしながら描画することで、パラパラマンガのように絵を動かすことができます。
require 'dxopal'
include DXOpal
GROUND_Y = 400
Image.register(:player, 'images/player.png')
Window.load_resources do
# 変数の初期化
x = 0
Window.loop do
# 毎フレーム8ピクセルずつ進む
x += 8
Window.draw_box_fill(0, 0, Window.width, GROUND_Y, [128, 255, 255])
Window.draw_box_fill(0, GROUND_Y, Window.width, Window.height, [0, 128, 0])
# x座標を変数にした
Window.draw(x, GROUND_Y - Image[:player].height, Image[:player])
end
end
このように、ゲームプログラミングでは
という手順を何度も繰り返すことでゲームを進めていきます。この「入力→移動→描画」1回分を1フレームと呼びます。
DXOpalでは基本的に1秒60フレームです。(参考)
次はキーボードの矢印キーでキャラクターが左右に移動するようにしてみましょう。
require 'dxopal'
include DXOpal
GROUND_Y = 400
Image.register(:player, 'images/player.png')
Window.load_resources do
# 最初は真ん中にする
x = Window.width / 2
Window.loop do
# キー入力をチェック
if Input.key_down?(K_LEFT)
x -= 8
elsif Input.key_down?(K_RIGHT)
x += 8
end
Window.draw_box_fill(0, 0, Window.width, GROUND_Y, [128, 255, 255])
Window.draw_box_fill(0, GROUND_Y, Window.width, Window.height, [0, 128, 0])
Window.draw(x, GROUND_Y - Image[:player].height, Image[:player])
end
end
ブラウザをリロードして、カーソルキーの左右を押すとキャラクターが動くはずです。(カーソルがアドレスバーにあったりすると動かないかもしれません。ページ内を一度マウスでクリックしてみてください。)
ところで、上のプログラムだと画面の端にたどりついてもキャラクターが止まらず、画面外に隠れてしまうはずです。if式の条件を以下のようにすると、左右に行きすぎないようになります。
# キー入力をチェック
if Input.key_down?(K_LEFT) && x > 0
x -= 8
elsif Input.key_down?(K_RIGHT) && x < (Window.width - Image[:player].width)
x += 8
end
さて、主人公の次は敵キャラ出して、アイテム出して……と行きたいところですが、変数名に「x」を使っているのが ちょっと気になります。 例えば敵キャラを出すなら、プレイヤーの座標は player_x、敵キャラの座標は enemy_x のように改名しないといけないですよね。 さらにアイテムの座標も……と考えると、似たような変数がたくさんあって混乱してしまいそうです。
Rubyはオブジェクト指向言語なので、こういうときはクラスを作ります。特にDXRubyではゲームの各要素はSpriteクラスを継承しておくと、当たり判定が簡単に実装できたりして便利です。
require 'dxopal'
include DXOpal
GROUND_Y = 400
Image.register(:player, 'images/player.png')
# プレイヤーを表すクラスを定義
class Player < Sprite
def initialize
x = Window.width / 2
y = GROUND_Y - Image[:player].height
image = Image[:player]
super(x, y, image)
end
# 移動処理(xからself.xになった)
def update
if Input.key_down?(K_LEFT) && self.x > 0
self.x -= 8
elsif Input.key_down?(K_RIGHT) && self.x < (Window.width - Image[:player].width)
self.x += 8
end
end
end
# クラスここまで
Window.load_resources do
# Playerクラスのオブジェクトを作る
player = Player.new
Window.loop do
# 入力と移動の処理をする
player.update
Window.draw_box_fill(0, 0, Window.width, GROUND_Y, [128, 255, 255])
Window.draw_box_fill(0, GROUND_Y, Window.width, Window.height, [0, 128, 0])
# 描画する
player.draw
end
end
動作としては同じですが、機能を増やしていくための土台ができました。
Spriteクラスはx座標、y座標、画像を持ち、player.drawのようにしてdrawメソッドを呼ぶとx座標、y座標の場所に画像が表示されます。これらのデータはPlayerクラスの内部からはself.x, self.y, self.imageのようにしてアクセスできます。
Spriteクラスを使うときは、移動などの更新処理はupdateというメソッドに書くことになっています。(Sprite.updateでまとめて呼べたりします)
主人公だけでは寂しいので、他の物も描画してみましょう。imagesディレクトリにりんごと爆弾の絵があるので、「爆弾を避けつつリンゴを集める」ゲームにしてみましょうか。まずは、images/apple.pngにりんごの絵があるのでそれを使います。
アイテムを表すItemクラスと、アイテムの生成・削除を行うItemsクラスを作りましょう。
require 'dxopal'
include DXOpal
GROUND_Y = 400
Image.register(:player, 'images/player.png')
# アイテム用の画像を宣言
Image.register(:apple, 'images/apple.png')
class Player < Sprite
# ...一緒なので省略...
end
# アイテムを表すクラスを追加
class Item < Sprite
def initialize
image = Image[:apple]
x = rand(Window.width - image.width) # x座標をランダムに決める
y = 0
super(x, y, image)
@speed_y = rand(9) + 4 # 落ちる速さをランダムに決める
end
def update
self.y += @speed_y
if self.y > Window.height
self.vanish
end
end
end
# アイテム群を管理するクラスを追加
class Items
# 同時に出現するアイテムの個数
N = 5
def initialize
@items = []
end
def update
# 各スプライトのupdateメソッドを呼ぶ
Sprite.update(@items)
# vanishしたスプライトを配列から取り除く
Sprite.clean(@items)
# 消えた分を補充する(常にアイテムがN個あるようにする)
(N - @items.size).times do
@items.push(Item.new)
end
end
def draw
# 各スプライトのdrawメソッドを呼ぶ
Sprite.draw(@items)
end
end
# クラスここまで
Window.load_resources do
player = Player.new
# Itemsクラスのオブジェクトを作る
items = Items.new
Window.loop do
player.update
# アイテムの作成・移動・削除
items.update
Window.draw_box_fill(0, 0, Window.width, GROUND_Y, [128, 255, 255])
Window.draw_box_fill(0, GROUND_Y, Window.width, Window.height, [0, 128, 0])
player.draw
# アイテムの描画
items.draw
end
end
こんな感じになったでしょうか。
ItemクラスはPlayerクラスと同様に、Spriteクラスを継承しています。
Itemsクラスはアイテムの個数を管理するクラスで、Spriteの機能は特に使わないため普通のクラスにしています。ただし、更新を行うメソッドはupdate、描画を行うメソッドはdrawのように、名前だけ合わせています。同じことをするメソッドは同じ名前にしたほうが分かりやすいですからね。
Itemクラスのupdateメソッドでは、y座標を少しずつ増やすことでアイテムの落下を実装しています。y座標がWindow.heightより大きくなったら、画面外に出たということなので、vanishメソッドを呼んでいます。vanishを呼ぶと、Spriteオブジェクトのvanishフラグが立ちます。
vanishフラグは、Sprite.cleanと組み合わせて使います。Spriteクラスには、Spriteオブジェクトの配列を渡して一括で操作するメソッドがいくつかあります。(以下ではSpriteオブジェクトのことを、単に「スプライト」と呼びます)
次はアイテムの種類を増やしてみましょう。images/bomb.pngに爆弾の絵があるのでそれを使います。
アイテムの絵を変えるのはどうしましょうか。一番単純なのはItemクラスにフラグを持たせる方法ですね。絵を変えるだけならそれでもいいですが、今回はりんごの方は「当たってもいいアイテム」、爆弾は「当たってはいけないアイテム」と、違う動作をさせたいので、別々のクラスにしておきます。とはいえ落下などの基本的な動作は同じなので、Itemクラスを継承してAppleクラスとBombクラスを作ります。
require 'dxopal'
include DXOpal
GROUND_Y = 400
Image.register(:player, 'images/player.png')
Image.register(:apple, 'images/apple.png')
# アイテム画像を追加
Image.register(:bomb, 'images/bomb.png')
class Player < Sprite
# ...一緒なので省略...
end
class Item < Sprite
# imageを引数にとるようにした
def initialize(image)
x = rand(Window.width - image.width)
y = 0
super(x, y, image)
@speed_y = rand(9) + 4
end
def update
# ...一緒なので省略...
end
end
# 加点アイテムのクラスを追加
class Apple < Item
def initialize
super(Image[:apple])
end
end
# 妨害アイテムのクラスを追加
class Bomb < Item
def initialize
super(Image[:bomb])
end
end
class Items
N = 5
def initialize
@items = []
end
def update
Sprite.update(@items)
Sprite.clean(@items)
(N - @items.size).times do
# どっちのアイテムにするか、ランダムで決める
if rand(1..100) < 40
@items.push(Apple.new)
else
@items.push(Bomb.new)
end
end
end
def draw
Sprite.draw(@items)
end
end
Window.load_resources do
# ...一緒なので省略...
end
りんごと爆弾がまぜこぜに発生するよう、Itemsクラスを修正しています。
上から落ちてきたものがすり抜けてしまうのではゲームになりませんね。次は当たり判定を付けて、
という風にしてみましょう。
require 'dxopal'
include DXOpal
GROUND_Y = 400
Image.register(:player, 'images/player.png')
Image.register(:apple, 'images/apple.png')
Image.register(:bomb, 'images/bomb.png')
# ゲームの状態を記憶するハッシュを追加
GAME_INFO = {
score: 0 # 現在のスコア
}
class Player < Sprite
def initialize
x = Window.width / 2
y = GROUND_Y - Image[:player].height
image = Image[:player]
super(x, y, image)
# 当たり判定を円で設定(中心x, 中心y, 半径)
self.collision = [image.width / 2, image.height / 2, 16]
end
# ...省略...
end
# ...省略...
class Apple < Item
def initialize
super(Image[:apple])
# 衝突範囲を円で設定(中心x, 中心y, 半径)
self.collision = [image.width / 2, image.height / 2, 56]
end
# playerと衝突したとき呼ばれるメソッドを追加
def hit
self.vanish
GAME_INFO[:score] += 10
end
end
# 妨害アイテム
class Bomb < Item
def initialize
super(Image[:bomb])
# 衝突範囲を円で設定(中心x, 中心y, 半径)
self.collision = [image.width / 2, image.height / 2, 42]
end
# playerと衝突したとき呼ばれるメソッドを追加
def hit
self.vanish
GAME_INFO[:score] = 0 # スコアを0点にする
end
end
class Items
# ...省略...
# playerを引数に取るようにした
def update(player)
@items.each{|x| x.update(player)}
# playerとitemsが衝突しているかチェックする。衝突していたらhitメソッドが呼ばれる
Sprite.check(player, @items)
Sprite.clean(@items)
(N - @items.size).times do
if rand(100) < 40
@items.push(Apple.new)
else
@items.push(Bomb.new)
end
end
end
# ...省略...
end
Window.load_resources do
player = Player.new
items = Items.new
Window.loop do
player.update
items.update(player) # 引数を増やした
Window.draw_box_fill(0, 0, Window.width, GROUND_Y, [128, 255, 255])
Window.draw_box_fill(0, GROUND_Y, Window.width, Window.height, [0, 128, 0])
# スコアを画面に表示する
Window.draw_font(0, 0, "SCORE: #{GAME_INFO[:score]}", Font.default)
player.draw
items.draw
end
end
最初にGAME_INFOという定数を用意しています。今はスコアしか入れていませんが、あとでゲームのいろいろな状態を持たせるために使います。
Spriteクラスは当たり判定の機能を持っています。collision=メソッドを呼ぶことで当たり判定が設定されます。当たり判定の形状は点、円、長方形、三角形の4種類があり、配列の長さによって指定します。
# 点
self.collision = [x, y]
# 円
self.collision = [x, y, r]
# 長方形
self.collision = [x1, y1, x2, y2]
# 三角形
self.collision = [x1, y1, x2, y2, x3, y3]
今回はプレイヤー、リンゴ、爆弾のいずれも円で当たり判定を指定することにしました。絵に対して厳密ではありませんが、そのほうがゲームとしては面白く感じることもあります。特に加点アイテムは当たり判定を広めに、減点アイテムは少し狭めにしておくと、気持ちいいゲームになります。
collision=で当たり判定を設定したあとは、Sprite.checkというメソッドでスプライト同士が衝突したかをチェックできます。衝突している場合、衝突されたオブジェクトのhitメソッドが呼ばれます。
Sprite.checkは他にもいろいろな機能があるので知りたい場合はマニュアルを見てください。
DXOpalにはWebAudioを使って効果音を鳴らす機能があります。アイテムに当たったときに音を鳴らすようにしてみましょう。
require 'dxopal'
include DXOpal
GROUND_Y = 400
Image.register(:player, 'images/player.png')
Image.register(:bomb, 'images/bomb.png')
Image.register(:apple, 'images/apple.png')
# 読み込みたい音声を登録する
Sound.register(:get, 'sounds/get.wav')
Sound.register(:explosion, 'sounds/explosion.wav')
# ...省略...
class Apple < Item
# ...省略...
def hit
# 効果音を鳴らす
Sound[:get].play
self.vanish
GAME_INFO[:score] += 10
end
end
class Bomb < Item
# ...省略...
def hit
# 効果音を鳴らす
Sound[:explosion].play
self.vanish
GAME_INFO[:score] = 0
end
end
# ...あとは一緒なので省略...
効果音は画像と同じように、Sound.registerで名前とファイル名を宣言します。そうするとWindow.load_resourcesの中で「Sound[名前]」という形でアクセスできるようになります。
だいぶゲームらしくなりましたね。最後の仕上げとして、タイトル画面とゲームオーバー画面を作ってみましょう。
# ...省略...
GAME_INFO = {
scene: :title, # 現在のシーン(起動直後は:title)
score: 0,
}
# ...省略...
class Bomb < Item
# ...省略...
def hit
Sound[:explosion].play
self.vanish
# スコアを0にするのをやめて、ゲームオーバー画面に遷移するようにした
GAME_INFO[:scene] = :game_over
end
end
# ...省略...
Window.load_resources do
player = Player.new
items = Items.new
Window.loop do
# 背景とスコア表示は、どの画面でも出すことにする
Window.draw_box_fill(0, 0, Window.width, GROUND_Y, [128, 255, 255])
Window.draw_box_fill(0, GROUND_Y, Window.width, Window.height, [0, 128, 0])
Window.draw_font(0, 0, "SCORE: #{GAME_INFO[:score]}", Font.default)
# シーンごとの処理
case GAME_INFO[:scene]
when :title
# タイトル画面
Window.draw_font(0, 30, "PRESS SPACE", Font.default)
# スペースキーが押されたらシーンを変える
if Input.key_push?(K_SPACE)
GAME_INFO[:scene] = :playing
end
when :playing
# ゲーム中
player.update
items.update(player)
player.draw
items.draw
when :game_over
# ゲームオーバー画面
Window.draw_font(0, 30, "PRESS SPACE", Font.default)
player.draw
items.draw
# スペースキーが押されたらゲームの状態をリセットし、シーンを変える
if Input.key_push?(K_SPACE)
player = Player.new
items = Items.new
GAME_INFO[:score] = 0
GAME_INFO[:scene] = :playing
end
end
end
end
GAME_INFOに:scene (シーン)という項目を追加しました。今回は:title (タイトル画面)、:playing (ゲーム中)、:game_over (ゲームオーバー画面)という3つのシーンを用意しました。
Window.loopでGAME_INFO[:scene]によって別々の処理をしています。GAME_INFO[:scene]にシーン名を代入することで、シーンが切り替わります。最初はシーン:titleで、スペースキーが押されたら:playingになって、爆弾に当たったら:game_overになります。ゲームオーバー画面でスペースキーを押すと:playingに戻ります。(この辺は好みで、タイトル画面に戻るようにしても良いでしょう)
ここまでで今回のゲームはいったん完成とします。以降では補足として、ゲームがもっと大きくなったときのためのヒントをいくつか紹介します。
上ではWindow.load_resourcesの中にゲーム本体の処理を書いていましたが、規模が大きくなるとload_resourcesの中が長くなりすぎて大変かもしれません。こういうときは、ゲーム本体を表すクラスを作るという方法があります。以下は例です。
# ...省略...
# ゲーム本体を表すクラス
class Game
def initialize
reset
end
# ゲームの状態をリセットする
def reset
@player = Player.new
@items = Items.new
GAME_INFO[:score] = 0
end
# ゲームを実行する
def run
Window.loop do
Window.draw_box_fill(0, 0, Window.width, GROUND_Y, [128, 255, 255])
Window.draw_box_fill(0, GROUND_Y, Window.width, Window.height, [0, 128, 0])
Window.draw_font(0, 0, "SCORE: #{GAME_INFO[:score]}", Font.default)
case GAME_INFO[:scene]
when :title
Window.draw_font(0, 30, "PRESS SPACE", Font.default)
if Input.key_push?(K_SPACE)
GAME_INFO[:scene] = :playing
end
when :playing
@player.update
@items.update(@player)
@player.draw
@items.draw
when :game_over
@player.draw
@items.draw
Window.draw_font(0, 30, "PRESS SPACE", Font.default)
if Input.key_push?(K_SPACE)
reset
GAME_INFO[:scene] = :playing
end
end
end
end
end
Window.load_resources do
game = Game.new
game.run
end
こうしておけば、シーンが増えてrunメソッドが長くなっても、メソッドを分割することで整理できます。
今回はmain.rbに全てのプログラムを書きましたが、クラスが増えてくるとこの方法では大変です。main.rbが長くなってきたら、クラスごとにファイルを分けるのが良いでしょう。DXOpalではrequire_remoteでファイルをロードすることができます。例えばPlayerクラスをplayer.rbというファイルに切り出した場合は、main.rbに以下のように書くとplayer.rbをロードできます。
require_remote "player.rb"
(通常のRubyのrequireと違い、「.rb」は省略できません。)
Rubyのプログラムをデバッグするときは「p」メソッドをよく使います。pメソッドはOpal/DXOpalでも使えます(開発者コンソールに表示されます)が、ゲームプログラミングでは同じ処理が1秒に60回実行されたりするので、この方法だと表示が出すぎて困ることがあります。
そこでDXOpalでは、「p_」というメソッドを用意しています。p_にハッシュを渡すとその内容が開発者コンソールに出力されますが、10回以上実行した場合はそれ以上出力しなくなります。例えば、Itemクラスのupdateメソッドに以下の行を書いてみてください。
p_ x: self.x, y: self.y
実は本稿は2007年のRubyist Magazine記事のリライト版なのでした。