3

I want my node to travel in a sine curve wave, and I tried using that for CGPath. How do I create a CGPath that follows a sine curve? Is there any way other than to manually find the points on the curve, or could I just pass in a sine function?

let action = SKAction.followPath(<the sine path>, asOffset: true, orientToPath: true, duration: 5)

Could this be done through Bezier Paths and then converted into CGPaths? Thanks.

Dieblitzen
  • 544
  • 4
  • 22

1 Answers1

9

No, there isn't a built in method to build a path from a function, but you can easily write one of your own. In Swift 3:

/// Build path within rectangle
///
/// Given a `function` that converts values between zero and one to another values between zero and one, this method will create `UIBezierPath` within `rect` using that `function`.
///
/// - parameter rect:      The `CGRect` of points on the screen.
///
/// - parameter count:     How many points should be rendered. Defaults to `rect.size.width`.
///
/// - parameter function:  A closure that will be passed an floating point number between zero and one and should return a return value between zero and one as well.

private func path(in rect: CGRect, count: Int? = nil, function: (CGFloat) -> (CGFloat)) -> UIBezierPath {
    let numberOfPoints = count ?? Int(rect.size.width)

    let path = UIBezierPath()
    path.move(to: convert(point: CGPoint(x: 0, y: function(0)), in: rect))
    for i in 1 ..< numberOfPoints {
        let x = CGFloat(i) / CGFloat(numberOfPoints - 1)
        path.addLine(to: convert(point: CGPoint(x: x, y: function(x)), in: rect))
    }
    return path
}

/// Convert point with x and y values between 0 and 1 within the `CGRect`.
///
/// - parameter point:  A `CGPoint` value with x and y values between 0 and 1.
/// - parameter rect:   The `CGRect` within which that point should be converted.

private func convert(point: CGPoint, in rect: CGRect) -> CGPoint {
    return CGPoint(
        x: rect.origin.x + point.x * rect.size.width,
        y: rect.origin.y + rect.size.height - point.y * rect.size.height
    )
}

So, let's pass a function that does one sine curve as it progresses across the width of the of the rect:

func sinePath(in rect: CGRect, count: Int? = nil) -> UIBezierPath {
    // note, since sine returns values between -1 and 1, let's add 1 and divide by two to get it between 0 and 1
    return path(in: rect, count: count) { (sin($0 * .pi * 2.0) + 1.0) / 2.0 }
}

Note, the above assumed that you wanted traverse from left to right, building the path defined by the function. You could also do more of a parametric rendition:

/// Build path within rectangle
///
/// Given a `function` that converts values between zero and one to another values between zero and one, this method will create `UIBezierPath` within `rect` using that `function`.
///
/// - parameter rect:      The `CGRect` of points on the screen.
///
/// - parameter count:     How many points should be rendered. Defaults to `rect.size.width` or `rect.size.width`, whichever is larger.
///
/// - parameter function:  A closure that will be passed an floating point number between zero and one and should return a `CGPoint` with `x` and `y` values between 0 and 1.

private func parametricPath(in rect: CGRect, count: Int? = nil, function: (CGFloat) -> (CGPoint)) -> UIBezierPath {
    let numberOfPoints = count ?? max(Int(rect.size.width), Int(rect.size.height))

    let path = UIBezierPath()
    let result = function(0)
    path.move(to: convert(point: CGPoint(x: result.x, y: result.y), in: rect))
    for i in 1 ..< numberOfPoints {
        let t = CGFloat(i) / CGFloat(numberOfPoints - 1)
        let result = function(t)
        path.addLine(to: convert(point: CGPoint(x: result.x, y: result.y), in: rect))
    }
    return path
}

Then you can modify the x coordinate using sine curve, and just increment y:

func verticalSinePath(in rect: CGRect, count: Int? = nil) -> UIBezierPath {
    // note, since sine returns values between -1 and 1, let's add 1 and divide by two to get it between 0 and 1
    return parametricPath(in: rect, count: count) { CGPoint(
        x: (sin($0 * .pi * 2.0) + 1.0) / 2.0,
        y: $0
    ) }
}

The virtue of this is that you could also now define any sort of path you want, e.g. a spiral:

func spiralPath(in rect: CGRect, count: Int? = nil) -> UIBezierPath {
    return parametricPath(in: rect, count: count) { t in
        let r = 1.0 - sin(t * .pi / 2.0)
        return CGPoint(
            x: (r * sin(t * 10.0 * .pi * 2.0) + 1.0) / 2.0,
            y: (r * cos(t * 10.0 * .pi * 2.0) + 1.0) / 2.0
        )
    }
}

Here are the Swift 2 renditions of the above:

/// Build path within rectangle
///
/// Given a `function` that converts values between zero and one to another values between zero and one, this method will create `UIBezierPath` within `rect` using that `function`.
///
/// - parameter rect:      The `CGRect` of points on the screen.
///
/// - parameter count:     How many points should be rendered. Defaults to `rect.size.width`.
///
/// - parameter function:  A closure that will be passed an floating point number between zero and one and should return a return value between zero and one as well.

private func path(in rect: CGRect, count: Int? = nil, function: (CGFloat) -> (CGFloat)) -> UIBezierPath {
    let numberOfPoints = count ?? Int(rect.size.width)

    let path = UIBezierPath()
    path.moveToPoint(convert(point: CGPoint(x: 0, y: function(0)), rect: rect))
    for i in 1 ..< numberOfPoints {
        let x = CGFloat(i) / CGFloat(numberOfPoints - 1)
        path.addLineToPoint(convert(point: CGPoint(x: x, y: function(x)), rect: rect))
    }
    return path
}

/// Convert point with x and y values between 0 and 1 within the `CGRect`.
///
/// - parameter point:  A `CGPoint` value with x and y values between 0 and 1.
/// - parameter rect:   The `CGRect` within which that point should be converted.

private func convert(point point: CGPoint, rect: CGRect) -> CGPoint {
    return CGPoint(
        x: rect.origin.x + point.x * rect.size.width,
        y: rect.origin.y + rect.size.height - point.y * rect.size.height
    )
}

func sinePath(in rect: CGRect, count: Int? = nil) -> UIBezierPath {
    // note, since sine returns values between -1 and 1, let's add 1 and divide by two to get it between 0 and 1
    return path(in: rect, count: count) { (sin($0 * CGFloat(M_PI * 2.0)) + 1.0) / 2.0 }
}

/// Build path within rectangle
///
/// Given a `function` that converts values between zero and one to another values between zero and one, this method will create `UIBezierPath` within `rect` using that `function`.
///
/// - parameter rect:      The `CGRect` of points on the screen.
///
/// - parameter count:     How many points should be rendered. Defaults to `rect.size.width`.
///
/// - parameter function:  A closure that will be passed an floating point number between zero and one and should return a `CGPoint` with `x` and `y` values between 0 and 1.

private func parametricPath(in rect: CGRect, count: Int? = nil, function: (CGFloat) -> (CGPoint)) -> UIBezierPath {
    let numberOfPoints = count ?? max(Int(rect.size.width), Int(rect.size.height))

    let path = UIBezierPath()
    let result = function(0)
    path.moveToPoint(convert(point: CGPoint(x: result.x, y: result.y), rect: rect))
    for i in 1 ..< numberOfPoints {
        let t = CGFloat(i) / CGFloat(numberOfPoints - 1)
        let result = function(t)
        path.addLineToPoint(convert(point: CGPoint(x: result.x, y: result.y), rect: rect))
    }
    return path
}

func verticalSinePath(in rect: CGRect, count: Int? = nil) -> UIBezierPath {
    // note, since sine returns values between -1 and 1, let's add 1 and divide by two to get it between 0 and 1
    return parametricPath(in: rect, count: count) { CGPoint(
        x: (sin($0 * CGFloat(M_PI * 2.0)) + 1.0) / 2.0,
        y: $0
    ) }
}

func spiralPath(in rect: CGRect, count: Int? = nil) -> UIBezierPath {
    return parametricPath(in: rect, count: count) { t in
        let r = 1.0 - sin(t * CGFloat(M_PI_2))
        return CGPoint(
            x: (r * sin(t * 10.0 * CGFloat(M_PI * 2.0)) + 1.0) / 2.0,
            y: (r * cos(t * 10.0 * CGFloat(M_PI * 2.0)) + 1.0) / 2.0
        )
    }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • @Dieblitzen - If it looks "upside down", it's because this was designed for UIKit coordinate system. If you want it the other way around, just tweak `convertPoint:rect:` so that rather than using a `y` value of `rect.origin.y + rect.size.height - point.y * rect.size.height`, you can just use `rect.origin.y + point.y * rect.size.height`. – Rob Dec 26 '15 at 21:59
  • Thanks a ton! This looks like the work of a genius! Although at the moment it is starting in the centre of the screen. What should I do to change its start position? And what code do I have to edit to change the amplitude and frequency of the curve? Thanks again! @Rob – Dieblitzen Dec 27 '15 at 03:22
  • @Dieblitzen - In terms of positioning, that's just a function of the `CGRect` of the visible portion of the scene, so, you're likely passing the wrong `CGRect`. I used `SKView` method `convertPoint:toScene:` to convert the coordinates of the visible `view.bounds` into the `SKScene`'s coordinates. E.g. https://gist.github.com/robertmryan/680113628a06705e17dd – Rob Dec 27 '15 at 18:22
  • @Dieblitzen - In terms of amplitude, multiply the `sin()` value by the appropriate factor, e.g. this is 1/3rd the amplitude: `pathInRect(rect) { (0.333 * sin($0 * CGFloat(M_PI * 2.0)) + 1.0) / 2.0 }`. In terms of frequency, you multiply the value inside the `sin` function, e.g. for three sine curves: `pathInRect(rect) { (sin(3.0 * $0 * CGFloat(M_PI * 2.0)) + 1.0) / 2.0 }`. – Rob Dec 27 '15 at 18:22
  • You can also adjust the amplitude by changing the rect's height – 0x141E Dec 27 '15 at 18:31
  • @0x141E Yep, lots of ways to skin the cat. – Rob Dec 27 '15 at 18:36
  • Hi that works great! Thanks! Just one last question... how do you reverse the path of the node so that it flies from right to left? Thanks! @Rob – Dieblitzen Dec 28 '15 at 01:49
  • You can either write a variation of `pathInRect` that goes the other way, or, easier, just use the parametric rendition where the `x` is `1.0-t` the `y` portion is your existing function, also using `1.0-t` where you did have just `t` before. – Rob Dec 28 '15 at 11:04