阿里开源游戏引擎 Hilo

HelloWorld

Hilo 的库是模块化的,下载地址:https://github.com/hiloteam/Hilo/tree/dev/build

Hilo 除了提供一个独立无依赖的版本(Standalone)外,还提供AMD、CommonJS、CMD、CommonJS等多种模块范式的版本。开发者可以根据自己的习惯,下载Hilo的不同范式版本使用。

hilo/
└── build/
    ├── standalone/     #Standalone独立版本
    ├── amd/            #RequireJS(AMD) 版本
    ├── commonjs/       #CommonJS 版本
    ├── kissy/          #SeaJS(CMD) 版本
    └── cmd/            #Kissy 版本

我们暂不考虑其他范式版本,引入 hilo-standalone 独立版本。

<script type="text/javascript" src="hilo-standalone.js"></script>

最基础的 Hilo 必须包含舞台(Stage), 定时器(Ticker),可是对象(View):

<body onload="init();">
    <div id="game-container"></div>
    <script type="text/javascript">
        function init(){
            //初始化舞台
            var stage = new Hilo.Stage({
                renderType: 'canvas',
                container: 'game-container',
                width: 320,
                height: 480
            });

            //启动舞台定时器
            var ticker = new Hilo.Ticker(20);
            ticker.addTick(stage);
            ticker.start();

            //创建一个图片对象
            var bmp = new Hilo.Bitmap({
                image: 'fish.png',
                rect: [0, 0, 174, 126],
                x: 75,
                y: 50
            }).addTo(stage);
        }
    </script>
</body>

上面我们将一个长图(包含很多精灵的动作)显示第一帧,然后将舞台绑定到 game-container 中, 实际上在里面创建了一个 canvas 对象。

可视对象

Hilo 中提供的可视对象类有:

  • View - 可视对象基类。包含可视对象的基本属性和和方法。
  • Container - 容器类。可包含子对象,实现容器或组的功能。
  • Stage - 舞台类。它是一个特殊的容器,所有可视对象都必须添加到舞台才会显示出来。
  • Bitmap - 位图类。最简单的可视对象,只显示一个图片。
  • Sprite - 精灵类。多帧图片组成的动画序列。
  • Graphics - 绘图类。使用canvas绘图API绘制各种形状的类。
  • DOMElement - DOM元素类。可以像其他可视对象一样来控制和管理DOM元素。
  • Text - 简单文本类。简单的canvas文本显示类。
  • Button - 简单按钮类。实现简单图片按钮功能的类。

比如我们显示一个文本:

var txt = new Hilo.Text({
    text: '显示了一段文字',
    color: 'red',
    align: Hilo.align.TOP_RIGHT
}).addTo(stage);

接下来我们来创建一个精灵对象:

//定义纹理集合图片
var atlas = new Hilo.TextureAtlas({
    image: 'fish.png',
    width: 174,    //纹理图片的宽度
    height: 1512,  //纹理图片的高度
    frames: {
        frameWidth: 174,   //帧宽
        frameHeight: 126,  //帧高
        numFrames: 12      //帧数量
    },
    sprites: {     //定义精灵对象
        fish: {from:0, to:7}  //起始索引和末尾索引
    }
});

//创建一个精灵对象
var sprite = new Hilo.Sprite({
    frames: atlas.getSprite('fish'),
    x: 0,
    y: 100,
    interval: 6,   //精灵动画的帧间隔, 如果timeBased为true,则单位为毫秒,否则为帧数。
    timeBased: false, //指定精灵动画是否是以时间为基准。默认为false,即以帧为基准。
    loop: true,  //判断精灵是否可以循环播放。默认为true。
    //更新可视对象,此方法会在可视对象渲染之前调用。
    onUpdate: function(){
        if(this.x > stage.width - this.pivotX){
            this.x = 0;
        }else{
            this.x += 3;
        }
    }
}).addTo(stage);

创建精灵对象的 frames 对象有一个快捷方法,如下:

[Static] createSpriteFrames(name:String|Array, frames:String, w:Number, h:Number, loop:Boolean, duration:Number, duration)

  • name:String|Array — 动画名称|一组动画数据
  • frames:String — 帧数据 eg:“0-5”代表第0到第5帧
  • w:Number — 每帧的宽
  • h:Number — 每帧的高
  • loop:Boolean — 是否循环
  • duration:Number — 每帧间隔 默认单位帧, 如果sprite的timeBased为true则单位是毫秒,默认一帧
  • duration

    var sprite = new Hilo.Sprite({
        frames: Hilo.TextureAtlas.createSpriteFrames("swim", "0-7", document.getElementById("fish"), 174, 126, true),
        x: 0,
        y: 100,
        interval: 6,
        timeBased: false,
        loop: true,
        onUpdate: function(){
            if(this.x > stage.width - this.pivotX){
                this.x = 0;
            }else{
                this.x += 3;
            }
        }
    }).addTo(stage);
    }
    

注意上面代码需要在 head 内定义一个不可见的 <img> 标签:

<img src="fish.png" alt="" id="fish" style="display:none">

预加载资源

在游戏开发中,会涉及到各种资源的预加载,比如图片素材、声音音效、各种数据等等。Hilo 提供了一个简单的资源加载队列工具 LoadQueue。

//预加载资源
var queue = new Hilo.LoadQueue();
queue.maxConnections = 2; //设置同时下载的最大连接数,默认为2
queue.add({id:'mylogo', type:'image/png', src:'https://www.gravatar.com/avatar/f8e844e3f14663f08fe222fe2d406fa7?s=128', loader:{
    load: function(data){
        console.log("success, data= " + data);
        new Hilo.Bitmap({
            image: data.src,
        }).addTo(stage);
    },
    onLoad: function(e){
        return e.target;
    },
    onError: function(e){
        return e;
    }
}});
queue.start();

FlappyBird游戏分析

游戏源码下载:https://hiloteam.github.io/tutorial/res/flappybird.zip 游戏在线演示:https://dp2px.com/demo/flappybird/

场景分析

游戏背景 - 背景图和移动的地面是贯穿整个游戏,没有变化的。 准备场景 - 一个简单的游戏提示画面。游戏开始前和失败后重新开始都会进入此场景。 游戏场景 - 障碍物不断的从右往左移动,玩家控制小鸟的飞行。 结束场景 - 游戏失败后,显示得分以及相关按钮等。 接下来,我们就开始用Hilo来创建这4个场景。

游戏背景

由于背景是不变的,为了减少canvas的重复绘制,我们采用DOM+CSS来设置背景。先创建一个div,设置其CSS背景为游戏背景图片,再把它加入到舞台的canvas后面。

此时我们改变上面的思路,删除 <body> 中的所有标签:

<body>

</body>

添加一个立即执行函数 (function(){})() , 并在立即执行函数中定义一个 game 对象, 然后在 window.onload 中调用 game 的初始化方法 init。

<script type="text/javascript">
    (function(){
        window.onload = function() {
            game.init();
        }
        var game = window.game = {
            init: function(){
                console.log('game-init');
            }
        }
    })()
</script>

接下来我们使用 DOM 给 <body> 添加舞台 <canvas> 和背景 <div>.

//初始化舞台
init: function(){
    //舞台画布
    var renderType = location.search.indexOf('dom') != -1 ? 'dom' : 'canvas';

    //初始化舞台
    this.stage = new Hilo.Stage({
        renderType: renderType,
        width: this.width,
        height: this.height,
        scaleX: this.scale,
        scaleY: this.scale
    });

    //追加到 body 的内部
    document.body.appendChild(this.stage.canvas);

    //启动舞台定时器s
    this.ticker = new Hilo.Ticker(60);
    this.ticker.addTick(this.stage);
    this.ticker.start();

    this.initBackground();
}

//初始化背景
initBackground: function(){
    var bgWidth = this.width * this.scale;
    var bgHeight = this.height * this.scale;
    //添加到body的子元素最前面
    document.body.insertBefore(Hilo.createElement('div', {
        id: 'bg',
        style: {
            position: 'absolute',
            background: 'url(images/bg.png) no-repeat',
            backgroundSize: bgWidth + 'px, ' + bgHeight + 'px',
            width: bgWidth + 'px',
            height: bgHeight + 'px'
        }
    }), this.stage.canvas);
}

图片预加载

我们将上面的 js 放到 game.js 中,然后将预加载图片功能放到 Asset.js 文件中,结构如下:

<script type="text/javascript" src="src/game.js"></script>
<script type="text/javascript" src="src/Asset.js"></script>

接下来我们来编写 Asset.js 中的图片加载功能:

(function(ns){
    var Asset = ns.Asset = {

    }
})(window.game)

这里将 Asset 对象绑定到 game 对象上面,可以直接通过 game.Asset 访问, 接下来实现两个方法 loadonComplete:

(function(ns){
    var Asset = ns.Asset = {
        load: function(){
            var resources = [
                {id:'bg', src:'images/bg.png'},
                {id:'ground', src:'images/ground.png'},
                {id:'ready', src:'images/ready.png'},
                {id:'over', src:'images/over.png'},
                {id:'number', src:'images/number.png'},
                {id:'bird', src:'images/bird.png'},
                {id:'holdback', src:'images/holdback.png'}
            ];
    
            this.queue = new Hilo.LoadQueue();
            this.queue.add(resources);
            this.queue.on('complete', this.onComplete.bind(this));
            this.queue.start();
        },

        onComplete: function(e){
            this.bg = this.queue.get('bg').content;
            this.ground = this.queue.get('ground').content;
            this.ready = this.queue.get('ready').content;
            this.over = this.queue.get('over').content;
            this.holdback = this.queue.get('holdback').content;
    
            this.birdAtlas = new Hilo.TextureAtlas({
                image: this.queue.get('bird').content,
                frames: [
                    [0, 120, 86, 60], 
                    [0, 60, 86, 60], 
                    [0, 0, 86, 60]
                ],
                sprites: {
                    bird: [0, 1, 2]
                }
            });
    
            var number = this.queue.get('number').content;
            this.numberGlyphs = {
                0: {image:number, rect:[0,0,60,91]},
                1: {image:number, rect:[61,0,60,91]},
                2: {image:number, rect:[121,0,60,91]},
                3: {image:number, rect:[191,0,60,91]},
                4: {image:number, rect:[261,0,60,91]},
                5: {image:number, rect:[331,0,60,91]},
                6: {image:number, rect:[401,0,60,91]},
                7: {image:number, rect:[471,0,60,91]},
                8: {image:number, rect:[541,0,60,91]},
                9: {image:number, rect:[611,0,60,91]}
            };
            console.log('加载完毕');
            this.queue.off('complete');
        }
    }
})(window.game)

此时我们在 game.js 中调用此方法,然后可以看到控制台打印了 加载完毕:

//初始化
init: function(){
    game.Asset.load(); //加载资源
    
    this.initStage();
}

很显然,上面的资源加载是异步的,所以导致在 initStage() 中不能通过 game.Asset.bg 使用背景图片。所以我们得想一个办法,将资源加载结果异步回调到这里。

混入属性和事件

在 hilo 中有一个 Hilo.Class 用来创建一个类,可以在其中混入一些新的属性和方法,配合 EventMixin 类可以混入一个监听事件。

(function(ns){
    var Asset = ns.Asset = Hilo.Class.create({
        Mixes: Hilo.EventMixin,
        load: function(){
            // 不变化
        },
        onComplete: function(e){
            // 不变化
            this.fire('complete');
        }
    });

})(window.game);

上面的 fire 方法就是发送事件的方法(回调),然后在 on 方法中可以监听此事件。

然后在 game.js 中使用, 这样就可以使用 this.asset.bg 获取到背景图片了。

//初始化
init: function(){
    this.asset = new game.Asset();
    this.asset.on('complete', function(e) {
        this.asset.off('complete');
        this.initStage();
    }.bind(this));
    this.asset.load();
}

移动的地面

地面不是静止的。它是从右到左的不断循环的移动着的。我们注意到地面的图片素材比背景要宽一些,而地面图片本身也是一些循环的斜四边形组成的。这样的特性,意味着如果我们把图片从当前位置移动到下一个斜四边形的某一位置时,舞台上的地面会看起来是没有移动过一样。我们找到当地面的x轴坐标为-60时,跟x轴为0时的地面的图形是没有差异的。这样,若地面在0到-60之间不断循环变化时,地面就循环移动起来了。

注意:这段代码中我们将上面的背景 <div> 去掉了,全部换成了 Hilo.Bitmap 使用 canvas 绘制上去了。

//初始化背景和地面
initBackground: function(){
    var bgImg = this.asset.bg;
    this.bg = new Hilo.Bitmap({
        id: 'bg',
        image: bgImg,
        scaleX: this.width / bgImg.width,
        scaleY: this.height / bgImg.height
    }).addTo(this.stage);

    var groundImg = this.asset.ground;
    var groundOffset = 60;
    this.ground = new Hilo.Bitmap({
        id: 'ground',
        image: groundImg,
        scaleX: (this.width + groundOffset * 2) / groundImg.width
    }).addTo(this.stage);

    //设置地面的y轴坐标
    this.ground.y = this.height - this.ground.height;
}

接下来我们使用 Hilo.Tween 让地面循环移动起来:

var groundImg = this.asset.ground;
var groundOffset = 60;
this.ground = new Hilo.Bitmap({
    id: 'ground',
    image: groundImg,
    scaleX: (this.width + groundOffset * 2) / groundImg.width
}).addTo(this.stage);

//设置地面的y轴坐标
this.ground.y = this.height - this.ground.height;

//移动地面
var groundOffset = 60;
Hilo.Tween.to(this.ground, {
    x: -groundOffset * this.ground.scaleX
}, {
    duration: 400,
    loop: true
});

需要注意的是,使用 Tween 之前,需要添加到 Ticker 中, 如下面第三行:

//启动舞台定时器
this.ticker = new Hilo.Ticker(60);
this.ticker.addTick(Hilo.Tween);
this.ticker.addTick(this.stage);
this.ticker.start();

准备场景

新建一个 ReadyScene.js 文件来创建我们的准备场景, 创建一个类继承自 Hilo.Container:

(function(ns){

var ReadyScene = ns.ReadyScene = Hilo.Class.create({
    Extends: Hilo.Container,
    constructor: function(properties){
        ReadyScene.superclass.constructor.call(this, properties);
        this.init(properties);
    },

    init: function(properties){
        //准备Get Ready!
        var getready = new Hilo.Bitmap({
            image: properties.image,
            rect: [0, 0, 508, 158]
        });

        //开始提示tap
        var tap = new Hilo.Bitmap({
            image: properties.image,
            rect: [0, 158, 286, 246]
        });
        
        //确定getready和tap的位置
        tap.x = this.width - tap.width >> 1;
        tap.y = this.height - tap.height + 40 >> 1;
        getready.x = this.width - getready.width >> 1;
        getready.y = tap.y - getready.height >> 0;

        //将 Get Ready 图片 和 TAP 图片对象添加到 Container
        this.addChild(tap, getready);
    }
});

})(window.game);

注意:记得将上面 js 文件添加到我们的游戏 html 中:

<script type="text/javascript" src="hilo/hilo-standalone.js"></script>
<script type="text/javascript" src="hilo/hilo-flash.js" data-auto="true"></script>
<script type="text/javascript" src="src/game.js"></script>
<script type="text/javascript" src="src/Asset.js"></script>
<script type="text/javascript" src="src/ReadyScene.js"></script>

然后在 game.js 中创建 ReadyScene 对象:

//初始化场景
initScenes: function() {
    //准备场景
    this.gameReadyScene = new game.ReadyScene({
        id: 'readyScene',
        width: this.width,
        height: this.height,
        image: this.asset.ready
    }).addTo(this.stage);
}

创建小鸟

和创建准备场景同样的道理,新建一个 Bird.js 文件来创建小鸟:

(function(ns){

var Bird = ns.Bird = Hilo.Class.create({
    Extends: Hilo.Sprite,
    constructor: function(properties){
        Bird.superclass.constructor.call(this, properties);
        
        this.addFrame(properties.atlas.getSprite('bird'));
        this.interval = 6;
        this.pivotX = 43;
        this.pivotY = 30;

        this.gravity = 10 / 1000 * 0.3;
        this.flyHeight = 80;
        this.initVelocity = Math.sqrt(2 * this.flyHeight * this.gravity);
    },

    startX: 0, //小鸟的起始x坐标
    startY: 0, //小鸟的起始y坐标
    groundY: 0, //地面的坐标
    gravity: 0, //重力加速度
    flyHeight: 0, //小鸟每次往上飞的高度
    initVelocity: 0, //小鸟往上飞的初速度

    isDead: true, //小鸟是否已死亡
    isUp: false, //小鸟是在往上飞阶段,还是下落阶段
    flyStartY: 0, //小鸟往上飞的起始y轴坐标
    flyStartTime: 0, //小鸟飞行起始时间

    getReady: function(){
        //设置起始坐标
        this.x = this.startX;
        this.y = this.startY;

        this.rotation = 0;
        this.interval = 6;
        this.play();
        this.tween = Hilo.Tween.to(this, {y:this.y + 10, rotation:-8}, {duration:400, reverse:true, loop:true});
    }
});

})(window.game);

game.js 中添加:

//创建小鸟
initBird: function() {
    this.bird = new game.Bird({
        id: 'bird',
        atlas: this.asset.birdAtlas,
        startX: 100,
        startY: this.height >> 1,
        groundY: this.ground.y - 12
    }).addTo(this.stage, this.ground.depth - 1);

    //初始化位置
    this.bird.getReady();
}

绑定交互事件

在 Hilo 中可以使用 View.on 方法绑定一个事件监听:

on(type:String, listener:Function, once:Boolean):Object

我们给舞台 stage 绑定一个事件监听:

this.stage.on(Hilo.event.POINTER_START, this.onUserInput.bind(this));

回调方法 onUserInput 定义如下:

//绑定鼠标点击事件回调
onUserInput: function(e) {
    console.log(e.eventTarget, e.stageX, e.stageY);
    console.log('启动游戏场景');
}

在使用绑定监听之前,我们必须使用 stage 的 enableDOMEvent 方法开启舞台相应事件。

//绑定交互事件
this.stage.enableDOMEvent(Hilo.event.POINTER_START, true);
this.stage.on(Hilo.event.POINTER_START, this.onUserInput.bind(this));

控制小鸟

hilo 的 View 对象有一个 onUpdate 属性方法,此方法会在可视对象渲染之前调用,此函数可以返回一个Boolean值。若返回false,则此对象不会渲染。默认值为null。

接下来我们来实现这个属性:

startFly: function(){
    this.isDead = false;
    this.interval = 3;
    this.flyStartY = this.y;
    //注意加号意思是转成时间戳
    this.flyStartTime = +new Date(); 
    if(this.tween) this.tween.stop();
},

onUpdate: function(){
    if(this.isDead) return;
    
    //飞行时间
    var time = (+new Date()) - this.flyStartTime;

    //飞行距离
    var distance = this.initVelocity * time - 0.5 * this.gravity * time * time;
    //y轴坐标
    var y = this.flyStartY - distance;

    if(y <= this.groundY){
        //小鸟未落地
        this.y = y;
        if(distance > 0 && !this.isUp){
            //往上飞时,角度上仰20度
            this.tween = Hilo.Tween.to(this, {rotation:-20}, {duration:200});
            this.isUp = true;
        }else if(distance < 0 && this.isUp){
            //往下跌落时,角度往下90度
            this.tween = Hilo.Tween.to(this, {rotation:90}, {duration:this.groundY - this.y});
            this.isUp = false;
        }
    }else{
        //小鸟已经落地,即死亡
        this.y = this.groundY;
        this.isDead = true;
    }
}

上面代码需要注意的一点是:JavaScript中可以在某个元素前使用 ‘+’ 号,这个操作是将该元素转换成Number类型,如果转换失败,那么将得到 NaN。

此时你会发小小鸟做了一个自由落体运动,这里涉及到另一个自由落体运动计算公式得出小鸟自由落体的距离:

0.5 * this.gravity * time * time

还有一个上抛运动的初始速度计算公式得出小鸟向上移动的速度:

Math.sqrt(2 * this.flyHeight * this.gravity);

最后差值就是在 y 轴方向上的移动距离:

var distance = this.initVelocity * time - 0.5 * this.gravity * time * time;

我们通过修改 flyStartTime 来实现小鸟向上飞行。

然后我们修改舞台的绑定事件执行内容, 让隐藏准备界面,并且执行 startFly() 方法, 我们每点击一次就会调一次该方法:

//绑定鼠标点击事件回调
onUserInput: function(e) {
    this.gameReadyScene.visible = false;
    this.bird.startFly();
}

这个分析的内容实在太多了,下面的内容大同小异,所以偷个懒!更多关于该游戏的解析请参考游戏源码和Hilo官网文档