2024 抖音歡笑中國年之招財神龍互動技術揭秘
前言
本次春節活動,使用到了字節內的主要前端、跨端、互動技術產品。主要涉及:
- 跨端框架 提供了首屏直出的方案使其具有較短的首屏時間,能夠大大提升業務加載成功率。跨端框架也提供了 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(字節內部客戶端配置動態下發平臺)下發。
基于這些技術,每次場景切換時,我們根據當前的機型信息和實時內存數據來判斷采用哪種策略,例如,剩余內存不夠多時,加載“尋寶”場景,并銷毀“家”場景的所有資源,以此來保障游戲穩定性。