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に取り込まれたらいいね。