Upgrade to Pro — share decks privately, control downloads, hide ads and more …

(ほぼ)標準ライブラリだけでスロットゲームを実装した話

 (ほぼ)標準ライブラリだけでスロットゲームを実装した話

ROPPONGI.swift #5 での登壇資料です。

Tomohiro Imaizumi

August 24, 2018
Tweet

More Decks by Tomohiro Imaizumi

Other Decks in Programming

Transcript

  1. (΄΅)ඪ४ϥΠϒϥϦ͚ͩͰ
    εϩοτήʔϜΛ࣮૷ͨ͠࿩
    ROPPONGI.swift #5
    @imaizume

    View full-size slide

  2. 今泉 智博 @imaizume
    2017年株式会社ミクシィ新卒入社
    株式会社 所属
    iOS利用経験0から iOS版開発担当に
    (ほぼ)毎日健康COMP生活はもうすぐ1周年

    View full-size slide

  3. 1. 背景
    2. スロットの仕様
    3. スロット実装の解説とDEMO
    4. 実装過程での課題と解決方法
    5. 将来的な話
    ຊ೔ͷINDEX

    View full-size slide

  4. 夏はマッチングアプリが盛り上がる大切な時期
    Poiboyも夏にキャンペーンを実施
    今年の企画: スロットゲームで豪華賞品プレゼント
    എܠ
    実は昨年やるはずがビジネス的事情により1年寝かされていました
    詳細は↓の登壇&インタビューを御覧ください
    https://mixil.mixi.co.jp/people/2103

    View full-size slide

  5. (1)Symbol: 絵柄1つのこと
    (2)Reel: 絵柄のパターンの1セット
    (3)ReelSet: 複数リールのセット
    (imaizumeオリジナル用語)
    (4)Drum: スロットの中の1レーン
    (右の図では3ドラム)
    ຊ୊…ͷલʹεϩοτήʔϜͷ༻ޠղઆ
    ギャンブルに詳しくない方のために
    このあとの説明で必要になるので
    (1)
    (2)
    (3)
    スロットの例
    (4)

    View full-size slide

  6. ✓ ゲームの開始はユーザータイミング
    ✓ 当たり判定はゲーム開始時に決定済み
    ✓ 3つのドラムは独立して回転
    ✓ 回転数不明=無限スクロール
    ✓ 適度な回転スピードと停止時の加速度
    ✓ 目標となる絵柄を各ドラムの中心で停止
    ✓ 賞品ポップアップを全ドラムの停止後に表示
    ✓ Auto Layout対応必須
    ՆΩϟϯϖʔϯͷεϩοτήʔϜͷ࢓༷
    あなたならどうやってスロットを実装しますか❓
    (※౰࣌͸SwiftΛॻ͖࢝Ίͯ4 ϲ݄໨

    View full-size slide

  7. 1: SpriteKitで絵柄単体を動かす ❌
    速度を変えたときに絵柄間の間隔が変わる
    (渋滞の車列みたいになる)
    2: UICollectionView / UITableView
    絵柄の余白調整や中心での停止が難しい
    cellのreuseによる表示のバグも懸念
    3. UIScrollView ⭕
    一番自由度が高く良さそう
    中心で止められそう
    ࣗ෼͕ߟ࣮͑ͨ૷ͷީิ
    (※લड़ͷొஃࢿྉͰ͸UICollectionViewΛ࢖ͬͨͱ͋Δͷ͸ޡΓͰ͢ )

    View full-size slide

  8. 1.絵柄に対応するUIViewを作成
    2.絵柄を連ねてReelを作成
    3.複数のReel群 = ReelSetを縦につなげる
    4.ReelSetをUIScrollViewに入れDrumにする
    Ͳ͏΍࣮ͬͯݱ͔ͨ͠(View)
    Symbol Reel ReelSet Drum1
    可視領域

    View full-size slide

  9. ࣮ࡍͷView֊૚

    View full-size slide

  10. 1.遠くの座標にcontentOffsetを設定
    2.下に向かって自動スクロールで下端到達
    3.上部の同絵柄にジャンプ
    4.1~3を繰り返す
    Ͳ͏΍࣮ͬͯݱ͔ͨ͠(ಈ͖)
    setContentOffset
    (y: 5000)
    ~~~
    2.下端に到達 3.同じ絵柄にジャンプ
    drumScrollView.
    setContentOffset
    (500)
    すぐにゴールを
    遠くに戻す
    y=5000
    y=0
    1.offsetを遠くに

    View full-size slide

  11. ίϯϙʔωϯτͷਤ
    DrumScrollView: UIScrollView
    enum Symbol: Int
    symbol: [Symbol]
    numberOfReel: Int
    symbolViews:[UIView]
    drum: DrumScrollView

    drumSet: [DrumScrollView]
    numberOfDrum: Int

    View full-size slide

  12. DrumScrollView(ύϥϝʔλઃఆ)
    var drumParam : DrumParam = DrumParam() {
    didSet {
    // MARK: View設定
    self.subviews.forEach({ $0.removeFromSuperview() })
    self.contentSize = CGSize(
    width: self.frame.width,
    height: (CGFloat)(self.drumParam.numberOfSymbol) * self.symbolHeight)
    for (index, view) in self.drumParam.totalSetOfSymbolImageView.enumerated() {
    view.frame = CGRect(
    x: 0, y: (CGFloat)(index) * self.symbolHeight,
    width: self.frame.width, height: self.symbolHeight
    )
    self.addSubview(view)
    }
    self.setNeedsLayout()
    self.layoutIfNeeded()
    // MARK: ゲーム設定
    self.remainingScrollCount = self.drumParam.numberToScroll
    // 開始時のOffsetをランダムにずらず
    let startIndex: UInt32 = arc4random_uniform(UInt32(self.drumParam.numberOfReel))
    self.contentOffset.y = ((CGFloat)(startIndex) + 0.5) * self.symbolHeight
    // 停止位置座標の決定
    var targetY: CGFloat = self.symbolHeight * ((CGFloat)(self.drumParam.targetIndex) + 0.5) - self.frame.height / 2
    // 頭のリールは切れてしまうので前半のリールは後半の後ろに持っていく
    if self.drumParam.targetIndex < self.drumParam.numberOfReel / 2 {
    targetY += (CGFloat)(self.drumParam.numberOfReel) * reelHeight
    }
    self.stopTargetPoint = targetY
    }
    }
    縦にSymbolをつなげる
    contentViewの領域確保
    ちょっとした気遣い
    パラメータ用Structを定義

    View full-size slide

  13. DumScrollView(͖͍͠஋ઃఆ)
    /// 残り回転数
    fileprivate var remainingScrollCount: Int = 0
    /// 回転を減速するべきか
    fileprivate var shouldSlowDownScroll: Bool {
    let isNearTheTarget = abs(self.contentOffset.y - self.stopTargetPoint) < self.scrollTargetDistanceForSlowSpeed
    let isLastScroll = self.remainingScrollCount == 0
    return isNearTheTarget && isLastScroll
    }
    /// 回転を停止するべきか
    fileprivate var shouldFinishScroll: Bool {
    let isOnTheTarget = abs(self.contentOffset.y - self.stopTargetPoint) < self.bufferForTargetPosition
    let isLastScroll = self.remainingScrollCount == 0
    return isOnTheTarget && isLastScroll
    }
    /// 回転位置を戻すべきか
    fileprivate var shouldRewindScroll: Bool { return self.contentOffset.y <= self.reelHeight }
    fileprivate var stopTargetPoint: CGFloat = 0
    /// 停止位置ちょうどに止めることはできないため許容される停止位置までの誤差
    fileprivate let bufferForTargetPosition: CGFloat = 10.0
    /// 通常回転時のスクロール目標位置
    fileprivate let scrollTargetDistanceForNormalSpeed : CGFloat = 5000
    /// 減速時のスクロール目標位置
    fileprivate let scrollTargetDistanceForSlowSpeed : CGFloat = 100
    停止までの回転数(5とか)
    停止/減速までのバッファを作るのがポイント
    POINT: 停止位置にはバッファ(ズレの許容度)を与える
    回転速度が早すぎるとバッファを通過してしまう
    ズレは小さくしたいので直前で減速させる

    View full-size slide

  14. DrumScrollView(ಈ࡞ઃఆ)
    func updateViews() {
    if self.remainingScrollCount < 0 { return }
    if self.shouldFinishScroll { // ఀࢭ࣌
    self.setContentOffset(CGPoint(x: 0, y: self.stopTargetPoint), animated: true)
    self.gameFinishCallback()
    } else if self.shouldSlowDownScroll { // ݮ଎࣌
    let scrollTargetPoint = -(100 + self.contentOffset.y)
    self.setContentOffset(CGPoint(x: 0, y: scrollTargetPoint), animated: !self.shouldRewindScroll)
    return
    } else if self.shouldRewindScroll { // ্୺ʹୡͨ࣌͠
    let scrollTargetPoint = (self.contentSize.height / (CGFloat)(self.drumParam.numberOfReelSet) * 2) + self.contentOffset.y
    self.decreaseScrollCount()
    self.setContentOffset(CGPoint(x: 0, y: scrollTargetPoint), animated: !self.shouldRewindScroll)
    } else { // ௨ৗͷճస࣌
    let scrollTargetPoint = -(5000 + self.contentOffset.y)
    self.setContentOffset(CGPoint(x: 0, y: scrollTargetPoint), animated: !self.shouldRewindScroll)
    }
    }
    停止後に呼ぶコール
    バック後で解説

    View full-size slide

  15. ❇とりあえず時間をもらった (いろいろ試すしかない)
    チームの理解も得られていろいろとトライさせてくれた
    ௚໘ͨ͠՝୊ͱղܾํ๏
    1. ࣾ಺֎໰Θͣલྫ͕ͳ͍
    力技(ひたすらdebugポイントとprintを埋め込み)
    タイミングよくrevealを教えてもらったのはラッキー
    SymbolやDrumに処理を移譲してカプセル化
    2. ಈ͖ͷσόοά͕େม
    Offsetや回転スピードの関係を確認するため
    値を計測してグラフ化したりもしました ➡

    View full-size slide

  16. ௚໘ͨ͠՝୊ͱղܾํ๏
    UIScollViewはスクロールスピードを指定できない
    現在のOffset + αにsetContentOffsetで定速スクロール
    3. ఀࢭ΍ݮ଎࣌ͷՃ଎౓ௐ੔
    offset: 100
    drumScrollView.
    setContentOffset
    (100 + 200)

    offset: 100
    drumScrollView.
    setContentOffset
    (100 + 1000)

    offset: 120
    drumScrollView.
    setContentOffset
    (120 + 200)
    offset: 200
    drumScrollView.
    setContentOffset
    (200 + 1000)

    View full-size slide

  17. ここだけPromiseKitを使いました
    各Drumの停止時コールバックでfulfillをcall
    3つのDrumの停止後に当選ポップアップを表示
    (当選結果自体はViewController側で保持)
    let games = self.drumScrollViews.map { drum in
    return Promise { (fullfil, _) in
    drum.gameFinishCallback = { fullfil(()) }
    }
    }
    // MEMO: 当選ポップアップの表示
    when(resolved: games).always {
    self.showWinPopup()
    }
    ௚໘ͨ͠՝୊ͱղܾํ๏
    4. ಠཱͨ͠ճసͷऴྃͷ଴ͪ߹Θͤ

    View full-size slide

  18. কདྷతͳ࿩
    ϥΠϒϥϦԽ͍ͨ͠
    ‣ 今回はゲーム開始前に結果が決まっていた
    ‣ 本来は動的に停止位置や速度調整をする必要あり
    ‣ Viewの制約で上端や下端では止められない
    ‣ 横スクロールにすれば回転寿司みたいなUIも作れそう?
    PromiseKitͷษڧձΛͦͷ͏ͪ΍Γ·͢
    ‣ 今回の実装でPromiseKitをうまく扱えるようになった
    ‣ コールバック直書きのコードもいい感じに書き換えた
    ‣ 現在勉強会企画中 (SupporterZ CoLabを予定)

    View full-size slide

  19. ࢀߟࢿྉ
    新規サービスのアプリ開発で経験したリアルな出来事
    https://speakerdeck.com/imaizume/xin-gui-sabisufalseapurikai-fa-dejing-
    yan-sitariarunachu-lai-shi
    失敗こそ学びの力に!貪欲にチャレンジし続ける ~新卒1年
    目 成長の軌跡~#5|ミクシル
    https://mixil.mixi.co.jp/people/2103
    mxcl/PromiseKit: Promises for Swift & ObjC
    https://github.com/mxcl/PromiseKit

    View full-size slide