保守しやすいコードを書くための "2つ" の原則
昔のゲーム開発であればプロジェクトにもよりますが "数カ月" で完成し、ロットチェック (各ゲームプラットフォームで販売できるかの技術・品質・ガイドラインなどのチェック) が終わればそのコードに手を入れることがないというケースもありました。
ですが昨今のゲーム開発の場合は、開発だけで "2~3年以上" かけることも多くなり、さらにリリース後もバージョンアップ対応、オンラインゲームの開発であれば長期の運用に耐えうるコードを書くことが必要となりました。
今回はそういった長い期間をかけて開発するゲームにおいて、「長期間、誰かが触り続けることを前提にした保守しやすいコード」をどのように書くべきかの考え方を紹介したいと思います。
目次
長期開発で起きやすい問題
まず、長期開発で起きやすい問題とは何かを考えてみます。
頻繁に人が入れ替わる
ゲーム開発に限らず、ソフトウェア開発では最初から最後まで同じメンバーであるケースは少ないです。そして有能なプログラマーほど、プロジェクトが安定した段階で別プロジェクトに異動しがちです。そして「ちょっとした修正要員」や「バージョンアップ対応専門」のような短期間のみの参加もありえます。
これにより過去の自分が書いたコードや他人が書いたコードを触る場面が必ず発生します。そのため「誰が見ても理解しやすい・手を入れやすいコード」「途中参加でも把握しやすい構造」となる工夫が求められます。
度重なる仕様変更・仕様追加が発生しやすい
ゲーム開発は仕様通り作っても、その結果「面白くない」場合には、大きな仕様変更が入る可能性があります。また最近はリリース後にパッチを当てられるようになったので、リリース後のアップデートで追加要素やバランス調整を行うなど、リリース後であってもコードの修正が頻繁に入る可能性があります。
これにより、既存コードを「開発当初は想定しなかった動作に対応させる」「機能拡張していく」ことが求められます。
問題が起きやすい場所
長期開発で問題が起きやすいコードは、個人的に以下の2つが大きいと考えています。
- フローが複雑になりやすい部分
- 複数が触る共通モジュール、機能
"フロー" が複雑になりやすい部分
ゲームシステムが複雑に絡み合っている部分はフローが複雑になりやすいです。ここで言う "フロー" の代表的な部分としては「ターン制のゲームの状態遷移」「プレイヤー制御」「通信画面のフロー」など…。
取りうる状態が多いと「Switch-Case」による状態遷移やステート管理が肥大化しやすく、条件分岐が絡み合って追いきれなくなり、コードがスパゲッティ化しやすい傾向があります。
複数人が触る共通モジュール・機能
特定の機能を実装する担当者が不在(≒不明確)であったり、各人の作業領域が曖昧だと起きやすい問題です。
例えば「プレイヤー制御」の明確な担当者が存在せず複数人が手を入れたり、「テキスト共通処理」といった多くのプログラマーが扱う・手を入れるコードは複雑化しやすいです。
なぜ複数人が編集すると問題が起きるのか。その理由として、複数人が編集するコードは各プログラマーの都合や書き方の方針の違いが混在します。また「便利だからここに足そう」と継ぎ足しで仕様が追加された結果、関数やクラスが肥大化し設計意図が見えないコードとなりやすいです。
そして複数人で編集すると責任の所在が曖昧になって、コードが整理されないまま放置されやすいという理由もあります。
複雑なコードにしないための "2つ" の原則
長期開発で起こり得る問題について説明しました。
ただ複雑なコードにならないようにする方法としてはとてもシンプルで、以下の 2つのことに注意します。
- シーケンス(流れ)と機能(中身)を明確に分離する
- 状態遷移を階層化し、遷移可能なパターンを明確にする
[原則1] シーケンス(流れ)と機能(中身)を明確に分離する
ここで言う "シーケンス" というのは「状態遷移」や「フロー管理」のことで、今がどの状態(フェーズ)であり、次にどの状態に遷移することです。
わかりやすい例としては、メインメニューからショップ画面に遷移したり、キャラクター選択画面に遷移したり、通信対戦画面に遷移する…といったものです。
"機能" というのは、流れというよりは固有の処理で「お金を使う」「UIのデータ受け渡し」「ダメージ計算」「実際のゲームロジック」といったものです。
よくないコード例として、この "シーケンス" と "機能" が1つのコードの混在するケースです。
switch(m_State) {
case eState_Setup: // セットアップ.
m_Bg.Setup(); // 背景のセットアップ
m_Chara.Setup(); // キャラのセットアップ.
m_Window.Setup(); // ウィンドウのセットアップ.
if(FlagCheck(100)) {
// フラグ100がONなら実績解除.
}
m_State = eState_FadeIn;
break;
case eState_FadeIn: // フェードイン.
if(/*フェードイン完了*/) {
m_State = eState_AnimIn;
}
break;
case eState_AnimIn: // アニメーション入場.
//
break;
...
}このSwitch-Caseでは、「セットアップ処理→フェードイン→アニメーション入場」といった "シーケンス" を実装していますが、セットアップシーケンスに各リソースのセットアップが直接書かれていたり、実績の解除判定が書かれています。この各セットアップ処理や実績の判定が "機能" に該当します。正直なところ上記コードは単純化しているため、この程度の処理の長さであれば混在しても問題ありません。
ただ、ここで強調したいのは「"シーケンス" は状態の遷移を中心に書くべきであり、その中に "機能" を長々と書くべきではない」ということです。
例えば、以下のように機能を明確に分離することが一つの方法です。
switch(m_State) {
case eState_Setup: // セットアップ.
// リソースをセットアップ.
m_Resources.Setup();
// メインメニューの実績判定.
AchievementMgr::Check(eAchievementMgr_MainMenu);
m_State = eState_FadeIn;
break;
case eState_FadeIn: // フェードイン.
if(/*フェードイン完了*/) {
m_State = eState_AnimIn;
}
break;
case eState_AnimIn: // アニメーション入場.
//
break;
...
}- シーケンス用のクラス / モジュールは「流れの制御」に専念させる
- 実際の処理は、別のクラス・関数に委譲する
[原則2] 状態遷移を階層化し、遷移可能なパターンを明確にする
大きな switch-case を書くのではなく、
- 上位の状態(例:バトル中 / リザルト / メニュー)
- 下位の状態(例:プレイヤーターン / 敵ターン / 演出再生中)
のように 階層を持った状態の構造を意識します。
この構造を作るための切り分けのパターンとしては、上位ほど抽象的・汎用的なシーケンス、下位ほど具体的・特殊な (汎用的でない固有の) シーケンスとなることを知っておくと良いです。例えば、上位のシーケンスとしては「リソースロード、セットアップ、フェードイン、フェードアウト」など。下位には「バトル、攻撃処理、UI表示」などです。
補足:モードごとに“まるごとクラスを分ける”勇気を持つ
ゲームモードごとにフローが変わる場合は、ついつい楽をして1つのシーケンス内に以下のように書いてしまいがちです。
switch(m_State) {
case eState_AAA:
if(m_Mode == eNormalMode) {
// ノーマルモードの処理.
}
else if(m_Mode == eHardMode) {
// ハードモードの処理.
}
}これが1箇所だけであれば問題ないですが、複数箇所に散らばると読みにくくなり、もしモードが追加されたときに修正箇所が多く、書き換えも大変です。
そこであえて冗長となりますが別のシーケンスクラスを用意して、そのシーケンス起動時にモードに合ったシーケンスクラスを切り替える設計にします。この場合、重複しそうな処理は「共通モジュールやヘルパー関数」として切り出し、各シーケンスからそれを呼び出すようにして処理を共通化します。
まとめ
今回紹介した「保守しやすいコード」を書くために守るべき大前提の原則は「コードをシンプルな構造にすること」です。
例えば、親子関係を持つオブジェクトがそれぞれ勝手に並行して複雑な処理を走らせる…としてしまうとわかりにくい処理となりやすいです。こういった場合、どのオブジェクトが処理の流れを握っているのかを明確にして、できるだけ「1つのオブジェクトで1本の処理フロー・シーケンスを完結させる」といったシンプルな作りになるようにすることが重要です。あちこちでバラバラにシーケンスを動かさず、中心となるクラスが流れをまとめる、といった作りを心がけることが、保守しやすく長期運用しやすいコードにするための1つの考え方です。
以上、保守しやすいコードを書くための参考になれば何よりです。

