ビジュアルとAPIイベントのレイヤリング:スケーラブルなプロジェクトのためのベストプラクティスガイド
プロジェクトが成長するにつれて、最もよく聞かれる質問の一つが*「ビジュアルツールとスクリプトAPI、どちらを使うべきですか?」*です。答えは両方です。ただし、どこで各アプローチが輝くかを知ることが、クリーンでスケーラブルなイベントアーキテクチャと、自重で崩壊するアーキテクチャを分けるポイントです。
さまざまな規模のチームがGESをどう使っているかを見てきた経験から、イベントが50個でも500個でもプロジェクトの保守性を維持できるレイヤードアプローチを共有したいと思います。
ゲームオブジェクトの2つの世界
すべてのUnityプロジェクトには、根本的に異なる2つのカテゴリのオブジェクトがあります。
シーン常駐オブジェクト — 編集時にHierarchyに存在するもの。UIキャンバス、レベルジオメトリ、カメラ、永続マネージャー、環境トリガー。見ることも、選択することも、参照をドラッグすることもできます。
ランタイム生成オブジェクト — Instantiate() でランタイムに作成されるプレハブインスタンス。敵、弾丸、ドロップアイテム、プーリングされたVFX、動的に生成されるUI要素。ゲームが動き出すまで存在しません。
この区別が、イベント使用のレイヤリングの基盤です。
レイヤー1:シーン常駐オブジェクトのビジュアル設定
シーンに存在するすべてのオブジェクトには、Editor、Behaviorウィンドウ、Flow Graphを使用しましょう。
なぜか?シーン常駐オブジェクトには安定したInspector参照があるからです。UIキャンバス上のヘルスバー、レベル内のドアトリガー、BGMコントローラー — これらのオブジェクトはHierarchyにそこにあるのです。以下のことができます:
- Game Event Editor を開き、イベントを見つけてBehaviorボタンをクリック
- Action Conditions をビジュアルに設定(例:
health < 30%の時だけダメージフラッシュを発動) - Scheduleタイミング を設定(ヒット音の0.2秒遅延、画面振動を0.1秒間隔で3回繰り返し)
- Hierarchyから直接ターゲットオブジェクトをドラッグして UnityEvent actions を接続
- Flow Graph で複雑なシーケンスを構築(画面フェードアウト → シーン読み込み → プレイヤー再配置 → 画面フェードイン)
ここはデザイナー、アーティスト、サウンドエンジニアが最も活躍する場です。ゲームフィールの調整 — 遅延を0.2秒から0.35秒に変更、低HPの敵にエフェクトをスキップする条件を追加、Chainシーケンスの順序を変更 — コードに一切触れず、リコンパイルを待つ必要もありません。
最適な用途
- UIレスポンス — ボタンクリック、パネルトランジション、HUD更新
- レベルスクリプティング — ドア開放、トラップ発動、カットシーントリガー
- オーディオイベント — ゲーム状態に基づく再生/停止/クロスフェード
- カメラ動作 — シェイク、ズーム、追従ターゲット切り替え
- 環境リアクション — ライティング変化、パーティクルエフェクト、天候遷移
- チュートリアルシーケンス — 条件付きステップバイステップのChainガイド
例
OnPlayerDeath イベントで:画面を暗くする、「You Died」パネルを表示する、サウンドを再生する、プレイヤー入力を無効にする。4つのレスポンスすべてが、Hierarchyに既に存在するUIとシーンオブジェクトに接続されています。これはBehaviorウィンドウの教科書的ユースケース — 4つのAction、1つのイベント、コードゼロ。デザイナーは後からパネル表示に0.5秒の遅延を追加したり、水中ではサウンドをスキップする条件を追加できます。コード変更リクエストは一切不要です。
レイヤー2:ランタイム生成インスタンスのスクリプトAPI
ランタイムに作成されるプレハブインスタンスには、AddListener()、RemoveListener()、Raise() を使用しましょう。
なぜか?プレハブをインスタンス化する時、参照をドラッグするInspectorがないからです。オブジェクトプールから生成したばかりの敵は OnPauseGame をリッスンしてAIをフリーズさせる必要があります。弾丸はターゲットに衝突した時に OnEnemyHit を発火する必要があります。これらのバインディングはコードで、オブジェクトが生まれた瞬間に行う必要があります。
public class Enemy : MonoBehaviour
{
[GameEventDropdown] public GameEvent onPauseGame;
[GameEventDropdown] public GameEvent onResumeGame;
void OnEnable()
{
onPauseGame.AddListener(Freeze);
onResumeGame.AddListener(Unfreeze);
}
void OnDisable()
{
onPauseGame.RemoveListener(Freeze);
onResumeGame.RemoveListener(Unfreeze);
}
void Freeze() => agent.isStopped = true;
void Unfreeze() => agent.isStopped = false;
}
重要な点に注目してください:イベントアセット自体は、プレハブの [GameEventDropdown] 属性を通じて割り当てられています。イベント参照はビジュアル — プレハブアセット上のドラッグ&ドロップフィールドです。リスナー登録だけがコードです。なぜなら、インスタンスは編集時には存在しないからです。
最適な用途
- 生成された敵/NPCがグローバルイベントに反応(ポーズ、スローモーション、エリアエフェクト)
- 弾丸やVFXが衝突やライフタイム終了時にイベントを発火
- プーリングされたオブジェクトがアクティブ/非アクティブ時にサブスクライブ/アンサブスクライブ
- 動的に生成されるUI要素(インベントリスロット、チャットメッセージ、リーダーボード行)
- 編集時にリスナー数が不明なすべてのシステム
例
タワーディフェンスゲームを作っているとします。タワーはランタイムに配置されます。各タワーは OnWaveStarted をリッスンして照準を開始し、OnWaveEnded でアイドル状態に入る必要があります。タワーは動的にインスタンス化されるため、各タワーが OnEnable() で独自のリスナーを登録し、OnDisable() でクリーンアップします。一方、OnWaveStarted を発火するウェーブマネージャーは、タイミングがすべてBehaviorウィンドウで設定されたシーン常駐シングルトンかもしれません。
レイヤー3:ハイブリッド — 魔法が起きる場所
GESの真の力は、両方のレイヤーを意図的に組み合わせた時に現れます。
プログラマーがイベントアーキテクチャを定義し、ランタイムシステムの発火/リッスンコードを書きます。どんなイベントが存在するか、どんなデータを運ぶか、いつ発火するかを決定します。
デザイナーとアーティストがBehaviorウィンドウ、Flow Graph、Condition Treeを使ってレスポンスの内容を設定します。ゲームフィール、タイミング、条件、ビジュアル/オーディオの仕上げをコントロールします。
具体的なハイブリッドワークフロー:
[プログラマーが書く]
- EnemyHealth.cs: ヒット時に OnEnemyDamaged(int damage) を発火
- EnemyHealth.cs: HP <= 0 時に OnEnemyDeath(GameObject enemy) を発火
- WaveManager.cs: 敵の生成/消滅時にリスナーを動的に追加/削除
[デザイナーがBehaviorウィンドウで設定]
- OnEnemyDamaged -> ダメージ数字UIのフラッシュ、カメラシェイク(条件:damage > 20)
- OnEnemyDeath -> 死亡VFX再生、スコア加算、ウェーブ完了チェック
[デザイナーがFlow Graphで設定]
- OnLastEnemyDeath -> OnWaveCompleteをトリガー
- OnWaveComplete -> チェーン:報酬パネル表示 -> 3秒待機 -> 次のウェーブ生成
プログラマーは画面振動の持続時間を調整する必要がありません。デザイナーはリスナー登録コードを書く必要がありません。各人が自分の専門領域で作業し、イベントアセットが彼らの間の共有契約となります。
スケーリングのための実践ガイドライン
プロジェクトが成長するにつれて、以下の原則を心に留めてください。
1. オブジェクトのライフタイムに選択を委ねる
編集時にシーンにある → ビジュアル。ランタイムにインスタンス化 → API。このルール1つで90%の判断が解決します。
2. コード内でもイベント参照はビジュアルに保つ
MonoBehaviourフィールドには常に [GameEventDropdown] を使い、イベント検索をハードコーディングしないでください。プレハブに型安全で検索可能なドロップダウンが提供され、コード変更なしでイベントを差し替えられます。
3. レスポンス調整にはBehaviorウィンドウ、レスポンスロジックにはコード
レスポンスが「HPが50%以下の時、0.3秒遅延でこのサウンドを再生」なら設定 — Behaviorウィンドウへ。レスポンスが「アーマータイプ、属性耐性、バフスタックに基づいてダメージ軽減を計算」ならロジック — コードで書く。
4. ランタイムリスナーの後片付けを徹底する
OnEnable() → サブスクライブ。OnDisable() → アンサブスクライブ。例外なし。オブジェクトプールや頻繁なシーン読み込みがあるプロジェクトで、メモリリークやゴーストリスナーを防ぐための最も重要な習慣です。
5. 実行順序が重要な時はプライオリティリスナーを使用
複数のシステムが同じイベントに応答する場合、登録順序に頼らないでください。AddPriorityListener() で明示的なプライオリティ値を指定します。データ保存はプライオリティ1000、ゲームステート更新は100、UI更新は0、オーディオ再生は-100。実行順序が自己文書化されます。
6. Flow Graphで見えない関係を可視化する
イベントがTriggerやChainで他のイベントを発火する場合、必ずFlow Graphでモデリングしてください。6ヶ月後、OnDoorOpened が OnLightActivated、OnMusicChanged、OnTutorialStep3 をトリガーすることを覚えている人はいません。Flow Graphならこれらの関係が一目で分かります。
7. 型ではなくドメインで整理する
イベントデータベースは技術的分類(Voidイベント、Intイベント)ではなく、ゲームドメイン(コンバット、UI、オーディオ、プログレッション)を軸に構成してください。コンバットデザイナーが何かを調整する必要がある時、コンバット関連のすべてを1か所で見つけられるべきです。
判断を一目で
| レイヤー | アプローチ | 担当者 | タイミング |
|---|---|---|---|
| シーンオブジェクト | ビジュアル(Editor、Behavior、Flow Graph) | デザイナー、アーティスト、サウンド | 編集時にHierarchyに存在するオブジェクト |
| ランタイムインスタンス | スクリプトAPI(AddListener、Raise) | プログラマー | ゲームプレイ中にインスタンス化されるプレハブ |
| ハイブリッド | 共有契約としてのイベント | 全員 | プログラマーが発火、デザイナーがレスポンス |
まとめ
目標は2つのアプローチのどちらかを選ぶことではありません。目標は各チームメンバーがそれぞれの専門性に合ったツールで作業し、イベントシステムが各専門分野間のクリーンな境界として機能することです。
アーキテクチャはコードで構築する。体験はエディターで磨く。ゲームは一緒に出荷する。
