🦔

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回までのコードは、SetAchievementSetStat を呼ぶたびに必ず StoreStats() を呼んでいます。

1プレイで複数の実績を解除したり、複数のStatsを更新したりすると、その回数だけSteamサーバへの通信が走ります。同じフレーム内に4つのStatsを更新すると、StoreStats() が4回呼ばれることになります。

実際には、同じフレーム内なら最後に1回呼べば十分です。

3. ID文字列が各所に散らばる

ACH_CLEARstat_total_enemies_defeated のような文字列が、Unlock呼び出し側、Reset呼び出し側、ログ出力など複数の場所に出てきます。

実績が増えていくと、どこかでタイプミスが入る可能性も上がっていきます。

これらを SteamAchievementManager という1つのクラスにまとめることで、Stats受信を待ち、StoreStats() を1回にまとめ、Steam操作の窓口を集約できます。

SteamBootstrapでStats受信を待つ

まず、Steamの初期化後に「Stats受信が完了したか」を持っておくためのスクリプトを作ります。

  1. Assets/Scripts/Steam フォルダを右クリックする
  2. 作成 > Scripting > Empty C# Script を選ぶ
  3. ファイル名を 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つです。

  1. Callback<UserStatsReceived_t>.Create で、Steamからの「Stats受信完了」イベントを受け取る準備をする
  2. SteamUserStats.RequestCurrentStats() でStatsの送信をSteamに依頼する
  3. イベントが来たら _statsReadytrue にする
  4. 外から IsStatsReady で受信完了かどうかを確認できるようにする

Callbackはフィールドで保持する

ここで重要なのは、_statsReceivedCallbackクラスのフィールド として持っていることです。

Callback<UserStatsReceived_t>.Create(...) の戻り値をローカル変数で受けてしまうと、メソッドを抜けた瞬間にC#のガベージコレクションで破棄されることがあります。破棄されてしまうと、Steamからイベントが来ても何も起きません。

「Stats受信完了のログが出ない」という不具合の多くは、Callbackをフィールドではなくローカル変数で受けてしまっているのが原因です。

RequestCurrentStats の役割

SteamUserStats.RequestCurrentStats() は、現在のStats値をSteam側から送ってもらうための依頼です。

これを呼ぶと、しばらくしてからSteamが UserStatsReceived_t イベントで実際の値を返してきます。Callback<UserStatsReceived_t>.Create(...) で登録したメソッドがそのタイミングで呼ばれます。

SteamAchievementManagerを作る

次に、実績解除・累計値更新・リセットを一元化するスクリプトを作ります。

  1. Assets/Scripts/Steam フォルダを右クリックする
  2. 作成 > Scripting > Empty C# Script を選ぶ
  3. ファイル名を 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() をまとめる

UnlockAddStat は、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 ゲームオブジェクトに SteamBootstrapSteamAchievementManager を追加します。

  1. SteamTestScene を開く
  2. ヒエラルキーSteamManager ゲームオブジェクトを選択する
  3. インスペクターコンポーネントを追加 を押す
  4. SteamBootstrap を検索して追加する
  5. もう一度 コンポーネントを追加 を押す
  6. SteamAchievementManager を検索して追加する
  7. シーンを保存する

SteamBootstrapSteamAchievementManager はどちらも AwakeDontDestroyOnLoad を呼ぶので、シーンを切り替えても残ります。

動作確認: Stats受信完了を見る

まずはManager経由で何か呼ぶ前に、Stats受信が完了することを確認します。

  1. Steamクライアントを起動してログインしておく
  2. Unityエディター上部の再生ボタンを押す
  3. コンソール ウィンドウに 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.csSteamStats.cs をプロジェクトから削除します。

第2回のリセットボタンを差し替える

第2回の SteamAchievementResetTestButton から SteamAchievementResetter を呼んでいる箇所も、Manager経由に統合できます。

SteamAchievementResetTestButton.csReset All 用メソッドを次のように変更します。

public void ResetAllStatsAndAchievements()
{
    SteamAchievementManager.Instance.ResetAllForDebug();
}

これで、第2回で作った SteamAchievementResetter.cs も削除して構いません。リセット処理はManager側の ResetAllForDebug に統合されました。

ContextMenuからリセットを呼ぶ

Manager側で [ContextMenu("Reset All Stats and Achievements (Debug)")] を付けているので、UIボタンを使わずにInspectorからもリセットを呼べます。

  1. Play Modeに入る
  2. ヒエラルキーSteamManager ゲームオブジェクトを選択する
  3. インスペクターSteamAchievementManager コンポーネント右上のメニューアイコンを押す
  4. Reset All Stats and Achievements (Debug) を選ぶ
  5. コンソールSteam StatsとAchievementをリセットしました(デバッグ用) が出ることを確認する

本番ビルドから外す

SteamAchievementManager 自体は本番ビルドに含めて構いません。UnlockAddStat はゲーム本編から呼ぶための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受信完了 が出ない

SteamBootstrapCallback<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.Instancenull になる

SteamAchievementManager をシーンに付け忘れているか、まだ Awake が呼ばれる前に参照しています。

SteamTestSceneSteamManager ゲームオブジェクトに SteamAchievementManager コンポーネントが付いているか確認してください。

別のシーンから始めてテストする場合は、そのシーンにも SteamManager ゲームオブジェクト一式(SteamManager + SteamBootstrap + SteamAchievementManager)を置くか、起動シーンを経由してから本編シーンへ遷移する作りにします。

Unlock を呼んでも何も起きない

Stats受信前に Unlock を呼ぶと、IsReady()false を返してno-opになります。コンソール にもログが出ません(警告も出ません)。

Steam Stats受信完了 のログが出る前に Unlock を呼んでいないか確認してください。タイトル画面の表示などで自然に時間が空けば、通常は受信が間に合います。

ゲーム開始直後にいきなり実績解除する設計にしている場合は、SteamBootstrap.Instance.IsStatsReady を直接見て、true になるのを待ってから解除するなどの対応が必要です。

StoreStats() がいつまでも呼ばれない

SteamAchievementManagerLateUpdate がそもそも呼ばれていない可能性があります。

SteamAchievementManager を付けたゲームオブジェクトが非アクティブになっていないか、AwakeDestroy(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. 第1回: SetAchievementStoreStats で1個の実績を解除する
  2. 第2回: ClearAchievementResetAllStats で開発中にやり直せるようにする
  3. 第3回: Statsを使って累計型の実績を作る
  4. 第4回: 複数の実績とStatsを SteamAchievementManager で管理する

第4回まで進めれば、実際のゲームに複数の実績とStatsを組み込むための基本的な土台はできています。

新しい実績やStatsを追加するときは、Steamworksパートナーサイト側で登録してPublishし、Unity側からは SteamAchievementManager.Instance.Unlock(...) または SteamAchievementManager.Instance.AddStat(...) を呼ぶだけです。

最後に、Steam実績は「プレイヤーの達成記録」です。リセット系の処理を本番ビルドに混ぜないことだけは、最後まで気をつけてください。

Discussion