本文首发于:kapeter.com/post/29
前言
近两年,网络平台掀起了一股互动游戏热潮。三大电商平台都不约而同地推出来一些互动小游戏(种树、养宠物、大富翁等),其目的在于通过趣味游戏提高APP日活,增强用户粘性,进而转化成订单。在这种趋势下,前端中的细分领域——WebGL成了新的技术热点。淘系技术部推出了互动引擎EVAJS,蚂蚁金服推出了Web 3D 引擎Oasis Engine,相信后续还会有其他的框架推出。为了不被时代淘汰,笔者也开始研究这部分知识。本文将以Pixi
作为渲染引擎,使用DragonBones
骨骼动画,打造一个简易的游戏Demo。
基本概念
Pixi.js
Pixi
这个不用多做介绍,大名鼎鼎的HTML5 2D渲染引擎,完善的技术文档,丰富的API,众多的插件,适合WebGL初学者入门学习。
DragonBones
DragonBones
是由白鹭时代(Egret)推出的2D 骨骼动画解决方案。相较于竞品Spine
(EVAJS使用的动画方案),DragonBones
最大的优势在于它是免费的,适合个人开发这学习使用。
简单介绍一下基本概念,理解了这些概念,也就能理解DragonBones
的数据结构。
- 骨架(armature):骨架是骨骼的集合,骨架中至少包含一个骨骼。一个项目中可以包含多付骨架。
- 骨骼(bone):骨骼是骨骼动画的基本组成部分。骨骼可以旋转,缩放,平移。
- 插槽(slot):插槽是图片的容器,是骨骼和图片的桥梁。主场景中,图片的层次关系由插槽在层级面板的层次关系体现。
- 图片(texture):图片是最基本的设计素材,图片需要以插槽为中介来和骨骼绑定,在webGL中也叫纹理。
动画素材准备
开发首先需要一些视觉素材,我们首先去官网下载并安装DragonBones
的编辑器。
安装好后,打开软件,在欢迎界面有一些学习资源供我们使用。我们随便选择一个素材打开,就进入了编辑界面。
可以看到,官方已经把我们做好了全部工作,我们不需要再对素材进行编辑,直接选择菜单栏中的“文件”->“导出”,会弹出一个导出弹框。数据类型选择JSON,勾选“打包zip”,点击“完成”,我们就能得到一个zip包。解压之后,里面有三个文件,一个png文件,两个JSON文件,这就是后续项目中需要用到的素材。
创建项目
因为demo不需要引入业务组件,所以就用create-react-app
快速创建一个项目。
npx create-react-app dragonBones-demo
为什么使用React,而不是直接使用游戏引擎?这主要基于业务考虑:在电商互动游戏中,游戏只占项目本身的一部分,其他还涉及商品、分享、发券等逻辑。如果使用游戏引擎,则无法复用团队内部这些业务组件,导致开发周期大幅提高。如果你是新团队或者专门的游戏团队,没有技术包袱,可以考虑直接使用游戏引擎。
在写代码之前,我们需要引入基本的运行库。通过研究DragonBones
运行库代码,我遗憾地发现运行库并不支持NPM引入,需要通过CLI的方式生成对应版本的运行库。我使用Pixi
,因此需要生成的是Pixi
对应的运行库。
根据官方的文档,我们全局安装dragonbones-runtime
。然后执行dbr <engine-name>@<version>
即可在执行命令的目录下的dragonbones-out
目录下生成该引擎依赖的 运行库:
npm install -g dragonbones-runtime
dbr pixijs@4.6.2
这里遇到一个问题,目前Pixi
稳定版本是5.0,我们希望dragonbones
运行库也支持5.0,但dbr
的提示是目前不支持5.0版本。真的是这样吗?
通过对DragonBonesJS代码仓库的分析,我们可以看到有5.0的版本,我这边猜测是CLI未更新导致的信息不一致。所以我们不通过CLI,而且直接下载代码自行打包,就能获得最新的运行库代码。然后,按照项目readme.md
的介绍进行打包。
打包完之后,我们把运行库和PIXI
文件放在项目的public文件夹下,通过<script></script>
标签引入。
<script src="%PUBLIC_URL%/libs/pixi.min.js"></script>
<script src="%PUBLIC_URL%/libs/pixi-sound.js"></script>
<script src="%PUBLIC_URL%/libs/dragonBones.min.js"></script>
最后,把我们准备好的视觉、音频、骨骼动画等素材也放到项目的public文件夹下,前期准备工作就完成了。
整体项目构建
我构想出的页面流程分为四部分:加载资源->游戏倒计时->游戏进行->游戏结束。
根据这四个步骤,我初步划分出四个组件(或页面):
- Loading
- CountDown
- Game
- GameOver
Loading(加载组件)
我们需要一个progress
变量来知道当前资源加载进度,当progress
加载到100,就说明资源加载完成,我们可以关闭加载页面,进入下一步。
const [progress, setProgress] = useState(0);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (progress >= 100) {
// 延迟可以让进度条动画到了100%之后才消失,也可以给渲染提供一点时间
setTimeout(() => {
setIsLoading(false);
}, 200);
}
}, [progress]);
CountDown(倒计时组件)
loading就开始游戏,用户会反应不过来,这里加一个三秒倒计时蒙层盖在游戏画面上。当倒计时结束,游戏开始。
const [countDown, setCountDown] = useState(3);
const [isPlaying, setIsPlaying] = useState(false);
useInterval(() => {
setCountDown(countDown - 1);
}, (countDown > 0 && !isLoading) ? 1000 : null);
useEffect(() => {
if (countDown <= 0) {
setIsPlaying(true);
}
}, [countDown]);
Game(游戏组件)
资源加载进度、游戏进程其实都需要游戏组件来控制。得益于Hooks,我们只要把函数传进去就能管理这些状态。当然你也可以使用统一的数据管理,如redux等。对于我们这个demo来说,这种方式足够了。
<Game
setProgress={setProgress}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
setHeadCount={setHeadCount}
/>
GameOver(结束弹框)
我们通过isPlaying
来判断是否展示。然后在弹框的按钮绑定一个点击事件(replay
),完成流程循环。
useEffect(() => {
// isPlaying在一开始是false,但我们不希望游戏还没开始,就出现这弹框,这里做个累加器
if (isPlaying) {
gameCount++;
} else {
if (gameCount > 0) {
setShowGameOver(true);
}
}
}, [isPlaying]);
游戏模块
接下来,我们来实现游戏模块,这也是本文的重点。
初始化
Html方面,我们只需要创建一个div容器,给一个id就行。
<div id="my-canvas" className="my-canvas"></div>
在页面加载好后,执行游戏初始化。
useEffect(() => {
const state = { setHeadCount, headCount };
init(props, state);
}, []);
/**
* @description 游戏初始化
* @param {*} props
* @param {*} state
*/
function init(props, state) {
app = new PIXI.Application({
backgroundColor: 0x7976b6
});
if (document.getElementById("my-canvas") && app) {
document.getElementById("my-canvas").appendChild(app.view);
}
// 屏幕适配
detectOrient();
// 挂载props到app上
app.reactProps = props;
app.reactState = state;
app.loader.add([
{ name: 'bg', url: `${process.env.REACT_APP_RES_PATH}resources/bg.png` },
{ name: 'swordsManBonesData', url: `${process.env.REACT_APP_RES_PATH}resources/SwordsMan/SwordsMan_ske.json` },
{ name: 'swordsManTexData', url: `${process.env.REACT_APP_RES_PATH}resources/SwordsMan/SwordsMan_tex.json` },
{ name: 'swordsManTex', url: `${process.env.REACT_APP_RES_PATH}resources/SwordsMan/SwordsMan_tex.png` },
{ name: 'bgSound', url: `${process.env.REACT_APP_RES_PATH}resources/bg.mp3` },
//…… 省略一堆资源列表
]);
app.loader.on("progress", ({ progress }) => {
app.reactProps.setProgress(progress.toFixed(2));
});
app.loader.once("complete", setup, this);
app.loader.load();
}
在init
函数中,我们做了以下几件事:
- 通过
PIXI.Application
创建一个画布,并挂载到我们刚才设置的div容器中; - 屏幕适配,这个下文详细讲述;
- 把react的
props
和state
挂载到app
对象上,后续操作起来比较方便; - 使用
PIXI.Loader
加载游戏资源,在progress
事件中,把当前进度传递给Loading 组件,在complete
事件中,触发setup
函数。
素材装载
setup
函数用来把加载好的素材加入到画布上,然后启动游戏。
我们游戏一共三个元素,我们一个个来加载。
/**
* @description 启动游戏
* @param {*} target
* @param {*} resource 资源列表
*/
function setup(target, resource) {
addBg(resource);
addMonster(resource);
addMaster(resource);
play();
}
游戏背景(平铺精灵)
平铺精灵(TilingSprite
)是一种特殊的精灵,可以在一定的范围内重复一个纹理。我们可以使用它们创建无限滚动的背景效果。
/**
* @description 加入背景
* @param {*} resource
*/
function addBg(resource) {
const textureImg = resource["bg"].texture;
tilingSprite = new PIXI.TilingSprite(textureImg, 960, 375);
tilingSprite.position.y = getY(0);
tilingSprite.position.x = 0;
app.stage.addChild(tilingSprite);
}
事物运动都是有参照物的。因此我们想制造一个人物前进的效果,我们有两种做法,第一种,人物向右运动,背景不动;第二种,人物不动,背景向左运动。根据这个原理,我们就可以在不改变人物位置的情况下,实现前进效果。
游戏主角(骨骼动画)
现在,我们来装载第一个骨骼动画。
const dragonbonesFactory = dragonBones.PixiFactory.factory; //新建骨骼动画制作工厂
let swordsManDisplay = null;
/**
* @description 设置角色
* @param {*} resource 资源列表
*/
function addMaster(resource) {
let textureImg = resource["swordsManTex"].texture;
let textureData = resource["swordsManTexData"].data;
let skeletonData = resource["swordsManBonesData"].data;
//骨骼动画实现
dragonbonesFactory.parseDragonBonesData(skeletonData); //解析骨骼数据
dragonbonesFactory.parseTextureAtlasData(textureData, textureImg); //解析纹理数据
swordsManDisplay = dragonbonesFactory.buildArmatureDisplay(skeletonData.armature[0].name); //构建骨骼动画
swordsManDisplay.x = 200;
swordsManDisplay.y = getY(350);
swordsManDisplay.scale.set(0.25, 0.25);
swordsManDisplay.animation.play('steady', 0); //执行动画
app.stage.addChild(swordsManDisplay);
}
在dragonBones
中,由工厂类(Factory
)管理骨骼动画。需要注意两点:
- 当使用一个 Factory 时,需要注意避免龙骨数据或骨架数据重名。
- 如果没有特殊需求,建议不要使用多个 Factory 实例
所以,我们这边先复制一个PixiFactory
对象。然后使用工厂类的parseDragonBonesData
和parseTextureAtlasData
解析已经加载好的资源文件,然后构建出一个显示对象(DisplayObject
),这个对象同时继承了PIXI
的DisplayObject
对象和dragonBones
的BaseObject
对象,可以使用两者的方法。这也是我们主要的操作对象。由于包含的类实在太多,这里就不一一介绍,有兴趣的可以查看官方API文档。
装载好后,我们调整这个人物的位置和大小,使之贴合背景。
然后,我们给这个人物一个默认动作,执行显示对象的animation
属性上的play
,并把执行次数设置成0(循环播放)。
最后,把这个显示对象加入画布,一个做着待机动作的机器人就出现在画面上。
游戏怪物(骨骼动画)
怪兽的装载和主角基本一致。
值得一提的是,怪兽在主角右边,我们希望他面向主角放技能,这样更符合逻辑。我们需要对骨骼进行一个水平翻转:设置armature
的flipX
属性为true
,即可完成。同理,设置armature
的flipY
属性为true
,即可完成垂直翻转。
/**
* @description 加载怪兽
* @param {*} resource 资源列表
*/
function addMonster(resource) {
// ...省略重复代码
demonDisplay.armature.flipX = true;
// ...省略重复代码
app.stage.addChild(demonDisplay);
}
游戏流程
play
是控制游戏进行的核心函数,通过requestAnimationFrame
进行循环调用。
/**
* @description 游戏
*/
function play() {
if (app.reactProps.isPlaying) {
// 游戏开始,变动初始动作
if (swordsManDisplay.animation.lastAnimationName === 'steady') {
swordsManDisplay.animation.play('walk', 0);
}
if (!attackState.isPlaying && !jumpState.isPlaying) {
// 背景滚动
tilingSprite.tilePosition.x -= 5;
// 重置怪物
if (demonDisplay.x < -150) {
demonDisplay.x = getX(parseInt(Math.random() * 400));
demonDisplay.animation.play('uniqueAttack', 0); //执行动画
} else {
demonDisplay.x -= 5;
}
}
// 判定结束游戏
if (isHit(250) && !attackState.isPlaying && demonDisplay.animation.lastAnimationName !== 'dead') {
app.reactProps.setIsPlaying(false);
app.reactProps.setHeadCount(app.reactState.headCount);
app.reactState.setHeadCount(0);
swordsManDisplay.animation.play('steady', 0);
demonDisplay.x = clientWidth + parseInt(Math.random() * 400);
}
}
requestAnimationFrame(play);
}
该函数主要做了以下几件事:
- 判断机器人的前一个动作是不是待机动作,如果是,则要把机器人的动作设置成走路,表明游戏开始。该操作在游戏周期中只执行一次;
- 控制背景滚动,通过视差产生人物往前走的效果;
- 当怪物超出屏幕范围时,重置它的状态和位置,使之可以重复利用,较少开销。这里可以理解为对象池的简单应用;
- 游戏结束条件判定,当机器人与怪物产生碰撞时,且机器人未做出攻击动作,则游戏结束,弹出游戏结束弹框。
碰撞检测
原本打算使用dragonBones
提供的containsPoint
方法和intersectsSegment
方法进行碰撞检测,但涉及到本地坐标系和世界坐标系的转换,官方Demo也不是很清楚,尝试了很多次,都没碰撞成功。
我这边使用一个比较取巧的方法进行检测。因为每一个插值(slot)也是一个displayObject
,我就可以调用PIXI
上的方法,获取它的世界坐标,然后比较它的X值。
function isHit(x) {
const target = demonDisplay.armature.getSlot('body').display;
const bounds = target.getBounds();
return bounds.x < x;
}
动作交互
我在游戏中设置了两个动作:jump
和attack
。在没框架帮助的情况下,使用PIXI
开发HUD比较麻烦,所以,我这边用DOM直接写了两个按钮。
重点来看,attack
函数的实现。
/**
* @description 攻击动作
*/
function attack() {
if (!attackState.isPlaying) {
playSound('attackSound');
attackState = swordsManDisplay.animation.gotoAndPlayByFrame('attack1', 20, 1); //执行动画
if (isHit(500) && demonDisplay.animation.lastAnimationName !== 'dead') {
demonDisplay.animation.play('dead', 1);
app.reactState.setHeadCount(++app.reactState.headCount);
}
}
}
attackState
记录了当前动画的状态。我做了一个防频,当攻击动作未结束的时候,跳过本次点击事件。
然后执行以下三个操作:
- 播放效果音;
- 执行攻击动作,并把动画状态赋值给
attackState
。这里使用了一个新的播放函数:gotoAndPlayByFrame
,它控制动画从哪一帧开始播放,使得两个动作衔接更自然; - 碰撞检测,当碰撞到怪物且怪物还处于活跃状态,则算击杀怪物,怪物执行
dead
动作,人头数加一。
一个攻击动作执行完后,我们需要进行复位。可以在装载人物的时候,给人物添加一个动作执行完成(COMPLETE
)事件。这样,我们就不需要每次都手动复位初始动作了。
这里,我发现一个小问题,dragonBones
好像在重复使用动画状态的内存空间,导致attackState
值一直在变。为了防止出现混淆的情况,每次执行完动作后,就把这块空间释放掉。
swordsManDisplay.on(dragonBones.EventObject.COMPLETE, () => {
swordsManDisplay.animation.play('walk', 0);
// 似乎这块空间是公用的
attackState = {};
jumpState = {};
});
音乐模块
音乐部分,我们借助pixi-sound
这个官方插件来完成,也是通过<script></script>
引入,注意它需要在PIXI
之后。
然后封装两个方法:playSound
和stopSound
,就能控制所有声音的播放,我这边就两个:背景音和主角的攻击声。
/**
* @description 播放声音
* @param {*} name 资源名
* @param {boolean} [loop=false] 是否循环
*/
function playSound(name, loop = false) {
const sound = app.loader.resources[name].sound;
sound.play({
loop
});
}
/**
* @description 暂停声音
* @param {*} name 资源名
*/
function stopSound(name) {
const sound = app.loader.resources[name].sound;
sound.stop();
}
需要注意的是,chrome禁止声音自动播放,需要用户出现交互时,才能播放,所以我们在右上角做了一个开关控制背景音。攻击声音本来就是需要交互触发,所以不需要考虑这个。
横屏适配
因为我们是横屏游戏,所以需要对竖屏的情况进行强制横屏。
这里借鉴凹凸实验室的实践,对resize
事件进行监听,当屏幕是竖屏的时候,整个画面进行90度旋转。
const detectOrient = function () {
let width = document.documentElement.clientWidth,
height = document.documentElement.clientHeight,
$wrapper = document.getElementById("app"),
style = "";
if (getOrientation() === 'landscape') { // 横屏
style = `
width: ${width}px;
height: ${height}px;
-webkit-transform: rotate(0); transform: rotate(0);
-webkit-transform-origin: 0 0;
transform-origin: 0 0;
`;
} else { // 竖屏
style = `
width: ${height}px;
height: ${width}px;
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
-webkit-transform-origin: ${width / 2}px ${width / 2}px;
transform-origin: ${width / 2}px ${width / 2}px;
`
}
$wrapper.style.cssText = style;
}
useEffect(() => {
detectOrient();
}, []);
useEventListener('resize', detectOrient);
调研了几个判断屏幕方向的函数,其他API或多或少有点兼容性问题,我这边选择使用mediaQuery进行判断。
export function getOrientation() {
const mql = window.matchMedia("(orientation: portrait)")
return mql.matches ? 'portrait' : 'landscape';
}
虽然,画面旋转了90度,但我们的游戏画布并不是随之旋转的,我们需要单独调整。
function detectOrient() {
clientWidth = document.documentElement.clientWidth;
clientHeight = document.documentElement.clientHeight;
if (getOrientation() == 'portrait') {
app.renderer.resize(clientHeight, clientWidth);
} else {
app.renderer.resize(clientWidth, clientHeight);
}
}
通过PIXI
的renderer
对象对整个画布重绘。此时,发现画布上的元素都发生了错位,我们需要根据屏幕方向调整位置。
/**
* @description 获取相对位置
* @param {*} y
* @returns {*}
*/
function getY(y) {
return getOrientation() === 'landscape' ? clientHeight - 375 + y : clientWidth - 375 + y;
}
/**
* @description 获取相对位置
* @param {*} x
* @returns {*}
*/
function getX(x) {
return getOrientation() === 'landscape' ? clientWidth + x : clientHeight + x;
}
至此,整个游戏就完成了。
部署上线
如果你是发布到网站根目录,可以直接略过这一部分。
如果你是发布到网站根目录,可以直接略过这一部分。
本地一切正常,但当我打包上传到服务器上时,问题出现了。由于我发布的地址带路径(比如xxx.com/xxx/index.html
),在第一步中引入的js路径就变成了:xxx.com/libs/pixi.min.js
,但实际地址是:xxx.com/xxx/libs/pixi.min.js
。修改方法也很简单,我们只要修改PUBLIC_URL
即可。
根据create-react-app
文档,我们在项目根目录创建.env.production
文件,里面添加两行:
PUBLIC_URL=https://xxx.com/xxx
REACT_APP_RES_PATH=/xxx
第一行是来修改index.html
中PUBLIC_URL
的指向,第二行来修改项目中的静态资源前缀。
当然这只是一个简单的处理,在实际项目中,我们可以通过工程化的手段来解决这些问题,比如部署CDN。
总结
本文基于React + Pixi + DragonBones做了一个简单的游戏demo,基本走通了2D游戏开发流程,可以为后续的项目开发提供一些经验教训。
在开发过程中,我也遇到一些问题,比如:
- 运行库不支持
NPM
,需要通过标签引入; DragonBones
官方文档不够完善,且长时间未更新,导致踩坑过程十分艰难;- WebGL国内还算一个细分领域,相关的文章较少,需要自行摸索,或者看英文论坛。
后续,我也将继续探索学习,比如引入前端工程化、尝试其他骨骼动画方案(比如Live2D、Spine)等,解决开发中的痛点,真正将WebGL技术应用于实际业务。欢迎有同样兴趣的同学一起参与讨论。
参考资料
- DragonBones官方文档
- DragonBonesJS代码仓库
- PixiJS API Documentation
- H5游戏开发:横屏适配
- 学习 PixiJS — 视觉效果
本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。
在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。
本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。
除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。
在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!
代办报建
本公司承接江浙沪报建代办施工许可证。
联系人:张经理,18321657689(微信同号)。
11条评论
这里的资源非常丰富,帮助我解决了很多问题。http://x6zbw.http://www.whitebisonar.com
这么好的帖子,应该加精华!http://dvmx.tgd9.cn
你觉得该怎么做呢?http://m15hed.qubaa.net
刚看见一个妹子,很漂亮!http://8g0.youngartsy.com
支持一个http://0xo.rgsyhs.com
楼主是一个典型的文艺青年啊!https://ys.seo998.com/vodtypehtml/omejjp.html
楼主写的很经典!https://ys.seo998.com/vodtypehtml/kwzjkp.html
写的太好啦,评论一个http://www.guangcexing.net/voddetail/QVfqPgC.html
这个帖子好无聊啊!http://www.guangcexing.net/voddetail/NngyVgDMkn.html
楼主英明!https://i4-pc.com
林子大了,什么鸟都有了啊!https://www.skypeis.com/
发表评论