Skip to content
On this page

经典数字拼图实现

简介

最近看到小朋友买了一个拼图板,但却不怎么会玩。想教她玩去发现这个玩具质感有点差,操作时经常卡住。而且由于一个图片,不便于讲解解法,所以想写一个程序来模拟。 puzzle

打算实现的效果如下

puzzle

可以过程向滑动来控制方块的移动,当解题成功的时候能弹出正确的提示。

实现界面

我们实现的是一个4x4的正方形格子,每个格子是一个数字,我们需要先实现一个格子的格式.我们简单实现,一个棕色背景上用一个白色的数字。这里的大小我们也写死了50。其实这里应该可以通过屏幕尺寸计算合理大小,在留相应边界的情况下取1/4.然后我们通过将数字为0的时候不透明度设置为0来模拟这个方块是个空白。

swift
 @ViewBuilder func CellView(number:Int)->some View{
        VStack{
                Text("\(number)")
                    .font(.largeTitle)
                    .foregroundColor(.white)
        }.frame(width: 50, height: 50)
            .background(RoundedRectangle(cornerRadius: 5).fill(.brown)).opacity(number==0 ? 0 : 1)
    }

上面实现的是一个简单的版本,直接用Text实现,通过font来控制大小,但这样还是不够灵活。所以后面实现了另外一个版本的格子。同时也支持自定义图片.这个版本当有自定义图片的时候,使用对应数字自定义图片。当没有自定义图片时,使用SF Symbol中的矢量图片来实现,方便适配各各大小的格子尺寸。同时实现的图片格子大小根据屏幕大小的自适应。(更进一步,应该可以使用GeometryReader

swift
 let size = (min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)*0.8 - 80) / 4
@ViewBuilder func CellView(number:Int)->some View{
        
        VStack(spacing:0){
            if image != nil {
            Image(uiImage: number == 0 ? puzzleImages[15] : puzzleImages [number - 1])
                .resizable()
                .aspectRatio(contentMode: .fill)
                .frame(width: size, height: size)
                .clipped()
            }else{
                Image(systemName: "\(number).square.fill")
                    .resizable()
                    .scaledToFill()
                    .foregroundColor(.brown)
            }
        }.frame(width: size, height: size )
            .opacity(number==0 ? 0 : 1)
    }

实现了一个格子的样子,我们来实现整个游戏区域的样子.这里我们定义了一个数组,用来存放每个格子上显示的数字。

swift
  @State var puzzle:[Int] = Array(1...16)
  //code in body
 VStack {
            ForEach(0...3,id:\.self){nx in
                HStack {
                    ForEach(0...3,id:\.self){ny in
                        CellView(number: puzzle[(nx*4)+ny])
                    }
                }
            }
        }.padding()
            .border(.purple,width: 5)

至此一个简单的数字拼图大体的样子已经基本实现。

实现逻辑

初始化拼图

刚才我们实现的拼图,显示的数字是顺序的1到16.和我们期望的16为空格不同。初始的拼图的目的就是为了有一个打算顺序的拼图。当然简单打乱顺序,可以粗暴地使用数组的交换来实现。但这样会导致初始化的拼图不一定能拼成我们期望的图案。所以我们最好是通过老实的移动空格子来打算整个棋盘。这里我们初始化的方法是将空格子移动128次(因为条件限制,不并是都会真的发生移动)

swift
    func initPuzzle() {
        var puzzle =  Array(1...16)
        puzzle[15] = 0
        for _ in 1...128 {
            let index = Int.random(in: 0...3)
            move(Direction.allCases[index], puzzle: &puzzle)
        }
        print(puzzle)
        self.puzzle = puzzle
    }

下面我们来实现移动的逻辑,这个逻辑也不复杂,左右就交换顺序,上下就交换行。只需要保证是在可移动的范围即可。

swift

enum Direction:CaseIterable {
    case up
    case down
    case left
    case right
}

func move(_ dir:Direction,  puzzle:inout [Int]){
        let zeroindex = puzzle.firstIndex(of: 0)!
        switch dir {
        case .up,.down:
            let destIndex = dir == .up ? zeroindex + 4 : zeroindex - 4
            if destIndex >= 0 && destIndex <= 15 {
                puzzle.swapAt(zeroindex, destIndex)
            }
        case .left:
            if (zeroindex % 4) != 3 {
                puzzle.swapAt(zeroindex, zeroindex+1)
            }
        case .right:
            if (zeroindex % 4) != 0 {
                puzzle.swapAt(zeroindex, zeroindex-1)
            }
        }
    }

至此,我们实现了一个乱序的puzzle数组的方法。只需要在页面展示的时候调用这个方法来初始化棋盘即可。

swift
 .onAppear{
                initPuzzle()
            }

玩法实现

我们实现的逻辑比较简单,只要在整个棋盘任何位置上滑动,判断滑动的方向。来实现空格子旁边的元素移动即可。由于前面我们已经在初始棋盘的时候实现了移动格子位置的逻辑。所以我们只需要实现监控滑动事件即可,这里我们通过一个拖动的动作来实现

swift
 .gesture(DragGesture(minimumDistance: 20, coordinateSpace: .global)
                        .onEnded { value in
                            
                            let horizontalAmount = value.translation.width as CGFloat
                            let verticalAmount = value.translation.height as CGFloat
                            var puzzle = self.puzzle
                            if abs(horizontalAmount) > abs(verticalAmount) {
                                print(horizontalAmount < 0 ? "left swipe" : "right swipe")
                                move(horizontalAmount < 0 ? .left : .right,puzzle: &puzzle)
                            } else {
                                print(verticalAmount < 0 ? "up swipe" : "down swipe")
                                move(verticalAmount < 0 ? .up : .down,puzzle: &puzzle)
                            }
                            if puzzle != self.puzzle {
                                self.puzzle = puzzle
                            }
                           
                        })

格子移动实现之后,我们只需要在每次格子移动的时候判断一下当前棋盘是不是已经正确排列即可,当正确排列后,我们将游戏胜利设置为true。三秒中之后自动重新开始下一局:

swift
 .onChange(of: puzzle){ p in
                            var winArray = Array(1...16)
                            winArray[15] = 0
                            if p == winArray {
                                win = true
                                DispatchQueue.main.asyncAfter(deadline: .now() + 3){
                                    win = false
                                    initPuzzle()
                                }
                            }
                        }

当游戏胜利,我们展示一个胜利的提示,这里直接在棋盘上overlay一个文本提示来实现。同时棋盘设置为模糊,以获取更好的效果。

swift
.blur(radius: win ? 5 : 0)
            .overlay{
                if win {
                    VStack{
                        Text("拼图成功")
                            .foregroundColor(.blue)
                            .font(.title.bold())
                            .padding()
                    }
                        .background(.ultraThinMaterial,in: RoundedRectangle(cornerRadius: 10))
                }
            }

至此一个简单的数字拼图小游戏就实现了,完整的代码可以在这里查看。代码还有非常多可以优化的地方,除了刚才提到格子大小问题,还有格子实现自定义棋盘大小,实现自定义拼图图片,实现格子交换的动画效果,实现胜利提示的动画效果等。由于时间原因,这里就不展开了。

自定义图片

实现自定义图片也不复杂,我们添加一个按钮来触发图片选择. 同时也实现重新开始以及清除图片的功能。逻辑比较简单,看代码即可

swift
 VStack(spacing:10){
                if image != nil {
                    Image(uiImage: image!.toSquare())
                        .resizable()
                        .scaledToFill()
                        .frame(width: 100, height: 100)
                }
                Button("选择图片"){
                    self.showImagePicker = true
                }.buttonStyle(.borderedProminent).padding(.vertical)
                Button("重新开始"){
                    initPuzzle()
                }.buttonStyle(.borderedProminent).padding(.vertical)
                Button("清除图片"){
                    self.image = nil
                    initPuzzle()
                }.buttonStyle(.borderedProminent).padding(.vertical)
                    .disabled(self.image==nil)
            }.padding(.horizontal)

图片选择实现一个抽屉即可,选择完图片,然后这里有一个重要步骤,就是把图片的方法重置统一重置为朝上,不然在切分图片的时候会发生行和列错乱的问题。 fixedOrientation具体要实现可以参考这里

swift
.sheet(isPresented: $showImagePicker) {
            ImagePickerView(sourceType: .photoLibrary) { image in
                self.image = image.fixedOrientation()
                initPuzzle()
            }
        }

添加游戏计时

我们希望在开始解题时进行计时,实时显示时间的消耗。在拼图完成的时候,显示耗时多少。 对于计时,我们使用TimelineView来实现。为了保证游戏区域不跳变,在未计时显示一个占位Text。 TimelineView设置为按秒更新,显示距离开始时间已消耗了多少秒

swift
 if !win && startDate != .distantPast {
                    TimelineView(.periodic(from:.now, by: 1.0)) { context in
                        Text("已耗时:\(Int(context.date.timeIntervalSince(startDate).rounded()))")
                    }
                }else{
                    Text("未在计时")
                }

我们初始化的时候将startDate设置为.distantPast,然后这个来判断是不是始始值。当有滑动,并且导致puzzle实际发生了变化的时候,我们将它设置为当前时间。

swift
                          if puzzle != self.puzzle {
                                if startDate == .distantPast{
                                    startDate = .now
                                }
                                self.puzzle = puzzle
                            }

修改拼图完成后的提示,加上已经共消耗的时间.

swift
                            VStack{
                                Text("拼图成功\n共耗时:\(Int(Date.now.timeIntervalSince(startDate).rounded()))")
                                    .multilineTextAlignment(.center)
                                    .foregroundColor(.blue)
                                    .font(.title.bold())
                                    .padding()
                            }
                            .background(.ultraThinMaterial,in: RoundedRectangle(cornerRadius: 10))

至此基本完成了游戏耗时的统计。然后只需在initPullzel()将startDate重新设置为.distantPast,以保证下一局可以正确统计时间,就完成整个耗时统计的完整实现.