CreatorPrimer|飞机大战(二)

之前的飞机大战一我们实现了地图场景的滚动和简单的直上直下的子弹的发射,在之前的基础上,对线性发射器做了简单的修改,实现了如下效果:

飞机大战-子弹发射1.gif

飞机大战-子弹发射2.gif

下面我来就介绍一下,实现上面效果是如何实现的,先看视频:

1、子弹角度计算

子弹发射器

这里为子弹发射器增加了一个rotation属性用于控制子弹发射的角度,但是如何计算子弹的飞行终点坐标呢?

image.png

不好意思,请允许我重新翻开初中的数学关于三角函数章节,通过直角三角形中,角度与边的关系可以计算出飞行终点,我们根据上图,看下我们已知的参数:
1. 子弹角度β,是我们的组件属性
2. 飞行距离r,这里为了简单我使用了常用cc.winSize.height

因此通过三角公式可以得出:

x = r * sin(β)
y = r * cos(β)

需要注意的是JS中Math.sin\cos函数中的参数是弧度单位,每1角度为 Math.PI / 180 弧度,下面看终点的计算代码:

 _emmitNode() {
        //创建子弹
        let node = cc.instantiate(this.prefab);
        node.position = this.offset.add(this.node.position);
        node.parent = this.node.parent;
        node.rotation = this.rotation;

        //计算终点
        let endPoint = cc.v2();
        endPoint.x = cc.winSize.height * Math.sin(this.rotation * RADIAN);
        endPoint.y = cc.winSize.height * Math.cos(this.rotation * RADIAN);

        //计算飞行持续时间
        let distance = endPoint.sub(node.position).mag();
        let duration = distance / this.speed;

        //运行动作
        let moveBy = cc.moveBy(duration, endPoint);
        let removeSelf = cc.removeSelf();
        let sequence = cc.sequence(moveBy, removeSelf);
        node.runAction(sequence);
    }

2. 动态旋转

动态旋转很简单,在update每帧调用函数中,不断修改rotation属性值,看下面代码:

update(dt) {
     if (this.spin === 0) {
         return;
    }
    this.rotation += dt * this.spin;
}

spin在这里是一个旋绕速度参数,相同于电风扇上的摇头马达,通过子弹产生速度、飞行速度、旋转速度你可以创造了各种样式花丽的子弹效果,下面是我弄的几张截图:

给飞机挂了两个带spin参数的发射器,一个spin为360顺时针旋转,一个spin值为-360逆时针旋转,像对一凤凰的翅膀。

这个是挂了4个发射器,起始rotation分别为0、90、180、270,飞行速度快一些,spin值都是一样的,像刮起的凤凰旋风。

3. 小结

我们看似复杂的子弹效果,其实每一个都是用的直线动作,通过挂载多个子弹发射器,调节枪口角度、角度动态旋转可以生成出各式花样。工程源码可以在公众上回复“子弹发射器”或“LineEmmiter”获取,感谢你的阅读!

CreatorPrimer|飞机大战(一)

前两天在Cocos官方公众号上学习了「大掌教」的Cocos Creator 2.x Camera教程,总算是对摄像机组件有了一个初步的认识。乘热打铁,Shawn即刻就使用Camera摄像机练习了一个飞机游戏的,目前主要实现3个功能:
1. 无限滚动背景
2. 控制飞机移动
3. 子弹发射

我们现看下面游戏视频:

1. 无限滚动背景

滚动背景我们当然是使用最新的摄像机来实现,我这里做了一个卷轴摄像机组件ScrollCamera,我们现来看一下组件暴露的属性:
滚动摄像机

ScrollCamera组件很像真实世界中的摄像机的推进器,Speed是推进速度,LoopGrounds是一个节点数组,他们是一组可首尾衔接的精灵节点,看下图:
可首尾衔接的精灵节点

我们再看一下ScrollCamera组件的代码:


cc.Class({ editor: { requireComponent: cc.Camera, //前置要求摄像机组件 }, extends: cc.Component, properties: { speed: 300, //滚动速度 loopGrounds: [cc.Node], //循环节点 }, start () { //获取节点上的摄像机组件 this.camera = this.getComponent(cc.Camera); }, /** *每帧更新函数 *1. 更新摄像机位置 *2. 检查循环节点,设置新位置 **/ update(dt) { //获取当前节点 let current = this.loopGrounds[0]; //计算当前节点在摄像机中的位置 let pt = this.camera.getWorldToCameraPoint(current.position); //当前节点超出摄像机范围(摄像机可视范围就是屏幕大小) if (pt.y <= -cc.winSize.height) { //取最后一个地图节点 let last = this.loopGrounds[this.loopGrounds.length - 1]; //将当前节点从数组中移除 this.loopGrounds.shift(); //将当前节点放到数组最后 this.loopGrounds.push(current); //将当前节点位置移动到最顶部位置 current.y = last.y + (last.height + current.height) / 2; } //更新摄像机节点位置 this.node.y += dt * this.speed; } });

推动摄像机的代码很简单,看update函数中的最后一行:

this.node.y += dt * this.speed;

update中前面的几行代码是在做loopGrounds节点的检查和位置更新,每一行都注释,这里就不再过多赘述了。

将这个组件直接拖动到场景编辑器或层级管理器,设置background节点为background分组:

分组设置

同时设置ScrollCamera的cullingMask属性只勾选background,看下图:
摄像机分组

通过上面的设置和ScrollCamera的十几代码,无限滚动背景就搞定了。

2. 控制飞机移动

不知道大家还记得公众号之前的一篇文章《Cocos Creator基础教程(11)—可拖拽组件》,我直接将Dragable.js组件脚本拿过来,挂载到飞机节点上就OK了,代码也很简单:

/**
 * 可拖动组件
 */
cc.Class({
    extends: cc.Component,

    onLoad() {
        //注册TOUCH_MOVE事件
        this.node.on(cc.Node.EventType.TOUCH_MOVE, this._onTouchMove, this);
        cc.log('onload');
    },

    _onTouchMove(touchEvent) {
        //let location = touchEvent.getLocation();
        //this.node.position = this.node.parent.convertToNodeSpaceAR(location); 

        //获取触摸移动增量
        let delta = touchEvent.getDelta();
        //当前节点位置+增量,更新节点位置
        this.node.position = delta.add(this.node.position);
    }
});

_onTouchMove函数稍微调整了一下,之前使用方法的是当前节点设置为触摸点位置,需要将全局坐标转换为当前节点的父节点坐标(设置一个节点的位置,是设置它在其父节点中的位置),拖动时节点总是保持在移动点的中心,特别是在第一次拖动节点时会有一个跳跃感,不够平滑。

我这里简单改进了一下,通过获取移动增量再加上当前节点位置,可以拖动节点的任意位置,不会出现突然将节点拉动到手指中心的突兀感。

Shawn在做这个飞机游戏过程中也尝试了一下消灭病毒当下这个火热的游戏,他的整个屏幕任意位置都可以控制飞机的移动,它是怎么做的呢,大家先可以思考一下?

我们这里再修改一下Dragable组件,增加一个target节点属性,将它从飞机节点上移到外层foreground节点,看下图:

可拖拽目标节点

触摸事件发生在foreground节点上,但移动的是target属性所指向的节点,我们看下代码:

/**
 * 可拖动组件
 */
cc.Class({
    extends: cc.Component,
    properties: {
        target: cc.Node,
    },
    ...
    _onTouchMove(touchEvent) {
        //获取触摸移动增量
        let delta = touchEvent.getDelta();
        //如果this.target未设置,使用移动当前节点(兼容之前的用法)
        let node = this.target || this.node;
        //当前节点位置+增量,更新节点位置
        node.position = delta.add(node.position);
    }
});

代码就增加了一个target节点的定义,在TouchMove事件中检查this.target存在就用它,不存在默认移动当前节点,这样可以兼容曾经该组件的地方,不用做修改。

3. 子弹发射

飞机游戏的一个亮点就是子弹发射的华丽视觉效果,Shawn这里在网上找了些子弹特效图片,然后编辑了一个子弹Bullet的预制体,使用到之前文章《Cocos Creator基础教程(12)—精灵变身》中的SpriteEx.js组件,在上面配置了几张子弹图片,使用index属性可以方便切换子弹的表现效果,看下图:

SpriteEx.gif

Bullet子弹只是表现效果,要让子弹运动起来,我这里编写了一个LineEmmiter.js(线性发射器)的脚本,将它挂载到飞机节点上,用它来实例化Bullet预制体并让它动起来,先看一下LineEmmiter组件的属性:

线性发射器.png

之前的文章中提到过:组件为节点赋予能力,飞机节点上有一个Sprite可显示图片纹理,我们再挂上LineEmmiter组件,让它具有发射子弹的能力。

发射器的主要属性是子弹预制体、发射频率、子弹飞行速度,OffsetX属性要特别一点,它可以控制子弹与飞机的偏移位置,以实现同时发射多行子弹的效果,看下图:

多行发射.png

我们再看下发射器的组件代码:

cc.Class({
    extends: cc.Component,

    properties: {
        prefab: cc.Prefab,
        rate: 1,        //发射间隔
        speed: 1000,    //移动速度
        offsetX: 0,
    },

    start() {
        this.schedule(this._emmitNode, this.rate);
    },

    _emmitNode() {
        //实例化节点,设置位置&父节
        let node = cc.instantiate(this.prefab);
        node.position = this.node.position;
        node.x += this.offsetX;
        node.parent = this.node.parent;
        //计算子弹需要飞行的距离,飞行时间 = 距离 / 速度
        let distance = ((cc.winSize.height / 2) - this.node.y);
        let duration = distance / this.speed;
        //使用moveBy动作,完成后删除子弹节点
        let moveBy = cc.moveBy(duration, cc.v2(0, distance));
        let removeSelf = cc.removeSelf();
        let sequence = cc.sequence(moveBy, removeSelf);
        node.runAction(sequence);    
    }
});

发射器代码也很简单:
1. 实例化子弹节点
2. 让子弹飞起来

我们这里子弹是垂直飞行的,直接使moveBy动作就可以完成,子弹从当前飞机节点出发直到屏幕顶部结束,这是它飞行的距离根据公式:距离/速度=时间,计算每颗子弹的飞行时间,保证飞机在不同位置,所有子弹都是按同样的速度飞行。

4. 小结

本次教程我们实现了一个最小飞机游戏的简单原型,我们的核心地图滚动与子弹发射代码只有70多行,有没有觉得使用Cocos Creator开发游戏飞一般的简单呢…

不过还有很多欠缺的地方,比如:限制飞机不要跑出屏幕之外、子弹应该使用内存池进行优化,在功能上还缺少敌机生成、少子弹碰撞、得分计算等等,这些内容我们留到下次继续。

奎特尔星球

CreatorPrimer|编写一个版本号组件

在集合类游戏中,不论是大厅还是子游戏都会涉及到版本的更新,在开发调试阶段,检查更新是否生效的一个直观的方法就是观察版本号的变化,因此版本号的显示是游戏中不可缺少的细节,特别是集合类游戏。

1. 熟悉manifest

这里我们使用 Cocos Creator 提供的 AssetsManager 热更新框架所要求的 project.manifest 它是一个JSON格式的配置文件:

{
    "version": "0.0.1",
    "packageUrl": "http://192.168.1.100/update",
    "remoteManifestUrl": "http://192.168.1.100/update/hall-project.manifest",
    "remoteVersionUrl": "http://192.168.1.100/update/hall-version.manifest",
    "assets": {}
}

上面是一个hall-project.manifest,再看一个game-project.manifest内容如下:

{
    "version": "0.1.1",
    "packageUrl": "http://192.168.1.100/update",
    "remoteManifestUrl": "http://192.168.1.100/update/game-project.manifest",
    "remoteVersionUrl": "http://192.168.1.100/update/game-version.manifest",
    "assets": {}
}

获取版本号,其实就是读取 manifest 中的 version 字段,显示到一个 cc.Label 组件上。这对大多数人来说都是小菜一碟,但是Shawn发现,利用组件方式实现一个相对通用的版本号组件做法却并不多见。

2. VersionLabel组件

了解过 manifest,我们就可以开始动手编写一个基于 Cocos Creator 引擎提供的 AssetsManager 热更新框架的版本号组件,这里将组件取名为“VersionLabel”,先看一下组件提供的属性接口:

VersionLabel

对于组件的使者关心的是ModuleName属性,当你想显示不同游戏模块的版本时,只需要指定正确的ModuleName就可以了,下面是组件代码:

cc.Class({
    extends: cc.Component,
    editor: CC_EDITOR && {
        requireComponent: cc.Label, //强制依赖cc.Label组件
    },

    properties: {
        default: '0.0.0',
        moduleName: {
            default: '',
            notify(oldValue) {
                if (CC_EDITOR || oldValue === this.moduleName) {
                    return;
                }
                this._updateContent();
            } 
        }
    },

    start () {
        //获取Label组件
        this.label = this.getComponent(cc.Label);
        //更新版本内容
        this._updateContent();
    },

    /**
     * 更新内容
     */
    _updateContent() {
        //加载“resources/manifest/xxx-project.manifest”
        let url = `manifest/${this.moduleName}-project`;
        this._getManifestContent(url, (content) => {
            this._setVersion(content);
        });
    },

    /**
     * 
     * @param {String} url      resources以下路径
     * @param {Function} cb     异步回调函数,返回manifest上下文
     */
    _getManifestContent(url, cb) {
        cc.loader.loadRes(url, cc.Asset, null, (error, asset) => {
            if (error) {
                cb(null);
                return;
            }
            //通过nativeUrl读取文件内容
            let content = cc.loader.getRes(asset.nativeUrl);
            cb(content); 
        });
    },

    /**
     * 设置Label文本
     * @param {String} content 
     */
    _setVersion(content) {
        let data;
        try {
            data = JSON.parse(content);
            this.label.string = data.version;
        } catch(e) {
            cc.warn(e);
            this.label.string = this.default;
        }
    }
});

简单说明一下上面的代码:
1. 该组件提供了一个moduleName的属性,这里注意不要使用name属性,因为是‘name’是Cocos Creator组件内置属性,还有定义manifest文件需要按照一定文件名命规范,我这里的名命模版是“xxx-project.manifest”
2. 我们是将版本号文本显示到Label组件上,因此requireComponent: cc.Label 是定义该组件强制依赖cc.Label组件。使用它的好处是,当直接将该脚本拖动到场景或层级管理器时,会自动挂载一个cc.Label组件,增强组件的使用体验。
3. manifest文件是放在resources目录下的,虽然manifest内部是json格式,但目前cc.loader还不能直接解析manifest这个扩展名的文件内容,当使用cc.loader.loadRes加载后,只能获取到文件的基本信息,通过使用cc.loader.getRes(asset.nativeUrl)获取文件内容。

3. 读取搜索路径下的manifest

上面的组件代码还存在一个Bug,我们是读取的安装包中的manifest文件,看下面代码:

let url = `manifest/${this.moduleName}-project`;

此文件存在于 resources 目录,当运行在原生设备上它是存在于安装路径中,当游戏通过热更新下载了新的 manifest 文件,此路径是否还正确呢?当然就不对了,我们需要读取热更新包中的 manifest 文件才能获得正确的版本号,看下面代码:

cc.Class({
    extends: cc.Component,
    ...

    /**
     * 更新内容
     */
    _updateContent() {
        //如果为原生环境,尝试加载可写路径下的xxx-project.manifes文件
        if (cc.sys.isNative) {
            let remoteAssets = cc.path.join(jsb.fileUtils ? jsb.fileUtils.getWritablePath() : '/', 'remote-assets');
            let url = cc.path.join(remoteAssets, this.moduleName, '-project.manifest');
            //使用jsb函数读取文件内容
            let content = jsb.fileUtils.getStringFromFile(url);
            if (content) {
                this._setVersion(content);
                return;
            }
        }

        let url = `manifest/${this.moduleName}-project`;
        this._getManifestContent(url, (content) => {
            this._setVersion(content);
        });
    },

    ...
});

上面代码在_updateContent函数中检查当前如果为原生环境,先尝试读取可写路径/remote-assets/xxx-project.manifest文件,如果文件内容不存在才读取安装包resources目录下的manifest文件。

需要注意的是remote-assets是使用AssetsManager下载热更新包时指定的,你需要根据自己的实际情况设置正确的路径。

源码地址:https://github.com/ShawnZhang2015/CreatorPrimer

4. 小结

读取版本号这个逻辑上下文只需要关心到那里去获取版本字符串,使用组件的方式,可以将很多逻辑细节代码封装到一段小代码中独立执行,以达到与其它代码老死不相往来,从而有效减少耦合。

同时借助Cocos Creator的可视化属性面板暴露接口,可以让非程序员也能轻松使用组件搭建出游戏内容,不知道大家获取版本号是如何实现的呢,也欢迎分享你的实现方案。


奎特尔星球

Creator组件编码心得(下)

这次是《组件编码心得》的最后一篇,第一次我们讲到将组件分为两大类:功能型控制型;第二篇介绍了功能型组件与控制型组件的编码上需要注意的地方,最后还提到控制型组件与预制件的组合形成独立模块,这次分享我在预制件上编码、编辑时的一点心得。

1. 预制件与控制组件的关系

简单梳理一下配套的预制件与控制型组件的关系是:

肉体与灵魂
显示与控制

预制体由1~n个节点构成,就像人体的骨骼,挂载到各个节点的功能型组件则为血脉、神经以支撑整体模块的运作,而控制组件就是这个模块的灵魂。

还有一种更有意思比喻,预制件与控制组件是朋友与朋友的关系,到底那男女如何对应大家可以思考一下,欢迎留言讨论!

2. 控制组件编码心得

其实前一篇只介绍了控制组件的对内、对外要点,在编码上具体要如何去做没有细说,请看下图:

控制组件编码心得

3. 预制件编辑心得

上面讲的是控制组件的编写,我们再来看预制件的编辑,需要注意些什么:

预制件编辑心得

  1. 配套的组件与预制件文件同名、放在同一路径是为了方便管理,要走一起走,要留一起留下,天生一对!看下面的例子:

预制与组件同步

将有关联的一组预制件与组件脚本放在一个路径下,相互配套的则命名一至。

  1. 还有一点就是将控制组件挂载到预制件的根节点上,最好只挂载一个组件,由它来统领当前预制件,看下图:
    组件挂在预制根上

上图中预制体文件名预制体根节点名字组件的名字三者保持一至,当把这个预制实例化出来时,在编辑器上直接可看到它的控制组件,继续看图:

实例化预制时方便看到组件

在使用代码实例化预制件时,可以用预制体的名字索引到匹配的组件对象,看下面代码:

实例化预制,获取控制组件

  1. 最后节点命名要有意义、保持清晰层级关系这些是为了让预制件经后可维护,一套规范化的节点命名规范还是很有必要的。

我自己习惯以下划线”_”为前缀,在代码中会获取这类节点,将规范告诉UI编辑人员,遇到以下划线”_“开头的节点不需要随便删除修改,其它非下划”_“的节点可以随便操作。

保持稳定的UI树结构是因为控制型组件中会使用到getChildByName、cc.find、getComponent等函数来获取子节点,所以节点树不能随意改动,如果要修改同时也需要修改组件代码。

4. 小结

本篇主是介绍组件与预制体的结合,这也是《组件心得三部曲》的最后一篇分享。单纯写好代码并不是最重要的,探索如何高效率、高质量产出代码的方法提升生产力才是关键,且更具价值,希望我的一点经验能起到抛砖引玉的作用,欢迎留言讨论!


果觉得公众号上的文章对您或您身边的朋友有帮助,请分享给他们,愿我们一起成长!

image

Creator组件编码心得(中)

接上次教程中的内容,讲到功能型组件与控制型组件,这里再简单回顾一下他们的要点:
功能型&控制型
1. 功能型组件:以装饰宿主节点为己任,常用的有Sprite、Labe、Widget属于这类。
2. 控制型组件:管理和控制子孙节点,比如:ScreollView、ToggleContainer,它们内部是由多个子孙点节点组合而成。

今天分享我在组件编码上一点经验,供大家参考。

1. 功能型组件设计思路

核心:以装饰宿主节点为己任

具体要怎么去做呢?我下面整理了三个要点:
1. 专注自身节点和当前组件对象自己,尽可能少的去控制其它的对象。尽可能少不是说不能控制,比如cc.LabelOutline,它需要依赖cc.Label组件控制Label的渲染;物理碰撞组件需要依赖刚体组件。
2. 谨慎设计properties属性,减少对外部组件、节点、资源的依赖,也就是说对游戏设计师无用的属性都不要暴露到属性面板上。组件暴露的接口不应该是程序员相当然的,而是对游戏内容设计相关的属性才应该暴露到属性面板上。
3. 功能型组件内部代码中杜绝对外部场景树的假设,避免出现cc.find、getChildByName等方法获取外部节点。

功能型组件一般会以纯脚本形式存在,如果做到了以上三点,我们编写的组件就可以像引擎内置的组件一样可以被随处使用了。甚至还可以跨越不同的项目,Shawn在CreatorPrimer配套代码仓库中的组件大多都是这类,供大家参考:https://github.com/ShawnZhang2015/CreatorPrimer.git

在实际项目中,不可避免的会存在有相互依赖的组件,比如:上面代码中的几个物理组件。 Shawn建议将有相互依赖的组件代码,归类存放同一路径方便管理。

功能型组件,我们提出了不少制约条件,它只能解决功能点的问题,
而不能解决,业务流程、相对复杂逻辑问题。这些问题的处理办法
我们需要使用控制型组件来完成。

2. 控制型组件设计思路

核心:管理和控制子孙节点

控制型组件,他的职责没有像功能型组件那么单纯,还需要从他们两方面来进行说明。

控制型组件对内要点

控制型组件对内要点

控制型组件对内它管理和控制子孙节点,这是划定了他的控制的范围,不是兄弟、父级组件。其次,控制型组件管理的手段是调用子孙节点上的组件方法、属性或监听子孙节点发出的事件,尽量不参杂功能细节上的代码,这些应该交由子孙节点或组件完成。

控制型组件对外要点

内部安定好,我们看怎么面对外部,看下图:

控制型组件对外要点

站在更高的层次去看控制型组件,它也可以被上层控制组件所管理,从上层视角看控制型组件也是功能型组件,解决点上的问题,比如最常见的:cc.ScrollView。

控制型组件同样遵守部分功能型组件的一些规则:
1. 不要暴露内部节点和组件,也就是说,不要为了在代码中访问内部子节点或组件方便,将它们设计成properties属性,他们对游戏设计师没有任何帮助,反面形成了干扰,这里需要关注的是封装性。
2. 控制型组通常会与预制件结合形成独立的模块,并能与其它组件相互组合嵌套,形成更复杂的业务模块,以达到模块化能力。

以这种方式设计组件,尽可能为项目生产出一个个相对独立的模块,再以各个模块搭建完整的游戏。

3. 小结

本篇主要介绍了功能型组件与控制型组件的一点心得,最为主要的还是对面向对象设计原则的应用。下一次继续为大家分享,控制型组件与与预制件编辑的一些心得,欢迎关注。


如果觉得公众号上的文章对您或您身边的朋友有帮助,请分享给他们,愿我们一起成长!

image

Creator组件编码心得(上)

Cocos Creator的核心是组件化,如何编写出高质量的组件代码值得程序员们不断探索,Shawn今天分享一点组件编码的心得供大家参考:“怎样才是一个合格的组件?”。

1. 组件编码常见问题

Shawn在2年的Cocos Creator项目经验和案例中总结了两条组件编码问题:
1. 滥用properties属性:把暴露到编辑器上的组件属性当成成员变量的一种实现方式;或将properties属性当成访问外部节点、资源的便利的通道。不必要暴露的属性,为上层使用者造成负担。
2. 场景树结构假设:组件代码中存对场景树的硬编码,导致组件只能工作在这种特定的场景树结构下,失去了重用能力,同时也限制了场景树不能轻易变量动。

上面两个问题带来的后果是:组件与组件组件与外部节点组件与资源组件与场景树结构形成高度耦合,如下图所示:

高度耦合的组件设计

组件与外部对象产生了千线万缕的关系,这样的设计让组件、界面都完全动弹不得,完全背离了组件化开发的本质,陷入了高度耦合的泥沼之中。

2. 合格组件参考标准

怎样才算是一个合格的组件?

这个问题困扰Shawn很长一段时间,其实答案近在眼前,那就是:模仿Cocos Creator内置组件,以引擎内置组件为参考标准。

Cocos Creator的内置组件绝大部分都是可通用的,可以挂载到任意节点,这里简单总结三点:

  1. 简单易用:程序员要将设计师看见是你的客户,提供给他简单好用的组件。
  2. 复用性强:编写一次可以在更多的地方使用,解决普遍性问题。
  3. 易于测试:不管是程序员还是设计师都要能方便的营造组件测试预览环境。

有了好的参考的标准,就有了行动的指南针,接下来看内置组件给我们的启发。

3. 组件的类型

之前Shawn的教程中就提到,组件分为两类:神器与结界。随着教程的不断升级,Shawn也在思考使用更为贴切的用词,庆幸得到引擎组大神们的帮助,规范用词,将两类组件定义为:功能型组件控制型组件,请看下图:

功能型&控制型

  1. 功能型组件:以装饰宿主节点为己任,常用的有Sprite、Labe、Widget属于这类。
  2. 控制型组件:管理和控制子孙节点,比如:ScreollView、ToggleContainer,它们内部是由多个子孙点节点组合而成。

在编写自定组件时,需要明确我们是要提供什么类型的组件去解决问题,比如我们教程Demo中的:节点ZIndex控制、节点可拖动、点击节点切换图片,它们都是功能型组件,通常是一个纯组件脚本文件。

在项目中,我们做的:提示对话框、玩家头像、背包道具,它们通常是由背景、前景、图片、边框、文字等等节点构成,我们就需要为它们使用定制各自的控制型组件脚本。

功能型组件解决“点”上的问题,控制型组件解决“线”“面”上的问题,它们之间又可以相互嵌套、组合从而解决“体”上的问题。

4. 小结

本篇教程主要是分享Shanw在组件编程中发现的问题,思考“怎样才是一个合格的组件?”。探索编写合格组件的指导思想,总结了功能型控制型两类组件模型,供大家参考。

下一次我们再继续这个话题,如何去编写简单易用、复用性强、易于测试的组件,具体说明功能型和控制型组件的编码心得。

编写高质量的组件的目的是为了提高开发效率和产品质量,在这条道路上任重道远,大家一起努、加油!


奎特尔星球

触摸事件冒泡

这两天正在愁公众号写点什么,打开微信看到uikiller用户「悦雨」遇到了一个问题:

地图拖动与子节点触摸事件产生冲突,表现为在子节点上拖动,地图不能动

一句话不太好描述问题,在征得「悦雨」同意后,将这次交流的内容截图出来:

第一话

问题描述

第二话

ScrollView解决方案

在与「悦雨」的交流过程中,我用ScrollView+TileMap+Button+AudioSource花了五分钟做了一个小测试,将TiledMap放在ScrollView中,在TiledMap中又放值了一个按钮,验证了一下曾经的经验是否任然有效,结果是OK的,于是将测试场景发给了「悦雨」。

第三话

结果是OK的,于是将测试场景发给了「悦雨」同学,但ScrollView不是想要的,继续聊这个问题:

第四话

不想用ScrollView,还有什么方案呢?触摸事件捕获!继续对话:

快速原型

有了这个案例今天就以这个地图场景为例,看看不写代码,利用引擎内置组件,如何快速实现一个原型或组件功能测试 ,请看下面视频:

从源码中学习

为什么在ScrollView中拖动,不会触发子节点的TOUCH事件呢?先看下ScrollView上的一个关键属性:
ScrollView.png

有了Cancel Inner Event这个线索,我们直接从ScrollView组件源码入手,看看它是什么实现的。

以cc.ScrollView组件为例,看如何定位组件源码:
1. 使用Chrome浏览器启动游戏预览
2. 打开Chrome DevTools工具
3. 键盘快捷键:ctrl + p 或 cmd + p
4. 输入:ccscrollview (引擎组件原文件名公式:cc + 组件名)
5. 从显示的列表上找到要查看的源码文件

选择CCScrollView.js文件,自动跳转到Sources标签,打开文件内容,键入ctrl + f 或 cmd + f 在当前文件中搜索:cancelInnerEvents,找到关键代码:

ScrollView源码.png

  1. 可以看到976行中,当this.cancelInnerEvents变量为真可能会执行到下面的代码,设置成员变量this._touchMoved = true
  2. 再看1006行_onTouchEnd函数,在这里判断了_touchMoved这个变量,停止TOUCH_END事件的传播,这样子节点的触摸事件就不会被触发了
  3. 993行_onTouchMoved函数最后一行代码this._stopPropagationIfTargetIsMe(event)它是在有条件地停止TOUCH_MOVE事件的传播。

通过上面的分析,再通过断点跟踪,在ScrollView和Button组件中分别打上断点,我们在Button组件上做点击,ScrollView组件的_onTouchEnded居然先被断下来,它是怎么做到的呢?

在CCScrollView.js源码中搜“TOUCH_END”关键字,找到TOUCH事件注册的代码:

image.png

看看这里有与自己平时注册TOUCH事件有什么不同?相信你已经发现了,关键在最后一个参数:useCapture,用于是否捕获子节点事件,又称之为向下冒泡(默认是向上冒泡),下面以TOUC_END事件为例,简单说明一下:

this.node.on(
  cc.Node.EventType.TOUCH_END,   //触摸事件类型
  this._onTouchEnded,            //事件处理函数
  this,                          //事件处理函数的this上下文(使用箭头函数时通常被省略)
  true                           //是否捕获子节点Touch事件
);

为了帮助大家更好地理解,我做了个简单的小组件,请看代码:

cc.Class({
    extends: cc.Component,
    properties: {
        useCapture: false, //是否启用捕获
    },
    onLoad () {
        this.node.on(
            cc.Node.EventType.TOUCH_END,
            () => cc.log('touchend', this.node.name), //测试时观察日志输入出
            this, 
            this.useCapture
        );
    }
});

把这个组件挂到两个父子关系的节点上,在父节点上开启捕获,看下面截图:

运行点击红色节点,看看日志输出:

从日志中看到白色节点先响应,然后是红色节点,我们把白色父节点的UseCapture关闭,再看看日志输出:


这次是红色子节点先响应,白色父节点后响应,更多细节可以参考Cocos Creator官方文档:https://docs.cocos.com/creator/manual/zh/scripting/internal-events.html?h=%E5%86%92%E6%B3%A1

还有对应的官方范例:TouchPropagation

题外话

这次除了教程,还想再聊一个事情,经常会有同学通过微信、QQ、公众号向Shawn咨询问题,首先感谢大家对shawn的信任,如果是在自己的能力范围内且对大家帮助的内容,Shawn一定真诚对待,这也正是「奎特尔星球」内容的重要来源。但是因为个人能力和时间有限,不是每一个人的问题Shawn都能解答,还望大家见谅。

微信、QQ很容易让人在工作时分心,一般我在做事的时候会将手机静音或离远一点,公众号上偶尔也收有留言,但有时会忘记去公众号上查看,超过24小时的留言,看到了想回复也无没办法,很是无奈。为了能把公众号做好,Shawn特地定制了一个邮箱:shawn@creator-star.cn,如有问题讨论或投稿欢邮件联系,公众号需要大家的支持和帮助,愿我们一起成长!