手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践

作者|驭剑

今年金币庄园迎来了一次改版,改版后的金币小镇给用户带来了更丰富的视觉风格和全新的玩法。EVA体系是我们团队在互动业务多年探索的基础上产出的,它是一套上手快、开发效率高、能力完善的互动研发体系。EVA体系能大幅提升研发效率,小镇快速改版上线就是一个很好的范例。本文将介绍EVA体系是如何在小镇项目中实践以及一些个人的总结。

如下图就是金币小镇的首页界面,它包含了上方的游戏区域和下方的商业化部分,今天主要介绍上方的游戏区域。
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践

游戏方案

2.5D 简介

小镇的游戏方案采用了 2D Isometric(等轴测或者等距)的方案来实现,2D Isometric一般被简单称为2.5D。2.5D 是指一种在2D游戏中制造出3D效果的显示方法,这种方案更多的是对于视觉设计的规范,视觉按照一定的规范来设计素材,游戏开发同学基于素材来做放置拼接素材来搭建场景。

手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践2.5D游戏素材【左侧】,拼接后的效果【右侧】(注:图片素材来源kenney网站)

细心看就会发现上方右侧图片地面上有一个个平行四边形的格子,素材就是基于这一个个格子作为基线来摆放拼接的,这就是我们经常说的Tiled Map(瓦片地图)。Tiled Map是使用一些小单元(瓷砖)来拼成一副大地图的游戏做法,Tiled Map可以通过Tiled Map Editor这类工具来搭建。

小镇用的就是用的上方的方案,下图就是小镇游戏区域的Tiled Map视觉示意图,其中数字代表的是每个建筑的点位。
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践

小镇游戏分层

小镇项目整体涉及建筑部分逻辑相对比较单一(渲染和升级替换资源),整体逻辑集中在领金币和多人的助力合力业务玩法部分。针对这种互动游戏中碰到的普遍情况,既有Canvas又有DOM+CSS,我们一般使用混合开发的开发方式。

小镇项目中会将游戏区分为三层,具体分层如下:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践

Background层负责游戏场景的背景图片,Scene层(游戏引擎开发)负责建筑的排列渲染,Hud层负责业务逻辑的展示,利用传统DOM和CSS的排版优势,更能跟上业务的节奏。

开发链路

小镇开发的基本工作链路如下:通过EVA Store的一站式上传、预览、代码导出流程后就能交付游戏引擎的资源了,然后将交付后资源使用EVA编辑器搭建场景后输出场景数据,最终将场景数据交由EVAJS渲染游戏场景,业务UI层使用DOM+CSS开发。
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践

游戏部分

基于上方的三层分层后,Scene层渲染使用到EVAJS游戏引擎,EVAJS采用了ECS的设计模式作为底层架构,EVAJS ECS设计如下:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
了解Unity的同学一定对这个不陌生,EVAJS提供了全套对于EVA Store丰富素材System、Component的支持。

我们举例要渲染一个龙骨动画,这时候我们需要创建一个实体,然后将龙骨动画(Dragonbones)组件添加到实体上,最终龙骨动画渲染系统来管理相关的龙骨动画组件。伪代码如下:


import { Game, GameObject, resource, RESOURCE_TYPE } from '@ali/eva.js';
import { RendererSystem } from '@ali/eva-plugin-renderer';
import { DragonBone, DragonBoneSystem } from '@ali/eva-plugin-renderer-dragonbone';

const game = new Game({
  systems: [
    new RendererSystem({
      canvas: document.getElementById('canvas'),
      width: 324,
      height: 240,
      transparent: true,
    }),
    // System: DragonBone系统
    new DragonBoneSystem()
  ],
});

// Entiy:游戏对象
const dragonbone = new GameObject('dragonbone', {
  position: {
    x: 162,
    y: 240
  }
});

// Component: Dragonbone组件
const dragonboneCom = new DragonBone({
  resource: 'dragonbone',
  armatureName: 'B-1-9-3-2x2'
});

// 将Component添加到Entiy
const animation = dragonbone.addComponent(dragonboneCom);

animation.play('newAnimation');

互动素材准备

我们了解了如何使用EVAJS渲染一个龙骨动画到场景中,小镇中每个建筑对应的是一个龙骨动画,小镇中随着用户等级提升龙骨动画素材个数会达到120个以上,这时候这么多的素材要如何管理呢?

互动中的素材管理面临如下三个问题:

  1. 素材格式众多:面对图片素材、模型素材、动效素材、音视频这么多个素材格式,每个素材格式又可能包含多个资源文件,如果我们采用单独上传到CDN,对于引擎使用和后期的维护都是相当困难的。
  2. 如何最优化素材:不同格式的素材在上传后如何才能最优的跑在引擎上,如何让设计师同学即时预览到素材效果?
  3. 多人协作:在协作流程中,如何让不同角色的人协作起来

针对上面三个问题,EVA Store给出了很好的一站式解决方案:

1、EVA Store支持的如下众多格式,并且这些互动素材的协议标准是由经济体互动小组统一制定的,这就意味这些沉淀在平台上的素材资源可以放心的使用。
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
2、针对每种不同的素材格式,EVA Store都有相对应的算法进行优化,如帧动画的图片合成压缩、龙骨动画(DragonBone)的顶点优化、雪碧图最小内存占用压缩等,这些操作可以保证素材达到最优化的效果,同时上传后给提供了即时预览方便设计同学查看效果,如下

手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践

3、由于游戏有好多章节,每个章节有对应各自建筑的素材,借助EVA Store我们可以将每个章节设置一个项目并且设置权限方便不同角色之间的分工协作。
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
同时EVA Store的代码预览和在线实时编辑功能也能帮助前端定位资源问题:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践

场景搭建

素材管理的事情EVA Store很好的帮我解决了,接下来就是如何将这些素材按照视觉稿样子放置龙骨动画后生成场景数据,上面我们提到了tiled Map Editor工具能帮助我们搭建场景,但是现阶段要和我们的EVA体系进行整合还是需要费点力气:

  1. 小镇中建筑使用的龙骨动画,这个很难在tiled Map Editor中实现所见即所得的效果,同时在面对后期更多的素材资源格式也不能很好适应。
  2. 前端要基于视觉稿中的点位来放置建筑,类似于数格子后将建筑放上去,这是一个比较枯燥费时的事。
  3. 需要产出基于我们EVA规范的数据地图文件,方便后期二次开发。

我们做了个简单的编辑工具来解决上述问题(EVA Design已经在开发中):

1、我们提供了基于EVA Store素材格式导入(未来打通个*限下的素材,选择项目素材后导入资源面板)
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
2、采用将视觉稿叠加在搭建场景下方层来协助我们将素材资源放置在对应的点位即可
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
3、支持导出json格式保存到EVA Store,我们制定了基于这类2.5D游戏场景格式,如下格式:


{
  "mapConfig": {
    "width": 30,
    "height": 30,
    "tileWidth": 108,
    "tileHeight": 54
  },
  "layers": [
    {
      "layerName": "buildings",
      "align": [-0.5, -1],
      "data": [
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        // 1这个数字又对应的下方objects数组中index为1的元素( 即:building1 建筑)
        [0,0,0,0,0,0,0,0,0,0,0,0,  1, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
      ],
      "layerOrder": 0,
      // 对应上方数组中的index
      "objects": [
        {}, 
        { "resourceKey": "building1" }
      ],
      // 对应上方的objects中的resourceKey, building1描述的是个龙骨动画
      "resources": {
        "building1": {
          "name": "building1",
          "type": "DRAGONBONE",
          "src": {
            "image": {
              "type": "png",
              "url": "https://gw.alicdn.com/tfs/TB1A85jLEz1gK0jSZLeXXb9kVXa-256-256.png"
            },
            "tex": {
              "type": "json",
              "url": "https://pages.tmall.com/wow/eva/f9b692a8e8b90fb695caf9a5fedf12ee.json"
            },
            "ske": {
              "type": "json",
              "url": "https://pages.tmall.com/wow/eva/7ec54ea534ef1c121bdefb04636dee7e.json"
            }
          }
        }
      }
    }
  ]
}

很多人可能会问,直接使用视觉稿中的定位来放置不是更简单吗? 其实搞这么复杂的二维矩阵有它一定的优势:
1、建筑之间是深度的,存在相互遮挡关系,越是靠近屏幕顶部的物体应当越早地被画出来,我们现在只要按顺序遍历二维数组就能做到这点,不需要开发过程中人为指定

2、导出地图想当于将整个地图划分成一个个格子,我们可以通过移动格子来方便定位

3、方便实现移动对象的移动碰撞操作, 我们通过是否是道路瓦片来生成一份可行走的地图,如下伪代码:


// 0:可移动 1:障碍
const walkable = [
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0],
  [0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
  [0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
  [0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0],
  [0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
  [1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1],
  [0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
  [0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
];

可行走的地图数组产出后,我们就可以使用寻路算法(如AStarFinder)来实现角色从一个点移动到另一个点的寻路效果了。

建筑渲染

EVAJS支持完善的插件机制,我们可以很简单的基于EVAJS的Plugin脚手架来开发插件,场景渲染中我们需要将上方产出的json格式渲染成建筑群。我们需要将一个个的点位转换成画布上的x,y值,这些x,y值就是Component中的纯数据表现,Componet伪代码如下:



/**
 * 创建一个isometric的sprite精灵
 * isoSprite相对于传统的sprite的Position是通过x和y根据tileWidth和tileHeight排布的
 * isoSprite还具有sortable的,所以需要设置zIndex
 *
 * @extends Eva.Component
 * @param {number} x - 在二维矩阵中第几列
 * @param {number} y - 在二维矩阵中的第几行
 * @param {number} tileWidth - 瓦片宽度,计算isoX,isoY使用
 * @param {number} tileHeight - 瓦片高度,计算isoX,isoY使用
 */
class IsoSprite extends Component {
  static componentName = 'IsoSprite';

  _depth: number = 0;
  isoX: number;
  isoY: number;

  init(params: IIsoSprite) {
    const { x, y, tileWidth, tileHeight } = params;
    this.isoX = (x - y) * tileWidth / 2;
    this.isoY = (x + y) * tileHeight / 2;

    // 尽量拉开每个面片的层级,为了方便后期插入元素时设置层级
    this._depth = 10 * (x + y);

    this.addComponents();
  }

  addComponents() {
    this.gameObject.addComponent(
      new Render({
        zIndex: this._depth,
      })
    );

    this.gameObject.transform.position = {
      x: this.isoX,
      y: this.isoY,
    };
  }

  setZorder(depth: number) {
    this._depth = depth;
  }
}

System通过装饰器监听它所关心的Component,这里面我们监听的是上方的IsoSprite,当IsoSprite的_depth更变时就会触发System相关操作



@decorators.componentObserver({
  IsoSprite: ["_depth"],
  Render: ["zIndex"],
})
class TileSystem extends System {
  static systemName: string = 'TileSystem';
 
  init(params: TileSystemParams) {
    this.placeTile();
  }   


  createComponentByType(resourceName: number, spriteName?: string) {
    // 通过不同的资源类型生成不同的资源实例
  }

  /**
   * 通过map放置点位
   * @param layer 地图点位
   * @param mapConfig 地图信息
   */
  placeTile(layer, mapConfig) {
    const { tileWidth, tileHeight } = mapConfig;
    const { data, align, layerName, objects } = layer;
    for (let y = 0; y < data.length; y++) {
      for (let x = 0; x < data[y].length; x++) {
        // 基于不同的资源类型放置不同的资源
        this.createComponentByType()
      }
    }
  }

  update() {
    const changes = this.componentObserver.clear();
    for (const change of changes) {
      if (change.type === "ADD") {
        // do something tiles add
      }
      if (change.type === "CHANGE") {
        // do something tiles change
      }
      if(change.type === 'REMOVE') {
        // do something tiles remove
      }
    }
  }
}

上方开发的Component组件中有一个_depth(深度)变量,这个深度就类似CSS的zIndex,如下图的主建筑①的zIndex会高于遮挡住建筑②,由于建筑①占用了很多位置,这时会导致建筑②的一大半部分被挡住导致很难点中。
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
游戏中我们会设置模型的hitArea来设定它的可点区域,我们使用右侧小工具来生成hitArea数据,我们设定主建筑的可点区域为如上形状,这样就能避免在点击到主建筑空白区域也触发事件,这样就可以规避了遮挡问题。

游戏和DOM交互

混合开发方式中游戏和DOM层由于分层了后,两个层之间的交互一般采用的消息事件来进行调度,消息事件机制是游戏开发中比较常见的解耦工具,为了规范事件调度机制,方便多人协作中事件发送监听的无缝衔接,我们采用EVA Base中的 MX(一套数据流转和事件通讯的方案)作为事件和数据中枢。
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
如下代码演示了如何通过消息事件机制来实现游戏区和HUD层的交互:

/** Eva Game */
import {Event} from '@ali/eva-plugin-render';
const evt = dragonboneGameObject.addComponent(new Event())
evt.on('tap', (e) => {
  // 点击游戏建筑
  mx.event.emit('ClickOnGameBuilding', {
    ev: e
  });
});

/** HUD */
function HUD(props) {
  useEffect(() => {
    mx.event.on('ClickOnGameBuilding', e => {
      console.log('show game building tooltip')
    });
  }, []);
  return <div></div>
}

HUD层

可访问性优化

值得庆幸的是,我们在金币小镇上全链路是对Web可访问性做了优化。对于过渡依赖于读屏幕软件,比如iOS的“VoiceOver”, Android的“TalkBack”,可以顺畅的在小镇玩耍:

点击查看视频

金币小镇中针对Web可访问性方面(也称之为无障碍)的优化主要两个部分:游戏区域和非游戏区域。在这里我们主要和大家一起探讨游戏区域的可访问性是如何进行优化的。就是上图红色框中的部分:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
这个区域是金币小镇的游戏化区域,也是用户进来互动的区域,比如点击建筑物有相应的提示介绍,进点按钮会进入到相应的二级页面或者说拉引任务系统之类等等。

就从这个区域开始吧!如果你是Web开发者,通过浏览器调试工具查看这个区域,可以看到 <canvas> 和其他一些HTML标签(比如 <div> )组合在一起:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
采用浏览器调试工具的分层工具来查看将会更清晰一些:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
前面提到过,开发游戏区域前端主要采用的是Rax EVA进行开发的,游戏区域的可访问性优化都集中在 Hud 层:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
在整个游戏区域点击每个建筑物都会有相应的提示信息,或者有弹窗以及跳转等交互:

点击查看视频

对于Hud中的图标按钮,这个问题不大,他们就是纯DOM:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
比较麻烦的是 点击游戏区域建筑物的交互 。为了让这个交互能和屏幕阅读器这样的辅助技术有一个较好的通讯,我们采用了 Canvas和DOM分离的操作。即:在Hud层内置了和游戏区域相匹配的点击锚点 , 比如下图中小圆圈所示:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
这些锚点并不会影响我们整个UI效果,我们使用CSS做了一些处理,正常情况下他们都是一个 2px x 2px 的透明矩形,但在开启屏幕阅读器下面,锚点得到焦点时会有相应的焦点样式:
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
锚点的DOM结构大致像下面这样:

<!-- 提示框未显式,用户未点击锚点对应的建筑物 -->
<div class="home__inresultant--force">
  <div class="tooltip__anchor" role="button" tabindex="-1" aria-expanded="false">
    <span class="sr-only">氧氧温室</span>
  </div>
</div>

<!-- 提示框显式,用户点击锚点对应的建筑物 -->
<div class="home__inresultant--force">
  <div class="tooltip__anchor" role="button" tabindex="-1" aria-expanded="true" aria-describedby="a11y__inresultant--force">
    <span class="sr-only">氧氧温室</span>
  </div>
</div>

比如上面示例的锚点,它有对应的提示框:

<div>
  <div class="tooltips tooltip__inresultant--force " role="alert" tabindex="-1" id="a11y__inresultant--force" >
    <svg focusable="false" aria-hidden="true" width="216" height="118.4375" >
      <!-- 提示框UI,使用SVG构建 -->
    </svg>
    <div class="tooltips__content">
      <div class="tooltip__inresultant--force--content">
        <!-- 提示框内容 --> 
      </div>
    </div>
  </div>
</div>

不管是在锚点还是提示框的DOM元素上,我们都看到了ARIA相关的特性,比如 角色 属性状态

  • 角色 :在锚点的 div 使用了 role="button" 告诉屏幕阅读器,它是一个按钮;在提示框的 div 使用了 role="alert" 告诉屏幕阅读器,它是一个警告框(或提示框)
  • 属性 :在锚点的 div 使用了 aria-describedby 属性绑定提示框的 id 值,让他们有一个绑定关系
  • 状态 :在锚点的 div 使用了 aria-expanded 来告诉屏幕阅读器提示框的状态,如果提示框未显示,该属性的值为 false 表示提示框是折叠状态,反之为 true ,表示提示框是展示状态,屏幕阅读器可以读出提示框的相应信息

有关于ARIA更多的介绍这里就不展开了,如果你对这方面知识感兴趣的话,可以阅读下面这些资料:

我们从ARIA的世界中回来。

在锚点和提示框上除了使用ARIA之外,还使用了一些其他对屏幕阅读器友好的特性,比如使用 tabindex 来给非聚焦元素设置焦点;比如在不需要被屏幕阅读器识别(朗读出来)的元素上显式设置 aria-hidden="true" ; 比如使用CSS让文本只让屏幕阅读器可以识别:

.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0,0,0,0);
    clip-path: inset(100%);
    white-space: nowrap;
    border-width: 0;
}

由于这样的场景在整个游戏区的使用频率非常的高,加上UI个性化较强(Tooltips尖角各异),因此我们封闭了一个svg-tooltip的组件,在封装这个组件的时候,我们把无障碍相关的特性内置进去。在使用的时候只需要像下面这样即可:

<SvgToolTip
  className="tooltip__growth-wrap"
  a11yId="a11y__lock"
  a11yRole="tooltip"
  visible={true}
  trigger="none"
  content={growthContent}
  closeOutSide={false}
  {...NormalToolTip}
  onVisibleChange={v => onToolTipVisibleChange(v, config.petName)}>
    <div
      className="tooltip-anchor"
      style={calPosStyle(toolTipsPos)}
      role="button"
      tabIndex={-1}>
      <span className="sr-only">{skin.name}</span>
    </div>
</SvgToolTip>

在调用SvgToolTip时,需要给该组件透传a11yId这个props,并且与触发SvgToolTip元素的aria-describedby绑定在一起。即aria-describedby的值和a11yId值等同。

另外有一个细节需要注意的就是,Tooltips提示框有两种不同的交互类型,一种是无需任何交互,一进入页面提示框就展示;另外一种就是带有交互,用户点击建筑物之后提示框展示,经过几秒或用户点击另外的地方,该提示框会隐藏。因此在设置 a11yRole 时要选择不同的值:

  • 提示框不需要任何交互显示的,给 a11yRole 传一个 tooltip
  • 提示框需要点击才显示的,给 a11yRole 传一个 alert

这样在编译出来的代码,就是像我们上面所说的一样。

SVG 的使用

手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
在项目中我们使用了很多不规则尖角的气泡样式,为了兼顾优雅和通用性,我们使用了SVG来实现toolTip的外框,并且开发相对应的工具来解决更复杂的气泡样式。具体文章可以移步《用SVG实现一个优雅的提示框》。

动态弹窗方案之 EVA Ware

受制于苹果对于动态化H5游戏审核策略的限制,我们的项目需要跟随IOS手淘的节奏来集成代码到包中,这样一来大大降低了H5动态能力。面对业务中大量的玩法策略需要由弹窗来承接,我们接入了一套经历过大促考验的弹窗规模化解决方案,简单来说就是拉取弹窗表现层的DSL,在客户端来渲染并且基于弹窗管理器来管理各个弹窗的生命周期, 具体方案可以移步《互动生产力进化之路 | 618 淘系前端技术分享》

其他

  • CSS不规则形状的蒙层: 领淘金币按钮上的扫光效果使用的css不规则蒙层
  • 适度使用APNG: APNG在手淘中表现已经非常不错了,项目中部分动效我们使用了APNG,在配置和修改的便利度来说远胜于一般动效。

最后

第十五届 D2 前端技术论坛的 D2 SPACE 也是使用 EVA 来开发的,由淘系互动团队倾情支持。欢迎大家使用 EVA 体系来开发互动项目,我们团队的目标是【人人可开发,处处有互动】。如果你对 工程/搭建/低代码研发方向 或者 WebGL/图形渲染/特效方向 等感兴趣,欢迎微信联系/钉钉进群一起交流。
手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践


手把手教你开发互动游戏,看 EVA 互动技术体系在金币小镇的实践
关注「Alibaba F2E」
把握阿里巴巴前端新动向

上一篇:【云图】如何制作东莞酒店地图?


下一篇:秦粤:“和你聊聊 JS 历史和今年 D2 的语言框架专场”