SceneKitで作ったパズルゲームをリリースした

## はじめに
iOSのScenekKitで作ったゲームをリリースしたので、メモとして残しておきます。

## どんなゲーム？
一言で言うと動画のジグソーパズル。
ピースが下部に配置されているので、それを画面上部のエリアにドラッグし、上下左右を合わせて正しい形にセットするジグソーパズルっぽいゲーム。
（要は昔ファミコンであったきね子というゲーム）

![Simulator Screenshot - iPad Air 13-inch (M2) - 2024-09-06 at 17.05.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30218/9bfc7173-1a4c-a314-f591-4e1315374650.png)
![Simulator Screenshot - iPad Air 13-inch (M2) - 2024-09-01 at 13.45.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30218/8a4f7537-efd8-ee7d-e987-9ddc8af23ddf.png)


## アーキテクチャ（仕組み）

・動画部分を何で作るか
静止画のパズルであれば、画像を分割すればいいだけ。
動くパズルということで、ここを何で作るかが一番の課題。

movやmpegなどのムービーファイルを用いる案は、実装は簡単そうだけど、動画自体をオリジナルで作るのは大変。どこかから用意する必要がある。

リアルタイムに描画して作成したほうがいいと思ったので、3Dも可能な
SceneKitを採用。ただしこちらは実装が多分難しそう。
かつSceneKitは結構マイナーな技術なのでWEBに情報はあまりなさそう。

ただ現在はAIがあるので、聞きながら作れば、情報がないのはあまり関係ない。


## 実装ポイント

SceneKitのViewは分割できるか？が一番のポイント

解決方法として、
SceneKit の View を CADisplayLink でミラーリング し、それを分割して各 View に適用する方法を採用。下記にざっくり概要を解説。

* SCNView のミラーリング

SceneKit の View (SCNView) を 定期的にキャプチャ し、それを画像として利用するために CADisplayLink を使用します。

```
var _displayLink: CADisplayLink!

func startMirroring() {
    _displayLink = CADisplayLink(target: self, selector: #selector(updateMirrorView))
    _displayLink.preferredFramesPerSecond = 30  // 30fps
    _displayLink.add(to: .current, forMode: .common)
}

func stopMirroring() {
    _displayLink.invalidate()
    _displayLink = nil
}
```
✔ CADisplayLink を利用することで、一定間隔で SceneKit の描画をキャプチャ可能
✔ フレームレートを 30fps に設定し、負荷を抑える

* SceneKit の描画を画像として取得

UIGraphicsImageRenderer を使用し、現在の SceneKit の描画内容を画像 (UIImage) として取得 します。

```
@objc func updateMirrorView() {
    let renderer = UIGraphicsImageRenderer(size: scnView.bounds.size)
    let image = renderer.image { _ in
        scnView.drawHierarchy(in: scnView.bounds, afterScreenUpdates: false)
    }
```
✔ drawHierarchy(in:afterScreenUpdates:) を使うことで、現在の SceneKit の描画をそのままキャプチャ
✔ リアルタイムの表示内容を取得可能

* 画像の分割

キャプチャした UIImage を グリッド状に分割し、各セルの View に適用 します。
```
    var croppedImages: [UIImage] = []  // 分割後の画像を保持する配列
    let scale = Int(UIScreen.main.scale)  // デバイスのスケールを考慮
    let cropWidth = Int(scnView.bounds.width) / Board.shared.colCount * scale
    let cropHeight = Int(scnView.bounds.height) / Board.shared.rowCount * scale

    for y in 0..<Board.shared.rowCount {
        for x in 0..<Board.shared.colCount {
            let cropRect = CGRect(x: x * cropWidth, y: y * cropHeight, width: cropWidth, height: cropHeight)
            guard let cgImage = image.cgImage?.cropping(to: cropRect) else {
                continue
            }
            let croppedImage = UIImage(cgImage: cgImage)
```
✔ cgImage?.cropping(to:) を使って画像を切り出し
✔ デバイスの scale を考慮し、高品質な分割を実現

* 画像のリサイズ

分割した画像を、各 View のサイズに合わせてリサイズします。
```
            let newSize = CGSize(width: Board.shared.pieceWidth, height: Board.shared.pieceHeight)
            UIGraphicsBeginImageContextWithOptions(newSize, false, 0.0)
            croppedImage.draw(in: CGRect(origin: .zero, size: newSize))
            let resizedImage = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
            
            croppedImages.append(resizedImage!)
        }
    }
}
```
✔ UIGraphicsBeginImageContextWithOptions を使って適切なサイズにリサイズ
✔ 高解像度デバイス（Retinaディスプレイ）でも美しく表示

## ステージ追加は楽

SceneKitで作ったことによって、気分転換がてらに3Dの勉強しつつステージを追加してアップデートできるので、なかなかいい仕組みで作れたと思います。（現在７ステージ）
![Simulator Screenshot - iPhone 16 Pro - 2025-02-15 at 16.14.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30218/0abea026-0ed7-41a8-b67f-53d098bc1fe9.png)

## AppStoreリンク
https://apps.apple.com/jp/app/%E5%8B%95%E3%81%8F%E3%82%B8%E3%82%B0%E3%82%BD%E3%83%BC%E3%83%91%E3%82%BA%E3%83%AB-motionpuzzles/id6636527683