导读

2024年元旦期间,快手推出了一款名为“驭雪冲锋赛”的滑雪竞技游戏,该游戏以Pixi渲染引擎为基础,通过精心设计的多样化玩法,为用户带来有趣的社交互动体验。

整个项目由三大核心板块组成:游戏首页、滑雪竞技和许愿星空。在滑雪冲关的过程中,玩家们不仅可以体验到速度与激情的碰撞,还能收获诸多精心设计的道具和奖励,例如“烟雾弹”、“横冲直撞”、许愿烟花、头像挂件以及现金红包等。本文将从前端开发的角度,重点关注滑雪游戏部分在开发过程中遇到的技术挑战以及相应的解决策略。在这个环节中,技术团队面临了众多难题,包括如何确保游戏在多种设备上的流畅运行、如何优化Pixi渲染引擎以提供更为逼真的滑雪体验、以及如何实现与后端服务器的稳定通信等……

全文共8912字,预计阅读时间20分钟。

一、项目背景

2024年元旦期间,快手推出了一款基于Pixi渲染引擎的滑雪竞技游戏"驭雪冲锋赛",该项目通过丰富多样的玩法设计,为用户带去更加有趣的社交体验。项目包括首页、滑雪竞技和许愿星空三大板块。用户在滑雪冲关的过程中,可获得各种道具和奖励,如“烟雾弹”、“横冲直撞”、许愿烟花、头像挂件以及现金红包等。使用“烟雾弹”可对好友投掷烟雾干扰;使用“横冲直撞”,可以在滑行过程中无视障碍,快速滑行;而许愿烟花则可在许愿星空中许下新年心愿。本文将从前端开发的视角,重点探讨该项目中滑雪游戏部分在开发中遇到的技术难点及相关解决思路。

abe6dc5ce48c770e8f083890f60f6f89.gif

二、游戏现实

01 游戏玩法介绍

滑雪游戏一共分为6个关卡,每个关卡都配置了不同的滑行速度、目标滑行距离、障碍物布局以及获得奖品的概率,其中第6个关卡为无限火力关卡,此关卡不设置目标滑行距离,主要是提供给玩家用于刷新排名。游戏玩法的核心在于玩家手势操控的灵活度,玩家需要通过左右滑动来控制角色的滑动方向以此来与障碍物产生交互。障碍物主要分为三类:石头、礼盒和火箭:

  • 碰到石头:非横冲直撞模式下碰到石头会导致游戏结束,需要使用复活卡才能继续游戏或者重头开始玩本关;

<!---->

  • 碰到礼盒:礼盒打开获得随机奖励(心愿烟花、头像挂件、烟雾弹或者什么都没有)

<!---->

  • 碰到火箭:开启一定时限(比如10s)的横冲直撞模式,此模式下可以畅通无阻,并且获得的奖励会正常叠加。

image.png

当玩家进入无限火力关卡后,可以根据活动的奖励规则(在活动结算周期内排名全服榜前3000名的玩家可以均分十万元大奖)继续滑行来刷新排名。

02 游戏引擎选择

滑雪游戏涉及手势交互、角色移动、碰撞效果等多种复杂动画交互,因此在技术选型时需重点考虑上手难度、应用场景和性能三个方面因素。经过对Three.js、Cocos和Pixi等引擎的详细对比分析,我们最终确定采用Pixi作为游戏的核心渲染引擎。

方案呈现方式上手难度应用场景性能
Three.js❌3D(WebGL+CSS3D)3D类的H5游戏**/小游戏
Cocos❌3D/2D移动设备/桌面系统
Phaser❌2D(Canvas+webGL)大型游戏开发良好
Pixi ✅2D(Canvas+webGL)Web游戏开发

03 游戏分层实现

滑雪游戏的界面主要由两个部分构成:基于Vue.js实现的UI层,以及使用Pixi引擎制作的游戏层。其中,UI层负责展示与游戏交互核心无关的内容,比如顶部里程进度、关卡提示、获得的道具以及各种动效元素;而游戏层负责游戏引擎的渲染更新、游戏角色的布局与交互等核心游戏元素。两个层级之间通过事件派发的方式进行数据和交互的通信。整体的分层结构如图所示:\

在游戏层内部,又可进一步划分为五个子层级:背景层、道具&障碍物层、角色层、道具效果层(如烟雾弹效果) 以及顶部蒙层。从最底层的背景层到最上层的顶部蒙层层级依次升高。接下来,我们将按照层级由低到高的顺序,分别详细介绍这些游戏子层级的具体实现。

(1)背景层

从视觉效果上来看,游戏中的角色需要不断向前滑行。基于相对运动的原理,只需保持角色在Y轴上的坐标保持不变,同时向上移动背景图,就能产生角色前滑的视觉效果。具体的实现思路是:将一张大背景图等分为6张小图,然后循环拼接并向上移动这些小图。当第一张背景图移出屏幕外后,将其Y轴坐标进行调整,重新拼接到最后一张小图的位置,从而形成无缝循环滚动的背景效果。

步骤一:

  1. 初始化一次性load 6张图片,将6张图片的纹理写入一个数组;
  2. 计算每个设备在视口内需要多少张图片(设备高度除以每张图片的高度后加1);
  3. 设置每张图片的Y坐标并将图片放置到背景层container里面以显示

<!---->

init() {
  return new Promise((resolve) => {
    // bgs是图片数组,一次性load多张图片
    AssetsManger.instance.load(bgs).then((textures: { [s: string]: Texture } | ArrayLike<Texture>) => {
    // 将图片的纹理写入bgTextures里
    this.bgTextures.push(...Object.values<Texture>(textures));
    // needBg是每个设备需要展示的图片数量,设备高度除以每张图片的高度后加1,
    for (let i = 0; i < this.needBg; i++) {
      // 用图片纹理创建精灵,并设置精灵的宽高及位置
      const bg = new Sprite(this.bgTextures[i]);
      bg.width = designResolution.width;
      bg.height = perBgHeight;
      bg.position = { x: 0, y: perBgHeight * i };
      this.spriteBgList.push(bg);
      // 将精灵挂载到背景层container上
      this._parent.addChild(bg);
    }
    resolve(true);
  });
});
}

步骤二 :判断到达临界点

// ticker 游戏更新逻辑通常会每帧运行一次
app!.ticker.add((dt: number) => {
  // this.moveLayer.y 是画布层向上移动的距离
  this.currentRoundDistance = Math.abs(this.moveLayer.y); // 米数更新
  // _bgStartY初始化为0,perBgHeight是每张画布的高度。
  // 判断第一张画布刚移出屏幕就进行画布的替换
  if (this.currentRoundDistance > this._bgStartY + perBgHeight) {
    this._bgStartY += perBgHeight;
    this._bgEndY += perBgHeight;
    this.bgIndx++;
    if (this.bgIndx > needBg) {
      // 需要加载的画布索引,大于needBg后需要从0开始
      this.bgIndx = 0;
    }
    // 调用步骤三的替换图片方法
    this._bgInstance.update(this.bgIndx, this._bgEndY - perBgHeight);
  }
}, this);

步骤三:替换图片

取出第一个精灵,改变其纹理,设置Y轴坐标,将它塞到最后

/**
* @param bgIndx 需要追加的图片索引
* @param startY 需要追加的图片Y轴坐标
*/
update(bgIndx: number, startY: number) {
    // 取出第一个精灵
    const bg = this.spriteBgList.shift()!;
    // 获取需要追加的纹理
    const texture = this.bgTextures[bgIndx - 1];
    // 设置精灵的纹理
    bg.texture = texture;
    // 设置精灵的坐标
    bg.position = { x: 0, y: startY };
    // 将这个精灵push到精灵数组里面
    this.spriteBgList.push(bg);
}

图示最终背景层效果如下所示:
image.png

(2)道具、障碍物层

image.png
 title=在道具与障碍物层的实现过程中,需要重点解决以下几个关键问题:障碍物实例的动态创建,撞击时的视觉动效处理,以及画布上障碍物节点过多导致的内存占用和性能问题。

障碍物类型障碍物碰撞热区
图片图片转存失败,建议直接上传图片文件

① 障碍物实例创建

动效效果:

礼盒&火箭上下漂浮效果

示意效果:

实现方式:

礼盒和火箭的漂浮效果,使用缓动动画实现,设置盒子在每一帧进行循环上下移动,采用TWEEN

**动画库,实现动画效果.⚠️设置贝塞尔曲线的时候,可以把默认的ease设置为none 

yoyo(true):设置往返循环动画。

const Tween1 = new Tween(sprite)
.to({ y: sprite.y - 10 })
.easing(Easing.Quadratic.InOut.NONE)
.duration(duration)
.interpolation(Interpolation.Bezier([0.42, 0, 0.58, 1]))
.repeat(Infinity)
.yoyo(true);

动效效果:

礼盒&火箭发光效果

示意效果:

图片转存失败,建议直接上传图片文件

实现方式:

礼盒周边的发光效果,采用两段缓动动画实现,设置光圈静态图片的scale属性和opacity属性,通过两个光圈实例的动画时间差实现礼盒周边发光效果。(1)初始化光圈元素,设置光圈初始化属性,并添加到画布中。

// 初始化光圈元素,设置光圈初始化属性,并添加到画布中
const ringBgSprite = new Sprite(texture);
...
this.ringBgSprite = ringBgSprite;
this._parent.addChildAt(ringBgSprite, 0);

(2)实现光圈缩放、透明度动画。

// 背景光圈scale动画
const Tween3 = new Tween(this.ringBgSprite.scale)
  .to({ x: 1.08, y: 1.08 })
  .easing(Easing.Quadratic.InOut)
  .duration(1250)
  .interpolation(Interpolation.Bezier([0.05, 0.0, 0.3, 1.0]))
  .repeat(Infinity);

(3)通过delay属性设置第二个光圈实例动画的延迟时间为第一个光圈实例播放850ms之后开始播放。


动效效果:

礼盒碰撞后打开效果

示意效果:

图片转存失败,建议直接上传图片文件

图片转存失败,建议直接上传图片文件

实现方式:

使用序列帧实现。

将设计输出的序列帧使用AnimatedSprite库,设置相关参数,实现动画的播放。

监听动画播放完成事件,动画播放完成,销毁AnimatedSprite元素。


体积优化

将序列帧做成雪碧图,减少资源体积,优化动画加载效果。

//初始化动画精灵实例
  animatedSprite = new AnimatedSprite(textureList!);
  // 设置 AnimatedSprite 的位置和播放速度
  animatedSprite.width = this.width;
  animatedSprite.height = this.height;
  animatedSprite.animationSpeed = 0.4; // 0.4由设计师提供
  animatedSprite.autoUpdate = false;
  // 设置动画循环
  animatedSprite.loop = loop || false;
  // 设置动画的位置
  animatedSprite.x = x;
  animatedSprite.y = y;
  animatedSprite.visible = true;
  // 播放动画
  animatedSprite.gotoAndPlay(0);

  // 动画播放完一组后的事件
  animatedSprite.onComplete = () => {
    // console.log('动画播放完一组');
    animatedSprite.visible = false;
    // 销毁序列帧实例
    animatedSprite.destroy();
  };

② 性能优化措施

  • 障碍物缓存池复用纹理实例、减少图形处理中的延迟,提高图形渲染的效率和性能;在障碍物使用完毕销毁的同时,设置缓存池,用于缓存障碍物纹理,若缓存池中无此纹理,将此纹理添加到缓存池当中,并重置纹理状态为初始状态,当创建新的障碍物纹理时,优先从缓存池中寻找是否有满足条件的障碍物纹理,若存在直接使用缓存池的纹理作为新障碍物纹理,设置对应的纹理属性为新的障碍物属性。
  • 序列帧改为缓动动画:减少内存占用,降级包体积
  • 实例及时销毁:销毁不再需要的实例,及时释放资源;每次渲染新的障碍物前,判断已渲染到画布的障碍物所在的位置是否在非视口内且玩家已经滑过的距离范围内,移除并销毁掉障碍物实例释放内存。

(3)角色层与游戏控制

角色层主要包括游戏角色的制作与游戏角色的控制。

① 游戏角色制作

游戏角色有两套皮肤,每套皮肤有6组动作(向前滑行、向左摔倒、向右摔倒、坐火箭、左转身、右转身),所有动作都使用序列帧制作完成,其中左转身和右转身、左摔倒和右摔倒用同一套序列帧,镜像而成。Pixi通过AnimatedSprite播放序列帧。

image.png
默认皮肤序列帧示意

image.png
红色皮肤序列帧示意

② 游戏角色控制

角色控制是指玩家通过左右滑动的方式改变游戏角色的X轴坐标,固定游戏角色的Y轴坐标,从而控制角色在游戏中的移动。难点主要在于左右跑道的切换。

image.png
角色控制是指玩家通过左右滑动的方式改变游戏角色的X轴坐标,固定游戏角色的Y轴坐标,从而控制角色在游戏中的移动。难点主要在于左右跑道的切换。\
主要实现思路是当游戏处理进行中状态时,监听页面的touchstart和touchend事件:

  • 手指触摸屏幕时,记录手指触摸位置在屏幕中X轴的位置slideStartX
  • 手指离开屏幕时,记录手指触摸位置在屏幕中X轴的位置slideEndX,计算差值diffX = slideEndX - slideStartX
    • 如果diffX > 0,表示向右滑(如果当前滑动方向本身就是向右,则不做处理)
    • 如果diffX < 0,表示向左滑(如果当前滑动方向本身就是向左,则不做处理)
    • 滑动时,通过tweenjs缓动函数将玩家移动到左/右的固定位置。

(4)碰撞检测

在本次活动的游戏中,碰撞主要分为两类:障碍物碰撞和路过好友。

① 障碍物碰撞

障碍物分为三类:礼盒、火箭和石头

  • 碰到石头:游戏结束,需用复活卡继续游戏或者重玩本关

<!---->

  • 碰到礼盒:打开礼盒,可能飞出心愿烟花(4种)、头像挂件(3种)、烟雾弹、空
  • 碰到火箭:开启横冲直撞模式,碰到石头不会导致游戏结束

image.png
实现步骤:

  1. 确定碰撞热区。由于障碍物的形状不规则,为了保证用户视觉上能感受到确实撞上了,需要约定一下碰撞的热区(备注:玩家热区应该是53*53,设计稿标注未及时更新)

image.png

  1. 获取玩家和障碍物的坐标

在构建玩家类时,会定义一个方法获取玩家的坐标信息,由于玩家容器的位置是以中心点为基准的,因此在水平方向的左边界x1和右边界x2会在中心点x的基础上加/减去一半的热区宽度(26.5 = 53 / 2)

// 获取玩家产生碰撞检测的位置
public get playerCollisionPosition() {
  return {
    x: this._parent.position.x,
    x1: this._parent.position.x - 26.5, // 左边界
    y: this._parent.position.y + 26.5, // 下边界
    x2: this._parent.position.x + 26.5, // 右边界
  };
}

摆放障碍物时,将障碍物在画布上的实时位置坐标作为障碍物实例的必须属性,即\_X和\_Y。障碍物容器以左上角的的位置为基准,计算方式跟玩家的有差异。

export interface IObstacle {
    _X: number; // 障碍物热区的X
    _Y: number; // 障碍物热区的Y
    ObstacleObjectX: number; // 障碍物的X
    ObstacleObjectY: number; // 障碍物的Y
    ObstacleObjectIndex: number; // 障碍物服务端位置
    _width: number; // 障碍物热区的宽度
    _height: number; // 障碍物热区的高度
    height: number;
    width: number;
    type: number;
    subType: number;
    id: string;
    isNeedCollision: boolean;
    imgElement: string;
    ObstacleObject: Sprite;
    // 碰撞检测
    // 初始化服务端数据
    init: (x: number, y: number, img: string, width: number, height: number) => void;
    // 碰撞效果
    collision: () => Promise<boolean>;
    // 异常
    error: () => void;
    // 销毁
    destroy: () => void;
    // 设置碰撞状态
    needCollision: (val: boolean) => void;
}

// 障碍物的坐标计算 ObstacleHotSize为70
this._X = x + (width - ObstacleHotSize) / 2;
this._Y = y + (height - ObstacleHotSize) / 2;
  1. 碰撞计算。获得了玩家与障碍物的坐标之后,就可以进行碰撞计算了 (判断是否有交叉即可)

玩家的坐标为:

  • 玩家热区的中心点:x

<!---->

  • 玩家热区的左边界:x1
  • 玩家热区的右边界:x2
  • 玩家热区的下边界:y + this.currentRoundDistance

备注:由于玩家实际上只在水平方向移动,y值初始化之后一直是不变的,纵向上是画布在移动,因此做碰撞检测时,为了保证玩家和障碍物的参照物一样,需要将玩家的y加上当前关卡画布移动的距离,也就是this.currentRoundDistance障碍物的坐标为:

  • 障碍物热区的左边界:item.\_X
  • 障碍物热区的右边界:item.\_X + item.\_width(热区的宽度,非实际宽度)
  • 障碍物热区的上边界:item.\_Y
  • 障碍物热区的下边界:item.\_Y + item.\_height(热区的高度,非实际高度)

那么,判断是否有交叉

item._Y <= y + this.currentRoundDistance <= item._Y + item._height &&
((item._X <= x1 <= item._X + item._width) || (item._X <= x2 <= item._X + item._width))

通过以上的步骤,已经能够判断出是否产生碰撞了,但还是存在一些多余的计算,因此需要做下优化。进行碰撞检测时,需要遍历画布可视区上的障碍物列表,那么可考虑的优化点有三个:

  1. 只判断离玩家最近的障碍物即可。(这一步其实障碍物列表已经做了,因为障碍物列表就是根据障碍物的摆放顺序返回的)
  2. 只遍历某一侧的障碍物即可。(如果我们能确定玩家已经位于左边或者右边的滑道,那么只需要计算一侧的就可以,可以新增一个校验条件)

<!---->

export const slideLeftX = 137; // 左跑道中心点的位置
export const slideRightX = 277; // 右跑道中心点的位置

// 判断玩家是否到达了跑道的边界
// item._X < 207表示障碍物在左边的滑道(207=414/2,414是设计稿的宽度,207表示页面中心点的位置,如果礼盒的左边界小于这个值,表示在左滑道,反之在右滑道)
// x === slideLeftX 表示玩家的中心点与跑道的中心点重合,表示玩家在左跑道
const reachedSide = (x === slideLeftX && item._X < 207) || (x === slideRightX && item._X > 207);

// 玩家的中心点既不与左跑道重合,也不与右跑道重合,说明在切换方向中,此时左右跑道上的障碍物都要计算
const checkX = (x !== slideRightX && x !== slideLeftX) || reachedSide;
  1. 同一个障碍物检测一次即可。由于碰撞检测函数是在ticker中执行的,执行会非常频繁,所以会存在一个ticker的时间内同一个障碍物被反复计算的问题,因此在定义障碍物的数据结构时,新增了isNeedCollision的标识(默认为true),如果障碍物产生碰撞,那么isNeedCollision会置为false,下次遍历时会过滤掉这个障碍物

具体实现

// 玩家产生碰撞检测的位置
const { x, x1, x2, y } = this._playerInstance.playerCollisionPosition;
const obstacle = this.hasShowObstaclePool.find((item: IObstacle) => {
  // 判断是否到达了左右两侧的边界
  const reachedSide = (x === slideLeftX && item._X < 207) || (x === slideRightX && item._X > 207);
  const checkX = (x !== slideRightX && x !== slideLeftX) || reachedSide;
  return (
    item.isNeedCollision && 
    checkX && 
    item._Y <= y + this.currentRoundDistance && y + this.currentRoundDistance <= item._Y + item._height && 
    ((item._X <= x1 && x1 <= item._X + item._width) || (item._X <= x2 && x2 <= item._X + item._width)));
});

// 取离玩家最近的障碍物进行碰撞校验
if (obstacle) {
  const index = this.hasShowObstaclePool.findIndex((item) => obstacle === item);
  index > -1 && this.hasShowObstaclePool.splice(index, 1);
  obstacle.needCollision(false);
  const entityType = obstacle.type > ObstacleType.BoX ? ObstacleType.BoX : obstacle.type;
  switch (entityType) {
    case ObstacleType.BoX:
      this.handleBox(obstacle);
      break;
    case ObstacleType.Rocket:
      this.handleRocket(obstacle);
      break;
    case ObstacleType.Stone:
      this.handleStone(obstacle);
      break;
    default:
      obstacle.collision();
      // console.error('撞到了不知道是啥', obstacle);
      return;
  }
}

② 路过好友

是否路过好友相比障碍物的碰撞判断要简单,我们只需要判断纵向方向上是否有交叉即可\
好友的坐标为:

  • 好友的上边界:item.y
  • 好友的下边界:item.y + item.height

判断是否有交叉:item.y <= y + this.currentRoundDistance <=  item.y + item.height

image.png
具体实现

// 找到离玩家最近的好友
const friend: IFriendContainer | undefined = this.hasShowFriendPool.find((item: IFriendContainer) => {
  return (
    item.isNeedCollision &&
    item.y + item.height >= y + this.currentRoundDistance &&
    y + this.currentRoundDistance >= item.y
  );
});
if (friend) {
  friend.isNeedCollision = false;
  this.handleFriend(friend); // 发私信
}

04 游戏状态管理

在游戏开发中,游戏状态管理逻辑用于跟踪游戏的当前状态和目标状态之间的转换,包括游戏主菜单的控制,游戏中,游戏暂停,游戏死亡,游戏过关等状态。

  • 状态定义:首先需要明确定义游戏中可能存在的各种状态,比如主菜单、游戏中、暂停、胜利、失败等。
  • 状态切换:确定触发状态切换的条件,如玩家点击开始游戏按钮会从主菜单状态切换到游戏中所经历的状态,游戏过关会从游戏中状态切换到胜利状态等。
  • 状态处理:针对每个状态,需要编写处理逻辑,比如在游戏中状态需要处理输入、更新游戏对象的状态、渲染游戏画面等。
  • 状态堆栈:有时游戏可能需要支持多个状态的叠加,比如在游戏中状态中打开菜单,这就需要维护一个状态堆栈以正确处理叠加状态之间的转换和交互。
  • 状态持久化:有些游戏可能需要在玩家离开游戏时保存当前状态,以便下次继续游戏,因此需要考虑状态的持久化和恢复逻辑。

本次滑雪游戏基于以上5个管理逻辑的设计,游戏的状态管理设计为5个层级进行调度,UI效果层、用户交互层、事件处理层、游戏层、数据管理层。

image.png

(1)游戏状态机设计

DEFAULT = 0, // 默认状态
INITE = 1, // 初始化状态,app创建,资源和布局还未就绪
READY_INIT = 2, // 背景、角色初始化完成
READY_DATA = 3, // 障碍物、好友、道具等从服务端获取的数据就绪
READY = 4, // 就绪状态,可以开始玩游戏
COUNTDOWN = 5, // 读秒倒计时
RUNING = 7, // 游戏进行状态
STOPED = 8, // 游戏暂停状态
OVER = 9, // 游戏结束状态

设置统一的方案管理游戏状态的变化,主要状态转换逻辑如下图所示。
image.png

(2)游戏通信设计

  • 游戏实例和用户交互层采用事件总线(eventBus)的方式进行通信。

<!---->

// game通知UI
  GAME_OVER = 'GAME_OVER', // 游戏死亡,参数是{ level: '当前关卡', isContinue: '是否是继续滑行' }
  TOOL_UPDATE = 'TOOL_UPDATE', // 道具效果更新(类如横冲直撞),参数{toolType: '道具类型', objIndex: '道具位置', objId: ' 道具id'}
  UPDATE_DATA = 'UPDATE_DATA', // 布局信息不足,需要从服务端获取新的数据,不需要传参数,参数统一model层管理
  START_COUNTDOWN = 'START_COUNTDOWN', // 开始倒计时,参数是倒计时时间
  START_NEW_LEVEL = 'START_NEW_LEVEL', // 开启新的一关,参数是关卡信息, 参数是{ level: '当前关卡'}
  ARRIVAL_LEVEL = 'ARRIVAL_LEVEL', // 到达关卡
  USE_SMOKE = 'USE_SMOKE', // 使用烟雾弹,透传用户信息 , 参数是{ userInfo: '当前关卡'}
  GAME_READY = 'GAME_READY', // 游戏初始化完成
  // UI通知game
  START_GAME = 'START_GAME', // 倒计时结束或进入游戏后的关卡动画播放结束
  STOP_GAME = 'STOP_GAME', // 停止游戏
  RESUME_GAME = 'RESUME_GAME', // 唤醒游戏 参数是{ resumeType: '0-开始游戏;1-重玩本关, 2-原地复活, 3-下一关 }
  START_NEW_LEVEL_END = 'START_NEW_LEVEL_END', // 新关提示结束
  HANDLE_SKIN = 'HANDLE_SKIN',
  UPDATE_GAME_CONFIG = 'UPDATE_GAME_CONFIG', // 更新速度和角度配置
  • 游戏实例和ui效果层采用props / $emit方式进行通信。

image.png

  • 数据管理层采用store状态管理维护游戏数据。

数据层主要用户处理游戏数据,核心包括障碍物数据的处理,和游戏相关配置处理。游戏数据兜底数据处理:滑雪游戏中,游戏启动后,没一帧的屏幕刷新都会产生游戏画布的渲染以及游戏状态的更新,因此游戏相关的数据时效性对游戏的状态影响较大。数据的时效性主要是障碍物数据的接口请求延迟和数据的更新延迟。\
存在的问题:(1)请求数据接口的延迟导致玩家在玩的过程中会存在障碍物数据和玩家所在位置的游戏数据不匹配的问题(2)玩家经过的路段无对应的障碍物数据。\
数据处理方案:数据预缓存和兜底数据。

  1. 数据****预缓存:开启游戏的时候,设置一个障碍物缓存数据,并设置缓存区大小的最小阈值,在游戏中,不断的从缓存区中取障碍物数据进行渲染,并检测当前缓存区的障碍物数据是否小于最小阈值,当小于最小阈值时,通知数据接口请求数据并补充到缓存区中。

image.png

最小阈值(minSize) 的设置逻辑:因不同关卡的玩家速度是不同的,因此在接口响应时间(RequestTime)内,可经过的障碍数据是不同的,因此需要将缓存区缓存的障碍物数据大小与速度相关联,根据不同的速度动态调整缓存区的大小,除此之外,在滑行过程中会存在横冲直撞的道具使用会在常规速度(Speed)基础上进行加成,因此我们以玩家实时的最大加成速度(BufferSpeedRatio)作为计算最小阈值的变量。

image.png

  1. 兜底 数据:本地存储对于每个关卡设置一份中难度的兜底数据作为接口请求超时或者接口请求异常的兜底数据(兜底数据仅包含石头),并设置障碍物接口请求的超时时间,本次项目设置的超时时间为2s(可根据接口的实际耗时时间进行调整)。

我们本次的障碍物数据返回格式是每次返回一段跑道的障碍物数据,数据形式如下:

export type IGameMaps = {
    startIndex: number; // 地图开始位置
    endIndex: number; // 地图结束位置
    itemList: IObstacleItem[];
};
export type IObstacleItem = {
    index: number; // 纵向位置数(格子)
    runway: number; // 横向位置数(格子)
    itemType: number; // 物品类型:1-障碍物 2-横冲直撞 3-复活卡 4-皮肤碎片 5-心愿烟花 6-头像框 7-空宝箱
    treasureBoxId: string; // itemType不为1; 宝箱ID
    subType: number; // "itemType": 5  烟花/挂件类型
};

我们设计的兜底数据是50个格子的障碍物数据。(index:0-50),每次接口请求成功时,保存当前已有数据的最后一个数据的位置(lastDataEndIndex),当接口请求异常或者超时时采用兜底数据时,在兜底数据中每个障碍物的纵向位置上加上lastDataEndIndex,Mock为请求的路段数据添加到缓存区中。\
格子:在本次滑雪中,为了方便根据策略摆放障碍物,我们根据每个障碍物的尺寸将游戏画布划分为多行两列的格子布局,每个格子可摆放一个障碍物。根据摆放策略将下发的障碍物摆放到指定的位置上。相互换算:1格子=30米=120px

image.png

三、总结

本文主要阐述了基于Pixi引擎搭建类3D游戏的全过程,包括从零开始的实践历程以及在此过程中遇到的技术挑战及解决方案。与此同时,我们一直在探索更加趣味多样的游戏玩法,期待能与更多同学一起讨论、研究并分享前端游戏的应用实现、优化手段以及底层原理。

本文作者:阮叶丽

如果您在阅读这篇文章后深感其价值,恳请您慷慨点赞。您的每一次认可和鼓励,都将成为我们不断前行的动力!


快手技术
7 声望3 粉丝