Swift中实现SpriteKit节点沿路径自由移动(替代SKAction follow)
Great question! Instead of letting SKAction.follow move the node automatically, you want players to drag the node along a predefined path (like a straight line or Bézier ellipse) while keeping it constrained to that path. Here's a step-by-step implementation to make this work in SpriteKit:
Core Idea
Instead of relying on automatic path following, we’ll handle touch events directly. For every touch movement, we calculate the closest point on the predefined path to the user’s touch location, then move the draggable node to that point. This ensures the node stays strictly on the path while following the user’s drag.
Step 1: Set Up Your Scene, Node, and Path
First, define your draggable node and path as properties in your SKScene, then initialize them:
import SpriteKit import UIKit class GameScene: SKScene { // Properties var draggableNode: SKSpriteNode! var path: CGPath! var isDragging = false override func didMove(to view: SKView) { // Create a draggable node (red square for example) draggableNode = SKSpriteNode(color: .red, size: CGSize(width: 50, height: 50)) // Start the node on the path (adjust this to your path's starting point) draggableNode.position = CGPoint(x: 100, y: 200) addChild(draggableNode) // Example 1: Create an ellipse path (using UIBezierPath) path = UIBezierPath(ovalIn: CGRect(x: 100, y: 100, width: 300, height: 200)).cgPath // Example 2: Create a straight line path (uncomment to use) // let straightPath = CGMutablePath() // straightPath.move(to: CGPoint(x: 100, y: 200)) // straightPath.addLine(to: CGPoint(x: 400, y: 200)) // path = straightPath // Optional: Draw the path to visualize it (blue line) let pathNode = SKShapeNode(path: path) pathNode.strokeColor = .blue pathNode.lineWidth = 2 addChild(pathNode) } }
Step 2: Handle Touch Events
Add touch handlers to detect when the user starts dragging, moves their finger, and stops dragging:
// MARK: - Touch Handling override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = touches.first else { return } let touchLocation = touch.location(in: self) // Only start dragging if the user taps the draggable node if draggableNode.contains(touchLocation) { isDragging = true } } override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { guard isDragging, let touch = touches.first else { return } let touchLocation = touch.location(in: self) // Find the closest point on the path to the touch location let closestPathPoint = closestPointOnPath(touchLocation, path: path) // Move the node to this constrained point draggableNode.position = closestPathPoint } override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { isDragging = false } override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { isDragging = false }
Step 3: Calculate the Closest Point on the Path
This is the critical part: we need functions to compute the closest point on line segments and cubic Bézier curves (since ellipses and complex paths use Bézier curves). Add these helper functions and extensions to your scene:
// MARK: - Path Helper Functions func closestPointOnPath(_ touchPoint: CGPoint, path: CGPath) -> CGPoint { let bezierPath = UIBezierPath(cgPath: path) var closestPoint = CGPoint.zero var minDistance = CGFloat.infinity let tolerance: CGFloat = 0.1 // Adjust for precision vs performance var prevPoint = CGPoint.zero var isFirstPoint = true // Iterate through every element in the path bezierPath.forEach { element in switch element { case .moveTo(let point): prevPoint = point isFirstPoint = true case .lineTo(let point): let lineClosest = closestPointOnLineSegment(touchPoint: touchPoint, start: prevPoint, end: point) let distance = lineClosest.distance(to: touchPoint) if distance < minDistance { minDistance = distance closestPoint = lineClosest } prevPoint = point isFirstPoint = false case .curveTo(let control1, let control2, let end): // Handle cubic Bézier curves (used for ellipses, circles, etc.) let curveClosest = closestPointOnCubicBezier( touchPoint: touchPoint, start: prevPoint, control1: control1, control2: control2, end: end, tolerance: tolerance ) let distance = curveClosest.distance(to: touchPoint) if distance < minDistance { minDistance = distance closestPoint = curveClosest } prevPoint = end isFirstPoint = false case .close: // Connect the last point back to the path's start let lineClosest = closestPointOnLineSegment(touchPoint: touchPoint, start: prevPoint, end: bezierPath.currentPoint) let distance = lineClosest.distance(to: touchPoint) if distance < minDistance { minDistance = distance closestPoint = lineClosest } } } return closestPoint } // Find closest point on a straight line segment func closestPointOnLineSegment(touchPoint: CGPoint, start: CGPoint, end: CGPoint) -> CGPoint { let lineVector = end - start let lengthSquared = lineVector.x * lineVector.x + lineVector.y * lineVector.y guard lengthSquared > 0 else { return start } // Avoid division by zero // Calculate the projection of the touch point onto the line let t = max(0, min(1, (touchPoint - start).dot(lineVector) / lengthSquared)) return start + lineVector * t } // Find closest point on a cubic Bézier curve (recursive subdivision method) func closestPointOnCubicBezier(touchPoint: CGPoint, start: CGPoint, control1: CGPoint, control2: CGPoint, end: CGPoint, tolerance: CGFloat) -> CGPoint { // Subdivide the curve until it's short enough to treat as a line let midStart = (start + control1) / 2 let midControl1 = (control1 + control2) / 2 let midControl2 = (control2 + end) / 2 let midMid = (midStart + midControl1) / 2 let midMid2 = (midControl1 + midControl2) / 2 let midEnd = (midMid + midMid2) / 2 let distanceToLine = distanceFromPointToLine(touchPoint, lineStart: start, lineEnd: end) if distanceToLine < tolerance { return closestPointOnLineSegment(touchPoint: touchPoint, start: start, end: end) } // Recursively check left and right halves of the curve let leftClosest = closestPointOnCubicBezier( touchPoint: touchPoint, start: start, control1: midStart, control2: midMid, end: midEnd, tolerance: tolerance ) let rightClosest = closestPointOnCubicBezier( touchPoint: touchPoint, start: midEnd, control1: midMid2, control2: midControl2, end: end, tolerance: tolerance ) // Return the closer of the two points return leftClosest.distance(to: touchPoint) < rightClosest.distance(to: touchPoint) ? leftClosest : rightClosest } // Calculate distance from a point to an infinite line func distanceFromPointToLine(_ point: CGPoint, lineStart: CGPoint, lineEnd: CGPoint) -> CGFloat { let lineVector = lineEnd - lineStart let pointVector = point - lineStart let crossProduct = lineVector.x * pointVector.y - lineVector.y * pointVector.x return abs(crossProduct) / lineVector.length() } // MARK: - CGPoint Extensions extension CGPoint { static func -(lhs: CGPoint, rhs: CGPoint) -> CGPoint { CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y) } static func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint { CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y) } static func *(lhs: CGPoint, scalar: CGFloat) -> CGPoint { CGPoint(x: lhs.x * scalar, y: lhs.y * scalar) } static func /(lhs: CGPoint, scalar: CGFloat) -> CGPoint { CGPoint(x: lhs.x / scalar, y: lhs.y / scalar) } func dot(_ other: CGPoint) -> CGFloat { x * other.x + y * other.y } func length() -> CGFloat { sqrt(x * x + y * y) } func distance(to other: CGPoint) -> CGFloat { (self - other).length() } }
How It Works
- Touch Detection: We only enable dragging when the user taps directly on the draggable node, preventing unintended movement.
- Path Constraining: For every touch move, we calculate the closest point on your predefined path to the user’s finger. This ensures the node never strays off the path, whether it’s a straight line, ellipse, or complex Bézier curve.
- Precision Adjustment: The
tolerancevalue in the Bézier curve function lets you balance precision and performance—lower values are more precise but use more processing power.
内容的提问来源于stack exchange,提问作者willy wijaya

