最近Nuxtでファイヤーエンブレム風なゲーム作ってみたんですが、
こちらのゲーム、実は大きな問題を抱えております。
(プロジェクト自体はこちらにあります→ https://github.com/koheiobe/spa-simulation-game)
それが、下記のゲームアプリのメインロジックである
オンライン対戦 の実装部分になります。
すごく長い問題のコード(確認するならサラッとでOKです...)
<script lang="ts">
import Component from 'vue-class-component'
import { Vue, Prop, Watch } from 'vue-property-decorator'
import { namespace } from 'vuex-class'
import DevFieldUi from './devFieldUi.vue'
import {
calculateDamage,
onEndCalculateDamage
} from '~/utility/helper/battle/damageCalculator'
import {
IField,
ILatlng,
ActionType,
WeaponType,
CellType,
IMovableArea
} from '~/types/battle'
import {
fillMovableArea,
fillInteractiveArea
} from '~/utility/helper/battle/field'
import FieldCell from '~/components/battle/FieldCell.vue'
import SideMenu from '~/components/battle/SideMenu.vue'
import Modal from '~/components/utility/Modal.vue'
import BattleDialogue from '~/components/battle/ModalContent/Action/index.vue'
import CharacterRenderer from '~/components/CharacterRenderer.vue'
import { ICharacter } from '~/types/store'
import { downloadFile } from '~/utility/download'
import {
attackCharacterAnimation,
takeDamageCharacterAnimation
} from '~/utility/animation'
import {
counter,
sequncialAttack,
summonOnDead
} from '~/utility/helper/battle/skills'
const CharacterModule = namespace('character')
@Component({
components: {
FieldCell,
SideMenu,
Modal,
BattleDialogue,
CharacterRenderer,
DevFieldUi
}
})
export default class Field extends Vue {
// キャラクターが移動するときに一時的に使用する。行動が完了したらdbに反映
@CharacterModule.State('interactiveCharacter')
private interactiveCharacter!: ICharacter | undefined
@CharacterModule.State('characters')
private storeCharacters!: ICharacter[]
@CharacterModule.Action('setCharacterParam')
private setCharacterParam!: (characterObj: {
id: string
value: any
}) => Promise<null>
@CharacterModule.Action('updateCharacter')
private updateCharacter!: (dbInfo: {
battleId: string
character: ICharacter
}) => Promise<void>
@CharacterModule.Mutation('setInteractiveCharacter')
private setInteractiveCharacter!: (cellCharacterId: string) => void
@CharacterModule.Mutation('updateInteractiveCharacter')
private updateInteractiveCharacter!: (character: ICharacter) => void
@Prop({ default: null })
private _winnerCell!: { host: ILatlng; guest: ILatlng }
@Prop({ default: () => [] })
deployableArea!: { [key: string]: Boolean }
@Prop({ default: false })
isDeployModeEnd!: boolean
@Prop({ default: false })
isMyTurn!: boolean
@Prop({ default: () => null })
field!: IField
@Prop({ default: '' })
isHostOrGuest!: 'host' | 'guest'
@Prop({ default: null })
lastInteractCharacter?: ICharacter
@Prop({ default: (_: ICharacter | undefined) => false })
isMyCharacter!: (character: ICharacter | undefined) => boolean
@Prop({ default: () => {} })
charactersLatLngMap!: IField
@Prop({ default: '' })
battleId!: string
public deployCharacterId: string = ''
// 素早くアクセスするためにdeployableAreaとmovableAreaはobjectで作成
// public deployableArea: { [key: string]: Boolean } = {}
public movableArea: IMovableArea = {}
public interactiveArea: ILatlng[] = []
// TODO: 各characterの移動距離と置き換える
public moveNum = 8
public isBattleModalOpen: boolean = false
// 開発用
private isDevMode = false
private selectedFieldIcon = ''
decideCellType(latLng: ILatlng): CellType {
if (Object.keys(this.movableArea).length > 0) {
return this.movableArea[`${latLng.y}_${latLng.x}`] > 0 ? 'move' : null
} else if (this.interactiveArea.length > 0) {
return this.isInteractiveArea(latLng) ? 'interact' : null
} else if (this.isDeploying) {
return this.deployableArea[`${latLng.y}_${latLng.x}`] ? 'deploy' : null
}
return null
}
isInteractiveArea(latLng: ILatlng) {
return this.interactiveArea.some(
(cell) => cell.x === latLng.x && cell.y === latLng.y
)
}
onClickCell(cellType: CellType, latLng: ILatlng, cellCharacterId: string) {
// 開発用
if (this.isDevMode) {
this.mergeField(latLng, this.selectedFieldIcon)
return
}
if (this.isDeploying) {
if (cellType === 'deploy') this.deployCharacter(latLng, cellCharacterId)
return
}
switch (cellType) {
case 'move':
this.moveCharacter(latLng, cellCharacterId)
break
case 'interact':
this.interactCharacter(cellCharacterId)
break
default:
// キャラクターを選択する
if (cellCharacterId.length > 0) {
this.setInteractiveCharacter(cellCharacterId)
if (!this.interactiveCharacter) return
this.movableArea = fillMovableArea(
latLng,
this.interactiveCharacter,
this.charactersLatLngMap
)
} else {
// インタラクトモードで、アクティブセル以外をクリックした時に状態をキャンセルするため
this.resetCharacterState()
}
}
}
selectDeployCharacter(id: string) {
if (this.deployCharacterId === id) {
this.setInteractiveCharacter(id)
this.setModal(true)
}
this.deployCharacterId = id
}
deployCharacter(latLng: ILatlng, cellCharacterId: string) {
const isCharacterDeployedCell = cellCharacterId.length > 0
// クリックしたセルにキャラクターが存在したら、キャラクターを除外
const updatedLatLng = isCharacterDeployedCell ? { x: -1, y: -1 } : latLng
const targetCharacterId = isCharacterDeployedCell
? cellCharacterId
: this.deployCharacterId
// HACK: storeのみを書き換えた結果、vuexfireのrefが外れてしまう。
// deployモードを終了する時に再度、vuexfireのrefを設定する必要がある
this.setCharacterParam({
id: targetCharacterId,
value: {
latLng: updatedLatLng,
lastLatLng: updatedLatLng
}
})
this.deployCharacterId = ''
}
moveCharacter(latLng: ILatlng, cellCharacterId: string) {
if (!this.interactiveCharacter) return
const isMovableCell =
this.interactiveCharacter.id === cellCharacterId ||
cellCharacterId.length === 0
if (
isMovableCell &&
this.isMyTurn &&
this.isMyCharacter(this.interactiveCharacter) &&
this.interactiveCharacter.actionState.isEnd === false
) {
this.updateInteractiveCharacter({
...this.interactiveCharacter,
latLng,
lastLatLng: this.interactiveCharacter.latLng
})
this.setModal(true)
}
if (
latLng.x === this.interactiveCharacter.latLng.x &&
latLng.y === this.interactiveCharacter.latLng.y
) {
this.setModal(true)
}
this.movableArea = {}
}
onSelectBattleAction(action: ActionType) {
try {
if (!this.interactiveCharacter) {
throw new Error('interactiveCharacter が 存在しません')
}
switch (action) {
case 'attack':
this.beforeInteractCharacter(action, 'closeRange')
break
case 'wait':
this.onFinishAction(this.interactiveCharacter)
break
case 'item':
this.beforeInteractCharacter(action, 'closeRange', 1)
break
}
} catch (e) {
console.error(e)
this.resetCharacterState()
}
}
beforeInteractCharacter(
actionType: ActionType,
interactType: WeaponType,
itemId: number = 0
) {
if (this.interactiveCharacter === undefined) return
this.updateInteractiveCharacter({
...this.interactiveCharacter,
actionState: {
...this.interactiveCharacter.actionState,
name: actionType,
itemId
}
})
this.interactiveArea = fillInteractiveArea(
this.interactiveCharacter.latLng,
interactType
)
this.setModal(false)
}
interactCharacter(cellCharacterId: string) {
try {
if (!this.interactiveCharacter) {
throw new Error('interactiveCharacter が 存在しません')
}
const targetCharacter = this.storeCharacters.find(
(character) => character.id === cellCharacterId
)
if (targetCharacter) {
if (this.isMyCharacter(targetCharacter)) return
this.updateInteractiveCharacter({
...this.interactiveCharacter,
actionState: {
...this.interactiveCharacter.actionState,
interactLatLng: targetCharacter.latLng
}
})
this.onFinishAction(this.interactiveCharacter)
} else {
this.resetCharacterState()
}
} catch (e) {
console.error(e)
this.resetCharacterState()
}
}
useItem(cellCharacterId: string) {
// TODO: 開発段階
console.log('item', cellCharacterId)
}
async onFinishAction(interactiveCharacter: ICharacter) {
if (
interactiveCharacter.latLng.x === this.winnerCell.x &&
interactiveCharacter.latLng.y === this.winnerCell.y
) {
this.$emit('onWin')
}
this.$emit('setLastInteractCharacter', {
id: this.battleId,
lastInteractCharacter: interactiveCharacter
})
this.$emit('setcharactersLatLngMap', interactiveCharacter)
await this.applyInteractiveCharacterStore(interactiveCharacter)
this.resetCharacterState()
}
resetCharacterState() {
this.setInteractiveCharacter('')
this.setModal(false)
this.interactiveArea = []
this.movableArea = {}
}
applyInteractiveCharacterStore(
interactiveCharacter: ICharacter
): Promise<void> {
return this.updateCharacter({
battleId: this.battleId,
character: {
...interactiveCharacter,
actionState: {
...interactiveCharacter.actionState,
isEnd: true
}
}
})
}
@Watch('lastInteractCharacter')
onChangeLastInteractCharacter(interacter: ICharacter | undefined) {
if (!interacter) return
const interacterEl = document.getElementById(interacter.id)
if (!interacterEl) return
switch (interacter.actionState.name) {
case 'attack':
this.attackCharacter(interacterEl, interacter)
}
this.$emit('setcharactersLatLngMap', interacter)
}
async attackCharacter(attackerEl: HTMLElement, attacker: ICharacter) {
await attackCharacterAnimation(attackerEl, attacker)
const takerLatLng = attacker.actionState.interactLatLng
const taker = this.storeCharacters.find(
(character) =>
character.latLng.x === takerLatLng.x &&
character.latLng.y === takerLatLng.y
)
if (!taker) return
const enemyEl = document.getElementById(taker.id)
if (!enemyEl) return
await takeDamageCharacterAnimation(enemyEl)
const damageTakenCharacter = await this.updateDamageTakenCharacter(
attacker,
taker
)
this.onEndAttackCharacter(attacker, damageTakenCharacter)
}
async updateDamageTakenCharacter(attacker: ICharacter, taker: ICharacter) {
const damage = calculateDamage(attacker, taker)
const damageTakenCharacter = {
...taker,
hp: taker.hp - damage
}
if (attacker.skill.includes('bloodSucking')) {
const suckedHp = attacker.hp + damage
await this.updateCharacter({
battleId: this.battleId,
character: {
...attacker,
hp: suckedHp > attacker.maxHp ? attacker.maxHp : suckedHp
}
})
}
const finalDamageTakenCharacter = onEndCalculateDamage(damageTakenCharacter)
if (finalDamageTakenCharacter.hp <= 0) {
finalDamageTakenCharacter.lastLatLng = finalDamageTakenCharacter.latLng
finalDamageTakenCharacter.latLng = { x: -1, y: -1 }
}
this.$emit('setcharactersLatLngMap', finalDamageTakenCharacter)
await this.updateCharacter({
battleId: this.battleId,
character: finalDamageTakenCharacter
})
return finalDamageTakenCharacter
}
...長すぎるので省略
</style>
で、何が言いたいかというと、とにかくコード量が多くて汚い!
なぜ、こうも汚いコンポーネントとなってしまったのかというと、理由は明白で実装する前にちゃんと 設計していなかったから です。
設計といってもフロントエンドの設計といえば一番に思い浮かぶのがコンポーネント設計。(自分だけ?)
画面内のコンポーネントをどの粒度で分割するのかを設計するわけですが、ここに関しては正直そこまで問題はないと考えています。
なので問題なのは ゲームロジック の部分です。先ほどの長ーいコードの中にはゲームロジックと画面表示処理がごちゃまぜになって、コードが軽くスパゲッティ状態です。
要するにしっかりと設計ができていれば
- どこまでがゲームロジックか
- どこまでが画面表示処理か
ハッキリとした結果、componentには画面表示処理のみを記述でき、1コンポーネント内の記述量は少なくなっていたはずです。
「なんで最初にちゃんと設計しなかったの?」って言われそうですが、大きな原因として とりあえず作りながら考えるか と見切り発射で開発を進めてしまったからです。
仕事じゃなくてプライベートで作っているということもあり、そこまで真剣に考えずつくり始めてしまいました。
また、私の普段の業務が小規模での開発が多い(最小だとプログラマーの自分とプロジェクトマネージャーのみ)ので、「とりあえずプロトタイプ作ってみましょうか」という流れになることが多く、作る前にじっくり設計するという習慣がなかったのも大きな要因の一つです。
全体像を把握するにはどうしたらいい?
では、今回の問題を解決するには具体的にどういった設計工程が足りていなかったのかというと、 概念を洗い出し、それぞれの概念をクラスにまで落とし込む クラス設計 が足りていなかったと思います。
もちろん、この工程はいつでも必要というわけではありません。例えば、自分一人で使う予定のTODOアプリに出てくる概念なんて、自分が登録する "タスク" とそれに関連するデータ&処理ぐらいのものです。(フロント部分のみ考慮)
ですが今回のゲームアプリのオンライン対戦コンポーネントに関しては、正直すべての概念を正確に洗い出せていたかというと、ちょっと怪しいです。おまけにそれら概念同士がどのように関連しあっているか、把握できていない状態で開発をスタートしてしまいました。
どこにどのような処理を書くべきなのか、あやふやな状態で開発を進めてしまった結果がスパゲッティなコードです。
なので、
- どのような概念が存在しているか
- 各概念がどのように関係しているのか
- どのようにデータが流れていくのか
というロジックの全体像を頭のなかで思い描けなかった時点で「このまま実装してはまずいな...」と気づくべきでした。
ではどうしたら全体像をつかむできるんでしょうか。ここでちょっとご紹介したい書籍がこちら。
こちらの本、書いている内容を三行でまとめてしまうと
- オブジェクト思考とは
- オブジェクト思考だけでは正しい設計は行えない
- 正しい設計を学ぶためにはデザインパターンを学ばなければならない(デザインパターンの例が盛り沢山!)
って感じの本で、主にデザインパターンを活かした設計手法について学べる本です、が今回はデザインパターンについては一切ふれません。(正直、この本を紹介しておいてデザインパターンに触れないというのもあれなんですが、話が複雑になるので..。また機会があれば別の記事で感想なりを書きたいです)
ここでは、この書籍で紹介されている 共通性/可変性分析 というものを参考に、このオンライン対戦コンポーネント内に出てくる概念を洗い出し、クラスにまで落とし込みたいと思います。
共通性/可変性分析でクラス設計してみよう!
では早速実践してみましょう。名前を聞いただけだと「なんか難しそう」と思うかもしれませんが、特に複雑なことはありません。
step1 キーワードを書き出す
まずはじめに、思いつく限り問題となっている領域のキーワードを書き出していきます。
例えば私が作ったゲームアプリの場合、
- キャラクター
- マス目
- 移動
- 攻撃力
- 防御力
- フィールド
- 山
- 川
- ターン
- 時間制限
- スキル
- etc...
そのゲームの機能や何らかの概念を表す名前など思いつく限り書いていきます。例を挙げると「このゲームには **キャラクターが存在して、そのキャラクターがフィールド内のマス目を移動して敵を攻撃するから攻撃力が存在して...」みたいな感じですね。
もしキーワードがなかなか出てこないというのであれば、このステップを業務知識に詳しい人と一緒に行ったり、まずは業務知識を学ぶ必要があるのかと思います。
そしてある程度出尽くしたなと思ったら次のステップです。
step2 共通性と流動的要素に仕分けする
キーワードが出切ったらそれを 共通性 と 流動的要素 に分けていきます。
例えば 攻撃力 や 防御力 といった要素の共通性として パラーメーター という概念を挙げられます。
要するにカテゴリー分けしていく感じですが、『オブジェクト思考のこころ』で紹介されている方法はテーブルを作って左に共通性、右に流動的要素を入れていく、というものです。
ただ、今回は自己流でmarkdownのlist方式でまとめさせていただきました。おそらく、共通性と可変性が理解できたら何でもいいのではないかと。
実際に出来上がったものが下記のとおりです。
- フィールド
- マス目
- マス目のタイプ
- 山
- 川
- 森
- 家
- 草原
- 特殊効果マスの効果内容
- 回避率アップ or ダウン
- 攻撃力アップ or ダウン
- 防御力アップ or ダウン
- 移動力アップ or ダウン
- 移動不可
- 移動したら死亡
- アクション可能マス表示処理
- 移動可能マスの表示
- 攻撃可能マスの表示
- アイテム使用可能マスの表示
- キャラクター
- キャラクターの構成要素
- 名前
- 現在位置
- キャラクターのアクション
- 攻撃
- 魔法
- 反撃
- アイテムを使う
- 回復
- 移動
- キャラクターのパラメーター
- レベル
- 攻撃力
- 防御力
- 移動力
- 速さ
- 魔法攻撃力
- 魔法防御力
- キャラクターの装備
- 剣
- 盾
- 鎧
- アクセサリー
- 装備の能力アップ要素
- 攻撃力アップ
- 防御力アップ
- 素早さアップ
- etc...
- 装備のスキル付加要素
- 連続攻撃
- 必殺
- カウンター
- etc..
- キャラクターの状態
- 死亡
- 毒
- 麻痺
- 睡眠
- 行動終了
- etc..
- キャラクターのスキル
- 連続攻撃
- 必殺
- カウンター
- etc..
- キャラクターが保有するアイテム
- 薬草
- etc..
- ターン
- 自分のターン
- 敵のターン
- ターンの時間制限
- キャラクターの初期配置
- キャラクターの選択
- ダイアログ
- キャラクターダイアログ(攻撃、アイテムを使うなどの選択肢を選ぶ)
- オプションダイアログ(ターン数を確認したり、降参するといった選択肢を選ぶ)
- ユーザー名
- 勝利条件
- 城を制圧したら
- 敵を全員倒したら
- xターン城を守りきったら
- 相手が降参したら
- プレイヤー
- 1P
- 2P
- 第三勢力(NPC)
ちなみに、開発の大変さの都合上ドロップした概念(アイテムとか装備とか)も入れています。開発前に行うべき設計を想定しておりますので。
step3 クラス図(or 概念モデル)を作る
そして出し切ったリストを確認しながらそれぞれの概念を抽出し、それをクラス図に反映していきます。
例えば、今回抽出した概念の中に「フィールド」と「マス目」というものがあります。
フィールドを構成するのがマス目なので、クラス図に落とし込んでいくとこんな感じでしょうか。
Fieldクラスは複数のCellクラス(マス目)を集約しています。
また、先ほどのリストの中には アクション可能マス表示処理 というロジックが存在します。
これはフィールドでキャラクターが移動可能な範囲(複数のマス目)を表示したり、キャラクターが攻撃可能な範囲を表示するのに必要な処理です。
なので、こちらのクラス図にはこの様に処理を付け加えます。
またマス目には 特殊効果 が存在する場合があります。
例えば特定のマスに存在するきキャラクターの攻撃力が20%upしたり、逆に防御力が10%downしたり、といった内容です。
なのでCellクラスには「キャラクターの能力にマス目の特殊効果を反映させる」処理が必要です。
また、フィールドにはキャラクターが存在しているマス目のCellクラスを返す処理が必要となるはずです。
それぞれ反映すると下記のようになります。
(applySpecialEffect(character: Character) はキャラクターを引数にとって特殊効果をパラメーターに反映させたキャラクターを返すようにしています)
こうして、全てのリスト内の要素をクラスに反映させていった結果がこちら。
うーん、クラス図とかちゃんと書いたことないので、これで正しく図示できているのかわかりませんが...
とりあえず細かいところは省いてざっくり説明すると
- GameControllerクラスがオンライン対戦全体の管理を行う
- TurnControllerクラスを使用して、現在が相手のターンか、自分のターンなのかを判定する
- CharacterControllerクラスが自分のキャラクター(複数)の管理を行う
- FieldControllerがアクション可能なマス目を表示する(攻撃可能マス、移動可能マス,etc...)
- Cellクラスはマス目のタイプ(山、川、草原、など)とそれに伴う特殊効果(攻撃力UPなど)の内容を保持する
- Characterクラスがキャラクターの攻撃力や防御力といったパラメーター、敵キャラクターへの攻撃結果の算出などを行う
- ParameterManagerがキャラクターの能力計算を行う
- EquipManagerが装備品の管理を行い、パラメーターの反映をParameterManagerを通して行う
- SkillControllerがキャラクターのスキルに応じて様々な処理を行う(ターン終了時に自分のキャラクターを召喚したり、キャラクター攻撃時に特殊な処理を追加したり)
こんな感じでしょうか。
今回はプロパティまで書き込みましたが、『オブジェクト思考のこころ』では、この共通性/可変性分析のアウトプットのクラス図にはプロパティまで書き込まれていません。
なので、どこまで詳細に仕様を決定したいかにもよりますが、ざっくりと全体像をつかみたい、ぐらいなんだったら簡易な概念モデル図とかでもいいのかもしれません。
結果どうなったか
そして、このクラス図を元にコードをリファクタリングしてみた結果がこちら。
https://github.com/koheiobe/spa-simulation-game/blob/master/components/battle/Field.vue
ざっとみただけでは違いが分かりにくいかもしれませんが、
- 577行→406行にコード量削減
- オブジェクト単位で責務が分割されたから
- バグが出た時の原因箇所がわかりやすくなった
- 機能追加しやすくなった
- 仕様変更に強くなった
と、いろいろとメリットは大きいです。
例えば、下記のロジックはキャラクターを移動可能なマス目に移動させる処理ですが、
moveCharacter(latLng: ILatlng, cellCharacterId: string) {
if (!this.interactiveCharacter) return
const isMovableCell =
this.interactiveCharacter.id === cellCharacterId ||
cellCharacterId.length === 0
if (
isMovableCell &&
this.isMyTurn &&
this.isMyCharacter(this.interactiveCharacter) &&
this.interactiveCharacter.actionState.isEnd === false
) {
this.updateInteractiveCharacter({
...this.interactiveCharacter,
latLng,
lastLatLng: this.interactiveCharacter.latLng
})
this.setModal(true)
}
if (
latLng.x === this.interactiveCharacter.latLng.x &&
latLng.y === this.interactiveCharacter.latLng.y
) {
this.setModal(true)
}
this.movableArea = {}
}
これが、下記のようにすべてCharacterControllerクラスに移譲できました。
moveCharacter(latLng: ILatlng, cellCharacterId: string) {
if (
this.characterController.moveCharacter(
latLng,
cellCharacterId,
this.isMyTurn,
this.isHostOrGuest
)
) {
this.setModal(true)
this.fieldController.finishMoveMode()
}
}
また、下記のコードは攻撃後のキャラクターの状態を更新する処理ですが、
async attackCharacter(attackerEl: HTMLElement, attacker: ICharacter) {
await attackCharacterAnimation(attackerEl, attacker)
const takerLatLng = attacker.actionState.interactLatLng
const taker = this.storeCharacters.find(
(character) =>
character.latLng.x === takerLatLng.x &&
character.latLng.y === takerLatLng.y
)
if (!taker) return
const enemyEl = document.getElementById(taker.id)
if (!enemyEl) return
await takeDamageCharacterAnimation(enemyEl)
const damageTakenCharacter = await this.updateDamageTakenCharacter(
attacker,
taker
)
this.onEndAttackCharacter(attacker, damageTakenCharacter)
}
async updateDamageTakenCharacter(attacker: ICharacter, taker: ICharacter) {
const damage = calculateDamage(attacker, taker)
const damageTakenCharacter = {
...taker,
hp: taker.hp - damage
}
if (attacker.skill.includes('bloodSucking')) {
const suckedHp = attacker.hp + damage
await this.updateCharacter({
battleId: this.battleId,
character: {
...attacker,
hp: suckedHp > attacker.maxHp ? attacker.maxHp : suckedHp
}
})
}
const finalDamageTakenCharacter = onEndCalculateDamage(damageTakenCharacter)
if (finalDamageTakenCharacter.hp <= 0) {
finalDamageTakenCharacter.lastLatLng = finalDamageTakenCharacter.latLng
finalDamageTakenCharacter.latLng = { x: -1, y: -1 }
}
this.$emit('setcharactersLatLngMap', finalDamageTakenCharacter)
await this.updateCharacter({
battleId: this.battleId,
character: finalDamageTakenCharacter
})
return finalDamageTakenCharacter
}
updateDamageTakenCharacterという関数については丸々CharacterControllerのほうに移すことができ、component側の記述はだいぶすっきりしました。
async attackCharacter(attackerEl: HTMLElement, attacker: ICharacter) {
const attackResultObj = await this.characterController.attackCharacter(
attackerEl,
attacker,
this.characterList
)
if (!attackResultObj) return
attackResultObj.attacker.actionState.isEnd = true
this.updateCharacter({
battleId: this.battleId,
character: _.cloneDeep(attackResultObj.attacker)
})
this.updateCharacter({
battleId: this.battleId,
character: _.cloneDeep(attackResultObj.taker)
})
this.onEndAttackCharacter(attackResultObj.attacker, attackResultObj.taker)
}
ただ、作成したクラス図通りにリファクタリングできたわけではありません。既に出来上がっている構造上の問題で、どうしてもクラス図通りに行かなかったところも多々あります。
最大の難点はfirebaseのfirestoreを経由して違うクライアントと常に同期する必要があるデータが存在することです。例えばこのゲームアプリでは、対戦中のキャラクターの状態や位置を対戦相手と常に同期している必要がある構造となっているため、characterの状態をCharacterControllerのクラス変数として取り扱いずらいです。
firestoreのデータにはvuexfireという外部ラリブラリーを使用し、vueコンポーネントからアクセスする必要があるので、vueを継承していないクラスからはアクセスできません。
このため、例えばCharacterControllerで必要となる最新のキャラクター情報は常に外部(Field.vue)から渡すようにしています。
async attackCharacter(attackerEl: HTMLElement, attacker: ICharacter) {
const attackResultObj = await this.characterController.attackCharacter(
attackerEl,
attacker,
// CharacterController内で値を保持できない!!
this.characterList
)
if (!attackResultObj) return
attackResultObj.attacker.actionState.isEnd = true
this.updateCharacter({
battleId: this.battleId,
character: _.cloneDeep(attackResultObj.attacker)
})
this.updateCharacter({
battleId: this.battleId,
character: _.cloneDeep(attackResultObj.taker)
})
this.onEndAttackCharacter(attackResultObj.attacker, attackResultObj.taker)
}
ここは正直考慮がもれていました。リアルタイムでデータの同期が必要などの特殊な仕様がある場合、設計段階で特に注意しなければいけないのでしょう。
あと、やはり一度出来上がったものを変更するのは骨がおれます...。変更するたびに仕様通りに動いているのか確認しなければならないのでリファクタリングが超面倒でした。
やはり初っ端からしっかりと設計しておかないと後々苦労することになりますね。。
今回の教訓は?
常に自分に 全体像を描けているのか? と自問自答しながら開発することの大切さを学びました。
普段から設計業務をしている人が聞くと「そんな当たり前のことを...」と言われそうですが、開発に集中していると詳細に囚われすぎて、全体像を忘れてしまうことはよくあるのではないかと思います。
特に私の場合、しっかり考えてから開発じゃなくて、開発しながら考える方が性に合っているんで余計にそうなる傾向にあるのかもしれません。
全体→部分というの常に意識したいものです。
以上。何らかの参考になれば幸いです。