はじめに
私は学生で、新たな取り組みとしてWebゲームという題材を設定いたしました。最初はTypeScriptで開発をしていたのですが設計の限界を感じ、TypeScriptからC#への移行を機に、単なる言語の置き換えではなく、オブジェクト指向の原則に基づいた抜本的な再設計を行い、Web Assembly(Wasm)でゲームを実現させる方向へとシフトした際に起きた設計の失敗談をご紹介いたします。
当時の設計
本プロジェクトの処理は、ざっくり説明すると以下の様な構造となっております。しかし、TypeScriptの時は 『一部のStateが常に完全状態を持たない・各プロパティの更新が別々のメソッドに分割されている』 という構造であり、GameSimulator側やRenderer側が状態を再構築し始め責務が崩壊してしまいました。
GameSimulator側で以下のような呼出しが頻発していました。
setPosition()
setBlock()
setAngle()
GameSimulator側で生成した後にデータを挿入する必要があり、挿入し忘れによるバグの原因となっていた。
class GhostState {
private _currentBlock: Block | null = null;
private _position: Point | null = null;
public setCurrentBlock(block: Block): void {
this._currentBlock = this.cloneBlock(block);
this._position = null; //セッターがバラバラなため呼出しコストが増加
this.dirtyFlag |= DirtyType.SHAPE;
}
public get currentBlock(): Block {
// 防御的設計により、コードが肥大化している。
if (!this._currentBlock) {
throw new Error("currentBlock is not initialized!");
}
return this.cloneBlock(this._currentBlock);
}
}
この設計の問題点
『一部のStateが常に完全状態を持たない・各プロパティの更新が別々のメソッドに分割されている』 という状態の設計にすると以下のような技術的負債が発生します。
- State群を所持し状態を更新する責務を持つSimulator/Managerに、nullチェックなどが入り込み、コードが肥大化する
- プロパティを更新するセッターがバラバラだと、そのセッターを呼び忘れた際に状態の不整合が起きるようなバグの温床となる
- SimulatorがStateの再構築を始めるため、State内部の情報を知り過ぎ責務崩壊を招く
- Renderer側にnullがある場合の処理が走り、コードが肥大化する
Stateクラスは 『全システムが参照する唯一の真実』である必要 があり、nullを許容したり状態が完全に確定しない設計にしたりすると状態不整合・コードの肥大化といった技術的負債が発生します。
Stateクラスは1つのアクションに応じたAPIを持つべきであり、各プロパティごとにセッターを持つような設計にすると
- メソッドの呼び忘れ
- Simulator/ManagerクラスがStateの内部を知り過ぎる
状態となり、整合性が崩壊する原因になります。
解決策
生成されるタイミングでコンストラクタからデータを受け取り、1つのアクションに応じたAPIを持つように設計を変更しました。
State側では以下の様なAPIに刷新し、コンストラクタで生成された瞬間から完全な状態になるように変更
public class GhostState : IGhostState
{
public Block CurrentBlock { get; private set; }
public Point Point { get; private set; }
public int Angle { get; private set; }
public bool CanPlace { get; private set; }
private DirtyType DirtyFlag; // フラグは専用メソッドからしか読み取れない。
public DirtyType ConsumeUpdates()
{
var flag = DirtyFlag;
DirtyFlag = DirtyType.None; // フラグをリセットして、次の更新に備える。
return flag;
}
// コンストラクタで最初の完全な状態を強制する。
public GhostState(Block block, Point point, int angle, bool canPlace)
{
CurrentBlock = block;
Point = point;
Angle = angle;
CanPlace = canPlace;
// 初回は全てを描画させる。
DirtyFlag = DirtyType.All;
}
public void Move(Point newPoint, bool canPlace)
{
Apply(CurrentBlock, newPoint, Angle, canPlace);
}
public void Rotate(Block newBlock, Point newPoint, int newAngle, bool canPlace)
{
Apply(newBlock, newPoint, newAngle, canPlace);
}
public void Setup(Block newBlock, Point point, int angle, bool canPlace)
{
Apply(newBlock, point, angle, canPlace);
}
// 内部状態を一括で更新するヘルパーメソッド。これにより、状態の整合性が保たれる。
private void Apply(Block block, Point point, int angle, bool canPlace)
{
if (CurrentBlock != block) DirtyFlag |= DirtyType.Shape;
if (Point != point) DirtyFlag |= DirtyType.Position;
if (Angle != angle) DirtyFlag |= DirtyType.Rotation;
if (CanPlace != canPlace) DirtyFlag |= DirtyType.Validity;
CurrentBlock = block;
Point = point;
Angle = angle;
CanPlace = canPlace;
}
}
よくある落とし穴
「セッターがバラバラなら、Update()メソッド一つにまとめ、オプション引数にすればよいのでは?」
public void Update(Block? block = null, Point? point = null, int? angle = null, bool? canPlace = null)
{
// null でなければ新しい値、null なら現在の値を使用
var nextBlock = block ?? CurrentBlock;
var nextPoint = point ?? Point;
var nextAngle = angle ?? Angle;
var nextCanPlace = canPlace ?? CanPlace;
// 変化があるかチェックしてフラグを立てる
if (CurrentBlock != nextBlock || Angle != nextAngle) DirtyFlag |= DirtyType.Shape;
if (Point != nextPoint) DirtyFlag |= DirtyType.Position;
if (CanPlace != nextCanPlace) DirtyFlag |= DirtyType.Validity;
SetInternal(nextBlock, nextPoint, nextAngle, nextCanPlace);
}
このよう発想が出てくる方もいるでしょう。私もそうでした。しかし、デフォルト引数や null 判定による条件分岐が増えると、メソッド内が if の嵐になり、フラグの立て忘れや判定ミスといった「ロジックの漏れ」が必ず発生します。
さらに、プロパティが増えるたびに Update メソッドを改造するのは、「開散閉鎖の原則(Open-Closed Principle)」 にも反します。
学んだこと
プログラミングにおいて、「何が起きるか(移動・回転・設置)」という文脈をコードに込めることは、単に冗長さを減らすだけでなく、将来の自分や他の開発者に対する「意図の表明」になります。
ゲームの開発ではLogicやRendererの設計や実装が汚くても、修正をすることは可能です。しかし、唯一の真実であるStateの設計が汚いとLogic, Simulator/Manager, Renderer側に必ずその汚さが伝染しバグの温床となることを学び、Stateを綺麗にすることは疎結合への近道であるということを学びました。