util-evalでゲームの設定を書く

Scala Advent Calendar jp 2011 - [PARTAKE]の8日目です。


Scalaを利用している企業というとTwitterが有名ですね。そのTwitterは開発したScalaライブラリを色々と公開しています。
util-evalはその内のひとつで、実行時にScalaコードをコンパイル&評価するライブラリです。主に設定ファイルをScalaで記述するために利用されます。


で、ゲームの設定もScalaで書いたら(自分が)幸せになれるので使ってみました。

設定の定義

ゲームの設定として、IDが割り振られた武器のMapがあるとします。
武器には名前と攻撃力が設定できます。

case class Weapon(name: String, attack: Int)

trait GameConfig {
  def weapons: Map[Int, Weapon]
}

武器設定ファイル

1つの設定ファイルに1つの武器を書くとします。

1 -> Weapon(
  name = "ナイフ",
  attack = 10
)

(key, value) のタプルです。
読み込み時にディレクトリを指定するとその下のファイルを全部拾ってきてMapを構築します。
また、1ファイルにMap全体を書くこともできて、その場合は読み込み時にファイルを指定することにします。


設定フォルダはこんな構成。

config
├weapons
│ ├axe.scala
│ ├bow.scala
│ └knife.scala
└config.scala

weapons以下のファイルには先程のように武器が1つずつ定義されています。
config.scalaは後述。

Eval!

そんな感じで設定を読み込んで評価するtraitです。
util-evalがscala-compilerに依存していて楽だったのでnsc.ioを使っていますが、ホントは別のものを使った方がいいでしょう。

import com.twitter.util.Eval
import scala.tools.nsc.io.{File, Path}

trait ConfigReader {
  private val eval = new Eval()

  def read[A](file: File): A = {
    val in = file.inputStream()
    try eval[A](in)
    finally in.close()
  }

  def readMap[A, B](path: Path): Map[A, B] =
    if (path.isFile)
      read[Map[A, B]](path.toFile)
    else
      readDirectoryMap(path)

  private def readDirectoryMap[A, B](dir: Path): Map[A, B] =
    dir.walkFilter(_.isFile)
      .map(_.toFile)
      .map(read[(A, B)])
      .foldLeft(Map.empty[A, B])(_ + _)
}

readメソッド内の eval[A](in) が実際にファイルを読んで評価している部分になります。
内部ではファイルの内容を Function0[Any] を継承したクラスのapplyで包んでコンパイル、そのクラスのインスタンスを生成してapplyを呼び、結果を型キャストしています。*1
要するに、設定ファイル内ではメソッド内で書けるものが全て書けて*2、ファイル末尾に書いた値が帰ってくるということです。


readMap[A,B](Path)は引数にファイルが渡されたらそのままreadを、ディレクトリならPath#walkFilterを利用してディレクトリ内のファイルを再帰的に評価し、Mapにまとめます。

読み込み

ゲームからはconfig/config.scalaを読んでGameConfigを取得します。
config.scalaの中身はこんな感じ。

import scala.tools.nsc.io.Path

(path: Path) => new GameConfig with ConfigReader {
  val weapons = readMap[Int, Weapon](path / "weapons")
}

これを以下のようにして読み込み。

import scala.tools.nsc.io.Path

object Main extends App with ConfigReader {
  val path = Path("config")

  val config = read[Path => GameConfig](path / "config.scala" toFile)(path)

  println(config.weapons)  // Map(1 -> Weapon(ナイフ,10), 2 -> Weapon(弓,20), 3 -> Weapon(斧,30))
}

設定ファイルに関数を記述できるのがポイントですね。
武器以外の設定が必要になったらGameConfigに追加していくわけです。

ローカライズ

さて、作ったゲームを沢山の国の人に遊んでもらうにはローカライズが必要です。
ConfigReaderに以下のようなメソッドを追加します。

  def localize[A, B](base: Map[A, B], path: Path): Map[A, B] = {
    val loc = readMap[A, B => B](path)
    for ((id, config) <- base)
    yield id -> (loc.get(id).map(_(config)) getOrElse config)
  }

型Bを変換する関数を値に持つマップを読み込み、IDが一致する設定に対して適用します。


config/localize.scalaとして以下の内容のファイルを作成します。

import scala.tools.nsc.io.Path

(base: GameConfig, path: Path) => new GameConfig with ConfigReader {
  val weapons = localize(base.weapons, path / "weapons.scala")
}

weapons.scalaはこんな内容です。

Map[Int, Weapon => Weapon](
  1 -> (_.copy(name = "knife", attack = 15)),
  2 -> (_.copy(name = "bow")),
  3 -> (_.copy(name = "axe"))
)

case classのcopyメソッドで名前だけ変更することができます。
ナイフは弱かったので攻撃力も上げてみました。


読み込みはこうなります。

object Main extends App with ConfigReader {
  /* 省略 */
  val localized = read[(GameConfig, Path) => GameConfig](path / "localize.scala" toFile)(config, path / "localize" / "en")

  println(localized.weapons)  // Map(1 -> Weapon(knife,15), 2 -> Weapon(bow,20), 3 -> Weapon(axe,30))
}

ローカライズできました!

嬉しいこと

設定ファイルがScalaで何が嬉しいか。
Scalaそのものを書けることで自由度が高いというのはもちろんですが、IDEの支援をフルに受けることができるのが大きいでしょう。
ちょっと例を挙げてみます。

コード補完

f:id:kxbmap:20111208231809p:image
シンタックスハイライトも効きますね。

リファクタリング

f:id:kxbmap:20111208231810p:image
設定ファイル内でリファクタリングできます。設定ファイルから定義にジャンプすることもできます。

エラーハイライト

f:id:kxbmap:20111208231811p:image
もちろん型が違ったら表示してくれます。

検索

f:id:kxbmap:20111208231812p:image
参照の検索も。


素晴らしいですねー。

おまけ

Scalaz*3を使ってreadMapを以下のように書き換えます。

  def readMap[A, B: Semigroup](path: Path): Map[A, B] =
    /* 省略 */

  private def readDirectoryMap[A, B: Semigroup](dir: Path): Map[A, B] = {
    implicit val r = Reducer[(A, B), Map[A, B]](Map(_))
    dir.walkFilter(_.isFile).toStream
      .map(_.toFile)
      .map(readFile[(A, B)])
      .foldReduce
  }

そしてWeaponのコンパニオンオブジェクトを定義。

object Weapon {
  implicit val WeaponSemigroup: Semigroup[Weapon] =
    semigroup((w1, w2) => throw new IllegalArgumentException("duplicate key: " + w1 + ", " + w2))
}

元のコードではweaponsのkeyが被っていた場合後から読んだもので上書きされていましたが、これによって被ってたらエラーを吐くようになります。他にも先に読んだものを優先したり、どうにかして合成する、という定義もできますね。


さらにはこんな感じ

object Weapon extends ConfigReader {
  implicit lazy val WeaponSemigroup: Semigroup[Weapon] =
    read("typeclasses" / "WeaponSemigroup.scala" toFile)
}

色々楽しいことができそうな予感がします。

*1:このとき生成されるクラスのコードは実行時引数に-Deval.debugを指定すると標準出力に出力されます

*2:#includeというのもありますが割愛

*3:Scalazについては[http://partake.in/events/4b3afdc8-e4ec-4010-b8ec-31b89210dda0:title]を楽しみにしているといいんじゃなイカ