75142913在线留言
【SwiftUI实战】记忆卡片游戏(斯坦福大学2020CS193P学习笔记)_IOS开发_网络人

【SwiftUI实战】记忆卡片游戏(斯坦福大学2020CS193P学习笔记)

Kwok 发表于:2021-04-20 09:20:14 点击:69 评论: 0

斯坦福大学2020CS193P教程学到了第6课基本完成了记忆卡片游戏,在代码中我们可以学到:

第1课:讲解了课程的基本介绍及SwiftUI的基本介绍与使用。

基本的VHZstack布局,形状

第2课:理解MVVM的编辑模式及Swift的类型系统

第3课:响应式UI +协议+布局

第4课:表格、枚举、可选类型

第5课:形状与视图修改器

第6课:动画详解

SwiftUI实战记忆卡片游戏斯坦福大学2020CS193P学习笔记

一、ContentView.swift 主界面文件

import SwiftUI

struct EmojiMemoryGameView: View {
    @ObservedObject var viewModel: EmojiMemoryGame
    var body: some View {
        VStack{
            Spacer()//卡片挤上去
            //垂直布局,弹性宽度范围在120~130之间
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 72, maximum: 80))]) {
                ForEach(viewModel.cards){ card in
                    SingleCardView(card: card).onTapGesture {
                        withAnimation(Animation.linear(duration: 2.5)){
                            viewModel.choose(card: card)//点击后调用choose方法
                        }
                    }
                    .frame(height: 135)//设置卡片的统一高度
                    .foregroundColor(.orange)//卡片统一颜色
                    .rotationEffect(.zero)
                }
                .animation(.easeInOut)//淡进淡出
            }
            .padding()//留LazyVGrid边框
            Spacer()//卡片挤上去
        }
    }
}

struct SingleCardView:View {
    var card:MemoryGame<String>.Card //单张卡片
    var body: some View{
        GeometryReader{ getmetry in
            self.body(size: getmetry.size) //通过getmetry传入容器的大小
        }
    }
    @State private var animatiedBonusremaining: Double = 0
    private func startBonusTimeAnimation(){
        animatiedBonusremaining = card.bonusRemaining //监听剩余时间
        //利用线性动画的duration时充当计时器
        withAnimation(Animation.linear(duration: 10)){
            animatiedBonusremaining = 0
        }
    }
    @ViewBuilder //作为子视图生成闭包参数的参数属性,允许这些闭包提供多个子视图。
    private func body(size:CGSize) -> some View{
        //只显示卡片朝上或者未匹配的卡片
        if card.isFaceUp || !card.isMatched{
            ZStack{
                Group{
                    if card.isConsumingBonusTime {
                        //Pie是自己定义的一个图形
                        Pie(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(-animatiedBonusremaining*360-90))//从12点钟位置开始画到endAngle
                            .onAppear{
                                self.startBonusTimeAnimation()
                            }
                    }else{
                        Pie(startAngle: Angle.degrees(-90), endAngle: Angle.degrees(270))//从12点钟位置开始画到endAngle
                    }
                    /*
                     我们需要随着时间的流逝变化 0-90 ... 360-90
                     */
                }
                .transition(.scale)
                .padding(5).opacity(0.4)//填充并减淡颜色
                
                
                Text(card.content)
                    .font(Font.system(size: fontSize(size: size)))//根据容器大下设置字号
                    .rotationEffect(Angle.degrees(card.isMatched ? 360 : 0))
                    .animation(card.isMatched ? Animation.linear(duration: 1).repeatForever(autoreverses: false) : .default)
                //当卡片匹配时播放360度旋转动画不间隙。
            }
            .cardify(isFaceUp: card.isFaceUp) //调用自定义的modifier
            //AnyTransition是一种擦除的过度效果
            .transition(AnyTransition.offset(CGSize(width: -1000, height: 2000)))//飞出屏幕
            //            .transition(AnyTransition.scale)//缩小消失过渡        
            
        }
    }
    private func fontSize(size:CGSize) -> CGFloat {
        //min返回两个可比较值中较小的一个。
        min(size.width, size.height) * 0.7 //返回字体的大小为容器的75%
    }
}

//预览界面
struct ContentView_Previews: PreviewProvider {
    static let game: EmojiMemoryGame = EmojiMemoryGame()//初始化静态的game
    static var previews: some View {
        game.choose(card: game.cards[0])//选中一张卡
        return Group {
            EmojiMemoryGameView(viewModel: game)
        }
    }
}

二、Model.swift 数据模型文件

import Foundation
//CardContent 为卡片内容,类型:泛型 条件 卡片内容可以比对
struct MemoryGame<CardContent> where CardContent: Equatable {
    private(set) var cards: Array<Card>//定义只读的卡片数组<卡片数据结构>
    //找到一个唯一朝上的卡片索引值(可为空)->这是核心算法
    private var indexOfTheOneAndOnlyFaceUpCard: Int? {
        get{
            //indices按升序对集合下标有效的索引。filter返回一个数组,按顺序包含序列中满足给定谓词的元素。
            cards.indices.filter { cards[$0].isFaceUp }.only //only为扩展,只返回1个值的数组
        }
        set{
            for i in cards.indices{
                cards[i].isFaceUp = i == newValue //把每个值跑一次,如果索引与新值相等,卡片向上
            }
        }
    }
    
    //选择一张卡片并修改里的bool值->这也是核心算法
    mutating func choose(card:Card) {
        //先使用firstIndex(自己扩展的功能)找到被点击卡片的索引,然后判断[索引]卡未朝上,且未被匹配过
        if let choosenIndex = cards.firstIndex(maching:card),!cards[choosenIndex].isFaceUp,!cards[choosenIndex].isMatched{
            //从indexOfTheOneAndOnlyFaceUpCard里找到一张向上的卡片,充分理解这个变量是核心
            if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard{
                //匹配一对卡片(对比被点击的卡与上次被点击的卡)
                if cards[choosenIndex].content == cards[potentialMatchIndex].content {
                    cards[choosenIndex].isMatched = true
                    cards[potentialMatchIndex].isMatched = true
                }
                //被点击的卡片朝上
                cards[choosenIndex].isFaceUp = true
                //                print("已找到卡片:(card)")//打印卡片信息
            }else{
                indexOfTheOneAndOnlyFaceUpCard = choosenIndex //没有找到卡片,则设置choosenIndex(被点击的)卡片向上
                //                print("未找到卡片:(card)")//打印卡片信息
            }
        }
        //        print("所有卡片信息:(cards)")//打印卡片信息
    }
    
    //初始化numberOfPairsOfCards(几对卡片),CardContentFactory(卡片内容初始化)
    init(numberOfPairsOfCards: Int, cardContentFactory:(Int) -> CardContent){
        cards = Array<Card>() //初始化一个空的卡片数组
        //将传入的要实例化的数量(numberOfPairsOfCards)里所有的卡片追加到cards数组里
        
        for i in 0..<numberOfPairsOfCards{
            let content = cardContentFactory(i) //为每个卡片返回卡片内容
            cards.append(Card(id: i * 2, content: content))//追加第一个卡片
            cards.append(Card(id: i * 2 + 1, content: content))//追加第二个卡片
            //因为卡片需要成对的出现,所以这里要根据ID的不同需要追加2次
        }
        cards.shuffle()//随机排序(自己也有扩展的,这个是系统自带的)
    }
    
    //定义一个ID识别卡片
    struct Card:Identifiable {
        var id:Int //识别ID
        var isFaceUp:Bool = false //是否翻转
        {
            didSet{
                if isFaceUp {
                    startUsingBonusTime() //当卡片被翻开后开始计时
                }else{
                    stopUsingBonusTime() //卡片翻转到背面停止计时
                }
            }
        }
        var isMatched:Bool = false//是否匹配
        {
            didSet{
                stopUsingBonusTime()//当卡片匹配就停止计时
            }
        }
        var content:CardContent//卡片内容<泛型>
        
        //剩余时间算法,根据每次翻开或者匹配卡片进行时间的跟踪处理动画与数据
        var bonusTimeLimit: TimeInterval = 6 //单次匹配最大耗时
        var lastFaceUpDate: Date? //卡片上次翻转的时间
        var pastFaceUpTime: TimeInterval = 0 //卡片朝上的过去时间初始
        
        //卡片翻转已用时
        private var faceUpTime: TimeInterval{
            if let lastFaceUpDate = self.lastFaceUpDate{
                return pastFaceUpTime + Date().timeIntervalSince(lastFaceUpDate)//将上次的时间转为Int交给已耗时
            }else{
                return pastFaceUpTime
            }
        }
        //奖金剩余时间
        var bonusTimeRemaining: TimeInterval{
            max(0, bonusTimeLimit - faceUpTime)
        }
        //剩余奖金
        var bonusRemaining: Double{
            (bonusTimeLimit > 0 && bonusTimeRemaining > 0) ? bonusTimeRemaining / bonusTimeLimit : 0
        }
        //赢得了奖金
        var hasEarnedBonus: Bool{
            isMatched && bonusRemaining > 0
        }
        //是否在浪费额外的时间
        var isConsumingBonusTime: Bool{
            isFaceUp && !isMatched && bonusRemaining > 0
        }
        //开始使用奖金时间的方法
        private mutating func startUsingBonusTime(){
            if isConsumingBonusTime,lastFaceUpDate == nil{
                lastFaceUpDate = Date()
            }
        }
        //结束使用奖金时间的方法
        private mutating func stopUsingBonusTime(){
            pastFaceUpTime = faceUpTime
            self.lastFaceUpDate = nil
        }
    }
}

三、ModelView.swift 视图模型文件 

import Foundation

class EmojiMemoryGame: ObservableObject {
    @Published private var model: MemoryGame<String> = EmojiMemoryGame.createEmojiMemoryGame()//调用自己的静态方法createEmojiMemoryGame创建一个Emoji游戏数据
    /*
     通过下面定义的createEmojiMemoryGame静态方法创建一组数据供View使用
     Publisthed表示让视图根据model的变化刷新
     */
    //定义一个静态的方法,用于存Emoji
    private static func createEmojiMemoryGame() -> MemoryGame<String>{
        let emojis = ["A","B","C","D","E","F","G"] //可以使用表情
        //返回一个MemoryGame初始化后的Card数组,emojis[$0]里为卡片的内容
        return MemoryGame<String>(numberOfPairsOfCards:emojis.count){emojis[$0]}
    }
    var cards:Array<MemoryGame<String>.Card>{
        model.cards
    }
    func choose(card:MemoryGame<String>.Card) {
        var i = 0        
        for card in model.cards{
            if !card.isMatched{
                i += 1
            }
        }
        print(i)// 12 10 8 6 4 2 0
        if i <= 2 {
            restartGame() //最后一组时重新开始游戏
        }else{
            model.choose(card:card)//大于2张卡片未匹配时继续选择卡片
        }
    }
    func restartGame() {
        model = EmojiMemoryGame.createEmojiMemoryGame()//重新开始游戏
    }
}

四、Cardify.swift 单个卡片文件

import SwiftUI

//定义一个卡片修改器,符合AnimatableModifier协议,该协议继承  Animatable, ViewModifier
fileprivate struct Cardify: AnimatableModifier {
    var rotation: Double
    init(isFaceUp:Bool) {
        rotation = isFaceUp ? 0 : 180//通过isFaceUp确定rotation的值
    }
    var isFaceUp:Bool{
        rotation < 90 //通过rotation确定isFaceUp的值
    }
    //因为AnimatableModifier继承了Animatable,所以需要使用animatableData
    //animatableData是将数据进行动画处理。
    var animatableData: Double{
        get{
            return rotation//将直接获取rotation的值,因为它们一样的
        }
        set{
            rotation = newValue //修改animatableData的同时也修改rotation
        }
    }
    func body(content:Content) -> some View {
        ZStack{
            Group{
                RoundedRectangle(cornerRadius: cornerRadius).fill(Color(UIColor(displayP3Red: 1, green: 2, blue: 3, alpha: 4)))//填充纯白色
                RoundedRectangle(cornerRadius: cornerRadius).stroke(lineWidth: edgeLineWidth)//设置边框
                content.transition(.scale)//被修改的内容放到这里并使用缩放过度                    
            }
            .opacity(isFaceUp ? 1 : 0)//隐藏/显示卡片内容,以保证动画一直存在
            //卡片未被匹配才显示纯色
            RoundedRectangle(cornerRadius: cornerRadius).fill().transition(.opacity)//加入透明过度效果
                .opacity(isFaceUp ? 0 : 1)//隐藏/显示纯色,以保证动画一直存在
        }
        .rotation3DEffect(Angle.degrees(rotation),axis: (0,1,0))
        
    }
    private let cornerRadius:CGFloat = 10.0 //圆角值
    private let edgeLineWidth:CGFloat = 3.0//边框大小
}
//扩展View直接使用.Cardify修改视图
extension View{
    //扩展操作后在主视图里就可以不使用.modifier了
    func cardify(isFaceUp:Bool) -> some View {
        self.modifier(Cardify(isFaceUp: isFaceUp))//指定使用的修改器
    }
}

五、Pie.swift计时用的动画

import SwiftUI
/*
 Shape协议绘制视图时可以使用的二维形状。
 没有填充或描边的形状将根据前景色获得默认填充。
 您可以定义与隐式参考框架相关的形状,例如包含它的视图的自然大小。
 另外,你也可以用绝对坐标来定义形状。
 */
struct Pie: Shape {
    //下面参数需要
    var startAngle:Angle //开始位置的角度
    var endAngle:Angle //结束位置的角度
    var clockwise:Bool = true //是否顺时针
    /* Angle是一种几何角度,其值可以以弧度或角度来访问。0到360°,用弧度是0到2π,2πr是圆的周长 */
    
    //将动画数据化,AnimatablePair返回两组Double数据
    var animatableData: AnimatablePair<Double,Double>{
        get{
            AnimatablePair(startAngle.radians, endAngle.radians) //动画化的数据返回为开始角度与结束角度
        }
        set{
            //            startAngle.radians = newValue.first //将开始角度数据同步
            //            endAngle.radians = newValue.second//将结束角度的数据同步
            startAngle = Angle.radians(newValue.first) //将开始角度数据同步
            endAngle = Angle.radians(newValue.second) //将结束角度的数据同步
        }
    }
    //定义一个符合Shape协议的路径,要求格式:path(in rect:CGRect) -> Path
    func path(in rect:CGRect) -> Path {
        //以CG开头的类型是系统提供的底层图形
        let center = CGPoint(x: rect.midX, y: rect.midY) //找到图形的中心点坐标
        let radius = min(rect.width,rect.height) / 2 //中心半径
        //下面的start主要是找到开始位置的线条怎么画出来(看不懂就照抄)
        let start = CGPoint(
            x: center.x + radius * cos(CGFloat(startAngle.radians)),//X坐标开始于中心点X + 半径值 * cos余弦(开始点半径)
            y: center.y + radius * sin(CGFloat(startAngle.radians))//Y坐标开始于中心点Y + 半径值 * sin正弦(开始点半径)
        )
        var p = Path() //定义一个路径
        p.move(to: center)//路径从图形的中心开始画
        p.addLine(to: start)//从圆心画一条线到开始点(边缘坐标)
        //addArc在路径上添加一个圆弧,指定半径和角度。
        p.addArc(
            center: center,//中心线
            radius: radius,//中心半径
            startAngle: startAngle, //开始的角度
            endAngle: endAngle,//结束的角度
            clockwise: clockwise
        )
        return p//返回画好以后的图形
    }
}

六、Array+.swift Array扩展功能文件 

import Foundation
//扩展Array 条件:Identifiable可识别的
extension Array where Element:Identifiable{
    //根据可识别数组里的ID去对比并返回当前的索引值(i)
    func firstIndex(maching:Element) -> Int? {
        for i in 0..<self.count{
            if self[i].id == maching.id{
                return i//找到第i个
            }
        }
        return nil //没有找到相等的索引
    }
}

//扩展一个only变量,只返回1个值的数组
extension Array{
    //only为可选值
    var only:Element? {
        count == 1 ? first : nil //如果数组里只有1个值则返回当前值,否则返回空
    }
}
除非注明,网络人的文章均为原创,转载请以链接形式标明本文地址:https://www.55mx.com/post/139
标签:游戏SwiftUI
0
感谢打赏!

《【SwiftUI实战】记忆卡片游戏(斯坦福大学2020CS193P学习笔记)》的网友评论(0)

本站推荐阅读

热门点击文章