使用Grunt实现资源自动化同步

同步美术、策划资源是日常开发中极为频繁的事情,shawn借用Web前端的一些思想和工具,将Grunt自动化框架引入Cocos Creator项目,可以实现相对高效地将图片、动画、配置、音效等游戏资源导入到客户端工程中。

grunt介绍

在开始之前先用简单介绍一下Grunt是什么:

image.png

为什么选择使用Grunt做自动化工具,我简单总结下面几点:
1. 使用JavaScript语言,与Cocos Creator开发使用相同的语言,减少学习成本
2. 插件丰富,6000+(本篇文章只介绍两个grunt-sync和grunt-shell)
3. 使用json配置插件完成任务,没有太多逻辑条件,使用简单容易上手,当配置好一个模块后,其它模块可以依葫芦画瓢,策划人员也可以上手配置
4. Grunt基于Nodejs,可以借用大量插件与npm模块实现各种复杂需求
5. 跨平台

安装grunt与插件

首先,使用npm安装全局grunt-cli工具:

>npm install grunt-cli -g

然后在Cocos Creator项目根目录初始化npm的包管理文件package.json:

>npm init

输入npm init后一路回车,然后在项目中安装grunt npm模块:

>npm install grunt --save-dev

grunt只是一个自动化框架,我们这里还需要安装上面说的两个插件

>npm install grunt-sync --save-dev   //文件同步插件
>npm install grunt-shell --save-dev  //shell插件

Grunt任务模块目录结构

安装好Grunt的命令行、插件后,在项目根目录创建Gruntfile.js文件,这是Grunt自动任务的入口文件。同时在根项目新建一个tools目录,用于存放各种与项目有关的工具或脚本,再添加一个grunt-task目录,用于存放具体的grunt任务配置脚本,请看下图:

上图中xxx-task.js就是各子模块的自动化任务。

Gruntfile

shawn在早期使用Grunt时,是将所有任务都编写在Gruntfile.js文件,当模块越来越多,维护起来越来越困难,因此将不同模块的自动化任务独立开来,在Gruntfile.js进行统一加载和任务注册,下面看下Gruntfile文件的内容:

//引入rd模块读取文件
let rd = require('rd');
//获./tools/grunt-task目录下取所有文件
let taskScripts = rd.readFileSync('./tools/grunt-task');

module.exports = (grunt) => {
    //我们这里使用了grunt-shel&grunt-sync插件
    //下面shell与sync对象分别用于收集两种任务配置
    let shell = {};
    let sync = {};

    //将grunt设置为全局变量
    global.grunt = grunt;

    //require所有任务模块,放入tasks数组
    let tasks = [];
    taskScripts.forEach((script) => {
        let task = require(script);
        if (task.init) {
            //让task对象自己填充sync和shell内容
            task.init(sync, shell);
            tasks.push(task);
        }
    });
    //配置sync、shell两大任务
    grunt.initConfig({
        sync,
        shell,
    });

    //注册grunt-shell插件,用于执行外部shell命令
    grunt.loadNpmTasks('grunt-shell');
    //注册grunt-sync插件,用于本地文件同步
    grunt.loadNpmTasks('grunt-sync');

    //注册自定义的grunt任务
    tasks.forEach(task => task.registerTask());
};

对上面代码简单说明:
1. 加载tools/grunt-task下所有任务脚本
2. 为每个任务对象传入sync、shell两个任务集合对象,各任务模块在sync、shell对象中配置自己的任务内容。
3. 使用了两个grunt插件:grunt-shell、grunt-sync
4. 最后调用所有task.registerTask()将命令注册到grunt命令行

在命令控制台上执行grunt –help会看到我们所编写的自动化任务,下图是shawn曾经项目中创建的Grunt任务:

文件同步任务

你现在去执行grunt --help还是空的,还没有注册具体的任务,前面介绍过,Grunt是使用插件 + JSON配置的方式来创建任务,我们看一个子模块美术资源为例:

资源仓库客户端Assets按上图所示建立对应关系,其中绿色箭头是文件同步操作,其中headIcon目录中的图片,在项目中是动态加载的,需要同步到assets/resources/game1/texture目录下,这些操作我们可以使用grunt-sycn来完成。

其中比较特别是atlas目录,里面是经过分类需要合并图集的图片,文件合并后放到客户端项目assets/game1/texture/atlas目录,这个操作shawn是借用TexturePacker命令行工具 + Nodejs脚本来完成。

UI资源同步

梳理好了资源目录结构,现在我们将上流程编写成grunt同步任务,创建一个game1-task.js的文件,内容如下:

let path = require('path');
//获取grunt参数,是否模拟执行,不真实复制文件
let pretend = grunt.option('pretend');
//获取资源根路径,资源路径是定义在单独的define.js文件
let { UI_ROOT_PATH,ANI_ROOT_PATH } = require('./define');
//定义模块名,也就是在assets下的目录名
const moduleName = 'game1';
//定义同步任务
const syncTask = {
    //同步UI任务:普通图片、背景图片
    'sync-ui': {
        files: [
            //同步UI图片
            {
                //设置过滤器,排除atlas、headIcon、svn、隐藏文件
                src: ['**', '!atlas/', '!headIcon/', '!**/.*', '!**/.svn', '!**/.svn/**'],  
                dest: `./assets/${moduleName}/texture/ui`,  //目标路径,将文件同步到此处
                cwd: path.join(UI_ROOT_PATH, moduleName, 'ui')  //美术UI资源目录
            },
            //同步headIcon
            {
                //排除 atlas 目录及下面的子目录、文件
                src: ['**', '!**/.*', '!**/.svn', '!**/.svn/**'], 
                //同步到resources/moduleName/texture目录下
                dest: `./assets/resources/${moduleName}/texture`, 
                cwd: path.join(UI_ROOT_PATH, moduleName, 'ui', 'headIcon')
            },
            //同步背景图片
            {
                src: ['**', '!**/.*', '!**/.svn', '!**/.svn/**'],
                dest: `./assets/${moduleName}/texture/bg`,
                cwd: path.join(UI_ROOT_PATH, moduleName, 'bg')
            }
        ],
        verbose: true, // 显示日志
        pretend: false, // 模拟输出
        updateAndDelete: true, // 删除dst冗余文件
        compareUsing: 'md5', // 可选'mtime/md5'
        ignoreInDest: ['**/*.meta', '**/*.pac', '**/.svn/**'],  // 不删除.meta文件
    },
}

上面代码中sync-ui就是一个同步任务,其中files数组中配置同步目录,每一个数组元素包含三个字段:
1. src: 文件过滤器
2. dest: 目标路径,同步到那里去,以当前Gruntfile文件为相对路径
3. cwd: 源路径,从那里去复制文件,同样以Gruntfile文件为相对路径

然后是同步选项:
1. verbose: 打印日志输出,这对我们检查路径是否正确非常有用,建议设置为true
2. pretend: 同步模拟,当值为真时,配合verbose使用只会显示要同步的文件,不会真实写入或删除文件目标文件
3. updateAndDelete:删除冗余文件,比如你动随意放入一个图片到客户端ui目录,当执行ui同步资源,这个文件并未在资源仓库中,它会被同步删除掉
4. compareUsing:文件比较策略,可选项”md5″与“mtime”,建议使用md5保证正确性
5. ignoreInDest:指定同步时不删除那些文件,这个选项非常有用,我们都知道Cocos Creator会为每个文件生成同名.meta文件,这里一定要注意,不能被同步掉了,除了meta文件外,还有自动图集、svn等文件。下面是执行grunt-shell命令的效果:

⮀ grunt up-hall --pretend
Running "sync:hall-ui" (sync) task
Copying ../../../hall/ui/hall_btn_jlb.png -> assets/hall/texture/ui/hall_btn_jlb.png
Copying ../../../hall/ui/hall_btn_kefu.png -> assets/hall/texture/ui/hall_btn_kefu.png
Copying ../../../hall/ui/hall_btn_mail.png -> assets/hall/texture/ui/hall_btn_mail.png
Copying ../../../hall/ui/hall_btn_set.png -> assets/hall/texture/ui/hall_btn_set.png
Copying ../../../hall/ui/hall_btn_share.png -> assets/hall/texture/ui/hall_btn_share.png
Copying ../../../hall/ui/hall_Btn_shop.png -> assets/hall/texture/ui/hall_Btn_shop.png
Copying ../../../hall/ui/hall_btn_yxsj.png -> assets/hall/texture/ui/hall_btn_yxsj.png
Copying ../../../hall/ui/hall_img_dianchi.png -> assets/hall/texture/ui/hall_img_dianchi.png
Copying ../../../hall/ui/hall_img_dianliang.png -> assets/hall/texture/ui/hall_img_dianliang.png
Copying ../../../hall/ui/hall_img_gold.png -> assets/hall/texture/ui/hall_img_gold.png
Copying ../../../hall/ui/hall_img_head_00.png -> assets/hall/texture/ui/hall_img_head_00.png
Copying ../../../hall/ui/hall_img_head_01.png -> assets/hall/texture/ui/hall_img_head_01.png
Copying ../../../hall/ui/hall_img_laba.png -> assets/hall/texture/ui/hall_img_laba.png
Copying ../../../hall/ui/hall_img_red.png -> assets/hall/texture/ui/hall_img_red.png
Copying ../../../hall/ui/hall_img_xinhao.png -> assets/hall/texture/ui/hall_img_xinhao.png
Copying ../../../hall/ui/hall_mask_bottom.png -> assets/hall/texture/ui/hall_mask_bottom.png
Unlinking assets/resources/hall/texture/headimg/default.png because it was removed from src.
Unlinking assets/resources/hall/texture/ui/ttz_bg_toast.png because it was removed from src.
Removing dir assets/resources/hall/texture/ui because not longer in src.

上面可以看到以Copying开头的是文件复制信息,使用verbose参数,它显示了从那儿复制文件那儿,Unlinking是删除文件,同样显示了被删除的文件路径。

动画资源同步

上面讲了UI资源的同步, 对于动画资源我们处理方式有些不同,因此需要单独创建一个同步任务:

const syncTask = {
    'sync-ui': { ... }
    'sync-ani': {
         files: [
            //同步UI
            {
                src: ['**', '**/!.DS_Store', '!**/.svn', '!**/.svn/**', '!**/.gitignore'], //过滤器
                dest: `./assets/{moduleName}/animations`,
                cwd: path.join(ANI_ROOT_PATH, moduleName, 'animations')
            },
          ],
          verbose: true, // 显示日志
          pretend: pretend || false, // 模拟输出
          updateAndDelete: true, // 删除dst冗余文件
          compareUsing: 'md5', // 可选'mtime/md5'
          ignoreInDest: ['**/.svn/**', '.DS_Store', '**/.gitignore'],  // 不删除.svn下文件
    },    
    }
}

动画同步与UI同步最大的差别在于,ignoreInDes同步选项不能忽略meta文件。在shawn的项目中,动画是由美术人员在独立的Cocos Creator工程中编辑的,美术人员可以在动画工程中使用Cocos Creator动画编辑器或Spine、DragonBones等动画资源,使用Prefab进行整合,客户端主要依赖美术提供的动画prefab文件以及动画名字,动画同步任务需要将所有动画资源全部同步到客户端项目中,其中包括所有的meta文件。

这里可能会有一个小小的风险,就是动画工程中的meta文件与客户端界面中的meta文件发生UUID冲突,这种冲突的可能性是完全存在的,但在shawn一年半十多个子模块的动画项目中暂时还未遇到过,冲突的概率非常低。

图集合并同步

在UI目录中有一个类特殊的图片,需要做成图集提高游戏渲染性能,在一个游戏项目初期由于UI风格不稳定或使用临时图片,让美术同学经常去合并图集是一个效率较低的事情。因此shawn将需要合并图集的文件放入atlas的子目录中,由程序调用TexturePacker的命令行工具,以atlas子目录为单位生成图集,直接存入客户端模块atlas目录。

图集合并并完全是动态的,shawn编写了一个Node脚本,用于遍历atlas下的子目录文件,生成图集文件,然后再使用grunt-shell插件进行整合,看下面代码:

//TexturePacker图集合并工具
let tpimg = require('../tpimage');
//shell任务
const shellTask = {
    //TexturePacker合并图片
    'tp-img': {
        command() {
            //工作路径,资源仓库atlas
            let cwd = path.join(UI_ROOT_PATH, `${moduleName}/ui/atlas`);
            //目标路径,图集文件保存到这里
            let dst = `./assets/${moduleName}/texture/ui/atlas`;
            //使用编写的Node脚本生成TexturePacker命令数组
            let commands = tpimg.tpdirs({ cwd, dst });

            //模拟输出命令
            if (pretend) {
                console.log(`cwd:${cwd}, dst:${dst}`);
                console.log(commands.join('\n'));
                return '';
            }
            //返回命令字符串,由grunt-shell插件执行
            return commands.join('&&');
        }
    },
}

下面是任务执行时的效果:


两次执行,每一次执行时生成了icon.png、icon.plist,马上再次执行,提示未发生改变没有再重新生成图集,这比我们手动打图或使用Cocos Creator的自动图集效率要高。

资源仓库更新

上面介绍了美术UI、动画、图集等资源的同步,但一个完整的模块资源同步,还需要涉及到对资源仓库的更新,具体操作就是用git或svn将资源仓库更新到最新状态,下面看使用grunt-shell命令更新资源仓库:

//svn更动画资源
'svn-ui': {
    command: [
        `svn up ${UI_ROOT_PATH}/${moduleName}/ui`,
        `svn up ${UI_ROOT_PATH}/${moduleName}/bg`
    ].json('&&')    
},

//svn更新动画资源
'svn-ani': {
    //如果使用nosvn参数,返回空字符串,跳过svn更新
    command: nosvn ? '' : `svn up ${ANI_ROOT_PATH}/${moduleName}/animation`,
}

有时会遇到svn暂时不可用,或有同学未安装svn命令行工具,会导致任务失败,可以shawn这里设置了一个nosvn的参数,用于控制是否跳过svn操作,直接返回了一个空字符串。

任务整合

一个子模块完整的资源同步任务大概需要经历下面几个步骤:

我们前面都建立的单个任务,使用grunt.registerTask可以将任意单个任务进行自由组合,看下图:

hall-up.png

grunt.registerTask的参数包括:任务名、任务说明、子任务列表,子任务列表是一系列的任务或插件任务字符串的组合,上图中up-hall-svn任务,是由三个shell插件任务组成。

此时我们在项目根目录中,执行:
>grunt up-hall
即可享受到所有美术图片、动画、音效、配置等等资源的同步到我们的客户端hall模块。

我们经常会遇到这样一个场景:

美术同学:“xxx程序我增加了大厅商店道具张图片,你更新一下呢,我想看看效果”。

程序同学:“这几张图需要与策划配置文件配合才能生效,yyy策划你更新下商店配置”。

策划同学:“今天早上一来我就已经更好了,你直接取吧!”。

动画同学:“商店里几个动画特效我也更新了,你也取一下”。

程序同学打开终端,键入:grunt up-hall,屏幕一阵疯狂输闪,涌现出无数文字符号...
1、2、3、4、5 ....
五秒钟过去,点击Cocos Creator编译资源,观察控制台,一切ok!

程序同学:“你们使用我的IP:7456自己去看吧!”

美术策划同学,一脸惊讶地看着你,效率这么高…

曾经十几分钟都搞不定的事情,现在几秒就解决了,每天做50遍都不会觉得累!也不需要做50遍,将grunt任务与Cocos Creator插件结合,嵌入到Cocos Creator界面菜单,让程序员多休息一会儿吧!


欢迎关注「奎特尔星球」公众号,欢迎大家投稿,来我们一起成长!

奎特尔星球微信公众号

奎特尔星球博客网站

奎特尔英雄联盟

1. 前言

《奎特尔数字大冒险》第一个版本上线微信小游戏有5天了,收到不少朋友们的积极反馈,在此感谢大家的支持!Shawn在此分享一下《奎特尔数字大冒险》上线的表现:

image.png

1月4号星期5下午上线,之后周未两天表现还不错,1月7号周二早上看刚刚过线,拿到了流量主权限,但从7、8号的数据就有些凄惨了,一下子跌到几十,从中也能体会到个人开发者们的不容易!不仅要会折腾代码,还要操心游戏的推广!

对于个人开发者来说一没资金,二没资源,只能靠发点好友、朋友圈,很难去优化调整,甚至坚持下去,Shawn还算是幸运的,至少此刻还可以在公众号上来一吐心声!

预告一下,《奎特尔数字大冒险》下一个版本增加了简单模式,家里的小朋友终于玩起来不再扔手机了!(之前BOSS一出来,就把手机扔给我,你来算!)

下一个版本即将推出,期待大家持续观注,提出你的意见或建议!同时在此征集高分截图,感谢大家!

2. 联盟推荐

上篇推文中Shawn讲过,公众号荣幸为个人开发者免费推荐作品!

以个人的能力坚持将一个游戏从无到有的做出来,再到上线到平台,不论是技术经验还是学习能力,以及对软件编程、游戏的热爱都是毋庸置疑,克服重重困难,战胜自己,每一个人都是自己心目中的英雄!

论坛上的前辈们,用他们的血泪经验为后来的人提供了指引,在你们身上肯定有不少可以与人分享的经验与故事!物以类聚,人以群分,携手互助,砥砺前行!

帮助他人,就是成就自己,诚邀个人开发者加入「奎特尔英雄联盟」!

用您的正能量影响更多的人,目前在论坛上收集到如下个人发者的小游戏作品:

「lnh526331397」的合成骑士

合成骑士.jpeg

完成度很高的游戏,武器全合,画面也好!作者还在论坛中热帮回答大家的问题!

合成骑士

论坛地址:https://forum.cocos.com/t/topic/68199

「小狐狸狗狗」的消灭星星

消灭星星
消灭星星,玩法丰富,我妈妈最爱的游戏,60多岁的人了,有时还玩到凌晨一两点!

消灭星星

论坛地址:https://forum.cocos.com/t/6-4/58845

「cocos亨亨」的太空弹球机

cocos亨亨-太空弹球机截图.jpg

弹球机,画面细腻,让我想机小时候的小霸王游戏!

cocos亨亨-太空弹球机.png

「阒无一人的喧嚣」的蛇行

蛇行
游戏很有趣,Shader带感,但游戏也很有难度,我一截屏就死掉了,想截个炫丽点的画面好难!

image.png

论坛地址:https://forum.cocos.com/t/shader/72016

「手写第七章」的全民消一消

全民消一消
创意消除,画面清新,据作者透露每天日活200左右,每日收入5-10元,这个信息相信对大家有用。

全民消一消

论坛地址:https://forum.cocos.com/t/topic/72044

3. 联盟行动

如果您觉得上面的某个游戏有意思,或作者在论坛中的分享对你有帮助,恳请借您的朋友圈帮助转发一下,留下一句正面的语言!

Shawn目前也拿不出什么有价值的东西来回报大家,凡是热心帮助转发本篇文章 + 以上任意小游戏二维码的同学,赠送出100份Shawn在Cocos Creator商店中的付费插件pbkiller,感恩大家的帮助!

转发本篇文章也可以获得pbkiller, 通过截图发到公众号,并留下你的邮箱地址!感恩大家的帮助!

4. 联盟投稿

如果你有游戏开发经验或心得,愿意分享给他人,欢迎来「奎特尔星球」,帮助他人成就自己,愿我们一起成长,在前进的道路上我们相互砥砺前行!

投稿需知:
1. 邮件标题: 投稿《你的文章题目》【笔名】
2. 一份MarkDown格式的文章内容,分享你的技术经验、心得!
3. 如果文章内包含图片,请打包为zip附件(微信公众号上不支持外链图片)
4. 如果文章中包含视频,需提供腾讯视频页面链接

5. 游戏推荐

如果您是一名个人开发者,有自己的微信小游戏,且小游戏内容健康向上,「奎特尔星球」荣幸为您的小游戏做推荐!

推荐需知:
1. 邮件标题: 小游戏推荐《你的文章题目》【笔名】
2. 一份MarkDown格式的文章内容,介绍你的游戏玩法、技术亮点、开发心路历程等都可以,为路上同行的伙伴增强信心!
3. 如果文章内包含图片,请打包为zip附件(微信公众号上不支持外链图片)
4. 如果文章中包含视频,需提供腾讯视频页面链接

凡投稿技术文章达到3篇或有个人小游戏且在公众号上分享过1次经历的同学,可加入到「奎特尔英雄联盟」,我们一起携手共进!

欢迎关注「奎特尔星球」公众号,欢迎大家投稿,来我们一起成长!
image

奎特尔手写冒险日记

奎特尔数字大冒险

2018年1月4日,从想法到实现用了368天《奎特尔数字大冒险》终于上线微信小游戏!

不知道大家还记得在2018年1月1日,微信小游戏刚出来时,Shawn发过一篇文章《元旦快乐—小游戏!小游戏!小游戏!》当时还发了一个自己做的Demo,不知道是否还有人记还得,看下图:

image.png

1. 大家关心的问题

猜一猜,这两天收到最多的问题是什么?

手写数字识别怎么做的?

我想这是大多数程序员同学体验了《奎特尔数字大冒险》最为关注的问题,也引起不少群中的讨论!

数字大冒险中没有图像识别的技术,是用的轨迹近似匹配实现的。

记得很久以前玩过一个叫《刀剑封魔录》的游戏,就是使画“一”、“V”、“Z”等书写轨迹发大招,看下图:

数字轨迹模版

首先是将0~9的数字写一遍,将每个数字的轨迹坐标点保存下来,大概数据结构如下:

[
  {name: '1',  data: [{x, y},{x, y},{x, y},{x, y},{x, y}]}
  {name: '2',  data: [{x, y},{x, y},{x, y},{x, y},{x, y}]}
  {name: '3',  data: [{x, y},{x, y},{x, y},{x, y},{x, y}]}
  {name: '3',  data: [{x, y},{x, y},{x, y},{x, y},{x, y}]}
  ...
]

为了适应大多数人的书写方式,需要为每个数字多保存几个书写轨迹!看下面视频:
数字轨迹播放

视频地址:https://v.qq.com/x/page/i0823npmetu.html

我这里做了一个轨迹播放器,大家有兴趣可以看一下,按照这种近似的路径去写就容易被匹配上,里面还有更多sample示例。

轨迹匹配

在游戏中监听写字板节点的TouchStart、TouchMove事件绘制轨迹,同时保存轨迹坐标点,在TouchEnd时将收集到的坐标点数组与事先录入的模版数据挨个做对比,看与那个近似,将最接近的轨迹name值拿出来。

在这里4和5比较特殊,他们是由两笔写成,因此将4、5的第一笔也做了单独的轨迹模板,如果识别到写的是4或5的第一笔时,就等待用户输入第二笔。

Shawn目前还不能把代码直接拿出来,暂且只能分享大致原理,后面有时间我会再做更多这方面的分享,也欢迎大家讨论!

2. 冒险之旅

Shawn是从2018年7月开始独自踏上冒险之旅,一边四处打游击做点小外包和Creator的入门培训,一边写点公众号文章,想把数字大冒险游戏给做出来!想的是比较美好,但总是一拖再拖,拖到了12月才正式全身心投入!

手写识别在半年前就解决了,用它来做一个什么样的游戏呢?当时唯一的想法就是,一定是要意义的游戏,可以真实帮助到小朋友学习数学运算、锻炼思维记忆。

其中的意义就是来自于我的女儿,一个老爸想在女儿面显得很厉害!在她幼儿园的时候,我经常对女儿说:

“这个游戏不错,爸爸也可以给你做一个,还可以永远不会死掉”,
“这个游戏太没意思了,爸爸给你做一个更好玩的!”

但每一次熬更赶夜换来的无一都是鄙视的表情,太丑了!

但每一次熬更赶夜换来的无一都是鄙视的表情:“太丑了,全是方方块的!“。不过还好,我送女儿上学的路上,我们会不时讨论一下,怎么做她会接受!

游戏的创意也不是Shawn发明的,任天堂早在10前就有不少这类益智游戏了,Shawn也是任天堂的粉丝,从Wii到NDS、3DS、Switch一个没落下,如果大家有兴趣做小游戏,任天堂的益智游戏绝对是一个不错的老师!

怀揣着一颗萌动的心,一个朝夕相处鞭策你的用户,一个可以学习模仿的老师,开始独自踏出了第一步!

3. 冒险计划

任天堂的益智游戏,咋一眼看起来简单,但深入体验你会感受到作品的中无比的诚意。

《奎特尔数字大冒险》匆忙上线微信小游戏,明显也缺乏诚意,导致大家所关注的焦点是手写识别,而非游戏本身!游戏难度颇大,其实更适合大朋友,而非小朋友!说的是帮助小朋友锻炼思维记忆,却是惹的小朋友玩的冒火想打人!

静下心来,把自己当成一个小孩子,我能从《奎特尔数字大冒险》学习到什么呢?带着这种想法,我思考了好几天,整理了一份粗略的计划!

奎特尔数字大冒险计划

一年来的想法和思考始终没有记录下来,不能踏实去执行,这次将所有的想法写下来,一条一条地死磕下去!

显然上面的工作量是非常巨大的,Shawn是一个程序员,同时扮演一个伪策划,好几次使用PhotoShop切点图,想把电脑给炸了!

有人问:“狼居然会天上飞,你想象力还真丰富!”
我回答说:“不是猪也会飞的吗?我这是没资源嘛,先做出来再说吧!”

没有美术就像有枪没子弹、有锅没米,而且今年冬天也特别冷,储藏的食物也快用尽,先把东西做出来再说,先让狼飞一会儿吧!终于在1月4号把第一颗子打出去了!

Shawn在此感谢帮助过我的朋友们,成都村雨科技的「大白」、「onescc」近乎无尝给我提供部分美术资源;感谢「丁丁猫」的骨骼动画,以及对游戏的建议,让我好好看下女儿的课本给了我不少启发;感谢「jacklling」在微信小游戏程序开发中提供的帮助!也感谢帮助我分享分享、传播的同学们!

4. 帮助更多的人

对程序员来说,美术与推广是最难的,今天算是为自己写了一篇广告!
如果你也有个人开发的小游戏与有趣的故事,欢迎来奎特尔星球,免费为大家提供推荐,帮助他人就是帮助自己,愿我们一起成长!


欢迎关注「奎特尔星球」公众号,欢迎大家投稿,来我们一起成长!

奎特尔星球公众号

CocosCreator组件化编程的探索

使用CocosCreator已经一年了,在此期间一直在摸索,如何才是组件化编程的最优实践。Shawn属于半野生的路子,水平不高,但不时会陷入一些问题瞎琢磨。我根据自己的经验,总结了一套组件化编程模型:法宝结界模型。

但在介绍法宝结界组件模型之前,先回顾一下控件和组件的概念。

一、控件与组件

在Cocos2d-x/lua/js的年代,UI元素都以控件类的形式存在。只有cc.Node的子类才能在界面上显示,例如:cc.Image、cc.Button、cc.Text。我们编写的界面代码也属于cc.Node的子类或系统控件的子类。

在Creator中我们自己编写的cc.Component的子类脚本,能称之为控件吗?

image

1.我所理解的控件

我觉得要能称的上控件,必须是能够被界面编辑器代码所控制,并能相对独立完成一项或多项任务的程序模块。而且控件具有一定范围的通用性,可以独立运行,可以被独立测试。

我们要自定控件,一类是cc.Componet子类脚本 + 预制体的结合;第二类是纯cc.Componet的子类脚本(不含预制体),也可以是系统组件的派生类。不含预制体的脚本其实是引擎自动帮我们生成的,当拖入一个组件脚本到场景编辑器,Creator会自动生成一个节点,并将脚本挂载到这个节点上。

这两类控件有什么不同呢?

脚本+预制体:控制的是预制体中的节点和子节点,以及节点上的控件。

纯脚本: 只能控制当前节点,也可以控制当前节点上的其它组件。

可以看出,这两类组件代码在他们控制的范围上是不同的。

2. 组件的悲剧

cc.Componet的子类都是组件,但他们要想要上升成为控件却很难。因为大多组件代码,都无法像系统控件那样独立完成一项目任务,其原因之一是滥用组件的properties可视化编辑功能,将本职范围内的节点做为成员变量,目的仅仅是为了方便访问。

image.png

从Creator范例工程中的TestList首场景为例,Menu.js组件脚本挂载到Menu节点上,最后一个Menu.testList属性设置是非当前节点的子节点,控制权延生到了外面去了。前面几个属性(Text, Readme…)都是通过编辑器拖拽将Menu节点下的几个子节点配置到了组件脚本上,他们对于Menu.js应该属于私有成员变量,也变成了公开的了。 

通过简单拖拽配置成员属性确实让程序开发变的简单,但如果滥用会有一个严重的问题:控件属性由原来的点状(控制自身节点)或线性(控制子节点或成员节点)关系,变成了网状关系(控制自身以外的节点)。

这将导致组件脚本难以独立完成任务和测试,必须通过编辑器正确配置才能工作,就像在一个模块代码中访问了全局变量一样。要让Menu.js成为控件的办法,最好是将TestList节点放到Menu节点内部。把Menu节点拖到资源管理器中成为一个prefab。

image

不知道如何下手,设置这些属性

不知道大家有没有遇到过,在属性检查器上密密麻麻的属性配置,不知道该如何下手?更让人头痛的是,不小心代码冲突,导致组件属性配置丢了,再看代码,脚本中的属性变量与节点名字又对应不上,就连编写这个模块代码的人都搞不懂是怎么会事!

网状关系的程序组织结构,会导至模块之间相互依赖,可重用性极低。如何规范组件的编写方式,确保模块的内聚性值得我们多多思考。

二、法宝与结界

下面来聊聊我总结的法宝结界模型,假想一个完整的世界,为了维护这个世界的有序运行,设置了一个结界。结界中有无数的法宝参与到世界的运行之中,贡献出力量。

1.法宝型组件

法宝型组件:以装饰宿主节点为己任,从不控制其它节点。

法宝型的特点是通用性强,可挂载任意节点运行,Creator内置的组件绝大多数属于这类。例如有Sprite、Label、Button、Widget等,可以看出纯脚本的组件就属于法宝型。

2.结界型组件

结界型组件:管理和控制其它节点及节点上的组件,通常会根据上层业务要求,调用其它节点的属性方法完成任务。

结界型的特点是业务逻辑性强,通用性差,通常是对法宝型组件的指挥和管理。组件+prefab就属于这类,由于结界型组件大多是定制的,它并且不能随便挂载到别的节点上(更多的是只能挂载到唯一的节点上)。

3. 结界的秘密

话说天有九重,九只是个虚数,其实是很多的意思。一个结界型组件,对于它的上层结界来说,他又是一个法宝型组件,这就形成了模块化。

image

比如有一个名为A的prefab,将组件脚本A.js挂载到prefab的根结点。当另一个场景或预制体中实例化这个A.prefab时,A.js就上升为一个控件,他管理了A.prefab下的所有节点,但对于当前场景来说,它又体现为一个法宝型组件,而成为了一个控件。

对内是结界,从根节点开始自下而下管理所有子节点;对外是法宝,从根节点获取法宝暴露的属性方法。这样以内部线性、总体树状的程序结构,是不是要比网状的结构更好些呢?

三、小结

uikiller库是我在组件化编程上的一点成果,可以方便管理prefab下的任意节点和组件,以及节点上的触摸事件。奉上一段uikill的使用视频 ,结束这篇分享。
http://v.youku.com/v_show/id_XMzMxNzUxMzYzMg==.html?spm=a2hzp.8244740.0.0

uikiller使用手册

一、前言

uikiller是使用名命规则来控制UI节点、组件和触摸事件,减少UI相关的代码与编辑器设置,实现原理是提前对UI树的遍历。

在CocosCreator中UI编程基于组件模式,我根据自己的项目经验,将组件分为两类:法宝型结界型

法宝型组件

法宝型组件:以装饰宿主节点为己任,从不控制其它节点。

特点:通用性强,可挂载任意节点,Creator内置的组件绝大多数属于这类。
举例:Sprite、Label、Button、Widget等。

结界型组件

结界型组件:管理和控制其它节点及节点上的组件,通常会根据上层业务要求,调用其它节点的属性方法完成任务。

特点:业务逻辑性强,通用性差。
举例:完成具体业务功能的自定义组件。

关于法宝型和结界型组件的探索可以参考我的另一篇文章《CocosCreator组件化编程的探索》

有了上面的了解,我将uikiller定位为结界的管理者,下面介绍一下uikiller的基本用法。

一、Thor组件

Thor组件继承自cc.Component,同时封装了uikiller的组件绑定能力,提供了当前UI树的直接访问控制能力。

//导入Thor组件
let Thor = require('Thor');
cc.Class({
    extends: Thor,  //继承Thor组件
    onLoad() {
    }
});

使用uikiller提供的Thor组件做为自定组件的基类,并挂载到场景或预制体的根节点上,该组件脚本即可拥有控制UI树的两大能力:节点访问触摸事件监听

二、节点访问

在Thor子类脚本中,可直访问整个UI树中以下划线“_”开头命名的节点。

节点名命名

let Thor = require('Thor');
cc.Class({
    extends: Thor,
    properties: {
    },
    onLoad() {
        //直接访问节点
        cc.log(this._image.name);
        cc.log(this._label.name);
        cc.log(this._button.name);
    },
});

三、组件访问

在node节点访问的基础上,使用“$” + “组件名”访问节点上挂载的组件对象。

let Thor = require('Thor');
cc.Class({
    extends: Thor,
    properties: {
    },
    onLoad() {
        //在节点上使用“$组件名”访问组件
        this._label.$Label.string = 'hello world';

        //注意继承了Thor的子类,onLoad函数在编辑器状态就会被执行,可以根据具体业务使用CC_EDITOR变量逻辑判定是否要在编辑状态时间
        if (!CC_EDITOR) {
            //禁用按钮
            this._disableBtn.$Button.interactable = false;
        }
    },
});

三、触摸事件监听

下划线“_”开头的节点可以自动关联其触摸事件。

目前支持五个触摸事件:

TouchStart、TouchMove、TouchEnd、TouchCancel、TouchLong

命名规则

_on + 控件名(去下划线,首字母大写) + 触摸事件

例如节点名为_label,事件函数为:_onLabelTouchEnd
为什么定义这样的命名规则呢?首先“___”开头表示私有,on表示事件,后面形成形成驼峰命名,以具体触摸事件为后缀。

1. 监听节点事件

let Thor = require('Thor');
cc.Class({
    extends: Thor,
    /*
    *sender 响应事件的节点
    *event  事件对象,可以从中获取触摸坐标点等信息
    */
    _onLabelTouchEnd(sender, event) {
        cc.assert(sender === this._label);
        sender.$Label.string = '你抚摸了我';    
    }

2. 节点触摸事件监听

上面讲的都是子节点的触摸事件监听,如果要组件监听当前节点(this.node)如何操作呢?
###命名规则

_on + 触摸事件(首字母大写,形成驼峰命名)

同样支持五个事件:TouchStart、TouchMove、TouchEnd、TouchCancel、TouchLong

let Thor = require('Thor');
cc.Class({
    extends: Thor,

    //监听当前节点的触摸事件
    _onTouchStart(sender) {
        cc.assert(this.node === sender);
    }
})

3. 长按事件监听

长按事件是uikill扩展的触摸事件类型,可以给节点设置touchLongTime属性控制长按触发时间,默认是1秒,注意以毫秒为单位。

let Thor = require('Thor');

cc.Class({
    extends: Thor,

    properties: {
    },

    // use this for initialization
    onLoad() {
        this._label.touchLongTime = 500;
    },

    _onLabelTouchLong(sender) {
        cc.assert(sender === this._label);
    },

四、小结

uikiller的基础功能就介绍完了,uikiller可以帮助你编写更为精简的代码,去除冗余,如果有兴趣可以访问github仓库进行体验。

奎特尔入侵指南

有不少人对奎特尔了解的不多,梦想成为一名优秀创世之主。但想要入侵奎特尔,表面上看起来容易,但稍有不注意容易迷失。Shawn在奎特尔星球上呆了有大半年了,再加上之前在奎特尔的兄弟星球cocos2d-js上开垦了三年,略有心得,愿与你分享。

征服者

一、javascript语言

  1. javascript语言基础
  2. 了解javascript常见的坑,容易犯错的地方
  3. 深入了解javascript中的函数,还有this、call、apply、bind
  4. 学习javascript流行的三方库,推介lodash\async

二、UI

  1. 熟练使用Creator图形编辑器,掌握常见的ui布局思路和策略
  2. 组件编程,熟练使用creator提供的系统组件,实现常见UI功能
  3. 在系统组件上进行扩展或组合,编写出可扩展、通信性强的组件
  4. 音乐、音效、多语言

三、动画

  1. 了解creator提供的动画编辑器,掌握动画组件cc.Animation的使用
  2. 熟悉粒子组件,会编辑修改,能通过代码粒子的属性和播放
  3. 熟练cocos action动作API
  4. 多节点的动画、动作控制,异步动画编程

四、网络

  1. 掌握XMLHttpRequest
  2. WebSocket/socketio,不过Creator中Native上的socketio模块目前还不太完善。
  3. 网络协议,了解json、protobuf等常见的协议编码方式,以及通信协议的设计策略。
  4. 至少一种服务器编程语言,会搭建、配置http服务,推荐nodejs。

五、Native

  1. 会编译ios\android\mac\win32 这些平台上的原生应用,至少2个
  2. 了解ios\android\mac\win32工程配置,能看懂SDK接入文档,至少2个
  3. 最好是会用c++,ios\android开发懂一点最好
  4. 熟悉Creator提供的jsb相关api,反射机制,jsb绑定

六、调试

  1. chrome调试creator web应用
  2. 微信devtool调试creator web应用
  3. Safari调试iphone web
  4. firefox调试原生ios/android/mac/win ,目前1.7已经不用firefox了,苹果上用safari,其它平台用chrome

七、工程自动化

  1. 熟悉一门脚本语言:nodejs或python,会一点批处理和shell更好
  2. 根据项目情况,设计符合项目的工作流:程序、策划、美术
  3. 使用脚本编写自动化工具,常见有:资源同步、资源转换、配置转换、构建打包、上传下载、GM工具等

如果是从c++转做cocos creator,前期重点可放在js语言与creator 组件的UI开发上面,当有所产出后,可以从windows c++或xcode ios进入cocos2dx源码学习,有所领悟后可根据项目重点深入学习。

如果是从web前端转做creator的话,js语言就不多说了,建议多学习下面向对象编程,将火力集中在UI开发上和动画方面,能有输出后,根据项目需要学习其它内容。如是只做H5的话,可以不用管Native会省心不少。

如果是从java开发转入Creator,大多数搞java的都了解一些web开发熟悉js语言,重点可以放在UI开发上,然后从Android java上进入Native这部分。

不管是做Native还是H5,不论之前是做什么语言,调试技术是不可少的,而且通过调试和阅读源码,可以让你更进一步学习好的设计方法。
好了,以上建议供你参考!


欢迎关注「奎特尔星球」微信公众号,有代码、有教程、有视频、有故事,等你一起来奎特尔星球玩耍!

当creator遇上protobufjs|效率

在cocos creator中使用protobufjs(一)
在cocos creator中使用protobufjs(二)
通过前面两篇我们探索了如何在creator中使用protobuf,并且让其能正常工作在浏览器、JSB上,最后聊到protobuf在js项目中使用上的一些痛点。这篇博文我要把这些痛点一条一条地扳开,分析为什么它让我痛,以及我的治疗方案。

一、proto文件的加载问题

我遇到的第一个痛点就是proto文件的加载问题。有人可能会问,前面不是讲了怎么加载方法很简单的:

...
let builder = new protobuf.Builder();
protobuf.loadProtoFile('aaa.proto', builder);
protobuf.loadProtoFile('bbb.proto', builder);
...

protobufjs是一个很优秀的库,他提供的loadProtoFile接口简单直接,但是在真实的项目开发中会像是上面这样的吗?proto文件是一开始就设计好了,固定不变的吗?文件名会修改吗?文件会新增、删除吗?

痛点分析

我只有第一天在cocos-js项目中使用proto时是将一个一个的proto文件名写死在loadProtoFile的参数中的,因为那是我中途参与的项目,当时我就发现了问题:
1. 路径名、文件较长容易写错字。
2. 项目开发中协议会不断新增,会写漏,少加载了proto文件。
3. 某些原因会修改proto文件名,原来加载的没及时修改,加载时会出错。
4. 人工手写这个加载文件会很累,效率低下,容易出错,在文件众多的情况下极度消耗脑细胞。

解决办法

编写代码来生成代码

我的解决办法是编写一个程序,扫描proto文件目录,生成一个文件列表的数组,从而完全解放人工操作。

//protoFiles.js 用脚本自动生成的文件
module.exports = [
  res/proto/aaa.proto,
  res/proto/bbb.proto,
  res/proto/zzz.proto,
  res/proto/login/xxx.proto
  ...
]

//pbhelper.js 编写一个加载器
let protoFiles = require('protoFiles'); //导入自动生成的proto文件列表
...
loadProtoFile() {
    let builder = new protobuf.Builder();
    //遍历文件名,逐一加载
    protoFiles.forEach((protoFile) => {
        protobuf.loadProtoFile(protoFile, builder);
    })
    ...
}

从此再也不用担心proto文件加载方面的问题了。

解放更多人工操作

在编写proto扫描脚本的同时,还可以将proto文件同步到自己的工程目录中,以解决proto文件的手工复制粘贴问题,如果你还要更进一步,还可以将svn/git的拉取给做了。
总结一下脚本要做的事:

1.从svn或git获取最新的proto文件(svn: svn up, git: git pull origin master)
2.将proto文件同步到工程目录
3.扫描工程目录中的proto文件,生成一个文件列表数组

Creator中的新发现

最早在Creator中使用proto时我也是使用的上面的方法,但随着对Creator的了解越来越多,我就在想,Creator不是管理了我们所有的资源了吗?cc.loader.loadResDir不是要以加载一个目录下的所有资源,是否可以有更简单的办法?于是我尝试着去调试loadResDir函数有惊喜发现。

let files = [];
//xxx是assets/resources目录下的一个目录名
cc.loader._resources.getUuidArray('xxx', null, files);
//files会得到所有的文件名
cc.log(files);

通过这个发现,可以省去生成protoFiles.js的工作了。

二、proto对象的实例化问题

proto对象的实例化是一个痛点,估计很多人会觉得有点小题大作。protobufjs不是提供了操作方法吗,那么简单:

//实例化登录请求
let loginReq = new pb.LoginRep();
loginReq.account = 'zxh';
loginReq.password = '123456';
//假如net是封装好了的网络模块
net.send(pb.ActionCode.LOGIN, loginRsp, (data) => {
    //收到数据,反序列化
    let loginRsp = pb.LoginRsp.decode(data);
    ...
});

如果是做过网络开发的应该对上面的代码不难理解,这里还是简单的解释一下:

1.xxxRep是客户端请求消息,xxxRsp 是服务器响应消息,成对的设计请求、响应协议比较好管理。
2.pb.ActionCode.LOGIN是一个常量定义,是设计的请求操作码,用于服务器识别你发的消息是登录请求,而不是其它,不然序列化后的二进制内容服务器无法反序列化。
3.这里没有出现客户端proto对象的序列化操作,因为可以封装到net.send函数中,所以它不足以成为一个痛点。
4.net.send中的回调函数是客户端响应处理函数,通过参数获得服务器发送的数据,因为二进制数据,所以需要用pb.LoginRsp.decode(data)进行反序列化。

痛点分析

let loginReq = new pb.LoginRep();

  1. 在js中使用proto有个特点,proto对象一般IDE都没有代码提示和着色,在用调用proto对象解码时输入效率低下,还容易打错。
  2. 这句代码暴露了协议细节,如果pb.LoginRep改名了也不知道,代码会报错。
  3. net.send(pb.ActionCode.LOGIN, loginReq, () => { }) 明明已经是发送的登录消息了,为什么还需要一个操作码呢?感觉有些累赘、重复。

解决办法

工厂模式

如果能像下面一样是不是会更清爽:

//使用工厂函数获得LoginReq对象
let req = pb.newReq(pb.ActionCode.LOGIN);
req.account = 'zxh';
req.password = '123456';
//在工厂函数时做个小动作:req.action = pb.ActionCode.LOGIN
//send时就不需要消息号参数了。
net.send(req, ...);

通过pb.newReq隐藏协议细节,也不需要管消息的名字,用的什么protobuf库,返回的req上绑定上action消息号减少调用send时的重复参数,上层操作简单明了。
除了设计工厂函数外,还需要定义pb.ActionCode.LOGIN,让它能被IDE自动提示、代码补全,文本着色,我们会省心很多。
利用工厂模式隐藏实现细节

三、proto对象的反序列化问题

我们再看下反序列化的场景

...
//发送数据,net假如是封装好了的网络模块
net.send(pb.ActionCode.LOGIN, loginReq, (data) => {
    //发送的是登录请求,反序列化时要用登录响应,不然会失败
    let loginRsp = pb.LoginRsp.decode(data);
    ...
});

痛点分析

反序列化成为痛点有部分原因与实例化相同,而且当你收到一个响应时,该用那个proto对象去反序列化会杀死不少脑细包,特别是在设计协议消息名字时不注意规范时更容易出错。

解决办法

1.设计通信协议头
2.请求\响应唯一序列号
3.工厂模式

tcp协议头
通信协议头是客户端、服务器在收到二进制数据时,可以使用一个固定的协议结构去反序列也称之为解码。 解码后可以获得基本的数据,比如路由号、时间戳、用户ID、下层协议数据(二进制)等,大概如下:

message PBMessage{
    int32 action = 1;     //消息号用于指明data字段(标识下层协议类型)
    int32 sequence = 2;   //请求序列
    uint64 timestamp = 3; //时间戳
    int32 userID = 4;     //帐号
    bytes data = 5;       //请求或响应数据(序列化后的二进制数据)
}

其中的sequence字段是客户端向服务器发出一个请求时,生成的唯一ID。当服务器响应你这个请求时,传回这个sequence,通过这个sequence + action你就能确定你的响应消息对象,从而正确解码。

//收到网络数据
message(event) {
    var pbMessage = pb.PBMessage.decode(event.data);
    //从缓存对象中取出请求时的参数对象
    var obj = this.cache[pbMessage.sequence];
    //删除缓存数据
    delete this.cache[pbMessage.sequence];
    try{
        //检测缓存数据是否存在
        if (!obj) {
            return;
        }
        //使用工厂创建响应对象
        let rsp = pb.newRsp(obj.action, obj.data);
        //调用请求时的回函数 
        obj.callback(rsp);
    }catch(e) {
      cc.log('处理响应错误');
    }        
}
  1. cache是缓存net.send时的参数包括:action、sequence、callback,其中sequence是自动生成的并以它为key。
  2. 当收到服务器数据时,先解码PBMessage,用解码后的sequence去查找出action。
  3. 使用action和data做为响应工厂函数的参数,反序列化出响应对象。
  4. 调用响应处理函数。

这时响应函数就可以很轻松的处理业务了

//发送数据,net假如是封装好了的网络模块
net.send(loginReq, (loginRsp) => {
    //直接访问响应对象,不需去解码了
    this.label.string = loginRsp.player.name;
    ...
});

核心问题

不论是解决实例化还是反序列化,最核心的问题是实现那两个工厂函数

let req = newReq(action);
let rsp = newRsp(action, data);

而实现这两个工厂函数的前提是明确请求操作码、请求对象、响应对象,需要建立一个映射表,类似下面的定义

//proto中定义Action
enum ActionCode {
  LOGIN: 1,
  LOGOUT: 2,    
}

//protoMap.js文件
protoMap = {
    1: {
        req: pb.LoginRes,
        rsp: pb.LoginRsp,
    }
    ...
}

有了protoMap工厂函数就简单了

//工厂函数
let protoMap = require('protoMap');
//请求工厂函数
newReq(action) {
  let obj = protoMap[action];
  let req = new obj.req();
  req.action = action;
  return req;
}

//响应工厂函数
newRsp(action, data) {
  let obj = protoMap[action];
  return obj.rsp.decode(data);
}

四、protoMap如何而来?

我们的问题是不是都解决呢?如果你觉得都解决了,那是高兴的太早了。
目前protoMap.js文件是需要人手工去编写的,同样的问题又来了。

痛点分析

1 一个项目与服务器的请求少则几十个,多则上百上千,手工方式维护protoMap的难度大。
2.手工编写这个protoMap.js文件在协议新增、修改、删除时容易出错。
3.出了错问题还很不好找,只有在调用到的地方才能暴露问题。

解决办法

编写代码来生成代码

因为protoMap.js是根据proto的定义动态变化的,我采取的办法是通过一个程序去分析proto文件生成protoMap代码。不过这里为了让protoMap生成器不要太复杂,我在proto定义ActionCode时做了点小手脚

//proto中定义Action
enum ActionCode {
  LOGIN: 1,  //LoginReq;LoginRsp;
  LOGOUT: 2, //LogoutReq;LogoutRsp;   
}

在定义ActionCode时,我们为每一个消息码加上注释,第一个是请求,第二个是响应。
如果在设计协议时,能有严格的规范可以将注释写的简单些。

enum ActionCode {
  LOGIN: 1,  //Login
  LOGOUT: 2, //Logout
}

通过在ActionCode中加点小手脚,再去解析这段文本,生成protoMap会简单很多了。在protoMap生成器中,可以去校验一下注释中写的请求、响应对象是否正确。

还有一种方案是在请求协议上添加注释:

//action:1
message LoginReq {
    ...
}

//action:2
message LogoutReq {
  ...
}

这种方案我也在项目中使用过,也可以方便提取生成protoMap。

五、最后的痛

关于protobuf在js中还剩下最后一个痛,那就是目前的IDE都不能支持proto对象属性的

自动补全,代码提示,文本着色

let req = pb.newReq(pb.ActionCode.LOGIN);
req.useName = 'zxh'; //这里应该是userName被写成useName
req.pwd = '123456';  //这里应该是password被写成pwd

痛点分析

1.js中没有代码提示容易笔误,而且问题大多数在运行到代码那一刻才会暴露出来。
2.没有自动补全需要多打很多字。
3.没有函数着色,敲出来的代码心里不踏实。

代码提示、自动补全可以提高开发效率、减少出错

解决办法

要解决这个问题我目前的办法是,将proto对象生成对应的js代码,如果还想做的更好,可以学习Creator那样,生成一个d.ts文件。

六、觉知你心中的痛

在开发中不能觉知到开发体验,估计也很难觉知到用户体验,因为自己就是自己项目的用户。不能觉知到痛,如何去解决痛?

当creator遇上protobufjs|深入

一、 不修改源码让protobufjs适应多平台

我们上一篇《在cocos creator中使用protobufjs(一)》讲解了通过修改源码的方案,让protobufjs能正常运行在jsb环境上。这个方案适合将protobufjs源码直接放到项目中,而我们使用npm来管理三方库的方式,这种方案就显得不太优雅。
##1. 解决IS_NODE的检查
之前源码中已经看到Util.IS_NODE是用来区分代码是运行在nodejs上还是浏览器上。我们可以模拟cocos-jsb为nodejs环境,我们看protobufjs是怎么来检查环境的。

Util.IS_NODE = !!(
    typeof process === 'object' && process+'' === '[object process]' && !process['browser']
);

上面这段代码我们注意两个地方:

  • !!:在一个变量或表达示前面使用“!!”的意思是将其值转换为boolean值即true或false,这是js中常用的技术,第一次见这种写法的人可会犯晕。
  • process:process对象是nodejs的内置进程对象,在cocos-jsb上肯定是没这货,那怎么办呢?
方案一:伪装者

伪装者---适配器模式
在require(‘protobufjs’)之前我们自己定义一个process对象

if(cc.sys.isNative) {
    global.process = { 
        toString: () => '[object process]'
    }
}
...
require('protobufjs');

这种方案相当于欺骗protobufjs我们是nodejs,这段代码也解释两句:

  • global: global对象是js中很特殊的对象,全局的方法、属性都集中在一个对象中。我们这里将process对象放到global上相当于定义了全局变量。
  • toString方法:js中所有对象上都具有toString方法(除null\undefined外),当你在对象上使用字符串连接“+”操作时,其实是调用的对象的toString方法。

这种方法可将coco-jsb化身为nodejs,但感觉有点文绉绉的,我们再看看更直接的方法。

方案二:霸王硬上弓

霸王硬上弓---直接修改内存
在require(‘protobufjs’)之后强制修改Util.IS_NODE的值

protobufjs.Util.IS_NODE = cc.sys.isNative;

这个方法简单直接,而且不怕他修改检查方案,我觉得这个方法更好。

2. 解决fs.readFile/fs.readFileSync

...
if (Util.IS_NODE) {
    //cocos中那来的fs模块呀?
    var fs = require("fs");
    if (callback) {
        fs.readFile(path, function(err, data) {
            if (err)
                callback(null);
            else
                callback(""+data);
        });
    } else
        try {
            return fs.readFileSync(path);
        } catch (e) {
            return null;
        }
}
...

这里不能硬来了,硬来只能改源码,使用伪装的方法,我们去编写一个fs模块

//fs.js
module.exports = {
    //同步读取文件
    readFileSync(path) {
        //cocos-jsb提供有相同功能的函数,就借用下它
        return jsb.fileUtils.getStringFromFile(path);
    }

    //异步读取文件
    readFile(path, cb) {
        //cocos-jsb没提供异步读取文件的函数,这里只能简单执行下回调传回读取内容
        let str = jsb.fileUtils.getStringFromFile(path);
        cb(null, str); 
    },
}

我们这里是偷梁换柱,实现了一个fs模块,这关算是过了。这里需要注意的是jsb.fileUtils对象,上面封装有不少原生上的文件操作。
jsb.fileUtils对象上的方法

大多数方法一看名字就知道用法了,这里就不再一一说明。

3. 解决require(“path”)问题

源码中有对path模块的使用:

...
filename = require("path")['resolve'](filename);
...
fname = require("path")['join'](root, filename.file);
...

乍眼一看感觉这种写法有点乱,其实它等同如下代码:

let path = require("path");
filename = path.resolve(filename);
filename = path.join(root, filename.file);

这样看就明白了,有个path模块,调用了他的resolve和join方法,path伪装再次登录场:

//path.js
module.exports = {
    //获取全路径
    resolve: (subPath) => {
        //使用cc.url.raw实现获取全路径
        return cc.url.raw(`resources/${subPath}`);
    },
    // 方法使用平台特定的分隔符把全部给定的path片段连接到一起
    join: () => {
        //使用cocos提供的cc.path.join实现
        return cc.path.join.apply(null, arguments); 
    }
}

问题终于被被解决了,估计好多人会觉得好麻烦!我的demo中已经实现了这些伪装者文件。 写这么多其实主要是想让大家了解的是javascript语言的灵活性,以及一种思路一种可能性。如果觉得还是不能接受,下面我再给大家介绍一种方案,预编译proto文件。

二、 使用预编译方案

在静态语言中使用protobuf都需要将proto文件编译成目标代码,protobufjs模块也为我们提供了pbjs命令行工具。
####1. pbjs工具介绍
pbjs命令帮助

上图是pbjs命令工具的帮助,看起来参数不少,但我们这里只需要很简单的使用,生成json格式或js格式。
####2. 将proto编译为json

pbjs xxx.proto > xxx.json

无需任何选项,直接输入文件名,将输出json格式的proto文件。
我们来看下如何使用:

let protobuf = require('protobufjs')
let builder = new protobuf.Builder();
protobuf.loadJsonFile('xxx.json', builder);
protobuf.loadJsonFile('yyy.json', builder);
let PB = builder.build('xxx.yyy.zzz');

其实使用json格式与使用proto格式没什么大的差别。读过源码的话知道,protobufjs库加载proto文件的顺序大致如下:

  1. 加载proto文件
  2. 将获取的proto字符串,解析为json对象
  3. build操作将json对象转换为proto对象

使用预编译json加载相当于省略了第二步,直接加载json文件转换proto对象。
当proto文件比较多的时候,使用json加载可以提高一些效率。

3. 将proto编译为js

pbjs -t commonjs xxx.proto > xxx.js

使用pbjs提供的-t参数将proto文件编译为目标格式,这里我们指定的commonjs,后面紧跟proto文件名。

//-----------------------------proto文件内容-----------------------------------
syntax = "proto3";
package grace.proto.msg;

message Player {
    uint32  id = 1;         //唯一ID  首次登录时设置为0,由服务器分配
    string  name = 2;       //显示名字
    uint64  enterTime = 3;  //登录时间
}
//-----------------------------编译后的js文件内容-------------------------------
module.exports = require("protobufjs").newBuilder({})['import']({
    "package": "grace.proto.msg",
    "syntax": "proto2",
    "messages": [
        {
            "name": "Player",
            "syntax": "proto3",
            "fields": [
                {
                    "rule": "optional",
                    "type": "uint32",
                    "name": "id",
                    "id": 1
                },
                {
                    "rule": "optional",
                    "type": "string",
                    "name": "name",
                    "id": 2
                },
                {
                    "rule": "optional",
                    "type": "uint64",
                    "name": "enterTime",
                    "id": 3
                }
            ]
        }
    ],
    "isNamespace": true
}).build();

大致一看编译后的js文件,其实与使用proto文件、json文件加载没什么本质的区别,简单分析下面代码:

module.exports = require("protobufjs").newBuilder({})['import']({
  //proto内容的json格式
  ...
}).build();

1.require(“protobufjs”)导入protobufjs模块,
2.newBuilder({}) 实例化一个builder对象
3.‘import’ 调用builder实例上的import方法导入一段json
4.build() 调用builder实例build方法,生成proto对象
5.module.exports 导出build()后的对象
使用预编译js的方式不需要加载文件,proto直接编写在js文件中,当proto文件较多时可以提高性能。

三、 protobuf爱你不容易


我在使用protobuf的过程也不是一帆风顺,只能说protobuf爱你不容易!
####1. 第一个项目
在最初的项目中,使用的是直接加载proto文件,当时也没想过使用预编译的方式。项目中有接近上百个proto文件,proto文件由服务端程序定义的,粒度非常小,几个message就是一个proto文件。开发期间觉得没什么问题,后来发布时,发现加载比较慢,性能差点的手机会特别明显,因此还为加载proto文件的整个过程做了一个进度条。
####2. 卡牌项目
之后的一个卡牌项目中,我们吸取了之前的经验,与服务端程序讨论定义proto文件时将同类数据结构尽量定在一个文件中,不要太过分散,任然使用直接加载proto文件的方式。在这项目中虽然protobuf的数据结构更多,更复杂,但文件数量较少加载过程中没有太大影响。
####3. SLG项目
后来在一个SLG项目里我们任然使用直接加载proto文件,但SLG项目的复杂度比之前的卡牌上升了好几个数量级,protobuf文件个数、数据结构的规模都翻了几倍,加载proto的加载过程在低配置手机上显的非常慢,又只好为proto的加载过程制作进度条。

4. 小结

至此开始我才开始意识到直接加载大量proto文件的缺陷,在细读protobufjs库的文档之后开始使用在项目中尝试使用预编译的方式。

预编译js方式解决了文件加载,但增加代码编译时间,在creator中可以将编译的proto文件设置为插件,不参与编译,但文件多了也是很麻烦。

预编译json方式不会增加编译时间,减少了proto到json的转换时间,但文件io操作任然是最大的瓶颈。

四、 觉知开发中的痛点

在protobuf的使用上,除了proto加载方案的选择外,还存在不少其它问题。

有项目使用json做协议,无需解码,客户端处理服务器响应逻辑时比较方便。但protobuf必须做解码后才能读取数据结构,proto对象的new、decode代码充斥着客户端项目。

在javascript项目使用protobuf还有一个痛点就是IDE无法很好支持proto对象的代码补全,需要在代码与proto原文件中来回切换,不时出现单词拼写错误等问题。

下一次我们将继续探索在项目中如何相对高效使用protobuf。

当creator遇上protobufjs|起步

一. 环境准备

我一直在探索cocos H5正确的开发姿势,目前做javascript项目已经离不开 nodejs、npm或grunt等脚手架工具了。
##1.初始化package.json文件

npm init

当新建好cocos-js或creator项目,在项目根目录使用npm init命令,一路回车,将在当前目录创建package.json文件用于nodejs三方模块的管理。关于npm的使用细节网络上有很多教程,在此不用细说。

2. protobufjs模块


本人最早在cocos2dx 2.x时代就开始用protobufjs模块来操纵protobuf一直到现在。所以下面所有内容都是关于protobufjs在cocos creator中的使用,包括原生平台(cocos2d-js也是大同小异)。

安装protobufjs到项目

npm install protobufjs@5 –save

使用npm install命令安装模块,注意我们这里使用的是protobufjs 5.x版本。 虽然protobufjs目前最新的 6.x版本,提供了ts、rpc等功能的支持,但接口变化太大,目前还不太会使用。

安装protobufjs到全局

npm install -g protobufjs@5

使用npm install -g 参数将模块安装到全局,目的主要是方便使用protobufjs提供的pbjs命令行工具。pbjs可以将proto原文件转换成json、js等,以提供不同的加载proto的方式,我们可以根据自己的实际情况选择使用。

二. protobufjs用法

下面是demo中定义的Player.proto文件的内容

syntax = "proto3";
package grace.proto.msg;

message Player {
    uint32  id = 1;         //唯一ID  首次登录时设置为0,由服务器分配
    string  name = 2;       //显示名字
    uint64  enterTime = 3;  //登录时间
}

关于proto具体语法细节这里就不多说了,我们重点如何将Player.proto文件中定义的Player对象在js中实例化、属性赋值、序列化、反序列化操作。

1. 静态语言中使用proto文件

在c++/java这类静态语言中使用protobuf通常是使用官方提供的protoc命令将proto文件编译成c++/java代码,像下面这样:

protoc –cpp_out=输出路径 xxx.proto
protoc –java_out=输出路径 xxx.proto

将输出路径的文件导入对应语言的工程中使用。

2. 在creator项目中使用proto文件

javascript是动态语言,可以在运行时产生对象,因此protobufjs提供了更为便捷的动态编译,将proto文件中的对象生成js对象,下面简要讲解一下在creator中具体的使用步骤:

1.加载proto文件并编译生成proto对象

//导入protobufjs模块
let protobuf = require("protobufjs");
//获取一个builder对象
let builder = protobuf.newBuilder();
//使用protobufjs加文件,并与一个builder对象关联
protobuf.protoFromFile('xxx.proto', builder);
protobuf.protoFromFile('yyy.proto', builder);
...
let PB = builder.build('grace.proto.msg'); 

这步操作主要是使用protobufjs加载、编译proto文件。

2.实例化proto对象与属性赋值

let PB = builder.build('grace.proto.msg')

build函数返回值PB对象中将包含的是在proto中定义所有message对象,现在已经成为js对象,可以被实例化,代码如下:

//实例化Player
let player = new PB.Player();  
//属性赋值
player.name = '张三';             
player.enterTime = Date.now();

3.proto对象的序列化与反序列化

不说废话,还是直接上代码

...
//使用实例对象上的toArrayBuffer函数将对象序列化为二进制数据
let data = player.toArrayBuffer();
//使用类型对象上的decode函数将二进制数据反序列化为实例对象
let otherPlayer = PB.player.decode(data);

如果幸运你可以在web上使用protobuf了, 为什么只是在web上呢,当你把上面的代码运行在jsb环境下的时候,你会体验到悲催的事情正在发生。

三. 拯救cocos-jsb上的protobufjs

为什么在原生上运行就挂掉了呢?要理解这个问题需要对nodejs\ 浏览器\cocos-jsb这三个javascript的运行宿主环境有一定的了解。

我之前的文章提到过在选择nodejs模块时,要注意是否同时支持nodejs和web,只要是纯js的模块在cocos中一般都可以随便用,比如async、undersocre、lodash等。
protobufjs这个模块是可以很好的在浏览器和nodejs环境上运行的。但运行在cocos-jsb上就会出问题,首先我们要定位到出问题的关键代码:

protobuf.protoFromFile('xxx.proto', builder);

1. 问题分析

从protobuf.protoFromFile函数名上看就知道是要进行文件的加载,一想到文件加载,就涉及到文件操作的api,我们来整理一下不同平台上的文件接口:

宿主平台 文件接口 说明
浏览器 XMLHttpRequest 浏览器中动态加载资源、文件等AJAX操作的基础
nodejs fs.readFile / fs.readFileSync nodejs上的文件操作模块,底层由c/c++实现
cocos-jsb jsb.fileUtils.getStringFromFile cocos-js提供的读取文件内容接口,在不台平台(ios\android\windows)由不同底层api实现

看到这里相信很多人已经明白为什么在cocos-jsb上会有问题了,我们再来读一下protobufjs源码,证实下我们的分析。

2. 分析protobufjs源码


找到protobufjs加载文件的主要代码,下面我为源码加上了注释,请认真读一下注释内容:

Util.fetch = function(path, callback) {
    //检查callback参数,callback参数决定是否为异步加载
    if (callback && typeof callback != 'function')
        callback = null;

    //运行环境是否为nodejs
    if (Util.IS_NODE) {
        //加载nodejs的文件系统模块
        var fs = require("fs");  
        //检查是否有callback,存在使用fs.readFile异步函数读取文件内容
        if (callback) {
            fs.readFile(path, function(err, data) {
                if (err)
                    callback(null);
                else
                    callback(""+data);
            });
        } else
            //使用fs.readFileSync同步函数读取文件内容 
            try {
                return fs.readFileSync(path);
            } catch (e) {
                return null;
            }
    } else {
        //当不为nodejs运行环境使用XmlHttpRequest加载文件
        var xhr = Util.XHR();
        //根据callbcak参数是否存在,使用异步还是同步方式
        xhr.open('GET', path, callback ? true : false);
        // xhr.setRequestHeader('User-Agent', 'XMLHTTP/1.0');
        xhr.setRequestHeader('Accept', 'text/plain');
        if (typeof xhr.overrideMimeType === 'function') xhr.overrideMimeType('text/plain');
        //通过XmlHttpRequest.onreadystatechange事件函数异步获取文件数据
        if (callback) {
            xhr.onreadystatechange = function() {
                if (xhr.readyState != 4) return;
                if (/* remote */ xhr.status == 200 || /* local */ (xhr.status == 0 && typeof xhr.responseText === 'string'))
                    callback(xhr.responseText);
                else
                    callback(null);
            };
            if (xhr.readyState == 4)
                return;
            //调用send方法发起AJAX请求
            xhr.send(null);
        } else {
            ////调用send方法发起AJAX请求,同步获取文件数据
            xhr.send(null);
            if (/* remote */ xhr.status == 200 || /* local */ (xhr.status == 0 && typeof xhr.responseText === 'string'))
                return xhr.responseText;
            return null;
        }
    }
};

从上面的代码可以看出protobufjs库是为浏览器和nodejs准备的,根本就没考虑过cocos-jsb的存在(吐槽:建议cocos官方提供的接口能模仿nodejs这样能少很多事),所以要在cocos-jsb中使用protobufjs其中的一个办法就是修改protobufjs的源码,如下:

Util.fetch = function(path, callback) {
    if (callback && typeof callback != 'function')
        callback = null;
    //将平台检查代码改为cocos提供的接口
    if (cc.sys.isNative) {
        //文件读取使用cocos-jsb提供的函数
        try {
            let data = jsb.fileUtils.getStringFromFile(path);
            cc.log(`proto文件内容: {data}`);
            return data;
        } catch (e) {
            return null;
        }
    } else {
        //web端无需修改,略
        ...
};

我们用cocos的接口将代码修改一下,加载问题就被化解了,问题真的被解决了吗?
不好意思,除了上面要代码外还有一处代码需要修改,源码如下:

BuilderPrototype["import"] = function(json, filename) {
    var delim = '/';

    // Make sure to skip duplicate imports

    if (typeof filename === 'string') {
        //这里又出现了平台检查
        if (ProtoBuf.Util.IS_NODE)
            // require("path")是加载nodejs的path模块,resolve
            filename = require("path")['resolve'](filename);
        if (this.files[filename] === true)
            return this.reset();
        this.files[filename] = true;

    } else if (typeof filename === 'object') { // Object with root, file.

        var root = filename.root;
        //这里还要修改
        if (ProtoBuf.Util.IS_NODE)
            root = require("path")['resolve'](root);
        if (root.indexOf("\\") >= 0 || filename.file.indexOf("\\") >= 0)
            delim = '\\';
        var fname;
         //这里还要修改
        if (ProtoBuf.Util.IS_NODE)
            fname = require("path")['join'](root, filename.file);
        else
            fname = root + delim + filename.file;
        if (this.files[fname] === true)
            return this.reset();
        this.files[fname] = true;
    }
    ...
}

这里我就不再贴修改代码了,大家自行解决。

四 为protobuf继续填坑

本来写到这里,问题大多已经解决了, 但此时,如果你满怀信心地使用改造后的protobufjs源码,将你的代码运行起来那一刻,我相信绝大多数人会一脸蒙逼。

妈的根本就不行!!看了好多字,好不容易读到这里,不仅在模拟器上跑不起来,在web上同样也跑不起来。

怎么办,为了彻底解决问题,我还得继续写下去。
####1. 了解creator动态加载资源的方法
请大家思考一个问题,creator项目中的一张图片,在web与cocos-jsb上他们的文件路径会一样吗?直接使用protobuf.protoFromFile(‘xxx.proto’)去加载一个proto文件会成功吗?
cocos文档中说过要动态加载一个图片资源需要将文件存放在assets/resources目录下,使用如下方法加载:

cc.loader.loadRes('resources/xxx')

尝试将proto文件存放在resources/pb/目录下,用使用以下代码:

protobuf.protoFromFile('resources/pb/xxx.proto')

同样会得到失败的提示,该如何办呢?怎么才能获得正确的资源路径?
算了,不买关子了,写累了直接出答案吧!

protobuf.protoFromFile(cc.url.raw('resources/pb/xxx.proto'));

cc.url.raw这个函数在浏览器、模拟器、手机上会返回不同的资源路径,这才是真正的资源路径,这下代码应该可以正常运行起来了。

####2. 更好的解决法办
我一直在探索cocos H5正确的开发方式,虽然通过修改protobufjs源码的方法可以来解决在cocos-jsb上运行的问题,但这并不是唯一的解决方案。

我这里编写了一个creator + protobufjs的demo没有使用上述方案,地址如下:

https://github.com/ShawnZhang2015/grace

如何在不修改protobufjs源码的情况下让代码运行起来,以及使用pbjs工具预编译proto文件为JSON和js文件的用法,请继续观注我的系列文章《探索cocosH5正确的开发姿势》!

uikiller支持Cocos Creator 2.0

1. 前言

早在8月24日,公众号收到网友「Vincent」的留言,提醒我uikiller库在Cocos Creator 2.0上有错!惊出一身冷汗,怎么忘记这个事了。晚上回到家,迅速启动Cocos Creator 2.0打开uikiller工程,确实是有问题,还好问题不严重,立马做了修改并推送到github仓库增加了v2分支,在此感谢「Vincent」提醒!

虽然uikiller的使用者并不多,但是他们是将uikiller应用到了自己的项目,一旦有问题,我也是有责任的!在这里肯请大家,如果发现我的问题,希望能即时向我指出。

2. uikiller的进化

uikiller在Cocos Creator 2.0上的问题,主要是Cocos Creator有部分API发生了变化和调整,目前Shawn还没有使用Cocos Creator 2.0做项目,有些后知后觉!我将在uikiller中发现的API调整做一个简单分享:

使用rect.contains替换cc.rectContainsPoint

image.png

cc.pXXX系列函数被废弃,使用cc.Vec2成员函数

image.png
cc.pAdd需改为p.add,cc.pMult改为p.mul

image.png

两个点的距离计算cc.pDistance改为p1.sub(p2).mag()。

cc.audioEngine.play不建议使用url参数

image.png

cc.audioEngine.play(url)使用下面方法代替

cc.loader.loadRes(url, cc.AudioClip, (error, audioClip) => {
    cc.audioEngine.play(audioClip);
})
更多参见Cocos Creator2.0升级指南

Shawn这里讲的都非常简单,更多引擎升级问题请参考官方文档:Cocos Creator v2.0 升级文档

3. uikiller简单控制器支持

将界面逻辑与业务逻辑都编写在组件脚本中是否合适?如何将MVC模型应用于Cocos Creator游戏开发之中?Shawn经过对PureMVC的项目实践经验,结合Cocos Creator组件化开发,总结出了一套更加简化的MVC模型,不过目前只提供了控制器的简单应用。

uikiller连接控件器.png

如果你也感兴趣,可以参考uikiller源码,测试场景test3、test10可以具体使用方法和效果。

一个预制体,两个脚本(组件脚本与控件器脚本),这让我联想到《斗罗大陆》唐三的双生武魂,左手蓝银草主控制,右手昊天锤主攻击,请看下图:

image

不吹那么多了,多年心得尽在uikiller,源码地址奉上:https://github.com/ShawnZhang2015/uikiller

注意:控制器功能在v2分支。

4. 小结

在此欢迎大家来「奎特尔星球」投稿,我们一起学习共同成长,将自己的经验用文字记录下来,整理成行之有效的方法论,使之能被迭代、复制。


欢迎关注「奎特尔星球」微信公众号,来我们一起成长!