UnityのSteam実績を実運用向けに整理する: Manager化、Callback、保存タイミング
第1回〜第3回で、Steam実績を扱う3つの基本パターン(直接解除、リセット、累計)を一通り作りました。
ここまでのコードは、流れを理解するためのシンプルな実装でした。実際のゲームに複数の実績やStatsを組み込んでいくと、次のような困りごとが出てきます。
- 実績解除コードが複数のスクリプトに散らばる
- Steam起動前やStats受信前に処理を呼んでしまって動かない
- 1フレームに何度も
StoreStats()を呼んでしまっている - Steam未起動でテストプレイするとログが警告だらけになる
- リセット処理が本番ビルドに混ざるのが怖い
第4回では、これらを整理した SteamAchievementManager を作ります。複数の実績とStatsを1か所で扱い、Stats受信完了を待ち、StoreStats() をまとめて呼ぶ、という構成に発展させます。
この記事で作るもの
第4回で追加・置き換えるスクリプトは次の2つです。
| スクリプト | 役割 |
|---|---|
SteamBootstrap |
Steam初期化後にStats受信を待ち、IsStatsReady を公開する |
SteamAchievementManager |
実績解除・累計値更新・リセットを一元化し、StoreStats() をまとめて呼ぶ |
第1回の SteamAchievement と第3回の SteamStats は、Manager経由の呼び出しに置き換えていきます。
最終的に、ゲーム本編からは次のように呼べるようになります。
SteamAchievementManager.Instance.Unlock("ACH_CLEAR");
SteamAchievementManager.Instance.AddStat("stat_total_enemies_defeated", 1);
最小実装から実運用実装へ
第1回〜第3回の実装には、実運用で困る共通の弱点があります。
1. Stats受信の完了を待っていない
SteamAPI.Init() を呼んで初期化が成功しても、Steam側からの累計値の受信はその直後には終わっていません。
Steamは初期化のあとに UserStatsReceived_t というイベントを返してきます。このイベントが届くまでは、GetStat で取得した値が古かったり、SetStat した結果がきちんと反映されなかったりすることがあります。
第3回までのコードは、このイベントを待たずに GetStat / SetStat を呼んでしまっています。
2. StoreStats() を呼びすぎている
第3回までのコードは、SetAchievement や SetStat を呼ぶたびに必ず StoreStats() を呼んでいます。
1プレイで複数の実績を解除したり、複数のStatsを更新したりすると、その回数だけSteamサーバへの通信が走ります。同じフレーム内に4つのStatsを更新すると、StoreStats() が4回呼ばれることになります。
実際には、同じフレーム内なら最後に1回呼べば十分です。
3. ID文字列が各所に散らばる
ACH_CLEAR や stat_total_enemies_defeated のような文字列が、Unlock呼び出し側、Reset呼び出し側、ログ出力など複数の場所に出てきます。
実績が増えていくと、どこかでタイプミスが入る可能性も上がっていきます。
これらを SteamAchievementManager という1つのクラスにまとめることで、Stats受信を待ち、StoreStats() を1回にまとめ、Steam操作の窓口を集約できます。
SteamBootstrapでStats受信を待つ
まず、Steamの初期化後に「Stats受信が完了したか」を持っておくためのスクリプトを作ります。
-
Assets/Scripts/Steamフォルダを右クリックする -
作成 > Scripting > Empty C# Scriptを選ぶ - ファイル名を
SteamBootstrapにする
SteamBootstrap.cs を開き、次のコードに置き換えます。
using UnityEngine;
using Steamworks;
[DisallowMultipleComponent]
public class SteamBootstrap : MonoBehaviour
{
public static SteamBootstrap Instance { get; private set; }
Callback<UserStatsReceived_t> _statsReceivedCallback;
bool _statsReady;
public bool IsStatsReady => _statsReady;
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
void Start()
{
if (!SteamManager.Initialized)
{
Debug.LogWarning("Steamを使う準備ができていないため、Stats受信を開始できません");
return;
}
_statsReceivedCallback = Callback<UserStatsReceived_t>.Create(OnUserStatsReceived);
bool requestOk = SteamUserStats.RequestCurrentStats();
if (!requestOk)
{
Debug.LogWarning("Steam Statsの受信要求に失敗しました");
}
}
void OnUserStatsReceived(UserStatsReceived_t data)
{
if (data.m_nGameID != (ulong)SteamUtils.GetAppID().m_AppId)
{
return;
}
_statsReady = true;
Debug.Log("Steam Stats受信完了");
}
}
このスクリプトでやっていることは次の4つです。
-
Callback<UserStatsReceived_t>.Createで、Steamからの「Stats受信完了」イベントを受け取る準備をする -
SteamUserStats.RequestCurrentStats()でStatsの送信をSteamに依頼する - イベントが来たら
_statsReadyをtrueにする - 外から
IsStatsReadyで受信完了かどうかを確認できるようにする
Callbackはフィールドで保持する
ここで重要なのは、_statsReceivedCallback を クラスのフィールド として持っていることです。
Callback<UserStatsReceived_t>.Create(...) の戻り値をローカル変数で受けてしまうと、メソッドを抜けた瞬間にC#のガベージコレクションで破棄されることがあります。破棄されてしまうと、Steamからイベントが来ても何も起きません。
「Stats受信完了のログが出ない」という不具合の多くは、Callbackをフィールドではなくローカル変数で受けてしまっているのが原因です。
RequestCurrentStats の役割
SteamUserStats.RequestCurrentStats() は、現在のStats値をSteam側から送ってもらうための依頼です。
これを呼ぶと、しばらくしてからSteamが UserStatsReceived_t イベントで実際の値を返してきます。Callback<UserStatsReceived_t>.Create(...) で登録したメソッドがそのタイミングで呼ばれます。
SteamAchievementManagerを作る
次に、実績解除・累計値更新・リセットを一元化するスクリプトを作ります。
-
Assets/Scripts/Steamフォルダを右クリックする -
作成 > Scripting > Empty C# Scriptを選ぶ - ファイル名を
SteamAchievementManagerにする
SteamAchievementManager.cs を開き、次のコードに置き換えます。
using UnityEngine;
using Steamworks;
[DisallowMultipleComponent]
public class SteamAchievementManager : MonoBehaviour
{
public static SteamAchievementManager Instance { get; private set; }
bool _dirty;
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
public void Unlock(string achievementId)
{
if (!IsReady()) return;
if (!SteamUserStats.SetAchievement(achievementId))
{
Debug.LogWarning($"Steam実績を解除できませんでした: {achievementId}");
return;
}
_dirty = true;
Debug.Log($"Steam実績を解除しました: {achievementId}");
}
public void AddStat(string statId, int amount)
{
if (!IsReady()) return;
if (!SteamUserStats.GetStat(statId, out int current))
{
Debug.LogWarning($"Steam Statの現在値を取得できませんでした: {statId}");
return;
}
int next = current + amount;
if (!SteamUserStats.SetStat(statId, next))
{
Debug.LogWarning($"Steam Statを更新できませんでした: {statId} = {next}");
return;
}
_dirty = true;
Debug.Log($"Steam Statを更新しました: {statId} = {next}");
}
[ContextMenu("Reset All Stats and Achievements (Debug)")]
public void ResetAllForDebug()
{
if (!IsReady()) return;
if (!SteamUserStats.ResetAllStats(true))
{
Debug.LogWarning("Steam StatsとAchievementのリセットに失敗しました");
return;
}
_dirty = true;
Debug.Log("Steam StatsとAchievementをリセットしました(デバッグ用)");
}
void LateUpdate()
{
if (!_dirty) return;
if (SteamUserStats.StoreStats())
{
_dirty = false;
}
}
bool IsReady()
{
if (!SteamManager.Initialized)
{
return false;
}
if (SteamBootstrap.Instance == null || !SteamBootstrap.Instance.IsStatsReady)
{
return false;
}
return true;
}
}
このManagerが第1回〜第3回のコードに対して追加しているのは、主に次の3つです。
1. IsReady() でまとめてチェック
Steam未起動、Stats未受信のどちらでも、Unlock / AddStat / ResetAllForDebug は何もせず終わります(no-op)。
ゲームをSteam外で起動した場合や、Stats受信が終わる前に呼ばれた場合に、警告ログだけ出して落ちないようにできます。
2. _dirty フラグで StoreStats() をまとめる
Unlock や AddStat は、SetAchievement / SetStat を呼んだあと、すぐには StoreStats() しません。代わりに _dirty = true で「保存待ち」の印だけ付けます。
実際の StoreStats() は LateUpdate で1フレームに最大1回だけ呼びます。
これにより、同じフレーム内で「実績を1つ解除し、Statsを2つ更新する」ような場合でも、Steamへの保存通信は1回だけになります。
3. デバッグ用リセットを [ContextMenu] で
第2回で作った Reset All 機能をManagerに統合し、[ContextMenu("Reset All Stats and Achievements (Debug)")] を付けています。
これでInspectorのコンポーネントメニューから直接呼べるので、テスト用のUIボタンを別途用意しなくても済みます。
シーンに配置する
第1回で作った SteamTestScene を開き、SteamManager ゲームオブジェクトに SteamBootstrap と SteamAchievementManager を追加します。
-
SteamTestSceneを開く -
ヒエラルキーでSteamManagerゲームオブジェクトを選択する -
インスペクターのコンポーネントを追加を押す -
SteamBootstrapを検索して追加する - もう一度
コンポーネントを追加を押す -
SteamAchievementManagerを検索して追加する - シーンを保存する
SteamBootstrap と SteamAchievementManager はどちらも Awake で DontDestroyOnLoad を呼ぶので、シーンを切り替えても残ります。
動作確認: Stats受信完了を見る
まずはManager経由で何か呼ぶ前に、Stats受信が完了することを確認します。
- Steamクライアントを起動してログインしておく
- Unityエディター上部の再生ボタンを押す
-
コンソールウィンドウにSteam Stats受信完了が出ることを確認する
このログが出ない場合、次の点を順に確認します。
- 第1回の
Steam接続OKが出ているか(出ていなければSteam未起動かappid不一致) -
SteamBootstrapがシーンに付いているか -
_statsReceivedCallbackをフィールドではなくローカル変数で受けていないか
Steam Stats受信完了 のログが出れば、SteamAchievementManager.Instance.IsReady 相当の準備が整っています。
既存のコードをManager経由に差し替える
SteamAchievementManager が動くようになったら、第1回・第2回・第3回で書いたコードをManager経由に差し替えていきます。
第1回のテストボタンを差し替える
SteamAchievementTestButton.cs を開きます。中身は次のようになっているはずです。
public void UnlockClearAchievement()
{
SteamAchievement.Unlock("ACH_CLEAR");
}
これを、Manager経由に変更します。
public void UnlockClearAchievement()
{
SteamAchievementManager.Instance.Unlock("ACH_CLEAR");
}
保存してUnityエディターに戻り、コンソール に赤いエラーがないことを確認します。
Play Modeで Unlock ACH_CLEAR ボタンを押すと、Manager経由で解除されます。コンソール には Steam実績を解除しました: ACH_CLEAR が出ます。
第3回のテストボタンを差し替える
SteamStatsTestButton.cs も同じように差し替えます。
public void AddOneDefeat()
{
SteamAchievementManager.Instance.AddStat("stat_total_enemies_defeated", 1);
}
public void AddTenDefeats()
{
SteamAchievementManager.Instance.AddStat("stat_total_enemies_defeated", 10);
}
保存してUnityエディターに戻り、Play Modeで Add 10 Defeats ボタンを連打すると、Statsが累計100に達した時点で ACH_DEFEAT_100 がSteam側で解除されます。
第1回の SteamAchievement.cs と第3回の SteamStats.cs はどうするか
Manager経由に差し替えたあと、もう SteamAchievement.Unlock(...) や SteamStats.AddTotalEnemiesDefeated(...) を呼んでいる場所が無ければ、これらのスクリプトは削除して構いません。
すべての呼び出し箇所をManager経由に置き換えてから、SteamAchievement.cs と SteamStats.cs をプロジェクトから削除します。
第2回のリセットボタンを差し替える
第2回の SteamAchievementResetTestButton から SteamAchievementResetter を呼んでいる箇所も、Manager経由に統合できます。
SteamAchievementResetTestButton.cs の Reset All 用メソッドを次のように変更します。
public void ResetAllStatsAndAchievements()
{
SteamAchievementManager.Instance.ResetAllForDebug();
}
これで、第2回で作った SteamAchievementResetter.cs も削除して構いません。リセット処理はManager側の ResetAllForDebug に統合されました。
ContextMenuからリセットを呼ぶ
Manager側で [ContextMenu("Reset All Stats and Achievements (Debug)")] を付けているので、UIボタンを使わずにInspectorからもリセットを呼べます。
- Play Modeに入る
-
ヒエラルキーでSteamManagerゲームオブジェクトを選択する -
インスペクターのSteamAchievementManagerコンポーネント右上のメニューアイコンを押す -
Reset All Stats and Achievements (Debug)を選ぶ -
コンソールにSteam StatsとAchievementをリセットしました(デバッグ用)が出ることを確認する
本番ビルドから外す
SteamAchievementManager 自体は本番ビルドに含めて構いません。Unlock や AddStat はゲーム本編から呼ぶためのAPIです。
危ないのは、Inspectorの ContextMenu から呼べる ResetAllForDebug です。
ただし、ContextMenu はUnityエディター上のInspector機能なので、ビルド後の実行ファイルからは呼べません。プレイヤーがリセットを呼ぶ手段は存在しないので、そのままで安全です。
第2回で作ったテスト用ボタン(Clear ACH_CLEAR / Reset All)は SteamTestScene 上にあるので、Build Settings から SteamTestScene を外せば本番ビルドには含まれません。第2回と同じ対策のままで大丈夫です。
AppID 480と本番AppIDの違い
第4回まで進めると、AppID 480と本番AppIDで動きが違うポイントが整理できます。
| 項目 | AppID 480 | 本番AppID |
|---|---|---|
SteamManager.Initialized |
true になる |
true になる |
Steam Stats受信完了 |
出る | 出る |
自作実績の Unlock
|
失敗する | 解除できる |
自作Statsの GetStat / SetStat
|
失敗する | 動作する |
ResetAllForDebug |
Statsもなく実績もないので意味なし | 動作する |
AppID 480はSteamworks.NETが動いているか確認するためのテスト用です。Manager構成の動作確認の最終段階は、必ず本番AppIDで行います。
よくある失敗
Steam Stats受信完了 が出ない
SteamBootstrap の Callback<UserStatsReceived_t> がローカル変数で受けられている可能性が高いです。
// NG: メソッド終了でGCされる
void Start()
{
var callback = Callback<UserStatsReceived_t>.Create(OnUserStatsReceived);
}
// OK: クラスのフィールドで保持される
Callback<UserStatsReceived_t> _statsReceivedCallback;
void Start()
{
_statsReceivedCallback = Callback<UserStatsReceived_t>.Create(OnUserStatsReceived);
}
第4回のコードはフィールドで持つ形になっています。自分で書き直す場合は、ここを必ずフィールドにしてください。
SteamAchievementManager.Instance が null になる
SteamAchievementManager をシーンに付け忘れているか、まだ Awake が呼ばれる前に参照しています。
SteamTestScene の SteamManager ゲームオブジェクトに SteamAchievementManager コンポーネントが付いているか確認してください。
別のシーンから始めてテストする場合は、そのシーンにも SteamManager ゲームオブジェクト一式(SteamManager + SteamBootstrap + SteamAchievementManager)を置くか、起動シーンを経由してから本編シーンへ遷移する作りにします。
Unlock を呼んでも何も起きない
Stats受信前に Unlock を呼ぶと、IsReady() が false を返してno-opになります。コンソール にもログが出ません(警告も出ません)。
Steam Stats受信完了 のログが出る前に Unlock を呼んでいないか確認してください。タイトル画面の表示などで自然に時間が空けば、通常は受信が間に合います。
ゲーム開始直後にいきなり実績解除する設計にしている場合は、SteamBootstrap.Instance.IsStatsReady を直接見て、true になるのを待ってから解除するなどの対応が必要です。
StoreStats() がいつまでも呼ばれない
SteamAchievementManager の LateUpdate がそもそも呼ばれていない可能性があります。
SteamAchievementManager を付けたゲームオブジェクトが非アクティブになっていないか、Awake で Destroy(gameObject) されていないかを確認してください。
シーンに SteamAchievementManager を付けたゲームオブジェクトが複数あると、2つ目以降は Awake で破棄されます。意図せず2つ置かれていないか確認します。
累計値が古いまま読まれる
Stats受信が完了していないタイミングで GetStat を呼ぶと、ローカルの初期値が返ってきます。IsReady() で受信完了をチェックすればこの問題は起きません。
第4回のManager経由のコードは、IsReady() を通っているのでこの問題は出ません。第1回・第3回のクラスを直接呼ぶ古いコードが残っている場合、そちらを差し替え忘れていないか確認します。
第4回では扱わないこと
第4回のManager構成は、Steam実績まわりを実運用に持っていくときの基礎になります。さらに本格的に作り込む場合は、次のような話題があります。
- リーダーボード(ランキング)の扱い
- 実績達成の進捗バーをゲーム内UIで表示する
- プラットフォームごとのSteam以外のサービス対応(GOG、Epicなど)
- 複数のSteamアカウントが切り替わるケース
- セキュリティを意識したサーバ側での実績判定
これらは本シリーズの範囲を超えるため、必要になったタイミングで個別に調べていきます。
シリーズのまとめ
第1回〜第4回までで、Steam実績まわりを次の順で発展させてきました。
- 第1回:
SetAchievementとStoreStatsで1個の実績を解除する - 第2回:
ClearAchievementとResetAllStatsで開発中にやり直せるようにする - 第3回: Statsを使って累計型の実績を作る
- 第4回: 複数の実績とStatsを
SteamAchievementManagerで管理する
第4回まで進めれば、実際のゲームに複数の実績とStatsを組み込むための基本的な土台はできています。
新しい実績やStatsを追加するときは、Steamworksパートナーサイト側で登録してPublishし、Unity側からは SteamAchievementManager.Instance.Unlock(...) または SteamAchievementManager.Instance.AddStat(...) を呼ぶだけです。
最後に、Steam実績は「プレイヤーの達成記録」です。リセット系の処理を本番ビルドに混ぜないことだけは、最後まで気をつけてください。
Discussion