【算法篇】用Swift完成贝塞尔曲线游戏

2,927 阅读7分钟

最近接了个需求,用iOS端的原生代码完成一个随机曲线的绘制,复杂的手势交互处理我们先放在一边,本篇主要想记录一下曲线绘制的算法。

首先我们需要知道有哪些具体要求

  1. 起始点和起始线条固定
  2. 绘制区域有范围限定
  3. 线条绘制需要有随机性,每次都不同
  4. 随机的线条需要保证一定的美观性
  5. 传入duration(绘制时间),绘制速度speed为15pt/s

前期准备

UIBezierPath

由于笔者没做过类似的UI需求,所以去学习了一下曲线绘制。在iOS中,特殊线条和形状的绘制基本都是依靠贝塞尔曲线。UIBezierPath封装了CGPath数据类型。一个UIBezierPath对象定义一个完整的路径(一个/多个子路径)

// 椭圆
public convenience init(ovalIn rect: CGRect)

// 圆角矩形
public convenience init(roundedRect rect: CGRect,
                        cornerRadius: CGFloat)

// 圆角矩形。根据一个Rect针对四角中的某个或多个角设置圆角
public convenience init(roundedRect rect: CGRect,
                        byRoundingCorners corners: UIRectCorner,
                        cornerRadii: CGSize)

// 弧线。基于指定圆心,指定半径、起始弧度、绘制方向。
public convenience init(arcCenter center: CGPoint,
                        radius: CGFloat,
                        startAngle: CGFloat,
                        endAngle: CGFloat,
                        clockwise: Bool)

// 根据CGPath创建并返回一个新的UIBezierPath对象
public convenience init(cgPath CGPath: CGPath)

// 三次贝塞尔曲线。通过moveToPoint:设置起始端点,endPoint为终止端点。以及两个控制点。
open func addCurve(to endPoint: CGPoint,
                   controlPoint1: CGPoint,
                   controlPoint2: CGPoint)

// 二次贝塞尔曲线。通过moveToPoint:设置起始端点。endPoint为终止端点,controlPoint为控制点。
open func addQuadCurve(to endPoint: CGPoint,
                       controlPoint: CGPoint)

在各种尝试过后,发现弧线的绘制更符合需求。原本也试过使用二次/三次贝塞尔曲线,发现美观性上不如弧线,且上一段和下一段曲线的连接处不太好处理(调研时间有限,也许是可以的,以后再研究)。那么我们就需要对圆弧的绘制有一些基本的数学了解。

弧度与角度

首先,我们要对弧度的表示有基本了解,如下图所示

Untitled.jpeg

// 角度 -> 弧度
func degreeToRadian(_ number: Double) -> Double {
  return number * .pi / 180
}

// 弧度 -> 角度
func radianToDegree(_ number: Double) -> Double {
  return number * 180 / .pi
}

CGPath的长度计算

iOS原生的UIKitCoreGraphics库中都无法直接获取CGPath的长度,所以我目前直接用了一个bezierpath-length库中的扩展来计算。感兴趣的小伙伴可以去学习一下具体实现,这边不多赘述了。

方案敲定

基本方案描述如下

  1. 通过传入的duration和speed得到最终需要绘制的总长度

  2. 绘制多段曲线,最终拼接在一起。

    1. 已知起始路径(根据已知信息计算得出)
    2. 随机取得下一条路径的半径弧度(边界条件特殊处理)
    3. 为了保证连接处的丝滑,通过上一条弧线圆心终点确定下一条弧线的圆心。
    4. 下一条的绘制方向与上一条相反

“下一条路径”的计算如图所示

Untitled.png

具体算法说明

startAngle与endAngle

由于每次传入的只有起始点坐标(即上一条路径的终点)、圆心坐标与半径,我们需要计算出startAngleendAngle,算法如下

startPoint的角度计算其实就是已知三角形三个点坐标,计算夹角角度的问题。

func calStartAngle(startPoint:CGPoint,
                   centerPoint:CGPoint,
                   radius: CGFloat) -> Double {
// 此处endPoint为辅助点
  let endPoint = CGPoint(x: centerPoint.x + radius,
                         y: centerPoint.y)
    
    //排除特殊情况,三个点一条线
  if (startPoint.x == centerPoint.x && centerPoint.x == endPoint.x) ||
      (startPoint.y == centerPoint.x && centerPoint.x == endPoint.x) {
    return 0
  }
    
  let x1 = startPoint.x - centerPoint.x
  let y1 = startPoint.y - centerPoint.y

  let x2 = endPoint.x - centerPoint.x // radius
  let y2 = endPoint.y - centerPoint.y // 0

  let x = x1 * x2 + y1 * y2
  let y = x1 * y2 - x2 * y1

  var angle = acos(x / sqrt(x * x + y * y))

	// 钝角
  if startPoint.y < centerPoint.y {
    angle = -angle
  }

  return angle
}

func calEndAngle(startAngle: Double,
								 clockWise: BOOL) -> Double {
	let endOffset = angle / 180 * Double.pi
  let endAngle: CGFloat = clockWise ? (startAngle + endOffset) : (startAngle - endOffset)
}

UIBezierPath初始化方法

最终整体的UIBezierPath路径 = 一个固定的起始UIBezierPath + 多个随机计算得出的UIBezierPath

所以基于已有数据,我们需要如下的UIBezierPath初始化方法

extension UIBezierPath {
  public convenience init(start: CGPoint,
                          center: CGPoint,
                          radius: CGFloat,
                          clockWise: Bool,
                          angle: CGFloat) {
    let startAngle = center.calAngleInCircle(pointInCircle: start,
                                             radius: radius)
    let endOffset = angle / 180 * Double.pi
    let endAngle: CGFloat = clockWise ? (startAngle + endOffset) : (startAngle - endOffset)

    self.init(arcCenter: center,
              radius: radius,
              startAngle: startAngle,
              endAngle: endAngle,
              clockwise: clockWise)
  }
}

起始路径的计算

根据需求1(起始点和起始线条固定)已知

  1. 起始路径的起始点:CGPoint(x: 80, y: 517)
  2. 起始路径的圆心:CGPoint(x: startPoint.x + startRadius, y: startPoint.y)
  3. 起始路径圆弧的半径:80pt
  4. 起始路径的整体绘制角度:120度
  5. 起始路径的绘制方向:clockWise (顺时针)

如下所示

let startPoint = CGPoint(x: 80,
                         y: 517)
let startRadius: CGFloat = 80 * 3 / 2 / Double.pi
let center = CGPointMake(startPoint.x + startRadius,
                         startPoint.y)
let angle = 120

那么为了创建该条路径,我们需要计算的是:

  1. 起始点角度startAngle
  2. 终点角度endEngle
let startAngle = calStartAngle(startPoint: startPoint,
                               centerPoint: center,
                               radius: startRadius)
let endOffset = angle / 180 * Double.pi
let endAngle: CGFloat = startAngle + endOffset

Untitled 1.png

剩余路径的计算

对于剩余的路径,已知条件有所不同

  1. 随机取得的半径,在kRadiusList中随机取一个值,其实也可以直接取某个范围内的随机值,这里主要是为了一定的美观性,所以暂时固定在几个值中获取
let kWidth = UIScreen.main.bounds.width
let kRadiusList = [kWidth / 4, kWidth / 3, kWidth / 10, kWidth / 12]
  1. 随机取得的圆弧整体角度。
let kAngleList: [CGFloat] = [180, 210, 240, 270, 300]
  1. 绘制方向。(为上一条路径绘制方向的反方向,由于已知起始路径是顺时针的,所以剩余路径的绘制方向都是可以获取的)
  2. 起始点:也就是上一条路径的终点,可以根据UIBezierPathcurrentPoint属性获得

计算流程

  1. 圆心位置:当我们得到上述条件之后,可以算得下一个圆的圆心位置
var nextCenter = prevCenter.calDestination(pastPoint: nextStart,
                                                nextRadius: nextRadius)

extension CGPoint {
/// self为center point,计算角度
    /// - Parameters:
    ///   - p2: 两圆相交点坐标
    ///   - r2: 下一个圆的圆心
    /// - Returns: 终点(即下一个圆心位置)坐标
  func calDestination(pastPoint p2: CGPoint,
                      nextRadius r2: CGFloat) -> CGPoint {
    let p1 = self
    let prevRadius = sqrt(pow(p2.x - p1.x, 2) + pow(p2.y - p1.y, 2))

    let yOffset1 = abs(p2.y - p1.y)
    let xOffset1 = abs(p2.x - p1.x)

    if xOffset1 == 0 {
      let nextCenterY = (p2.y > p1.y) ? (p2.y + r2) : (p2.y - r2)
      return CGPoint(x: p1.x,
                     y: nextCenterY)
    }

    if yOffset1 == 0 {
      let nextCenterX = (p2.x > p1.x) ? (p2.x + r2) : (p2.x - r2)
      return CGPoint(x: nextCenterX,
                     y: p1.y)
    }

    let xOffset2 = xOffset1 * (prevRadius + r2) / prevRadius
    let yOffset2 = yOffset1 * (prevRadius + r2) / prevRadius

    let nextCenterX = (p2.x < p1.x) ? ceil(p1.x - xOffset2) : ceil(p1.x + xOffset2)
    let nextCenterY = (p2.y < p1.y) ? ceil(p1.y - yOffset2) : ceil(p1.y + yOffset2)

    let nextCenter = CGPoint(x: nextCenterX,
                             y: nextCenterY)

    return nextCenter
  }
}
  1. 安全区域判断。若下一个圆的位置超出了安全区域,则将radius取二分之一再次计算和判断,直到保证下一个圆完全在安全区域内为止
while (!pointSafeArea.rangeJudgeLegal(center: nextCenter,
                                            radius: nextRadius)) {
        legalFlag = false
        nextRadius = nextRadius / 2
        nextCenter = prevCenter.calDestination(pastPoint: nextStart,
                                               nextRadius: nextRadius)
}

extension CGRect {
  func rangeJudgeLegal(center: CGPoint,
                       radius: CGFloat) -> Bool {
    if (center.x - radius < minX ) ||
        (center.x + radius > maxX ) ||
        (center.y - radius < minY ) ||
        (center.y + radius > maxY ) {
      return false
    }
    return true
  }
}
  1. 如上述代码展示,需要记录legalFlag,若曾经经历过重新计算,说明原半径偏大,则代表即将靠近安全区域的边界,则将angle设置为330度(让路线往回转)
let path = UIBezierPath(start: nextStart,
                              center: nextCenter,
                              radius: nextRadius,
                              clockWise: tempClockWise,
                              angle: legalFlag ? kAngleList.random()! : 330)
  1. 最后一条路径。若上面的path创建后,计算当前全部路径长度若已经超过指定路径总长,则计算出angle,重新创建UIBezierPath并跳出循环
if (currentLength + path.cgPath.length > wholeLengthWithoutStart) {
        let leftLength = wholeLengthWithoutStart - currentLength
        let angle = (leftLength / (Double.pi * 2 * nextRadius)) * 180 / .pi
        let lastPath = UIBezierPath(start: nextStart,
                                    center: nextCenter,
                                    radius: nextRadius,
                                    clockWise: tempClockWise,
                                    angle: legalFlag ? kAngleList.random()! : 330)
        tempList.append(lastPath)
        break
   }

异常情况

暴力处理。在上面计算边界情况时,会碰到一些异常情况,比如算得终点为NaN,所以在这里返回nil。并在外部判断和重新计算。

if (__inline_isnand(path.currentPoint.x) != 0) {
        return nil
}

// 外部
var leftPaths = startPath.subPaths(startClockWise: true,
                                       startCenter: startCenter,
                                       wholeLengthWithoutStart: lengthNeededToRun,
                                       pointSafeArea: pointSafeArea)

    while leftPaths == nil {
      leftPaths = startPath.subPaths(startClockWise: true,
                                     startCenter: startCenter,
                                     wholeLengthWithoutStart: lengthNeededToRun,
                                     pointSafeArea: pointSafeArea)
    }

优化点

  1. 摒弃角度,只用弧度
  2. 在拼接完路径开始绘制之后,points可以清除
  3. 计算时的autorelease
  4. 异常情况的处理。现在比较暴力,其实对于angle和radius的取值上可以再优化,比如应该先算得目前的合法radius,再在范围中取值等等。

参考

UIBezierPath长度计算:bezierpath-length