ゲーム(FEH)におけるダメージ管理で学ぶ増やせるメソッド設計

前置き

あるオブジェクトのメソッドはそのオブジェクトを操作対象にしたコードブロックである、という話をしました。↓の二つは同じオブジェクトを対象にするときは等価です。

class A (var x : Int){
    fun incX() = x++
    val incX:(A)->Int = {a->a.x++}
}

そして、fun incXはAのメソッドですから当然Aに依存する操作であり、このincXはclass Aの外側に出しても「たまたま物理的にAの外にある」Aに依存する操作です。今回はこれを利用して、
image.png
この計算式すべてを記述すると同時に、この計算式自体をまるっと交換できる構造を目指します。

各項目の詳細とそれが攻撃側・被害側のどちらに依存するかの説明をしますが読み飛ばしてもらって構いません。

Atk:攻撃側の攻撃能力値
Eff:攻撃側の武器と被害側の兵/武器種による特効
Adv:攻撃側と被害側の武器(色)相性による補正(お互いのスキルにより変動あり)
SpcStat:攻撃側の奥義発動時に参照する攻撃側能力値
SpcMod:攻撃側の奥義発動時の能力値倍率
Mit:攻撃側の武器と被害側の能力値によるダメージ軽減
MitMod:攻撃側の奥義によるダメージ増加と被害側地形によるダメージ軽減
OffMult:攻撃側の奥義によるダメージ増加と武器によるダメージ減少
OffFlat:攻撃側のスキル一般による追加ダメージ
DefMult:被害側奥義・スキルによるダメージ軽減
DefFlat:被害側スキル一般によるダメージ軽減

えらいことになってしまいましたがまずはこれを前回に倣って攻撃の対象にダメージを与えるメソッドとして書き、攻撃側と被害側を分離しましょう。

 攻撃側と被害側を分離する

image.png
この計算式で青いところが攻撃側のパラメータによる部分、赤いところが被害側のパラメータによる部分、紫部分が両方にかかわるところです。
まずは被害側にダメージを与えるもの(メッセージ・もしくはアフォーダンス)があり、被害側はそれを受け取って自分のHPを減らすことにしましょう。
計算式中の赤いところは自分(被害側)だけに依存するのでこれはダメージではなく自分側に書けます。

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon, var special: Special) {
    fun damage(damage: Damage) {
        val dealed = damage.dealValue(this)
        val preventedDamage = prevent(dealed)//prevent()内で計算して減算するがダメージに依存しないので省略
        hp = if (hp > preventedDamage) hp - preventedDamage else 0
    }
}

class Damage(/* これから書く攻撃側から渡すパラメータ*/) {
    fun dealValue(target: Hero): Int {/* これから書くダメージを計算する部分*/
    }
}

残り
image.png

逆にDamageクラス側に攻撃側からパラメータを受け取って計算する処理を書きます。
この時に厄介なのが「図の紫の部分は両方に依存する処理」ということと「SpcStatは参照パラメータが奥義に依存する・MitMod/OffMultは数値が奥義に依存する」
という点です。
まずは両方に依存する処理を分離しましょう.まずは基本ダメージとなるこの部分です。
val atkEffAdv = atk*eff+atk*eff*adv
atkは攻撃側の能力値なのでシンプルですが、effとadvは攻撃側と被害側の色やスキルで変わってしまうのでこれはもう分離できません。
攻撃側にatkEffAdvメソッドまたはコードブロックを作って被害側を引数として渡すしかありません。
さいわい、このメソッドは攻撃側と被害側を参照して値を計算するだけなので、両者とも状態が変わりません。
いわば、両者を引数として受け取る関数のようなものです。
実装は後で考えるとしてダメージとの関係を関数として書きましょう。Mitは前回書いたように対象を参照する武器の関数として書けます。

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon, var special: Special) {
    //メソッドの場合
    fun atkEffAdv(target: Hero) = atk * eff(target) + atk * eff(target) * adv(target)
    //コードブロックの場合
    val atkEffAdvFnc: (Hero) -> Int = { target -> atk * eff(target) + atk * eff(target) * adv(target) }
}

class Damage(val source: Hero) {
    fun dealValue(target: Hero): Int = source.atkEffAdv(target) - source.weapon.selectPreventParam(target)
}

メソッドではなくコードブロックにした場合はダメージ計算式から攻撃側への参照を除去することができます。
このダメージクラスは被害側に依存するものですが攻撃側に依存するものではないのでこれが正しい形ですが最初からこの形で書くのは難しいのであまり気にしないほうが良いでしょう。
なお、攻撃側の参照を取り除くことで攻撃側と被害側の取り違えを防ぐ事が出来ます。
この攻撃側と被害側の取り違えはRPGではメジャーなバグで、特に命中率・回避率のあるゲームでは体感しにくいためか回避率を上げる魔法を使ったら敵の回避率が上がるなんてことがしょっちゅうあります。

class Damage(val atkEffAdvFnc: (Hero) -> Int, val weapon: Weapon) {
    fun dealValue(target: Hero): Int = atkEffAdvFnc(target) - weapon.selectPreventParam(target)
}

 本題:奥義は色々あるので交換・追加を考慮する必要がある

残りは奥義によって計算される部分です。
image.png
特に奥義は大きく分けて「特定の能力値のn%追加ダメージ」「最終的なダメージ倍率増加」「被害側の能力値によるダメージ軽減をn%減らす」とパターンが豊富です。
これをwhenで書くと以下のようになります。

val spcStatSpcMod = when (special){
    Special.緋炎->hero.def * 0.5f
    Special.華炎->hero.def * 0.8f
    Special.氷華->hero.res * 0.8f
    else -> 0
}
val mitMod = when (special){
    Special.月虹-> -0.3f
    Special.月光-> -0.5f
    else -> 0
}
val offMult = when (special){
    Special.凶星-> 1.5f
    Special.流星-> 2.5f
    else -> 1
}

val damage = (atkEffAdv + spcStatSpcMod - (mit + mit * mitMod) ) * offMult + offFlat

問題はこのダメージを変える奥義が20以上あること、たまに増える事、どう増えるかが予測できない事です。
速さの40%の分だけ追加ダメージ、なんて奥義追加するのだったらwhenを1行追加するだけで済みますが

Special.剣姫の流星->hero.spd * 0.4f

奥義が増えるたびにソースを修正していては間違えて既存の奥義を上書きしてしまったりします。

val mitMod = when (hero.skill){
    Special.月虹-> -0.3f
    Special.天空-> -0.5f //コピペして天空を追加したつもりが月光に上書きして消してしまった!
    else -> 0f
}

 奥義にダメージ計算式をメソッドとして持たせる

奥義によってダメージが変わるのですから奥義にダメージ計算式を持たせてしまいましょう。定数に直接書いてしまいます。

interface BaseSpecial {
    fun damage(source: Hero, atkEffAdv: Int, mit: Int, offFlat: Int) = atkEffAdv - mit + offFlat
}

enum class Special: BaseSpecial {
    通常攻撃,
    緋炎 {
        override fun damage(source: Hero, atkEffAdv: Int, mit: Int, offFlat: Int) = atkEffAdv + source.def * 5 / 10 - mit + offFlat
    },
    華炎 {
        override fun damage(source: Hero, atkEffAdv: Int, mit: Int, offFlat: Int) = atkEffAdv + source.def * 8 / 10 - mit + offFlat
    },
    流星 {
        override fun damage(source: Hero, atkEffAdv: Int, mit: Int, offFlat: Int) = (atkEffAdv - mit) * 25 / 10 + offFlat
    },
//奥義が増えたらここに追加していけばいい
}

class Damage(val source: Hero, val offFlat: Int) {
    fun dealValue(target: Hero): Int = source.special.damage(source, source.atkEffAdv(target), source.weapon.selectPreventParam(target), offFlat)
}

奥義は発生しなければ通常攻撃であり、また通常攻撃とダメージが変わらない奥義もあるのでインタフェースにデフォルトのメソッドとして通常攻撃のダメージ計算式を書き、それを奥義側でオーバーライドします。
奥義が増えたらenumが増えますが既存のコードに手を入れる危険が無くenumとwhenを両方増やすより楽で、コピペをミスった場合はほぼコンパイルエラーになるので安心です。

 共通項を括りだしていく

これでも十分役を果たしますが、MitModをコードブロックにしたようにSpcStat * SpcModもコードブロックになりますし、 ダメージ倍率など数値が違うだけの物はまとめることができます。Enumなので初期化パラメータとして渡してしまいましょう。

enum class Special(val spcStatSpcMod: (Hero) -> Int = { _ -> 0 }, val mitMod: Float = 0f, val offMult: Float = 1f) : BaseSpecial {
    通常攻撃,
    緋炎(spcStatSpcMod = { hero: Hero -> hero.def / 2 }),
    月光(mitMod = -0.5f),
    流星(offMult = 2.5f)
    ;

    override fun damage(source: Hero, atkEffAdv: Int, mit: Int, offFlat: Int) = ((atkEffAdv + spcStatSpcMod(source) - mit + mit * mitMod) * offMult).toInt()
}

奥義の効果はゲーム内の説明ではこう書かれています。
- 緋炎:守備の50%をダメージに加算
- 月光:敵の守備、魔防-50%扱いで攻撃
- 流星:与えるダメージ2.5倍
上記のコードがゲーム内の説明をそのまま表現していると思いませんか?

 正しく分割し正しく合成する

纏めたコードは以下のようになります。

data class Hero(var hp: Int, var atk: Int, var spd: Int, var def: Int, var res: Int, var weapon: Weapon, var special: Special) {
    // targetを攻撃してダメージを与える
    fun attack(target: Hero) {
        val damage = Damage(this, offFlat())
        target.damage(damage)
    }

    // ダメージを受け取って計算しHPを減らす
    fun damage(damage: Damage) {
        val dealed = damage.dealValue(this)
        val preventedDamage = prevent(dealed)

        //状態を変更するのはここだけ!
        hp = if (hp > preventedDamage) hp - preventedDamage else 0
    }

    //メソッドの場合の攻撃力計算
    fun atkEffAdv(target: Hero) = atk * eff(target) + atk * eff(target) * adv(target)

    //コードブロックの場合の攻撃力計算
    val atkEffAdvFnc: (Hero) -> Int = { target -> atk * eff(target) + atk * eff(target) * adv(target) }

    //特効・色によるダメージ・固定値ダメージ・スキルのダメージ軽減は省略
    fun eff(target: Hero) = 0
    fun adv(target: Hero) = 0
    fun offFlat() = 0
    fun prevent(damage: Int) = 0
}

interface BaseSpecial {
    fun damage(source: Hero, atkEffAdv: Int, mit: Int, offFlat: Int) = atkEffAdv - mit + offFlat
}

// 奥義
enum class Special(val spcStatSpcMod: (Hero) -> Int = { _ -> 0 }, val mitMod: Float = 0f, val offMult: Float = 0f) : BaseSpecial {
    通常攻撃,
    緋炎(spcStatSpcMod = { hero: Hero -> hero.def / 2 }),
    月光(mitMod = -0.5f),
    流星(offMult = 1.5f)
// 奥義が増えたときはここに追加する
    ;
//万一計算式を完全に取り換えるときはこれをオブジェクト側でさらにOverrideする
   override fun damage(source: Hero, atkEffAdv: Int, mit: Int, offFlat: Int) = ((atkEffAdv + spcStatSpcMod(source) - mit - mit * mitMod) * offMult).toInt()
}

// 武器
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.def < target.res) target.def else target.res }),
    MaterialWeapon({ target -> target.def }),
    MagicWeapon({ target -> target.res }),
// 武器が増えたときはここに追加する
}

// ダメージを受け渡すためのクラス。前回も書いたようにコードブロックでも構わない。これを交換することもできる
// privateなので攻撃側の参照は漏れない=外からは攻撃側に依存していないように見えるし、参照を完全に取り除くこともできる
class Damage(private val source: Hero, private val offFlat: Int) {
    fun dealValue(target: Hero): Int = source.special.damage(source, source.atkEffAdv(target), source.weapon.selectPreventParam(target), offFlat)
}

正しく分割・合成されたオブジェクトは奥義が増えたときは奥義が増え他のコードに影響を与えず、武器が増えたら武器が増え他のコードに影響を与えず、他にスキルなどいろいろあるものも同様です。
また、攻撃側と被害側は完全に分けられており、状態を変えるコードはHeroの被害側メソッドにしかありません。奥義やダメージオブジェクトなどは全てHeroに影響を与えるアフォーダンスであるか、Heroを参照して値を出力する値オブジェクトです。
なおDDDで言えば、これはHeroを集約ルートにしたドメインモデルとなります。

実のところ色の相性にしろスキルにしろお互いの能力値やスキルを参照するのでそう綺麗にはわかれませんし、書いたコードに思い違いがあればそれを変更・修正することになるでしょう。ですがこのようにメソッドやコードブロックを切り出して交換可能とすることにより、少なくとも追加に強い設計は実現可能です。

と言う記事をQiitaに書いたけど今回はあまり読んでもらえなかったなー