如何通过UIPanGestureRecognizer持续移动SKSpriteNode?
解决SKScene中精灵随手势持续移动的问题
我来帮你搞定这个问题!首先得说清楚你当前代码的核心问题:每次手势触发.changed状态时,你只是给精灵添加了一个固定距离的moveTo动作,这就导致它只会执行一次移动,没法跟着手势全程持续动起来。下面我先给你改好UIPan的实现,再聊聊更适合游戏的摇杆替代方案。
一、改进UIPan手势实现持续移动
我们需要利用手势的速度(velocity)或者位移增量,在每次.changed触发时实时更新精灵位置,而不是用固定时长的moveTo动作。这样就能实现拖动手势全程的持续移动了:
@objc func didPan(gesture: UIPanGestureRecognizer) { guard let sceneView = view else { return } let panVelocity = gesture.velocity(in: sceneView) // 调整这个系数可以控制移动速度,根据你的场景需求来改 let moveSpeed: CGFloat = 0.05 switch gesture.state { case .began: // 手势开始时先停止之前的移动动作,避免动作冲突 for sprite in levelSprites { sprite.removeAction(forKey: "backgroundMove") } // 开始播放对应方向的循环行走动画 if panVelocity.x > abs(panVelocity.y) { player.run(SKAction.repeatForever(SKAction(named: "WalkRight")!), withKey: "playerWalk") } else if panVelocity.x < -abs(panVelocity.y) { player.run(SKAction.repeatForever(SKAction(named: "WalkLeft")!), withKey: "playerWalk") } else if panVelocity.y > abs(panVelocity.x) { player.run(SKAction.repeatForever(SKAction(named: "WalkUp")!), withKey: "playerWalk") } else if panVelocity.y < -abs(panVelocity.x) { player.run(SKAction.repeatForever(SKAction(named: "WalkDown")!), withKey: "playerWalk") } case .changed: // 计算每帧的移动增量,背景向手势相反方向移动 let deltaX = -panVelocity.x * moveSpeed let deltaY = -panVelocity.y * moveSpeed for sprite in levelSprites { // 实时更新位置,实现持续移动 sprite.position = CGPoint(x: sprite.position.x + deltaX, y: sprite.position.y + deltaY) } case .ended, .cancelled: // 手势结束时停止玩家动画 player.removeAction(forKey: "playerWalk") // 给背景加个减速停止的动作,让过渡更自然 let stopAction = SKAction.moveBy(x: 0, y: 0, duration: 0.2) for sprite in levelSprites { sprite.run(stopAction, withKey: "backgroundMove") } default: break } }
这里的关键是:在.changed状态下直接修改精灵的position属性,而不是添加一次性的移动动作。这样每次手势更新时,精灵都会跟着移动,完美实现持续拖动的效果。
二、替代UIPan的更优方案:虚拟摇杆
既然你后续打算加入摇杆控制,那直接实现虚拟摇杆才是最适合游戏的方案——它的操作逻辑更符合玩家习惯,能持续提供方向输入,比手势控制直观得多。下面是一个简单的自定义摇杆实现:
1. 自定义摇杆类
class Joystick: SKNode { private let backgroundNode: SKSpriteNode private let knobNode: SKSpriteNode private var inputVector = CGVector.zero // 摇杆最大拖动半径,根据你的UI调整 private let maxRadius: CGFloat = 50 // 摇杆输入变化的回调,给外部场景传递方向信息 var onInputChanged: ((CGVector) -> Void)? init(backgroundTexture: SKTexture, knobTexture: SKTexture) { backgroundNode = SKSpriteNode(texture: backgroundTexture) knobNode = SKSpriteNode(texture: knobTexture) super.init() backgroundNode.position = .zero backgroundNode.isUserInteractionEnabled = false addChild(backgroundNode) knobNode.position = .zero knobNode.isUserInteractionEnabled = false addChild(knobNode) isUserInteractionEnabled = true } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = touches.first else { return } updateKnobPosition(with: touch) } override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = touches.first else { return } updateKnobPosition(with: touch) } override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { resetKnobPosition() } override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { resetKnobPosition() } private func updateKnobPosition(with touch: UITouch) { let touchLocation = touch.location(in: self) let distance = hypot(touchLocation.x, touchLocation.y) if distance <= maxRadius { knobNode.position = touchLocation // 将输入归一化到-1到1的范围 inputVector = CGVector(dx: touchLocation.x / maxRadius, dy: touchLocation.y / maxRadius) } else { // 限制摇杆在最大半径内 let angle = atan2(touchLocation.y, touchLocation.x) knobNode.position = CGPoint(x: maxRadius * cos(angle), y: maxRadius * sin(angle)) inputVector = CGVector(dx: cos(angle), dy: sin(angle)) } onInputChanged?(inputVector) } private func resetKnobPosition() { knobNode.run(SKAction.move(to: .zero, duration: 0.1)) inputVector = .zero onInputChanged?(inputVector) } }
2. 在SKScene中使用摇杆
class GameScene: SKScene { private var joystick: Joystick! // 背景移动速度,根据场景调整 private let moveSpeed: CGFloat = 100 // 记录帧间隔时间,用于平滑移动 private var dt: CGFloat = 0 private var lastUpdateTime: TimeInterval = 0 override func didMove(to view: SKView) { // 初始化摇杆,替换成你的纹理资源 let bgTexture = SKTexture(imageNamed: "joystick_bg") let knobTexture = SKTexture(imageNamed: "joystick_knob") joystick = Joystick(backgroundTexture: bgTexture, knobTexture: knobTexture) // 把摇杆放在屏幕左下角,位置可以自定义 joystick.position = CGPoint(x: 100, y: 100) addChild(joystick) // 监听摇杆输入变化 joystick.onInputChanged = { [weak self] vector in guard let self = self else { return } // 根据摇杆输入,计算背景移动增量(相反方向) let deltaX = -vector.dx * self.moveSpeed * self.dt let deltaY = -vector.dy * self.moveSpeed * self.dt // 更新所有背景精灵的位置 for sprite in self.levelSprites { sprite.position.x += deltaX sprite.position.y += deltaY } // 播放对应方向的玩家动画 let isMoving = vector.dx != 0 || vector.dy != 0 if isMoving { if abs(vector.dx) > abs(vector.dy) { vector.dx > 0 ? self.playAnimation("WalkRight") : self.playAnimation("WalkLeft") } else { vector.dy > 0 ? self.playAnimation("WalkUp") : self.playAnimation("WalkDown") } } else { self.player.removeAction(forKey: "playerWalk") } } } override func update(_ currentTime: TimeInterval) { // 计算帧间隔时间,保证移动速度不受帧率影响 if lastUpdateTime == 0 { lastUpdateTime = currentTime } dt = CGFloat(currentTime - lastUpdateTime) lastUpdateTime = currentTime } private func playAnimation(_ name: String) { // 避免重复播放相同动画 if player.action(forKey: "playerWalk")?.key != name { player.removeAction(forKey: "playerWalk") player.run(SKAction.repeatForever(SKAction(named: name)!), withKey: "playerWalk") } } }
这种摇杆方案的优势很明显:玩家可以按住摇杆保持持续移动,操作逻辑和传统游戏完全一致,体验比手势控制好太多,也更符合你后续的功能规划。
总结
- 如果想继续用UIPan手势,核心是在
.changed状态下实时更新精灵position,而不是用固定的moveTo动作; - 考虑到你后续要做摇杆,直接实现虚拟摇杆是更优的选择,操作体验更贴合游戏场景。
内容的提问来源于stack exchange,提问作者amq
相关产品推荐
相关产品推荐

