[TOC]

前言

        最近有初学CocosCreator的小伙伴问到一个点击穿透的问题,正好整理些方案一起来看下。通常在制作课件的过程中,会遇到点击或拖拽多边图形的需求,很多时候就会不可避免的遇到一个问题:两个图形的叠加在了一起。比如A和B两个Sprite,我们会发现,层级高的会被点击,但想点击下方的B,却始终或者无法精准的点到B,这跟cocos本身的点击机制有关。

有什么办法可以解决这个问题吗? 答案肯定是有的,接下来就一起看下几种解决方案。


一、知识准备

       通过修改节点的_hitTest函数即可快速的达到我们想要的效果,对于刚接触cocos的开发者来说,可能对这个不是很熟悉,因为官方原本也没有直接暴露这个方法给外部使用,算是私有方法。为了让大家更清晰的了解我们接下来要说的内容,还是先把_hitTest函数做一下讲解,在CCNode.js文件中我们可以找到相关代码段(官方源码是没有注释的):

// 使用_hitTest的地方(省略了部分代码段)
var _touchStartHandler = function (touch, event) {
    ...
    if (node._hitTest(pos, this)) {
       ...
        return true;
    }
    return false;
};
...

/**
 * @param point 触发的坐标点位置
 * @param listener  节点本身
 */
_hitTest (point, listener) {
    let w = this._contentSize.width,
    h = this._contentSize.height,
    cameraPt = _vec2a,
    testPt = _vec2b;

    // 获取节点所在的第一个摄像机
    let camera = cc.Camera.findCamera(this);
    if (camera) {
        // 将一个摄像机坐标系下的点转换到世界坐标系下
        camera.getCameraToWorldPoint(point, cameraPt);
    }
    else {
        cameraPt.set(point);
    }

    // 更新世界坐标矩阵
    this._updateWorldMatrix();
    // 逆矩阵赋值计算, 返回的是下面要用到的_mat4_temp
    math.mat4.invert(_mat4_temp, this._worldMatrix);
    // 变换矩阵赋值计算,返回的是计算后的testPt
    math.vec2.transformMat4(testPt, cameraPt, _mat4_temp);
    // 根据锚点和宽高计算出需要检测的点的xy值
    testPt.x += this._anchorPoint.x * w;
    testPt.y += this._anchorPoint.y * h;

    // 检测点是否在node节点的区域内
    if (testPt.x >= 0 && testPt.y >= 0 && testPt.x <= w && testPt.y <= h) {
        if (listener && listener.mask) { // 如果用到mask,会在其父节点进行推算
            var mask = listener.mask;
            var parent = this;
            for (var i = 0; parent && i < mask.index; ++i, parent = parent.parent) {
            }
            // find mask parent, should hit test it 如备注所言
            if (parent === mask.node) {
                var comp = parent.getComponent(cc.Mask);
                return (comp && comp.enabledInHierarchy) ? comp._hitTest(cameraPt) : true;
            }
            // mask parent no longer exists
            else {
                listener.mask = null;
                return true;
            }
        }
        else {
            // 很显然,多数情况下我们是不会使用mask的,通常会走到这里
            return true;
        }
    }
    else {
        // 不在区域内,则返回false
        return false;
    }
}

       查看源码会发现,_hitTes函数在触摸和鼠标事件回调函数中基本都有用到。关于_hitTest具体实现,大部分我已经在代码段中做了注释来加以解释。代码段中矩阵变换相关的知识以后我会在WebGL相关知识讲解里面会提到,到时再一起探讨,这里大家只需要知道矩阵计算在此处有用到即可,喜欢深究的同学可以自行查看相关代码段。


二、解决方案

方案一:重写_hitTest

通过修改_hitTest函数的判定,来达到我们想要的"像素级"检测。

// 启用透明区检测
useTransparencyCheck() {
    this.node._hitTest = this.hitTest.bind(this);
}

/**
 * point : 鼠标点击的坐标
 */
hitTest(point) {
    // 坐标转换
    let hitPos = this.node.convertToNodeSpace(point);
    // 获取节点尺寸
    let nodeSize = this.node.getContentSize();
    // 矩形区域判断
    var rect = cc.rect(0, 0, nodeSize.width, nodeSize.height);
    if(!rect.contains(hitPos)) return false;
    // 获取Sprite节点
    let sprite = this.node.getComponent(cc.Sprite);
    if(sprite) {
        var image = sprite.spriteFrame.getTexture().getHtmlElementObj();
        if(this.isTransparency(image, hitPos.x, nodeSize.height - hitPos.y)) {
            return true
        }else {
            return false;
        }
    }
    return false;
}

// 判断
isTransparency(img, x, y) {
    var cvs = document.createElement("canvas");
    var ctx = cvs.getContext('2d');
    cvs.width = 1;
    cvs.height = 1;
    ctx.drawImage(img,x,y,1,1,0,0,1,1);
    var imgdata = ctx.getImageData(0,0,1,1);
    return imgdata.data[3]; // 第三个分量来判断,webgl常用来判断点击的手法
}


start() {
    // 测试:方案一没有改动其他地方,正常的使用如下监听方式即可
    this.node.on(cc.Node.EventType.TOUCH_END,()=>{
        cc.log("node name:",this.node.name);
    });
    this.useTransparencyCheck();
}

       在获取节点尺寸那里,要注意一点,假设有一个正方形,实际渲染尺寸为5050,但图片尺寸为10050,左右分别多出了25像素的透明区,那么会对接下来的操作产生影响,点击区域发生偏移。解决方法也是有的,我们可以来编写this.node._getLocalBounds函数,通过实现该方法以提供自定义的轴向对齐的包围盒(AABB),以便编辑器的场景视图可以正确地执行点选测试。通常的办法是自己或找美术老师用PS把图片多余的透明区剪裁掉即可。


方案二:使用吞没事件

判断部分同方案一,区别在于使用了cc.EventListener.TOUCH_ONE_BY_ONE事件和吞没事件的判定变量swallowTouches,实现点击事件的向下传递。当swallowTouches = true时,事件不会向下传递,反之,事件会依次向下传递。

useOneByOneCheck() {
    let self = this;
    this.hitListenerCallBack = cc.eventManager.addListener({
        event: cc.EventListener.TOUCH_ONE_BY_ONE,
        onTouchBegan: function (touch, event) {
            if(self.hitTest(touch.getLocation())) {
                this.swallowTouches = true;
                return true;
            }else {
                this.swallowTouches = false;
            }
            return false;
        },
        onTouchMoved: function (touch, event) {
            // cc.log('onTouchMoved: ' + self.node.name);
        },
        onTouchEnded: function (touch, event) {
            cc.log('onTouchEnded: ' + self.node.name);
        },
        onTouchCancelled: function (touch, event) {
            // cc.log('onTouchCancelled: ' + self.node.name);
        }
    }, this.node);
}

// 注意:如果只是拷贝粘贴代码进行测试,记得把前面start()函数中的this.node.on注释掉,不然不会走到你重写的onTouchXXX里面。

       这里需要注意的有两点:

  • 1、this的作用域,这里只是为了让代码看上去直观才这么干的。
  • 2、需要重写onTouchBegan、onTouchMoved、onTouchEnded、onTouchCancelled方法来达到你想要的效果。

       好啦,结合上面的两组方案基本已经可以实现"像素级"检测了,但是你会发现一个问题,如果监听touch事件的节点过多,就会出现较为明显的效率问题,因为每一个监听touch时间的节点,都会走一遍hitTest和isTransparency两个函数,那么还有没有更优一些的方案呢?必然是有的,接下来我们一起看下。

方案三:借助碰撞组件

       使用碰撞系统中的Collider组件来绘制我们想要的区域,这里用可以处理多边形的PolygonCollider组件来做方案演示,代码段部分很简单,只需要在我们前面提到的hitTest方法中添加如下代码即可(代码中已添加备注,直接上完整代码段):

hitTest(point) {
    // 坐标转换
    let hitPos = this.node.convertToNodeSpace(point);
    // 获取节点尺寸
    let nodeSize = this.node.getContentSize();
    // 扩展:对碰撞系统的支持
    let polygonCollider = this.getComponent(cc.PolygonCollider);
    if (polygonCollider) {
        hitPos.x -= nodeSize.width / 2;
        hitPos.y -= nodeSize.height / 2;
        // console.log("碰撞组件的点击测试")
        return cc.Intersection.pointInPolygon(hitPos, polygonCollider.points);
    }
    // 矩形区域判断
    let rect = cc.rect(0, 0, nodeSize.width, nodeSize.height);
    if(!rect.contains(hitPos)) return false;
    // 获取Sprite节点
    let sprite = this.node.getComponent(cc.Sprite);
    if(sprite) {
        var image = sprite.spriteFrame.getTexture().getHtmlElementObj();
        if(this.isTransparency(image, hitPos.x, nodeSize.height - hitPos.y)) {
            return true
        }else {
            return false;
        }
    }
    return false;
}

       这里需要注意的有两点:

  • 1、适当调整Threshold属性值,可以在一定程度上减少计算量。
  • 2、对于过于复杂的图形,并不使用这套方案,因为。我们会发现很难去完美的勾勒出所有的点。

三、小结

       节点Sprite的SpriteFrame如果源于图集,会存在触摸或点击不精准和可触发区域异常的问题,因为如果启用了动态合图功能,动态合图会自动将合适的贴图在开始场景时动态合并到一张大图上来减少 drawcall,同时会将贴图合并到大图中会修改原始贴图的 uv 坐标。在isTransparency()中的返回分量w来作为判断依据的方法就会存在偏差。

       到此,Cocos Creator点击透明区穿透的解决方案也都一一讲解完了,根据自己的理解和需求来有选择地使用吧,如果有其他的解决方案也欢迎提出来一起学习。

03-05 14:20