UE5プロジェクトを2本3本と作っていくと、毎回似たコードを書き直していることに気づきます。インベントリ、セーブ、設定画面、実績解除、UIフレーム——内容は微妙に違うのに、構造はだいたい同じです。プラグインに切り出せばいいのは分かっているのですが、.Build.csの依存関係やPublic/Privateの分割で詰まり、結局ゲームコード側にベタ書きしてしまった——そんな経験はないでしょうか。
私自身、UE5でゲームを作りながら同じ壁に何度もぶつかりました。Antigravityを使い始めてからは、エージェントがBuild.csの書き方やヘッダの公開範囲まで提案してくれるおかげで、プラグイン化の心理的ハードルがかなり下がっています。ただし、AIが提案するコードをそのまま受け入れているとビルドが通らない場面もあり、「AIが力を発揮しやすいプラグイン構造」を意識して設計する必要があると感じました。
ここではAntigravityと相性の良いUE5プラグインの設計方針を、3つのゲームシステム(インベントリ・セーブ・実績)を題材に解説します。コードはそのまま自分のプロジェクトに移植できる完全な形で示し、最後には複数タイトルで使い回すためのバージョン運用と配布パターンまで踏み込みます。
なぜ「AIと相性の良いプラグイン構造」を意識するのか
Antigravityのエージェントは、AGENTS.mdやプロジェクトのファイル構造を手がかりにコードを生成します。UE5プロジェクトをそのまま開かせると、エージェントはSource/<GameName>/配下を一塊のモジュールとして扱おうとするため、プラグイン側のヘッダを探しに行ったり、Plugins/配下のシンボルをリンクしたりする際に文脈が途切れがちです。
特につまずきやすいのが、Public/とPrivate/の境界です。UE5ではPublic/に置いたヘッダだけが他モジュールから#includeでき、Private/は内部実装専用というルールがあります。これを無視してエージェントに「インベントリのアイテム追加処理を書いて」と頼むと、Private/の構造体を外部モジュールから直接参照するコードを書いてくれるため、UnresolvedExternalのリンクエラーが出ます。
つまり、プラグインを「APIゾーン」と「実装ゾーン」に明確に分け、Antigravityにその構造を伝えるドキュメント(後述のAGENTS.mdスニペット)を置いておくと、エージェントの提案精度が劇的に上がります。これがこの記事の出発点です。
プラグインの骨格を作る:.upluginと.Build.cs
まずは骨格を作ります。プロジェクト直下のPlugins/CommonGameSystems/にプラグインを置く想定で、最小構成は次のようになります。
Plugins/CommonGameSystems/
├── CommonGameSystems.uplugin
└── Source/
├── CommonGameSystemsRuntime/
│ ├── CommonGameSystemsRuntime.Build.cs
│ ├── Public/
│ │ ├── Inventory/InventoryComponent.h
│ │ ├── SaveSystem/GameSaveSubsystem.h
│ │ └── Achievements/AchievementSubsystem.h
│ └── Private/
│ ├── CommonGameSystemsRuntime.cpp
│ ├── Inventory/InventoryComponent.cpp
│ ├── SaveSystem/GameSaveSubsystem.cpp
│ └── Achievements/AchievementSubsystem.cpp
└── CommonGameSystemsEditor/
├── CommonGameSystemsEditor.Build.cs
├── Public/
└── Private/
RuntimeとEditorを分けるのが本番運用の必須要件です。Editorモジュールはパッケージビルド時には組み込まれないため、エディタ専用ツールやアセットアクションを置く場所として機能します。これを最初に分けておかないと、後でゲームをパッケージングしたときに「Slateがない」「UnrealEdが解決できない」という典型的なリンクエラーに悩まされます。
CommonGameSystems.upluginの中身は次の通りです。
{
"FileVersion" : 3 ,
"Version" : 1 ,
"VersionName" : "1.0.0" ,
"FriendlyName" : "Common Game Systems" ,
"Description" : "Reusable runtime systems (inventory, save, achievements) for UE5 indie titles." ,
"Category" : "Gameplay" ,
"CreatedBy" : "Your Studio" ,
"EnabledByDefault" : true ,
"CanContainContent" : true ,
"Installed" : false ,
"Modules" : [
{
"Name" : "CommonGameSystemsRuntime" ,
"Type" : "Runtime" ,
"LoadingPhase" : "Default"
},
{
"Name" : "CommonGameSystemsEditor" ,
"Type" : "Editor" ,
"LoadingPhase" : "PostEngineInit"
}
]
}
LoadingPhaseをDefaultにしておくと、UGameInstanceの初期化と前後してモジュールがロードされます。PreDefaultにしてしまうとエンジンサブシステムにアクセスできず、Antigravityの提案コードがUAssetManager::Get()でクラッシュするケースが頻発するので注意してください。
CommonGameSystemsRuntime.Build.csはAntigravityがもっとも提案を間違えやすいファイルです。以下を雛形として置いておくと、エージェントが余計な依存を足したときに差分でレビューしやすくなります。
// Copyright (c) Your Studio. All Rights Reserved.
using UnrealBuildTool ;
public class CommonGameSystemsRuntime : ModuleRules
{
public CommonGameSystemsRuntime ( ReadOnlyTargetRules Target ) : base (Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
bUseUnity = false ; // 単独ファイルのビルド速度を優先(プラグイン開発時に重要)
PublicIncludePaths. AddRange ( new [] { "CommonGameSystemsRuntime/Public" });
PrivateIncludePaths. AddRange ( new [] { "CommonGameSystemsRuntime/Private" });
PublicDependencyModuleNames. AddRange ( new []
{
"Core" , "CoreUObject" , "Engine" ,
"GameplayTags" , // タグでアイテム種別を識別
"DeveloperSettings" , // 設定値のデータドリブン化
});
PrivateDependencyModuleNames. AddRange ( new []
{
"Json" , "JsonUtilities" , // セーブデータの直列化
"OnlineSubsystem" , // 実績連携
});
}
}
bUseUnity = falseは本番では好みが分かれますが、プラグイン開発初期は単一ファイル単位の差分でビルドされるため、AIが書いたコードの問題箇所を絞り込みやすくなります。完成後にtrueに戻して全体の最適化を測る、という流れがおすすめです。
システム1:インベントリ — UActorComponentとしてのAPI設計
最初のシステムはインベントリです。プレイヤーが持てるアイテムを管理する基本機能で、ゲームのジャンルが変わっても構造はだいたい同じになります。
ヘッダ(Public/Inventory/InventoryComponent.h)はこうなります。
// Public/Inventory/InventoryComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GameplayTagContainer.h"
#include "InventoryComponent.generated.h"
USTRUCT (BlueprintType)
struct COMMONGAMESYSTEMSRUNTIME_API FInventoryItem
{
GENERATED_BODY ()
UPROPERTY (EditAnywhere, BlueprintReadWrite)
FGameplayTag ItemTag;
UPROPERTY ( EditAnywhere , BlueprintReadWrite )
int32 Quantity = 0 ;
UPROPERTY ( EditAnywhere , BlueprintReadWrite )
int32 MaxStack = 99 ;
};
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams (
FOnInventoryChanged, FGameplayTag, ItemTag, int32, NewQuantity);
UCLASS (ClassGroup = (CommonGameSystems), meta = (BlueprintSpawnableComponent))
class COMMONGAMESYSTEMSRUNTIME_API UInventoryComponent : public UActorComponent
{
GENERATED_BODY ()
public:
UInventoryComponent ();
UFUNCTION ( BlueprintCallable , Category = "Inventory" )
bool AddItem ( FGameplayTag ItemTag , int32 Quantity );
UFUNCTION ( BlueprintCallable , Category = "Inventory" )
bool RemoveItem ( FGameplayTag ItemTag , int32 Quantity );
UFUNCTION ( BlueprintPure , Category = "Inventory" )
int32 GetQuantity ( FGameplayTag ItemTag ) const ;
UPROPERTY ( BlueprintAssignable , Category = "Inventory" )
FOnInventoryChanged OnInventoryChanged;
private:
UPROPERTY ()
TArray < FInventoryItem > Items;
};
COMMONGAMESYSTEMSRUNTIME_APIマクロは絶対に外さないでください。これがないとプラグインを使う側のモジュールからAddItemを呼べず、リンク時にunresolved external symbolが出ます。Antigravityはこのマクロを忘れがちなので、生成後にgrepで確認するのが習慣です。
実装側(Private/Inventory/InventoryComponent.cpp)は次の通り。
// Private/Inventory/InventoryComponent.cpp
#include "Inventory/InventoryComponent.h"
UInventoryComponent :: UInventoryComponent ()
{
PrimaryComponentTick.bCanEverTick = false ;
}
bool UInventoryComponent :: AddItem ( FGameplayTag ItemTag , int32 Quantity )
{
if ( ! ItemTag. IsValid () || Quantity <= 0 ) { return false ; }
for (FInventoryItem & Slot : Items)
{
if (Slot.ItemTag == ItemTag && Slot.Quantity < Slot.MaxStack)
{
const int32 Add = FMath :: Min (Quantity, Slot.MaxStack - Slot.Quantity);
Slot.Quantity += Add;
Quantity -= Add;
OnInventoryChanged. Broadcast (ItemTag, Slot.Quantity);
if (Quantity == 0 ) { return true ; }
}
}
while (Quantity > 0 )
{
FInventoryItem NewSlot;
NewSlot.ItemTag = ItemTag;
NewSlot.Quantity = FMath :: Min (Quantity, NewSlot.MaxStack);
Quantity -= NewSlot.Quantity;
OnInventoryChanged. Broadcast (NewSlot.ItemTag, NewSlot.Quantity);
Items. Add (NewSlot);
}
return true ;
}
bool UInventoryComponent :: RemoveItem ( FGameplayTag ItemTag , int32 Quantity )
{
int32 Remaining = Quantity;
for (int32 i = Items. Num () - 1 ; i >= 0 && Remaining > 0 ; -- i)
{
if (Items[i].ItemTag != ItemTag) { continue ; }
const int32 Sub = FMath :: Min (Remaining, Items[i].Quantity);
Items[i].Quantity -= Sub;
Remaining -= Sub;
OnInventoryChanged. Broadcast (ItemTag, Items[i].Quantity);
if (Items[i].Quantity == 0 ) { Items. RemoveAt (i); }
}
return Remaining == 0 ;
}
int32 UInventoryComponent :: GetQuantity ( FGameplayTag ItemTag ) const
{
int32 Total = 0 ;
for ( const FInventoryItem & Slot : Items)
{
if (Slot.ItemTag == ItemTag) { Total += Slot.Quantity; }
}
return Total;
}
期待動作としては、AddItem(Tag.Item.Health, 5)を呼ぶとOnInventoryChangedが1回発火し、GetQuantityが5を返します。スタック上限を超える場合は新しいスロットが作られ、その都度デリゲートが呼ばれます。
なぜGameplayTagでアイテム種別を識別するのかというと、Blueprint側で型安全な分岐が書きやすく、Data Tableからの読み込みとも相性が良いからです。enumにしてしまうとアイテムを足すたびにC++を再ビルドする必要があり、レベルデザイナーの作業を止めてしまいます。
システム2:セーブ — UGameInstanceSubsystemとしての実装
UE5のUSaveGameはそのまま使うと「どのスロットにいつ書いた」という管理が煩雑になります。プラグイン側でラッパーを用意しておくと、ゲーム側のコードがSaveSubsystem->SaveSlot("AutoSave")の一行で済みます。
// Public/SaveSystem/GameSaveSubsystem.h
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "GameSaveSubsystem.generated.h"
DECLARE_DYNAMIC_DELEGATE_OneParam (FOnSaveCompleted, bool , bSucceeded);
UCLASS (BlueprintType)
class COMMONGAMESYSTEMSRUNTIME_API UGameSaveSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY ()
public:
virtual void Initialize ( FSubsystemCollectionBase & Collection ) override ;
UFUNCTION ( BlueprintCallable , Category = "SaveSystem" )
void SaveSlotAsync ( const FString & SlotName , const FOnSaveCompleted & OnDone );
UFUNCTION ( BlueprintCallable , Category = "SaveSystem" )
bool LoadSlot ( const FString & SlotName );
UFUNCTION ( BlueprintCallable , Category = "SaveSystem" )
void Set ( const FString & Key , const FString & Value );
UFUNCTION ( BlueprintPure , Category = "SaveSystem" )
FString Get ( const FString & Key , const FString & Default = TEXT ( "" )) const ;
private:
UPROPERTY ()
TMap < FString, FString > KV;
};
実装はUSaveGame派生クラスを内部で生成し、UGameplayStatics::AsyncSaveGameToSlotに橋渡しします。
// Private/SaveSystem/GameSaveSubsystem.cpp
#include "SaveSystem/GameSaveSubsystem.h"
#include "GameFramework/SaveGame.h"
#include "Kismet/GameplayStatics.h"
#include "Serialization/JsonSerializer.h"
UCLASS ()
class UCommonSaveContainer : public USaveGame
{
GENERATED_BODY ()
public:
UPROPERTY () TMap < FString, FString > KV;
};
void UGameSaveSubsystem :: Initialize ( FSubsystemCollectionBase & Collection )
{
Super :: Initialize (Collection);
UE_LOG (LogTemp, Log, TEXT ( "[GameSaveSubsystem] Initialized" ));
}
void UGameSaveSubsystem :: SaveSlotAsync ( const FString & SlotName , const FOnSaveCompleted & OnDone )
{
UCommonSaveContainer * Container = Cast < UCommonSaveContainer >(
UGameplayStatics :: CreateSaveGameObject ( UCommonSaveContainer :: StaticClass ()));
if ( ! Container) { OnDone. ExecuteIfBound ( false ); return ; }
Container->KV = KV;
UGameplayStatics :: AsyncSaveGameToSlot (Container, SlotName, 0 ,
FAsyncSaveGameToSlotDelegate :: CreateLambda (
[ OnDone ]( const FString & , const int32 , bool bOk ) { OnDone. ExecuteIfBound (bOk); }));
}
bool UGameSaveSubsystem :: LoadSlot ( const FString & SlotName )
{
if ( auto* Container = Cast < UCommonSaveContainer >(
UGameplayStatics :: LoadGameFromSlot (SlotName, 0 )))
{
KV = Container->KV;
return true ;
}
return false ;
}
void UGameSaveSubsystem :: Set ( const FString & K , const FString & V ) { KV. Add (K, V); }
FString UGameSaveSubsystem :: Get ( const FString & K , const FString & Def ) const
{
const FString * Found = KV. Find (K);
return Found ? * Found : Def;
}
エラーハンドリングとして、AsyncSaveGameToSlotはディスク容量不足や権限エラーでも例外を投げずbOk = falseを返してきます。ゲーム側でこのbOkを握りつぶすと「セーブできているように見えて実は失敗」という最悪のケースに陥るため、OnDoneコールバックでUI通知を出す前提で組むことを必ずチームに共有してください。
システム3:実績 — OnlineSubsystemへの薄いラッパー
実績はSteam・Epic・PSN・Xboxで微妙にAPIが違うため、プラグイン側でUAchievementSubsystemという薄いラッパーを置き、ストア固有の実装はPrivate/に隠します。これにより、ゲームコードはAchievementSubsystem->Unlock("FirstClear")の一行で済むようになります。
// Public/Achievements/AchievementSubsystem.h
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "AchievementSubsystem.generated.h"
UCLASS ()
class COMMONGAMESYSTEMSRUNTIME_API UAchievementSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY ()
public:
UFUNCTION (BlueprintCallable, Category = "Achievements" )
void Unlock ( FName AchievementId );
UFUNCTION ( BlueprintCallable , Category = "Achievements" )
void ReportProgress ( FName AchievementId , float Percent );
private:
void FlushPending ();
TMap < FName, float> Pending;
};
Private/Achievements/AchievementSubsystem.cppではIOnlineAchievementsPtrを取得し、未取得時はローカルのキューに溜めてFlushPendingで再送する設計にします。OnlineSubsystemはOSS::Get()がnullptrを返す場面(オフライン起動・ストア未認証)が普通にあるため、ここでif (!OSS) return;と握りつぶすと「実績が永遠に解除されない」という不具合になります。Antigravityにこの部分を書かせると見落とされやすいので、レビュー時の重点ポイントです。
Antigravityにプラグイン構造を伝えるAGENTS.md
ここまで設計したプラグインを、Antigravityのエージェントに正しく扱ってもらうためのAGENTS.mdをプラグイン直下に置きます。プロジェクトルートではなくプラグイン直下に置くのがコツで、エージェントがそのプラグイン専用のコンテキストとして読み込んでくれます。
# CommonGameSystems プラグイン — エージェント向け指示
## モジュール境界
- 公開API: `Plugins/CommonGameSystems/Source/CommonGameSystemsRuntime/Public/`
- 内部実装: `Plugins/CommonGameSystems/Source/CommonGameSystemsRuntime/Private/`
- エディタ専用: `Plugins/CommonGameSystems/Source/CommonGameSystemsEditor/`
## コード生成時のルール
1. 公開する型・関数には必ず `COMMONGAMESYSTEMSRUNTIME_API` を付与すること
2. `Public/` のヘッダから `Private/` のヘッダを `#include` しないこと
3. 新しい依存モジュールを `Build.cs` に追加するときは、必ず理由をコメントで残すこと
4. `OnlineSubsystem` 関連のコードは null チェックを必須とし、未取得時はローカルキューに溜めること
## 命名規約
- サブシステム: `UXxxSubsystem` (GameInstanceSubsystem派生)
- アクターコンポーネント: `UXxxComponent`
- ブループリント呼出可能関数: `BlueprintCallable` または `BlueprintPure`
## 禁止事項
- `Plugins/CommonGameSystems/` 配下に `#include "<GameName>/..."` を書くこと(循環依存を生む)
- `Editor` モジュールのヘッダを `Runtime` モジュールから参照すること
このドキュメントを置いておくと、私の体感では「COMMONGAMESYSTEMSRUNTIME_APIが抜けている」「EditorモジュールにSlateを入れ忘れている」といった初歩的なミスが目に見えて減ります。
よくある間違い・落とし穴
実際に私が踏んだもの、AIが提案したコードでビルドが壊れたパターンを共有します。
第一に、Build.csでPublicDependencyModuleNamesとPrivateDependencyModuleNamesを取り違えるケースです。Public/のヘッダで#includeしている型は必ずPublicDependencyModuleNamesに入れる必要があります。Antigravityはここを「使うのは内部実装だけだからPrivateでいい」と判断しがちで、結果的にプラグインを使う側のモジュールから当該ヘッダが見えなくなります。
第二に、upluginのLoadingPhaseをPreDefaultにしてしまう問題です。前述の通り、UAssetManagerやUGameInstanceが初期化されていないタイミングでサブシステムが起動するとクラッシュします。明示的な理由がなければDefaultに固定してください。
第三に、EditorモジュールのbUseUnity = falseを忘れる問題です。EditorモジュールでUnity Buildを有効のままにすると、UnrealEdへの遷移的な依存がRuntime側のヘッダにまで滲み出してくることがあり、パッケージビルド時に「Slateが見つからない」というエラーになります。プラグイン全体のBuild.csでbUseUnity = falseを最初に設定し、安定後に切り替える運用が安全です。
複数タイトルへの展開:バージョン管理と配布
プラグインを2本3本と展開する段階になると、バージョン管理の設計が重要になります。私のおすすめは、プラグインを単独のGitリポジトリで管理し、各ゲームプロジェクトではgit submoduleまたはPlugins/配下にCIでcheckoutする方式です。
upluginのVersionNameをMAJOR.MINOR.PATCHで管理し、ゲーム側の<GameName>.uprojectで次のように依存バージョンを記述します。
"Plugins" : [
{
"Name" : "CommonGameSystems" ,
"Enabled" : true ,
"MarketplaceURL" : "git+https://github.com/yourstudio/CommonGameSystems.git@1.0.0"
}
]
UE5自体はMarketplaceURLを厳密にチェックしないため、これはチーム内でのバージョン明示の意味合いが強いのですが、Antigravityにこのuprojectを読ませると「このゲームはCommonGameSystems 1.0.0を使っている」という文脈を持って提案してくれるようになります。
ABI互換性の観点では、Public/のヘッダで定義したUSTRUCTにメンバを足す際は、新規メンバを末尾に追加し、Bluerpintで使うフラグはUPROPERTY(BlueprintReadOnly)で公開するのが基本です。途中に挿入したりenum値の数値を変えると、既存のセーブデータが壊れるため絶対に避けてください。AGENTS.mdにこのルールを書いておくと、AIが既存型を勝手に書き換えるのを抑止できます。
CIでのビルド確認とAntigravityの活用
複数タイトルでプラグインを使い回すなら、プラグインのリポジトリ単独でビルド検証できるCIを組むのが本番運用の必須要件です。GitHub ActionsでUnrealAutomationToolを呼び出す簡易な例を示します。
# .github/workflows/plugin-build.yml
name : Plugin Build
on : [ push , pull_request ]
jobs :
build :
runs-on : windows-2022
steps :
- uses : actions/checkout@v4
- name : Setup UE5
run : |
# 自社のUE5バイナリパスをマウントする想定
$env:UE5_ROOT = "C:\UE_5.4"
- name : Build Plugin
shell : pwsh
run : |
& "$env:UE5_ROOT\Engine\Build\BatchFiles\RunUAT.bat" `
BuildPlugin `
-Plugin="$pwd\CommonGameSystems.uplugin" `
-Package="$pwd\Build\CommonGameSystems-1.0.0" `
-TargetPlatforms=Win64
このCIをAntigravityに監視させ、ビルドエラーが出たら自動でissueを開くフローにすると、本業のゲーム制作に集中しながらプラグインの健全性を保てます。私はAntigravityのBrowser Sub-Agentにhttps://github.com/yourstudio/CommonGameSystems/actionsを巡回させ、失敗ログを要約してSlackに投げるワークフローを組んでいます。
UE5のオートメーションフレームワークでプラグインをユニットテストする
複数タイトルで使い回すプラグインにはテストが必須です。UE5のオートメーションフレームワークは公式の機構として用意されており、AntigravityにAGENTS.mdのルールを参照させながらテストケースを生成させると効率良く整備できます。
// Source/CommonGameSystemsTests/CommonGameSystemsTests.Build.cs
using UnrealBuildTool ;
public class CommonGameSystemsTests : ModuleRules
{
public CommonGameSystemsTests ( ReadOnlyTargetRules Target ) : base (Target)
{
bUseUnity = false ;
PublicDependencyModuleNames. AddRange ( new [] {
"Core" , "CoreUObject" , "Engine" ,
"CommonGameSystemsRuntime" , // <- テスト対象モジュール
});
PrivateDependencyModuleNames. AddRange ( new [] { "AutomationController" });
}
}
インベントリのスタッキングを検証するシンプルなテストはこうなります。
// Private/InventoryStackingTest.cpp
#include "Misc/AutomationTest.h"
#include "Inventory/InventoryComponent.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST (
FInventoryStackingTest,
"CommonGameSystems.Inventory.Stacking" ,
EAutomationTestFlags ::ApplicationContextMask | EAutomationTestFlags ::SmokeFilter)
bool FInventoryStackingTest :: RunTest ( const FString & Parameters )
{
UInventoryComponent * Inv = NewObject < UInventoryComponent >();
FGameplayTag Tag = FGameplayTag :: RequestGameplayTag ( FName ( "Item.Health" ));
// 250個のヘルスポーションを追加 — 99 + 99 + 52 の3スロットに分割される想定
TestTrue ( TEXT ( "Add 250" ), Inv-> AddItem (Tag, 250 ));
TestEqual ( TEXT ( "Total" ), Inv-> GetQuantity (Tag), 250 );
// 100個削除 — 残り150個
TestTrue ( TEXT ( "Remove 100" ), Inv-> RemoveItem (Tag, 100 ));
TestEqual ( TEXT ( "After remove" ), Inv-> GetQuantity (Tag), 150 );
return true ;
}
エディタ上でWindow → Test Automationを開き、CommonGameSystemsフィルタで実行するとプロセス内でテストが走ります。Antigravityにこのテストを見せて「失敗ケースも追加して」と頼むと、空インベントリからの削除やQuantity = 0の境界値など、4〜6個のエッジケースを生成してくれます。差分レビューで取捨選択するだけで、ほぼ手放しでテスト網羅性が上がります。
注意点として、このテストモジュールはCommonGameSystemsRuntimeにリンクするため、ランタイム側のAPI変更で即座にテストビルドが落ちます。これは欠点ではなく利点で、ABIレベルのミスを出荷前に検出できる仕掛けです。
反復速度を上げる:Live CodingとHot Reload
UE5のLive Codingは、エディタを起動したままC++をパッチできる機能です。プラグイン開発との相性がよく、典型的なインベントリ調整サイクルが「90秒のコンパイル+リスタート」から「Ctrl+Alt+F11で5秒」に短縮されます。
設定で押さえるべき点は2つです。
Editor Preferences → General → Live Codingで「Enable Live Coding」をオンにし、「Restart」を「When required」に設定します。「Always」だとヘッダ変更で毎回再起動が要求されるため、せっかくのインクリメンタルパッチが活かせません。
プラグインのBuild.csで開発中はbUseUnity = falseを維持します。Live Codingは.cppファイル単位でパッチを当てるため、ユニティビルドだと「何を再コンパイルしたのか追えない」状態になりがちです。
落とし穴として、Live CodingはUPROPERTYフラグやUSTRUCTメンバの並びの変更を反映しません。シリアライズに影響する変更を加えた場合は必ずエディタを再起動してください。さもないと保存済みデータと実行時データが食い違い、原因不明のクラッシュを生みます。Antigravityがたまに「整理」と称してUPROPERTYの並びを入れ替える提案を出してきますが、反復中はこれを拒否し、VersionNameを上げるタイミングまで温存するのが安全です。
パフォーマンス:1万スロットのインベントリに耐える
ここまでの実装は典型的なRPG向け(50〜500スロット)には十分ですが、サバイバル・ファクトリー系で数千〜1万スロット規模になると、AddItemの線形走査がボトルネックになります。実用的な対処は、TMap<FGameplayTag, TArray<int32>>という補助インデックスを保持し、各タグの所在スロット番号をキャッシュする方式です。
Antigravityにこのリファクタリングを依頼するときは、ファイル先頭にこのようなコメントを置いておくと精度が上がります。
// PERF: このコンポーネントはサバイバルゲームで最大1万スロットまで使われる想定。
// AddItem/RemoveItem/GetQuantityは特定タグに対し平均O(1)で動くこと。
// 補助インデックスを維持し、Items[]を真実の源とする。
このコメント付きでリファクタリングを依頼すると、私の試した範囲では補助インデックスを正しく更新する実装が返ってきました。ただしLoadSlot後にインデックスを再構築する処理は忘れがちなので、レビュー時の重点ポイントです。
配布の選択肢:Fab公開と社内利用の違い
UE Marketplaceの後継であるFabにプラグインを公開する場合、内部利用とは別の制約が発生します。
第一に、リストアップした全エンジンバージョンでクリーンビルドする必要があります。複数バージョン対応はCIマトリクスを意味し、#if ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 4のような条件付きコードが増えます。Antigravityはこの種の互換コードを書くのが得意なので、ここで真価を発揮します。
第二に、サードパーティ依存はライセンス確認が必須です。今回の例のJson・OnlineSubsystemはファーストパーティなので問題ありませんが、nlohmann/jsonなどを足すと話が変わります。
第三に、ドキュメントが必須でありレビュアーは実際に読みます。AntigravityにAGENTS.mdからREADME.mdの下書きを生成させ、それに自分の文体を被せるのが現実的です。AI生成のままだと文体で読み取られ、レビューで指摘される可能性があります。
私自身は、内部利用のプラグインまでFabに公開することはしていません。ただし、後から公開を検討する可能性があるなら、最初からFabのガイドラインを意識してPublic/の境界を整えておくと、後の手戻りが小さくなります。
クロスプラットフォーム開発:Windows・Mac・Linuxの一元管理
UE5プラグインは、コンソール対応や分散チームの事情で、Windows・Mac・Linuxのいずれでもビルドが通る必要が出てくる場合があります。今回構築したプラグインはこれら3OSで素直にコンパイルできますが、後々の苦労を減らすために最初から押さえておきたい習慣があります。
最大の落とし穴はパス文字列の扱いです。UE5にはFPaths::ConvertRelativePathToFullやFPaths::CombineというOS非依存のAPIがあり、これらはセパレータを正しく解決してくれます。一方で、FStringを"\\"で生のまま連結するコードはWindowsでしか動きません。Antigravityはエディタ系のアセットユーティリティを書くときに、訓練データの偏りでWindows風のパスを書くことが時々あります。AGENTS.mdに「ファイルシステムのパスは必ずFPaths::Combineを使うこと。生のセパレータ文字を直書きしないこと」と一行入れておくと、この種の提案を未然に防げます。
CIは対応プラットフォームごとにplugin-build.ymlを行列展開すると現実的です。
jobs :
build :
strategy :
matrix :
os : [ windows-2022 , macos-14 ]
engine_version : [ "5.4" , "5.5" ]
runs-on : ${{ matrix.os }}
steps :
- uses : actions/checkout@v4
- name : Build Plugin
shell : bash
run : |
if [ "${{ matrix.os }}" = "windows-2022" ]; then
UAT="$UE5_ROOT/Engine/Build/BatchFiles/RunUAT.bat"
else
UAT="$UE5_ROOT/Engine/Build/BatchFiles/RunUAT.sh"
fi
"$UAT" BuildPlugin -Plugin="$PWD/CommonGameSystems.uplugin" \
-Package="$PWD/Build/${{ matrix.os }}-${{ matrix.engine_version }}" \
-TargetPlatforms=Win64+Mac
このマトリクスは4つのジョブに展開され、リリースタグを打つ前に4つの組み合わせ全てがビルドできるという確証が得られます。私の体感では、プラグイン更新の5回に1回くらいはこのいずれかが失敗します。原因の多くは「Windowsでは通るがMacのClangで弾かれるヘッダ」です。
Linuxは公式のunreal-engine-linuxコンテナイメージがGitHub Actionsのホストランナーではまだ使えないため、セルフホストランナーにLinux SDKを入れて運用するのが一般的です。ここだけ運用コストが現実的に発生しますが、多くのスタジオではWin64 + Macの組み合わせから始めれば十分です。
運用衛生:ログチャネルとクラッシュ診断
本番運用に向けたプラグインに最初から組み込むべき要素として、専用のログカテゴリがあります。DECLARE_LOG_CATEGORY_EXTERN(LogCommonGameSystems, Log, All);をランタイムモジュールに追加し、プラグイン内のUE_LOGを全てこのカテゴリに通すと、ユーザーから不具合報告を受けたときに自プラグイン分のログだけ抽出できます。LogTempの海をgrepする作業から解放されるのは大きい違いです。
CommonGameSystemsRuntime.hに置く宣言と、CommonGameSystemsRuntime.cppの実装は次の通りです。
// Public/CommonGameSystemsRuntime.h
#pragma once
#include "CoreMinimal.h"
DECLARE_LOG_CATEGORY_EXTERN (LogCommonGameSystems, Log, All);
// Private/CommonGameSystemsRuntime.cpp
#include "CommonGameSystemsRuntime.h"
DEFINE_LOG_CATEGORY (LogCommonGameSystems);
class FCommonGameSystemsRuntimeModule : public IModuleInterface
{
public:
virtual void StartupModule () override
{
UE_LOG (LogCommonGameSystems, Log, TEXT ( "CommonGameSystems starting up" ));
}
virtual void ShutdownModule () override
{
UE_LOG (LogCommonGameSystems, Log, TEXT ( "CommonGameSystems shutting down" ));
}
};
IMPLEMENT_MODULE (FCommonGameSystemsRuntimeModule, CommonGameSystemsRuntime)
既存コードのUE_LOG(LogTemp, ...)をLogCommonGameSystemsに置換する作業は、Antigravityにプラグインフォルダを渡して「このモジュール内のLogTemp参照を全てLogCommonGameSystemsに置き換えて」と頼めば、ラムダやマクロの中まで含めて一括置換してくれます。この種の機械的なリファクタリングはAIの得意領域で、人間が手作業でやると見落としが必ず発生する箇所です。
PC向けにシップする場合は、UE5のFGenericCrashContextにプラグイン情報を載せる仕組みも組んでおくと、ユーザーから届くクラッシュレポートが格段に診断しやすくなります。現在のバージョン・最終セーブスロット・最終実績進捗といったプラグイン固有のメタデータをダンプに含めるだけで、再現条件の特定が楽になります。
プラグイン化「しない」判断基準
複数タイトルを出すならプラグイン化が正解になる場面が多いのですが、逆にインラインのまま残すべきパターンもあります。
特定ゲームの経済バランスや進行曲線に深く結びついている処理——例えばクエストシステムの状態機械が物語上の固有要素を参照している場合——を無理に抽出すると、不自然に汎用的な抽象を作る羽目になります。Build.cs・Public/Private分離・バージョン管理といったプラグイン化のコストが、重複削減のメリットを上回ってしまいます。Antigravityは早すぎる抽出を提案してくることがありますが、抽象が苦しいと感じたら抵抗してください。
インラインのままにすべきもう一つの兆候は、その機能がそのゲーム1本でしか使わないものであることが明らかな場合です。マップ移動のロジック、クレジットロール、タイトル画面のアトラクトループなどがこれにあたります。再利用の物語が無いので、プラグイン化のスキャフォールディングは純粋にオーバーヘッドにしかなりません。私が使っている判断基準は「明日新規プロジェクトを作ったら、このフォルダをそのまま持っていきたいか」です。「はい、パラメータを1〜2個調整するだけで」なら抽出する価値がありますが、「はい、ただしゲーム性に合わせて半分書き直す」なら抽出しない方が良いという判断になります。
最後に、チームが1〜2人で、明確な1つのリリース目標がある場合は先回りしてプラグイン化しないでください。コストは「AIがプラグイン境界をまたぐ変更を提案するたびに、2つのフォルダを行ったり来たりする」という形で表れ、小規模チームでは時間を実際に削っていきます。プラグイン抽出のROIが最も高いのは、(a)少なくとも1本シップ済み、(b)2本目のプロジェクトが確定している、(c)半日を移行作業に当てられる、という3条件が揃ったときです。それ以外なら、まずゲームをリリースしてからで十分です。
結びに
プラグイン化の初期投資は確かに重く、ゲームを1本だけ作るならベタ書きの方が速いと感じる場面もあります。ですが2本目を作り始めた瞬間に、再利用できる土台があることの価値は跳ね上がります。Antigravityはこの「再利用可能な土台」を作る作業をかなり助けてくれるツールで、特にBuild.csやAGENTS.mdのような設計の制約を明文化するファイルとの相性が抜群です。
まずは今作っているゲームから「インベントリ」だけでもプラグインに切り出してみてください。Plugins/に1つフォルダを作り、ヘッダ1つ、Build.cs1つから始めれば、1〜2時間で動く状態まで持っていけます。そこからAIにレビューさせながら洗練していくのが、現実的な第一歩だと感じています。
本記事のBuild.csまわりの理解を補強する内容です。
Antigravityの本番運用に必要なコンテキスト設計や、エージェントの分業設計についてさらに踏み込みたい場合は、関連記事Antigravity コンテキスト設計完全ガイド — AGENTS.md と Knowledge Items で AI に「伝わる」プロジェクトを作る とAntigravityのためのハーネスエンジニアリング — エージェントを「作業者」に育てる指示設計の実践 も合わせてご覧ください。