6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

iOSAdvent Calendar 2023

Day 10

スライドパズルをつくろう(Swift)

Last updated at Posted at 2023-12-10

Xcode-15 iOS-17

はじめに

こんな感じの任意の画像をつかった 3 × 3 のスライドパズルをつくります!

puzzle

今回は簡単そうなので UIKit でやります。

ソース全体

説明とかいらない人はこちらをどうぞ。

ソース
import UIKit

enum Direction {
    case up, down, right, left
}

extension UIImage {

    func trimming(rect: CGRect) -> UIImage {
        return UIImage(cgImage: self.cgImage!.cropping(to: rect)!,
                       scale: self.scale,
                       orientation: self.imageOrientation)
    }
}

final class ViewController: UIViewController {

    private var views: [UIView] = []
    private var back: UIView!
    private let pieceSize: CGFloat = 50

    override func viewDidLoad() {
        super.viewDidLoad()

        back = UIView(frame: .init(origin: view.center, size: .init(width: pieceSize*3, height: pieceSize*3)))
        back.center = view.center
        back.backgroundColor = .black
        view.addSubview(back)

        setupPuzzle()
    }

    private func setupPuzzle() {
        var imageIndexes = [Int]()
        while true {
            if let indexes = makeIndexes() {
                imageIndexes = indexes
                break
            }
        }

        let image = UIImage(named: "target")!
        let width = image.size.width/3
        let height = image.size.height/3
        var images = [UIImage]()
        for i in 0...2 {
            let x = width * CGFloat(i)
            for j in 0...2 {
                let y = height * CGFloat(j)
                let img = image.trimming(rect: .init(origin: .init(x: x, y: y), size: .init(width: width, height: height)))
                images.append(img)
            }
        }

        var index = 0
        for i in 0...2 {
            let x = pieceSize * CGFloat(i)
            for j in 0...2 {
                let y = pieceSize * CGFloat(j)
                let v = UIImageView(frame: .init(origin: .init(x: x, y: y),
                                                 size: .init(width: pieceSize, height: pieceSize)))
                v.image = images[imageIndexes[index]]
                v.isUserInteractionEnabled = true
                back.addSubview(v)
                views.append(v)
                index += 1
            }
        }
        views.popLast()?.removeFromSuperview()
    }

    private func makeIndexes() -> [Int]? {
        let startIndexes = [0, 1, 2, 3, 4, 5, 6, 7]
        var imageIndexes = startIndexes.shuffled()
        let result = imageIndexes + [8]

        var count = 0
        for i in 0..<imageIndexes.count-1 {
            let t1 = imageIndexes[i]
            let t2 = startIndexes[i]
            if t1 == t2 {
                continue
            } else {
                let index = imageIndexes.firstIndex(of: t2)!
                imageIndexes[i] = t2
                imageIndexes[index] = t1
                count += 1
            }
        }
        return count%2 == 0 ? result : nil
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first,
              let target = touch.view,
              views.contains(target) else {
            return
        }

        func _calculateDirection(touch: UITouch) -> Direction {
            let currentLocation = touch.location(in: view)
            let preLocation = touch.previousLocation(in: view)
            let x = currentLocation.x - preLocation.x
            let y = currentLocation.y - preLocation.y
            let direction: Direction
            if abs(x) > abs(y) {
                direction = x < 0 ? .left : .right
            } else {
                direction = y < 0 ? .up : .down
            }
            return direction
        }

        let targetPosition = target.frame.origin
        var position = targetPosition
        switch _calculateDirection(touch: touch) {
        case .up:
            position.y -= pieceSize
        case .down:
            position.y += pieceSize
        case .right:
            position.x += pieceSize
        case .left:
            position.x -= pieceSize
        }
        if !back.bounds.contains(position) || views.contains(where: { $0.frame.contains(position) }) {
            return
        }
        target.frame = .init(origin: position, size: target.frame.size)
    }
}

手順

スライドパズルで必要そうなのは下記。

  1. 任意の画像を9分割する
  2. 分割した画像をランダムに配置
  3. ジェスチャで分割した画像を動かす

たぶんこれができればスライドパズルがつくれます。

任意の画像を9分割する

下記の UIImage の extension を使って画像を分割します。

extension UIImage {

    func trimming(rect: CGRect) -> UIImage {
        return UIImage(cgImage: self.cgImage!.cropping(to: rect)!,
                       scale: self.scale,
                       orientation: self.imageOrientation)
    }
}

単純化するために正方形の画像前提で作業します。

let image = UIImage(named: "target")!
let width = image.size.width/3
let height = image.size.height/3
var images = [UIImage]()
for i in 0...2 {
    let x = width * CGFloat(i)
    for j in 0...2 {
        let y = height * CGFloat(j)
        let img = image.trimming(rect: .init(origin: .init(x: x, y: y), size: .init(width: width, height: height)))
        images.append(img)
    }
}      

これで images の中には下記のような 9 分割された UIImage が格納されます。

1

分割した画像をランダムに配置

ランダム配置は上記で作成した images をシャッフルすればいいかなと思ったのですがスライドパズルにはどうやら完成不可になる初期配置というものがあるようです。

参考:
8パズル,15パズルの不可能な配置と判定法

判定方法は下記らしいです。

s に対応する状態からはじめたとき,

8パズルが完成できる ⟺ s を t にする置換のパリティ(偶奇)と「空き」の最短距離の偶奇が等しい。

よくわかりませんが今回は単純化するために空きの場所を右下に固定します。
空きの最短距離は常に 0 なので置換のパリティ(偶奇)が偶になればいいことになります。

たぶんこれでいけるはず。

private func makeIndexes() -> [Int]? {
    // 0~7 までをシャッフルして 8(空き)は右下固定なので後から追加
    let startIndexes = [0, 1, 2, 3, 4, 5, 6, 7]
    var imageIndexes = startIndexes.shuffled()
    let result = imageIndexes + [8]

    // シャッフルした配列(imageIndexes)が何回の操作で初期配置(startIndexes)に戻るか調べる
    var count = 0
    for i in 0..<imageIndexes.count-1 {
        let t1 = imageIndexes[i]
        let t2 = startIndexes[i]
        if t1 == t2 {
            continue
        } else {
            let index = imageIndexes.firstIndex(of: t2)!
            imageIndexes[i] = t2
            imageIndexes[index] = t1
            count += 1
        }
    }
    // 偶数回の操作だったら結果を返す
    return count%2 == 0 ? result : nil
}

// これで置換のパリティ(偶奇)が偶になるまで処理を続ける
var imageIndexes = [Int]()
while true {
    if let indexes = makeIndexes() {
        imageIndexes = indexes
        break
    }
}

画像のシャッフルはできたのでこれを画面に配置していきます。

画面中央に 150 × 150 の View を用意してここに画像を配置します。

final class ViewController: UIViewController {

    private var views: [UIView] = []
    private var back: UIView!
    private let pieceSize: CGFloat = 50

    override func viewDidLoad() {
        super.viewDidLoad()

        // 画像を配置する View を用意
        back = UIView(frame: .init(origin: view.center, size: .init(width: pieceSize*3, height: pieceSize*3)))
        back.center = view.center
        back.backgroundColor = .systemGreen
        view.addSubview(back)

        // 画像配置
        setupPuzzle()
    }

    private func setupPuzzle() {
        // 画像の配置を決める
        var imageIndexes = [Int]()
        while true {
            if let indexes = makeIndexes() {
                imageIndexes = indexes
                break
            }
        }

        // 画像を9分割
        let image = UIImage(named: "target")!
        let width = image.size.width/3
        let height = image.size.height/3
        var images = [UIImage]()
        for i in 0...2 {
            let x = width * CGFloat(i)
            for j in 0...2 {
                let y = height * CGFloat(j)
                let img = image.trimming(rect: .init(origin: .init(x: x, y: y), size: .init(width: width, height: height)))
                images.append(img)
            }
        }

        // 画像を画面上に配置
        var index = 0
        for i in 0...2 {
            let x = pieceSize * CGFloat(i)
            for j in 0...2 {
                let y = pieceSize * CGFloat(j)
                let v = UIImageView(frame: .init(origin: .init(x: x, y: y),
                                                 size: .init(width: pieceSize, height: pieceSize)))
                v.image = images[imageIndexes[index]]
                back.addSubview(v)
                views.append(v)
                index += 1
            }
        }

        // 右下の空きをつくる
        views.popLast()?.removeFromSuperview()
    }
}
2

ジェスチャで分割した画像を動かす

あとはジェスチャで画像を動かすだけです。

ViewController を使っているのでジェスチャには以下を使います。

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

画像を動かせる方向は決まっているので以下のような判定を入れる必要があります。

  • どの画像を動かそうとしているのか
  • どの方向に動かそうとしているのか
  • 指定の方向に動かすことは可能か

どの画像を動かそうとしているのか

これは簡単で以下のようにすると触っている View がとれます。
views は配置した画像(UIImageView)のリストなので views.contains(target) で触っているのが画像なのか判定しています。

override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
     guard let touch = touches.first,
          let target = touch.view,
          views.contains(target) else {
        return
    }
}

注意点として UIImageViewisUserInteractionEnabled はデフォが false なので true にしておく必要があります。

どの方向に動かそうとしているのか

下記でジェスチャの方向を判定します。
とくに説明することもないですが縦横の移動量を比較して大きい方を優先しています。

enum Direction {
    case up, down, right, left
}

func _calculateDirection(touch: UITouch) -> Direction {
    let currentLocation = touch.location(in: view)
    let preLocation = touch.previousLocation(in: view)
    let x = currentLocation.x - preLocation.x
    let y = currentLocation.y - preLocation.y
    let direction: Direction
    if abs(x) > abs(y) {
        direction = x < 0 ? .left : .right
    } else {
        direction = y < 0 ? .up : .down
    }
    return direction
}

指定の方向に動かすことは可能か

移動先が下記の場合は移動不可です。

  • 枠外
  • 別の画像がある

こんな感じ。

let targetPosition = target.frame.origin
// 移動先
var position = targetPosition
switch _calculateDirection(touch: touch) {
case .up:
    position.y -= pieceSize
case .down:
    position.y += pieceSize
case .right:
    position.x += pieceSize
case .left:
    position.x -= pieceSize
}
if !back.bounds.contains(position) || views.contains(where: { $0.frame.contains(position) }) {
    // 枠外もしくは他の画像がある
    return
}
// 移動
target.frame = .init(origin: position, size: target.frame.size)

これで完成:tada:

おわりに

スライドパズル作成は競プロぽくて普段の業務では使わない頭を使ったのでなかなか面白かったです。もっといい感じに書けるかもしれないので気が向いたらだれか挑戦してみてください。

暗号学園のいろは 3 (ジャンプコミックス)を読んでスライドパズルて解けない配置あるんだというのを知ったのが作った動機です。

6
2
0

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
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?