前言
本次春节活动,使用到了字节内的主要前端、跨端、互动技术产品。主要涉及:
- 跨端框架 提供了首屏直出的方案使其具有较短的首屏时间,能够大大提升业务加载成功率。跨端框架也提供了 Canvas 作为
SAR Creator
等渲染引擎的运行环境。 - SAR Creator 是抖音前端架构自研的一款基于 TypeScript 的高性能互动解决方案。SAR Creator 提供面向设计和研发同学的工作流,内置常见 2D / 3D 渲染能力、动效、粒子、物理等效果支持。
活动中,主要支持了 5 个互动玩法:“招财神龙”、“神龙探宝”、“摇福签”、“保卫现金”和“红包雨”,如下所示。
我们会通过系列文章,介绍春节玩法用到的互动技术。文章所说的互动技术指以图形 API(如:WebGL)为基础,结合前端工程化、图形渲染、引擎技术、交互能力和跨平台能力,面向前端技术栈的动效和游戏化技术,如下图所示。
在活动开发中,前端 UI 如:滑动列表、页面布局,可以用成熟的前端框架(React)。需要图形绘制的地方,如:渲染 3D 模型,就要用到互动解决方案(SAR Creator)。互动所用到的图形绘制部分往往是页面中的一个区域,我们会把互动部分封装成一个 SDK,通过使用 SDK API 和前端进行通信。
本文作为系列开篇,主要从“招财神龙”玩法视角,分享团队前端互动玩法的相关开发经验。
活动玩法介绍
下面是招财神龙玩法示意,用户可以点击“去寻宝”按钮(称此时的场景为「家场景」),让神龙去寻宝(称此时的场景为「寻宝场景」),寻宝过程中神龙会遇到福袋和龙蛋。福袋自动掉落到宝箱中,而龙蛋需要用户点击。寻宝过程中,红色的主按钮上有倒计时,倒计时结束后寻宝结束,用户可以打开宝箱领取奖励。寻宝过程中,场景中会有一些可点击的发光建筑,用户点击它们,发光效果消失,可能触发任务。
在「家场景」,用户可以点击小女孩,与之产生轻互动,如下图所示。
包含四个主题的「寻宝场景」,每次寻宝会随机一个主题,如下,从左到右分别是山川、雪乡、丹霞和江南。
招财神龙互动玩法实现
实现招财神龙的互动玩法,需要多个工种配合。首先产品要提出玩法需求,描述整个场景的构成要素和玩法逻辑。然后设计同学根据产品的描述,产出设计草图,逐步细化,最终通过 DCC 软件(如:C4D)生成 3D 模型、视频、2D 贴图或动画等美术资源。程序需要根据产品需求和设计产出实现互动玩法的代码逻辑。整个开发过程需要三方通力合作,尤其需要程序和设计同学的有效沟通,以确保设计方案可以用程序顺利实施。为了保障产品质量,还需要测试人员验收产品。整个开发过程大致如下图所示。开发过程是持续迭代的,比如:产品可能在开发中期提出新需求,就需要设计、程序和测试做出响应。
这里以程序的视角描述招财神龙互动玩法的实现。如上文所述,招财神龙互动部分由「家场景」和「寻宝场景」构成,两个场景通过一个转场动画过渡。每个场景使用了不同的美术资源和互动技术。程序不直接消费 DCC 软件生成的美术资源,而是消费 SAR Creator 产出的资源包(即 bundle)。
- bundle: 设计在 SAR Creator 编辑器中导入 DCC 软件的产物(如:3D 模型),通过二进制序列化生成的运行时消费用的资源包。
- prefab: 一个 bundle 可以包含多个 prefab(预制体),一个 prefab 可以包含 3D 模型、2D 贴图、动画甚至脚本代码等元素。
SAR Creator 为 bundle 及 prefab 提供了序列化、反序列化和管理等功能。
接下来让我们先了解一下招财神龙页面元素的构成。
招财神龙页面元素构成
招财神龙活动在抖音App及多端(抖极、头条、西瓜、番茄等)的任务页上线,为了让大家对整个招财神龙前端页面有个清晰的认识,这里我们以任务页为例,为大家讲解一下页面构成。
如上图所示:
- 任务页(图左):字节系 App(例如西瓜视频),大多会有一个长期在线的激励页面,如上面左图所示,用户可以通过完成任务获得现金、或者积分等虚拟货币奖励。
- 互动区域(图右):如上面右图所示,互动区域即为场景区域,是页面主 KV (Key Visual) 中的核心区域,用 Canvas 承载,使用 SAR Creator 来渲染互动内容。
任务页在非活动期间,以日常的形态展示(各 App 独立迭代),而在活动阶段,则以统一的活动内容展示。这是怎么做到的呢?
如上图所示,我们把任务页抽象为收益区 + 主 KV + 任务专区。在有活动的时候,我们只需要替换主 KV 对应的内容就可以了。在实际开发中,活动的主 KV 则抽象为活动 SDK。在满足活动条件时,服务端下发活动内容字段,任务页动态渲染活动组件,完成活动内容的展示;在活动结束后,服务端移除活动内容字段,页面切换回日常形态。
在任务页上开发互动内容,存在较大的性能挑战。任务页前端 UI 繁多,业务逻辑复杂,而互动的资源加载往往又是 CPU 密集型任务,所以往往在首次渲染页面时,造成页面和互动区域的 JS 线程繁忙,进而形成卡顿和渲染时间过长。同时由于任务页已经存在大量的前端 UI 和动画,留给互动部分可用的内存安全余量往往仅有 200-300 MB,稍有不慎就有可能导致 OOM。在任务页上既要完成视觉表现精美,又要保证性能良好,是一件非常有挑战的事情。
招财神龙前端与互动的交互
我们将前端的同学分为两部分,一部分负责处理活动的主逻辑,例如和服务端交互、处理业务元素(例如进度条、明信片等挂件、任务列表等),这一部分的工作角色,我们通常称之为“前端同学”,另一部分同学主要用来处理游戏相关的逻辑,聚焦在互动上,我们称之为“互动同学”。 他们相互协作,共同实现了招财神龙的活动玩法。二者的协作方式如下图所示。
游戏初始化阶段,游戏加载完 SAR Creator 运行框架后,向前端同学“索要”本次初始化的服务端数据,用来判断该用户进入游戏后,应该展示的是「家场景」还是「寻宝场景」。用户完成相关任务后,主接口刷新。前端同学以事件通信的形式通知互动同学渲染当前场景并播放相关动效。互动同学也会监听主接口数据,更新互动模块专有的逻辑或效果。
「家场景」的实现
「家场景」是引导用户“唤醒神龙”、“去寻宝”以及“领取福袋”的核心场景,如下图所示。本章节会将介绍「家场景」的搭建过程,并分享「家场景」开发过程中有趣的实现。
整个「家场景」是由 3D 和 2D 元素混合构建的。3D 部分包括小女孩、龙、地面和雪堆。2D 元素主要有炮仗、房子以及神龙回家后小女孩头上的提示气泡,是用图片实现的。还有一些 2D 动画元素,比如房子后面一直循环播放的红包动画、龙沉睡时嘴角的“zzz”呼吸效果。
场景搭建
设计同学使用 SAR Creator 编辑器搭建「家场景」,包括 3D 模型/2D 精灵的摆放、灯光和相机参数的设置等。SAR Creator 编辑器提供了图形化界面,可以方便地调整场景元素的层级关系、位置、朝向、缩放比例以及材质参数等。「家场景」的 3D 模型使用透视相机渲染,而 2D 精灵等使用正交相机渲染。最终,SAR Creator 渲染出的场景画面还原了设计稿的效果。
SAR Creator 场景中所有元素,包括相机、灯光等,都以 entity(实体)的形式存在,entity 之间存在父子关系,形成一棵节点树,如下图左上角“层级”标签页下的内容。父节点 entity 的 Transform3D 组件的位置、旋转和缩放属性,会影响子节点的相同属性。Enity 上可以挂载自定义脚本,影响 enity 的行为逻辑。SAR Creator 提供了大量操纵 entity 的引擎能力。
动画播放
为了呈现出精彩的效果、给用户带来尽可能好的视觉体验,我们设计了14个模型动画,并通过出色的逻辑串联,保证了动画播放流程的简洁高效。
export enum HomeAnimName {
HomeSleep = 'home_sleep', // 沉睡
HomeAwake = 'home_awake', // 苏醒
HomeIdle = 'home_idle', // 待机
HomeClick = 'home_click', // 点击效果1
HomeClickA = 'home_click_a', // 点击效果2
HomeClickB = 'home_click_b', // 点击效果3
HomeHappy = 'home_happy', // 完成任务,开心状态
HomeGoHome = 'home_gohome', // 龙回家
HomeHoldBox = 'home_hold_box', // 宝箱状态
HomeOpenBox = 'home_open_box', // 龙推宝箱
HomeCloseBox = 'home_close_box', // 关闭宝箱
HomeCloseBoxIdle = 'home_close_box_idle', // 关闭宝箱后的待机态
HomeOpenBoxIdle = 'home_open_box_idle', // 开完宝箱后的待机态
HomeGoOut = 'home_goout' // 龙去寻宝
}
我们使用了 SAR Creator 提供的动画播放能力:Animator 组件。获取到 3D 模型的 animator 组件,并调用它的crossFade
函数,在第二个参数duration
指定的时间内,从当前动画状态过渡到另一个动画状态,即下面代码中的第一个参数anim
。调用animator.on('finished',cbFunc)
可以自定义动画结束后的回调函数。
this._dragonAnimator.crossFade(anim as string, duration);
this._charAnimator.crossFade(anim as string, duration);
this._dragonAnimator.on('finished', () => this.onAnimEnd(anim, params));
设置动画的loopCount
属性,可以指定该动画播放的次数。设置clampWhenFinished
可以指定播放完该动画后,是否停留在最后一帧。
const setClip = () => {
const loopCount = loop ? -1 : 1;
const _dragonClip = this._animator.clips.find((i) => i.clip?.name === anim);
if (_dragonClip) {
_dragonClip.loopCount = loopCount;
const action = this._dragonAnimator.getAction(anim);
if (action) {
action.clampWhenFinished = !loop;
}
}
}
基于上述的这些底层的Api,我们实现了一套AnimationGraph来帮助研发和设计同学更好地开发提效。
对于设计同学使用来说,例如想实现一个龙睡觉状态到龙待机状态,我们可以将HomeAwake
HomeIdle
动画拖入到graph中,并创建动画链路。
HomeAwake
动画播完以后,会在HomeIdle
动画进行loop播放。选中链路,可以对链路进行配置和预览。
对于研发同学,可以基于graph进行逻辑条件的配置。
如上图所示,例如进入游戏后,用户可能是在“龙沉睡”或者“龙待机”的状态,我们通过在Graph的变量区建立代码运行的逻辑条件(支持Number和Boolean两种类型),可以自定义一个case
变量,当case = 1,播放“龙沉睡”、当case = 2,播放“龙待机”。
在代码中,我们可以通过使用AnimationController.setValue(variableName,value)
来触发动画执行。
const animationController = this.entity.getScript(AnimationController);
if(showAwake) {
// 需要播沉睡
animationController.setValue("case", 1)
}else if(showIdle){
//需要播放idle
animationController.setValue("case", 2)
}
再比如,在某一个时间,用户点击了“去寻宝”按钮,这时候通过设置animationController.setValue("showGoOut",true)
即可触发龙去寻宝的动画。
我们还为动画播放提供了钩子函数,在动画播放的特定时间,触发自定义的逻辑回调。
| 在进入状态时触发 |
| 在完全退出状态时触发 |
| 在状态更新时触发 |
// 获取动画控制器组件
const animationController = this.entity.getScript(AnimationController);
animationController.on('onStateEnter',(controller:AnimationController,state:AnimationState)=>{
//在此处实现业务逻辑
});
坐标同步
在实现一些特殊效果时,为了保障效果的高度还原,我们使用了坐标同步。例如小女孩头上的提示气泡和龙嘴角的“zzz”呼吸特效,接下来以气泡为例介绍一下这一部分的实现。
若用常规的模式在 3D 场景中摆放一个 2D 的片,会导致小女孩动的时候,渲染出来的气泡会穿帮或者 z-fighting。
3D-2D 坐标同步的做法是将 Bubble 节点放在 UICanvas(SAR Creator 处理 2D 元素的节点)中,每一帧将小女孩模型里的骨骼变换节点在 3D 空间中的位置转化成 UICanvas 坐标系的坐标,再实时设置 Bubble 的位置属性。
坐标同步代码如下👇
const TEMP_VEC3 = new Vector3();
export const threeD2UICanvas = (entity: Entity, camera: PerspectiveCamera) => {
entity?.object?.getWorldPosition(TEMP_VEC3);
const vec3 = camera?.project(TEMP_VEC3) || new Vector3();
// 375 * 500 为画布大小
const x = vec3.x * 375;
const y = vec3.y * 500;
return { x, y };
};
每一帧设置 UICanvas 画布中气泡节点的位置,最终实现小女孩在 3D 场景中动来动去,头上的气泡也会跟着一起移动。
class BubbleScript extends Script {
// ECS 脚本每一帧的回调
onUpdate() {
if(NEED_SYNC_POS){
const bubbleRootIn3D = CharGlb.getChildByName('girl_Root_for_bubble')
const bubbleEntityIn2D = UICanvas.getChildByName('Bubble')
// 3D场景下的相机
const cameraIn3D = MainScene.getChildByName('MainCamera')
// sync pos
const pos = threeD2UICanvas(bubbleRootIn3D, cameraIn3D)
bubbleEntityIn2D.position?.set(pos.x, pos.y)
}
}
}
「寻宝场景」的实现
「寻宝场景」是一个纯 2D 互动场景,是招财神龙玩法的重要环节。为了实现有趣、自然的互动效果,「寻宝场景」要处理许多复杂逻辑。为了让互动和前端在动效上衔接流畅,互动和前端会在必要时通信。
简化版的“寻宝”逻辑如下图所示,包括地形等美术资源的加载、相机处理、探测点检测、福袋和龙蛋触发以及地形回收等逻辑。每次寻宝开始前,服务端提前下发“寻宝数据”,包括本次寻宝开始和结束的时间戳以及 timeline 信息,timeline 是一个“道具”触发列表,列表中每个元素包含一个道具 id、触发时间戳、道具类型和道具状态等信息。
「寻宝场景」的 timeline 数据结构伪代码如下面所示。其中"prop_type"是道具类型,可能是福袋或龙蛋。福袋不需要用户点击交互,寻宝结束后总是发放给用户。在视觉效果上神龙会撞上福袋。但龙蛋需要用户手动点击,若不点击就会错失对应奖励。"timestamp"是道具触发的时间戳。
/** 一次寻宝的信息 */
export interface TreasureHuntData {
/** 当前状态 */
treasure_hunt_status: TreasureHuntStatus;
/** 时间轴开始时间 */
start: Int64;
/** 时间轴结束时间 */
end: Int64;
/** 时间轴信息 */
timeline: Array<PropTriggerInfo>;
current_time: Int64;
// ...
}
export interface PropTriggerInfo {
/** 道具的id */
prop_id: string;
/** 在时间轴上的时间戳 */
timestamp: Int64;
/** 类型 */
prop_type: PropType;
/** 道具领取状态,寻宝结束时才有 */
propStatus?: PropStatus;
}
相机逻辑
「寻宝场景」使用一个正交相机渲染。「寻宝场景」的地形大部分时间保持不动,相机不停地往前移动。相机的逻辑比较简单,只是 x 轴不停地增加,其伪代码如下所示。
// deltaTime是上一帧到当前帧的时间间隔,_moveSpeed是相机移动速度
this._camEntity.position.x += deltaTime * this._moveSpeed;
相机移动速度可在 SAR Creator 中配置,如下图中红框中的 Speed
所示。
SAR Creator 提供了装饰器工具@ScriptUtil,用于把一个脚本及其字段暴露给编辑器。相机配置脚本 TravelCameraConfig.ts 挂在上图中 TravelCamera 节点下,其伪代码如下:
import { Script, ScriptUtil } from '@sar-creator/toolkit/engine/core';
// TravelCameraConfig是一个脚本,继承SAR Creator的Script类。
// 脚本类名前使用装饰器@ScriptUtil.Register(),可以在编辑器中挂到节点上
@ScriptUtil.Register('TravelCameraConfig')
export default class TravelCameraConfig extends Script {
// 在脚本字段名前使用装饰器@ScriptUtil.Field(),可以在编辑器中编辑该字段
@ScriptUtil.Field('float', { default: 0, params: { precision: 4 } })
speed = 0;
// ...
}
构建无限地形
每次寻宝的时间长度由服务端动态下发,最长为 20 分钟。每个主题的「寻宝场景」都有一个地形块队列,每个地形块以 prefab 的形式提供。线上,每个主题的地形队列由两个地形块构成,这里我们记作 map_x_a.prefab 和 map_x_b.prefab,其中 x 是主题的索引。每个主题的地形块 prefab 由设计同学在 SAR Creator 中制作完成,并以资源包的形式提供给研发同学,极大地减少了二者的工作耦合度,提升了开发效率。
「寻宝场景」一屏的设计分辨为 750x1000。每个地形块的宽度为 3750。这样两个地形块的宽度就是 10 屏,能提供足够多的细节差异、降低场景元素的重复感。下图是一个地形块 prefab 在 SAR Creator 中的样子,可以看出它在 3750x1000 的矩形外,还会多出一些视觉元素(如左右边界上的云),这些多出的视觉元素能够和另一个地形块上的视觉元素有机地融合。
为了让大家更容易理解,我们把山川主题的两个地形 prefab 都拖到 SAR Creator 中,如下图所示,它们总是可以无缝拼接的。
在实际项目中,因为主题是随机指定的,所以这两个地形 prefab 是用代码动态加载的,而非直接拖到场景中。为了让用户更早地看到「寻宝场景」的视觉内容,我们同步加载第一个地形块 prefab , 异步加载第二个地形块 prefab。其伪代码如下所示。
async _loadTerrains(travelScene2D: Object2D): Promise<void> {
const terrainNames = TerrainNamesByTheme[this._theme];
// 加载当前主题第一块地形prefab
const firstTerrainName = terrainNames[0];
// 注_loadTerrain是异步的,返回promise。我们会在本函数返回前await此promise。
this._firstTerrainPromise = this._loadTerrain(firstTerrainName!);
// 异步加载其它地形prefab,其实对于线上的情况就只有第二块地形了。
const terrainPromises =
terrainNames.filter((_, idx) => idx !== 0).map((i) => this._loadTerrain(i));
void Promise.all(terrainPromises).then(async () => {
// 注意要保证第一块地形已经加载好了,_tryCreateFirstTerrainBlock函数内部做判断,
// 保证第一块地形块不被创建两次。
await this._tryCreateFirstTerrainBlock(travelScene2D);
let lastTerrainBlock = this._firstPrefabBlock;
const terrainPos = this._terrainOffset.clone();
for (const terrainPromise of terrainPromises) {
if (lastTerrainBlock !== undefined) {
const terrainEntity = await terrainPromise;
terrainPos.x += lastTerrainBlock.getBlockSize();
lastTerrainBlock = this._createTerrainBlock(travelScene2D, terrainEntity, false, terrainPos);
}
}
});
// 同步加载第一个地形block
await this._tryCreateFirstTerrainBlock(travelScene2D);
}
当队首的地形块完全离开屏幕后,把它移到队尾,成为“新”的地形块。为了处理地形块边缘多出的部分视觉元素,延迟一屏让当前队首地形块消失,提前一屏让队列中第二个地形块可见,让用户看不到任何缝隙,伪代码如下所示。
_recycleTerrain(cameraPos: Vector3): void {
const headRightX = this._terrainBlocks[0].getBlockRightPositionWorld();
const terrainScreenWidth = this._terrainBlocks[0].getTerrainScreenWidth();
const screenLeftEdge = cameraPos.x - this._halfScreenWidth!;
const screenRightEdge = cameraPos.x + this._halfScreenWidth!;
// 首队地图延迟一屏消失
if (headRightX + terrainScreenWidth < screenLeftEdge) {
// 队首地形右边界,离开屏幕左边缘
this._terrainBlocks[0]?.setVisible(false);
const headBlock = this._terrainBlocks.shift();
if (headBlock) {
const tailPos = this._terrainBlocks[this._terrainBlocks.length - 1].getPosition();
headBlock.setPositionX(tailPos.x + this._terrainPrefabLength);
this._terrainBlocks.push(headBlock);
// 重置队首地形上的探测点
headBlock.resetDetectors();
// 通知prefab离开屏幕等
}
} else if (
headRightX - terrainScreenWidth <= screenRightEdge &&
!this._terrainBlocks[1].getVisible()
) {
// 地图队列中第二个地图,提前一屏显示。队首地形右边界,离开屏幕右边缘
this._terrainBlocks[1].setVisible(true);
// 通知prefab进入屏幕等
}
}
地形上还有一些挂载点,程序根据当前机型的评分等,挂载不同类型的发光建筑。例如,高端机会挂载用 Spine 制作的发光建筑,而低端机挂载普通的精灵图。高端机能实现好的视觉 效 果,低端机减轻了 CPU 负担,保障程序运行流畅。下图红框中的节点就是发光建筑的挂载点。
探测点、神龙和福袋
「寻宝场景」中,神龙是最显眼的视觉元素,是用 Spine 制作的,但它并非一直在播放。神龙的行为和场景中一些被称为探测点的特殊节点有关。下图左边红框内有一个名为"commonDetector_common_2"的探测点,该探测点同层级还有一个福袋槽位"redpacket_2"节点,下图右边的福袋就是挂在"redpacket_2"节点上。下图右边的紫色方块是左边的探测点的可视化“符号”,只在开发阶段标识探测点位置,方便调试。一个地形块可能有 5 到 6 个探测点,大约一屏的宽度一个探测点。
探测点命名规则是程序和设计约定好的,第一个下划线“_”前面的部分是探测点类型,而后面部分神龙动画名称,如下图左边红、蓝框圈住的地方。探测点有多种类型,后面详述。
每个探测点上还挂有一个可配置脚本,如下图右下角红框圈住的区域,可以配置当前探测点触发时的神龙在 z 轴上的层级("Z value"),以及该探测点触发后对应的福袋槽位上挂的福袋多少秒后播放 “ 出现 ” 动画,即下图左下角的"Red Packet Appear Time"),多少秒后播放 “ 消失 ” 动画,即下图左下角的"Red Packet Hide Time"。
在水平方向上,当一个探测点位于屏幕中心时,该探测点被触发。不能以 x 轴上探测点到屏幕中心的距离接近 0 来判断一个探测点到达了屏幕中心,这样有很大误差,因为相机移动速度很快。甚至有可能错过探测点的触发时机,比如下一帧本来要触发的,结果当前帧由于某种原因卡顿了一下,deltaTime 突然变大很多,导致下一帧探测点直接越过屏幕中心,且距离远大于零。
在实际项目中,每个探测点都有一个标志位,这里记作 isTriggered,当探测点在屏幕中心右边时,isTriggered 记为 false,当某一帧探测点突然在屏幕中心左边时,说明探测点刚刚越过了屏幕中心,探测点触发,设置其值为 true。在回收队首地形块时,其上所有探测点的 isTriggered 标志位都要重置为 false,因为此时该地形块会被移到场景的最右边,也在屏幕中心右边。
当一个探测点被触发时,程序播放对应的神龙动画,神龙在场景中游动。同时,程序根据探测点上配置的时间设置定时器,经过"Red Packet Appear Time"秒后,播 放 福袋的“出现”动画,出现动画结束后自动播福袋的待机动画,经过"Red Packet Hide Time"秒后,播放福袋的“消失”动画。此神龙头部恰好撞到福袋。这些时间的值是设计同学根据神龙和福袋的动画时长在 SAR Creator 中配置的。程序只需读取配置,实现代码逻辑。时间线上,探测点触发与神龙和福袋动画的关系如下图所示。
设计同学在制作神龙的每个动画时,都是以对应主题的第一个地形块的左下角为参考点。程序运行时,需要在第一个地形块中,记录每个探测点和地形块左下角的偏移向量。在后面所有地形块中播放神龙动画前,都要重置神龙的位置。神龙的新位置是以探测点为基准,减去对应的偏移向量得到的。设计同学保证所有探测点都在第一个地形块中出现。
每个主题的「寻宝场景」有多个类型的探测点,如上图所示。普通探测点达到屏幕中心时,播放对应的神龙动画,并且若福袋槽位上挂有福袋,会在配置的时间后,播放对应的福袋动画。两个特殊探测点是在普通探测点基础上添加了限制或功能。
- 好友龙探测点:用户获得其他用户助力后去寻宝,好友龙探测点首先被触发,程序除了播放主龙动画外,还播放好友龙动画,下图左边半透明的就是好友龙。
- 接近好友龙探测点的探测点:以“nearFriendDetector_”开头的探测点是在位置上十分接近“好友龙探测点”的探测点,如下图右边红框中第二个探测点(紫色方块)。当有好友助力时,在第一个地形块首次出现时,程序不触发这类探测点,因为好友龙相关的动画只出现一次且不能被打断。其它情况,其行为和普通探测点一致。
寻宝过程中福袋其实有两种美术表达形式:
- Spine 动画:神龙“撞”到的福袋,即上面所述的,是互动侧实现的,每个福袋是一个 Spine 动画,如下图左边红框里圈住的部分。
- Lottie 动画 : 当神龙撞到 Spine 福袋后,Spine 福袋消失,同时屏幕中心出现一个大福袋,它是一个 Lottie 动画 ,并掉落到底部的宝箱中,如右图蓝框中圈中的福袋,这是前端同学实现的。
「寻宝场景」的龙蛋视觉效果,是前端同学实现的。互动侧代码根据 timeline 数据检测到一个龙蛋触发时,就向前端发送消息,前端代码弹出一个龙蛋的 Lottie 动画,如本文开头的视频所示。
2D 场景实现“3D”效果
「寻宝场景」是 2D 的,如何实现自然的“3D”效果呢?下图左边是丹霞主题,龙可以穿过石拱门,龙身一部分在拱门前,另一部分在拱门后;右边是山川主题,龙可以绕着山体一圈,龙尾在山后,而龙头在山前。这是设计同学通过在 SAR Creator 中设置 2D 元素的层级(z-轴)实现的。
以上图右边的“龙绕山”为例,山顶其实有一小片是单独的精灵图,有单独的层级 ,与山体的层级不一样。当龙走到这里时,程序把龙的层级设置为一个恰好处于山顶和山体层级之间的值,这样就达到视觉效果了。下图右边是把山顶往左偏移后的效果,方便观察。
「寻宝场景」有四个主题的地形,但神龙只有一个,独立于地形之外。在不同主题和探测点处,神龙的层级值是不同的,这一点是通过在探测点上添加 Z Value 配置实现的,在上一小节的截图中展示过。每当一个探测点触发时,程序先读取配置的 Z Value,并把它赋值给神龙的 Entity,然后再播放神龙动画,就实现“龙绕山”的“3D”效果了。相关流程如下图所示。
相关伪代码:
// 播放每一个动画时,主龙的transform3D.position.z的值都有一个对应的配置,以实现渲染层级。
newWorldPos.z = curDetector.ipZValue; // ipZValue是探测器上配置的Z Value。
const ipAnimEventInfo = {
isFriendDragon: curDetector.isFriendDector,
animName: curDetector.ipAnimName,
newIPPos: newWorldPos
};
// 播放神龙动画,playIPAnimation()内部重置神龙位置
playIPAnimation(GameEvent.TRAVEL_IP_ANIMATE, ipAnimEventInfo);
}
正是为了实现这种“3D 效果” ,我们才引入探测点的概念,互动代码需要感知「寻宝场景」的环境信息,以播放对应的神龙动画。引入福袋槽位的概念是为了方便实现神龙头部撞上福袋的视觉效果。福袋槽位的位置是由设计同学精心设计好的,可以保证龙头恰好在对应的时间经过那里。福袋槽位的位置是固定的,所以互动代码并不能完全遵循服务器下发的 timeline 中福袋的触发时间戳,而是尽量和它对齐,同时有一些自己的规则,比如:不能早于 timeline 中的时间触发福袋、优先把福袋放置在最近的可用福袋槽位上等。
场景管理策略
上文介绍了「家场景」和「寻宝场景」的实现。如何将这两个场景串起来,做好场景管理呢?
首先,我们需要确认游戏初始化完后,应该加载哪个场景。引擎能力准备完毕后,互动向前端获取本次用户进入游戏的活动数据,判断进入游戏后是“寻宝中”还是“在家”的状态,根据状态,加载对应场景的资源,然后展示给用户。
如何实现两个场景的丝滑切换?例如,用户此刻在「家场景」,点击“去寻宝”,如下图所示。
龙转场
我们会播一个龙的转场动画,转场完,给用户展示出另一个场景。
首先,设计同学导出一份 spine 动画资源, 有 start、loop、end 三个动画,分别为龙从屏幕左下角起飞、龙身占满整个屏幕循环播放、龙身离开直到龙尾离开屏幕。
在龙身 loop 动画的这一段时间内,进行另一个场景的加载和逻辑处理。
相关代码如下:
interface TransferLifeCycle {
onStart?: () => Promise<void>; // loop动画开始时机,此时用户完全看不到转场后面的内容
onEnd?: () => void; // loop动画结束时机,此时用户能看到转场后面的一些内容
onRemove?: () => void; // end动画结束时机。此时龙尾巴完全离开屏幕
onError?: (e: Error) => void; // 转场出错
}
// 转场逻辑
class Transfer {
_spine: Spine; // 转场的动画资源
_transfer!: TransferLifeCycle; // 存转场的钩子函数
_canEnd = false // 标记用户的start逻辑是否处理完毕
startTransfer = async (params: TransferLifeCycle) => {
this._transfer = { ...this._transfer, ...params }
try {
// 开始触发spine的start动画播放,交由spine的complete监听来处理每一个阶段的逻辑
this._canEnd = false
// 若未加载Spine,则加载spine资源,并播放其的'start'动画,略。
} catch(e){
this._transfer?.onError?.(e);
}
}
// Spine资源加载完毕后,此回调函数被自动调用
async onSpineAnimComplete(entry: any) {
const animateName = entry.animation.name;
// start动画播完 => 需要开始播loop动画,并处理onStart的逻辑
try {
if (animateName === 'start'){
// 播放Spine的'loop'动画, 略。
await this._transferParams.onStart?.()
this._canEnd = true // 标记用户处理完了onStart逻辑
} else if(animationName === 'loop'){
if(this._canEnd) {
// 处理完了onStart逻辑。播放end动画,略。
this._transfer?.onEnd?.()
}
} else if (animationName === 'end'){
this._transfer?.onRemove?.()
}
} catch (e){
this._transfer?.onError?.(e)
}
}
}
“家”和“寻宝”两个场景的管理怎么做呢?主要使用了“预加载”、“缓存”和“销毁”三种手段。
预加载
为了做到场景加载的更快,对场景进行预加载,提升用户的体验。
游戏初始化后,若加载的是“家”场景,则充分利用加载完“家”到用户“点击寻宝”之间的这段时间,对“寻宝”场景进行预加载。
const isHome = mainData.isHome // 是否是家场景
const preloadTravel = () => {
const { bundle } = assetManager.loadBundle('travel')
bundle.load('Travel.prefab')
}
const preloadHome = () => {
xxx
}
// 预加载另一个场景
const preload = () => {
if(isHome){
preloadTravel()
}else{
preloadHome()
}
}
利用 bundle.preload( prefab )可以将 prefab 依赖到的资源提前 fetch 到本地 。
缓存和销毁
除了预加载资源,我们还适当地使用了缓存,用空间换时间,提升切换场景的速度。
SAR Creator 提供了将子节点从父节点移除,但是不销毁其依赖的资源的能力。这是实现缓存逻辑的基础。
class SceneManager {
homeRoot?: Entity; // 家场景
travelRoot?: Entity; // 寻宝场景
// 加载
async loadHomeRoot() {
// 若有缓存,这步就不会走,直接addChild即可
if(!this.homeRoot){
this.homeRoot = await bundle.load('HomeRoot.prefab')
}
// 加载缓存的或者第一次初始化出来的家场景
if(this.homeRoot){
scene.addChild(this.homeRoot)
}
}
async loadTravelRoot() {
if(!this.travelRoot){
this.travelRoot = await bundle.load('TravelRoot.prefab')
}
}
// 缓存
dispose() {
// 缓存
if(USE_STORAGE){
// 将节点从场景中移除,但保留其依赖的资源
this.homeRoot?.parent?._deleteEntityFromChildren(this.homeRoot)
}else{
// 销毁
entity.dispose()
}
}
}
所有机型无差别地缓存,风险很大。为此,我们对低端机采取资源销毁的逻辑。
使用entity.dispose
方法实现销毁逻辑,它会递归该 entity 及所有子 entity 依赖的资源,释放其纹理、material、geometry 等。
对于使用缓存还是销毁,程序定义了如下数据结构:
export interface DowngradeIParams {
// 静态获取
enable: boolean,
blackList: [],
i32Forbidden: boolean, // 是否在32位包上禁用缓存能力
deviceScoreHigh: 10, // 超过此评分算高端
deviceScoreMid: 8, // 超过此评分算中端
deviceLevel: ['high', 'mid', 'low'], // 缓存能力启用的机型
// 动态获取
memoryLimit: Infinity // 剩余内存超过这个数才启用
}
上面数据结构提供了全局开启/关闭(enable)、机型黑名单、32 包禁用、机型打分、动态内存等多个度量标准来帮助我们做缓存/销毁的判断,配置的数据走 settings(字节内部客户端配置动态下发平台)下发。
基于这些技术,每次场景切换时,我们根据当前的机型信息和实时内存数据来判断采用哪种策略,例如,剩余内存不够多时,加载“寻宝”场景,并销毁“家”场景的所有资源,以此来保障游戏稳定性。