You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.29 17:45:40