実績と統計

プレイヤーの統計値を追跡し、しきい値で実績を自動アンロックし、進捗をディスクに永続化し、アンロックイベントを発行します。

実績はデータテーブルで宣言し、追跡・アンロック・保存はフレームワークが行います。

インターフェースIAchievementService
オフスイッチNullAchievementService
アセンブリCommonGameSystem.Core
起動ブート時に自動登録。定義テーブルのロードは利用者側で行う

できること

実績と統計は、プレイヤーの進捗を追跡し達成を称えるための仕組みです。どちらも ScriptableObject のテーブルで宣言します。統計値は名前付きのカウンター("enemies_defeated""playtime_seconds")で、実績は ID と表示テキストの組であり、必要に応じて目標しきい値付きで統計値にバインドできます。サービスは統計値の変化を記録し、値が動いた瞬間にアンロック条件をチェックし、すべてを Save/Load サービス経由でディスクに保存し、実績がアンロックされるたびに Event Bus へ AchievementUnlocked イベントを発行します。

この分業は意図的です。追跡・アンロック・永続化はフレームワークが担い、トーストやポップアップの表示は利用者の UI が担います。モジュールは完全にヘッドレスで、レンダリングコードを一切含まないため、どんな UI アプローチとも組み合わせられます。

クイックスタート

テーブルを一度ロードし、ゲームプレイコードから統計値を記録します:

var achievements = ServiceLocator.Resolve<IAchievementService>();
achievements.AddToStat("enemies_defeated", 1);

アンロックイベントを購読して通知を表示します:

ServiceLocator.Resolve<IEventBus>().Subscribe<AchievementUnlocked>(evt =>
{
    Debug.Log($"Unlocked: {evt.AchievementId}");
});

API リファレンス

テーブルのロード

  • void LoadTables(IReadOnlyList<AchievementTableAsset> achievements, IReadOnlyList<StatTableAsset> stats) — 必須の最初の呼び出しです。定義とルックアップインデックスを構築します。再度呼び出すとすべてが置き換えられ、メモリ内の状態はリセットされます。

統計値の操作

  • long AddToStat(string statId, long delta) — 統計値に加算(または減算)します。オーバーフローセーフで、デフォルトでは下限 0 で止まります。その統計値にバインドされた実績を即座に再チェックします。新しい値を返します。
  • long SetStat(string statId, long value) — 統計値を絶対値で設定します。下限としきい値チェックは同様です。格納された値を返します。
  • long GetStat(string statId) — 現在の値を読み取ります。未知の ID は 0L を返します。アロケーションは発生しません。

アンロック

  • void Unlock(string achievementId) — 統計値にバインドされていない実績(ストーリーのマイルストーン、隠し要素の発見)を直接アンロックします。二度呼んでも安全で、アンロック済みの実績が二度目のイベントを発火することはありません。
  • bool IsUnlocked(string achievementId) — 実績がアンロック済みかどうか。未知の ID は false を返します。
  • bool TryGetProgress(string achievementId, out AchievementProgress progress) — 進捗スナップショット(現在値、目標、達成率、状態)を埋めます。既知の実績なら統計値バインドの有無を問わず true を返し、未知の ID の場合のみ false を返します。

クエリ

  • void GetVisible(List<AchievementDefinition> results) — 可視の実績(非表示でないもの、または非表示だがアンロック済みのもの)でリストを埋めます。先にリストをクリアします。アロケーションは発生しません — リストの所有権は利用者側にあります。
  • void GetAll(List<AchievementDefinition> results) — ロック中の非表示実績も含む全定義でリストを埋めます。先にリストをクリアします。アロケーションは発生しません。
  • bool ContainsAchievement(string id) — 実績 ID が定義されているかどうか。
  • bool ContainsStat(string id) — 統計値 ID が定義されているかどうか。

永続化

  • SaveResult Save() — メモリ内の状態を Save/Load サービス経由でディスクに書き込みます。変更がない場合は書き込みをスキップします。保存ステータスを返します。
  • void Load()"achievements" という名前のセーブスロットから保存済み状態を読み込みます。セーブが存在しない、または壊れている場合は、静かに空の状態としてロードします。アンロック済みの実績のイベントが再発火することはありません。
  • void ResetAll() — すべての統計値を初期値に戻し、すべての実績をロックします。メモリ内のみの操作です。リセットを永続化するには、その後に Save() を呼んでください。

例: フルセットアップ

using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class AchievementManager : MonoBehaviour
{
    [SerializeField] private AchievementTableAsset[] _achievementTables; // assign in the Inspector
    [SerializeField] private StatTableAsset[] _statTables;               // assign in the Inspector

    private IAchievementService _achievements;
    private IEventBus _bus;

    private void Start()
    {
        _achievements = ServiceLocator.Resolve<IAchievementService>();
        _bus = ServiceLocator.Resolve<IEventBus>();

        // Load the table definitions first, then the saved state from disk.
        _achievements.LoadTables(_achievementTables, _statTables);
        _achievements.Load();

        // Subscribe to unlock events.
        _bus.Subscribe<AchievementUnlocked>(OnAchievementUnlocked);
    }

    public void OnEnemyDefeated()
    {
        long newCount = _achievements.AddToStat("enemies_defeated", 1);
        Debug.Log($"Enemies defeated: {newCount}");
    }

    private void OnAchievementUnlocked(AchievementUnlocked evt)
    {
        Debug.Log($"Achievement unlocked: {evt.AchievementId}");
        // Show your toast or popup here.
    }
}

無効化する

ServiceLocator.Replace<IAchievementService>(new NullAchievementService());

これでモジュール全体が無効化されます。テーブルのロードは何もせず、すべての変更操作は no-op になり、GetStat0L を、IsUnlockedfalse を返し、ディスクへの書き込みは発生せず、イベントも発行されません。実績なしでテストしたいときや、最小構成のビルドを出荷するときに使ってください。

よくある落とし穴

  • LoadTables は必須。 統計値や実績の操作の前に呼んでください。空のサービスに対する AddToStat は警告をログに出しますが、クラッシュはしません。
  • アンロックはデフォルトで即座にディスクへ書き込む。 FlushOnUnlock オプションのデフォルトは true で、アンロックのたびに Save/Load サービス経由の同期セーブが実行されます。多数の実績が一度にアンロックされ得る場合は、AchievementOptionsFlushOnUnlock = false に設定し、チェックポイントで自分で Save() を呼んでください。
  • しきい値チェックは即時。 AddToStatSetStat が実行された瞬間に、バインドされた実績が再評価されます。「後で進捗をチェックする」ような遅延はなく、条件を満たした時点でイベントが発火します。
  • アンロックは一方通行。 一度アンロックされた実績はアンロックされたままです。(AllowNegativeDeltas オプションを有効にして)後から統計値が減っても、実績が再ロックされることはありません。
  • 進捗イベントはオプトイン。 テーブル内の実績定義で PublishProgress = true を設定すると AchievementProgressChanged イベントが有効になります。playtime_seconds のような高頻度の統計値によるイベントスパムを避けるため、デフォルトではオフです。
  • カスタム型は IL2CPP で link.xml が必要。 モジュール自身の public 型(AchievementSaveData など)はすでに保持されています。プレイヤービルドで独自の型を JsonUtility でシリアライズする場合は、IL2CPP ビルドでストリップされないよう、プロジェクトの link.xml にエントリーを追加してください。

関連ページ

  • Save / LoadSave()Load() を支えるセーブスロットとアトミック書き込み
  • Event BusAchievementUnlockedAchievementProgressChanged イベント