作者简介:唐文城,来自抖音互动技术团队,21 年毕业后持续探索互动技术,参与过若干个抖音活动业务,国庆项目互动玩法与动效核心开发者,喜欢做“可以看见”的事情。
前言
经过若干个月的点滴积累,我有幸参与到抖音国庆活动的开发,这是我第一次完整参与大型活动项目的开发,它是全员关注的一个重点项目,致力于让用户领略美好中国,指导用户在抖音中搜索与获取旅行攻略和出游信息。
在本项目中使用的技术栈为Lynx
Cocos
的组合。Lynx
是字节跳动自研的一个跨端框架,首屏直出的方案使其具有较短的首屏时间,能够带来可观的业务收益。我负责其中的互动玩法侧部分,使用Cocos
进行开发,Lynx
提供一个 canvas 作为Cocos
的容器,Lynx
的 UI 线程与 JS 线程是隔离的,其与Cocos
运行在同一个 JS 线程上。
考虑到许多同学可能没有接触过Cocos
,本文在前半部分首先对Cocos
的基本概念进行介绍,使大家有一个初步印象,接着简要过一遍官方的小游戏 Demo 代码,了解一个简单的小游戏是如何跑起来的,后半部分则是主题:国庆项目中的一些经验之谈。
Cocos 简介
Cocos 产品包括 Cocos Creator、2d-x 等一系列产品,本文所讲的 Cocos 指 Cocos Creator。
Cocos Creator 是一个完整的游戏开发解决方案,它包括编辑器与游戏引擎。由于图形化的编辑器、优良的系统设计以及完善的文档,Cocos 上手非常容易,对新人十分友好。
Cocos 基本概念
节点与组件
Cocos 以组件式开发为核心,这种架构方式即实体组件系统(ECS)。
ECS 是一种流行的结构思想,遵循组合优于继承的原则。我对它的理解是:通过节点与组件的组合来构建实体,达到目的,这与继承的方式有所区别。
通过继承的方式来造一辆 Bus:
首先我们拥有一个基类:Vehicle
,它有轮子、发动机、长宽高、载客量等属性,并有一个开门方法。
然后我们定义一个Vechicle
的子类Bus
,明确有 6 个轮子,能乘坐 30 人,并重写开门方法(需要司机通过按钮控制门的开关而不是乘客用手拉门),这样便有了一个 Bus 类。
问题来了,如果除了Bus
之外还要实现其他类型的车,甚至是火车、动车呢?它们的开门方法都是司机来控制,因此具有一致的开门方法,为了能够复用这个方法,可能需要再定义一个Vehicle
的子类,它实现了司机控制开门的方法,接着Bus
、火车、动车再去继承这个类。
ECS 的思想则是组合优于继承,根据它的思想,要造一辆Bus
,首先我们在世界中添加一个空的实体,给它取名为Bus
,这样我们便知道现在这个看不见摸不着的实体未来将会是一辆Bus
。
接着给它添加上Vehicle
组件,这个组件是上帝给我们的,它将给这个实体提供运动、载人的基本能力,我们在这个Vehicle
组件中设定轮子个数为 6,载人量 30。
现在要解决开门的问题了,区别于继承的方式,我们要通过组件组合的方式去解决未来造不同类型汽车开关门方法不同的问题。
ECS 的方式是准备一系列门组件,有电控门、推拉门、滑轨门等等,对于现在要造的Bus
,装上电控门组件即可,如果未来造别的车需要不同形式的门,只需要装上不同类型的门组件。
那么这种思想在 Cocos 中是如何体现的?在 Cocos 中,节点(Node)是承载组件的实体,我们通过将具有各种功能的 组件(Component)挂载到节点上,来让节点具有各式各样的表现和功能。
接下来以一个标签节点为例,它具有显示文字的能力。
首先创建一个空节点(当然也可直接创建一个标签节点,殊途同归),我们就拥有了一个还不具备任何能力的实体。我们使用标签节点是因为我们需要显示文字的能力,因此我们为该节点添加 Label 组件,它提供了显示文字所需要的能力,包括字体大小、种类、加粗、斜体等。
为了让该标签在任何不同尺寸比例的屏幕上显示时都固定在屏幕底部,我们需要类似 css 中 position 的能力,Widget 组件提供了对应的能力。点击添加组件,选择 UI 组件中的 Widget 组件,勾选 Bottom,此时该标签节点便拥有了自动对齐的能力。
如果这个标签还需要添加淡入淡出的效果呢?可以添加一个 Animation 组件,它提供了使用动画编辑器来制作动画的能力。
如何在代码中控制这个标签的文本内容?
首先新建一个 ts 文件,在 Cocos 中,ts/js 文件属于用户脚本组件,并编写以下代码,其功能是每秒刷新显示的时间。
代码语言:javascript复制const { ccclass, property } = cc._decorator;
@ccclass
export default class LabelDemo extends cc.Component {
start() {
// 获取标签节点的标签组件
const labelComponent = this.node.getComponent(cc.Label);
// 设定一个定时器,每秒修改显示的内容
this.schedule(() => {
labelComponent.string = `当前时间:${Date()}`;
}, 1);
}
}
由于代码也是组件,我们可以将它添加到节点上去,这样该节点就拥有了显示时间的能力。
坐标系统
Cocos 中使用是笛卡尔坐标系,与 WebGl 相同。
在 Cocos 中有一个很基础的概念:锚点。锚点的位置代表整个节点的位置,锚点不仅影响自身以及子节点的定位,还会影响缩放和旋转。在 Web 开发中一般没有锚点的概念,用一个不太准确的例子类比一下,在 css 中设置定位为 fixed,设定 left、top 的大小时,这个元素的锚点就是自身左上角。在 Cocos 中锚点可以处于节点自身约束框中的任意位置。实际开发中,为了计算或定位的方便应该将锚点放置在一个合适的位置,例如人物的脚底。
Web 开发中常用屏幕坐标系,与 Cocos 的笛卡尔右手系不同。有时一些需求要求物体移动到屏幕上的某个点,而给到的坐标是屏幕坐标系的,例如国庆项目中金币飞起至进度条红包中,而进度条是 lynx 元素。此时就需要进行坐标换算,好在换算比较简单,只需在纸上列出一个方程组即可得到换算公式。
层级顺序与生命周期
在节点树中,子节点永远显示在父节点之上,对于同级的节点,后面的节点会显示在前面的节点之上。可以通过修改节点的 zIndex 属性来控制其层级,但这仅限于同级节点之间。
Cocos 为组件脚本提供了以下生命周期。
- onLoad
- start
- update
- lateUpdate
- onDestroy
- onEnable
- onDisable
其中最常用的是onLoad
start
update
这三个生命周期。
节点更新以深度优先遍历的顺序进行,因此不同节点的生命周期回调执行顺序总是父节点早于子节点,前面的兄弟节点早于后面。
onLoad
回调在节点首次激活时触发,该阶段保证了可以获取到场景中的其他节点以及关联的资源数据 ,因此如果要为该节点挂载预制节点,应该在该阶段进行,但需要注意的是,如果需要获取在遍历顺序之后的某些节点,而这些节点又是预制节点,将有可能无法获取到这些节点导致发生错误。
start
回调在组件首次激活时触发,start
总是晚于onload
。一般在本阶段对数据进行初始化。
update
回调在组件每帧渲染前执行,可以理解为由requestAnimationFrame
驱动。游戏开发的一个关键点是在每一帧渲染前更新物体的行为、位置等,通常都放在该回调中。例如当玩家按下前进按钮时,应在每帧的回调中更新玩家的位置。回调函数参数是一个 number 类型的 dt,为上一帧与本帧之间的时间间隔,距离 = 时间 * 速度,这样即可让玩家在任何帧率下都保持恒定的速度前进,即使帧率有较大波动。
吃⭐️小游戏 Demo 解析
接下来我将简要讲解一下这个 Demo 是如何跑起来的,目的是通过这个简单的