如何实现一个Canvas渲染引擎(三):碰撞检测

友情提示

  1. 请先看这里 👉 链接
  2. 代码的GitHub地址 👉 链接
  3. 阅读本文建议将代码回退到2db5b341bd905125a9847dc97d01f68356630534这个commit,以去掉一些与本文功能无关的代码。

1. 前言

假设我们现在在canvas上画了很多个节点(DisplayObject),这些节点形成了一套层级关系,我们将鼠标移动到canvas元素上并点击了一下左键,这个时候,如何判断鼠标点到了哪个节点呢?

碰撞检测是非常重要的一个功能,因为下一篇要讲的事件系统,就是基于碰撞检测做的。本文将讲述如何实现碰撞检测。

2. 两种方案

2.1 像素标记法

像素标记法的做法是:除了用户看得见的那个canvas元素(以下称canvasA),渲染引擎会在内部维护一个离屏的canvas元素(以下称canvasB),每个要被绘制的元素(DisplayObject),都会被赋予一种独一无二的颜色,这些元素除了在canvasA上绘制一次,还要在canvasB上绘制一次(用上面提到的那个独一无二的颜色绘制),并且绘制的顺序是一样的(先序遍历对象树),在canvasA上触发点击事件时,我们会拿到鼠标落在canvasA上的坐标,然后去canvasB上取到这个坐标对应的那一个像素的色值,由于每个要被绘制的元素都被赋予了一种独一无二的色值,所以这个时候我们就已经可以根据这个色值来得到对应的元素了。

2.2 射线法(奇偶环绕法则)

射线法是针对单个多边形的一种碰撞检测方式

假设现在有一个封闭的多边形,射线法的逻辑是:先维护一个计数器count,然后从要检测的那个点发出一条无限远的射线,这条射线每穿过一次这个多边形的一条边,就让count加1,如果最后count是一个奇数,那么我们就判断这个点在这个多边形的内部。

2.3 两种方案的对比

这两种方案各有利弊,像素标记法的逻辑比较简单,它利用了canvas的能力来做碰撞检测这件事情,Konva这个库就用了像素标记法,但像素标记法的缺点就是每个图形都需要绘制2次,这对于性能的影响是非常大的,如果要绘制的图形比较多,那么绘制时间会明显地上升许多。射线法的逻辑要更复杂一些,它不利用canvas的能力,而是完全自己来实现碰撞检测,对编码的要求相对于像素标记法要高一些,但是射线法的性能比像素标记法要高很多,pixiJS就使用了射线法来做碰撞检测。

2.4 该用哪种方案?

用哪种方案没有一个明确的答案,还是要根据实际情况来做选择,本文将会实现这两种方案,但是和pixijs一样,在本文的渲染引擎中最终会采用射线法这种方案,像素标记法的代码会被放到另一个分支feat/hit-test-by-marking-pixel上,而主分支上只有射线法这种方案的代码,后续的事件系统也是基于射线法这种方案的。

3. 像素标记法实现

3.1 分配一个独一无二的颜色

在Container类上挂载一个uniqueColor属性,每次实例化这个类都会生成一个随机的16进制颜色

typescript 复制代码
public uniqueColor: string = randomHexCreator()

3.2 创建离屏canvas元素

在CanvasRenderer类上创建一个离屏canvas元素

typescript 复制代码
private offscreenCanvas = document.createElement('canvas')

3.3 创建map用来记录色值对应的元素

在Container类上创建静态属性hitTestMap

typescript 复制代码
public static hitTestMap: { [anyKey: string]: Container } = {}

3.4 在离屏canvas元素上绘制

在调用Graphics类的renderCanvas函数完成绘制后,还需要要调用renderOnOffscreenCanvas函数来在离屏canvas元素上再次绘制一次

typescript 复制代码
private renderOnOffscreenCanvas(render: CanvasRenderer) {
  this.startPoly()

  const offscreenCtx = render.offscreenCtx
  const { a, b, c, d, tx, ty } = this.transform.worldTransform

  offscreenCtx.setTransform(a, b, c, d, tx, ty)

  const graphicsData = this._geometry.graphicsData

  offscreenCtx.fillStyle = this.uniqueColor // 填充色为被赋予的独一无二的颜色
  offscreenCtx.globalAlpha = 1 // 所有填充的不透明度都是1

  // 用map将uniqueColor和要绘制的图形对应起来
  if (!Container.hitTestMap[this.uniqueColor]) {
    Container.hitTestMap[this.uniqueColor] = this
  }

  for (let i = 0; i < graphicsData.length; i++) {
    const data = graphicsData[i]
    const { fillStyle, shape } = data

    offscreenCtx.beginPath()

    if (shape instanceof Rectangle) {
      const rectangle = shape
      const { x, y, width, height } = rectangle
      if (fillStyle.visible) {
        offscreenCtx.fillRect(x, y, width, height)
      }
    }

    if (shape instanceof Circle) {
      const circle = shape
      const { x, y, radius } = circle

      offscreenCtx.arc(x, y, radius, 0, 2 * Math.PI)

      if (fillStyle.visible) {
        offscreenCtx.fill()
      }
    }

    if (shape instanceof RoundedRectangle) {
      const roundedRectangle = shape
      const { x, y, width, height, radius } = roundedRectangle

      offscreenCtx.moveTo(x + radius, y)
      offscreenCtx.arc(
        x + radius,
        y + radius,
        radius,
        Math.PI * 1.5,
        Math.PI,
        true
      )
      offscreenCtx.lineTo(x, y + height - radius)
      offscreenCtx.arc(
        x + radius,
        y + height - radius,
        radius,
        Math.PI,
        Math.PI / 2,
        true
      )
      offscreenCtx.lineTo(x + width - radius, y + height)
      offscreenCtx.arc(
        x + width - radius,
        y + height - radius,
        radius,
        Math.PI / 2,
        0,
        true
      )
      offscreenCtx.lineTo(x + width, y + radius)
      offscreenCtx.arc(
        x + width - radius,
        y + radius,
        radius,
        0,
        Math.PI * 1.5,
        true
      )
      offscreenCtx.closePath()

      if (fillStyle.visible) {
        offscreenCtx.fill()
      }
    }

    if (shape instanceof Ellipse) {
      const ellipse = shape
      const { x, y, radiusX, radiusY } = ellipse

      offscreenCtx.ellipse(x, y, radiusX, radiusY, 0, 0, Math.PI * 2)

      if (fillStyle.visible) {
        offscreenCtx.fill()
      }
    }

    if (shape instanceof Polygon) {
      const polygon = shape

      const { points, closeStroke } = polygon

      offscreenCtx.moveTo(points[0], points[1])

      for (let i = 2; i < points.length; i += 2) {
        offscreenCtx.lineTo(points[i], points[i + 1])
      }

      if (closeStroke) {
        offscreenCtx.closePath()
      }

      if (fillStyle.visible) {
        offscreenCtx.fill()
      }
    }
  }
}

renderOnOffscreenCanvas函数和renderCanvas的执行逻辑差不多,都是遍历_geometry属性将所有子图形绘制出来,但是它们有一些区别:

  • 由于线条不参与碰撞检测,所以renderOnOffscreenCanvas没有stroke。
  • renderOnOffscreenCanvas在绘制时,不透明度(ctx.globalAlpha)都是1,色值为那个被赋予的独一无二的色值
  • 在绘制的时候,将当前元素与那个独一无二的色值对应起来(放在hitTestMap里)

3.5 监听点击事件,并在点击后拿到对应的点击的元素

我们会在用户能看到的那个canvas元素上监听事件,而不是离屏canvas元素

typescript 复制代码
this.canvasEle.addEventListener('click', (e) => {
  // 取到鼠标落点对应的那个像素
  const [r, g, b] = this.offscreenCtx.getImageData(
    e.offsetX,
    e.offsetY,
    1,
    1
  ).data

  // 将10进制转化成16进制,注意有可能出现转化后只有1位的情况,这个时候需要补一个0
  const hexR =
    r.toString(16).length === 1 ? '0' + r.toString(16) : r.toString(16)
  const hexG =
    g.toString(16).length === 1 ? '0' + g.toString(16) : g.toString(16)
  const hexB =
    b.toString(16).length === 1 ? '0' + b.toString(16) : b.toString(16)
  const color = `#${hexR}${hexG}${hexB}`

  // 去map上取到对应的碰撞对象
  const target = Container.hitTestMap[color]

  if (target?.uniqueColor) {
    message.success(`点到了色值为${target.uniqueColor}的元素`)
  } else {
    message.error(`没有点到任何元素`)
  }
})

getImageData函数来取到鼠标点到的那个像素

3.6 测试

在画布上绘制了绿色、黑色、粉色三个元素

typescript 复制代码
const g1 = new Graphics().beginFill('green').drawRect(200, 200, 200, 200)
const g2 = new Graphics().beginFill('black').drawRect(200, 200, 100, 100)
const g3 = new Graphics().beginFill('pink').drawRect(250, 250, 100, 100)
const c = new Container()
c.addChild(g2)
c.addChild(g3)

app.stage.addChild(g1)
app.stage.addChild(c)

可以看到,点击了对应的元素之后,拿到了这个元素的被分配的那个独一无二的色值,如果没有点到任何元素,target为null。

code sandbox地址

4. 射线法实现

针对复杂的多边形,可以采用射线法来做碰撞检测,对于规则图形则用不到射线法,比如圆,碰撞检测的方式是:判断待检测点与圆心的距离是否小于圆的半径就行了。

4.1 原理(奇偶环绕法则)

维护一个计数器count,计数器的初始值为0,然后从待检测点发出一条射线,这条射线每穿过封闭图形的边一次,就让count加1,如果最后count为奇数,则判断该点在封闭图形内部,如果为偶数,则判断该点在封闭图形外部。

举个几个例子:

例子1: 红点为待检测点,从该点发出一条射线,这条射线穿过了封闭图形的边一次,count最后的结果为1,为奇数,所以红点在这个封闭图形的内部。

例子2: 从红点发出的射线穿过了封闭图形的边3次,count为3,为奇数,所以判断红点在封闭图形的内部

例子3: 从红点发出的射线穿过了封闭图形的边2次,这个时候判断红点在封闭图形的外部。

4.2 如何实现

判断射线与曲线线段是否相交,是比较困难的,但是判断射线与直线线段相交,相对就简单许多,在上一篇文章说了,除了一些规则的曲线图形(完整的圆、椭圆),其他的不规则的曲线图形,都会用直边多边形来代替。

所以,射线法其实只需要处理直边多边形的情况,如下:

具体做法就是,用for循环判断这个直边多边形的每一条边,如果相交则让count+1,循环结束后就能得到count了。

所以,问题就来到了:如何判断一条射线与一条线段是否相交

4.3 如何判断射线与线段是否相交

我们会从待检测点发出一条水平向右的无限远的射线

这里是图片

首先我们可以排除一些一定不相交的情况:

1: 线段在射线上方

2: 线段在射线下方

3: 线段的两个端点都在待检测点 的左边

排除了以上3种一定不相交 的情况后,接下来会有一种一定相交 的情况,也就是线段的2个端点都在待检测点 的右边:

最后,还剩下了1种情况:线段的一个端点在待检测点 的左边,另一个端点在待检测点的右边,这个时候可能相交,也可能不相交:

  1. 不相交:

  2. 相交:

这种情况下,计算出射线所处的直线 与线段的交点的x坐标 ,然后判断这个交点的x坐标 是否大于待检测点的x坐标 ,如果是,则说明射线与线段相交了,反之则没有相交。 上图中,除了未知点X(P1P2和射线的交点),其他所有点的坐标,都是已知的,我们可以得到线段P2O的长度和线段P1O的长度的比值,等于线段P2Q的长度和线段XQ的长度的比值,

即: <math xmlns="http://www.w3.org/1998/Math/MathML"> P 2 O P 1 O = P 2 Q X Q \frac{P2O}{P1O}=\frac{P2Q}{XQ} </math>P1OP2O=XQP2Q

接下来我们就可以算出线段XQ的长度了,然后再用点P2的x坐标减去这个长度,就得到了未知点X的x坐标了,如果未知点X的x坐标大于待检测点P的x坐标,说明射线与线段相交了,否则,射线与线段没有相交。

4.4 代码实现

4.4.1 判断线段与射线是否相交(Polygon.isIntersect)

typescript 复制代码
private isIntersect(
  px: number,
  py: number,
  p1x: number,
  p1y: number,
  p2x: number,
  p2y: number
) {
  // 线段在射线上方
  if (p1y > py && p2y > py) {
    return false
  }

  // 线段在射线下方
  if (p1y < py && p2y < py) {
    return false
  }

  // 线段的两个端点都在待检测点的左边
  if (p1x < px && p2x < px) {
    return false
  }

  // 线段的2个端点都在待检测点的右边
  if (p1x > px && p2x > px) {
    return true
  }

  const p2o = p1y - p2y
  const p1o = p2x - p1x
  const p2q = py - p2y

  const x = p2x - (p1o / p2o) * p2q
  if (x > px) {
    return true
  } else {
    return false
  }
}

4.4.2 判断待检测点是否在一个多边形内部(Polygon.contains)

遍历多边形的每一条边,然后用isIntersect函数分别判断。

typescript 复制代码
public contains(p: Point): boolean {
  const len = this.points.length
  let count = 0

  // points数组的每两个元素为一个顶点的坐标
  for (let i = 2; i <= len - 2; i += 2) {
    const p1x = this.points[i - 2]
    const p1y = this.points[i - 1]
    const p2x = this.points[i]
    const p2y = this.points[i + 1]
    if (this.isIntersect(p.x, p.y, p1x, p1y, p2x, p2y)) {
      count++
    }
  }

  // 还需要判断最后一个点和第一个点的连线是否与射线相交
  const p1x = this.points[0]
  const p1y = this.points[1]
  const p2x = this.points[len - 2]
  const p2y = this.points[len - 1]
  if (this.isIntersect(p.x, p.y, p1x, p1y, p2x, p2y)) {
    count++
  }

  if (count % 2 === 0) {
    return false
  } else {
    return true
  }
}

pixijs的代码实现更简洁一些,这里贴一下pixijs的代码(Polygon.contains):

typescript 复制代码
contains(x: number, y: number): boolean
{
    let inside = false;

    // use some raycasting to test hits
    // https://github.com/substack/point-in-polygon/blob/master/index.js
    const length = this.points.length / 2;

    for (let i = 0, j = length - 1; i < length; j = i++)
    {
        const xi = this.points[i * 2];
        const yi = this.points[(i * 2) + 1];
        const xj = this.points[j * 2];
        const yj = this.points[(j * 2) + 1];
        const intersect = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * ((y - yi) / (yj - yi))) + xi);

        if (intersect)
        {
            inside = !inside;
        }
    }

    return inside;
}

至此,多边形的碰撞检测就实现了。

5. 补充所有类型的图形的碰撞检测方法

目前所有图形都继承自Shape这个抽象类,Shape要求每个子类都实现contains函数以作为碰撞检测的基础,在这一节将会实现所有图形的contains函数

5.1 圆Circle

只要待检测点离圆心的距离小于半径,就判断待检测点在该封闭图形的内部

5.1.1 代码实现

typescript 复制代码
public contains(p: Point): boolean {
  if (
    (p.x - this.x) * (p.x - this.x) + (p.y - this.y) * (p.y - this.y) <
    this.radius * this.radius
  ) {
    return true
  } else {
    return false
  }
}

5.2 矩形Rectangle

代码实现:

typescript 复制代码
public contains(p: Point): boolean {
  if (
    p.x > this.x &&
    p.x < this.x + this.width &&
    p.y > this.y &&
    p.y < this.y + this.height
  ) {
    return true
  } else {
    return false
  }
}

5.3 椭圆Ellipse

椭圆的方程是:

<math xmlns="http://www.w3.org/1998/Math/MathML"> x 2 a 2 + y 2 b 2 = 1 \frac{x^2}{a^2}+\frac{y^2}{b^2}=1 </math>a2x2+b2y2=1

只要 <math xmlns="http://www.w3.org/1998/Math/MathML"> x 2 a 2 + y 2 b 2 < 1 \frac{x^2}{a^2}+\frac{y^2}{b^2}<1 </math>a2x2+b2y2<1,我们就判断待检测点落在椭圆的内部

5.3.1 代码实现

typescript 复制代码
public contains(p: Point): boolean {
  if (
    ((p.x - this.x) * (p.x - this.x)) / (this.radiusX * this.radiusX) +
      ((p.y - this.y) * (p.y - this.y)) / (this.radiusY * this.radiusY) <
    1
  ) {
    return true
  } else {
    return false
  }
}

5.4 圆角矩形RoundedRectangle

先判断是否落在这个大矩形里(红色区域),如果不在,说明待检测点在圆角矩形外部。

如果通过了第一轮检测,则进入下一轮检测,第二轮检测会判断待检测点是否落在4个圆角外(红色区域),如果是,说明待检测点在圆角矩形外部,反之则说明在内部。

5.4.1 代码

typescript 复制代码
public contains(p: Point): boolean {
  const con1 =
    p.x > this.x &&
    p.x < this.x + this.width &&
    p.y > this.y &&
    p.y < this.y + this.height
  if (!con1) {
    return false
  }

  // 判断左上角
  const c1x = this.x + this.radius
  const c1y = this.y + this.radius
  if (p.x < c1x && p.y < c1y) {
    if (
      (p.x - c1x) * (p.x - c1x) + (p.y - c1y) * (p.y - c1y) <
      this.radius * this.radius
    ) {
      return true
    } else {
      return false
    }
  }

  // 判断左下角
  const c2x = this.x + this.radius
  const c2y = this.y + this.height - this.radius
  if (p.x < c2x && p.y > c2y) {
    if (
      (p.x - c2x) * (p.x - c2x) + (p.y - c2y) * (p.y - c2y) <
      this.radius * this.radius
    ) {
      return true
    } else {
      return false
    }
  }

  // 判断右上角
  const c3x = this.x + this.width - this.radius
  const c3y = this.y + this.radius
  if (p.x > c3x && p.y < c3y) {
    if (
      (p.x - c3x) * (p.x - c3x) + (p.y - c3y) * (p.y - c3y) <
      this.radius * this.radius
    ) {
      return true
    } else {
      return false
    }
  }

  // 判断右下角
  const c4x = this.x + this.width - this.radius
  const c4y = this.y + this.height - this.radius
  if (p.x > c4x && p.y < c4y) {
    if (
      (p.x - c4x) * (p.x - c4x) + (p.y - c4y) * (p.y - c4y) <
      this.radius * this.radius
    ) {
      return true
    } else {
      return false
    }
  }

  return true
}

至此,所有图形的碰撞检测方法都有了

6. Graphics类的碰撞检测函数

前面讲述了Graphics类支持的所有的图形的碰撞检测函数,接下来就是补充Graphics类的碰撞检测函数了。原理比较简单,遍历Graphics类实例的所有子图形,如果碰撞到了其中一个,则相当于碰撞到了Graphics类实例。

6.1 Container.containsPoint函数

首先我们在Container类上挂载一个containsPoint函数,这个函数是用来判断某个点是否与当前类的实例产生了碰撞,Container类的子类也会实现这个函数。

typescript 复制代码
public containsPoint(p: Point) {
  return false
}

由于Container自身没有可以碰撞的内容,所以它直接返回false。

6.2 Graphics类的containsPoint函数

Graphics类的图形放在_geometry(GraphicsGeometry)属性里,所以我们先补全这个类的containsPoint函数。

GraphicsGeometry.containsPoint函数:

typescript 复制代码
public containsPoint(p: Point): boolean {
  for (let i = 0; i < this.graphicsData.length; i++) {
    const { shape, fillStyle } = this.graphicsData[i]
    if (!fillStyle.visible) {
      continue
    }
    if (shape.contains(p)) {
      return true
    }
  }

  return false
}

Graphics.containsPoint函数:

typescript 复制代码
public containsPoint(p: Point): boolean {
  return this._geometry.containsPoint(p)
}

至此,Graphics类的碰撞检测逻辑已经完成。

7. 引入了层级关系的碰撞检测

前面一步步地描述了如何对不规则图形以及规则图形进行碰撞检测,接下来会引入层级关系,实现真正可用的碰撞检测。

7.1 原理

本系列的文章的第一篇中提到了:渲染引擎在拿到这棵带有层级关系的对象树(根节点为stage)后,会采用先序遍历的方式来渲染这棵树,这意味着,父节点会比子节点先渲染,而相同层级的兄弟节点,则按照zIndex来排序,zIndex越大的兄弟节点越晚被渲染,zIndex相同的兄弟节点则按照数组中的顺序来渲染,这也是层级关系的核心所在。

对于碰撞检测,同样需要遍历这棵对象树,只不过遍历的顺序不一样了。

对于碰撞检测的遍历顺序,只有一条原则:谁处于层级关系的更高层,谁先被检测(这也是为什么我们能使用像素标记法来做碰撞检测)。这一点和渲染的顺序是反过来的。这其实也非常好理解,假设在桌子上放了一堆纸,这些纸形成了一个层级关系,然后在这堆纸所在的区域随机滴一滴墨水,这滴墨水肯定是滴在尽可能上层的那张纸上。

可以得到:我们会后序遍历这棵对象树,越晚被渲染出来的元素,越早进行碰撞检测。

7.2 代码实现

代码:

typescript 复制代码
let hasFoundTarget = false
let hitTarget: Container | null = null

const hitTestRecursive = (curTarget: Container, globalPos: Point) => {
  // 如果对象不可见
  if (!curTarget.visible) {
    return
  }

  if (hasFoundTarget) {
    return
  }

  // 深度优先遍历子元素
  for (let i = curTarget.children.length - 1; i >= 0; i--) {
    const child = curTarget.children[i]
    hitTestRecursive(child, globalPos)
  }

  if (hasFoundTarget) {
    return
  }

  // 最后检测自身
  const p = curTarget.worldTransform.applyInverse(globalPos)
  if (curTarget.containsPoint(p)) {
    hitTarget = curTarget
    hasFoundTarget = true
  }
}

const hitTest = (root: Container, globalPos: Point) => {
  hasFoundTarget = false
  hitTarget = null

  hitTestRecursive(root, globalPos)

  return hitTarget
}

8. 测试

8.1 实现一个cursor: pointer效果

测试方式:在canvas元素上监听pointermove事件,触发pointermove事件后拿到鼠标的坐标,然后调用hitTest函数进行碰撞检测,只要碰撞到了任何目标,都将canvas元素的鼠标指针改成cursor: pointer。 测试代码:

typescript 复制代码
// 在Application类的构造函数里执行这段逻辑
this.view.addEventListener('pointermove', (e) => {
  const target = hitTest(this.stage, new Point(e.offsetX, e.offsetY))
  if (target) {
    this.view.style.cursor = 'pointer'
  } else {
    this.view.style.cursor = 'auto'
  }
})

测试图形就用上一篇文章末尾的那个带有各种曲线的图形,代码如下:

typescript 复制代码
const path = new Graphics()
    .lineStyle(3, 'purple')
    .beginFill('pink', 0.6)
    .moveTo(100, 100)
    .lineTo(300, 100)
    .arc(300, 300, 200, Math.PI * 1.5, Math.PI * 2)
    .bezierCurveTo(500, 400, 600, 500, 700, 500)
    .lineTo(600, 300)
    .arcTo(700, 100, 800, 300, 150)
    .quadraticCurveTo(900, 100, 1100, 200)
    .closePath()

app.stage.addChild(path)

看一下效果:

可以看到,鼠标移到了我们绘制的图形的上方后,鼠标指针就变成了cursor:pointer效果,如果鼠标没有碰撞到任何元素,则回到了cursor:auto效果。

code sandbox地址

8.2 实现一个点击效果

测试方式:在画布上绘制一些图形(Graphics类的实例),然后给这些图形监听click事件(on('click'))(DisplayObject类继承自Eventemitter)。 在canvas元素上监听click事件,触发click事件后,拿到鼠标落点的坐标,然后调用hitTest函数进行碰撞检测,碰撞到了哪个图形,就执行哪个图形的click事件(emit('click'))

在canvas元素上监听click事件:

typescript 复制代码
this.view.addEventListener('click', (e) => {
  const target = hitTest(this.stage, new Point(e.offsetX, e.offsetY))
  if (target) {
    target.emit('click')
  }
})

在画布上绘制一些图形,然后给这些图形监听click事件:

typescript 复制代码
const c = new Container()
const redRect = new Graphics()
  .beginFill('red')
  .drawRect(400, 300, 200, 200)
  .on('click', () => {
    message.success(<span style={{ color: 'red' }}>点击了红色的矩形</span>)
  })
c.addChild(redRect)
const bluePoly = new Graphics()
  .beginFill('blue', 0.7)
  .moveTo(100, 200)
  .lineTo(400, 0)
  .lineTo(1000, 300)
  .lineTo(900, 600)
  .closePath()
  .on('click', () => {
    message.success(<span style={{ color: 'blue' }}>点击了蓝色的多边形</span>)
  })
c.addChild(bluePoly)

const path = new Graphics()
  .lineStyle(3, 'purple')
  .beginFill('pink', 0.6)
  .moveTo(100, 100)
  .lineTo(300, 100)
  .arc(300, 300, 200, Math.PI * 1.5, Math.PI * 2)
  .bezierCurveTo(500, 400, 600, 500, 700, 500)
  .lineTo(600, 300)
  .arcTo(700, 100, 800, 300, 150)
  .quadraticCurveTo(900, 100, 1100, 200)
  .closePath()
  .on('click', () => {
    message.success(<span style={{ color: 'pink' }}>点击了粉色的多边形</span>)
  })

const greenCircle = new Graphics()
  .beginFill('green')
  .drawCircle(200, 400, 200)
  .on('click', () => {
    message.success(<span style={{ color: 'green' }}>点击了绿色的圆</span>)
  })

app.stage.addChild(c)
app.stage.addChild(path)
app.stage.addChild(greenCircle)

测试效果:

点击红色矩形:

点击蓝色多边形:

点击绿色圆:

点击粉色多边形:

可以看到,触发点击事件后,hitTest函数都能够在对象树中找到正确的对象。

code sandbox地址

9. 结语

本文讲述了拿到鼠标落点坐标后,如何在层级关系树中定位到碰撞的节点,有2种方法:像素标记法射线法像素标记法利用了canvas的绘制能力,而射线法基于奇偶环绕法则后序遍历对象树。两种方法各有利弊,像素标记法的实现简单,但是性能比较低;射线法实现起来更困难一些,但是性能高上许多,大家可以根据实际情况来选择自己的方案。

本文的内容到此就结束了,下一篇文章将会基于本文的内容来讲述事件系统的实现。

谢谢观看🙏,如果觉得本文还不错,就点个赞吧👍。

相关推荐
昨天;明天。今天。10 分钟前
案例-任务清单
前端·javascript·css
一丝晨光36 分钟前
C++、Ruby和JavaScript
java·开发语言·javascript·c++·python·c·ruby
Front思38 分钟前
vue使用高德地图
javascript·vue.js·ecmascript
zqx_71 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己2 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称2 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色2 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2343 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河3 小时前
CSS总结
前端·css
NiNg_1_2343 小时前
Vue3 Pinia持久化存储
开发语言·javascript·ecmascript