一文读懂「五福欢乐套圈圈」开发全流程

avatar
花呗借呗前端团队 @蚂蚁集团

本文来自于支付宝体验技术部晓白的投稿

玩法介绍

在 23 年的福气乐园中,在福气店两边还引入了小游戏供用户体验,本文将和大家分享其中欢乐套圈圈的开发全流程。支付宝首页输入玩一玩,进入即可体验欢乐套圈圈:

image.png
套圈采用了以局为单位的重复游戏模式,每局标的物根据规则随机,初始化n个圈并有m秒时间,当圈丢完或时间结束后,当局结束,一局内套中t个标的后,会刷新宝箱,套中宝箱后结算时会开启宝箱获得对应的五福奖励或实体奖励

  • 操作:模拟套圈扔圈过程,上划屏幕丢圈
  • 道具:包含两种道具,天女散花和加时器,天女散花可以使玩家一次性扔出大量圈儿,加时器可以增加本局剩余的时间

设计与实现

名词解释

术语名称术语描述
套圈圈游戏内投掷物,投出后可套中场上不同的标的
标的游戏转盘上可以被圈套中的目标物品(如兔子、道具箱、宝箱等)
奖品套中宝箱开出的价值权益,如福气值、玩乐豆、红包、优惠券、实物奖品等

前后端交互

这次五福套圈游戏中,玩家手部会左右移动,而标的物和转台也会旋转,滑动扔圈的时机显得尤为重要,如果采用每次扔圈时都与服务端通信,交换数据的话,至少会有20-30ms的延时,而且在弱网情况下,扔圈的延迟将会极大地影响游戏的体验,为了确保玩家的游戏体验,最终采用了游戏局内单机,开始游戏前和结束游戏后与服务端交换数据的方式,在整个游戏过程中始终在前端计算数据,不需要与服务端通信。
于是在单机局内的基础上,防止作弊和验证兑换奖励非常重要。在开局时,服务端会根据时间生成本局的唯一ID,并向前端发送所有标的物的信息,这些标的物包含对应的唯一ID。
在开始游戏后,前端将初始化在标的台上的前8个标的物截取,剩余的标的物则缓存起来,并在局内补充标的物时使用。在游戏过程中,前端会累计投掷圈数,并在命中标的物时累计命中的标的物。当本局游戏结束后,前端会判断本局的合法性,并将本局的ID和标的物的数据发送到服务端进行合法性校验,以及进行开奖操作。

  • 单机的优势:
    • 无延时,操作响应同时,体验好。
  • 单机的劣势:
    • 数据前端维护,与服务端交互时需要防作弊手段。

架构设计

游戏以react组件库的形式通过npm包引入项目,组件库默认暴露了Game组件以及相关的log上报方法,和event实例,业务侧在init时绑定事件,通过切换页面周期状态,切换页面内容,控制游戏组件创建和销毁。
image.png

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实例化时,自动收集并注册,然后在单独的逻辑目录下,以玩法(关卡)为单位独自处理自己的逻辑
image.png
以开始游戏为例,我们这里需要执行一连串的动作,然后开始游戏,这时我们就可以编写一个脚本,通过上面的RingtossScript的onGameStart,然后把需要操作的entity从EntityManager中获取出来然后执行对应的单独的逻辑。
当然,上面只是一个步骤的操作示例,实际的开发中,就可以以关卡为单位,单独定制开发每个关卡中的行为逻辑和流程,且天然带有隔离,不需要担心逻辑互相冲突。

套圈实现

物理碰撞

目前oasis中支持了4中ColliderShape,分别为盒形,球形,平面,以及胶囊。collider又分为两种,静态的StaticCollider以及动态的DynamicCollider。

  • 套圈中的物体拥有自己的碰撞盒。通过Toolkit 提供的碰撞盒辅助线绘制能力,可以看到:
    • 转台,标的,地面,用于被动的与圈接触的物体都为StaticCollider,基本由一个胶囊简化组成,地面由平面组成。
    • 圈作为主动运动触发的物体为DynamicCollider,由12个胶囊包围组成圈体,用于相互碰撞,中间部分用一个扁平的box组成trigger,trigger在发生碰撞时会执行对应的钩子函数。

image.png

发射计算

圈在左右移动时,圈在屏幕两端的位置 对应了脱手后扔到转台对应的两边,于是在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;
};

image.png

image.png

  • 最终通过上述方法获取到每个方向对应的速度之后通过collider.linearVelocity发射出去。

结束判定

  • 飞行中圈的update会记录当前的y轴坐标,并缓存到数组中,最多缓存最近三帧的数值,当三帧内y坐标差小于一定阈值时,基本可以确定圈停止了(落地),如果trigger与标的触发碰撞,事件中会记录命中的标的物。
  • 落地后,会对比记录的标的物与圈的位置坐标,计算相对距离,当小于一个阈值(小于圈的半径)时,就可以判定为命中。

image.png

视觉降级

场景中使用了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压缩

压缩后场景内资源文件大小仅有6M,展开后显存占用40.6M。

image.png

实例复用

由于套圈游戏过程中需要不断扔圈,在不做优化的情况下,会频繁的创建,销毁,导致机器发热,卡顿,于是在套圈中引入了一个独立的圈管理模块,用于维护圈的生成和销毁。
在这个模块中,主要提供了一个生成圈的方法,一个销毁圈的方法,和一个对象池,当有圈落地并产生结果需要销毁时,并不直接销毁这个圈,而是将其从渲染树上摘除,并还原所有属性,存入对象池中,后续如果外部调用生成圈时,会优先从对象池中获取可复用的圈,如果没有再重新实例化。

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的情况。

image.png
在safari中也可以通过 开发者工具 => 图形 来查看 canvas 的实际情况,销毁不完全时,可以看到有多个canvas实例同时存在。

image.png

解决方法

在这类问题中可以使用queryObjectsqueryHolders两个方法查看具体的情况。

  • queryObjects(),可以检查到某种已创建的实例具体的数量,chrome/safari均可用。

image.png

  • queryHolders(),可以检查到某个实例具体被谁引用,仅safari中可用。

image.png

通过上述两个api可以一步步排除影响因素找到具体原因。

总结

这次五福乐园的开发中Oasis Engine提供了丰富的组件和工具,可以帮助开发者快速构建高性能、高质量的3D应用,支持多平台、多语言,可以满足不同开发者的需求,提供了完善的文档和社区支持,可以帮助开发者更好地使用和学习Oasis Engine。

如何联系我们

添加群管理员微信:zengxinxin2010

网站

官网地址
oasisengine.cn
Engine 源码地址
github.com/oasis-engin…
Engine Toolkit 源码地址
github.com/oasis-engin…