皆さんはボードゲームやSLGは嗜まれますか?
ボードゲームやSLGには盤上で駒を動かすものがあり、ファイアーエムブレムやそのスマホ版であるファイアーエムブレムヒーローズもその一種です。
盤上で駒を動かすこと自体は難しくありません。将棋は入門記事やサンプルなどでもたまに使われます。ですが、SLGではちょっと話が変わってきます。駒同士の戦闘が発生するのでその戦闘予測を表示したり、それを見て行動をやり直したりできるように実装しないとストレスのたまるゲームとなります。スマホ上ではさらに操作が制限されます―実質タップとドラッグしか使えません。Bボタンでキャンセルとかあればいいのに!
FEHではこんな感じ
ターン開始直後(未選択)
マルスをタップ(選択)
盤面をタップ(移動)
敵をタップ(攻撃準備)
そのまま敵をタップ(攻撃後)
FEHではこの駒の操作を以下のように実現していると思われます
・最初は何も選択していない
・駒をタップするとその駒を選択する
・選択した駒があるうえでその駒をタップすると選択を解除する
・選択した駒があるうえで移動可能範囲をタップするとそこへ移動する
・移動先があるうえで自分をタップすると移動終了とする。なお移動先と移動元が同じときは代わりに選択を解除する
・選択した駒があるうえで敵をタップすると戦闘可能位置へ移動し、その敵を戦闘準備対象とし戦闘結果予測を表示する
・戦闘準備した敵がいるうえで他の場所や他の敵をタップするとそこへ移動したりそちらを戦闘準備対象とする
・戦闘準備した敵がいるうえでその駒かその敵をタップすると戦闘を実行し行動を終了する
・駒をドラッグした場合、ドラッグ開始した駒を選択し指を離したところでタップしたのと同じ扱いとする。なお敵の駒に重ねたときに戦闘準備とし、指を離したらそのまま戦闘を実行する
・上記中において移動範囲外をタップしたら行動をキャンセルし選択を解除する
これを if で書くとこんな感じになります
var 選択駒 : 駒? = null
var 戦闘準備駒 : 駒? = null
var 移動先 : 枡? = null
var 移動元 : 枡? = null
fun tap(対象枡 : 枡, 対象駒 : 駒?){
if (選択駒 == null && 対象駒 != null) 選択(対象駒) // 選択駒 = 対象駒 になる
else if (選択駒 != null && 対象駒 == null) 選択駒!!.移動(対象枡) // 移動先 = 対象枡
else if (選択駒 != null && 選択駒 == 対象駒 && (移動先 == null || 移動先 == 移動元)) 選択解除(選択駒) // 選択駒 = null; 移動元 = null; 移動先 = null
else if (選択駒 != null && 選択駒 == 対象駒 && 移動先 != null && 移動先 != 移動元) 移動確定(選択駒)
...
}
まだ半分も書いてないのにこれはしんどいですね!皆さんも一度腕試しに書いてみませんか?
戦闘に関わる if 分には加えて「対象の駒が敵か味方か」なんてのも入ってきます。
また、FEHでは環境設定で「ドラッグ時に戦闘準備をする/省略する」「駒をダブルタップしたら行動完了とする」なんて切り替えもできますのでさらにIf文は増えることでしょう。
単に if が多すぎるという問題のも問題ですが、操作対象が null だったり null でなかったりというのも大問題です。
kotlin はNullを柔軟に扱える言語ですが操作対象が null でも正しく動くかなんてのは言語以前の問題ですのでフォローしきれず、選択駒!!.移動() みたいに null だとやっぱり落ちるコードを書くことになります。これはとてもしんどいものですし、NullPointerExceptionを出さずに書ききるのは常人には不可能でしょう。
そこで、State Patternを使います
Stateは状態です。つまり、上記の仕様をこう書き換えます。
・最初は未選択状態とする
・駒をタップするとその駒を選択状態にする。なおその枡を移動元と移動先とする
・選択状態でその駒をタップすると選択を解除する
・選択状態で移動可能範囲をタップするとそこを移動先にした選択状態にする
・選択状態で自分をタップした場合、移動終了とする。なお移動先と移動元が同じときは代わりに選択を解除する
・選択/移動状態で敵をタップすると戦闘可能位置へ移動し、その敵を戦闘準備対象とし戦闘結果予測を表示する
・戦闘準備状態で他の場所や他の敵をタップするとそこへ移動したりそちらとの戦闘準備状態になる
・戦闘準備状態でその駒かその敵をタップすると戦闘を実行し行動を終了する
・駒をドラッグした場合、ドラッグ開始した駒を選択し指を離したところでタップしたのと同じ扱いとする。なお敵の駒に重ねたときに戦闘準備とし、指を離したらそのまま戦闘を実行する
・上記中において移動範囲外をタップしたら行動をキャンセルし選択を解除する
全然変わってないって?そりゃそうでしょう。むしろ同じ仕様なのに変わってしまったら困ります。
変わるのはここからです。
ある状態に対して駒または盤をタップしたら次はどの状態になるか、という視点で図にするとこうなります。
これを状態遷移図(State Machine / State Chart)と呼び、状態にアクションを加えたら次にどの状態になるかを表しています。
一見複雑に見えますが、行動終了≒未選択状態なので実質的に
未選択・選択・戦闘準備の3状態と「盤タップ」「駒タップ」の2アクション
しかありません。自分か敵か・移動しているかはアクション内で判定できます。
なのでクラス図にするととてもシンプルになります。
最近は継承が忌避されることが多いですしオブジェクトの生成も嫌がられがちです。他の記事も Interface を使いオブジェクトはSingletonにするものが多いですね。
ですが私としては「状態により存在するものと存在しないものとがある」場合には親を抽象クラスとし、 オブジェクトは必要なパラメータを埋め込んだ値オブジェクトにすることをお勧めします。
現在の行動 Stateは他のStateと入れ替えることができるためvariant で、行動のメソッドを叩くと次の行動状態を返すように作り、これを現在の行動へセットしていきます。
呼び出すコードはこんな感じになります。タップするたびに行動の状態が変わります。とてもシンプルですね!
//初期値は未選択
var 行動 = 未選択
fun tap(対象枡 : 枡, 対象駒 : 駒?){
行動 = if (対象駒 != null) 行動.駒タップ(対象駒, 対象枡) else 行動.盤タップ(対象枡) //タップするたび公道を更新する
}
盤タップ(対象枡) や駒タップ(対象駒, 対象枡) で次の状態を返します
class 未選択 {
override fun 盤タップ(対象枡 : 枡) = 未選択() //何も起きない
}
この返り値を作るときに状態内に駒や移動先を埋め込んでやるんです。
class 未選択 {
override fun 駒タップ(対象駒 : 駒?, 対象枡 : 枡?) = 選択(選択駒 = 対象駒, 移動先 = 対象枡, 移動元 = 対象枡) //駒をタップしたときはその駒を選択する
}
この「選択したときには選択した駒があるが選択してないときは選択した駒はない」、
つまりNullableなデータというのはとても扱いが難しいものです。
状態と別に対象駒を持った場合は対象駒をnullのままにしてしまったりすることがありますが
var 行動 = 未選択
var 選択駒 : 駒? = null
class 未選択 {
override fun 駒タップ(対象駒 : 駒, 対象枡 : 枡) {
// TODO:選択駒 = 対象駒 //対象駒セットし忘れた!
return 選択()
}
}
Kotlinではフィールドを override する際に not null 制約をつけることができるので次の状態に必要なパラメータが指定されなかったときはコンパイルエラーになります。
これにより選択時に選択した駒がnullでないことを強制できます。
class 選択(override val 選択駒 : 駒, override val 移動元 : 枡, override val 移動先 : 枡) : 行動(選択駒, null, 移動元, 移動先){}
class 未選択 {
override fun 駒タップ(対象駒 : 駒, 対象枡 : 枡) = 選択() // コンパイルエラー
}
また、not null であることはコンパイラが把握しているので、? や !! といった nullable へのアクセス手段をとる必要がなくなります。逆に ? や !! が出てきたらそれはロジックのミスだとわかるようになります。
class 戦闘準備(override val 選択駒 : 駒, override val 戦闘駒 : 駒, override val 移動元 : 枡, override val 移動先 : 枡) : 行動(選択駒, 戦闘駒, 移動元, 移動先){
fun なんかの関数(){
戦闘予測表示(選択駒, 対象駒)// 駒が not null なので必ず計算できる
}
}
class 選択(override val 選択駒 : 駒, override val 移動元 : 枡, override val 移動先 : 枡){
fun なんかの関数(){
戦闘予測表示(選択駒, 対象駒!! )// 攻撃対象が nullable なので実行できなさそう、つまりこの行は間違えてる!
}
}
// State Pattern でないときは多くの nullable 変数を見ることになるので!!だらけになるしちょっと間違えるとNullPointerExceptionで落ちる
var 選択駒 : 駒? = null
var 戦闘駒 : 駒? = null
var 移動先 : 枡? = null
var 移動元 : 枡? = null
fun tap(対象枡 : 枡, 対象駒 : 駒?){
if (選択駒 != null && 対象駒 != null && 選択駒 != 対象駒) 戦闘結果表示(選択駒!!, 対象駒!!)
また、どの状態の時も移動するロジックなどは同じなので、親クラスに共通化できます。
最終的なコードはこんな感じになります。実際には次の状態を返す前に移動できるか判定したりキャラを動かしたり戦闘予測を表示したりなんやかんやしてから return 次の状態() ですが省略します。選択.盤タップ() だけ書いとくので他は想像してください。
class 駒
class 枡
abstract class 行動(open val 選択駒 : 駒?, open val 戦闘駒 : 駒?, open val 移動元 : 枡?, open val 移動先 : 枡?){
abstract fun 駒タップ(対象駒 : 駒, 対象枡 : 枡) : 行動
abstract fun 盤タップ(対象駒 : 駒, 対象枡 : 枡) : 行動
//他の関数はoverride禁止
fun 他共通関数(){}
fun 選択時の共通関数(対象駒 : 駒, 移動元 : 枡, 移動先 : 枡) : 行動 {
if (!対象駒.移動可能(移動元, 移動先)) return 未選択()
表示の場所を変えるコードとか()
return 選択(対象駒, 移動元, 移動先)
}
}
class 未選択() : 行動(null, null, null, null) {
override fun 駒タップ(対象駒 : 駒, 対象枡 : 枡) = 選択(対象駒, 対象枡, 対象枡)
override fun 盤タップ(対象駒 : 駒, 対象枡 : 枡) = 未選択()
}
class 選択(override val 選択駒 : 駒, override val 移動元 : 枡, override val 移動先 : 枡) : 行動(選択駒, null, 移動元, 移動先) {
override fun 駒タップ(対象駒 : 駒, 対象枡 : 枡) = if (選択駒 == 対象駒) 未選択() else 戦闘準備(選択駒, 対象駒, 移動元, 移動先)
override fun 盤タップ(対象駒 : 駒, 対象枡 : 枡) = 選択時の共通関数(対象駒, 移動元, 対象枡)
}
class 戦闘準備(override val 選択駒 : 駒, override val 戦闘駒 : 駒, override val 移動元 : 枡, override val 移動先 : 枡) : 行動(選択駒, 戦闘駒, 移動元, 移動先){
override fun 駒タップ(対象駒 : 駒, 対象枡 : 枡) = 未選択()
override fun 盤タップ(対象駒 : 駒, 対象枡 : 枡) = 選択(対象駒, 移動元, 対象枡)
}
クラスのextends(継承/拡張)は機能ではなく制約の継承/拡張
最近継承は良くないとか避けるべきとか間違ってるとか言いますが、そもそも継承とは何でしょうか?
私はオブジェクト指向についてはよく知らないのでその前から存在する継承として考えたいと思います。
継承が最初に登場したのはアリストテレスの動物誌/形而上学です。
継承とは引き継ぐことです。ですが引き継ぐというのは必ず「何か」を引き継ぎます。
ではクラスの継承は何を引き継いでいるのでしょうか?
それは「制約」であるというのがアリストテレスの見解です。
そもそも「何か」について記述するという事は、わかっていないことを分かっていることに置き換える作業であり、わかっているという事は「そうではない」部分を除外していくことです。
例えば「魚は卵を産む」は正しいでしょうか?もちろん正しくありません。
アリストテレスは「魚は卵か子供を産む」(分裂して増えたりはしない)としており、
具体的な魚ごとに「ほとんどの魚は卵を産む」「軟骨魚類は子供を産む」「ウナギは泥から生まれる(知らないという意味)」と記述しています(※現在の動物学的にはもちろん正しくありません)。
この「魚」が親クラス、「魚の種類」が子クラスであり、子クラスは親クラスの「卵か子供を産む」の制約を継承したうえで「この種類はどちらかを産むからもう一方を産むことはない」とその在り方をより強く制限しています。
このように「物事について記述することはその在り方を制限していくこと」というのがまずあり、そこから「記述を拡大することはその在り方の制限を拡大すること」が導き出されます。
クラス記述はそのクラスの在り方を記述したものですから、当然拡張されたクラスは元のクラスに比べて在り方が制限される、つまり「特化」されたものとなります。オブジェクト指向の extends(継承/拡張) とUMLなどで使われる specialization(特化)ってまるで逆のような印象を受けますが、機能じゃなくて制約の話であるとすればなにも問題ないですね!
さて今回の継承は正しく継承できているでしょうか?
クラス全体としては「行動」を「ある段階の行動」により強く制限しているのでこのレベルでは間違っていないと思われます。中身としても「あるnullable」を「not null」 により強く制限しているため特に問題はなさそうです。
もちろん物事は常にこんなに簡単であるわけでもないし、この考え方だと新しい何かを発見するたびに常に親クラスが更新されてしまいます。例えば物理学は「人の知ってる限りの物理法則」を対象にしますが、新しい物理法則が発見されるたびに更新されまくっています。例えばニュートン力学は元々はそれ自体が物理法則と同等でしたが、現在は「相対性理論や量子力学の影響が無視できる制限付きで成立する物理法則」という立ち位置です。ニュートン力学自体は変わってないし間違ってもいないのに親クラスである物理学のほうがより広くなってしまったわけですね。
私は遠慮せず親クラスに機能を追加してしまう人間ですが、プログラミングで親クラスを変更するのは良くないというのは知られているとおりです。ならば子クラスで機能を拡大しよう、となりがちでプログラミングで子クラスは機能を拡張・継承することが多く、私もそれは否定しません。ただ本来想定していた使い方と異なるので十分注意を払い間違いを減らす工夫をしたうえで使うことが重要でしょう。
ん?機能を拡張することを本来想定してる仕組みはないのかって?そりゃありますよ。それは一般に型クラスと呼ばれています。