post Image
ARKitとPolyでクリスマスっぽいソシャゲを作ってみた

こんにちは!Life is Tech !でiPhoneメンターしているとうようです!

この記事はLife is Tech ! メンター Advent Calendar 2017の12日目の記事です。今回紹介する内容のARKitの部分を抽出してとARKit Advent Calendar 2017の12日目の記事も用意しているのでそちらもよければご覧ください。

ちなみにメンターの有志でやっているLife is Tech !メンターのアドベントカレンダーAdventarにもカレンダーを作成していますのであわせてチェックしてみてください。

さぁ怒涛のiPhoneメンター三連チャン最終日となった今日ですが、未来のクリスマス風アプリを製作した上での技術的な点を語って締めたいと思います。初心者へのHow Toというよりは中級者へのTips的な面が強く結構難易度高めですが楽しんでくれたらうれしいです!!!


※この記事では製作の過程を大雑把に追っていきながら、その中でさまざまなTopicを紹介していければと思っています。細かく目次をつけておきますので、もし長いなと思ったら適宜飛ばしながら読んでもらえればと思います。

きっかけ

今回の記事のアプリは、「せっかくだしクリスマスっぽいなにかを作りたいな」という思いから生まれました。

そこでメンターの勉強会でもハンズオンを行ったARKitを使ってなにか面白いアプリがつくれたらと思い考え始めました。

作るもの

そんなわけで今回作るアプリはその名もXmas Present Goです。(著作権バリバリ引っかかりそうな危ういアプリなのでリリースはしない予定です笑)

具体的には以下のようなフローで遊べるアプリを目指します。

  1. ユーザーは任意の場所になんらかのプレゼントを置くことができる
  2. その後他のユーザーが同じ場所にいくとそこにプレゼントの箱が置いてある
  3. プレゼントをタップするとそのプレゼントを獲得できる

シンプルながらARKitの応用例としていい感じです。

技術・サービスの選択

次にこのアプリを作るために用いる技術やサービスについて考え、2つのものを採用することに決めました。

まずひとつは他の人とプレゼントを共有するという機能を実現するためのデータベースの選択です。今回はリリースの予定が無かったこともあり、これには定番のFirebaseを利用することに決めました。

もうひとつはARKitでプレゼントを置くとなった時にいかにそこにクリスマス感、そして飽きさせない多様性を演出するかということでした。せっかく現実世界でのゲームになるので3Dを使いたいのですが、これを実現できるモデリング力は残念ながら持ち合わせていません。そのため無料で3Dモデルを提供してくれるサービスを探す必要がありました。

Polyの登場

そんな時登場したのがPolyというサービスです。Googleが提供している3Dモデルの共有サイトなのですが、このサービスがARKitに対応した素材を提供するというニュースをたまたま目にしました。

ScreenShot 2017-12-01 10.51.15.png
Polyのトップページ

これからのARKit開発においても重要な立ち位置を占めて来そうな予感もあって、僕はこのサイトからいくつかCCライセンスのクリスマスプレゼントっぽいモデルデータをダウンロードし利用させていただくことにしました。

難航する製作

ここからは製作途中に困ったことを述べつつ、様々なトピックに注目していきたいと思います。

ARKitの限界

機能としてはシンプルなアプリだったのですが、基本コンセプトに実は大きな壁がありました。それは

ARKitの取得する座標はあくまでARAnchorやカメラからの相対座標にすぎない

というものです。つまりある端末でプレゼントを置いてもそれを他の端末で同じ場所に出現させるというのは単純に実装するだけでは出来ませんでした。

ですがこれは全くの実現不可能ということではありません。

例えばWorld Brushというアプリは似たようなアイデアで「世界中の人がARで自由に落書きをしてそれを共有できる」というものです。これを知っていたのでやってみようの精神で調査をはじめました。

似たサンプルアプリを探す

手始めに見つけたのがこのナビゲーションをARで実現するチュートリアルシリーズでした。

現状まだ途中なのですが、完成版コードもあがっており比較的丁寧に解説してくれています。(※英語です)

たとえばCoreLocationのextensionとして以下のようにある座標からある座標への方位角などが求められます。

CLLocationCoordinate2D+Extension.swift
let metersPerRadianLat: Double = 6373000.0
let metersPerRadianLon: Double = 5602900.0

extension CLLocationCoordinate2D {

    /// Calcurate bearing toward other coordinate
    func bearing(to coordinate: CLLocationCoordinate2D) -> Double {

        let a = sin(coordinate.longitude.radian - longitude.radian) * cos(coordinate.latitude.radian)
        let b = cos(latitude.radian) * sin(coordinate.latitude.radian) - sin(latitude.radian) * cos(coordinate.latitude.radian) * cos(coordinate.longitude.radian - longitude.radian)
        return atan2(a, b)
    }

    /// Calcurate direction toward other coordinate
    func direction(to coordinate: CLLocationCoordinate2D) -> CLLocationDirection {

        return self.bearing(to: coordinate).degree
    }

    /// Calcurate coordinate with bearing and distance
    func coordinate(with bearing: Double, and distance: Double) -> CLLocationCoordinate2D {

        let distRadiansLat = distance / metersPerRadianLat
        let distRadiansLon = distance / metersPerRadianLon

        let lat1 = self.latitude.radian
        let lon1 = self.longitude.radian

        let lat2 = asin(sin(lat1) * cos(distRadiansLat) + cos(lat1) * sin(distRadiansLat) * cos(bearing))
        let lon2 = lon1 + atan2(sin(bearing) * sin(distRadiansLon) * cos(lat1), cos(distRadiansLon) - sin(lat1) * sin(lat2))

        return CLLocationCoordinate2D(latitude: lat2.degree, longitude: lon2.degree)
    }
}

extension Double {

    /// Convert degree value to radian value
    var radian: Double {

        return self * .pi / 180.0
    }

    /// Convert radian value to degree value
    var degree: Double {

        return self * 180.0 / .pi
    }
}

これはデモのREADMEにあるGIFからもわかるように地図上の一定距離離れた地点にモデルを置くという処理に対して非常に有効な手段となります。細かい解説は元記事に任せますが、たとえば自分の現在位置と置きたい場所の座標を用いてどの位置にモデルを出現させるかということを決める行列を求めるメソッド群が以下のように書けます。

MatrixHelper
static func rotateAroundY(with matrix: matrix_float4x4, for degrees: Float) -> matrix_float4x4 {

    var matrix: matrix_float4x4 = matrix

    matrix.columns.0.x = cos(degrees)
    matrix.columns.0.z = -sin(degrees)

    matrix.columns.2.x = sin(degrees)
    matrix.columns.2.z = cos(degrees)
    return matrix.inverse
}

static func translationMatrix(with matrix: matrix_float4x4, for translation: vector_float4) -> matrix_float4x4 {

    var matrix = matrix
    matrix.columns.3 = translation
    return matrix
}

/// This method calcurate distance and bearing between one and the other location
static func transformMatrix(for matrix: simd_float4x4, originLocation: CLLocation, location: CLLocation) -> simd_float4x4 {

    let distance = Float(location.distance(from: originLocation))
    let bearing = GLKMathDegreesToRadians(Float(originLocation.coordinate.direction(to: location.coordinate)))
    let position = vector_float4(0.0, 0.0, -distance, 0.0)
    let translationMatrix = MatrixHelper.translationMatrix(with: matrix_identity_float4x4, for: position)
    let rotationMatrix = MatrixHelper.rotateAroundY(with: matrix_identity_float4x4, for: bearing)
    let transformMatrix = simd_mul(rotationMatrix, translationMatrix)
    return simd_mul(matrix, transformMatrix)
}

このまま使っても良かったのですが、今回はナビゲーションよりさらに高い精度を求められるアプリというのもあり、記事のように地球を完全な球体と見た近似は粗い可能性がありました。そのためもっと精密に求められるアルゴリズムを探しました。

Vincentyの公式

そこで見つけたのがこのページで使われていた距離と方位角から緯度経度を算出するという公式です。Vincentyの公式というもので、最初はWikipediaを見ながらやっていたのですが、数式から落とし込む時にミスが多かったので最終的にページのJavaScriptをSwiftに変換する方針で次のように実装しました。

Vincentry
/// Radius at equator [m]
private let a: Double = 6378137.0
/// Flattening of the ellipsoid
private let f: Double = 1 / 298.257223563
/// Radius at the poles [m]
private let b: Double = 6356752.314245
/// Reduced latitude
private func u(of latitude: Double) -> Double {

    return atan((1 - f) * tan(latitude))
}

// MARK: - Internal

func calcurateDistanceAndAzimuths(at location1: CLLocationCoordinate2D, and location2: CLLocationCoordinate2D) -> (s: Double, a1: Double, a2: Double) {

    let lat1 = location1.latitude.radian
    let lat2 = location2.latitude.radian
    let lon1 = location1.longitude.radian
    let lon2 = location2.longitude.radian

    let omega = lon2 - lon1
    let tanU1 = (1 - f) * tan(lat1)
    let cosU1 = 1 / sqrt(1 + pow(tanU1, 2.0))
    let sinU1 = tanU1 * cosU1
    let tanU2 = (1 - f) * tan(lat2)
    let cosU2 = 1 / sqrt(1 + pow(tanU2, 2.0))
    let sinU2 = tanU2 * cosU2

    var lambda = omega
    var lastLambda = omega - 100

    var cos2alpha: Double = 0.0
    var sinSigma: Double = 0.0
    var cosSigma: Double = 0.0
    var cos2sm: Double = 0.0
    var sigma: Double = 0.0
    var sinLambda: Double = 0.0
    var cosLambda: Double = 0.0

    while abs(lastLambda - lambda) > pow(10, -12.0) {

        sinLambda = sin(lambda)
        cosLambda = cos(lambda)
        let sin2sigma = pow(cosU2 * sinLambda, 2.0) + pow(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda, 2.0)
        sinSigma = sqrt(sin2sigma)
        cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda
        sigma = atan2(sinSigma, cosSigma)
        let sinAlpha = cosU1 * cosU2 * sinLambda / sinSigma
        cos2alpha = 1 - pow(sinAlpha, 2.0)
        if cos2alpha == 0 {

            cos2sm = 0
        } else {

            cos2sm = cosSigma - 2 * sinU1 * sinU2 / cos2alpha
        }
        let C = f / 16.0 * cos2alpha * (4 + f * (4 - 3 * cos2alpha))
        lastLambda = lambda
        lambda = omega + (1 - C) * f * sinAlpha * (sigma + C * sinSigma * (cos2sm + C * cosSigma * (2 * pow(cos2sm, 2.0) - 1)))
    }

    let u2 = cos2alpha * (pow(a, 2.0) - pow(b, 2.0)) / pow(b, 2.0)
    let A = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
    let B = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
    let dSigma = B * sinSigma * (cos2sm + B / 4 * (cosSigma * (2 * pow(cos2sm, 2.0) - 1) - B / 6 * cos2sm * (4 * pow(sinSigma, 2.0) - 3) * (4 * pow(cos2sm, 2.0) - 3)))

    // Result
    let s = b * A * (sigma - dSigma)
    let a1 = atan2(cosU2 * sinLambda, cosU1 * sinU2 - sinU1 * cosU2 * cosLambda)
    let a2 = atan2(cosU1 * sinLambda, cosU1 * sinU2 * cosLambda - sinU1 * cosU2)
    return (s: s, a1: a1.degree, a2: a2.degree)
}

func calcurateNextPointLocation(from location: CLLocationCoordinate2D, s: Double, a1: Double) -> (location: CLLocationCoordinate2D, a2: Double) {

    let latRad = location.latitude.radian
    let lonRad = location.longitude.radian
    let a1Rad = a1.radian

    let u1 = u(of: latRad)
    let sigma1 = atan2(tan(u1), cos(a1Rad))
    let sinalp = cos(u1) * sin(a1Rad)
    let cos2alp = 1 - pow(sinalp, 2.0)
    let u22 = cos2alp * (pow(a, 2.0) - pow(b, 2.0)) / pow(b, 2.0)
    let A = 1 + u22 / 16384 * (4096 + u22 * (u22 * (320 - 175 * u22) - 768))
    let B = u22 / 1024 * (256 + u22 * (u22 * (74 - 47 * u22) - 128))

    var sigma = s / b / A
    var lastSigma = sigma - 100

    var dm2: Double = 0.0

    while abs(lastSigma - sigma) > pow(10, -9.0) {

        lastSigma = sigma
        dm2 = 2 * sigma1 + sigma
        let x = cos(sigma) * (2 * pow(cos(dm2), 2.0) - 1) - B / 6 * cos(dm2) * (4 * pow(sin(dm2), 2.0) - 3) * (4 * pow(cos(dm2), 2.0) - 3)
        let dsigma = B * sin(sigma) * (cos(dm2) + B / 4 * x)
        sigma = s / b / A + dsigma
    }

    let x = sin(u1) * cos(sigma) + cos(u1) * sin(sigma) * cos(a1Rad)
    let y = (1 - f) * sqrt(pow(sinalp, 2.0) + pow(sin(u1) * sin(sigma) - cos(u1) * cos(sigma) * cos(a1Rad), 2.0))
    let lambda = atan2(sin(sigma) * sin(a1Rad), cos(u1) * cos(sigma) - cos(u1) * sin(sigma) * cos(a1Rad))
    let C = f / 16 * cos2alp * (4 + f * (4 - 3 * cos2alp))
    let z = cos(dm2) + C * cos(sigma) * (2 * pow(cos(dm2), 2.0) - 1)
    let dL = lambda - (1 - C) * f * sinalp * (sigma + C * sin(sigma) * z)

    // Result
    let latitude = atan2(x, y)
    let longitude = lonRad + dL
    let a2 = atan2(sinalp, cos(u1) * cos(sigma) * cos(a1) - sin(u1) * sin(sigma))
    return (location: CLLocationCoordinate2D(latitude: latitude.degree, longitude: longitude.degree), a2: a2.degree)
}

論文が30年ほど前に出されていたアルゴリズムで、これなら緯度経度から距離と方位角を測る演算も距離と方位角から緯度経度を導く演算もサポートしていてしかもかなりの精度が見込まれます。

せっかくどちらも実装したので同じ入力に対してどのくらい変わるのか確認してみたところ、Vincentyの公式では小数以下第六位ぐらいまであうところが先程の方法だと整数部分しかあっていないことがわかりました。これはかなりの差ですね。探してよかった。。。

これが実装できたことにより以下のようなフローでモデルの位置と座標を対応付けることにしました。

  1. モデルを置いた際は、座標を取得し置いた場所までの方位角と距離を出しておく
  2. 次にそれを置いた場所の緯度経度に変換する。ここまでできたらプレゼントの種類、緯度経度、ユーザーの端末のUUIDをセットにしてFirebaseにアップ
  3. データベースが更新されたら全てのデータをダウンロードする。そして新しいデータがあった場合、緯度経度と端末の位置からどの場所に置けばいいかを決定する。他人のプレゼントは置く際にプレゼントボックスにしておく
  4. モードを用意しGetモードではタップしたプレゼントボックスを中身のモデルに置き換える。また置くモードでは指定したモデルをタップした場所に置く。

高さは今回は割愛させてもらい進めていくことにしました。

方位角などの処理

方位角をカメラの向きをz軸とした座標系で計算するのはいささか骨が折れます。
そこで今回はy軸を重力の向き、x軸を東西、z軸を南北に固定したいと思います。

これにはARSessionのconfigurationのうちworldAlignmentという項目をいじります。

  • .camera
  • .gravity
  • .gravityAndHeading

の3つの中から選ぶことができるのですが、このうち.gravityAndHeadingがまさに求めていたものだったので採用しました。
具体的にどう設定すればいいかというと、SceneKitであればviewWillAppear(_ animated: Bool)内で

gravityAndHeading
override func viewWillAppear(_ animated: Bool) {

    super.viewWillAppear(animated)

    // Create a session configuration
    let configuration = ARWorldTrackingConfiguration()

    // z: North and South, x: East and West, y: parallel to gravity
    configuration.worldAlignment = .gravityAndHeading

    // Run the view's session
    sceneView.session.run(configuration)
}

というように書いてあげます。これで特別意識しなくとも座標と緯度経度の変換が簡単な計算と先程のメソッド達で計算できるようになりました。

というわけでMatrixHelperの実装を変更します。

MatrixHelper
/// This method calcurate distance and bearing between one and the other location
static func transformMatrix(for matrix: simd_float4x4, originLocation: CLLocation, location: CLLocation) -> simd_float4x4 {

    let (s: distance, a1: bearing, a2: a2) = VincentyHelper.shared.calcurateDistanceAndAzimuths(at: originLocation.coordinate, and: location.coordinate)
    let position = vector_float4(0.0, 0.0, Float(-distance), 0.0)
    let translationMatrix = MatrixHelper.translationMatrix(with: matrix_identity_float4x4, for: position)
    let rotationMatrix = MatrixHelper.rotateAroundY(with: matrix_identity_float4x4, for: Float(bearing.radian))
    let transformMatrix = simd_mul(rotationMatrix, translationMatrix)
    return simd_mul(matrix, transformMatrix)
}

/// This method calcurate ar location to real world location
static func transformLocation(for matrix: simd_float4x4, originLocation: CLLocation, location: SCNVector3) -> CLLocation {

    let x2 = pow(location.x, 2.0)
    let y2 = pow(location.y, 2.0)
    let z2 = pow(location.z, 2.0)
    // Bearing and distance in AR World
    let distance = sqrt(x2 + y2 + z2)
    let a1 = atan2(Double(location.x), Double(location.z)).degree

    let (location: location, a2: _) = VincentyHelper.shared.calcurateNextPointLocation(from: originLocation.coordinate, s: Double(distance), a1: a1)

    return CLLocation(latitude: location.latitude, longitude: location.longitude)
}

これで緯度経度と距離方位角を交互に変換する準備が整いました。

現在地取得は楽をしたい

さてここまで来たら次は現在地の取得です。現在地を取得するラッパーライブラリとしてLocationManagerなどがあると思いますが、これが最新のXcodeではうまく動かなかったのでかわりにこんなシングルトンをつくりました。(コードが長くなるのでGitHubの該当箇所へのリンクだけにし、記事としてコードは割愛します)

(これを使って得られた知見なのですが、シングルトンの初期化は一番最初に使った時に起きるんですね。バッドノウハウ的な雰囲気も漂ってたりしなかったりしますが、とりあえず出来ているのでこれを使っていきます。)

準備が整ったのでひとまずデータがちゃんととれるのか確認してみましょう。
以下のようにとりあえず画面の触ったところにプレゼントを置いて、その場所の座標を計算してみます。

touchesBegan
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {

    if let touchLocation = touches.first?.location(in: sceneView) {

        guard let firstHit = sceneView.hitTest(touchLocation, types: .featurePoint).first else {

            return
        }

        let hitTransform = SCNMatrix4(firstHit.worldTransform)
        let hitPosition = SCNVector3Make(hitTransform.m41, hitTransform.m42, hitTransform.m43)
        let newNode = ARManager.shared.generateModel(.present)
        newNode.position = hitPosition
        sceneView.scene.rootNode.addChildNode(newNode)
        print(MatrixHelper.transformLocation(for: matrix_identity_float4x4, originLocation: ARCLManager.shared.location, location: hitPosition).coordinate)
        print(ARCLManager.shared.location.altitude + Double(hitPosition.z))
    }
}

ここは何も工夫する必要はないですね。実際に動かしてみた所しっかりとそれらしき座標を出力することができました。

最後の壁:タップしたプレゼントを検知する

ここが最後の壁となりました。詳しくは別記事に書きますが、主な問題として

  1. ARSCNViewのhitTestはノードの情報を返してくれない
  2. 置いた後、そのままだと誰が置いたプレゼントなのか情報が持てなくなる

これをどうにかこうにか解決しました。

Firestoreとの通信・仕上げ

さてここまででARKitまわりの作業が終わったので最後に通信部分を作っていきます。

(※今回はきちんと利用できていませんが、実際にはLocalCollection.swiftというファイルにあるクラスを利用するとキャッシュが利用できるようになるようです。)

まずは保存するための構造体の定義です。DocumentSerializableというプロトコル(このプロトコルじゃなくても中にあるinitが使えれば十分便利)とEquatableプロトコル(後で既に置かれているオブジェクトかどうかをこのデータで判断したいため)を適用しています。

ObjectData
struct ObjectData {

    var latitude: Double
    var longitude: Double
    var object: Int
    var userID: String

    var dictionary: [String: Any] {

        return [
            "latitude": latitude,
            "longitude": longitude,
            "objectID": object,
            "userID": userID
        ]
    }
}

extension ObjectData: DocumentSerializable {

    init?(dictionary: [String : Any]) {

        guard let latitude = dictionary["latitude"] as? Double,
            let longitude = dictionary["longitude"] as? Double,
            let objectRaw = dictionary["objectID"] as? Int,
            let userId = dictionary["userID"] as? String else {

                return nil
        }

        self.init(latitude: latitude, longitude: longitude, object: objectRaw, userID: userId)
    }
}

extension ObjectData: Equatable {

    static func ==(lhs: ObjectData, rhs: ObjectData) -> Bool {

        return lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude &&
            lhs.object == rhs.object && lhs.userID == rhs.userID
    }
}

続いてこの構造体を実際に書き込んだり読み込んだりするためのメソッドを書いていきます。
長くなるのでコード自体はこちらを参照してください。

送信には将来的に汎用性の高いbatchを使いました。これを使うと同時に複数のdocumentに書き込むことができます。またqueryを自動で監視する実装もしっかりと行っています。
またデータが更新された時のためにデリゲートを用意しました。データが追加されるとobserveQueryで行われているaddSnapshotListenerによってデータが全て読み込まれ、その中で最新のデータをデリゲートメソッドに渡すことで画面に反映するようにするわけです。

大規模になってくるとほりー(@HALU5071)が9日目に紹介してくれていたCloud Functionsなどを利用して近くのデータ何件かだけ取ってくるといった処理が必要になりそうですが、今回は一旦この単純な方法で実装しました。

オブジェクトの更新処理自体は次のようになります。

FirestoreHelperDelegate.updateObjects
// MARK: - Firestore Delegate

extension MainViewController: FirestoreHelperDelegate {

    func updateObjects(_ objects: [ObjectData]) {

        for object in FirestoreHelper.shared.objects {

            guard !self.addedObjects.contains(object) else {

                continue
            }
            let location = CLLocation(latitude: object.latitude, longitude: object.longitude)
            let isMe = FirestoreHelper.shared.userId == object.userID
            let (dist, _, _) = VincentyHelper.shared.calcurateDistanceAndAzimuths(at: ARCLManager.shared.coordinate, and: location.coordinate)
            if abs(dist) < 100 {

                let newNode = ARManager.shared.generateModel(isMe ? ARManager.Model(rawValue: object.object)! : .present)
                newNode.objectData = object
                let newPosition = SCNMatrix4(MatrixHelper.transformMatrix(for: matrix_identity_float4x4, originLocation: ARCLManager.shared.location, location: location))
                newNode.position = SCNVector3Make(newPosition.m41, newPosition.m42, newPosition.m43)
                sceneView.scene.rootNode.addChildNode(newNode)
                addedObjects.append(object)
            }
        }
    }
}

先程Equatableを用意していたので既にあるものは飛ばすという処理がcontainsを使ってとてもシンプルに書けました。
一つ躓いたのはpositionの設定です。最初transformに直接MatrixHelperの結果を渡していたのですが、transformは都度上書きされてしまうので上のようにきちんと別に適用させるべきならば行列の一部をとってくる方針にするのが都合がいいです。

まとめ

以上で全ての工程が終わりました。実際の動作の様子を載せたかったのですが、iPhoneが二台以上必要なのと適切なロケーションなどが確保できなかったので一旦見送らせてください…汗

// TODO: ここにデモ動画を入れたい

スクショだとこんな感じです(壁の向こうらへんにあるせいで遠くてプレゼントがタッチできないの図)

IMG_4154.PNG

残っている課題として

  • 高さが変わってしまう
  • GPSの誤差で時々少し離れた場所に表示されてしまう(これでもいいアルゴリズムを使っているので限界なのかもしれません…)
  • 一度獲得したデータの状態が保存できておらずアプリを閉じるとまたプレゼントの状態に戻る

などがありますが、目指していたメイン機能は実装できたので今回は良しとします。

全体としてのコードはこちらにあります。MITライセンスとしているのでぜひクローンして問題点を解決してみたりプルリクおくってみたりしてください(設計が汚くなってしまってる感があるのはお許しを。。。)

さて明日はWebSメンターのたいが(@shichisan)がgulp bower skrollr.jsについて書いてくれるそうです!
ついに折り返し地点に来ましたがまだまだ続くLife is Tech ! Mentor Advent Calendar 2017、ぜひ最後まで楽しんでください👍👍

追記 (2017/12/12 16:01)

せっかくなのでDeploygateのリンクを貼っておきます

XmasPresentGo

多分適当なタイミングで打ち切るのと、Deploygateの使い方をマスターできるまで配布ができない気がするので、興味がある方のみぜひ入れてみてください。


『 Swift 』Article List