LoginSignup
4
1

More than 5 years have passed since last update.

【Swift】iOSで放置型育成ゲームを作るよ(9) ~資源をUserDefaultsに保存してデータを永続化させる その2~

Last updated at Posted at 2017-07-30

今日のゴール

  • UserDefaultsに資源を保存する
  • アプリ起動時にUserDefaultsから資源量を読み込む

今日のキーワード

  • Generics
  • RawRepresentable
  • private(set)
  • NSCoder

やったこと

一度落ち着いて考える

  • 一旦バックグラウンドフォアグラウンドは忘れよう
  • アプリキル時に値を保存、再起動時に資源量を読み込んで更新するようにするよ
  • 実は資源名とか資源画像とか値が不変なものはシリアライズして保存しなくていいのでは?という疑念が浮かんできたけど、インスタンスの保存にはうまくいっているはずなのでこれも一回忘れるよ

処理フローを考える

  • アプリが起動して資源が自動生産を始める
  • アプリがキルされるタイミングでその時の時刻とインスタンスをUserDefaultsに保存する
  • アプリが再起動される
  • VCでResourceからインスタンスを作った後、UserDefaultsを確認してデータが存在していれば読み込む
  • 読み込んだ時刻と保存してある時刻を比較してどの程度時間が経過しているか差分を計算する
  • 差分に応じて値を資源保有量に足す

という方針でまずは進めるよ
前回willTerminateで受け取った保存処理をResourceクラス側に実装していたけど、実際のResourceインスタンスもってるのがVCなのでやっぱり処理をVC側に移すよ(そして多分設計的な話だとビジネスロジックはVCに書くべきではないのでアプリ設計がよろしくない けどまあ気にしない)

ViewDidLoadに必要な処理を書いていく

ViewController.swift
    override func viewDidLoad() {
        super.viewDidLoad()

        // キャラクターのインスタンスを生成してViewControllerのプロパティに設定
        guard let valkyrie = Character(name: "【戦乙女】ヴァルキリー", charaImage: UIImage(named: "sozai.png")) else {
            return
        }

        self.valkyrie = valkyrie

        // imageViewのimageにキャラのimageを設定
        imageView.image = valkyrie.standardImage
        name.text = valkyrie.name
        name.adjustsFontSizeToFitWidth = true

        strLabel.text = "攻撃力: \(String(describing: valkyrie.status.str))"
        defLabel.text = "防御力: \(String(describing: valkyrie.status.def))"
        lucLabel.text = "天運: \(String(describing: valkyrie.status.luc))"
        conditionLabel.text =  "状態: " + valkyrie.getCondition().description()

        // UserDefaultsに保存した資源があるかチェック
        if let savedObject = UserDefaults.standard.object(forKey: "resource") as? [String: Any] {

            guard let parsedDate = parse(from: savedObject) else { return }
            resource = parsedDate.resource
            resource.manufacture(from: parsedDate.interval)
        }

        resource.setUpTimer()
        resourceImageView.image = resource.image
        resourceQuantityLabel.text = String(self.resource.getQuantity())
        resourceQuantityLabel.sizeToFit()

        NotificationCenter.default.addObserver(forName: Notification.Name(resource.name), object: nil, queue: .main) {notification in
            self.reloadResource(with: notification)
        }

        NotificationCenter.default.addObserver(forName: .UIApplicationWillTerminate, object: nil, queue: .main) { (_) in
            print("willTernimate...")

            // 資源をシリアライズ化、アプリ終了時刻とともにUserDefaultsに保存する
            let serializeResource = NSKeyedArchiver.archivedData(withRootObject: self.resource),
                terminateDate = Date(),
            saveData: [String: Any] = ["resource": serializeResource,
            "terminateDate": terminateDate]

            let userDefault = UserDefaults.standard
            userDefault.set(saveData, forKey: "resource")
        }
    }

    // UserDefaultsに保存されたオブジェクトをパースして(Resource, TimeInterval)のタプルで返すメソッド
    func parse(from savedObject: [String: Any]) -> (resource: Resource, interval: TimeInterval)? {

        guard let parsedResourceData = savedObject["resource"] as? Data,
                let resource = NSKeyedUnarchiver.unarchiveObject(with: parsedResourceData) as? Resource,
            let parsedTerminateDate = savedObject["terminateDate"] as? Date else {
                return nil
        }

        return (resource, Date().timeIntervalSince(parsedTerminateDate))
    }

ResourceクラスにはTimeIntervalから増加分を計算して資源保有量に追加するmanufactureメソッドを追加するよ

Resource.swift
~省略~
    func manufacture(from interval: TimeInterval) {
        quantity += lround(interval / incrementInterval) * increaseAmount
    }
~省略~

アプリをキルして・・・
スクリーンショット 2017-07-17 14.05.02.png

ちょっと待ってから起動すると・・・
スクリーンショット 2017-07-17 14.04.45.png

おお、保存されとるやんけ!

だけどこれだけではダメ

  • 今の状態だとよろしくないことができてしまうよ・・・
  • iPhone本体の「設定」から時間を進めると資源を増加させることができるよ・・・
    • 例えば1日分進めると、3600秒 / 5(incrementInterval) * 100(increaseAmount)で72,000も増やせてしまうよ・・・
  • 良さげな解決手段が思い浮かばないので一旦issueとして積んでおくよ
  • こういうチート対策ってどうしてるんだろうか🤔

シリアライズ・デシリアライズを安全に書きやすく拡張する

  • 保存が案外簡単にうまくできたので、諸々調べてる途中で見つけた下記記事を参考にちょっとリファクタするよ
  • encode/decodeする際のkeyからrawValueを駆逐するよ

  • NSCoder+CodingKeySettable.swiftを実装する

  • NSCoderを拡張してrawValueを駆逐する

  • ResourceでUIImageをシリアライズ・デシリアライズしていたけど、あまり良くなさそうなので画像ファイル名をプロパティとしてもち、それを保存することにする

  • ついでに、Resourceのgetter書くのめんどいのでprivate(set)で隠蔽する

NSCoder+CodingKeySettable.swift
import Foundation

extension NSCoder {
    func encode<T: RawRepresentable>(_ object: Any?, forKey key: T) {
        encode(object, forKey: key.rawValue as! String)
    }

    func encodeInteger<T: RawRepresentable>(_ object: Int, forKey key: T) {
        encode(object, forKey: key.rawValue as! String)
    }

    func encodeFloat<T: RawRepresentable>(_ object: Float, forKey key: T) {
        encode(object, forKey: key.rawValue as! String)
    }

    func encodeDouble<T: RawRepresentable>(_ object: Double, forKey key: T) {
        encode(object, forKey: key.rawValue as! String)
    }

    func encodeBool<T: RawRepresentable>(_ object: Bool, forKey key: T) {
        encode(object, forKey: key.rawValue as! String)
    }

    func decodeObject<T: RawRepresentable>(forKey key: T) -> Any? {
        return decodeObject(forKey: key.rawValue as! String)
    }

    func decodeInteger<T: RawRepresentable>(forKey key: T) -> Int {
        return decodeInteger(forKey: key.rawValue as! String)
    }

    func decodeFloat<T: RawRepresentable>(forKey key: T) -> Float {
        return decodeFloat(forKey: key.rawValue as! String)
    }

    func decodeDouble<T: RawRepresentable>(forKey key: T) -> Double {
        return decodeDouble(forKey: key.rawValue as! String)
    }

    func decodeBool<T: RawRepresentable>(forKey key: T) -> Bool {
        return decodeBool(forKey: key.rawValue as! String)
    }
}
Resource.swift
final class Resource: NSObject, NSCoding {

    let name: String                                // 資源名(ex: 光のオーブ)
    let imageName: String
    let image: UIImage                              // 資源image
    private(set) var quantity: Int                       // 資源保有量 デフォルト10000
    private(set) var incrementInterval: TimeInterval     // 増加間隔(秒)  デフォルト5秒
    private(set) var incrementAmount: Int                 // 増加量 デフォルトでは100
    private(set) var incrementTimer: Timer

    init(name: String,
         imageName: String,
         quantity: Int = 100000,
         incrementInterval: TimeInterval = 0.1,
         increaseAmount: Int = 100) {
        self.name = name
        self.imageName = imageName
        self.image = UIImage(named: imageName)!
        self.quantity = quantity
        self.incrementInterval = incrementInterval
        self.incrementAmount = increaseAmount
        self.incrementTimer = Timer()
        super.init()
    }

    private enum CodingKey: String {
        case name
        case imageName
        case quantity
        case incrementInterval
        case incrementAmount
    }


    required init?(coder aDecoder: NSCoder) {
        guard
            let name = aDecoder.decodeObject(forKey: CodingKey.name) as? String,
            let imageName = aDecoder.decodeObject(forKey: CodingKey.imageName) as? String,
            let image = UIImage(named: imageName)
            else {
                print("coder失敗")
                return nil
        }

        let quantity = aDecoder.decodeInteger(forKey: CodingKey.quantity),
        incrementInterval = aDecoder.decodeDouble(forKey: CodingKey.incrementInterval),
        increaseAmount = aDecoder.decodeInteger(forKey: CodingKey.incrementAmount)

        self.name = name
        self.imageName = imageName
        self.image = image
        self.quantity = quantity
        self.incrementInterval = incrementInterval
        self.incrementAmount = increaseAmount
        self.incrementTimer = Timer()
        super.init()
    }

    func encode(with aCoder: NSCoder) {
        aCoder.encode(name, forKey: CodingKey.name)
        aCoder.encode(imageName, forKey: CodingKey.imageName)
        aCoder.encodeInteger(quantity, forKey: CodingKey.quantity)
        aCoder.encodeDouble(incrementInterval, forKey: CodingKey.incrementInterval)
        aCoder.encodeInteger(incrementAmount, forKey: CodingKey.incrementAmount)
    }
~~省略~~
}

keyを手打ちしなくてよいので安全になる!(はず!)
CodingKeyすらも駆逐したかったけど、いい感じの実装が思い浮かばず・・・orz

結果

オススメの開発手法について

  • HKDD(Hiroti Karaoke Driven Development)という開発手法をオススメします
  • 実践方法は簡単。カラオケ屋に一人でPCを持ち込んで開発、行き詰まったらカラオケで数曲熱唱して再び開発に戻るだけ。
  • 歌うことによってストレスが発散され、新たな発想を得やすくなります!(割とマジで)
  • みなさんぜひHKDDで快適なプログラミングライフを!

次回

最後に

ご指摘・リファクタ大歓迎です:pray:

4
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1