ローファイ日記

出てくるコード片、ぼくが書いたものは断りがない場合 MIT License としています http://udzura.mit-license.org/

wasmbotsをRubyで遊ぼう

まず、wasmbotsとは

という話をしないといけません。これはwasmバイナリを作って、ダンジョン探索をするゲームです。

shaneliesegang.com

具体的には?

ダンジョンを探索するためのプログラムを書いて、それを特定のインタフェースを満たしたWASMファイルにコンパイルすれば、あとはそれをアップロードすることでプレイヤーに自動でダンジョン探索をさせることができる、というブラウザゲームです。

プロジェクトのサンプルダンジョンにアクセスしてみれば即イメージが掴めると思います。

shaneliesegang.com

アクセスしたら、いくつかプリセットでアルゴリズムが用意されていることがわかります。

プリセットのプログラムのリスト

適当に1つ選んだら ▶️ のボタンを押すと探索が始まります。

同じように、ダンジョン探索のアルゴリズムを任意の言語で書いて、WASMにコンパイルしてアップロードしたら、自分のコードでプレイヤーにダンジョンを探索させられます。

ただ、任意の言語と言っても、以下の条件を満たしたWASMを生成できないといけません*1

  • 特定の関数をexportするよう指定しなければならない
    • 以下の4つ
      • void clientInitialize(void)
      • size_t setup(size_t requestReserve)
      • bool receiveGameParams(size_t offset)
      • void tick(size_t offset)
  • 特定の関数をimportして利用することも可能である
    • int32_t getRandomInt(int32_t min, int32_t max), void logFunction(int logLevel, unsigned int msgPtr, unsigned int msgLen), void shutdown(void) が用意されている

この条件では例えばGo(GOARCH=wasm などの場合)、そしてRuby.wasmではちょっと難しいかもしれません。また、WASM GCの環境に依存した言語だと動くブラウザと動かないブラウザが出てきそうに思います。

公式のサンプルでは以下の言語が用意されています。

  • C
  • Rust
  • Zig
  • AssemblyScript
  • tinygo

これらのサンプルをベースにコードを少しずつ変えて遊んでみることができそうです。WASMの素振りにも良い題材ではないでしょうか。

ところで、せっかくなので...

wasmbotsをRubyで遊ぶ手順を紹介しますね...。

まず、WASM生成の環境として、先述の通りruby.wasmでは難しいですので、 mruby/edge を使いましょう。

github.com

インストールは以下のような手順です。正確にはmruby/edgeを埋め込んだWASMバイナリを作るコマンドラインツールをインストールします。前提としてRustの環境は必須になるので入れておきましょう

$ cargo install mec

その上で以下の3つのファイルを用意します。全て同じディレクトリに置いてください。

bot.rb

$memory = nil

## wasmbots framework part; 今回使用するenumだけ用意

LOGLEVEL_INFO = 2
LOGLEVEL_WARN = 1
LOGLEVEL_ERROR = 0

MESSAGE_TYPE_ERROR = 1
MESSAGE_TYPE_INITIAL_PARAMETERS = 2
MESSAGE_TYPE_PRESENT_CIRCUMSTANCES = 3
MESSAGE_TYPE_WAIT = 4
MESSAGE_TYPE_RESIGN = 5
MESSAGE_TYPE_MOVE_TO = 6
MESSAGE_TYPE_OPEN = 7
MESSAGE_TYPE_CLOSE = 8

DIRECTION_NORTH = 0
DIRECTION_NORTHEAST = 1
DIRECTION_EAST = 2
DIRECTION_SOUTHEAST = 3
DIRECTION_SOUTH = 4
DIRECTION_SOUTHWEST = 5
DIRECTION_WEST = 6
DIRECTION_NORTHWEST = 7

class PresentCircumstances
  def initialize(last_tick_duration, last_move_result, hit_points, surroundings)
    @last_tick_duration = last_tick_duration
    @last_move_result = last_move_result
    @hit_points = hit_points
    @surroundings = surroundings
  end
  attr_reader :last_tick_duration, :last_move_result, :hit_points, :surroundings
end

def clientInitialize
  logFunction(LOGLEVEL_INFO, "Hello, world! This is made by #{RUBY_ENGINE}")
end

def setup(requested_size)
  logFunction(LOGLEVEL_INFO, "received setup with size: #{requested_size}")

  $memory = SharedMemory.new(requested_size)
  name = "mruby/edge wasmbot"
  $memory[0..17] = name
  # $memory[18..25] = "\0" * 8
  $memory[26..31] = [0, 2, 0].pack("S S S")
  $memory
end
  
def receiveGameParams(offset)
  param = $memory[offset..(offset+10)].unpack("S S S S C C C")
  logFunction(LOGLEVEL_INFO, "param version: #{param[0]}")
  logFunction(LOGLEVEL_INFO, "param engine version: #{param[1]}.#{param[2]}.#{param[3]}")
  logFunction(LOGLEVEL_INFO, "param diagonal_movement: #{param[4]}")
  logFunction(LOGLEVEL_INFO, "param player_stride: #{param[5]}")
  logFunction(LOGLEVEL_INFO, "param player_open_reach: #{param[6]}")
  true
end
  
def tick(offset)
  param_pre = $memory[offset..(offset+8)].unpack("I C S S")

  surroundings_len = param_pre[3]
  surroundings = []

  surroundings_len.times do |i|
    ptr = offset + 9 + i
    tile = $memory[ptr..ptr].unpack("C")
    surroundings.push tile[0]
  end
  curcumstances = PresentCircumstances.new(param_pre[0], param_pre[1], param_pre[2], surroundings)
  $brain.on_tick(curcumstances)
rescue Exception => e
  logFunction(LOGLEVEL_ERROR, "error: #{e.message}")
  logFunction("stopped")
  $memory[0..0] = [MESSAGE_TYPE_RESIGN].pack("C")
end

## main player class

class Brain
  def initialize
    @turn = 0
  end
  attr_reader :turn

  def write_move!(direction)
    logFunction(LOGLEVEL_INFO, "direction: #{direction}")
    $memory[0..2] = [MESSAGE_TYPE_MOVE_TO, direction, 1].pack("C C C")
  end

  def on_tick(curcumstances)
    if turn > 100
      logFunction(LOGLEVEL_ERROR, "turn > 100, stopped")
      $memory[0..0] = [MESSAGE_TYPE_RESIGN].pack("C")
      return
    end

    mod = @turn % 4
    direction = case mod
    when 0
      DIRECTION_NORTH
    when 1
      DIRECTION_EAST
    when 2
      DIRECTION_SOUTH
    else
      DIRECTION_WEST
    end

    write_move!(direction)
    @turn += 1
  end
end

$brain = Brain.new

bot.import.rbs

def shutdown: () -> void

def logFunction: (Integer, String) -> void

def getRandomInt: (Integer, Integer) -> Integer

bot.export.rbs

def clientInitialize: () -> void

def setup: (Integer) -> SharedMemory

def receiveGameParams: (Integer) -> bool

def tick: (Integer) -> void

ここまで準備したら、mecコマンドでWASMファイルをコンパイルできます。

$ mec --no-wasi wasmbots/bot.rb
running: `cp ... src/`
running: `cargo build --target wasm32-unknown-unknown --release`
running: `cp ./target/wasm32-unknown-unknown/release/mywasm.wasm .../bot.wasm`
running: `cd .. && rm -rf work-mrubyedge-vtkixnyy5X5IYJAIZ9ikamdiUKnqm9aB`
[ok] wasm file is generated: bot.wasm

作業ディレクトリに bot.wasm ができるので、このファイルをwasmbotsのダンジョンにuploadしたら動き始めます。

もう少し解説

フレームワーク部分は置いておいて、カスタマイズする部分はここになる感じです。

class Brain
  def initialize
    @turn = 0
  end
  attr_reader :turn

  def write_move!(direction)
    logFunction(LOGLEVEL_INFO, "direction: #{direction}")
    $memory[0..2] = [MESSAGE_TYPE_MOVE_TO, direction, 1].pack("C C C")
  end

  def on_tick(curcumstances)
    if turn > 100
      logFunction(LOGLEVEL_ERROR, "turn > 100, stopped")
      $memory[0..0] = [MESSAGE_TYPE_RESIGN].pack("C")
      return
    end

    mod = @turn % 4
    direction = case mod
    when 0
      DIRECTION_NORTH
    when 1
      DIRECTION_EAST
    when 2
      DIRECTION_SOUTH
    else
      DIRECTION_WEST
    end

    write_move!(direction)
    @turn += 1
  end
end

$brain = Brain.new

#on_tick(curcumstances) というメソッドを実装したクラスを実装して、 $brain というグローバル変数に格納すれば動かせるように作っています。

今回のアルゴリズムRubyで読めばわかるよねって感じですが、1ターンごとに北→東→南→西に動いてぐるぐる回るだけです。また、100ターン動いたら終了させています。

メソッドの最後で $memory というオブジェクトにメッセージを書き込めば、次のアクション(移動、停止...)を指定できます。この $memory オブジェクト(SharedMemory という、mruby/edgeの組み込みクラスのインスタンスです)はWASMの外側、ブラウザ側からもアクセスできるメモリの領域を表現したものです。メッセージはバイナリを書き込むのですが、どういうレイアウトかは Cのサンプルコード を見るのが一番速いかも...*2

合わせて、 PresentCircumstancesインスタンスも渡されます。どういうクラスはコードを見る感じで...。curcumstancesを見るとどの方向に何のオブジェクトがあるかわかるので、例えば扉を開く、のようなアクションを取れたりもします。

細かいところはCやRustのサンプルコードを見て頑張る感じになります(そのうちもうちょっとドキュメントを書くかも)!

RubyでWASMを触ってみたい方、ぜひ遊んでください。

ちなみにmruby/edgeは絶賛基本的な機能を実装中なので、要望やコントリビュートもお待ちしています...。

udzura.hatenablog.jp

なおバージョンは今はもう 1.0.5 です。

*1:インタフェースの表現はC関数に合わせました

*2:少しでもRubyから書き込みやすいように、 Array#pack の一部の機能もmruby/edgeでサポート済みです