Posted at

ゲームプログラミングで学ぶScala -- 座標を表現してみる


このシリーズの趣旨

ゲームでよく使われるデータ構造やロジックをテーマにScalaでのプログラミングを学んでみようと思います。

主にScala初学者(Scalaの詳細は分からないけど、他のプログラミング言語を扱ったことはあり、Scalaに興味がある)が対象です。


この記事の趣旨

今回のテーマは「座標」です。

座標は、マス目のようなフィールドの表現や、ドット単位のグラフィックス、衝突や反射などを扱う物理演算などなど...、ゲーム内の様々な用途で使われます。

基本編では座標を表すデータ構造について考察し、仕様に沿ってScalaコードとして定義してみます。

また、応用編として、座標を利用するパターンのいくつかを紹介し、それをどのようにScalaコードとして扱うのか、段階的に見ていきます。


基本編


ここで扱う座標の仕様


  • 座標は2次元(XとY)で表すものとします

  • 座標は値オブジェクトとして扱い、イミュータブルにします

  • 座標には有効範囲を設けることができ、その範囲外の座標の生成は失敗するようにします

  • 座標を表す数値の型は型パラメータとして受け取ることで汎用的に使えるようにします


case classを用いた定義

まずは、座標を表すデータ型に名前をつけ、構造を定義します。

(とりあえず、座標は整数値として保持します)

final case class Pos(x: Int, y: Int)

val pos = Pos(0, 0)

assert(pos.x == 0 && pos.y == 0)


finalをクラスの定義につけると、そのクラスを継承することが出来なくなります。

個人的には、抽象クラスではない(未実装の抽象メンバを持っていない)クラスは常にfinalにすることを検討するのが良いと考えています。


case classは非常に便利な機能を備えていますが、ここでは上記のコードの範囲内で少しだけ紹介します。

まず、定義をみてみましょう。

final case class Pos(x: Int, y: Int)

このようにcase classを定義すると、コンストラクタ引数であるxyが自動的にpublicで不変なメンバ変数(valをつけたもの)として解釈されます。ですので、Posのインスタンスに対してpos.xとアクセス出来ます。

このメンバ変数は不変なので、書き換えることは出来ません。

pos.x = 100 // コンパイルエラー!

case classをインスタンス化する際にnewは必要ありません。

これだけでインスタンス化されます。

val pos = Pos(0, 0)

書き換えたい場合は、copyメソッドを利用してメンバを書き換えた新しいインスタンスを作ることが出来ます。

val pos2 = pos.copy(y = 100)

assert(pos2.x == 0 && pos2.y == 100)

ここまでで、以下が実現できました。


  • 座標は2次元(XとY)で表すものとします

  • 座標は値オブジェクトとして扱い、イミュータブルにします


生成時のチェック

続いて、有効範囲の設定です。

仕様はこうなっていました。


  • 座標には有効範囲を設けることができ、その範囲外の座標の生成は失敗するようにします

生成に失敗する、ということは、無効な座標を持った座標インスタンスが存在しないことが保証されます。

インスタンス生成用のファクトリメソッドをコンパニオンオブジェクトに設置しましょう。

ここで、xyの値について、有効範囲のチェックをすることにしますが、まずは直接ファクトリメソッドの中に書くことにします。

(ここでは1以上、9以下とします。これは、将棋盤のマスをイメージしていただければわかりやすいと思います)

object Pos {

def factory(x: Int, y: Int): Pos = {
require(x >= 1 && x <= 9 && y >= 1 && y <= 9)
apply(x, y)
}
}

ここでは、requireを使い、入力値の制約を設けました。

これで、不正な座標インスタンスが生成できません。

ただ、このコードにはいくつか課題があります。


  • 依然として、Posクラスのインスタンスを生成する方法は残っているため、ファクトリメソッドを迂回すれば、不正なインスタンスを生成できてしまう。

  • 有効範囲に関する知識がハードコードされているため、汎用性に乏しい作りになっている。


  • requireは条件を満たさない場合に例外を投げてしまう。

これらの課題をひとつずつ見ていきましょう。

まず、不正なインスタンス作成ですが、これを塞ぐためには、以下を防がないといけません。



  • newによるコンストラクタ


  • copyメソッドによるコピーコンストラクタ

  • コンパニオンオブジェクトに生成されるapply経由でのインスタンス生成

  • 継承による派生経由のインスタンス生成

これらを回避する手段として、sealed abstract case classを利用する方法があります。

sealed abstract case class Pos(x: Int, y: Int)

object Pos {
def factory(x: Int, y: Int): Pos = {
require(x >= 1 && x <= 9 && y >= 1 && y <= 9)
new Pos(x, y) {}
}
}

// 生成側
val pos = Pos.factory(1, 3) // OK



  • sealedは同じファイル内でしか継承を許さないため、継承経由でのインスタンス化を塞ぎます


  • abstractが付いている抽象クラスは継承しないとインスタンス化できないため、その他のインスタンス化を塞ぎます


    • ファクトリメソッド内のnew Pos(x, y){}が、無名クラスを作ることにより、この制約を回避しています。



続いて、値の有効範囲に関する知識をファクトリメソッドの外に出してみましょう。

ここではPosRuleという座標のルールを表した型を定義して、そのインスタンスを受け取ることで、ルールの外部化を試みます。

trait PosRule {

def verify(x: Int, y: Int): Boolean
}

// ファクトリメソッドのみ抜粋
def factory(x: Int, y: Int)(implicit rule: PosRule): Pos = {
require(rule.verify(x, y))
new Pos(x, y){}
}

// 1~9の範囲に収めるルールを定義
implicit val posRule9x9: PosRule = (x, y) =>
x >= 1 && x <= 9 && y >= 1 && y <= 9

// 上記が見えるスコープ内ではこのルールが適用される
Pos.factory(1, 5) // OK
Pos.factory(1, 10) // NG


posRule9x9の定義はSAM(Single Abstract Method) interfaceによって簡略化されています。ここでは詳しくは触れませんが、気になる方は調べてみてください。


これで、値の制約も汎用的に適用できるようになりました。

最後に、requireが例外を投げる問題です。

ここは正直、考え方が分かれるところかもしれませんが、個人的には例外ではなく、結果型としてインスタンスの生成に成功したのかどうかを返したいと考えています。例外の場合、ファクトリメソッドのシグネチャには表れません。呼び出す側が責任を持ってハンドリングする必要があります。

それに対して、結果型であれば、戻り値の型として明示的にシグネチャに表れます。

今回は結果の型として、シンプルにOptionを採用し、失敗であればNoneを返すようにしてみます。

もちろん、Eitherを採用して、具体的にどんなエラーだったのかを返すのも良いですし、その他の型で包むのもありです。その辺りは要件次第でよしなに選びましょう。

// ファクトリメソッドのみ抜粋

def factory(x: Int, y: Int)(implicit rule: PosRule): Option[Pos] = {
if(rule.verify(x, y)) Some(new Pos(x, y){})
else None
}

// 前述のルールを適用してインスタンスを生成
Pos.factory(1, 1) // Some(Pos(1, 1))
Pos.factory(1, 10) // None


型パラメータを利用して汎用化

これまで実装してきた座標は常にIntを使う前提でした。

しかし、座標を表すのに使われる型はIntのみとは限りません。場合によってはFloatDouble、その他の型の座標を定義したいこともあるでしょう。

では、それぞれの型の実装を別々に定義しますか?

sealed abstract case class IntPos(x: Int, y: Int)

sealed abstract case class DoublePos(x: Double, y: Double)
...

これは冗長ですね。

型パラメータを使った定義に置き換えてみましょう。

sealed abstract case class Pos[T](x: T, y: T)

これで、メンバxyに任意の型Tを取ることが出来るようになりました。

しかし、ちょっと待ってください。仕様は以下の通りでした。


  • 座標を表す数値の型は型パラメータとして受け取ることで汎用的に使えるようにします

Tが任意の型ということは、数値以外の型も取れてしまいます。これは今回定義している座標の仕様として正しくありません。

Tに対して制約を設けた上で、ファクトリメソッドとルールも修正してみましょう。

ここでは、数値型を表すNumericを利用します。

sealed abstract case class Pos[T: Numeric](x: T, y: T)

object Pos {
def factory[T: Numeric](x: T, y: T)(implicit rule: PosRule[T]): Option[Pos[T]] = {
if(rule.verify(x, y)) Some(new Pos(x, y){})
else None
}
}

trait PosRule[T] {
def verify(x: T, y: T): Boolean
}

// このルールでは1~9の範囲の整数値を要求するため、明示的にIntを指定
implicit val posRule9x9: PosRule[Int] = (x, y) =>
x >= 1 && x <= 9 && y >= 1 && y <= 9

コード全体としては上記のようになりました。

これで、当初の仕様を満たせているかと思います。


型パラメータについたT: Numericの表記はContextBoundというもので、暗黙の引数Numeric[T]を要求する糖衣構文です。これにより、Numeric[T]のインスタンスが見つからないTに対してはコンパイルエラーが発生します。興味がある方は、scala.math.Numericを調べてみてください。



応用編


移動

ある点から別な点への移動はどのように表現できるでしょうか?

単位時間の移動量として速度を用いるのがわかりやすいですね。

この速度という概念をコードに落とし込んでみます。

まずはデータ構造の定義です。

final case class Speed[T: Numeric](x: T, y: T)

X軸、Y軸のそれぞれの方向に対する単位時間あたりの移動量を持っています。シンプルですね。

Posのコンパニオンオブジェクトに座標と速度受け取り、新しい座標を返すメソッドを作ってみましょう。

// object Posの中に追記

def moved[T: Numeric](pos: Pos[T], speed: Speed[T]): Option[Pos[T]] {
factory(pos.x + speed.x, pos.y + speed.y)
}

計算後の座標が不正な可能性があるので、このメソッドは結果をOptionで返します。

Posはイミュータブルなので、座標自体が動くわけではなく、計算後の新しい座標が返ってくることに注意です。つまり、位置の座標を持った動く主体を定義した場合、移動後の座標を新しく保持し直す作りとなります。

[課題]

座標と速度を持った等速で移動する物体を表す抽象表現とその具象実装をScalaコードで書いてみましょう。


方向

ある点からある点を見たときに、どの方向にあるかを考えてみます。

まずは、方向を定義します。今回はシンプルに上下左右の4方向を考えます。

(終点が始点と同一だった場合に方向が確定しなくなるので、その場合はNoDirectionを返すようにします。)

sealed trait Direction

case object Left extends Direction
case object Right extends Direction
case object Up extends Direction
case object Down extends Direction
case object NoDirection extends Direction

これらの方向は、単純に、XYの座標の正負と絶対値の大きさを比較することで決定できます。

Posクラスにdirectionメソッドを生やしてみましょう。

// 以下をPosクラスに追記

def direction(that: Pos[T])(implicit n: Numeric[T]): Direction = {
if (n.gt(n.abs(dx), n.abs(dy))) {
n.compare(that.x, x) match {
case 1 => Right
case -1 => Left
case _ => NoDirection
}
} else {
n.compare(that.y, y) match {
case 1 => Down
case -1 => Up
case _ => NoDirection
}
}
}

T型の値の比較を行うために、Numeric[T]のインスタンスを取得しています。これが出来るのは、Tに対するNumericの制約があるおかげです。


距離

座標間の距離を計算するケースは良くあります。

計算自体は三平方の定理から簡単に導かれます。

コードに落としてみましょう。

// p1, p2間の距離

val distance = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2))

さて、Math.sqrtの戻り値はDoubleですが、このままではこれが距離なのか、その他の何かの値なのか区別がつきません。

距離を表すデータ型を導入して、組み込んでみましょう。

final case class Distance(value: Double) extends AnyVal

// p1, p2間の距離
def distance[T](p1: Pos[T], p2: Pos[T])(implicit n: Numeric[T]): Distance = {
Distance(
Math.sqrt(
Math.pow(n.toDouble(p2.x) - n.toDouble(p1.x), 2) +
Math.pow(n.toDouble(p2.y) - n.toDouble(p1.y), 2))
)
}

AnyValueを継承すると、値クラスと呼ばれる特別なクラスを定義できます。

コンパイル時はDistanceとして扱われますが、実行時はDoubleとして扱われるので、余計なオブジェクト生成を伴いません。

値クラスを利用するには、いくつかの制約がありますので、詳細はドキュメントを参照してみてください。


あとがき

座標まわりの基礎的な実装について、Scalaではこう書けるというのを、駆け足で見てきました。

Posという型を定義し、その型に対する操作を付け足していくことで、様々なロジックを拡張できます。型の制約があるおかげで、安全かつ合理的に書けると思います。

今回のアプローチは、色々な選択肢がある中のひとつにすぎないと思っていますので、もっとこうした方がいいよ、というご意見をいただけましたら幸いです。

また、当然ですが、要件によって欲しい実装は変わります。重要なことは、実装に入る前に何が求められているのかしっかりと見極めることだと思います。

次回はステートマシンについて書く予定です。