「砂漠にクラーケンが出るんです。森では砂嵐が吹いて、海底にサボテンが生えてて……プレイヤーからの報告が止まらなくて」
僕は神崎。ゲーム会社「Arclight Studios」のサーバーサイドエンジニアだ。経験4年、26歳。うちの看板タイトル「Elysium Online」のプロシージャル世界生成を担当している。
最初は3つのバイオーム——森、砂漠、海——だけだった。地形を作って、生物を配置して、天候を設定する。シンプルなスクリプトで十分だった。
ところがサービスが成長して、バイオームが増え、プランナーから「バイオーム間の遷移ゾーンも作ってくれ」と言われて——気づいたら、世界がおかしくなっていた。
森の中をサンドワームが泳ぎ、砂漠の上空に渦潮が渦巻き、海底でエルフが弓を引いている。
そして今朝、ディレクターがSlackでこう言った。
「神崎くん、次のアプデで火山バイオーム追加ね。2週間で」
火山。新しい地形、新しいモンスター、新しい天候。つまり、あの地獄のif文を3つのメソッド全部に追加して、遷移ゾーンのバグもさらに増えて——。
僕は「検討します」とだけ返し、昼休みに雑居ビルの階段を上がった。
「レガシー・コード・インベスティゲーション(LCI)」
ドアを開けると、三枚のモニターに異世界のマップらしきものが映っていた。デスクトップPCの排熱で妙に暖かい室内。飲みかけのエナジードリンク缶が散乱している。革張りの椅子の男は缶を傾けながら、地図上の何かを凝視していた。
「……ほう。珍しいクライアントだな。ゲームの世界からお越しか」
「あの、初めてなんですが——」
「初めてだろうと百回目だろうと、ここでは皆ワトソン君だ。さあ、今日の事件を聞こう」
僕はノートPCを開き、Elysium Onlineの世界生成コードを見せた。
現場検証:壊れた箱庭
「まず全体像を見せたまえ」
僕は世界生成の中核モジュールを開いた。
| |
「3つのメソッドが全部同じ構造です。$type で分岐して、それぞれのバイオームに合った地形・生物・天候を返す。でも問題は——」
「3つのメソッドが独立していることだろう?」ロックが先に言った。
「え、はい。地形は地形、生物は生物、天候は天候で、バラバラに生成しています。同じ $type を渡してるから普通は揃うんですが……」
「普通は、な。では普通でなくなった瞬間を見せたまえ」
僕はうなだれながら、遷移ゾーン生成のコードを開いた。
| |
「遷移ゾーンは2つのバイオームの要素を混ぜるつもりだったんです。でも、誰がどの引数を渡すかはプログラマの記憶頼みで……」
「結果、森の地形に海の生物が配置された。砂漠にクラーケン、深海に砂嵐——すべてはここから生まれた」
ロックはホワイトボードに図を描き始めた。
graph TD
WG[WorldGenerator]
WG -->|"$type 引き回し"| T["_create_terrain(if/elsif)"]
WG -->|"$type 引き回し"| C["_create_creature(if/elsif)"]
WG -->|"$type 引き回し"| W["_create_weather(if/elsif)"]
subgraph "森ファミリー"
FT[古代樹の森]
FC[エルフの弓兵]
FW[精霊の霧]
end
subgraph "海ファミリー"
OC[クラーケン]
end
T --> FT
C -.->|"$to = ocean"| OC
W --> FW
style OC fill:#f66,stroke:#333
「森の地形と霧の中に、海のクラーケンが立っている。ファミリーが壊れているんだ」
「ファミリー……?」
「世界の生き物には血統がある、ワトソン君。森のエルフは森の霧雨の中でこそ美しく、砂漠のサンドワームは砂嵐の中でこそ恐ろしい。地形・生物・天候は同じ血統に属する一族であるべきだ。ところが君のコードでは、3つの生成メソッドが独立しているから、血統の異なる者同士を混ぜ合わせることができてしまう」
「確かに、_create_terrain と _create_creature に別々の $type を渡しても、コンパイラは何も文句を言わない……」
「そしてもう一つの問題。火山バイオームを追加するとどうなる?」
「3つのメソッド全部に elsif ($type eq 'volcano') を追加します。遷移ゾーンのテストケースも増えて……」
「すべてのif文に手を入れる。そして手を入れるたびに、新たな血統汚染のリスクが生まれる。これが今回の犯人だ——Mismatched Product Family(製品ファミリー不一致)。関連するオブジェクト群を個別に生成するがゆえに、異なるファミリーの部品が混在する」
推理披露:血統を守る工場(Abstract Factory)
「ワトソン君。品種の純血を守る方法を知っているかね?」
「品種……ブリーダーが血統書を管理する、とか?」
「その通り。一つの血統に属する者は、一つの工場から送り出す。工場が血統書の役割を果たし、異なる血統の混入を構造的に不可能にする」
「工場……Factory Methodとは違うんですか? 前に聞いたことがあって」
「Factory Methodは単一の製品の生成をサブクラスに委ねる。だがAbstract Factoryは違う。製品ファミリー全体——地形も生物も天候も——を一つの工場で生成する。一族をまとめて送り出すのさ」
ロックはキーボードを叩き始めた。
【After】製品クラスの定義
| |
「まず、地形・生物・天候をそれぞれ required な属性を持つクラスにする。ハッシュリファレンスではなく、型付きのオブジェクトだ。不完全な生成を防ぐ」
【After】血統書(BiomeFactory ロール)
| |
「BiomeFactory ロールは、全てのバイオーム工場が守るべき契約だ。地形・生物・天候——この3つをセットで生成する責務を負う。1つだけ作ることも、別のファミリーから借りてくることもできない」
【After】各血統の工場
| |
「ForestFactory は森の地形・森の生物・森の天候だけを生成する。OceanFactory は海のものだけ。工場を選んだ時点で、ファミリーが確定する。異なる血統の混入は、構造的に起こり得ない」
「でも、WorldGenerator はどう変わるんですか?」
【After】ファクトリーに委ねる世界生成
| |
僕は画面を見つめた。Before では $type を3つのメソッドに引き回していた if/elsif の壁が、すべて消えている。
「WorldGenerator はもう $type を知らない。知る必要がない。どのファクトリーを受け取ったかだけで、すべてが決まる」
「Before だと generate_transition で $from と $to を間違えてクラーケンが森に出ていた……。今の構造だと、ファクトリーを渡す時点でファミリーが決まるから、混ぜようがない」
「その通りだ」
ロックはホワイトボードに新しい図を描いた。
graph TD
WG[WorldGenerator]
WG -->|"has factory"| BF["BiomeFactory (Role)"]
BF -.->|with| FF[ForestFactory]
BF -.->|with| DF[DesertFactory]
BF -.->|with| OF[OceanFactory]
FF --> FT[古代樹の森]
FF --> FC[エルフの弓兵]
FF --> FW[精霊の霧]
DF --> DT[灼熱の砂漠]
DF --> DC[サンドワーム]
DF --> DW[砂嵐]
OF --> OT[深淵の海底]
OF --> OC[クラーケン]
OF --> OW[大渦潮]
「WorldGenerator は BiomeFactory ロールだけに依存する。どの具体工場が来るかは知らない。そして各工場は自分の血統だけを送り出す。これが血統書のある世界だ」
「初期化はどうなりますか?」
| |
「Before の generate_zone('forest') が WorldGenerator->new(factory => ForestFactory->new)->generate に変わった。長くなったように見えるが、ファクトリーを受け取った時点で血統が保証される。呼び出し側が $type を間違える余地がない」
「そして——ワトソン君が恐れていた火山バイオームの追加だ」
【After】火山の血統——VolcanoFactory
| |
| |
「……え、ForestFactory も DesertFactory も OceanFactory も WorldGenerator も何も触ってないですか?」
「一切触っていない。VolcanoFactory は BiomeFactory ロールを満たしているから、WorldGenerator はそのまま受け入れる。新しい血統を追加しても、既存の血統には一切影響しない」
「Before だったら、3つのメソッド全部に elsif ($type eq 'volcano') を追加して、遷移ゾーンのテストケースも追加して……」
「その恐怖はもう過去のものだ」
解決:血統書のある世界
ロックがテストを実行すると、ターミナルに結果が並んだ。
| |
「Before のテスト5を見たまえ。遷移ゾーンで森の地形に海のクラーケンが混入している。After のテスト7〜8——製品はハッシュではなく型付きオブジェクトだ。テスト9〜11、火山ファミリーが正しく生成されている。テスト12〜15、どのファクトリーでもWorldGeneratorは動く」
「砂漠にクラーケンが出る世界は、もう終わりだ……」
「血統書が世界を守る。一族は一族として生まれ、異なる血が混じることはない。これがAbstract Factoryパターンだ」
僕はPCを閉じかけたが、ロックが手を上げた。
「報酬は——そうだな。Elysium Online の限定マウント『黒曜石のドラゴン』をいただこうか。火山バイオーム初クリア報酬のやつだ」
「それ、まだ実装してないんですけど……」
ロックは人差し指を立てた。
「最後に一つ。Abstract Factoryはファミリーの一貫性を守る強力な手法だ。新しいバイオーム——つまり新しいファミリーの追加は容易い。だが新しい製品種の追加は重い。たとえば全バイオームに『BGM』を追加したくなったら、BiomeFactory ロールに create_bgm を追加し、既存の全ファクトリーに実装を強いることになる」
「全ファクトリーに……」
「ファミリーの数が増えるのは得意だが、ファミリーの中身が増えるのは苦手。血統を増やすのは容易いが、血統の定義を変えるのは重い。それを忘れなければ、君の箱庭は美しく保たれるだろう」
僕はLCIを出て、ディレクターへの返信を書いた。「火山バイオーム、1週間で行けます」
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
Mismatched Product Family(製品ファミリー不一致)。関連するオブジェクト群(地形・生物・天候)を個別のメソッドで生成し、$type 引数の引き回しに依存。呼び出し側の引数ミスで異なるファミリーの混在が発生し、世界の整合性が崩壊。 | Abstract Factory パターン。製品ファミリー全体を1つのファクトリーで生成し、ファクトリーを選んだ時点でファミリーの一貫性が保証される。WorldGenerator は具体ファクトリーを知らず、BiomeFactory ロールにのみ依存。 | ファミリー不一致が構造的に不可能に。新バイオーム追加で既存コード修正ゼロ。if/elsif 分岐が全メソッドから完全に消滅。製品がハッシュから型付きオブジェクト(Terrain/Creature/Weather)に昇格し、不完全な生成を防止。 |
推理のステップ
- 製品クラスを定義する: 地形(Terrain)・生物(Creature)・天候(Weather)をそれぞれ
requiredな属性を持つ独立したクラスとして定義する。ハッシュリファレンスからの卒業。 - Abstract Factory ロールを定義する:
BiomeFactoryロールでcreate_terrain/create_creature/create_weatherの3メソッドをrequiresとして宣言する。ファミリーの契約を明示する。 - Concrete Factory を実装する:
ForestFactory/DesertFactory/OceanFactoryの各ファクトリーがBiomeFactoryロールを消費し、自分の血統に属する製品だけを生成する。 - WorldGenerator をファクトリーに依存させる:
has factoryでファクトリーを受け取り、generateメソッドではファクトリー経由で全製品を生成する。$typeの引き回しを完全に排除する。 - 新ファミリーを追加する:
VolcanoFactoryのように新しいファクトリーを1つ追加するだけ。既存のファクトリーもWorldGeneratorも一切変更しない。
ロックより
ワトソン君。Factory Methodは1種類の製品を子クラスに委ねる手法だった。Abstract Factoryはその発展形——製品ファミリー全体を1つの工場に委ねる。地形だけ、生物だけではない。地形も生物も天候も——血を同じくする者たちを、一つの血統として送り出す。
この手法の強みは「ファミリーの追加が軽いこと」だ。新しいバイオームを追加するとき、既存の工場には指一本触れない。だがその裏返しとして、「ファミリーの定義変更は重い」。全バイオームにBGMを追加しようとすれば、全工場の改修が必要になる。
だから適用するなら、まず自問したまえ。「増えるのはファミリーの種類か、それともファミリーの中身か」。種類が増えるならAbstract Factory。中身が増えるなら、別の手法を探るべきだ。血統書は、血統の定義が安定しているからこそ価値を持つのだから。
