Steam 実績の実装サンプルが使いづらかったので使いやすいものを作った

# はじめに

Steam 実績を実装するにあたって、ほとんどの人が [このリポジトリ](https://github.com/rlabrecque/Steamworks.NET-Example) のお世話になったと思う。

お世話になったうえでとても言いにくいのだが、正直、コードが古くてイケてない……。
じゃあお前はイケてんのかって話なのだが、自分なりに頑張ってみたのでいいじゃんって思ったら使ってみてほしい。

# 作ったもの

https://github.com/john95206/SteamAchievementManager

# やったこと解説

## 前提

ここから先は Steam 実績がどのようにして実現しているのかをある程度理解している前提で進めるので、Steam 実績の仕組みについては詳しく解説はしない。
もしわからなくなったら [公式ドキュメント](https://partner.steamgames.com/doc/features/achievements/ach_guide) や [サンプルプロジェクト解説](https://partner.steamgames.com/doc/sdk/api/example?l=japanese) を参照されたい。

## ゲーム側が実績に関する状態をなるべく持つ必要がないようにしてみた

サンプルプロジェクトでは、以下のようにスゴイ量のフィールド変数がある。

```cs
// Our GameID
private CGameID m_GameID;

// Did we get the stats from Steam?
private bool m_bRequestedStats;
private bool m_bStatsValid;

// Should we store stats this frame?
private bool m_bStoreStats;

// Current Stat details
private float m_flGameFeetTraveled;
private float m_ulTickCountGameStart;
private double m_flGameDurationSeconds;

// Persisted Stat details
private int m_nTotalGamesPlayed;
private int m_nTotalNumWins;
private int m_nTotalNumLosses;
private float m_flTotalFeetTraveled;
private float m_flMaxFeetTraveled;
private float m_flAverageSpeed;

protected Callback<UserStatsReceived_t> m_UserStatsReceived;
protected Callback<UserStatsStored_t> m_UserStatsStored;
protected Callback<UserAchievementStored_t> m_UserAchievementStored;
```

そして、ゲーム側でプレイヤーがアクションしたとき（サンプルプロジェクトでは GUIButton が押されたとき）、
ステートが変化し、ステートに応じてこれらの変数が更新されるという設計だと思う。

```cs
if (eNewState == EClientGameState.k_EClientGameActive) {
    // Reset per-game stats
    m_flGameFeetTraveled = 0;
    m_ulTickCountGameStart = Time.time;
}
else if (eNewState == EClientGameState.k_EClientGameWinner || eNewState == EClientGameState.k_EClientGameLoser) {
    if (eNewState == EClientGameState.k_EClientGameWinner) {
        m_nTotalNumWins++;
    }
    else {
        m_nTotalNumLosses++;
    }

    // Tally games
    m_nTotalGamesPlayed++;

    // Accumulate distances
    m_flTotalFeetTraveled += m_flGameFeetTraveled;

    // New max?
    if (m_flGameFeetTraveled > m_flMaxFeetTraveled)
        m_flMaxFeetTraveled = m_flGameFeetTraveled;

    // Calc game duration
    m_flGameDurationSeconds = Time.time - m_ulTickCountGameStart;

    // We want to update stats the next frame.
    m_bStoreStats = true;
}
```

正直なところ、状態は Steam サーバーが持っているので、ゲーム側が保持する積極的な理由はないと思う。
このサンプルでは表示用に使っているので、まあわからなくはないが、値の二重管理は避けたい。

そこでこのようにしてみた。

```cs
public void UpdateAchievement(ISteamAchievement achievement, out string progress)
{
    progress = string.Empty;
    if (!SteamManager.Initialized)
    {
        Log($"Steam Error: Initialize Failed", true);
        return;
    }

    if (_achievedList.Contains(achievement.AchievementKey))
    {
        Log($"Already achieved: {achievement.AchievementKey}");
        progress = achievement.Progress;
        return;
    }

    string achievementKey = achievement.AchievementKey.ToString();

    // 進捗系でなければ発火即実績解除
    if (string.IsNullOrEmpty(achievement.Progress) ||
        string.IsNullOrEmpty(achievement.StatsKey))
    {
        UnlockAchievement(achievementKey);
    }
    else
    {
        UpdateProgressiveAchievement(achievement, out progress);
    }
}
```

呼び出し側はこんな感じ

```cs
AchievementManager.Instance.UpdateAchievement(new SteamAchievement
(
    key,
    ApiType.INT,
    _totalNumOfWins.ToString()
), out progress);
_totalWinText.text = progress;
```

呼び出されたらその呼び出された実績についてだけ処理を行い、結果を out として返す。
これならば状態を持つ必要はなく、最新の値を持ちたいならば out で返ってきた値を使えばよい。

## ゲーム側が Steam のことをなるべく知らなくていいようにした

サンプルプロジェクトでは、実績解除を以下のように実装している。

```cs
foreach (Achievement_t achievement in m_Achievements) {
    if (achievement.m_bAchieved)
        continue;
    
    switch (achievement.m_eAchievementID) {
        case Achievement.ACH_WIN_ONE_GAME:
            if (m_nTotalNumWins != 0) {
                UnlockAchievement(achievement);
            }
            break;
        case Achievement.ACH_WIN_100_GAMES:
            if (m_nTotalNumWins >= 100) {
                UnlockAchievement(achievement);
            }
            break;
        case Achievement.ACH_TRAVEL_FAR_ACCUM:
            if (m_flTotalFeetTraveled >= 5280) {
                UnlockAchievement(achievement);
            }
            break;
        case Achievement.ACH_TRAVEL_FAR_SINGLE:
            if (m_flGameFeetTraveled >= 500) {
                UnlockAchievement(achievement);
            }
            break;
    }
}
```

`UnlockAchievement` では 単に `SteamUserStats.SetAchievement` を呼び出している。

これは、何か起こるたびに `SteamUserStats.GetStat` によって Steam 側に保持されているデータの値をゲーム側で受け取り続け、実績解除できる状態になったかをゲーム側が判断して実績解除しているということである。

`SteamUserStats.SetAchievement` を実行すれば、確かにどんな実績でも解除できるのだが、その判断をマジックナンバーを使ってローカル側（ゲーム側）が行うというのは正直イケてない。

そもそも、 `SteamUserStats.StoreStats` によって Steam サーバーに値が更新されたとき、データと紐づけてある実績ならば、最大値を超えたタイミングで自動的に実績は解除される。

以上から、このように改善してみた。

```cs
private void UpdateProgressiveAchievement(ISteamAchievement achievement, out string progress)
{
    progress = achievement.Progress;
    if (string.IsNullOrEmpty(achievement.Progress))
    {
        return;
    }

    if (!GetStat(achievement, out string registedProgress))
    {
        return;
    }

    // 値が更新されていなければ早期 return
    if (achievement.Progress == registedProgress)
    {
        return;
    }

    if (!float.TryParse(achievement.Progress, out var parsedProgress))
    {
        return;
    }

    if (parsedProgress < float.Parse(registedProgress))
    {
        progress = registedProgress;
    }

    if (!SetStat(achievement, progress))
    {
        return;
    }

    if (!SteamUserStats.StoreStats())
    {
        Log($"Steam Error: Couldn't Update Status", true);
        return;
    }
    // SetAchievement は実行しない
}
```

もちろん、これは「敵を100体倒す」とかそういう「進行系」の実績に必要なことであって、
「最後のボスを倒す」みたいなそもそも進行状態が存在しない実績には必要がない。

そのため、このようにして実績を解除するメソッドをひとつだけ公開するようにした。
「進行系」でないならば進行状態の値が存在しないので、値が入っていなければ `SetAchievement` を実行するという流れだ。

```cs
public void UpdateAchievement(ISteamAchievement achievement, out string progress)
{
    progress = string.Empty;
    if (!SteamManager.Initialized)
    {
        Log($"Steam Error: Initialize Failed", true);
        return;
    }

    if (_achievedList.Contains(achievement.AchievementKey))
    {
        Log($"Already achieved: {achievement.AchievementKey}");
        progress = achievement.Progress;
        return;
    }

    string achievementKey = achievement.AchievementKey.ToString();

    // 進捗系でなければ発火即実績解除
    if (string.IsNullOrEmpty(achievement.Progress) ||
        string.IsNullOrEmpty(achievement.StatsKey))
    {
        UnlockAchievement(achievementKey);
    }
    else
    {
        UpdateProgressiveAchievement(achievement, out progress);
    }
}
```

## 実績(Achievement) とデータ(Stats) を紐づけた

サンプルプロジェクトでは、以下のようになっている。

``` 実績部分.cs
// 実績の値を格納するクラス
private class Achievement_t {
    public Achievement m_eAchievementID;
    public string m_strName;
    public string m_strDescription;
    public bool m_bAchieved;

    /// <summary>
    /// Creates an Achievement. You must also mirror the data provided here in https://partner.steamgames.com/apps/achievements/yourappid
    /// </summary>
    /// <param name="achievement">The "API Name Progress Stat" used to uniquely identify the achievement.</param>
    /// <param name="name">The "Display Name" that will be shown to players in game and on the Steam Community.</param>
    /// <param name="desc">The "Description" that will be shown to players in game and on the Steam Community.</param>
    public Achievement_t(Achievement achievementID, string name, string desc) {
        m_eAchievementID = achievementID;
        m_strName = name;
        m_strDescription = desc;
        m_bAchieved = false;
    }
}

// 実績の値を格納するクラスのインスタンス
private Achievement_t[] m_Achievements = new Achievement_t[] {
	new Achievement_t(Achievement.ACH_WIN_ONE_GAME, "Winner", ""),
	new Achievement_t(Achievement.ACH_WIN_100_GAMES, "Champion", ""),
	new Achievement_t(Achievement.ACH_TRAVEL_FAR_ACCUM, "Interstellar", ""),
	new Achievement_t(Achievement.ACH_TRAVEL_FAR_SINGLE, "Orbiter", "")
};
```

``` データ更新部分.cs
SteamUserStats.SetStat("NumGames", m_nTotalGamesPlayed);
SteamUserStats.SetStat("NumWins", m_nTotalNumWins);
SteamUserStats.SetStat("NumLosses", m_nTotalNumLosses);
SteamUserStats.SetStat("FeetTraveled", m_flTotalFeetTraveled);
SteamUserStats.SetStat("MaxFeetTraveled", m_flMaxFeetTraveled);
// Update average feet / second stat
SteamUserStats.UpdateAvgRateStat("AverageSpeed", m_flGameFeetTraveled, m_flGameDurationSeconds);
// The averaged result is calculated for us
SteamUserStats.GetStat("AverageSpeed", out m_flAverageSpeed);

bool bSuccess = SteamUserStats.StoreStats();
```

データ更新はデータ更新するタイミングで一気に全部更新するという設計だと思う。
これは個人の感想かもだが、触っていない値も毎回更新する必要はあまりないと思うし、特定の実績が解除されたか？を確認するときにだけ、その実績に関わるデータだけが更新されるだけで十分では？と思う。

以上から、このように実績の値を格納するインターフェースとして用意し、StatsKey も格納することにした。

``` ISteamAchievement.cs
public interface ISteamAchievement
{
    // 実績Key
    public string AchievementKey { get; }
    // データAPI Key
    public string StatsKey {get; }
    // 進行状況（進行系でなければ空）
    public string Progress { get; }
    // Avgrate 型のために用意（正直やりたくなかった）
    public double Duration { get; }
    // データAPI で設定した Type
    public ApiType ApiType { get; }
}
```

実際に使う場合はこんな感じ

``` SteamAchievement.cs
public class SteamAchievement : ISteamAchievement
{
	public SteamAchievement(AchievementKeyType key, ApiType apiType, string progress = "", double duration = double.MinValue)
	{
		_achievementKey = key;
		_progress = progress;
		_apiType = apiType;
		_duration = duration;

		SetStatsKeyIfNeed();
	}

	public string AchievementKey => _achievementKey.ToString();
	private AchievementKeyType _achievementKey;
	public string StatsKey => _statsKey;
	private string _statsKey;
	public string Progress => _progress;
	private string _progress;
	public double Duration => _duration;
	private double _duration;
	private ApiType _apiType;
	public ApiType ApiType => _apiType;

	protected void SetStatsKeyIfNeed()
	{
		switch (_achievementKey)
		{
			case AchievementKeyType.ACH_WIN_ONE_GAME:
			case AchievementKeyType.ACH_WIN_100_GAMES:
				_statsKey = SteamStatsKey.NUM_WINS;
				break;
			case AchievementKeyType.ACH_TRAVEL_FAR_ACCUM:
				_statsKey = SteamStatsKey.FEET_TRAVELED;
				break;
			case AchievementKeyType.ACH_TRAVEL_FAR_SINGLE:
				_statsKey = SteamStatsKey.AVERAGE_SPEED;
				break;
		}
	}
}
```

``` AchievementKeyType.cs
public enum AchievementKeyType
{
    ACH_WIN_ONE_GAME,
    ACH_WIN_100_GAMES,
    ACH_TRAVEL_FAR_ACCUM,
    ACH_TRAVEL_FAR_SINGLE,
}
```

StatsKey は AchievementKey が定まるタイミングで一意に決まるので、コンストラクタで取得するようにした。
StatsKey はこんな感じで const として置いておけばよい。

``` SteamStatsKey.cs
public class SteamStatsKey
{
    public const string NUM_GAMES = "NumGames";
    public const string NUM_WINS = "NumWins";
    public const string FEET_TRAVELED = "FeetTraveled";
    public const string AVERAGE_SPEED = "AverageSpeed";
}
```

# まとめ

なんだ結局面倒くさそうじゃんってなったかもしれない。気持ちはわかる。
それでも、もとのサンプルプロジェクトをそのまま使う場合、

実績を増やすたびに

- フィールド変数を増やさなければならないし、
- `OnGameStateChange` はどんどん肥大化するし、
- 実績達成の条件をゲーム側でも用意しなければならない

これらの手間を考えれば、まあまあのものができたかもしれない。
正直各プロジェクトでいい感じのライブラリを作っているだろうけど、

新たに実績実装しなければならないな、とか
今のライブラリを見直したいな、とか

の際にたたき台になれば幸いに思う。