Swift 游戏开发之黎锦拼图(二)

1,543 阅读6分钟

前言

在上篇文章中,我们完成了对拼图的元素拆分和基本拖拽的用户操作逻辑。现在我们先来补充完整当用户拖拽拼图元素时的逻辑。

在现实生活中,拼图游戏总是被「禁固」在一个确定画布上,玩家只能在这个画布中发挥自己的想象力,恢复拼图。因此,我们也需要在画布上给用户限定一个「区域」。

从之前的两篇文章中,我们知道了「黎锦拼图」中的拼图元素只能在画布的左部分进行操作,不能超出屏幕之外的范围进行操作。因此我们需要对拼图元素做一个限定。

限定拼图

为了能够较好的看到元素的边界,我们先给拼图元素加上「边界」。补充 Puzzle 里的

extension Puzzle {
    @objc
    fileprivate func pan(_ panGesture: UIPanGestureRecognizer) {
        switch panGesture.state {
        case .began:
            layer.borderColor = UIColor.white.cgColor
            layer.borderWidth = 1
        case .changed:
        case .ended:
            layer.borderWidth = 0
        default: break
        }

        let translation = panGesture.translation(in: superview)
        center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
        panGesture.setTranslation(.zero, in: superview)
    }
}

加上边界的思路比较简单,我们的目的是为了让用户在拖拽拼图元素的过程,对拼图元素能够有个比较好的边界把控。运行工程,拖拽拼图元素,拼图元素的边界已经加上啦!

![拼图元素边界]](i.loli.net/2019/09/08/…)

限定拼图元素的可移动位置,可以在 Puzzle 的拖拽手势的回调方法中进行边界确认。我们先来「防止」拼图元素跨越画布的中间线。

extension Puzzle {
    @objc
    fileprivate func pan(_ panGesture: UIPanGestureRecognizer) {
        let translation = panGesture.translation(in: superview)
        
        let newRightPoint = centerX + width / 2
        
        switch panGesture.state {
        case .began:
            layer.borderColor = UIColor.white.cgColor
            layer.borderWidth = 1
        case .changed:
            if newRightPoint > superview!.width / 2 {
                right = superview!.width / 2
            }
        case .ended:
            layer.borderWidth = 0
        default: break
        }
        
        center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
        panGesture.setTranslation(.zero, in: superview)
    }
}

在拼图元素的拖拽回调方法里,在手势 state 枚举值的 .change 判断里,根据当前拼图元素的「最右边」位置,也就是 self.frame.origin.x + self.frame.size.width / 2 与父视图中间位置的对比,来决定出是否该拼图元素是否越界。

运行工程!发现我们再也不能把拼图元素拖到右边画布里去啦~

限定拼图

状态维护

经过上一个游戏「能否关个灯」的讲解,我们已经大致了解了如何通过状态去维护游戏逻辑,对于一个拼图游戏来说,能否把各个拼图元素按照一定的顺序给复原回去,决定游戏是否牲胜利。

「黎锦拼图」依然还是个 2D 游戏,细心的你一定也会发现,这个游戏本质上与「能否关个灯」这个游戏是一样的,我们都可以把游戏画布按照一定的划分规则切割出来,并通过一个二维列表与切割完成的拼图元素做映射,每次用户对拼图元素的拖拽行为结束后,都去触发一次状态的更新。最后,我们根据每次更新完成后的状态去判断出玩家是否赢得了当前游戏。

状态创建

我们的 Puzzle 类代表着拼图元素本身,拼图游戏的胜利条件是我们要把各个拼图元素按照一定顺序复原,重点在按照一定的顺序。我们可以通过给 puzzle 对象设置 tag 来做到标识每一块拼图元素。

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        // ......
        
        for itemY in 0..<itemVCount {
            for itemX in 0..<itemHCount {
                let x = itemW * itemX
                let y = itemW * itemY
                
                let img = contentImageView.image!.image(with: CGRect(x: x, y: y, width: itemW, height: itemW))
                let puzzle = Puzzle(size: CGSize(width: itemW, height: itemW), isCopy: false)
                puzzle.image = img
                // 添加 tag
                puzzle.tag = (itemY * itemHCount) + itemX
                print(puzzle.tag)
                
                puzzles.append(puzzle)
                view.addSubview(puzzle)
            }
        }
    }
}

ViewController.swift 文件中的 puzzles 是用于存放所有被切割完成后的 Puzzle 实例对象,如果我们相对游戏的状态进行维护,还需要一个 contentPuzzles 用于管理被用户拖拽到画布上的拼图元素,只有当位于画布上的拼图元素按照一定顺序放置在画布上,才能赢得比赛。

为了完成以上所表达的逻辑,我们先来把「元素下图」。在画布上提供一个「功能栏」,让用户从功能栏中拖拽出拼图元素到画布上,从而完成之前已经完成的元素上图过程。

功能栏

功能栏的作用在于承载所有拼图,在 ViewController.swift 补充相关代码:

class ViewController: UIViewController {

    // ...        
    let bottomView = UIView(frame: CGRect(x: 0, y: view.height, width: view.width, height: 64 + bottomSafeAreaHeight))
    bottomView.backgroundColor = .white
    view.addSubview(bottomView)
    
    UIView.animate(withDuration: 0.25, delay: 0.5, options: .curveEaseIn, animations: {
        bottomView.bottom = self.view.height
    })
}

运行工程,底部功能栏加上动画后,效果还不错~

底部功能栏

为了能够较好的处理底部功能栏中所承载的功能,我们需要对底部功能栏进行封装,创建一个新的类 LiBottomView

class LiBottomView: UIView {

}

现在,我们要把拼图元素都「布置」到功能栏上,采用 UICollectionView 长铺布局,也需要创建一个 LiBottomCollectionViewLiBottomCollectionViewCell

水平布局的 LiBottomCollectionView 中,我们也没有过多的动画要求,因此实现起来较为简单。

class LiBottomCollectionView: UICollectionView {

    let cellIdentifier = "PJLineCollectionViewCell"
    var viewModels = [Puzzle]()

    override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
        super.init(frame: frame, collectionViewLayout: layout)
        initView()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    private func initView() {
        backgroundColor = .clear
        showsHorizontalScrollIndicator = false
        isPagingEnabled = true
        dataSource = self
        
        register(LiBottomCollectionViewCell.self, forCellWithReuseIdentifier: "LiBottomCollectionViewCell")
    }
}

extension LiBottomCollectionView: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return viewModels.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "LiBottomCollectionViewCell", for: indexPath) as! LiBottomCollectionViewCell
        cell.viewModel = viewModels[indexPath.row]
        return cell
    }
}

在新建的 LiBottomCollectionViewCell 补充代码。

class LiBottomCollectionViewCell: UICollectionViewCell {
    var img = UIImageView()
    
    var viewModel: Puzzle? {
        didSet { setViewModel() }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        layer.borderWidth = 1
        layer.borderColor = UIColor.darkGray.cgColor
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowRadius = 10
        layer.shadowOffset = CGSize.zero
        layer.shadowOpacity = 1
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setViewModel() {
        img.contentMode = .scaleAspectFit
        img.image = viewModel?.image
        img.frame = CGRect(x: 0, y: 0, width: width, height: height)
        if !subviews.contains(img) {
            addSubview(img)
        }
    }
}

运行工程~可以看到我们的底部功能栏已经把拼图元素都布局好啦~

完整的底部功能栏 UI

功能栏上图

当我们把一个拼图元素从功能栏中拖拽到画布上时,原先位于功能栏上的拼图元素需要被移除,我们先来实现拼图元素在功能栏上的移除功能。

底部功能栏上的拼图元素数据源来自 LiBottomCollectionViewviewModels,当该数据源被赋值时会调用 reloadDate() 方法刷新页面,因此我们只需要通过某个「方法」移除在数据源中的拼图元素即可。

给底部功能栏的 Cell 添加上长按手势,在长按手势识别器的回调方法中,传递出当前 Cell 的数据源,通过 LiBottomCollectionView 操作主数据源进行删除,再执行 reloadData() 方法即可。

class LiBottomCollectionViewCell: UICollectionViewCell {
    var longTapBegan: ((Int) -> ())?
    var longTapChange: ((CGPoint) -> ())?
    var longTapEnded: ((Int) -> ())?
    var index: Int?

    // ...

    override init(frame: CGRect) {
        // ...        
        
        let longTapGesture = UILongPressGestureRecognizer(target: self, action: .longTap)
        addGestureRecognizer(longTapGesture)
    }

    // ...
}

extension LiBottomCollectionViewCell {
    @objc
    fileprivate func longTap(_ longTapGesture: UILongPressGestureRecognizer) {
        guard let index = index else { return }
        
        switch longTapGesture.state {
        case .began:
            longTapBegan?(index)
        case .changed:
            let translation = longTapGesture.location(in: superview)
            let point = CGPoint(x: translation.x, y: translation.y)
            longTapChange?(point)
        case .ended:
            longTapEnded?(index)
        default: break
        }
    }
}

在 Cell 的长按手势识别器回调方法中,我们分别对手势的三个状态 .began.changed.ended 进行了处理。在 .began 手势状态中,通过 longTapBegan() 闭包把当前 Cell 的索引传递出去给父视图,在 .changed 手势状态中,通过 longTapChange() 闭包把用户在当前视图上操作的坐标转化成与父视图一致的坐标,在 .ended 方法中同样把当前视图的索引传递出去。

在父视图 LiBottomCollectionView 中,修改 cellForRow 方法:

// ...


extension LiBottomCollectionView: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "LiBottomCollectionViewCell", for: indexPath) as! LiBottomCollectionViewCell
        cell.viewModel = viewModels[indexPath.row]
        cell.index = viewModels[indexPath.row].tag

        cell.longTapBegan = { [weak self] index in
            guard let self = self else { return }
            guard self.viewModels.count != 0 else { return }
            self.longTapBegan?(self.viewModels[index], cell.center)
        }
        cell.longTapChange = {
            self.longTapChange?($0)
        }
        cell.longTapEnded = {
            self.longTapEnded?(self.viewModels[$0])
            self.viewModels.remove(at: $0)
            self.reloadData()
        }
        
        return cell
    }
}

cellForRow 方法中,对 cell 的闭包 longTapEnded() 执行了移除视图操作,达到当用户在底部功能栏对拼图元素执行长按手势操作后,再「释放」长按手势时,从底部功能栏中移除该拼图元素的效果。剩下的两个 cell 的闭包通过 collectionView 再传递到了对应的父视图中。

LiBottomView 这一层级的视图进行拼图元素的上图操作。

class LiBottomView: UIView {
    // ...
    
    private func initView() {
        // ...        
    
        collectionView!.longTapBegan = {
            let center = $1
            let tempPuzzle = Puzzle(size: $0.frame.size, isCopy: false)
            tempPuzzle.image = $0.image
            tempPuzzle.center = center
            tempPuzzle.y += self.top
            self.tempPuzzle = tempPuzzle
            
            self.superview!.addSubview(tempPuzzle)
        }
        collectionView!.longTapChange = {
            guard let tempPuzzle = self.tempPuzzle else { return }
            tempPuzzle.center = CGPoint(x: $0.x, y: $0.y + self.top)
        }
    }
}

longTapBegan() 方法中新建一个 Puzzle 拼图元素,注意此时不能直接使用传递出来的 puzzle 对象,否则会因为引用关系而导致后续一些奇怪的问题产生。

longTapChange() 方法中维护由在 LiBottomCollectionViewCell 中所触发的长按手势事件回调出的 CGPoint。在实现从底部功能栏上图这一环节中,很容易会想到在 longTapBegan() 新建 Puzzle 对象时,给该新建对象再绑上一个 UIPanGesture 拖拽手势,但其实仔细一想,UILongPressGestureRecognizer 是继承于 UIGestureRecognizer 类的,UIGestureRecognizer 中维护了一套与用户手势相关识别流程,不管是轻扫、拖拽还是长按,本质上也都是通过在一定时间间隔点判断用户手势的移动距离和趋势来决定出具体是哪个手势类型,因此我们直接使用 UILongPressGestureRecognizer 即可。

运行工程~在底部功能栏里选中一个你喜欢的拼图,长按它!激发手势,慢慢的拖拽到画笔上,好好感受一下吧~

拼图元素上图

后记

在这篇文章中,我们主要关注了底部功能栏的逻辑实现,让底部功能栏具备初步「功能」的作用,并完善了上一篇文章中「元素上图」的需求,使其更加完整,目前,我们完成的需求有:

  • 拼图素材准备;
  • 元素上图;
  • 状态维护;
  • 元素吸附;
  • UI 完善;
  • 判赢逻辑;
  • 胜利动效。

GitHub 地址:github.com/windstormey…