個人開発 Advent Calendar 2017 の三日目の記事です。
この記事は筆者が初めて作ったAndroidアプリの設計を解説します。
何分初めてなものなので問題があったら指摘をいただければ記事(とアプリ)を修正します。
ソースは [https://github.com/turanukimaru/fehs] に公開していますがリファクタリング途中なので汚いですしGitHubも初めてなので正直よくわかっていません。
#概要
このアプリはゲームの戦闘結果計算ツールです。
[https://fire-emblem-heroes.com/ja/]
任天堂の出しているファイアーエムブレムヒーローズというゲームがあります。
このゲームは将棋盤上の画面で駒を動かすSLGの一種なのですが、
戦闘に乱数が絡まないので計算結果は一定である
という特徴があります。
駒同士の計算結果を事前に知ることができると便利なので計算してくれるツールを作ります。
#DDD(ドメイン駆動設計)で作る
DDDが良いと聞きますが具体的なコードはあまり見たことが無いし業務で設計する機会もあまりないのでこの機会にDDDっぽく作ります。上記の「戦闘に乱数が絡まないので計算結果は一定である」および戦闘中に人の手が絡まないというのはモデルの例として最適なのもポイントです。
##DDDとしての構造
DDDの構造についてはオニオンアーキテクチャなどが知られていると思います。
それを実現するためにモデルをモジュールに分割し、強制的に他のコードへ依存できなくします。
android/core/desktop はLibGDXで将棋盤上の画面を作成するためのものなので今回は説明しませんというかまだできてません。
今回は
-fehbs:シミュレータアプリ。android依存部
-fehsbattlemodel:戦闘ロジックが格納されるドメインモデル部
-repos:戦闘するキャラクターを編集して保存するためのリポジトリ
の三つに大きく分けます。
依存関係は android依存部->リポジトリ->戦闘ロジック への一方通行です。
##Android依存部
画面の説明はしません(良くわかってません)。
ただしRepositoryにRealmを使い、RealmはAndroidに依存しているためここで準備します。準備と言っても初期化してリポジトリに直接インジェクションするだけですが。
ここでArmedHeroRepositoryとRealmArmedHeroContentはともにシングルトンのオブジェクトです。
通常AndroidでDBをどう扱うのか知りませんが、オブジェクトにしておくとどのアクティビティからも同じデータソースとして使えるので安心感がありますし、Androidでない環境で動かすときにはRealmの代わりになるものを突っ込めばいいだけになります。というかゲームで遊んでる最中に計算ツールを起動すると画面が隠れるので最初からデスクトップかWebアプリとして作るべきでしたね。
class BattleSimApp : Application() {
override fun onCreate() {
super.onCreate()
Realm.init(this)
val realmConfig = RealmConfiguration.Builder().deleteRealmIfMigrationNeeded().build()
Realm.setDefaultConfiguration(realmConfig)
ArmedHeroRepository.repo = RealmArmedHeroContent
}
}
##リポジトリ
リポジトリはRealmのサンプルそのままです。リポジトリ内でRealmオブジェクトを作成し、モデルのオブジェクトに変換して外へ出します。
Realmのライブオブジェクトを完全に捨てることになりますが、ライブオブジェクトは別スレッドから参照するだけで安全性のために死んでしまうのでActivityをまたいだ時などに信用できません。Activityをまたいだ時に死ぬという事はサブドメイン、例えばシミュレータではなく実際にゲームを作ったときに死ぬという事なのでもう捨ててモデルのオブジェクトに一本化するべきでしょう。
object RealmArmedHeroContent : ModelObjectRepository<ArmedHero>() {
/** realmのkotlin用ハンドラ */
private var realm: Realm by Delegates.notNull()
/** 初期化ブロック。テーブル変更時などはここでマイグレーションすることになる */
init {
// Open the realm for the UI thread.
realm = Realm.getDefaultInstance()
}
fun allItems(): List<ArmedHero> {
return realm.where(RealmArmedHero::class.java).findAll().map { e -> e.toModelObject() }
}
なおリポジトリでは基準値のキャラクターと編集したキャラクターを別に扱っています。が出てくるときは同じです。
リポジトリにRealmへのアクセスrepoを入れた場合、repoはnullableなので直接触るとコードが!!だらけになり汚いのですがリポジトリに閉じ込めると!!もリポジトリ内に閉じ込められていい感じです。reposモジュールへ提供するインタフェースは適当です。どうせ必要になったら増やすものですし。
object ArmedHeroRepository {
var repo: ModelObjectRepository<ArmedHero>? = null
fun getById(id: String): ArmedHero? = if (StandardBaseHero.containsKey(id)) ArmedHero(StandardBaseHero.get(id)!!) else repo!!.getById(id)
fun allItems(includeDb: Boolean = false): List<ArmedHero> = if (includeDb) StandardBaseHero.allItems().map { e -> ArmedHero(e) }.union(repo!!.allItems()).toList() else StandardBaseHero.allItems().map { e -> ArmedHero(e) }
fun isStandardBattleClass(id: String): Boolean = StandardBaseHero.containsKey(id)
fun createItem(battleHero: ArmedHero) = repo!!.createOrUpdate(battleHero)
}
interface ModelObjectRepository<T> {
fun createOrUpdate(item: T): T
fun deleteById(id: String): Int
fun allItems(): List<T>
fun getById(id: String): T?
}
#ドメインモデル
ここから本題のドメインモデルの話ですがその前にアクセスする画面側のコードです。
switchは攻撃する側/される側の切り替え、battleUnitは計算対象のキャラクター、filteredUnitsは対戦相手のリストです。
キャラクターから戦闘ユニットを作成してHP初期化・バフや地形効果を計算し、全ユニットと戦闘した結果をモデルから得ます。外から見えるのはキャラクターの能力値とそのステータスであるBattleUnitと戦闘結果だけです。なおバフ計算はActivity内にあり画面入力した情報をセットしていますが結構長いので拡張関数にして見やすくしています。
val resultList = filteredUnits.map({ e ->
if (switch) BattleUnit(e, e.maxHp).buff().fightAndAfterEffect(battleUnit)
else battleUnit.fightAndAfterEffect(BattleUnit(e, e.maxHp).buff())
})
##クラス
###後で後悔する例
武器は武器クラスを作りたくなりますが我慢します。
//後で後悔する例
class Weapon(val name:String, val atk:Int){
fun attack(heroAtk:Int, target:BattleUnit){target.damage(heroAtk + atk)}
}
class Falchion (val name:String="ファルシオン", val atk:Int=16):Weapon{
fun attack(heroAtk:Int, target:BattleUnit){
if(target.isDragon){target.specialDamage(heroAtk + atk)
}else...
}
val SilverSword = Weapon("銀の剣",15)
val falchion = Falchion()
この方法だと武器ごとに個性的な特徴をつけられますが、攻撃に関係する要素が増えたときに引数が増えます。
//後悔した例
class Weapon(val name:String, val atk:Int){
fun attack(heroAtk:Int, target:BattleUnit, lastAttacker:BattleUnit){target.damage(heroAtk + atk, lastAttacker)}
}
「連続攻撃を軽減するスキル」なるものが実際に追加されました。攻撃を受ける側は最後に攻撃した側がどちらかも受け取り、スキルがあれば軽減します。もちろんこれだけなら置換するだけでなんとかなりますが、問題は影響が全てのクラスに及ぶことと、それが予想できない事です。
##全てのスキルを「キャラクターを強化するもの」と抽象化する
-BaseHero:マルスとかシーダとかのキャラクター.基準能力値と初期スキルを持つ。リポジトリ管理ではない。
-ArmedHero:個体値や装備の変更されたキャラクター。リポジトリに保存される。
-BattleUnit:HPやバフなどの戦闘時に変動するステータスと戦闘用ロジックを持つ。モデルのファサードでもある。
-Skill:スキルのインタフェース。起動タイミングでの(何もしない)動作とスキル効果関数を実装している。
-Skillを継承したEnum群:武器・奥義・A~Cのスキル・聖印・その後追加される諸々
として、全てのスキルを「同じもの」として扱います。これにより、何が追加されても新スキルが追加されたこととして処理することができるようになります。
全てのスキルはキャラクターを強化するものなので「大量の起動タイミングを持ち、合致する起動条件で引数として渡されるキャラクターを強化する」オブジェクトとします。
interface Skill {
val level: Int get() = 0
val type: SkillType get() = SkillType.NONE
/** 戦闘時の効果 */
fun bothEffect(battleUnit: BattleUnit, lv: Int = level): BattleUnit {
if (type == SkillType.BOW && battleUnit.enemy!!.armedHero.baseHero.moveType == MoveType.FLIER) battleUnit.effectiveAgainst = EffectiveAgainst.FLIER
return battleUnit
}
/** 攻撃時の効果 */
fun attackEffect(battleUnit: BattleUnit, lv: Int = level): BattleUnit {return battleUnit}
/** 反撃時の効果 */
fun counterEffect(battleUnit: BattleUnit, lv: Int = level): BattleUnit {return battleUnit}
KotlinはSkillに実装を書けるので、デフォルトの計算や空実装を全部書きます。プロパティも書けるので、全て空文字や0にします。また、「能力値上昇量など関数内で使うものは全て外側で定義して関数の引数にする」ことを心がけます。
ただしデフォルトの計算はsuper()を呼び忘れるバグを生みますので慎重に使いましょう。実際に特殊効果のある弓に特効が発生しないバグを作り込みました。たぶん私だけじゃないです。ゲーム本編であるGBAのFEでもミュルグレに飛行特効が無いという仕様(?)があります。
###全てのスキルをEnumにする
スキルは使用者の戦闘力をあげるもの、ということは武器含めスキルは状態を持たないとも言えます。よって全てのスキルをEnumとして書きます。
この際に必要なプロパティを上書きします。また、武器の特殊効果を武器ごとに書きます。実を言うととても面倒なので表にしたりできないかな?とか思いましたがスキルはそれぞれが違いすぎててまったく統一できませんでした。表を書くより覚悟して書くのが早いです。表が必要になったらオブジェクトから表を作るのが早いというのもあります。
enum class Weapon(override val jp: String, override val type: Skill.SkillType, override val level: Int = 0, override val preSkill: Skill = Skill.NONE, val refineSkillType: RefineSkill.RefineType = RefineSkill.RefineType.NONE) : Skill {
IronSword("鉄の剣", Skill.SkillType.SWORD, 6),
SteelSword("鋼の剣", Skill.SkillType.SWORD, 8, IronSword),
BraveSword("勇者の剣", Skill.SkillType.SWORD, 5, SteelSword) {
override fun equip(armedHero: ArmedHero, lv: Int): ArmedHero = equipBrave(armedHero, lv)
override fun attackEffect(battleUnit: BattleUnit, lv: Int): BattleUnit = doubleAttack(battleUnit)
},
Falchion("ファルシオン", Skill.SkillType.SWORD, 16, SilverSword) {
override fun bothEffect(battleUnit: BattleUnit, lv: Int): BattleUnit = effectiveAgainst(WeaponType.DRAGON, battleUnit)
},
パッシブももちろんEnumにします。なお、スキルの効果は全てSkillインタフェースに書くと武器とスキルで同じ効果を使えてよいです。とはいっても二度とないような武器効果などを共通化する必要もないでしょう。一回は直接書いて、2回目になったらそれをSkill側に移動してEnum側はそれを呼び出すようにするのが良いかと思われます。
enum class SkillA(override val jp: String, override val type: Skill.SkillType, override val level: Int = 0, override val preSkill: Skill = Skill.Companion.NONE, override val maxLevel: Int = 3) : Skill {
Hp("HP", Skill.SkillType.A) {
override fun equip(armedHero: ArmedHero, lv: Int): ArmedHero = equipHp(armedHero, lv+2)
},
Furry("獅子奮迅", Skill.SkillType.A) {
override fun equip(armedHero: ArmedHero, lv: Int): ArmedHero = furry(armedHero, lv)
override fun bothEffect(battleUnit: BattleUnit, lv: Int): BattleUnit = attackHpLoss(battleUnit, lv * 2)
},
武器もそうですがEnumとしてでなくクラスで普通に書いてもいいのですがどうせ生成するのと「スキルはアイデンティティと状態を持つか?」を考えたときに恐らくアイデンティティも状態もない、ということになるのでアイデンティティと状態のないものはシングルトンにするのが形而上学的に正しい態度です。武器が状態を持つようになったら…例えば、戦闘中に誰かの武器を強化したうえで他の人に渡せる、みたいなゲームになったら武器はアイデンティティと状態を持ちますのでそのときはシングルトンでない普通のオブジェクトにするべきでしょう。
###Enumに委譲するレベルを変えられるクラスを作る
スキルはレベルを持ちますがEnumは状態を持てません。よって真面目に実装するとスキルLevel1,スキルLevel2,スキル...みたいなことになってキリがありません。良くあるのはPair(スキル,レベル)としてもつものですがKotlinなので委譲を使います
class LappedSkill(val skill: Skill, override val level: Int) : Skill by skill {
override fun equals(other:Any?)=other is LappedSkill && other.skill == this.skill && other.level == this.level
}
enum class SkillA(/* 省略 */): Skill {
fun lv(lv: Int) = if (level == lv) this else LappedSkill(this, lv)
}
val furry3 = Furry.lv(3)
ここでわざわざインタフェースにプロパティを書いた甲斐が出てきました。Skillの空実装とEnumのスキル効果に委譲されるためこれ以上何もする必要がありません。このクラスから作られるオブジェクトは完全にスキルとして振舞いますし、任意のレベルを与えることができます。Kotlinでは==はequalsなので===を使われない限りはenumと区別もつきません。そして不変のままです。lv()を呼び出す前のEnumはレベルのないスキルかレベル0のスキルか?という問題は発生しますが今回はレベル0のスキルが無いので関係ないでしょう。
これで全てのスキル効果が再現されました!フィールド上で効果があるスキルは名前を書いただけですが。
最後に念のためダメージ計算式もスキルとして分離します。
fun damage(battleUnit: BattleUnit, target: BattleUnit, results: List<AttackResult>, skill: Skill? = null): Pair<Int, Skill?> {
val damage = battleUnit.halfByStaff(target.preventByDefResTerrain(battleUnit.colorAttack(), battleUnit.armedHero.weapon.type))
return Pair(if (damage > 0) damage else 0, skill)
}
enum class Special(/* 略 */) : Skill {
Glimmer("凶星", Skill.SkillType.SPECIAL_A, 2) {
override fun damage(battleUnit: BattleUnit, target: BattleUnit, results: List<AttackResult>, skill: Skill?): Pair<Int, Skill?> {
return Pair(super.damage(battleUnit, target, results, this).first * 15 / 10, this)
}
},
奥義のダメージ計算式が怪しいので、変な奥義が追加されたら計算式をそっくり取り換えられるようにします。このダメージを防御側では奥義で軽減したりなんやかんやしますがそれは防御側や防御側の奥義発動場所に書きます。
これでダメージの計算ができるようになりましたので、あとは実際に攻撃してダメージを計算するだけです。
書き忘れましたがキャラクターの能力を強化するだけではなく攻撃順を変更するスキルもありますが同じように単に攻撃順を入れ替えてます。
この際、ダメージの累計を使うかユニットのHPを減らしていくかは難しい問題ですがHPに依存する奥義もあるのでHPでいいでしょう。そのままHPを減らすのではなく遂次コピーしてそのコピーのHPを減らすようにし、経過を含めて全ての攻撃結果のリストを返すようにすると、全ての計算を適用した後にアニメーション表示したくなった時に役に立ちます。
なお、スキルを計算するときは単に自分を引数にして所持スキルを全部呼ぶだけです。
data class ArmedHero(
val baseHero: BaseHero,
var weapon: Skill = Skill.NONE,
var aSkill: Skill = Skill.NONE,
/* 略 */
){
val skills get() = listOfNotNull(weapon, assist, special, aSkill, bSkill, cSkill, seal)
fun bothEffect(battleUnit: BattleUnit): BattleUnit {
return skills.fold(battleUnit, { b, skill -> skill.bothEffect(b) })
}
fun attackEffect(battleUnit: BattleUnit): BattleUnit {
return skills.fold(battleUnit, { b, skill -> skill.attackEffect(b) })
}
#大型アップデートきたる
プレイヤーならわかるでしょうがリリースから半年たった時点でインフレブレムが始まってしまいました。シナリオ第二部の開始に合わせて待ちに待った旧キャラのテコ入れとなる大型アップデートです。
第二部と新キャラはこのツールに関係ないので旧キャラのテコ入れとなる武器錬成だけ対応します。
##武器錬成の効果
一部の武器を(消費アイテムを使って)強化することができる。
強化の種類は武器によって4または特殊能力が追加できるならば5種類である。
強化内容は
-近武器共通:HP+3,Atk+1
-遠武器共通:特になし
-近武器選択:特殊能力追加,HP+2&ATK+2,HP+2&SPD+3,HP+2&DEF+4,HP+2&RES+4,から一種類
-遠武器選択:特殊能力追加,HP+2&ATK+1,HP+2&SPD+2,HP+2&DEF+3,HP+2&RES+3,から一種類
-銀装備など一部の武器はAtk+1
-暗器など一部の武器はAtk+2/3/4と武器によって異なる強化
-季節ものなど一部の武器は特殊効果が変化する
-マムクートのブレスは射程武器の敵に対してDEFとRESの低いほうを適用するようになる
-風神弓は特殊能力を選んだ時のみ新しい特殊能力を得るとともに今までの能力がなくなる
※錬成していない武器の特殊効果は現在のまま
( ^ω^)・・・
上四つは共通ルールとして簡単に実装できそうに思えますが残りが問題です。
一部であるため一律に適用できないうえ、現在の効果を変更できません。そこで、新武器として追加したくなりますがこれは下手に全て新武器とすると今後強化対象が追加されるたびに新武器が追加されることになり手間です。
そこで「錬成をスキルの一種にする」事にします。
##新スキルを追加する
enum class RefineSkill(val us: String = "", override val jp: String, val hp: Int, val atk: Int, val spd: Int, val def: Int, val res: Int, val refineSkillType: RefineSkill.RefineType = RefineSkill.RefineType.NONE, override val preSkill: Skill = Skill.NONE, override val level: Int = 0, override val type: Skill.SkillType = Skill.SkillType.REFINERY) : Skill {
//基本ルール
Range1Atk("Atk(melee)", "攻撃(近)", 5, 2, 0, 0, 0, RefineType.Range1),
Range1Spd("Spd(melee)", "速さ(近)", 5, 0, 3, 0, 0, RefineType.Range1),
Range1Def("Def(melee)", "守備(近)", 5, 0, 0, 4, 0, RefineType.Range1),
Range1Res("Res(melee)", "魔防(近)", 5, 0, 0, 0, 4, RefineType.Range1),
Range2Atk("Atk(Ranged)", "攻撃(遠)", 2, 1, 0, 0, 0, RefineType.Range2),
Range2Spd("Spd(Ranged)", "速さ(遠)", 2, 0, 2, 0, 0, RefineType.Range2),
Range2Def("Def(Ranged)", "守備(遠)", 2, 0, 0, 3, 0, RefineType.Range2),
Range2Res("Res(Ranged)", "魔防(遠)", 2, 0, 0, 0, 3, RefineType.Range2),
//武器特有能力
WrathfulStaff("WrathfulStaff", "神罰", 0, 0, 0, 0, 0, RefineType.Staff){
override fun bothEffect(battleUnit: BattleUnit, lv: Int): BattleUnit = wrathfulStaff(battleUnit, 3)
},
SolKatti("BrashAssault", "差し違え", 3, 0, 0, 0, 0, RefineType.DependWeapon, Weapon.SolKatti) {
override fun attackEffect(battleUnit: BattleUnit, lv: Int): BattleUnit = brashAssault(battleUnit, 75)
override fun attackPlan(fightPlan: FightPlan, lv: Int): FightPlan = desperation(fightPlan, lv)
},
//武器自体を置き換えるもの
SilverSword2("", "", 0, 1, 0, 0, 0, RefineType.ReplaceWeapon, Weapon.SilverSword2, 15, Skill.SkillType.SWORD),
CarrotLance2("", "", 0, 1, 0, 0, 0, RefineType.ReplaceWeapon, Weapon.CarrotLance2, 13, Skill.SkillType.LANCE) {
override fun bothEffect(battleUnit: BattleUnit, lv: Int): BattleUnit = attackHeal(battleUnit, 4)
},
}
enum class Weapon(/* 略 */){
//錬成のタイプのみ旧武器に追加する
SilverSword2("銀の剣+", Skill.SkillType.SWORD, 15, SilverSword, RefineSkill.RefineType.Range1),
錬成を使う側も残念ながら修正が必要になります。といってもスキルが増えるのと、錬成時に武器が置き換わるものであるならば置き換えるだけですが。なお、weaponを直接置き換えたくなりますが、直接置き換えると「錬成武器を持った人がさらに別の練成しようとしたとき」のことを考える必要が出てきてしまいます。錬成前の武器と錬成後に置き換えた武器は同じに扱うことができないから別に扱います。別のものは別に扱うのも設計のうちです。
data class ArmedHero(/* 略 */
var baseWeapon: Skill = Skill.NONE,
var refinedWeapon: Skill = Skill.NONE,
){
val weapon get()= if (refinedWeapon != Skill.NONE) RefineSkill.valueOfWeapon(baseWeapon) ?: baseWeapon else baseWeapon
val skills get() = listOfNotNull(weapon, refinedWeapon, assist, special, aSkill, bSkill, cSkill, seal)
なんということでしょう!対処に困る大型アップデートでしたが「新スキル追加」「錬成できる武器に錬成できるマーク」「スキルを使う側に数行修正」だけで済んでしまいました!
リポジトリは1カラム増えただけ、登録画面もそれを表示/選択できるようにしただけ、シミュレータ実行画面に至っては修正なしです!
実際はマムクートのブレスとかうっかり実装し忘れていた神罰を実装しなおすとか色々ありましたが。あと簡単に書いてますが実際には一番シンプルな方法を模索して徹夜しています。
なお、私が作ったわけではありませんが同じようなというか一部ソースをパク…参考にしたJavascriptのシミュレータがあります。[https://github.com/Andu2/FEH-Mass-Simulator] こちらはほぼ手続きで書かれているのでソースを見比べると設計思想の違いが良くわかると思います。なお、アップデート対応はとても早かったので継承だの移譲だのよりif連打のほうが早いのかもしれません。ついでに言うと私の書いたコードはスキルが凄い回数呼び出されるのでかなり遅いです。
なお実際の画面はこんな感じです.google playではFEH battle simulatorとして公開していますがSEO的に何もしていないのでタイトル全部入力して検索しないと出てきません。いまさらですがbattleは無いですね。でもduelだとパクリに…
###まとめ
DDD的に作るとモデルが外に漏れません。正しくモデルを作ればアップデート時の対応がシンプルになります(簡単とは言ってない)。Javaでは色々大変なこともKotlinでは自然にできます。ていうか今無職ですがKotlinの仕事したいですね。
DDD的にはここから更にドメインを考えていくことになります。具体的には自分で操作できるゲーム画面も作った場合は同じドメインのサブドメインです。逆にゲームを作る側から考えると、ゲームがドメインでありこういったシミュレータはサブドメインになります。ゲームエンジン・アーキテクチャにも記載がありましたが、ゲームを作る最中にゲームの戦闘モデルを直接シミュレートできれば開発が捗るでしょうし、キャラクターの8割をワンパンするような壊れキャラを作ってしまうことも減るでしょう。
画面をドメインとした場合、画面を将棋盤やボードゲームの舞台として考えた場合にはボードゲームのドメイン言語が使えます。実際にどうなるかの話もしたいところですが私はボードゲーム作家どころかどんなゲーム屋でもないので説得力のある話ができないのが残念です。
明日…はあいて明後日は@dala00氏です。
###おまけ
ゲームのツールなんだから解析されてる資料を基にすれば楽勝、仕様ミスなんてない。と思ってた時期が私にもありました。
実装されてるけどまだ使えないエフラムの武器錬成ですが
日本語:自分のHPが90%以上で、自分から攻撃した時、絶対追撃
英語:Unit makes a guaranteed follow-up attack when HP ≥ 90% and attacking a foe that can counter.
おわかりいただけるだろうか?
運営には先日メールしましたが日本語表記だと弓魔導士ワンパン祭りになるし多分実際の挙動である英語表記にすると弱体化となります。こりゃホンマ詫び石もんやでえ