ROPPONGI.swift #5 での登壇資料です。
(΄΅)ඪ४ϥΠϒϥϦ͚ͩͰεϩοτήʔϜΛ࣮ͨ͠ROPPONGI.swift #5@imaizume
View Slide
今泉 智博 @imaizume2017年株式会社ミクシィ新卒入社株式会社 所属iOS利用経験0から iOS版開発担当に(ほぼ)毎日健康COMP生活はもうすぐ1周年
1. 背景2. スロットの仕様3. スロット実装の解説とDEMO4. 実装過程での課題と解決方法5. 将来的な話ຊͷINDEX
夏はマッチングアプリが盛り上がる大切な時期Poiboyも夏にキャンペーンを実施今年の企画: スロットゲームで豪華賞品プレゼントഎܠ実は昨年やるはずがビジネス的事情により1年寝かされていました詳細は↓の登壇&インタビューを御覧くださいhttps://mixil.mixi.co.jp/people/2103
(1)Symbol: 絵柄1つのこと(2)Reel: 絵柄のパターンの1セット(3)ReelSet: 複数リールのセット(imaizumeオリジナル用語)(4)Drum: スロットの中の1レーン(右の図では3ドラム)ຊ…ͷલʹεϩοτήʔϜͷ༻ޠղઆギャンブルに詳しくない方のためにこのあとの説明で必要になるので(1)(2)(3)スロットの例(4)
✓ ゲームの開始はユーザータイミング✓ 当たり判定はゲーム開始時に決定済み✓ 3つのドラムは独立して回転✓ 回転数不明=無限スクロール✓ 適度な回転スピードと停止時の加速度✓ 目標となる絵柄を各ドラムの中心で停止✓ 賞品ポップアップを全ドラムの停止後に表示✓ Auto Layout対応必須ՆΩϟϯϖʔϯͷεϩοτήʔϜͷ༷あなたならどうやってスロットを実装しますか❓(※࣌SwiftΛॻ͖࢝Ίͯ4 ϲ݄
1: SpriteKitで絵柄単体を動かす ❌速度を変えたときに絵柄間の間隔が変わる (渋滞の車列みたいになる)2: UICollectionView / UITableView 絵柄の余白調整や中心での停止が難しい cellのreuseによる表示のバグも懸念3. UIScrollView ⭕一番自由度が高く良さそう 中心で止められそう͕ࣗߟ࣮͑ͨͷީิ(※લड़ͷొஃࢿྉͰUICollectionViewΛͬͨͱ͋ΔͷޡΓͰ͢ )
1.絵柄に対応するUIViewを作成2.絵柄を連ねてReelを作成3.複数のReel群 = ReelSetを縦につなげる4.ReelSetをUIScrollViewに入れDrumにするͲ͏࣮ͬͯݱ͔ͨ͠(View)Symbol Reel ReelSet Drum1可視領域
࣮ࡍͷView֊
1.遠くの座標にcontentOffsetを設定2.下に向かって自動スクロールで下端到達3.上部の同絵柄にジャンプ4.1~3を繰り返すͲ͏࣮ͬͯݱ͔ͨ͠(ಈ͖)setContentOffset(y: 5000)~~~2.下端に到達 3.同じ絵柄にジャンプdrumScrollView.setContentOffset(500)すぐにゴールを遠くに戻すy=5000y=01.offsetを遠くに
ίϯϙʔωϯτͷਤDrumScrollView: UIScrollViewenum Symbol: Intsymbol: [Symbol]numberOfReel: IntsymbolViews:[UIView]drum: DrumScrollView✕drumSet: [DrumScrollView]numberOfDrum: Int✕
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を定義
DumScrollView(͖͍͠ઃఆ)/// 残り回転数fileprivate var remainingScrollCount: Int = 0/// 回転を減速するべきかfileprivate var shouldSlowDownScroll: Bool {let isNearTheTarget = abs(self.contentOffset.y - self.stopTargetPoint) < self.scrollTargetDistanceForSlowSpeedlet isLastScroll = self.remainingScrollCount == 0return isNearTheTarget && isLastScroll}/// 回転を停止するべきかfileprivate var shouldFinishScroll: Bool {let isOnTheTarget = abs(self.contentOffset.y - self.stopTargetPoint) < self.bufferForTargetPositionlet isLastScroll = self.remainingScrollCount == 0return 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: 停止位置にはバッファ(ズレの許容度)を与える回転速度が早すぎるとバッファを通過してしまう ズレは小さくしたいので直前で減速させる
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.yself.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)}}停止後に呼ぶコールバック後で解説
❇とりあえず時間をもらった (いろいろ試すしかない)チームの理解も得られていろいろとトライさせてくれた໘ͨ͠՝ͱղܾํ๏1. ࣾ֎Θͣલྫ͕ͳ͍力技(ひたすらdebugポイントとprintを埋め込み)タイミングよくrevealを教えてもらったのはラッキーSymbolやDrumに処理を移譲してカプセル化2. ಈ͖ͷσόοά͕େมOffsetや回転スピードの関係を確認するため値を計測してグラフ化したりもしました ➡
໘ͨ͠՝ͱղܾํ๏UIScollViewはスクロールスピードを指定できない現在のOffset + αにsetContentOffsetで定速スクロール3. ఀࢭݮ࣌ͷՃௐoffset: 100drumScrollView.setContentOffset(100 + 200)offset: 100drumScrollView.setContentOffset(100 + 1000)offset: 120drumScrollView.setContentOffset(120 + 200) offset: 200drumScrollView.setContentOffset(200 + 1000)
ここだけPromiseKitを使いました各Drumの停止時コールバックでfulfillをcall3つのDrumの停止後に当選ポップアップを表示(当選結果自体はViewController側で保持)let games = self.drumScrollViews.map { drum inreturn Promise { (fullfil, _) indrum.gameFinishCallback = { fullfil(()) }}}// MEMO: 当選ポップアップの表示when(resolved: games).always {self.showWinPopup()}໘ͨ͠՝ͱղܾํ๏4. ಠཱͨ͠ճసͷऴྃͷͪ߹Θͤ
কདྷతͳϥΠϒϥϦԽ͍ͨ͠‣ 今回はゲーム開始前に結果が決まっていた‣ 本来は動的に停止位置や速度調整をする必要あり‣ Viewの制約で上端や下端では止められない‣ 横スクロールにすれば回転寿司みたいなUIも作れそう?PromiseKitͷษڧձΛͦͷ͏ͪΓ·͢‣ 今回の実装でPromiseKitをうまく扱えるようになった‣ コールバック直書きのコードもいい感じに書き換えた‣ 現在勉強会企画中 (SupporterZ CoLabを予定)
ࢀߟࢿྉ新規サービスのアプリ開発で経験したリアルな出来事https://speakerdeck.com/imaizume/xin-gui-sabisufalseapurikai-fa-dejing-yan-sitariarunachu-lai-shi失敗こそ学びの力に!貪欲にチャレンジし続ける ~新卒1年目 成長の軌跡~#5|ミクシルhttps://mixil.mixi.co.jp/people/2103mxcl/PromiseKit: Promises for Swift & ObjChttps://github.com/mxcl/PromiseKit