SpriteKit中双节点纹理追踪与限时配对校验实现方案
SpriteKit 配对类游戏核心功能实现方案
现有代码核心问题梳理
touchesBegan方法存在四层嵌套冗余逻辑,所有卡牌的点击判断硬编码,点击屏幕任意位置都会遍历执行全部卡牌的纹理修改逻辑,完全不符合交互预期- 无状态追踪变量,无法记录已选中卡牌、已配对卡牌状态,也没有交互锁防止快速点击导致的逻辑错乱
- 倒计时动作没有持有引用,游戏结束(通关/超时)时无法主动停止,会导致重复跳转场景、内存泄漏问题
- 卡牌初始化代码完全重复,没有复用逻辑,后续扩展卡牌数量时维护成本极高
- 没有配对校验、不匹配复位、全量通关判断的相关逻辑
核心功能实现思路
1. 双节点纹理追踪配对逻辑
首先在类中定义必要的状态属性:
- 固定卡牌背面默认纹理,避免重复加载资源
- 用可选类型变量存储当前选中的第一张待校验卡牌,初始值为
nil - 用集合存储所有已配对完成的卡牌,用于后续通关校验
- 增加布尔类型交互锁,翻牌动画、配对校验过程中禁止用户点击,避免逻辑冲突
- 不直接对比纹理判断是否匹配,给每个卡牌的
userData绑定配对ID(同图案卡牌ID相同),判断逻辑更稳定
交互流程:
- 点击触发后先校验交互锁状态、点击节点是否为可交互卡牌(已配对卡牌、非卡牌节点直接忽略)
- 点击有效卡牌后执行翻牌逻辑(切换为正面纹理,可搭配翻转动画提升体验)
- 若当前没有已选中的待校验卡牌,将当前点击卡牌存入待校验变量,等待第二次点击
- 若当前已有待校验卡牌,立刻开启交互锁,对比两张卡牌的配对ID:
- 匹配:将两张卡牌加入已配对集合,清空待校验变量,关闭交互锁,立刻执行全量配对校验
- 不匹配:延迟0.8秒(给用户留记忆牌面的时间)后将两张卡牌切回背面纹理,清空待校验变量,关闭交互锁
2. 限时配对校验逻辑
- 将倒计时的重复SKAction存为类属性,无论是超时跳转重试场景、还是通关跳转胜利场景,都先主动移除计时器动作,避免后台继续执行
- 每次配对成功后立刻校验已配对卡牌数量,若数量等于总卡牌数,立刻停止计时器,跳转胜利场景
- 原有倒计时到0的逻辑保留,跳转重试场景前先停止计时器动作
优化后完整代码
import Foundation import SpriteKit class EasyScreen: SKScene { // MARK: - 配置常量 private let totalGameTime = 90 private let cardBackTextureName = "Fruit Match Card" private let cardSize = CGSize(width: 150, height: 300) private let cardZPosition: CGFloat = 1 private let matchCheckDelay: TimeInterval = 0.8 // MARK: - 状态属性 private var selectedCard: SKSpriteNode? private var matchedCards = Set<SKSpriteNode>() private var isInteractionEnabled = true private let cardBackTexture: SKTexture // MARK: - 初始化 override init(size: CGSize) { cardBackTexture = SKTexture(imageNamed: cardBackTextureName) super.init(size: size) } required init?(coder aDecoder: NSCoder) { cardBackTexture = SKTexture(imageNamed: cardBackTextureName) super.init(coder: aDecoder) } override func didMove(to view: SKView) { // 初始化背景 let background = SKSpriteNode(imageNamed: "Easy Screen Background") background.position = CGPoint(x: size.width/2, y: size.height/2) background.size = self.size background.zPosition = 0 addChild(background) // 初始化倒计时文本 let timerText = SKLabelNode(fontNamed: "Arial") timerText.fontSize = 40 timerText.fontColor = SKColor.white timerText.position = CGPoint(x: 20, y: size.height - 40) timerText.zPosition = 2 timerText.name = "TimerLabel" addChild(timerText) // 卡牌配置:(位置, 正面纹理名, 配对ID) let cardConfigs: [(position: CGPoint, frontTexture: String, matchId: Int)] = [ (CGPoint(x: size.width/2 - 125, y: size.height/2 + 60), "Apple", 1), (CGPoint(x: size.width/2 - 125, y: size.height/2 - 260), "Apple", 1), (CGPoint(x: size.width/2 + 70, y: size.height/2 + 60), "Grapes", 2), (CGPoint(x: size.width/2 + 70, y: size.height/2 - 260), "Grapes", 2) ] // 循环生成卡牌,避免重复代码 for (index, config) in cardConfigs.enumerated() { let card = SKSpriteNode(texture: cardBackTexture, size: cardSize) card.name = "MatchCard_\(index)" card.position = config.position card.zPosition = cardZPosition card.userData = ["matchId": config.matchId, "frontTexture": SKTexture(imageNamed: config.frontTexture)] addChild(card) } // 启动倒计时 var counter = totalGameTime timerText.text = "Time: \(counter)" let countDownAction = SKAction.repeatForever(SKAction.sequence([ SKAction.run { [weak self] in guard let self = self else { return } counter -= 1 timerText.text = "Time: \(counter)" if counter <= 0 { self.removeAction(forKey: "GameTimer") let retryScene = TryAgainScreen(fileNamed: "Try Again Screen") retryScene?.scaleMode = .aspectFill self.view?.presentScene(retryScene) } }, SKAction.wait(forDuration: 1) ])) run(countDownAction, withKey: "GameTimer") } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { // 交互锁关闭时直接忽略点击 guard isInteractionEnabled, let touch = touches.first else { return } let touchPosition = touch.location(in: self) let tappedNodes = nodes(at: touchPosition) // 找到点击的有效卡牌 guard let tappedCard = tappedNodes.first(where: { node in guard let card = node as? SKSpriteNode, card.name?.starts(with: "MatchCard_") == true else { return false } // 已经配对的卡牌不可点击 return !matchedCards.contains(card) }) as? SKSpriteNode else { return } // 不能点同一张卡两次 guard tappedCard != selectedCard else { return } // 翻到正面 guard let frontTexture = tappedCard.userData?["frontTexture"] as? SKTexture else { return } // 翻牌动画 let flipAction = SKAction.sequence([ SKAction.scaleX(to: 0, duration: 0.15), SKAction.run { tappedCard.texture = frontTexture }, SKAction.scaleX(to: 1, duration: 0.15) ]) tappedCard.run(flipAction) // 处理选中逻辑 if let firstCard = selectedCard { // 已经选了第一张,开始配对校验 isInteractionEnabled = false guard let firstMatchId = firstCard.userData?["matchId"] as? Int, let secondMatchId = tappedCard.userData?["matchId"] as? Int else { isInteractionEnabled = true selectedCard = nil return } if firstMatchId == secondMatchId { // 配对成功 matchedCards.insert(firstCard) matchedCards.insert(tappedCard) selectedCard = nil isInteractionEnabled = true // 校验是否全部配对完成 if matchedCards.count == children.filter({ $0.name?.starts(with: "MatchCard_") == true }).count { // 停止计时器,跳转胜利场景 removeAction(forKey: "GameTimer") let winScene = WinScreen(fileNamed: "Win Screen") winScene?.scaleMode = .aspectFill view?.presentScene(winScene) } } else { // 配对失败,延迟复位 DispatchQueue.main.asyncAfter(deadline: .now() + matchCheckDelay) { [weak self, weak firstCard, weak tappedCard] in guard let self = self, let firstCard = firstCard, let tappedCard = tappedCard else { return } // 翻回背面动画 let resetFirst = SKAction.sequence([ SKAction.scaleX(to: 0, duration: 0.15), SKAction.run { firstCard.texture = self.cardBackTexture }, SKAction.scaleX(to: 1, duration: 0.15) ]) let resetSecond = SKAction.sequence([ SKAction.scaleX(to: 0, duration: 0.15), SKAction.run { tappedCard.texture = self.cardBackTexture }, SKAction.scaleX(to: 1, duration: 0.15) ]) firstCard.run(resetFirst) tappedCard.run(resetSecond) self.selectedCard = nil self.isInteractionEnabled = true } } } else { // 选中第一张卡 selectedCard = tappedCard } } }
额外优化建议
- 卡牌生成时可以把配对ID打乱随机分配,避免每次开局牌面位置固定
- 可以给匹配成功/失败的卡牌加音效、粒子效果,提升游戏反馈
- 后续扩展难度增加卡牌时,只需要修改
cardConfigs数组的配置即可,不需要改动核心逻辑 - 可以把卡牌配置、游戏时长等参数抽到单独的配置文件,方便做不同难度的场景
内容的提问来源于stack exchange,提问作者Developer0113
相关产品推荐
相关产品推荐

