导读
2024年元旦期间,快手推出了一款名为“驭雪冲锋赛”的滑雪竞技游戏,该游戏以Pixi渲染引擎为基础,通过精心设计的多样化玩法,为用户带来有趣的社交互动体验。
全文共8912字,预计阅读时间20分钟。
#
项目背景
项目背景
#
游戏现实
01 游戏玩法介绍
02 游戏引擎选型
03 游戏分层实现
(1)背景层
(2)道具、障碍物层
(3)角色层与游戏控制
(4)碰撞检测
04 游戏状态管理
(1)游戏状态机设计
(2)游戏通信设计
#
总结
总结
2024年元旦期间,快手推出了一款基于Pixi渲染引擎的滑雪竞技游戏"驭雪冲锋赛",该项目通过丰富多样的玩法设计,为用户带去更加有趣的社交体验。项目包括首页、滑雪竞技和许愿星空三大板块。用户在滑雪冲关的过程中,可获得各种道具和奖励,如“烟雾弹”、“横冲直撞”、许愿烟花、头像挂件以及现金红包等。使用“烟雾弹”可对好友投掷烟雾干扰;使用“横冲直撞”,可以在滑行过程中无视障碍,快速滑行;而许愿烟花则可在许愿星空中许下新年心愿。本文将从前端开发的视角,重点探讨该项目中滑雪游戏部分在开发中遇到的技术难点及相关解决思路。
01 游戏玩法介绍
滑雪游戏一共分为6个关卡,每个关卡都配置了不同的滑行速度、目标滑行距离、障碍物布局以及获得奖品的概率,其中第6个关卡为无限火力关卡,此关卡不设置目标滑行距离,主要是提供给玩家用于刷新排名。游戏玩法的核心在于玩家手势操控的灵活度,玩家需要通过左右滑动来控制角色的滑动方向以此来与障碍物产生交互。障碍物主要分为三类:石头、礼盒和火箭:
碰到石头:非横冲直撞模式下碰到石头会导致游戏结束,需要使用复活卡才能继续游戏或者重头开始玩本关;
碰到礼盒:礼盒打开获得随机奖励(心愿烟花、头像挂件、烟雾弹或者什么都没有)
碰到火箭:开启一定时限(比如10s)的横冲直撞模式,此模式下可以畅通无阻,并且获得的奖励会正常叠加。
当玩家进入无限火力关卡后,可以根据活动的奖励规则(在活动结算周期内排名全服榜前3000名的玩家可以均分十万元大奖)继续滑行来刷新排名。
02 游戏引擎选择
03 游戏分层实现
(1)背景层
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);
/**
* @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);
}
(2)道具、障碍物层
① 障碍物实例创建
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);
// 初始化光圈元素,设置光圈初始化属性,并添加到画布中
const ringBgSprite = new Sprite(texture);
...
this.ringBgSprite = ringBgSprite;
this._parent.addChildAt(ringBgSprite, 0);
// 背景光圈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);
实现方式:
使用序列帧实现。
将设计输出的序列帧使用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)角色层与游戏控制
① 游戏角色制作
② 游戏角色控制
(4)碰撞检测
① 障碍物碰撞
// 获取玩家产生碰撞检测的位置
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, // 右边界
};
}
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;
item._Y <= y + this.currentRoundDistance <= item._Y + item._height &&
((item._X <= x1 <= item._X + item._width) || (item._X <= x2 <= item._X + item._width))
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;
// 玩家产生碰撞检测的位置
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;
}
}
② 路过好友
// 找到离玩家最近的好友
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 游戏状态管理
(1)游戏状态机设计
DEFAULT = 0, // 默认状态
INITE = 1, // 初始化状态,app创建,资源和布局还未就绪
READY_INIT = 2, // 背景、角色初始化完成
READY_DATA = 3, // 障碍物、好友、道具等从服务端获取的数据就绪
READY = 4, // 就绪状态,可以开始玩游戏
COUNTDOWN = 5, // 读秒倒计时
RUNING = 7, // 游戏进行状态
STOPED = 8, // 游戏暂停状态
OVER = 9, // 游戏结束状态
(2)游戏通信设计
// 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', // 更新速度和角度配置
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 烟花/挂件类型
};
本文主要阐述了基于Pixi引擎搭建类3D游戏的全过程,包括从零开始的实践历程以及在此过程中遇到的技术挑战及解决方案。与此同时,我们一直在探索更加趣味多样的游戏玩法,期待能与更多同学一起讨论、研究并分享前端游戏的应用实现、优化手段以及底层原理。
本文作者:阮叶丽
快来聊聊
有奖互动:H5小游戏,你有哪些经验?留下你的H5小游戏开发经验或故事,不论是遇到的技术挑战、独特的创意点子,还是那些让人捧腹大笑的编程趣事,欢迎评论留言。我们将选取1条优质评论,送出快手小六公仔1个(下图随机一款)。评论截止6月17日中午12点。
如果您在阅读这篇文章后深感其价值,恳请您慷慨点赞。您的每一次认可和鼓励,都将成为我们不断前行的动力!