本文来自于支付宝体验技术部晓白的投稿
玩法介绍
在 23 年的福气乐园中,在福气店两边还引入了小游戏供用户体验,本文将和大家分享其中欢乐套圈圈的开发全流程。支付宝首页输入玩一玩,进入即可体验欢乐套圈圈:
套圈采用了以局为单位的重复游戏模式,每局标的物根据规则随机,初始化n个圈并有m秒时间,当圈丢完或时间结束后,当局结束,一局内套中t个标的后,会刷新宝箱,套中宝箱后结算时会开启宝箱获得对应的五福奖励或实体奖励
设计与实现
名词解释
术语名称 | 术语描述 |
---|---|
圈 | 套圈圈游戏内投掷物,投出后可套中场上不同的标的 |
标的 | 游戏转盘上可以被圈套中的目标物品(如兔子、道具箱、宝箱等) |
奖品 | 套中宝箱开出的价值权益,如福气值、玩乐豆、红包、优惠券、实物奖品等 |
前后端交互
这次五福套圈游戏中,玩家手部会左右移动,而标的物和转台也会旋转,滑动扔圈的时机显得尤为重要,如果采用每次扔圈时都与服务端通信,交换数据的话,至少会有20-30ms的延时,而且在弱网情况下,扔圈的延迟将会极大地影响游戏的体验,为了确保玩家的游戏体验,最终采用了游戏局内单机,开始游戏前和结束游戏后与服务端交换数据的方式,在整个游戏过程中始终在前端计算数据,不需要与服务端通信。
于是在单机局内的基础上,防止作弊和验证兑换奖励非常重要。在开局时,服务端会根据时间生成本局的唯一ID,并向前端发送所有标的物的信息,这些标的物包含对应的唯一ID。
在开始游戏后,前端将初始化在标的台上的前8个标的物截取,剩余的标的物则缓存起来,并在局内补充标的物时使用。在游戏过程中,前端会累计投掷圈数,并在命中标的物时累计命中的标的物。当本局游戏结束后,前端会判断本局的合法性,并将本局的ID和标的物的数据发送到服务端进行合法性校验,以及进行开奖操作。
架构设计
游戏以react组件库的形式通过npm包引入项目,组件库默认暴露了Game组件以及相关的log上报方法,和event实例,业务侧在init时绑定事件,通过切换页面周期状态,切换页面内容,控制游戏组件创建和销毁。
import { Event, event, Game, Resources } from '@alipay/wufu-ringtoss-game';
// Event: event的类型
// event: 事件系统实例
// Game: 游戏组件
// Resources: 资源类型
<Game
musicSwitch
className="ringtoss"
config={config}
gameRoundConfig={gameRoundConfig}
onError={(error?:Error)=>{}}
resources={resources}
/>
业务侧需要提供局内的一些配置,以及错误回调和资源数据
import { ILoadItem } from '@alipay/venus-helpers';
// Assets: 资源key
export type Resources = {
[prop in Assets]?: ILoadItem;
};
// 游戏props 包含局内配置,游戏配置,开关等
export interface GameProps {
className: string;
onError?: (error?: Error) => void;
gameRoundConfig: {
initRingCount: number;
initRoundDuration: number;
};
musicSwitch: boolean;
resources: Resources;
config: {
ringPosition: [number, number];
valuePosition: [number, number];
};
}
加载和销毁策略
开发初期,套圈采用了和福气乐园、飞飞兔同样的加载策略,在进入页面时,加载资源,并初始化引擎,不需要每局结束后销毁engine,下一局开始时复用。
但五福活动下有多次跳转的场景,通常会由A页面->B页面->C页面->...,再这样多次跳转的过程中,内存压力会非常大,为了缓解多次跳转的内存压力,防止在不受控的第三方页面中发生意外情况,开发后期,改造了加载流程,最终效果为进入页面时只展示DOM层,点击开始游戏后loading游戏资源,初始化引擎等,并在游戏结束后销毁游戏资源。
通信方式
业务侧和游戏组件采用事件通信的方式,为了隔离组件内部系统事件单独实例化了对外暴露的事件总线示例,并在双端通信的事件中补充了完善的类型提示
import { EventParam, Event } from '@/constants';
import { EventDispatcher } from '@alipay/venus-engine';
// Event 事件key
// EventParam 事件对应的参数
interface _EventDispatcher extends EventDispatcher {
dispatch: <T extends Event>(event: T, param: EventParam[T]) => boolean;
on: <T extends Event>(
event: T,
callback: (param: EventParam[T]) => void,
) => EventDispatcher;
off: <T extends Event>(event: T, fn?: Function) => EventDispatcher;
}
export const event: _EventDispatcher = new EventDispatcher();
状态维护
由于游戏游玩是单机过程,游玩中间的状态维护需要完全在前端处理,游戏侧采用zustand维护数据状态和流转。为了方便实用,简单封装了subscribe和unSubscribe,用于在游戏开始和结束统一进行对值的订阅和取消
export const subscribe = <T extends keyof Store>(
state: T,
callback: (val: Store[T], pre: Store[T]) => void,
fireImmediately = false,
) => {
const unSub = store.subscribe(
(_state) => _state[state],
(val, pre) => {
callback(val, pre);
},
{
equalityFn: shallow,
fireImmediately,
},
);
unSubscribeArray.push(unSub);
};
export const unSubscribe = () => {
unSubscribeArray.forEach((unSub) => {
unSub();
});
unSubscribeArray = [];
};
在一局内游戏的状态有这些:
export enum GameState {
idle = 1, // 待机
stop, // 后台
pause, // 暂停
going, // 进行中
guidance, // 新手教程中
}
当局内游戏状态变更时,通常会有对应的逻辑,比如从游戏进行中到押后台 需要执行对应的押后台逻辑,押后台到执行中,需要执行对应的恢复押后台逻辑,为了方便的在script(脚本)中使用这些状态,基于以上状态定制了套圈游戏内部自动执行状态切换钩子的RingtossScript(也可以理解为带有一个游戏内部状态流转的状态机),这样在编写脚本组件时,只需要往对应的钩子里写对应的逻辑
export class RingtossScript extends Script {
constructor(entity) {
super(entity);
subscribe('gameState', (val, pre) => {
switch (val) {
case GameState.idle: {
if (pre === GameState.stop) {
this.onGameBackstageResume(val);
}
if (pre === GameState.pause) {
this.onGameResume(val);
}
if (pre === GameState.going) {
this.onGameOver();
}
if (pre === GameState.guidance) {
this.onGuidanceOver();
}
break;
}
case GameState.going: {
if (pre === GameState.stop) {
this.onGameBackstageResume(val);
}
if (pre === GameState.pause) {
this.onGameResume(val);
}
if (pre === GameState.idle) {
this.onGameStart();
}
break;
}
case GameState.stop: {
this.onGameBackstage();
break;
}
case GameState.pause: {
this.onGamePause();
break;
}
case GameState.guidance: {
if (pre === GameState.stop) {
this.onGameBackstageResume(val);
}
if (pre === GameState.pause) {
this.onGameResume(val);
}
if (pre === GameState.idle) {
this.onGuidanceStart();
}
break;
}
default:
break;
}
});
}
onGameStart(): void {}
onGamePause(): void {}
onGameResume(state: GameState): void {}
onGameBackstage(): void {}
onGameBackstageResume(state: GameState): void {}
onGameOver(): void {}
onGuidanceStart(): void {}
onGuidanceOver(): void {}
}
逻辑隔离
套圈游戏的游玩流程是线性的,逻辑编写时难免有互相调用(耦合)的情况,为了处理这部分内容,套圈中的文件以渲染树为目录结构,各自中只提供对应的渲染逻辑,和供其他模块调用的api,并不直接调用其他模块的方法,取而代之的是,提供了一个EntityManager,在每个entity实例化时,自动收集并注册,然后在单独的逻辑目录下,以玩法(关卡)为单位独自处理自己的逻辑
以开始游戏为例,我们这里需要执行一连串的动作,然后开始游戏,这时我们就可以编写一个脚本,通过上面的RingtossScript的onGameStart,然后把需要操作的entity从EntityManager中获取出来然后执行对应的单独的逻辑。
当然,上面只是一个步骤的操作示例,实际的开发中,就可以以关卡为单位,单独定制开发每个关卡中的行为逻辑和流程,且天然带有隔离,不需要担心逻辑互相冲突。
套圈实现
物理碰撞
目前oasis中支持了4中ColliderShape,分别为盒形,球形,平面,以及胶囊。collider又分为两种,静态的StaticCollider以及动态的DynamicCollider。
- 套圈中的物体拥有自己的碰撞盒。通过Toolkit 提供的碰撞盒辅助线绘制能力,可以看到:
- 转台,标的,地面,用于被动的与圈接触的物体都为StaticCollider,基本由一个胶囊简化组成,地面由平面组成。
- 圈作为主动运动触发的物体为DynamicCollider,由12个胶囊包围组成圈体,用于相互碰撞,中间部分用一个扁平的box组成trigger,trigger在发生碰撞时会执行对应的钩子函数。
发射计算
圈在左右移动时,圈在屏幕两端的位置 对应了脱手后扔到转台对应的两边,于是在x!==0时圈在扔出时在三个方向上都会有位移,那么我们只要算出对应的速度就可以实现扔圈这一过程了,且在这个过程中(y轴重力抛物线公式也适用),运动时间是一个我们可以规定的变量。
- 当我们通过上划操作触发扔圈时
- 我们会通过圈的x坐标计算出结束时对应在转台上的x轴位置,然后推算出圈在x轴上的位移,除以时间得到x轴方向的速度。
- 然后通过转台的半径和对应上面x轴位移的距离求出对应的z轴上的位移,除以时间得到z轴方向的速度。
- 最后通过圈和标的y坐标的差值与结束时需要留出的位移余量求出相对的高度差,再带入重力抛物线公式,求出y轴方向的速度。
// flyTime 飞行时间是可配置值
export const getYSpeed = () => {
const altitudeDifference = getAltitudeDifference() + 0.33;
// 原公式
// const speed = (0.5 * gravity * Math.pow(flyTime, 2) + altitudeDifference) / flyTime;
// 化简一次后的公式
const speed = 0.5 * -gravity.y * flyTime + altitudeDifference / flyTime;
return speed;
};
结束判定
- 飞行中圈的update会记录当前的y轴坐标,并缓存到数组中,最多缓存最近三帧的数值,当三帧内y坐标差小于一定阈值时,基本可以确定圈停止了(落地),如果trigger与标的触发碰撞,事件中会记录命中的标的物。
- 落地后,会对比记录的标的物与圈的位置坐标,计算相对距离,当小于一个阈值(小于圈的半径)时,就可以判定为命中。
视觉降级
场景中使用了lottie强化氛围,同时提供了对应的降级方案。
通过getDowngradeResult获取用户当前设备的性能等级,根据用户的设备性能选择性加载对应的资源,同时也支持服务端下发降级效果key值,用于紧急情况下,全量降级某些效果。
项目中共有三处getDowngradeResult,对应的techPoint:
- webGL的入口降级。
- lottieWeb的特效降级。
- audio音频开关的降级。
import { commonResources, high, middle, low } from './resources';
export const getResources = (
downgrade: DowngradeResult,
lottieDowngrade: boolean,
animationDowngrade: Assets[],
): Resources => {
let finalResources: Resources = {};
switch (downgrade.deviceLevel) {
case 'low': {
finalResources = { ...commonResources, ...low };
break;
}
case 'medium': {
finalResources = { ...commonResources, ...middle, ...low };
break;
}
case 'high':
default: {
finalResources = { ...commonResources, ...high, ...middle, ...low };
break;
}
}
if (animationDowngrade.length) {
animationDowngrade.forEach((name) => {
if (finalResources[name]) {
delete finalResources[name];
}
});
}
if (lottieDowngrade) {
Object.keys(finalResources).forEach((name) => {
if (finalResources[name]?.type === 'lottie') {
delete finalResources[name];
}
});
}
return finalResources;
};
后续优化
资源优化
- 贴图压缩
- 基于各个资源在视觉中的占比,压缩贴图分辨率,除了转台以外都压缩到128,256大小,文件压缩采用webp。
- gltf压缩
- 使用 glTF 扩展KHR_mesh_quantization,该扩展可以减少约45%左右数据。
压缩后场景内资源文件大小仅有6M,展开后显存占用40.6M。
实例复用
由于套圈游戏过程中需要不断扔圈,在不做优化的情况下,会频繁的创建,销毁,导致机器发热,卡顿,于是在套圈中引入了一个独立的圈管理模块,用于维护圈的生成和销毁。
在这个模块中,主要提供了一个生成圈的方法,一个销毁圈的方法,和一个对象池,当有圈落地并产生结果需要销毁时,并不直接销毁这个圈,而是将其从渲染树上摘除,并还原所有属性,存入对象池中,后续如果外部调用生成圈时,会优先从对象池中获取可复用的圈,如果没有再重新实例化。
export class RingManager {
pool: Ring[] = []; // 回收池
// 获取圈 优先从回收复用 否则创建
getRing(engine: WebGLEngine) {
if (this.pool.length) {
return this.pool.shift();
}
return this.createRing(engine);
}
// 创建
createRing(engine: WebGLEngine) {
const ring = new Ring(engine);
return ring;
}
// 回收
recycleRing(ring: Ring) {
ring.parent.removeChild(ring);
ring.reset();
this.pool.push(ring);
}
}
内存优化
因为套圈首页有福卡广告任务等跳转行为,为保障在跳转其他页面情况下的内存情况,在五福后续的开发中,套圈从打开页面就加载资源,后续不重复创建的方案,改造成了打开页面后不加载资源,等开始游戏时再加载资源,并每局结束后销毁,为了方便业务侧使用,采用将互动游戏(组件)通过 react 组件彻底销毁重建的方式。
function App (props) {
const [active, setActive] = React.useState(false);
return (
<div>
<!-- 互动组件部分 -->
{active && <Game {...} />}
<!-- -->
<div onClick={() => setActive(true)}>新建</div>
<div onClick={() => setActive(false)}>销毁</div>
</div>
);
}
正常情况下,重复的创建销毁,只要销毁完全,那么canvas element全局同时只会有一个,但在销毁不完全的情况下,就意味着大量的 ArrayBuffer 数据、图片资源、纹理、画布等残留,导致内存被明显累积占用。
但是改造后通过MPerf发现,组件销毁不完全,有OOM的情况。
在safari中也可以通过 开发者工具 => 图形 来查看 canvas 的实际情况,销毁不完全时,可以看到有多个canvas实例同时存在。
解决方法
在这类问题中可以使用queryObjects和queryHolders两个方法查看具体的情况。
- queryObjects(),可以检查到某种已创建的实例具体的数量,chrome/safari均可用。
- queryHolders(),可以检查到某个实例具体被谁引用,仅safari中可用。
总结
这次五福乐园的开发中Oasis Engine提供了丰富的组件和工具,可以帮助开发者快速构建高性能、高质量的3D应用,支持多平台、多语言,可以满足不同开发者的需求,提供了完善的文档和社区支持,可以帮助开发者更好地使用和学习Oasis Engine。
如何联系我们
网站
官网地址
oasisengine.cn
Engine 源码地址
github.com/oasis-engin…
Engine Toolkit 源码地址
github.com/oasis-engin…