Unity実践:PlayerPrefsにJSONで複数セーブスロットを保存し、必要に応じて暗号化する
目次
この記事でできること
- PlayerPrefs に JSON文字列 として 複数スロット のセーブデータを保存・読み出し
- オプションで 暗号化(AES) + 改ざん検知(HMAC) に対応
- 「とりあえず動く」から「最低限の安全性」まで、段階的に導入できる
前提:保存するデータ構造
JsonUtility を使うので publicフィールド を持つ [Serializable] なクラスにします。
[System.Serializable]
public class GameData
{
public int level = 1; // 1〜99
public float score = 0f;
public string item = "なし";
}
1. 複数スロットをJSON文字列で保存(暗号化なし)
方針
- スロットごとに PlayerPrefs のキーを分ける:SaveSlot.{slot}
- 最後に使ったスロットを覚える:LastSaveSlot
- JSONはそのまま文字列で格納
using UnityEngine;
public static class PrefJsonSlots
{
const string SlotKeyPrefix = "SaveSlot.";
const string LastSlotKey = "LastSaveSlot";
static string SlotKey(int slot) => $"{SlotKeyPrefix}{slot}";
public static void SaveToSlot(int slot, GameData data)
{
// 例: バリデーション(必要なら)
if (data.level < 1 || 99 < data.level)
throw new System.Exception("レベルは1〜99で指定してください");
string json = JsonUtility.ToJson(data);
PlayerPrefs.SetString(SlotKey(slot), json);
PlayerPrefs.SetInt(LastSlotKey, slot);
PlayerPrefs.Save();
Debug.Log($"[PrefJsonSlots] Saved slot={slot} json={json}");
}
public static bool TryLoadSlot(int slot, out GameData data)
{
string key = SlotKey(slot);
if (!PlayerPrefs.HasKey(key))
{
data = null;
return false;
}
string json = PlayerPrefs.GetString(key);
data = JsonUtility.FromJson<GameData>(json);
return data != null;
}
public static int GetLastSlotOrDefault(int defaultSlot = 1)
{
return PlayerPrefs.GetInt(LastSlotKey, defaultSlot);
}
public static void DeleteSlot(int slot)
{
PlayerPrefs.DeleteKey(SlotKey(slot));
PlayerPrefs.Save();
}
}
使い方(例)
// セーブ
var data = new GameData { level = 12, score = 3456.7f, item = "勇者の剣" };
PrefJsonSlots.SaveToSlot(2, data);
// ロード
if (PrefJsonSlots.TryLoadSlot(2, out var loaded))
{
Debug.Log($"Loaded slot2 → L{loaded.level}, S{loaded.score}, I:{loaded.item}");
}
2. 暗号化(AES)+改ざん検知(HMAC)に対応する
なぜ必要?
- PlayerPrefsは平文のため覗かれ・改ざんされやすい
- チート対策の第一歩として、暗号化と改ざん検知(署名/HMAC)を導入
注意:完璧なチート対策ではありません(キーがアプリ内にあるため)。サーバ連携や本格的なアンチチートは別途検討が必要です。
実装方針
- 暗号化:AES-CBC(初学者向けに実装しやすい)
- 改ざん検知:HMAC-SHA256(Encrypt-then-MAC)
- 保存形式:Base64( IV | Ciphertext | HMAC ) を1本の文字列として PlayerPrefs に保存
- 鍵の扱い:サンプルでは簡易のため固定キーを使用。実運用では PBKDF2での鍵導出 や サーバ配布 等を検討
using System;
using System.Security.Cryptography;
using System.Text;
public static class Crypto
{
// サンプル用の固定キー(32バイト = AES-256)。実運用では安全な管理を。
// 例:PBKDF2でパスフレーズから導出、もしくはサーバ連携で配布など。
static readonly byte[] AesKey = Encoding.UTF8.GetBytes("0123456789abcdef0123456789abcdef"); // 32B
static readonly byte[] HmacKey = Encoding.UTF8.GetBytes("fedcba9876543210fedcba9876543210"); // 32B
public static string EncryptToBase64(string plaintext)
{
using var aes = Aes.Create();
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = AesKey;
aes.GenerateIV(); // ランダムIV
byte[] plainBytes = Encoding.UTF8.GetBytes(plaintext);
using var enc = aes.CreateEncryptor();
byte[] cipher = enc.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
// 連結: IV | CIPHER
byte[] ivPlusCipher = new byte[aes.IV.Length + cipher.Length];
Buffer.BlockCopy(aes.IV, 0, ivPlusCipher, 0, aes.IV.Length);
Buffer.BlockCopy(cipher, 0, ivPlusCipher, aes.IV.Length, cipher.Length);
// HMAC( IV | CIPHER )
using var hmac = new HMACSHA256(HmacKey);
byte[] tag = hmac.ComputeHash(ivPlusCipher);
// 連結: IV | CIPHER | HMAC
byte[] all = new byte[ivPlusCipher.Length + tag.Length];
Buffer.BlockCopy(ivPlusCipher, 0, all, 0, ivPlusCipher.Length);
Buffer.BlockCopy(tag, 0, all, ivPlusCipher.Length, tag.Length);
return Convert.ToBase64String(all);
}
public static string DecryptFromBase64(string base64)
{
byte[] all = Convert.FromBase64String(base64);
if (all.Length < 16 + 1 + 32) throw new Exception("データ長不正");
// 末尾32BはHMAC
int tagLen = 32;
byte[] tag = new byte[tagLen];
Buffer.BlockCopy(all, all.Length - tagLen, tag, 0, tagLen);
// 先頭〜(末尾-32B)は IV|CIPHER
int ivCipherLen = all.Length - tagLen;
byte[] ivPlusCipher = new byte[ivCipherLen];
Buffer.BlockCopy(all, 0, ivPlusCipher, 0, ivCipherLen);
// HMAC検証
using var hmac = new HMACSHA256(HmacKey);
byte[] calc = hmac.ComputeHash(ivPlusCipher);
if (!FixedTimeEquals(tag, calc)) throw new Exception("改ざん検知: HMAC不一致");
// IV と Cipher に分割
byte[] iv = new byte[16];
Buffer.BlockCopy(ivPlusCipher, 0, iv, 0, 16);
byte[] cipher = new byte[ivCipherLen - 16];
Buffer.BlockCopy(ivPlusCipher, 16, cipher, 0, cipher.Length);
using var aes = Aes.Create();
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = AesKey;
aes.IV = iv;
using var dec = aes.CreateDecryptor();
byte[] plain = dec.TransformFinalBlock(cipher, 0, cipher.Length);
return Encoding.UTF8.GetString(plain);
}
static bool FixedTimeEquals(byte[] a, byte[] b)
{
if (a.Length != b.Length) return false;
int diff = 0;
for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i];
return diff == 0;
}
}
3. 複数スロット × 暗号化つき PlayerPrefs 実装
using UnityEngine;
public static class SecurePrefJsonSlots
{
const string SlotKeyPrefix = "SaveSlotEnc.";
const string LastSlotKey = "LastSaveSlotEnc";
static string SlotKey(int slot) => $"{SlotKeyPrefix}{slot}";
public static void SaveToSlot(int slot, GameData data, bool useEncryption = true)
{
if (data.level < 1 || 99 < data.level)
throw new System.Exception("レベルは1〜99で指定してください");
string json = JsonUtility.ToJson(data);
string payload = useEncryption
? Crypto.EncryptToBase64(json)
: json;
PlayerPrefs.SetString(SlotKey(slot), payload);
PlayerPrefs.SetInt(LastSlotKey, slot);
PlayerPrefs.Save();
Debug.Log($"[SecurePrefJsonSlots] Saved slot={slot}, enc={useEncryption}");
}
public static bool TryLoadSlot(int slot, out GameData data, bool useEncryption = true)
{
string key = SlotKey(slot);
if (!PlayerPrefs.HasKey(key))
{
data = null;
return false;
}
string payload = PlayerPrefs.GetString(key);
string json = useEncryption
? Crypto.DecryptFromBase64(payload)
: payload;
data = JsonUtility.FromJson<GameData>(json);
return data != null;
}
public static int GetLastSlotOrDefault(int defaultSlot = 1)
{
return PlayerPrefs.GetInt(LastSlotKey, defaultSlot);
}
public static void DeleteSlot(int slot)
{
PlayerPrefs.DeleteKey(SlotKey(slot));
PlayerPrefs.Save();
}
}
使い方(例)
// 保存(暗号化ON)
var data = new GameData { level = 20, score = 9876.5f, item = "賢者の杖" };
SecurePrefJsonSlots.SaveToSlot(1, data, useEncryption: true);
// 読み出し(暗号化ON)
if (SecurePrefJsonSlots.TryLoadSlot(1, out var loaded, useEncryption: true))
{
Debug.Log($"[SECURE] slot1 → L{loaded.level}, S{loaded.score}, I:{loaded.item}");
}
4. スロット運用のTips
- スロット一覧:固定数(例:1〜3)にしてメニューから選ばせる。
- 表示名:保存時刻などを一緒にJSONへ格納して、メニューで表示。
- 自動バックアップ:保存前に旧データを SaveSlotEnc.{slot}.bak などで別キーに待避。
- サイズ上限:PlayerPrefsは大容量向けではありません(数KB〜数十KB目安)。長文化したらファイル保存へ移行。
5. セキュリティと運用の注意
- 鍵管理:サンプルは固定キー。実運用は PBKDF2等でパスフレーズから鍵導出、あるいはサーバ配布を検討。
- 完全防御ではない:クライアント内に鍵がある限りリバース解析の可能性は残る。サーバ検証やオンライン同期で補強。
- 例外処理:復号失敗(HMAC不一致等)の際はセーフフォールバック(新規データ/復旧)を用意。
- パフォーマンス:PlayerPrefsは書き込みでディスクI/Oが走るため、連打は避ける(バッファリング/保存間隔を調整)。
6. まとめ
- 複数スロットは SaveSlot.{n} のようにキーを分ければ簡単に実現できる
- 文字列の中身を JSON にすることで拡張が容易
- さらに AES暗号化 + HMAC を加えると、最低限の覗き見・改ざん対策になる
- データが成長したら JSONファイル保存 へ段階的に移行する設計が現実的
訪問数 3 回, 今日の訪問数 3回
ディスカッション
コメント一覧
まだ、コメントがありません