スポンサーリンク
UnityUnityメモ

Unityで実績(Achievement)システムを作る方法|条件管理・セーブ・UI表示まで完全解説

Unity
  1. はじめに
  2. 1. 実績(Achievement)システムの設計方針
    1. 実績システムを構成する基本要素
    2. よくある失敗パターン
    3. この記事で目指すゴール
  3. 2. 実績データの作り方(定義:ScriptableObject推奨)
    1. AchievementDataに持たせたい項目(例)
    2. 条件タイプをenumで作る(まずは少数でOK)
    3. ScriptableObjectで実績を定義する
    4. ScriptableObjectにするメリット
    5. 次にやること:進捗(カウント)と判定の仕組みを作る
  4. 3. 条件管理(カウント・判定・解除フロー)
    1. 実績の「状態」を表すクラスを用意する
    2. AchievementSystem(管理役)を作る
    3. 「何を」「どこで」カウントするか
    4. カウント更新 → 判定 → 解除 の流れを実装する
    5. 解除時にやること(最低限これだけ)
    6. 次にやること:保存とロード(PlayerPrefs)
  5. 4. 保存とロード(まずはPlayerPrefsで完成させる)
    1. 保存方針(キー設計)
    2. AchievementSystemにSave/Loadを追加する
    3. ロード(起動時に状態を復元)
    4. セーブ(解除時・進捗更新時に保存)
    5. 進捗も保存したい場合(AddProgressで保存)
    6. テスト時の初期化(DeleteAllの扱い注意)
    7. 次にやること:UI表示(一覧・セル・フィルタ)
  6. 5. UI表示(一覧・セル・フィルタ)
    1. UIのざっくり構成
    2. AchievementCell(1件分の表示)を作る
    3. AchievementsUI(一覧管理)を作る
    4. フィルタ(未解除のみ / 解除済みのみ)を付けたい場合
    5. 次にやること:解除通知の演出(ポップアップ)
  7. 6. 解除通知の演出(ポップアップ)
    1. UIを用意する(NotificationPanel)
    2. AchievementsNotification(通知管理)を作る
    3. よくあるUIバグ対策(連続解除)
    4. 次にやること:保存を強化したい場合(Easy Save)
  8. 7. 発展:保存を「壊れにくく」したいなら(Easy Save)
    1. Easy Saveで何が楽になる?
    2. 置き換えの考え方:PlayerPrefs層を“保存サービス”にする
    3. 実績状態をまとめて保存する例(イメージ)
    4. どのタイミングでEasy Saveを検討する?(目安)
    5. Easy Save(商品リンク)
  9. 8. 発展:クラウド同期(UGS / Google Play Games ざっくり導入方針)
    1. 選択肢1:Unity Gaming Services(UGS)でクラウド同期する
    2. 選択肢2:Google Play Games Services(Android向け)
    3. ローカル実績とプラットフォーム実績、どう棲み分ける?
  10. まとめ
    1. この記事の要点(サクッと振り返り)
    2. あわせて読みたい
    3. 参考書(1冊で体系的に学びたい人へ)
    4. 参考文献
  11. よくある質問(FAQ)
    1. 関連記事:

はじめに

ゲームを遊んでいて、「あ、今の行動で何か達成した感あるな」と思った瞬間ってありませんか?
その“達成感”を分かりやすく形にしてくれるのが、実績(Achievement)システムです。

実績は、プレイヤーの行動を肯定し、やり込みや継続プレイを自然に促してくれる、とても強力な仕組み。
でもいざ実装しようとすると、

  • 条件の管理がごちゃごちゃする
  • 保存とロードが不安
  • UI更新や通知演出が後回しになりがち

……こんな壁にぶつかる人も多いんですよね。

この記事では、Unityで実績(Achievement)システムを一から組み立てる方法を、
「条件管理 → 保存 → UI表示 → 解除通知」という流れで、順番に解説していきます。

まずはPlayerPrefsを使った最小構成で完成させ、
そこから「保存を強化したい」「クラウド対応したい」となったときの拡張ポイントまでカバーします。

実績システムは、最初から完璧を目指すより、
壊れにくく、後から育てられる設計がいちばん大事。

「小さな成功体験を、ちゃんとゲームに刻みたい」
そんな人に向けて、できるだけ分かりやすく、実践寄りで進めていきますね 😊




1. 実績(Achievement)システムの設計方針

まず最初に大事なのは、「どう作るか」より「どう壊れにくくするか」を考えることです。
実績システムは、ゲームが成長するほど条件や種類が増えやすく、後から修正しづらい要素でもあります。

そこで今回は、次の3点を軸に設計していきます。

  • 実績の定義とロジックをできるだけ分離する
  • 条件判定はシンプルに、拡張しやすくする
  • 保存・UI・演出を後から差し替えられる構造にする

実績システムを構成する基本要素

実績は一見シンプルですが、実際には複数の役割が組み合わさっています。

  • 実績データ:タイトル、説明、解除条件、必要数などの定義情報
  • 進捗データ:撃破数やクリア回数など、現在のカウント状態
  • 判定ロジック:条件を満たしたかどうかをチェックする処理
  • 保存処理:ゲーム終了後も状態を保持する仕組み
  • UI表示:一覧表示、ロック/解除状態の見せ方
  • 通知演出:解除時のポップアップや効果音

これらを1つのスクリプトに詰め込まないのが、長く使える設計のコツです。

よくある失敗パターン

実績システムでよく見かけるのが、こんな状態です。

  • 敵を倒した処理の中に実績判定がベタ書きされている
  • 実績IDが文字列で散らばっていて管理できない
  • UI更新とデータ更新が密結合している
  • 保存方式を後から変えられない

最初は動いていても、実績が増えた瞬間に一気に辛くなります…。

この記事で目指すゴール

本記事では、次のような状態をゴールにします。

  • 実績をデータとして定義できる
  • 条件カウントと判定を整理して管理できる
  • PlayerPrefsで確実に保存・復元できる
  • 一覧UIと解除通知が正しく連動する
  • 後から保存方式やUIを差し替えられる

次の章からは、まず実績データそのものをどう作るかを見ていきましょう。
ここが整理できると、後の実装が一気に楽になりますよ 🙂




2. 実績データの作り方(定義:ScriptableObject推奨)

実績システムで最初に整えておきたいのが、「実績そのものの定義」です。
ここがコードにベタ書きだと、実績を1個増やすたびに修正箇所が増えて、だんだん地獄を見ます…😇

そこでおすすめなのが、ScriptableObjectで実績をデータ化するやり方。
「実績一覧を編集したい」「説明文や必要数を調整したい」といった運用が、Inspectorだけでできるようになります。

AchievementDataに持たせたい項目(例)

まずは、実績1件分が持つべき情報を整理します。最小構成なら次のような項目があると便利です。

  • ID(ユニークな識別子:例 kill_10
  • タイトル(一覧表示用)
  • 説明(何を達成すればいいか)
  • アイコン(未解除/解除で差し替えるなら両方)
  • 条件タイプ(撃破数、クリア回数などをenumで)
  • 必要数(例:10回、100個など)

「解除済みかどうか」「現在の進捗」は、実績の“定義”ではなく“状態(進捗)”なので、
後の章で別管理(保存対象)にするのが分かりやすいです。

条件タイプをenumで作る(まずは少数でOK)

条件を文字列で管理するとミスが増えやすいので、enumにしておくのが安全です。

public enum AchievementConditionType
{
    EnemyKillCount,
    ClearCount,
    GameOverCount,
    // 必要になったら増やす
}

後から増えても大丈夫なように、最初は「自分のゲームで確実に使う条件だけ」入れればOKです。

ScriptableObjectで実績を定義する

プロジェクトウィンドウを右クリック「Create」→「C# Script」を選んで、
新しいスクリプトを作成し、「AchievementData」と名前を付けます。

using UnityEngine;

[CreateAssetMenu(menuName = "Achievements/Achievement Data")]
public class AchievementData : ScriptableObject
{
    [Header("Basic")]
    public string id;
    public string title;
    [TextArea] public string description;

    [Header("Visual")]
    public Sprite lockedIcon;
    public Sprite unlockedIcon;

    [Header("Condition")]
    public AchievementConditionType conditionType;
    public int targetValue = 1;
}

これで、Inspectorから「実績の一覧」を作れるようになりました。

ScriptableObjectにするメリット

  • 実績を追加してもコードを書かなくて済む(運用が楽)
  • タイトルや説明、必要数を調整しやすい(バランス調整向き)
  • UIの一覧表示が作りやすい(データを並べるだけ)

次にやること:進捗(カウント)と判定の仕組みを作る

ここまでで「実績の設計図(定義)」ができました。
次の章では、撃破数などのカウントをどう集めて、どう判定して、どう解除するかを作っていきます。

実績システムはここが心臓部。いよいよ動かしていきましょう 🙂




3. 条件管理(カウント・判定・解除フロー)

実績データ(AchievementData)を作れたら、次は「進捗(カウント)」「判定」です。
ここでのコツは、条件判定をゲーム中のあちこちに散らさず、1か所に集約すること。

今回はシンプルに、実績システム側で「カウント更新 → 判定 → 解除」を完結させます。
(将来的にEventで疎結合にしたい場合も、この形がベースになります)

実績の「状態」を表すクラスを用意する

AchievementDataは“定義”なので、解除済みかどうか・現在の進捗は別で持ちます。
まずは実績の状態を表すために、AchievementStateのようなクラスを用意します。

[System.Serializable]
public class AchievementState
{
    public string id;
    public int currentValue;
    public bool unlocked;

    public AchievementState(string id)
    {
        this.id = id;
        currentValue = 0;
        unlocked = false;
    }
}

「定義(ScriptableObject)」と「状態(進捗/解除)」を分けると、保存もしやすくなります。

AchievementSystem(管理役)を作る

次に、実績データの一覧を受け取り、状態を管理するクラスを作ります。
プロジェクトウィンドウを右クリック「Create」→「C# Script」で、AchievementSystemを作成してください。

using System;
using System.Collections.Generic;
using UnityEngine;

public class AchievementSystem : MonoBehaviour
{
    public static AchievementSystem Instance { get; private set; }

    [Header("Definitions")]
    [SerializeField] private List<AchievementData> achievementDefinitions;

    private Dictionary<string, AchievementState> states = new();
    private Dictionary<AchievementConditionType, int> stats = new();

    public event Action<AchievementData> OnUnlocked;

    private void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;
        DontDestroyOnLoad(gameObject);

        InitializeStates();
        InitializeStats();
    }

    private void InitializeStates()
    {
        states.Clear();
        foreach (var def in achievementDefinitions)
        {
            if (def == null || string.IsNullOrEmpty(def.id)) continue;
            states[def.id] = new AchievementState(def.id);
        }
    }

    private void InitializeStats()
    {
        stats.Clear();
        foreach (AchievementConditionType type in Enum.GetValues(typeof(AchievementConditionType)))
        {
            stats[type] = 0;
        }
    }

    public IReadOnlyList<AchievementData> GetDefinitions() => achievementDefinitions;

    public AchievementState GetState(string id)
    {
        states.TryGetValue(id, out var state);
        return state;
    }
}

ここでは、次の2つを用意しています。

  • states:各実績の進捗と解除状態(ID→State)
  • stats:撃破数など、ゲーム中の統計(ConditionType→数値)

「何を」「どこで」カウントするか

実績の条件は、ゲーム中のイベント(敵撃破、クリア、死亡など)で増えます。
重要なのは、増やす側は“実績のことを考えない”こと。

たとえば敵を倒したとき、ゲーム側はこう呼ぶだけにします。

AchievementSystem.Instance.AddProgress(AchievementConditionType.EnemyKillCount, 1);

「この撃破でどの実績が解除されるか?」はAchievementSystemが勝手にやる。
これが散らからない設計の基本です。

カウント更新 → 判定 → 解除 の流れを実装する

AchievementSystemに、進捗を加算して判定するメソッドを追加します。

public void AddProgress(AchievementConditionType type, int amount)
{
    if (!stats.ContainsKey(type)) stats[type] = 0;

    stats[type] += amount;

    // この条件タイプに紐づく実績だけチェックする
    foreach (var def in achievementDefinitions)
    {
        if (def == null) continue;
        if (def.conditionType != type) continue;

        if (!states.TryGetValue(def.id, out var state)) continue;
        if (state.unlocked) continue;

        // 今回は「統計 = 進捗」として扱う(シンプル)
        state.currentValue = stats[type];

        if (state.currentValue >= def.targetValue)
        {
            Unlock(def);
        }
    }
}

private void Unlock(AchievementData def)
{
    if (!states.TryGetValue(def.id, out var state)) return;
    if (state.unlocked) return;

    state.unlocked = true;

    // 通知/UI側に知らせる
    OnUnlocked?.Invoke(def);

    // ※保存は次の章で実装(ここで呼ぶ想定)
}

これで、条件が加算されるたびに「該当する実績だけ」を判定できるようになります。

解除時にやること(最低限これだけ)

実績が解除された瞬間にやりたいことは、だいたいこの3つです。

  • 解除フラグをON(二重解除を防ぐ)
  • UIに通知(一覧更新、ポップアップ表示)
  • セーブ予約(ゲーム再起動でも解除状態が残るように)

ここまでの段階では、まだセーブは未実装なので「解除フラグON+通知」まで完成しています。

次にやること:保存とロード(PlayerPrefs)

実績システムが一度動き始めたら、次に必ず必要になるのが永続化(セーブ/ロード)です。

次の章では、まずは導入しやすいPlayerPrefsで、解除状態と進捗を保存・復元する仕組みを作ります。




4. 保存とロード(まずはPlayerPrefsで完成させる)

実績システムで「それっぽく動いた!」の次に必ず来るのが、保存(セーブ)です。
実績って、ゲームを終了しても残っていてほしいですよね。むしろ残らないと悲しい…😢

まずは実装コストが低いPlayerPrefsで完成させます。
ここで“ちゃんと保存できる形”を作っておくと、後からEasy Saveやクラウドに置き換える時もスムーズです。

保存方針(キー設計)

PlayerPrefsはキーが文字列なので、実績ID(例:kill_10)を使って保存します。
ただしキー衝突を避けるために、接頭辞を付けるのがおすすめです。

  • 解除状態:ach_unlock_{id}(0 or 1)
  • 進捗値:ach_value_{id}(int)

※PlayerPrefsはboolを直接保存しづらいので、0/1で保存するのが定番です。

AchievementSystemにSave/Loadを追加する

さっそくAchievementSystemに、保存とロードの処理を追加します。
まずはキー生成用のヘルパーを用意します。

private string UnlockKey(string id) => $"ach_unlock_{id}";
private string ValueKey(string id)  => $"ach_value_{id}";

ロード(起動時に状態を復元)

最初にロードを作ります。Awakeの初期化が終わった後に呼ぶのがポイントです。
AchievementSystemのAwakeの最後あたりで、Load()を呼びましょう。

private void Awake()
{
    if (Instance != null && Instance != this)
    {
        Destroy(gameObject);
        return;
    }
    Instance = this;
    DontDestroyOnLoad(gameObject);

    InitializeStates();
    InitializeStats();

    Load(); // ★ここで復元
}

Load本体はこちらです。

public void Load()
{
    foreach (var def in achievementDefinitions)
    {
        if (def == null || string.IsNullOrEmpty(def.id)) continue;
        if (!states.TryGetValue(def.id, out var state)) continue;

        int unlockedInt = PlayerPrefs.GetInt(UnlockKey(def.id), 0);
        int valueInt = PlayerPrefs.GetInt(ValueKey(def.id), 0);

        state.unlocked = unlockedInt == 1;
        state.currentValue = valueInt;

        // 進捗の基準として stats も合わせて復元しておく(シンプル構成)
        if (!stats.ContainsKey(def.conditionType)) stats[def.conditionType] = 0;
        stats[def.conditionType] = Mathf.Max(stats[def.conditionType], state.currentValue);
    }
}

ここでのポイントは、states(実績ごとの進捗)と、stats(統計)を矛盾しないように合わせることです。
今回の実装では「統計値=進捗の元」なので、ロード時に統計も戻しておくと判定が安定します。

セーブ(解除時・進捗更新時に保存)

次にセーブです。シンプルに、解除されたら必ず保存するようにします。
(必要なら、進捗更新ごとに保存する方式にもできますが、まずは解除時だけでもOK)

先ほど作ったUnlock()の最後でSaveOne(def.id)を呼びます。

private void Unlock(AchievementData def)
{
    if (!states.TryGetValue(def.id, out var state)) return;
    if (state.unlocked) return;

    state.unlocked = true;

    OnUnlocked?.Invoke(def);

    SaveOne(def.id); // ★解除したら保存
}

保存処理(1件分)はこんな感じ。

public void SaveOne(string id)
{
    if (!states.TryGetValue(id, out var state)) return;

    PlayerPrefs.SetInt(UnlockKey(id), state.unlocked ? 1 : 0);
    PlayerPrefs.SetInt(ValueKey(id), state.currentValue);

    PlayerPrefs.Save();
}

これで「解除した実績は、次回起動しても解除状態が残る」ようになりました 🎉

進捗も保存したい場合(AddProgressで保存)

「解除だけじゃなく、途中の進捗も残したい」なら、AddProgress内で対象実績の進捗を保存すればOKです。
ただし頻繁に保存すると負荷が増えることがあるので、まずは解除時だけで運用して、必要なら追加するのがおすすめです。

// AddProgressの中で、state.currentValueを書き換えた後に
SaveOne(def.id);

テスト時の初期化(DeleteAllの扱い注意)

実績のテストをするときは、保存データを消して何度も試したくなります。
その時に使えるのがPlayerPrefs.DeleteAll()です。

public void ResetAllForDebug()
{
    PlayerPrefs.DeleteAll();
    PlayerPrefs.Save();

    InitializeStates();
    InitializeStats();
}

ただしこれは開発中専用です。
リリース版に残してしまうと、プレイヤーの実績が消える事故につながるので、最後は必ず削除(またはビルド条件で無効化)してくださいね。

次にやること:UI表示(一覧・セル・フィルタ)

ここまでで、実績の「定義・判定・保存」が揃いました。

次の章では、これを一覧UIとして見える形にしていきます。
“解除済みはキラッと、未解除はグレー”みたいな、あの気持ちいい画面を作りましょう 🙂




5. UI表示(一覧・セル・フィルタ)

実績は「解除された」だけだと地味なので、一覧として見える化すると一気に気持ちよくなります。
ここでは、よくある「実績一覧画面」を想定して、 AchievementsUI(一覧管理)AchievementCell(1件表示)に分けて作っていきます。

UIのざっくり構成

まずはUnityのUIをこんな感じで用意します(名前は例です)。

  • Canvas
  • AchievementsPanel(実績画面の親)
    • Scroll View(一覧)
    • (任意)FilterButtons(未解除のみ/解除済みなど)

Scroll Viewの中身(Content)に、AchievementCellを並べる形式が定番です。

AchievementCell(1件分の表示)を作る

まずはPrefab化するセルを用意します。セルの中には最低限これがあるとOKです。

  • Image:アイコン
  • Text:タイトル
  • Text:説明
  • Text:進捗(例:7 / 10)
  • (任意)Image:ロック用のオーバーレイ(暗くする用)

プロジェクトウィンドウを右クリック「Create」→「C# Script」で、 AchievementCellを作成します。

using UnityEngine;
using UnityEngine.UI;

public class AchievementCell : MonoBehaviour
{
    [Header("UI")]
    [SerializeField] private Image iconImage;
    [SerializeField] private Text titleText;
    [SerializeField] private Text descriptionText;
    [SerializeField] private Text progressText;
    [SerializeField] private GameObject lockedOverlay; // 任意

    private string achievementId;

    public void Setup(AchievementData def, AchievementState state)
    {
        achievementId = def.id;

        titleText.text = def.title;
        descriptionText.text = def.description;

        bool unlocked = state != null && state.unlocked;

        // アイコン切り替え(未解除/解除)
        iconImage.sprite = unlocked ? def.unlockedIcon : def.lockedIcon;

        // 進捗表示
        int current = state != null ? state.currentValue : 0;
        progressText.text = unlocked
            ? "Unlocked!"
            : $"{Mathf.Clamp(current, 0, def.targetValue)} / {def.targetValue}";

        // 未解除を暗くするなど(任意)
        if (lockedOverlay != null)
            lockedOverlay.SetActive(!unlocked);
    }
}

これで、セル1個に「定義+状態」を渡せば表示できるようになりました。

AchievementsUI(一覧管理)を作る

次に、実績定義のリストからセルを生成して並べる管理クラスです。
プロジェクトウィンドウを右クリック「Create」→「C# Script」で、 AchievementsUIを作成します。

using System.Collections.Generic;
using UnityEngine;

public class AchievementsUI : MonoBehaviour
{
    [Header("UI References")]
    [SerializeField] private Transform contentParent;
    [SerializeField] private AchievementCell cellPrefab;

    private readonly List<AchievementCell> spawnedCells = new();

    private void OnEnable()
    {
        if (AchievementSystem.Instance != null)
        {
            AchievementSystem.Instance.OnUnlocked += HandleUnlocked;
        }

        RebuildList();
    }

    private void OnDisable()
    {
        if (AchievementSystem.Instance != null)
        {
            AchievementSystem.Instance.OnUnlocked -= HandleUnlocked;
        }
    }

    public void RebuildList()
    {
        // 既存セルを削除
        foreach (var cell in spawnedCells)
        {
            if (cell != null) Destroy(cell.gameObject);
        }
        spawnedCells.Clear();

        var defs = AchievementSystem.Instance.GetDefinitions();
        foreach (var def in defs)
        {
            var state = AchievementSystem.Instance.GetState(def.id);

            var cell = Instantiate(cellPrefab, contentParent);
            cell.Setup(def, state);

            spawnedCells.Add(cell);
        }
    }

    private void HandleUnlocked(AchievementData def)
    {
        // 解除されたら一覧を更新
        // (軽量化したいなら「該当セルだけ更新」に変更OK)
        RebuildList();
    }
}

これで、実績が解除されたら自動的に一覧が更新されます。
「Unlocked!」表示が増えていくの、ちょっと嬉しいやつです🙂

フィルタ(未解除のみ / 解除済みのみ)を付けたい場合

一覧が増えてくると、フィルタがあると便利です。
まずは簡易フィルタとして、表示対象を絞るだけでOKです。

例として「未解除のみ」を表示するなら、RebuildListで生成前にチェックします。

public enum AchievementFilter
{
    All,
    LockedOnly,
    UnlockedOnly
}

[SerializeField] private AchievementFilter filter = AchievementFilter.All;

public void SetFilter(int filterIndex)
{
    filter = (AchievementFilter)filterIndex;
    RebuildList();
}

そしてRebuildList内で分岐を追加します。

// RebuildListのforeach内で
var state = AchievementSystem.Instance.GetState(def.id);
bool unlocked = state != null && state.unlocked;

if (filter == AchievementFilter.LockedOnly && unlocked) continue;
if (filter == AchievementFilter.UnlockedOnly && !unlocked) continue;

ボタン側は、ButtonのOnClickにSetFilterを登録して、
引数に 0=All / 1=LockedOnly / 2=UnlockedOnly を渡せばOKです。

次にやること:解除通知の演出(ポップアップ)

一覧UIができたら、次は「解除した瞬間に出る通知」です。

“ポン!”って出るだけで達成感が倍増するので、ここはぜひ作りましょう。次の章でやります!




6. 解除通知の演出(ポップアップ)

実績システムの“気持ちよさ”を決めるのが、解除時の通知です。
一覧だけでも機能的にはOKなんだけど、解除した瞬間に「実績解除!」って出るとテンションが上がります🙂

ここでは、画面外からスッ…と出てきて、数秒後にスッ…と消えるポップアップを作ります。
実績が連続で解除された場合も想定して、キュー(順番待ち)で表示できる形にしておくのが安心です。

UIを用意する(NotificationPanel)

Canvasの中に、通知用のパネルを作ります。構成例はこんな感じ。

  • NotificationPanel(Image)
  • ├ TitleText(Text:実績名)
    ├ Icon(Image:実績アイコン)
    └ MessageText(Text:固定文でもOK)

NotificationPanelはRectTransformで、初期位置を画面外に置いておきます(左でも上でもOK)。
出現位置と退場位置を座標で管理して、Coroutineで動かします。

AchievementsNotification(通知管理)を作る

プロジェクトウィンドウを右クリック「Create」→「C# Script」で、AchievementsNotificationを作成します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class AchievementsNotification : MonoBehaviour
{
    [Header("UI")]
    [SerializeField] private RectTransform panel;
    [SerializeField] private Text titleText;
    [SerializeField] private Text messageText;
    [SerializeField] private Image iconImage;

    [Header("Motion")]
    [SerializeField] private Vector2 hiddenPos = new Vector2(-800f, 0f);
    [SerializeField] private Vector2 shownPos  = new Vector2(40f, 0f);
    [SerializeField] private float slideDuration = 0.25f;
    [SerializeField] private float staySeconds = 2.0f;

    private readonly Queue<AchievementData> queue = new();
    private Coroutine playRoutine;

    private void Awake()
    {
        if (panel != null)
        {
            panel.anchoredPosition = hiddenPos;
        }
    }

    private void OnEnable()
    {
        if (AchievementSystem.Instance != null)
        {
            AchievementSystem.Instance.OnUnlocked += Enqueue;
        }
    }

    private void OnDisable()
    {
        if (AchievementSystem.Instance != null)
        {
            AchievementSystem.Instance.OnUnlocked -= Enqueue;
        }
    }

    private void Enqueue(AchievementData def)
    {
        if (def == null) return;

        queue.Enqueue(def);

        if (playRoutine == null)
        {
            playRoutine = StartCoroutine(PlayQueue());
        }
    }

    private IEnumerator PlayQueue()
    {
        while (queue.Count > 0)
        {
            var def = queue.Dequeue();
            SetupUI(def);

            // 出現
            yield return Slide(panel, hiddenPos, shownPos, slideDuration);

            // 滞在
            yield return new WaitForSeconds(staySeconds);

            // 退場
            yield return Slide(panel, shownPos, hiddenPos, slideDuration);

            // 連続解除が多いとき、少し間を空けると見やすい(任意)
            yield return new WaitForSeconds(0.05f);
        }

        playRoutine = null;
    }

    private void SetupUI(AchievementData def)
    {
        if (titleText != null) titleText.text = def.title;
        if (messageText != null) messageText.text = "Achievement Unlocked!";
        if (iconImage != null) iconImage.sprite = def.unlockedIcon != null ? def.unlockedIcon : def.lockedIcon;
    }

    private IEnumerator Slide(RectTransform rt, Vector2 from, Vector2 to, float duration)
    {
        if (rt == null) yield break;

        float t = 0f;
        rt.anchoredPosition = from;

        while (t < duration)
        {
            t += Time.unscaledDeltaTime; // UIは一時停止中も動かしたいことが多いので unscaled
            float p = Mathf.Clamp01(t / duration);

            // ちょいイージング(直線より気持ちいい)
            float eased = 1f - Mathf.Pow(1f - p, 3f);

            rt.anchoredPosition = Vector2.LerpUnclamped(from, to, eased);
            yield return null;
        }

        rt.anchoredPosition = to;
    }
}

これで、実績解除イベント(OnUnlocked)が来たら通知キューに積まれて、順番に表示されます。

よくあるUIバグ対策(連続解除)

実績が連続解除されると「通知が上書きされる」「途中で消える」などが起きがちです。
今回の仕組みはキュー+コルーチンなので、基本的に崩れにくいです。

それでも気になる場合は、

  • staySecondsを少し短くする
  • 同一フレームで複数解除する設計なら、解除をまとめて1件にする
  • 通知に効果音を付ける(AudioSourceでOK)

次にやること:保存を強化したい場合(Easy Save)

ここまでで、実績の「条件管理・保存・UI一覧・通知」まで一通り完成しました 🎉

次の章では、PlayerPrefsから一歩進んで、保存をもっと安全&拡張しやすくする選択肢としてEasy Saveの考え方を紹介します。




7. 発展:保存を「壊れにくく」したいなら(Easy Save)

ここまでで、PlayerPrefsを使った実績システムは完成です。おめでとう〜!🎉
ただ、実際の開発ではこんな瞬間が来がちです。

  • 実績の数が増えて、保存キーが大量になる
  • 「進捗」「解除日時」「統計」「複数条件」など、保存したい情報が増える
  • アップデートで実績IDを変えたくなった(でも既存セーブが怖い)
  • 端末移行やバックアップを意識し始めた

こうなると、PlayerPrefsは「手軽」な反面、だんだん管理がしんどくなります。
そこで選択肢に入ってくるのが、保存専用ツールです。

Easy Saveで何が楽になる?

Easy Saveの強みは、ざっくり言うと「保存・復元の面倒をまるっと減らせる」こと。
実績システムで嬉しいポイントは特にこのへんです。

  • 複数データをまとめて保存しやすい(IDごとにキー地獄になりにくい)
  • リストや辞書などの構造を保存しやすい(進捗の集合を丸ごと扱える)
  • 将来的な拡張(実績データの項目増加)に対応しやすい

「今はPlayerPrefsで十分だけど、拡張で破綻しそう…」という場合、移行先として有力です。

置き換えの考え方:PlayerPrefs層を“保存サービス”にする

後から保存方式を差し替えたいなら、AchievementSystemの中に保存処理をベタ書きせず、
「保存役(サービス)」を挟むのがコツです。

たとえば、保存に必要なのは最終的にこの2つです。

  • 実績ごとの状態(AchievementState:解除済み/進捗)
  • (必要なら)統計(stats:撃破数など)

これを「Save/Loadクラス」に分けておけば、今はPlayerPrefs、将来はEasy Saveへ差し替えができます。

実績状態をまとめて保存する例(イメージ)

ここでは“考え方”だけ軽く紹介します。
実績状態(states)をListにして丸ごと保存できるようにすると、管理がかなり楽になります。

// 例:DictionaryをListにしてまとめる
[System.Serializable]
public class AchievementsSaveData
{
    public List<AchievementState> states = new();
}

PlayerPrefsだと「1件ずつキー保存」になりがちですが、保存ツールだとこういう構造を扱いやすいです。

どのタイミングでEasy Saveを検討する?(目安)

  • 実績数が増えてきて、キー管理がつらい
  • 解除状態だけでなく、複雑な進捗や追加情報も保存したい
  • アップデートでデータ構造が変わる可能性が高い
  • 今後、セーブデータ全体をしっかり管理したい

逆に、ミニゲーム規模や短編作品ならPlayerPrefsで十分なことも多いです。
「規模に合わせて選ぶ」でOKです🙂

Easy Save(商品リンク)

実績だけでなく、セーブデータ全体を堅牢にしたいなら、専用ツールを使うのも手です。

Easy Save
Asset Storeでチェックする

次の章では、さらに発展としてクラウド同期(UGS)やプラットフォーム実績の方向性も触れていきます。
「端末を変えても実績を引き継ぎたい!」ってなったら、ここが出番です。




8. 発展:クラウド同期(UGS / Google Play Games ざっくり導入方針)

ローカル実績(PlayerPrefsなど)が完成すると、次に気になってくるのがクラウド同期です。
端末を変えたり再インストールしたりしても、実績が残っていてほしい…!というやつですね。

ここでは実装の細部まで踏み込むというより、「どの選択肢があるか」「どう棲み分けるか」を整理します。
方向性が決まるだけで、後の設計がかなり楽になりますよ🙂

選択肢1:Unity Gaming Services(UGS)でクラウド同期する

Unity公式のサービスを使って、実績をクラウドに寄せる方法です。
ざっくり言うと、次の流れになります。

  1. Authenticationでユーザーを識別(匿名ログインなど)
  2. Achievements(Building Blocks)で実績定義を用意
  3. Cloud Saveで進捗を保存して端末間同期
  4. 必要に応じてCloud Codeで判定や報酬付与をサーバー側に寄せる

“クラウドで同期したい”が主目的なら、UGSはかなり現実的な選択肢です。
特に「iOS/Android/PCなど複数プラットフォームで同じ実績を持たせたい」場合に相性が良いです。

UGSの設計でよくあるパターン

  • 判定はローカル(今の記事のAchievementSystem)で行い、結果だけクラウド保存
  • チート対策が必要なら、判定や報酬をCloud Code側へ寄せる

最初は「ローカル判定+クラウド保存」から始めると、作業量が増えすぎません。

選択肢2:Google Play Games Services(Android向け)

Androidで「プラットフォーム標準の実績」を使うなら、Google Play Games Servicesが候補です。
これはUGSとは目的が少し違っていて、 “Googleの実績UIに載せる”のがメインになります。

やれることのイメージはこんな感じ。

  • 実績解除(進捗100%で解除)
  • 増分実績(例:敵を100体倒す→1体ごとに加算)
  • 標準UI表示(実績一覧のGoogle標準画面を出せる)

Androidだけ特別に対応する場合は強いですが、マルチプラットフォーム前提だと管理が複雑になりがちなので、 “UGS中心+必要ならPlay Games”という棲み分けが多いです。

ローカル実績とプラットフォーム実績、どう棲み分ける?

ここは悩みやすいところなので、よくある方針を整理します。

  • ローカル(自前)実績:ゲーム内のやり込み・収集・隠し実績など、柔軟に増やしたいもの
  • プラットフォーム実績:代表的な実績だけ(チュートリアル完了、エンディング到達など)

全部をプラットフォームに寄せると、実績の追加・調整が面倒になることが多いので、
「ゲーム内実績は自前」「目立つ実績だけ外部にも連携」がバランス取りやすいです。

これで、ローカルで完結する実績システムから、クラウド・プラットフォーム連携の方向性まで一通り見えました。




まとめ

ここまで、Unityで実績(Achievement)システムを作る流れを、 「条件管理 → 保存 → UI表示 → 通知演出」まで一通りつなげて解説してきました。

この記事の要点(サクッと振り返り)

  • 実績は「定義(AchievementData)」「状態(進捗・解除)」を分けると整理しやすい
  • 条件カウントはゲーム中に散らさず、AchievementSystemに集約すると破綻しにくい
  • まずはPlayerPrefsで最小構成を完成させると、全体の動作確認が早い
  • 一覧UIはAchievementsUI(一覧)AchievementCell(1件)に分けると拡張しやすい
  • 解除通知はキュー+Coroutineで、連続解除でも安定して表示できる
  • 規模が大きくなるなら、保存はEasy Saveなどで“壊れにくく”する選択もあり
  • 端末間同期が欲しいなら、UGS(クラウド)Google Play Gamesなどの方向性を検討

実績って「たくさん用意すること」よりも、運用しやすく、後から増やせることの方が大事だったりします。
最初は少数の実績でもOK。まずはシステムを完成させて、ゲームの手触りに合わせて増やしていくのがいちばん強いです🙂

そして、実績はプレイヤーの行動に「ちゃんと反応してくれる」仕組みなので、
作り込むほどゲームが生き生きして見えるようになります。ぜひ、自分のゲームに合う形に育てていってくださいね!


あわせて読みたい

実績システムを作っていると、
「保存まわりをもう少し理解したい」「イベント設計を整理したい」「UIを強化したい」
というポイントが必ず出てきます。

そんなときに役立つ、関連性の高い記事をピックアップしました👇

実績システムは、保存・イベント・UIの知識が一気につながるテーマです。
気になるところから、少しずつ理解を深めていくのがおすすめですよ 🙂


参考書(1冊で体系的に学びたい人へ)

実績システムだけでなく、Unityゲーム開発全体の設計・実装をまとめて整理したいならこちら。

Unityゲーム プログラミング・バイブル 2nd Generation
Amazonでチェックする| ✅ 楽天でチェックする

参考文献


よくある質問(FAQ)

Q
実績の条件が増えてきたら、管理が大変になりませんか?
A

なります。なので、最初から「増える前提」で設計するのが大切です。

この記事で紹介したように、

  • 実績の定義は ScriptableObject にまとめる
  • 条件タイプは enum で管理する
  • カウントと判定は AchievementSystem に集約する

この形にしておくと、実績が増えても「データを追加するだけ」で済むようになります。
実績数が10個でも100個でも、コードの複雑さがほとんど変わらないのが理想です。

Q
PlayerPrefsで実績を保存するのって、安全なんですか?
A

小〜中規模のゲームなら、実用上は問題ありません

ただし、PlayerPrefsは

  • データ構造が複雑になると管理しづらい
  • キーの増加や仕様変更に弱い
  • チート対策や暗号化には向かない

という弱点もあります。

なのでおすすめは、

  • 最初は PlayerPrefsで完成させる
  • 規模が大きくなったら Easy Saveやクラウド保存に移行

という段階的な考え方です。
「いきなり完璧」を目指さない方が、結果的に楽ですよ 🙂

Q
実績解除が一気に起きると、UIがバグりませんか?
A

何も考えずに作ると、かなり高確率でバグります…😅

よくあるのが、

  • 通知が上書きされる
  • 途中で消える
  • 音だけ何回も鳴る

この記事では、キュー(Queue)+Coroutineで順番に表示する方式を紹介しました。
この形にしておくと、連続解除が起きても安定して表示できます。

実績解除の演出は「気持ちよさ」に直結する部分なので、
多少コードが増えても、ここは丁寧に作るのがおすすめです。

※当サイトはアフィリエイト広告を利用しています。リンクを経由して商品を購入された場合、当サイトに報酬が発生することがあります。

※本記事に記載しているAmazon商品情報(価格、在庫状況、割引、配送条件など)は、執筆時点のAmazon.co.jp上の情報に基づいています。
最新の価格・在庫・配送条件などの詳細は、Amazonの商品ページをご確認ください。

スポンサーリンク