cocos2dxでmrubyを使えるようにして、スマホのゲームをrubyで作る

2013-09-18
blog

Ruby好きってのはいるもので、RubyでiOSのアプリを書けるRubyMotionMobiRubyというものがあるらしい。

RubyMotionはすげーことに、LLVMを使っているのでRubyのコードがネイティブで動くバイナリにコンパイルされるらしい。ただし約$200(20,482円)かかる。30日間は返金可能らしいが、試してない。

MobiRubyはmrubyを組み込んでいるとのこと。オープンソースとのことでgithubから落としてGetting startedの通りrakeしたものの失敗する。あれ これやってみたけど動かないので断念。

ちょっと試したいだけなのでお金も出せないし、環境設定とか難しくてわからないし…ということで、自分でmrubyを組み込んでみることにした。スマホ対応のゲームエンジンであるCocos2dxはゲームのロジック部をC/C++で書く以外に、スクリプト言語でも書けるようになっていて、LuaやJavaScriptが用意されている。これに加えてmrubyを使えるようにした。

mrubyのソースを取り出して組み込む

本家のmrubyリポジトリのビルドはコマンドライン上からmakeとやると、minirakeが走ってなんかいろいろやるようになってる。けど自分はどういう手順で最終的なソースが生成されているのか知らないしXcodeやEclipseからそういうものを動かす方法も知らないので、コマンドライン版をビルドした生成物を使ってXcodeやEclipseにソースを組み込むことにする。

必要となるソースファイルを取り出すには、

  • mrubyをコマンドラインからmakeしてコンパイル、リンクしてソースをすべて生成する
  • 中間生成されたものは mruby/build/host/ 以下に出力されている
  • 生成された mruby/build/host/mrblib/mrblib.c, mruby/build/host/src/y.tab.c を取り出す
  • リポジトリに含まれる、mruby/src/.c, mruby/src/.h, mruby/src/lex.def, mruby/include/* も取り出す
    • lex.defってリポジトリに含まれてるけど、src/mruby_core.rakeのターゲットとして書かれてるし自動生成されるものじゃないのかな?
  • 今のところmrbgemsは使わない

これをXcodeのプロジェクトに組み込んでiOS上で動かすには、

  • 例えばlibs/mruby_bindigというディレクトリを作って取り出したソースを入れる
  • Xcodeで、Bulid Settings > Search Paths > User Header Search Paths に "$(PROJECT_NAME)/libs/mruby_binding/include" を追加
  • Build Settings > Search Paths > Always Search User Paths を No にする(non-recursiveにしてるのに、libs/cocos2dx/support/zip_support/unzip.cpp あたりから mruby/include/mruby/string.h が読まれてしまうっぽい)
  • 今回gemのものは使わないので、Build Settings > Apple LLVM compiler 4.2 - Preprocessing > Preprocessor Macros で、DebugとRelease両方に DISABLE_GEMSマクロを定義する
  • ENABLE_STDIOは、DISABLE_STDIOを定義しなければ mrbconf.h で有効にされる
  • 浮動小数点数にdoubleじゃなくてfloatを使うようにする MRB_USE_FLOAT も定義してやる

Cocos2dx用のmrubyスクリプトインタフェースの実装

Cocos2dxのスクリプト言語のインタフェースは CCScriptEngineProtocol というクラスを継承し、抽象メソッドをすべて実装してやれば使えるようになる。抽象メソッドには、ファイルからスクリプトを実行する executeScriptFile()やレイヤーへのタッチイベントを扱うexecuteLayerTouchesEvent()などなど、多数ある。それらのメソッドを適切にmrubyに渡して処理すればよい。

スクリプト側の関数をコールバックとして登録しておいてCocos2dx側から呼び出す仕組みは、Cocos2dx側にはスクリプトの関数のハンドラ(intの値)を登録して、コールバック時にはそのハンドラに対応する関数を呼び出す、という仕組みになっている。

Cocos2dxのクラスやそのメソッドのバインディング

Cocos2dxには大量にクラスがあり、それぞれメソッドを持っているので、ちゃんとやるのであればすべてmrubyから呼び出せるようにバインドしてやる必要がある。

面倒なのは、CCPointCCSizeなどといった小さな、C/C++では実体として持つような構造体もいちいちmrubyのデータポインタとしてつつんでやって受け渡しをしないといけない。またC++側でシングルトンオブジェクトとなっているものも毎回作り直しになってしまう。

mrubybindというある程度自動で関数やメンバ関数をバインドしてくれるライブラリは、intfloatstringなどの単純な値の引数や戻り値のものにしか使えないので、結局かなりの項目を手書きしないといけない。辛い。

Luaのバインディングはtolua++というのを使っている。tolua++はCのヘッダファイルのような定義ファイルを書いてツールに食わせると、スクリプトとCの関数をつなぐグルーコードを生成してくれて、それを組み込んで使うという方式。今は手で書いてるけど、将来的にはこういうものが欲しいね。

成果

サンプルを動かすのに必要な最低限のバインダだけ用意して、Cocos2dxのLuaテンプレートと同様のものが動くようになったところで、githubに上げた。

シーン構築部のソース:ま、このくらいならあまりLua版と変わらないね

class TestScene
include Cocos2d
include CocosDenshion

def initialize
visibleSize = CCDirector.sharedDirector.getVisibleSize
origin = CCDirector.sharedDirector.getVisibleOrigin

# play background music, preload effect

# uncomment below for the BlackBerry version
# bgMusicPath = CCFileUtils.sharedFileUtils.fullPathForFilename("background.ogg")
bgMusicPath = CCFileUtils.sharedFileUtils.fullPathForFilename("background.mp3")
SimpleAudioEngine.sharedEngine.playBackgroundMusic(bgMusicPath, true)
effectPath = CCFileUtils.sharedFileUtils.fullPathForFilename("effect1.wav")
SimpleAudioEngine.sharedEngine.preloadEffect(effectPath)

# run
sceneGame = CCScene.create
sceneGame.addChild(createLayerFarm(visibleSize, origin))
sceneGame.addChild(createLayerMenu(visibleSize, origin))

CCDirector.sharedDirector.runWithScene(sceneGame)
end

def createLayerFarm(visibleSize, origin)
@layerFarm = CCLayer.create

bg = CCSprite.create("farm.jpg")
bg.setPosition(origin.x + visibleSize.width / 2 + 80, origin.y + visibleSize.height / 2)
@layerFarm.addChild(bg)

# add land sprite
4.times do |i|
2.times do |j|
spriteLand = CCSprite.create("land.png")
spriteLand.setPosition(200 + j * 180 - i % 2 * 90, 10 + i * 95 / 2)
@layerFarm.addChild(spriteLand)
end
end

# add crop
frameCrop = CCSpriteFrame.create("crop.png", CCRectMake(0, 0, 105, 95))
4.times do |i|
2.times do |j|
spriteCrop = CCSprite.createWithSpriteFrame(frameCrop)
spriteCrop.setPosition(10 + 200 + j * 180 - i % 2 * 90, 30 + 10 + i * 95 / 2)
@layerFarm.addChild(spriteCrop)
end
end

# add moving dog
spriteDog = creatDog(visibleSize, origin)
@layerFarm.addChild(spriteDog)

# handing touch events
@touchBeginPoint = nil

@layerFarm.registerScriptTouchHandler do |eventType, x, y|
if eventType == CCTOUCHBEGAN
onTouchBegan(x, y)
elsif eventType == CCTOUCHMOVED
onTouchMoved(x, y)
else # ENDED or CANCELLED
onTouchEnded(x, y)
end
end
@layerFarm.setTouchEnabled(true)

return @layerFarm
end

def creatDog(visibleSize, origin)
frameWidth = 105
frameHeight = 95

# create dog animate
textureDog = CCTextureCache.sharedTextureCache.addImage("dog.png")
rect = CCRectMake(0, 0, frameWidth, frameHeight)
frame0 = CCSpriteFrame.createWithTexture(textureDog, rect)
rect = CCRectMake(frameWidth, 0, frameWidth, frameHeight)
frame1 = CCSpriteFrame.createWithTexture(textureDog, rect)

spriteDog = CCSprite.createWithSpriteFrame(frame0)
@spriteDogIsPaused = false
spriteDog.setPosition(origin.x, origin.y + visibleSize.height / 4 * 3)

animFrames = CCArray.create

animFrames.addObject(frame0)
animFrames.addObject(frame1)

animation = CCAnimation.createWithSpriteFrames(animFrames, 0.5)
animate = CCAnimate.create(animation)
spriteDog.runAction(CCRepeatForever.create(animate))

# moving dog at every frame
CCDirector.sharedDirector.getScheduler.scheduleScriptFunc(0, false) do
unless @spriteDogIsPaused
x = spriteDog.getPositionX
if x > origin.x + visibleSize.width
x = origin.x
else
x = x + 1
end

spriteDog.setPositionX(x)
end
end

return spriteDog
end

def onTouchBegan(x, y)
puts("onTouchBegan: #{x}, #{y}")
@touchBeginPoint = {:x => x, :y => y}
@spriteDogIsPaused = true
# CCTOUCHBEGAN event must return true
return true
end

def onTouchMoved(x, y)
puts("onTouchMoved: #{x}, #{y}")
if @touchBeginPoint
pos = @layerFarm.getPosition
@layerFarm.setPosition(pos.x + x - @touchBeginPoint[:x], pos.y + y - @touchBeginPoint[:y])
@touchBeginPoint = {:x => x, :y => y}
end
end

def onTouchEnded(x, y)
puts("onTouchEnded: #{x}, #{y}")
@touchBeginPoint = nil
@spriteDogIsPaused = false
end

# create menu
def createLayerMenu(visibleSize, origin)
layerMenu = CCLayer.create

menuPopup = menuTools = effectID = nil

# add a popup menu
menuPopupItem = CCMenuItemImage.create("menu2.png", "menu2.png")
menuPopupItem.setPosition(0, 0)
menuPopupItem.registerScriptTapHandler do
# stop test sound effect
SimpleAudioEngine.sharedEngine.stopEffect(effectID)
menuPopup.setVisible(false)
end
menuPopup = CCMenu.createWithItem(menuPopupItem)
menuPopup.setPosition(origin.x + visibleSize.width / 2, origin.y + visibleSize.height / 2)
menuPopup.setVisible(false)
layerMenu.addChild(menuPopup)

# add the left-bottom "tools" menu to invoke menuPopup
menuToolsItem = CCMenuItemImage.create("menu1.png", "menu1.png")
menuToolsItem.setPosition(0, 0)
menuToolsItem.registerScriptTapHandler do
# loop test sound effect
effectPath = CCFileUtils.sharedFileUtils.fullPathForFilename("effect1.wav")
effectID = SimpleAudioEngine.sharedEngine.playEffect(effectPath)
menuPopup.setVisible(true)
end
menuTools = CCMenu.createWithItem(menuToolsItem)
itemSize = menuToolsItem.getContentSize
menuTools.setPosition(origin.x + itemSize.width/2, origin.y + itemSize.height/2)
layerMenu.addChild(menuTools)

return layerMenu
end
end

TestScene.new

実際に使えるようになって、最終的にはCocos2dxに取り込まれたらいいね。