ゲーム(FEH)におけるダメージ管理で学ぶメソッド設計の前置き

RPGSLGの多くでは敵味方キャラクター間の戦闘が発生し、お互いにダメージを与えHPを削りあいます。
この時に能力値をパラメータとしてダメージを計算することになるのですが、これがゲームバランスやギミックの要求により結構面倒な計算式になったりしますので管理しやすい設計を考えます。

1.FEHのダメージ計算
例としてFEH(ファイアーエムブレムヒーローズ)を挙げます。言語はKotlinですがKotlin特有の記述はそんなに使わないはずなので誰でも読めるかと思います。

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon)

FEHの能力値はHP,攻撃、速さ、守備、魔防の4種類です。武器には物理攻撃と非物理攻撃がありisMaterial()などのメソッドで判別できるものとします。今回速さは使いません。なおHPは現在値と最大値の2種類を持つのが現実的です。同じオブジェクトが持つべきかはまた別問題ですが。

基本式
ダメージ=攻撃側の攻撃 - (攻撃側が物理攻撃の時は被害側の守備 | そうでないときは被害側の魔防)
ダメージがマイナスの時は0とする
攻撃された側はダメージの分だけHPが減少する
HPがマイナスの時は0とする

攻撃側をAttacker, 被害側をTargetとします
これをシンプルにコード化して、例えばattacker.attack(target)メソッドと名付けると次のようになるでしょう

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon) {
    fun attack(target: Hero) {
        val damage = atk - if (weapon.isMaterial) target.def else target.res
        val validDamage = if (damage >= 0) damage else 0
        target.hp = if (damage >= target.hp) 0 else target.hp - damage
    }
}

これだけならそのまま書いても問題ないでしょう。ですが最近次のような武器が追加されました。
フェリシアの氷皿:ダメージ=攻撃側の攻撃 - (被害側の守備 と魔防の低いほう)
錬成ブレス:ダメージ=攻撃側の攻撃 - (被害側が2射程武器の時は被害側の守備 と魔防の低いほう|そうでないときは被害側の魔防)

これをWhen式を使って追加するとこうなるでしょう(これよりHPの減少部分は省略します)

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon) {
     fun attack(target: Hero) {
        val damage = atk - when (weapon) {
            Weapon.FeliciasPlate -> if (target.def < target.res) target.def else target.res
            Weapon.RefinedBreath -> if (!target.weapon.isRanged || target.res < target.def) target.res else target.def
            else -> if (weapon.isMaterial) target.def else target.res
        }
    }
}

これでもいいといえばいいのですが、将来また別の計算をする武器が追加されるたびにWhenを追加することになってしまいますし、実のところこれはダメージ計算のごく一部でしかありません。
実際のダメージ計算式は以下のようになります(英語のWikiから転載)
image.png

上記の守備・魔防の計算はこの式のMitの部分だけです。
この式のどの部分をとっても攻撃側と被害側の関係や能力値によって値が変わってくるので、同じように書くとIfとWhenの山ができてしまいます。この計算を整理することが必要です。

 どうやって整理するか?

オブジェクト指向の設計原則と呼ばれるものはいくつもあります。
オブジェクト指向三原則
単一責任の原則
オープン・クローズドの原則
クリーンアーキテクチャのように「依存関係を一方向にする」なんてのもよく言われますね。
では、これらを実現するにはどうすればいいでしょうか?
よく疎結合にするとか内部のデータ構造を隠すとかオブジェクトではなくインタフェースに依存するとか言いますが、このときに当たり前すぎてめったに言及されないことがあります。

それは、外と内、つまり、攻撃側と被害側を正しく分けるべきである、ということです。

 正しく分けるにはどうすればいいか?

一口に正しく分けるといってもこれではまだ具体的とは言えません。
具体的にどうすればいいか?ここはひとつ人文学の研究成果を借りてみましょう。
ギブソンアフォーダンスです。

 アフォーダンス

Wikipediaから引用します。
アフォーダンス(afforedance)とは、環境が動物に対して与える「意味のことである」(中略)
ギブソンの提唱した本来の意味でのアフォーダンスとは「動物と物の間に存在する行為についての関係性そのもの」である。例えば引き手のついたタンスについて語るのであれば、「"私"はそのタンスについて引いて開けるという行為が可能である」、この可能性が存在するという関係を「このタンスと私には引いて開けるというアフォーダンスが存在する」あるいは「このタンスが引いて開けるという行為をアフォードする」と表現するのである。

心理学的にどういう意味なのかは説明しかねますが、これを工学的に言うとこうなります。
私がタンスを開ける、という文を「タンスが"タンスを開けるという行為"というアフォーダンスを提供する」「私はその行為を実行することができる」の二つに分けて記述することができる。つまり、アフォーダンスを途中に置くことによって両者を分けることができる、ということです。

被害側が攻撃側に「攻撃する行為」をアフォードする
まずは今までシンプルに攻撃側として書いていたメソッドを被害側に移しましょう。

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon) {

    fun attack(attacker: Hero) {
        val damage = attacker.atk - when (attacker.weapon) {
            Weapon.FeliciasPlate -> if (def < res) def else res
            Weapon.RefinedBreath -> if (!weapon.isRanged || res < def) res else def
            else -> if (attacker.weapon.isMaterial) def else res
        }

    }
}

タンスのアフォーダンスはタンスが提供しますが、これは私を対象にして能動的に送り付けるものではなく環境的に存在するものです。つまり、アフォーダンスは提供された相手(私)への参照を持ちません。これを使って攻撃側と被害側に分離します。
具体的にはアフォーダンスを提供される攻撃側への参照を持たないようにする、つまりメソッドの引数から攻撃側への参照を取り除きます。

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon) {
    fun attack(damage: Int, weapon: Weaopn) {
        val preventedDamage= damage - when (weapon) {
            Weapon.FeliciasPlate -> if (def < res) def else res
            Weapon.RefinedBreath -> if (!this.weapon.isRanged || res < def) res else def
            else -> if (weapon.isMaterial) def else res
        }

    }
}

ダメージはただの数値なので攻撃側に計算して送ってもらいます。これで攻撃側と被害側を分離できました!
とはいえまだ武器への参照が残っています。しかも武器が具体的であるため、将来武器が追加されたときにこのWhenの中に武器が追加されることになります。これではオブジェクトが正しく分離できてるとは言えません。そこでWhenを取り除きます。そう、守備か魔防かを出力するアフォーダンスを武器が提供する、ということにするのです。
アフォーダンスは対象へ何かの行為を行うものです。これはつまり、対象を引数にしたコードブロック(関数)もやはりアフォーダンスだということです。そこで、各武器にアフォーダンスとなるコードブロックを保持させ、それを被害側に提供するというコードとして表現することもできます。
アフォーダンスは何かを対象とするアフォーダンスとして環境的に存在するので、こうやって対象以外から提供することもできます。

enum class Weapon(val selectPreventParam: (target: Hero) -> Int = { 0 }, val isRanged:Boolean = false) {
    FeliciasPlate({ target -> if (target.def < target.res) target.def else target.res }),
    RefinedBreath({ target -> if (!target.weapon.isRanged || target.res < target.def) target.res else target.def }),
    MaterialWeapon({ target -> target.def}),
    MagicWeapon({ target -> target.res}),
}

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon) {
    fun attack(damage: Int, weapon: Weapon) {
        val preventedDamage = damage - weapon.selectPreventParam(this)
    }
}

でも武器への参照が残っているのが気持ち悪いという人もいるのではないでしょうか?
もちろんこれも除去することができます。

enum class Weapon(val selectPreventParam: (target: Hero) -> Int = { 0 }) {
    FeliciasPlate({ target -> if (target.def < target.res) target.def else target.res }),
    RefinedBreath({ target -> if (!target.weapon.isRanged || target.res < target.def) target.res else target.def }),
    MaterialWeapon({ target -> target.def}),
    MagicWeapon({ target -> target.res}),
}

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon) {
    fun attack(damage: Int, selectPreventParam: (target: Hero) -> Int) {
        val preventedDamage = damage - let(selectPreventParam)
    }
}

被害側はコードブロックを受信し、letを使って自分を対象に実行します。もちろんselectPreventParam(this)と書いても同じです。

余談ですがこれを推し進めていくと、例えば数値のダメージとその軽減も一つのコードブロックにすることもできます。

Data Class Hero (val hp:Int, val atk:Int, val spd:Int, val def:Int, val res:Int, val weapon:Boolean) {
    fun attack(damageDealer: (target: Hero) -> Int) {
        val preventedDamage = let(damageDealer)
    }
}

//攻撃側の呼び出し
val wpn = Weapon.FeliciasPlate
val damage = 10//攻撃側のダメージを計算し↓コードブロックのdamageにバインドする
val damageDealer:(Hero)->Int = {target->damage - target.let(wpn.selectPreventParam)}
target.attack(damageDealer)

このdamageDealerコードブロックはクロージャとして数値をバインドしているだけなので、やはり攻撃側への参照は持っていません。…がさすがにこれはやりすぎですね。コードブロックはだれが持っても同じですから普通に被害側に持たせましょう。ダメージと武器を引数としてを受け取りコードブロックを返すメソッドを作ってみましょう。

Data Class Hero (val hp:Int, val atk:Int, val spd:Int, val def:Int, val res:Int, val weapon:Boolean) {
    fun damageDealer(damage:Int, weapon:Weapon):(Hero)->Int = {target-> damage - target.let(weapon.selectPreventParam)}
    fun attack(damageDealer: (target: Hero) -> Int) {
        val preventedDamage = let(damageDealer)
    }
}

ところでdamageDealer,つまりダメージと武器によるダメージ減少を保持するコードは別の何かと交換する必要は有るでしょうか?ないならわざわざ引数として渡す必要は無いですよね?ということで単なるメソッドにすると一周回ってただのメソッドになってしまいました。

Data Class Hero (val hp:Int, val atk:Int, val spd:Int, val def:Int, val res:Int, val weapon:Boolean) {
    fun damage(damage:Int, weapon:Weapon):Int = damage - let(weapon.selectPreventParam)
    fun attack(damage:Int, weapon:Weapon) {
        val preventedDamage = damage(damage,weapon)
    }

}

 これはメッセージを送って実行させているだけでは?

はい。
一周して戻ってきた感がありますが、この記事で言いたいことはあるオブジェクトを引数にとるコードブロックをそのオブジェクトに送信して実行させることとそのオブジェクトのメソッドを実行させることは等価ということです。
それらは対象となるオブジェクト以外の要素、つまりパラメータをどうやってバインドしているかとそのコードブロックがどこに格納されているかの違いでしかありません。
オブジェクト指向言語の元祖であるSmalltalkでは実際にコードブロックを送信して対象のオブジェクトに実行させます。その伝統を受けて、letやapplyでコードブロックを受け取るオブジェクトをレシーバと呼ぶわけですね。

そしてメソッドが最終的に対象となるもの・対象をとって操作するもの(アフォーダンス)・それを提供されて利用するものに分割できること、あるオブジェクトのメソッドが自分以外を操作対象にしない場合、それは自分自身を対象にとるアフォーダンスであることが説明できたかと思います。

そしてアフォーダンスで重要なことはあるオブジェクトを操作対象とするアフォーダンスはその操作対象に依存する存在であること、それは環境的に存在するので操作対象が提供するとは限らずコードとしてはどこにあってもいいこと、アフォーダンス自体は状態を持たないということです。つまり操作対象とコードとしてどこにあるかでメソッドは分類でき、用途によって向き不向きが有るのでそれを意識してメソッドを設計することができるわけですが前置きだけで長くなったので一度切ります。