2024 抖音歡笑中國年之招財(cái)神龍互動技術(shù)揭秘
前言
本次春節(jié)活動,使用到了字節(jié)內(nèi)的主要前端、跨端、互動技術(shù)產(chǎn)品。主要涉及:
- 跨端框架 提供了首屏直出的方案使其具有較短的首屏?xí)r間,能夠大大提升業(yè)務(wù)加載成功率??缍丝蚣芤蔡峁┝?Canvas 作為
SAR Creator
等渲染引擎的運(yùn)行環(huán)境。 - SAR Creator 是抖音前端架構(gòu)自研的一款基于 TypeScript 的高性能互動解決方案。SAR Creator 提供面向設(shè)計(jì)和研發(fā)同學(xué)的工作流,內(nèi)置常見 2D / 3D 渲染能力、動效、粒子、物理等效果支持。
活動中,主要支持了 5 個(gè)互動玩法:“招財(cái)神龍”、“神龍?zhí)綄殹?、“搖福簽”、“保衛(wèi)現(xiàn)金”和“紅包雨”,如下所示。
我們會通過系列文章,介紹春節(jié)玩法用到的互動技術(shù)。文章所說的互動技術(shù)指以圖形 API(如:WebGL)為基礎(chǔ),結(jié)合前端工程化、圖形渲染、引擎技術(shù)、交互能力和跨平臺能力,面向前端技術(shù)棧的動效和游戲化技術(shù),如下圖所示。
在活動開發(fā)中,前端 UI 如:滑動列表、頁面布局,可以用成熟的前端框架(React)。需要圖形繪制的地方,如:渲染 3D 模型,就要用到互動解決方案(SAR Creator)?;铀玫降膱D形繪制部分往往是頁面中的一個(gè)區(qū)域,我們會把互動部分封裝成一個(gè) SDK,通過使用 SDK API 和前端進(jìn)行通信。
本文作為系列開篇,主要從“招財(cái)神龍”玩法視角,分享團(tuán)隊(duì)前端互動玩法的相關(guān)開發(fā)經(jīng)驗(yàn)。
活動玩法介紹
下面是招財(cái)神龍玩法示意,用戶可以點(diǎn)擊“去尋寶”按鈕(稱此時(shí)的場景為「家場景」),讓神龍去尋寶(稱此時(shí)的場景為「尋寶場景」),尋寶過程中神龍會遇到福袋和龍蛋。福袋自動掉落到寶箱中,而龍蛋需要用戶點(diǎn)擊。尋寶過程中,紅色的主按鈕上有倒計(jì)時(shí),倒計(jì)時(shí)結(jié)束后尋寶結(jié)束,用戶可以打開寶箱領(lǐng)取獎勵。尋寶過程中,場景中會有一些可點(diǎn)擊的發(fā)光建筑,用戶點(diǎn)擊它們,發(fā)光效果消失,可能觸發(fā)任務(wù)。
在「家場景」,用戶可以點(diǎn)擊小女孩,與之產(chǎn)生輕互動,如下圖所示。
包含四個(gè)主題的「尋寶場景」,每次尋寶會隨機(jī)一個(gè)主題,如下,從左到右分別是山川、雪鄉(xiāng)、丹霞和江南。
招財(cái)神龍互動玩法實(shí)現(xiàn)
實(shí)現(xiàn)招財(cái)神龍的互動玩法,需要多個(gè)工種配合。首先產(chǎn)品要提出玩法需求,描述整個(gè)場景的構(gòu)成要素和玩法邏輯。然后設(shè)計(jì)同學(xué)根據(jù)產(chǎn)品的描述,產(chǎn)出設(shè)計(jì)草圖,逐步細(xì)化,最終通過 DCC 軟件(如:C4D)生成 3D 模型、視頻、2D 貼圖或動畫等美術(shù)資源。程序需要根據(jù)產(chǎn)品需求和設(shè)計(jì)產(chǎn)出實(shí)現(xiàn)互動玩法的代碼邏輯。整個(gè)開發(fā)過程需要三方通力合作,尤其需要程序和設(shè)計(jì)同學(xué)的有效溝通,以確保設(shè)計(jì)方案可以用程序順利實(shí)施。為了保障產(chǎn)品質(zhì)量,還需要測試人員驗(yàn)收產(chǎn)品。整個(gè)開發(fā)過程大致如下圖所示。開發(fā)過程是持續(xù)迭代的,比如:產(chǎn)品可能在開發(fā)中期提出新需求,就需要設(shè)計(jì)、程序和測試做出響應(yīng)。
這里以程序的視角描述招財(cái)神龍互動玩法的實(shí)現(xiàn)。如上文所述,招財(cái)神龍互動部分由「家場景」和「尋寶場景」構(gòu)成,兩個(gè)場景通過一個(gè)轉(zhuǎn)場動畫過渡。每個(gè)場景使用了不同的美術(shù)資源和互動技術(shù)。程序不直接消費(fèi) DCC 軟件生成的美術(shù)資源,而是消費(fèi) SAR Creator 產(chǎn)出的資源包(即 bundle)。
- bundle: 設(shè)計(jì)在 SAR Creator 編輯器中導(dǎo)入 DCC 軟件的產(chǎn)物(如:3D 模型),通過二進(jìn)制序列化生成的運(yùn)行時(shí)消費(fèi)用的資源包。
- prefab: 一個(gè) bundle 可以包含多個(gè) prefab(預(yù)制體),一個(gè) prefab 可以包含 3D 模型、2D 貼圖、動畫甚至腳本代碼等元素。
SAR Creator 為 bundle 及 prefab 提供了序列化、反序列化和管理等功能。
接下來讓我們先了解一下招財(cái)神龍頁面元素的構(gòu)成。
招財(cái)神龍頁面元素構(gòu)成
招財(cái)神龍活動在抖音App及多端(抖極、頭條、西瓜、番茄等)的任務(wù)頁上線,為了讓大家對整個(gè)招財(cái)神龍前端頁面有個(gè)清晰的認(rèn)識,這里我們以任務(wù)頁為例,為大家講解一下頁面構(gòu)成。
如上圖所示:
- 任務(wù)頁(圖左):字節(jié)系 App(例如西瓜視頻),大多會有一個(gè)長期在線的激勵頁面,如上面左圖所示,用戶可以通過完成任務(wù)獲得現(xiàn)金、或者積分等虛擬貨幣獎勵。
- 互動區(qū)域(圖右):如上面右圖所示,互動區(qū)域即為場景區(qū)域,是頁面主 KV (Key Visual) 中的核心區(qū)域,用 Canvas 承載,使用 SAR Creator 來渲染互動內(nèi)容。
任務(wù)頁在非活動期間,以日常的形態(tài)展示(各 App 獨(dú)立迭代),而在活動階段,則以統(tǒng)一的活動內(nèi)容展示。這是怎么做到的呢?
如上圖所示,我們把任務(wù)頁抽象為收益區(qū) + 主 KV + 任務(wù)專區(qū)。在有活動的時(shí)候,我們只需要替換主 KV 對應(yīng)的內(nèi)容就可以了。在實(shí)際開發(fā)中,活動的主 KV 則抽象為活動 SDK。在滿足活動條件時(shí),服務(wù)端下發(fā)活動內(nèi)容字段,任務(wù)頁動態(tài)渲染活動組件,完成活動內(nèi)容的展示;在活動結(jié)束后,服務(wù)端移除活動內(nèi)容字段,頁面切換回日常形態(tài)。
在任務(wù)頁上開發(fā)互動內(nèi)容,存在較大的性能挑戰(zhàn)。任務(wù)頁前端 UI 繁多,業(yè)務(wù)邏輯復(fù)雜,而互動的資源加載往往又是 CPU 密集型任務(wù),所以往往在首次渲染頁面時(shí),造成頁面和互動區(qū)域的 JS 線程繁忙,進(jìn)而形成卡頓和渲染時(shí)間過長。同時(shí)由于任務(wù)頁已經(jīng)存在大量的前端 UI 和動畫,留給互動部分可用的內(nèi)存安全余量往往僅有 200-300 MB,稍有不慎就有可能導(dǎo)致 OOM。在任務(wù)頁上既要完成視覺表現(xiàn)精美,又要保證性能良好,是一件非常有挑戰(zhàn)的事情。
招財(cái)神龍前端與互動的交互
我們將前端的同學(xué)分為兩部分,一部分負(fù)責(zé)處理活動的主邏輯,例如和服務(wù)端交互、處理業(yè)務(wù)元素(例如進(jìn)度條、明信片等掛件、任務(wù)列表等),這一部分的工作角色,我們通常稱之為“前端同學(xué)”,另一部分同學(xué)主要用來處理游戲相關(guān)的邏輯,聚焦在互動上,我們稱之為“互動同學(xué)”。 他們相互協(xié)作,共同實(shí)現(xiàn)了招財(cái)神龍的活動玩法。二者的協(xié)作方式如下圖所示。
游戲初始化階段,游戲加載完 SAR Creator 運(yùn)行框架后,向前端同學(xué)“索要”本次初始化的服務(wù)端數(shù)據(jù),用來判斷該用戶進(jìn)入游戲后,應(yīng)該展示的是「家場景」還是「尋寶場景」。用戶完成相關(guān)任務(wù)后,主接口刷新。前端同學(xué)以事件通信的形式通知互動同學(xué)渲染當(dāng)前場景并播放相關(guān)動效?;油瑢W(xué)也會監(jiān)聽主接口數(shù)據(jù),更新互動模塊專有的邏輯或效果。
「家場景」的實(shí)現(xiàn)
「家場景」是引導(dǎo)用戶“喚醒神龍”、“去尋寶”以及“領(lǐng)取福袋”的核心場景,如下圖所示。本章節(jié)會將介紹「家場景」的搭建過程,并分享「家場景」開發(fā)過程中有趣的實(shí)現(xiàn)。
整個(gè)「家場景」是由 3D 和 2D 元素混合構(gòu)建的。3D 部分包括小女孩、龍、地面和雪堆。2D 元素主要有炮仗、房子以及神龍回家后小女孩頭上的提示氣泡,是用圖片實(shí)現(xiàn)的。還有一些 2D 動畫元素,比如房子后面一直循環(huán)播放的紅包動畫、龍沉睡時(shí)嘴角的“zzz”呼吸效果。
場景搭建
設(shè)計(jì)同學(xué)使用 SAR Creator 編輯器搭建「家場景」,包括 3D 模型/2D 精靈的擺放、燈光和相機(jī)參數(shù)的設(shè)置等。SAR Creator 編輯器提供了圖形化界面,可以方便地調(diào)整場景元素的層級關(guān)系、位置、朝向、縮放比例以及材質(zhì)參數(shù)等?!讣覉鼍啊沟?3D 模型使用透視相機(jī)渲染,而 2D 精靈等使用正交相機(jī)渲染。最終,SAR Creator 渲染出的場景畫面還原了設(shè)計(jì)稿的效果。
SAR Creator 場景中所有元素,包括相機(jī)、燈光等,都以 entity(實(shí)體)的形式存在,entity 之間存在父子關(guān)系,形成一棵節(jié)點(diǎn)樹,如下圖左上角“層級”標(biāo)簽頁下的內(nèi)容。父節(jié)點(diǎn) entity 的 Transform3D 組件的位置、旋轉(zhuǎn)和縮放屬性,會影響子節(jié)點(diǎn)的相同屬性。Enity 上可以掛載自定義腳本,影響 enity 的行為邏輯。SAR Creator 提供了大量操縱 entity 的引擎能力。
動畫播放
為了呈現(xiàn)出精彩的效果、給用戶帶來盡可能好的視覺體驗(yàn),我們設(shè)計(jì)了14個(gè)模型動畫,并通過出色的邏輯串聯(lián),保證了動畫播放流程的簡潔高效。
export enum HomeAnimName {
HomeSleep = 'home_sleep', // 沉睡
HomeAwake = 'home_awake', // 蘇醒
HomeIdle = 'home_idle', // 待機(jī)
HomeClick = 'home_click', // 點(diǎn)擊效果1
HomeClickA = 'home_click_a', // 點(diǎn)擊效果2
HomeClickB = 'home_click_b', // 點(diǎn)擊效果3
HomeHappy = 'home_happy', // 完成任務(wù),開心狀態(tài)
HomeGoHome = 'home_gohome', // 龍回家
HomeHoldBox = 'home_hold_box', // 寶箱狀態(tài)
HomeOpenBox = 'home_open_box', // 龍推寶箱
HomeCloseBox = 'home_close_box', // 關(guān)閉寶箱
HomeCloseBoxIdle = 'home_close_box_idle', // 關(guān)閉寶箱后的待機(jī)態(tài)
HomeOpenBoxIdle = 'home_open_box_idle', // 開完寶箱后的待機(jī)態(tài)
HomeGoOut = 'home_goout' // 龍去尋寶
}
我們使用了 SAR Creator 提供的動畫播放能力:Animator 組件。獲取到 3D 模型的 animator 組件,并調(diào)用它的crossFade
函數(shù),在第二個(gè)參數(shù)duration
指定的時(shí)間內(nèi),從當(dāng)前動畫狀態(tài)過渡到另一個(gè)動畫狀態(tài),即下面代碼中的第一個(gè)參數(shù)anim
。調(diào)用animator.on('finished',cbFunc)
可以自定義動畫結(jié)束后的回調(diào)函數(shù)。
this._dragonAnimator.crossFade(anim as string, duration);
this._charAnimator.crossFade(anim as string, duration);
this._dragonAnimator.on('finished', () => this.onAnimEnd(anim, params));
設(shè)置動畫的loopCount
屬性,可以指定該動畫播放的次數(shù)。設(shè)置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,我們實(shí)現(xiàn)了一套AnimationGraph來幫助研發(fā)和設(shè)計(jì)同學(xué)更好地開發(fā)提效。
對于設(shè)計(jì)同學(xué)使用來說,例如想實(shí)現(xiàn)一個(gè)龍睡覺狀態(tài)到龍待機(jī)狀態(tài),我們可以將HomeAwake
HomeIdle
動畫拖入到graph中,并創(chuàng)建動畫鏈路。
HomeAwake
動畫播完以后,會在HomeIdle
動畫進(jìn)行l(wèi)oop播放。選中鏈路,可以對鏈路進(jìn)行配置和預(yù)覽。
對于研發(fā)同學(xué),可以基于graph進(jìn)行邏輯條件的配置。
如上圖所示,例如進(jìn)入游戲后,用戶可能是在“龍沉睡”或者“龍待機(jī)”的狀態(tài),我們通過在Graph的變量區(qū)建立代碼運(yùn)行的邏輯條件(支持Number和Boolean兩種類型),可以自定義一個(gè)case
變量,當(dāng)case = 1,播放“龍沉睡”、當(dāng)case = 2,播放“龍待機(jī)”。
在代碼中,我們可以通過使用AnimationController.setValue(variableName,value)
來觸發(fā)動畫執(zhí)行。
const animationController = this.entity.getScript(AnimationController);
if(showAwake) {
// 需要播沉睡
animationController.setValue("case", 1)
}else if(showIdle){
//需要播放idle
animationController.setValue("case", 2)
}
再比如,在某一個(gè)時(shí)間,用戶點(diǎn)擊了“去尋寶”按鈕,這時(shí)候通過設(shè)置animationController.setValue("showGoOut",true)
即可觸發(fā)龍去尋寶的動畫。
我們還為動畫播放提供了鉤子函數(shù),在動畫播放的特定時(shí)間,觸發(fā)自定義的邏輯回調(diào)。
| 在進(jìn)入狀態(tài)時(shí)觸發(fā) |
| 在完全退出狀態(tài)時(shí)觸發(fā) |
| 在狀態(tài)更新時(shí)觸發(fā) |
// 獲取動畫控制器組件
const animationController = this.entity.getScript(AnimationController);
animationController.on('onStateEnter',(controller:AnimationController,state:AnimationState)=>{
//在此處實(shí)現(xiàn)業(yè)務(wù)邏輯
});
坐標(biāo)同步
在實(shí)現(xiàn)一些特殊效果時(shí),為了保障效果的高度還原,我們使用了坐標(biāo)同步。例如小女孩頭上的提示氣泡和龍嘴角的“zzz”呼吸特效,接下來以氣泡為例介紹一下這一部分的實(shí)現(xiàn)。
若用常規(guī)的模式在 3D 場景中擺放一個(gè) 2D 的片,會導(dǎo)致小女孩動的時(shí)候,渲染出來的氣泡會穿幫或者 z-fighting。
3D-2D 坐標(biāo)同步的做法是將 Bubble 節(jié)點(diǎn)放在 UICanvas(SAR Creator 處理 2D 元素的節(jié)點(diǎn))中,每一幀將小女孩模型里的骨骼變換節(jié)點(diǎn)在 3D 空間中的位置轉(zhuǎn)化成 UICanvas 坐標(biāo)系的坐標(biāo),再實(shí)時(shí)設(shè)置 Bubble 的位置屬性。
坐標(biāo)同步代碼如下??
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 };
};
每一幀設(shè)置 UICanvas 畫布中氣泡節(jié)點(diǎn)的位置,最終實(shí)現(xiàn)小女孩在 3D 場景中動來動去,頭上的氣泡也會跟著一起移動。
class BubbleScript extends Script {
// ECS 腳本每一幀的回調(diào)
onUpdate() {
if(NEED_SYNC_POS){
const bubbleRootIn3D = CharGlb.getChildByName('girl_Root_for_bubble')
const bubbleEntityIn2D = UICanvas.getChildByName('Bubble')
// 3D場景下的相機(jī)
const cameraIn3D = MainScene.getChildByName('MainCamera')
// sync pos
const pos = threeD2UICanvas(bubbleRootIn3D, cameraIn3D)
bubbleEntityIn2D.position?.set(pos.x, pos.y)
}
}
}
「尋寶場景」的實(shí)現(xiàn)
「尋寶場景」是一個(gè)純 2D 互動場景,是招財(cái)神龍玩法的重要環(huán)節(jié)。為了實(shí)現(xiàn)有趣、自然的互動效果,「尋寶場景」要處理許多復(fù)雜邏輯。為了讓互動和前端在動效上銜接流暢,互動和前端會在必要時(shí)通信。
簡化版的“尋寶”邏輯如下圖所示,包括地形等美術(shù)資源的加載、相機(jī)處理、探測點(diǎn)檢測、福袋和龍蛋觸發(fā)以及地形回收等邏輯。每次尋寶開始前,服務(wù)端提前下發(fā)“尋寶數(shù)據(jù)”,包括本次尋寶開始和結(jié)束的時(shí)間戳以及 timeline 信息,timeline 是一個(gè)“道具”觸發(fā)列表,列表中每個(gè)元素包含一個(gè)道具 id、觸發(fā)時(shí)間戳、道具類型和道具狀態(tài)等信息。
「尋寶場景」的 timeline 數(shù)據(jù)結(jié)構(gòu)偽代碼如下面所示。其中"prop_type"是道具類型,可能是福袋或龍蛋。福袋不需要用戶點(diǎn)擊交互,尋寶結(jié)束后總是發(fā)放給用戶。在視覺效果上神龍會撞上福袋。但龍蛋需要用戶手動點(diǎn)擊,若不點(diǎn)擊就會錯(cuò)失對應(yīng)獎勵。"timestamp"是道具觸發(fā)的時(shí)間戳。
/** 一次尋寶的信息 */
export interface TreasureHuntData {
/** 當(dāng)前狀態(tài) */
treasure_hunt_status: TreasureHuntStatus;
/** 時(shí)間軸開始時(shí)間 */
start: Int64;
/** 時(shí)間軸結(jié)束時(shí)間 */
end: Int64;
/** 時(shí)間軸信息 */
timeline: Array<PropTriggerInfo>;
current_time: Int64;
// ...
}
export interface PropTriggerInfo {
/** 道具的id */
prop_id: string;
/** 在時(shí)間軸上的時(shí)間戳 */
timestamp: Int64;
/** 類型 */
prop_type: PropType;
/** 道具領(lǐng)取狀態(tài),尋寶結(jié)束時(shí)才有 */
propStatus?: PropStatus;
}
相機(jī)邏輯
「尋寶場景」使用一個(gè)正交相機(jī)渲染。「尋寶場景」的地形大部分時(shí)間保持不動,相機(jī)不停地往前移動。相機(jī)的邏輯比較簡單,只是 x 軸不停地增加,其偽代碼如下所示。
// deltaTime是上一幀到當(dāng)前幀的時(shí)間間隔,_moveSpeed是相機(jī)移動速度
this._camEntity.position.x += deltaTime * this._moveSpeed;
相機(jī)移動速度可在 SAR Creator 中配置,如下圖中紅框中的 Speed
所示。
SAR Creator 提供了裝飾器工具@ScriptUtil,用于把一個(gè)腳本及其字段暴露給編輯器。相機(jī)配置腳本 TravelCameraConfig.ts 掛在上圖中 TravelCamera 節(jié)點(diǎn)下,其偽代碼如下:
import { Script, ScriptUtil } from '@sar-creator/toolkit/engine/core';
// TravelCameraConfig是一個(gè)腳本,繼承SAR Creator的Script類。
// 腳本類名前使用裝飾器@ScriptUtil.Register(),可以在編輯器中掛到節(jié)點(diǎn)上
@ScriptUtil.Register('TravelCameraConfig')
export default class TravelCameraConfig extends Script {
// 在腳本字段名前使用裝飾器@ScriptUtil.Field(),可以在編輯器中編輯該字段
@ScriptUtil.Field('float', { default: 0, params: { precision: 4 } })
speed = 0;
// ...
}
構(gòu)建無限地形
每次尋寶的時(shí)間長度由服務(wù)端動態(tài)下發(fā),最長為 20 分鐘。每個(gè)主題的「尋寶場景」都有一個(gè)地形塊隊(duì)列,每個(gè)地形塊以 prefab 的形式提供。線上,每個(gè)主題的地形隊(duì)列由兩個(gè)地形塊構(gòu)成,這里我們記作 map_x_a.prefab 和 map_x_b.prefab,其中 x 是主題的索引。每個(gè)主題的地形塊 prefab 由設(shè)計(jì)同學(xué)在 SAR Creator 中制作完成,并以資源包的形式提供給研發(fā)同學(xué),極大地減少了二者的工作耦合度,提升了開發(fā)效率。
「尋寶場景」一屏的設(shè)計(jì)分辨為 750x1000。每個(gè)地形塊的寬度為 3750。這樣兩個(gè)地形塊的寬度就是 10 屏,能提供足夠多的細(xì)節(jié)差異、降低場景元素的重復(fù)感。下圖是一個(gè)地形塊 prefab 在 SAR Creator 中的樣子,可以看出它在 3750x1000 的矩形外,還會多出一些視覺元素(如左右邊界上的云),這些多出的視覺元素能夠和另一個(gè)地形塊上的視覺元素有機(jī)地融合。
為了讓大家更容易理解,我們把山川主題的兩個(gè)地形 prefab 都拖到 SAR Creator 中,如下圖所示,它們總是可以無縫拼接的。
在實(shí)際項(xiàng)目中,因?yàn)橹黝}是隨機(jī)指定的,所以這兩個(gè)地形 prefab 是用代碼動態(tài)加載的,而非直接拖到場景中。為了讓用戶更早地看到「尋寶場景」的視覺內(nèi)容,我們同步加載第一個(gè)地形塊 prefab , 異步加載第二個(gè)地形塊 prefab。其偽代碼如下所示。
async _loadTerrains(travelScene2D: Object2D): Promise<void> {
const terrainNames = TerrainNamesByTheme[this._theme];
// 加載當(dāng)前主題第一塊地形prefab
const firstTerrainName = terrainNames[0];
// 注_loadTerrain是異步的,返回promise。我們會在本函數(shù)返回前await此promise。
this._firstTerrainPromise = this._loadTerrain(firstTerrainName!);
// 異步加載其它地形prefab,其實(shí)對于線上的情況就只有第二塊地形了。
const terrainPromises =
terrainNames.filter((_, idx) => idx !== 0).map((i) => this._loadTerrain(i));
void Promise.all(terrainPromises).then(async () => {
// 注意要保證第一塊地形已經(jīng)加載好了,_tryCreateFirstTerrainBlock函數(shù)內(nèi)部做判斷,
// 保證第一塊地形塊不被創(chuàng)建兩次。
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);
}
}
});
// 同步加載第一個(gè)地形block
await this._tryCreateFirstTerrainBlock(travelScene2D);
}
當(dāng)隊(duì)首的地形塊完全離開屏幕后,把它移到隊(duì)尾,成為“新”的地形塊。為了處理地形塊邊緣多出的部分視覺元素,延遲一屏讓當(dāng)前隊(duì)首地形塊消失,提前一屏讓隊(duì)列中第二個(gè)地形塊可見,讓用戶看不到任何縫隙,偽代碼如下所示。
_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!;
// 首隊(duì)地圖延遲一屏消失
if (headRightX + terrainScreenWidth < screenLeftEdge) {
// 隊(duì)首地形右邊界,離開屏幕左邊緣
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);
// 重置隊(duì)首地形上的探測點(diǎn)
headBlock.resetDetectors();
// 通知prefab離開屏幕等
}
} else if (
headRightX - terrainScreenWidth <= screenRightEdge &&
!this._terrainBlocks[1].getVisible()
) {
// 地圖隊(duì)列中第二個(gè)地圖,提前一屏顯示。隊(duì)首地形右邊界,離開屏幕右邊緣
this._terrainBlocks[1].setVisible(true);
// 通知prefab進(jìn)入屏幕等
}
}
地形上還有一些掛載點(diǎn),程序根據(jù)當(dāng)前機(jī)型的評分等,掛載不同類型的發(fā)光建筑。例如,高端機(jī)會掛載用 Spine 制作的發(fā)光建筑,而低端機(jī)掛載普通的精靈圖。高端機(jī)能實(shí)現(xiàn)好的視覺 效 果,低端機(jī)減輕了 CPU 負(fù)擔(dān),保障程序運(yùn)行流暢。下圖紅框中的節(jié)點(diǎn)就是發(fā)光建筑的掛載點(diǎn)。
探測點(diǎn)、神龍和福袋
「尋寶場景」中,神龍是最顯眼的視覺元素,是用 Spine 制作的,但它并非一直在播放。神龍的行為和場景中一些被稱為探測點(diǎn)的特殊節(jié)點(diǎn)有關(guān)。下圖左邊紅框內(nèi)有一個(gè)名為"commonDetector_common_2"的探測點(diǎn),該探測點(diǎn)同層級還有一個(gè)福袋槽位"redpacket_2"節(jié)點(diǎn),下圖右邊的福袋就是掛在"redpacket_2"節(jié)點(diǎn)上。下圖右邊的紫色方塊是左邊的探測點(diǎn)的可視化“符號”,只在開發(fā)階段標(biāo)識探測點(diǎn)位置,方便調(diào)試。一個(gè)地形塊可能有 5 到 6 個(gè)探測點(diǎn),大約一屏的寬度一個(gè)探測點(diǎn)。
探測點(diǎn)命名規(guī)則是程序和設(shè)計(jì)約定好的,第一個(gè)下劃線“_”前面的部分是探測點(diǎn)類型,而后面部分神龍動畫名稱,如下圖左邊紅、藍(lán)框圈住的地方。探測點(diǎn)有多種類型,后面詳述。
每個(gè)探測點(diǎn)上還掛有一個(gè)可配置腳本,如下圖右下角紅框圈住的區(qū)域,可以配置當(dāng)前探測點(diǎn)觸發(fā)時(shí)的神龍?jiān)?z 軸上的層級("Z value"),以及該探測點(diǎn)觸發(fā)后對應(yīng)的福袋槽位上掛的福袋多少秒后播放 “ 出現(xiàn) ” 動畫,即下圖左下角的"Red Packet Appear Time"),多少秒后播放 “ 消失 ” 動畫,即下圖左下角的"Red Packet Hide Time"。
在水平方向上,當(dāng)一個(gè)探測點(diǎn)位于屏幕中心時(shí),該探測點(diǎn)被觸發(fā)。不能以 x 軸上探測點(diǎn)到屏幕中心的距離接近 0 來判斷一個(gè)探測點(diǎn)到達(dá)了屏幕中心,這樣有很大誤差,因?yàn)橄鄼C(jī)移動速度很快。甚至有可能錯(cuò)過探測點(diǎn)的觸發(fā)時(shí)機(jī),比如下一幀本來要觸發(fā)的,結(jié)果當(dāng)前幀由于某種原因卡頓了一下,deltaTime 突然變大很多,導(dǎo)致下一幀探測點(diǎn)直接越過屏幕中心,且距離遠(yuǎn)大于零。
在實(shí)際項(xiàng)目中,每個(gè)探測點(diǎn)都有一個(gè)標(biāo)志位,這里記作 isTriggered,當(dāng)探測點(diǎn)在屏幕中心右邊時(shí),isTriggered 記為 false,當(dāng)某一幀探測點(diǎn)突然在屏幕中心左邊時(shí),說明探測點(diǎn)剛剛越過了屏幕中心,探測點(diǎn)觸發(fā),設(shè)置其值為 true。在回收隊(duì)首地形塊時(shí),其上所有探測點(diǎn)的 isTriggered 標(biāo)志位都要重置為 false,因?yàn)榇藭r(shí)該地形塊會被移到場景的最右邊,也在屏幕中心右邊。
當(dāng)一個(gè)探測點(diǎn)被觸發(fā)時(shí),程序播放對應(yīng)的神龍動畫,神龍?jiān)趫鼍爸杏蝿?。同時(shí),程序根據(jù)探測點(diǎn)上配置的時(shí)間設(shè)置定時(shí)器,經(jīng)過"Red Packet Appear Time"秒后,播 放 福袋的“出現(xiàn)”動畫,出現(xiàn)動畫結(jié)束后自動播福袋的待機(jī)動畫,經(jīng)過"Red Packet Hide Time"秒后,播放福袋的“消失”動畫。此神龍頭部恰好撞到福袋。這些時(shí)間的值是設(shè)計(jì)同學(xué)根據(jù)神龍和福袋的動畫時(shí)長在 SAR Creator 中配置的。程序只需讀取配置,實(shí)現(xiàn)代碼邏輯。時(shí)間線上,探測點(diǎn)觸發(fā)與神龍和福袋動畫的關(guān)系如下圖所示。
設(shè)計(jì)同學(xué)在制作神龍的每個(gè)動畫時(shí),都是以對應(yīng)主題的第一個(gè)地形塊的左下角為參考點(diǎn)。程序運(yùn)行時(shí),需要在第一個(gè)地形塊中,記錄每個(gè)探測點(diǎn)和地形塊左下角的偏移向量。在后面所有地形塊中播放神龍動畫前,都要重置神龍的位置。神龍的新位置是以探測點(diǎn)為基準(zhǔn),減去對應(yīng)的偏移向量得到的。設(shè)計(jì)同學(xué)保證所有探測點(diǎn)都在第一個(gè)地形塊中出現(xiàn)。
每個(gè)主題的「尋寶場景」有多個(gè)類型的探測點(diǎn),如上圖所示。普通探測點(diǎn)達(dá)到屏幕中心時(shí),播放對應(yīng)的神龍動畫,并且若福袋槽位上掛有福袋,會在配置的時(shí)間后,播放對應(yīng)的福袋動畫。兩個(gè)特殊探測點(diǎn)是在普通探測點(diǎn)基礎(chǔ)上添加了限制或功能。
- 好友龍?zhí)綔y點(diǎn):用戶獲得其他用戶助力后去尋寶,好友龍?zhí)綔y點(diǎn)首先被觸發(fā),程序除了播放主龍動畫外,還播放好友龍動畫,下圖左邊半透明的就是好友龍。
- 接近好友龍?zhí)綔y點(diǎn)的探測點(diǎn):以“nearFriendDetector_”開頭的探測點(diǎn)是在位置上十分接近“好友龍?zhí)綔y點(diǎn)”的探測點(diǎn),如下圖右邊紅框中第二個(gè)探測點(diǎn)(紫色方塊)。當(dāng)有好友助力時(shí),在第一個(gè)地形塊首次出現(xiàn)時(shí),程序不觸發(fā)這類探測點(diǎn),因?yàn)楹糜妖埾嚓P(guān)的動畫只出現(xiàn)一次且不能被打斷。其它情況,其行為和普通探測點(diǎn)一致。
尋寶過程中福袋其實(shí)有兩種美術(shù)表達(dá)形式:
- Spine 動畫:神龍“撞”到的福袋,即上面所述的,是互動側(cè)實(shí)現(xiàn)的,每個(gè)福袋是一個(gè) Spine 動畫,如下圖左邊紅框里圈住的部分。
- Lottie 動畫 : 當(dāng)神龍撞到 Spine 福袋后,Spine 福袋消失,同時(shí)屏幕中心出現(xiàn)一個(gè)大福袋,它是一個(gè) Lottie 動畫 ,并掉落到底部的寶箱中,如右圖藍(lán)框中圈中的福袋,這是前端同學(xué)實(shí)現(xiàn)的。
「尋寶場景」的龍蛋視覺效果,是前端同學(xué)實(shí)現(xiàn)的?;觽?cè)代碼根據(jù) timeline 數(shù)據(jù)檢測到一個(gè)龍蛋觸發(fā)時(shí),就向前端發(fā)送消息,前端代碼彈出一個(gè)龍蛋的 Lottie 動畫,如本文開頭的視頻所示。
2D 場景實(shí)現(xiàn)“3D”效果
「尋寶場景」是 2D 的,如何實(shí)現(xiàn)自然的“3D”效果呢?下圖左邊是丹霞主題,龍可以穿過石拱門,龍身一部分在拱門前,另一部分在拱門后;右邊是山川主題,龍可以繞著山體一圈,龍尾在山后,而龍頭在山前。這是設(shè)計(jì)同學(xué)通過在 SAR Creator 中設(shè)置 2D 元素的層級(z-軸)實(shí)現(xiàn)的。
以上圖右邊的“龍繞山”為例,山頂其實(shí)有一小片是單獨(dú)的精靈圖,有單獨(dú)的層級 ,與山體的層級不一樣。當(dāng)龍走到這里時(shí),程序把龍的層級設(shè)置為一個(gè)恰好處于山頂和山體層級之間的值,這樣就達(dá)到視覺效果了。下圖右邊是把山頂往左偏移后的效果,方便觀察。
「尋寶場景」有四個(gè)主題的地形,但神龍只有一個(gè),獨(dú)立于地形之外。在不同主題和探測點(diǎn)處,神龍的層級值是不同的,這一點(diǎn)是通過在探測點(diǎn)上添加 Z Value 配置實(shí)現(xiàn)的,在上一小節(jié)的截圖中展示過。每當(dāng)一個(gè)探測點(diǎn)觸發(fā)時(shí),程序先讀取配置的 Z Value,并把它賦值給神龍的 Entity,然后再播放神龍動畫,就實(shí)現(xiàn)“龍繞山”的“3D”效果了。相關(guān)流程如下圖所示。
相關(guān)偽代碼:
// 播放每一個(gè)動畫時(shí),主龍的transform3D.position.z的值都有一個(gè)對應(yīng)的配置,以實(shí)現(xiàn)渲染層級。
newWorldPos.z = curDetector.ipZValue; // ipZValue是探測器上配置的Z Value。
const ipAnimEventInfo = {
isFriendDragon: curDetector.isFriendDector,
animName: curDetector.ipAnimName,
newIPPos: newWorldPos
};
// 播放神龍動畫,playIPAnimation()內(nèi)部重置神龍位置
playIPAnimation(GameEvent.TRAVEL_IP_ANIMATE, ipAnimEventInfo);
}
正是為了實(shí)現(xiàn)這種“3D 效果” ,我們才引入探測點(diǎn)的概念,互動代碼需要感知「尋寶場景」的環(huán)境信息,以播放對應(yīng)的神龍動畫。引入福袋槽位的概念是為了方便實(shí)現(xiàn)神龍頭部撞上福袋的視覺效果。福袋槽位的位置是由設(shè)計(jì)同學(xué)精心設(shè)計(jì)好的,可以保證龍頭恰好在對應(yīng)的時(shí)間經(jīng)過那里。福袋槽位的位置是固定的,所以互動代碼并不能完全遵循服務(wù)器下發(fā)的 timeline 中福袋的觸發(fā)時(shí)間戳,而是盡量和它對齊,同時(shí)有一些自己的規(guī)則,比如:不能早于 timeline 中的時(shí)間觸發(fā)福袋、優(yōu)先把福袋放置在最近的可用福袋槽位上等。
場景管理策略
上文介紹了「家場景」和「尋寶場景」的實(shí)現(xiàn)。如何將這兩個(gè)場景串起來,做好場景管理呢?
首先,我們需要確認(rèn)游戲初始化完后,應(yīng)該加載哪個(gè)場景。引擎能力準(zhǔn)備完畢后,互動向前端獲取本次用戶進(jìn)入游戲的活動數(shù)據(jù),判斷進(jìn)入游戲后是“尋寶中”還是“在家”的狀態(tài),根據(jù)狀態(tài),加載對應(yīng)場景的資源,然后展示給用戶。
如何實(shí)現(xiàn)兩個(gè)場景的絲滑切換?例如,用戶此刻在「家場景」,點(diǎn)擊“去尋寶”,如下圖所示。
龍轉(zhuǎn)場
我們會播一個(gè)龍的轉(zhuǎn)場動畫,轉(zhuǎn)場完,給用戶展示出另一個(gè)場景。
首先,設(shè)計(jì)同學(xué)導(dǎo)出一份 spine 動畫資源, 有 start、loop、end 三個(gè)動畫,分別為龍從屏幕左下角起飛、龍身占滿整個(gè)屏幕循環(huán)播放、龍身離開直到龍尾離開屏幕。
在龍身 loop 動畫的這一段時(shí)間內(nèi),進(jìn)行另一個(gè)場景的加載和邏輯處理。
相關(guān)代碼如下:
interface TransferLifeCycle {
onStart?: () => Promise<void>; // loop動畫開始時(shí)機(jī),此時(shí)用戶完全看不到轉(zhuǎn)場后面的內(nèi)容
onEnd?: () => void; // loop動畫結(jié)束時(shí)機(jī),此時(shí)用戶能看到轉(zhuǎn)場后面的一些內(nèi)容
onRemove?: () => void; // end動畫結(jié)束時(shí)機(jī)。此時(shí)龍尾巴完全離開屏幕
onError?: (e: Error) => void; // 轉(zhuǎn)場出錯(cuò)
}
// 轉(zhuǎn)場邏輯
class Transfer {
_spine: Spine; // 轉(zhuǎn)場的動畫資源
_transfer!: TransferLifeCycle; // 存轉(zhuǎn)場的鉤子函數(shù)
_canEnd = false // 標(biāo)記用戶的start邏輯是否處理完畢
startTransfer = async (params: TransferLifeCycle) => {
this._transfer = { ...this._transfer, ...params }
try {
// 開始觸發(fā)spine的start動畫播放,交由spine的complete監(jiān)聽來處理每一個(gè)階段的邏輯
this._canEnd = false
// 若未加載Spine,則加載spine資源,并播放其的'start'動畫,略。
} catch(e){
this._transfer?.onError?.(e);
}
}
// Spine資源加載完畢后,此回調(diào)函數(shù)被自動調(diào)用
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 // 標(biāo)記用戶處理完了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)
}
}
}
“家”和“尋寶”兩個(gè)場景的管理怎么做呢?主要使用了“預(yù)加載”、“緩存”和“銷毀”三種手段。
預(yù)加載
為了做到場景加載的更快,對場景進(jìn)行預(yù)加載,提升用戶的體驗(yàn)。
游戲初始化后,若加載的是“家”場景,則充分利用加載完“家”到用戶“點(diǎn)擊尋寶”之間的這段時(shí)間,對“尋寶”場景進(jìn)行預(yù)加載。
const isHome = mainData.isHome // 是否是家場景
const preloadTravel = () => {
const { bundle } = assetManager.loadBundle('travel')
bundle.load('Travel.prefab')
}
const preloadHome = () => {
xxx
}
// 預(yù)加載另一個(gè)場景
const preload = () => {
if(isHome){
preloadTravel()
}else{
preloadHome()
}
}
利用 bundle.preload( prefab )可以將 prefab 依賴到的資源提前 fetch 到本地 。
緩存和銷毀
除了預(yù)加載資源,我們還適當(dāng)?shù)厥褂昧?strong>緩存,用空間換時(shí)間,提升切換場景的速度。
SAR Creator 提供了將子節(jié)點(diǎn)從父節(jié)點(diǎn)移除,但是不銷毀其依賴的資源的能力。這是實(shí)現(xiàn)緩存邏輯的基礎(chǔ)。
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){
// 將節(jié)點(diǎn)從場景中移除,但保留其依賴的資源
this.homeRoot?.parent?._deleteEntityFromChildren(this.homeRoot)
}else{
// 銷毀
entity.dispose()
}
}
}
所有機(jī)型無差別地緩存,風(fēng)險(xiǎn)很大。為此,我們對低端機(jī)采取資源銷毀的邏輯。
使用entity.dispose
方法實(shí)現(xiàn)銷毀邏輯,它會遞歸該 entity 及所有子 entity 依賴的資源,釋放其紋理、material、geometry 等。
對于使用緩存還是銷毀,程序定義了如下數(shù)據(jù)結(jié)構(gòu):
export interface DowngradeIParams {
// 靜態(tài)獲取
enable: boolean,
blackList: [],
i32Forbidden: boolean, // 是否在32位包上禁用緩存能力
deviceScoreHigh: 10, // 超過此評分算高端
deviceScoreMid: 8, // 超過此評分算中端
deviceLevel: ['high', 'mid', 'low'], // 緩存能力啟用的機(jī)型
// 動態(tài)獲取
memoryLimit: Infinity // 剩余內(nèi)存超過這個(gè)數(shù)才啟用
}
上面數(shù)據(jù)結(jié)構(gòu)提供了全局開啟/關(guān)閉(enable)、機(jī)型黑名單、32 包禁用、機(jī)型打分、動態(tài)內(nèi)存等多個(gè)度量標(biāo)準(zhǔn)來幫助我們做緩存/銷毀的判斷,配置的數(shù)據(jù)走 settings(字節(jié)內(nèi)部客戶端配置動態(tài)下發(fā)平臺)下發(fā)。
基于這些技術(shù),每次場景切換時(shí),我們根據(jù)當(dāng)前的機(jī)型信息和實(shí)時(shí)內(nèi)存數(shù)據(jù)來判斷采用哪種策略,例如,剩余內(nèi)存不夠多時(shí),加載“尋寶”場景,并銷毀“家”場景的所有資源,以此來保障游戲穩(wěn)定性。